476 lines
19 KiB
Python
476 lines
19 KiB
Python
"""
|
||
Views для уведомлений.
|
||
"""
|
||
from rest_framework import viewsets, status, generics
|
||
from rest_framework.decorators import action
|
||
from rest_framework.response import Response
|
||
from rest_framework.permissions import IsAuthenticated
|
||
from django.conf import settings
|
||
from .models import Notification, NotificationPreference, ParentChildNotificationSettings, PushSubscription
|
||
from .serializers import (
|
||
NotificationSerializer,
|
||
NotificationPreferenceSerializer,
|
||
ParentChildNotificationSettingsSerializer,
|
||
PushSubscriptionSerializer
|
||
)
|
||
from .services import TelegramLinkService
|
||
import asyncio
|
||
|
||
|
||
class NotificationViewSet(viewsets.ReadOnlyModelViewSet):
|
||
"""ViewSet для уведомлений (только чтение)."""
|
||
|
||
queryset = Notification.objects.all()
|
||
serializer_class = NotificationSerializer
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
def get_queryset(self):
|
||
"""Только in_app уведомления текущего пользователя."""
|
||
queryset = Notification.objects.filter(
|
||
recipient=self.request.user,
|
||
channel='in_app' # Показываем только in_app уведомления
|
||
)
|
||
|
||
# Оптимизация: для списка используем only() для ограничения полей
|
||
# Не используем select_related для recipient, так как он не нужен в сериализаторе
|
||
if self.action == 'list':
|
||
return queryset.only(
|
||
'id', 'notification_type', 'channel', 'priority',
|
||
'title', 'message', 'data', 'action_url',
|
||
'is_read', 'read_at', 'is_sent', 'created_at'
|
||
).order_by('is_read', '-created_at') # сначала непрочитанные, затем по дате
|
||
|
||
# Для детального просмотра можно использовать select_related, если нужно
|
||
return queryset.order_by('is_read', '-created_at')
|
||
|
||
@action(detail=False, methods=['get'])
|
||
def unread(self, request):
|
||
"""Получить непрочитанные уведомления."""
|
||
queryset = self.get_queryset().filter(is_read=False)
|
||
# Оптимизация: используем len() вместо count() для уже загруженного queryset
|
||
serializer = self.get_serializer(queryset, many=True)
|
||
return Response({
|
||
'success': True,
|
||
'data': serializer.data,
|
||
'count': len(serializer.data) # Используем len() вместо count() для оптимизации
|
||
})
|
||
|
||
@action(detail=True, methods=['post'])
|
||
def mark_as_read(self, request, pk=None):
|
||
"""Отметить как прочитанное."""
|
||
notification = self.get_object()
|
||
notification.mark_as_read()
|
||
|
||
return Response({
|
||
'success': True,
|
||
'message': 'Уведомление отмечено как прочитанное'
|
||
})
|
||
|
||
@action(detail=False, methods=['post'])
|
||
def mark_all_as_read(self, request):
|
||
"""Отметить все как прочитанные."""
|
||
from django.core.cache import cache
|
||
count = self.get_queryset().filter(is_read=False).update(is_read=True)
|
||
# Очищаем кеш дашборда для обновления счетчика непрочитанных уведомлений
|
||
user_id = request.user.id
|
||
cache.delete(f'mentor_dashboard_{user_id}')
|
||
cache.delete(f'client_dashboard_{user_id}')
|
||
cache.delete(f'parent_dashboard_{user_id}')
|
||
|
||
return Response({
|
||
'success': True,
|
||
'message': f'Отмечено {count} уведомлений'
|
||
})
|
||
|
||
|
||
class NotificationPreferenceViewSet(viewsets.ModelViewSet):
|
||
"""ViewSet для настроек уведомлений."""
|
||
|
||
queryset = NotificationPreference.objects.all()
|
||
serializer_class = NotificationPreferenceSerializer
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
def get_queryset(self):
|
||
"""Только настройки текущего пользователя."""
|
||
queryset = NotificationPreference.objects.filter(user=self.request.user).select_related('user')
|
||
|
||
# Оптимизация: для списка используем only() для ограничения полей
|
||
if self.action == 'list':
|
||
queryset = queryset.only(
|
||
'id', 'user_id', 'enabled', 'email_enabled', 'telegram_enabled',
|
||
'in_app_enabled', 'type_preferences', 'quiet_hours_enabled',
|
||
'quiet_hours_start', 'quiet_hours_end', 'created_at', 'updated_at'
|
||
)
|
||
|
||
return queryset
|
||
|
||
@action(detail=False, methods=['get', 'put', 'patch'])
|
||
def me(self, request):
|
||
"""Получить или обновить свои настройки."""
|
||
try:
|
||
preferences = request.user.notification_preferences
|
||
except:
|
||
# Создаем если нет
|
||
from .services import create_notification_preferences
|
||
create_notification_preferences(request.user)
|
||
preferences = request.user.notification_preferences
|
||
|
||
if request.method == 'GET':
|
||
serializer = self.get_serializer(preferences)
|
||
return Response({
|
||
'success': True,
|
||
'data': serializer.data
|
||
})
|
||
|
||
else: # PUT, PATCH
|
||
serializer = self.get_serializer(
|
||
preferences,
|
||
data=request.data,
|
||
partial=request.method == 'PATCH'
|
||
)
|
||
serializer.is_valid(raise_exception=True)
|
||
serializer.save()
|
||
|
||
return Response({
|
||
'success': True,
|
||
'message': 'Настройки обновлены',
|
||
'data': serializer.data
|
||
})
|
||
|
||
@action(detail=False, methods=['get'], url_path='telegram/bot-info')
|
||
def get_telegram_bot_info(self, request):
|
||
"""
|
||
Получение информации о Telegram боте (username, ссылка).
|
||
|
||
GET /api/notifications/preferences/telegram/bot-info/
|
||
"""
|
||
try:
|
||
import logging
|
||
from telegram import Bot
|
||
from telegram.error import TelegramError
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
if not settings.TELEGRAM_BOT_TOKEN:
|
||
return Response({
|
||
'success': False,
|
||
'error': 'TELEGRAM_BOT_TOKEN не настроен'
|
||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||
|
||
async def get_bot_info():
|
||
bot = Bot(token=settings.TELEGRAM_BOT_TOKEN)
|
||
try:
|
||
me = await bot.get_me()
|
||
return {
|
||
'username': me.username,
|
||
'first_name': me.first_name,
|
||
'id': me.id,
|
||
'link': f'https://t.me/{me.username}'
|
||
}
|
||
finally:
|
||
# Игнорируем ошибки при закрытии (rate limiting)
|
||
try:
|
||
await bot.close()
|
||
except (TelegramError, Exception) as close_error:
|
||
logger.warning(f'Ошибка при закрытии бота (игнорируется): {close_error}')
|
||
|
||
bot_info = asyncio.run(get_bot_info())
|
||
return Response({
|
||
'success': True,
|
||
**bot_info
|
||
})
|
||
except Exception as e:
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
logger.error(f'Ошибка при получении информации о Telegram боте: {str(e)}', exc_info=True)
|
||
return Response({
|
||
'success': False,
|
||
'error': f'Не удалось получить информацию о боте: {str(e)}'
|
||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||
|
||
@action(detail=False, methods=['post'], url_path='telegram/generate-code')
|
||
def generate_telegram_code(self, request):
|
||
"""
|
||
Генерация кода для связывания Telegram аккаунта.
|
||
|
||
POST /api/notifications/preferences/telegram/generate-code/
|
||
"""
|
||
code = TelegramLinkService.generate_link_code(request.user.id)
|
||
|
||
return Response({
|
||
'success': True,
|
||
'code': code,
|
||
'message': 'Код сгенерирован. Действителен 15 минут.',
|
||
'instructions': (
|
||
f'1. Откройте Telegram бота\n'
|
||
f'2. Отправьте команду: /link {code}\n'
|
||
f'3. Ваш аккаунт будет связан'
|
||
)
|
||
})
|
||
|
||
@action(detail=False, methods=['post'], url_path='telegram/unlink')
|
||
def unlink_telegram(self, request):
|
||
"""
|
||
Отвязка Telegram аккаунта.
|
||
|
||
POST /api/notifications/preferences/telegram/unlink/
|
||
"""
|
||
user = request.user
|
||
|
||
if not user.telegram_id:
|
||
return Response({
|
||
'success': False,
|
||
'error': 'Telegram аккаунт не привязан'
|
||
}, status=status.HTTP_400_BAD_REQUEST)
|
||
|
||
user.telegram_id = None
|
||
user.telegram_username = ''
|
||
user.save(update_fields=['telegram_id', 'telegram_username'])
|
||
|
||
# Выключаем Telegram уведомления
|
||
try:
|
||
preferences = user.notification_preferences
|
||
preferences.telegram_enabled = False
|
||
preferences.save(update_fields=['telegram_enabled'])
|
||
except:
|
||
pass
|
||
|
||
return Response({
|
||
'success': True,
|
||
'message': 'Telegram аккаунт успешно отвязан'
|
||
})
|
||
|
||
@action(detail=False, methods=['get'], url_path='telegram/status')
|
||
def telegram_status(self, request):
|
||
"""
|
||
Проверка статуса связывания Telegram.
|
||
|
||
GET /api/notifications/preferences/telegram/status/
|
||
"""
|
||
user = request.user
|
||
|
||
return Response({
|
||
'success': True,
|
||
'linked': bool(user.telegram_id),
|
||
'telegram_id': user.telegram_id,
|
||
'telegram_username': user.telegram_username,
|
||
'notifications_enabled': (
|
||
user.notification_preferences.telegram_enabled
|
||
if hasattr(user, 'notification_preferences')
|
||
else False
|
||
)
|
||
})
|
||
|
||
|
||
class ParentChildNotificationSettingsViewSet(viewsets.ModelViewSet):
|
||
"""ViewSet для настроек уведомлений родителя для детей."""
|
||
|
||
serializer_class = ParentChildNotificationSettingsSerializer
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
def get_queryset(self):
|
||
"""Только настройки текущего родителя."""
|
||
user = self.request.user
|
||
if user.role != 'parent':
|
||
return ParentChildNotificationSettings.objects.none()
|
||
|
||
try:
|
||
parent = user.parent_profile
|
||
except:
|
||
return ParentChildNotificationSettings.objects.none()
|
||
|
||
queryset = ParentChildNotificationSettings.objects.filter(
|
||
parent=parent
|
||
).select_related(
|
||
'parent', 'parent__user',
|
||
'child', 'child__user'
|
||
)
|
||
|
||
# Фильтр по ребенку
|
||
child_id = self.request.query_params.get('child_id')
|
||
if child_id:
|
||
try:
|
||
from apps.users.models import Client
|
||
child = Client.objects.get(user_id=child_id)
|
||
# Проверяем, что ребенок связан с родителем
|
||
if child in parent.children.all():
|
||
queryset = queryset.filter(child=child)
|
||
else:
|
||
queryset = ParentChildNotificationSettings.objects.none()
|
||
except Client.DoesNotExist:
|
||
queryset = ParentChildNotificationSettings.objects.none()
|
||
except Exception:
|
||
queryset = ParentChildNotificationSettings.objects.none()
|
||
|
||
return queryset
|
||
|
||
def perform_create(self, serializer):
|
||
"""Создание настроек с автоматическим определением родителя."""
|
||
user = self.request.user
|
||
if user.role != 'parent':
|
||
from rest_framework.exceptions import PermissionDenied
|
||
raise PermissionDenied("Только родители могут создавать настройки уведомлений")
|
||
|
||
parent = user.parent_profile
|
||
serializer.save(parent=parent)
|
||
|
||
@action(detail=False, methods=['get', 'post', 'put', 'patch'], url_path='for_child')
|
||
def for_child(self, request):
|
||
"""
|
||
Получить или обновить настройки уведомлений для конкретного ребенка.
|
||
|
||
GET /api/notifications/parent-child-settings/for_child/?child_id=17
|
||
POST/PUT/PATCH /api/notifications/parent-child-settings/for_child/?child_id=17
|
||
Body: {
|
||
"enabled": true,
|
||
"type_settings": {
|
||
"lesson_created": true,
|
||
"homework_assigned": false,
|
||
...
|
||
}
|
||
}
|
||
"""
|
||
user = request.user
|
||
if user.role != 'parent':
|
||
return Response(
|
||
{'error': 'Только родители могут управлять настройками уведомлений'},
|
||
status=status.HTTP_403_FORBIDDEN
|
||
)
|
||
|
||
child_id = request.query_params.get('child_id')
|
||
if not child_id:
|
||
return Response(
|
||
{'error': 'Параметр child_id обязателен'},
|
||
status=status.HTTP_400_BAD_REQUEST
|
||
)
|
||
|
||
try:
|
||
from apps.users.models import Client, Parent
|
||
parent = user.parent_profile
|
||
if not parent:
|
||
return Response(
|
||
{'error': 'Профиль родителя не найден'},
|
||
status=status.HTTP_404_NOT_FOUND
|
||
)
|
||
|
||
# Получаем ребенка по user_id и проверяем, что он связан с родителем
|
||
try:
|
||
child = Client.objects.get(user_id=child_id)
|
||
except Client.DoesNotExist:
|
||
return Response(
|
||
{'error': 'Ребенок не найден'},
|
||
status=status.HTTP_404_NOT_FOUND
|
||
)
|
||
|
||
# Проверяем, что ребенок связан с этим родителем
|
||
if child not in parent.children.all():
|
||
return Response(
|
||
{'error': 'Ребенок не связан с этим родителем'},
|
||
status=status.HTTP_403_FORBIDDEN
|
||
)
|
||
except Exception as e:
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
logger.error(f'Ошибка в for_child: {str(e)}', exc_info=True)
|
||
return Response(
|
||
{'error': f'Ошибка при получении данных: {str(e)}'},
|
||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||
)
|
||
|
||
# Получаем или создаем настройки
|
||
settings, created = ParentChildNotificationSettings.objects.get_or_create(
|
||
parent=parent,
|
||
child=child,
|
||
defaults={'enabled': True, 'type_settings': {}}
|
||
)
|
||
|
||
if request.method == 'GET':
|
||
serializer = self.get_serializer(settings)
|
||
return Response(serializer.data)
|
||
|
||
# Обновление настроек
|
||
serializer = self.get_serializer(settings, data=request.data, partial=request.method == 'PATCH')
|
||
serializer.is_valid(raise_exception=True)
|
||
serializer.save()
|
||
|
||
return Response(serializer.data)
|
||
|
||
|
||
class PushSubscriptionView(generics.GenericAPIView):
|
||
"""
|
||
API endpoint для сохранения Push Notification subscription.
|
||
|
||
POST /api/notifications/push-subscription/
|
||
"""
|
||
serializer_class = PushSubscriptionSerializer
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
def post(self, request, *args, **kwargs):
|
||
"""Сохранение или обновление Push subscription."""
|
||
user = request.user
|
||
subscription_data = request.data.get('subscription_data')
|
||
user_agent = request.META.get('HTTP_USER_AGENT', '')
|
||
|
||
if not subscription_data:
|
||
return Response({
|
||
'success': False,
|
||
'error': 'subscription_data обязателен'
|
||
}, status=status.HTTP_400_BAD_REQUEST)
|
||
|
||
# Проверяем, есть ли уже такая subscription
|
||
# Используем endpoint из subscription_data как уникальный идентификатор
|
||
endpoint = subscription_data.get('endpoint')
|
||
if endpoint:
|
||
existing = PushSubscription.objects.filter(
|
||
user=user,
|
||
subscription_data__endpoint=endpoint
|
||
).first()
|
||
|
||
if existing:
|
||
# Обновляем существующую
|
||
existing.subscription_data = subscription_data
|
||
existing.user_agent = user_agent
|
||
existing.is_active = True
|
||
existing.save()
|
||
|
||
return Response({
|
||
'success': True,
|
||
'message': 'Push subscription обновлена',
|
||
'subscription_id': existing.id
|
||
}, status=status.HTTP_200_OK)
|
||
|
||
# Создаем новую subscription
|
||
subscription = PushSubscription.objects.create(
|
||
user=user,
|
||
subscription_data=subscription_data,
|
||
user_agent=user_agent
|
||
)
|
||
|
||
return Response({
|
||
'success': True,
|
||
'message': 'Push subscription сохранена',
|
||
'subscription_id': subscription.id
|
||
}, status=status.HTTP_201_CREATED)
|
||
|
||
def delete(self, request, *args, **kwargs):
|
||
"""Удаление Push subscription."""
|
||
user = request.user
|
||
endpoint = request.data.get('endpoint')
|
||
|
||
if not endpoint:
|
||
return Response({
|
||
'success': False,
|
||
'error': 'endpoint обязателен'
|
||
}, status=status.HTTP_400_BAD_REQUEST)
|
||
|
||
# Деактивируем subscription
|
||
count = PushSubscription.objects.filter(
|
||
user=user,
|
||
subscription_data__endpoint=endpoint
|
||
).update(is_active=False)
|
||
|
||
return Response({
|
||
'success': True,
|
||
'message': f'Деактивировано {count} subscription(s)'
|
||
}, status=status.HTTP_200_OK)
|