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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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}"
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):
"""
Промокод для скидок на подписки.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@ -1,99 +1,120 @@
/**
* Блок «Календарь занятий» обёртка над LessonsCalendar.
* Используется в Dashboard и других страницах.
*/
'use client';
import React from 'react';
import { format, startOfDay } from 'date-fns';
import { LessonsCalendar } from '@/components/dashboard/LessonsCalendar';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
export interface CalendarLesson {
id: number | string;
title?: string;
start_time: string;
end_time: string;
status?: string;
client?: number;
client_name?: string;
subject?: string;
}
export interface CalendarProps {
/** Занятия для отображения в календаре */
lessons: CalendarLesson[];
/** Идёт загрузка занятий */
lessonsLoading?: boolean;
/** Выбранная дата (подсветка в календаре) */
selectedDate: Date;
/** Клик по ячейке дня или по слоту */
onSelectSlot?: (date: Date) => void;
/** Клик по событию (занятию) */
onSelectEvent?: (lesson: { id: string }) => void;
/** Смена видимого месяца (start/end месяца) */
onMonthChange?: (start: Date, end: Date) => void;
}
export const Calendar: React.FC<CalendarProps> = ({
lessons,
lessonsLoading = false,
selectedDate,
onSelectSlot,
onSelectEvent,
onMonthChange,
}) => {
const mappedLessons = React.useMemo(
() =>
lessons.map((lesson) => ({
id: String(lesson.id),
title: lesson.title || 'Занятие',
start_time: lesson.start_time,
end_time: lesson.end_time,
status: (lesson.status || 'scheduled') as 'scheduled' | 'in_progress' | 'completed' | 'cancelled',
client: lesson.client_name
? {
id: String(lesson.client ?? ''),
name: lesson.client_name,
first_name: lesson.client_name.split(' ')[0] || lesson.client_name,
last_name: lesson.client_name.split(' ').slice(1).join(' ') || '',
}
: undefined,
})),
[lessons]
);
return (
<div
className="ios-glass-panel"
style={{
borderRadius: '20px',
padding: '24px',
height: '100%',
minHeight: 0,
display: 'flex',
flexDirection: 'column',
}}
>
{lessonsLoading ? (
<LoadingSpinner size="medium" />
) : (
<LessonsCalendar
lessons={mappedLessons}
selectedDate={selectedDate}
onSelectSlot={(date) => {
try {
const d = startOfDay(date);
if (!Number.isNaN(d.getTime())) onSelectSlot?.(d);
} catch {
/* игнор невалидной даты */
}
}}
onSelectEvent={onSelectEvent}
onMonthChange={onMonthChange}
/>
)}
</div>
);
};
/**
* Блок «Календарь занятий» обёртка над LessonsCalendar.
* Используется в Dashboard и других страницах.
*/
'use client';
import React from 'react';
import { format, startOfDay } from 'date-fns';
import { LessonsCalendar } from '@/components/dashboard/LessonsCalendar';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
export interface CalendarLesson {
id: number | string;
title?: string;
start_time: string;
end_time: string;
status?: string;
client?: number;
client_name?: string;
mentor_name?: string;
subject?: string;
}
export interface CalendarProps {
/** Занятия для отображения в календаре */
lessons: CalendarLesson[];
/** Идёт загрузка занятий */
lessonsLoading?: boolean;
/** Выбранная дата (подсветка в календаре) */
selectedDate: Date;
/** Клик по ячейке дня или по слоту */
onSelectSlot?: (date: Date) => void;
/** Клик по событию (занятию) */
onSelectEvent?: (lesson: { id: string }) => void;
/** Смена видимого месяца (start/end месяца) */
onMonthChange?: (start: Date, end: Date) => void;
/** Ментор — показывает ученика; студент — показывает предмет и ментора */
isMentor?: boolean;
/** Часовой пояс пользователя (например, 'UTC+8') */
userTimezone?: string;
}
export const Calendar: React.FC<CalendarProps> = ({
lessons,
lessonsLoading = false,
selectedDate,
onSelectSlot,
onSelectEvent,
onMonthChange,
isMentor = true,
userTimezone,
}) => {
const mappedLessons = React.useMemo(
() =>
lessons.map((lesson) => {
if (isMentor && lesson.client_name) {
return {
id: String(lesson.id),
title: lesson.title || 'Занятие',
start_time: lesson.start_time,
end_time: lesson.end_time,
status: (lesson.status || 'scheduled') as 'scheduled' | 'in_progress' | 'completed' | 'cancelled',
client: {
id: String(lesson.client ?? ''),
name: lesson.client_name,
first_name: lesson.client_name.split(' ')[0] || lesson.client_name,
last_name: lesson.client_name.split(' ').slice(1).join(' ') || '',
},
};
}
const subject = lesson.subject || 'Занятие';
const mentorName = lesson.mentor_name || '';
const displayTitle = mentorName ? `${subject}${mentorName}` : subject;
return {
id: String(lesson.id),
title: displayTitle,
start_time: lesson.start_time,
end_time: lesson.end_time,
status: (lesson.status || 'scheduled') as 'scheduled' | 'in_progress' | 'completed' | 'cancelled',
client: undefined,
};
}),
[lessons, isMentor]
);
return (
<div
className="ios-glass-panel"
style={{
borderRadius: '20px',
padding: '24px',
height: '100%',
minHeight: 0,
display: 'flex',
flexDirection: 'column',
}}
>
{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.
* Opens a calendar inside a MUI Dialog (works well on mobile and inside other dialogs).
*/
'use client';
import React, { useState, useMemo } from 'react';
import {
format,
startOfMonth,
endOfMonth,
eachDayOfInterval,
isSameDay,
isSameMonth,
addMonths,
subMonths,
startOfWeek,
endOfWeek,
} from 'date-fns';
import { ru } from 'date-fns/locale';
import { Dialog, DialogContent, Box, Button } from '@mui/material';
interface DatePickerProps {
value: string; // YYYY-MM-DD format
onChange: (value: string) => void;
disabled?: boolean;
required?: boolean;
label?: string;
}
export const DatePicker: React.FC<DatePickerProps> = ({
value,
onChange,
disabled = false,
required = false,
label,
}) => {
const [open, setOpen] = useState(false);
const [displayMonth, setDisplayMonth] = useState(
value ? new Date(value + 'T00:00:00') : new Date(),
);
const selectedDate = useMemo(
() => (value ? new Date(value + 'T00:00:00') : null),
[value],
);
const openPicker = () => {
if (disabled) return;
setDisplayMonth(selectedDate ?? new Date());
setOpen(true);
};
const closePicker = () => setOpen(false);
const handleDateSelect = (date: Date) => {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
onChange(`${y}-${m}-${d}`);
closePicker();
};
const days = useMemo(() => {
const start = startOfMonth(displayMonth);
const end = endOfMonth(displayMonth);
return eachDayOfInterval({
start: startOfWeek(start, { locale: ru }),
end: endOfWeek(end, { locale: ru }),
});
}, [displayMonth]);
const weekDays = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
const displayValue = selectedDate
? format(selectedDate, 'd MMMM yyyy', { locale: ru })
: label || 'Выберите дату';
return (
<>
<button
type="button"
onClick={openPicker}
disabled={disabled}
aria-required={required}
style={{
width: '100%',
padding: '12px 16px',
fontSize: '16px',
color: value
? 'var(--md-sys-color-on-surface)'
: 'var(--md-sys-color-on-surface-variant)',
background: 'var(--md-sys-color-surface)',
border: '1px solid var(--md-sys-color-outline)',
borderRadius: '4px',
fontFamily: 'inherit',
cursor: disabled ? 'not-allowed' : 'pointer',
outline: 'none',
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '12px',
textAlign: 'left',
}}
>
<span>{displayValue}</span>
<span
className="material-symbols-outlined"
style={{ fontSize: 20, opacity: 0.7 }}
>
calendar_today
</span>
</button>
<Dialog
open={open}
onClose={closePicker}
fullWidth
maxWidth="xs"
slotProps={{
paper: {
sx: {
borderRadius: '24px',
overflow: 'visible',
bgcolor: 'var(--md-sys-color-surface)',
},
},
}}
>
<DialogContent sx={{ p: 2 }}>
{/* Month/year header */}
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 1.5,
}}
>
<button
type="button"
onClick={() => setDisplayMonth(subMonths(displayMonth, 1))}
style={{
width: 36,
height: 36,
padding: 0,
background: 'transparent',
border: 'none',
borderRadius: '50%',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--md-sys-color-on-surface)',
}}
>
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>
chevron_left
</span>
</button>
<span
style={{
fontSize: 16,
fontWeight: 500,
color: 'var(--md-sys-color-on-surface)',
textTransform: 'capitalize',
}}
>
{format(displayMonth, 'LLLL yyyy', { locale: ru })}
</span>
<button
type="button"
onClick={() => setDisplayMonth(addMonths(displayMonth, 1))}
style={{
width: 36,
height: 36,
padding: 0,
background: 'transparent',
border: 'none',
borderRadius: '50%',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--md-sys-color-on-surface)',
}}
>
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>
chevron_right
</span>
</button>
</Box>
{/* Weekday headers */}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(7, 1fr)',
gap: 2,
marginBottom: 4,
}}
>
{weekDays.map((day) => (
<div
key={day}
style={{
textAlign: 'center',
fontSize: 12,
fontWeight: 500,
color: 'var(--md-sys-color-on-surface-variant)',
padding: '6px 0',
}}
>
{day}
</div>
))}
</div>
{/* Calendar days */}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(7, 1fr)',
gap: 2,
}}
>
{days.map((day, idx) => {
const isSelected = selectedDate && isSameDay(day, selectedDate);
const isCurrent = isSameMonth(day, displayMonth);
const isToday = isSameDay(day, new Date());
return (
<button
key={idx}
type="button"
onClick={() => handleDateSelect(day)}
style={{
width: '100%',
aspectRatio: '1',
maxWidth: 40,
margin: '0 auto',
padding: 0,
background: isSelected
? 'var(--md-sys-color-primary)'
: 'transparent',
border:
isToday && !isSelected
? '1px solid var(--md-sys-color-primary)'
: 'none',
borderRadius: '50%',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 14,
fontWeight: isSelected ? 600 : 400,
color: isSelected
? 'var(--md-sys-color-on-primary)'
: isCurrent
? 'var(--md-sys-color-on-surface)'
: 'var(--md-sys-color-on-surface-variant)',
opacity: isCurrent ? 1 : 0.35,
transition: 'background 0.15s',
}}
>
{format(day, 'd')}
</button>
);
})}
</div>
{/* Actions */}
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mt: 2,
pt: 1.5,
borderTop: '1px solid var(--md-sys-color-outline-variant)',
}}
>
<Button
onClick={() => handleDateSelect(new Date())}
variant="text"
sx={{
color: 'var(--md-sys-color-primary)',
textTransform: 'none',
fontWeight: 500,
fontSize: 14,
}}
>
Сегодня
</Button>
<Button
onClick={closePicker}
variant="text"
sx={{
color: 'var(--md-sys-color-on-surface-variant)',
textTransform: 'none',
fontWeight: 500,
fontSize: 14,
}}
>
Отмена
</Button>
</Box>
</DialogContent>
</Dialog>
</>
);
};
/**
* Material Design 3 Date Picker Dialog variant.
* Opens a calendar inside a MUI Dialog (works well on mobile and inside other dialogs).
*/
'use client';
import React, { useState, useMemo } from 'react';
import {
format,
startOfMonth,
endOfMonth,
eachDayOfInterval,
isSameDay,
isSameMonth,
addMonths,
subMonths,
startOfWeek,
endOfWeek,
} from 'date-fns';
import { ru } from 'date-fns/locale';
import { Dialog, DialogContent, Box, Button } from '@mui/material';
interface DatePickerProps {
value: string; // YYYY-MM-DD format
onChange: (value: string) => void;
disabled?: boolean;
required?: boolean;
label?: string;
}
export const DatePicker: React.FC<DatePickerProps> = ({
value,
onChange,
disabled = false,
required = false,
label,
}) => {
const [open, setOpen] = useState(false);
const [displayMonth, setDisplayMonth] = useState(
value ? new Date(value + 'T00:00:00') : new Date(),
);
const selectedDate = useMemo(
() => (value ? new Date(value + 'T00:00:00') : null),
[value],
);
const openPicker = () => {
if (disabled) return;
setDisplayMonth(selectedDate ?? new Date());
setOpen(true);
};
const closePicker = () => setOpen(false);
const handleDateSelect = (date: Date) => {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
onChange(`${y}-${m}-${d}`);
closePicker();
};
const days = useMemo(() => {
const start = startOfMonth(displayMonth);
const end = endOfMonth(displayMonth);
return eachDayOfInterval({
start: startOfWeek(start, { locale: ru }),
end: endOfWeek(end, { locale: ru }),
});
}, [displayMonth]);
const weekDays = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
const displayValue = selectedDate
? format(selectedDate, 'd MMMM yyyy', { locale: ru })
: label || 'Выберите дату';
return (
<>
<button
type="button"
onClick={openPicker}
disabled={disabled}
aria-required={required}
style={{
width: '100%',
padding: '12px 16px',
fontSize: '16px',
color: value
? 'var(--md-sys-color-on-surface)'
: 'var(--md-sys-color-on-surface-variant)',
background: 'var(--md-sys-color-surface)',
border: '1px solid var(--md-sys-color-outline)',
borderRadius: '4px',
fontFamily: 'inherit',
cursor: disabled ? 'not-allowed' : 'pointer',
outline: 'none',
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '12px',
textAlign: 'left',
}}
>
<span>{displayValue}</span>
<span
className="material-symbols-outlined"
style={{ fontSize: 20, opacity: 0.7 }}
>
calendar_today
</span>
</button>
<Dialog
open={open}
onClose={closePicker}
fullWidth
maxWidth="xs"
slotProps={{
paper: {
sx: {
borderRadius: '24px',
overflow: 'visible',
bgcolor: 'var(--md-sys-color-surface)',
},
},
}}
>
<DialogContent sx={{ p: 2 }}>
{/* Month/year header */}
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 1.5,
}}
>
<button
type="button"
onClick={() => setDisplayMonth(subMonths(displayMonth, 1))}
style={{
width: 36,
height: 36,
padding: 0,
background: 'transparent',
border: 'none',
borderRadius: '50%',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--md-sys-color-on-surface)',
}}
>
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>
chevron_left
</span>
</button>
<span
style={{
fontSize: 16,
fontWeight: 500,
color: 'var(--md-sys-color-on-surface)',
textTransform: 'capitalize',
}}
>
{format(displayMonth, 'LLLL yyyy', { locale: ru })}
</span>
<button
type="button"
onClick={() => setDisplayMonth(addMonths(displayMonth, 1))}
style={{
width: 36,
height: 36,
padding: 0,
background: 'transparent',
border: 'none',
borderRadius: '50%',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--md-sys-color-on-surface)',
}}
>
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>
chevron_right
</span>
</button>
</Box>
{/* Weekday headers */}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(7, 1fr)',
gap: 2,
marginBottom: 4,
}}
>
{weekDays.map((day) => (
<div
key={day}
style={{
textAlign: 'center',
fontSize: 12,
fontWeight: 500,
color: 'var(--md-sys-color-on-surface-variant)',
padding: '6px 0',
}}
>
{day}
</div>
))}
</div>
{/* Calendar days */}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(7, 1fr)',
gap: 2,
}}
>
{days.map((day, idx) => {
const isSelected = selectedDate && isSameDay(day, selectedDate);
const isCurrent = isSameMonth(day, displayMonth);
const isToday = isSameDay(day, new Date());
return (
<button
key={idx}
type="button"
onClick={() => handleDateSelect(day)}
style={{
width: '100%',
aspectRatio: '1',
maxWidth: 40,
margin: '0 auto',
padding: 0,
background: isSelected
? 'var(--md-sys-color-primary)'
: 'transparent',
border:
isToday && !isSelected
? '1px solid var(--md-sys-color-primary)'
: 'none',
borderRadius: '50%',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 14,
fontWeight: isSelected ? 600 : 400,
color: isSelected
? 'var(--md-sys-color-on-primary)'
: isCurrent
? 'var(--md-sys-color-on-surface)'
: 'var(--md-sys-color-on-surface-variant)',
opacity: isCurrent ? 1 : 0.35,
transition: 'background 0.15s',
}}
>
{format(day, 'd')}
</button>
);
})}
</div>
{/* Actions */}
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mt: 2,
pt: 1.5,
borderTop: '1px solid var(--md-sys-color-outline-variant)',
}}
>
<Button
onClick={() => handleDateSelect(new Date())}
variant="text"
sx={{
color: 'var(--md-sys-color-primary)',
textTransform: 'none',
fontWeight: 500,
fontSize: 14,
}}
>
Сегодня
</Button>
<Button
onClick={closePicker}
variant="text"
sx={{
color: 'var(--md-sys-color-on-surface-variant)',
textTransform: 'none',
fontWeight: 500,
fontSize: 14,
}}
>
Отмена
</Button>
</Box>
</DialogContent>
</Dialog>
</>
);
};

View File

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

View File

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

View File

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

View File

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

Before

Width:  |  Height:  |  Size: 782 B

After

Width:  |  Height:  |  Size: 786 B

View File

@ -1,19 +1,19 @@
{
"name": "Uchill Platform",
"short_name": "Uchill",
"description": "Образовательная платформа",
"start_url": "/dashboard",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#ffffff",
"theme_color": "#7444FD",
"icons": [
{
"src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
}
]
}
{
"name": "Uchill Platform",
"short_name": "Uchill",
"description": "Образовательная платформа",
"start_url": "/dashboard",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#ffffff",
"theme_color": "#7444FD",
"icons": [
{
"src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"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=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200');
/* Material Symbols — локальный шрифт для быстрой загрузки */
@font-face {
font-family: 'Material Symbols Outlined';
font-style: normal;
font-weight: 100 700;
font-display: swap;
src: url('/fonts/material-symbols-outlined.woff2') format('woff2');
}
.material-symbols-outlined {
font-family: 'Material Symbols Outlined';
font-weight: normal;
font-style: normal;
font-size: 24px;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-feature-settings: 'liga';
font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased;
}
/* CSS Variables для темизации (быстрее чем JS темы) */
:root {

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;
}
}