fix bugs
Deploy to Production / deploy-production (push) Successful in 27s
Details
Deploy to Production / deploy-production (push) Successful in 27s
Details
This commit is contained in:
parent
d4c4dbb087
commit
d9121fe6ef
|
|
@ -349,8 +349,9 @@ class Message(models.Model):
|
||||||
self.chat.increment_messages_count()
|
self.chat.increment_messages_count()
|
||||||
self.chat.update_last_message()
|
self.chat.update_last_message()
|
||||||
|
|
||||||
|
# Системные сообщения (уведомления) не увеличивают счётчик непрочитанных — уведомления есть отдельно
|
||||||
|
if self.message_type != 'system':
|
||||||
# Увеличиваем счетчик непрочитанных для всех участников кроме отправителя
|
# Увеличиваем счетчик непрочитанных для всех участников кроме отправителя
|
||||||
# Оптимизация: используем bulk_update вместо цикла с save()
|
|
||||||
participants = list(self.chat.participants.exclude(user=self.sender))
|
participants = list(self.chat.participants.exclude(user=self.sender))
|
||||||
for participant in participants:
|
for participant in participants:
|
||||||
participant.unread_count += 1
|
participant.unread_count += 1
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from .models import Chat, ChatParticipant, Message, MessageFile, MessageRead, MessageReaction
|
from .models import Chat, ChatParticipant, Message, MessageFile, MessageRead, MessageReaction
|
||||||
|
from .services import ChatService
|
||||||
from apps.users.serializers import UserSerializer
|
from apps.users.serializers import UserSerializer
|
||||||
from apps.users.mixins import TimezoneAwareSerializerMixin
|
from apps.users.mixins import TimezoneAwareSerializerMixin
|
||||||
from apps.users.utils import format_datetime_for_user
|
from apps.users.utils import format_datetime_for_user
|
||||||
|
|
@ -531,19 +532,17 @@ class ChatCreateSerializer(serializers.ModelSerializer):
|
||||||
participant_ids = validated_data.pop('participant_ids')
|
participant_ids = validated_data.pop('participant_ids')
|
||||||
user = self.context['request'].user
|
user = self.context['request'].user
|
||||||
|
|
||||||
# Для личного чата проверяем что такой чат уже не существует
|
# Для личного чата используем сервис с защитой от race condition
|
||||||
if validated_data['chat_type'] == 'direct':
|
if validated_data['chat_type'] == 'direct':
|
||||||
existing_chat = Chat.objects.filter(
|
other_user = User.objects.get(id=participant_ids[0])
|
||||||
chat_type='direct',
|
chat, _ = ChatService.get_or_create_direct_chat(
|
||||||
participants__user=user
|
user1=user,
|
||||||
).filter(
|
user2=other_user,
|
||||||
participants__user_id=participant_ids[0]
|
created_by=user
|
||||||
).first()
|
)
|
||||||
|
return chat
|
||||||
|
|
||||||
if existing_chat:
|
# Для группового чата - обычная логика
|
||||||
return existing_chat
|
|
||||||
|
|
||||||
# Создаем чат
|
|
||||||
chat = Chat.objects.create(
|
chat = Chat.objects.create(
|
||||||
created_by=user,
|
created_by=user,
|
||||||
**validated_data
|
**validated_data
|
||||||
|
|
@ -557,7 +556,6 @@ class ChatCreateSerializer(serializers.ModelSerializer):
|
||||||
)
|
)
|
||||||
|
|
||||||
# Добавляем остальных участников
|
# Добавляем остальных участников
|
||||||
# Оптимизация: используем bulk_create вместо цикла с create()
|
|
||||||
users = list(User.objects.filter(id__in=participant_ids))
|
users = list(User.objects.filter(id__in=participant_ids))
|
||||||
participants_to_create = [
|
participants_to_create = [
|
||||||
ChatParticipant(
|
ChatParticipant(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
"""
|
||||||
|
Сервисы для системы чата.
|
||||||
|
Централизованная логика создания и управления чатами.
|
||||||
|
"""
|
||||||
|
from django.db import transaction
|
||||||
|
from django.db.models import Q
|
||||||
|
from .models import Chat, ChatParticipant
|
||||||
|
|
||||||
|
|
||||||
|
class ChatService:
|
||||||
|
"""
|
||||||
|
Сервис для работы с чатами.
|
||||||
|
Обеспечивает атомарность операций и предотвращает создание дубликатов.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_or_create_direct_chat(user1, user2, created_by=None):
|
||||||
|
"""
|
||||||
|
Получить или создать личный чат между двумя пользователями.
|
||||||
|
|
||||||
|
Использует блокировку для предотвращения race condition
|
||||||
|
при одновременных запросах на создание чата.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user1: Первый пользователь (User)
|
||||||
|
user2: Второй пользователь (User)
|
||||||
|
created_by: Кто создает чат (по умолчанию user1)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (chat, created) - объект чата и флаг создания
|
||||||
|
"""
|
||||||
|
if user1.id == user2.id:
|
||||||
|
raise ValueError("Нельзя создать чат с самим собой")
|
||||||
|
|
||||||
|
# Нормализуем порядок пользователей для консистентного поиска
|
||||||
|
users = sorted([user1, user2], key=lambda u: u.id)
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
# Ищем существующий чат между пользователями
|
||||||
|
# Используем select_for_update для блокировки найденных записей
|
||||||
|
existing_chat = Chat.objects.select_for_update().filter(
|
||||||
|
chat_type='direct',
|
||||||
|
participants__user=users[0]
|
||||||
|
).filter(
|
||||||
|
participants__user=users[1]
|
||||||
|
).distinct().first()
|
||||||
|
|
||||||
|
if existing_chat:
|
||||||
|
return existing_chat, False
|
||||||
|
|
||||||
|
# Чата нет - создаем новый
|
||||||
|
creator = created_by or users[0]
|
||||||
|
chat = Chat.objects.create(
|
||||||
|
chat_type='direct',
|
||||||
|
created_by=creator
|
||||||
|
)
|
||||||
|
|
||||||
|
# Определяем роли участников
|
||||||
|
# Создатель становится админом
|
||||||
|
ChatParticipant.objects.create(
|
||||||
|
chat=chat,
|
||||||
|
user=users[0],
|
||||||
|
role='admin' if users[0] == creator else 'member'
|
||||||
|
)
|
||||||
|
|
||||||
|
ChatParticipant.objects.create(
|
||||||
|
chat=chat,
|
||||||
|
user=users[1],
|
||||||
|
role='admin' if users[1] == creator else 'member'
|
||||||
|
)
|
||||||
|
|
||||||
|
return chat, True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_direct_chat(user1, user2):
|
||||||
|
"""
|
||||||
|
Получить существующий личный чат между двумя пользователями.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user1: Первый пользователь
|
||||||
|
user2: Второй пользователь
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Chat или None
|
||||||
|
"""
|
||||||
|
return Chat.objects.filter(
|
||||||
|
chat_type='direct',
|
||||||
|
participants__user=user1
|
||||||
|
).filter(
|
||||||
|
participants__user=user2
|
||||||
|
).distinct().first()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def ensure_participant(chat, user, role='member'):
|
||||||
|
"""
|
||||||
|
Убедиться что пользователь является участником чата.
|
||||||
|
Если нет - добавить его.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
chat: Чат
|
||||||
|
user: Пользователь
|
||||||
|
role: Роль (по умолчанию 'member')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (participant, created)
|
||||||
|
"""
|
||||||
|
return ChatParticipant.objects.get_or_create(
|
||||||
|
chat=chat,
|
||||||
|
user=user,
|
||||||
|
defaults={'role': role}
|
||||||
|
)
|
||||||
|
|
@ -17,6 +17,7 @@ from .serializers import (
|
||||||
ChatParticipantSerializer
|
ChatParticipantSerializer
|
||||||
)
|
)
|
||||||
from .permissions import IsChatParticipant
|
from .permissions import IsChatParticipant
|
||||||
|
from .services import ChatService
|
||||||
from .utils import (
|
from .utils import (
|
||||||
save_file_to_preload,
|
save_file_to_preload,
|
||||||
move_file_from_preload_to_chat,
|
move_file_from_preload_to_chat,
|
||||||
|
|
@ -233,47 +234,26 @@ class ChatViewSet(viewsets.ModelViewSet):
|
||||||
'error': 'Вы можете создавать чаты только со связанными пользователями'
|
'error': 'Вы можете создавать чаты только со связанными пользователями'
|
||||||
}, status=status.HTTP_403_FORBIDDEN)
|
}, status=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
# Проверяем существует ли уже чат
|
# Используем сервис для атомарного создания/получения чата
|
||||||
existing_chat = Chat.objects.filter(
|
chat, created = ChatService.get_or_create_direct_chat(
|
||||||
chat_type='direct',
|
user1=request.user,
|
||||||
participants__user=request.user
|
user2=other_user,
|
||||||
).filter(
|
created_by=request.user
|
||||||
participants__user_id=other_user_id
|
)
|
||||||
).first()
|
|
||||||
|
|
||||||
if existing_chat:
|
serializer = ChatDetailSerializer(chat)
|
||||||
serializer = ChatDetailSerializer(existing_chat)
|
if created:
|
||||||
|
return Response({
|
||||||
|
'success': True,
|
||||||
|
'data': serializer.data
|
||||||
|
}, status=status.HTTP_201_CREATED)
|
||||||
|
else:
|
||||||
return Response({
|
return Response({
|
||||||
'success': True,
|
'success': True,
|
||||||
'data': serializer.data,
|
'data': serializer.data,
|
||||||
'message': 'Чат уже существует'
|
'message': 'Чат уже существует'
|
||||||
})
|
})
|
||||||
|
|
||||||
# Создаем новый чат
|
|
||||||
chat = Chat.objects.create(
|
|
||||||
chat_type='direct',
|
|
||||||
created_by=request.user
|
|
||||||
)
|
|
||||||
|
|
||||||
# Добавляем участников
|
|
||||||
ChatParticipant.objects.create(
|
|
||||||
chat=chat,
|
|
||||||
user=request.user,
|
|
||||||
role='admin'
|
|
||||||
)
|
|
||||||
|
|
||||||
ChatParticipant.objects.create(
|
|
||||||
chat=chat,
|
|
||||||
user=other_user,
|
|
||||||
role='member'
|
|
||||||
)
|
|
||||||
|
|
||||||
serializer = ChatDetailSerializer(chat)
|
|
||||||
return Response({
|
|
||||||
'success': True,
|
|
||||||
'data': serializer.data
|
|
||||||
}, status=status.HTTP_201_CREATED)
|
|
||||||
|
|
||||||
@action(detail=True, methods=['post'])
|
@action(detail=True, methods=['post'])
|
||||||
def mark_read(self, request, uuid=None):
|
def mark_read(self, request, uuid=None):
|
||||||
"""
|
"""
|
||||||
|
|
@ -357,10 +337,12 @@ class ChatViewSet(viewsets.ModelViewSet):
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Пересчитываем непрочитанные
|
# Пересчитываем непрочитанные (системные сообщения не учитываем)
|
||||||
unread_count = Message.objects.filter(
|
unread_count = Message.objects.filter(
|
||||||
chat=chat,
|
chat=chat,
|
||||||
is_deleted=False
|
is_deleted=False
|
||||||
|
).exclude(
|
||||||
|
message_type='system'
|
||||||
).exclude(
|
).exclude(
|
||||||
reads__user=request.user
|
reads__user=request.user
|
||||||
).exclude(
|
).exclude(
|
||||||
|
|
@ -454,65 +436,28 @@ class ChatViewSet(viewsets.ModelViewSet):
|
||||||
'error': 'У урока должны быть указаны ментор и клиент'
|
'error': 'У урока должны быть указаны ментор и клиент'
|
||||||
}, status=status.HTTP_400_BAD_REQUEST)
|
}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
# Ищем существующий личный чат между ментором и клиентом
|
# Используем сервис для атомарного создания/получения чата
|
||||||
existing_chat = Chat.objects.filter(
|
chat, created = ChatService.get_or_create_direct_chat(
|
||||||
chat_type='direct',
|
user1=mentor,
|
||||||
participants__user=mentor
|
user2=client_user,
|
||||||
).filter(
|
created_by=mentor
|
||||||
participants__user=client_user
|
|
||||||
).distinct().first()
|
|
||||||
|
|
||||||
if existing_chat:
|
|
||||||
# Проверяем, является ли текущий пользователь участником
|
|
||||||
participant = existing_chat.participants.filter(user=request.user).first()
|
|
||||||
if not participant:
|
|
||||||
# Если текущий пользователь не участник, но это ментор или клиент урока - добавляем
|
|
||||||
if request.user == mentor or request.user == client_user:
|
|
||||||
ChatParticipant.objects.get_or_create(
|
|
||||||
chat=existing_chat,
|
|
||||||
user=request.user,
|
|
||||||
defaults={'role': 'member'}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
serializer = ChatDetailSerializer(existing_chat, context={'request': request})
|
# Если текущий пользователь не участник чата (родитель), добавляем его
|
||||||
return Response({
|
|
||||||
'success': True,
|
|
||||||
'data': serializer.data
|
|
||||||
})
|
|
||||||
|
|
||||||
# Если чата нет, создаем новый личный чат между ментором и клиентом
|
|
||||||
# Используем ту же логику, что и в create_direct
|
|
||||||
chat = Chat.objects.create(
|
|
||||||
chat_type='direct',
|
|
||||||
created_by=request.user
|
|
||||||
)
|
|
||||||
|
|
||||||
# Добавляем участников
|
|
||||||
ChatParticipant.objects.create(
|
|
||||||
chat=chat,
|
|
||||||
user=mentor,
|
|
||||||
role='admin'
|
|
||||||
)
|
|
||||||
|
|
||||||
ChatParticipant.objects.create(
|
|
||||||
chat=chat,
|
|
||||||
user=client_user,
|
|
||||||
role='member'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Если текущий пользователь не ментор и не клиент, добавляем его тоже
|
|
||||||
if request.user != mentor and request.user != client_user:
|
if request.user != mentor and request.user != client_user:
|
||||||
ChatParticipant.objects.create(
|
ChatService.ensure_participant(chat, request.user, role='member')
|
||||||
chat=chat,
|
|
||||||
user=request.user,
|
|
||||||
role='member'
|
|
||||||
)
|
|
||||||
|
|
||||||
serializer = ChatDetailSerializer(chat, context={'request': request})
|
serializer = ChatDetailSerializer(chat, context={'request': request})
|
||||||
|
if created:
|
||||||
return Response({
|
return Response({
|
||||||
'success': True,
|
'success': True,
|
||||||
'data': serializer.data
|
'data': serializer.data
|
||||||
}, status=status.HTTP_201_CREATED)
|
}, status=status.HTTP_201_CREATED)
|
||||||
|
else:
|
||||||
|
return Response({
|
||||||
|
'success': True,
|
||||||
|
'data': serializer.data
|
||||||
|
})
|
||||||
|
|
||||||
@action(detail=True, methods=['post'])
|
@action(detail=True, methods=['post'])
|
||||||
def mark_as_read(self, request, uuid=None):
|
def mark_as_read(self, request, uuid=None):
|
||||||
|
|
|
||||||
|
|
@ -209,12 +209,12 @@ class HomeworkViewSet(viewsets.ModelViewSet):
|
||||||
# Инвалидируем кеш дашборда после создания ДЗ
|
# Инвалидируем кеш дашборда после создания ДЗ
|
||||||
from apps.users.cache_utils import invalidate_dashboard_cache
|
from apps.users.cache_utils import invalidate_dashboard_cache
|
||||||
invalidate_dashboard_cache(homework.mentor.id, 'mentor')
|
invalidate_dashboard_cache(homework.mentor.id, 'mentor')
|
||||||
# Оптимизация: используем list() для кеширования запроса
|
|
||||||
students = list(homework.assigned_to.all())
|
students = list(homework.assigned_to.all())
|
||||||
for student in students:
|
for student in students:
|
||||||
invalidate_dashboard_cache(student.id, 'client')
|
invalidate_dashboard_cache(student.id, 'client')
|
||||||
|
|
||||||
# Отправляем уведомление о новом ДЗ
|
# Отправляем уведомление о новом ДЗ только если НЕ отложенное
|
||||||
|
if not homework.fill_later:
|
||||||
from apps.notifications.services import NotificationService
|
from apps.notifications.services import NotificationService
|
||||||
NotificationService.send_homework_notification(homework, 'homework_assigned')
|
NotificationService.send_homework_notification(homework, 'homework_assigned')
|
||||||
|
|
||||||
|
|
@ -230,6 +230,9 @@ class HomeworkViewSet(viewsets.ModelViewSet):
|
||||||
Опубликовать ДЗ.
|
Опубликовать ДЗ.
|
||||||
|
|
||||||
POST /api/homework/homeworks/{id}/publish/
|
POST /api/homework/homeworks/{id}/publish/
|
||||||
|
|
||||||
|
Также используется для публикации отложенных ДЗ (fill_later=True).
|
||||||
|
При публикации сбрасывается флаг fill_later.
|
||||||
"""
|
"""
|
||||||
homework = self.get_object()
|
homework = self.get_object()
|
||||||
|
|
||||||
|
|
@ -240,12 +243,19 @@ class HomeworkViewSet(viewsets.ModelViewSet):
|
||||||
status=status.HTTP_403_FORBIDDEN
|
status=status.HTTP_403_FORBIDDEN
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Запоминаем был ли это отложенный ДЗ (для отправки уведомления)
|
||||||
|
was_fill_later = homework.fill_later
|
||||||
|
|
||||||
|
# Сбрасываем fill_later при публикации
|
||||||
|
if homework.fill_later:
|
||||||
|
homework.fill_later = False
|
||||||
|
homework.save(update_fields=['fill_later'])
|
||||||
|
|
||||||
homework.publish()
|
homework.publish()
|
||||||
|
|
||||||
# Инвалидируем кеш дашборда после публикации ДЗ
|
# Инвалидируем кеш дашборда после публикации ДЗ
|
||||||
from apps.users.cache_utils import invalidate_dashboard_cache
|
from apps.users.cache_utils import invalidate_dashboard_cache
|
||||||
invalidate_dashboard_cache(homework.mentor.id, 'mentor')
|
invalidate_dashboard_cache(homework.mentor.id, 'mentor')
|
||||||
# Оптимизация: используем list() для кеширования запроса
|
|
||||||
students = list(homework.assigned_to.all())
|
students = list(homework.assigned_to.all())
|
||||||
for student in students:
|
for student in students:
|
||||||
invalidate_dashboard_cache(student.id, 'client')
|
invalidate_dashboard_cache(student.id, 'client')
|
||||||
|
|
@ -264,7 +274,10 @@ class HomeworkViewSet(viewsets.ModelViewSet):
|
||||||
)
|
)
|
||||||
|
|
||||||
serializer = HomeworkSerializer(homework)
|
serializer = HomeworkSerializer(homework)
|
||||||
return Response(serializer.data)
|
response_data = serializer.data
|
||||||
|
if was_fill_later:
|
||||||
|
response_data['was_fill_later'] = True
|
||||||
|
return Response(response_data)
|
||||||
|
|
||||||
@action(detail=True, methods=['post'])
|
@action(detail=True, methods=['post'])
|
||||||
def archive(self, request, pk=None):
|
def archive(self, request, pk=None):
|
||||||
|
|
@ -287,6 +300,35 @@ class HomeworkViewSet(viewsets.ModelViewSet):
|
||||||
serializer = HomeworkSerializer(homework)
|
serializer = HomeworkSerializer(homework)
|
||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def fill_later_list(self, request):
|
||||||
|
"""
|
||||||
|
Получить список отложенных ДЗ (fill_later=True) для ментора.
|
||||||
|
|
||||||
|
GET /api/homework/homeworks/fill_later_list/
|
||||||
|
|
||||||
|
Возвращает ДЗ, которые были созданы с флагом "заполнить позже"
|
||||||
|
и ожидают заполнения ментором.
|
||||||
|
"""
|
||||||
|
if request.user.role != 'mentor':
|
||||||
|
return Response(
|
||||||
|
{'error': 'Только ментор может просматривать отложенные ДЗ'},
|
||||||
|
status=status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
queryset = Homework.objects.filter(
|
||||||
|
mentor=request.user,
|
||||||
|
fill_later=True
|
||||||
|
).select_related('mentor', 'lesson', 'lesson__client', 'lesson__client__user').prefetch_related(
|
||||||
|
'assigned_to'
|
||||||
|
).order_by('-created_at')
|
||||||
|
|
||||||
|
serializer = HomeworkListSerializer(queryset, many=True, context={'request': request})
|
||||||
|
return Response({
|
||||||
|
'count': queryset.count(),
|
||||||
|
'results': serializer.data
|
||||||
|
})
|
||||||
|
|
||||||
@action(detail=True, methods=['get'])
|
@action(detail=True, methods=['get'])
|
||||||
def statistics(self, request, pk=None):
|
def statistics(self, request, pk=None):
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@ def duplicate_notification_to_chat(sender, instance, created, **kwargs):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from apps.chat.models import Chat, Message, ChatParticipant
|
from apps.chat.models import Chat, Message, ChatParticipant
|
||||||
|
from apps.chat.services import ChatService
|
||||||
from apps.users.models import User, Client, Parent
|
from apps.users.models import User, Client, Parent
|
||||||
|
|
||||||
recipient = instance.recipient
|
recipient = instance.recipient
|
||||||
|
|
@ -132,22 +133,12 @@ def duplicate_notification_to_chat(sender, instance, created, **kwargs):
|
||||||
if not mentor:
|
if not mentor:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Находим или создаем личный чат между ментором и получателем
|
# Используем сервис для атомарного создания/получения чата
|
||||||
chat = Chat.objects.filter(
|
chat, _ = ChatService.get_or_create_direct_chat(
|
||||||
chat_type='direct',
|
user1=mentor,
|
||||||
participants__user=mentor
|
user2=recipient,
|
||||||
).filter(
|
|
||||||
participants__user=recipient
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if not chat:
|
|
||||||
# Создаем чат если его нет
|
|
||||||
chat = Chat.objects.create(
|
|
||||||
chat_type='direct',
|
|
||||||
created_by=mentor
|
created_by=mentor
|
||||||
)
|
)
|
||||||
ChatParticipant.objects.create(chat=chat, user=mentor, role='admin')
|
|
||||||
ChatParticipant.objects.create(chat=chat, user=recipient, role='member')
|
|
||||||
|
|
||||||
# Создаем системное сообщение в чате (без HTML-тегов, чтобы в чате не отображались теги)
|
# Создаем системное сообщение в чате (без HTML-тегов, чтобы в чате не отображались теги)
|
||||||
title_plain = strip_tags(instance.title or '')
|
title_plain = strip_tags(instance.title or '')
|
||||||
|
|
|
||||||
|
|
@ -570,7 +570,14 @@ def send_lesson_notification(lesson_id, notification_type):
|
||||||
elif notification_type == 'lesson_cancelled':
|
elif notification_type == 'lesson_cancelled':
|
||||||
NotificationService.send_lesson_cancelled(lesson)
|
NotificationService.send_lesson_cancelled(lesson)
|
||||||
elif notification_type == 'lesson_reminder':
|
elif notification_type == 'lesson_reminder':
|
||||||
|
# Проверяем что напоминание ещё не отправлено (используем флаг для 1 часа как основной)
|
||||||
|
if lesson.reminder_1h_sent:
|
||||||
|
logger.info(f'Lesson {lesson_id} reminder already sent, skipping')
|
||||||
|
return f'Lesson {lesson_id} reminder already sent'
|
||||||
NotificationService.send_lesson_reminder(lesson)
|
NotificationService.send_lesson_reminder(lesson)
|
||||||
|
# Отмечаем что напоминание отправлено
|
||||||
|
lesson.reminder_1h_sent = True
|
||||||
|
lesson.save(update_fields=['reminder_1h_sent'])
|
||||||
elif notification_type == 'lesson_rescheduled':
|
elif notification_type == 'lesson_rescheduled':
|
||||||
NotificationService.send_lesson_rescheduled(lesson)
|
NotificationService.send_lesson_rescheduled(lesson)
|
||||||
elif notification_type == 'lesson_completed':
|
elif notification_type == 'lesson_completed':
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,10 @@ from .models import (
|
||||||
PointsTransaction,
|
PointsTransaction,
|
||||||
BonusTransaction,
|
BonusTransaction,
|
||||||
PromoCode,
|
PromoCode,
|
||||||
PromoCodeUsage
|
PromoCodeUsage,
|
||||||
|
ReferralInvitedEmail,
|
||||||
|
UserActivityDay,
|
||||||
|
PendingReferralBonus,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -339,3 +342,30 @@ class PromoCodeUsageAdmin(admin.ModelAdmin):
|
||||||
return obj.promo_code.code
|
return obj.promo_code.code
|
||||||
promo_code_code.short_description = 'Промокод'
|
promo_code_code.short_description = 'Промокод'
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ReferralInvitedEmail)
|
||||||
|
class ReferralInvitedEmailAdmin(admin.ModelAdmin):
|
||||||
|
"""Бэклог приглашённых email (защита от накрутки)."""
|
||||||
|
list_display = ['email', 'referrer', 'referred_user', 'created_at']
|
||||||
|
search_fields = ['email', 'referrer__email', 'referred_user__email']
|
||||||
|
readonly_fields = ['email', 'referrer', 'referred_user', 'created_at']
|
||||||
|
list_filter = ['created_at']
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(UserActivityDay)
|
||||||
|
class UserActivityDayAdmin(admin.ModelAdmin):
|
||||||
|
"""Дни активности пользователей (для проверки условий начисления бонусов)."""
|
||||||
|
list_display = ['user', 'date', 'created_at']
|
||||||
|
list_filter = ['date']
|
||||||
|
search_fields = ['user__email']
|
||||||
|
readonly_fields = ['user', 'date', 'created_at']
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(PendingReferralBonus)
|
||||||
|
class PendingReferralBonusAdmin(admin.ModelAdmin):
|
||||||
|
"""Ожидающие начисления бонусов за рефералов."""
|
||||||
|
list_display = ['referrer', 'referred_user', 'points', 'level', 'status', 'referred_at', 'paid_at']
|
||||||
|
list_filter = ['status', 'level']
|
||||||
|
search_fields = ['referrer__email', 'referred_user__email']
|
||||||
|
readonly_fields = ['referrer', 'referred_user', 'referred_at', 'points', 'level', 'reason', 'paid_at', 'created_at']
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
"""
|
||||||
|
Обработка отложенных бонусов за рефералов.
|
||||||
|
|
||||||
|
Начисление возможно только при выполнении одного из условий:
|
||||||
|
- Прошло 30+ дней с приглашения И реферал был активен 20+ дней;
|
||||||
|
- Реферал был активен 21+ день (независимо от срока).
|
||||||
|
|
||||||
|
Запуск: python manage.py process_pending_referral_bonuses
|
||||||
|
Рекомендуется добавить в cron (ежедневно).
|
||||||
|
"""
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from apps.referrals.models import (
|
||||||
|
PendingReferralBonus,
|
||||||
|
UserActivityDay,
|
||||||
|
UserReferralProfile,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Начислить бонусы за рефералов, выполнивших условия по активности (20+ дней за 30 дней или 21+ день всего)'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
'--dry-run',
|
||||||
|
action='store_true',
|
||||||
|
help='Только показать, что было бы начислено, без изменений в БД',
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
dry_run = options['dry_run']
|
||||||
|
now = timezone.now()
|
||||||
|
paid_count = 0
|
||||||
|
for pending in PendingReferralBonus.objects.filter(status=PendingReferralBonus.STATUS_PENDING).select_related(
|
||||||
|
'referrer', 'referred_user'
|
||||||
|
):
|
||||||
|
referred_at = pending.referred_at
|
||||||
|
referred_date = referred_at.date()
|
||||||
|
# Дней активности реферала с даты приглашения
|
||||||
|
active_days = UserActivityDay.objects.filter(
|
||||||
|
user=pending.referred_user,
|
||||||
|
date__gte=referred_date,
|
||||||
|
).count()
|
||||||
|
days_since_referral = (now - referred_at).days
|
||||||
|
past_30 = days_since_referral >= 30
|
||||||
|
# Условие: (30+ дней и 20+ активных) ИЛИ (21+ активных дней)
|
||||||
|
if (past_30 and active_days >= 20) or (active_days >= 21):
|
||||||
|
if dry_run:
|
||||||
|
self.stdout.write(
|
||||||
|
f'[dry-run] Начислили бы {pending.points} очков {pending.referrer.email} '
|
||||||
|
f'за реферала {pending.referred_user.email} (активных дней: {active_days}, прошло дней: {days_since_referral})'
|
||||||
|
)
|
||||||
|
paid_count += 1
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
referrer_profile = pending.referrer.referral_profile
|
||||||
|
referrer_profile.add_points(pending.points, reason=pending.reason or f'Реферал {pending.referred_user.email} выполнил условия активности')
|
||||||
|
pending.status = PendingReferralBonus.STATUS_PAID
|
||||||
|
pending.paid_at = now
|
||||||
|
pending.save(update_fields=['status', 'paid_at'])
|
||||||
|
paid_count += 1
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f'Начислено {pending.points} очков {pending.referrer.email} за {pending.referred_user.email} (активных дней: {active_days})'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except UserReferralProfile.DoesNotExist:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING(f'Пропуск {pending.id}: у реферера нет профиля')
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.ERROR(f'Ошибка при начислении {pending.id}: {e}')
|
||||||
|
)
|
||||||
|
if dry_run:
|
||||||
|
self.stdout.write(self.style.SUCCESS(f'[dry-run] Всего к начислению: {paid_count}'))
|
||||||
|
else:
|
||||||
|
self.stdout.write(self.style.SUCCESS(f'Начислено бонусов: {paid_count}'))
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
# Generated migration for referral antifraud: backlog, activity days, pending bonuses
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django.core.validators
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
('referrals', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ReferralInvitedEmail',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('email', models.EmailField(db_index=True, max_length=254, unique=True, verbose_name='Email приглашённого')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата')),
|
||||||
|
('referrer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invited_emails', to=settings.AUTH_USER_MODEL, verbose_name='Реферер')),
|
||||||
|
('referred_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Приглашённый пользователь')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Приглашённый email',
|
||||||
|
'verbose_name_plural': 'Бэклог приглашённых email',
|
||||||
|
'db_table': 'referrals_invited_emails',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='UserActivityDay',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('date', models.DateField(db_index=True, verbose_name='Дата')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activity_days', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'День активности',
|
||||||
|
'verbose_name_plural': 'Дни активности',
|
||||||
|
'db_table': 'referrals_user_activity_days',
|
||||||
|
'ordering': ['-date'],
|
||||||
|
'unique_together': {('user', 'date')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PendingReferralBonus',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('referred_at', models.DateTimeField(db_index=True, verbose_name='Дата приглашения')),
|
||||||
|
('points', models.IntegerField(validators=[django.core.validators.MinValueValidator(0)], verbose_name='Очки к начислению')),
|
||||||
|
('level', models.IntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(2)], verbose_name='Уровень (1 — прямой, 2 — непрямой)')),
|
||||||
|
('reason', models.CharField(blank=True, max_length=255, verbose_name='Причина')),
|
||||||
|
('status', models.CharField(choices=[('pending', 'Ожидает'), ('paid', 'Начислено'), ('cancelled', 'Отменено')], db_index=True, default='pending', max_length=20, verbose_name='Статус')),
|
||||||
|
('paid_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата начисления')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
|
||||||
|
('referrer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pending_referral_bonuses', to=settings.AUTH_USER_MODEL, verbose_name='Реферер')),
|
||||||
|
('referred_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pending_bonuses_for_me', to=settings.AUTH_USER_MODEL, verbose_name='Реферал')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Ожидающий бонус за реферала',
|
||||||
|
'verbose_name_plural': 'Ожидающие бонусы за рефералов',
|
||||||
|
'db_table': 'referrals_pending_referral_bonus',
|
||||||
|
'ordering': ['referred_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='useractivityday',
|
||||||
|
index=models.Index(fields=['user', 'date'], name='referrals_u_user_id_8b0b0d_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='pendingreferralbonus',
|
||||||
|
index=models.Index(fields=['status', 'referred_at'], name='referrals_p_status_9c2e2a_idx'),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
# Data migration: fill ReferralInvitedEmail from existing UserReferralProfile (referred_by is not null)
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_invited_emails(apps, schema_editor):
|
||||||
|
UserReferralProfile = apps.get_model('referrals', 'UserReferralProfile')
|
||||||
|
ReferralInvitedEmail = apps.get_model('referrals', 'ReferralInvitedEmail')
|
||||||
|
for profile in UserReferralProfile.objects.filter(referred_by__isnull=False).select_related('user', 'referred_by'):
|
||||||
|
email_lower = profile.user.email.lower().strip()
|
||||||
|
if not ReferralInvitedEmail.objects.filter(email=email_lower).exists():
|
||||||
|
ReferralInvitedEmail.objects.create(
|
||||||
|
email=email_lower,
|
||||||
|
referrer=profile.referred_by,
|
||||||
|
referred_user=profile.user,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def noop(apps, schema_editor):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('referrals', '0002_add_referral_antifraud_models'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(backfill_invited_emails, noop),
|
||||||
|
]
|
||||||
|
|
@ -493,6 +493,152 @@ class BonusTransaction(models.Model):
|
||||||
return f"{self.user.email}: {sign}{self.amount} ₽"
|
return f"{self.user.email}: {sign}{self.amount} ₽"
|
||||||
|
|
||||||
|
|
||||||
|
class ReferralInvitedEmail(models.Model):
|
||||||
|
"""
|
||||||
|
Бэклог email-адресов, которые уже были приглашены (защита от накрутки).
|
||||||
|
Один email может быть в списке только один раз.
|
||||||
|
"""
|
||||||
|
email = models.EmailField(
|
||||||
|
unique=True,
|
||||||
|
db_index=True,
|
||||||
|
verbose_name='Email приглашённого'
|
||||||
|
)
|
||||||
|
referrer = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='invited_emails',
|
||||||
|
verbose_name='Реферер'
|
||||||
|
)
|
||||||
|
referred_user = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='+',
|
||||||
|
verbose_name='Приглашённый пользователь'
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(
|
||||||
|
auto_now_add=True,
|
||||||
|
verbose_name='Дата'
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'referrals_invited_emails'
|
||||||
|
verbose_name = 'Приглашённый email'
|
||||||
|
verbose_name_plural = 'Бэклог приглашённых email'
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.email} (пригласил: {self.referrer.email})"
|
||||||
|
|
||||||
|
|
||||||
|
class UserActivityDay(models.Model):
|
||||||
|
"""
|
||||||
|
Учёт дней активности пользователя на платформе (один день — одна запись).
|
||||||
|
Используется для проверки «реферал был активен 20+ дней» перед начислением бонуса.
|
||||||
|
"""
|
||||||
|
user = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='activity_days',
|
||||||
|
verbose_name='Пользователь'
|
||||||
|
)
|
||||||
|
date = models.DateField(
|
||||||
|
verbose_name='Дата',
|
||||||
|
db_index=True
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(
|
||||||
|
auto_now_add=True,
|
||||||
|
verbose_name='Создано'
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'referrals_user_activity_days'
|
||||||
|
verbose_name = 'День активности'
|
||||||
|
verbose_name_plural = 'Дни активности'
|
||||||
|
unique_together = [['user', 'date']]
|
||||||
|
ordering = ['-date']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['user', 'date']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user.email} — {self.date}"
|
||||||
|
|
||||||
|
|
||||||
|
class PendingReferralBonus(models.Model):
|
||||||
|
"""
|
||||||
|
Ожидающее начисление бонуса за реферала.
|
||||||
|
Начисляется после 30 дней при условии 20+ дней активности реферала,
|
||||||
|
либо при достижении рефералом 21 дня активности (если был менее активен).
|
||||||
|
"""
|
||||||
|
STATUS_PENDING = 'pending'
|
||||||
|
STATUS_PAID = 'paid'
|
||||||
|
STATUS_CANCELLED = 'cancelled'
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
(STATUS_PENDING, 'Ожидает'),
|
||||||
|
(STATUS_PAID, 'Начислено'),
|
||||||
|
(STATUS_CANCELLED, 'Отменено'),
|
||||||
|
]
|
||||||
|
|
||||||
|
referrer = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='pending_referral_bonuses',
|
||||||
|
verbose_name='Реферер'
|
||||||
|
)
|
||||||
|
referred_user = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='pending_bonuses_for_me',
|
||||||
|
verbose_name='Реферал'
|
||||||
|
)
|
||||||
|
referred_at = models.DateTimeField(
|
||||||
|
verbose_name='Дата приглашения',
|
||||||
|
db_index=True
|
||||||
|
)
|
||||||
|
points = models.IntegerField(
|
||||||
|
validators=[MinValueValidator(0)],
|
||||||
|
verbose_name='Очки к начислению'
|
||||||
|
)
|
||||||
|
level = models.IntegerField(
|
||||||
|
default=1,
|
||||||
|
validators=[MinValueValidator(1), MaxValueValidator(2)],
|
||||||
|
verbose_name='Уровень (1 — прямой, 2 — непрямой)'
|
||||||
|
)
|
||||||
|
reason = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Причина'
|
||||||
|
)
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=STATUS_CHOICES,
|
||||||
|
default=STATUS_PENDING,
|
||||||
|
db_index=True,
|
||||||
|
verbose_name='Статус'
|
||||||
|
)
|
||||||
|
paid_at = models.DateTimeField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Дата начисления'
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(
|
||||||
|
auto_now_add=True,
|
||||||
|
verbose_name='Создано'
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'referrals_pending_referral_bonus'
|
||||||
|
verbose_name = 'Ожидающий бонус за реферала'
|
||||||
|
verbose_name_plural = 'Ожидающие бонусы за рефералов'
|
||||||
|
ordering = ['referred_at']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['status', 'referred_at']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.referrer.email} <- {self.referred_user.email}: {self.points} очков ({self.get_status_display()})"
|
||||||
|
|
||||||
|
|
||||||
class PromoCode(models.Model):
|
class PromoCode(models.Model):
|
||||||
"""
|
"""
|
||||||
Промокод для скидок на подписки.
|
Промокод для скидок на подписки.
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from rest_framework.decorators import action
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||||
from django.db.models import Sum, Q, F
|
from django.db.models import Sum, Q, F
|
||||||
|
from django.utils import timezone
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
|
|
@ -18,6 +19,8 @@ from .models import (
|
||||||
BonusTransaction,
|
BonusTransaction,
|
||||||
PromoCode,
|
PromoCode,
|
||||||
PromoCodeUsage,
|
PromoCodeUsage,
|
||||||
|
ReferralInvitedEmail,
|
||||||
|
PendingReferralBonus,
|
||||||
)
|
)
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
ReferralLevelSerializer,
|
ReferralLevelSerializer,
|
||||||
|
|
@ -126,27 +129,54 @@ class ReferralViewSet(viewsets.ViewSet):
|
||||||
status=status.HTTP_400_BAD_REQUEST
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Защита от накрутки: email уже был приглашён ранее (бэклог)
|
||||||
|
email_lower = request.user.email.lower().strip()
|
||||||
|
if ReferralInvitedEmail.objects.filter(email=email_lower).exists():
|
||||||
|
return Response(
|
||||||
|
{'error': 'Этот email уже был приглашён ранее'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
# Устанавливаем реферера
|
# Устанавливаем реферера
|
||||||
profile.referred_by = referrer_profile.user
|
profile.referred_by = referrer_profile.user
|
||||||
profile.save()
|
profile.save()
|
||||||
|
|
||||||
# Обновляем статистику и начисляем очки (сигнал update_referrer_stats
|
# Добавляем email в бэклог приглашённых
|
||||||
# срабатывает только при created=True, а здесь — update существующего профиля)
|
ReferralInvitedEmail.objects.create(
|
||||||
|
email=email_lower,
|
||||||
|
referrer=referrer_profile.user,
|
||||||
|
referred_user=request.user,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Обновляем счётчик рефералов (без начисления очков — очки начисляются после проверки активности)
|
||||||
settings_obj = ReferralSettings.get_settings()
|
settings_obj = ReferralSettings.get_settings()
|
||||||
referrer_profile.direct_referrals_count += 1
|
referrer_profile.direct_referrals_count += 1
|
||||||
referrer_profile.save(update_fields=['direct_referrals_count'])
|
referrer_profile.save(update_fields=['direct_referrals_count'])
|
||||||
referrer_profile.add_points(
|
|
||||||
settings_obj.points_direct_referral,
|
# Очки начисляются отложенно: через 30 дней при 20+ днях активности реферала или при 21 дне активности
|
||||||
reason=f'Регистрация реферала {request.user.email}'
|
now = timezone.now()
|
||||||
|
PendingReferralBonus.objects.create(
|
||||||
|
referrer=referrer_profile.user,
|
||||||
|
referred_user=request.user,
|
||||||
|
referred_at=now,
|
||||||
|
points=settings_obj.points_direct_referral,
|
||||||
|
level=1,
|
||||||
|
reason=f'Регистрация реферала {request.user.email}',
|
||||||
|
status=PendingReferralBonus.STATUS_PENDING,
|
||||||
)
|
)
|
||||||
if referrer_profile.referred_by:
|
if referrer_profile.referred_by:
|
||||||
try:
|
try:
|
||||||
level2_profile = referrer_profile.referred_by.referral_profile
|
level2_profile = referrer_profile.referred_by.referral_profile
|
||||||
level2_profile.indirect_referrals_count += 1
|
level2_profile.indirect_referrals_count += 1
|
||||||
level2_profile.save(update_fields=['indirect_referrals_count'])
|
level2_profile.save(update_fields=['indirect_referrals_count'])
|
||||||
level2_profile.add_points(
|
PendingReferralBonus.objects.create(
|
||||||
settings_obj.points_indirect_referral,
|
referrer=referrer_profile.referred_by,
|
||||||
reason=f'Регистрация непрямого реферала {request.user.email}'
|
referred_user=request.user,
|
||||||
|
referred_at=now,
|
||||||
|
points=settings_obj.points_indirect_referral,
|
||||||
|
level=2,
|
||||||
|
reason=f'Регистрация непрямого реферала {request.user.email}',
|
||||||
|
status=PendingReferralBonus.STATUS_PENDING,
|
||||||
)
|
)
|
||||||
except (UserReferralProfile.DoesNotExist, AttributeError):
|
except (UserReferralProfile.DoesNotExist, AttributeError):
|
||||||
pass
|
pass
|
||||||
|
|
|
||||||
|
|
@ -635,6 +635,7 @@ class LessonCalendarSerializer(serializers.Serializer):
|
||||||
class LessonCalendarItemSerializer(serializers.ModelSerializer):
|
class LessonCalendarItemSerializer(serializers.ModelSerializer):
|
||||||
"""Лёгкий сериализатор для календаря: только поля, нужные для списка и календаря."""
|
"""Лёгкий сериализатор для календаря: только поля, нужные для списка и календаря."""
|
||||||
client_name = serializers.CharField(source='client.user.get_full_name', read_only=True)
|
client_name = serializers.CharField(source='client.user.get_full_name', read_only=True)
|
||||||
|
mentor_name = serializers.CharField(source='mentor.get_full_name', read_only=True)
|
||||||
subject = serializers.SerializerMethodField()
|
subject = serializers.SerializerMethodField()
|
||||||
|
|
||||||
def get_subject(self, obj):
|
def get_subject(self, obj):
|
||||||
|
|
@ -658,7 +659,7 @@ class LessonCalendarItemSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Lesson
|
model = Lesson
|
||||||
fields = ['id', 'title', 'start_time', 'end_time', 'status', 'client', 'client_name', 'subject', 'subject_name']
|
fields = ['id', 'title', 'start_time', 'end_time', 'status', 'client', 'client_name', 'mentor', 'mentor_name', 'subject', 'subject_name']
|
||||||
|
|
||||||
|
|
||||||
class LessonHomeworkSubmissionSerializer(serializers.ModelSerializer):
|
class LessonHomeworkSubmissionSerializer(serializers.ModelSerializer):
|
||||||
|
|
|
||||||
|
|
@ -30,14 +30,8 @@ def lesson_saved(sender, instance, created, **kwargs):
|
||||||
lesson_id=instance.id,
|
lesson_id=instance.id,
|
||||||
notification_type='lesson_created'
|
notification_type='lesson_created'
|
||||||
)
|
)
|
||||||
|
# Напоминания отправляются периодической задачей send_lesson_reminders
|
||||||
# Планируем напоминание за 1 час до занятия
|
# (проверяет флаги reminder_24h_sent, reminder_1h_sent, reminder_15m_sent)
|
||||||
reminder_time = instance.start_time - timedelta(hours=1)
|
|
||||||
if reminder_time > timezone.now():
|
|
||||||
send_lesson_notification.apply_async(
|
|
||||||
args=[instance.id, 'lesson_reminder'],
|
|
||||||
eta=reminder_time
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
# Занятие изменено
|
# Занятие изменено
|
||||||
# Проверяем, что именно изменилось
|
# Проверяем, что именно изменилось
|
||||||
|
|
|
||||||
|
|
@ -86,12 +86,9 @@ class LessonViewSet(viewsets.ModelViewSet):
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Клиенты видят свои занятия
|
# Студенты (клиенты) видят ТОЛЬКО свои занятия — не расписание ментора
|
||||||
elif user.role == 'client':
|
elif getattr(user, 'role', None) == 'client':
|
||||||
try:
|
queryset = queryset.filter(client__user_id=user.id)
|
||||||
queryset = queryset.filter(client=user.client_profile)
|
|
||||||
except:
|
|
||||||
queryset = Lesson.objects.none()
|
|
||||||
|
|
||||||
# Родители видят занятия своих детей
|
# Родители видят занятия своих детей
|
||||||
elif user.role == 'parent':
|
elif user.role == 'parent':
|
||||||
|
|
@ -619,18 +616,29 @@ class LessonViewSet(viewsets.ModelViewSet):
|
||||||
has_homework_files_param = request.data.get('has_homework_files') in (True, 'true', 1)
|
has_homework_files_param = request.data.get('has_homework_files') in (True, 'true', 1)
|
||||||
request_has_files = has_homework_files_param or request_has_file_ids
|
request_has_files = has_homework_files_param or request_has_file_ids
|
||||||
|
|
||||||
has_homework = request_has_text or request_has_files
|
# Проверяем флаг "заполнить позже"
|
||||||
|
homework_fill_later = request.data.get('homework_fill_later') in (True, 'true', 1, 'True')
|
||||||
|
|
||||||
|
has_homework = request_has_text or request_has_files or homework_fill_later
|
||||||
|
|
||||||
homework_id = None
|
homework_id = None
|
||||||
if has_homework:
|
if has_homework:
|
||||||
# Есть текст ДЗ или файлы – создаём или обновляем опубликованное задание
|
|
||||||
title = lesson.title or 'Домашнее задание'
|
title = lesson.title or 'Домашнее задание'
|
||||||
description = (lesson.homework_text or '').strip() or '' # описание не обязательно
|
description = (lesson.homework_text or '').strip() or ''
|
||||||
|
|
||||||
|
# Определяем статус и fill_later флаг
|
||||||
|
if homework_fill_later:
|
||||||
|
hw_status = 'draft'
|
||||||
|
hw_fill_later = True
|
||||||
|
else:
|
||||||
|
hw_status = 'published'
|
||||||
|
hw_fill_later = False
|
||||||
|
|
||||||
if existing_hw:
|
if existing_hw:
|
||||||
existing_hw.title = title
|
existing_hw.title = title
|
||||||
existing_hw.description = description
|
existing_hw.description = description
|
||||||
existing_hw.status = 'published'
|
existing_hw.status = hw_status
|
||||||
|
existing_hw.fill_later = hw_fill_later
|
||||||
existing_hw.lesson = lesson
|
existing_hw.lesson = lesson
|
||||||
existing_hw.save()
|
existing_hw.save()
|
||||||
homework_obj = existing_hw
|
homework_obj = existing_hw
|
||||||
|
|
@ -640,7 +648,8 @@ class LessonViewSet(viewsets.ModelViewSet):
|
||||||
description=description,
|
description=description,
|
||||||
mentor=lesson.mentor,
|
mentor=lesson.mentor,
|
||||||
lesson=lesson,
|
lesson=lesson,
|
||||||
status='published',
|
status=hw_status,
|
||||||
|
fill_later=hw_fill_later,
|
||||||
)
|
)
|
||||||
homework_id = homework_obj.id
|
homework_id = homework_obj.id
|
||||||
|
|
||||||
|
|
@ -652,6 +661,8 @@ class LessonViewSet(viewsets.ModelViewSet):
|
||||||
client_user = lesson_with_client.client.user
|
client_user = lesson_with_client.client.user
|
||||||
if client_user:
|
if client_user:
|
||||||
homework_obj.assigned_to.add(client_user)
|
homework_obj.assigned_to.add(client_user)
|
||||||
|
# Уведомление отправляем ТОЛЬКО если ДЗ опубликовано (не fill_later)
|
||||||
|
if not hw_fill_later:
|
||||||
from apps.notifications.services import NotificationService
|
from apps.notifications.services import NotificationService
|
||||||
NotificationService.send_homework_notification(homework_obj, 'homework_assigned')
|
NotificationService.send_homework_notification(homework_obj, 'homework_assigned')
|
||||||
|
|
||||||
|
|
@ -885,6 +896,8 @@ class LessonViewSet(viewsets.ModelViewSet):
|
||||||
Получить занятия для календаря.
|
Получить занятия для календаря.
|
||||||
|
|
||||||
GET /api/schedule/lessons/calendar/?start_date=2024-01-01&end_date=2024-01-31
|
GET /api/schedule/lessons/calendar/?start_date=2024-01-01&end_date=2024-01-31
|
||||||
|
|
||||||
|
Студент видит только свои занятия. Ментор — свои. Родитель — занятия детей.
|
||||||
"""
|
"""
|
||||||
serializer = LessonCalendarSerializer(data=request.query_params)
|
serializer = LessonCalendarSerializer(data=request.query_params)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
@ -894,6 +907,9 @@ class LessonViewSet(viewsets.ModelViewSet):
|
||||||
start_time__date__gte=data['start_date'],
|
start_time__date__gte=data['start_date'],
|
||||||
start_time__date__lte=data['end_date']
|
start_time__date__lte=data['end_date']
|
||||||
)
|
)
|
||||||
|
# Доп. защита: студент никогда не должен видеть чужие занятия
|
||||||
|
if getattr(request.user, 'role', None) == 'client':
|
||||||
|
queryset = queryset.filter(client__user_id=request.user.id)
|
||||||
if data.get('status'):
|
if data.get('status'):
|
||||||
queryset = queryset.filter(status=data['status'])
|
queryset = queryset.filter(status=data['status'])
|
||||||
lessons = LessonCalendarItemSerializer(
|
lessons = LessonCalendarItemSerializer(
|
||||||
|
|
@ -1434,7 +1450,8 @@ class LessonHomeworkSubmissionViewSet(viewsets.ModelViewSet):
|
||||||
NotificationService.send_homework_notification(
|
NotificationService.send_homework_notification(
|
||||||
homework,
|
homework,
|
||||||
'homework_reviewed',
|
'homework_reviewed',
|
||||||
student=submission.student
|
student=submission.student,
|
||||||
|
submission=submission
|
||||||
)
|
)
|
||||||
except Homework.DoesNotExist:
|
except Homework.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
"""
|
"""
|
||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
|
import secrets
|
||||||
from django.contrib.auth.models import AbstractUser, BaseUserManager
|
from django.contrib.auth.models import AbstractUser, BaseUserManager
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
@ -314,31 +315,37 @@ class User(AbstractUser):
|
||||||
alphabet = string.ascii_uppercase + string.digits
|
alphabet = string.ascii_uppercase + string.digits
|
||||||
for _ in range(100):
|
for _ in range(100):
|
||||||
code = ''.join(random.choices(alphabet, k=8))
|
code = ''.join(random.choices(alphabet, k=8))
|
||||||
|
# Проверяем уникальность кода
|
||||||
if not User.objects.filter(universal_code=code).exclude(pk=self.pk).exists():
|
if not User.objects.filter(universal_code=code).exclude(pk=self.pk).exists():
|
||||||
return code
|
return code
|
||||||
raise ValueError('Не удалось сгенерировать уникальный universal_code')
|
# Если не удалось сгенерировать за 100 попыток, используем более длинный fallback
|
||||||
|
return secrets.token_hex(4).upper()
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
|
# 1. Нормализация телефона
|
||||||
if self.phone:
|
if self.phone:
|
||||||
self.phone = normalize_phone(self.phone)
|
self.phone = normalize_phone(self.phone)
|
||||||
|
|
||||||
# Автоматическая генерация username из email, если не задан
|
# 2. Генерация username из email
|
||||||
if not self.username and self.email:
|
if not self.username and self.email:
|
||||||
self.username = self.email.split('@')[0]
|
self.username = self.email.split('@')[0]
|
||||||
# Добавляем цифры, если username уже существует
|
|
||||||
counter = 1
|
counter = 1
|
||||||
original_username = self.username
|
original_username = self.username
|
||||||
while User.objects.filter(username=self.username).exclude(pk=self.pk).exists():
|
while User.objects.filter(username=self.username).exclude(pk=self.pk).exists():
|
||||||
self.username = f"{original_username}{counter}"
|
self.username = f"{original_username}{counter}"
|
||||||
counter += 1
|
counter += 1
|
||||||
|
if kwargs.get('update_fields') is not None:
|
||||||
|
fields = set(kwargs['update_fields'])
|
||||||
|
fields.add('username')
|
||||||
|
kwargs['update_fields'] = list(fields)
|
||||||
|
|
||||||
# Гарантируем 8-символьный код (universal_code)
|
# 3. Гарантируем 8-символьный код (universal_code)
|
||||||
if not self.universal_code:
|
if not self.universal_code or len(str(self.universal_code).strip()) != 8:
|
||||||
try:
|
|
||||||
self.universal_code = self._generate_universal_code()
|
self.universal_code = self._generate_universal_code()
|
||||||
except Exception:
|
if kwargs.get('update_fields') is not None:
|
||||||
# Если не удалось сгенерировать, не прерываем сохранение
|
fields = set(kwargs['update_fields'])
|
||||||
pass
|
fields.add('universal_code')
|
||||||
|
kwargs['update_fields'] = list(fields)
|
||||||
|
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,13 +68,10 @@ class ProfileViewSet(viewsets.ViewSet):
|
||||||
GET /api/users/profile/me/
|
GET /api/users/profile/me/
|
||||||
"""
|
"""
|
||||||
user = request.user
|
user = request.user
|
||||||
# Убедиться, что у пользователя есть 8-символьный код (для старых пользователей)
|
# User.save() автоматически создаст universal_code, если он отсутствует
|
||||||
if not user.universal_code or len(user.universal_code) != 8:
|
if not user.universal_code or len(str(user.universal_code).strip()) != 8:
|
||||||
try:
|
|
||||||
user.universal_code = user._generate_universal_code()
|
|
||||||
user.save(update_fields=['universal_code'])
|
user.save(update_fields=['universal_code'])
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
serializer = UserSerializer(user, context={'request': request})
|
serializer = UserSerializer(user, context={'request': request})
|
||||||
|
|
||||||
# Добавляем дополнительную информацию
|
# Добавляем дополнительную информацию
|
||||||
|
|
@ -381,13 +378,6 @@ class ProfileViewSet(viewsets.ViewSet):
|
||||||
|
|
||||||
user = request.user
|
user = request.user
|
||||||
|
|
||||||
# 8-символьный код: если нет — генерируем при обновлении профиля
|
|
||||||
if not user.universal_code or len(user.universal_code) != 8:
|
|
||||||
try:
|
|
||||||
user.universal_code = user._generate_universal_code()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Обработка удаления аватара
|
# Обработка удаления аватара
|
||||||
if 'avatar' in request.data:
|
if 'avatar' in request.data:
|
||||||
avatar_value = request.data.get('avatar')
|
avatar_value = request.data.get('avatar')
|
||||||
|
|
@ -1505,27 +1495,6 @@ class InvitationViewSet(viewsets.ViewSet):
|
||||||
city=city
|
city=city
|
||||||
)
|
)
|
||||||
|
|
||||||
# Гарантируем 8-символьный код для приглашений (ментор/студент)
|
|
||||||
if not student_user.universal_code or len(str(student_user.universal_code or '').strip()) != 8:
|
|
||||||
try:
|
|
||||||
# Теперь метод _generate_universal_code определен в базовой модели User
|
|
||||||
student_user.universal_code = student_user._generate_universal_code()
|
|
||||||
student_user.save(update_fields=['universal_code'])
|
|
||||||
except Exception:
|
|
||||||
# Fallback на случай ошибок генерации
|
|
||||||
import string
|
|
||||||
import random
|
|
||||||
try:
|
|
||||||
alphabet = string.ascii_uppercase + string.digits
|
|
||||||
for _ in range(500):
|
|
||||||
code = ''.join(random.choices(alphabet, k=8))
|
|
||||||
if not User.objects.filter(universal_code=code).exclude(pk=student_user.pk).exists():
|
|
||||||
student_user.universal_code = code
|
|
||||||
student_user.save(update_fields=['universal_code'])
|
|
||||||
break
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Генерируем персональный токен для входа
|
# Генерируем персональный токен для входа
|
||||||
student_user.login_token = secrets.token_urlsafe(32)
|
student_user.login_token = secrets.token_urlsafe(32)
|
||||||
student_user.save(update_fields=['login_token'])
|
student_user.save(update_fields=['login_token'])
|
||||||
|
|
|
||||||
|
|
@ -156,15 +156,6 @@ class RegisterSerializer(serializers.ModelSerializer):
|
||||||
**validated_data
|
**validated_data
|
||||||
)
|
)
|
||||||
|
|
||||||
# Гарантированно задаём 8-символьный код при создании
|
|
||||||
if not user.universal_code or len(str(user.universal_code or '').strip()) != 8:
|
|
||||||
try:
|
|
||||||
user.universal_code = user._generate_universal_code()
|
|
||||||
user.save(update_fields=['universal_code'])
|
|
||||||
except Exception:
|
|
||||||
# Если не удалось, код будет сгенерирован в RegisterView или при запросе профиля
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Создаем профиль в зависимости от роли
|
# Создаем профиль в зависимости от роли
|
||||||
if user.role == 'client':
|
if user.role == 'client':
|
||||||
Client.objects.create(user=user)
|
Client.objects.create(user=user)
|
||||||
|
|
|
||||||
|
|
@ -126,25 +126,6 @@ class TelegramAuthView(generics.GenericAPIView):
|
||||||
user.set_unusable_password()
|
user.set_unusable_password()
|
||||||
user.save()
|
user.save()
|
||||||
|
|
||||||
# Гарантируем 8-символьный код для приглашений
|
|
||||||
if not user.universal_code or len(str(user.universal_code or '').strip()) != 8:
|
|
||||||
try:
|
|
||||||
user.universal_code = user._generate_universal_code()
|
|
||||||
user.save(update_fields=['universal_code'])
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f'Ошибка генерации universal_code для Telegram пользователя {user.id}: {e}')
|
|
||||||
# Пробуем ещё раз
|
|
||||||
try:
|
|
||||||
alphabet = string.ascii_uppercase + string.digits
|
|
||||||
for _ in range(500):
|
|
||||||
code = ''.join(random.choices(alphabet, k=8))
|
|
||||||
if not User.objects.filter(universal_code=code).exclude(pk=user.pk).exists():
|
|
||||||
user.universal_code = code
|
|
||||||
user.save(update_fields=['universal_code'])
|
|
||||||
break
|
|
||||||
except Exception:
|
|
||||||
pass # Код будет сгенерирован при следующем запросе профиля
|
|
||||||
|
|
||||||
is_new_user = True
|
is_new_user = True
|
||||||
message = 'Регистрация через Telegram выполнена успешно'
|
message = 'Регистрация через Telegram выполнена успешно'
|
||||||
|
|
||||||
|
|
@ -182,31 +163,6 @@ class RegisterView(generics.CreateAPIView):
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
user = serializer.save()
|
user = serializer.save()
|
||||||
|
|
||||||
# Всегда задаём 8-символьный код при регистрации (для приглашений ментор/студент)
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
need_code = not user.universal_code or len(str(user.universal_code or '').strip()) != 8
|
|
||||||
if need_code:
|
|
||||||
try:
|
|
||||||
user.universal_code = user._generate_universal_code()
|
|
||||||
user.save(update_fields=['universal_code'])
|
|
||||||
except Exception as e:
|
|
||||||
# Если не удалось сгенерировать код, пробуем ещё раз с большим количеством попыток
|
|
||||||
logger.warning(f'Ошибка генерации universal_code для пользователя {user.id}: {e}, пробуем ещё раз')
|
|
||||||
try:
|
|
||||||
alphabet = string.ascii_uppercase + string.digits
|
|
||||||
for _ in range(500):
|
|
||||||
code = ''.join(random.choices(alphabet, k=8))
|
|
||||||
if not User.objects.filter(universal_code=code).exclude(pk=user.pk).exists():
|
|
||||||
user.universal_code = code
|
|
||||||
user.save(update_fields=['universal_code'])
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
# Если всё равно не получилось, не прерываем регистрацию
|
|
||||||
logger.error(f'Не удалось сгенерировать unique universal_code для пользователя {user.id} после 500 попыток')
|
|
||||||
except Exception as e2:
|
|
||||||
logger.error(f'Критическая ошибка генерации universal_code для пользователя {user.id}: {e2}')
|
|
||||||
# Не прерываем регистрацию, код будет сгенерирован при следующем запросе профиля
|
|
||||||
|
|
||||||
# Токен для подтверждения email
|
# Токен для подтверждения email
|
||||||
verification_token = secrets.token_urlsafe(32)
|
verification_token = secrets.token_urlsafe(32)
|
||||||
user.email_verification_token = verification_token
|
user.email_verification_token = verification_token
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Проверка доступности портов SMTP (465 и 2525) для smtp.mail.ru."""
|
||||||
|
import socket
|
||||||
|
|
||||||
|
host = "smtp.mail.ru"
|
||||||
|
ports = [465, 2525]
|
||||||
|
timeout = 10
|
||||||
|
|
||||||
|
for port in ports:
|
||||||
|
try:
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
s.settimeout(timeout)
|
||||||
|
err = s.connect_ex((host, port))
|
||||||
|
s.close()
|
||||||
|
status = "доступен" if err == 0 else "недоступен"
|
||||||
|
print(f" {host}:{port} — {status}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" {host}:{port} — ошибка: {e}")
|
||||||
|
|
@ -40,6 +40,10 @@ export interface User {
|
||||||
universal_code?: string;
|
universal_code?: string;
|
||||||
invitation_link?: string;
|
invitation_link?: string;
|
||||||
invitation_link_token?: string;
|
invitation_link_token?: string;
|
||||||
|
timezone?: string;
|
||||||
|
language?: string;
|
||||||
|
city?: string;
|
||||||
|
country?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -258,6 +258,33 @@ export function validateHomeworkFiles(files: File[]): { valid: boolean; error?:
|
||||||
return { valid: true };
|
return { valid: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновить домашнее задание (для черновиков fill_later).
|
||||||
|
* PATCH /api/homework/homeworks/{id}/
|
||||||
|
*/
|
||||||
|
export async function updateHomework(
|
||||||
|
homeworkId: string | number,
|
||||||
|
data: {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
deadline?: string | null;
|
||||||
|
status?: 'draft' | 'published';
|
||||||
|
fill_later?: boolean;
|
||||||
|
}
|
||||||
|
): Promise<Homework> {
|
||||||
|
const res = await apiClient.patch<Homework>(`/homework/homeworks/${homeworkId}/`, data);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Опубликовать домашнее задание (из черновика в published).
|
||||||
|
* POST /api/homework/homeworks/{id}/publish/
|
||||||
|
*/
|
||||||
|
export async function publishHomework(homeworkId: string | number): Promise<Homework> {
|
||||||
|
const res = await apiClient.post<Homework>(`/homework/homeworks/${homeworkId}/publish/`);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function submitHomework(
|
export async function submitHomework(
|
||||||
homeworkId: string | number,
|
homeworkId: string | number,
|
||||||
data: { content?: string; text?: string; files?: File[] },
|
data: { content?: string; text?: string; files?: File[] },
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { CheckLesson } from '@/components/checklesson/checklesson';
|
||||||
import { getLessonsCalendar, getLesson, createLesson, updateLesson, deleteLesson } from '@/api/schedule';
|
import { getLessonsCalendar, getLesson, createLesson, updateLesson, deleteLesson } from '@/api/schedule';
|
||||||
import { getStudents } from '@/api/students';
|
import { getStudents } from '@/api/students';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import { createDateTimeInUserTimezone, parseISOToUserTimezone } from '@/utils/timezone';
|
||||||
import { useSelectedChild } from '@/contexts/SelectedChildContext';
|
import { useSelectedChild } from '@/contexts/SelectedChildContext';
|
||||||
import { getSubjects, getMentorSubjects } from '@/api/subjects';
|
import { getSubjects, getMentorSubjects } from '@/api/subjects';
|
||||||
import { loadComponent } from '@/lib/material-components';
|
import { loadComponent } from '@/lib/material-components';
|
||||||
|
|
@ -132,6 +133,9 @@ export default function SchedulePage() {
|
||||||
client_name: lesson.client_name ?? (lesson.client?.user
|
client_name: lesson.client_name ?? (lesson.client?.user
|
||||||
? `${lesson.client.user.first_name || ''} ${lesson.client.user.last_name || ''}`.trim()
|
? `${lesson.client.user.first_name || ''} ${lesson.client.user.last_name || ''}`.trim()
|
||||||
: undefined),
|
: undefined),
|
||||||
|
mentor_name: lesson.mentor_name ?? (lesson.mentor?.first_name || lesson.mentor?.last_name
|
||||||
|
? `${lesson.mentor?.first_name || ''} ${lesson.mentor?.last_name || ''}`.trim()
|
||||||
|
: undefined),
|
||||||
subject: lesson.subject ?? lesson.subject_name ?? '',
|
subject: lesson.subject ?? lesson.subject_name ?? '',
|
||||||
}));
|
}));
|
||||||
setLessons(mappedLessons);
|
setLessons(mappedLessons);
|
||||||
|
|
@ -156,9 +160,15 @@ export default function SchedulePage() {
|
||||||
|
|
||||||
const lessonsForSelectedDate: LessonPreview[] = lessons
|
const lessonsForSelectedDate: LessonPreview[] = lessons
|
||||||
.filter((lesson) => {
|
.filter((lesson) => {
|
||||||
const lessonDate = startOfDay(new Date(lesson.start_time));
|
// Парсим дату в timezone пользователя для правильной фильтрации
|
||||||
|
const parsed = parseISOToUserTimezone(lesson.start_time, user?.timezone);
|
||||||
|
const lessonDate = startOfDay(parsed.dateObj);
|
||||||
return lessonDate.getTime() === selectedDate.getTime();
|
return lessonDate.getTime() === selectedDate.getTime();
|
||||||
})
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
// Сортируем по времени начала (раньше → первые)
|
||||||
|
return new Date(a.start_time).getTime() - new Date(b.start_time).getTime();
|
||||||
|
})
|
||||||
.map((lesson) => ({
|
.map((lesson) => ({
|
||||||
id: String(lesson.id),
|
id: String(lesson.id),
|
||||||
title: lesson.title || 'Занятие',
|
title: lesson.title || 'Занятие',
|
||||||
|
|
@ -229,15 +239,18 @@ export default function SchedulePage() {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const details = await getLesson(String(lesson.id));
|
const details = await getLesson(String(lesson.id));
|
||||||
const start = new Date(details.start_time);
|
|
||||||
const end = new Date(details.end_time);
|
// Парсим время в timezone пользователя
|
||||||
const safeStart = startOfDay(start);
|
const startParsed = parseISOToUserTimezone(details.start_time, user?.timezone);
|
||||||
|
const safeStart = startOfDay(startParsed.dateObj);
|
||||||
|
|
||||||
// синхронизируем правую панель с датой урока
|
// синхронизируем правую панель с датой урока
|
||||||
setSelectedDate(safeStart);
|
setSelectedDate(safeStart);
|
||||||
setDisplayDate(safeStart);
|
setDisplayDate(safeStart);
|
||||||
|
|
||||||
const duration = (() => {
|
const duration = (() => {
|
||||||
|
const start = new Date(details.start_time);
|
||||||
|
const end = new Date(details.end_time);
|
||||||
const mins = differenceInMinutes(end, start);
|
const mins = differenceInMinutes(end, start);
|
||||||
return Number.isFinite(mins) && mins > 0 ? mins : 60;
|
return Number.isFinite(mins) && mins > 0 ? mins : 60;
|
||||||
})();
|
})();
|
||||||
|
|
@ -246,8 +259,8 @@ export default function SchedulePage() {
|
||||||
client: details.client?.id ? String(details.client.id) : '',
|
client: details.client?.id ? String(details.client.id) : '',
|
||||||
title: details.title ?? '',
|
title: details.title ?? '',
|
||||||
description: details.description ?? '',
|
description: details.description ?? '',
|
||||||
start_date: format(start, 'yyyy-MM-dd'),
|
start_date: startParsed.date,
|
||||||
start_time: format(start, 'HH:mm'),
|
start_time: startParsed.time,
|
||||||
duration,
|
duration,
|
||||||
price: typeof details.price === 'number' ? details.price : undefined,
|
price: typeof details.price === 'number' ? details.price : undefined,
|
||||||
is_recurring: !!(details as any).is_recurring,
|
is_recurring: !!(details as any).is_recurring,
|
||||||
|
|
@ -337,7 +350,12 @@ export default function SchedulePage() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const startUtc = new Date(`${formData.start_date}T${formData.start_time}`).toISOString();
|
// Конвертируем время из timezone пользователя в UTC
|
||||||
|
const startUtc = createDateTimeInUserTimezone(
|
||||||
|
formData.start_date,
|
||||||
|
formData.start_time,
|
||||||
|
user?.timezone
|
||||||
|
);
|
||||||
const title = generateTitle();
|
const title = generateTitle();
|
||||||
|
|
||||||
if (isEditingMode && editingLessonId) {
|
if (isEditingMode && editingLessonId) {
|
||||||
|
|
@ -425,6 +443,8 @@ export default function SchedulePage() {
|
||||||
onSelectSlot={handleSelectSlot}
|
onSelectSlot={handleSelectSlot}
|
||||||
onSelectEvent={handleSelectEvent}
|
onSelectEvent={handleSelectEvent}
|
||||||
onMonthChange={handleMonthChange}
|
onMonthChange={handleMonthChange}
|
||||||
|
isMentor={isMentor}
|
||||||
|
userTimezone={user?.timezone}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="ios26-schedule-right-wrap">
|
<div className="ios26-schedule-right-wrap">
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,14 @@ export default function RootLayout({
|
||||||
<head>
|
<head>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||||
|
{/* Preload локального шрифта иконок */}
|
||||||
|
<link
|
||||||
|
rel="preload"
|
||||||
|
href="/fonts/material-symbols-outlined.woff2"
|
||||||
|
as="font"
|
||||||
|
type="font/woff2"
|
||||||
|
crossOrigin="anonymous"
|
||||||
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<Providers>
|
<Providers>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ export interface CalendarLesson {
|
||||||
status?: string;
|
status?: string;
|
||||||
client?: number;
|
client?: number;
|
||||||
client_name?: string;
|
client_name?: string;
|
||||||
|
mentor_name?: string;
|
||||||
subject?: string;
|
subject?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -34,6 +35,10 @@ export interface CalendarProps {
|
||||||
onSelectEvent?: (lesson: { id: string }) => void;
|
onSelectEvent?: (lesson: { id: string }) => void;
|
||||||
/** Смена видимого месяца (start/end месяца) */
|
/** Смена видимого месяца (start/end месяца) */
|
||||||
onMonthChange?: (start: Date, end: Date) => void;
|
onMonthChange?: (start: Date, end: Date) => void;
|
||||||
|
/** Ментор — показывает ученика; студент — показывает предмет и ментора */
|
||||||
|
isMentor?: boolean;
|
||||||
|
/** Часовой пояс пользователя (например, 'UTC+8') */
|
||||||
|
userTimezone?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Calendar: React.FC<CalendarProps> = ({
|
export const Calendar: React.FC<CalendarProps> = ({
|
||||||
|
|
@ -43,25 +48,40 @@ export const Calendar: React.FC<CalendarProps> = ({
|
||||||
onSelectSlot,
|
onSelectSlot,
|
||||||
onSelectEvent,
|
onSelectEvent,
|
||||||
onMonthChange,
|
onMonthChange,
|
||||||
|
isMentor = true,
|
||||||
|
userTimezone,
|
||||||
}) => {
|
}) => {
|
||||||
const mappedLessons = React.useMemo(
|
const mappedLessons = React.useMemo(
|
||||||
() =>
|
() =>
|
||||||
lessons.map((lesson) => ({
|
lessons.map((lesson) => {
|
||||||
|
if (isMentor && lesson.client_name) {
|
||||||
|
return {
|
||||||
id: String(lesson.id),
|
id: String(lesson.id),
|
||||||
title: lesson.title || 'Занятие',
|
title: lesson.title || 'Занятие',
|
||||||
start_time: lesson.start_time,
|
start_time: lesson.start_time,
|
||||||
end_time: lesson.end_time,
|
end_time: lesson.end_time,
|
||||||
status: (lesson.status || 'scheduled') as 'scheduled' | 'in_progress' | 'completed' | 'cancelled',
|
status: (lesson.status || 'scheduled') as 'scheduled' | 'in_progress' | 'completed' | 'cancelled',
|
||||||
client: lesson.client_name
|
client: {
|
||||||
? {
|
|
||||||
id: String(lesson.client ?? ''),
|
id: String(lesson.client ?? ''),
|
||||||
name: lesson.client_name,
|
name: lesson.client_name,
|
||||||
first_name: lesson.client_name.split(' ')[0] || lesson.client_name,
|
first_name: lesson.client_name.split(' ')[0] || lesson.client_name,
|
||||||
last_name: lesson.client_name.split(' ').slice(1).join(' ') || '',
|
last_name: lesson.client_name.split(' ').slice(1).join(' ') || '',
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
: undefined,
|
const subject = lesson.subject || 'Занятие';
|
||||||
})),
|
const mentorName = lesson.mentor_name || '';
|
||||||
[lessons]
|
const displayTitle = mentorName ? `${subject} — ${mentorName}` : subject;
|
||||||
|
return {
|
||||||
|
id: String(lesson.id),
|
||||||
|
title: displayTitle,
|
||||||
|
start_time: lesson.start_time,
|
||||||
|
end_time: lesson.end_time,
|
||||||
|
status: (lesson.status || 'scheduled') as 'scheduled' | 'in_progress' | 'completed' | 'cancelled',
|
||||||
|
client: undefined,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
[lessons, isMentor]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -82,6 +102,7 @@ export const Calendar: React.FC<CalendarProps> = ({
|
||||||
<LessonsCalendar
|
<LessonsCalendar
|
||||||
lessons={mappedLessons}
|
lessons={mappedLessons}
|
||||||
selectedDate={selectedDate}
|
selectedDate={selectedDate}
|
||||||
|
userTimezone={userTimezone}
|
||||||
onSelectSlot={(date) => {
|
onSelectSlot={(date) => {
|
||||||
try {
|
try {
|
||||||
const d = startOfDay(date);
|
const d = startOfDay(date);
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { getCurrentUser, User } from '@/api/auth';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { DatePicker } from '@/components/common/DatePicker';
|
import { DatePicker } from '@/components/common/DatePicker';
|
||||||
import { TimePicker } from '@/components/common/TimePicker';
|
import { TimePicker } from '@/components/common/TimePicker';
|
||||||
|
import { createDateTimeInUserTimezone } from '@/utils/timezone';
|
||||||
|
|
||||||
interface CreateLessonDialogProps {
|
interface CreateLessonDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -250,8 +251,12 @@ export const CreateLessonDialog: React.FC<CreateLessonDialogProps> = ({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Объединяем дату и время в ISO строку
|
// Объединяем дату и время в ISO строку с учётом timezone пользователя
|
||||||
const startUtc = new Date(`${formData.start_date}T${formData.start_time}`).toISOString();
|
const startUtc = createDateTimeInUserTimezone(
|
||||||
|
formData.start_date,
|
||||||
|
formData.start_time,
|
||||||
|
currentUser?.timezone
|
||||||
|
);
|
||||||
|
|
||||||
const payload: any = {
|
const payload: any = {
|
||||||
client: formData.client,
|
client: formData.client,
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,12 @@
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { LessonPreview } from '@/api/dashboard';
|
import { LessonPreview } from '@/api/dashboard';
|
||||||
import { createLiveKitRoom } from '@/api/livekit';
|
import { createLiveKitRoom } from '@/api/livekit';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import { parseISOToUserTimezone } from '@/utils/timezone';
|
||||||
|
|
||||||
interface LessonCardProps {
|
interface LessonCardProps {
|
||||||
lesson: LessonPreview;
|
lesson: LessonPreview;
|
||||||
|
|
@ -35,6 +37,7 @@ export const LessonCard: React.FC<LessonCardProps> = ({
|
||||||
onClick,
|
onClick,
|
||||||
}) => {
|
}) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { user } = useAuth();
|
||||||
const [connectLoading, setConnectLoading] = useState(false);
|
const [connectLoading, setConnectLoading] = useState(false);
|
||||||
const [canJoin, setCanJoin] = useState(false);
|
const [canJoin, setCanJoin] = useState(false);
|
||||||
|
|
||||||
|
|
@ -64,8 +67,13 @@ export const LessonCard: React.FC<LessonCardProps> = ({
|
||||||
[canJoin, connectLoading, lesson.id, router]
|
[canJoin, connectLoading, lesson.id, router]
|
||||||
);
|
);
|
||||||
|
|
||||||
const startTime = new Date(lesson.start_time);
|
// Парсим время с учётом timezone пользователя
|
||||||
const endTime = new Date(lesson.end_time);
|
const { startParsed, endParsed } = useMemo(() => {
|
||||||
|
return {
|
||||||
|
startParsed: parseISOToUserTimezone(lesson.start_time, user?.timezone),
|
||||||
|
endParsed: parseISOToUserTimezone(lesson.end_time, user?.timezone),
|
||||||
|
};
|
||||||
|
}, [lesson.start_time, lesson.end_time, user?.timezone]);
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
|
|
@ -188,20 +196,14 @@ export const LessonCard: React.FC<LessonCardProps> = ({
|
||||||
<polyline points="12 6 12 12 16 14"></polyline>
|
<polyline points="12 6 12 12 16 14"></polyline>
|
||||||
</svg>
|
</svg>
|
||||||
<span>
|
<span>
|
||||||
{startTime.toLocaleDateString('ru-RU', {
|
{startParsed.dateObj.toLocaleDateString('ru-RU', {
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
month: 'short'
|
month: 'short'
|
||||||
})}
|
})}
|
||||||
{' в '}
|
{' в '}
|
||||||
{startTime.toLocaleTimeString('ru-RU', {
|
{startParsed.time}
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
})}
|
|
||||||
{' - '}
|
{' - '}
|
||||||
{endTime.toLocaleTimeString('ru-RU', {
|
{endParsed.time}
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
})}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import {
|
||||||
import { ru } from 'date-fns/locale';
|
import { ru } from 'date-fns/locale';
|
||||||
import { Box, IconButton, Typography } from '@mui/material';
|
import { Box, IconButton, Typography } from '@mui/material';
|
||||||
import { ChevronLeft, ChevronRight } from '@mui/icons-material';
|
import { ChevronLeft, ChevronRight } from '@mui/icons-material';
|
||||||
|
import { parseISOToUserTimezone } from '@/utils/timezone';
|
||||||
|
|
||||||
interface Lesson {
|
interface Lesson {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -46,6 +47,7 @@ interface LessonsCalendarProps {
|
||||||
onSelectSlot?: (date: Date) => void;
|
onSelectSlot?: (date: Date) => void;
|
||||||
onMonthChange?: (start: Date, end: Date) => void;
|
onMonthChange?: (start: Date, end: Date) => void;
|
||||||
selectedDate?: Date;
|
selectedDate?: Date;
|
||||||
|
userTimezone?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LessonsCalendar: React.FC<LessonsCalendarProps> = ({
|
export const LessonsCalendar: React.FC<LessonsCalendarProps> = ({
|
||||||
|
|
@ -54,6 +56,7 @@ export const LessonsCalendar: React.FC<LessonsCalendarProps> = ({
|
||||||
onSelectSlot,
|
onSelectSlot,
|
||||||
onMonthChange,
|
onMonthChange,
|
||||||
selectedDate,
|
selectedDate,
|
||||||
|
userTimezone,
|
||||||
}) => {
|
}) => {
|
||||||
const safeSelectedDate = useMemo(() => {
|
const safeSelectedDate = useMemo(() => {
|
||||||
if (selectedDate && !Number.isNaN(selectedDate.getTime())) return startOfDay(selectedDate);
|
if (selectedDate && !Number.isNaN(selectedDate.getTime())) return startOfDay(selectedDate);
|
||||||
|
|
@ -79,16 +82,16 @@ export const LessonsCalendar: React.FC<LessonsCalendarProps> = ({
|
||||||
onMonthChange?.(start, end);
|
onMonthChange?.(start, end);
|
||||||
}, [currentMonth, onMonthChange]);
|
}, [currentMonth, onMonthChange]);
|
||||||
|
|
||||||
// Группируем занятия по дате (ключ YYYY-MM-DD)
|
// Группируем занятия по дате (ключ YYYY-MM-DD) с учётом timezone пользователя
|
||||||
const lessonsByDay = useMemo(() => {
|
const lessonsByDay = useMemo(() => {
|
||||||
const map = new Map<string, Lesson[]>();
|
const map = new Map<string, Lesson[]>();
|
||||||
if (!lessons || lessons.length === 0) return map;
|
if (!lessons || lessons.length === 0) return map;
|
||||||
|
|
||||||
lessons.forEach((lesson) => {
|
lessons.forEach((lesson) => {
|
||||||
try {
|
try {
|
||||||
const day = startOfDay(new Date(lesson.start_time));
|
// Используем timezone пользователя для определения дня
|
||||||
if (isNaN(day.getTime())) return;
|
const parsed = parseISOToUserTimezone(lesson.start_time, userTimezone);
|
||||||
const key = format(day, 'yyyy-MM-dd');
|
const key = parsed.date; // уже в формате 'yyyy-MM-dd'
|
||||||
const existing = map.get(key) || [];
|
const existing = map.get(key) || [];
|
||||||
existing.push(lesson);
|
existing.push(lesson);
|
||||||
map.set(key, existing);
|
map.set(key, existing);
|
||||||
|
|
@ -97,8 +100,16 @@ export const LessonsCalendar: React.FC<LessonsCalendarProps> = ({
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Сортируем занятия внутри каждого дня по времени
|
||||||
|
map.forEach((dayLessons, key) => {
|
||||||
|
dayLessons.sort((a, b) =>
|
||||||
|
new Date(a.start_time).getTime() - new Date(b.start_time).getTime()
|
||||||
|
);
|
||||||
|
map.set(key, dayLessons);
|
||||||
|
});
|
||||||
|
|
||||||
return map;
|
return map;
|
||||||
}, [lessons]);
|
}, [lessons, userTimezone]);
|
||||||
|
|
||||||
const monthLabel = useMemo(() => {
|
const monthLabel = useMemo(() => {
|
||||||
const label = format(currentMonth, 'LLLL yyyy', { locale: ru });
|
const label = format(currentMonth, 'LLLL yyyy', { locale: ru });
|
||||||
|
|
@ -343,7 +354,9 @@ export const LessonsCalendar: React.FC<LessonsCalendarProps> = ({
|
||||||
{dayLessons.slice(0, 2).map((lesson) => {
|
{dayLessons.slice(0, 2).map((lesson) => {
|
||||||
const timeStr = (() => {
|
const timeStr = (() => {
|
||||||
try {
|
try {
|
||||||
return format(new Date(lesson.start_time), 'HH:mm', { locale: ru });
|
// Используем timezone пользователя для отображения времени
|
||||||
|
const parsed = parseISOToUserTimezone(lesson.start_time, userTimezone);
|
||||||
|
return parsed.time;
|
||||||
} catch {
|
} catch {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,718 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
updateHomework,
|
||||||
|
publishHomework,
|
||||||
|
type Homework,
|
||||||
|
type HomeworkFileItem,
|
||||||
|
} from '@/api/homework';
|
||||||
|
import { getMyMaterials } from '@/api/materials';
|
||||||
|
import type { Material } from '@/api/materials';
|
||||||
|
import apiClient from '@/lib/api-client';
|
||||||
|
|
||||||
|
const MAX_FILE_SIZE_MB = 10;
|
||||||
|
const MAX_FILES = 10;
|
||||||
|
|
||||||
|
interface EditHomeworkDraftModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
homework: Homework | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileUrl(file: HomeworkFileItem | null): string {
|
||||||
|
if (!file?.file) return '';
|
||||||
|
if (file.file.startsWith('http')) return file.file;
|
||||||
|
const base = typeof window !== 'undefined' ? `${window.location.protocol}//${window.location.hostname}:8123` : '';
|
||||||
|
return file.file.startsWith('/') ? `${base}${file.file}` : `${base}/${file.file}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditHomeworkDraftModal({
|
||||||
|
isOpen,
|
||||||
|
homework,
|
||||||
|
onClose,
|
||||||
|
onSuccess,
|
||||||
|
}: EditHomeworkDraftModalProps) {
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [deadline, setDeadline] = useState('');
|
||||||
|
const [existingFiles, setExistingFiles] = useState<HomeworkFileItem[]>([]);
|
||||||
|
const [newFiles, setNewFiles] = useState<File[]>([]);
|
||||||
|
const [uploadingFiles, setUploadingFiles] = useState<Set<string>>(new Set());
|
||||||
|
const [materials, setMaterials] = useState<Material[]>([]);
|
||||||
|
const [materialsLoading, setMaterialsLoading] = useState(false);
|
||||||
|
const [selectedMaterialIds, setSelectedMaterialIds] = useState<Set<string>>(new Set());
|
||||||
|
const [materialsSearch, setMaterialsSearch] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [publishing, setPublishing] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || !homework) return;
|
||||||
|
setTitle(homework.title || '');
|
||||||
|
setDescription(homework.description || '');
|
||||||
|
setDeadline(homework.deadline ? homework.deadline.slice(0, 16) : '');
|
||||||
|
setExistingFiles(homework.files?.filter(f => f.file_type === 'assignment') || []);
|
||||||
|
setNewFiles([]);
|
||||||
|
setSelectedMaterialIds(new Set());
|
||||||
|
setError(null);
|
||||||
|
}, [isOpen, homework]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
setMaterialsLoading(true);
|
||||||
|
getMyMaterials()
|
||||||
|
.then((list) => setMaterials(Array.isArray(list) ? list : []))
|
||||||
|
.catch(() => setMaterials([]))
|
||||||
|
.finally(() => setMaterialsLoading(false));
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = Array.from(e.target.files || []);
|
||||||
|
if (!files.length || !homework) return;
|
||||||
|
|
||||||
|
const validFiles: File[] = [];
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.size > MAX_FILE_SIZE_MB * 1024 * 1024) {
|
||||||
|
setError(`Файл "${file.name}" больше ${MAX_FILE_SIZE_MB} МБ`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (existingFiles.length + newFiles.length + validFiles.length >= MAX_FILES) {
|
||||||
|
setError(`Максимум ${MAX_FILES} файлов`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
validFiles.push(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of validFiles) {
|
||||||
|
const fileKey = `${file.name}-${Date.now()}`;
|
||||||
|
setUploadingFiles((prev) => new Set(prev).add(fileKey));
|
||||||
|
setNewFiles((prev) => [...prev, file]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('homework', String(homework.id));
|
||||||
|
formData.append('file_type', 'assignment');
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const res = await apiClient.post<HomeworkFileItem>('/homework/files/', formData);
|
||||||
|
setExistingFiles((prev) => [...prev, res.data]);
|
||||||
|
setNewFiles((prev) => prev.filter((f) => f !== file));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Ошибка загрузки файла');
|
||||||
|
setNewFiles((prev) => prev.filter((f) => f !== file));
|
||||||
|
} finally {
|
||||||
|
setUploadingFiles((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(fileKey);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveFile = async (fileId: number) => {
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/homework/files/${fileId}/`);
|
||||||
|
setExistingFiles((prev) => prev.filter((f) => f.id !== fileId));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Ошибка удаления файла');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMaterialToggle = (materialId: string) => {
|
||||||
|
setSelectedMaterialIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(materialId)) {
|
||||||
|
next.delete(materialId);
|
||||||
|
} else {
|
||||||
|
next.add(materialId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const attachMaterialFiles = async () => {
|
||||||
|
if (!homework || selectedMaterialIds.size === 0) return;
|
||||||
|
|
||||||
|
for (const materialId of selectedMaterialIds) {
|
||||||
|
const material = materials.find((m) => String(m.id) === materialId);
|
||||||
|
if (!material?.file) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(material.file);
|
||||||
|
const blob = await response.blob();
|
||||||
|
const filename = material.title || material.file.split('/').pop() || 'material';
|
||||||
|
const file = new File([blob], filename, { type: blob.type });
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('homework', String(homework.id));
|
||||||
|
formData.append('file_type', 'assignment');
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const res = await apiClient.post<HomeworkFileItem>('/homework/files/', formData);
|
||||||
|
setExistingFiles((prev) => [...prev, res.data]);
|
||||||
|
} catch {
|
||||||
|
// Ignore material attach errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSelectedMaterialIds(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!homework) return;
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
setSaving(true);
|
||||||
|
await attachMaterialFiles();
|
||||||
|
await updateHomework(homework.id, {
|
||||||
|
title: title.trim() || homework.title,
|
||||||
|
description: description.trim(),
|
||||||
|
deadline: deadline ? new Date(deadline).toISOString() : null,
|
||||||
|
});
|
||||||
|
onSuccess();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Ошибка сохранения');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePublish = async () => {
|
||||||
|
if (!homework) return;
|
||||||
|
if (!title.trim()) {
|
||||||
|
setError('Укажите название задания');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!description.trim()) {
|
||||||
|
setError('Укажите текст задания');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
setPublishing(true);
|
||||||
|
await attachMaterialFiles();
|
||||||
|
await updateHomework(homework.id, {
|
||||||
|
title: title.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
deadline: deadline ? new Date(deadline).toISOString() : null,
|
||||||
|
});
|
||||||
|
await publishHomework(homework.id);
|
||||||
|
onSuccess();
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Ошибка публикации');
|
||||||
|
} finally {
|
||||||
|
setPublishing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen || !homework) return null;
|
||||||
|
|
||||||
|
const isLoading = saving || publishing || uploadingFiles.size > 0;
|
||||||
|
|
||||||
|
const filteredMaterials = materials.filter((m) => {
|
||||||
|
if (!materialsSearch.trim()) return true;
|
||||||
|
const q = materialsSearch.toLowerCase();
|
||||||
|
return (
|
||||||
|
(m.title || '').toLowerCase().includes(q) ||
|
||||||
|
(m.description || '').toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
background: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
zIndex: 999,
|
||||||
|
}}
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="ios26-panel"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: '90vw',
|
||||||
|
maxWidth: 600,
|
||||||
|
background: 'var(--md-sys-color-surface)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
zIndex: 1001,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 12,
|
||||||
|
padding: '20px 24px',
|
||||||
|
borderBottom: '1px solid var(--md-sys-color-outline-variant)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 600,
|
||||||
|
margin: 0,
|
||||||
|
color: 'var(--md-sys-color-on-surface)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Заполнить домашнее задание
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 12,
|
||||||
|
border: 'none',
|
||||||
|
background: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--md-sys-color-on-surface-variant)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 24 }}>
|
||||||
|
close
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '24px',
|
||||||
|
paddingBottom: 'max(24px, env(safe-area-inset-bottom, 0px) + 100px)',
|
||||||
|
overflowY: 'auto',
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||||
|
{/* Title */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
marginBottom: 8,
|
||||||
|
color: 'var(--md-sys-color-on-surface-variant)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Название задания *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Введите название"
|
||||||
|
disabled={isLoading}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderRadius: 12,
|
||||||
|
border: '1px solid var(--md-sys-color-outline)',
|
||||||
|
background: 'var(--md-sys-color-surface)',
|
||||||
|
fontSize: 15,
|
||||||
|
color: 'var(--md-sys-color-on-surface)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
marginBottom: 8,
|
||||||
|
color: 'var(--md-sys-color-on-surface-variant)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Текст задания *
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
placeholder="Опишите задание, шаги, ссылки..."
|
||||||
|
disabled={isLoading}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderRadius: 12,
|
||||||
|
border: '1px solid var(--md-sys-color-outline)',
|
||||||
|
background: 'var(--md-sys-color-surface)',
|
||||||
|
fontSize: 15,
|
||||||
|
color: 'var(--md-sys-color-on-surface)',
|
||||||
|
resize: 'vertical',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Deadline */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
marginBottom: 8,
|
||||||
|
color: 'var(--md-sys-color-on-surface-variant)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Дедлайн (опционально)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={deadline}
|
||||||
|
onChange={(e) => setDeadline(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
style={{
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderRadius: 12,
|
||||||
|
border: '1px solid var(--md-sys-color-outline)',
|
||||||
|
background: 'var(--md-sys-color-surface)',
|
||||||
|
fontSize: 15,
|
||||||
|
color: 'var(--md-sys-color-on-surface)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Files */}
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
marginBottom: 8,
|
||||||
|
color: 'var(--md-sys-color-on-surface-variant)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Файлы и материалы к ДЗ
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File upload */}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
ref={fileInputRef}
|
||||||
|
className="hidden"
|
||||||
|
id="edit-homework-file"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
disabled={isLoading}
|
||||||
|
accept=".pdf,.doc,.docx,.txt,.jpg,.jpeg,.png,.zip,.rar"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="edit-homework-file"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 8,
|
||||||
|
padding: '14px 20px',
|
||||||
|
borderRadius: 12,
|
||||||
|
border: '2px dashed var(--md-sys-color-outline)',
|
||||||
|
background: 'var(--md-sys-color-surface-variant)',
|
||||||
|
color: 'var(--md-sys-color-on-surface-variant)',
|
||||||
|
fontSize: 14,
|
||||||
|
cursor: isLoading ? 'not-allowed' : 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>
|
||||||
|
upload_file
|
||||||
|
</span>
|
||||||
|
{uploadingFiles.size > 0
|
||||||
|
? `Загрузка ${uploadingFiles.size}…`
|
||||||
|
: 'Загрузить файлы'}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Existing files */}
|
||||||
|
{existingFiles.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 8,
|
||||||
|
marginTop: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{existingFiles.map((file) => {
|
||||||
|
const url = getFileUrl(file);
|
||||||
|
const isImage = /\.(jpe?g|png|gif|webp|bmp)$/i.test(
|
||||||
|
file.filename || ''
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={file.id}
|
||||||
|
style={{
|
||||||
|
width: 80,
|
||||||
|
aspectRatio: '1',
|
||||||
|
borderRadius: 12,
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: '2px solid var(--md-sys-color-outline-variant)',
|
||||||
|
background: 'var(--md-sys-color-surface-variant)',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isImage && url ? (
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt=""
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined"
|
||||||
|
style={{
|
||||||
|
fontSize: 28,
|
||||||
|
color: 'var(--md-sys-color-primary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
description
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
fontSize: 10,
|
||||||
|
padding: 4,
|
||||||
|
background: 'rgba(0,0,0,0.6)',
|
||||||
|
color: '#fff',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{file.filename}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemoveFile(file.id)}
|
||||||
|
disabled={isLoading}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 2,
|
||||||
|
right: 2,
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: '50%',
|
||||||
|
border: 'none',
|
||||||
|
background: 'var(--md-sys-color-error)',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 14,
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Materials */}
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
marginBottom: 8,
|
||||||
|
color: 'var(--md-sys-color-on-surface-variant)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Прикрепить из моих материалов
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={materialsSearch}
|
||||||
|
onChange={(e) => setMaterialsSearch(e.target.value)}
|
||||||
|
placeholder="Поиск материалов..."
|
||||||
|
disabled={isLoading}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '10px 14px',
|
||||||
|
borderRadius: 10,
|
||||||
|
border: '1px solid var(--md-sys-color-outline)',
|
||||||
|
background: 'var(--md-sys-color-surface)',
|
||||||
|
fontSize: 14,
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{materialsLoading ? (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: 'var(--md-sys-color-on-surface-variant)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Загрузка материалов…
|
||||||
|
</p>
|
||||||
|
) : filteredMaterials.length === 0 ? (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: 'var(--md-sys-color-on-surface-variant)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Нет материалов
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 8,
|
||||||
|
maxHeight: 160,
|
||||||
|
overflowY: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filteredMaterials.slice(0, 20).map((m) => {
|
||||||
|
const materialId = String(m.id);
|
||||||
|
const isSelected = selectedMaterialIds.has(materialId);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={materialId}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleMaterialToggle(materialId)}
|
||||||
|
disabled={isLoading}
|
||||||
|
style={{
|
||||||
|
padding: '8px 14px',
|
||||||
|
borderRadius: 10,
|
||||||
|
border: `2px solid ${
|
||||||
|
isSelected
|
||||||
|
? 'var(--md-sys-color-primary)'
|
||||||
|
: 'var(--md-sys-color-outline-variant)'
|
||||||
|
}`,
|
||||||
|
background: isSelected
|
||||||
|
? 'var(--md-sys-color-primary-container)'
|
||||||
|
: 'var(--md-sys-color-surface-variant)',
|
||||||
|
color: isSelected
|
||||||
|
? 'var(--md-sys-color-on-primary-container)'
|
||||||
|
: 'var(--md-sys-color-on-surface)',
|
||||||
|
fontSize: 13,
|
||||||
|
cursor: 'pointer',
|
||||||
|
maxWidth: 200,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{m.title || 'Без названия'}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: 16,
|
||||||
|
background: 'rgba(186,26,26,0.1)',
|
||||||
|
borderRadius: 12,
|
||||||
|
color: 'var(--md-sys-color-error)',
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 12,
|
||||||
|
paddingTop: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handlePublish}
|
||||||
|
disabled={isLoading}
|
||||||
|
style={{
|
||||||
|
padding: '14px 28px',
|
||||||
|
borderRadius: 14,
|
||||||
|
border: 'none',
|
||||||
|
background: 'var(--md-sys-color-primary)',
|
||||||
|
color: 'var(--md-sys-color-on-primary)',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: isLoading ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isLoading ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{publishing ? 'Публикация...' : 'Опубликовать ДЗ'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isLoading}
|
||||||
|
style={{
|
||||||
|
padding: '14px 28px',
|
||||||
|
borderRadius: 14,
|
||||||
|
border: '1px solid var(--md-sys-color-outline)',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--md-sys-color-on-surface)',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: isLoading ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isLoading ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{saving ? 'Сохранение...' : 'Сохранить черновик'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
} from '@/api/homework';
|
} from '@/api/homework';
|
||||||
import { getBackendOrigin } from '@/lib/api-client';
|
import { getBackendOrigin } from '@/lib/api-client';
|
||||||
import { SubmitHomeworkModal } from './SubmitHomeworkModal';
|
import { SubmitHomeworkModal } from './SubmitHomeworkModal';
|
||||||
|
import { EditHomeworkDraftModal } from './EditHomeworkDraftModal';
|
||||||
|
|
||||||
interface HomeworkDetailsModalProps {
|
interface HomeworkDetailsModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -303,6 +304,9 @@ export function HomeworkDetailsModal({ isOpen, homework, userRole, childId, onCl
|
||||||
const [imagePreviewUrl, setImagePreviewUrl] = useState<string | null>(null);
|
const [imagePreviewUrl, setImagePreviewUrl] = useState<string | null>(null);
|
||||||
const [documentViewer, setDocumentViewer] = useState<{ url: string; filename: string; type: 'pdf' | 'text' } | null>(null);
|
const [documentViewer, setDocumentViewer] = useState<{ url: string; filename: string; type: 'pdf' | 'text' } | null>(null);
|
||||||
|
|
||||||
|
// Модальное окно редактирования черновика ДЗ
|
||||||
|
const [editDraftOpen, setEditDraftOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen || !homework) return;
|
if (!isOpen || !homework) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
@ -484,6 +488,39 @@ export function HomeworkDetailsModal({ isOpen, homework, userRole, childId, onCl
|
||||||
flex: 1,
|
flex: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Черновик fill_later — показываем кнопку редактирования */}
|
||||||
|
{userRole === 'mentor' && homework.fill_later && (
|
||||||
|
<div style={{ marginBottom: 28, padding: 20, background: 'var(--md-sys-color-tertiary-container)', borderRadius: 16 }}>
|
||||||
|
<h4 style={{ fontSize: 16, fontWeight: 600, marginBottom: 12, color: 'var(--md-sys-color-on-tertiary-container)' }}>
|
||||||
|
Черновик — требуется заполнение
|
||||||
|
</h4>
|
||||||
|
<p style={{ fontSize: 14, color: 'var(--md-sys-color-on-tertiary-container)', marginBottom: 16, opacity: 0.8 }}>
|
||||||
|
Это домашнее задание было создано с пометкой «заполнить позже». Заполните детали задания и опубликуйте его для студента.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditDraftOpen(true)}
|
||||||
|
style={{
|
||||||
|
padding: '14px 28px',
|
||||||
|
borderRadius: 14,
|
||||||
|
border: 'none',
|
||||||
|
background: 'var(--md-sys-color-primary)',
|
||||||
|
color: 'var(--md-sys-color-on-primary)',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>edit</span>
|
||||||
|
Заполнить задание
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Обычное отображение для опубликованных заданий */}
|
||||||
{homework.description && (
|
{homework.description && (
|
||||||
<div style={{ marginBottom: 28 }}>
|
<div style={{ marginBottom: 28 }}>
|
||||||
<h4 style={{ fontSize: 16, fontWeight: 600, marginBottom: 12 }}>Описание</h4>
|
<h4 style={{ fontSize: 16, fontWeight: 600, marginBottom: 12 }}>Описание</h4>
|
||||||
|
|
@ -1358,6 +1395,16 @@ export function HomeworkDetailsModal({ isOpen, homework, userRole, childId, onCl
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<EditHomeworkDraftModal
|
||||||
|
isOpen={editDraftOpen}
|
||||||
|
homework={homework}
|
||||||
|
onClose={() => setEditDraftOpen(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
setEditDraftOpen(false);
|
||||||
|
onSuccess();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import { completeLesson, type Lesson } from '@/api/schedule';
|
import { completeLesson, type Lesson } from '@/api/schedule';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import { parseISOToUserTimezone } from '@/utils/timezone';
|
||||||
|
|
||||||
interface FeedbackModalProps {
|
interface FeedbackModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -11,6 +13,7 @@ interface FeedbackModalProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FeedbackModal({ isOpen, lesson, onClose, onSuccess }: FeedbackModalProps) {
|
export function FeedbackModal({ isOpen, lesson, onClose, onSuccess }: FeedbackModalProps) {
|
||||||
|
const { user } = useAuth();
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
mentor_grade: '',
|
mentor_grade: '',
|
||||||
school_grade: '',
|
school_grade: '',
|
||||||
|
|
@ -19,6 +22,15 @@ export function FeedbackModal({ isOpen, lesson, onClose, onSuccess }: FeedbackMo
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Парсим время с учётом timezone пользователя
|
||||||
|
const parsedTimes = useMemo(() => {
|
||||||
|
if (!lesson) return null;
|
||||||
|
return {
|
||||||
|
start: parseISOToUserTimezone(lesson.start_time, user?.timezone),
|
||||||
|
end: parseISOToUserTimezone(lesson.end_time, user?.timezone),
|
||||||
|
};
|
||||||
|
}, [lesson, user?.timezone]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && lesson) {
|
if (isOpen && lesson) {
|
||||||
setFormData({
|
setFormData({
|
||||||
|
|
@ -29,7 +41,7 @@ export function FeedbackModal({ isOpen, lesson, onClose, onSuccess }: FeedbackMo
|
||||||
}
|
}
|
||||||
}, [isOpen, lesson]);
|
}, [isOpen, lesson]);
|
||||||
|
|
||||||
if (!lesson) return null;
|
if (!lesson || !parsedTimes) return null;
|
||||||
|
|
||||||
const visible = isOpen;
|
const visible = isOpen;
|
||||||
|
|
||||||
|
|
@ -155,15 +167,15 @@ export function FeedbackModal({ isOpen, lesson, onClose, onSuccess }: FeedbackMo
|
||||||
<div>
|
<div>
|
||||||
<span style={{ color: 'var(--md-sys-color-on-surface-variant)' }}>Дата: </span>
|
<span style={{ color: 'var(--md-sys-color-on-surface-variant)' }}>Дата: </span>
|
||||||
<span style={{ color: 'var(--md-sys-color-on-surface)', fontWeight: 500 }}>
|
<span style={{ color: 'var(--md-sys-color-on-surface)', fontWeight: 500 }}>
|
||||||
{new Date(lesson.start_time).toLocaleDateString('ru-RU')}
|
{parsedTimes.start.dateObj.toLocaleDateString('ru-RU')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span style={{ color: 'var(--md-sys-color-on-surface-variant)' }}>Время: </span>
|
<span style={{ color: 'var(--md-sys-color-on-surface-variant)' }}>Время: </span>
|
||||||
<span style={{ color: 'var(--md-sys-color-on-surface)', fontWeight: 500 }}>
|
<span style={{ color: 'var(--md-sys-color-on-surface)', fontWeight: 500 }}>
|
||||||
{new Date(lesson.start_time).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
|
{parsedTimes.start.time}
|
||||||
{' — '}
|
{' — '}
|
||||||
{new Date(lesson.end_time).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
|
{parsedTimes.end.time}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -4,7 +4,32 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap');
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200');
|
|
||||||
|
/* Material Symbols — локальный шрифт для быстрой загрузки */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Material Symbols Outlined';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 100 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts/material-symbols-outlined.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-symbols-outlined {
|
||||||
|
font-family: 'Material Symbols Outlined';
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: normal;
|
||||||
|
text-transform: none;
|
||||||
|
display: inline-block;
|
||||||
|
white-space: nowrap;
|
||||||
|
word-wrap: normal;
|
||||||
|
direction: ltr;
|
||||||
|
-webkit-font-feature-settings: 'liga';
|
||||||
|
font-feature-settings: 'liga';
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
/* CSS Variables для темизации (быстрее чем JS темы) */
|
/* CSS Variables для темизации (быстрее чем JS темы) */
|
||||||
:root {
|
:root {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,201 @@
|
||||||
|
/**
|
||||||
|
* Утилиты для работы с часовыми поясами.
|
||||||
|
*
|
||||||
|
* Поддерживаемые форматы timezone:
|
||||||
|
* - UTC+X, UTC-X (например, "UTC+8", "UTC-5")
|
||||||
|
* - GMT+X, GMT-X
|
||||||
|
* - IANA названия (например, "Europe/Moscow", "Asia/Irkutsk")
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Парсить timezone и получить смещение в минутах.
|
||||||
|
*
|
||||||
|
* Примеры:
|
||||||
|
* - "UTC+8" -> 480 (8 * 60)
|
||||||
|
* - "UTC-5" -> -300 (-5 * 60)
|
||||||
|
* - "UTC+5:30" -> 330 (5 * 60 + 30)
|
||||||
|
*
|
||||||
|
* @returns смещение в минутах или null если не удалось распарсить
|
||||||
|
*/
|
||||||
|
export function parseTimezoneOffset(timezone: string | undefined): number | null {
|
||||||
|
if (!timezone) return null;
|
||||||
|
|
||||||
|
const trimmed = timezone.trim();
|
||||||
|
|
||||||
|
// Парсим формат UTC+X, UTC-X, GMT+X, GMT-X
|
||||||
|
const match = trimmed.match(/^(?:UTC|GMT)([+-])(\d{1,2})(?::(\d{2}))?$/i);
|
||||||
|
if (match) {
|
||||||
|
const sign = match[1] === '+' ? 1 : -1;
|
||||||
|
const hours = parseInt(match[2], 10);
|
||||||
|
const minutes = match[3] ? parseInt(match[3], 10) : 0;
|
||||||
|
return sign * (hours * 60 + minutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить смещение часового пояса в минутах.
|
||||||
|
*
|
||||||
|
* Поддерживает:
|
||||||
|
* - UTC+X формат (парсит напрямую)
|
||||||
|
* - IANA названия (использует Intl API)
|
||||||
|
*
|
||||||
|
* @returns смещение в минутах (положительное = восток от UTC)
|
||||||
|
*/
|
||||||
|
export function getTimezoneOffsetMinutes(timezone: string | undefined): number {
|
||||||
|
if (!timezone) {
|
||||||
|
// Браузерный timezone
|
||||||
|
return -new Date().getTimezoneOffset();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сначала пробуем распарсить UTC+X формат
|
||||||
|
const parsedOffset = parseTimezoneOffset(timezone);
|
||||||
|
if (parsedOffset !== null) {
|
||||||
|
return parsedOffset;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Для IANA названий используем Intl API
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
const utcDate = new Date(now.toLocaleString('en-US', { timeZone: 'UTC' }));
|
||||||
|
const tzDate = new Date(now.toLocaleString('en-US', { timeZone: timezone }));
|
||||||
|
return Math.round((tzDate.getTime() - utcDate.getTime()) / 60000);
|
||||||
|
} catch {
|
||||||
|
// Fallback на браузерный timezone
|
||||||
|
return -new Date().getTimezoneOffset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создать ISO строку даты/времени с учетом часового пояса пользователя.
|
||||||
|
*
|
||||||
|
* Пример: Если пользователь в Улан-Удэ (UTC+8) вводит 18:00,
|
||||||
|
* то нужно отправить на сервер 10:00 UTC (18:00 - 8 часов).
|
||||||
|
*
|
||||||
|
* @param dateStr - дата в формате 'YYYY-MM-DD'
|
||||||
|
* @param timeStr - время в формате 'HH:mm'
|
||||||
|
* @param userTimezone - часовой пояс пользователя (например, 'UTC+8', 'Europe/Moscow')
|
||||||
|
* @returns ISO строка в UTC
|
||||||
|
*/
|
||||||
|
export function createDateTimeInUserTimezone(
|
||||||
|
dateStr: string,
|
||||||
|
timeStr: string,
|
||||||
|
userTimezone: string | undefined
|
||||||
|
): string {
|
||||||
|
// Парсим дату и время
|
||||||
|
const [year, month, day] = dateStr.split('-').map(Number);
|
||||||
|
const [hours, minutes] = timeStr.split(':').map(Number);
|
||||||
|
|
||||||
|
// Создаем дату как будто она в UTC
|
||||||
|
const utcDate = new Date(Date.UTC(year, month - 1, day, hours, minutes, 0, 0));
|
||||||
|
|
||||||
|
// Получаем смещение timezone пользователя
|
||||||
|
const offsetMinutes = getTimezoneOffsetMinutes(userTimezone);
|
||||||
|
|
||||||
|
// Корректируем: вычитаем смещение, чтобы получить UTC
|
||||||
|
// Например: 18:00 в UTC+8 = 10:00 UTC, значит вычитаем 8 часов (480 минут)
|
||||||
|
utcDate.setMinutes(utcDate.getMinutes() - offsetMinutes);
|
||||||
|
|
||||||
|
return utcDate.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Парсить ISO дату и получить локальную дату/время в часовом поясе пользователя.
|
||||||
|
*
|
||||||
|
* Работает с любым форматом timezone:
|
||||||
|
* - UTC+8: добавляет 8 часов к UTC
|
||||||
|
* - Europe/Moscow: использует Intl API
|
||||||
|
*
|
||||||
|
* @param isoString - ISO строка даты (например, '2026-02-21T10:00:00Z' для UTC)
|
||||||
|
* @param userTimezone - часовой пояс пользователя (например, 'UTC+8')
|
||||||
|
* @returns объект с date и time в часовом поясе пользователя
|
||||||
|
*/
|
||||||
|
export function parseISOToUserTimezone(
|
||||||
|
isoString: string,
|
||||||
|
userTimezone: string | undefined
|
||||||
|
): { date: string; time: string; dateObj: Date } {
|
||||||
|
// Парсим ISO строку в UTC timestamp
|
||||||
|
const utcDate = new Date(isoString);
|
||||||
|
const utcMs = utcDate.getTime();
|
||||||
|
|
||||||
|
// Получаем смещение timezone пользователя в минутах
|
||||||
|
const offsetMinutes = getTimezoneOffsetMinutes(userTimezone);
|
||||||
|
|
||||||
|
// Применяем смещение: UTC + offset = локальное время
|
||||||
|
// Например: 10:00 UTC + 8 часов = 18:00 в UTC+8
|
||||||
|
const localMs = utcMs + offsetMinutes * 60 * 1000;
|
||||||
|
const localDate = new Date(localMs);
|
||||||
|
|
||||||
|
// Извлекаем компоненты даты/времени в UTC (потому что мы уже добавили offset)
|
||||||
|
const year = localDate.getUTCFullYear();
|
||||||
|
const month = String(localDate.getUTCMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(localDate.getUTCDate()).padStart(2, '0');
|
||||||
|
const hours = String(localDate.getUTCHours()).padStart(2, '0');
|
||||||
|
const minutes = String(localDate.getUTCMinutes()).padStart(2, '0');
|
||||||
|
|
||||||
|
const dateStr = `${year}-${month}-${day}`;
|
||||||
|
const timeStr = `${hours}:${minutes}`;
|
||||||
|
|
||||||
|
// Создаем Date объект для использования в UI (в локальном времени браузера)
|
||||||
|
const displayDate = new Date(`${dateStr}T${timeStr}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
date: dateStr,
|
||||||
|
time: timeStr,
|
||||||
|
dateObj: displayDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Форматировать дату для отображения в часовом поясе пользователя.
|
||||||
|
*
|
||||||
|
* @param isoString - ISO строка даты
|
||||||
|
* @param userTimezone - часовой пояс пользователя (например, 'UTC+8')
|
||||||
|
* @param options - опции форматирования Intl.DateTimeFormat
|
||||||
|
*/
|
||||||
|
export function formatDateInUserTimezone(
|
||||||
|
isoString: string,
|
||||||
|
userTimezone: string | undefined,
|
||||||
|
options: Intl.DateTimeFormatOptions = {}
|
||||||
|
): string {
|
||||||
|
// Получаем локальное время в timezone пользователя
|
||||||
|
const parsed = parseISOToUserTimezone(isoString, userTimezone);
|
||||||
|
|
||||||
|
// Форматируем используя Intl (dateObj уже в правильном времени)
|
||||||
|
return new Intl.DateTimeFormat('ru-RU', options).format(parsed.dateObj);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить текущую дату/время в часовом поясе пользователя.
|
||||||
|
*/
|
||||||
|
export function getNowInUserTimezone(userTimezone: string | undefined): Date {
|
||||||
|
const now = new Date();
|
||||||
|
const parsed = parseISOToUserTimezone(now.toISOString(), userTimezone);
|
||||||
|
return parsed.dateObj;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить название часового пояса с offset.
|
||||||
|
* Например: 'Europe/Moscow' -> 'Europe/Moscow (UTC+3)'
|
||||||
|
* Для 'UTC+8' -> 'UTC+8'
|
||||||
|
*/
|
||||||
|
export function getTimezoneDisplayName(timezone: string): string {
|
||||||
|
if (!timezone) return '';
|
||||||
|
|
||||||
|
// Если уже в формате UTC+X, возвращаем как есть
|
||||||
|
if (/^(?:UTC|GMT)[+-]\d/i.test(timezone)) {
|
||||||
|
return timezone;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const offsetMinutes = getTimezoneOffsetMinutes(timezone);
|
||||||
|
const hours = Math.floor(Math.abs(offsetMinutes) / 60);
|
||||||
|
const mins = Math.abs(offsetMinutes) % 60;
|
||||||
|
const sign = offsetMinutes >= 0 ? '+' : '-';
|
||||||
|
const offsetStr = mins > 0 ? `${hours}:${mins.toString().padStart(2, '0')}` : `${hours}`;
|
||||||
|
return `${timezone} (UTC${sign}${offsetStr})`;
|
||||||
|
} catch {
|
||||||
|
return timezone;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue