From d9121fe6ef01ecf6dcc017e4cf9617bdf343eec3 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 21 Feb 2026 23:50:05 +0300 Subject: [PATCH] fix bugs --- AUTO-BACKUP-SETUP.md | 186 +- backend/apps/chat/models.py | 15 +- backend/apps/chat/serializers.py | 22 +- backend/apps/chat/services.py | 111 ++ backend/apps/chat/views.py | 123 +- backend/apps/homework/views.py | 54 +- backend/apps/notifications/signals.py | 327 ++- backend/apps/notifications/tasks.py | 7 + backend/apps/referrals/admin.py | 32 +- .../process_pending_referral_bonuses.py | 81 + .../0002_add_referral_antifraud_models.py | 78 + .../0003_backfill_invited_emails.py | 31 + backend/apps/referrals/models.py | 146 ++ backend/apps/referrals/views.py | 46 +- backend/apps/schedule/serializers.py | 3 +- backend/apps/schedule/signals.py | 10 +- backend/apps/schedule/tasks.py | 896 ++++----- backend/apps/schedule/views.py | 45 +- backend/apps/users/models.py | 27 +- backend/apps/users/profile_views.py | 39 +- backend/apps/users/serializers.py | 9 - backend/apps/users/views.py | 44 - backend/check_smtp_ports.py | 18 + front_material/api/auth.ts | 412 ++-- front_material/api/homework.ts | 27 + .../app/(protected)/schedule/page.tsx | 36 +- front_material/app/layout.tsx | 8 + .../components/calendar/calendar.tsx | 219 +- .../components/checklesson/checklesson.tsx | 1766 ++++++++--------- .../components/common/DatePicker.tsx | 632 +++--- .../dashboard/CreateLessonDialog.tsx | 9 +- .../components/dashboard/LessonCard.tsx | 554 +++--- .../components/dashboard/LessonsCalendar.tsx | 795 ++++---- .../homework/EditHomeworkDraftModal.tsx | 718 +++++++ .../homework/HomeworkDetailsModal.tsx | 47 + .../components/schedule/FeedbackModal.tsx | 666 ++++--- front_material/hooks/useIsMobile.ts | 34 +- .../fonts/material-symbols-outlined.woff2 | Bin 0 -> 3223880 bytes front_material/public/icon.svg | 8 +- front_material/public/manifest.json | 38 +- front_material/styles/globals.css | 27 +- front_material/utils/timezone.ts | 201 ++ 42 files changed, 5047 insertions(+), 3500 deletions(-) create mode 100644 backend/apps/chat/services.py create mode 100644 backend/apps/referrals/management/commands/process_pending_referral_bonuses.py create mode 100644 backend/apps/referrals/migrations/0002_add_referral_antifraud_models.py create mode 100644 backend/apps/referrals/migrations/0003_backfill_invited_emails.py create mode 100644 backend/check_smtp_ports.py create mode 100644 front_material/components/homework/EditHomeworkDraftModal.tsx create mode 100644 front_material/public/fonts/material-symbols-outlined.woff2 create mode 100644 front_material/utils/timezone.ts diff --git a/AUTO-BACKUP-SETUP.md b/AUTO-BACKUP-SETUP.md index 3653d88..3b9a92b 100644 --- a/AUTO-BACKUP-SETUP.md +++ b/AUTO-BACKUP-SETUP.md @@ -1,93 +1,93 @@ -# Настройка автоматического резервного копирования БД - -## 🎯 Автоматический бэкап дважды в день - -Система автоматически создаёт бэкапы PROD и DEV БД: -- **00:00** (полночь) -- **12:00** (полдень) - -## 📋 Установка - -```bash -cd /var/www/platform/prod - -# Сделать скрипты исполняемыми -chmod +x backup-db-auto.sh setup-cron-backup.sh remove-cron-backup.sh - -# Настроить автоматический бэкап -./setup-cron-backup.sh -``` - -## ✅ Проверка - -```bash -# Проверить, что задача добавлена в cron -crontab -l | grep backup-db-auto - -# Должно быть: -# 0 0,12 * * * PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin /var/www/platform/prod/backup-db-auto.sh >> /var/www/platform/prod/backups/cron.log 2>&1 -``` - -## 📊 Логи - -```bash -# Логи автоматических бэкапов -tail -f /var/www/platform/prod/backups/backup.log - -# Логи cron (ошибки выполнения) -tail -f /var/www/platform/prod/backups/cron.log -``` - -## 🗂️ Хранение бэкапов - -- **Директория**: `/var/www/platform/prod/backups/` -- **Формат файлов**: `platform_prod_db_YYYYMMDD_HHMMSS.sql.gz` -- **Автоочистка**: Бэкапы старше 30 дней удаляются автоматически -- **Проверка места**: При использовании диска > 80% в лог пишется предупреждение - -## 🔄 Ручной запуск - -```bash -# Запустить бэкап вручную (для тестирования) -/var/www/platform/prod/backup-db-auto.sh -``` - -## 🗑️ Удаление автоматического бэкапа - -```bash -# Удалить задачу из cron -./remove-cron-backup.sh - -# Или вручную -crontab -l | grep -v backup-db-auto | crontab - -``` - -## 📝 Что делает скрипт - -1. ✅ Проверяет, что контейнеры БД запущены -2. ✅ Создаёт бэкапы PROD и DEV БД -3. ✅ Сжимает бэкапы (gzip) -4. ✅ Проверяет размер бэкапов -5. ✅ Удаляет бэкапы старше 30 дней -6. ✅ Логирует все действия -7. ✅ Предупреждает о нехватке места на диске - -## ⚠️ Важно - -- Скрипт работает от пользователя `root` (нужен доступ к Docker) -- Бэкапы сохраняются в `/var/www/platform/prod/backups/` -- Старые бэкапы (30+ дней) удаляются автоматически -- При ошибках информация записывается в лог - -## 🔍 Мониторинг - -```bash -# Посмотреть последние бэкапы -ls -lh /var/www/platform/prod/backups/*.sql.gz | tail -10 - -# Проверить размер всех бэкапов -du -sh /var/www/platform/prod/backups/ - -# Посмотреть последние записи в логе -tail -20 /var/www/platform/prod/backups/backup.log -``` +# Настройка автоматического резервного копирования БД + +## 🎯 Автоматический бэкап дважды в день + +Система автоматически создаёт бэкапы PROD и DEV БД: +- **00:00** (полночь) +- **12:00** (полдень) + +## 📋 Установка + +```bash +cd /var/www/platform/prod + +# Сделать скрипты исполняемыми +chmod +x backup-db-auto.sh setup-cron-backup.sh remove-cron-backup.sh + +# Настроить автоматический бэкап +./setup-cron-backup.sh +``` + +## ✅ Проверка + +```bash +# Проверить, что задача добавлена в cron +crontab -l | grep backup-db-auto + +# Должно быть: +# 0 0,12 * * * PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin /var/www/platform/prod/backup-db-auto.sh >> /var/www/platform/prod/backups/cron.log 2>&1 +``` + +## 📊 Логи + +```bash +# Логи автоматических бэкапов +tail -f /var/www/platform/prod/backups/backup.log + +# Логи cron (ошибки выполнения) +tail -f /var/www/platform/prod/backups/cron.log +``` + +## 🗂️ Хранение бэкапов + +- **Директория**: `/var/www/platform/prod/backups/` +- **Формат файлов**: `platform_prod_db_YYYYMMDD_HHMMSS.sql.gz` +- **Автоочистка**: Бэкапы старше 30 дней удаляются автоматически +- **Проверка места**: При использовании диска > 80% в лог пишется предупреждение + +## 🔄 Ручной запуск + +```bash +# Запустить бэкап вручную (для тестирования) +/var/www/platform/prod/backup-db-auto.sh +``` + +## 🗑️ Удаление автоматического бэкапа + +```bash +# Удалить задачу из cron +./remove-cron-backup.sh + +# Или вручную +crontab -l | grep -v backup-db-auto | crontab - +``` + +## 📝 Что делает скрипт + +1. ✅ Проверяет, что контейнеры БД запущены +2. ✅ Создаёт бэкапы PROD и DEV БД +3. ✅ Сжимает бэкапы (gzip) +4. ✅ Проверяет размер бэкапов +5. ✅ Удаляет бэкапы старше 30 дней +6. ✅ Логирует все действия +7. ✅ Предупреждает о нехватке места на диске + +## ⚠️ Важно + +- Скрипт работает от пользователя `root` (нужен доступ к Docker) +- Бэкапы сохраняются в `/var/www/platform/prod/backups/` +- Старые бэкапы (30+ дней) удаляются автоматически +- При ошибках информация записывается в лог + +## 🔍 Мониторинг + +```bash +# Посмотреть последние бэкапы +ls -lh /var/www/platform/prod/backups/*.sql.gz | tail -10 + +# Проверить размер всех бэкапов +du -sh /var/www/platform/prod/backups/ + +# Посмотреть последние записи в логе +tail -20 /var/www/platform/prod/backups/backup.log +``` diff --git a/backend/apps/chat/models.py b/backend/apps/chat/models.py index e41325b..d73b214 100644 --- a/backend/apps/chat/models.py +++ b/backend/apps/chat/models.py @@ -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): """Отметить как отредактированное.""" diff --git a/backend/apps/chat/serializers.py b/backend/apps/chat/serializers.py index a17b5cf..a0ffe58 100644 --- a/backend/apps/chat/serializers.py +++ b/backend/apps/chat/serializers.py @@ -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() - - if existing_chat: - return existing_chat + 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 - # Создаем чат + # Для группового чата - обычная логика 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( diff --git a/backend/apps/chat/services.py b/backend/apps/chat/services.py new file mode 100644 index 0000000..ed55338 --- /dev/null +++ b/backend/apps/chat/services.py @@ -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} + ) diff --git a/backend/apps/chat/views.py b/backend/apps/chat/views.py index 92f3789..a79fc1a 100644 --- a/backend/apps/chat/views.py +++ b/backend/apps/chat/views.py @@ -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,46 +234,25 @@ 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,65 +436,28 @@ 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'} - ) - - serializer = ChatDetailSerializer(existing_chat, context={'request': request}) + # Если текущий пользователь не участник чата (родитель), добавляем его + if request.user != mentor and request.user != client_user: + ChatService.ensure_participant(chat, request.user, role='member') + + 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): diff --git a/backend/apps/homework/views.py b/backend/apps/homework/views.py index 205bd31..3714f9a 100644 --- a/backend/apps/homework/views.py +++ b/backend/apps/homework/views.py @@ -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): """ diff --git a/backend/apps/notifications/signals.py b/backend/apps/notifications/signals.py index 19c8531..4f1f908 100644 --- a/backend/apps/notifications/signals.py +++ b/backend/apps/notifications/signals.py @@ -1,168 +1,159 @@ -""" -Сигналы для автоматической отправки уведомлений. -""" -from django.db.models.signals import post_save -from django.dispatch import receiver -from django.utils.html import strip_tags -from .services import NotificationService, create_notification_preferences - - -# Сигналы пользователей -@receiver(post_save, sender='users.User') -def create_user_notification_preferences(sender, instance, created, **kwargs): - """Создание настроек уведомлений для нового пользователя.""" - if created: - create_notification_preferences(instance) - - -# Сигналы занятий -@receiver(post_save, sender='schedule.Lesson') -def handle_lesson_notifications(sender, instance, created, **kwargs): - """ - Обработка уведомлений для занятий. - - Примечание: Уведомление о создании занятия отправляется явно в perform_create, - чтобы избежать дублирования. Здесь обрабатываем только обновления. - """ - if not created: - # Занятие обновлено - проверяем статус - # Уведомление об отмене отправляется явно в perform_destroy, - # но оставляем здесь на случай, если статус меняется другим способом - if instance.status == 'cancelled' and instance.cancelled_at: - # Проверяем, не было ли уже отправлено уведомление - # (чтобы избежать дублирования с perform_destroy) - pass # Уведомление об отмене отправляется явно в perform_destroy - - -# Сигналы уведомлений - дублирование в чат -@receiver(post_save, sender='notifications.Notification') -def duplicate_notification_to_chat(sender, instance, created, **kwargs): - """ - Дублирование системных уведомлений в чат между ментором и учеником/родителем. - - Когда создается уведомление для ученика или родителя от ментора, - оно также создается как системное сообщение в соответствующем чате. - """ - if not created: - return - - # Дублируем только in_app уведомления - if instance.channel != 'in_app': - return - - # Дублируем только определенные типы уведомлений - notification_types_to_duplicate = [ - 'lesson_created', - 'lesson_updated', - 'lesson_cancelled', - 'lesson_rescheduled', - 'lesson_reminder', - 'lesson_started', - 'lesson_completed', - 'homework_assigned', - 'homework_submitted', - 'homework_reviewed', - 'homework_returned', - 'homework_deadline_reminder', - 'homework_overdue', - 'material_added', - 'subscription_expiring', - 'subscription_expired', - 'payment_received', - 'system', - ] - - if instance.notification_type not in notification_types_to_duplicate: - return - - try: - from apps.chat.models import Chat, Message, ChatParticipant - from apps.users.models import User, Client, Parent - - recipient = instance.recipient - - # Определяем ментора для создания чата - mentor = None - - # Если получатель - ученик, находим его ментора - if recipient.role == 'client': - try: - client = Client.objects.get(user=recipient) - mentors = client.mentors.all() - if mentors.exists(): - mentor = mentors.first() - except Client.DoesNotExist: - pass - - # Если получатель - родитель, находим ментора через детей - elif recipient.role == 'parent': - try: - parent = Parent.objects.get(user=recipient) - children = parent.children.all() - if children.exists(): - # Берем первого ментора первого ребенка - child = children.first() - mentors = child.mentors.all() - if mentors.exists(): - mentor = mentors.first() - except Parent.DoesNotExist: - pass - - # Если получатель - ментор, находим ученика/родителя из контекста уведомления - elif recipient.role == 'mentor': - # Для уведомлений ментору нужно найти связанного ученика/родителя - # Это зависит от типа уведомления и content_object - if instance.content_object: - content_obj = instance.content_object - # Если это занятие, берем клиента из занятия - if hasattr(content_obj, 'client'): - client = content_obj.client - if client and client.user: - recipient = client.user - # Если это ДЗ, берем студента из ДЗ - elif hasattr(content_obj, 'student'): - recipient = content_obj.student - # Если это submission, берем студента - elif hasattr(content_obj, 'homework') and hasattr(content_obj, 'student'): - recipient = content_obj.student - else: - return # Не можем определить получателя - mentor = instance.recipient - - 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') - - # Создаем системное сообщение в чате (без HTML-тегов, чтобы в чате не отображались теги) - title_plain = strip_tags(instance.title or '') - message_plain = strip_tags(instance.message or '') - message_content = f"🔔 {title_plain}\n{message_plain}" - Message.objects.create( - chat=chat, - sender=None, # Системное сообщение - message_type='system', - content=message_content - ) - - except Exception as e: - # Логируем ошибку, но не прерываем создание уведомления - import logging - logger = logging.getLogger(__name__) - logger.error(f'Error duplicating notification to chat: {e}', exc_info=True) - +""" +Сигналы для автоматической отправки уведомлений. +""" +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.utils.html import strip_tags +from .services import NotificationService, create_notification_preferences + + +# Сигналы пользователей +@receiver(post_save, sender='users.User') +def create_user_notification_preferences(sender, instance, created, **kwargs): + """Создание настроек уведомлений для нового пользователя.""" + if created: + create_notification_preferences(instance) + + +# Сигналы занятий +@receiver(post_save, sender='schedule.Lesson') +def handle_lesson_notifications(sender, instance, created, **kwargs): + """ + Обработка уведомлений для занятий. + + Примечание: Уведомление о создании занятия отправляется явно в perform_create, + чтобы избежать дублирования. Здесь обрабатываем только обновления. + """ + if not created: + # Занятие обновлено - проверяем статус + # Уведомление об отмене отправляется явно в perform_destroy, + # но оставляем здесь на случай, если статус меняется другим способом + if instance.status == 'cancelled' and instance.cancelled_at: + # Проверяем, не было ли уже отправлено уведомление + # (чтобы избежать дублирования с perform_destroy) + pass # Уведомление об отмене отправляется явно в perform_destroy + + +# Сигналы уведомлений - дублирование в чат +@receiver(post_save, sender='notifications.Notification') +def duplicate_notification_to_chat(sender, instance, created, **kwargs): + """ + Дублирование системных уведомлений в чат между ментором и учеником/родителем. + + Когда создается уведомление для ученика или родителя от ментора, + оно также создается как системное сообщение в соответствующем чате. + """ + if not created: + return + + # Дублируем только in_app уведомления + if instance.channel != 'in_app': + return + + # Дублируем только определенные типы уведомлений + notification_types_to_duplicate = [ + 'lesson_created', + 'lesson_updated', + 'lesson_cancelled', + 'lesson_rescheduled', + 'lesson_reminder', + 'lesson_started', + 'lesson_completed', + 'homework_assigned', + 'homework_submitted', + 'homework_reviewed', + 'homework_returned', + 'homework_deadline_reminder', + 'homework_overdue', + 'material_added', + 'subscription_expiring', + 'subscription_expired', + 'payment_received', + 'system', + ] + + if instance.notification_type not in notification_types_to_duplicate: + return + + 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 + + # Определяем ментора для создания чата + mentor = None + + # Если получатель - ученик, находим его ментора + if recipient.role == 'client': + try: + client = Client.objects.get(user=recipient) + mentors = client.mentors.all() + if mentors.exists(): + mentor = mentors.first() + except Client.DoesNotExist: + pass + + # Если получатель - родитель, находим ментора через детей + elif recipient.role == 'parent': + try: + parent = Parent.objects.get(user=recipient) + children = parent.children.all() + if children.exists(): + # Берем первого ментора первого ребенка + child = children.first() + mentors = child.mentors.all() + if mentors.exists(): + mentor = mentors.first() + except Parent.DoesNotExist: + pass + + # Если получатель - ментор, находим ученика/родителя из контекста уведомления + elif recipient.role == 'mentor': + # Для уведомлений ментору нужно найти связанного ученика/родителя + # Это зависит от типа уведомления и content_object + if instance.content_object: + content_obj = instance.content_object + # Если это занятие, берем клиента из занятия + if hasattr(content_obj, 'client'): + client = content_obj.client + if client and client.user: + recipient = client.user + # Если это ДЗ, берем студента из ДЗ + elif hasattr(content_obj, 'student'): + recipient = content_obj.student + # Если это submission, берем студента + elif hasattr(content_obj, 'homework') and hasattr(content_obj, 'student'): + recipient = content_obj.student + else: + return # Не можем определить получателя + mentor = instance.recipient + + if not mentor: + return + + # Используем сервис для атомарного создания/получения чата + chat, _ = ChatService.get_or_create_direct_chat( + user1=mentor, + user2=recipient, + created_by=mentor + ) + + # Создаем системное сообщение в чате (без HTML-тегов, чтобы в чате не отображались теги) + title_plain = strip_tags(instance.title or '') + message_plain = strip_tags(instance.message or '') + message_content = f"🔔 {title_plain}\n{message_plain}" + Message.objects.create( + chat=chat, + sender=None, # Системное сообщение + message_type='system', + content=message_content + ) + + except Exception as e: + # Логируем ошибку, но не прерываем создание уведомления + import logging + logger = logging.getLogger(__name__) + logger.error(f'Error duplicating notification to chat: {e}', exc_info=True) + diff --git a/backend/apps/notifications/tasks.py b/backend/apps/notifications/tasks.py index 11bd8e8..40c3c0d 100644 --- a/backend/apps/notifications/tasks.py +++ b/backend/apps/notifications/tasks.py @@ -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': diff --git a/backend/apps/referrals/admin.py b/backend/apps/referrals/admin.py index d9f3b00..18557f2 100644 --- a/backend/apps/referrals/admin.py +++ b/backend/apps/referrals/admin.py @@ -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'] + diff --git a/backend/apps/referrals/management/commands/process_pending_referral_bonuses.py b/backend/apps/referrals/management/commands/process_pending_referral_bonuses.py new file mode 100644 index 0000000..b5e8e2d --- /dev/null +++ b/backend/apps/referrals/management/commands/process_pending_referral_bonuses.py @@ -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}')) diff --git a/backend/apps/referrals/migrations/0002_add_referral_antifraud_models.py b/backend/apps/referrals/migrations/0002_add_referral_antifraud_models.py new file mode 100644 index 0000000..cd4eb86 --- /dev/null +++ b/backend/apps/referrals/migrations/0002_add_referral_antifraud_models.py @@ -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'), + ), + ] diff --git a/backend/apps/referrals/migrations/0003_backfill_invited_emails.py b/backend/apps/referrals/migrations/0003_backfill_invited_emails.py new file mode 100644 index 0000000..0b4823b --- /dev/null +++ b/backend/apps/referrals/migrations/0003_backfill_invited_emails.py @@ -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), + ] diff --git a/backend/apps/referrals/models.py b/backend/apps/referrals/models.py index 8ded769..f694965 100644 --- a/backend/apps/referrals/models.py +++ b/backend/apps/referrals/models.py @@ -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): """ Промокод для скидок на подписки. diff --git a/backend/apps/referrals/views.py b/backend/apps/referrals/views.py index d88fdd7..4aa5472 100644 --- a/backend/apps/referrals/views.py +++ b/backend/apps/referrals/views.py @@ -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 diff --git a/backend/apps/schedule/serializers.py b/backend/apps/schedule/serializers.py index 03dbdaf..b03fc3f 100644 --- a/backend/apps/schedule/serializers.py +++ b/backend/apps/schedule/serializers.py @@ -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): diff --git a/backend/apps/schedule/signals.py b/backend/apps/schedule/signals.py index 072fd7b..5e1a0f9 100644 --- a/backend/apps/schedule/signals.py +++ b/backend/apps/schedule/signals.py @@ -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: # Занятие изменено # Проверяем, что именно изменилось diff --git a/backend/apps/schedule/tasks.py b/backend/apps/schedule/tasks.py index 3986ba1..6e4172e 100644 --- a/backend/apps/schedule/tasks.py +++ b/backend/apps/schedule/tasks.py @@ -1,448 +1,448 @@ -# Celery задачи для schedule - -from celery import shared_task -import logging -from django.utils import timezone -from datetime import timedelta -from django.db.models import Count -from .models import Lesson, Subject, MentorSubject - -logger = logging.getLogger(__name__) - - -@shared_task -def send_lesson_reminders(): - """ - Отправка напоминаний о предстоящих занятиях. - - Отправляет напоминания за: - - 24 часа до занятия - - 1 час до занятия - - 15 минут до занятия - - Задача запускается каждые 15 минут через Celery Beat. - """ - from apps.notifications.services import NotificationService - - now = timezone.now() - sent_24h = 0 - sent_1h = 0 - sent_15m = 0 - - try: - # Находим все запланированные занятия, которые еще не начались и не отменены - lessons = Lesson.objects.filter( - start_time__gt=now, - status='scheduled' - ).select_related('client', 'client__user', 'mentor') - - # Напоминания за 24 часа (от 23:30 до 24:30) - time_24h_min = now + timedelta(hours=23, minutes=30) - time_24h_max = now + timedelta(hours=24, minutes=30) - - lessons_24h = lessons.filter( - start_time__gte=time_24h_min, - start_time__lte=time_24h_max, - reminder_24h_sent=False - ) - - # Оптимизация: используем bulk_update вместо цикла с save() - lessons_24h_list = list(lessons_24h) - lessons_24h_to_update = [] - for lesson in lessons_24h_list: - try: - NotificationService.send_lesson_reminder(lesson, time_before="24 часа") - lesson.reminder_24h_sent = True - lessons_24h_to_update.append(lesson) - sent_24h += 1 - logger.info(f'Отправлено напоминание за 24 часа для занятия {lesson.id}') - except Exception as e: - logger.error(f'Ошибка отправки напоминания за 24 часа для занятия {lesson.id}: {e}') - if lessons_24h_to_update: - Lesson.objects.bulk_update(lessons_24h_to_update, ['reminder_24h_sent']) - - # Напоминания за 1 час (от 50 минут до 70 минут) - time_1h_min = now + timedelta(minutes=50) - time_1h_max = now + timedelta(minutes=70) - - lessons_1h = lessons.filter( - start_time__gte=time_1h_min, - start_time__lte=time_1h_max, - reminder_1h_sent=False - ) - - # Оптимизация: используем bulk_update вместо цикла с save() - lessons_1h_list = list(lessons_1h) - lessons_1h_to_update = [] - for lesson in lessons_1h_list: - try: - NotificationService.send_lesson_reminder(lesson, time_before="1 час") - lesson.reminder_1h_sent = True - lessons_1h_to_update.append(lesson) - sent_1h += 1 - logger.info(f'Отправлено напоминание за 1 час для занятия {lesson.id}') - except Exception as e: - logger.error(f'Ошибка отправки напоминания за 1 час для занятия {lesson.id}: {e}') - if lessons_1h_to_update: - Lesson.objects.bulk_update(lessons_1h_to_update, ['reminder_1h_sent']) - - # Напоминания за 15 минут (от 10 минут до 20 минут) - time_15m_min = now + timedelta(minutes=10) - time_15m_max = now + timedelta(minutes=20) - - lessons_15m = lessons.filter( - start_time__gte=time_15m_min, - start_time__lte=time_15m_max, - reminder_15m_sent=False - ) - - # Оптимизация: используем bulk_update вместо цикла с save() - lessons_15m_list = list(lessons_15m) - lessons_15m_to_update = [] - for lesson in lessons_15m_list: - try: - NotificationService.send_lesson_reminder(lesson, time_before="15 минут") - lesson.reminder_15m_sent = True - lessons_15m_to_update.append(lesson) - sent_15m += 1 - logger.info(f'Отправлено напоминание за 15 минут для занятия {lesson.id}') - except Exception as e: - logger.error(f'Ошибка отправки напоминания за 15 минут для занятия {lesson.id}: {e}') - if lessons_15m_to_update: - Lesson.objects.bulk_update(lessons_15m_to_update, ['reminder_15m_sent']) - - total_sent = sent_24h + sent_1h + sent_15m - logger.info( - f'[send_lesson_reminders] Отправлено напоминаний: ' - f'24ч - {sent_24h}, 1ч - {sent_1h}, 15м - {sent_15m} (всего: {total_sent})' - ) - - return f'Отправлено: 24ч - {sent_24h}, 1ч - {sent_1h}, 15м - {sent_15m}' - - except Exception as e: - logger.error(f'[send_lesson_reminders] Ошибка: {str(e)}', exc_info=True) - raise - - -@shared_task -def send_attendance_confirmation_requests(): - """ - Отправка запросов о подтверждении присутствия за 3 часа до занятия. - Проверяет все занятия, которые начинаются через 3 часа или меньше, - и отправляет запрос студенту, если еще не отправлен. - """ - from django.utils import timezone - from datetime import timedelta - from apps.notifications.services import NotificationService - - now = timezone.now() - # Занятия, которые начинаются через 3 часа или меньше - time_threshold = now + timedelta(hours=3) - - # Находим занятия, которые: - # 1. Еще не начались (start_time > now) - # 2. Начинаются через 3 часа или меньше (start_time <= time_threshold) - # 3. Еще не отменены - # 4. Запрос о присутствии еще не отправлен - lessons = Lesson.objects.filter( - start_time__gt=now, - start_time__lte=time_threshold, - status='scheduled', - attendance_confirmation_sent=False - ).select_related('client', 'client__user', 'mentor') - - sent_count = 0 - lessons_to_update = [] - - for lesson in lessons: - try: - # Отправляем запрос - NotificationService.send_attendance_confirmation_request(lesson) - - # Отмечаем что запрос отправлен (накапливаем для bulk_update) - lesson.attendance_confirmation_sent = True - lessons_to_update.append(lesson) - sent_count += 1 - - logger.info(f'Отправлен запрос о присутствии для занятия {lesson.id}') - except Exception as e: - logger.error(f'Ошибка отправки запроса о присутствии для занятия {lesson.id}: {e}') - - # Оптимизация: используем bulk_update вместо цикла с save() - if lessons_to_update: - Lesson.objects.bulk_update(lessons_to_update, ['attendance_confirmation_sent'], batch_size=100) - - logger.info(f'[send_attendance_confirmation_requests] Отправлено {sent_count} запросов о присутствии') - return f'Отправлено {sent_count} запросов' - - -@shared_task -def maintain_recurring_lessons(): - """ - Поддержание 12 будущих занятий для повторяющихся занятий. - - Задача проверяет все повторяющиеся занятия и добавляет недостающие, - чтобы всегда было 12 будущих занятий впереди. - - Запускается каждый день через Celery Beat. - """ - now = timezone.now() - added_count = 0 - - try: - # Находим все уникальные серии повторяющихся занятий - recurring_series = Lesson.objects.filter( - is_recurring=True, - recurring_series_id__isnull=False - ).values_list('recurring_series_id', flat=True).distinct() - - for series_id in recurring_series: - # Находим все занятия этой серии, которые еще не прошли - series_lessons = Lesson.objects.filter( - recurring_series_id=series_id, - start_time__gt=now # Только будущие занятия - ).order_by('start_time') - - if not series_lessons.exists(): - # Если нет будущих занятий, пропускаем эту серию - continue - - # Находим последнее занятие в серии (самое дальнее по времени) - last_lesson = series_lessons.last() - - # Подсчитываем, сколько будущих занятий есть - future_count = series_lessons.count() - - # Если меньше 12, добавляем недостающие - if future_count < 12: - # Находим первое занятие серии для получения шаблона - first_lesson = Lesson.objects.filter( - recurring_series_id=series_id, - parent_lesson__isnull=True # Родительское занятие - ).first() - - if not first_lesson: - # Если нет родительского, берем первое занятие серии - first_lesson = Lesson.objects.filter( - recurring_series_id=series_id - ).order_by('start_time').first() - - if not first_lesson: - continue - - # Получаем время начала и окончания последнего занятия - last_start_time = last_lesson.start_time - last_end_time = last_lesson.end_time - duration_minutes = last_lesson.duration - - # Вычисляем, сколько занятий нужно добавить - lessons_to_add = 12 - future_count - - # Создаем недостающие занятия - new_lessons = [] - for i in range(1, lessons_to_add + 1): - # Каждое следующее занятие через неделю после предыдущего - new_start_time = last_start_time + timedelta(weeks=i) - new_end_time = new_start_time + timedelta(minutes=duration_minutes) - - lesson_data = { - 'mentor': first_lesson.mentor, - 'client': first_lesson.client, - 'group': first_lesson.group, - 'start_time': new_start_time, - 'end_time': new_end_time, - 'duration': duration_minutes, - 'title': first_lesson.title, - 'description': first_lesson.description or '', - 'subject': first_lesson.subject, - 'mentor_subject': first_lesson.mentor_subject, - 'subject_name': first_lesson.subject_name or (first_lesson.subject.name if first_lesson.subject else '') or (first_lesson.mentor_subject.name if first_lesson.mentor_subject else ''), - 'template': first_lesson.template, - 'price': first_lesson.price, - 'is_recurring': True, - 'recurring_series_id': series_id, - 'parent_lesson': first_lesson if first_lesson.parent_lesson is None else first_lesson.parent_lesson, - } - new_lessons.append(Lesson(**lesson_data)) - - # Массовое создание для оптимизации - if new_lessons: - Lesson.objects.bulk_create(new_lessons) - added_count += len(new_lessons) - logger.info( - f'Добавлено {len(new_lessons)} занятий для серии {series_id}. ' - f'Теперь будущих занятий: {future_count + len(new_lessons)}' - ) - - logger.info(f'[maintain_recurring_lessons] Добавлено {added_count} занятий для поддержания 12 будущих занятий') - return f'Добавлено {added_count} занятий' - - except Exception as e: - logger.error(f'[maintain_recurring_lessons] Ошибка: {str(e)}', exc_info=True) - raise - - -@shared_task -def promote_mentor_subjects_to_subjects(): - """ - Переносит кастомные предметы менторов в общую модель Subject, - если предмет используется более чем 10 менторами. - - Запускается периодически через Celery Beat (например, раз в день). - """ - promoted_count = 0 - - try: - # Находим все уникальные названия кастомных предметов - # и подсчитываем количество менторов, использующих каждый предмет - from django.db.models import Count - mentor_subjects_stats = MentorSubject.objects.values('name').annotate( - mentor_count=Count('mentor', distinct=True) - ).filter(mentor_count__gte=10) # Используется 10+ менторами - - for stat in mentor_subjects_stats: - subject_name = stat['name'] - mentor_count = stat['mentor_count'] - - # Проверяем, существует ли уже такой предмет в Subject - existing_subject = Subject.objects.filter(name__iexact=subject_name).first() - - if existing_subject: - # Если предмет уже существует, просто активируем его - if not existing_subject.is_active: - existing_subject.is_active = True - existing_subject.save() - logger.info(f'Активирован существующий предмет: {subject_name}') - else: - # Создаем новый предмет в Subject - new_subject = Subject.objects.create( - name=subject_name, - is_active=True - ) - logger.info(f'Создан новый предмет в общей модели: {subject_name} (используется {mentor_count} менторами)') - - # Обновляем все занятия, использующие этот кастомный предмет - # Заменяем mentor_subject на subject - mentor_subjects = MentorSubject.objects.filter(name__iexact=subject_name) - - for mentor_subject in mentor_subjects: - # Находим или создаем Subject - subject = Subject.objects.filter(name__iexact=subject_name).first() - if not subject: - subject = Subject.objects.create(name=subject_name, is_active=True) - - # Обновляем занятия - updated_lessons = Lesson.objects.filter(mentor_subject=mentor_subject).update( - subject=subject, - mentor_subject=None, - subject_name=subject.name - ) - - # Обновляем шаблоны - from .models import LessonTemplate - LessonTemplate.objects.filter(mentor_subject=mentor_subject).update( - subject=subject, - mentor_subject=None, - subject_name=subject.name - ) - - if updated_lessons > 0: - logger.info( - f'Обновлено {updated_lessons} занятий и шаблонов для предмета "{subject_name}" ' - f'(ментор: {mentor_subject.mentor.get_full_name()})' - ) - - # Удаляем кастомные предметы, которые были перенесены - deleted_count = mentor_subjects.delete()[0] - if deleted_count > 0: - logger.info(f'Удалено {deleted_count} кастомных предметов "{subject_name}" после переноса в общую модель') - promoted_count += deleted_count - - logger.info(f'[promote_mentor_subjects_to_subjects] Перенесено {promoted_count} кастомных предметов в общую модель') - return f'Перенесено {promoted_count} предметов' - - except Exception as e: - logger.error(f'[promote_mentor_subjects_to_subjects] Ошибка: {str(e)}', exc_info=True) - raise - - -@shared_task(name='apps.schedule.tasks.start_lessons_automatically') -def start_lessons_automatically(): - """ - Автоматическое начало и завершение занятий по времени. - - Обновляет статус занятий: - - 'scheduled' -> 'in_progress' когда наступает время начала (start_time <= now) - - 'scheduled' или 'in_progress' -> 'completed' когда время окончания прошло (end_time < now) - - Запускается каждую минуту через Celery Beat. - """ - now = timezone.now() - started_count = 0 - completed_count = 0 - - try: - # Находим все запланированные занятия, которые должны начаться - # start_time <= now (время начала уже наступило) - # end_time >= now (время окончания еще не наступило) - # status = 'scheduled' (еще не начались) - lessons_to_start = Lesson.objects.filter( - status='scheduled', - start_time__lte=now, - end_time__gte=now - ).select_related('mentor', 'client') - - # Оптимизация: используем bulk_update вместо цикла с save() - lessons_to_start_list = list(lessons_to_start) - for lesson in lessons_to_start_list: - lesson.status = 'in_progress' - if lessons_to_start_list: - Lesson.objects.bulk_update(lessons_to_start_list, ['status']) - started_count = len(lessons_to_start_list) - for lesson in lessons_to_start_list: - logger.info(f'Занятие {lesson.id} автоматически переведено в статус "in_progress"') - - # Находим занятия, которые уже прошли и должны быть завершены - # end_time < now - 5 минут (время окончания прошло более 5 минут назад - даём время на завершение) - # status in ['scheduled', 'in_progress'] (еще не завершены) - five_minutes_ago = now - timedelta(minutes=5) - lessons_to_complete = Lesson.objects.filter( - status__in=['scheduled', 'in_progress'], - end_time__lt=five_minutes_ago - ).select_related('mentor', 'client') - - # Оптимизация: используем bulk_update вместо цикла с save() - lessons_to_complete_list = list(lessons_to_complete) - for lesson in lessons_to_complete_list: - lesson.status = 'completed' - lesson.completed_at = now - if lessons_to_complete_list: - Lesson.objects.bulk_update(lessons_to_complete_list, ['status', 'completed_at']) - completed_count = len(lessons_to_complete_list) - for lesson in lessons_to_complete_list: - logger.info(f'Занятие {lesson.id} автоматически переведено в статус "completed" (время окончания прошло)') - - # Закрываем LiveKit комнату, если она есть - try: - from apps.video.models import VideoRoom - from apps.video.services import get_sfu_client, SFUClientError - - video_room = VideoRoom.objects.filter(lesson=lesson).first() - if video_room and video_room.room_id: - sfu_client = get_sfu_client() - try: - sfu_client.delete_room(str(video_room.room_id)) - logger.info(f'LiveKit комната {video_room.room_id} закрыта для урока {lesson.id}') - except SFUClientError as e: - logger.warning(f'Не удалось закрыть LiveKit комнату {video_room.room_id} для урока {lesson.id}: {e}') - except Exception as e: - logger.error(f'Ошибка закрытия LiveKit комнаты для урока {lesson.id}: {str(e)}', exc_info=True) - - if started_count > 0 or completed_count > 0: - logger.info(f'[start_lessons_automatically] Начато: {started_count}, Завершено: {completed_count}') - - return f'Начато {started_count}, Завершено {completed_count}' - - except Exception as e: - logger.error(f'[start_lessons_automatically] Ошибка: {str(e)}', exc_info=True) - raise +# Celery задачи для schedule + +from celery import shared_task +import logging +from django.utils import timezone +from datetime import timedelta +from django.db.models import Count +from .models import Lesson, Subject, MentorSubject + +logger = logging.getLogger(__name__) + + +@shared_task +def send_lesson_reminders(): + """ + Отправка напоминаний о предстоящих занятиях. + + Отправляет напоминания за: + - 24 часа до занятия + - 1 час до занятия + - 15 минут до занятия + + Задача запускается каждые 15 минут через Celery Beat. + """ + from apps.notifications.services import NotificationService + + now = timezone.now() + sent_24h = 0 + sent_1h = 0 + sent_15m = 0 + + try: + # Находим все запланированные занятия, которые еще не начались и не отменены + lessons = Lesson.objects.filter( + start_time__gt=now, + status='scheduled' + ).select_related('client', 'client__user', 'mentor') + + # Напоминания за 24 часа (от 23:30 до 24:30) + time_24h_min = now + timedelta(hours=23, minutes=30) + time_24h_max = now + timedelta(hours=24, minutes=30) + + lessons_24h = lessons.filter( + start_time__gte=time_24h_min, + start_time__lte=time_24h_max, + reminder_24h_sent=False + ) + + # Оптимизация: используем bulk_update вместо цикла с save() + lessons_24h_list = list(lessons_24h) + lessons_24h_to_update = [] + for lesson in lessons_24h_list: + try: + NotificationService.send_lesson_reminder(lesson, time_before="24 часа") + lesson.reminder_24h_sent = True + lessons_24h_to_update.append(lesson) + sent_24h += 1 + logger.info(f'Отправлено напоминание за 24 часа для занятия {lesson.id}') + except Exception as e: + logger.error(f'Ошибка отправки напоминания за 24 часа для занятия {lesson.id}: {e}') + if lessons_24h_to_update: + Lesson.objects.bulk_update(lessons_24h_to_update, ['reminder_24h_sent']) + + # Напоминания за 1 час (от 50 минут до 70 минут) + time_1h_min = now + timedelta(minutes=50) + time_1h_max = now + timedelta(minutes=70) + + lessons_1h = lessons.filter( + start_time__gte=time_1h_min, + start_time__lte=time_1h_max, + reminder_1h_sent=False + ) + + # Оптимизация: используем bulk_update вместо цикла с save() + lessons_1h_list = list(lessons_1h) + lessons_1h_to_update = [] + for lesson in lessons_1h_list: + try: + NotificationService.send_lesson_reminder(lesson, time_before="1 час") + lesson.reminder_1h_sent = True + lessons_1h_to_update.append(lesson) + sent_1h += 1 + logger.info(f'Отправлено напоминание за 1 час для занятия {lesson.id}') + except Exception as e: + logger.error(f'Ошибка отправки напоминания за 1 час для занятия {lesson.id}: {e}') + if lessons_1h_to_update: + Lesson.objects.bulk_update(lessons_1h_to_update, ['reminder_1h_sent']) + + # Напоминания за 15 минут (от 10 минут до 20 минут) + time_15m_min = now + timedelta(minutes=10) + time_15m_max = now + timedelta(minutes=20) + + lessons_15m = lessons.filter( + start_time__gte=time_15m_min, + start_time__lte=time_15m_max, + reminder_15m_sent=False + ) + + # Оптимизация: используем bulk_update вместо цикла с save() + lessons_15m_list = list(lessons_15m) + lessons_15m_to_update = [] + for lesson in lessons_15m_list: + try: + NotificationService.send_lesson_reminder(lesson, time_before="15 минут") + lesson.reminder_15m_sent = True + lessons_15m_to_update.append(lesson) + sent_15m += 1 + logger.info(f'Отправлено напоминание за 15 минут для занятия {lesson.id}') + except Exception as e: + logger.error(f'Ошибка отправки напоминания за 15 минут для занятия {lesson.id}: {e}') + if lessons_15m_to_update: + Lesson.objects.bulk_update(lessons_15m_to_update, ['reminder_15m_sent']) + + total_sent = sent_24h + sent_1h + sent_15m + logger.info( + f'[send_lesson_reminders] Отправлено напоминаний: ' + f'24ч - {sent_24h}, 1ч - {sent_1h}, 15м - {sent_15m} (всего: {total_sent})' + ) + + return f'Отправлено: 24ч - {sent_24h}, 1ч - {sent_1h}, 15м - {sent_15m}' + + except Exception as e: + logger.error(f'[send_lesson_reminders] Ошибка: {str(e)}', exc_info=True) + raise + + +@shared_task +def send_attendance_confirmation_requests(): + """ + Отправка запросов о подтверждении присутствия за 3 часа до занятия. + Проверяет все занятия, которые начинаются через 3 часа или меньше, + и отправляет запрос студенту, если еще не отправлен. + """ + from django.utils import timezone + from datetime import timedelta + from apps.notifications.services import NotificationService + + now = timezone.now() + # Занятия, которые начинаются через 3 часа или меньше + time_threshold = now + timedelta(hours=3) + + # Находим занятия, которые: + # 1. Еще не начались (start_time > now) + # 2. Начинаются через 3 часа или меньше (start_time <= time_threshold) + # 3. Еще не отменены + # 4. Запрос о присутствии еще не отправлен + lessons = Lesson.objects.filter( + start_time__gt=now, + start_time__lte=time_threshold, + status='scheduled', + attendance_confirmation_sent=False + ).select_related('client', 'client__user', 'mentor') + + sent_count = 0 + lessons_to_update = [] + + for lesson in lessons: + try: + # Отправляем запрос + NotificationService.send_attendance_confirmation_request(lesson) + + # Отмечаем что запрос отправлен (накапливаем для bulk_update) + lesson.attendance_confirmation_sent = True + lessons_to_update.append(lesson) + sent_count += 1 + + logger.info(f'Отправлен запрос о присутствии для занятия {lesson.id}') + except Exception as e: + logger.error(f'Ошибка отправки запроса о присутствии для занятия {lesson.id}: {e}') + + # Оптимизация: используем bulk_update вместо цикла с save() + if lessons_to_update: + Lesson.objects.bulk_update(lessons_to_update, ['attendance_confirmation_sent'], batch_size=100) + + logger.info(f'[send_attendance_confirmation_requests] Отправлено {sent_count} запросов о присутствии') + return f'Отправлено {sent_count} запросов' + + +@shared_task +def maintain_recurring_lessons(): + """ + Поддержание 12 будущих занятий для повторяющихся занятий. + + Задача проверяет все повторяющиеся занятия и добавляет недостающие, + чтобы всегда было 12 будущих занятий впереди. + + Запускается каждый день через Celery Beat. + """ + now = timezone.now() + added_count = 0 + + try: + # Находим все уникальные серии повторяющихся занятий + recurring_series = Lesson.objects.filter( + is_recurring=True, + recurring_series_id__isnull=False + ).values_list('recurring_series_id', flat=True).distinct() + + for series_id in recurring_series: + # Находим все занятия этой серии, которые еще не прошли + series_lessons = Lesson.objects.filter( + recurring_series_id=series_id, + start_time__gt=now # Только будущие занятия + ).order_by('start_time') + + if not series_lessons.exists(): + # Если нет будущих занятий, пропускаем эту серию + continue + + # Находим последнее занятие в серии (самое дальнее по времени) + last_lesson = series_lessons.last() + + # Подсчитываем, сколько будущих занятий есть + future_count = series_lessons.count() + + # Если меньше 12, добавляем недостающие + if future_count < 12: + # Находим первое занятие серии для получения шаблона + first_lesson = Lesson.objects.filter( + recurring_series_id=series_id, + parent_lesson__isnull=True # Родительское занятие + ).first() + + if not first_lesson: + # Если нет родительского, берем первое занятие серии + first_lesson = Lesson.objects.filter( + recurring_series_id=series_id + ).order_by('start_time').first() + + if not first_lesson: + continue + + # Получаем время начала и окончания последнего занятия + last_start_time = last_lesson.start_time + last_end_time = last_lesson.end_time + duration_minutes = last_lesson.duration + + # Вычисляем, сколько занятий нужно добавить + lessons_to_add = 12 - future_count + + # Создаем недостающие занятия + new_lessons = [] + for i in range(1, lessons_to_add + 1): + # Каждое следующее занятие через неделю после предыдущего + new_start_time = last_start_time + timedelta(weeks=i) + new_end_time = new_start_time + timedelta(minutes=duration_minutes) + + lesson_data = { + 'mentor': first_lesson.mentor, + 'client': first_lesson.client, + 'group': first_lesson.group, + 'start_time': new_start_time, + 'end_time': new_end_time, + 'duration': duration_minutes, + 'title': first_lesson.title, + 'description': first_lesson.description or '', + 'subject': first_lesson.subject, + 'mentor_subject': first_lesson.mentor_subject, + 'subject_name': first_lesson.subject_name or (first_lesson.subject.name if first_lesson.subject else '') or (first_lesson.mentor_subject.name if first_lesson.mentor_subject else ''), + 'template': first_lesson.template, + 'price': first_lesson.price, + 'is_recurring': True, + 'recurring_series_id': series_id, + 'parent_lesson': first_lesson if first_lesson.parent_lesson is None else first_lesson.parent_lesson, + } + new_lessons.append(Lesson(**lesson_data)) + + # Массовое создание для оптимизации + if new_lessons: + Lesson.objects.bulk_create(new_lessons) + added_count += len(new_lessons) + logger.info( + f'Добавлено {len(new_lessons)} занятий для серии {series_id}. ' + f'Теперь будущих занятий: {future_count + len(new_lessons)}' + ) + + logger.info(f'[maintain_recurring_lessons] Добавлено {added_count} занятий для поддержания 12 будущих занятий') + return f'Добавлено {added_count} занятий' + + except Exception as e: + logger.error(f'[maintain_recurring_lessons] Ошибка: {str(e)}', exc_info=True) + raise + + +@shared_task +def promote_mentor_subjects_to_subjects(): + """ + Переносит кастомные предметы менторов в общую модель Subject, + если предмет используется более чем 10 менторами. + + Запускается периодически через Celery Beat (например, раз в день). + """ + promoted_count = 0 + + try: + # Находим все уникальные названия кастомных предметов + # и подсчитываем количество менторов, использующих каждый предмет + from django.db.models import Count + mentor_subjects_stats = MentorSubject.objects.values('name').annotate( + mentor_count=Count('mentor', distinct=True) + ).filter(mentor_count__gte=10) # Используется 10+ менторами + + for stat in mentor_subjects_stats: + subject_name = stat['name'] + mentor_count = stat['mentor_count'] + + # Проверяем, существует ли уже такой предмет в Subject + existing_subject = Subject.objects.filter(name__iexact=subject_name).first() + + if existing_subject: + # Если предмет уже существует, просто активируем его + if not existing_subject.is_active: + existing_subject.is_active = True + existing_subject.save() + logger.info(f'Активирован существующий предмет: {subject_name}') + else: + # Создаем новый предмет в Subject + new_subject = Subject.objects.create( + name=subject_name, + is_active=True + ) + logger.info(f'Создан новый предмет в общей модели: {subject_name} (используется {mentor_count} менторами)') + + # Обновляем все занятия, использующие этот кастомный предмет + # Заменяем mentor_subject на subject + mentor_subjects = MentorSubject.objects.filter(name__iexact=subject_name) + + for mentor_subject in mentor_subjects: + # Находим или создаем Subject + subject = Subject.objects.filter(name__iexact=subject_name).first() + if not subject: + subject = Subject.objects.create(name=subject_name, is_active=True) + + # Обновляем занятия + updated_lessons = Lesson.objects.filter(mentor_subject=mentor_subject).update( + subject=subject, + mentor_subject=None, + subject_name=subject.name + ) + + # Обновляем шаблоны + from .models import LessonTemplate + LessonTemplate.objects.filter(mentor_subject=mentor_subject).update( + subject=subject, + mentor_subject=None, + subject_name=subject.name + ) + + if updated_lessons > 0: + logger.info( + f'Обновлено {updated_lessons} занятий и шаблонов для предмета "{subject_name}" ' + f'(ментор: {mentor_subject.mentor.get_full_name()})' + ) + + # Удаляем кастомные предметы, которые были перенесены + deleted_count = mentor_subjects.delete()[0] + if deleted_count > 0: + logger.info(f'Удалено {deleted_count} кастомных предметов "{subject_name}" после переноса в общую модель') + promoted_count += deleted_count + + logger.info(f'[promote_mentor_subjects_to_subjects] Перенесено {promoted_count} кастомных предметов в общую модель') + return f'Перенесено {promoted_count} предметов' + + except Exception as e: + logger.error(f'[promote_mentor_subjects_to_subjects] Ошибка: {str(e)}', exc_info=True) + raise + + +@shared_task(name='apps.schedule.tasks.start_lessons_automatically') +def start_lessons_automatically(): + """ + Автоматическое начало и завершение занятий по времени. + + Обновляет статус занятий: + - 'scheduled' -> 'in_progress' когда наступает время начала (start_time <= now) + - 'scheduled' или 'in_progress' -> 'completed' когда время окончания прошло (end_time < now) + + Запускается каждую минуту через Celery Beat. + """ + now = timezone.now() + started_count = 0 + completed_count = 0 + + try: + # Находим все запланированные занятия, которые должны начаться + # start_time <= now (время начала уже наступило) + # end_time >= now (время окончания еще не наступило) + # status = 'scheduled' (еще не начались) + lessons_to_start = Lesson.objects.filter( + status='scheduled', + start_time__lte=now, + end_time__gte=now + ).select_related('mentor', 'client') + + # Оптимизация: используем bulk_update вместо цикла с save() + lessons_to_start_list = list(lessons_to_start) + for lesson in lessons_to_start_list: + lesson.status = 'in_progress' + if lessons_to_start_list: + Lesson.objects.bulk_update(lessons_to_start_list, ['status']) + started_count = len(lessons_to_start_list) + for lesson in lessons_to_start_list: + logger.info(f'Занятие {lesson.id} автоматически переведено в статус "in_progress"') + + # Находим занятия, которые уже прошли и должны быть завершены + # end_time < now - 5 минут (время окончания прошло более 5 минут назад - даём время на завершение) + # status in ['scheduled', 'in_progress'] (еще не завершены) + five_minutes_ago = now - timedelta(minutes=5) + lessons_to_complete = Lesson.objects.filter( + status__in=['scheduled', 'in_progress'], + end_time__lt=five_minutes_ago + ).select_related('mentor', 'client') + + # Оптимизация: используем bulk_update вместо цикла с save() + lessons_to_complete_list = list(lessons_to_complete) + for lesson in lessons_to_complete_list: + lesson.status = 'completed' + lesson.completed_at = now + if lessons_to_complete_list: + Lesson.objects.bulk_update(lessons_to_complete_list, ['status', 'completed_at']) + completed_count = len(lessons_to_complete_list) + for lesson in lessons_to_complete_list: + logger.info(f'Занятие {lesson.id} автоматически переведено в статус "completed" (время окончания прошло)') + + # Закрываем LiveKit комнату, если она есть + try: + from apps.video.models import VideoRoom + from apps.video.services import get_sfu_client, SFUClientError + + video_room = VideoRoom.objects.filter(lesson=lesson).first() + if video_room and video_room.room_id: + sfu_client = get_sfu_client() + try: + sfu_client.delete_room(str(video_room.room_id)) + logger.info(f'LiveKit комната {video_room.room_id} закрыта для урока {lesson.id}') + except SFUClientError as e: + logger.warning(f'Не удалось закрыть LiveKit комнату {video_room.room_id} для урока {lesson.id}: {e}') + except Exception as e: + logger.error(f'Ошибка закрытия LiveKit комнаты для урока {lesson.id}: {str(e)}', exc_info=True) + + if started_count > 0 or completed_count > 0: + logger.info(f'[start_lessons_automatically] Начато: {started_count}, Завершено: {completed_count}') + + return f'Начато {started_count}, Завершено {completed_count}' + + except Exception as e: + logger.error(f'[start_lessons_automatically] Ошибка: {str(e)}', exc_info=True) + raise diff --git a/backend/apps/schedule/views.py b/backend/apps/schedule/views.py index e9f296c..65a2d18 100644 --- a/backend/apps/schedule/views.py +++ b/backend/apps/schedule/views.py @@ -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': @@ -618,19 +615,30 @@ class LessonViewSet(viewsets.ModelViewSet): request_has_file_ids = isinstance(lesson_file_ids_raw, list) and len(lesson_file_ids_raw or []) > 0 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 + + # Проверяем флаг "заполнить позже" + homework_fill_later = request.data.get('homework_fill_later') in (True, 'true', 1, 'True') - has_homework = request_has_text or request_has_files + 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 diff --git a/backend/apps/users/models.py b/backend/apps/users/models.py index fed4304..0dad7a7 100644 --- a/backend/apps/users/models.py +++ b/backend/apps/users/models.py @@ -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) diff --git a/backend/apps/users/profile_views.py b/backend/apps/users/profile_views.py index cd6bc35..3207139 100644 --- a/backend/apps/users/profile_views.py +++ b/backend/apps/users/profile_views.py @@ -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']) diff --git a/backend/apps/users/serializers.py b/backend/apps/users/serializers.py index a062b5a..2787e72 100644 --- a/backend/apps/users/serializers.py +++ b/backend/apps/users/serializers.py @@ -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) diff --git a/backend/apps/users/views.py b/backend/apps/users/views.py index 0d705d0..58365d7 100644 --- a/backend/apps/users/views.py +++ b/backend/apps/users/views.py @@ -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 diff --git a/backend/check_smtp_ports.py b/backend/check_smtp_ports.py new file mode 100644 index 0000000..e190b52 --- /dev/null +++ b/backend/check_smtp_ports.py @@ -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}") diff --git a/front_material/api/auth.ts b/front_material/api/auth.ts index d561aa7..392c8f3 100644 --- a/front_material/api/auth.ts +++ b/front_material/api/auth.ts @@ -1,204 +1,208 @@ -/** - * API модуль для аутентификации - */ - -import apiClient from '@/lib/api-client'; - -export interface LoginCredentials { - email: string; - password: string; -} - -export interface RegisterData { - email: string; - password: string; - password_confirm: string; - first_name?: string; - last_name?: string; - role?: 'mentor' | 'client' | 'parent'; - city?: string; - timezone?: string; -} - -export interface AuthResponse { - access: string; - refresh?: string; - user?: any; -} - -export interface User { - id: number; - email: string; - first_name?: string; - last_name?: string; - phone?: string; - role: 'mentor' | 'client' | 'parent'; - is_verified?: boolean; - avatar_url?: string | null; - avatar?: string | null; - telegram_id?: number | null; - universal_code?: string; - invitation_link?: string; - invitation_link_token?: string; -} - -/** - * Вход в систему - */ -export async function login(credentials: LoginCredentials): Promise { - console.log('[auth.login] Sending request to /auth/login/', credentials.email); - const response = await apiClient.post('/auth/login/', credentials); - console.log('[auth.login] Raw response:', response); - console.log('[auth.login] response.data:', response.data); - - // API возвращает { success, message, data: { user, tokens: { access, refresh } } } - const data = response.data?.data; - console.log('[auth.login] Parsed data:', data); - console.log('[auth.login] Tokens:', data?.tokens); - - if (data?.tokens) { - const result = { - access: data.tokens.access, - refresh: data.tokens.refresh, - user: data.user - }; - console.log('[auth.login] Returning:', { ...result, access: result.access?.substring(0, 20) + '...' }); - return result; - } - - // Fallback для старого формата - console.log('[auth.login] Using fallback structure'); - return response.data?.data || response.data; -} - -/** - * Регистрация - */ -export async function register(data: RegisterData): Promise { - const response = await apiClient.post('/auth/register/', data); - // API возвращает { success, message, data: { user, tokens: { access, refresh } } } - const responseData = response.data?.data; - if (responseData?.tokens) { - return { - access: responseData.tokens.access, - refresh: responseData.tokens.refresh, - user: responseData.user - }; - } - // Fallback для старого формата - return response.data?.data || response.data; -} - -/** - * Выход из системы - */ -export async function logout(): Promise { - await apiClient.post('/auth/logout/'); -} - -/** - * Получить текущего пользователя - * Endpoint: GET /api/profile/me/ - */ -export async function getCurrentUser(): Promise { - try { - // Используем ProfileViewSet.me() - возвращает request.user - console.log('[getCurrentUser] Requesting /profile/me/'); - const response = await apiClient.get('/profile/me/'); - console.log('[getCurrentUser] Success:', response.data); - return response.data; - } catch (error: any) { - console.error('[getCurrentUser] Error with /profile/me/:', error); - console.error('[getCurrentUser] Error status:', error.response?.status); - console.error('[getCurrentUser] Error data:', error.response?.data); - console.error('[getCurrentUser] Error config:', error.config?.url); - - // Fallback: используем UserViewSet с ID из токена - const token = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null; - if (!token) { - console.error('[getCurrentUser] No token found for fallback'); - throw error; - } - - try { - const payload = JSON.parse(atob(token.split('.')[1])); - const userId = payload.user_id; - - if (!userId) { - console.error('[getCurrentUser] No user_id in token payload:', payload); - throw error; - } - - console.log('[getCurrentUser] Trying fallback /users/' + userId + '/'); - const userResponse = await apiClient.get(`/users/${userId}/`); - console.log('[getCurrentUser] Fallback success:', userResponse.data); - return userResponse.data; - } catch (e) { - console.error('[getCurrentUser] Fallback error:', e); - throw error; // Бросаем оригинальную ошибку, а не fallback ошибку - } - } -} - -/** - * Обновить access-токен по refresh-токену (запрос без Authorization, чтобы не слать истёкший токен). - */ -export async function refreshToken(refresh: string): Promise<{ access: string }> { - const response = await apiClient.getInstance().post<{ access: string }>( - '/auth/token/refresh/', - { refresh }, - { __skipAuth: true } as any - ); - return response.data; -} - -/** - * Смена пароля - */ -export async function changePassword( - oldPassword: string, - newPassword: string -): Promise { - await apiClient.post('/auth/change-password/', { - old_password: oldPassword, - new_password: newPassword, - }); -} - -/** - * Запрос на сброс пароля - */ -export async function requestPasswordReset(data: { email: string }): Promise { - await apiClient.post('/auth/password-reset/', data); -} - -/** - * Подтверждение email по токену из письма - */ -export async function verifyEmail(token: string): Promise<{ success: boolean; message?: string }> { - const response = await apiClient.post<{ success: boolean; message?: string }>( - '/auth/verify-email/', - { token }, - { __skipAuth: true } as any - ); - return response.data; -} - -/** - * Подтверждение сброса пароля (по ссылке из письма) - */ -export async function confirmPasswordReset( - token: string, - newPassword: string, - newPasswordConfirm: string -): Promise { - await apiClient.post( - '/auth/password-reset-confirm/', - { - token, - new_password: newPassword, - new_password_confirm: newPasswordConfirm, - }, - { __skipAuth: true } as any - ); -} +/** + * API модуль для аутентификации + */ + +import apiClient from '@/lib/api-client'; + +export interface LoginCredentials { + email: string; + password: string; +} + +export interface RegisterData { + email: string; + password: string; + password_confirm: string; + first_name?: string; + last_name?: string; + role?: 'mentor' | 'client' | 'parent'; + city?: string; + timezone?: string; +} + +export interface AuthResponse { + access: string; + refresh?: string; + user?: any; +} + +export interface User { + id: number; + email: string; + first_name?: string; + last_name?: string; + phone?: string; + role: 'mentor' | 'client' | 'parent'; + is_verified?: boolean; + avatar_url?: string | null; + avatar?: string | null; + telegram_id?: number | null; + universal_code?: string; + invitation_link?: string; + invitation_link_token?: string; + timezone?: string; + language?: string; + city?: string; + country?: string; +} + +/** + * Вход в систему + */ +export async function login(credentials: LoginCredentials): Promise { + console.log('[auth.login] Sending request to /auth/login/', credentials.email); + const response = await apiClient.post('/auth/login/', credentials); + console.log('[auth.login] Raw response:', response); + console.log('[auth.login] response.data:', response.data); + + // API возвращает { success, message, data: { user, tokens: { access, refresh } } } + const data = response.data?.data; + console.log('[auth.login] Parsed data:', data); + console.log('[auth.login] Tokens:', data?.tokens); + + if (data?.tokens) { + const result = { + access: data.tokens.access, + refresh: data.tokens.refresh, + user: data.user + }; + console.log('[auth.login] Returning:', { ...result, access: result.access?.substring(0, 20) + '...' }); + return result; + } + + // Fallback для старого формата + console.log('[auth.login] Using fallback structure'); + return response.data?.data || response.data; +} + +/** + * Регистрация + */ +export async function register(data: RegisterData): Promise { + const response = await apiClient.post('/auth/register/', data); + // API возвращает { success, message, data: { user, tokens: { access, refresh } } } + const responseData = response.data?.data; + if (responseData?.tokens) { + return { + access: responseData.tokens.access, + refresh: responseData.tokens.refresh, + user: responseData.user + }; + } + // Fallback для старого формата + return response.data?.data || response.data; +} + +/** + * Выход из системы + */ +export async function logout(): Promise { + await apiClient.post('/auth/logout/'); +} + +/** + * Получить текущего пользователя + * Endpoint: GET /api/profile/me/ + */ +export async function getCurrentUser(): Promise { + try { + // Используем ProfileViewSet.me() - возвращает request.user + console.log('[getCurrentUser] Requesting /profile/me/'); + const response = await apiClient.get('/profile/me/'); + console.log('[getCurrentUser] Success:', response.data); + return response.data; + } catch (error: any) { + console.error('[getCurrentUser] Error with /profile/me/:', error); + console.error('[getCurrentUser] Error status:', error.response?.status); + console.error('[getCurrentUser] Error data:', error.response?.data); + console.error('[getCurrentUser] Error config:', error.config?.url); + + // Fallback: используем UserViewSet с ID из токена + const token = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null; + if (!token) { + console.error('[getCurrentUser] No token found for fallback'); + throw error; + } + + try { + const payload = JSON.parse(atob(token.split('.')[1])); + const userId = payload.user_id; + + if (!userId) { + console.error('[getCurrentUser] No user_id in token payload:', payload); + throw error; + } + + console.log('[getCurrentUser] Trying fallback /users/' + userId + '/'); + const userResponse = await apiClient.get(`/users/${userId}/`); + console.log('[getCurrentUser] Fallback success:', userResponse.data); + return userResponse.data; + } catch (e) { + console.error('[getCurrentUser] Fallback error:', e); + throw error; // Бросаем оригинальную ошибку, а не fallback ошибку + } + } +} + +/** + * Обновить access-токен по refresh-токену (запрос без Authorization, чтобы не слать истёкший токен). + */ +export async function refreshToken(refresh: string): Promise<{ access: string }> { + const response = await apiClient.getInstance().post<{ access: string }>( + '/auth/token/refresh/', + { refresh }, + { __skipAuth: true } as any + ); + return response.data; +} + +/** + * Смена пароля + */ +export async function changePassword( + oldPassword: string, + newPassword: string +): Promise { + await apiClient.post('/auth/change-password/', { + old_password: oldPassword, + new_password: newPassword, + }); +} + +/** + * Запрос на сброс пароля + */ +export async function requestPasswordReset(data: { email: string }): Promise { + await apiClient.post('/auth/password-reset/', data); +} + +/** + * Подтверждение email по токену из письма + */ +export async function verifyEmail(token: string): Promise<{ success: boolean; message?: string }> { + const response = await apiClient.post<{ success: boolean; message?: string }>( + '/auth/verify-email/', + { token }, + { __skipAuth: true } as any + ); + return response.data; +} + +/** + * Подтверждение сброса пароля (по ссылке из письма) + */ +export async function confirmPasswordReset( + token: string, + newPassword: string, + newPasswordConfirm: string +): Promise { + await apiClient.post( + '/auth/password-reset-confirm/', + { + token, + new_password: newPassword, + new_password_confirm: newPasswordConfirm, + }, + { __skipAuth: true } as any + ); +} diff --git a/front_material/api/homework.ts b/front_material/api/homework.ts index 65d9403..8b4c2f5 100644 --- a/front_material/api/homework.ts +++ b/front_material/api/homework.ts @@ -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 { + const res = await apiClient.patch(`/homework/homeworks/${homeworkId}/`, data); + return res.data; +} + +/** + * Опубликовать домашнее задание (из черновика в published). + * POST /api/homework/homeworks/{id}/publish/ + */ +export async function publishHomework(homeworkId: string | number): Promise { + const res = await apiClient.post(`/homework/homeworks/${homeworkId}/publish/`); + return res.data; +} + export async function submitHomework( homeworkId: string | number, data: { content?: string; text?: string; files?: File[] }, diff --git a/front_material/app/(protected)/schedule/page.tsx b/front_material/app/(protected)/schedule/page.tsx index 7c3c7db..4870583 100644 --- a/front_material/app/(protected)/schedule/page.tsx +++ b/front_material/app/(protected)/schedule/page.tsx @@ -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() {
diff --git a/front_material/app/layout.tsx b/front_material/app/layout.tsx index d3fe3ec..18dd9c6 100644 --- a/front_material/app/layout.tsx +++ b/front_material/app/layout.tsx @@ -37,6 +37,14 @@ export default function RootLayout({ + {/* Preload локального шрифта иконок */} + diff --git a/front_material/components/calendar/calendar.tsx b/front_material/components/calendar/calendar.tsx index 3b7bf57..0d5fced 100644 --- a/front_material/components/calendar/calendar.tsx +++ b/front_material/components/calendar/calendar.tsx @@ -1,99 +1,120 @@ -/** - * Блок «Календарь занятий» — обёртка над LessonsCalendar. - * Используется в Dashboard и других страницах. - */ - -'use client'; - -import React from 'react'; -import { format, startOfDay } from 'date-fns'; -import { LessonsCalendar } from '@/components/dashboard/LessonsCalendar'; -import { LoadingSpinner } from '@/components/common/LoadingSpinner'; - -export interface CalendarLesson { - id: number | string; - title?: string; - start_time: string; - end_time: string; - status?: string; - client?: number; - client_name?: string; - subject?: string; -} - -export interface CalendarProps { - /** Занятия для отображения в календаре */ - lessons: CalendarLesson[]; - /** Идёт загрузка занятий */ - lessonsLoading?: boolean; - /** Выбранная дата (подсветка в календаре) */ - selectedDate: Date; - /** Клик по ячейке дня или по слоту */ - onSelectSlot?: (date: Date) => void; - /** Клик по событию (занятию) */ - onSelectEvent?: (lesson: { id: string }) => void; - /** Смена видимого месяца (start/end месяца) */ - onMonthChange?: (start: Date, end: Date) => void; -} - -export const Calendar: React.FC = ({ - lessons, - lessonsLoading = false, - selectedDate, - onSelectSlot, - onSelectEvent, - onMonthChange, -}) => { - 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 - ? { - 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] - ); - - return ( -
- {lessonsLoading ? ( - - ) : ( - { - try { - const d = startOfDay(date); - if (!Number.isNaN(d.getTime())) onSelectSlot?.(d); - } catch { - /* игнор невалидной даты */ - } - }} - onSelectEvent={onSelectEvent} - onMonthChange={onMonthChange} - /> - )} -
- ); -}; +/** + * Блок «Календарь занятий» — обёртка над LessonsCalendar. + * Используется в Dashboard и других страницах. + */ + +'use client'; + +import React from 'react'; +import { format, startOfDay } from 'date-fns'; +import { LessonsCalendar } from '@/components/dashboard/LessonsCalendar'; +import { LoadingSpinner } from '@/components/common/LoadingSpinner'; + +export interface CalendarLesson { + id: number | string; + title?: string; + start_time: string; + end_time: string; + status?: string; + client?: number; + client_name?: string; + mentor_name?: string; + subject?: string; +} + +export interface CalendarProps { + /** Занятия для отображения в календаре */ + lessons: CalendarLesson[]; + /** Идёт загрузка занятий */ + lessonsLoading?: boolean; + /** Выбранная дата (подсветка в календаре) */ + selectedDate: Date; + /** Клик по ячейке дня или по слоту */ + onSelectSlot?: (date: Date) => void; + /** Клик по событию (занятию) */ + onSelectEvent?: (lesson: { id: string }) => void; + /** Смена видимого месяца (start/end месяца) */ + onMonthChange?: (start: Date, end: Date) => void; + /** Ментор — показывает ученика; студент — показывает предмет и ментора */ + isMentor?: boolean; + /** Часовой пояс пользователя (например, 'UTC+8') */ + userTimezone?: string; +} + +export const Calendar: React.FC = ({ + lessons, + lessonsLoading = false, + selectedDate, + onSelectSlot, + onSelectEvent, + onMonthChange, + isMentor = true, + userTimezone, +}) => { + const mappedLessons = React.useMemo( + () => + 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(' ') || '', + }, + }; + } + 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 ( +
+ {lessonsLoading ? ( + + ) : ( + { + try { + const d = startOfDay(date); + if (!Number.isNaN(d.getTime())) onSelectSlot?.(d); + } catch { + /* игнор невалидной даты */ + } + }} + onSelectEvent={onSelectEvent} + onMonthChange={onMonthChange} + /> + )} +
+ ); +}; diff --git a/front_material/components/checklesson/checklesson.tsx b/front_material/components/checklesson/checklesson.tsx index 4b7a46a..3ed32b2 100644 --- a/front_material/components/checklesson/checklesson.tsx +++ b/front_material/components/checklesson/checklesson.tsx @@ -1,883 +1,883 @@ -/** - * Блок «Список занятий на выбранный день» / «Форма создания/редактирования» с анимацией cube. - * Используется в Dashboard и других страницах. - */ - -'use client'; - -import React, { useState } from 'react'; -import { format, startOfDay, addDays, subDays } from 'date-fns'; -import { ru } from 'date-fns/locale'; -import { LessonCard } from '@/components/dashboard/LessonCard'; -import { StudentSelect } from '@/components/dashboard/StudentSelect'; -import { SubjectSelect } from '@/components/dashboard/SubjectSelect'; -import { LoadingSpinner } from '@/components/common/LoadingSpinner'; -import { Switch } from '@/components/common/Switch'; -import { DatePicker } from '@/components/common/DatePicker'; -import { TimePicker } from '@/components/common/TimePicker'; -import type { LessonPreview } from '@/api/dashboard'; -import type { Student } from '@/api/students'; -import type { Subject, MentorSubject } from '@/api/subjects'; - -export interface CheckLessonFormData { - client: string; - title: string; - description: string; - start_date: string; - start_time: string; - duration: number; - price: number | undefined; - is_recurring: boolean; -} - -export interface CheckLessonProps { - /** Выбранная дата */ - selectedDate: Date; - /** Дата для отображения в заголовке (например, displayDate) */ - displayDate: Date; - /** Идёт загрузка занятий */ - lessonsLoading: boolean; - /** Занятия на выбранный день */ - lessonsForSelectedDate: LessonPreview[]; - /** Открыта ли форма (создание/редактирование) — переворот cube */ - isFormVisible: boolean; - /** Предыдущий день */ - onPrevDay: () => void; - /** Следующий день */ - onNextDay: () => void; - /** Добавить занятие (открыть форму создания) */ - onAddLesson: () => void; - /** Клик по карточке занятия (открыть форму редактирования или просмотра) */ - onLessonClick: (lesson: { id: string }) => void; - /** Ментор — может добавлять и редактировать; для остальных только просмотр */ - isMentor?: boolean; - /** Кнопка «Добавить» и компоненты загружены */ - buttonComponentsLoaded?: boolean; - - // Форма - formComponentsLoaded: boolean; - lessonEditLoading: boolean; - isEditingMode: boolean; - formLoading: boolean; - formError: string | null; - formData: CheckLessonFormData; - setFormData: React.Dispatch>; - selectedSubjectId: number | null; - selectedMentorSubjectId: number | null; - onSubjectChange: (subjectId: number | null, mentorSubjectId: number | null) => void; - students: Student[]; - subjects: Subject[]; - mentorSubjects: MentorSubject[]; - onSubmit: (e: React.FormEvent) => void; - onCancel: () => void; - /** Удалить занятие (только в режиме редактирования). deleteAllFuture — удалить всю цепочку постоянных. */ - onDelete?: (deleteAllFuture: boolean) => void; -} - -export const CheckLesson: React.FC = ({ - selectedDate, - displayDate, - lessonsLoading, - lessonsForSelectedDate, - isFormVisible, - onPrevDay, - onNextDay, - onAddLesson, - onLessonClick, - isMentor = false, - buttonComponentsLoaded = false, - formComponentsLoaded, - lessonEditLoading, - isEditingMode, - formLoading, - formError, - formData, - setFormData, - selectedSubjectId, - selectedMentorSubjectId, - onSubjectChange, - students, - subjects, - mentorSubjects, - onSubmit, - onCancel, - onDelete, -}) => { - const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); - const navDisabled = lessonsLoading || isFormVisible; - const formDisabled = formLoading || lessonEditLoading; - - const headerDateLabel = React.useMemo(() => { - const now = new Date(); - const sameYear = displayDate?.getFullYear?.() === now.getFullYear(); - const fmt = sameYear ? 'd MMMM' : 'd MMMM yyyy'; - try { - return format(displayDate, fmt, { locale: ru }); - } catch { - return ''; - } - }, [displayDate]); - - const handleDeleteClick = () => { - if (!onDelete) return; - if (formData.is_recurring) { - setShowDeleteConfirm(true); - } else { - if (typeof window !== 'undefined' && window.confirm('Удалить занятие?')) { - onDelete(false); - } - } - }; - - const handleDeleteConfirm = (deleteAllFuture: boolean) => { - onDelete?.(deleteAllFuture); - setShowDeleteConfirm(false); - }; - - return ( -
-
- {/* Лицевая сторона: Список занятий */} -
-
-
- - -

- {headerDateLabel} -

- - -
-
- {/* Контент (скроллится) */} -
- {lessonsLoading ? ( - - ) : lessonsForSelectedDate.length === 0 ? ( -
- - - - - - -

Нет занятий на этот день

-
- ) : ( -
- {lessonsForSelectedDate.map((lesson) => ( - onLessonClick(lesson) : undefined} - /> - ))} -
- )} -
- - {/* Footer — кнопка «Добавить занятие» только для ментора */} - {buttonComponentsLoaded && isMentor && ( -
- -
- )} -
- - {/* Обратная сторона: Форма создания/редактирования */} -
- {!formComponentsLoaded || (isEditingMode && lessonEditLoading) ? ( - - ) : ( - <> -
-
-

- {isEditingMode ? 'Редактировать занятие' : 'Создать занятие'} -

- -
- - {formError && ( -
- {formError} -
- )} - -
- - setFormData((prev) => ({ ...prev, client: value }))} - disabled={formLoading || isEditingMode} - required - /> -
- -
- - { - if (value != null) { - const s = subjects.find((x) => x.id === value); - if (s) onSubjectChange(value, null); - else onSubjectChange(null, value); - } else { - onSubjectChange(null, null); - } - }} - disabled={formLoading} - required - /> -
- -
- -