fix bugs
Deploy to Production / deploy-production (push) Successful in 27s Details

This commit is contained in:
root 2026-02-21 23:50:05 +03:00
parent d4c4dbb087
commit d9121fe6ef
42 changed files with 5047 additions and 3500 deletions

View File

@ -1,93 +1,93 @@
# Настройка автоматического резервного копирования БД # Настройка автоматического резервного копирования БД
## 🎯 Автоматический бэкап дважды в день ## 🎯 Автоматический бэкап дважды в день
Система автоматически создаёт бэкапы PROD и DEV БД: Система автоматически создаёт бэкапы PROD и DEV БД:
- **00:00** (полночь) - **00:00** (полночь)
- **12:00** (полдень) - **12:00** (полдень)
## 📋 Установка ## 📋 Установка
```bash ```bash
cd /var/www/platform/prod cd /var/www/platform/prod
# Сделать скрипты исполняемыми # Сделать скрипты исполняемыми
chmod +x backup-db-auto.sh setup-cron-backup.sh remove-cron-backup.sh chmod +x backup-db-auto.sh setup-cron-backup.sh remove-cron-backup.sh
# Настроить автоматический бэкап # Настроить автоматический бэкап
./setup-cron-backup.sh ./setup-cron-backup.sh
``` ```
## ✅ Проверка ## ✅ Проверка
```bash ```bash
# Проверить, что задача добавлена в cron # Проверить, что задача добавлена в cron
crontab -l | grep backup-db-auto 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 # 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 ```bash
# Логи автоматических бэкапов # Логи автоматических бэкапов
tail -f /var/www/platform/prod/backups/backup.log tail -f /var/www/platform/prod/backups/backup.log
# Логи cron (ошибки выполнения) # Логи cron (ошибки выполнения)
tail -f /var/www/platform/prod/backups/cron.log tail -f /var/www/platform/prod/backups/cron.log
``` ```
## 🗂️ Хранение бэкапов ## 🗂️ Хранение бэкапов
- **Директория**: `/var/www/platform/prod/backups/` - **Директория**: `/var/www/platform/prod/backups/`
- **Формат файлов**: `platform_prod_db_YYYYMMDD_HHMMSS.sql.gz` - **Формат файлов**: `platform_prod_db_YYYYMMDD_HHMMSS.sql.gz`
- **Автоочистка**: Бэкапы старше 30 дней удаляются автоматически - **Автоочистка**: Бэкапы старше 30 дней удаляются автоматически
- **Проверка места**: При использовании диска > 80% в лог пишется предупреждение - **Проверка места**: При использовании диска > 80% в лог пишется предупреждение
## 🔄 Ручной запуск ## 🔄 Ручной запуск
```bash ```bash
# Запустить бэкап вручную (для тестирования) # Запустить бэкап вручную (для тестирования)
/var/www/platform/prod/backup-db-auto.sh /var/www/platform/prod/backup-db-auto.sh
``` ```
## 🗑️ Удаление автоматического бэкапа ## 🗑️ Удаление автоматического бэкапа
```bash ```bash
# Удалить задачу из cron # Удалить задачу из cron
./remove-cron-backup.sh ./remove-cron-backup.sh
# Или вручную # Или вручную
crontab -l | grep -v backup-db-auto | crontab - crontab -l | grep -v backup-db-auto | crontab -
``` ```
## 📝 Что делает скрипт ## 📝 Что делает скрипт
1. ✅ Проверяет, что контейнеры БД запущены 1. ✅ Проверяет, что контейнеры БД запущены
2. ✅ Создаёт бэкапы PROD и DEV БД 2. ✅ Создаёт бэкапы PROD и DEV БД
3. ✅ Сжимает бэкапы (gzip) 3. ✅ Сжимает бэкапы (gzip)
4. ✅ Проверяет размер бэкапов 4. ✅ Проверяет размер бэкапов
5. ✅ Удаляет бэкапы старше 30 дней 5. ✅ Удаляет бэкапы старше 30 дней
6. ✅ Логирует все действия 6. ✅ Логирует все действия
7. ✅ Предупреждает о нехватке места на диске 7. ✅ Предупреждает о нехватке места на диске
## ⚠️ Важно ## ⚠️ Важно
- Скрипт работает от пользователя `root` (нужен доступ к Docker) - Скрипт работает от пользователя `root` (нужен доступ к Docker)
- Бэкапы сохраняются в `/var/www/platform/prod/backups/` - Бэкапы сохраняются в `/var/www/platform/prod/backups/`
- Старые бэкапы (30+ дней) удаляются автоматически - Старые бэкапы (30+ дней) удаляются автоматически
- При ошибках информация записывается в лог - При ошибках информация записывается в лог
## 🔍 Мониторинг ## 🔍 Мониторинг
```bash ```bash
# Посмотреть последние бэкапы # Посмотреть последние бэкапы
ls -lh /var/www/platform/prod/backups/*.sql.gz | tail -10 ls -lh /var/www/platform/prod/backups/*.sql.gz | tail -10
# Проверить размер всех бэкапов # Проверить размер всех бэкапов
du -sh /var/www/platform/prod/backups/ du -sh /var/www/platform/prod/backups/
# Посмотреть последние записи в логе # Посмотреть последние записи в логе
tail -20 /var/www/platform/prod/backups/backup.log tail -20 /var/www/platform/prod/backups/backup.log
``` ```

View File

@ -349,13 +349,14 @@ class Message(models.Model):
self.chat.increment_messages_count() self.chat.increment_messages_count()
self.chat.update_last_message() self.chat.update_last_message()
# Увеличиваем счетчик непрочитанных для всех участников кроме отправителя # Системные сообщения (уведомления) не увеличивают счётчик непрочитанных — уведомления есть отдельно
# Оптимизация: используем bulk_update вместо цикла с save() if self.message_type != 'system':
participants = list(self.chat.participants.exclude(user=self.sender)) # Увеличиваем счетчик непрочитанных для всех участников кроме отправителя
for participant in participants: participants = list(self.chat.participants.exclude(user=self.sender))
participant.unread_count += 1 for participant in participants:
if participants: participant.unread_count += 1
ChatParticipant.objects.bulk_update(participants, ['unread_count']) if participants:
ChatParticipant.objects.bulk_update(participants, ['unread_count'])
def mark_as_edited(self): def mark_as_edited(self):
"""Отметить как отредактированное.""" """Отметить как отредактированное."""

View File

@ -4,6 +4,7 @@
from rest_framework import serializers from rest_framework import serializers
from django.db import models from django.db import models
from .models import Chat, ChatParticipant, Message, MessageFile, MessageRead, MessageReaction from .models import Chat, ChatParticipant, Message, MessageFile, MessageRead, MessageReaction
from .services import ChatService
from apps.users.serializers import UserSerializer from apps.users.serializers import UserSerializer
from apps.users.mixins import TimezoneAwareSerializerMixin from apps.users.mixins import TimezoneAwareSerializerMixin
from apps.users.utils import format_datetime_for_user from apps.users.utils import format_datetime_for_user
@ -531,19 +532,17 @@ class ChatCreateSerializer(serializers.ModelSerializer):
participant_ids = validated_data.pop('participant_ids') participant_ids = validated_data.pop('participant_ids')
user = self.context['request'].user user = self.context['request'].user
# Для личного чата проверяем что такой чат уже не существует # Для личного чата используем сервис с защитой от race condition
if validated_data['chat_type'] == 'direct': if validated_data['chat_type'] == 'direct':
existing_chat = Chat.objects.filter( other_user = User.objects.get(id=participant_ids[0])
chat_type='direct', chat, _ = ChatService.get_or_create_direct_chat(
participants__user=user user1=user,
).filter( user2=other_user,
participants__user_id=participant_ids[0] created_by=user
).first() )
return chat
if existing_chat:
return existing_chat
# Создаем чат # Для группового чата - обычная логика
chat = Chat.objects.create( chat = Chat.objects.create(
created_by=user, created_by=user,
**validated_data **validated_data
@ -557,7 +556,6 @@ class ChatCreateSerializer(serializers.ModelSerializer):
) )
# Добавляем остальных участников # Добавляем остальных участников
# Оптимизация: используем bulk_create вместо цикла с create()
users = list(User.objects.filter(id__in=participant_ids)) users = list(User.objects.filter(id__in=participant_ids))
participants_to_create = [ participants_to_create = [
ChatParticipant( ChatParticipant(

View File

@ -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}
)

View File

@ -17,6 +17,7 @@ from .serializers import (
ChatParticipantSerializer ChatParticipantSerializer
) )
from .permissions import IsChatParticipant from .permissions import IsChatParticipant
from .services import ChatService
from .utils import ( from .utils import (
save_file_to_preload, save_file_to_preload,
move_file_from_preload_to_chat, move_file_from_preload_to_chat,
@ -233,46 +234,25 @@ class ChatViewSet(viewsets.ModelViewSet):
'error': 'Вы можете создавать чаты только со связанными пользователями' 'error': 'Вы можете создавать чаты только со связанными пользователями'
}, status=status.HTTP_403_FORBIDDEN) }, status=status.HTTP_403_FORBIDDEN)
# Проверяем существует ли уже чат # Используем сервис для атомарного создания/получения чата
existing_chat = Chat.objects.filter( chat, created = ChatService.get_or_create_direct_chat(
chat_type='direct', user1=request.user,
participants__user=request.user user2=other_user,
).filter( created_by=request.user
participants__user_id=other_user_id )
).first()
if existing_chat: serializer = ChatDetailSerializer(chat)
serializer = ChatDetailSerializer(existing_chat) if created:
return Response({
'success': True,
'data': serializer.data
}, status=status.HTTP_201_CREATED)
else:
return Response({ return Response({
'success': True, 'success': True,
'data': serializer.data, 'data': serializer.data,
'message': 'Чат уже существует' 'message': 'Чат уже существует'
}) })
# Создаем новый чат
chat = Chat.objects.create(
chat_type='direct',
created_by=request.user
)
# Добавляем участников
ChatParticipant.objects.create(
chat=chat,
user=request.user,
role='admin'
)
ChatParticipant.objects.create(
chat=chat,
user=other_user,
role='member'
)
serializer = ChatDetailSerializer(chat)
return Response({
'success': True,
'data': serializer.data
}, status=status.HTTP_201_CREATED)
@action(detail=True, methods=['post']) @action(detail=True, methods=['post'])
def mark_read(self, request, uuid=None): def mark_read(self, request, uuid=None):
@ -357,10 +337,12 @@ class ChatViewSet(viewsets.ModelViewSet):
except Exception: except Exception:
pass pass
# Пересчитываем непрочитанные # Пересчитываем непрочитанные (системные сообщения не учитываем)
unread_count = Message.objects.filter( unread_count = Message.objects.filter(
chat=chat, chat=chat,
is_deleted=False is_deleted=False
).exclude(
message_type='system'
).exclude( ).exclude(
reads__user=request.user reads__user=request.user
).exclude( ).exclude(
@ -454,65 +436,28 @@ class ChatViewSet(viewsets.ModelViewSet):
'error': 'У урока должны быть указаны ментор и клиент' 'error': 'У урока должны быть указаны ментор и клиент'
}, status=status.HTTP_400_BAD_REQUEST) }, status=status.HTTP_400_BAD_REQUEST)
# Ищем существующий личный чат между ментором и клиентом # Используем сервис для атомарного создания/получения чата
existing_chat = Chat.objects.filter( chat, created = ChatService.get_or_create_direct_chat(
chat_type='direct', user1=mentor,
participants__user=mentor user2=client_user,
).filter( created_by=mentor
participants__user=client_user )
).distinct().first()
if existing_chat: # Если текущий пользователь не участник чата (родитель), добавляем его
# Проверяем, является ли текущий пользователь участником if request.user != mentor and request.user != client_user:
participant = existing_chat.participants.filter(user=request.user).first() ChatService.ensure_participant(chat, request.user, role='member')
if not participant:
# Если текущий пользователь не участник, но это ментор или клиент урока - добавляем serializer = ChatDetailSerializer(chat, context={'request': request})
if request.user == mentor or request.user == client_user: if created:
ChatParticipant.objects.get_or_create( return Response({
chat=existing_chat, 'success': True,
user=request.user, 'data': serializer.data
defaults={'role': 'member'} }, status=status.HTTP_201_CREATED)
) else:
serializer = ChatDetailSerializer(existing_chat, context={'request': request})
return Response({ return Response({
'success': True, 'success': True,
'data': serializer.data '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']) @action(detail=True, methods=['post'])
def mark_as_read(self, request, uuid=None): def mark_as_read(self, request, uuid=None):

View File

@ -209,14 +209,14 @@ class HomeworkViewSet(viewsets.ModelViewSet):
# Инвалидируем кеш дашборда после создания ДЗ # Инвалидируем кеш дашборда после создания ДЗ
from apps.users.cache_utils import invalidate_dashboard_cache from apps.users.cache_utils import invalidate_dashboard_cache
invalidate_dashboard_cache(homework.mentor.id, 'mentor') invalidate_dashboard_cache(homework.mentor.id, 'mentor')
# Оптимизация: используем list() для кеширования запроса
students = list(homework.assigned_to.all()) students = list(homework.assigned_to.all())
for student in students: for student in students:
invalidate_dashboard_cache(student.id, 'client') invalidate_dashboard_cache(student.id, 'client')
# Отправляем уведомление о новом ДЗ # Отправляем уведомление о новом ДЗ только если НЕ отложенное
from apps.notifications.services import NotificationService if not homework.fill_later:
NotificationService.send_homework_notification(homework, 'homework_assigned') from apps.notifications.services import NotificationService
NotificationService.send_homework_notification(homework, 'homework_assigned')
response_serializer = HomeworkSerializer(homework) response_serializer = HomeworkSerializer(homework)
return Response( return Response(
@ -230,6 +230,9 @@ class HomeworkViewSet(viewsets.ModelViewSet):
Опубликовать ДЗ. Опубликовать ДЗ.
POST /api/homework/homeworks/{id}/publish/ POST /api/homework/homeworks/{id}/publish/
Также используется для публикации отложенных ДЗ (fill_later=True).
При публикации сбрасывается флаг fill_later.
""" """
homework = self.get_object() homework = self.get_object()
@ -240,12 +243,19 @@ class HomeworkViewSet(viewsets.ModelViewSet):
status=status.HTTP_403_FORBIDDEN status=status.HTTP_403_FORBIDDEN
) )
# Запоминаем был ли это отложенный ДЗ (для отправки уведомления)
was_fill_later = homework.fill_later
# Сбрасываем fill_later при публикации
if homework.fill_later:
homework.fill_later = False
homework.save(update_fields=['fill_later'])
homework.publish() homework.publish()
# Инвалидируем кеш дашборда после публикации ДЗ # Инвалидируем кеш дашборда после публикации ДЗ
from apps.users.cache_utils import invalidate_dashboard_cache from apps.users.cache_utils import invalidate_dashboard_cache
invalidate_dashboard_cache(homework.mentor.id, 'mentor') invalidate_dashboard_cache(homework.mentor.id, 'mentor')
# Оптимизация: используем list() для кеширования запроса
students = list(homework.assigned_to.all()) students = list(homework.assigned_to.all())
for student in students: for student in students:
invalidate_dashboard_cache(student.id, 'client') invalidate_dashboard_cache(student.id, 'client')
@ -264,7 +274,10 @@ class HomeworkViewSet(viewsets.ModelViewSet):
) )
serializer = HomeworkSerializer(homework) serializer = HomeworkSerializer(homework)
return Response(serializer.data) response_data = serializer.data
if was_fill_later:
response_data['was_fill_later'] = True
return Response(response_data)
@action(detail=True, methods=['post']) @action(detail=True, methods=['post'])
def archive(self, request, pk=None): def archive(self, request, pk=None):
@ -287,6 +300,35 @@ class HomeworkViewSet(viewsets.ModelViewSet):
serializer = HomeworkSerializer(homework) serializer = HomeworkSerializer(homework)
return Response(serializer.data) return Response(serializer.data)
@action(detail=False, methods=['get'])
def fill_later_list(self, request):
"""
Получить список отложенных ДЗ (fill_later=True) для ментора.
GET /api/homework/homeworks/fill_later_list/
Возвращает ДЗ, которые были созданы с флагом "заполнить позже"
и ожидают заполнения ментором.
"""
if request.user.role != 'mentor':
return Response(
{'error': 'Только ментор может просматривать отложенные ДЗ'},
status=status.HTTP_403_FORBIDDEN
)
queryset = Homework.objects.filter(
mentor=request.user,
fill_later=True
).select_related('mentor', 'lesson', 'lesson__client', 'lesson__client__user').prefetch_related(
'assigned_to'
).order_by('-created_at')
serializer = HomeworkListSerializer(queryset, many=True, context={'request': request})
return Response({
'count': queryset.count(),
'results': serializer.data
})
@action(detail=True, methods=['get']) @action(detail=True, methods=['get'])
def statistics(self, request, pk=None): def statistics(self, request, pk=None):
""" """

View File

@ -1,168 +1,159 @@
""" """
Сигналы для автоматической отправки уведомлений. Сигналы для автоматической отправки уведомлений.
""" """
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.html import strip_tags from django.utils.html import strip_tags
from .services import NotificationService, create_notification_preferences from .services import NotificationService, create_notification_preferences
# Сигналы пользователей # Сигналы пользователей
@receiver(post_save, sender='users.User') @receiver(post_save, sender='users.User')
def create_user_notification_preferences(sender, instance, created, **kwargs): def create_user_notification_preferences(sender, instance, created, **kwargs):
"""Создание настроек уведомлений для нового пользователя.""" """Создание настроек уведомлений для нового пользователя."""
if created: if created:
create_notification_preferences(instance) create_notification_preferences(instance)
# Сигналы занятий # Сигналы занятий
@receiver(post_save, sender='schedule.Lesson') @receiver(post_save, sender='schedule.Lesson')
def handle_lesson_notifications(sender, instance, created, **kwargs): def handle_lesson_notifications(sender, instance, created, **kwargs):
""" """
Обработка уведомлений для занятий. Обработка уведомлений для занятий.
Примечание: Уведомление о создании занятия отправляется явно в perform_create, Примечание: Уведомление о создании занятия отправляется явно в perform_create,
чтобы избежать дублирования. Здесь обрабатываем только обновления. чтобы избежать дублирования. Здесь обрабатываем только обновления.
""" """
if not created: if not created:
# Занятие обновлено - проверяем статус # Занятие обновлено - проверяем статус
# Уведомление об отмене отправляется явно в perform_destroy, # Уведомление об отмене отправляется явно в perform_destroy,
# но оставляем здесь на случай, если статус меняется другим способом # но оставляем здесь на случай, если статус меняется другим способом
if instance.status == 'cancelled' and instance.cancelled_at: if instance.status == 'cancelled' and instance.cancelled_at:
# Проверяем, не было ли уже отправлено уведомление # Проверяем, не было ли уже отправлено уведомление
# (чтобы избежать дублирования с perform_destroy) # (чтобы избежать дублирования с perform_destroy)
pass # Уведомление об отмене отправляется явно в perform_destroy pass # Уведомление об отмене отправляется явно в perform_destroy
# Сигналы уведомлений - дублирование в чат # Сигналы уведомлений - дублирование в чат
@receiver(post_save, sender='notifications.Notification') @receiver(post_save, sender='notifications.Notification')
def duplicate_notification_to_chat(sender, instance, created, **kwargs): def duplicate_notification_to_chat(sender, instance, created, **kwargs):
""" """
Дублирование системных уведомлений в чат между ментором и учеником/родителем. Дублирование системных уведомлений в чат между ментором и учеником/родителем.
Когда создается уведомление для ученика или родителя от ментора, Когда создается уведомление для ученика или родителя от ментора,
оно также создается как системное сообщение в соответствующем чате. оно также создается как системное сообщение в соответствующем чате.
""" """
if not created: if not created:
return return
# Дублируем только in_app уведомления # Дублируем только in_app уведомления
if instance.channel != 'in_app': if instance.channel != 'in_app':
return return
# Дублируем только определенные типы уведомлений # Дублируем только определенные типы уведомлений
notification_types_to_duplicate = [ notification_types_to_duplicate = [
'lesson_created', 'lesson_created',
'lesson_updated', 'lesson_updated',
'lesson_cancelled', 'lesson_cancelled',
'lesson_rescheduled', 'lesson_rescheduled',
'lesson_reminder', 'lesson_reminder',
'lesson_started', 'lesson_started',
'lesson_completed', 'lesson_completed',
'homework_assigned', 'homework_assigned',
'homework_submitted', 'homework_submitted',
'homework_reviewed', 'homework_reviewed',
'homework_returned', 'homework_returned',
'homework_deadline_reminder', 'homework_deadline_reminder',
'homework_overdue', 'homework_overdue',
'material_added', 'material_added',
'subscription_expiring', 'subscription_expiring',
'subscription_expired', 'subscription_expired',
'payment_received', 'payment_received',
'system', 'system',
] ]
if instance.notification_type not in notification_types_to_duplicate: if instance.notification_type not in notification_types_to_duplicate:
return return
try: try:
from apps.chat.models import Chat, Message, ChatParticipant from apps.chat.models import Chat, Message, ChatParticipant
from apps.users.models import User, Client, Parent from apps.chat.services import ChatService
from apps.users.models import User, Client, Parent
recipient = instance.recipient
recipient = instance.recipient
# Определяем ментора для создания чата
mentor = None # Определяем ментора для создания чата
mentor = None
# Если получатель - ученик, находим его ментора
if recipient.role == 'client': # Если получатель - ученик, находим его ментора
try: if recipient.role == 'client':
client = Client.objects.get(user=recipient) try:
mentors = client.mentors.all() client = Client.objects.get(user=recipient)
if mentors.exists(): mentors = client.mentors.all()
mentor = mentors.first() if mentors.exists():
except Client.DoesNotExist: mentor = mentors.first()
pass except Client.DoesNotExist:
pass
# Если получатель - родитель, находим ментора через детей
elif recipient.role == 'parent': # Если получатель - родитель, находим ментора через детей
try: elif recipient.role == 'parent':
parent = Parent.objects.get(user=recipient) try:
children = parent.children.all() parent = Parent.objects.get(user=recipient)
if children.exists(): children = parent.children.all()
# Берем первого ментора первого ребенка if children.exists():
child = children.first() # Берем первого ментора первого ребенка
mentors = child.mentors.all() child = children.first()
if mentors.exists(): mentors = child.mentors.all()
mentor = mentors.first() if mentors.exists():
except Parent.DoesNotExist: mentor = mentors.first()
pass except Parent.DoesNotExist:
pass
# Если получатель - ментор, находим ученика/родителя из контекста уведомления
elif recipient.role == 'mentor': # Если получатель - ментор, находим ученика/родителя из контекста уведомления
# Для уведомлений ментору нужно найти связанного ученика/родителя elif recipient.role == 'mentor':
# Это зависит от типа уведомления и content_object # Для уведомлений ментору нужно найти связанного ученика/родителя
if instance.content_object: # Это зависит от типа уведомления и content_object
content_obj = instance.content_object if instance.content_object:
# Если это занятие, берем клиента из занятия content_obj = instance.content_object
if hasattr(content_obj, 'client'): # Если это занятие, берем клиента из занятия
client = content_obj.client if hasattr(content_obj, 'client'):
if client and client.user: client = content_obj.client
recipient = client.user if client and client.user:
# Если это ДЗ, берем студента из ДЗ recipient = client.user
elif hasattr(content_obj, 'student'): # Если это ДЗ, берем студента из ДЗ
recipient = content_obj.student elif hasattr(content_obj, 'student'):
# Если это submission, берем студента recipient = content_obj.student
elif hasattr(content_obj, 'homework') and hasattr(content_obj, 'student'): # Если это submission, берем студента
recipient = content_obj.student elif hasattr(content_obj, 'homework') and hasattr(content_obj, 'student'):
else: recipient = content_obj.student
return # Не можем определить получателя else:
mentor = instance.recipient return # Не можем определить получателя
mentor = instance.recipient
if not mentor:
return if not mentor:
return
# Находим или создаем личный чат между ментором и получателем
chat = Chat.objects.filter( # Используем сервис для атомарного создания/получения чата
chat_type='direct', chat, _ = ChatService.get_or_create_direct_chat(
participants__user=mentor user1=mentor,
).filter( user2=recipient,
participants__user=recipient created_by=mentor
).first() )
if not chat: # Создаем системное сообщение в чате (без HTML-тегов, чтобы в чате не отображались теги)
# Создаем чат если его нет title_plain = strip_tags(instance.title or '')
chat = Chat.objects.create( message_plain = strip_tags(instance.message or '')
chat_type='direct', message_content = f"🔔 {title_plain}\n{message_plain}"
created_by=mentor Message.objects.create(
) chat=chat,
ChatParticipant.objects.create(chat=chat, user=mentor, role='admin') sender=None, # Системное сообщение
ChatParticipant.objects.create(chat=chat, user=recipient, role='member') message_type='system',
content=message_content
# Создаем системное сообщение в чате (без HTML-тегов, чтобы в чате не отображались теги) )
title_plain = strip_tags(instance.title or '')
message_plain = strip_tags(instance.message or '') except Exception as e:
message_content = f"🔔 {title_plain}\n{message_plain}" # Логируем ошибку, но не прерываем создание уведомления
Message.objects.create( import logging
chat=chat, logger = logging.getLogger(__name__)
sender=None, # Системное сообщение logger.error(f'Error duplicating notification to chat: {e}', exc_info=True)
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)

View File

@ -570,7 +570,14 @@ def send_lesson_notification(lesson_id, notification_type):
elif notification_type == 'lesson_cancelled': elif notification_type == 'lesson_cancelled':
NotificationService.send_lesson_cancelled(lesson) NotificationService.send_lesson_cancelled(lesson)
elif notification_type == 'lesson_reminder': elif notification_type == 'lesson_reminder':
# Проверяем что напоминание ещё не отправлено (используем флаг для 1 часа как основной)
if lesson.reminder_1h_sent:
logger.info(f'Lesson {lesson_id} reminder already sent, skipping')
return f'Lesson {lesson_id} reminder already sent'
NotificationService.send_lesson_reminder(lesson) NotificationService.send_lesson_reminder(lesson)
# Отмечаем что напоминание отправлено
lesson.reminder_1h_sent = True
lesson.save(update_fields=['reminder_1h_sent'])
elif notification_type == 'lesson_rescheduled': elif notification_type == 'lesson_rescheduled':
NotificationService.send_lesson_rescheduled(lesson) NotificationService.send_lesson_rescheduled(lesson)
elif notification_type == 'lesson_completed': elif notification_type == 'lesson_completed':

View File

@ -12,7 +12,10 @@ from .models import (
PointsTransaction, PointsTransaction,
BonusTransaction, BonusTransaction,
PromoCode, PromoCode,
PromoCodeUsage PromoCodeUsage,
ReferralInvitedEmail,
UserActivityDay,
PendingReferralBonus,
) )
@ -339,3 +342,30 @@ class PromoCodeUsageAdmin(admin.ModelAdmin):
return obj.promo_code.code return obj.promo_code.code
promo_code_code.short_description = 'Промокод' promo_code_code.short_description = 'Промокод'
@admin.register(ReferralInvitedEmail)
class ReferralInvitedEmailAdmin(admin.ModelAdmin):
"""Бэклог приглашённых email (защита от накрутки)."""
list_display = ['email', 'referrer', 'referred_user', 'created_at']
search_fields = ['email', 'referrer__email', 'referred_user__email']
readonly_fields = ['email', 'referrer', 'referred_user', 'created_at']
list_filter = ['created_at']
@admin.register(UserActivityDay)
class UserActivityDayAdmin(admin.ModelAdmin):
"""Дни активности пользователей (для проверки условий начисления бонусов)."""
list_display = ['user', 'date', 'created_at']
list_filter = ['date']
search_fields = ['user__email']
readonly_fields = ['user', 'date', 'created_at']
@admin.register(PendingReferralBonus)
class PendingReferralBonusAdmin(admin.ModelAdmin):
"""Ожидающие начисления бонусов за рефералов."""
list_display = ['referrer', 'referred_user', 'points', 'level', 'status', 'referred_at', 'paid_at']
list_filter = ['status', 'level']
search_fields = ['referrer__email', 'referred_user__email']
readonly_fields = ['referrer', 'referred_user', 'referred_at', 'points', 'level', 'reason', 'paid_at', 'created_at']

View File

@ -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}'))

View File

@ -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'),
),
]

View File

@ -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),
]

View File

@ -493,6 +493,152 @@ class BonusTransaction(models.Model):
return f"{self.user.email}: {sign}{self.amount}" return f"{self.user.email}: {sign}{self.amount}"
class ReferralInvitedEmail(models.Model):
"""
Бэклог email-адресов, которые уже были приглашены (защита от накрутки).
Один email может быть в списке только один раз.
"""
email = models.EmailField(
unique=True,
db_index=True,
verbose_name='Email приглашённого'
)
referrer = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='invited_emails',
verbose_name='Реферер'
)
referred_user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='+',
verbose_name='Приглашённый пользователь'
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name='Дата'
)
class Meta:
db_table = 'referrals_invited_emails'
verbose_name = 'Приглашённый email'
verbose_name_plural = 'Бэклог приглашённых email'
ordering = ['-created_at']
def __str__(self):
return f"{self.email} (пригласил: {self.referrer.email})"
class UserActivityDay(models.Model):
"""
Учёт дней активности пользователя на платформе (один день одна запись).
Используется для проверки «реферал был активен 20+ дней» перед начислением бонуса.
"""
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='activity_days',
verbose_name='Пользователь'
)
date = models.DateField(
verbose_name='Дата',
db_index=True
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name='Создано'
)
class Meta:
db_table = 'referrals_user_activity_days'
verbose_name = 'День активности'
verbose_name_plural = 'Дни активности'
unique_together = [['user', 'date']]
ordering = ['-date']
indexes = [
models.Index(fields=['user', 'date']),
]
def __str__(self):
return f"{self.user.email}{self.date}"
class PendingReferralBonus(models.Model):
"""
Ожидающее начисление бонуса за реферала.
Начисляется после 30 дней при условии 20+ дней активности реферала,
либо при достижении рефералом 21 дня активности (если был менее активен).
"""
STATUS_PENDING = 'pending'
STATUS_PAID = 'paid'
STATUS_CANCELLED = 'cancelled'
STATUS_CHOICES = [
(STATUS_PENDING, 'Ожидает'),
(STATUS_PAID, 'Начислено'),
(STATUS_CANCELLED, 'Отменено'),
]
referrer = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='pending_referral_bonuses',
verbose_name='Реферер'
)
referred_user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='pending_bonuses_for_me',
verbose_name='Реферал'
)
referred_at = models.DateTimeField(
verbose_name='Дата приглашения',
db_index=True
)
points = models.IntegerField(
validators=[MinValueValidator(0)],
verbose_name='Очки к начислению'
)
level = models.IntegerField(
default=1,
validators=[MinValueValidator(1), MaxValueValidator(2)],
verbose_name='Уровень (1 — прямой, 2 — непрямой)'
)
reason = models.CharField(
max_length=255,
blank=True,
verbose_name='Причина'
)
status = models.CharField(
max_length=20,
choices=STATUS_CHOICES,
default=STATUS_PENDING,
db_index=True,
verbose_name='Статус'
)
paid_at = models.DateTimeField(
null=True,
blank=True,
verbose_name='Дата начисления'
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name='Создано'
)
class Meta:
db_table = 'referrals_pending_referral_bonus'
verbose_name = 'Ожидающий бонус за реферала'
verbose_name_plural = 'Ожидающие бонусы за рефералов'
ordering = ['referred_at']
indexes = [
models.Index(fields=['status', 'referred_at']),
]
def __str__(self):
return f"{self.referrer.email} <- {self.referred_user.email}: {self.points} очков ({self.get_status_display()})"
class PromoCode(models.Model): class PromoCode(models.Model):
""" """
Промокод для скидок на подписки. Промокод для скидок на подписки.

View File

@ -6,6 +6,7 @@ from rest_framework.decorators import action
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated, AllowAny from rest_framework.permissions import IsAuthenticated, AllowAny
from django.db.models import Sum, Q, F from django.db.models import Sum, Q, F
from django.utils import timezone
from decimal import Decimal from decimal import Decimal
from .models import ( from .models import (
@ -18,6 +19,8 @@ from .models import (
BonusTransaction, BonusTransaction,
PromoCode, PromoCode,
PromoCodeUsage, PromoCodeUsage,
ReferralInvitedEmail,
PendingReferralBonus,
) )
from .serializers import ( from .serializers import (
ReferralLevelSerializer, ReferralLevelSerializer,
@ -126,27 +129,54 @@ class ReferralViewSet(viewsets.ViewSet):
status=status.HTTP_400_BAD_REQUEST status=status.HTTP_400_BAD_REQUEST
) )
# Защита от накрутки: email уже был приглашён ранее (бэклог)
email_lower = request.user.email.lower().strip()
if ReferralInvitedEmail.objects.filter(email=email_lower).exists():
return Response(
{'error': 'Этот email уже был приглашён ранее'},
status=status.HTTP_400_BAD_REQUEST
)
# Устанавливаем реферера # Устанавливаем реферера
profile.referred_by = referrer_profile.user profile.referred_by = referrer_profile.user
profile.save() profile.save()
# Обновляем статистику и начисляем очки (сигнал update_referrer_stats # Добавляем email в бэклог приглашённых
# срабатывает только при created=True, а здесь — update существующего профиля) ReferralInvitedEmail.objects.create(
email=email_lower,
referrer=referrer_profile.user,
referred_user=request.user,
)
# Обновляем счётчик рефералов (без начисления очков — очки начисляются после проверки активности)
settings_obj = ReferralSettings.get_settings() settings_obj = ReferralSettings.get_settings()
referrer_profile.direct_referrals_count += 1 referrer_profile.direct_referrals_count += 1
referrer_profile.save(update_fields=['direct_referrals_count']) referrer_profile.save(update_fields=['direct_referrals_count'])
referrer_profile.add_points(
settings_obj.points_direct_referral, # Очки начисляются отложенно: через 30 дней при 20+ днях активности реферала или при 21 дне активности
reason=f'Регистрация реферала {request.user.email}' now = timezone.now()
PendingReferralBonus.objects.create(
referrer=referrer_profile.user,
referred_user=request.user,
referred_at=now,
points=settings_obj.points_direct_referral,
level=1,
reason=f'Регистрация реферала {request.user.email}',
status=PendingReferralBonus.STATUS_PENDING,
) )
if referrer_profile.referred_by: if referrer_profile.referred_by:
try: try:
level2_profile = referrer_profile.referred_by.referral_profile level2_profile = referrer_profile.referred_by.referral_profile
level2_profile.indirect_referrals_count += 1 level2_profile.indirect_referrals_count += 1
level2_profile.save(update_fields=['indirect_referrals_count']) level2_profile.save(update_fields=['indirect_referrals_count'])
level2_profile.add_points( PendingReferralBonus.objects.create(
settings_obj.points_indirect_referral, referrer=referrer_profile.referred_by,
reason=f'Регистрация непрямого реферала {request.user.email}' referred_user=request.user,
referred_at=now,
points=settings_obj.points_indirect_referral,
level=2,
reason=f'Регистрация непрямого реферала {request.user.email}',
status=PendingReferralBonus.STATUS_PENDING,
) )
except (UserReferralProfile.DoesNotExist, AttributeError): except (UserReferralProfile.DoesNotExist, AttributeError):
pass pass

View File

@ -635,6 +635,7 @@ class LessonCalendarSerializer(serializers.Serializer):
class LessonCalendarItemSerializer(serializers.ModelSerializer): class LessonCalendarItemSerializer(serializers.ModelSerializer):
"""Лёгкий сериализатор для календаря: только поля, нужные для списка и календаря.""" """Лёгкий сериализатор для календаря: только поля, нужные для списка и календаря."""
client_name = serializers.CharField(source='client.user.get_full_name', read_only=True) client_name = serializers.CharField(source='client.user.get_full_name', read_only=True)
mentor_name = serializers.CharField(source='mentor.get_full_name', read_only=True)
subject = serializers.SerializerMethodField() subject = serializers.SerializerMethodField()
def get_subject(self, obj): def get_subject(self, obj):
@ -658,7 +659,7 @@ class LessonCalendarItemSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Lesson model = Lesson
fields = ['id', 'title', 'start_time', 'end_time', 'status', 'client', 'client_name', 'subject', 'subject_name'] fields = ['id', 'title', 'start_time', 'end_time', 'status', 'client', 'client_name', 'mentor', 'mentor_name', 'subject', 'subject_name']
class LessonHomeworkSubmissionSerializer(serializers.ModelSerializer): class LessonHomeworkSubmissionSerializer(serializers.ModelSerializer):

View File

@ -30,14 +30,8 @@ def lesson_saved(sender, instance, created, **kwargs):
lesson_id=instance.id, lesson_id=instance.id,
notification_type='lesson_created' notification_type='lesson_created'
) )
# Напоминания отправляются периодической задачей send_lesson_reminders
# Планируем напоминание за 1 час до занятия # (проверяет флаги reminder_24h_sent, reminder_1h_sent, reminder_15m_sent)
reminder_time = instance.start_time - timedelta(hours=1)
if reminder_time > timezone.now():
send_lesson_notification.apply_async(
args=[instance.id, 'lesson_reminder'],
eta=reminder_time
)
else: else:
# Занятие изменено # Занятие изменено
# Проверяем, что именно изменилось # Проверяем, что именно изменилось

View File

@ -1,448 +1,448 @@
# Celery задачи для schedule # Celery задачи для schedule
from celery import shared_task from celery import shared_task
import logging import logging
from django.utils import timezone from django.utils import timezone
from datetime import timedelta from datetime import timedelta
from django.db.models import Count from django.db.models import Count
from .models import Lesson, Subject, MentorSubject from .models import Lesson, Subject, MentorSubject
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@shared_task @shared_task
def send_lesson_reminders(): def send_lesson_reminders():
""" """
Отправка напоминаний о предстоящих занятиях. Отправка напоминаний о предстоящих занятиях.
Отправляет напоминания за: Отправляет напоминания за:
- 24 часа до занятия - 24 часа до занятия
- 1 час до занятия - 1 час до занятия
- 15 минут до занятия - 15 минут до занятия
Задача запускается каждые 15 минут через Celery Beat. Задача запускается каждые 15 минут через Celery Beat.
""" """
from apps.notifications.services import NotificationService from apps.notifications.services import NotificationService
now = timezone.now() now = timezone.now()
sent_24h = 0 sent_24h = 0
sent_1h = 0 sent_1h = 0
sent_15m = 0 sent_15m = 0
try: try:
# Находим все запланированные занятия, которые еще не начались и не отменены # Находим все запланированные занятия, которые еще не начались и не отменены
lessons = Lesson.objects.filter( lessons = Lesson.objects.filter(
start_time__gt=now, start_time__gt=now,
status='scheduled' status='scheduled'
).select_related('client', 'client__user', 'mentor') ).select_related('client', 'client__user', 'mentor')
# Напоминания за 24 часа (от 23:30 до 24:30) # Напоминания за 24 часа (от 23:30 до 24:30)
time_24h_min = now + timedelta(hours=23, minutes=30) time_24h_min = now + timedelta(hours=23, minutes=30)
time_24h_max = now + timedelta(hours=24, minutes=30) time_24h_max = now + timedelta(hours=24, minutes=30)
lessons_24h = lessons.filter( lessons_24h = lessons.filter(
start_time__gte=time_24h_min, start_time__gte=time_24h_min,
start_time__lte=time_24h_max, start_time__lte=time_24h_max,
reminder_24h_sent=False reminder_24h_sent=False
) )
# Оптимизация: используем bulk_update вместо цикла с save() # Оптимизация: используем bulk_update вместо цикла с save()
lessons_24h_list = list(lessons_24h) lessons_24h_list = list(lessons_24h)
lessons_24h_to_update = [] lessons_24h_to_update = []
for lesson in lessons_24h_list: for lesson in lessons_24h_list:
try: try:
NotificationService.send_lesson_reminder(lesson, time_before="24 часа") NotificationService.send_lesson_reminder(lesson, time_before="24 часа")
lesson.reminder_24h_sent = True lesson.reminder_24h_sent = True
lessons_24h_to_update.append(lesson) lessons_24h_to_update.append(lesson)
sent_24h += 1 sent_24h += 1
logger.info(f'Отправлено напоминание за 24 часа для занятия {lesson.id}') logger.info(f'Отправлено напоминание за 24 часа для занятия {lesson.id}')
except Exception as e: except Exception as e:
logger.error(f'Ошибка отправки напоминания за 24 часа для занятия {lesson.id}: {e}') logger.error(f'Ошибка отправки напоминания за 24 часа для занятия {lesson.id}: {e}')
if lessons_24h_to_update: if lessons_24h_to_update:
Lesson.objects.bulk_update(lessons_24h_to_update, ['reminder_24h_sent']) Lesson.objects.bulk_update(lessons_24h_to_update, ['reminder_24h_sent'])
# Напоминания за 1 час (от 50 минут до 70 минут) # Напоминания за 1 час (от 50 минут до 70 минут)
time_1h_min = now + timedelta(minutes=50) time_1h_min = now + timedelta(minutes=50)
time_1h_max = now + timedelta(minutes=70) time_1h_max = now + timedelta(minutes=70)
lessons_1h = lessons.filter( lessons_1h = lessons.filter(
start_time__gte=time_1h_min, start_time__gte=time_1h_min,
start_time__lte=time_1h_max, start_time__lte=time_1h_max,
reminder_1h_sent=False reminder_1h_sent=False
) )
# Оптимизация: используем bulk_update вместо цикла с save() # Оптимизация: используем bulk_update вместо цикла с save()
lessons_1h_list = list(lessons_1h) lessons_1h_list = list(lessons_1h)
lessons_1h_to_update = [] lessons_1h_to_update = []
for lesson in lessons_1h_list: for lesson in lessons_1h_list:
try: try:
NotificationService.send_lesson_reminder(lesson, time_before="1 час") NotificationService.send_lesson_reminder(lesson, time_before="1 час")
lesson.reminder_1h_sent = True lesson.reminder_1h_sent = True
lessons_1h_to_update.append(lesson) lessons_1h_to_update.append(lesson)
sent_1h += 1 sent_1h += 1
logger.info(f'Отправлено напоминание за 1 час для занятия {lesson.id}') logger.info(f'Отправлено напоминание за 1 час для занятия {lesson.id}')
except Exception as e: except Exception as e:
logger.error(f'Ошибка отправки напоминания за 1 час для занятия {lesson.id}: {e}') logger.error(f'Ошибка отправки напоминания за 1 час для занятия {lesson.id}: {e}')
if lessons_1h_to_update: if lessons_1h_to_update:
Lesson.objects.bulk_update(lessons_1h_to_update, ['reminder_1h_sent']) Lesson.objects.bulk_update(lessons_1h_to_update, ['reminder_1h_sent'])
# Напоминания за 15 минут (от 10 минут до 20 минут) # Напоминания за 15 минут (от 10 минут до 20 минут)
time_15m_min = now + timedelta(minutes=10) time_15m_min = now + timedelta(minutes=10)
time_15m_max = now + timedelta(minutes=20) time_15m_max = now + timedelta(minutes=20)
lessons_15m = lessons.filter( lessons_15m = lessons.filter(
start_time__gte=time_15m_min, start_time__gte=time_15m_min,
start_time__lte=time_15m_max, start_time__lte=time_15m_max,
reminder_15m_sent=False reminder_15m_sent=False
) )
# Оптимизация: используем bulk_update вместо цикла с save() # Оптимизация: используем bulk_update вместо цикла с save()
lessons_15m_list = list(lessons_15m) lessons_15m_list = list(lessons_15m)
lessons_15m_to_update = [] lessons_15m_to_update = []
for lesson in lessons_15m_list: for lesson in lessons_15m_list:
try: try:
NotificationService.send_lesson_reminder(lesson, time_before="15 минут") NotificationService.send_lesson_reminder(lesson, time_before="15 минут")
lesson.reminder_15m_sent = True lesson.reminder_15m_sent = True
lessons_15m_to_update.append(lesson) lessons_15m_to_update.append(lesson)
sent_15m += 1 sent_15m += 1
logger.info(f'Отправлено напоминание за 15 минут для занятия {lesson.id}') logger.info(f'Отправлено напоминание за 15 минут для занятия {lesson.id}')
except Exception as e: except Exception as e:
logger.error(f'Ошибка отправки напоминания за 15 минут для занятия {lesson.id}: {e}') logger.error(f'Ошибка отправки напоминания за 15 минут для занятия {lesson.id}: {e}')
if lessons_15m_to_update: if lessons_15m_to_update:
Lesson.objects.bulk_update(lessons_15m_to_update, ['reminder_15m_sent']) Lesson.objects.bulk_update(lessons_15m_to_update, ['reminder_15m_sent'])
total_sent = sent_24h + sent_1h + sent_15m total_sent = sent_24h + sent_1h + sent_15m
logger.info( logger.info(
f'[send_lesson_reminders] Отправлено напоминаний: ' f'[send_lesson_reminders] Отправлено напоминаний: '
f'24ч - {sent_24h}, 1ч - {sent_1h}, 15м - {sent_15m} (всего: {total_sent})' f'24ч - {sent_24h}, 1ч - {sent_1h}, 15м - {sent_15m} (всего: {total_sent})'
) )
return f'Отправлено: 24ч - {sent_24h}, 1ч - {sent_1h}, 15м - {sent_15m}' return f'Отправлено: 24ч - {sent_24h}, 1ч - {sent_1h}, 15м - {sent_15m}'
except Exception as e: except Exception as e:
logger.error(f'[send_lesson_reminders] Ошибка: {str(e)}', exc_info=True) logger.error(f'[send_lesson_reminders] Ошибка: {str(e)}', exc_info=True)
raise raise
@shared_task @shared_task
def send_attendance_confirmation_requests(): def send_attendance_confirmation_requests():
""" """
Отправка запросов о подтверждении присутствия за 3 часа до занятия. Отправка запросов о подтверждении присутствия за 3 часа до занятия.
Проверяет все занятия, которые начинаются через 3 часа или меньше, Проверяет все занятия, которые начинаются через 3 часа или меньше,
и отправляет запрос студенту, если еще не отправлен. и отправляет запрос студенту, если еще не отправлен.
""" """
from django.utils import timezone from django.utils import timezone
from datetime import timedelta from datetime import timedelta
from apps.notifications.services import NotificationService from apps.notifications.services import NotificationService
now = timezone.now() now = timezone.now()
# Занятия, которые начинаются через 3 часа или меньше # Занятия, которые начинаются через 3 часа или меньше
time_threshold = now + timedelta(hours=3) time_threshold = now + timedelta(hours=3)
# Находим занятия, которые: # Находим занятия, которые:
# 1. Еще не начались (start_time > now) # 1. Еще не начались (start_time > now)
# 2. Начинаются через 3 часа или меньше (start_time <= time_threshold) # 2. Начинаются через 3 часа или меньше (start_time <= time_threshold)
# 3. Еще не отменены # 3. Еще не отменены
# 4. Запрос о присутствии еще не отправлен # 4. Запрос о присутствии еще не отправлен
lessons = Lesson.objects.filter( lessons = Lesson.objects.filter(
start_time__gt=now, start_time__gt=now,
start_time__lte=time_threshold, start_time__lte=time_threshold,
status='scheduled', status='scheduled',
attendance_confirmation_sent=False attendance_confirmation_sent=False
).select_related('client', 'client__user', 'mentor') ).select_related('client', 'client__user', 'mentor')
sent_count = 0 sent_count = 0
lessons_to_update = [] lessons_to_update = []
for lesson in lessons: for lesson in lessons:
try: try:
# Отправляем запрос # Отправляем запрос
NotificationService.send_attendance_confirmation_request(lesson) NotificationService.send_attendance_confirmation_request(lesson)
# Отмечаем что запрос отправлен (накапливаем для bulk_update) # Отмечаем что запрос отправлен (накапливаем для bulk_update)
lesson.attendance_confirmation_sent = True lesson.attendance_confirmation_sent = True
lessons_to_update.append(lesson) lessons_to_update.append(lesson)
sent_count += 1 sent_count += 1
logger.info(f'Отправлен запрос о присутствии для занятия {lesson.id}') logger.info(f'Отправлен запрос о присутствии для занятия {lesson.id}')
except Exception as e: except Exception as e:
logger.error(f'Ошибка отправки запроса о присутствии для занятия {lesson.id}: {e}') logger.error(f'Ошибка отправки запроса о присутствии для занятия {lesson.id}: {e}')
# Оптимизация: используем bulk_update вместо цикла с save() # Оптимизация: используем bulk_update вместо цикла с save()
if lessons_to_update: if lessons_to_update:
Lesson.objects.bulk_update(lessons_to_update, ['attendance_confirmation_sent'], batch_size=100) Lesson.objects.bulk_update(lessons_to_update, ['attendance_confirmation_sent'], batch_size=100)
logger.info(f'[send_attendance_confirmation_requests] Отправлено {sent_count} запросов о присутствии') logger.info(f'[send_attendance_confirmation_requests] Отправлено {sent_count} запросов о присутствии')
return f'Отправлено {sent_count} запросов' return f'Отправлено {sent_count} запросов'
@shared_task @shared_task
def maintain_recurring_lessons(): def maintain_recurring_lessons():
""" """
Поддержание 12 будущих занятий для повторяющихся занятий. Поддержание 12 будущих занятий для повторяющихся занятий.
Задача проверяет все повторяющиеся занятия и добавляет недостающие, Задача проверяет все повторяющиеся занятия и добавляет недостающие,
чтобы всегда было 12 будущих занятий впереди. чтобы всегда было 12 будущих занятий впереди.
Запускается каждый день через Celery Beat. Запускается каждый день через Celery Beat.
""" """
now = timezone.now() now = timezone.now()
added_count = 0 added_count = 0
try: try:
# Находим все уникальные серии повторяющихся занятий # Находим все уникальные серии повторяющихся занятий
recurring_series = Lesson.objects.filter( recurring_series = Lesson.objects.filter(
is_recurring=True, is_recurring=True,
recurring_series_id__isnull=False recurring_series_id__isnull=False
).values_list('recurring_series_id', flat=True).distinct() ).values_list('recurring_series_id', flat=True).distinct()
for series_id in recurring_series: for series_id in recurring_series:
# Находим все занятия этой серии, которые еще не прошли # Находим все занятия этой серии, которые еще не прошли
series_lessons = Lesson.objects.filter( series_lessons = Lesson.objects.filter(
recurring_series_id=series_id, recurring_series_id=series_id,
start_time__gt=now # Только будущие занятия start_time__gt=now # Только будущие занятия
).order_by('start_time') ).order_by('start_time')
if not series_lessons.exists(): if not series_lessons.exists():
# Если нет будущих занятий, пропускаем эту серию # Если нет будущих занятий, пропускаем эту серию
continue continue
# Находим последнее занятие в серии (самое дальнее по времени) # Находим последнее занятие в серии (самое дальнее по времени)
last_lesson = series_lessons.last() last_lesson = series_lessons.last()
# Подсчитываем, сколько будущих занятий есть # Подсчитываем, сколько будущих занятий есть
future_count = series_lessons.count() future_count = series_lessons.count()
# Если меньше 12, добавляем недостающие # Если меньше 12, добавляем недостающие
if future_count < 12: if future_count < 12:
# Находим первое занятие серии для получения шаблона # Находим первое занятие серии для получения шаблона
first_lesson = Lesson.objects.filter( first_lesson = Lesson.objects.filter(
recurring_series_id=series_id, recurring_series_id=series_id,
parent_lesson__isnull=True # Родительское занятие parent_lesson__isnull=True # Родительское занятие
).first() ).first()
if not first_lesson: if not first_lesson:
# Если нет родительского, берем первое занятие серии # Если нет родительского, берем первое занятие серии
first_lesson = Lesson.objects.filter( first_lesson = Lesson.objects.filter(
recurring_series_id=series_id recurring_series_id=series_id
).order_by('start_time').first() ).order_by('start_time').first()
if not first_lesson: if not first_lesson:
continue continue
# Получаем время начала и окончания последнего занятия # Получаем время начала и окончания последнего занятия
last_start_time = last_lesson.start_time last_start_time = last_lesson.start_time
last_end_time = last_lesson.end_time last_end_time = last_lesson.end_time
duration_minutes = last_lesson.duration duration_minutes = last_lesson.duration
# Вычисляем, сколько занятий нужно добавить # Вычисляем, сколько занятий нужно добавить
lessons_to_add = 12 - future_count lessons_to_add = 12 - future_count
# Создаем недостающие занятия # Создаем недостающие занятия
new_lessons = [] new_lessons = []
for i in range(1, lessons_to_add + 1): for i in range(1, lessons_to_add + 1):
# Каждое следующее занятие через неделю после предыдущего # Каждое следующее занятие через неделю после предыдущего
new_start_time = last_start_time + timedelta(weeks=i) new_start_time = last_start_time + timedelta(weeks=i)
new_end_time = new_start_time + timedelta(minutes=duration_minutes) new_end_time = new_start_time + timedelta(minutes=duration_minutes)
lesson_data = { lesson_data = {
'mentor': first_lesson.mentor, 'mentor': first_lesson.mentor,
'client': first_lesson.client, 'client': first_lesson.client,
'group': first_lesson.group, 'group': first_lesson.group,
'start_time': new_start_time, 'start_time': new_start_time,
'end_time': new_end_time, 'end_time': new_end_time,
'duration': duration_minutes, 'duration': duration_minutes,
'title': first_lesson.title, 'title': first_lesson.title,
'description': first_lesson.description or '', 'description': first_lesson.description or '',
'subject': first_lesson.subject, 'subject': first_lesson.subject,
'mentor_subject': first_lesson.mentor_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 ''), '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, 'template': first_lesson.template,
'price': first_lesson.price, 'price': first_lesson.price,
'is_recurring': True, 'is_recurring': True,
'recurring_series_id': series_id, 'recurring_series_id': series_id,
'parent_lesson': first_lesson if first_lesson.parent_lesson is None else first_lesson.parent_lesson, 'parent_lesson': first_lesson if first_lesson.parent_lesson is None else first_lesson.parent_lesson,
} }
new_lessons.append(Lesson(**lesson_data)) new_lessons.append(Lesson(**lesson_data))
# Массовое создание для оптимизации # Массовое создание для оптимизации
if new_lessons: if new_lessons:
Lesson.objects.bulk_create(new_lessons) Lesson.objects.bulk_create(new_lessons)
added_count += len(new_lessons) added_count += len(new_lessons)
logger.info( logger.info(
f'Добавлено {len(new_lessons)} занятий для серии {series_id}. ' f'Добавлено {len(new_lessons)} занятий для серии {series_id}. '
f'Теперь будущих занятий: {future_count + len(new_lessons)}' f'Теперь будущих занятий: {future_count + len(new_lessons)}'
) )
logger.info(f'[maintain_recurring_lessons] Добавлено {added_count} занятий для поддержания 12 будущих занятий') logger.info(f'[maintain_recurring_lessons] Добавлено {added_count} занятий для поддержания 12 будущих занятий')
return f'Добавлено {added_count} занятий' return f'Добавлено {added_count} занятий'
except Exception as e: except Exception as e:
logger.error(f'[maintain_recurring_lessons] Ошибка: {str(e)}', exc_info=True) logger.error(f'[maintain_recurring_lessons] Ошибка: {str(e)}', exc_info=True)
raise raise
@shared_task @shared_task
def promote_mentor_subjects_to_subjects(): def promote_mentor_subjects_to_subjects():
""" """
Переносит кастомные предметы менторов в общую модель Subject, Переносит кастомные предметы менторов в общую модель Subject,
если предмет используется более чем 10 менторами. если предмет используется более чем 10 менторами.
Запускается периодически через Celery Beat (например, раз в день). Запускается периодически через Celery Beat (например, раз в день).
""" """
promoted_count = 0 promoted_count = 0
try: try:
# Находим все уникальные названия кастомных предметов # Находим все уникальные названия кастомных предметов
# и подсчитываем количество менторов, использующих каждый предмет # и подсчитываем количество менторов, использующих каждый предмет
from django.db.models import Count from django.db.models import Count
mentor_subjects_stats = MentorSubject.objects.values('name').annotate( mentor_subjects_stats = MentorSubject.objects.values('name').annotate(
mentor_count=Count('mentor', distinct=True) mentor_count=Count('mentor', distinct=True)
).filter(mentor_count__gte=10) # Используется 10+ менторами ).filter(mentor_count__gte=10) # Используется 10+ менторами
for stat in mentor_subjects_stats: for stat in mentor_subjects_stats:
subject_name = stat['name'] subject_name = stat['name']
mentor_count = stat['mentor_count'] mentor_count = stat['mentor_count']
# Проверяем, существует ли уже такой предмет в Subject # Проверяем, существует ли уже такой предмет в Subject
existing_subject = Subject.objects.filter(name__iexact=subject_name).first() existing_subject = Subject.objects.filter(name__iexact=subject_name).first()
if existing_subject: if existing_subject:
# Если предмет уже существует, просто активируем его # Если предмет уже существует, просто активируем его
if not existing_subject.is_active: if not existing_subject.is_active:
existing_subject.is_active = True existing_subject.is_active = True
existing_subject.save() existing_subject.save()
logger.info(f'Активирован существующий предмет: {subject_name}') logger.info(f'Активирован существующий предмет: {subject_name}')
else: else:
# Создаем новый предмет в Subject # Создаем новый предмет в Subject
new_subject = Subject.objects.create( new_subject = Subject.objects.create(
name=subject_name, name=subject_name,
is_active=True is_active=True
) )
logger.info(f'Создан новый предмет в общей модели: {subject_name} (используется {mentor_count} менторами)') logger.info(f'Создан новый предмет в общей модели: {subject_name} (используется {mentor_count} менторами)')
# Обновляем все занятия, использующие этот кастомный предмет # Обновляем все занятия, использующие этот кастомный предмет
# Заменяем mentor_subject на subject # Заменяем mentor_subject на subject
mentor_subjects = MentorSubject.objects.filter(name__iexact=subject_name) mentor_subjects = MentorSubject.objects.filter(name__iexact=subject_name)
for mentor_subject in mentor_subjects: for mentor_subject in mentor_subjects:
# Находим или создаем Subject # Находим или создаем Subject
subject = Subject.objects.filter(name__iexact=subject_name).first() subject = Subject.objects.filter(name__iexact=subject_name).first()
if not subject: if not subject:
subject = Subject.objects.create(name=subject_name, is_active=True) subject = Subject.objects.create(name=subject_name, is_active=True)
# Обновляем занятия # Обновляем занятия
updated_lessons = Lesson.objects.filter(mentor_subject=mentor_subject).update( updated_lessons = Lesson.objects.filter(mentor_subject=mentor_subject).update(
subject=subject, subject=subject,
mentor_subject=None, mentor_subject=None,
subject_name=subject.name subject_name=subject.name
) )
# Обновляем шаблоны # Обновляем шаблоны
from .models import LessonTemplate from .models import LessonTemplate
LessonTemplate.objects.filter(mentor_subject=mentor_subject).update( LessonTemplate.objects.filter(mentor_subject=mentor_subject).update(
subject=subject, subject=subject,
mentor_subject=None, mentor_subject=None,
subject_name=subject.name subject_name=subject.name
) )
if updated_lessons > 0: if updated_lessons > 0:
logger.info( logger.info(
f'Обновлено {updated_lessons} занятий и шаблонов для предмета "{subject_name}" ' f'Обновлено {updated_lessons} занятий и шаблонов для предмета "{subject_name}" '
f'(ментор: {mentor_subject.mentor.get_full_name()})' f'(ментор: {mentor_subject.mentor.get_full_name()})'
) )
# Удаляем кастомные предметы, которые были перенесены # Удаляем кастомные предметы, которые были перенесены
deleted_count = mentor_subjects.delete()[0] deleted_count = mentor_subjects.delete()[0]
if deleted_count > 0: if deleted_count > 0:
logger.info(f'Удалено {deleted_count} кастомных предметов "{subject_name}" после переноса в общую модель') logger.info(f'Удалено {deleted_count} кастомных предметов "{subject_name}" после переноса в общую модель')
promoted_count += deleted_count promoted_count += deleted_count
logger.info(f'[promote_mentor_subjects_to_subjects] Перенесено {promoted_count} кастомных предметов в общую модель') logger.info(f'[promote_mentor_subjects_to_subjects] Перенесено {promoted_count} кастомных предметов в общую модель')
return f'Перенесено {promoted_count} предметов' return f'Перенесено {promoted_count} предметов'
except Exception as e: except Exception as e:
logger.error(f'[promote_mentor_subjects_to_subjects] Ошибка: {str(e)}', exc_info=True) logger.error(f'[promote_mentor_subjects_to_subjects] Ошибка: {str(e)}', exc_info=True)
raise raise
@shared_task(name='apps.schedule.tasks.start_lessons_automatically') @shared_task(name='apps.schedule.tasks.start_lessons_automatically')
def start_lessons_automatically(): def start_lessons_automatically():
""" """
Автоматическое начало и завершение занятий по времени. Автоматическое начало и завершение занятий по времени.
Обновляет статус занятий: Обновляет статус занятий:
- 'scheduled' -> 'in_progress' когда наступает время начала (start_time <= now) - 'scheduled' -> 'in_progress' когда наступает время начала (start_time <= now)
- 'scheduled' или 'in_progress' -> 'completed' когда время окончания прошло (end_time < now) - 'scheduled' или 'in_progress' -> 'completed' когда время окончания прошло (end_time < now)
Запускается каждую минуту через Celery Beat. Запускается каждую минуту через Celery Beat.
""" """
now = timezone.now() now = timezone.now()
started_count = 0 started_count = 0
completed_count = 0 completed_count = 0
try: try:
# Находим все запланированные занятия, которые должны начаться # Находим все запланированные занятия, которые должны начаться
# start_time <= now (время начала уже наступило) # start_time <= now (время начала уже наступило)
# end_time >= now (время окончания еще не наступило) # end_time >= now (время окончания еще не наступило)
# status = 'scheduled' (еще не начались) # status = 'scheduled' (еще не начались)
lessons_to_start = Lesson.objects.filter( lessons_to_start = Lesson.objects.filter(
status='scheduled', status='scheduled',
start_time__lte=now, start_time__lte=now,
end_time__gte=now end_time__gte=now
).select_related('mentor', 'client') ).select_related('mentor', 'client')
# Оптимизация: используем bulk_update вместо цикла с save() # Оптимизация: используем bulk_update вместо цикла с save()
lessons_to_start_list = list(lessons_to_start) lessons_to_start_list = list(lessons_to_start)
for lesson in lessons_to_start_list: for lesson in lessons_to_start_list:
lesson.status = 'in_progress' lesson.status = 'in_progress'
if lessons_to_start_list: if lessons_to_start_list:
Lesson.objects.bulk_update(lessons_to_start_list, ['status']) Lesson.objects.bulk_update(lessons_to_start_list, ['status'])
started_count = len(lessons_to_start_list) started_count = len(lessons_to_start_list)
for lesson in lessons_to_start_list: for lesson in lessons_to_start_list:
logger.info(f'Занятие {lesson.id} автоматически переведено в статус "in_progress"') logger.info(f'Занятие {lesson.id} автоматически переведено в статус "in_progress"')
# Находим занятия, которые уже прошли и должны быть завершены # Находим занятия, которые уже прошли и должны быть завершены
# end_time < now - 5 минут (время окончания прошло более 5 минут назад - даём время на завершение) # end_time < now - 5 минут (время окончания прошло более 5 минут назад - даём время на завершение)
# status in ['scheduled', 'in_progress'] (еще не завершены) # status in ['scheduled', 'in_progress'] (еще не завершены)
five_minutes_ago = now - timedelta(minutes=5) five_minutes_ago = now - timedelta(minutes=5)
lessons_to_complete = Lesson.objects.filter( lessons_to_complete = Lesson.objects.filter(
status__in=['scheduled', 'in_progress'], status__in=['scheduled', 'in_progress'],
end_time__lt=five_minutes_ago end_time__lt=five_minutes_ago
).select_related('mentor', 'client') ).select_related('mentor', 'client')
# Оптимизация: используем bulk_update вместо цикла с save() # Оптимизация: используем bulk_update вместо цикла с save()
lessons_to_complete_list = list(lessons_to_complete) lessons_to_complete_list = list(lessons_to_complete)
for lesson in lessons_to_complete_list: for lesson in lessons_to_complete_list:
lesson.status = 'completed' lesson.status = 'completed'
lesson.completed_at = now lesson.completed_at = now
if lessons_to_complete_list: if lessons_to_complete_list:
Lesson.objects.bulk_update(lessons_to_complete_list, ['status', 'completed_at']) Lesson.objects.bulk_update(lessons_to_complete_list, ['status', 'completed_at'])
completed_count = len(lessons_to_complete_list) completed_count = len(lessons_to_complete_list)
for lesson in lessons_to_complete_list: for lesson in lessons_to_complete_list:
logger.info(f'Занятие {lesson.id} автоматически переведено в статус "completed" (время окончания прошло)') logger.info(f'Занятие {lesson.id} автоматически переведено в статус "completed" (время окончания прошло)')
# Закрываем LiveKit комнату, если она есть # Закрываем LiveKit комнату, если она есть
try: try:
from apps.video.models import VideoRoom from apps.video.models import VideoRoom
from apps.video.services import get_sfu_client, SFUClientError from apps.video.services import get_sfu_client, SFUClientError
video_room = VideoRoom.objects.filter(lesson=lesson).first() video_room = VideoRoom.objects.filter(lesson=lesson).first()
if video_room and video_room.room_id: if video_room and video_room.room_id:
sfu_client = get_sfu_client() sfu_client = get_sfu_client()
try: try:
sfu_client.delete_room(str(video_room.room_id)) sfu_client.delete_room(str(video_room.room_id))
logger.info(f'LiveKit комната {video_room.room_id} закрыта для урока {lesson.id}') logger.info(f'LiveKit комната {video_room.room_id} закрыта для урока {lesson.id}')
except SFUClientError as e: except SFUClientError as e:
logger.warning(f'Не удалось закрыть LiveKit комнату {video_room.room_id} для урока {lesson.id}: {e}') logger.warning(f'Не удалось закрыть LiveKit комнату {video_room.room_id} для урока {lesson.id}: {e}')
except Exception as e: except Exception as e:
logger.error(f'Ошибка закрытия LiveKit комнаты для урока {lesson.id}: {str(e)}', exc_info=True) logger.error(f'Ошибка закрытия LiveKit комнаты для урока {lesson.id}: {str(e)}', exc_info=True)
if started_count > 0 or completed_count > 0: if started_count > 0 or completed_count > 0:
logger.info(f'[start_lessons_automatically] Начато: {started_count}, Завершено: {completed_count}') logger.info(f'[start_lessons_automatically] Начато: {started_count}, Завершено: {completed_count}')
return f'Начато {started_count}, Завершено {completed_count}' return f'Начато {started_count}, Завершено {completed_count}'
except Exception as e: except Exception as e:
logger.error(f'[start_lessons_automatically] Ошибка: {str(e)}', exc_info=True) logger.error(f'[start_lessons_automatically] Ошибка: {str(e)}', exc_info=True)
raise raise

View File

@ -86,12 +86,9 @@ class LessonViewSet(viewsets.ModelViewSet):
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass
# Клиенты видят свои занятия # Студенты (клиенты) видят ТОЛЬКО свои занятия — не расписание ментора
elif user.role == 'client': elif getattr(user, 'role', None) == 'client':
try: queryset = queryset.filter(client__user_id=user.id)
queryset = queryset.filter(client=user.client_profile)
except:
queryset = Lesson.objects.none()
# Родители видят занятия своих детей # Родители видят занятия своих детей
elif user.role == 'parent': elif user.role == 'parent':
@ -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 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) has_homework_files_param = request.data.get('has_homework_files') in (True, 'true', 1)
request_has_files = has_homework_files_param or request_has_file_ids request_has_files = has_homework_files_param or request_has_file_ids
# Проверяем флаг "заполнить позже"
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 homework_id = None
if has_homework: if has_homework:
# Есть текст ДЗ или файлы создаём или обновляем опубликованное задание
title = lesson.title or 'Домашнее задание' title = lesson.title or 'Домашнее задание'
description = (lesson.homework_text or '').strip() or '' # описание не обязательно description = (lesson.homework_text or '').strip() or ''
# Определяем статус и fill_later флаг
if homework_fill_later:
hw_status = 'draft'
hw_fill_later = True
else:
hw_status = 'published'
hw_fill_later = False
if existing_hw: if existing_hw:
existing_hw.title = title existing_hw.title = title
existing_hw.description = description existing_hw.description = description
existing_hw.status = 'published' existing_hw.status = hw_status
existing_hw.fill_later = hw_fill_later
existing_hw.lesson = lesson existing_hw.lesson = lesson
existing_hw.save() existing_hw.save()
homework_obj = existing_hw homework_obj = existing_hw
@ -640,7 +648,8 @@ class LessonViewSet(viewsets.ModelViewSet):
description=description, description=description,
mentor=lesson.mentor, mentor=lesson.mentor,
lesson=lesson, lesson=lesson,
status='published', status=hw_status,
fill_later=hw_fill_later,
) )
homework_id = homework_obj.id homework_id = homework_obj.id
@ -652,8 +661,10 @@ class LessonViewSet(viewsets.ModelViewSet):
client_user = lesson_with_client.client.user client_user = lesson_with_client.client.user
if client_user: if client_user:
homework_obj.assigned_to.add(client_user) homework_obj.assigned_to.add(client_user)
from apps.notifications.services import NotificationService # Уведомление отправляем ТОЛЬКО если ДЗ опубликовано (не fill_later)
NotificationService.send_homework_notification(homework_obj, 'homework_assigned') if not hw_fill_later:
from apps.notifications.services import NotificationService
NotificationService.send_homework_notification(homework_obj, 'homework_assigned')
# Синхронизируем прикрепленные к уроку материалы с файлами ДЗ # Синхронизируем прикрепленные к уроку материалы с файлами ДЗ
lesson_file_ids = None 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 GET /api/schedule/lessons/calendar/?start_date=2024-01-01&end_date=2024-01-31
Студент видит только свои занятия. Ментор свои. Родитель занятия детей.
""" """
serializer = LessonCalendarSerializer(data=request.query_params) serializer = LessonCalendarSerializer(data=request.query_params)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
@ -894,6 +907,9 @@ class LessonViewSet(viewsets.ModelViewSet):
start_time__date__gte=data['start_date'], start_time__date__gte=data['start_date'],
start_time__date__lte=data['end_date'] start_time__date__lte=data['end_date']
) )
# Доп. защита: студент никогда не должен видеть чужие занятия
if getattr(request.user, 'role', None) == 'client':
queryset = queryset.filter(client__user_id=request.user.id)
if data.get('status'): if data.get('status'):
queryset = queryset.filter(status=data['status']) queryset = queryset.filter(status=data['status'])
lessons = LessonCalendarItemSerializer( lessons = LessonCalendarItemSerializer(
@ -1434,7 +1450,8 @@ class LessonHomeworkSubmissionViewSet(viewsets.ModelViewSet):
NotificationService.send_homework_notification( NotificationService.send_homework_notification(
homework, homework,
'homework_reviewed', 'homework_reviewed',
student=submission.student student=submission.student,
submission=submission
) )
except Homework.DoesNotExist: except Homework.DoesNotExist:
pass pass

View File

@ -3,6 +3,7 @@
""" """
import random import random
import string import string
import secrets
from django.contrib.auth.models import AbstractUser, BaseUserManager from django.contrib.auth.models import AbstractUser, BaseUserManager
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -314,31 +315,37 @@ class User(AbstractUser):
alphabet = string.ascii_uppercase + string.digits alphabet = string.ascii_uppercase + string.digits
for _ in range(100): for _ in range(100):
code = ''.join(random.choices(alphabet, k=8)) code = ''.join(random.choices(alphabet, k=8))
# Проверяем уникальность кода
if not User.objects.filter(universal_code=code).exclude(pk=self.pk).exists(): if not User.objects.filter(universal_code=code).exclude(pk=self.pk).exists():
return code return code
raise ValueError('Не удалось сгенерировать уникальный universal_code') # Если не удалось сгенерировать за 100 попыток, используем более длинный fallback
return secrets.token_hex(4).upper()
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
# 1. Нормализация телефона
if self.phone: if self.phone:
self.phone = normalize_phone(self.phone) self.phone = normalize_phone(self.phone)
# Автоматическая генерация username из email, если не задан # 2. Генерация username из email
if not self.username and self.email: if not self.username and self.email:
self.username = self.email.split('@')[0] self.username = self.email.split('@')[0]
# Добавляем цифры, если username уже существует
counter = 1 counter = 1
original_username = self.username original_username = self.username
while User.objects.filter(username=self.username).exclude(pk=self.pk).exists(): while User.objects.filter(username=self.username).exclude(pk=self.pk).exists():
self.username = f"{original_username}{counter}" self.username = f"{original_username}{counter}"
counter += 1 counter += 1
if kwargs.get('update_fields') is not None:
fields = set(kwargs['update_fields'])
fields.add('username')
kwargs['update_fields'] = list(fields)
# Гарантируем 8-символьный код (universal_code) # 3. Гарантируем 8-символьный код (universal_code)
if not self.universal_code: if not self.universal_code or len(str(self.universal_code).strip()) != 8:
try: self.universal_code = self._generate_universal_code()
self.universal_code = self._generate_universal_code() if kwargs.get('update_fields') is not None:
except Exception: fields = set(kwargs['update_fields'])
# Если не удалось сгенерировать, не прерываем сохранение fields.add('universal_code')
pass kwargs['update_fields'] = list(fields)
super().save(*args, **kwargs) super().save(*args, **kwargs)

View File

@ -68,13 +68,10 @@ class ProfileViewSet(viewsets.ViewSet):
GET /api/users/profile/me/ GET /api/users/profile/me/
""" """
user = request.user user = request.user
# Убедиться, что у пользователя есть 8-символьный код (для старых пользователей) # User.save() автоматически создаст universal_code, если он отсутствует
if not user.universal_code or len(user.universal_code) != 8: if not user.universal_code or len(str(user.universal_code).strip()) != 8:
try: user.save(update_fields=['universal_code'])
user.universal_code = user._generate_universal_code()
user.save(update_fields=['universal_code'])
except Exception:
pass
serializer = UserSerializer(user, context={'request': request}) serializer = UserSerializer(user, context={'request': request})
# Добавляем дополнительную информацию # Добавляем дополнительную информацию
@ -381,13 +378,6 @@ class ProfileViewSet(viewsets.ViewSet):
user = request.user user = request.user
# 8-символьный код: если нет — генерируем при обновлении профиля
if not user.universal_code or len(user.universal_code) != 8:
try:
user.universal_code = user._generate_universal_code()
except Exception:
pass
# Обработка удаления аватара # Обработка удаления аватара
if 'avatar' in request.data: if 'avatar' in request.data:
avatar_value = request.data.get('avatar') avatar_value = request.data.get('avatar')
@ -1505,27 +1495,6 @@ class InvitationViewSet(viewsets.ViewSet):
city=city city=city
) )
# Гарантируем 8-символьный код для приглашений (ментор/студент)
if not student_user.universal_code or len(str(student_user.universal_code or '').strip()) != 8:
try:
# Теперь метод _generate_universal_code определен в базовой модели User
student_user.universal_code = student_user._generate_universal_code()
student_user.save(update_fields=['universal_code'])
except Exception:
# Fallback на случай ошибок генерации
import string
import random
try:
alphabet = string.ascii_uppercase + string.digits
for _ in range(500):
code = ''.join(random.choices(alphabet, k=8))
if not User.objects.filter(universal_code=code).exclude(pk=student_user.pk).exists():
student_user.universal_code = code
student_user.save(update_fields=['universal_code'])
break
except Exception:
pass
# Генерируем персональный токен для входа # Генерируем персональный токен для входа
student_user.login_token = secrets.token_urlsafe(32) student_user.login_token = secrets.token_urlsafe(32)
student_user.save(update_fields=['login_token']) student_user.save(update_fields=['login_token'])

View File

@ -156,15 +156,6 @@ class RegisterSerializer(serializers.ModelSerializer):
**validated_data **validated_data
) )
# Гарантированно задаём 8-символьный код при создании
if not user.universal_code or len(str(user.universal_code or '').strip()) != 8:
try:
user.universal_code = user._generate_universal_code()
user.save(update_fields=['universal_code'])
except Exception:
# Если не удалось, код будет сгенерирован в RegisterView или при запросе профиля
pass
# Создаем профиль в зависимости от роли # Создаем профиль в зависимости от роли
if user.role == 'client': if user.role == 'client':
Client.objects.create(user=user) Client.objects.create(user=user)

View File

@ -126,25 +126,6 @@ class TelegramAuthView(generics.GenericAPIView):
user.set_unusable_password() user.set_unusable_password()
user.save() user.save()
# Гарантируем 8-символьный код для приглашений
if not user.universal_code or len(str(user.universal_code or '').strip()) != 8:
try:
user.universal_code = user._generate_universal_code()
user.save(update_fields=['universal_code'])
except Exception as e:
logger.warning(f'Ошибка генерации universal_code для Telegram пользователя {user.id}: {e}')
# Пробуем ещё раз
try:
alphabet = string.ascii_uppercase + string.digits
for _ in range(500):
code = ''.join(random.choices(alphabet, k=8))
if not User.objects.filter(universal_code=code).exclude(pk=user.pk).exists():
user.universal_code = code
user.save(update_fields=['universal_code'])
break
except Exception:
pass # Код будет сгенерирован при следующем запросе профиля
is_new_user = True is_new_user = True
message = 'Регистрация через Telegram выполнена успешно' message = 'Регистрация через Telegram выполнена успешно'
@ -182,31 +163,6 @@ class RegisterView(generics.CreateAPIView):
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
user = serializer.save() user = serializer.save()
# Всегда задаём 8-символьный код при регистрации (для приглашений ментор/студент)
logger = logging.getLogger(__name__)
need_code = not user.universal_code or len(str(user.universal_code or '').strip()) != 8
if need_code:
try:
user.universal_code = user._generate_universal_code()
user.save(update_fields=['universal_code'])
except Exception as e:
# Если не удалось сгенерировать код, пробуем ещё раз с большим количеством попыток
logger.warning(f'Ошибка генерации universal_code для пользователя {user.id}: {e}, пробуем ещё раз')
try:
alphabet = string.ascii_uppercase + string.digits
for _ in range(500):
code = ''.join(random.choices(alphabet, k=8))
if not User.objects.filter(universal_code=code).exclude(pk=user.pk).exists():
user.universal_code = code
user.save(update_fields=['universal_code'])
break
else:
# Если всё равно не получилось, не прерываем регистрацию
logger.error(f'Не удалось сгенерировать unique universal_code для пользователя {user.id} после 500 попыток')
except Exception as e2:
logger.error(f'Критическая ошибка генерации universal_code для пользователя {user.id}: {e2}')
# Не прерываем регистрацию, код будет сгенерирован при следующем запросе профиля
# Токен для подтверждения email # Токен для подтверждения email
verification_token = secrets.token_urlsafe(32) verification_token = secrets.token_urlsafe(32)
user.email_verification_token = verification_token user.email_verification_token = verification_token

View File

@ -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}")

View File

@ -1,204 +1,208 @@
/** /**
* API модуль для аутентификации * API модуль для аутентификации
*/ */
import apiClient from '@/lib/api-client'; import apiClient from '@/lib/api-client';
export interface LoginCredentials { export interface LoginCredentials {
email: string; email: string;
password: string; password: string;
} }
export interface RegisterData { export interface RegisterData {
email: string; email: string;
password: string; password: string;
password_confirm: string; password_confirm: string;
first_name?: string; first_name?: string;
last_name?: string; last_name?: string;
role?: 'mentor' | 'client' | 'parent'; role?: 'mentor' | 'client' | 'parent';
city?: string; city?: string;
timezone?: string; timezone?: string;
} }
export interface AuthResponse { export interface AuthResponse {
access: string; access: string;
refresh?: string; refresh?: string;
user?: any; user?: any;
} }
export interface User { export interface User {
id: number; id: number;
email: string; email: string;
first_name?: string; first_name?: string;
last_name?: string; last_name?: string;
phone?: string; phone?: string;
role: 'mentor' | 'client' | 'parent'; role: 'mentor' | 'client' | 'parent';
is_verified?: boolean; is_verified?: boolean;
avatar_url?: string | null; avatar_url?: string | null;
avatar?: string | null; avatar?: string | null;
telegram_id?: number | null; telegram_id?: number | null;
universal_code?: string; universal_code?: string;
invitation_link?: string; invitation_link?: string;
invitation_link_token?: string; invitation_link_token?: string;
} timezone?: string;
language?: string;
/** city?: string;
* Вход в систему country?: string;
*/ }
export async function login(credentials: LoginCredentials): Promise<AuthResponse> {
console.log('[auth.login] Sending request to /auth/login/', credentials.email); /**
const response = await apiClient.post<any>('/auth/login/', credentials); * Вход в систему
console.log('[auth.login] Raw response:', response); */
console.log('[auth.login] response.data:', response.data); export async function login(credentials: LoginCredentials): Promise<AuthResponse> {
console.log('[auth.login] Sending request to /auth/login/', credentials.email);
// API возвращает { success, message, data: { user, tokens: { access, refresh } } } const response = await apiClient.post<any>('/auth/login/', credentials);
const data = response.data?.data; console.log('[auth.login] Raw response:', response);
console.log('[auth.login] Parsed data:', data); console.log('[auth.login] response.data:', response.data);
console.log('[auth.login] Tokens:', data?.tokens);
// API возвращает { success, message, data: { user, tokens: { access, refresh } } }
if (data?.tokens) { const data = response.data?.data;
const result = { console.log('[auth.login] Parsed data:', data);
access: data.tokens.access, console.log('[auth.login] Tokens:', data?.tokens);
refresh: data.tokens.refresh,
user: data.user if (data?.tokens) {
}; const result = {
console.log('[auth.login] Returning:', { ...result, access: result.access?.substring(0, 20) + '...' }); access: data.tokens.access,
return result; refresh: data.tokens.refresh,
} user: data.user
};
// Fallback для старого формата console.log('[auth.login] Returning:', { ...result, access: result.access?.substring(0, 20) + '...' });
console.log('[auth.login] Using fallback structure'); return result;
return response.data?.data || response.data; }
}
// Fallback для старого формата
/** console.log('[auth.login] Using fallback structure');
* Регистрация return response.data?.data || response.data;
*/ }
export async function register(data: RegisterData): Promise<AuthResponse> {
const response = await apiClient.post<any>('/auth/register/', data); /**
// API возвращает { success, message, data: { user, tokens: { access, refresh } } } * Регистрация
const responseData = response.data?.data; */
if (responseData?.tokens) { export async function register(data: RegisterData): Promise<AuthResponse> {
return { const response = await apiClient.post<any>('/auth/register/', data);
access: responseData.tokens.access, // API возвращает { success, message, data: { user, tokens: { access, refresh } } }
refresh: responseData.tokens.refresh, const responseData = response.data?.data;
user: responseData.user if (responseData?.tokens) {
}; return {
} access: responseData.tokens.access,
// Fallback для старого формата refresh: responseData.tokens.refresh,
return response.data?.data || response.data; user: responseData.user
} };
}
/** // Fallback для старого формата
* Выход из системы return response.data?.data || response.data;
*/ }
export async function logout(): Promise<void> {
await apiClient.post('/auth/logout/'); /**
} * Выход из системы
*/
/** export async function logout(): Promise<void> {
* Получить текущего пользователя await apiClient.post('/auth/logout/');
* Endpoint: GET /api/profile/me/ }
*/
export async function getCurrentUser(): Promise<User> { /**
try { * Получить текущего пользователя
// Используем ProfileViewSet.me() - возвращает request.user * Endpoint: GET /api/profile/me/
console.log('[getCurrentUser] Requesting /profile/me/'); */
const response = await apiClient.get<User>('/profile/me/'); export async function getCurrentUser(): Promise<User> {
console.log('[getCurrentUser] Success:', response.data); try {
return response.data; // Используем ProfileViewSet.me() - возвращает request.user
} catch (error: any) { console.log('[getCurrentUser] Requesting /profile/me/');
console.error('[getCurrentUser] Error with /profile/me/:', error); const response = await apiClient.get<User>('/profile/me/');
console.error('[getCurrentUser] Error status:', error.response?.status); console.log('[getCurrentUser] Success:', response.data);
console.error('[getCurrentUser] Error data:', error.response?.data); return response.data;
console.error('[getCurrentUser] Error config:', error.config?.url); } catch (error: any) {
console.error('[getCurrentUser] Error with /profile/me/:', error);
// Fallback: используем UserViewSet с ID из токена console.error('[getCurrentUser] Error status:', error.response?.status);
const token = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null; console.error('[getCurrentUser] Error data:', error.response?.data);
if (!token) { console.error('[getCurrentUser] Error config:', error.config?.url);
console.error('[getCurrentUser] No token found for fallback');
throw error; // Fallback: используем UserViewSet с ID из токена
} const token = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null;
if (!token) {
try { console.error('[getCurrentUser] No token found for fallback');
const payload = JSON.parse(atob(token.split('.')[1])); throw error;
const userId = payload.user_id; }
if (!userId) { try {
console.error('[getCurrentUser] No user_id in token payload:', payload); const payload = JSON.parse(atob(token.split('.')[1]));
throw error; const userId = payload.user_id;
}
if (!userId) {
console.log('[getCurrentUser] Trying fallback /users/' + userId + '/'); console.error('[getCurrentUser] No user_id in token payload:', payload);
const userResponse = await apiClient.get<User>(`/users/${userId}/`); throw error;
console.log('[getCurrentUser] Fallback success:', userResponse.data); }
return userResponse.data;
} catch (e) { console.log('[getCurrentUser] Trying fallback /users/' + userId + '/');
console.error('[getCurrentUser] Fallback error:', e); const userResponse = await apiClient.get<User>(`/users/${userId}/`);
throw error; // Бросаем оригинальную ошибку, а не fallback ошибку 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/', * Обновить access-токен по refresh-токену (запрос без Authorization, чтобы не слать истёкший токен).
{ refresh }, */
{ __skipAuth: true } as any export async function refreshToken(refresh: string): Promise<{ access: string }> {
); const response = await apiClient.getInstance().post<{ access: string }>(
return response.data; '/auth/token/refresh/',
} { refresh },
{ __skipAuth: true } as any
/** );
* Смена пароля return response.data;
*/ }
export async function changePassword(
oldPassword: string, /**
newPassword: string * Смена пароля
): Promise<void> { */
await apiClient.post('/auth/change-password/', { export async function changePassword(
old_password: oldPassword, oldPassword: string,
new_password: newPassword, newPassword: string
}); ): Promise<void> {
} await apiClient.post('/auth/change-password/', {
old_password: oldPassword,
/** new_password: newPassword,
* Запрос на сброс пароля });
*/ }
export async function requestPasswordReset(data: { email: string }): Promise<void> {
await apiClient.post('/auth/password-reset/', data); /**
} * Запрос на сброс пароля
*/
/** export async function requestPasswordReset(data: { email: string }): Promise<void> {
* Подтверждение email по токену из письма await apiClient.post('/auth/password-reset/', data);
*/ }
export async function verifyEmail(token: string): Promise<{ success: boolean; message?: string }> {
const response = await apiClient.post<{ success: boolean; message?: string }>( /**
'/auth/verify-email/', * Подтверждение email по токену из письма
{ token }, */
{ __skipAuth: true } as any export async function verifyEmail(token: string): Promise<{ success: boolean; message?: string }> {
); const response = await apiClient.post<{ success: boolean; message?: string }>(
return response.data; '/auth/verify-email/',
} { token },
{ __skipAuth: true } as any
/** );
* Подтверждение сброса пароля (по ссылке из письма) return response.data;
*/ }
export async function confirmPasswordReset(
token: string, /**
newPassword: string, * Подтверждение сброса пароля (по ссылке из письма)
newPasswordConfirm: string */
): Promise<void> { export async function confirmPasswordReset(
await apiClient.post( token: string,
'/auth/password-reset-confirm/', newPassword: string,
{ newPasswordConfirm: string
token, ): Promise<void> {
new_password: newPassword, await apiClient.post(
new_password_confirm: newPasswordConfirm, '/auth/password-reset-confirm/',
}, {
{ __skipAuth: true } as any token,
); new_password: newPassword,
} new_password_confirm: newPasswordConfirm,
},
{ __skipAuth: true } as any
);
}

View File

@ -258,6 +258,33 @@ export function validateHomeworkFiles(files: File[]): { valid: boolean; error?:
return { valid: true }; return { valid: true };
} }
/**
* Обновить домашнее задание (для черновиков fill_later).
* PATCH /api/homework/homeworks/{id}/
*/
export async function updateHomework(
homeworkId: string | number,
data: {
title?: string;
description?: string;
deadline?: string | null;
status?: 'draft' | 'published';
fill_later?: boolean;
}
): Promise<Homework> {
const res = await apiClient.patch<Homework>(`/homework/homeworks/${homeworkId}/`, data);
return res.data;
}
/**
* Опубликовать домашнее задание (из черновика в published).
* POST /api/homework/homeworks/{id}/publish/
*/
export async function publishHomework(homeworkId: string | number): Promise<Homework> {
const res = await apiClient.post<Homework>(`/homework/homeworks/${homeworkId}/publish/`);
return res.data;
}
export async function submitHomework( export async function submitHomework(
homeworkId: string | number, homeworkId: string | number,
data: { content?: string; text?: string; files?: File[] }, data: { content?: string; text?: string; files?: File[] },

View File

@ -18,6 +18,7 @@ import { CheckLesson } from '@/components/checklesson/checklesson';
import { getLessonsCalendar, getLesson, createLesson, updateLesson, deleteLesson } from '@/api/schedule'; import { getLessonsCalendar, getLesson, createLesson, updateLesson, deleteLesson } from '@/api/schedule';
import { getStudents } from '@/api/students'; import { getStudents } from '@/api/students';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { createDateTimeInUserTimezone, parseISOToUserTimezone } from '@/utils/timezone';
import { useSelectedChild } from '@/contexts/SelectedChildContext'; import { useSelectedChild } from '@/contexts/SelectedChildContext';
import { getSubjects, getMentorSubjects } from '@/api/subjects'; import { getSubjects, getMentorSubjects } from '@/api/subjects';
import { loadComponent } from '@/lib/material-components'; import { loadComponent } from '@/lib/material-components';
@ -132,6 +133,9 @@ export default function SchedulePage() {
client_name: lesson.client_name ?? (lesson.client?.user client_name: lesson.client_name ?? (lesson.client?.user
? `${lesson.client.user.first_name || ''} ${lesson.client.user.last_name || ''}`.trim() ? `${lesson.client.user.first_name || ''} ${lesson.client.user.last_name || ''}`.trim()
: undefined), : undefined),
mentor_name: lesson.mentor_name ?? (lesson.mentor?.first_name || lesson.mentor?.last_name
? `${lesson.mentor?.first_name || ''} ${lesson.mentor?.last_name || ''}`.trim()
: undefined),
subject: lesson.subject ?? lesson.subject_name ?? '', subject: lesson.subject ?? lesson.subject_name ?? '',
})); }));
setLessons(mappedLessons); setLessons(mappedLessons);
@ -156,9 +160,15 @@ export default function SchedulePage() {
const lessonsForSelectedDate: LessonPreview[] = lessons const lessonsForSelectedDate: LessonPreview[] = lessons
.filter((lesson) => { .filter((lesson) => {
const lessonDate = startOfDay(new Date(lesson.start_time)); // Парсим дату в timezone пользователя для правильной фильтрации
const parsed = parseISOToUserTimezone(lesson.start_time, user?.timezone);
const lessonDate = startOfDay(parsed.dateObj);
return lessonDate.getTime() === selectedDate.getTime(); return lessonDate.getTime() === selectedDate.getTime();
}) })
.sort((a, b) => {
// Сортируем по времени начала (раньше → первые)
return new Date(a.start_time).getTime() - new Date(b.start_time).getTime();
})
.map((lesson) => ({ .map((lesson) => ({
id: String(lesson.id), id: String(lesson.id),
title: lesson.title || 'Занятие', title: lesson.title || 'Занятие',
@ -229,15 +239,18 @@ export default function SchedulePage() {
(async () => { (async () => {
try { try {
const details = await getLesson(String(lesson.id)); const details = await getLesson(String(lesson.id));
const start = new Date(details.start_time);
const end = new Date(details.end_time); // Парсим время в timezone пользователя
const safeStart = startOfDay(start); const startParsed = parseISOToUserTimezone(details.start_time, user?.timezone);
const safeStart = startOfDay(startParsed.dateObj);
// синхронизируем правую панель с датой урока // синхронизируем правую панель с датой урока
setSelectedDate(safeStart); setSelectedDate(safeStart);
setDisplayDate(safeStart); setDisplayDate(safeStart);
const duration = (() => { const duration = (() => {
const start = new Date(details.start_time);
const end = new Date(details.end_time);
const mins = differenceInMinutes(end, start); const mins = differenceInMinutes(end, start);
return Number.isFinite(mins) && mins > 0 ? mins : 60; return Number.isFinite(mins) && mins > 0 ? mins : 60;
})(); })();
@ -246,8 +259,8 @@ export default function SchedulePage() {
client: details.client?.id ? String(details.client.id) : '', client: details.client?.id ? String(details.client.id) : '',
title: details.title ?? '', title: details.title ?? '',
description: details.description ?? '', description: details.description ?? '',
start_date: format(start, 'yyyy-MM-dd'), start_date: startParsed.date,
start_time: format(start, 'HH:mm'), start_time: startParsed.time,
duration, duration,
price: typeof details.price === 'number' ? details.price : undefined, price: typeof details.price === 'number' ? details.price : undefined,
is_recurring: !!(details as any).is_recurring, is_recurring: !!(details as any).is_recurring,
@ -337,7 +350,12 @@ export default function SchedulePage() {
return; return;
} }
const startUtc = new Date(`${formData.start_date}T${formData.start_time}`).toISOString(); // Конвертируем время из timezone пользователя в UTC
const startUtc = createDateTimeInUserTimezone(
formData.start_date,
formData.start_time,
user?.timezone
);
const title = generateTitle(); const title = generateTitle();
if (isEditingMode && editingLessonId) { if (isEditingMode && editingLessonId) {
@ -421,10 +439,12 @@ export default function SchedulePage() {
<Calendar <Calendar
lessons={lessons} lessons={lessons}
lessonsLoading={lessonsLoading} lessonsLoading={lessonsLoading}
selectedDate={selectedDate} selectedDate={selectedDate}
onSelectSlot={handleSelectSlot} onSelectSlot={handleSelectSlot}
onSelectEvent={handleSelectEvent} onSelectEvent={handleSelectEvent}
onMonthChange={handleMonthChange} onMonthChange={handleMonthChange}
isMentor={isMentor}
userTimezone={user?.timezone}
/> />
</div> </div>
<div className="ios26-schedule-right-wrap"> <div className="ios26-schedule-right-wrap">

View File

@ -37,6 +37,14 @@ export default function RootLayout({
<head> <head>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" /> <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
{/* Preload локального шрифта иконок */}
<link
rel="preload"
href="/fonts/material-symbols-outlined.woff2"
as="font"
type="font/woff2"
crossOrigin="anonymous"
/>
</head> </head>
<body> <body>
<Providers> <Providers>

View File

@ -1,99 +1,120 @@
/** /**
* Блок «Календарь занятий» обёртка над LessonsCalendar. * Блок «Календарь занятий» обёртка над LessonsCalendar.
* Используется в Dashboard и других страницах. * Используется в Dashboard и других страницах.
*/ */
'use client'; 'use client';
import React from 'react'; import React from 'react';
import { format, startOfDay } from 'date-fns'; import { format, startOfDay } from 'date-fns';
import { LessonsCalendar } from '@/components/dashboard/LessonsCalendar'; import { LessonsCalendar } from '@/components/dashboard/LessonsCalendar';
import { LoadingSpinner } from '@/components/common/LoadingSpinner'; import { LoadingSpinner } from '@/components/common/LoadingSpinner';
export interface CalendarLesson { export interface CalendarLesson {
id: number | string; id: number | string;
title?: string; title?: string;
start_time: string; start_time: string;
end_time: string; end_time: string;
status?: string; status?: string;
client?: number; client?: number;
client_name?: string; client_name?: string;
subject?: string; mentor_name?: string;
} subject?: string;
}
export interface CalendarProps {
/** Занятия для отображения в календаре */ export interface CalendarProps {
lessons: CalendarLesson[]; /** Занятия для отображения в календаре */
/** Идёт загрузка занятий */ lessons: CalendarLesson[];
lessonsLoading?: boolean; /** Идёт загрузка занятий */
/** Выбранная дата (подсветка в календаре) */ lessonsLoading?: boolean;
selectedDate: Date; /** Выбранная дата (подсветка в календаре) */
/** Клик по ячейке дня или по слоту */ selectedDate: Date;
onSelectSlot?: (date: Date) => void; /** Клик по ячейке дня или по слоту */
/** Клик по событию (занятию) */ onSelectSlot?: (date: Date) => void;
onSelectEvent?: (lesson: { id: string }) => void; /** Клик по событию (занятию) */
/** Смена видимого месяца (start/end месяца) */ onSelectEvent?: (lesson: { id: string }) => void;
onMonthChange?: (start: Date, end: Date) => void; /** Смена видимого месяца (start/end месяца) */
} onMonthChange?: (start: Date, end: Date) => void;
/** Ментор — показывает ученика; студент — показывает предмет и ментора */
export const Calendar: React.FC<CalendarProps> = ({ isMentor?: boolean;
lessons, /** Часовой пояс пользователя (например, 'UTC+8') */
lessonsLoading = false, userTimezone?: string;
selectedDate, }
onSelectSlot,
onSelectEvent, export const Calendar: React.FC<CalendarProps> = ({
onMonthChange, lessons,
}) => { lessonsLoading = false,
const mappedLessons = React.useMemo( selectedDate,
() => onSelectSlot,
lessons.map((lesson) => ({ onSelectEvent,
id: String(lesson.id), onMonthChange,
title: lesson.title || 'Занятие', isMentor = true,
start_time: lesson.start_time, userTimezone,
end_time: lesson.end_time, }) => {
status: (lesson.status || 'scheduled') as 'scheduled' | 'in_progress' | 'completed' | 'cancelled', const mappedLessons = React.useMemo(
client: lesson.client_name () =>
? { lessons.map((lesson) => {
id: String(lesson.client ?? ''), if (isMentor && lesson.client_name) {
name: lesson.client_name, return {
first_name: lesson.client_name.split(' ')[0] || lesson.client_name, id: String(lesson.id),
last_name: lesson.client_name.split(' ').slice(1).join(' ') || '', title: lesson.title || 'Занятие',
} start_time: lesson.start_time,
: undefined, end_time: lesson.end_time,
})), status: (lesson.status || 'scheduled') as 'scheduled' | 'in_progress' | 'completed' | 'cancelled',
[lessons] client: {
); id: String(lesson.client ?? ''),
name: lesson.client_name,
return ( first_name: lesson.client_name.split(' ')[0] || lesson.client_name,
<div last_name: lesson.client_name.split(' ').slice(1).join(' ') || '',
className="ios-glass-panel" },
style={{ };
borderRadius: '20px', }
padding: '24px', const subject = lesson.subject || 'Занятие';
height: '100%', const mentorName = lesson.mentor_name || '';
minHeight: 0, const displayTitle = mentorName ? `${subject}${mentorName}` : subject;
display: 'flex', return {
flexDirection: 'column', id: String(lesson.id),
}} title: displayTitle,
> start_time: lesson.start_time,
{lessonsLoading ? ( end_time: lesson.end_time,
<LoadingSpinner size="medium" /> status: (lesson.status || 'scheduled') as 'scheduled' | 'in_progress' | 'completed' | 'cancelled',
) : ( client: undefined,
<LessonsCalendar };
lessons={mappedLessons} }),
selectedDate={selectedDate} [lessons, isMentor]
onSelectSlot={(date) => { );
try {
const d = startOfDay(date); return (
if (!Number.isNaN(d.getTime())) onSelectSlot?.(d); <div
} catch { className="ios-glass-panel"
/* игнор невалидной даты */ style={{
} borderRadius: '20px',
}} padding: '24px',
onSelectEvent={onSelectEvent} height: '100%',
onMonthChange={onMonthChange} minHeight: 0,
/> display: 'flex',
)} flexDirection: 'column',
</div> }}
); >
}; {lessonsLoading ? (
<LoadingSpinner size="medium" />
) : (
<LessonsCalendar
lessons={mappedLessons}
selectedDate={selectedDate}
userTimezone={userTimezone}
onSelectSlot={(date) => {
try {
const d = startOfDay(date);
if (!Number.isNaN(d.getTime())) onSelectSlot?.(d);
} catch {
/* игнор невалидной даты */
}
}}
onSelectEvent={onSelectEvent}
onMonthChange={onMonthChange}
/>
)}
</div>
);
};

File diff suppressed because it is too large Load Diff

View File

@ -1,316 +1,316 @@
/** /**
* Material Design 3 Date Picker Dialog variant. * Material Design 3 Date Picker Dialog variant.
* Opens a calendar inside a MUI Dialog (works well on mobile and inside other dialogs). * Opens a calendar inside a MUI Dialog (works well on mobile and inside other dialogs).
*/ */
'use client'; 'use client';
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { import {
format, format,
startOfMonth, startOfMonth,
endOfMonth, endOfMonth,
eachDayOfInterval, eachDayOfInterval,
isSameDay, isSameDay,
isSameMonth, isSameMonth,
addMonths, addMonths,
subMonths, subMonths,
startOfWeek, startOfWeek,
endOfWeek, endOfWeek,
} from 'date-fns'; } from 'date-fns';
import { ru } from 'date-fns/locale'; import { ru } from 'date-fns/locale';
import { Dialog, DialogContent, Box, Button } from '@mui/material'; import { Dialog, DialogContent, Box, Button } from '@mui/material';
interface DatePickerProps { interface DatePickerProps {
value: string; // YYYY-MM-DD format value: string; // YYYY-MM-DD format
onChange: (value: string) => void; onChange: (value: string) => void;
disabled?: boolean; disabled?: boolean;
required?: boolean; required?: boolean;
label?: string; label?: string;
} }
export const DatePicker: React.FC<DatePickerProps> = ({ export const DatePicker: React.FC<DatePickerProps> = ({
value, value,
onChange, onChange,
disabled = false, disabled = false,
required = false, required = false,
label, label,
}) => { }) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [displayMonth, setDisplayMonth] = useState( const [displayMonth, setDisplayMonth] = useState(
value ? new Date(value + 'T00:00:00') : new Date(), value ? new Date(value + 'T00:00:00') : new Date(),
); );
const selectedDate = useMemo( const selectedDate = useMemo(
() => (value ? new Date(value + 'T00:00:00') : null), () => (value ? new Date(value + 'T00:00:00') : null),
[value], [value],
); );
const openPicker = () => { const openPicker = () => {
if (disabled) return; if (disabled) return;
setDisplayMonth(selectedDate ?? new Date()); setDisplayMonth(selectedDate ?? new Date());
setOpen(true); setOpen(true);
}; };
const closePicker = () => setOpen(false); const closePicker = () => setOpen(false);
const handleDateSelect = (date: Date) => { const handleDateSelect = (date: Date) => {
const y = date.getFullYear(); const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0'); const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0'); const d = String(date.getDate()).padStart(2, '0');
onChange(`${y}-${m}-${d}`); onChange(`${y}-${m}-${d}`);
closePicker(); closePicker();
}; };
const days = useMemo(() => { const days = useMemo(() => {
const start = startOfMonth(displayMonth); const start = startOfMonth(displayMonth);
const end = endOfMonth(displayMonth); const end = endOfMonth(displayMonth);
return eachDayOfInterval({ return eachDayOfInterval({
start: startOfWeek(start, { locale: ru }), start: startOfWeek(start, { locale: ru }),
end: endOfWeek(end, { locale: ru }), end: endOfWeek(end, { locale: ru }),
}); });
}, [displayMonth]); }, [displayMonth]);
const weekDays = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']; const weekDays = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
const displayValue = selectedDate const displayValue = selectedDate
? format(selectedDate, 'd MMMM yyyy', { locale: ru }) ? format(selectedDate, 'd MMMM yyyy', { locale: ru })
: label || 'Выберите дату'; : label || 'Выберите дату';
return ( return (
<> <>
<button <button
type="button" type="button"
onClick={openPicker} onClick={openPicker}
disabled={disabled} disabled={disabled}
aria-required={required} aria-required={required}
style={{ style={{
width: '100%', width: '100%',
padding: '12px 16px', padding: '12px 16px',
fontSize: '16px', fontSize: '16px',
color: value color: value
? 'var(--md-sys-color-on-surface)' ? 'var(--md-sys-color-on-surface)'
: 'var(--md-sys-color-on-surface-variant)', : 'var(--md-sys-color-on-surface-variant)',
background: 'var(--md-sys-color-surface)', background: 'var(--md-sys-color-surface)',
border: '1px solid var(--md-sys-color-outline)', border: '1px solid var(--md-sys-color-outline)',
borderRadius: '4px', borderRadius: '4px',
fontFamily: 'inherit', fontFamily: 'inherit',
cursor: disabled ? 'not-allowed' : 'pointer', cursor: disabled ? 'not-allowed' : 'pointer',
outline: 'none', outline: 'none',
transition: 'all 0.2s ease', transition: 'all 0.2s ease',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
gap: '12px', gap: '12px',
textAlign: 'left', textAlign: 'left',
}} }}
> >
<span>{displayValue}</span> <span>{displayValue}</span>
<span <span
className="material-symbols-outlined" className="material-symbols-outlined"
style={{ fontSize: 20, opacity: 0.7 }} style={{ fontSize: 20, opacity: 0.7 }}
> >
calendar_today calendar_today
</span> </span>
</button> </button>
<Dialog <Dialog
open={open} open={open}
onClose={closePicker} onClose={closePicker}
fullWidth fullWidth
maxWidth="xs" maxWidth="xs"
slotProps={{ slotProps={{
paper: { paper: {
sx: { sx: {
borderRadius: '24px', borderRadius: '24px',
overflow: 'visible', overflow: 'visible',
bgcolor: 'var(--md-sys-color-surface)', bgcolor: 'var(--md-sys-color-surface)',
}, },
}, },
}} }}
> >
<DialogContent sx={{ p: 2 }}> <DialogContent sx={{ p: 2 }}>
{/* Month/year header */} {/* Month/year header */}
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
mb: 1.5, mb: 1.5,
}} }}
> >
<button <button
type="button" type="button"
onClick={() => setDisplayMonth(subMonths(displayMonth, 1))} onClick={() => setDisplayMonth(subMonths(displayMonth, 1))}
style={{ style={{
width: 36, width: 36,
height: 36, height: 36,
padding: 0, padding: 0,
background: 'transparent', background: 'transparent',
border: 'none', border: 'none',
borderRadius: '50%', borderRadius: '50%',
cursor: 'pointer', cursor: 'pointer',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
color: 'var(--md-sys-color-on-surface)', color: 'var(--md-sys-color-on-surface)',
}} }}
> >
<span className="material-symbols-outlined" style={{ fontSize: 22 }}> <span className="material-symbols-outlined" style={{ fontSize: 22 }}>
chevron_left chevron_left
</span> </span>
</button> </button>
<span <span
style={{ style={{
fontSize: 16, fontSize: 16,
fontWeight: 500, fontWeight: 500,
color: 'var(--md-sys-color-on-surface)', color: 'var(--md-sys-color-on-surface)',
textTransform: 'capitalize', textTransform: 'capitalize',
}} }}
> >
{format(displayMonth, 'LLLL yyyy', { locale: ru })} {format(displayMonth, 'LLLL yyyy', { locale: ru })}
</span> </span>
<button <button
type="button" type="button"
onClick={() => setDisplayMonth(addMonths(displayMonth, 1))} onClick={() => setDisplayMonth(addMonths(displayMonth, 1))}
style={{ style={{
width: 36, width: 36,
height: 36, height: 36,
padding: 0, padding: 0,
background: 'transparent', background: 'transparent',
border: 'none', border: 'none',
borderRadius: '50%', borderRadius: '50%',
cursor: 'pointer', cursor: 'pointer',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
color: 'var(--md-sys-color-on-surface)', color: 'var(--md-sys-color-on-surface)',
}} }}
> >
<span className="material-symbols-outlined" style={{ fontSize: 22 }}> <span className="material-symbols-outlined" style={{ fontSize: 22 }}>
chevron_right chevron_right
</span> </span>
</button> </button>
</Box> </Box>
{/* Weekday headers */} {/* Weekday headers */}
<div <div
style={{ style={{
display: 'grid', display: 'grid',
gridTemplateColumns: 'repeat(7, 1fr)', gridTemplateColumns: 'repeat(7, 1fr)',
gap: 2, gap: 2,
marginBottom: 4, marginBottom: 4,
}} }}
> >
{weekDays.map((day) => ( {weekDays.map((day) => (
<div <div
key={day} key={day}
style={{ style={{
textAlign: 'center', textAlign: 'center',
fontSize: 12, fontSize: 12,
fontWeight: 500, fontWeight: 500,
color: 'var(--md-sys-color-on-surface-variant)', color: 'var(--md-sys-color-on-surface-variant)',
padding: '6px 0', padding: '6px 0',
}} }}
> >
{day} {day}
</div> </div>
))} ))}
</div> </div>
{/* Calendar days */} {/* Calendar days */}
<div <div
style={{ style={{
display: 'grid', display: 'grid',
gridTemplateColumns: 'repeat(7, 1fr)', gridTemplateColumns: 'repeat(7, 1fr)',
gap: 2, gap: 2,
}} }}
> >
{days.map((day, idx) => { {days.map((day, idx) => {
const isSelected = selectedDate && isSameDay(day, selectedDate); const isSelected = selectedDate && isSameDay(day, selectedDate);
const isCurrent = isSameMonth(day, displayMonth); const isCurrent = isSameMonth(day, displayMonth);
const isToday = isSameDay(day, new Date()); const isToday = isSameDay(day, new Date());
return ( return (
<button <button
key={idx} key={idx}
type="button" type="button"
onClick={() => handleDateSelect(day)} onClick={() => handleDateSelect(day)}
style={{ style={{
width: '100%', width: '100%',
aspectRatio: '1', aspectRatio: '1',
maxWidth: 40, maxWidth: 40,
margin: '0 auto', margin: '0 auto',
padding: 0, padding: 0,
background: isSelected background: isSelected
? 'var(--md-sys-color-primary)' ? 'var(--md-sys-color-primary)'
: 'transparent', : 'transparent',
border: border:
isToday && !isSelected isToday && !isSelected
? '1px solid var(--md-sys-color-primary)' ? '1px solid var(--md-sys-color-primary)'
: 'none', : 'none',
borderRadius: '50%', borderRadius: '50%',
cursor: 'pointer', cursor: 'pointer',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
fontSize: 14, fontSize: 14,
fontWeight: isSelected ? 600 : 400, fontWeight: isSelected ? 600 : 400,
color: isSelected color: isSelected
? 'var(--md-sys-color-on-primary)' ? 'var(--md-sys-color-on-primary)'
: isCurrent : isCurrent
? 'var(--md-sys-color-on-surface)' ? 'var(--md-sys-color-on-surface)'
: 'var(--md-sys-color-on-surface-variant)', : 'var(--md-sys-color-on-surface-variant)',
opacity: isCurrent ? 1 : 0.35, opacity: isCurrent ? 1 : 0.35,
transition: 'background 0.15s', transition: 'background 0.15s',
}} }}
> >
{format(day, 'd')} {format(day, 'd')}
</button> </button>
); );
})} })}
</div> </div>
{/* Actions */} {/* Actions */}
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
mt: 2, mt: 2,
pt: 1.5, pt: 1.5,
borderTop: '1px solid var(--md-sys-color-outline-variant)', borderTop: '1px solid var(--md-sys-color-outline-variant)',
}} }}
> >
<Button <Button
onClick={() => handleDateSelect(new Date())} onClick={() => handleDateSelect(new Date())}
variant="text" variant="text"
sx={{ sx={{
color: 'var(--md-sys-color-primary)', color: 'var(--md-sys-color-primary)',
textTransform: 'none', textTransform: 'none',
fontWeight: 500, fontWeight: 500,
fontSize: 14, fontSize: 14,
}} }}
> >
Сегодня Сегодня
</Button> </Button>
<Button <Button
onClick={closePicker} onClick={closePicker}
variant="text" variant="text"
sx={{ sx={{
color: 'var(--md-sys-color-on-surface-variant)', color: 'var(--md-sys-color-on-surface-variant)',
textTransform: 'none', textTransform: 'none',
fontWeight: 500, fontWeight: 500,
fontSize: 14, fontSize: 14,
}} }}
> >
Отмена Отмена
</Button> </Button>
</Box> </Box>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</> </>
); );
}; };

View File

@ -13,6 +13,7 @@ import { getCurrentUser, User } from '@/api/auth';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { DatePicker } from '@/components/common/DatePicker'; import { DatePicker } from '@/components/common/DatePicker';
import { TimePicker } from '@/components/common/TimePicker'; import { TimePicker } from '@/components/common/TimePicker';
import { createDateTimeInUserTimezone } from '@/utils/timezone';
interface CreateLessonDialogProps { interface CreateLessonDialogProps {
open: boolean; open: boolean;
@ -250,8 +251,12 @@ export const CreateLessonDialog: React.FC<CreateLessonDialogProps> = ({
return; return;
} }
// Объединяем дату и время в ISO строку // Объединяем дату и время в ISO строку с учётом timezone пользователя
const startUtc = new Date(`${formData.start_date}T${formData.start_time}`).toISOString(); const startUtc = createDateTimeInUserTimezone(
formData.start_date,
formData.start_time,
currentUser?.timezone
);
const payload: any = { const payload: any = {
client: formData.client, client: formData.client,

View File

@ -1,276 +1,278 @@
/** /**
* Карточка урока для Dashboard * Карточка урока для Dashboard
*/ */
'use client'; 'use client';
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { LessonPreview } from '@/api/dashboard'; import { LessonPreview } from '@/api/dashboard';
import { createLiveKitRoom } from '@/api/livekit'; import { createLiveKitRoom } from '@/api/livekit';
import { useAuth } from '@/contexts/AuthContext';
interface LessonCardProps { import { parseISOToUserTimezone } from '@/utils/timezone';
lesson: LessonPreview;
showMentor?: boolean; interface LessonCardProps {
showClient?: boolean; lesson: LessonPreview;
onClick?: () => void; showMentor?: boolean;
} showClient?: boolean;
onClick?: () => void;
/** Подключение доступно за 10 минут до начала и до 15 минут после окончания */ }
function canJoinLesson(lesson: LessonPreview): boolean {
if (!lesson.start_time || !lesson.end_time) return false; /** Подключение доступно за 10 минут до начала и до 15 минут после окончания */
if (lesson.status === 'cancelled') return false; function canJoinLesson(lesson: LessonPreview): boolean {
const now = new Date(); if (!lesson.start_time || !lesson.end_time) return false;
const startTime = new Date(lesson.start_time); if (lesson.status === 'cancelled') return false;
const endTime = new Date(lesson.end_time); const now = new Date();
const allowedStart = new Date(startTime.getTime() - 10 * 60 * 1000); // за 10 минут до начала const startTime = new Date(lesson.start_time);
const allowedEnd = new Date(endTime.getTime() + 15 * 60 * 1000); // до 15 минут после окончания const endTime = new Date(lesson.end_time);
return now >= allowedStart && now <= allowedEnd; const allowedStart = new Date(startTime.getTime() - 10 * 60 * 1000); // за 10 минут до начала
} const allowedEnd = new Date(endTime.getTime() + 15 * 60 * 1000); // до 15 минут после окончания
return now >= allowedStart && now <= allowedEnd;
export const LessonCard: React.FC<LessonCardProps> = ({ }
lesson,
showMentor = false, export const LessonCard: React.FC<LessonCardProps> = ({
showClient = false, lesson,
onClick, showMentor = false,
}) => { showClient = false,
const router = useRouter(); onClick,
const [connectLoading, setConnectLoading] = useState(false); }) => {
const [canJoin, setCanJoin] = useState(false); const router = useRouter();
const { user } = useAuth();
useEffect(() => { const [connectLoading, setConnectLoading] = useState(false);
const check = () => setCanJoin(canJoinLesson(lesson)); const [canJoin, setCanJoin] = useState(false);
check();
const interval = setInterval(check, 60000); useEffect(() => {
return () => clearInterval(interval); const check = () => setCanJoin(canJoinLesson(lesson));
}, [lesson.start_time, lesson.end_time, lesson.status]); check();
const interval = setInterval(check, 60000);
const handleConnect = useCallback( return () => clearInterval(interval);
async (e: React.MouseEvent) => { }, [lesson.start_time, lesson.end_time, lesson.status]);
e.stopPropagation();
if (!canJoin || connectLoading) return; const handleConnect = useCallback(
setConnectLoading(true); async (e: React.MouseEvent) => {
try { e.stopPropagation();
const lessonId = typeof lesson.id === 'string' ? parseInt(lesson.id, 10) : lesson.id; if (!canJoin || connectLoading) return;
const res = await createLiveKitRoom(lessonId); setConnectLoading(true);
router.push( try {
`/livekit/${res.room_name}?lesson_id=${lesson.id}&token=${encodeURIComponent(res.access_token)}` const lessonId = typeof lesson.id === 'string' ? parseInt(lesson.id, 10) : lesson.id;
); const res = await createLiveKitRoom(lessonId);
} catch (err) { router.push(
console.error('LiveKit connect error:', err); `/livekit/${res.room_name}?lesson_id=${lesson.id}&token=${encodeURIComponent(res.access_token)}`
setConnectLoading(false); );
} } catch (err) {
}, console.error('LiveKit connect error:', err);
[canJoin, connectLoading, lesson.id, router] setConnectLoading(false);
); }
},
const startTime = new Date(lesson.start_time); [canJoin, connectLoading, lesson.id, router]
const endTime = new Date(lesson.end_time); );
const getStatusColor = (status: string) => { // Парсим время с учётом timezone пользователя
switch (status) { const { startParsed, endParsed } = useMemo(() => {
case 'completed': return {
// более серый фон для завершённых занятий startParsed: parseISOToUserTimezone(lesson.start_time, user?.timezone),
return 'color-mix(in srgb, var(--md-sys-color-surface-variant) 70%, #000 10%)'; endParsed: parseISOToUserTimezone(lesson.end_time, user?.timezone),
case 'in_progress': };
return 'var(--md-sys-color-tertiary)'; }, [lesson.start_time, lesson.end_time, user?.timezone]);
case 'cancelled':
return 'var(--md-sys-color-error)'; const getStatusColor = (status: string) => {
case 'scheduled': switch (status) {
default: case 'completed':
return 'var(--md-sys-color-primary)'; // более серый фон для завершённых занятий
} return 'color-mix(in srgb, var(--md-sys-color-surface-variant) 70%, #000 10%)';
}; case 'in_progress':
return 'var(--md-sys-color-tertiary)';
const getStatusTextColor = (status: string) => { case 'cancelled':
switch (status) { return 'var(--md-sys-color-error)';
case 'completed': case 'scheduled':
return 'var(--md-sys-color-on-surface-variant)'; default:
case 'in_progress': return 'var(--md-sys-color-primary)';
return 'var(--md-sys-color-on-tertiary)'; }
case 'cancelled': };
return 'var(--md-sys-color-on-error)';
case 'scheduled': const getStatusTextColor = (status: string) => {
default: switch (status) {
return 'var(--md-sys-color-on-primary)'; case 'completed':
} return 'var(--md-sys-color-on-surface-variant)';
}; case 'in_progress':
return 'var(--md-sys-color-on-tertiary)';
const getStatusText = (status: string) => { case 'cancelled':
switch (status) { return 'var(--md-sys-color-on-error)';
case 'completed': case 'scheduled':
return 'Завершено'; default:
case 'in_progress': return 'var(--md-sys-color-on-primary)';
return 'В процессе'; }
case 'cancelled': };
return 'Отменено';
default: const getStatusText = (status: string) => {
return 'Запланировано'; switch (status) {
} case 'completed':
}; return 'Завершено';
case 'in_progress':
const statusColor = getStatusColor(lesson.status); return 'В процессе';
const textColor = getStatusTextColor(lesson.status); case 'cancelled':
return 'Отменено';
return ( default:
<div return 'Запланировано';
role={onClick ? 'button' : undefined} }
tabIndex={onClick ? 0 : undefined} };
onClick={onClick}
onKeyDown={onClick ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick(); } } : undefined} const statusColor = getStatusColor(lesson.status);
style={{ const textColor = getStatusTextColor(lesson.status);
background: statusColor,
borderRadius: '16px', return (
padding: '16px', <div
marginBottom: '12px', role={onClick ? 'button' : undefined}
transition: 'all 0.2s ease', tabIndex={onClick ? 0 : undefined}
cursor: 'pointer', onClick={onClick}
position: 'relative', onKeyDown={onClick ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick(); } } : undefined}
}} style={{
onMouseEnter={(e) => { background: statusColor,
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.15)'; borderRadius: '16px',
e.currentTarget.style.transform = 'translateY(-2px)'; padding: '16px',
}} marginBottom: '12px',
onMouseLeave={(e) => { transition: 'all 0.2s ease',
e.currentTarget.style.boxShadow = 'none'; cursor: 'pointer',
e.currentTarget.style.transform = 'translateY(0)'; position: 'relative',
}} }}
> onMouseEnter={(e) => {
<div style={{ e.currentTarget.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.15)';
display: 'flex', e.currentTarget.style.transform = 'translateY(-2px)';
justifyContent: 'space-between', }}
alignItems: 'flex-start', onMouseLeave={(e) => {
marginBottom: '12px' e.currentTarget.style.boxShadow = 'none';
}}> e.currentTarget.style.transform = 'translateY(0)';
<div style={{ flex: 1 }}> }}
<h4 style={{ >
fontSize: '16px', <div style={{
fontWeight: '500', display: 'flex',
color: textColor, justifyContent: 'space-between',
margin: '0 0 4px 0' alignItems: 'flex-start',
}}> marginBottom: '12px'
{lesson.title} }}>
</h4> <div style={{ flex: 1 }}>
{lesson.subject && ( <h4 style={{
<p style={{ fontSize: '16px',
fontSize: '14px', fontWeight: '500',
color: textColor, color: textColor,
margin: '0', margin: '0 0 4px 0'
opacity: 0.9 }}>
}}> {lesson.title}
{lesson.subject} </h4>
</p> {lesson.subject && (
)} <p style={{
</div> fontSize: '14px',
<span style={{ color: textColor,
fontSize: '12px', margin: '0',
padding: '4px 10px', opacity: 0.9
borderRadius: '12px', }}>
background: 'rgba(255, 255, 255, 0.25)', {lesson.subject}
color: textColor, </p>
fontWeight: '500' )}
}}> </div>
{getStatusText(lesson.status)} <span style={{
</span> fontSize: '12px',
</div> padding: '4px 10px',
borderRadius: '12px',
<div style={{ background: 'rgba(255, 255, 255, 0.25)',
display: 'flex', color: textColor,
alignItems: 'center', fontWeight: '500'
gap: '16px', }}>
fontSize: '14px', {getStatusText(lesson.status)}
color: textColor, </span>
opacity: 0.9 </div>
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}> <div style={{
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> display: 'flex',
<circle cx="12" cy="12" r="10"></circle> alignItems: 'center',
<polyline points="12 6 12 12 16 14"></polyline> gap: '16px',
</svg> fontSize: '14px',
<span> color: textColor,
{startTime.toLocaleDateString('ru-RU', { opacity: 0.9
day: 'numeric', }}>
month: 'short' <div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
})} <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
{' в '} <circle cx="12" cy="12" r="10"></circle>
{startTime.toLocaleTimeString('ru-RU', { <polyline points="12 6 12 12 16 14"></polyline>
hour: '2-digit', </svg>
minute: '2-digit' <span>
})} {startParsed.dateObj.toLocaleDateString('ru-RU', {
{' - '} day: 'numeric',
{endTime.toLocaleTimeString('ru-RU', { month: 'short'
hour: '2-digit', })}
minute: '2-digit' {' в '}
})} {startParsed.time}
</span> {' - '}
</div> {endParsed.time}
</div> </span>
</div>
{(showMentor && lesson.mentor) && ( </div>
<div style={{
marginTop: '12px', {(showMentor && lesson.mentor) && (
paddingTop: '12px', <div style={{
borderTop: `1px solid color-mix(in srgb, ${textColor} 40%, transparent)`, marginTop: '12px',
fontSize: '14px', paddingTop: '12px',
color: textColor, borderTop: `1px solid color-mix(in srgb, ${textColor} 40%, transparent)`,
opacity: 0.9 fontSize: '14px',
}}> color: textColor,
Ментор: {lesson.mentor.first_name} {lesson.mentor.last_name} opacity: 0.9
</div> }}>
)} Ментор: {lesson.mentor.first_name} {lesson.mentor.last_name}
</div>
{(showClient && lesson.client) && ( )}
<div style={{
marginTop: '12px', {(showClient && lesson.client) && (
paddingTop: '12px', <div style={{
borderTop: `1px solid color-mix(in srgb, ${textColor} 40%, transparent)`, marginTop: '12px',
fontSize: '14px', paddingTop: '12px',
color: textColor, borderTop: `1px solid color-mix(in srgb, ${textColor} 40%, transparent)`,
opacity: 0.9 fontSize: '14px',
}}> color: textColor,
Ученик: {lesson.client.first_name} {lesson.client.last_name} opacity: 0.9
</div> }}>
)} Ученик: {lesson.client.first_name} {lesson.client.last_name}
</div>
{canJoin && (lesson.status === 'scheduled' || lesson.status === 'in_progress') && ( )}
<button
type="button" {canJoin && (lesson.status === 'scheduled' || lesson.status === 'in_progress') && (
onClick={handleConnect} <button
disabled={connectLoading} type="button"
style={{ onClick={handleConnect}
marginTop: '12px', disabled={connectLoading}
width: '100%', style={{
padding: '10px 16px', marginTop: '12px',
borderRadius: '12px', width: '100%',
border: 'none', padding: '10px 16px',
background: 'rgba(255, 255, 255, 0.9)', borderRadius: '12px',
color: statusColor, border: 'none',
fontSize: '14px', background: 'rgba(255, 255, 255, 0.9)',
fontWeight: '600', color: statusColor,
cursor: connectLoading ? 'wait' : 'pointer', fontSize: '14px',
display: 'flex', fontWeight: '600',
alignItems: 'center', cursor: connectLoading ? 'wait' : 'pointer',
justifyContent: 'center', display: 'flex',
gap: '8px', alignItems: 'center',
}} justifyContent: 'center',
> gap: '8px',
{connectLoading ? ( }}
<> >
<span className="material-symbols-outlined" style={{ fontSize: 18, animation: 'spin 1s linear infinite' }}> {connectLoading ? (
progress_activity <>
</span> <span className="material-symbols-outlined" style={{ fontSize: 18, animation: 'spin 1s linear infinite' }}>
Подключение... progress_activity
</> </span>
) : ( Подключение...
<> </>
<span className="material-symbols-outlined" style={{ fontSize: 18 }}> ) : (
videocam <>
</span> <span className="material-symbols-outlined" style={{ fontSize: 18 }}>
Подключиться к уроку videocam
</> </span>
)} Подключиться к уроку
</button> </>
)} )}
</div> </button>
); )}
}; </div>
);
};

View File

@ -1,391 +1,404 @@
/** /**
* Календарь занятий для Dashboard ментора * Календарь занятий для Dashboard ментора
* Реализация с нуля на Material UI (M3) в iOS-стиле: * Реализация с нуля на Material UI (M3) в iOS-стиле:
* - сетка месяца 7×6 с тонкими разделителями * - сетка месяца 7×6 с тонкими разделителями
* - число дня в правом верхнем углу * - число дня в правом верхнем углу
* - занятия плашками внутри ячейки (лимит + + ещё N) * - занятия плашками внутри ячейки (лимит + + ещё N)
* - без внутреннего скролла * - без внутреннего скролла
*/ */
'use client'; 'use client';
import React, { useMemo, useState, useEffect, useCallback } from 'react'; import React, { useMemo, useState, useEffect, useCallback } from 'react';
import { import {
addDays, addDays,
addMonths, addMonths,
endOfMonth, endOfMonth,
format, format,
isSameDay, isSameDay,
isSameMonth, isSameMonth,
startOfDay, startOfDay,
startOfMonth, startOfMonth,
startOfWeek, startOfWeek,
subMonths, subMonths,
} from 'date-fns'; } from 'date-fns';
import { ru } from 'date-fns/locale'; import { ru } from 'date-fns/locale';
import { Box, IconButton, Typography } from '@mui/material'; import { Box, IconButton, Typography } from '@mui/material';
import { ChevronLeft, ChevronRight } from '@mui/icons-material'; import { ChevronLeft, ChevronRight } from '@mui/icons-material';
import { parseISOToUserTimezone } from '@/utils/timezone';
interface Lesson {
id: string; interface Lesson {
title: string; id: string;
start_time: string; title: string;
end_time: string; start_time: string;
status?: 'scheduled' | 'in_progress' | 'completed' | 'cancelled'; end_time: string;
client?: { status?: 'scheduled' | 'in_progress' | 'completed' | 'cancelled';
id: string; client?: {
name?: string; id: string;
first_name?: string; name?: string;
last_name?: string; first_name?: string;
}; last_name?: string;
} };
}
interface LessonsCalendarProps {
lessons: Lesson[]; interface LessonsCalendarProps {
onSelectEvent?: (lesson: Lesson) => void; lessons: Lesson[];
onSelectSlot?: (date: Date) => void; onSelectEvent?: (lesson: Lesson) => void;
onMonthChange?: (start: Date, end: Date) => void; onSelectSlot?: (date: Date) => void;
selectedDate?: Date; onMonthChange?: (start: Date, end: Date) => void;
} selectedDate?: Date;
userTimezone?: string;
export const LessonsCalendar: React.FC<LessonsCalendarProps> = ({ }
lessons,
onSelectEvent, export const LessonsCalendar: React.FC<LessonsCalendarProps> = ({
onSelectSlot, lessons,
onMonthChange, onSelectEvent,
selectedDate, onSelectSlot,
}) => { onMonthChange,
const safeSelectedDate = useMemo(() => { selectedDate,
if (selectedDate && !Number.isNaN(selectedDate.getTime())) return startOfDay(selectedDate); userTimezone,
return startOfDay(new Date()); }) => {
}, [selectedDate]); const safeSelectedDate = useMemo(() => {
if (selectedDate && !Number.isNaN(selectedDate.getTime())) return startOfDay(selectedDate);
const [currentMonth, setCurrentMonth] = useState<Date>(() => startOfMonth(safeSelectedDate)); return startOfDay(new Date());
const [slideDir, setSlideDir] = useState<'prev' | 'next' | 'today' | null>(null); }, [selectedDate]);
useEffect(() => { const [currentMonth, setCurrentMonth] = useState<Date>(() => startOfMonth(safeSelectedDate));
setCurrentMonth(startOfMonth(safeSelectedDate)); const [slideDir, setSlideDir] = useState<'prev' | 'next' | 'today' | null>(null);
}, [safeSelectedDate]);
useEffect(() => {
useEffect(() => { setCurrentMonth(startOfMonth(safeSelectedDate));
if (!slideDir) return; }, [safeSelectedDate]);
const t = window.setTimeout(() => setSlideDir(null), 240);
return () => window.clearTimeout(t); useEffect(() => {
}, [slideDir]); if (!slideDir) return;
const t = window.setTimeout(() => setSlideDir(null), 240);
useEffect(() => { return () => window.clearTimeout(t);
const start = startOfMonth(currentMonth); }, [slideDir]);
const end = endOfMonth(currentMonth);
onMonthChange?.(start, end); useEffect(() => {
}, [currentMonth, onMonthChange]); const start = startOfMonth(currentMonth);
const end = endOfMonth(currentMonth);
// Группируем занятия по дате (ключ YYYY-MM-DD) onMonthChange?.(start, end);
const lessonsByDay = useMemo(() => { }, [currentMonth, onMonthChange]);
const map = new Map<string, Lesson[]>();
if (!lessons || lessons.length === 0) return map; // Группируем занятия по дате (ключ YYYY-MM-DD) с учётом timezone пользователя
const lessonsByDay = useMemo(() => {
lessons.forEach((lesson) => { const map = new Map<string, Lesson[]>();
try { if (!lessons || lessons.length === 0) return map;
const day = startOfDay(new Date(lesson.start_time));
if (isNaN(day.getTime())) return; lessons.forEach((lesson) => {
const key = format(day, 'yyyy-MM-dd'); try {
const existing = map.get(key) || []; // Используем timezone пользователя для определения дня
existing.push(lesson); const parsed = parseISOToUserTimezone(lesson.start_time, userTimezone);
map.set(key, existing); const key = parsed.date; // уже в формате 'yyyy-MM-dd'
} catch { const existing = map.get(key) || [];
/* ignore invalid date */ existing.push(lesson);
} map.set(key, existing);
}); } catch {
/* ignore invalid date */
return map; }
}, [lessons]); });
const monthLabel = useMemo(() => { // Сортируем занятия внутри каждого дня по времени
const label = format(currentMonth, 'LLLL yyyy', { locale: ru }); map.forEach((dayLessons, key) => {
return label.charAt(0).toUpperCase() + label.slice(1); dayLessons.sort((a, b) =>
}, [currentMonth]); new Date(a.start_time).getTime() - new Date(b.start_time).getTime()
);
const weekdayLabels = useMemo(() => { map.set(key, dayLessons);
const start = startOfWeek(new Date(), { weekStartsOn: 1, locale: ru }); });
return Array.from({ length: 7 }).map((_, idx) => {
const d = addDays(start, idx); return map;
return format(d, 'EE', { locale: ru }).substring(0, 2).toUpperCase(); }, [lessons, userTimezone]);
});
}, []); const monthLabel = useMemo(() => {
const label = format(currentMonth, 'LLLL yyyy', { locale: ru });
const daysGrid = useMemo(() => { return label.charAt(0).toUpperCase() + label.slice(1);
const monthStart = startOfMonth(currentMonth); }, [currentMonth]);
const gridStart = startOfWeek(monthStart, { weekStartsOn: 1, locale: ru });
return Array.from({ length: 42 }).map((_, i) => addDays(gridStart, i)); const weekdayLabels = useMemo(() => {
}, [currentMonth]); const start = startOfWeek(new Date(), { weekStartsOn: 1, locale: ru });
return Array.from({ length: 7 }).map((_, idx) => {
const handleDayClick = useCallback( const d = addDays(start, idx);
(day: Date) => { return format(d, 'EE', { locale: ru }).substring(0, 2).toUpperCase();
onSelectSlot?.(startOfDay(day)); });
}, }, []);
[onSelectSlot]
); const daysGrid = useMemo(() => {
const monthStart = startOfMonth(currentMonth);
const handleLessonClick = useCallback( const gridStart = startOfWeek(monthStart, { weekStartsOn: 1, locale: ru });
(day: Date, lesson: Lesson, e: React.MouseEvent) => { return Array.from({ length: 42 }).map((_, i) => addDays(gridStart, i));
e.stopPropagation(); }, [currentMonth]);
onSelectSlot?.(startOfDay(day));
onSelectEvent?.(lesson); const handleDayClick = useCallback(
}, (day: Date) => {
[onSelectEvent, onSelectSlot] onSelectSlot?.(startOfDay(day));
); },
[onSelectSlot]
const goToday = useCallback(() => { );
const today = startOfDay(new Date());
const curr = startOfMonth(currentMonth).getTime(); const handleLessonClick = useCallback(
const target = startOfMonth(today).getTime(); (day: Date, lesson: Lesson, e: React.MouseEvent) => {
if (target < curr) setSlideDir('prev'); e.stopPropagation();
else if (target > curr) setSlideDir('next'); onSelectSlot?.(startOfDay(day));
else setSlideDir('today'); onSelectEvent?.(lesson);
setCurrentMonth(startOfMonth(today)); },
onSelectSlot?.(today); [onSelectEvent, onSelectSlot]
}, [currentMonth, onSelectSlot]); );
const goPrevMonth = useCallback(() => { const goToday = useCallback(() => {
setSlideDir('prev'); const today = startOfDay(new Date());
setCurrentMonth((m) => subMonths(m, 1)); const curr = startOfMonth(currentMonth).getTime();
}, []); const target = startOfMonth(today).getTime();
if (target < curr) setSlideDir('prev');
const goNextMonth = useCallback(() => { else if (target > curr) setSlideDir('next');
setSlideDir('next'); else setSlideDir('today');
setCurrentMonth((m) => addMonths(m, 1)); setCurrentMonth(startOfMonth(today));
}, []); onSelectSlot?.(today);
}, [currentMonth, onSelectSlot]);
return (
<Box sx={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column' }}> const goPrevMonth = useCallback(() => {
{/* Header как в iOS */} setSlideDir('prev');
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}> setCurrentMonth((m) => subMonths(m, 1));
<Typography sx={{ fontSize: 18, fontWeight: 700, color: 'var(--md-sys-color-on-surface)' }}> }, []);
{monthLabel}
</Typography> const goNextMonth = useCallback(() => {
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}> setSlideDir('next');
<IconButton setCurrentMonth((m) => addMonths(m, 1));
onClick={goPrevMonth} }, []);
size="small"
sx={{ return (
borderRadius: 2, <Box sx={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column' }}>
border: '1px solid var(--md-sys-color-outline-variant)', {/* Header как в iOS */}
backgroundColor: 'var(--md-sys-color-surface)', <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
}} <Typography sx={{ fontSize: 18, fontWeight: 700, color: 'var(--md-sys-color-on-surface)' }}>
> {monthLabel}
<ChevronLeft fontSize="small" /> </Typography>
</IconButton> <Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<IconButton <IconButton
onClick={goToday} onClick={goPrevMonth}
size="small" size="small"
sx={{ sx={{
borderRadius: 2, borderRadius: 2,
px: 1.25, border: '1px solid var(--md-sys-color-outline-variant)',
border: '1px solid var(--md-sys-color-outline-variant)', backgroundColor: 'var(--md-sys-color-surface)',
backgroundColor: 'var(--md-sys-color-surface)', }}
}} >
> <ChevronLeft fontSize="small" />
<Typography sx={{ fontSize: 12, fontWeight: 700, color: 'var(--md-sys-color-on-surface)' }}> </IconButton>
Сегодня <IconButton
</Typography> onClick={goToday}
</IconButton> size="small"
<IconButton sx={{
onClick={goNextMonth} borderRadius: 2,
size="small" px: 1.25,
sx={{ border: '1px solid var(--md-sys-color-outline-variant)',
borderRadius: 2, backgroundColor: 'var(--md-sys-color-surface)',
border: '1px solid var(--md-sys-color-outline-variant)', }}
backgroundColor: 'var(--md-sys-color-surface)', >
}} <Typography sx={{ fontSize: 12, fontWeight: 700, color: 'var(--md-sys-color-on-surface)' }}>
> Сегодня
<ChevronRight fontSize="small" /> </Typography>
</IconButton> </IconButton>
</Box> <IconButton
</Box> onClick={goNextMonth}
size="small"
{/* Grid */} sx={{
<Box borderRadius: 2,
sx={{ border: '1px solid var(--md-sys-color-outline-variant)',
flex: 1, backgroundColor: 'var(--md-sys-color-surface)',
minHeight: 0, }}
borderRadius: 2, >
border: '1px solid var(--md-sys-color-outline-variant)', <ChevronRight fontSize="small" />
backgroundColor: 'var(--md-sys-color-surface)', </IconButton>
overflow: 'hidden', </Box>
display: 'flex', </Box>
flexDirection: 'column',
}} {/* Grid */}
> <Box
{/* Weekdays */} sx={{
<Box flex: 1,
sx={{ minHeight: 0,
display: 'grid', borderRadius: 2,
gridTemplateColumns: 'repeat(7, 1fr)', border: '1px solid var(--md-sys-color-outline-variant)',
borderBottom: '1px solid var(--md-sys-color-outline-variant)', backgroundColor: 'var(--md-sys-color-surface)',
}} overflow: 'hidden',
> display: 'flex',
{weekdayLabels.map((label, idx) => ( flexDirection: 'column',
<Box }}
key={`${label}-${idx}`} >
sx={{ {/* Weekdays */}
py: 1, <Box
textAlign: 'center', sx={{
fontSize: 11, display: 'grid',
fontWeight: 800, gridTemplateColumns: 'repeat(7, 1fr)',
letterSpacing: '0.04em', borderBottom: '1px solid var(--md-sys-color-outline-variant)',
color: 'var(--md-sys-color-on-surface-variant)', }}
}} >
> {weekdayLabels.map((label, idx) => (
{label} <Box
</Box> key={`${label}-${idx}`}
))} sx={{
</Box> py: 1,
textAlign: 'center',
{/* Days */} fontSize: 11,
<Box fontWeight: 800,
sx={{ letterSpacing: '0.04em',
flex: 1, color: 'var(--md-sys-color-on-surface-variant)',
minHeight: 0, }}
display: 'grid', >
gridTemplateColumns: 'repeat(7, 1fr)', {label}
gridTemplateRows: 'repeat(6, minmax(110px, 1fr))', </Box>
// слайдер при переключении месяцев ))}
'@keyframes iosCalSlideNext': { </Box>
from: { transform: 'translateX(16px)', opacity: 0.2 },
to: { transform: 'translateX(0)', opacity: 1 }, {/* Days */}
}, <Box
'@keyframes iosCalSlidePrev': { sx={{
from: { transform: 'translateX(-16px)', opacity: 0.2 }, flex: 1,
to: { transform: 'translateX(0)', opacity: 1 }, minHeight: 0,
}, display: 'grid',
'@keyframes iosCalFade': { gridTemplateColumns: 'repeat(7, 1fr)',
from: { opacity: 0.4 }, gridTemplateRows: 'repeat(6, minmax(110px, 1fr))',
to: { opacity: 1 }, // слайдер при переключении месяцев
}, '@keyframes iosCalSlideNext': {
animation: from: { transform: 'translateX(16px)', opacity: 0.2 },
slideDir === 'next' to: { transform: 'translateX(0)', opacity: 1 },
? 'iosCalSlideNext 220ms ease' },
: slideDir === 'prev' '@keyframes iosCalSlidePrev': {
? 'iosCalSlidePrev 220ms ease' from: { transform: 'translateX(-16px)', opacity: 0.2 },
: slideDir === 'today' to: { transform: 'translateX(0)', opacity: 1 },
? 'iosCalFade 180ms ease' },
: 'none', '@keyframes iosCalFade': {
}} from: { opacity: 0.4 },
key={`${currentMonth.getFullYear()}-${currentMonth.getMonth()}`} to: { opacity: 1 },
> },
{daysGrid.map((day) => { animation:
const dayKey = format(startOfDay(day), 'yyyy-MM-dd'); slideDir === 'next'
const dayLessons = lessonsByDay.get(dayKey) || []; ? 'iosCalSlideNext 220ms ease'
: slideDir === 'prev'
const inMonth = isSameMonth(day, currentMonth); ? 'iosCalSlidePrev 220ms ease'
const isToday = isSameDay(day, new Date()); : slideDir === 'today'
const isSelected = isSameDay(startOfDay(day), safeSelectedDate); ? 'iosCalFade 180ms ease'
: 'none',
const bg = !inMonth }}
? 'var(--md-sys-color-surface-variant)' key={`${currentMonth.getFullYear()}-${currentMonth.getMonth()}`}
: isSelected >
? 'rgba(116, 68, 253, 0.10)' {daysGrid.map((day) => {
: 'var(--md-sys-color-surface)'; const dayKey = format(startOfDay(day), 'yyyy-MM-dd');
const dayLessons = lessonsByDay.get(dayKey) || [];
return (
<Box const inMonth = isSameMonth(day, currentMonth);
key={dayKey} const isToday = isSameDay(day, new Date());
role="button" const isSelected = isSameDay(startOfDay(day), safeSelectedDate);
tabIndex={0}
onClick={() => handleDayClick(day)} const bg = !inMonth
onKeyDown={(e) => { ? 'var(--md-sys-color-surface-variant)'
if (e.key === 'Enter' || e.key === ' ') handleDayClick(day); : isSelected
}} ? 'rgba(116, 68, 253, 0.10)'
sx={{ : 'var(--md-sys-color-surface)';
position: 'relative',
minHeight: '110px', return (
px: 1, <Box
pt: 1, key={dayKey}
pb: 0.75, role="button"
borderRight: '1px solid var(--md-sys-color-outline-variant)', tabIndex={0}
borderBottom: '1px solid var(--md-sys-color-outline-variant)', onClick={() => handleDayClick(day)}
backgroundColor: bg, onKeyDown={(e) => {
overflow: 'hidden', if (e.key === 'Enter' || e.key === ' ') handleDayClick(day);
cursor: 'pointer', }}
opacity: inMonth ? 1 : 0.55, sx={{
transition: 'background-color 120ms ease', position: 'relative',
'&:hover': { minHeight: '110px',
backgroundColor: isSelected ? 'rgba(116, 68, 253, 0.13)' : 'rgba(116, 68, 253, 0.06)', px: 1,
}, pt: 1,
}} pb: 0.75,
> borderRight: '1px solid var(--md-sys-color-outline-variant)',
{/* Day number */} borderBottom: '1px solid var(--md-sys-color-outline-variant)',
<Box backgroundColor: bg,
sx={{ overflow: 'hidden',
position: 'absolute', cursor: 'pointer',
top: 8, opacity: inMonth ? 1 : 0.55,
right: 10, transition: 'background-color 120ms ease',
fontSize: 12, '&:hover': {
fontWeight: 800, backgroundColor: isSelected ? 'rgba(116, 68, 253, 0.13)' : 'rgba(116, 68, 253, 0.06)',
color: isToday ? 'var(--md-sys-color-primary)' : 'var(--md-sys-color-on-surface-variant)', },
lineHeight: 1, }}
}} >
> {/* Day number */}
{format(day, 'd')} <Box
</Box> sx={{
position: 'absolute',
{/* Lessons (no scroll) */} top: 8,
<Box right: 10,
sx={{ fontSize: 12,
mt: 2.5, fontWeight: 800,
display: 'flex', color: isToday ? 'var(--md-sys-color-primary)' : 'var(--md-sys-color-on-surface-variant)',
flexDirection: 'column', lineHeight: 1,
gap: 0.5, }}
height: 'calc(100% - 28px)', >
overflow: 'hidden', {format(day, 'd')}
}} </Box>
>
{dayLessons.slice(0, 2).map((lesson) => { {/* Lessons (no scroll) */}
const timeStr = (() => { <Box
try { sx={{
return format(new Date(lesson.start_time), 'HH:mm', { locale: ru }); mt: 2.5,
} catch { display: 'flex',
return ''; flexDirection: 'column',
} gap: 0.5,
})(); height: 'calc(100% - 28px)',
const baseTitle = lesson.client?.first_name || lesson.client?.name || lesson.title; overflow: 'hidden',
const title = }}
baseTitle && baseTitle.length > 18 ? baseTitle.substring(0, 16) + '…' : baseTitle; >
{dayLessons.slice(0, 2).map((lesson) => {
return ( const timeStr = (() => {
<Box try {
key={lesson.id} // Используем timezone пользователя для отображения времени
onClick={(e) => handleLessonClick(day, lesson, e)} const parsed = parseISOToUserTimezone(lesson.start_time, userTimezone);
sx={{ return parsed.time;
px: 0.75, } catch {
py: 0.25, return '';
borderRadius: 1.25, }
backgroundColor: 'rgba(116, 68, 253, 0.14)', })();
color: 'var(--md-sys-color-on-surface)', const baseTitle = lesson.client?.first_name || lesson.client?.name || lesson.title;
fontSize: 11, const title =
fontWeight: 700, baseTitle && baseTitle.length > 18 ? baseTitle.substring(0, 16) + '…' : baseTitle;
whiteSpace: 'nowrap',
overflow: 'hidden', return (
textOverflow: 'ellipsis', <Box
border: '1px solid rgba(116, 68, 253, 0.20)', key={lesson.id}
}} onClick={(e) => handleLessonClick(day, lesson, e)}
> sx={{
{timeStr ? `${timeStr} ${title}` : title} px: 0.75,
</Box> py: 0.25,
); borderRadius: 1.25,
})} backgroundColor: 'rgba(116, 68, 253, 0.14)',
color: 'var(--md-sys-color-on-surface)',
{dayLessons.length > 2 && ( fontSize: 11,
<Box sx={{ mt: 0.25, fontSize: 11, fontWeight: 800, color: 'var(--md-sys-color-primary)' }}> fontWeight: 700,
+ ещё {dayLessons.length - 2} whiteSpace: 'nowrap',
</Box> overflow: 'hidden',
)} textOverflow: 'ellipsis',
</Box> border: '1px solid rgba(116, 68, 253, 0.20)',
</Box> }}
); >
})} {timeStr ? `${timeStr} ${title}` : title}
</Box> </Box>
</Box> );
</Box> })}
);
}; {dayLessons.length > 2 && (
<Box sx={{ mt: 0.25, fontSize: 11, fontWeight: 800, color: 'var(--md-sys-color-primary)' }}>
+ ещё {dayLessons.length - 2}
</Box>
)}
</Box>
</Box>
);
})}
</Box>
</Box>
</Box>
);
};

View File

@ -0,0 +1,718 @@
'use client';
import React, { useEffect, useState, useRef } from 'react';
import {
updateHomework,
publishHomework,
type Homework,
type HomeworkFileItem,
} from '@/api/homework';
import { getMyMaterials } from '@/api/materials';
import type { Material } from '@/api/materials';
import apiClient from '@/lib/api-client';
const MAX_FILE_SIZE_MB = 10;
const MAX_FILES = 10;
interface EditHomeworkDraftModalProps {
isOpen: boolean;
homework: Homework | null;
onClose: () => void;
onSuccess: () => void;
}
function getFileUrl(file: HomeworkFileItem | null): string {
if (!file?.file) return '';
if (file.file.startsWith('http')) return file.file;
const base = typeof window !== 'undefined' ? `${window.location.protocol}//${window.location.hostname}:8123` : '';
return file.file.startsWith('/') ? `${base}${file.file}` : `${base}/${file.file}`;
}
export function EditHomeworkDraftModal({
isOpen,
homework,
onClose,
onSuccess,
}: EditHomeworkDraftModalProps) {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [deadline, setDeadline] = useState('');
const [existingFiles, setExistingFiles] = useState<HomeworkFileItem[]>([]);
const [newFiles, setNewFiles] = useState<File[]>([]);
const [uploadingFiles, setUploadingFiles] = useState<Set<string>>(new Set());
const [materials, setMaterials] = useState<Material[]>([]);
const [materialsLoading, setMaterialsLoading] = useState(false);
const [selectedMaterialIds, setSelectedMaterialIds] = useState<Set<string>>(new Set());
const [materialsSearch, setMaterialsSearch] = useState('');
const [saving, setSaving] = useState(false);
const [publishing, setPublishing] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!isOpen || !homework) return;
setTitle(homework.title || '');
setDescription(homework.description || '');
setDeadline(homework.deadline ? homework.deadline.slice(0, 16) : '');
setExistingFiles(homework.files?.filter(f => f.file_type === 'assignment') || []);
setNewFiles([]);
setSelectedMaterialIds(new Set());
setError(null);
}, [isOpen, homework]);
useEffect(() => {
if (!isOpen) return;
setMaterialsLoading(true);
getMyMaterials()
.then((list) => setMaterials(Array.isArray(list) ? list : []))
.catch(() => setMaterials([]))
.finally(() => setMaterialsLoading(false));
}, [isOpen]);
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (!files.length || !homework) return;
const validFiles: File[] = [];
for (const file of files) {
if (file.size > MAX_FILE_SIZE_MB * 1024 * 1024) {
setError(`Файл "${file.name}" больше ${MAX_FILE_SIZE_MB} МБ`);
continue;
}
if (existingFiles.length + newFiles.length + validFiles.length >= MAX_FILES) {
setError(`Максимум ${MAX_FILES} файлов`);
break;
}
validFiles.push(file);
}
for (const file of validFiles) {
const fileKey = `${file.name}-${Date.now()}`;
setUploadingFiles((prev) => new Set(prev).add(fileKey));
setNewFiles((prev) => [...prev, file]);
try {
const formData = new FormData();
formData.append('homework', String(homework.id));
formData.append('file_type', 'assignment');
formData.append('file', file);
const res = await apiClient.post<HomeworkFileItem>('/homework/files/', formData);
setExistingFiles((prev) => [...prev, res.data]);
setNewFiles((prev) => prev.filter((f) => f !== file));
} catch (err) {
setError(err instanceof Error ? err.message : 'Ошибка загрузки файла');
setNewFiles((prev) => prev.filter((f) => f !== file));
} finally {
setUploadingFiles((prev) => {
const next = new Set(prev);
next.delete(fileKey);
return next;
});
}
}
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleRemoveFile = async (fileId: number) => {
try {
await apiClient.delete(`/homework/files/${fileId}/`);
setExistingFiles((prev) => prev.filter((f) => f.id !== fileId));
} catch (err) {
setError(err instanceof Error ? err.message : 'Ошибка удаления файла');
}
};
const handleMaterialToggle = (materialId: string) => {
setSelectedMaterialIds((prev) => {
const next = new Set(prev);
if (next.has(materialId)) {
next.delete(materialId);
} else {
next.add(materialId);
}
return next;
});
};
const attachMaterialFiles = async () => {
if (!homework || selectedMaterialIds.size === 0) return;
for (const materialId of selectedMaterialIds) {
const material = materials.find((m) => String(m.id) === materialId);
if (!material?.file) continue;
try {
const response = await fetch(material.file);
const blob = await response.blob();
const filename = material.title || material.file.split('/').pop() || 'material';
const file = new File([blob], filename, { type: blob.type });
const formData = new FormData();
formData.append('homework', String(homework.id));
formData.append('file_type', 'assignment');
formData.append('file', file);
const res = await apiClient.post<HomeworkFileItem>('/homework/files/', formData);
setExistingFiles((prev) => [...prev, res.data]);
} catch {
// Ignore material attach errors
}
}
setSelectedMaterialIds(new Set());
};
const handleSave = async () => {
if (!homework) return;
try {
setError(null);
setSaving(true);
await attachMaterialFiles();
await updateHomework(homework.id, {
title: title.trim() || homework.title,
description: description.trim(),
deadline: deadline ? new Date(deadline).toISOString() : null,
});
onSuccess();
} catch (e) {
setError(e instanceof Error ? e.message : 'Ошибка сохранения');
} finally {
setSaving(false);
}
};
const handlePublish = async () => {
if (!homework) return;
if (!title.trim()) {
setError('Укажите название задания');
return;
}
if (!description.trim()) {
setError('Укажите текст задания');
return;
}
try {
setError(null);
setPublishing(true);
await attachMaterialFiles();
await updateHomework(homework.id, {
title: title.trim(),
description: description.trim(),
deadline: deadline ? new Date(deadline).toISOString() : null,
});
await publishHomework(homework.id);
onSuccess();
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : 'Ошибка публикации');
} finally {
setPublishing(false);
}
};
if (!isOpen || !homework) return null;
const isLoading = saving || publishing || uploadingFiles.size > 0;
const filteredMaterials = materials.filter((m) => {
if (!materialsSearch.trim()) return true;
const q = materialsSearch.toLowerCase();
return (
(m.title || '').toLowerCase().includes(q) ||
(m.description || '').toLowerCase().includes(q)
);
});
return (
<>
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0, 0, 0, 0.5)',
zIndex: 999,
}}
onClick={onClose}
/>
<div
className="ios26-panel"
style={{
position: 'fixed',
top: 0,
right: 0,
bottom: 0,
width: '90vw',
maxWidth: 600,
background: 'var(--md-sys-color-surface)',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
zIndex: 1001,
}}
>
{/* Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
padding: '20px 24px',
borderBottom: '1px solid var(--md-sys-color-outline-variant)',
flexShrink: 0,
}}
>
<h2
style={{
fontSize: 20,
fontWeight: 600,
margin: 0,
color: 'var(--md-sys-color-on-surface)',
}}
>
Заполнить домашнее задание
</h2>
<button
type="button"
onClick={onClose}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 44,
height: 44,
borderRadius: 12,
border: 'none',
background: 'none',
cursor: 'pointer',
color: 'var(--md-sys-color-on-surface-variant)',
}}
>
<span className="material-symbols-outlined" style={{ fontSize: 24 }}>
close
</span>
</button>
</div>
{/* Content */}
<div
style={{
padding: '24px',
paddingBottom: 'max(24px, env(safe-area-inset-bottom, 0px) + 100px)',
overflowY: 'auto',
flex: 1,
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
{/* Title */}
<div>
<label
style={{
display: 'block',
fontSize: 14,
fontWeight: 500,
marginBottom: 8,
color: 'var(--md-sys-color-on-surface-variant)',
}}
>
Название задания *
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Введите название"
disabled={isLoading}
style={{
width: '100%',
padding: '12px 16px',
borderRadius: 12,
border: '1px solid var(--md-sys-color-outline)',
background: 'var(--md-sys-color-surface)',
fontSize: 15,
color: 'var(--md-sys-color-on-surface)',
}}
/>
</div>
{/* Description */}
<div>
<label
style={{
display: 'block',
fontSize: 14,
fontWeight: 500,
marginBottom: 8,
color: 'var(--md-sys-color-on-surface-variant)',
}}
>
Текст задания *
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={4}
placeholder="Опишите задание, шаги, ссылки..."
disabled={isLoading}
style={{
width: '100%',
padding: '12px 16px',
borderRadius: 12,
border: '1px solid var(--md-sys-color-outline)',
background: 'var(--md-sys-color-surface)',
fontSize: 15,
color: 'var(--md-sys-color-on-surface)',
resize: 'vertical',
}}
/>
</div>
{/* Deadline */}
<div>
<label
style={{
display: 'block',
fontSize: 14,
fontWeight: 500,
marginBottom: 8,
color: 'var(--md-sys-color-on-surface-variant)',
}}
>
Дедлайн (опционально)
</label>
<input
type="datetime-local"
value={deadline}
onChange={(e) => setDeadline(e.target.value)}
disabled={isLoading}
style={{
padding: '12px 16px',
borderRadius: 12,
border: '1px solid var(--md-sys-color-outline)',
background: 'var(--md-sys-color-surface)',
fontSize: 15,
color: 'var(--md-sys-color-on-surface)',
}}
/>
</div>
{/* Files */}
<div>
<div
style={{
fontSize: 14,
fontWeight: 500,
marginBottom: 8,
color: 'var(--md-sys-color-on-surface-variant)',
}}
>
Файлы и материалы к ДЗ
</div>
{/* File upload */}
<input
type="file"
multiple
ref={fileInputRef}
className="hidden"
id="edit-homework-file"
onChange={handleFileChange}
disabled={isLoading}
accept=".pdf,.doc,.docx,.txt,.jpg,.jpeg,.png,.zip,.rar"
style={{ display: 'none' }}
/>
<label
htmlFor="edit-homework-file"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
padding: '14px 20px',
borderRadius: 12,
border: '2px dashed var(--md-sys-color-outline)',
background: 'var(--md-sys-color-surface-variant)',
color: 'var(--md-sys-color-on-surface-variant)',
fontSize: 14,
cursor: isLoading ? 'not-allowed' : 'pointer',
}}
>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>
upload_file
</span>
{uploadingFiles.size > 0
? `Загрузка ${uploadingFiles.size}`
: 'Загрузить файлы'}
</label>
{/* Existing files */}
{existingFiles.length > 0 && (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 8,
marginTop: 12,
}}
>
{existingFiles.map((file) => {
const url = getFileUrl(file);
const isImage = /\.(jpe?g|png|gif|webp|bmp)$/i.test(
file.filename || ''
);
return (
<div
key={file.id}
style={{
width: 80,
aspectRatio: '1',
borderRadius: 12,
overflow: 'hidden',
border: '2px solid var(--md-sys-color-outline-variant)',
background: 'var(--md-sys-color-surface-variant)',
position: 'relative',
}}
>
{isImage && url ? (
<img
src={url}
alt=""
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<span
className="material-symbols-outlined"
style={{
fontSize: 28,
color: 'var(--md-sys-color-primary)',
}}
>
description
</span>
</div>
)}
<span
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
fontSize: 10,
padding: 4,
background: 'rgba(0,0,0,0.6)',
color: '#fff',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{file.filename}
</span>
<button
type="button"
onClick={() => handleRemoveFile(file.id)}
disabled={isLoading}
style={{
position: 'absolute',
top: 2,
right: 2,
width: 20,
height: 20,
borderRadius: '50%',
border: 'none',
background: 'var(--md-sys-color-error)',
color: '#fff',
fontSize: 14,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
×
</button>
</div>
);
})}
</div>
)}
</div>
{/* Materials */}
<div>
<div
style={{
fontSize: 14,
fontWeight: 500,
marginBottom: 8,
color: 'var(--md-sys-color-on-surface-variant)',
}}
>
Прикрепить из моих материалов
</div>
<input
type="text"
value={materialsSearch}
onChange={(e) => setMaterialsSearch(e.target.value)}
placeholder="Поиск материалов..."
disabled={isLoading}
style={{
width: '100%',
padding: '10px 14px',
borderRadius: 10,
border: '1px solid var(--md-sys-color-outline)',
background: 'var(--md-sys-color-surface)',
fontSize: 14,
marginBottom: 8,
}}
/>
{materialsLoading ? (
<p
style={{
fontSize: 13,
color: 'var(--md-sys-color-on-surface-variant)',
}}
>
Загрузка материалов
</p>
) : filteredMaterials.length === 0 ? (
<p
style={{
fontSize: 13,
color: 'var(--md-sys-color-on-surface-variant)',
}}
>
Нет материалов
</p>
) : (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 8,
maxHeight: 160,
overflowY: 'auto',
}}
>
{filteredMaterials.slice(0, 20).map((m) => {
const materialId = String(m.id);
const isSelected = selectedMaterialIds.has(materialId);
return (
<button
key={materialId}
type="button"
onClick={() => handleMaterialToggle(materialId)}
disabled={isLoading}
style={{
padding: '8px 14px',
borderRadius: 10,
border: `2px solid ${
isSelected
? 'var(--md-sys-color-primary)'
: 'var(--md-sys-color-outline-variant)'
}`,
background: isSelected
? 'var(--md-sys-color-primary-container)'
: 'var(--md-sys-color-surface-variant)',
color: isSelected
? 'var(--md-sys-color-on-primary-container)'
: 'var(--md-sys-color-on-surface)',
fontSize: 13,
cursor: 'pointer',
maxWidth: 200,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{m.title || 'Без названия'}
</button>
);
})}
</div>
)}
</div>
{/* Error */}
{error && (
<div
style={{
padding: 16,
background: 'rgba(186,26,26,0.1)',
borderRadius: 12,
color: 'var(--md-sys-color-error)',
fontSize: 14,
}}
>
{error}
</div>
)}
{/* Actions */}
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 12,
paddingTop: 8,
}}
>
<button
type="button"
onClick={handlePublish}
disabled={isLoading}
style={{
padding: '14px 28px',
borderRadius: 14,
border: 'none',
background: 'var(--md-sys-color-primary)',
color: 'var(--md-sys-color-on-primary)',
fontSize: 16,
fontWeight: 600,
cursor: isLoading ? 'not-allowed' : 'pointer',
opacity: isLoading ? 0.7 : 1,
}}
>
{publishing ? 'Публикация...' : 'Опубликовать ДЗ'}
</button>
<button
type="button"
onClick={handleSave}
disabled={isLoading}
style={{
padding: '14px 28px',
borderRadius: 14,
border: '1px solid var(--md-sys-color-outline)',
background: 'transparent',
color: 'var(--md-sys-color-on-surface)',
fontSize: 16,
fontWeight: 600,
cursor: isLoading ? 'not-allowed' : 'pointer',
opacity: isLoading ? 0.7 : 1,
}}
>
{saving ? 'Сохранение...' : 'Сохранить черновик'}
</button>
</div>
</div>
</div>
</div>
</>
);
}

View File

@ -18,6 +18,7 @@ import {
} from '@/api/homework'; } from '@/api/homework';
import { getBackendOrigin } from '@/lib/api-client'; import { getBackendOrigin } from '@/lib/api-client';
import { SubmitHomeworkModal } from './SubmitHomeworkModal'; import { SubmitHomeworkModal } from './SubmitHomeworkModal';
import { EditHomeworkDraftModal } from './EditHomeworkDraftModal';
interface HomeworkDetailsModalProps { interface HomeworkDetailsModalProps {
isOpen: boolean; isOpen: boolean;
@ -303,6 +304,9 @@ export function HomeworkDetailsModal({ isOpen, homework, userRole, childId, onCl
const [imagePreviewUrl, setImagePreviewUrl] = useState<string | null>(null); const [imagePreviewUrl, setImagePreviewUrl] = useState<string | null>(null);
const [documentViewer, setDocumentViewer] = useState<{ url: string; filename: string; type: 'pdf' | 'text' } | null>(null); const [documentViewer, setDocumentViewer] = useState<{ url: string; filename: string; type: 'pdf' | 'text' } | null>(null);
// Модальное окно редактирования черновика ДЗ
const [editDraftOpen, setEditDraftOpen] = useState(false);
useEffect(() => { useEffect(() => {
if (!isOpen || !homework) return; if (!isOpen || !homework) return;
setError(null); setError(null);
@ -484,6 +488,39 @@ export function HomeworkDetailsModal({ isOpen, homework, userRole, childId, onCl
flex: 1, flex: 1,
}} }}
> >
{/* Черновик fill_later — показываем кнопку редактирования */}
{userRole === 'mentor' && homework.fill_later && (
<div style={{ marginBottom: 28, padding: 20, background: 'var(--md-sys-color-tertiary-container)', borderRadius: 16 }}>
<h4 style={{ fontSize: 16, fontWeight: 600, marginBottom: 12, color: 'var(--md-sys-color-on-tertiary-container)' }}>
Черновик требуется заполнение
</h4>
<p style={{ fontSize: 14, color: 'var(--md-sys-color-on-tertiary-container)', marginBottom: 16, opacity: 0.8 }}>
Это домашнее задание было создано с пометкой «заполнить позже». Заполните детали задания и опубликуйте его для студента.
</p>
<button
type="button"
onClick={() => setEditDraftOpen(true)}
style={{
padding: '14px 28px',
borderRadius: 14,
border: 'none',
background: 'var(--md-sys-color-primary)',
color: 'var(--md-sys-color-on-primary)',
fontSize: 16,
fontWeight: 600,
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: 8,
}}
>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>edit</span>
Заполнить задание
</button>
</div>
)}
{/* Обычное отображение для опубликованных заданий */}
{homework.description && ( {homework.description && (
<div style={{ marginBottom: 28 }}> <div style={{ marginBottom: 28 }}>
<h4 style={{ fontSize: 16, fontWeight: 600, marginBottom: 12 }}>Описание</h4> <h4 style={{ fontSize: 16, fontWeight: 600, marginBottom: 12 }}>Описание</h4>
@ -1358,6 +1395,16 @@ export function HomeworkDetailsModal({ isOpen, homework, userRole, childId, onCl
}); });
}} }}
/> />
<EditHomeworkDraftModal
isOpen={editDraftOpen}
homework={homework}
onClose={() => setEditDraftOpen(false)}
onSuccess={() => {
setEditDraftOpen(false);
onSuccess();
}}
/>
</> </>
); );
} }

View File

@ -1,327 +1,339 @@
'use client'; 'use client';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { completeLesson, type Lesson } from '@/api/schedule'; import { completeLesson, type Lesson } from '@/api/schedule';
import { useAuth } from '@/contexts/AuthContext';
interface FeedbackModalProps { import { parseISOToUserTimezone } from '@/utils/timezone';
isOpen: boolean;
lesson: Lesson | null; interface FeedbackModalProps {
onClose: () => void; isOpen: boolean;
onSuccess: () => void; lesson: Lesson | null;
} onClose: () => void;
onSuccess: () => void;
export function FeedbackModal({ isOpen, lesson, onClose, onSuccess }: FeedbackModalProps) { }
const [formData, setFormData] = useState({
mentor_grade: '', export function FeedbackModal({ isOpen, lesson, onClose, onSuccess }: FeedbackModalProps) {
school_grade: '', const { user } = useAuth();
mentor_notes: '', const [formData, setFormData] = useState({
}); mentor_grade: '',
const [loading, setLoading] = useState(false); school_grade: '',
const [error, setError] = useState<string | null>(null); mentor_notes: '',
});
useEffect(() => { const [loading, setLoading] = useState(false);
if (isOpen && lesson) { const [error, setError] = useState<string | null>(null);
setFormData({
mentor_grade: lesson.mentor_grade?.toString() || '', // Парсим время с учётом timezone пользователя
school_grade: lesson.school_grade?.toString() || '', const parsedTimes = useMemo(() => {
mentor_notes: lesson.mentor_notes || '', if (!lesson) return null;
}); return {
} start: parseISOToUserTimezone(lesson.start_time, user?.timezone),
}, [isOpen, lesson]); end: parseISOToUserTimezone(lesson.end_time, user?.timezone),
};
if (!lesson) return null; }, [lesson, user?.timezone]);
const visible = isOpen; useEffect(() => {
if (isOpen && lesson) {
const handleSubmit = async (e: React.FormEvent) => { setFormData({
e.preventDefault(); mentor_grade: lesson.mentor_grade?.toString() || '',
setError(null); school_grade: lesson.school_grade?.toString() || '',
setLoading(true); mentor_notes: lesson.mentor_notes || '',
try { });
await completeLesson( }
lesson.id, }, [isOpen, lesson]);
formData.mentor_notes.trim(),
formData.mentor_grade ? parseInt(formData.mentor_grade) : undefined, if (!lesson || !parsedTimes) return null;
formData.school_grade ? parseInt(formData.school_grade) : undefined
); const visible = isOpen;
onSuccess();
onClose(); const handleSubmit = async (e: React.FormEvent) => {
} catch (err: unknown) { e.preventDefault();
const errMsg = err instanceof Error ? err.message : 'Ошибка сохранения обратной связи'; setError(null);
setError(String(errMsg)); setLoading(true);
} finally { try {
setLoading(false); await completeLesson(
} lesson.id,
}; formData.mentor_notes.trim(),
formData.mentor_grade ? parseInt(formData.mentor_grade) : undefined,
const handleClose = () => { formData.school_grade ? parseInt(formData.school_grade) : undefined
if (!loading) { );
setError(null); onSuccess();
onClose(); onClose();
} } catch (err: unknown) {
}; const errMsg = err instanceof Error ? err.message : 'Ошибка сохранения обратной связи';
setError(String(errMsg));
const clientName = } finally {
typeof lesson.client === 'object' && lesson.client?.user setLoading(false);
? `${lesson.client.user.first_name} ${lesson.client.user.last_name}` }
: (lesson as { client_name?: string }).client_name || 'Студент'; };
const subjectName = const handleClose = () => {
typeof lesson.subject === 'string' if (!loading) {
? lesson.subject setError(null);
: (lesson.subject as { name?: string } | null | undefined)?.name || 'Занятие'; onClose();
}
return ( };
<>
{/* Затемнённый фон — клик закрывает панель */} const clientName =
<div typeof lesson.client === 'object' && lesson.client?.user
role="presentation" ? `${lesson.client.user.first_name} ${lesson.client.user.last_name}`
style={{ : (lesson as { client_name?: string }).client_name || 'Студент';
position: 'fixed',
inset: 0, const subjectName =
background: 'rgba(0,0,0,0.4)', typeof lesson.subject === 'string'
zIndex: 999, ? lesson.subject
opacity: visible ? 1 : 0, : (lesson.subject as { name?: string } | null | undefined)?.name || 'Занятие';
pointerEvents: visible ? 'auto' : 'none',
transition: 'opacity 0.25s ease', return (
}} <>
onClick={handleClose} {/* Затемнённый фон — клик закрывает панель */}
/> <div
{/* Панель справа */} role="presentation"
<div style={{
className="ios26-panel" position: 'fixed',
style={{ inset: 0,
position: 'fixed', background: 'rgba(0,0,0,0.4)',
top: 0, zIndex: 999,
right: 0, opacity: visible ? 1 : 0,
bottom: 0, pointerEvents: visible ? 'auto' : 'none',
width: '100%', transition: 'opacity 0.25s ease',
maxWidth: 480, }}
background: 'var(--md-sys-color-surface)', onClick={handleClose}
boxShadow: '-4px 0 24px rgba(0,0,0,0.15)', />
overflow: 'hidden', {/* Панель справа */}
display: 'flex', <div
flexDirection: 'column', className="ios26-panel"
zIndex: 1000, style={{
transform: visible ? 'translateX(0)' : 'translateX(100%)', position: 'fixed',
transition: 'transform 0.3s ease', top: 0,
}} right: 0,
> bottom: 0,
<div width: '100%',
style={{ maxWidth: 480,
display: 'flex', background: 'var(--md-sys-color-surface)',
alignItems: 'flex-start', boxShadow: '-4px 0 24px rgba(0,0,0,0.15)',
justifyContent: 'space-between', overflow: 'hidden',
padding: 24, display: 'flex',
borderBottom: '1px solid var(--md-sys-color-outline-variant)', flexDirection: 'column',
flexShrink: 0, zIndex: 1000,
}} transform: visible ? 'translateX(0)' : 'translateX(100%)',
> transition: 'transform 0.3s ease',
<div style={{ minWidth: 0, flex: 1 }}> }}
<h2 style={{ fontSize: 20, fontWeight: 600, color: 'var(--md-sys-color-on-surface)', margin: 0 }}> >
Обратная связь <div
</h2> style={{
<p style={{ fontSize: 14, color: 'var(--md-sys-color-on-surface-variant)', marginTop: 4 }}> display: 'flex',
{lesson.title} {subjectName} alignItems: 'flex-start',
</p> justifyContent: 'space-between',
</div> padding: 24,
<button borderBottom: '1px solid var(--md-sys-color-outline-variant)',
type="button" flexShrink: 0,
onClick={handleClose} }}
disabled={loading} >
style={{ <div style={{ minWidth: 0, flex: 1 }}>
background: 'none', <h2 style={{ fontSize: 20, fontWeight: 600, color: 'var(--md-sys-color-on-surface)', margin: 0 }}>
border: 'none', Обратная связь
cursor: loading ? 'not-allowed' : 'pointer', </h2>
padding: 8, <p style={{ fontSize: 14, color: 'var(--md-sys-color-on-surface-variant)', marginTop: 4 }}>
color: 'var(--md-sys-color-on-surface-variant)', {lesson.title} {subjectName}
flexShrink: 0, </p>
}} </div>
> <button
<span className="material-symbols-outlined">close</span> type="button"
</button> onClick={handleClose}
</div> disabled={loading}
style={{
<form onSubmit={handleSubmit} style={{ padding: 24, overflowY: 'auto', flex: 1 }}> background: 'none',
<div border: 'none',
style={{ cursor: loading ? 'not-allowed' : 'pointer',
background: 'var(--md-sys-color-primary-container)', padding: 8,
borderRadius: 12, color: 'var(--md-sys-color-on-surface-variant)',
padding: 16, flexShrink: 0,
marginBottom: 20, }}
}} >
> <span className="material-symbols-outlined">close</span>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, fontSize: 14 }}> </button>
<div> </div>
<span style={{ color: 'var(--md-sys-color-on-surface-variant)' }}>Дата: </span>
<span style={{ color: 'var(--md-sys-color-on-surface)', fontWeight: 500 }}> <form onSubmit={handleSubmit} style={{ padding: 24, overflowY: 'auto', flex: 1 }}>
{new Date(lesson.start_time).toLocaleDateString('ru-RU')} <div
</span> style={{
</div> background: 'var(--md-sys-color-primary-container)',
<div> borderRadius: 12,
<span style={{ color: 'var(--md-sys-color-on-surface-variant)' }}>Время: </span> padding: 16,
<span style={{ color: 'var(--md-sys-color-on-surface)', fontWeight: 500 }}> marginBottom: 20,
{new Date(lesson.start_time).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })} }}
{' — '} >
{new Date(lesson.end_time).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })} <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, fontSize: 14 }}>
</span> <div>
</div> <span style={{ color: 'var(--md-sys-color-on-surface-variant)' }}>Дата: </span>
<div> <span style={{ color: 'var(--md-sys-color-on-surface)', fontWeight: 500 }}>
<span style={{ color: 'var(--md-sys-color-on-surface-variant)' }}>Студент: </span> {parsedTimes.start.dateObj.toLocaleDateString('ru-RU')}
<span style={{ color: 'var(--md-sys-color-on-surface)', fontWeight: 500 }}>{clientName}</span> </span>
</div> </div>
<div> <div>
<span style={{ color: 'var(--md-sys-color-on-surface-variant)' }}>Длительность: </span> <span style={{ color: 'var(--md-sys-color-on-surface-variant)' }}>Время: </span>
<span style={{ color: 'var(--md-sys-color-on-surface)', fontWeight: 500 }}> <span style={{ color: 'var(--md-sys-color-on-surface)', fontWeight: 500 }}>
{(lesson as { duration?: number }).duration || 60} мин {parsedTimes.start.time}
</span> {' — '}
</div> {parsedTimes.end.time}
</div> </span>
</div> </div>
<div>
<div style={{ marginBottom: 20 }}> <span style={{ color: 'var(--md-sys-color-on-surface-variant)' }}>Студент: </span>
<h3 style={{ fontSize: 16, fontWeight: 600, marginBottom: 12, color: 'var(--md-sys-color-on-surface)' }}> <span style={{ color: 'var(--md-sys-color-on-surface)', fontWeight: 500 }}>{clientName}</span>
Оценки </div>
</h3> <div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}> <span style={{ color: 'var(--md-sys-color-on-surface-variant)' }}>Длительность: </span>
<div> <span style={{ color: 'var(--md-sys-color-on-surface)', fontWeight: 500 }}>
<label {(lesson as { duration?: number }).duration || 60} мин
htmlFor="mentor_grade" </span>
style={{ display: 'block', fontSize: 13, fontWeight: 500, color: 'var(--md-sys-color-on-surface-variant)', marginBottom: 6 }} </div>
> </div>
Оценка за занятие (15) </div>
</label>
<input <div style={{ marginBottom: 20 }}>
id="mentor_grade" <h3 style={{ fontSize: 16, fontWeight: 600, marginBottom: 12, color: 'var(--md-sys-color-on-surface)' }}>
type="number" Оценки
min={1} </h3>
max={5} <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
value={formData.mentor_grade} <div>
onChange={(e) => setFormData((p) => ({ ...p, mentor_grade: e.target.value }))} <label
style={{ htmlFor="mentor_grade"
width: '100%', style={{ display: 'block', fontSize: 13, fontWeight: 500, color: 'var(--md-sys-color-on-surface-variant)', marginBottom: 6 }}
padding: '12px 16px', >
borderRadius: 12, Оценка за занятие (15)
border: '1px solid var(--md-sys-color-outline)', </label>
background: 'var(--md-sys-color-surface)', <input
fontSize: 15, id="mentor_grade"
color: 'var(--md-sys-color-on-surface)', type="number"
}} min={1}
placeholder="5" max={5}
disabled={loading} value={formData.mentor_grade}
/> onChange={(e) => setFormData((p) => ({ ...p, mentor_grade: e.target.value }))}
</div> style={{
<div> width: '100%',
<label padding: '12px 16px',
htmlFor="school_grade" borderRadius: 12,
style={{ display: 'block', fontSize: 13, fontWeight: 500, color: 'var(--md-sys-color-on-surface-variant)', marginBottom: 6 }} border: '1px solid var(--md-sys-color-outline)',
> background: 'var(--md-sys-color-surface)',
Оценка в школе (15) fontSize: 15,
</label> color: 'var(--md-sys-color-on-surface)',
<input }}
id="school_grade" placeholder="5"
type="number" disabled={loading}
min={1} />
max={5} </div>
value={formData.school_grade} <div>
onChange={(e) => setFormData((p) => ({ ...p, school_grade: e.target.value }))} <label
style={{ htmlFor="school_grade"
width: '100%', style={{ display: 'block', fontSize: 13, fontWeight: 500, color: 'var(--md-sys-color-on-surface-variant)', marginBottom: 6 }}
padding: '12px 16px', >
borderRadius: 12, Оценка в школе (15)
border: '1px solid var(--md-sys-color-outline)', </label>
background: 'var(--md-sys-color-surface)', <input
fontSize: 15, id="school_grade"
color: 'var(--md-sys-color-on-surface)', type="number"
}} min={1}
placeholder="4" max={5}
disabled={loading} value={formData.school_grade}
/> onChange={(e) => setFormData((p) => ({ ...p, school_grade: e.target.value }))}
</div> style={{
</div> width: '100%',
</div> padding: '12px 16px',
borderRadius: 12,
<div style={{ marginBottom: 20 }}> border: '1px solid var(--md-sys-color-outline)',
<label background: 'var(--md-sys-color-surface)',
htmlFor="mentor_notes" fontSize: 15,
style={{ display: 'block', fontSize: 13, fontWeight: 500, color: 'var(--md-sys-color-on-surface-variant)', marginBottom: 6 }} color: 'var(--md-sys-color-on-surface)',
> }}
Комментарий к занятию placeholder="4"
</label> disabled={loading}
<textarea />
id="mentor_notes" </div>
value={formData.mentor_notes} </div>
onChange={(e) => setFormData((p) => ({ ...p, mentor_notes: e.target.value }))} </div>
rows={4}
style={{ <div style={{ marginBottom: 20 }}>
width: '100%', <label
padding: '12px 16px', htmlFor="mentor_notes"
borderRadius: 12, style={{ display: 'block', fontSize: 13, fontWeight: 500, color: 'var(--md-sys-color-on-surface-variant)', marginBottom: 6 }}
border: '1px solid var(--md-sys-color-outline)', >
background: 'var(--md-sys-color-surface)', Комментарий к занятию
fontSize: 15, </label>
color: 'var(--md-sys-color-on-surface)', <textarea
resize: 'vertical', id="mentor_notes"
}} value={formData.mentor_notes}
placeholder="Что прошли на занятии, успехи студента, рекомендации..." onChange={(e) => setFormData((p) => ({ ...p, mentor_notes: e.target.value }))}
disabled={loading} rows={4}
/> style={{
</div> width: '100%',
padding: '12px 16px',
{error && ( borderRadius: 12,
<div border: '1px solid var(--md-sys-color-outline)',
style={{ background: 'var(--md-sys-color-surface)',
background: 'rgba(186,26,26,0.1)', fontSize: 15,
border: '1px solid var(--md-sys-color-error)', color: 'var(--md-sys-color-on-surface)',
borderRadius: 12, resize: 'vertical',
padding: 12, }}
marginBottom: 20, placeholder="Что прошли на занятии, успехи студента, рекомендации..."
}} disabled={loading}
> />
<p style={{ fontSize: 14, color: 'var(--md-sys-color-error)' }}>{error}</p> </div>
</div>
)} {error && (
<div
<div style={{ display: 'flex', gap: 12 }}> style={{
<button background: 'rgba(186,26,26,0.1)',
type="button" border: '1px solid var(--md-sys-color-error)',
onClick={handleClose} borderRadius: 12,
disabled={loading} padding: 12,
style={{ marginBottom: 20,
flex: 1, }}
padding: '14px 24px', >
borderRadius: 14, <p style={{ fontSize: 14, color: 'var(--md-sys-color-error)' }}>{error}</p>
border: '1px solid var(--md-sys-color-outline)', </div>
background: 'transparent', )}
color: 'var(--md-sys-color-on-surface-variant)',
fontSize: 15, <div style={{ display: 'flex', gap: 12 }}>
fontWeight: 500, <button
cursor: loading ? 'not-allowed' : 'pointer', type="button"
}} onClick={handleClose}
> disabled={loading}
Отмена style={{
</button> flex: 1,
<button padding: '14px 24px',
type="submit" borderRadius: 14,
disabled={loading} border: '1px solid var(--md-sys-color-outline)',
style={{ background: 'transparent',
flex: 1, color: 'var(--md-sys-color-on-surface-variant)',
padding: '14px 24px', fontSize: 15,
borderRadius: 14, fontWeight: 500,
border: 'none', cursor: loading ? 'not-allowed' : 'pointer',
background: 'var(--md-sys-color-primary)', }}
color: 'var(--md-sys-color-on-primary)', >
fontSize: 15, Отмена
fontWeight: 600, </button>
cursor: loading ? 'not-allowed' : 'pointer', <button
opacity: loading ? 0.7 : 1, type="submit"
}} disabled={loading}
> style={{
{loading ? 'Сохранение...' : 'Сохранить'} flex: 1,
</button> padding: '14px 24px',
</div> borderRadius: 14,
</form> border: 'none',
</div> background: 'var(--md-sys-color-primary)',
</> color: 'var(--md-sys-color-on-primary)',
); fontSize: 15,
} fontWeight: 600,
cursor: loading ? 'not-allowed' : 'pointer',
opacity: loading ? 0.7 : 1,
}}
>
{loading ? 'Сохранение...' : 'Сохранить'}
</button>
</div>
</form>
</div>
</>
);
}

View File

@ -1,17 +1,17 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
export const MOBILE_BREAKPOINT = 767; export const MOBILE_BREAKPOINT = 767;
export function useIsMobile(breakpoint: number = MOBILE_BREAKPOINT) { export function useIsMobile(breakpoint: number = MOBILE_BREAKPOINT) {
const [isMobile, setIsMobile] = useState(false); const [isMobile, setIsMobile] = useState(false);
useEffect(() => { useEffect(() => {
const mq = window.matchMedia(`(max-width: ${breakpoint}px)`); const mq = window.matchMedia(`(max-width: ${breakpoint}px)`);
setIsMobile(mq.matches); setIsMobile(mq.matches);
const listener = () => setIsMobile(mq.matches); const listener = () => setIsMobile(mq.matches);
mq.addEventListener('change', listener); mq.addEventListener('change', listener);
return () => mq.removeEventListener('change', listener); return () => mq.removeEventListener('change', listener);
}, [breakpoint]); }, [breakpoint]);
return isMobile; return isMobile;
} }

View File

@ -1,4 +1,4 @@
<svg width="512" height="512" viewBox="0 8 130 130" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="512" height="512" viewBox="0 8 130 130" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect y="8" width="130" height="130" rx="20" fill="#7444FD"/> <rect y="8" width="130" height="130" rx="20" fill="#7444FD"/>
<path d="M31.8908 90.7V38H46.1708V87.555C46.1708 91.8617 47.2192 95.7717 49.3158 99.285C51.4125 102.798 54.2175 105.603 57.7308 107.7C61.3008 109.74 65.2108 110.76 69.4608 110.76C73.7675 110.76 77.6492 109.74 81.1058 107.7C84.6192 105.603 87.4242 102.798 89.5208 99.285C91.6175 95.7717 92.6658 91.8617 92.6658 87.555V38H106.946L107.031 123H92.7508L92.6658 112.205C89.6625 116.172 85.8658 119.345 81.2758 121.725C76.6858 124.048 71.7275 125.21 66.4008 125.21C60.0542 125.21 54.2458 123.68 48.9758 120.62C43.7625 117.503 39.5975 113.338 36.4808 108.125C33.4208 102.912 31.8908 97.1033 31.8908 90.7Z" fill="white"/> <path d="M31.8908 90.7V38H46.1708V87.555C46.1708 91.8617 47.2192 95.7717 49.3158 99.285C51.4125 102.798 54.2175 105.603 57.7308 107.7C61.3008 109.74 65.2108 110.76 69.4608 110.76C73.7675 110.76 77.6492 109.74 81.1058 107.7C84.6192 105.603 87.4242 102.798 89.5208 99.285C91.6175 95.7717 92.6658 91.8617 92.6658 87.555V38H106.946L107.031 123H92.7508L92.6658 112.205C89.6625 116.172 85.8658 119.345 81.2758 121.725C76.6858 124.048 71.7275 125.21 66.4008 125.21C60.0542 125.21 54.2458 123.68 48.9758 120.62C43.7625 117.503 39.5975 113.338 36.4808 108.125C33.4208 102.912 31.8908 97.1033 31.8908 90.7Z" fill="white"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 782 B

After

Width:  |  Height:  |  Size: 786 B

View File

@ -1,19 +1,19 @@
{ {
"name": "Uchill Platform", "name": "Uchill Platform",
"short_name": "Uchill", "short_name": "Uchill",
"description": "Образовательная платформа", "description": "Образовательная платформа",
"start_url": "/dashboard", "start_url": "/dashboard",
"scope": "/", "scope": "/",
"display": "standalone", "display": "standalone",
"orientation": "any", "orientation": "any",
"background_color": "#ffffff", "background_color": "#ffffff",
"theme_color": "#7444FD", "theme_color": "#7444FD",
"icons": [ "icons": [
{ {
"src": "/icon.svg", "src": "/icon.svg",
"sizes": "any", "sizes": "any",
"type": "image/svg+xml", "type": "image/svg+xml",
"purpose": "any" "purpose": "any"
} }
] ]
} }

View File

@ -4,7 +4,32 @@
*/ */
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200');
/* Material Symbols — локальный шрифт для быстрой загрузки */
@font-face {
font-family: 'Material Symbols Outlined';
font-style: normal;
font-weight: 100 700;
font-display: swap;
src: url('/fonts/material-symbols-outlined.woff2') format('woff2');
}
.material-symbols-outlined {
font-family: 'Material Symbols Outlined';
font-weight: normal;
font-style: normal;
font-size: 24px;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-feature-settings: 'liga';
font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased;
}
/* CSS Variables для темизации (быстрее чем JS темы) */ /* CSS Variables для темизации (быстрее чем JS темы) */
:root { :root {

View File

@ -0,0 +1,201 @@
/**
* Утилиты для работы с часовыми поясами.
*
* Поддерживаемые форматы timezone:
* - UTC+X, UTC-X (например, "UTC+8", "UTC-5")
* - GMT+X, GMT-X
* - IANA названия (например, "Europe/Moscow", "Asia/Irkutsk")
*/
/**
* Парсить timezone и получить смещение в минутах.
*
* Примеры:
* - "UTC+8" -> 480 (8 * 60)
* - "UTC-5" -> -300 (-5 * 60)
* - "UTC+5:30" -> 330 (5 * 60 + 30)
*
* @returns смещение в минутах или null если не удалось распарсить
*/
export function parseTimezoneOffset(timezone: string | undefined): number | null {
if (!timezone) return null;
const trimmed = timezone.trim();
// Парсим формат UTC+X, UTC-X, GMT+X, GMT-X
const match = trimmed.match(/^(?:UTC|GMT)([+-])(\d{1,2})(?::(\d{2}))?$/i);
if (match) {
const sign = match[1] === '+' ? 1 : -1;
const hours = parseInt(match[2], 10);
const minutes = match[3] ? parseInt(match[3], 10) : 0;
return sign * (hours * 60 + minutes);
}
return null;
}
/**
* Получить смещение часового пояса в минутах.
*
* Поддерживает:
* - UTC+X формат (парсит напрямую)
* - IANA названия (использует Intl API)
*
* @returns смещение в минутах (положительное = восток от UTC)
*/
export function getTimezoneOffsetMinutes(timezone: string | undefined): number {
if (!timezone) {
// Браузерный timezone
return -new Date().getTimezoneOffset();
}
// Сначала пробуем распарсить UTC+X формат
const parsedOffset = parseTimezoneOffset(timezone);
if (parsedOffset !== null) {
return parsedOffset;
}
// Для IANA названий используем Intl API
try {
const now = new Date();
const utcDate = new Date(now.toLocaleString('en-US', { timeZone: 'UTC' }));
const tzDate = new Date(now.toLocaleString('en-US', { timeZone: timezone }));
return Math.round((tzDate.getTime() - utcDate.getTime()) / 60000);
} catch {
// Fallback на браузерный timezone
return -new Date().getTimezoneOffset();
}
}
/**
* Создать ISO строку даты/времени с учетом часового пояса пользователя.
*
* Пример: Если пользователь в Улан-Удэ (UTC+8) вводит 18:00,
* то нужно отправить на сервер 10:00 UTC (18:00 - 8 часов).
*
* @param dateStr - дата в формате 'YYYY-MM-DD'
* @param timeStr - время в формате 'HH:mm'
* @param userTimezone - часовой пояс пользователя (например, 'UTC+8', 'Europe/Moscow')
* @returns ISO строка в UTC
*/
export function createDateTimeInUserTimezone(
dateStr: string,
timeStr: string,
userTimezone: string | undefined
): string {
// Парсим дату и время
const [year, month, day] = dateStr.split('-').map(Number);
const [hours, minutes] = timeStr.split(':').map(Number);
// Создаем дату как будто она в UTC
const utcDate = new Date(Date.UTC(year, month - 1, day, hours, minutes, 0, 0));
// Получаем смещение timezone пользователя
const offsetMinutes = getTimezoneOffsetMinutes(userTimezone);
// Корректируем: вычитаем смещение, чтобы получить UTC
// Например: 18:00 в UTC+8 = 10:00 UTC, значит вычитаем 8 часов (480 минут)
utcDate.setMinutes(utcDate.getMinutes() - offsetMinutes);
return utcDate.toISOString();
}
/**
* Парсить ISO дату и получить локальную дату/время в часовом поясе пользователя.
*
* Работает с любым форматом timezone:
* - UTC+8: добавляет 8 часов к UTC
* - Europe/Moscow: использует Intl API
*
* @param isoString - ISO строка даты (например, '2026-02-21T10:00:00Z' для UTC)
* @param userTimezone - часовой пояс пользователя (например, 'UTC+8')
* @returns объект с date и time в часовом поясе пользователя
*/
export function parseISOToUserTimezone(
isoString: string,
userTimezone: string | undefined
): { date: string; time: string; dateObj: Date } {
// Парсим ISO строку в UTC timestamp
const utcDate = new Date(isoString);
const utcMs = utcDate.getTime();
// Получаем смещение timezone пользователя в минутах
const offsetMinutes = getTimezoneOffsetMinutes(userTimezone);
// Применяем смещение: UTC + offset = локальное время
// Например: 10:00 UTC + 8 часов = 18:00 в UTC+8
const localMs = utcMs + offsetMinutes * 60 * 1000;
const localDate = new Date(localMs);
// Извлекаем компоненты даты/времени в UTC (потому что мы уже добавили offset)
const year = localDate.getUTCFullYear();
const month = String(localDate.getUTCMonth() + 1).padStart(2, '0');
const day = String(localDate.getUTCDate()).padStart(2, '0');
const hours = String(localDate.getUTCHours()).padStart(2, '0');
const minutes = String(localDate.getUTCMinutes()).padStart(2, '0');
const dateStr = `${year}-${month}-${day}`;
const timeStr = `${hours}:${minutes}`;
// Создаем Date объект для использования в UI (в локальном времени браузера)
const displayDate = new Date(`${dateStr}T${timeStr}`);
return {
date: dateStr,
time: timeStr,
dateObj: displayDate,
};
}
/**
* Форматировать дату для отображения в часовом поясе пользователя.
*
* @param isoString - ISO строка даты
* @param userTimezone - часовой пояс пользователя (например, 'UTC+8')
* @param options - опции форматирования Intl.DateTimeFormat
*/
export function formatDateInUserTimezone(
isoString: string,
userTimezone: string | undefined,
options: Intl.DateTimeFormatOptions = {}
): string {
// Получаем локальное время в timezone пользователя
const parsed = parseISOToUserTimezone(isoString, userTimezone);
// Форматируем используя Intl (dateObj уже в правильном времени)
return new Intl.DateTimeFormat('ru-RU', options).format(parsed.dateObj);
}
/**
* Получить текущую дату/время в часовом поясе пользователя.
*/
export function getNowInUserTimezone(userTimezone: string | undefined): Date {
const now = new Date();
const parsed = parseISOToUserTimezone(now.toISOString(), userTimezone);
return parsed.dateObj;
}
/**
* Получить название часового пояса с offset.
* Например: 'Europe/Moscow' -> 'Europe/Moscow (UTC+3)'
* Для 'UTC+8' -> 'UTC+8'
*/
export function getTimezoneDisplayName(timezone: string): string {
if (!timezone) return '';
// Если уже в формате UTC+X, возвращаем как есть
if (/^(?:UTC|GMT)[+-]\d/i.test(timezone)) {
return timezone;
}
try {
const offsetMinutes = getTimezoneOffsetMinutes(timezone);
const hours = Math.floor(Math.abs(offsetMinutes) / 60);
const mins = Math.abs(offsetMinutes) % 60;
const sign = offsetMinutes >= 0 ? '+' : '-';
const offsetStr = mins > 0 ? `${hours}:${mins.toString().padStart(2, '0')}` : `${hours}`;
return `${timezone} (UTC${sign}${offsetStr})`;
} catch {
return timezone;
}
}