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,13 +349,14 @@ class Message(models.Model):
|
|||
self.chat.increment_messages_count()
|
||||
self.chat.update_last_message()
|
||||
|
||||
# Увеличиваем счетчик непрочитанных для всех участников кроме отправителя
|
||||
# Оптимизация: используем bulk_update вместо цикла с save()
|
||||
participants = list(self.chat.participants.exclude(user=self.sender))
|
||||
for participant in participants:
|
||||
participant.unread_count += 1
|
||||
if participants:
|
||||
ChatParticipant.objects.bulk_update(participants, ['unread_count'])
|
||||
# Системные сообщения (уведомления) не увеличивают счётчик непрочитанных — уведомления есть отдельно
|
||||
if self.message_type != 'system':
|
||||
# Увеличиваем счетчик непрочитанных для всех участников кроме отправителя
|
||||
participants = list(self.chat.participants.exclude(user=self.sender))
|
||||
for participant in participants:
|
||||
participant.unread_count += 1
|
||||
if participants:
|
||||
ChatParticipant.objects.bulk_update(participants, ['unread_count'])
|
||||
|
||||
def mark_as_edited(self):
|
||||
"""Отметить как отредактированное."""
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
from rest_framework import serializers
|
||||
from django.db import models
|
||||
from .models import Chat, ChatParticipant, Message, MessageFile, MessageRead, MessageReaction
|
||||
from .services import ChatService
|
||||
from apps.users.serializers import UserSerializer
|
||||
from apps.users.mixins import TimezoneAwareSerializerMixin
|
||||
from apps.users.utils import format_datetime_for_user
|
||||
|
|
@ -531,19 +532,17 @@ class ChatCreateSerializer(serializers.ModelSerializer):
|
|||
participant_ids = validated_data.pop('participant_ids')
|
||||
user = self.context['request'].user
|
||||
|
||||
# Для личного чата проверяем что такой чат уже не существует
|
||||
# Для личного чата используем сервис с защитой от race condition
|
||||
if validated_data['chat_type'] == 'direct':
|
||||
existing_chat = Chat.objects.filter(
|
||||
chat_type='direct',
|
||||
participants__user=user
|
||||
).filter(
|
||||
participants__user_id=participant_ids[0]
|
||||
).first()
|
||||
other_user = User.objects.get(id=participant_ids[0])
|
||||
chat, _ = ChatService.get_or_create_direct_chat(
|
||||
user1=user,
|
||||
user2=other_user,
|
||||
created_by=user
|
||||
)
|
||||
return chat
|
||||
|
||||
if existing_chat:
|
||||
return existing_chat
|
||||
|
||||
# Создаем чат
|
||||
# Для группового чата - обычная логика
|
||||
chat = Chat.objects.create(
|
||||
created_by=user,
|
||||
**validated_data
|
||||
|
|
@ -557,7 +556,6 @@ class ChatCreateSerializer(serializers.ModelSerializer):
|
|||
)
|
||||
|
||||
# Добавляем остальных участников
|
||||
# Оптимизация: используем bulk_create вместо цикла с create()
|
||||
users = list(User.objects.filter(id__in=participant_ids))
|
||||
participants_to_create = [
|
||||
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
|
||||
)
|
||||
from .permissions import IsChatParticipant
|
||||
from .services import ChatService
|
||||
from .utils import (
|
||||
save_file_to_preload,
|
||||
move_file_from_preload_to_chat,
|
||||
|
|
@ -233,47 +234,26 @@ class ChatViewSet(viewsets.ModelViewSet):
|
|||
'error': 'Вы можете создавать чаты только со связанными пользователями'
|
||||
}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
# Проверяем существует ли уже чат
|
||||
existing_chat = Chat.objects.filter(
|
||||
chat_type='direct',
|
||||
participants__user=request.user
|
||||
).filter(
|
||||
participants__user_id=other_user_id
|
||||
).first()
|
||||
# Используем сервис для атомарного создания/получения чата
|
||||
chat, created = ChatService.get_or_create_direct_chat(
|
||||
user1=request.user,
|
||||
user2=other_user,
|
||||
created_by=request.user
|
||||
)
|
||||
|
||||
if existing_chat:
|
||||
serializer = ChatDetailSerializer(existing_chat)
|
||||
serializer = ChatDetailSerializer(chat)
|
||||
if created:
|
||||
return Response({
|
||||
'success': True,
|
||||
'data': serializer.data
|
||||
}, status=status.HTTP_201_CREATED)
|
||||
else:
|
||||
return Response({
|
||||
'success': True,
|
||||
'data': serializer.data,
|
||||
'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'])
|
||||
def mark_read(self, request, uuid=None):
|
||||
"""
|
||||
|
|
@ -357,10 +337,12 @@ class ChatViewSet(viewsets.ModelViewSet):
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
# Пересчитываем непрочитанные
|
||||
# Пересчитываем непрочитанные (системные сообщения не учитываем)
|
||||
unread_count = Message.objects.filter(
|
||||
chat=chat,
|
||||
is_deleted=False
|
||||
).exclude(
|
||||
message_type='system'
|
||||
).exclude(
|
||||
reads__user=request.user
|
||||
).exclude(
|
||||
|
|
@ -454,66 +436,29 @@ class ChatViewSet(viewsets.ModelViewSet):
|
|||
'error': 'У урока должны быть указаны ментор и клиент'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Ищем существующий личный чат между ментором и клиентом
|
||||
existing_chat = Chat.objects.filter(
|
||||
chat_type='direct',
|
||||
participants__user=mentor
|
||||
).filter(
|
||||
participants__user=client_user
|
||||
).distinct().first()
|
||||
# Используем сервис для атомарного создания/получения чата
|
||||
chat, created = ChatService.get_or_create_direct_chat(
|
||||
user1=mentor,
|
||||
user2=client_user,
|
||||
created_by=mentor
|
||||
)
|
||||
|
||||
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'}
|
||||
)
|
||||
# Если текущий пользователь не участник чата (родитель), добавляем его
|
||||
if request.user != mentor and request.user != client_user:
|
||||
ChatService.ensure_participant(chat, request.user, role='member')
|
||||
|
||||
serializer = ChatDetailSerializer(existing_chat, context={'request': request})
|
||||
serializer = ChatDetailSerializer(chat, context={'request': request})
|
||||
if created:
|
||||
return Response({
|
||||
'success': True,
|
||||
'data': serializer.data
|
||||
}, status=status.HTTP_201_CREATED)
|
||||
else:
|
||||
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:
|
||||
ChatParticipant.objects.create(
|
||||
chat=chat,
|
||||
user=request.user,
|
||||
role='member'
|
||||
)
|
||||
|
||||
serializer = ChatDetailSerializer(chat, context={'request': request})
|
||||
return Response({
|
||||
'success': True,
|
||||
'data': serializer.data
|
||||
}, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def mark_as_read(self, request, uuid=None):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -209,14 +209,14 @@ class HomeworkViewSet(viewsets.ModelViewSet):
|
|||
# Инвалидируем кеш дашборда после создания ДЗ
|
||||
from apps.users.cache_utils import invalidate_dashboard_cache
|
||||
invalidate_dashboard_cache(homework.mentor.id, 'mentor')
|
||||
# Оптимизация: используем list() для кеширования запроса
|
||||
students = list(homework.assigned_to.all())
|
||||
for student in students:
|
||||
invalidate_dashboard_cache(student.id, 'client')
|
||||
|
||||
# Отправляем уведомление о новом ДЗ
|
||||
from apps.notifications.services import NotificationService
|
||||
NotificationService.send_homework_notification(homework, 'homework_assigned')
|
||||
# Отправляем уведомление о новом ДЗ только если НЕ отложенное
|
||||
if not homework.fill_later:
|
||||
from apps.notifications.services import NotificationService
|
||||
NotificationService.send_homework_notification(homework, 'homework_assigned')
|
||||
|
||||
response_serializer = HomeworkSerializer(homework)
|
||||
return Response(
|
||||
|
|
@ -230,6 +230,9 @@ class HomeworkViewSet(viewsets.ModelViewSet):
|
|||
Опубликовать ДЗ.
|
||||
|
||||
POST /api/homework/homeworks/{id}/publish/
|
||||
|
||||
Также используется для публикации отложенных ДЗ (fill_later=True).
|
||||
При публикации сбрасывается флаг fill_later.
|
||||
"""
|
||||
homework = self.get_object()
|
||||
|
||||
|
|
@ -240,12 +243,19 @@ class HomeworkViewSet(viewsets.ModelViewSet):
|
|||
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()
|
||||
|
||||
# Инвалидируем кеш дашборда после публикации ДЗ
|
||||
from apps.users.cache_utils import invalidate_dashboard_cache
|
||||
invalidate_dashboard_cache(homework.mentor.id, 'mentor')
|
||||
# Оптимизация: используем list() для кеширования запроса
|
||||
students = list(homework.assigned_to.all())
|
||||
for student in students:
|
||||
invalidate_dashboard_cache(student.id, 'client')
|
||||
|
|
@ -264,7 +274,10 @@ class HomeworkViewSet(viewsets.ModelViewSet):
|
|||
)
|
||||
|
||||
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'])
|
||||
def archive(self, request, pk=None):
|
||||
|
|
@ -287,6 +300,35 @@ class HomeworkViewSet(viewsets.ModelViewSet):
|
|||
serializer = HomeworkSerializer(homework)
|
||||
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'])
|
||||
def statistics(self, request, pk=None):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ def duplicate_notification_to_chat(sender, instance, created, **kwargs):
|
|||
|
||||
try:
|
||||
from apps.chat.models import Chat, Message, ChatParticipant
|
||||
from apps.chat.services import ChatService
|
||||
from apps.users.models import User, Client, Parent
|
||||
|
||||
recipient = instance.recipient
|
||||
|
|
@ -132,22 +133,12 @@ def duplicate_notification_to_chat(sender, instance, created, **kwargs):
|
|||
if not mentor:
|
||||
return
|
||||
|
||||
# Находим или создаем личный чат между ментором и получателем
|
||||
chat = Chat.objects.filter(
|
||||
chat_type='direct',
|
||||
participants__user=mentor
|
||||
).filter(
|
||||
participants__user=recipient
|
||||
).first()
|
||||
|
||||
if not chat:
|
||||
# Создаем чат если его нет
|
||||
chat = Chat.objects.create(
|
||||
chat_type='direct',
|
||||
created_by=mentor
|
||||
)
|
||||
ChatParticipant.objects.create(chat=chat, user=mentor, role='admin')
|
||||
ChatParticipant.objects.create(chat=chat, user=recipient, role='member')
|
||||
# Используем сервис для атомарного создания/получения чата
|
||||
chat, _ = ChatService.get_or_create_direct_chat(
|
||||
user1=mentor,
|
||||
user2=recipient,
|
||||
created_by=mentor
|
||||
)
|
||||
|
||||
# Создаем системное сообщение в чате (без HTML-тегов, чтобы в чате не отображались теги)
|
||||
title_plain = strip_tags(instance.title or '')
|
||||
|
|
|
|||
|
|
@ -570,7 +570,14 @@ def send_lesson_notification(lesson_id, notification_type):
|
|||
elif notification_type == 'lesson_cancelled':
|
||||
NotificationService.send_lesson_cancelled(lesson)
|
||||
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)
|
||||
# Отмечаем что напоминание отправлено
|
||||
lesson.reminder_1h_sent = True
|
||||
lesson.save(update_fields=['reminder_1h_sent'])
|
||||
elif notification_type == 'lesson_rescheduled':
|
||||
NotificationService.send_lesson_rescheduled(lesson)
|
||||
elif notification_type == 'lesson_completed':
|
||||
|
|
|
|||
|
|
@ -12,7 +12,10 @@ from .models import (
|
|||
PointsTransaction,
|
||||
BonusTransaction,
|
||||
PromoCode,
|
||||
PromoCodeUsage
|
||||
PromoCodeUsage,
|
||||
ReferralInvitedEmail,
|
||||
UserActivityDay,
|
||||
PendingReferralBonus,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -339,3 +342,30 @@ class PromoCodeUsageAdmin(admin.ModelAdmin):
|
|||
return obj.promo_code.code
|
||||
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} ₽"
|
||||
|
||||
|
||||
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):
|
||||
"""
|
||||
Промокод для скидок на подписки.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from rest_framework.decorators import action
|
|||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||
from django.db.models import Sum, Q, F
|
||||
from django.utils import timezone
|
||||
from decimal import Decimal
|
||||
|
||||
from .models import (
|
||||
|
|
@ -18,6 +19,8 @@ from .models import (
|
|||
BonusTransaction,
|
||||
PromoCode,
|
||||
PromoCodeUsage,
|
||||
ReferralInvitedEmail,
|
||||
PendingReferralBonus,
|
||||
)
|
||||
from .serializers import (
|
||||
ReferralLevelSerializer,
|
||||
|
|
@ -126,27 +129,54 @@ class ReferralViewSet(viewsets.ViewSet):
|
|||
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.save()
|
||||
|
||||
# Обновляем статистику и начисляем очки (сигнал update_referrer_stats
|
||||
# срабатывает только при created=True, а здесь — update существующего профиля)
|
||||
# Добавляем email в бэклог приглашённых
|
||||
ReferralInvitedEmail.objects.create(
|
||||
email=email_lower,
|
||||
referrer=referrer_profile.user,
|
||||
referred_user=request.user,
|
||||
)
|
||||
|
||||
# Обновляем счётчик рефералов (без начисления очков — очки начисляются после проверки активности)
|
||||
settings_obj = ReferralSettings.get_settings()
|
||||
referrer_profile.direct_referrals_count += 1
|
||||
referrer_profile.save(update_fields=['direct_referrals_count'])
|
||||
referrer_profile.add_points(
|
||||
settings_obj.points_direct_referral,
|
||||
reason=f'Регистрация реферала {request.user.email}'
|
||||
|
||||
# Очки начисляются отложенно: через 30 дней при 20+ днях активности реферала или при 21 дне активности
|
||||
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:
|
||||
try:
|
||||
level2_profile = referrer_profile.referred_by.referral_profile
|
||||
level2_profile.indirect_referrals_count += 1
|
||||
level2_profile.save(update_fields=['indirect_referrals_count'])
|
||||
level2_profile.add_points(
|
||||
settings_obj.points_indirect_referral,
|
||||
reason=f'Регистрация непрямого реферала {request.user.email}'
|
||||
PendingReferralBonus.objects.create(
|
||||
referrer=referrer_profile.referred_by,
|
||||
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):
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -635,6 +635,7 @@ class LessonCalendarSerializer(serializers.Serializer):
|
|||
class LessonCalendarItemSerializer(serializers.ModelSerializer):
|
||||
"""Лёгкий сериализатор для календаря: только поля, нужные для списка и календаря."""
|
||||
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()
|
||||
|
||||
def get_subject(self, obj):
|
||||
|
|
@ -658,7 +659,7 @@ class LessonCalendarItemSerializer(serializers.ModelSerializer):
|
|||
|
||||
class Meta:
|
||||
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):
|
||||
|
|
|
|||
|
|
@ -30,14 +30,8 @@ def lesson_saved(sender, instance, created, **kwargs):
|
|||
lesson_id=instance.id,
|
||||
notification_type='lesson_created'
|
||||
)
|
||||
|
||||
# Планируем напоминание за 1 час до занятия
|
||||
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
|
||||
)
|
||||
# Напоминания отправляются периодической задачей send_lesson_reminders
|
||||
# (проверяет флаги reminder_24h_sent, reminder_1h_sent, reminder_15m_sent)
|
||||
else:
|
||||
# Занятие изменено
|
||||
# Проверяем, что именно изменилось
|
||||
|
|
|
|||
|
|
@ -86,12 +86,9 @@ class LessonViewSet(viewsets.ModelViewSet):
|
|||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Клиенты видят свои занятия
|
||||
elif user.role == 'client':
|
||||
try:
|
||||
queryset = queryset.filter(client=user.client_profile)
|
||||
except:
|
||||
queryset = Lesson.objects.none()
|
||||
# Студенты (клиенты) видят ТОЛЬКО свои занятия — не расписание ментора
|
||||
elif getattr(user, 'role', None) == 'client':
|
||||
queryset = queryset.filter(client__user_id=user.id)
|
||||
|
||||
# Родители видят занятия своих детей
|
||||
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)
|
||||
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
|
||||
if has_homework:
|
||||
# Есть текст ДЗ или файлы – создаём или обновляем опубликованное задание
|
||||
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:
|
||||
existing_hw.title = title
|
||||
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.save()
|
||||
homework_obj = existing_hw
|
||||
|
|
@ -640,7 +648,8 @@ class LessonViewSet(viewsets.ModelViewSet):
|
|||
description=description,
|
||||
mentor=lesson.mentor,
|
||||
lesson=lesson,
|
||||
status='published',
|
||||
status=hw_status,
|
||||
fill_later=hw_fill_later,
|
||||
)
|
||||
homework_id = homework_obj.id
|
||||
|
||||
|
|
@ -652,8 +661,10 @@ class LessonViewSet(viewsets.ModelViewSet):
|
|||
client_user = lesson_with_client.client.user
|
||||
if client_user:
|
||||
homework_obj.assigned_to.add(client_user)
|
||||
from apps.notifications.services import NotificationService
|
||||
NotificationService.send_homework_notification(homework_obj, 'homework_assigned')
|
||||
# Уведомление отправляем ТОЛЬКО если ДЗ опубликовано (не fill_later)
|
||||
if not hw_fill_later:
|
||||
from apps.notifications.services import NotificationService
|
||||
NotificationService.send_homework_notification(homework_obj, 'homework_assigned')
|
||||
|
||||
# Синхронизируем прикрепленные к уроку материалы с файлами ДЗ
|
||||
lesson_file_ids = None
|
||||
|
|
@ -885,6 +896,8 @@ class LessonViewSet(viewsets.ModelViewSet):
|
|||
Получить занятия для календаря.
|
||||
|
||||
GET /api/schedule/lessons/calendar/?start_date=2024-01-01&end_date=2024-01-31
|
||||
|
||||
Студент видит только свои занятия. Ментор — свои. Родитель — занятия детей.
|
||||
"""
|
||||
serializer = LessonCalendarSerializer(data=request.query_params)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
|
@ -894,6 +907,9 @@ class LessonViewSet(viewsets.ModelViewSet):
|
|||
start_time__date__gte=data['start_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'):
|
||||
queryset = queryset.filter(status=data['status'])
|
||||
lessons = LessonCalendarItemSerializer(
|
||||
|
|
@ -1434,7 +1450,8 @@ class LessonHomeworkSubmissionViewSet(viewsets.ModelViewSet):
|
|||
NotificationService.send_homework_notification(
|
||||
homework,
|
||||
'homework_reviewed',
|
||||
student=submission.student
|
||||
student=submission.student,
|
||||
submission=submission
|
||||
)
|
||||
except Homework.DoesNotExist:
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
"""
|
||||
import random
|
||||
import string
|
||||
import secrets
|
||||
from django.contrib.auth.models import AbstractUser, BaseUserManager
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
|
@ -314,31 +315,37 @@ class User(AbstractUser):
|
|||
alphabet = string.ascii_uppercase + string.digits
|
||||
for _ in range(100):
|
||||
code = ''.join(random.choices(alphabet, k=8))
|
||||
# Проверяем уникальность кода
|
||||
if not User.objects.filter(universal_code=code).exclude(pk=self.pk).exists():
|
||||
return code
|
||||
raise ValueError('Не удалось сгенерировать уникальный universal_code')
|
||||
# Если не удалось сгенерировать за 100 попыток, используем более длинный fallback
|
||||
return secrets.token_hex(4).upper()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# 1. Нормализация телефона
|
||||
if self.phone:
|
||||
self.phone = normalize_phone(self.phone)
|
||||
|
||||
# Автоматическая генерация username из email, если не задан
|
||||
# 2. Генерация username из email
|
||||
if not self.username and self.email:
|
||||
self.username = self.email.split('@')[0]
|
||||
# Добавляем цифры, если username уже существует
|
||||
counter = 1
|
||||
original_username = self.username
|
||||
while User.objects.filter(username=self.username).exclude(pk=self.pk).exists():
|
||||
self.username = f"{original_username}{counter}"
|
||||
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)
|
||||
if not self.universal_code:
|
||||
try:
|
||||
self.universal_code = self._generate_universal_code()
|
||||
except Exception:
|
||||
# Если не удалось сгенерировать, не прерываем сохранение
|
||||
pass
|
||||
# 3. Гарантируем 8-символьный код (universal_code)
|
||||
if not self.universal_code or len(str(self.universal_code).strip()) != 8:
|
||||
self.universal_code = self._generate_universal_code()
|
||||
if kwargs.get('update_fields') is not None:
|
||||
fields = set(kwargs['update_fields'])
|
||||
fields.add('universal_code')
|
||||
kwargs['update_fields'] = list(fields)
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
|
|
|||
|
|
@ -68,13 +68,10 @@ class ProfileViewSet(viewsets.ViewSet):
|
|||
GET /api/users/profile/me/
|
||||
"""
|
||||
user = request.user
|
||||
# Убедиться, что у пользователя есть 8-символьный код (для старых пользователей)
|
||||
if not user.universal_code or len(user.universal_code) != 8:
|
||||
try:
|
||||
user.universal_code = user._generate_universal_code()
|
||||
user.save(update_fields=['universal_code'])
|
||||
except Exception:
|
||||
pass
|
||||
# User.save() автоматически создаст universal_code, если он отсутствует
|
||||
if not user.universal_code or len(str(user.universal_code).strip()) != 8:
|
||||
user.save(update_fields=['universal_code'])
|
||||
|
||||
serializer = UserSerializer(user, context={'request': request})
|
||||
|
||||
# Добавляем дополнительную информацию
|
||||
|
|
@ -381,13 +378,6 @@ class ProfileViewSet(viewsets.ViewSet):
|
|||
|
||||
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:
|
||||
avatar_value = request.data.get('avatar')
|
||||
|
|
@ -1505,27 +1495,6 @@ class InvitationViewSet(viewsets.ViewSet):
|
|||
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.save(update_fields=['login_token'])
|
||||
|
|
|
|||
|
|
@ -156,15 +156,6 @@ class RegisterSerializer(serializers.ModelSerializer):
|
|||
**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':
|
||||
Client.objects.create(user=user)
|
||||
|
|
|
|||
|
|
@ -126,25 +126,6 @@ class TelegramAuthView(generics.GenericAPIView):
|
|||
user.set_unusable_password()
|
||||
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
|
||||
message = 'Регистрация через Telegram выполнена успешно'
|
||||
|
||||
|
|
@ -182,31 +163,6 @@ class RegisterView(generics.CreateAPIView):
|
|||
serializer.is_valid(raise_exception=True)
|
||||
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
|
||||
verification_token = secrets.token_urlsafe(32)
|
||||
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;
|
||||
invitation_link?: 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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновить домашнее задание (для черновиков 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(
|
||||
homeworkId: string | number,
|
||||
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 { getStudents } from '@/api/students';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { createDateTimeInUserTimezone, parseISOToUserTimezone } from '@/utils/timezone';
|
||||
import { useSelectedChild } from '@/contexts/SelectedChildContext';
|
||||
import { getSubjects, getMentorSubjects } from '@/api/subjects';
|
||||
import { loadComponent } from '@/lib/material-components';
|
||||
|
|
@ -132,6 +133,9 @@ export default function SchedulePage() {
|
|||
client_name: lesson.client_name ?? (lesson.client?.user
|
||||
? `${lesson.client.user.first_name || ''} ${lesson.client.user.last_name || ''}`.trim()
|
||||
: 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 ?? '',
|
||||
}));
|
||||
setLessons(mappedLessons);
|
||||
|
|
@ -156,9 +160,15 @@ export default function SchedulePage() {
|
|||
|
||||
const lessonsForSelectedDate: LessonPreview[] = lessons
|
||||
.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();
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// Сортируем по времени начала (раньше → первые)
|
||||
return new Date(a.start_time).getTime() - new Date(b.start_time).getTime();
|
||||
})
|
||||
.map((lesson) => ({
|
||||
id: String(lesson.id),
|
||||
title: lesson.title || 'Занятие',
|
||||
|
|
@ -229,15 +239,18 @@ export default function SchedulePage() {
|
|||
(async () => {
|
||||
try {
|
||||
const details = await getLesson(String(lesson.id));
|
||||
const start = new Date(details.start_time);
|
||||
const end = new Date(details.end_time);
|
||||
const safeStart = startOfDay(start);
|
||||
|
||||
// Парсим время в timezone пользователя
|
||||
const startParsed = parseISOToUserTimezone(details.start_time, user?.timezone);
|
||||
const safeStart = startOfDay(startParsed.dateObj);
|
||||
|
||||
// синхронизируем правую панель с датой урока
|
||||
setSelectedDate(safeStart);
|
||||
setDisplayDate(safeStart);
|
||||
|
||||
const duration = (() => {
|
||||
const start = new Date(details.start_time);
|
||||
const end = new Date(details.end_time);
|
||||
const mins = differenceInMinutes(end, start);
|
||||
return Number.isFinite(mins) && mins > 0 ? mins : 60;
|
||||
})();
|
||||
|
|
@ -246,8 +259,8 @@ export default function SchedulePage() {
|
|||
client: details.client?.id ? String(details.client.id) : '',
|
||||
title: details.title ?? '',
|
||||
description: details.description ?? '',
|
||||
start_date: format(start, 'yyyy-MM-dd'),
|
||||
start_time: format(start, 'HH:mm'),
|
||||
start_date: startParsed.date,
|
||||
start_time: startParsed.time,
|
||||
duration,
|
||||
price: typeof details.price === 'number' ? details.price : undefined,
|
||||
is_recurring: !!(details as any).is_recurring,
|
||||
|
|
@ -337,7 +350,12 @@ export default function SchedulePage() {
|
|||
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();
|
||||
|
||||
if (isEditingMode && editingLessonId) {
|
||||
|
|
@ -421,10 +439,12 @@ export default function SchedulePage() {
|
|||
<Calendar
|
||||
lessons={lessons}
|
||||
lessonsLoading={lessonsLoading}
|
||||
selectedDate={selectedDate}
|
||||
selectedDate={selectedDate}
|
||||
onSelectSlot={handleSelectSlot}
|
||||
onSelectEvent={handleSelectEvent}
|
||||
onMonthChange={handleMonthChange}
|
||||
isMentor={isMentor}
|
||||
userTimezone={user?.timezone}
|
||||
/>
|
||||
</div>
|
||||
<div className="ios26-schedule-right-wrap">
|
||||
|
|
|
|||
|
|
@ -37,6 +37,14 @@ export default function RootLayout({
|
|||
<head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<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>
|
||||
<body>
|
||||
<Providers>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export interface CalendarLesson {
|
|||
status?: string;
|
||||
client?: number;
|
||||
client_name?: string;
|
||||
mentor_name?: string;
|
||||
subject?: string;
|
||||
}
|
||||
|
||||
|
|
@ -34,6 +35,10 @@ export interface CalendarProps {
|
|||
onSelectEvent?: (lesson: { id: string }) => void;
|
||||
/** Смена видимого месяца (start/end месяца) */
|
||||
onMonthChange?: (start: Date, end: Date) => void;
|
||||
/** Ментор — показывает ученика; студент — показывает предмет и ментора */
|
||||
isMentor?: boolean;
|
||||
/** Часовой пояс пользователя (например, 'UTC+8') */
|
||||
userTimezone?: string;
|
||||
}
|
||||
|
||||
export const Calendar: React.FC<CalendarProps> = ({
|
||||
|
|
@ -43,25 +48,40 @@ export const Calendar: React.FC<CalendarProps> = ({
|
|||
onSelectSlot,
|
||||
onSelectEvent,
|
||||
onMonthChange,
|
||||
isMentor = true,
|
||||
userTimezone,
|
||||
}) => {
|
||||
const mappedLessons = React.useMemo(
|
||||
() =>
|
||||
lessons.map((lesson) => ({
|
||||
id: String(lesson.id),
|
||||
title: lesson.title || 'Занятие',
|
||||
start_time: lesson.start_time,
|
||||
end_time: lesson.end_time,
|
||||
status: (lesson.status || 'scheduled') as 'scheduled' | 'in_progress' | 'completed' | 'cancelled',
|
||||
client: lesson.client_name
|
||||
? {
|
||||
lessons.map((lesson) => {
|
||||
if (isMentor && lesson.client_name) {
|
||||
return {
|
||||
id: String(lesson.id),
|
||||
title: lesson.title || 'Занятие',
|
||||
start_time: lesson.start_time,
|
||||
end_time: lesson.end_time,
|
||||
status: (lesson.status || 'scheduled') as 'scheduled' | 'in_progress' | 'completed' | 'cancelled',
|
||||
client: {
|
||||
id: String(lesson.client ?? ''),
|
||||
name: lesson.client_name,
|
||||
first_name: lesson.client_name.split(' ')[0] || lesson.client_name,
|
||||
last_name: lesson.client_name.split(' ').slice(1).join(' ') || '',
|
||||
}
|
||||
: undefined,
|
||||
})),
|
||||
[lessons]
|
||||
},
|
||||
};
|
||||
}
|
||||
const subject = lesson.subject || 'Занятие';
|
||||
const mentorName = lesson.mentor_name || '';
|
||||
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 (
|
||||
|
|
@ -82,6 +102,7 @@ export const Calendar: React.FC<CalendarProps> = ({
|
|||
<LessonsCalendar
|
||||
lessons={mappedLessons}
|
||||
selectedDate={selectedDate}
|
||||
userTimezone={userTimezone}
|
||||
onSelectSlot={(date) => {
|
||||
try {
|
||||
const d = startOfDay(date);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { getCurrentUser, User } from '@/api/auth';
|
|||
import { format } from 'date-fns';
|
||||
import { DatePicker } from '@/components/common/DatePicker';
|
||||
import { TimePicker } from '@/components/common/TimePicker';
|
||||
import { createDateTimeInUserTimezone } from '@/utils/timezone';
|
||||
|
||||
interface CreateLessonDialogProps {
|
||||
open: boolean;
|
||||
|
|
@ -250,8 +251,12 @@ export const CreateLessonDialog: React.FC<CreateLessonDialogProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
// Объединяем дату и время в ISO строку
|
||||
const startUtc = new Date(`${formData.start_date}T${formData.start_time}`).toISOString();
|
||||
// Объединяем дату и время в ISO строку с учётом timezone пользователя
|
||||
const startUtc = createDateTimeInUserTimezone(
|
||||
formData.start_date,
|
||||
formData.start_time,
|
||||
currentUser?.timezone
|
||||
);
|
||||
|
||||
const payload: any = {
|
||||
client: formData.client,
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@
|
|||
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { LessonPreview } from '@/api/dashboard';
|
||||
import { createLiveKitRoom } from '@/api/livekit';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { parseISOToUserTimezone } from '@/utils/timezone';
|
||||
|
||||
interface LessonCardProps {
|
||||
lesson: LessonPreview;
|
||||
|
|
@ -35,6 +37,7 @@ export const LessonCard: React.FC<LessonCardProps> = ({
|
|||
onClick,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
const [connectLoading, setConnectLoading] = useState(false);
|
||||
const [canJoin, setCanJoin] = useState(false);
|
||||
|
||||
|
|
@ -64,8 +67,13 @@ export const LessonCard: React.FC<LessonCardProps> = ({
|
|||
[canJoin, connectLoading, lesson.id, router]
|
||||
);
|
||||
|
||||
const startTime = new Date(lesson.start_time);
|
||||
const endTime = new Date(lesson.end_time);
|
||||
// Парсим время с учётом timezone пользователя
|
||||
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) => {
|
||||
switch (status) {
|
||||
|
|
@ -188,20 +196,14 @@ export const LessonCard: React.FC<LessonCardProps> = ({
|
|||
<polyline points="12 6 12 12 16 14"></polyline>
|
||||
</svg>
|
||||
<span>
|
||||
{startTime.toLocaleDateString('ru-RU', {
|
||||
{startParsed.dateObj.toLocaleDateString('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'short'
|
||||
})}
|
||||
{' в '}
|
||||
{startTime.toLocaleTimeString('ru-RU', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
{startParsed.time}
|
||||
{' - '}
|
||||
{endTime.toLocaleTimeString('ru-RU', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
{endParsed.time}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import {
|
|||
import { ru } from 'date-fns/locale';
|
||||
import { Box, IconButton, Typography } from '@mui/material';
|
||||
import { ChevronLeft, ChevronRight } from '@mui/icons-material';
|
||||
import { parseISOToUserTimezone } from '@/utils/timezone';
|
||||
|
||||
interface Lesson {
|
||||
id: string;
|
||||
|
|
@ -46,6 +47,7 @@ interface LessonsCalendarProps {
|
|||
onSelectSlot?: (date: Date) => void;
|
||||
onMonthChange?: (start: Date, end: Date) => void;
|
||||
selectedDate?: Date;
|
||||
userTimezone?: string;
|
||||
}
|
||||
|
||||
export const LessonsCalendar: React.FC<LessonsCalendarProps> = ({
|
||||
|
|
@ -54,6 +56,7 @@ export const LessonsCalendar: React.FC<LessonsCalendarProps> = ({
|
|||
onSelectSlot,
|
||||
onMonthChange,
|
||||
selectedDate,
|
||||
userTimezone,
|
||||
}) => {
|
||||
const safeSelectedDate = useMemo(() => {
|
||||
if (selectedDate && !Number.isNaN(selectedDate.getTime())) return startOfDay(selectedDate);
|
||||
|
|
@ -79,16 +82,16 @@ export const LessonsCalendar: React.FC<LessonsCalendarProps> = ({
|
|||
onMonthChange?.(start, end);
|
||||
}, [currentMonth, onMonthChange]);
|
||||
|
||||
// Группируем занятия по дате (ключ YYYY-MM-DD)
|
||||
// Группируем занятия по дате (ключ YYYY-MM-DD) с учётом timezone пользователя
|
||||
const lessonsByDay = useMemo(() => {
|
||||
const map = new Map<string, Lesson[]>();
|
||||
if (!lessons || lessons.length === 0) return map;
|
||||
|
||||
lessons.forEach((lesson) => {
|
||||
try {
|
||||
const day = startOfDay(new Date(lesson.start_time));
|
||||
if (isNaN(day.getTime())) return;
|
||||
const key = format(day, 'yyyy-MM-dd');
|
||||
// Используем timezone пользователя для определения дня
|
||||
const parsed = parseISOToUserTimezone(lesson.start_time, userTimezone);
|
||||
const key = parsed.date; // уже в формате 'yyyy-MM-dd'
|
||||
const existing = map.get(key) || [];
|
||||
existing.push(lesson);
|
||||
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;
|
||||
}, [lessons]);
|
||||
}, [lessons, userTimezone]);
|
||||
|
||||
const monthLabel = useMemo(() => {
|
||||
const label = format(currentMonth, 'LLLL yyyy', { locale: ru });
|
||||
|
|
@ -343,7 +354,9 @@ export const LessonsCalendar: React.FC<LessonsCalendarProps> = ({
|
|||
{dayLessons.slice(0, 2).map((lesson) => {
|
||||
const timeStr = (() => {
|
||||
try {
|
||||
return format(new Date(lesson.start_time), 'HH:mm', { locale: ru });
|
||||
// Используем timezone пользователя для отображения времени
|
||||
const parsed = parseISOToUserTimezone(lesson.start_time, userTimezone);
|
||||
return parsed.time;
|
||||
} catch {
|
||||
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';
|
||||
import { getBackendOrigin } from '@/lib/api-client';
|
||||
import { SubmitHomeworkModal } from './SubmitHomeworkModal';
|
||||
import { EditHomeworkDraftModal } from './EditHomeworkDraftModal';
|
||||
|
||||
interface HomeworkDetailsModalProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -303,6 +304,9 @@ export function HomeworkDetailsModal({ isOpen, homework, userRole, childId, onCl
|
|||
const [imagePreviewUrl, setImagePreviewUrl] = useState<string | null>(null);
|
||||
const [documentViewer, setDocumentViewer] = useState<{ url: string; filename: string; type: 'pdf' | 'text' } | null>(null);
|
||||
|
||||
// Модальное окно редактирования черновика ДЗ
|
||||
const [editDraftOpen, setEditDraftOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !homework) return;
|
||||
setError(null);
|
||||
|
|
@ -484,6 +488,39 @@ export function HomeworkDetailsModal({ isOpen, homework, userRole, childId, onCl
|
|||
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 && (
|
||||
<div style={{ marginBottom: 28 }}>
|
||||
<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';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { completeLesson, type Lesson } from '@/api/schedule';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { parseISOToUserTimezone } from '@/utils/timezone';
|
||||
|
||||
interface FeedbackModalProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -11,6 +13,7 @@ interface FeedbackModalProps {
|
|||
}
|
||||
|
||||
export function FeedbackModal({ isOpen, lesson, onClose, onSuccess }: FeedbackModalProps) {
|
||||
const { user } = useAuth();
|
||||
const [formData, setFormData] = useState({
|
||||
mentor_grade: '',
|
||||
school_grade: '',
|
||||
|
|
@ -19,6 +22,15 @@ export function FeedbackModal({ isOpen, lesson, onClose, onSuccess }: FeedbackMo
|
|||
const [loading, setLoading] = useState(false);
|
||||
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(() => {
|
||||
if (isOpen && lesson) {
|
||||
setFormData({
|
||||
|
|
@ -29,7 +41,7 @@ export function FeedbackModal({ isOpen, lesson, onClose, onSuccess }: FeedbackMo
|
|||
}
|
||||
}, [isOpen, lesson]);
|
||||
|
||||
if (!lesson) return null;
|
||||
if (!lesson || !parsedTimes) return null;
|
||||
|
||||
const visible = isOpen;
|
||||
|
||||
|
|
@ -155,15 +167,15 @@ export function FeedbackModal({ isOpen, lesson, onClose, onSuccess }: FeedbackMo
|
|||
<div>
|
||||
<span style={{ color: 'var(--md-sys-color-on-surface-variant)' }}>Дата: </span>
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: 'var(--md-sys-color-on-surface-variant)' }}>Время: </span>
|
||||
<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>
|
||||
</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=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 темы) */
|
||||
: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