fix bugs
Deploy to Production / deploy-production (push) Successful in 27s
Details
Deploy to Production / deploy-production (push) Successful in 27s
Details
This commit is contained in:
parent
d4c4dbb087
commit
d9121fe6ef
|
|
@ -1,93 +1,93 @@
|
||||||
# Настройка автоматического резервного копирования БД
|
# Настройка автоматического резервного копирования БД
|
||||||
|
|
||||||
## 🎯 Автоматический бэкап дважды в день
|
## 🎯 Автоматический бэкап дважды в день
|
||||||
|
|
||||||
Система автоматически создаёт бэкапы PROD и DEV БД:
|
Система автоматически создаёт бэкапы PROD и DEV БД:
|
||||||
- **00:00** (полночь)
|
- **00:00** (полночь)
|
||||||
- **12:00** (полдень)
|
- **12:00** (полдень)
|
||||||
|
|
||||||
## 📋 Установка
|
## 📋 Установка
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /var/www/platform/prod
|
cd /var/www/platform/prod
|
||||||
|
|
||||||
# Сделать скрипты исполняемыми
|
# Сделать скрипты исполняемыми
|
||||||
chmod +x backup-db-auto.sh setup-cron-backup.sh remove-cron-backup.sh
|
chmod +x backup-db-auto.sh setup-cron-backup.sh remove-cron-backup.sh
|
||||||
|
|
||||||
# Настроить автоматический бэкап
|
# Настроить автоматический бэкап
|
||||||
./setup-cron-backup.sh
|
./setup-cron-backup.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
## ✅ Проверка
|
## ✅ Проверка
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Проверить, что задача добавлена в cron
|
# Проверить, что задача добавлена в cron
|
||||||
crontab -l | grep backup-db-auto
|
crontab -l | grep backup-db-auto
|
||||||
|
|
||||||
# Должно быть:
|
# Должно быть:
|
||||||
# 0 0,12 * * * PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin /var/www/platform/prod/backup-db-auto.sh >> /var/www/platform/prod/backups/cron.log 2>&1
|
# 0 0,12 * * * PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin /var/www/platform/prod/backup-db-auto.sh >> /var/www/platform/prod/backups/cron.log 2>&1
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📊 Логи
|
## 📊 Логи
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Логи автоматических бэкапов
|
# Логи автоматических бэкапов
|
||||||
tail -f /var/www/platform/prod/backups/backup.log
|
tail -f /var/www/platform/prod/backups/backup.log
|
||||||
|
|
||||||
# Логи cron (ошибки выполнения)
|
# Логи cron (ошибки выполнения)
|
||||||
tail -f /var/www/platform/prod/backups/cron.log
|
tail -f /var/www/platform/prod/backups/cron.log
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🗂️ Хранение бэкапов
|
## 🗂️ Хранение бэкапов
|
||||||
|
|
||||||
- **Директория**: `/var/www/platform/prod/backups/`
|
- **Директория**: `/var/www/platform/prod/backups/`
|
||||||
- **Формат файлов**: `platform_prod_db_YYYYMMDD_HHMMSS.sql.gz`
|
- **Формат файлов**: `platform_prod_db_YYYYMMDD_HHMMSS.sql.gz`
|
||||||
- **Автоочистка**: Бэкапы старше 30 дней удаляются автоматически
|
- **Автоочистка**: Бэкапы старше 30 дней удаляются автоматически
|
||||||
- **Проверка места**: При использовании диска > 80% в лог пишется предупреждение
|
- **Проверка места**: При использовании диска > 80% в лог пишется предупреждение
|
||||||
|
|
||||||
## 🔄 Ручной запуск
|
## 🔄 Ручной запуск
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Запустить бэкап вручную (для тестирования)
|
# Запустить бэкап вручную (для тестирования)
|
||||||
/var/www/platform/prod/backup-db-auto.sh
|
/var/www/platform/prod/backup-db-auto.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🗑️ Удаление автоматического бэкапа
|
## 🗑️ Удаление автоматического бэкапа
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Удалить задачу из cron
|
# Удалить задачу из cron
|
||||||
./remove-cron-backup.sh
|
./remove-cron-backup.sh
|
||||||
|
|
||||||
# Или вручную
|
# Или вручную
|
||||||
crontab -l | grep -v backup-db-auto | crontab -
|
crontab -l | grep -v backup-db-auto | crontab -
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📝 Что делает скрипт
|
## 📝 Что делает скрипт
|
||||||
|
|
||||||
1. ✅ Проверяет, что контейнеры БД запущены
|
1. ✅ Проверяет, что контейнеры БД запущены
|
||||||
2. ✅ Создаёт бэкапы PROD и DEV БД
|
2. ✅ Создаёт бэкапы PROD и DEV БД
|
||||||
3. ✅ Сжимает бэкапы (gzip)
|
3. ✅ Сжимает бэкапы (gzip)
|
||||||
4. ✅ Проверяет размер бэкапов
|
4. ✅ Проверяет размер бэкапов
|
||||||
5. ✅ Удаляет бэкапы старше 30 дней
|
5. ✅ Удаляет бэкапы старше 30 дней
|
||||||
6. ✅ Логирует все действия
|
6. ✅ Логирует все действия
|
||||||
7. ✅ Предупреждает о нехватке места на диске
|
7. ✅ Предупреждает о нехватке места на диске
|
||||||
|
|
||||||
## ⚠️ Важно
|
## ⚠️ Важно
|
||||||
|
|
||||||
- Скрипт работает от пользователя `root` (нужен доступ к Docker)
|
- Скрипт работает от пользователя `root` (нужен доступ к Docker)
|
||||||
- Бэкапы сохраняются в `/var/www/platform/prod/backups/`
|
- Бэкапы сохраняются в `/var/www/platform/prod/backups/`
|
||||||
- Старые бэкапы (30+ дней) удаляются автоматически
|
- Старые бэкапы (30+ дней) удаляются автоматически
|
||||||
- При ошибках информация записывается в лог
|
- При ошибках информация записывается в лог
|
||||||
|
|
||||||
## 🔍 Мониторинг
|
## 🔍 Мониторинг
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Посмотреть последние бэкапы
|
# Посмотреть последние бэкапы
|
||||||
ls -lh /var/www/platform/prod/backups/*.sql.gz | tail -10
|
ls -lh /var/www/platform/prod/backups/*.sql.gz | tail -10
|
||||||
|
|
||||||
# Проверить размер всех бэкапов
|
# Проверить размер всех бэкапов
|
||||||
du -sh /var/www/platform/prod/backups/
|
du -sh /var/www/platform/prod/backups/
|
||||||
|
|
||||||
# Посмотреть последние записи в логе
|
# Посмотреть последние записи в логе
|
||||||
tail -20 /var/www/platform/prod/backups/backup.log
|
tail -20 /var/www/platform/prod/backups/backup.log
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -349,13 +349,14 @@ class Message(models.Model):
|
||||||
self.chat.increment_messages_count()
|
self.chat.increment_messages_count()
|
||||||
self.chat.update_last_message()
|
self.chat.update_last_message()
|
||||||
|
|
||||||
# Увеличиваем счетчик непрочитанных для всех участников кроме отправителя
|
# Системные сообщения (уведомления) не увеличивают счётчик непрочитанных — уведомления есть отдельно
|
||||||
# Оптимизация: используем bulk_update вместо цикла с save()
|
if self.message_type != 'system':
|
||||||
participants = list(self.chat.participants.exclude(user=self.sender))
|
# Увеличиваем счетчик непрочитанных для всех участников кроме отправителя
|
||||||
for participant in participants:
|
participants = list(self.chat.participants.exclude(user=self.sender))
|
||||||
participant.unread_count += 1
|
for participant in participants:
|
||||||
if participants:
|
participant.unread_count += 1
|
||||||
ChatParticipant.objects.bulk_update(participants, ['unread_count'])
|
if participants:
|
||||||
|
ChatParticipant.objects.bulk_update(participants, ['unread_count'])
|
||||||
|
|
||||||
def mark_as_edited(self):
|
def mark_as_edited(self):
|
||||||
"""Отметить как отредактированное."""
|
"""Отметить как отредактированное."""
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from .models import Chat, ChatParticipant, Message, MessageFile, MessageRead, MessageReaction
|
from .models import Chat, ChatParticipant, Message, MessageFile, MessageRead, MessageReaction
|
||||||
|
from .services import ChatService
|
||||||
from apps.users.serializers import UserSerializer
|
from apps.users.serializers import UserSerializer
|
||||||
from apps.users.mixins import TimezoneAwareSerializerMixin
|
from apps.users.mixins import TimezoneAwareSerializerMixin
|
||||||
from apps.users.utils import format_datetime_for_user
|
from apps.users.utils import format_datetime_for_user
|
||||||
|
|
@ -531,19 +532,17 @@ class ChatCreateSerializer(serializers.ModelSerializer):
|
||||||
participant_ids = validated_data.pop('participant_ids')
|
participant_ids = validated_data.pop('participant_ids')
|
||||||
user = self.context['request'].user
|
user = self.context['request'].user
|
||||||
|
|
||||||
# Для личного чата проверяем что такой чат уже не существует
|
# Для личного чата используем сервис с защитой от race condition
|
||||||
if validated_data['chat_type'] == 'direct':
|
if validated_data['chat_type'] == 'direct':
|
||||||
existing_chat = Chat.objects.filter(
|
other_user = User.objects.get(id=participant_ids[0])
|
||||||
chat_type='direct',
|
chat, _ = ChatService.get_or_create_direct_chat(
|
||||||
participants__user=user
|
user1=user,
|
||||||
).filter(
|
user2=other_user,
|
||||||
participants__user_id=participant_ids[0]
|
created_by=user
|
||||||
).first()
|
)
|
||||||
|
return chat
|
||||||
if existing_chat:
|
|
||||||
return existing_chat
|
|
||||||
|
|
||||||
# Создаем чат
|
# Для группового чата - обычная логика
|
||||||
chat = Chat.objects.create(
|
chat = Chat.objects.create(
|
||||||
created_by=user,
|
created_by=user,
|
||||||
**validated_data
|
**validated_data
|
||||||
|
|
@ -557,7 +556,6 @@ class ChatCreateSerializer(serializers.ModelSerializer):
|
||||||
)
|
)
|
||||||
|
|
||||||
# Добавляем остальных участников
|
# Добавляем остальных участников
|
||||||
# Оптимизация: используем bulk_create вместо цикла с create()
|
|
||||||
users = list(User.objects.filter(id__in=participant_ids))
|
users = list(User.objects.filter(id__in=participant_ids))
|
||||||
participants_to_create = [
|
participants_to_create = [
|
||||||
ChatParticipant(
|
ChatParticipant(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
"""
|
||||||
|
Сервисы для системы чата.
|
||||||
|
Централизованная логика создания и управления чатами.
|
||||||
|
"""
|
||||||
|
from django.db import transaction
|
||||||
|
from django.db.models import Q
|
||||||
|
from .models import Chat, ChatParticipant
|
||||||
|
|
||||||
|
|
||||||
|
class ChatService:
|
||||||
|
"""
|
||||||
|
Сервис для работы с чатами.
|
||||||
|
Обеспечивает атомарность операций и предотвращает создание дубликатов.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_or_create_direct_chat(user1, user2, created_by=None):
|
||||||
|
"""
|
||||||
|
Получить или создать личный чат между двумя пользователями.
|
||||||
|
|
||||||
|
Использует блокировку для предотвращения race condition
|
||||||
|
при одновременных запросах на создание чата.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user1: Первый пользователь (User)
|
||||||
|
user2: Второй пользователь (User)
|
||||||
|
created_by: Кто создает чат (по умолчанию user1)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (chat, created) - объект чата и флаг создания
|
||||||
|
"""
|
||||||
|
if user1.id == user2.id:
|
||||||
|
raise ValueError("Нельзя создать чат с самим собой")
|
||||||
|
|
||||||
|
# Нормализуем порядок пользователей для консистентного поиска
|
||||||
|
users = sorted([user1, user2], key=lambda u: u.id)
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
# Ищем существующий чат между пользователями
|
||||||
|
# Используем select_for_update для блокировки найденных записей
|
||||||
|
existing_chat = Chat.objects.select_for_update().filter(
|
||||||
|
chat_type='direct',
|
||||||
|
participants__user=users[0]
|
||||||
|
).filter(
|
||||||
|
participants__user=users[1]
|
||||||
|
).distinct().first()
|
||||||
|
|
||||||
|
if existing_chat:
|
||||||
|
return existing_chat, False
|
||||||
|
|
||||||
|
# Чата нет - создаем новый
|
||||||
|
creator = created_by or users[0]
|
||||||
|
chat = Chat.objects.create(
|
||||||
|
chat_type='direct',
|
||||||
|
created_by=creator
|
||||||
|
)
|
||||||
|
|
||||||
|
# Определяем роли участников
|
||||||
|
# Создатель становится админом
|
||||||
|
ChatParticipant.objects.create(
|
||||||
|
chat=chat,
|
||||||
|
user=users[0],
|
||||||
|
role='admin' if users[0] == creator else 'member'
|
||||||
|
)
|
||||||
|
|
||||||
|
ChatParticipant.objects.create(
|
||||||
|
chat=chat,
|
||||||
|
user=users[1],
|
||||||
|
role='admin' if users[1] == creator else 'member'
|
||||||
|
)
|
||||||
|
|
||||||
|
return chat, True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_direct_chat(user1, user2):
|
||||||
|
"""
|
||||||
|
Получить существующий личный чат между двумя пользователями.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user1: Первый пользователь
|
||||||
|
user2: Второй пользователь
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Chat или None
|
||||||
|
"""
|
||||||
|
return Chat.objects.filter(
|
||||||
|
chat_type='direct',
|
||||||
|
participants__user=user1
|
||||||
|
).filter(
|
||||||
|
participants__user=user2
|
||||||
|
).distinct().first()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def ensure_participant(chat, user, role='member'):
|
||||||
|
"""
|
||||||
|
Убедиться что пользователь является участником чата.
|
||||||
|
Если нет - добавить его.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
chat: Чат
|
||||||
|
user: Пользователь
|
||||||
|
role: Роль (по умолчанию 'member')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (participant, created)
|
||||||
|
"""
|
||||||
|
return ChatParticipant.objects.get_or_create(
|
||||||
|
chat=chat,
|
||||||
|
user=user,
|
||||||
|
defaults={'role': role}
|
||||||
|
)
|
||||||
|
|
@ -17,6 +17,7 @@ from .serializers import (
|
||||||
ChatParticipantSerializer
|
ChatParticipantSerializer
|
||||||
)
|
)
|
||||||
from .permissions import IsChatParticipant
|
from .permissions import IsChatParticipant
|
||||||
|
from .services import ChatService
|
||||||
from .utils import (
|
from .utils import (
|
||||||
save_file_to_preload,
|
save_file_to_preload,
|
||||||
move_file_from_preload_to_chat,
|
move_file_from_preload_to_chat,
|
||||||
|
|
@ -233,46 +234,25 @@ class ChatViewSet(viewsets.ModelViewSet):
|
||||||
'error': 'Вы можете создавать чаты только со связанными пользователями'
|
'error': 'Вы можете создавать чаты только со связанными пользователями'
|
||||||
}, status=status.HTTP_403_FORBIDDEN)
|
}, status=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
# Проверяем существует ли уже чат
|
# Используем сервис для атомарного создания/получения чата
|
||||||
existing_chat = Chat.objects.filter(
|
chat, created = ChatService.get_or_create_direct_chat(
|
||||||
chat_type='direct',
|
user1=request.user,
|
||||||
participants__user=request.user
|
user2=other_user,
|
||||||
).filter(
|
created_by=request.user
|
||||||
participants__user_id=other_user_id
|
)
|
||||||
).first()
|
|
||||||
|
|
||||||
if existing_chat:
|
serializer = ChatDetailSerializer(chat)
|
||||||
serializer = ChatDetailSerializer(existing_chat)
|
if created:
|
||||||
|
return Response({
|
||||||
|
'success': True,
|
||||||
|
'data': serializer.data
|
||||||
|
}, status=status.HTTP_201_CREATED)
|
||||||
|
else:
|
||||||
return Response({
|
return Response({
|
||||||
'success': True,
|
'success': True,
|
||||||
'data': serializer.data,
|
'data': serializer.data,
|
||||||
'message': 'Чат уже существует'
|
'message': 'Чат уже существует'
|
||||||
})
|
})
|
||||||
|
|
||||||
# Создаем новый чат
|
|
||||||
chat = Chat.objects.create(
|
|
||||||
chat_type='direct',
|
|
||||||
created_by=request.user
|
|
||||||
)
|
|
||||||
|
|
||||||
# Добавляем участников
|
|
||||||
ChatParticipant.objects.create(
|
|
||||||
chat=chat,
|
|
||||||
user=request.user,
|
|
||||||
role='admin'
|
|
||||||
)
|
|
||||||
|
|
||||||
ChatParticipant.objects.create(
|
|
||||||
chat=chat,
|
|
||||||
user=other_user,
|
|
||||||
role='member'
|
|
||||||
)
|
|
||||||
|
|
||||||
serializer = ChatDetailSerializer(chat)
|
|
||||||
return Response({
|
|
||||||
'success': True,
|
|
||||||
'data': serializer.data
|
|
||||||
}, status=status.HTTP_201_CREATED)
|
|
||||||
|
|
||||||
@action(detail=True, methods=['post'])
|
@action(detail=True, methods=['post'])
|
||||||
def mark_read(self, request, uuid=None):
|
def mark_read(self, request, uuid=None):
|
||||||
|
|
@ -357,10 +337,12 @@ class ChatViewSet(viewsets.ModelViewSet):
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Пересчитываем непрочитанные
|
# Пересчитываем непрочитанные (системные сообщения не учитываем)
|
||||||
unread_count = Message.objects.filter(
|
unread_count = Message.objects.filter(
|
||||||
chat=chat,
|
chat=chat,
|
||||||
is_deleted=False
|
is_deleted=False
|
||||||
|
).exclude(
|
||||||
|
message_type='system'
|
||||||
).exclude(
|
).exclude(
|
||||||
reads__user=request.user
|
reads__user=request.user
|
||||||
).exclude(
|
).exclude(
|
||||||
|
|
@ -454,65 +436,28 @@ class ChatViewSet(viewsets.ModelViewSet):
|
||||||
'error': 'У урока должны быть указаны ментор и клиент'
|
'error': 'У урока должны быть указаны ментор и клиент'
|
||||||
}, status=status.HTTP_400_BAD_REQUEST)
|
}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
# Ищем существующий личный чат между ментором и клиентом
|
# Используем сервис для атомарного создания/получения чата
|
||||||
existing_chat = Chat.objects.filter(
|
chat, created = ChatService.get_or_create_direct_chat(
|
||||||
chat_type='direct',
|
user1=mentor,
|
||||||
participants__user=mentor
|
user2=client_user,
|
||||||
).filter(
|
created_by=mentor
|
||||||
participants__user=client_user
|
)
|
||||||
).distinct().first()
|
|
||||||
|
|
||||||
if existing_chat:
|
# Если текущий пользователь не участник чата (родитель), добавляем его
|
||||||
# Проверяем, является ли текущий пользователь участником
|
if request.user != mentor and request.user != client_user:
|
||||||
participant = existing_chat.participants.filter(user=request.user).first()
|
ChatService.ensure_participant(chat, request.user, role='member')
|
||||||
if not participant:
|
|
||||||
# Если текущий пользователь не участник, но это ментор или клиент урока - добавляем
|
serializer = ChatDetailSerializer(chat, context={'request': request})
|
||||||
if request.user == mentor or request.user == client_user:
|
if created:
|
||||||
ChatParticipant.objects.get_or_create(
|
return Response({
|
||||||
chat=existing_chat,
|
'success': True,
|
||||||
user=request.user,
|
'data': serializer.data
|
||||||
defaults={'role': 'member'}
|
}, status=status.HTTP_201_CREATED)
|
||||||
)
|
else:
|
||||||
|
|
||||||
serializer = ChatDetailSerializer(existing_chat, context={'request': request})
|
|
||||||
return Response({
|
return Response({
|
||||||
'success': True,
|
'success': True,
|
||||||
'data': serializer.data
|
'data': serializer.data
|
||||||
})
|
})
|
||||||
|
|
||||||
# Если чата нет, создаем новый личный чат между ментором и клиентом
|
|
||||||
# Используем ту же логику, что и в create_direct
|
|
||||||
chat = Chat.objects.create(
|
|
||||||
chat_type='direct',
|
|
||||||
created_by=request.user
|
|
||||||
)
|
|
||||||
|
|
||||||
# Добавляем участников
|
|
||||||
ChatParticipant.objects.create(
|
|
||||||
chat=chat,
|
|
||||||
user=mentor,
|
|
||||||
role='admin'
|
|
||||||
)
|
|
||||||
|
|
||||||
ChatParticipant.objects.create(
|
|
||||||
chat=chat,
|
|
||||||
user=client_user,
|
|
||||||
role='member'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Если текущий пользователь не ментор и не клиент, добавляем его тоже
|
|
||||||
if request.user != mentor and request.user != client_user:
|
|
||||||
ChatParticipant.objects.create(
|
|
||||||
chat=chat,
|
|
||||||
user=request.user,
|
|
||||||
role='member'
|
|
||||||
)
|
|
||||||
|
|
||||||
serializer = ChatDetailSerializer(chat, context={'request': request})
|
|
||||||
return Response({
|
|
||||||
'success': True,
|
|
||||||
'data': serializer.data
|
|
||||||
}, status=status.HTTP_201_CREATED)
|
|
||||||
|
|
||||||
@action(detail=True, methods=['post'])
|
@action(detail=True, methods=['post'])
|
||||||
def mark_as_read(self, request, uuid=None):
|
def mark_as_read(self, request, uuid=None):
|
||||||
|
|
|
||||||
|
|
@ -209,14 +209,14 @@ class HomeworkViewSet(viewsets.ModelViewSet):
|
||||||
# Инвалидируем кеш дашборда после создания ДЗ
|
# Инвалидируем кеш дашборда после создания ДЗ
|
||||||
from apps.users.cache_utils import invalidate_dashboard_cache
|
from apps.users.cache_utils import invalidate_dashboard_cache
|
||||||
invalidate_dashboard_cache(homework.mentor.id, 'mentor')
|
invalidate_dashboard_cache(homework.mentor.id, 'mentor')
|
||||||
# Оптимизация: используем list() для кеширования запроса
|
|
||||||
students = list(homework.assigned_to.all())
|
students = list(homework.assigned_to.all())
|
||||||
for student in students:
|
for student in students:
|
||||||
invalidate_dashboard_cache(student.id, 'client')
|
invalidate_dashboard_cache(student.id, 'client')
|
||||||
|
|
||||||
# Отправляем уведомление о новом ДЗ
|
# Отправляем уведомление о новом ДЗ только если НЕ отложенное
|
||||||
from apps.notifications.services import NotificationService
|
if not homework.fill_later:
|
||||||
NotificationService.send_homework_notification(homework, 'homework_assigned')
|
from apps.notifications.services import NotificationService
|
||||||
|
NotificationService.send_homework_notification(homework, 'homework_assigned')
|
||||||
|
|
||||||
response_serializer = HomeworkSerializer(homework)
|
response_serializer = HomeworkSerializer(homework)
|
||||||
return Response(
|
return Response(
|
||||||
|
|
@ -230,6 +230,9 @@ class HomeworkViewSet(viewsets.ModelViewSet):
|
||||||
Опубликовать ДЗ.
|
Опубликовать ДЗ.
|
||||||
|
|
||||||
POST /api/homework/homeworks/{id}/publish/
|
POST /api/homework/homeworks/{id}/publish/
|
||||||
|
|
||||||
|
Также используется для публикации отложенных ДЗ (fill_later=True).
|
||||||
|
При публикации сбрасывается флаг fill_later.
|
||||||
"""
|
"""
|
||||||
homework = self.get_object()
|
homework = self.get_object()
|
||||||
|
|
||||||
|
|
@ -240,12 +243,19 @@ class HomeworkViewSet(viewsets.ModelViewSet):
|
||||||
status=status.HTTP_403_FORBIDDEN
|
status=status.HTTP_403_FORBIDDEN
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Запоминаем был ли это отложенный ДЗ (для отправки уведомления)
|
||||||
|
was_fill_later = homework.fill_later
|
||||||
|
|
||||||
|
# Сбрасываем fill_later при публикации
|
||||||
|
if homework.fill_later:
|
||||||
|
homework.fill_later = False
|
||||||
|
homework.save(update_fields=['fill_later'])
|
||||||
|
|
||||||
homework.publish()
|
homework.publish()
|
||||||
|
|
||||||
# Инвалидируем кеш дашборда после публикации ДЗ
|
# Инвалидируем кеш дашборда после публикации ДЗ
|
||||||
from apps.users.cache_utils import invalidate_dashboard_cache
|
from apps.users.cache_utils import invalidate_dashboard_cache
|
||||||
invalidate_dashboard_cache(homework.mentor.id, 'mentor')
|
invalidate_dashboard_cache(homework.mentor.id, 'mentor')
|
||||||
# Оптимизация: используем list() для кеширования запроса
|
|
||||||
students = list(homework.assigned_to.all())
|
students = list(homework.assigned_to.all())
|
||||||
for student in students:
|
for student in students:
|
||||||
invalidate_dashboard_cache(student.id, 'client')
|
invalidate_dashboard_cache(student.id, 'client')
|
||||||
|
|
@ -264,7 +274,10 @@ class HomeworkViewSet(viewsets.ModelViewSet):
|
||||||
)
|
)
|
||||||
|
|
||||||
serializer = HomeworkSerializer(homework)
|
serializer = HomeworkSerializer(homework)
|
||||||
return Response(serializer.data)
|
response_data = serializer.data
|
||||||
|
if was_fill_later:
|
||||||
|
response_data['was_fill_later'] = True
|
||||||
|
return Response(response_data)
|
||||||
|
|
||||||
@action(detail=True, methods=['post'])
|
@action(detail=True, methods=['post'])
|
||||||
def archive(self, request, pk=None):
|
def archive(self, request, pk=None):
|
||||||
|
|
@ -287,6 +300,35 @@ class HomeworkViewSet(viewsets.ModelViewSet):
|
||||||
serializer = HomeworkSerializer(homework)
|
serializer = HomeworkSerializer(homework)
|
||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def fill_later_list(self, request):
|
||||||
|
"""
|
||||||
|
Получить список отложенных ДЗ (fill_later=True) для ментора.
|
||||||
|
|
||||||
|
GET /api/homework/homeworks/fill_later_list/
|
||||||
|
|
||||||
|
Возвращает ДЗ, которые были созданы с флагом "заполнить позже"
|
||||||
|
и ожидают заполнения ментором.
|
||||||
|
"""
|
||||||
|
if request.user.role != 'mentor':
|
||||||
|
return Response(
|
||||||
|
{'error': 'Только ментор может просматривать отложенные ДЗ'},
|
||||||
|
status=status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
queryset = Homework.objects.filter(
|
||||||
|
mentor=request.user,
|
||||||
|
fill_later=True
|
||||||
|
).select_related('mentor', 'lesson', 'lesson__client', 'lesson__client__user').prefetch_related(
|
||||||
|
'assigned_to'
|
||||||
|
).order_by('-created_at')
|
||||||
|
|
||||||
|
serializer = HomeworkListSerializer(queryset, many=True, context={'request': request})
|
||||||
|
return Response({
|
||||||
|
'count': queryset.count(),
|
||||||
|
'results': serializer.data
|
||||||
|
})
|
||||||
|
|
||||||
@action(detail=True, methods=['get'])
|
@action(detail=True, methods=['get'])
|
||||||
def statistics(self, request, pk=None):
|
def statistics(self, request, pk=None):
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -1,168 +1,159 @@
|
||||||
"""
|
"""
|
||||||
Сигналы для автоматической отправки уведомлений.
|
Сигналы для автоматической отправки уведомлений.
|
||||||
"""
|
"""
|
||||||
from django.db.models.signals import post_save
|
from django.db.models.signals import post_save
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.utils.html import strip_tags
|
from django.utils.html import strip_tags
|
||||||
from .services import NotificationService, create_notification_preferences
|
from .services import NotificationService, create_notification_preferences
|
||||||
|
|
||||||
|
|
||||||
# Сигналы пользователей
|
# Сигналы пользователей
|
||||||
@receiver(post_save, sender='users.User')
|
@receiver(post_save, sender='users.User')
|
||||||
def create_user_notification_preferences(sender, instance, created, **kwargs):
|
def create_user_notification_preferences(sender, instance, created, **kwargs):
|
||||||
"""Создание настроек уведомлений для нового пользователя."""
|
"""Создание настроек уведомлений для нового пользователя."""
|
||||||
if created:
|
if created:
|
||||||
create_notification_preferences(instance)
|
create_notification_preferences(instance)
|
||||||
|
|
||||||
|
|
||||||
# Сигналы занятий
|
# Сигналы занятий
|
||||||
@receiver(post_save, sender='schedule.Lesson')
|
@receiver(post_save, sender='schedule.Lesson')
|
||||||
def handle_lesson_notifications(sender, instance, created, **kwargs):
|
def handle_lesson_notifications(sender, instance, created, **kwargs):
|
||||||
"""
|
"""
|
||||||
Обработка уведомлений для занятий.
|
Обработка уведомлений для занятий.
|
||||||
|
|
||||||
Примечание: Уведомление о создании занятия отправляется явно в perform_create,
|
Примечание: Уведомление о создании занятия отправляется явно в perform_create,
|
||||||
чтобы избежать дублирования. Здесь обрабатываем только обновления.
|
чтобы избежать дублирования. Здесь обрабатываем только обновления.
|
||||||
"""
|
"""
|
||||||
if not created:
|
if not created:
|
||||||
# Занятие обновлено - проверяем статус
|
# Занятие обновлено - проверяем статус
|
||||||
# Уведомление об отмене отправляется явно в perform_destroy,
|
# Уведомление об отмене отправляется явно в perform_destroy,
|
||||||
# но оставляем здесь на случай, если статус меняется другим способом
|
# но оставляем здесь на случай, если статус меняется другим способом
|
||||||
if instance.status == 'cancelled' and instance.cancelled_at:
|
if instance.status == 'cancelled' and instance.cancelled_at:
|
||||||
# Проверяем, не было ли уже отправлено уведомление
|
# Проверяем, не было ли уже отправлено уведомление
|
||||||
# (чтобы избежать дублирования с perform_destroy)
|
# (чтобы избежать дублирования с perform_destroy)
|
||||||
pass # Уведомление об отмене отправляется явно в perform_destroy
|
pass # Уведомление об отмене отправляется явно в perform_destroy
|
||||||
|
|
||||||
|
|
||||||
# Сигналы уведомлений - дублирование в чат
|
# Сигналы уведомлений - дублирование в чат
|
||||||
@receiver(post_save, sender='notifications.Notification')
|
@receiver(post_save, sender='notifications.Notification')
|
||||||
def duplicate_notification_to_chat(sender, instance, created, **kwargs):
|
def duplicate_notification_to_chat(sender, instance, created, **kwargs):
|
||||||
"""
|
"""
|
||||||
Дублирование системных уведомлений в чат между ментором и учеником/родителем.
|
Дублирование системных уведомлений в чат между ментором и учеником/родителем.
|
||||||
|
|
||||||
Когда создается уведомление для ученика или родителя от ментора,
|
Когда создается уведомление для ученика или родителя от ментора,
|
||||||
оно также создается как системное сообщение в соответствующем чате.
|
оно также создается как системное сообщение в соответствующем чате.
|
||||||
"""
|
"""
|
||||||
if not created:
|
if not created:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Дублируем только in_app уведомления
|
# Дублируем только in_app уведомления
|
||||||
if instance.channel != 'in_app':
|
if instance.channel != 'in_app':
|
||||||
return
|
return
|
||||||
|
|
||||||
# Дублируем только определенные типы уведомлений
|
# Дублируем только определенные типы уведомлений
|
||||||
notification_types_to_duplicate = [
|
notification_types_to_duplicate = [
|
||||||
'lesson_created',
|
'lesson_created',
|
||||||
'lesson_updated',
|
'lesson_updated',
|
||||||
'lesson_cancelled',
|
'lesson_cancelled',
|
||||||
'lesson_rescheduled',
|
'lesson_rescheduled',
|
||||||
'lesson_reminder',
|
'lesson_reminder',
|
||||||
'lesson_started',
|
'lesson_started',
|
||||||
'lesson_completed',
|
'lesson_completed',
|
||||||
'homework_assigned',
|
'homework_assigned',
|
||||||
'homework_submitted',
|
'homework_submitted',
|
||||||
'homework_reviewed',
|
'homework_reviewed',
|
||||||
'homework_returned',
|
'homework_returned',
|
||||||
'homework_deadline_reminder',
|
'homework_deadline_reminder',
|
||||||
'homework_overdue',
|
'homework_overdue',
|
||||||
'material_added',
|
'material_added',
|
||||||
'subscription_expiring',
|
'subscription_expiring',
|
||||||
'subscription_expired',
|
'subscription_expired',
|
||||||
'payment_received',
|
'payment_received',
|
||||||
'system',
|
'system',
|
||||||
]
|
]
|
||||||
|
|
||||||
if instance.notification_type not in notification_types_to_duplicate:
|
if instance.notification_type not in notification_types_to_duplicate:
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from apps.chat.models import Chat, Message, ChatParticipant
|
from apps.chat.models import Chat, Message, ChatParticipant
|
||||||
from apps.users.models import User, Client, Parent
|
from apps.chat.services import ChatService
|
||||||
|
from apps.users.models import User, Client, Parent
|
||||||
recipient = instance.recipient
|
|
||||||
|
recipient = instance.recipient
|
||||||
# Определяем ментора для создания чата
|
|
||||||
mentor = None
|
# Определяем ментора для создания чата
|
||||||
|
mentor = None
|
||||||
# Если получатель - ученик, находим его ментора
|
|
||||||
if recipient.role == 'client':
|
# Если получатель - ученик, находим его ментора
|
||||||
try:
|
if recipient.role == 'client':
|
||||||
client = Client.objects.get(user=recipient)
|
try:
|
||||||
mentors = client.mentors.all()
|
client = Client.objects.get(user=recipient)
|
||||||
if mentors.exists():
|
mentors = client.mentors.all()
|
||||||
mentor = mentors.first()
|
if mentors.exists():
|
||||||
except Client.DoesNotExist:
|
mentor = mentors.first()
|
||||||
pass
|
except Client.DoesNotExist:
|
||||||
|
pass
|
||||||
# Если получатель - родитель, находим ментора через детей
|
|
||||||
elif recipient.role == 'parent':
|
# Если получатель - родитель, находим ментора через детей
|
||||||
try:
|
elif recipient.role == 'parent':
|
||||||
parent = Parent.objects.get(user=recipient)
|
try:
|
||||||
children = parent.children.all()
|
parent = Parent.objects.get(user=recipient)
|
||||||
if children.exists():
|
children = parent.children.all()
|
||||||
# Берем первого ментора первого ребенка
|
if children.exists():
|
||||||
child = children.first()
|
# Берем первого ментора первого ребенка
|
||||||
mentors = child.mentors.all()
|
child = children.first()
|
||||||
if mentors.exists():
|
mentors = child.mentors.all()
|
||||||
mentor = mentors.first()
|
if mentors.exists():
|
||||||
except Parent.DoesNotExist:
|
mentor = mentors.first()
|
||||||
pass
|
except Parent.DoesNotExist:
|
||||||
|
pass
|
||||||
# Если получатель - ментор, находим ученика/родителя из контекста уведомления
|
|
||||||
elif recipient.role == 'mentor':
|
# Если получатель - ментор, находим ученика/родителя из контекста уведомления
|
||||||
# Для уведомлений ментору нужно найти связанного ученика/родителя
|
elif recipient.role == 'mentor':
|
||||||
# Это зависит от типа уведомления и content_object
|
# Для уведомлений ментору нужно найти связанного ученика/родителя
|
||||||
if instance.content_object:
|
# Это зависит от типа уведомления и content_object
|
||||||
content_obj = instance.content_object
|
if instance.content_object:
|
||||||
# Если это занятие, берем клиента из занятия
|
content_obj = instance.content_object
|
||||||
if hasattr(content_obj, 'client'):
|
# Если это занятие, берем клиента из занятия
|
||||||
client = content_obj.client
|
if hasattr(content_obj, 'client'):
|
||||||
if client and client.user:
|
client = content_obj.client
|
||||||
recipient = client.user
|
if client and client.user:
|
||||||
# Если это ДЗ, берем студента из ДЗ
|
recipient = client.user
|
||||||
elif hasattr(content_obj, 'student'):
|
# Если это ДЗ, берем студента из ДЗ
|
||||||
recipient = content_obj.student
|
elif hasattr(content_obj, 'student'):
|
||||||
# Если это submission, берем студента
|
recipient = content_obj.student
|
||||||
elif hasattr(content_obj, 'homework') and hasattr(content_obj, 'student'):
|
# Если это submission, берем студента
|
||||||
recipient = content_obj.student
|
elif hasattr(content_obj, 'homework') and hasattr(content_obj, 'student'):
|
||||||
else:
|
recipient = content_obj.student
|
||||||
return # Не можем определить получателя
|
else:
|
||||||
mentor = instance.recipient
|
return # Не можем определить получателя
|
||||||
|
mentor = instance.recipient
|
||||||
if not mentor:
|
|
||||||
return
|
if not mentor:
|
||||||
|
return
|
||||||
# Находим или создаем личный чат между ментором и получателем
|
|
||||||
chat = Chat.objects.filter(
|
# Используем сервис для атомарного создания/получения чата
|
||||||
chat_type='direct',
|
chat, _ = ChatService.get_or_create_direct_chat(
|
||||||
participants__user=mentor
|
user1=mentor,
|
||||||
).filter(
|
user2=recipient,
|
||||||
participants__user=recipient
|
created_by=mentor
|
||||||
).first()
|
)
|
||||||
|
|
||||||
if not chat:
|
# Создаем системное сообщение в чате (без HTML-тегов, чтобы в чате не отображались теги)
|
||||||
# Создаем чат если его нет
|
title_plain = strip_tags(instance.title or '')
|
||||||
chat = Chat.objects.create(
|
message_plain = strip_tags(instance.message or '')
|
||||||
chat_type='direct',
|
message_content = f"🔔 {title_plain}\n{message_plain}"
|
||||||
created_by=mentor
|
Message.objects.create(
|
||||||
)
|
chat=chat,
|
||||||
ChatParticipant.objects.create(chat=chat, user=mentor, role='admin')
|
sender=None, # Системное сообщение
|
||||||
ChatParticipant.objects.create(chat=chat, user=recipient, role='member')
|
message_type='system',
|
||||||
|
content=message_content
|
||||||
# Создаем системное сообщение в чате (без HTML-тегов, чтобы в чате не отображались теги)
|
)
|
||||||
title_plain = strip_tags(instance.title or '')
|
|
||||||
message_plain = strip_tags(instance.message or '')
|
except Exception as e:
|
||||||
message_content = f"🔔 {title_plain}\n{message_plain}"
|
# Логируем ошибку, но не прерываем создание уведомления
|
||||||
Message.objects.create(
|
import logging
|
||||||
chat=chat,
|
logger = logging.getLogger(__name__)
|
||||||
sender=None, # Системное сообщение
|
logger.error(f'Error duplicating notification to chat: {e}', exc_info=True)
|
||||||
message_type='system',
|
|
||||||
content=message_content
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
# Логируем ошибку, но не прерываем создание уведомления
|
|
||||||
import logging
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
logger.error(f'Error duplicating notification to chat: {e}', exc_info=True)
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -570,7 +570,14 @@ def send_lesson_notification(lesson_id, notification_type):
|
||||||
elif notification_type == 'lesson_cancelled':
|
elif notification_type == 'lesson_cancelled':
|
||||||
NotificationService.send_lesson_cancelled(lesson)
|
NotificationService.send_lesson_cancelled(lesson)
|
||||||
elif notification_type == 'lesson_reminder':
|
elif notification_type == 'lesson_reminder':
|
||||||
|
# Проверяем что напоминание ещё не отправлено (используем флаг для 1 часа как основной)
|
||||||
|
if lesson.reminder_1h_sent:
|
||||||
|
logger.info(f'Lesson {lesson_id} reminder already sent, skipping')
|
||||||
|
return f'Lesson {lesson_id} reminder already sent'
|
||||||
NotificationService.send_lesson_reminder(lesson)
|
NotificationService.send_lesson_reminder(lesson)
|
||||||
|
# Отмечаем что напоминание отправлено
|
||||||
|
lesson.reminder_1h_sent = True
|
||||||
|
lesson.save(update_fields=['reminder_1h_sent'])
|
||||||
elif notification_type == 'lesson_rescheduled':
|
elif notification_type == 'lesson_rescheduled':
|
||||||
NotificationService.send_lesson_rescheduled(lesson)
|
NotificationService.send_lesson_rescheduled(lesson)
|
||||||
elif notification_type == 'lesson_completed':
|
elif notification_type == 'lesson_completed':
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,10 @@ from .models import (
|
||||||
PointsTransaction,
|
PointsTransaction,
|
||||||
BonusTransaction,
|
BonusTransaction,
|
||||||
PromoCode,
|
PromoCode,
|
||||||
PromoCodeUsage
|
PromoCodeUsage,
|
||||||
|
ReferralInvitedEmail,
|
||||||
|
UserActivityDay,
|
||||||
|
PendingReferralBonus,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -339,3 +342,30 @@ class PromoCodeUsageAdmin(admin.ModelAdmin):
|
||||||
return obj.promo_code.code
|
return obj.promo_code.code
|
||||||
promo_code_code.short_description = 'Промокод'
|
promo_code_code.short_description = 'Промокод'
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ReferralInvitedEmail)
|
||||||
|
class ReferralInvitedEmailAdmin(admin.ModelAdmin):
|
||||||
|
"""Бэклог приглашённых email (защита от накрутки)."""
|
||||||
|
list_display = ['email', 'referrer', 'referred_user', 'created_at']
|
||||||
|
search_fields = ['email', 'referrer__email', 'referred_user__email']
|
||||||
|
readonly_fields = ['email', 'referrer', 'referred_user', 'created_at']
|
||||||
|
list_filter = ['created_at']
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(UserActivityDay)
|
||||||
|
class UserActivityDayAdmin(admin.ModelAdmin):
|
||||||
|
"""Дни активности пользователей (для проверки условий начисления бонусов)."""
|
||||||
|
list_display = ['user', 'date', 'created_at']
|
||||||
|
list_filter = ['date']
|
||||||
|
search_fields = ['user__email']
|
||||||
|
readonly_fields = ['user', 'date', 'created_at']
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(PendingReferralBonus)
|
||||||
|
class PendingReferralBonusAdmin(admin.ModelAdmin):
|
||||||
|
"""Ожидающие начисления бонусов за рефералов."""
|
||||||
|
list_display = ['referrer', 'referred_user', 'points', 'level', 'status', 'referred_at', 'paid_at']
|
||||||
|
list_filter = ['status', 'level']
|
||||||
|
search_fields = ['referrer__email', 'referred_user__email']
|
||||||
|
readonly_fields = ['referrer', 'referred_user', 'referred_at', 'points', 'level', 'reason', 'paid_at', 'created_at']
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
"""
|
||||||
|
Обработка отложенных бонусов за рефералов.
|
||||||
|
|
||||||
|
Начисление возможно только при выполнении одного из условий:
|
||||||
|
- Прошло 30+ дней с приглашения И реферал был активен 20+ дней;
|
||||||
|
- Реферал был активен 21+ день (независимо от срока).
|
||||||
|
|
||||||
|
Запуск: python manage.py process_pending_referral_bonuses
|
||||||
|
Рекомендуется добавить в cron (ежедневно).
|
||||||
|
"""
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from apps.referrals.models import (
|
||||||
|
PendingReferralBonus,
|
||||||
|
UserActivityDay,
|
||||||
|
UserReferralProfile,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Начислить бонусы за рефералов, выполнивших условия по активности (20+ дней за 30 дней или 21+ день всего)'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
'--dry-run',
|
||||||
|
action='store_true',
|
||||||
|
help='Только показать, что было бы начислено, без изменений в БД',
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
dry_run = options['dry_run']
|
||||||
|
now = timezone.now()
|
||||||
|
paid_count = 0
|
||||||
|
for pending in PendingReferralBonus.objects.filter(status=PendingReferralBonus.STATUS_PENDING).select_related(
|
||||||
|
'referrer', 'referred_user'
|
||||||
|
):
|
||||||
|
referred_at = pending.referred_at
|
||||||
|
referred_date = referred_at.date()
|
||||||
|
# Дней активности реферала с даты приглашения
|
||||||
|
active_days = UserActivityDay.objects.filter(
|
||||||
|
user=pending.referred_user,
|
||||||
|
date__gte=referred_date,
|
||||||
|
).count()
|
||||||
|
days_since_referral = (now - referred_at).days
|
||||||
|
past_30 = days_since_referral >= 30
|
||||||
|
# Условие: (30+ дней и 20+ активных) ИЛИ (21+ активных дней)
|
||||||
|
if (past_30 and active_days >= 20) or (active_days >= 21):
|
||||||
|
if dry_run:
|
||||||
|
self.stdout.write(
|
||||||
|
f'[dry-run] Начислили бы {pending.points} очков {pending.referrer.email} '
|
||||||
|
f'за реферала {pending.referred_user.email} (активных дней: {active_days}, прошло дней: {days_since_referral})'
|
||||||
|
)
|
||||||
|
paid_count += 1
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
referrer_profile = pending.referrer.referral_profile
|
||||||
|
referrer_profile.add_points(pending.points, reason=pending.reason or f'Реферал {pending.referred_user.email} выполнил условия активности')
|
||||||
|
pending.status = PendingReferralBonus.STATUS_PAID
|
||||||
|
pending.paid_at = now
|
||||||
|
pending.save(update_fields=['status', 'paid_at'])
|
||||||
|
paid_count += 1
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f'Начислено {pending.points} очков {pending.referrer.email} за {pending.referred_user.email} (активных дней: {active_days})'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except UserReferralProfile.DoesNotExist:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING(f'Пропуск {pending.id}: у реферера нет профиля')
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.ERROR(f'Ошибка при начислении {pending.id}: {e}')
|
||||||
|
)
|
||||||
|
if dry_run:
|
||||||
|
self.stdout.write(self.style.SUCCESS(f'[dry-run] Всего к начислению: {paid_count}'))
|
||||||
|
else:
|
||||||
|
self.stdout.write(self.style.SUCCESS(f'Начислено бонусов: {paid_count}'))
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
# Generated migration for referral antifraud: backlog, activity days, pending bonuses
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django.core.validators
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
('referrals', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ReferralInvitedEmail',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('email', models.EmailField(db_index=True, max_length=254, unique=True, verbose_name='Email приглашённого')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата')),
|
||||||
|
('referrer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invited_emails', to=settings.AUTH_USER_MODEL, verbose_name='Реферер')),
|
||||||
|
('referred_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Приглашённый пользователь')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Приглашённый email',
|
||||||
|
'verbose_name_plural': 'Бэклог приглашённых email',
|
||||||
|
'db_table': 'referrals_invited_emails',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='UserActivityDay',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('date', models.DateField(db_index=True, verbose_name='Дата')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activity_days', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'День активности',
|
||||||
|
'verbose_name_plural': 'Дни активности',
|
||||||
|
'db_table': 'referrals_user_activity_days',
|
||||||
|
'ordering': ['-date'],
|
||||||
|
'unique_together': {('user', 'date')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PendingReferralBonus',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('referred_at', models.DateTimeField(db_index=True, verbose_name='Дата приглашения')),
|
||||||
|
('points', models.IntegerField(validators=[django.core.validators.MinValueValidator(0)], verbose_name='Очки к начислению')),
|
||||||
|
('level', models.IntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(2)], verbose_name='Уровень (1 — прямой, 2 — непрямой)')),
|
||||||
|
('reason', models.CharField(blank=True, max_length=255, verbose_name='Причина')),
|
||||||
|
('status', models.CharField(choices=[('pending', 'Ожидает'), ('paid', 'Начислено'), ('cancelled', 'Отменено')], db_index=True, default='pending', max_length=20, verbose_name='Статус')),
|
||||||
|
('paid_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата начисления')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
|
||||||
|
('referrer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pending_referral_bonuses', to=settings.AUTH_USER_MODEL, verbose_name='Реферер')),
|
||||||
|
('referred_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pending_bonuses_for_me', to=settings.AUTH_USER_MODEL, verbose_name='Реферал')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Ожидающий бонус за реферала',
|
||||||
|
'verbose_name_plural': 'Ожидающие бонусы за рефералов',
|
||||||
|
'db_table': 'referrals_pending_referral_bonus',
|
||||||
|
'ordering': ['referred_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='useractivityday',
|
||||||
|
index=models.Index(fields=['user', 'date'], name='referrals_u_user_id_8b0b0d_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='pendingreferralbonus',
|
||||||
|
index=models.Index(fields=['status', 'referred_at'], name='referrals_p_status_9c2e2a_idx'),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
# Data migration: fill ReferralInvitedEmail from existing UserReferralProfile (referred_by is not null)
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_invited_emails(apps, schema_editor):
|
||||||
|
UserReferralProfile = apps.get_model('referrals', 'UserReferralProfile')
|
||||||
|
ReferralInvitedEmail = apps.get_model('referrals', 'ReferralInvitedEmail')
|
||||||
|
for profile in UserReferralProfile.objects.filter(referred_by__isnull=False).select_related('user', 'referred_by'):
|
||||||
|
email_lower = profile.user.email.lower().strip()
|
||||||
|
if not ReferralInvitedEmail.objects.filter(email=email_lower).exists():
|
||||||
|
ReferralInvitedEmail.objects.create(
|
||||||
|
email=email_lower,
|
||||||
|
referrer=profile.referred_by,
|
||||||
|
referred_user=profile.user,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def noop(apps, schema_editor):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('referrals', '0002_add_referral_antifraud_models'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(backfill_invited_emails, noop),
|
||||||
|
]
|
||||||
|
|
@ -493,6 +493,152 @@ class BonusTransaction(models.Model):
|
||||||
return f"{self.user.email}: {sign}{self.amount} ₽"
|
return f"{self.user.email}: {sign}{self.amount} ₽"
|
||||||
|
|
||||||
|
|
||||||
|
class ReferralInvitedEmail(models.Model):
|
||||||
|
"""
|
||||||
|
Бэклог email-адресов, которые уже были приглашены (защита от накрутки).
|
||||||
|
Один email может быть в списке только один раз.
|
||||||
|
"""
|
||||||
|
email = models.EmailField(
|
||||||
|
unique=True,
|
||||||
|
db_index=True,
|
||||||
|
verbose_name='Email приглашённого'
|
||||||
|
)
|
||||||
|
referrer = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='invited_emails',
|
||||||
|
verbose_name='Реферер'
|
||||||
|
)
|
||||||
|
referred_user = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='+',
|
||||||
|
verbose_name='Приглашённый пользователь'
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(
|
||||||
|
auto_now_add=True,
|
||||||
|
verbose_name='Дата'
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'referrals_invited_emails'
|
||||||
|
verbose_name = 'Приглашённый email'
|
||||||
|
verbose_name_plural = 'Бэклог приглашённых email'
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.email} (пригласил: {self.referrer.email})"
|
||||||
|
|
||||||
|
|
||||||
|
class UserActivityDay(models.Model):
|
||||||
|
"""
|
||||||
|
Учёт дней активности пользователя на платформе (один день — одна запись).
|
||||||
|
Используется для проверки «реферал был активен 20+ дней» перед начислением бонуса.
|
||||||
|
"""
|
||||||
|
user = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='activity_days',
|
||||||
|
verbose_name='Пользователь'
|
||||||
|
)
|
||||||
|
date = models.DateField(
|
||||||
|
verbose_name='Дата',
|
||||||
|
db_index=True
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(
|
||||||
|
auto_now_add=True,
|
||||||
|
verbose_name='Создано'
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'referrals_user_activity_days'
|
||||||
|
verbose_name = 'День активности'
|
||||||
|
verbose_name_plural = 'Дни активности'
|
||||||
|
unique_together = [['user', 'date']]
|
||||||
|
ordering = ['-date']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['user', 'date']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user.email} — {self.date}"
|
||||||
|
|
||||||
|
|
||||||
|
class PendingReferralBonus(models.Model):
|
||||||
|
"""
|
||||||
|
Ожидающее начисление бонуса за реферала.
|
||||||
|
Начисляется после 30 дней при условии 20+ дней активности реферала,
|
||||||
|
либо при достижении рефералом 21 дня активности (если был менее активен).
|
||||||
|
"""
|
||||||
|
STATUS_PENDING = 'pending'
|
||||||
|
STATUS_PAID = 'paid'
|
||||||
|
STATUS_CANCELLED = 'cancelled'
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
(STATUS_PENDING, 'Ожидает'),
|
||||||
|
(STATUS_PAID, 'Начислено'),
|
||||||
|
(STATUS_CANCELLED, 'Отменено'),
|
||||||
|
]
|
||||||
|
|
||||||
|
referrer = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='pending_referral_bonuses',
|
||||||
|
verbose_name='Реферер'
|
||||||
|
)
|
||||||
|
referred_user = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='pending_bonuses_for_me',
|
||||||
|
verbose_name='Реферал'
|
||||||
|
)
|
||||||
|
referred_at = models.DateTimeField(
|
||||||
|
verbose_name='Дата приглашения',
|
||||||
|
db_index=True
|
||||||
|
)
|
||||||
|
points = models.IntegerField(
|
||||||
|
validators=[MinValueValidator(0)],
|
||||||
|
verbose_name='Очки к начислению'
|
||||||
|
)
|
||||||
|
level = models.IntegerField(
|
||||||
|
default=1,
|
||||||
|
validators=[MinValueValidator(1), MaxValueValidator(2)],
|
||||||
|
verbose_name='Уровень (1 — прямой, 2 — непрямой)'
|
||||||
|
)
|
||||||
|
reason = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Причина'
|
||||||
|
)
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=STATUS_CHOICES,
|
||||||
|
default=STATUS_PENDING,
|
||||||
|
db_index=True,
|
||||||
|
verbose_name='Статус'
|
||||||
|
)
|
||||||
|
paid_at = models.DateTimeField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Дата начисления'
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(
|
||||||
|
auto_now_add=True,
|
||||||
|
verbose_name='Создано'
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'referrals_pending_referral_bonus'
|
||||||
|
verbose_name = 'Ожидающий бонус за реферала'
|
||||||
|
verbose_name_plural = 'Ожидающие бонусы за рефералов'
|
||||||
|
ordering = ['referred_at']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['status', 'referred_at']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.referrer.email} <- {self.referred_user.email}: {self.points} очков ({self.get_status_display()})"
|
||||||
|
|
||||||
|
|
||||||
class PromoCode(models.Model):
|
class PromoCode(models.Model):
|
||||||
"""
|
"""
|
||||||
Промокод для скидок на подписки.
|
Промокод для скидок на подписки.
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from rest_framework.decorators import action
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||||
from django.db.models import Sum, Q, F
|
from django.db.models import Sum, Q, F
|
||||||
|
from django.utils import timezone
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
|
|
@ -18,6 +19,8 @@ from .models import (
|
||||||
BonusTransaction,
|
BonusTransaction,
|
||||||
PromoCode,
|
PromoCode,
|
||||||
PromoCodeUsage,
|
PromoCodeUsage,
|
||||||
|
ReferralInvitedEmail,
|
||||||
|
PendingReferralBonus,
|
||||||
)
|
)
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
ReferralLevelSerializer,
|
ReferralLevelSerializer,
|
||||||
|
|
@ -126,27 +129,54 @@ class ReferralViewSet(viewsets.ViewSet):
|
||||||
status=status.HTTP_400_BAD_REQUEST
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Защита от накрутки: email уже был приглашён ранее (бэклог)
|
||||||
|
email_lower = request.user.email.lower().strip()
|
||||||
|
if ReferralInvitedEmail.objects.filter(email=email_lower).exists():
|
||||||
|
return Response(
|
||||||
|
{'error': 'Этот email уже был приглашён ранее'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
# Устанавливаем реферера
|
# Устанавливаем реферера
|
||||||
profile.referred_by = referrer_profile.user
|
profile.referred_by = referrer_profile.user
|
||||||
profile.save()
|
profile.save()
|
||||||
|
|
||||||
# Обновляем статистику и начисляем очки (сигнал update_referrer_stats
|
# Добавляем email в бэклог приглашённых
|
||||||
# срабатывает только при created=True, а здесь — update существующего профиля)
|
ReferralInvitedEmail.objects.create(
|
||||||
|
email=email_lower,
|
||||||
|
referrer=referrer_profile.user,
|
||||||
|
referred_user=request.user,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Обновляем счётчик рефералов (без начисления очков — очки начисляются после проверки активности)
|
||||||
settings_obj = ReferralSettings.get_settings()
|
settings_obj = ReferralSettings.get_settings()
|
||||||
referrer_profile.direct_referrals_count += 1
|
referrer_profile.direct_referrals_count += 1
|
||||||
referrer_profile.save(update_fields=['direct_referrals_count'])
|
referrer_profile.save(update_fields=['direct_referrals_count'])
|
||||||
referrer_profile.add_points(
|
|
||||||
settings_obj.points_direct_referral,
|
# Очки начисляются отложенно: через 30 дней при 20+ днях активности реферала или при 21 дне активности
|
||||||
reason=f'Регистрация реферала {request.user.email}'
|
now = timezone.now()
|
||||||
|
PendingReferralBonus.objects.create(
|
||||||
|
referrer=referrer_profile.user,
|
||||||
|
referred_user=request.user,
|
||||||
|
referred_at=now,
|
||||||
|
points=settings_obj.points_direct_referral,
|
||||||
|
level=1,
|
||||||
|
reason=f'Регистрация реферала {request.user.email}',
|
||||||
|
status=PendingReferralBonus.STATUS_PENDING,
|
||||||
)
|
)
|
||||||
if referrer_profile.referred_by:
|
if referrer_profile.referred_by:
|
||||||
try:
|
try:
|
||||||
level2_profile = referrer_profile.referred_by.referral_profile
|
level2_profile = referrer_profile.referred_by.referral_profile
|
||||||
level2_profile.indirect_referrals_count += 1
|
level2_profile.indirect_referrals_count += 1
|
||||||
level2_profile.save(update_fields=['indirect_referrals_count'])
|
level2_profile.save(update_fields=['indirect_referrals_count'])
|
||||||
level2_profile.add_points(
|
PendingReferralBonus.objects.create(
|
||||||
settings_obj.points_indirect_referral,
|
referrer=referrer_profile.referred_by,
|
||||||
reason=f'Регистрация непрямого реферала {request.user.email}'
|
referred_user=request.user,
|
||||||
|
referred_at=now,
|
||||||
|
points=settings_obj.points_indirect_referral,
|
||||||
|
level=2,
|
||||||
|
reason=f'Регистрация непрямого реферала {request.user.email}',
|
||||||
|
status=PendingReferralBonus.STATUS_PENDING,
|
||||||
)
|
)
|
||||||
except (UserReferralProfile.DoesNotExist, AttributeError):
|
except (UserReferralProfile.DoesNotExist, AttributeError):
|
||||||
pass
|
pass
|
||||||
|
|
|
||||||
|
|
@ -635,6 +635,7 @@ class LessonCalendarSerializer(serializers.Serializer):
|
||||||
class LessonCalendarItemSerializer(serializers.ModelSerializer):
|
class LessonCalendarItemSerializer(serializers.ModelSerializer):
|
||||||
"""Лёгкий сериализатор для календаря: только поля, нужные для списка и календаря."""
|
"""Лёгкий сериализатор для календаря: только поля, нужные для списка и календаря."""
|
||||||
client_name = serializers.CharField(source='client.user.get_full_name', read_only=True)
|
client_name = serializers.CharField(source='client.user.get_full_name', read_only=True)
|
||||||
|
mentor_name = serializers.CharField(source='mentor.get_full_name', read_only=True)
|
||||||
subject = serializers.SerializerMethodField()
|
subject = serializers.SerializerMethodField()
|
||||||
|
|
||||||
def get_subject(self, obj):
|
def get_subject(self, obj):
|
||||||
|
|
@ -658,7 +659,7 @@ class LessonCalendarItemSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Lesson
|
model = Lesson
|
||||||
fields = ['id', 'title', 'start_time', 'end_time', 'status', 'client', 'client_name', 'subject', 'subject_name']
|
fields = ['id', 'title', 'start_time', 'end_time', 'status', 'client', 'client_name', 'mentor', 'mentor_name', 'subject', 'subject_name']
|
||||||
|
|
||||||
|
|
||||||
class LessonHomeworkSubmissionSerializer(serializers.ModelSerializer):
|
class LessonHomeworkSubmissionSerializer(serializers.ModelSerializer):
|
||||||
|
|
|
||||||
|
|
@ -30,14 +30,8 @@ def lesson_saved(sender, instance, created, **kwargs):
|
||||||
lesson_id=instance.id,
|
lesson_id=instance.id,
|
||||||
notification_type='lesson_created'
|
notification_type='lesson_created'
|
||||||
)
|
)
|
||||||
|
# Напоминания отправляются периодической задачей send_lesson_reminders
|
||||||
# Планируем напоминание за 1 час до занятия
|
# (проверяет флаги reminder_24h_sent, reminder_1h_sent, reminder_15m_sent)
|
||||||
reminder_time = instance.start_time - timedelta(hours=1)
|
|
||||||
if reminder_time > timezone.now():
|
|
||||||
send_lesson_notification.apply_async(
|
|
||||||
args=[instance.id, 'lesson_reminder'],
|
|
||||||
eta=reminder_time
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
# Занятие изменено
|
# Занятие изменено
|
||||||
# Проверяем, что именно изменилось
|
# Проверяем, что именно изменилось
|
||||||
|
|
|
||||||
|
|
@ -1,448 +1,448 @@
|
||||||
# Celery задачи для schedule
|
# Celery задачи для schedule
|
||||||
|
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
import logging
|
import logging
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
from .models import Lesson, Subject, MentorSubject
|
from .models import Lesson, Subject, MentorSubject
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def send_lesson_reminders():
|
def send_lesson_reminders():
|
||||||
"""
|
"""
|
||||||
Отправка напоминаний о предстоящих занятиях.
|
Отправка напоминаний о предстоящих занятиях.
|
||||||
|
|
||||||
Отправляет напоминания за:
|
Отправляет напоминания за:
|
||||||
- 24 часа до занятия
|
- 24 часа до занятия
|
||||||
- 1 час до занятия
|
- 1 час до занятия
|
||||||
- 15 минут до занятия
|
- 15 минут до занятия
|
||||||
|
|
||||||
Задача запускается каждые 15 минут через Celery Beat.
|
Задача запускается каждые 15 минут через Celery Beat.
|
||||||
"""
|
"""
|
||||||
from apps.notifications.services import NotificationService
|
from apps.notifications.services import NotificationService
|
||||||
|
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
sent_24h = 0
|
sent_24h = 0
|
||||||
sent_1h = 0
|
sent_1h = 0
|
||||||
sent_15m = 0
|
sent_15m = 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Находим все запланированные занятия, которые еще не начались и не отменены
|
# Находим все запланированные занятия, которые еще не начались и не отменены
|
||||||
lessons = Lesson.objects.filter(
|
lessons = Lesson.objects.filter(
|
||||||
start_time__gt=now,
|
start_time__gt=now,
|
||||||
status='scheduled'
|
status='scheduled'
|
||||||
).select_related('client', 'client__user', 'mentor')
|
).select_related('client', 'client__user', 'mentor')
|
||||||
|
|
||||||
# Напоминания за 24 часа (от 23:30 до 24:30)
|
# Напоминания за 24 часа (от 23:30 до 24:30)
|
||||||
time_24h_min = now + timedelta(hours=23, minutes=30)
|
time_24h_min = now + timedelta(hours=23, minutes=30)
|
||||||
time_24h_max = now + timedelta(hours=24, minutes=30)
|
time_24h_max = now + timedelta(hours=24, minutes=30)
|
||||||
|
|
||||||
lessons_24h = lessons.filter(
|
lessons_24h = lessons.filter(
|
||||||
start_time__gte=time_24h_min,
|
start_time__gte=time_24h_min,
|
||||||
start_time__lte=time_24h_max,
|
start_time__lte=time_24h_max,
|
||||||
reminder_24h_sent=False
|
reminder_24h_sent=False
|
||||||
)
|
)
|
||||||
|
|
||||||
# Оптимизация: используем bulk_update вместо цикла с save()
|
# Оптимизация: используем bulk_update вместо цикла с save()
|
||||||
lessons_24h_list = list(lessons_24h)
|
lessons_24h_list = list(lessons_24h)
|
||||||
lessons_24h_to_update = []
|
lessons_24h_to_update = []
|
||||||
for lesson in lessons_24h_list:
|
for lesson in lessons_24h_list:
|
||||||
try:
|
try:
|
||||||
NotificationService.send_lesson_reminder(lesson, time_before="24 часа")
|
NotificationService.send_lesson_reminder(lesson, time_before="24 часа")
|
||||||
lesson.reminder_24h_sent = True
|
lesson.reminder_24h_sent = True
|
||||||
lessons_24h_to_update.append(lesson)
|
lessons_24h_to_update.append(lesson)
|
||||||
sent_24h += 1
|
sent_24h += 1
|
||||||
logger.info(f'Отправлено напоминание за 24 часа для занятия {lesson.id}')
|
logger.info(f'Отправлено напоминание за 24 часа для занятия {lesson.id}')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Ошибка отправки напоминания за 24 часа для занятия {lesson.id}: {e}')
|
logger.error(f'Ошибка отправки напоминания за 24 часа для занятия {lesson.id}: {e}')
|
||||||
if lessons_24h_to_update:
|
if lessons_24h_to_update:
|
||||||
Lesson.objects.bulk_update(lessons_24h_to_update, ['reminder_24h_sent'])
|
Lesson.objects.bulk_update(lessons_24h_to_update, ['reminder_24h_sent'])
|
||||||
|
|
||||||
# Напоминания за 1 час (от 50 минут до 70 минут)
|
# Напоминания за 1 час (от 50 минут до 70 минут)
|
||||||
time_1h_min = now + timedelta(minutes=50)
|
time_1h_min = now + timedelta(minutes=50)
|
||||||
time_1h_max = now + timedelta(minutes=70)
|
time_1h_max = now + timedelta(minutes=70)
|
||||||
|
|
||||||
lessons_1h = lessons.filter(
|
lessons_1h = lessons.filter(
|
||||||
start_time__gte=time_1h_min,
|
start_time__gte=time_1h_min,
|
||||||
start_time__lte=time_1h_max,
|
start_time__lte=time_1h_max,
|
||||||
reminder_1h_sent=False
|
reminder_1h_sent=False
|
||||||
)
|
)
|
||||||
|
|
||||||
# Оптимизация: используем bulk_update вместо цикла с save()
|
# Оптимизация: используем bulk_update вместо цикла с save()
|
||||||
lessons_1h_list = list(lessons_1h)
|
lessons_1h_list = list(lessons_1h)
|
||||||
lessons_1h_to_update = []
|
lessons_1h_to_update = []
|
||||||
for lesson in lessons_1h_list:
|
for lesson in lessons_1h_list:
|
||||||
try:
|
try:
|
||||||
NotificationService.send_lesson_reminder(lesson, time_before="1 час")
|
NotificationService.send_lesson_reminder(lesson, time_before="1 час")
|
||||||
lesson.reminder_1h_sent = True
|
lesson.reminder_1h_sent = True
|
||||||
lessons_1h_to_update.append(lesson)
|
lessons_1h_to_update.append(lesson)
|
||||||
sent_1h += 1
|
sent_1h += 1
|
||||||
logger.info(f'Отправлено напоминание за 1 час для занятия {lesson.id}')
|
logger.info(f'Отправлено напоминание за 1 час для занятия {lesson.id}')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Ошибка отправки напоминания за 1 час для занятия {lesson.id}: {e}')
|
logger.error(f'Ошибка отправки напоминания за 1 час для занятия {lesson.id}: {e}')
|
||||||
if lessons_1h_to_update:
|
if lessons_1h_to_update:
|
||||||
Lesson.objects.bulk_update(lessons_1h_to_update, ['reminder_1h_sent'])
|
Lesson.objects.bulk_update(lessons_1h_to_update, ['reminder_1h_sent'])
|
||||||
|
|
||||||
# Напоминания за 15 минут (от 10 минут до 20 минут)
|
# Напоминания за 15 минут (от 10 минут до 20 минут)
|
||||||
time_15m_min = now + timedelta(minutes=10)
|
time_15m_min = now + timedelta(minutes=10)
|
||||||
time_15m_max = now + timedelta(minutes=20)
|
time_15m_max = now + timedelta(minutes=20)
|
||||||
|
|
||||||
lessons_15m = lessons.filter(
|
lessons_15m = lessons.filter(
|
||||||
start_time__gte=time_15m_min,
|
start_time__gte=time_15m_min,
|
||||||
start_time__lte=time_15m_max,
|
start_time__lte=time_15m_max,
|
||||||
reminder_15m_sent=False
|
reminder_15m_sent=False
|
||||||
)
|
)
|
||||||
|
|
||||||
# Оптимизация: используем bulk_update вместо цикла с save()
|
# Оптимизация: используем bulk_update вместо цикла с save()
|
||||||
lessons_15m_list = list(lessons_15m)
|
lessons_15m_list = list(lessons_15m)
|
||||||
lessons_15m_to_update = []
|
lessons_15m_to_update = []
|
||||||
for lesson in lessons_15m_list:
|
for lesson in lessons_15m_list:
|
||||||
try:
|
try:
|
||||||
NotificationService.send_lesson_reminder(lesson, time_before="15 минут")
|
NotificationService.send_lesson_reminder(lesson, time_before="15 минут")
|
||||||
lesson.reminder_15m_sent = True
|
lesson.reminder_15m_sent = True
|
||||||
lessons_15m_to_update.append(lesson)
|
lessons_15m_to_update.append(lesson)
|
||||||
sent_15m += 1
|
sent_15m += 1
|
||||||
logger.info(f'Отправлено напоминание за 15 минут для занятия {lesson.id}')
|
logger.info(f'Отправлено напоминание за 15 минут для занятия {lesson.id}')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Ошибка отправки напоминания за 15 минут для занятия {lesson.id}: {e}')
|
logger.error(f'Ошибка отправки напоминания за 15 минут для занятия {lesson.id}: {e}')
|
||||||
if lessons_15m_to_update:
|
if lessons_15m_to_update:
|
||||||
Lesson.objects.bulk_update(lessons_15m_to_update, ['reminder_15m_sent'])
|
Lesson.objects.bulk_update(lessons_15m_to_update, ['reminder_15m_sent'])
|
||||||
|
|
||||||
total_sent = sent_24h + sent_1h + sent_15m
|
total_sent = sent_24h + sent_1h + sent_15m
|
||||||
logger.info(
|
logger.info(
|
||||||
f'[send_lesson_reminders] Отправлено напоминаний: '
|
f'[send_lesson_reminders] Отправлено напоминаний: '
|
||||||
f'24ч - {sent_24h}, 1ч - {sent_1h}, 15м - {sent_15m} (всего: {total_sent})'
|
f'24ч - {sent_24h}, 1ч - {sent_1h}, 15м - {sent_15m} (всего: {total_sent})'
|
||||||
)
|
)
|
||||||
|
|
||||||
return f'Отправлено: 24ч - {sent_24h}, 1ч - {sent_1h}, 15м - {sent_15m}'
|
return f'Отправлено: 24ч - {sent_24h}, 1ч - {sent_1h}, 15м - {sent_15m}'
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'[send_lesson_reminders] Ошибка: {str(e)}', exc_info=True)
|
logger.error(f'[send_lesson_reminders] Ошибка: {str(e)}', exc_info=True)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def send_attendance_confirmation_requests():
|
def send_attendance_confirmation_requests():
|
||||||
"""
|
"""
|
||||||
Отправка запросов о подтверждении присутствия за 3 часа до занятия.
|
Отправка запросов о подтверждении присутствия за 3 часа до занятия.
|
||||||
Проверяет все занятия, которые начинаются через 3 часа или меньше,
|
Проверяет все занятия, которые начинаются через 3 часа или меньше,
|
||||||
и отправляет запрос студенту, если еще не отправлен.
|
и отправляет запрос студенту, если еще не отправлен.
|
||||||
"""
|
"""
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from apps.notifications.services import NotificationService
|
from apps.notifications.services import NotificationService
|
||||||
|
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
# Занятия, которые начинаются через 3 часа или меньше
|
# Занятия, которые начинаются через 3 часа или меньше
|
||||||
time_threshold = now + timedelta(hours=3)
|
time_threshold = now + timedelta(hours=3)
|
||||||
|
|
||||||
# Находим занятия, которые:
|
# Находим занятия, которые:
|
||||||
# 1. Еще не начались (start_time > now)
|
# 1. Еще не начались (start_time > now)
|
||||||
# 2. Начинаются через 3 часа или меньше (start_time <= time_threshold)
|
# 2. Начинаются через 3 часа или меньше (start_time <= time_threshold)
|
||||||
# 3. Еще не отменены
|
# 3. Еще не отменены
|
||||||
# 4. Запрос о присутствии еще не отправлен
|
# 4. Запрос о присутствии еще не отправлен
|
||||||
lessons = Lesson.objects.filter(
|
lessons = Lesson.objects.filter(
|
||||||
start_time__gt=now,
|
start_time__gt=now,
|
||||||
start_time__lte=time_threshold,
|
start_time__lte=time_threshold,
|
||||||
status='scheduled',
|
status='scheduled',
|
||||||
attendance_confirmation_sent=False
|
attendance_confirmation_sent=False
|
||||||
).select_related('client', 'client__user', 'mentor')
|
).select_related('client', 'client__user', 'mentor')
|
||||||
|
|
||||||
sent_count = 0
|
sent_count = 0
|
||||||
lessons_to_update = []
|
lessons_to_update = []
|
||||||
|
|
||||||
for lesson in lessons:
|
for lesson in lessons:
|
||||||
try:
|
try:
|
||||||
# Отправляем запрос
|
# Отправляем запрос
|
||||||
NotificationService.send_attendance_confirmation_request(lesson)
|
NotificationService.send_attendance_confirmation_request(lesson)
|
||||||
|
|
||||||
# Отмечаем что запрос отправлен (накапливаем для bulk_update)
|
# Отмечаем что запрос отправлен (накапливаем для bulk_update)
|
||||||
lesson.attendance_confirmation_sent = True
|
lesson.attendance_confirmation_sent = True
|
||||||
lessons_to_update.append(lesson)
|
lessons_to_update.append(lesson)
|
||||||
sent_count += 1
|
sent_count += 1
|
||||||
|
|
||||||
logger.info(f'Отправлен запрос о присутствии для занятия {lesson.id}')
|
logger.info(f'Отправлен запрос о присутствии для занятия {lesson.id}')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Ошибка отправки запроса о присутствии для занятия {lesson.id}: {e}')
|
logger.error(f'Ошибка отправки запроса о присутствии для занятия {lesson.id}: {e}')
|
||||||
|
|
||||||
# Оптимизация: используем bulk_update вместо цикла с save()
|
# Оптимизация: используем bulk_update вместо цикла с save()
|
||||||
if lessons_to_update:
|
if lessons_to_update:
|
||||||
Lesson.objects.bulk_update(lessons_to_update, ['attendance_confirmation_sent'], batch_size=100)
|
Lesson.objects.bulk_update(lessons_to_update, ['attendance_confirmation_sent'], batch_size=100)
|
||||||
|
|
||||||
logger.info(f'[send_attendance_confirmation_requests] Отправлено {sent_count} запросов о присутствии')
|
logger.info(f'[send_attendance_confirmation_requests] Отправлено {sent_count} запросов о присутствии')
|
||||||
return f'Отправлено {sent_count} запросов'
|
return f'Отправлено {sent_count} запросов'
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def maintain_recurring_lessons():
|
def maintain_recurring_lessons():
|
||||||
"""
|
"""
|
||||||
Поддержание 12 будущих занятий для повторяющихся занятий.
|
Поддержание 12 будущих занятий для повторяющихся занятий.
|
||||||
|
|
||||||
Задача проверяет все повторяющиеся занятия и добавляет недостающие,
|
Задача проверяет все повторяющиеся занятия и добавляет недостающие,
|
||||||
чтобы всегда было 12 будущих занятий впереди.
|
чтобы всегда было 12 будущих занятий впереди.
|
||||||
|
|
||||||
Запускается каждый день через Celery Beat.
|
Запускается каждый день через Celery Beat.
|
||||||
"""
|
"""
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
added_count = 0
|
added_count = 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Находим все уникальные серии повторяющихся занятий
|
# Находим все уникальные серии повторяющихся занятий
|
||||||
recurring_series = Lesson.objects.filter(
|
recurring_series = Lesson.objects.filter(
|
||||||
is_recurring=True,
|
is_recurring=True,
|
||||||
recurring_series_id__isnull=False
|
recurring_series_id__isnull=False
|
||||||
).values_list('recurring_series_id', flat=True).distinct()
|
).values_list('recurring_series_id', flat=True).distinct()
|
||||||
|
|
||||||
for series_id in recurring_series:
|
for series_id in recurring_series:
|
||||||
# Находим все занятия этой серии, которые еще не прошли
|
# Находим все занятия этой серии, которые еще не прошли
|
||||||
series_lessons = Lesson.objects.filter(
|
series_lessons = Lesson.objects.filter(
|
||||||
recurring_series_id=series_id,
|
recurring_series_id=series_id,
|
||||||
start_time__gt=now # Только будущие занятия
|
start_time__gt=now # Только будущие занятия
|
||||||
).order_by('start_time')
|
).order_by('start_time')
|
||||||
|
|
||||||
if not series_lessons.exists():
|
if not series_lessons.exists():
|
||||||
# Если нет будущих занятий, пропускаем эту серию
|
# Если нет будущих занятий, пропускаем эту серию
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Находим последнее занятие в серии (самое дальнее по времени)
|
# Находим последнее занятие в серии (самое дальнее по времени)
|
||||||
last_lesson = series_lessons.last()
|
last_lesson = series_lessons.last()
|
||||||
|
|
||||||
# Подсчитываем, сколько будущих занятий есть
|
# Подсчитываем, сколько будущих занятий есть
|
||||||
future_count = series_lessons.count()
|
future_count = series_lessons.count()
|
||||||
|
|
||||||
# Если меньше 12, добавляем недостающие
|
# Если меньше 12, добавляем недостающие
|
||||||
if future_count < 12:
|
if future_count < 12:
|
||||||
# Находим первое занятие серии для получения шаблона
|
# Находим первое занятие серии для получения шаблона
|
||||||
first_lesson = Lesson.objects.filter(
|
first_lesson = Lesson.objects.filter(
|
||||||
recurring_series_id=series_id,
|
recurring_series_id=series_id,
|
||||||
parent_lesson__isnull=True # Родительское занятие
|
parent_lesson__isnull=True # Родительское занятие
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if not first_lesson:
|
if not first_lesson:
|
||||||
# Если нет родительского, берем первое занятие серии
|
# Если нет родительского, берем первое занятие серии
|
||||||
first_lesson = Lesson.objects.filter(
|
first_lesson = Lesson.objects.filter(
|
||||||
recurring_series_id=series_id
|
recurring_series_id=series_id
|
||||||
).order_by('start_time').first()
|
).order_by('start_time').first()
|
||||||
|
|
||||||
if not first_lesson:
|
if not first_lesson:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Получаем время начала и окончания последнего занятия
|
# Получаем время начала и окончания последнего занятия
|
||||||
last_start_time = last_lesson.start_time
|
last_start_time = last_lesson.start_time
|
||||||
last_end_time = last_lesson.end_time
|
last_end_time = last_lesson.end_time
|
||||||
duration_minutes = last_lesson.duration
|
duration_minutes = last_lesson.duration
|
||||||
|
|
||||||
# Вычисляем, сколько занятий нужно добавить
|
# Вычисляем, сколько занятий нужно добавить
|
||||||
lessons_to_add = 12 - future_count
|
lessons_to_add = 12 - future_count
|
||||||
|
|
||||||
# Создаем недостающие занятия
|
# Создаем недостающие занятия
|
||||||
new_lessons = []
|
new_lessons = []
|
||||||
for i in range(1, lessons_to_add + 1):
|
for i in range(1, lessons_to_add + 1):
|
||||||
# Каждое следующее занятие через неделю после предыдущего
|
# Каждое следующее занятие через неделю после предыдущего
|
||||||
new_start_time = last_start_time + timedelta(weeks=i)
|
new_start_time = last_start_time + timedelta(weeks=i)
|
||||||
new_end_time = new_start_time + timedelta(minutes=duration_minutes)
|
new_end_time = new_start_time + timedelta(minutes=duration_minutes)
|
||||||
|
|
||||||
lesson_data = {
|
lesson_data = {
|
||||||
'mentor': first_lesson.mentor,
|
'mentor': first_lesson.mentor,
|
||||||
'client': first_lesson.client,
|
'client': first_lesson.client,
|
||||||
'group': first_lesson.group,
|
'group': first_lesson.group,
|
||||||
'start_time': new_start_time,
|
'start_time': new_start_time,
|
||||||
'end_time': new_end_time,
|
'end_time': new_end_time,
|
||||||
'duration': duration_minutes,
|
'duration': duration_minutes,
|
||||||
'title': first_lesson.title,
|
'title': first_lesson.title,
|
||||||
'description': first_lesson.description or '',
|
'description': first_lesson.description or '',
|
||||||
'subject': first_lesson.subject,
|
'subject': first_lesson.subject,
|
||||||
'mentor_subject': first_lesson.mentor_subject,
|
'mentor_subject': first_lesson.mentor_subject,
|
||||||
'subject_name': first_lesson.subject_name or (first_lesson.subject.name if first_lesson.subject else '') or (first_lesson.mentor_subject.name if first_lesson.mentor_subject else ''),
|
'subject_name': first_lesson.subject_name or (first_lesson.subject.name if first_lesson.subject else '') or (first_lesson.mentor_subject.name if first_lesson.mentor_subject else ''),
|
||||||
'template': first_lesson.template,
|
'template': first_lesson.template,
|
||||||
'price': first_lesson.price,
|
'price': first_lesson.price,
|
||||||
'is_recurring': True,
|
'is_recurring': True,
|
||||||
'recurring_series_id': series_id,
|
'recurring_series_id': series_id,
|
||||||
'parent_lesson': first_lesson if first_lesson.parent_lesson is None else first_lesson.parent_lesson,
|
'parent_lesson': first_lesson if first_lesson.parent_lesson is None else first_lesson.parent_lesson,
|
||||||
}
|
}
|
||||||
new_lessons.append(Lesson(**lesson_data))
|
new_lessons.append(Lesson(**lesson_data))
|
||||||
|
|
||||||
# Массовое создание для оптимизации
|
# Массовое создание для оптимизации
|
||||||
if new_lessons:
|
if new_lessons:
|
||||||
Lesson.objects.bulk_create(new_lessons)
|
Lesson.objects.bulk_create(new_lessons)
|
||||||
added_count += len(new_lessons)
|
added_count += len(new_lessons)
|
||||||
logger.info(
|
logger.info(
|
||||||
f'Добавлено {len(new_lessons)} занятий для серии {series_id}. '
|
f'Добавлено {len(new_lessons)} занятий для серии {series_id}. '
|
||||||
f'Теперь будущих занятий: {future_count + len(new_lessons)}'
|
f'Теперь будущих занятий: {future_count + len(new_lessons)}'
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f'[maintain_recurring_lessons] Добавлено {added_count} занятий для поддержания 12 будущих занятий')
|
logger.info(f'[maintain_recurring_lessons] Добавлено {added_count} занятий для поддержания 12 будущих занятий')
|
||||||
return f'Добавлено {added_count} занятий'
|
return f'Добавлено {added_count} занятий'
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'[maintain_recurring_lessons] Ошибка: {str(e)}', exc_info=True)
|
logger.error(f'[maintain_recurring_lessons] Ошибка: {str(e)}', exc_info=True)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def promote_mentor_subjects_to_subjects():
|
def promote_mentor_subjects_to_subjects():
|
||||||
"""
|
"""
|
||||||
Переносит кастомные предметы менторов в общую модель Subject,
|
Переносит кастомные предметы менторов в общую модель Subject,
|
||||||
если предмет используется более чем 10 менторами.
|
если предмет используется более чем 10 менторами.
|
||||||
|
|
||||||
Запускается периодически через Celery Beat (например, раз в день).
|
Запускается периодически через Celery Beat (например, раз в день).
|
||||||
"""
|
"""
|
||||||
promoted_count = 0
|
promoted_count = 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Находим все уникальные названия кастомных предметов
|
# Находим все уникальные названия кастомных предметов
|
||||||
# и подсчитываем количество менторов, использующих каждый предмет
|
# и подсчитываем количество менторов, использующих каждый предмет
|
||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
mentor_subjects_stats = MentorSubject.objects.values('name').annotate(
|
mentor_subjects_stats = MentorSubject.objects.values('name').annotate(
|
||||||
mentor_count=Count('mentor', distinct=True)
|
mentor_count=Count('mentor', distinct=True)
|
||||||
).filter(mentor_count__gte=10) # Используется 10+ менторами
|
).filter(mentor_count__gte=10) # Используется 10+ менторами
|
||||||
|
|
||||||
for stat in mentor_subjects_stats:
|
for stat in mentor_subjects_stats:
|
||||||
subject_name = stat['name']
|
subject_name = stat['name']
|
||||||
mentor_count = stat['mentor_count']
|
mentor_count = stat['mentor_count']
|
||||||
|
|
||||||
# Проверяем, существует ли уже такой предмет в Subject
|
# Проверяем, существует ли уже такой предмет в Subject
|
||||||
existing_subject = Subject.objects.filter(name__iexact=subject_name).first()
|
existing_subject = Subject.objects.filter(name__iexact=subject_name).first()
|
||||||
|
|
||||||
if existing_subject:
|
if existing_subject:
|
||||||
# Если предмет уже существует, просто активируем его
|
# Если предмет уже существует, просто активируем его
|
||||||
if not existing_subject.is_active:
|
if not existing_subject.is_active:
|
||||||
existing_subject.is_active = True
|
existing_subject.is_active = True
|
||||||
existing_subject.save()
|
existing_subject.save()
|
||||||
logger.info(f'Активирован существующий предмет: {subject_name}')
|
logger.info(f'Активирован существующий предмет: {subject_name}')
|
||||||
else:
|
else:
|
||||||
# Создаем новый предмет в Subject
|
# Создаем новый предмет в Subject
|
||||||
new_subject = Subject.objects.create(
|
new_subject = Subject.objects.create(
|
||||||
name=subject_name,
|
name=subject_name,
|
||||||
is_active=True
|
is_active=True
|
||||||
)
|
)
|
||||||
logger.info(f'Создан новый предмет в общей модели: {subject_name} (используется {mentor_count} менторами)')
|
logger.info(f'Создан новый предмет в общей модели: {subject_name} (используется {mentor_count} менторами)')
|
||||||
|
|
||||||
# Обновляем все занятия, использующие этот кастомный предмет
|
# Обновляем все занятия, использующие этот кастомный предмет
|
||||||
# Заменяем mentor_subject на subject
|
# Заменяем mentor_subject на subject
|
||||||
mentor_subjects = MentorSubject.objects.filter(name__iexact=subject_name)
|
mentor_subjects = MentorSubject.objects.filter(name__iexact=subject_name)
|
||||||
|
|
||||||
for mentor_subject in mentor_subjects:
|
for mentor_subject in mentor_subjects:
|
||||||
# Находим или создаем Subject
|
# Находим или создаем Subject
|
||||||
subject = Subject.objects.filter(name__iexact=subject_name).first()
|
subject = Subject.objects.filter(name__iexact=subject_name).first()
|
||||||
if not subject:
|
if not subject:
|
||||||
subject = Subject.objects.create(name=subject_name, is_active=True)
|
subject = Subject.objects.create(name=subject_name, is_active=True)
|
||||||
|
|
||||||
# Обновляем занятия
|
# Обновляем занятия
|
||||||
updated_lessons = Lesson.objects.filter(mentor_subject=mentor_subject).update(
|
updated_lessons = Lesson.objects.filter(mentor_subject=mentor_subject).update(
|
||||||
subject=subject,
|
subject=subject,
|
||||||
mentor_subject=None,
|
mentor_subject=None,
|
||||||
subject_name=subject.name
|
subject_name=subject.name
|
||||||
)
|
)
|
||||||
|
|
||||||
# Обновляем шаблоны
|
# Обновляем шаблоны
|
||||||
from .models import LessonTemplate
|
from .models import LessonTemplate
|
||||||
LessonTemplate.objects.filter(mentor_subject=mentor_subject).update(
|
LessonTemplate.objects.filter(mentor_subject=mentor_subject).update(
|
||||||
subject=subject,
|
subject=subject,
|
||||||
mentor_subject=None,
|
mentor_subject=None,
|
||||||
subject_name=subject.name
|
subject_name=subject.name
|
||||||
)
|
)
|
||||||
|
|
||||||
if updated_lessons > 0:
|
if updated_lessons > 0:
|
||||||
logger.info(
|
logger.info(
|
||||||
f'Обновлено {updated_lessons} занятий и шаблонов для предмета "{subject_name}" '
|
f'Обновлено {updated_lessons} занятий и шаблонов для предмета "{subject_name}" '
|
||||||
f'(ментор: {mentor_subject.mentor.get_full_name()})'
|
f'(ментор: {mentor_subject.mentor.get_full_name()})'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Удаляем кастомные предметы, которые были перенесены
|
# Удаляем кастомные предметы, которые были перенесены
|
||||||
deleted_count = mentor_subjects.delete()[0]
|
deleted_count = mentor_subjects.delete()[0]
|
||||||
if deleted_count > 0:
|
if deleted_count > 0:
|
||||||
logger.info(f'Удалено {deleted_count} кастомных предметов "{subject_name}" после переноса в общую модель')
|
logger.info(f'Удалено {deleted_count} кастомных предметов "{subject_name}" после переноса в общую модель')
|
||||||
promoted_count += deleted_count
|
promoted_count += deleted_count
|
||||||
|
|
||||||
logger.info(f'[promote_mentor_subjects_to_subjects] Перенесено {promoted_count} кастомных предметов в общую модель')
|
logger.info(f'[promote_mentor_subjects_to_subjects] Перенесено {promoted_count} кастомных предметов в общую модель')
|
||||||
return f'Перенесено {promoted_count} предметов'
|
return f'Перенесено {promoted_count} предметов'
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'[promote_mentor_subjects_to_subjects] Ошибка: {str(e)}', exc_info=True)
|
logger.error(f'[promote_mentor_subjects_to_subjects] Ошибка: {str(e)}', exc_info=True)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
@shared_task(name='apps.schedule.tasks.start_lessons_automatically')
|
@shared_task(name='apps.schedule.tasks.start_lessons_automatically')
|
||||||
def start_lessons_automatically():
|
def start_lessons_automatically():
|
||||||
"""
|
"""
|
||||||
Автоматическое начало и завершение занятий по времени.
|
Автоматическое начало и завершение занятий по времени.
|
||||||
|
|
||||||
Обновляет статус занятий:
|
Обновляет статус занятий:
|
||||||
- 'scheduled' -> 'in_progress' когда наступает время начала (start_time <= now)
|
- 'scheduled' -> 'in_progress' когда наступает время начала (start_time <= now)
|
||||||
- 'scheduled' или 'in_progress' -> 'completed' когда время окончания прошло (end_time < now)
|
- 'scheduled' или 'in_progress' -> 'completed' когда время окончания прошло (end_time < now)
|
||||||
|
|
||||||
Запускается каждую минуту через Celery Beat.
|
Запускается каждую минуту через Celery Beat.
|
||||||
"""
|
"""
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
started_count = 0
|
started_count = 0
|
||||||
completed_count = 0
|
completed_count = 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Находим все запланированные занятия, которые должны начаться
|
# Находим все запланированные занятия, которые должны начаться
|
||||||
# start_time <= now (время начала уже наступило)
|
# start_time <= now (время начала уже наступило)
|
||||||
# end_time >= now (время окончания еще не наступило)
|
# end_time >= now (время окончания еще не наступило)
|
||||||
# status = 'scheduled' (еще не начались)
|
# status = 'scheduled' (еще не начались)
|
||||||
lessons_to_start = Lesson.objects.filter(
|
lessons_to_start = Lesson.objects.filter(
|
||||||
status='scheduled',
|
status='scheduled',
|
||||||
start_time__lte=now,
|
start_time__lte=now,
|
||||||
end_time__gte=now
|
end_time__gte=now
|
||||||
).select_related('mentor', 'client')
|
).select_related('mentor', 'client')
|
||||||
|
|
||||||
# Оптимизация: используем bulk_update вместо цикла с save()
|
# Оптимизация: используем bulk_update вместо цикла с save()
|
||||||
lessons_to_start_list = list(lessons_to_start)
|
lessons_to_start_list = list(lessons_to_start)
|
||||||
for lesson in lessons_to_start_list:
|
for lesson in lessons_to_start_list:
|
||||||
lesson.status = 'in_progress'
|
lesson.status = 'in_progress'
|
||||||
if lessons_to_start_list:
|
if lessons_to_start_list:
|
||||||
Lesson.objects.bulk_update(lessons_to_start_list, ['status'])
|
Lesson.objects.bulk_update(lessons_to_start_list, ['status'])
|
||||||
started_count = len(lessons_to_start_list)
|
started_count = len(lessons_to_start_list)
|
||||||
for lesson in lessons_to_start_list:
|
for lesson in lessons_to_start_list:
|
||||||
logger.info(f'Занятие {lesson.id} автоматически переведено в статус "in_progress"')
|
logger.info(f'Занятие {lesson.id} автоматически переведено в статус "in_progress"')
|
||||||
|
|
||||||
# Находим занятия, которые уже прошли и должны быть завершены
|
# Находим занятия, которые уже прошли и должны быть завершены
|
||||||
# end_time < now - 5 минут (время окончания прошло более 5 минут назад - даём время на завершение)
|
# end_time < now - 5 минут (время окончания прошло более 5 минут назад - даём время на завершение)
|
||||||
# status in ['scheduled', 'in_progress'] (еще не завершены)
|
# status in ['scheduled', 'in_progress'] (еще не завершены)
|
||||||
five_minutes_ago = now - timedelta(minutes=5)
|
five_minutes_ago = now - timedelta(minutes=5)
|
||||||
lessons_to_complete = Lesson.objects.filter(
|
lessons_to_complete = Lesson.objects.filter(
|
||||||
status__in=['scheduled', 'in_progress'],
|
status__in=['scheduled', 'in_progress'],
|
||||||
end_time__lt=five_minutes_ago
|
end_time__lt=five_minutes_ago
|
||||||
).select_related('mentor', 'client')
|
).select_related('mentor', 'client')
|
||||||
|
|
||||||
# Оптимизация: используем bulk_update вместо цикла с save()
|
# Оптимизация: используем bulk_update вместо цикла с save()
|
||||||
lessons_to_complete_list = list(lessons_to_complete)
|
lessons_to_complete_list = list(lessons_to_complete)
|
||||||
for lesson in lessons_to_complete_list:
|
for lesson in lessons_to_complete_list:
|
||||||
lesson.status = 'completed'
|
lesson.status = 'completed'
|
||||||
lesson.completed_at = now
|
lesson.completed_at = now
|
||||||
if lessons_to_complete_list:
|
if lessons_to_complete_list:
|
||||||
Lesson.objects.bulk_update(lessons_to_complete_list, ['status', 'completed_at'])
|
Lesson.objects.bulk_update(lessons_to_complete_list, ['status', 'completed_at'])
|
||||||
completed_count = len(lessons_to_complete_list)
|
completed_count = len(lessons_to_complete_list)
|
||||||
for lesson in lessons_to_complete_list:
|
for lesson in lessons_to_complete_list:
|
||||||
logger.info(f'Занятие {lesson.id} автоматически переведено в статус "completed" (время окончания прошло)')
|
logger.info(f'Занятие {lesson.id} автоматически переведено в статус "completed" (время окончания прошло)')
|
||||||
|
|
||||||
# Закрываем LiveKit комнату, если она есть
|
# Закрываем LiveKit комнату, если она есть
|
||||||
try:
|
try:
|
||||||
from apps.video.models import VideoRoom
|
from apps.video.models import VideoRoom
|
||||||
from apps.video.services import get_sfu_client, SFUClientError
|
from apps.video.services import get_sfu_client, SFUClientError
|
||||||
|
|
||||||
video_room = VideoRoom.objects.filter(lesson=lesson).first()
|
video_room = VideoRoom.objects.filter(lesson=lesson).first()
|
||||||
if video_room and video_room.room_id:
|
if video_room and video_room.room_id:
|
||||||
sfu_client = get_sfu_client()
|
sfu_client = get_sfu_client()
|
||||||
try:
|
try:
|
||||||
sfu_client.delete_room(str(video_room.room_id))
|
sfu_client.delete_room(str(video_room.room_id))
|
||||||
logger.info(f'LiveKit комната {video_room.room_id} закрыта для урока {lesson.id}')
|
logger.info(f'LiveKit комната {video_room.room_id} закрыта для урока {lesson.id}')
|
||||||
except SFUClientError as e:
|
except SFUClientError as e:
|
||||||
logger.warning(f'Не удалось закрыть LiveKit комнату {video_room.room_id} для урока {lesson.id}: {e}')
|
logger.warning(f'Не удалось закрыть LiveKit комнату {video_room.room_id} для урока {lesson.id}: {e}')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Ошибка закрытия LiveKit комнаты для урока {lesson.id}: {str(e)}', exc_info=True)
|
logger.error(f'Ошибка закрытия LiveKit комнаты для урока {lesson.id}: {str(e)}', exc_info=True)
|
||||||
|
|
||||||
if started_count > 0 or completed_count > 0:
|
if started_count > 0 or completed_count > 0:
|
||||||
logger.info(f'[start_lessons_automatically] Начато: {started_count}, Завершено: {completed_count}')
|
logger.info(f'[start_lessons_automatically] Начато: {started_count}, Завершено: {completed_count}')
|
||||||
|
|
||||||
return f'Начато {started_count}, Завершено {completed_count}'
|
return f'Начато {started_count}, Завершено {completed_count}'
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'[start_lessons_automatically] Ошибка: {str(e)}', exc_info=True)
|
logger.error(f'[start_lessons_automatically] Ошибка: {str(e)}', exc_info=True)
|
||||||
raise
|
raise
|
||||||
|
|
|
||||||
|
|
@ -86,12 +86,9 @@ class LessonViewSet(viewsets.ModelViewSet):
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Клиенты видят свои занятия
|
# Студенты (клиенты) видят ТОЛЬКО свои занятия — не расписание ментора
|
||||||
elif user.role == 'client':
|
elif getattr(user, 'role', None) == 'client':
|
||||||
try:
|
queryset = queryset.filter(client__user_id=user.id)
|
||||||
queryset = queryset.filter(client=user.client_profile)
|
|
||||||
except:
|
|
||||||
queryset = Lesson.objects.none()
|
|
||||||
|
|
||||||
# Родители видят занятия своих детей
|
# Родители видят занятия своих детей
|
||||||
elif user.role == 'parent':
|
elif user.role == 'parent':
|
||||||
|
|
@ -618,19 +615,30 @@ class LessonViewSet(viewsets.ModelViewSet):
|
||||||
request_has_file_ids = isinstance(lesson_file_ids_raw, list) and len(lesson_file_ids_raw or []) > 0
|
request_has_file_ids = isinstance(lesson_file_ids_raw, list) and len(lesson_file_ids_raw or []) > 0
|
||||||
has_homework_files_param = request.data.get('has_homework_files') in (True, 'true', 1)
|
has_homework_files_param = request.data.get('has_homework_files') in (True, 'true', 1)
|
||||||
request_has_files = has_homework_files_param or request_has_file_ids
|
request_has_files = has_homework_files_param or request_has_file_ids
|
||||||
|
|
||||||
|
# Проверяем флаг "заполнить позже"
|
||||||
|
homework_fill_later = request.data.get('homework_fill_later') in (True, 'true', 1, 'True')
|
||||||
|
|
||||||
has_homework = request_has_text or request_has_files
|
has_homework = request_has_text or request_has_files or homework_fill_later
|
||||||
|
|
||||||
homework_id = None
|
homework_id = None
|
||||||
if has_homework:
|
if has_homework:
|
||||||
# Есть текст ДЗ или файлы – создаём или обновляем опубликованное задание
|
|
||||||
title = lesson.title or 'Домашнее задание'
|
title = lesson.title or 'Домашнее задание'
|
||||||
description = (lesson.homework_text or '').strip() or '' # описание не обязательно
|
description = (lesson.homework_text or '').strip() or ''
|
||||||
|
|
||||||
|
# Определяем статус и fill_later флаг
|
||||||
|
if homework_fill_later:
|
||||||
|
hw_status = 'draft'
|
||||||
|
hw_fill_later = True
|
||||||
|
else:
|
||||||
|
hw_status = 'published'
|
||||||
|
hw_fill_later = False
|
||||||
|
|
||||||
if existing_hw:
|
if existing_hw:
|
||||||
existing_hw.title = title
|
existing_hw.title = title
|
||||||
existing_hw.description = description
|
existing_hw.description = description
|
||||||
existing_hw.status = 'published'
|
existing_hw.status = hw_status
|
||||||
|
existing_hw.fill_later = hw_fill_later
|
||||||
existing_hw.lesson = lesson
|
existing_hw.lesson = lesson
|
||||||
existing_hw.save()
|
existing_hw.save()
|
||||||
homework_obj = existing_hw
|
homework_obj = existing_hw
|
||||||
|
|
@ -640,7 +648,8 @@ class LessonViewSet(viewsets.ModelViewSet):
|
||||||
description=description,
|
description=description,
|
||||||
mentor=lesson.mentor,
|
mentor=lesson.mentor,
|
||||||
lesson=lesson,
|
lesson=lesson,
|
||||||
status='published',
|
status=hw_status,
|
||||||
|
fill_later=hw_fill_later,
|
||||||
)
|
)
|
||||||
homework_id = homework_obj.id
|
homework_id = homework_obj.id
|
||||||
|
|
||||||
|
|
@ -652,8 +661,10 @@ class LessonViewSet(viewsets.ModelViewSet):
|
||||||
client_user = lesson_with_client.client.user
|
client_user = lesson_with_client.client.user
|
||||||
if client_user:
|
if client_user:
|
||||||
homework_obj.assigned_to.add(client_user)
|
homework_obj.assigned_to.add(client_user)
|
||||||
from apps.notifications.services import NotificationService
|
# Уведомление отправляем ТОЛЬКО если ДЗ опубликовано (не fill_later)
|
||||||
NotificationService.send_homework_notification(homework_obj, 'homework_assigned')
|
if not hw_fill_later:
|
||||||
|
from apps.notifications.services import NotificationService
|
||||||
|
NotificationService.send_homework_notification(homework_obj, 'homework_assigned')
|
||||||
|
|
||||||
# Синхронизируем прикрепленные к уроку материалы с файлами ДЗ
|
# Синхронизируем прикрепленные к уроку материалы с файлами ДЗ
|
||||||
lesson_file_ids = None
|
lesson_file_ids = None
|
||||||
|
|
@ -885,6 +896,8 @@ class LessonViewSet(viewsets.ModelViewSet):
|
||||||
Получить занятия для календаря.
|
Получить занятия для календаря.
|
||||||
|
|
||||||
GET /api/schedule/lessons/calendar/?start_date=2024-01-01&end_date=2024-01-31
|
GET /api/schedule/lessons/calendar/?start_date=2024-01-01&end_date=2024-01-31
|
||||||
|
|
||||||
|
Студент видит только свои занятия. Ментор — свои. Родитель — занятия детей.
|
||||||
"""
|
"""
|
||||||
serializer = LessonCalendarSerializer(data=request.query_params)
|
serializer = LessonCalendarSerializer(data=request.query_params)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
@ -894,6 +907,9 @@ class LessonViewSet(viewsets.ModelViewSet):
|
||||||
start_time__date__gte=data['start_date'],
|
start_time__date__gte=data['start_date'],
|
||||||
start_time__date__lte=data['end_date']
|
start_time__date__lte=data['end_date']
|
||||||
)
|
)
|
||||||
|
# Доп. защита: студент никогда не должен видеть чужие занятия
|
||||||
|
if getattr(request.user, 'role', None) == 'client':
|
||||||
|
queryset = queryset.filter(client__user_id=request.user.id)
|
||||||
if data.get('status'):
|
if data.get('status'):
|
||||||
queryset = queryset.filter(status=data['status'])
|
queryset = queryset.filter(status=data['status'])
|
||||||
lessons = LessonCalendarItemSerializer(
|
lessons = LessonCalendarItemSerializer(
|
||||||
|
|
@ -1434,7 +1450,8 @@ class LessonHomeworkSubmissionViewSet(viewsets.ModelViewSet):
|
||||||
NotificationService.send_homework_notification(
|
NotificationService.send_homework_notification(
|
||||||
homework,
|
homework,
|
||||||
'homework_reviewed',
|
'homework_reviewed',
|
||||||
student=submission.student
|
student=submission.student,
|
||||||
|
submission=submission
|
||||||
)
|
)
|
||||||
except Homework.DoesNotExist:
|
except Homework.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
"""
|
"""
|
||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
|
import secrets
|
||||||
from django.contrib.auth.models import AbstractUser, BaseUserManager
|
from django.contrib.auth.models import AbstractUser, BaseUserManager
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
@ -314,31 +315,37 @@ class User(AbstractUser):
|
||||||
alphabet = string.ascii_uppercase + string.digits
|
alphabet = string.ascii_uppercase + string.digits
|
||||||
for _ in range(100):
|
for _ in range(100):
|
||||||
code = ''.join(random.choices(alphabet, k=8))
|
code = ''.join(random.choices(alphabet, k=8))
|
||||||
|
# Проверяем уникальность кода
|
||||||
if not User.objects.filter(universal_code=code).exclude(pk=self.pk).exists():
|
if not User.objects.filter(universal_code=code).exclude(pk=self.pk).exists():
|
||||||
return code
|
return code
|
||||||
raise ValueError('Не удалось сгенерировать уникальный universal_code')
|
# Если не удалось сгенерировать за 100 попыток, используем более длинный fallback
|
||||||
|
return secrets.token_hex(4).upper()
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
|
# 1. Нормализация телефона
|
||||||
if self.phone:
|
if self.phone:
|
||||||
self.phone = normalize_phone(self.phone)
|
self.phone = normalize_phone(self.phone)
|
||||||
|
|
||||||
# Автоматическая генерация username из email, если не задан
|
# 2. Генерация username из email
|
||||||
if not self.username and self.email:
|
if not self.username and self.email:
|
||||||
self.username = self.email.split('@')[0]
|
self.username = self.email.split('@')[0]
|
||||||
# Добавляем цифры, если username уже существует
|
|
||||||
counter = 1
|
counter = 1
|
||||||
original_username = self.username
|
original_username = self.username
|
||||||
while User.objects.filter(username=self.username).exclude(pk=self.pk).exists():
|
while User.objects.filter(username=self.username).exclude(pk=self.pk).exists():
|
||||||
self.username = f"{original_username}{counter}"
|
self.username = f"{original_username}{counter}"
|
||||||
counter += 1
|
counter += 1
|
||||||
|
if kwargs.get('update_fields') is not None:
|
||||||
|
fields = set(kwargs['update_fields'])
|
||||||
|
fields.add('username')
|
||||||
|
kwargs['update_fields'] = list(fields)
|
||||||
|
|
||||||
# Гарантируем 8-символьный код (universal_code)
|
# 3. Гарантируем 8-символьный код (universal_code)
|
||||||
if not self.universal_code:
|
if not self.universal_code or len(str(self.universal_code).strip()) != 8:
|
||||||
try:
|
self.universal_code = self._generate_universal_code()
|
||||||
self.universal_code = self._generate_universal_code()
|
if kwargs.get('update_fields') is not None:
|
||||||
except Exception:
|
fields = set(kwargs['update_fields'])
|
||||||
# Если не удалось сгенерировать, не прерываем сохранение
|
fields.add('universal_code')
|
||||||
pass
|
kwargs['update_fields'] = list(fields)
|
||||||
|
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,13 +68,10 @@ class ProfileViewSet(viewsets.ViewSet):
|
||||||
GET /api/users/profile/me/
|
GET /api/users/profile/me/
|
||||||
"""
|
"""
|
||||||
user = request.user
|
user = request.user
|
||||||
# Убедиться, что у пользователя есть 8-символьный код (для старых пользователей)
|
# User.save() автоматически создаст universal_code, если он отсутствует
|
||||||
if not user.universal_code or len(user.universal_code) != 8:
|
if not user.universal_code or len(str(user.universal_code).strip()) != 8:
|
||||||
try:
|
user.save(update_fields=['universal_code'])
|
||||||
user.universal_code = user._generate_universal_code()
|
|
||||||
user.save(update_fields=['universal_code'])
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
serializer = UserSerializer(user, context={'request': request})
|
serializer = UserSerializer(user, context={'request': request})
|
||||||
|
|
||||||
# Добавляем дополнительную информацию
|
# Добавляем дополнительную информацию
|
||||||
|
|
@ -381,13 +378,6 @@ class ProfileViewSet(viewsets.ViewSet):
|
||||||
|
|
||||||
user = request.user
|
user = request.user
|
||||||
|
|
||||||
# 8-символьный код: если нет — генерируем при обновлении профиля
|
|
||||||
if not user.universal_code or len(user.universal_code) != 8:
|
|
||||||
try:
|
|
||||||
user.universal_code = user._generate_universal_code()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Обработка удаления аватара
|
# Обработка удаления аватара
|
||||||
if 'avatar' in request.data:
|
if 'avatar' in request.data:
|
||||||
avatar_value = request.data.get('avatar')
|
avatar_value = request.data.get('avatar')
|
||||||
|
|
@ -1505,27 +1495,6 @@ class InvitationViewSet(viewsets.ViewSet):
|
||||||
city=city
|
city=city
|
||||||
)
|
)
|
||||||
|
|
||||||
# Гарантируем 8-символьный код для приглашений (ментор/студент)
|
|
||||||
if not student_user.universal_code or len(str(student_user.universal_code or '').strip()) != 8:
|
|
||||||
try:
|
|
||||||
# Теперь метод _generate_universal_code определен в базовой модели User
|
|
||||||
student_user.universal_code = student_user._generate_universal_code()
|
|
||||||
student_user.save(update_fields=['universal_code'])
|
|
||||||
except Exception:
|
|
||||||
# Fallback на случай ошибок генерации
|
|
||||||
import string
|
|
||||||
import random
|
|
||||||
try:
|
|
||||||
alphabet = string.ascii_uppercase + string.digits
|
|
||||||
for _ in range(500):
|
|
||||||
code = ''.join(random.choices(alphabet, k=8))
|
|
||||||
if not User.objects.filter(universal_code=code).exclude(pk=student_user.pk).exists():
|
|
||||||
student_user.universal_code = code
|
|
||||||
student_user.save(update_fields=['universal_code'])
|
|
||||||
break
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Генерируем персональный токен для входа
|
# Генерируем персональный токен для входа
|
||||||
student_user.login_token = secrets.token_urlsafe(32)
|
student_user.login_token = secrets.token_urlsafe(32)
|
||||||
student_user.save(update_fields=['login_token'])
|
student_user.save(update_fields=['login_token'])
|
||||||
|
|
|
||||||
|
|
@ -156,15 +156,6 @@ class RegisterSerializer(serializers.ModelSerializer):
|
||||||
**validated_data
|
**validated_data
|
||||||
)
|
)
|
||||||
|
|
||||||
# Гарантированно задаём 8-символьный код при создании
|
|
||||||
if not user.universal_code or len(str(user.universal_code or '').strip()) != 8:
|
|
||||||
try:
|
|
||||||
user.universal_code = user._generate_universal_code()
|
|
||||||
user.save(update_fields=['universal_code'])
|
|
||||||
except Exception:
|
|
||||||
# Если не удалось, код будет сгенерирован в RegisterView или при запросе профиля
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Создаем профиль в зависимости от роли
|
# Создаем профиль в зависимости от роли
|
||||||
if user.role == 'client':
|
if user.role == 'client':
|
||||||
Client.objects.create(user=user)
|
Client.objects.create(user=user)
|
||||||
|
|
|
||||||
|
|
@ -126,25 +126,6 @@ class TelegramAuthView(generics.GenericAPIView):
|
||||||
user.set_unusable_password()
|
user.set_unusable_password()
|
||||||
user.save()
|
user.save()
|
||||||
|
|
||||||
# Гарантируем 8-символьный код для приглашений
|
|
||||||
if not user.universal_code or len(str(user.universal_code or '').strip()) != 8:
|
|
||||||
try:
|
|
||||||
user.universal_code = user._generate_universal_code()
|
|
||||||
user.save(update_fields=['universal_code'])
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f'Ошибка генерации universal_code для Telegram пользователя {user.id}: {e}')
|
|
||||||
# Пробуем ещё раз
|
|
||||||
try:
|
|
||||||
alphabet = string.ascii_uppercase + string.digits
|
|
||||||
for _ in range(500):
|
|
||||||
code = ''.join(random.choices(alphabet, k=8))
|
|
||||||
if not User.objects.filter(universal_code=code).exclude(pk=user.pk).exists():
|
|
||||||
user.universal_code = code
|
|
||||||
user.save(update_fields=['universal_code'])
|
|
||||||
break
|
|
||||||
except Exception:
|
|
||||||
pass # Код будет сгенерирован при следующем запросе профиля
|
|
||||||
|
|
||||||
is_new_user = True
|
is_new_user = True
|
||||||
message = 'Регистрация через Telegram выполнена успешно'
|
message = 'Регистрация через Telegram выполнена успешно'
|
||||||
|
|
||||||
|
|
@ -182,31 +163,6 @@ class RegisterView(generics.CreateAPIView):
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
user = serializer.save()
|
user = serializer.save()
|
||||||
|
|
||||||
# Всегда задаём 8-символьный код при регистрации (для приглашений ментор/студент)
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
need_code = not user.universal_code or len(str(user.universal_code or '').strip()) != 8
|
|
||||||
if need_code:
|
|
||||||
try:
|
|
||||||
user.universal_code = user._generate_universal_code()
|
|
||||||
user.save(update_fields=['universal_code'])
|
|
||||||
except Exception as e:
|
|
||||||
# Если не удалось сгенерировать код, пробуем ещё раз с большим количеством попыток
|
|
||||||
logger.warning(f'Ошибка генерации universal_code для пользователя {user.id}: {e}, пробуем ещё раз')
|
|
||||||
try:
|
|
||||||
alphabet = string.ascii_uppercase + string.digits
|
|
||||||
for _ in range(500):
|
|
||||||
code = ''.join(random.choices(alphabet, k=8))
|
|
||||||
if not User.objects.filter(universal_code=code).exclude(pk=user.pk).exists():
|
|
||||||
user.universal_code = code
|
|
||||||
user.save(update_fields=['universal_code'])
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
# Если всё равно не получилось, не прерываем регистрацию
|
|
||||||
logger.error(f'Не удалось сгенерировать unique universal_code для пользователя {user.id} после 500 попыток')
|
|
||||||
except Exception as e2:
|
|
||||||
logger.error(f'Критическая ошибка генерации universal_code для пользователя {user.id}: {e2}')
|
|
||||||
# Не прерываем регистрацию, код будет сгенерирован при следующем запросе профиля
|
|
||||||
|
|
||||||
# Токен для подтверждения email
|
# Токен для подтверждения email
|
||||||
verification_token = secrets.token_urlsafe(32)
|
verification_token = secrets.token_urlsafe(32)
|
||||||
user.email_verification_token = verification_token
|
user.email_verification_token = verification_token
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Проверка доступности портов SMTP (465 и 2525) для smtp.mail.ru."""
|
||||||
|
import socket
|
||||||
|
|
||||||
|
host = "smtp.mail.ru"
|
||||||
|
ports = [465, 2525]
|
||||||
|
timeout = 10
|
||||||
|
|
||||||
|
for port in ports:
|
||||||
|
try:
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
s.settimeout(timeout)
|
||||||
|
err = s.connect_ex((host, port))
|
||||||
|
s.close()
|
||||||
|
status = "доступен" if err == 0 else "недоступен"
|
||||||
|
print(f" {host}:{port} — {status}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" {host}:{port} — ошибка: {e}")
|
||||||
|
|
@ -1,204 +1,208 @@
|
||||||
/**
|
/**
|
||||||
* API модуль для аутентификации
|
* API модуль для аутентификации
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import apiClient from '@/lib/api-client';
|
import apiClient from '@/lib/api-client';
|
||||||
|
|
||||||
export interface LoginCredentials {
|
export interface LoginCredentials {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegisterData {
|
export interface RegisterData {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
password_confirm: string;
|
password_confirm: string;
|
||||||
first_name?: string;
|
first_name?: string;
|
||||||
last_name?: string;
|
last_name?: string;
|
||||||
role?: 'mentor' | 'client' | 'parent';
|
role?: 'mentor' | 'client' | 'parent';
|
||||||
city?: string;
|
city?: string;
|
||||||
timezone?: string;
|
timezone?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthResponse {
|
export interface AuthResponse {
|
||||||
access: string;
|
access: string;
|
||||||
refresh?: string;
|
refresh?: string;
|
||||||
user?: any;
|
user?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: number;
|
id: number;
|
||||||
email: string;
|
email: string;
|
||||||
first_name?: string;
|
first_name?: string;
|
||||||
last_name?: string;
|
last_name?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
role: 'mentor' | 'client' | 'parent';
|
role: 'mentor' | 'client' | 'parent';
|
||||||
is_verified?: boolean;
|
is_verified?: boolean;
|
||||||
avatar_url?: string | null;
|
avatar_url?: string | null;
|
||||||
avatar?: string | null;
|
avatar?: string | null;
|
||||||
telegram_id?: number | null;
|
telegram_id?: number | null;
|
||||||
universal_code?: string;
|
universal_code?: string;
|
||||||
invitation_link?: string;
|
invitation_link?: string;
|
||||||
invitation_link_token?: string;
|
invitation_link_token?: string;
|
||||||
}
|
timezone?: string;
|
||||||
|
language?: string;
|
||||||
/**
|
city?: string;
|
||||||
* Вход в систему
|
country?: string;
|
||||||
*/
|
}
|
||||||
export async function login(credentials: LoginCredentials): Promise<AuthResponse> {
|
|
||||||
console.log('[auth.login] Sending request to /auth/login/', credentials.email);
|
/**
|
||||||
const response = await apiClient.post<any>('/auth/login/', credentials);
|
* Вход в систему
|
||||||
console.log('[auth.login] Raw response:', response);
|
*/
|
||||||
console.log('[auth.login] response.data:', response.data);
|
export async function login(credentials: LoginCredentials): Promise<AuthResponse> {
|
||||||
|
console.log('[auth.login] Sending request to /auth/login/', credentials.email);
|
||||||
// API возвращает { success, message, data: { user, tokens: { access, refresh } } }
|
const response = await apiClient.post<any>('/auth/login/', credentials);
|
||||||
const data = response.data?.data;
|
console.log('[auth.login] Raw response:', response);
|
||||||
console.log('[auth.login] Parsed data:', data);
|
console.log('[auth.login] response.data:', response.data);
|
||||||
console.log('[auth.login] Tokens:', data?.tokens);
|
|
||||||
|
// API возвращает { success, message, data: { user, tokens: { access, refresh } } }
|
||||||
if (data?.tokens) {
|
const data = response.data?.data;
|
||||||
const result = {
|
console.log('[auth.login] Parsed data:', data);
|
||||||
access: data.tokens.access,
|
console.log('[auth.login] Tokens:', data?.tokens);
|
||||||
refresh: data.tokens.refresh,
|
|
||||||
user: data.user
|
if (data?.tokens) {
|
||||||
};
|
const result = {
|
||||||
console.log('[auth.login] Returning:', { ...result, access: result.access?.substring(0, 20) + '...' });
|
access: data.tokens.access,
|
||||||
return result;
|
refresh: data.tokens.refresh,
|
||||||
}
|
user: data.user
|
||||||
|
};
|
||||||
// Fallback для старого формата
|
console.log('[auth.login] Returning:', { ...result, access: result.access?.substring(0, 20) + '...' });
|
||||||
console.log('[auth.login] Using fallback structure');
|
return result;
|
||||||
return response.data?.data || response.data;
|
}
|
||||||
}
|
|
||||||
|
// Fallback для старого формата
|
||||||
/**
|
console.log('[auth.login] Using fallback structure');
|
||||||
* Регистрация
|
return response.data?.data || response.data;
|
||||||
*/
|
}
|
||||||
export async function register(data: RegisterData): Promise<AuthResponse> {
|
|
||||||
const response = await apiClient.post<any>('/auth/register/', data);
|
/**
|
||||||
// API возвращает { success, message, data: { user, tokens: { access, refresh } } }
|
* Регистрация
|
||||||
const responseData = response.data?.data;
|
*/
|
||||||
if (responseData?.tokens) {
|
export async function register(data: RegisterData): Promise<AuthResponse> {
|
||||||
return {
|
const response = await apiClient.post<any>('/auth/register/', data);
|
||||||
access: responseData.tokens.access,
|
// API возвращает { success, message, data: { user, tokens: { access, refresh } } }
|
||||||
refresh: responseData.tokens.refresh,
|
const responseData = response.data?.data;
|
||||||
user: responseData.user
|
if (responseData?.tokens) {
|
||||||
};
|
return {
|
||||||
}
|
access: responseData.tokens.access,
|
||||||
// Fallback для старого формата
|
refresh: responseData.tokens.refresh,
|
||||||
return response.data?.data || response.data;
|
user: responseData.user
|
||||||
}
|
};
|
||||||
|
}
|
||||||
/**
|
// Fallback для старого формата
|
||||||
* Выход из системы
|
return response.data?.data || response.data;
|
||||||
*/
|
}
|
||||||
export async function logout(): Promise<void> {
|
|
||||||
await apiClient.post('/auth/logout/');
|
/**
|
||||||
}
|
* Выход из системы
|
||||||
|
*/
|
||||||
/**
|
export async function logout(): Promise<void> {
|
||||||
* Получить текущего пользователя
|
await apiClient.post('/auth/logout/');
|
||||||
* Endpoint: GET /api/profile/me/
|
}
|
||||||
*/
|
|
||||||
export async function getCurrentUser(): Promise<User> {
|
/**
|
||||||
try {
|
* Получить текущего пользователя
|
||||||
// Используем ProfileViewSet.me() - возвращает request.user
|
* Endpoint: GET /api/profile/me/
|
||||||
console.log('[getCurrentUser] Requesting /profile/me/');
|
*/
|
||||||
const response = await apiClient.get<User>('/profile/me/');
|
export async function getCurrentUser(): Promise<User> {
|
||||||
console.log('[getCurrentUser] Success:', response.data);
|
try {
|
||||||
return response.data;
|
// Используем ProfileViewSet.me() - возвращает request.user
|
||||||
} catch (error: any) {
|
console.log('[getCurrentUser] Requesting /profile/me/');
|
||||||
console.error('[getCurrentUser] Error with /profile/me/:', error);
|
const response = await apiClient.get<User>('/profile/me/');
|
||||||
console.error('[getCurrentUser] Error status:', error.response?.status);
|
console.log('[getCurrentUser] Success:', response.data);
|
||||||
console.error('[getCurrentUser] Error data:', error.response?.data);
|
return response.data;
|
||||||
console.error('[getCurrentUser] Error config:', error.config?.url);
|
} catch (error: any) {
|
||||||
|
console.error('[getCurrentUser] Error with /profile/me/:', error);
|
||||||
// Fallback: используем UserViewSet с ID из токена
|
console.error('[getCurrentUser] Error status:', error.response?.status);
|
||||||
const token = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null;
|
console.error('[getCurrentUser] Error data:', error.response?.data);
|
||||||
if (!token) {
|
console.error('[getCurrentUser] Error config:', error.config?.url);
|
||||||
console.error('[getCurrentUser] No token found for fallback');
|
|
||||||
throw error;
|
// Fallback: используем UserViewSet с ID из токена
|
||||||
}
|
const token = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null;
|
||||||
|
if (!token) {
|
||||||
try {
|
console.error('[getCurrentUser] No token found for fallback');
|
||||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
throw error;
|
||||||
const userId = payload.user_id;
|
}
|
||||||
|
|
||||||
if (!userId) {
|
try {
|
||||||
console.error('[getCurrentUser] No user_id in token payload:', payload);
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||||
throw error;
|
const userId = payload.user_id;
|
||||||
}
|
|
||||||
|
if (!userId) {
|
||||||
console.log('[getCurrentUser] Trying fallback /users/' + userId + '/');
|
console.error('[getCurrentUser] No user_id in token payload:', payload);
|
||||||
const userResponse = await apiClient.get<User>(`/users/${userId}/`);
|
throw error;
|
||||||
console.log('[getCurrentUser] Fallback success:', userResponse.data);
|
}
|
||||||
return userResponse.data;
|
|
||||||
} catch (e) {
|
console.log('[getCurrentUser] Trying fallback /users/' + userId + '/');
|
||||||
console.error('[getCurrentUser] Fallback error:', e);
|
const userResponse = await apiClient.get<User>(`/users/${userId}/`);
|
||||||
throw error; // Бросаем оригинальную ошибку, а не fallback ошибку
|
console.log('[getCurrentUser] Fallback success:', userResponse.data);
|
||||||
}
|
return userResponse.data;
|
||||||
}
|
} catch (e) {
|
||||||
}
|
console.error('[getCurrentUser] Fallback error:', e);
|
||||||
|
throw error; // Бросаем оригинальную ошибку, а не fallback ошибку
|
||||||
/**
|
}
|
||||||
* Обновить access-токен по refresh-токену (запрос без Authorization, чтобы не слать истёкший токен).
|
}
|
||||||
*/
|
}
|
||||||
export async function refreshToken(refresh: string): Promise<{ access: string }> {
|
|
||||||
const response = await apiClient.getInstance().post<{ access: string }>(
|
/**
|
||||||
'/auth/token/refresh/',
|
* Обновить access-токен по refresh-токену (запрос без Authorization, чтобы не слать истёкший токен).
|
||||||
{ refresh },
|
*/
|
||||||
{ __skipAuth: true } as any
|
export async function refreshToken(refresh: string): Promise<{ access: string }> {
|
||||||
);
|
const response = await apiClient.getInstance().post<{ access: string }>(
|
||||||
return response.data;
|
'/auth/token/refresh/',
|
||||||
}
|
{ refresh },
|
||||||
|
{ __skipAuth: true } as any
|
||||||
/**
|
);
|
||||||
* Смена пароля
|
return response.data;
|
||||||
*/
|
}
|
||||||
export async function changePassword(
|
|
||||||
oldPassword: string,
|
/**
|
||||||
newPassword: string
|
* Смена пароля
|
||||||
): Promise<void> {
|
*/
|
||||||
await apiClient.post('/auth/change-password/', {
|
export async function changePassword(
|
||||||
old_password: oldPassword,
|
oldPassword: string,
|
||||||
new_password: newPassword,
|
newPassword: string
|
||||||
});
|
): Promise<void> {
|
||||||
}
|
await apiClient.post('/auth/change-password/', {
|
||||||
|
old_password: oldPassword,
|
||||||
/**
|
new_password: newPassword,
|
||||||
* Запрос на сброс пароля
|
});
|
||||||
*/
|
}
|
||||||
export async function requestPasswordReset(data: { email: string }): Promise<void> {
|
|
||||||
await apiClient.post('/auth/password-reset/', data);
|
/**
|
||||||
}
|
* Запрос на сброс пароля
|
||||||
|
*/
|
||||||
/**
|
export async function requestPasswordReset(data: { email: string }): Promise<void> {
|
||||||
* Подтверждение email по токену из письма
|
await apiClient.post('/auth/password-reset/', data);
|
||||||
*/
|
}
|
||||||
export async function verifyEmail(token: string): Promise<{ success: boolean; message?: string }> {
|
|
||||||
const response = await apiClient.post<{ success: boolean; message?: string }>(
|
/**
|
||||||
'/auth/verify-email/',
|
* Подтверждение email по токену из письма
|
||||||
{ token },
|
*/
|
||||||
{ __skipAuth: true } as any
|
export async function verifyEmail(token: string): Promise<{ success: boolean; message?: string }> {
|
||||||
);
|
const response = await apiClient.post<{ success: boolean; message?: string }>(
|
||||||
return response.data;
|
'/auth/verify-email/',
|
||||||
}
|
{ token },
|
||||||
|
{ __skipAuth: true } as any
|
||||||
/**
|
);
|
||||||
* Подтверждение сброса пароля (по ссылке из письма)
|
return response.data;
|
||||||
*/
|
}
|
||||||
export async function confirmPasswordReset(
|
|
||||||
token: string,
|
/**
|
||||||
newPassword: string,
|
* Подтверждение сброса пароля (по ссылке из письма)
|
||||||
newPasswordConfirm: string
|
*/
|
||||||
): Promise<void> {
|
export async function confirmPasswordReset(
|
||||||
await apiClient.post(
|
token: string,
|
||||||
'/auth/password-reset-confirm/',
|
newPassword: string,
|
||||||
{
|
newPasswordConfirm: string
|
||||||
token,
|
): Promise<void> {
|
||||||
new_password: newPassword,
|
await apiClient.post(
|
||||||
new_password_confirm: newPasswordConfirm,
|
'/auth/password-reset-confirm/',
|
||||||
},
|
{
|
||||||
{ __skipAuth: true } as any
|
token,
|
||||||
);
|
new_password: newPassword,
|
||||||
}
|
new_password_confirm: newPasswordConfirm,
|
||||||
|
},
|
||||||
|
{ __skipAuth: true } as any
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -258,6 +258,33 @@ export function validateHomeworkFiles(files: File[]): { valid: boolean; error?:
|
||||||
return { valid: true };
|
return { valid: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновить домашнее задание (для черновиков fill_later).
|
||||||
|
* PATCH /api/homework/homeworks/{id}/
|
||||||
|
*/
|
||||||
|
export async function updateHomework(
|
||||||
|
homeworkId: string | number,
|
||||||
|
data: {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
deadline?: string | null;
|
||||||
|
status?: 'draft' | 'published';
|
||||||
|
fill_later?: boolean;
|
||||||
|
}
|
||||||
|
): Promise<Homework> {
|
||||||
|
const res = await apiClient.patch<Homework>(`/homework/homeworks/${homeworkId}/`, data);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Опубликовать домашнее задание (из черновика в published).
|
||||||
|
* POST /api/homework/homeworks/{id}/publish/
|
||||||
|
*/
|
||||||
|
export async function publishHomework(homeworkId: string | number): Promise<Homework> {
|
||||||
|
const res = await apiClient.post<Homework>(`/homework/homeworks/${homeworkId}/publish/`);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function submitHomework(
|
export async function submitHomework(
|
||||||
homeworkId: string | number,
|
homeworkId: string | number,
|
||||||
data: { content?: string; text?: string; files?: File[] },
|
data: { content?: string; text?: string; files?: File[] },
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { CheckLesson } from '@/components/checklesson/checklesson';
|
||||||
import { getLessonsCalendar, getLesson, createLesson, updateLesson, deleteLesson } from '@/api/schedule';
|
import { getLessonsCalendar, getLesson, createLesson, updateLesson, deleteLesson } from '@/api/schedule';
|
||||||
import { getStudents } from '@/api/students';
|
import { getStudents } from '@/api/students';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import { createDateTimeInUserTimezone, parseISOToUserTimezone } from '@/utils/timezone';
|
||||||
import { useSelectedChild } from '@/contexts/SelectedChildContext';
|
import { useSelectedChild } from '@/contexts/SelectedChildContext';
|
||||||
import { getSubjects, getMentorSubjects } from '@/api/subjects';
|
import { getSubjects, getMentorSubjects } from '@/api/subjects';
|
||||||
import { loadComponent } from '@/lib/material-components';
|
import { loadComponent } from '@/lib/material-components';
|
||||||
|
|
@ -132,6 +133,9 @@ export default function SchedulePage() {
|
||||||
client_name: lesson.client_name ?? (lesson.client?.user
|
client_name: lesson.client_name ?? (lesson.client?.user
|
||||||
? `${lesson.client.user.first_name || ''} ${lesson.client.user.last_name || ''}`.trim()
|
? `${lesson.client.user.first_name || ''} ${lesson.client.user.last_name || ''}`.trim()
|
||||||
: undefined),
|
: undefined),
|
||||||
|
mentor_name: lesson.mentor_name ?? (lesson.mentor?.first_name || lesson.mentor?.last_name
|
||||||
|
? `${lesson.mentor?.first_name || ''} ${lesson.mentor?.last_name || ''}`.trim()
|
||||||
|
: undefined),
|
||||||
subject: lesson.subject ?? lesson.subject_name ?? '',
|
subject: lesson.subject ?? lesson.subject_name ?? '',
|
||||||
}));
|
}));
|
||||||
setLessons(mappedLessons);
|
setLessons(mappedLessons);
|
||||||
|
|
@ -156,9 +160,15 @@ export default function SchedulePage() {
|
||||||
|
|
||||||
const lessonsForSelectedDate: LessonPreview[] = lessons
|
const lessonsForSelectedDate: LessonPreview[] = lessons
|
||||||
.filter((lesson) => {
|
.filter((lesson) => {
|
||||||
const lessonDate = startOfDay(new Date(lesson.start_time));
|
// Парсим дату в timezone пользователя для правильной фильтрации
|
||||||
|
const parsed = parseISOToUserTimezone(lesson.start_time, user?.timezone);
|
||||||
|
const lessonDate = startOfDay(parsed.dateObj);
|
||||||
return lessonDate.getTime() === selectedDate.getTime();
|
return lessonDate.getTime() === selectedDate.getTime();
|
||||||
})
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
// Сортируем по времени начала (раньше → первые)
|
||||||
|
return new Date(a.start_time).getTime() - new Date(b.start_time).getTime();
|
||||||
|
})
|
||||||
.map((lesson) => ({
|
.map((lesson) => ({
|
||||||
id: String(lesson.id),
|
id: String(lesson.id),
|
||||||
title: lesson.title || 'Занятие',
|
title: lesson.title || 'Занятие',
|
||||||
|
|
@ -229,15 +239,18 @@ export default function SchedulePage() {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const details = await getLesson(String(lesson.id));
|
const details = await getLesson(String(lesson.id));
|
||||||
const start = new Date(details.start_time);
|
|
||||||
const end = new Date(details.end_time);
|
// Парсим время в timezone пользователя
|
||||||
const safeStart = startOfDay(start);
|
const startParsed = parseISOToUserTimezone(details.start_time, user?.timezone);
|
||||||
|
const safeStart = startOfDay(startParsed.dateObj);
|
||||||
|
|
||||||
// синхронизируем правую панель с датой урока
|
// синхронизируем правую панель с датой урока
|
||||||
setSelectedDate(safeStart);
|
setSelectedDate(safeStart);
|
||||||
setDisplayDate(safeStart);
|
setDisplayDate(safeStart);
|
||||||
|
|
||||||
const duration = (() => {
|
const duration = (() => {
|
||||||
|
const start = new Date(details.start_time);
|
||||||
|
const end = new Date(details.end_time);
|
||||||
const mins = differenceInMinutes(end, start);
|
const mins = differenceInMinutes(end, start);
|
||||||
return Number.isFinite(mins) && mins > 0 ? mins : 60;
|
return Number.isFinite(mins) && mins > 0 ? mins : 60;
|
||||||
})();
|
})();
|
||||||
|
|
@ -246,8 +259,8 @@ export default function SchedulePage() {
|
||||||
client: details.client?.id ? String(details.client.id) : '',
|
client: details.client?.id ? String(details.client.id) : '',
|
||||||
title: details.title ?? '',
|
title: details.title ?? '',
|
||||||
description: details.description ?? '',
|
description: details.description ?? '',
|
||||||
start_date: format(start, 'yyyy-MM-dd'),
|
start_date: startParsed.date,
|
||||||
start_time: format(start, 'HH:mm'),
|
start_time: startParsed.time,
|
||||||
duration,
|
duration,
|
||||||
price: typeof details.price === 'number' ? details.price : undefined,
|
price: typeof details.price === 'number' ? details.price : undefined,
|
||||||
is_recurring: !!(details as any).is_recurring,
|
is_recurring: !!(details as any).is_recurring,
|
||||||
|
|
@ -337,7 +350,12 @@ export default function SchedulePage() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const startUtc = new Date(`${formData.start_date}T${formData.start_time}`).toISOString();
|
// Конвертируем время из timezone пользователя в UTC
|
||||||
|
const startUtc = createDateTimeInUserTimezone(
|
||||||
|
formData.start_date,
|
||||||
|
formData.start_time,
|
||||||
|
user?.timezone
|
||||||
|
);
|
||||||
const title = generateTitle();
|
const title = generateTitle();
|
||||||
|
|
||||||
if (isEditingMode && editingLessonId) {
|
if (isEditingMode && editingLessonId) {
|
||||||
|
|
@ -421,10 +439,12 @@ export default function SchedulePage() {
|
||||||
<Calendar
|
<Calendar
|
||||||
lessons={lessons}
|
lessons={lessons}
|
||||||
lessonsLoading={lessonsLoading}
|
lessonsLoading={lessonsLoading}
|
||||||
selectedDate={selectedDate}
|
selectedDate={selectedDate}
|
||||||
onSelectSlot={handleSelectSlot}
|
onSelectSlot={handleSelectSlot}
|
||||||
onSelectEvent={handleSelectEvent}
|
onSelectEvent={handleSelectEvent}
|
||||||
onMonthChange={handleMonthChange}
|
onMonthChange={handleMonthChange}
|
||||||
|
isMentor={isMentor}
|
||||||
|
userTimezone={user?.timezone}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="ios26-schedule-right-wrap">
|
<div className="ios26-schedule-right-wrap">
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,14 @@ export default function RootLayout({
|
||||||
<head>
|
<head>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||||
|
{/* Preload локального шрифта иконок */}
|
||||||
|
<link
|
||||||
|
rel="preload"
|
||||||
|
href="/fonts/material-symbols-outlined.woff2"
|
||||||
|
as="font"
|
||||||
|
type="font/woff2"
|
||||||
|
crossOrigin="anonymous"
|
||||||
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<Providers>
|
<Providers>
|
||||||
|
|
|
||||||
|
|
@ -1,99 +1,120 @@
|
||||||
/**
|
/**
|
||||||
* Блок «Календарь занятий» — обёртка над LessonsCalendar.
|
* Блок «Календарь занятий» — обёртка над LessonsCalendar.
|
||||||
* Используется в Dashboard и других страницах.
|
* Используется в Dashboard и других страницах.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { format, startOfDay } from 'date-fns';
|
import { format, startOfDay } from 'date-fns';
|
||||||
import { LessonsCalendar } from '@/components/dashboard/LessonsCalendar';
|
import { LessonsCalendar } from '@/components/dashboard/LessonsCalendar';
|
||||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||||||
|
|
||||||
export interface CalendarLesson {
|
export interface CalendarLesson {
|
||||||
id: number | string;
|
id: number | string;
|
||||||
title?: string;
|
title?: string;
|
||||||
start_time: string;
|
start_time: string;
|
||||||
end_time: string;
|
end_time: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
client?: number;
|
client?: number;
|
||||||
client_name?: string;
|
client_name?: string;
|
||||||
subject?: string;
|
mentor_name?: string;
|
||||||
}
|
subject?: string;
|
||||||
|
}
|
||||||
export interface CalendarProps {
|
|
||||||
/** Занятия для отображения в календаре */
|
export interface CalendarProps {
|
||||||
lessons: CalendarLesson[];
|
/** Занятия для отображения в календаре */
|
||||||
/** Идёт загрузка занятий */
|
lessons: CalendarLesson[];
|
||||||
lessonsLoading?: boolean;
|
/** Идёт загрузка занятий */
|
||||||
/** Выбранная дата (подсветка в календаре) */
|
lessonsLoading?: boolean;
|
||||||
selectedDate: Date;
|
/** Выбранная дата (подсветка в календаре) */
|
||||||
/** Клик по ячейке дня или по слоту */
|
selectedDate: Date;
|
||||||
onSelectSlot?: (date: Date) => void;
|
/** Клик по ячейке дня или по слоту */
|
||||||
/** Клик по событию (занятию) */
|
onSelectSlot?: (date: Date) => void;
|
||||||
onSelectEvent?: (lesson: { id: string }) => void;
|
/** Клик по событию (занятию) */
|
||||||
/** Смена видимого месяца (start/end месяца) */
|
onSelectEvent?: (lesson: { id: string }) => void;
|
||||||
onMonthChange?: (start: Date, end: Date) => void;
|
/** Смена видимого месяца (start/end месяца) */
|
||||||
}
|
onMonthChange?: (start: Date, end: Date) => void;
|
||||||
|
/** Ментор — показывает ученика; студент — показывает предмет и ментора */
|
||||||
export const Calendar: React.FC<CalendarProps> = ({
|
isMentor?: boolean;
|
||||||
lessons,
|
/** Часовой пояс пользователя (например, 'UTC+8') */
|
||||||
lessonsLoading = false,
|
userTimezone?: string;
|
||||||
selectedDate,
|
}
|
||||||
onSelectSlot,
|
|
||||||
onSelectEvent,
|
export const Calendar: React.FC<CalendarProps> = ({
|
||||||
onMonthChange,
|
lessons,
|
||||||
}) => {
|
lessonsLoading = false,
|
||||||
const mappedLessons = React.useMemo(
|
selectedDate,
|
||||||
() =>
|
onSelectSlot,
|
||||||
lessons.map((lesson) => ({
|
onSelectEvent,
|
||||||
id: String(lesson.id),
|
onMonthChange,
|
||||||
title: lesson.title || 'Занятие',
|
isMentor = true,
|
||||||
start_time: lesson.start_time,
|
userTimezone,
|
||||||
end_time: lesson.end_time,
|
}) => {
|
||||||
status: (lesson.status || 'scheduled') as 'scheduled' | 'in_progress' | 'completed' | 'cancelled',
|
const mappedLessons = React.useMemo(
|
||||||
client: lesson.client_name
|
() =>
|
||||||
? {
|
lessons.map((lesson) => {
|
||||||
id: String(lesson.client ?? ''),
|
if (isMentor && lesson.client_name) {
|
||||||
name: lesson.client_name,
|
return {
|
||||||
first_name: lesson.client_name.split(' ')[0] || lesson.client_name,
|
id: String(lesson.id),
|
||||||
last_name: lesson.client_name.split(' ').slice(1).join(' ') || '',
|
title: lesson.title || 'Занятие',
|
||||||
}
|
start_time: lesson.start_time,
|
||||||
: undefined,
|
end_time: lesson.end_time,
|
||||||
})),
|
status: (lesson.status || 'scheduled') as 'scheduled' | 'in_progress' | 'completed' | 'cancelled',
|
||||||
[lessons]
|
client: {
|
||||||
);
|
id: String(lesson.client ?? ''),
|
||||||
|
name: lesson.client_name,
|
||||||
return (
|
first_name: lesson.client_name.split(' ')[0] || lesson.client_name,
|
||||||
<div
|
last_name: lesson.client_name.split(' ').slice(1).join(' ') || '',
|
||||||
className="ios-glass-panel"
|
},
|
||||||
style={{
|
};
|
||||||
borderRadius: '20px',
|
}
|
||||||
padding: '24px',
|
const subject = lesson.subject || 'Занятие';
|
||||||
height: '100%',
|
const mentorName = lesson.mentor_name || '';
|
||||||
minHeight: 0,
|
const displayTitle = mentorName ? `${subject} — ${mentorName}` : subject;
|
||||||
display: 'flex',
|
return {
|
||||||
flexDirection: 'column',
|
id: String(lesson.id),
|
||||||
}}
|
title: displayTitle,
|
||||||
>
|
start_time: lesson.start_time,
|
||||||
{lessonsLoading ? (
|
end_time: lesson.end_time,
|
||||||
<LoadingSpinner size="medium" />
|
status: (lesson.status || 'scheduled') as 'scheduled' | 'in_progress' | 'completed' | 'cancelled',
|
||||||
) : (
|
client: undefined,
|
||||||
<LessonsCalendar
|
};
|
||||||
lessons={mappedLessons}
|
}),
|
||||||
selectedDate={selectedDate}
|
[lessons, isMentor]
|
||||||
onSelectSlot={(date) => {
|
);
|
||||||
try {
|
|
||||||
const d = startOfDay(date);
|
return (
|
||||||
if (!Number.isNaN(d.getTime())) onSelectSlot?.(d);
|
<div
|
||||||
} catch {
|
className="ios-glass-panel"
|
||||||
/* игнор невалидной даты */
|
style={{
|
||||||
}
|
borderRadius: '20px',
|
||||||
}}
|
padding: '24px',
|
||||||
onSelectEvent={onSelectEvent}
|
height: '100%',
|
||||||
onMonthChange={onMonthChange}
|
minHeight: 0,
|
||||||
/>
|
display: 'flex',
|
||||||
)}
|
flexDirection: 'column',
|
||||||
</div>
|
}}
|
||||||
);
|
>
|
||||||
};
|
{lessonsLoading ? (
|
||||||
|
<LoadingSpinner size="medium" />
|
||||||
|
) : (
|
||||||
|
<LessonsCalendar
|
||||||
|
lessons={mappedLessons}
|
||||||
|
selectedDate={selectedDate}
|
||||||
|
userTimezone={userTimezone}
|
||||||
|
onSelectSlot={(date) => {
|
||||||
|
try {
|
||||||
|
const d = startOfDay(date);
|
||||||
|
if (!Number.isNaN(d.getTime())) onSelectSlot?.(d);
|
||||||
|
} catch {
|
||||||
|
/* игнор невалидной даты */
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onSelectEvent={onSelectEvent}
|
||||||
|
onMonthChange={onMonthChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,316 +1,316 @@
|
||||||
/**
|
/**
|
||||||
* Material Design 3 Date Picker — Dialog variant.
|
* Material Design 3 Date Picker — Dialog variant.
|
||||||
* Opens a calendar inside a MUI Dialog (works well on mobile and inside other dialogs).
|
* Opens a calendar inside a MUI Dialog (works well on mobile and inside other dialogs).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
format,
|
format,
|
||||||
startOfMonth,
|
startOfMonth,
|
||||||
endOfMonth,
|
endOfMonth,
|
||||||
eachDayOfInterval,
|
eachDayOfInterval,
|
||||||
isSameDay,
|
isSameDay,
|
||||||
isSameMonth,
|
isSameMonth,
|
||||||
addMonths,
|
addMonths,
|
||||||
subMonths,
|
subMonths,
|
||||||
startOfWeek,
|
startOfWeek,
|
||||||
endOfWeek,
|
endOfWeek,
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
import { ru } from 'date-fns/locale';
|
import { ru } from 'date-fns/locale';
|
||||||
import { Dialog, DialogContent, Box, Button } from '@mui/material';
|
import { Dialog, DialogContent, Box, Button } from '@mui/material';
|
||||||
|
|
||||||
interface DatePickerProps {
|
interface DatePickerProps {
|
||||||
value: string; // YYYY-MM-DD format
|
value: string; // YYYY-MM-DD format
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DatePicker: React.FC<DatePickerProps> = ({
|
export const DatePicker: React.FC<DatePickerProps> = ({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
required = false,
|
required = false,
|
||||||
label,
|
label,
|
||||||
}) => {
|
}) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [displayMonth, setDisplayMonth] = useState(
|
const [displayMonth, setDisplayMonth] = useState(
|
||||||
value ? new Date(value + 'T00:00:00') : new Date(),
|
value ? new Date(value + 'T00:00:00') : new Date(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedDate = useMemo(
|
const selectedDate = useMemo(
|
||||||
() => (value ? new Date(value + 'T00:00:00') : null),
|
() => (value ? new Date(value + 'T00:00:00') : null),
|
||||||
[value],
|
[value],
|
||||||
);
|
);
|
||||||
|
|
||||||
const openPicker = () => {
|
const openPicker = () => {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
setDisplayMonth(selectedDate ?? new Date());
|
setDisplayMonth(selectedDate ?? new Date());
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const closePicker = () => setOpen(false);
|
const closePicker = () => setOpen(false);
|
||||||
|
|
||||||
const handleDateSelect = (date: Date) => {
|
const handleDateSelect = (date: Date) => {
|
||||||
const y = date.getFullYear();
|
const y = date.getFullYear();
|
||||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
const d = String(date.getDate()).padStart(2, '0');
|
const d = String(date.getDate()).padStart(2, '0');
|
||||||
onChange(`${y}-${m}-${d}`);
|
onChange(`${y}-${m}-${d}`);
|
||||||
closePicker();
|
closePicker();
|
||||||
};
|
};
|
||||||
|
|
||||||
const days = useMemo(() => {
|
const days = useMemo(() => {
|
||||||
const start = startOfMonth(displayMonth);
|
const start = startOfMonth(displayMonth);
|
||||||
const end = endOfMonth(displayMonth);
|
const end = endOfMonth(displayMonth);
|
||||||
return eachDayOfInterval({
|
return eachDayOfInterval({
|
||||||
start: startOfWeek(start, { locale: ru }),
|
start: startOfWeek(start, { locale: ru }),
|
||||||
end: endOfWeek(end, { locale: ru }),
|
end: endOfWeek(end, { locale: ru }),
|
||||||
});
|
});
|
||||||
}, [displayMonth]);
|
}, [displayMonth]);
|
||||||
|
|
||||||
const weekDays = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
const weekDays = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
||||||
|
|
||||||
const displayValue = selectedDate
|
const displayValue = selectedDate
|
||||||
? format(selectedDate, 'd MMMM yyyy', { locale: ru })
|
? format(selectedDate, 'd MMMM yyyy', { locale: ru })
|
||||||
: label || 'Выберите дату';
|
: label || 'Выберите дату';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={openPicker}
|
onClick={openPicker}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-required={required}
|
aria-required={required}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
padding: '12px 16px',
|
padding: '12px 16px',
|
||||||
fontSize: '16px',
|
fontSize: '16px',
|
||||||
color: value
|
color: value
|
||||||
? 'var(--md-sys-color-on-surface)'
|
? 'var(--md-sys-color-on-surface)'
|
||||||
: 'var(--md-sys-color-on-surface-variant)',
|
: 'var(--md-sys-color-on-surface-variant)',
|
||||||
background: 'var(--md-sys-color-surface)',
|
background: 'var(--md-sys-color-surface)',
|
||||||
border: '1px solid var(--md-sys-color-outline)',
|
border: '1px solid var(--md-sys-color-outline)',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
fontFamily: 'inherit',
|
fontFamily: 'inherit',
|
||||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||||
outline: 'none',
|
outline: 'none',
|
||||||
transition: 'all 0.2s ease',
|
transition: 'all 0.2s ease',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
gap: '12px',
|
gap: '12px',
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>{displayValue}</span>
|
<span>{displayValue}</span>
|
||||||
<span
|
<span
|
||||||
className="material-symbols-outlined"
|
className="material-symbols-outlined"
|
||||||
style={{ fontSize: 20, opacity: 0.7 }}
|
style={{ fontSize: 20, opacity: 0.7 }}
|
||||||
>
|
>
|
||||||
calendar_today
|
calendar_today
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
onClose={closePicker}
|
onClose={closePicker}
|
||||||
fullWidth
|
fullWidth
|
||||||
maxWidth="xs"
|
maxWidth="xs"
|
||||||
slotProps={{
|
slotProps={{
|
||||||
paper: {
|
paper: {
|
||||||
sx: {
|
sx: {
|
||||||
borderRadius: '24px',
|
borderRadius: '24px',
|
||||||
overflow: 'visible',
|
overflow: 'visible',
|
||||||
bgcolor: 'var(--md-sys-color-surface)',
|
bgcolor: 'var(--md-sys-color-surface)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent sx={{ p: 2 }}>
|
<DialogContent sx={{ p: 2 }}>
|
||||||
{/* Month/year header */}
|
{/* Month/year header */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
mb: 1.5,
|
mb: 1.5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setDisplayMonth(subMonths(displayMonth, 1))}
|
onClick={() => setDisplayMonth(subMonths(displayMonth, 1))}
|
||||||
style={{
|
style={{
|
||||||
width: 36,
|
width: 36,
|
||||||
height: 36,
|
height: 36,
|
||||||
padding: 0,
|
padding: 0,
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
color: 'var(--md-sys-color-on-surface)',
|
color: 'var(--md-sys-color-on-surface)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>
|
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>
|
||||||
chevron_left
|
chevron_left
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
color: 'var(--md-sys-color-on-surface)',
|
color: 'var(--md-sys-color-on-surface)',
|
||||||
textTransform: 'capitalize',
|
textTransform: 'capitalize',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{format(displayMonth, 'LLLL yyyy', { locale: ru })}
|
{format(displayMonth, 'LLLL yyyy', { locale: ru })}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setDisplayMonth(addMonths(displayMonth, 1))}
|
onClick={() => setDisplayMonth(addMonths(displayMonth, 1))}
|
||||||
style={{
|
style={{
|
||||||
width: 36,
|
width: 36,
|
||||||
height: 36,
|
height: 36,
|
||||||
padding: 0,
|
padding: 0,
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
color: 'var(--md-sys-color-on-surface)',
|
color: 'var(--md-sys-color-on-surface)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>
|
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>
|
||||||
chevron_right
|
chevron_right
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Weekday headers */}
|
{/* Weekday headers */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: 'repeat(7, 1fr)',
|
gridTemplateColumns: 'repeat(7, 1fr)',
|
||||||
gap: 2,
|
gap: 2,
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{weekDays.map((day) => (
|
{weekDays.map((day) => (
|
||||||
<div
|
<div
|
||||||
key={day}
|
key={day}
|
||||||
style={{
|
style={{
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
color: 'var(--md-sys-color-on-surface-variant)',
|
color: 'var(--md-sys-color-on-surface-variant)',
|
||||||
padding: '6px 0',
|
padding: '6px 0',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{day}
|
{day}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Calendar days */}
|
{/* Calendar days */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: 'repeat(7, 1fr)',
|
gridTemplateColumns: 'repeat(7, 1fr)',
|
||||||
gap: 2,
|
gap: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{days.map((day, idx) => {
|
{days.map((day, idx) => {
|
||||||
const isSelected = selectedDate && isSameDay(day, selectedDate);
|
const isSelected = selectedDate && isSameDay(day, selectedDate);
|
||||||
const isCurrent = isSameMonth(day, displayMonth);
|
const isCurrent = isSameMonth(day, displayMonth);
|
||||||
const isToday = isSameDay(day, new Date());
|
const isToday = isSameDay(day, new Date());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={idx}
|
key={idx}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleDateSelect(day)}
|
onClick={() => handleDateSelect(day)}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
aspectRatio: '1',
|
aspectRatio: '1',
|
||||||
maxWidth: 40,
|
maxWidth: 40,
|
||||||
margin: '0 auto',
|
margin: '0 auto',
|
||||||
padding: 0,
|
padding: 0,
|
||||||
background: isSelected
|
background: isSelected
|
||||||
? 'var(--md-sys-color-primary)'
|
? 'var(--md-sys-color-primary)'
|
||||||
: 'transparent',
|
: 'transparent',
|
||||||
border:
|
border:
|
||||||
isToday && !isSelected
|
isToday && !isSelected
|
||||||
? '1px solid var(--md-sys-color-primary)'
|
? '1px solid var(--md-sys-color-primary)'
|
||||||
: 'none',
|
: 'none',
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: isSelected ? 600 : 400,
|
fontWeight: isSelected ? 600 : 400,
|
||||||
color: isSelected
|
color: isSelected
|
||||||
? 'var(--md-sys-color-on-primary)'
|
? 'var(--md-sys-color-on-primary)'
|
||||||
: isCurrent
|
: isCurrent
|
||||||
? 'var(--md-sys-color-on-surface)'
|
? 'var(--md-sys-color-on-surface)'
|
||||||
: 'var(--md-sys-color-on-surface-variant)',
|
: 'var(--md-sys-color-on-surface-variant)',
|
||||||
opacity: isCurrent ? 1 : 0.35,
|
opacity: isCurrent ? 1 : 0.35,
|
||||||
transition: 'background 0.15s',
|
transition: 'background 0.15s',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{format(day, 'd')}
|
{format(day, 'd')}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
mt: 2,
|
mt: 2,
|
||||||
pt: 1.5,
|
pt: 1.5,
|
||||||
borderTop: '1px solid var(--md-sys-color-outline-variant)',
|
borderTop: '1px solid var(--md-sys-color-outline-variant)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleDateSelect(new Date())}
|
onClick={() => handleDateSelect(new Date())}
|
||||||
variant="text"
|
variant="text"
|
||||||
sx={{
|
sx={{
|
||||||
color: 'var(--md-sys-color-primary)',
|
color: 'var(--md-sys-color-primary)',
|
||||||
textTransform: 'none',
|
textTransform: 'none',
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Сегодня
|
Сегодня
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={closePicker}
|
onClick={closePicker}
|
||||||
variant="text"
|
variant="text"
|
||||||
sx={{
|
sx={{
|
||||||
color: 'var(--md-sys-color-on-surface-variant)',
|
color: 'var(--md-sys-color-on-surface-variant)',
|
||||||
textTransform: 'none',
|
textTransform: 'none',
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Отмена
|
Отмена
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { getCurrentUser, User } from '@/api/auth';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { DatePicker } from '@/components/common/DatePicker';
|
import { DatePicker } from '@/components/common/DatePicker';
|
||||||
import { TimePicker } from '@/components/common/TimePicker';
|
import { TimePicker } from '@/components/common/TimePicker';
|
||||||
|
import { createDateTimeInUserTimezone } from '@/utils/timezone';
|
||||||
|
|
||||||
interface CreateLessonDialogProps {
|
interface CreateLessonDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -250,8 +251,12 @@ export const CreateLessonDialog: React.FC<CreateLessonDialogProps> = ({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Объединяем дату и время в ISO строку
|
// Объединяем дату и время в ISO строку с учётом timezone пользователя
|
||||||
const startUtc = new Date(`${formData.start_date}T${formData.start_time}`).toISOString();
|
const startUtc = createDateTimeInUserTimezone(
|
||||||
|
formData.start_date,
|
||||||
|
formData.start_time,
|
||||||
|
currentUser?.timezone
|
||||||
|
);
|
||||||
|
|
||||||
const payload: any = {
|
const payload: any = {
|
||||||
client: formData.client,
|
client: formData.client,
|
||||||
|
|
|
||||||
|
|
@ -1,276 +1,278 @@
|
||||||
/**
|
/**
|
||||||
* Карточка урока для Dashboard
|
* Карточка урока для Dashboard
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { LessonPreview } from '@/api/dashboard';
|
import { LessonPreview } from '@/api/dashboard';
|
||||||
import { createLiveKitRoom } from '@/api/livekit';
|
import { createLiveKitRoom } from '@/api/livekit';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
interface LessonCardProps {
|
import { parseISOToUserTimezone } from '@/utils/timezone';
|
||||||
lesson: LessonPreview;
|
|
||||||
showMentor?: boolean;
|
interface LessonCardProps {
|
||||||
showClient?: boolean;
|
lesson: LessonPreview;
|
||||||
onClick?: () => void;
|
showMentor?: boolean;
|
||||||
}
|
showClient?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
/** Подключение доступно за 10 минут до начала и до 15 минут после окончания */
|
}
|
||||||
function canJoinLesson(lesson: LessonPreview): boolean {
|
|
||||||
if (!lesson.start_time || !lesson.end_time) return false;
|
/** Подключение доступно за 10 минут до начала и до 15 минут после окончания */
|
||||||
if (lesson.status === 'cancelled') return false;
|
function canJoinLesson(lesson: LessonPreview): boolean {
|
||||||
const now = new Date();
|
if (!lesson.start_time || !lesson.end_time) return false;
|
||||||
const startTime = new Date(lesson.start_time);
|
if (lesson.status === 'cancelled') return false;
|
||||||
const endTime = new Date(lesson.end_time);
|
const now = new Date();
|
||||||
const allowedStart = new Date(startTime.getTime() - 10 * 60 * 1000); // за 10 минут до начала
|
const startTime = new Date(lesson.start_time);
|
||||||
const allowedEnd = new Date(endTime.getTime() + 15 * 60 * 1000); // до 15 минут после окончания
|
const endTime = new Date(lesson.end_time);
|
||||||
return now >= allowedStart && now <= allowedEnd;
|
const allowedStart = new Date(startTime.getTime() - 10 * 60 * 1000); // за 10 минут до начала
|
||||||
}
|
const allowedEnd = new Date(endTime.getTime() + 15 * 60 * 1000); // до 15 минут после окончания
|
||||||
|
return now >= allowedStart && now <= allowedEnd;
|
||||||
export const LessonCard: React.FC<LessonCardProps> = ({
|
}
|
||||||
lesson,
|
|
||||||
showMentor = false,
|
export const LessonCard: React.FC<LessonCardProps> = ({
|
||||||
showClient = false,
|
lesson,
|
||||||
onClick,
|
showMentor = false,
|
||||||
}) => {
|
showClient = false,
|
||||||
const router = useRouter();
|
onClick,
|
||||||
const [connectLoading, setConnectLoading] = useState(false);
|
}) => {
|
||||||
const [canJoin, setCanJoin] = useState(false);
|
const router = useRouter();
|
||||||
|
const { user } = useAuth();
|
||||||
useEffect(() => {
|
const [connectLoading, setConnectLoading] = useState(false);
|
||||||
const check = () => setCanJoin(canJoinLesson(lesson));
|
const [canJoin, setCanJoin] = useState(false);
|
||||||
check();
|
|
||||||
const interval = setInterval(check, 60000);
|
useEffect(() => {
|
||||||
return () => clearInterval(interval);
|
const check = () => setCanJoin(canJoinLesson(lesson));
|
||||||
}, [lesson.start_time, lesson.end_time, lesson.status]);
|
check();
|
||||||
|
const interval = setInterval(check, 60000);
|
||||||
const handleConnect = useCallback(
|
return () => clearInterval(interval);
|
||||||
async (e: React.MouseEvent) => {
|
}, [lesson.start_time, lesson.end_time, lesson.status]);
|
||||||
e.stopPropagation();
|
|
||||||
if (!canJoin || connectLoading) return;
|
const handleConnect = useCallback(
|
||||||
setConnectLoading(true);
|
async (e: React.MouseEvent) => {
|
||||||
try {
|
e.stopPropagation();
|
||||||
const lessonId = typeof lesson.id === 'string' ? parseInt(lesson.id, 10) : lesson.id;
|
if (!canJoin || connectLoading) return;
|
||||||
const res = await createLiveKitRoom(lessonId);
|
setConnectLoading(true);
|
||||||
router.push(
|
try {
|
||||||
`/livekit/${res.room_name}?lesson_id=${lesson.id}&token=${encodeURIComponent(res.access_token)}`
|
const lessonId = typeof lesson.id === 'string' ? parseInt(lesson.id, 10) : lesson.id;
|
||||||
);
|
const res = await createLiveKitRoom(lessonId);
|
||||||
} catch (err) {
|
router.push(
|
||||||
console.error('LiveKit connect error:', err);
|
`/livekit/${res.room_name}?lesson_id=${lesson.id}&token=${encodeURIComponent(res.access_token)}`
|
||||||
setConnectLoading(false);
|
);
|
||||||
}
|
} catch (err) {
|
||||||
},
|
console.error('LiveKit connect error:', err);
|
||||||
[canJoin, connectLoading, lesson.id, router]
|
setConnectLoading(false);
|
||||||
);
|
}
|
||||||
|
},
|
||||||
const startTime = new Date(lesson.start_time);
|
[canJoin, connectLoading, lesson.id, router]
|
||||||
const endTime = new Date(lesson.end_time);
|
);
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
// Парсим время с учётом timezone пользователя
|
||||||
switch (status) {
|
const { startParsed, endParsed } = useMemo(() => {
|
||||||
case 'completed':
|
return {
|
||||||
// более серый фон для завершённых занятий
|
startParsed: parseISOToUserTimezone(lesson.start_time, user?.timezone),
|
||||||
return 'color-mix(in srgb, var(--md-sys-color-surface-variant) 70%, #000 10%)';
|
endParsed: parseISOToUserTimezone(lesson.end_time, user?.timezone),
|
||||||
case 'in_progress':
|
};
|
||||||
return 'var(--md-sys-color-tertiary)';
|
}, [lesson.start_time, lesson.end_time, user?.timezone]);
|
||||||
case 'cancelled':
|
|
||||||
return 'var(--md-sys-color-error)';
|
const getStatusColor = (status: string) => {
|
||||||
case 'scheduled':
|
switch (status) {
|
||||||
default:
|
case 'completed':
|
||||||
return 'var(--md-sys-color-primary)';
|
// более серый фон для завершённых занятий
|
||||||
}
|
return 'color-mix(in srgb, var(--md-sys-color-surface-variant) 70%, #000 10%)';
|
||||||
};
|
case 'in_progress':
|
||||||
|
return 'var(--md-sys-color-tertiary)';
|
||||||
const getStatusTextColor = (status: string) => {
|
case 'cancelled':
|
||||||
switch (status) {
|
return 'var(--md-sys-color-error)';
|
||||||
case 'completed':
|
case 'scheduled':
|
||||||
return 'var(--md-sys-color-on-surface-variant)';
|
default:
|
||||||
case 'in_progress':
|
return 'var(--md-sys-color-primary)';
|
||||||
return 'var(--md-sys-color-on-tertiary)';
|
}
|
||||||
case 'cancelled':
|
};
|
||||||
return 'var(--md-sys-color-on-error)';
|
|
||||||
case 'scheduled':
|
const getStatusTextColor = (status: string) => {
|
||||||
default:
|
switch (status) {
|
||||||
return 'var(--md-sys-color-on-primary)';
|
case 'completed':
|
||||||
}
|
return 'var(--md-sys-color-on-surface-variant)';
|
||||||
};
|
case 'in_progress':
|
||||||
|
return 'var(--md-sys-color-on-tertiary)';
|
||||||
const getStatusText = (status: string) => {
|
case 'cancelled':
|
||||||
switch (status) {
|
return 'var(--md-sys-color-on-error)';
|
||||||
case 'completed':
|
case 'scheduled':
|
||||||
return 'Завершено';
|
default:
|
||||||
case 'in_progress':
|
return 'var(--md-sys-color-on-primary)';
|
||||||
return 'В процессе';
|
}
|
||||||
case 'cancelled':
|
};
|
||||||
return 'Отменено';
|
|
||||||
default:
|
const getStatusText = (status: string) => {
|
||||||
return 'Запланировано';
|
switch (status) {
|
||||||
}
|
case 'completed':
|
||||||
};
|
return 'Завершено';
|
||||||
|
case 'in_progress':
|
||||||
const statusColor = getStatusColor(lesson.status);
|
return 'В процессе';
|
||||||
const textColor = getStatusTextColor(lesson.status);
|
case 'cancelled':
|
||||||
|
return 'Отменено';
|
||||||
return (
|
default:
|
||||||
<div
|
return 'Запланировано';
|
||||||
role={onClick ? 'button' : undefined}
|
}
|
||||||
tabIndex={onClick ? 0 : undefined}
|
};
|
||||||
onClick={onClick}
|
|
||||||
onKeyDown={onClick ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick(); } } : undefined}
|
const statusColor = getStatusColor(lesson.status);
|
||||||
style={{
|
const textColor = getStatusTextColor(lesson.status);
|
||||||
background: statusColor,
|
|
||||||
borderRadius: '16px',
|
return (
|
||||||
padding: '16px',
|
<div
|
||||||
marginBottom: '12px',
|
role={onClick ? 'button' : undefined}
|
||||||
transition: 'all 0.2s ease',
|
tabIndex={onClick ? 0 : undefined}
|
||||||
cursor: 'pointer',
|
onClick={onClick}
|
||||||
position: 'relative',
|
onKeyDown={onClick ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick(); } } : undefined}
|
||||||
}}
|
style={{
|
||||||
onMouseEnter={(e) => {
|
background: statusColor,
|
||||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.15)';
|
borderRadius: '16px',
|
||||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
padding: '16px',
|
||||||
}}
|
marginBottom: '12px',
|
||||||
onMouseLeave={(e) => {
|
transition: 'all 0.2s ease',
|
||||||
e.currentTarget.style.boxShadow = 'none';
|
cursor: 'pointer',
|
||||||
e.currentTarget.style.transform = 'translateY(0)';
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
>
|
onMouseEnter={(e) => {
|
||||||
<div style={{
|
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.15)';
|
||||||
display: 'flex',
|
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||||
justifyContent: 'space-between',
|
}}
|
||||||
alignItems: 'flex-start',
|
onMouseLeave={(e) => {
|
||||||
marginBottom: '12px'
|
e.currentTarget.style.boxShadow = 'none';
|
||||||
}}>
|
e.currentTarget.style.transform = 'translateY(0)';
|
||||||
<div style={{ flex: 1 }}>
|
}}
|
||||||
<h4 style={{
|
>
|
||||||
fontSize: '16px',
|
<div style={{
|
||||||
fontWeight: '500',
|
display: 'flex',
|
||||||
color: textColor,
|
justifyContent: 'space-between',
|
||||||
margin: '0 0 4px 0'
|
alignItems: 'flex-start',
|
||||||
}}>
|
marginBottom: '12px'
|
||||||
{lesson.title}
|
}}>
|
||||||
</h4>
|
<div style={{ flex: 1 }}>
|
||||||
{lesson.subject && (
|
<h4 style={{
|
||||||
<p style={{
|
fontSize: '16px',
|
||||||
fontSize: '14px',
|
fontWeight: '500',
|
||||||
color: textColor,
|
color: textColor,
|
||||||
margin: '0',
|
margin: '0 0 4px 0'
|
||||||
opacity: 0.9
|
}}>
|
||||||
}}>
|
{lesson.title}
|
||||||
{lesson.subject}
|
</h4>
|
||||||
</p>
|
{lesson.subject && (
|
||||||
)}
|
<p style={{
|
||||||
</div>
|
fontSize: '14px',
|
||||||
<span style={{
|
color: textColor,
|
||||||
fontSize: '12px',
|
margin: '0',
|
||||||
padding: '4px 10px',
|
opacity: 0.9
|
||||||
borderRadius: '12px',
|
}}>
|
||||||
background: 'rgba(255, 255, 255, 0.25)',
|
{lesson.subject}
|
||||||
color: textColor,
|
</p>
|
||||||
fontWeight: '500'
|
)}
|
||||||
}}>
|
</div>
|
||||||
{getStatusText(lesson.status)}
|
<span style={{
|
||||||
</span>
|
fontSize: '12px',
|
||||||
</div>
|
padding: '4px 10px',
|
||||||
|
borderRadius: '12px',
|
||||||
<div style={{
|
background: 'rgba(255, 255, 255, 0.25)',
|
||||||
display: 'flex',
|
color: textColor,
|
||||||
alignItems: 'center',
|
fontWeight: '500'
|
||||||
gap: '16px',
|
}}>
|
||||||
fontSize: '14px',
|
{getStatusText(lesson.status)}
|
||||||
color: textColor,
|
</span>
|
||||||
opacity: 0.9
|
</div>
|
||||||
}}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
<div style={{
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
display: 'flex',
|
||||||
<circle cx="12" cy="12" r="10"></circle>
|
alignItems: 'center',
|
||||||
<polyline points="12 6 12 12 16 14"></polyline>
|
gap: '16px',
|
||||||
</svg>
|
fontSize: '14px',
|
||||||
<span>
|
color: textColor,
|
||||||
{startTime.toLocaleDateString('ru-RU', {
|
opacity: 0.9
|
||||||
day: 'numeric',
|
}}>
|
||||||
month: 'short'
|
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
})}
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
{' в '}
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
{startTime.toLocaleTimeString('ru-RU', {
|
<polyline points="12 6 12 12 16 14"></polyline>
|
||||||
hour: '2-digit',
|
</svg>
|
||||||
minute: '2-digit'
|
<span>
|
||||||
})}
|
{startParsed.dateObj.toLocaleDateString('ru-RU', {
|
||||||
{' - '}
|
day: 'numeric',
|
||||||
{endTime.toLocaleTimeString('ru-RU', {
|
month: 'short'
|
||||||
hour: '2-digit',
|
})}
|
||||||
minute: '2-digit'
|
{' в '}
|
||||||
})}
|
{startParsed.time}
|
||||||
</span>
|
{' - '}
|
||||||
</div>
|
{endParsed.time}
|
||||||
</div>
|
</span>
|
||||||
|
</div>
|
||||||
{(showMentor && lesson.mentor) && (
|
</div>
|
||||||
<div style={{
|
|
||||||
marginTop: '12px',
|
{(showMentor && lesson.mentor) && (
|
||||||
paddingTop: '12px',
|
<div style={{
|
||||||
borderTop: `1px solid color-mix(in srgb, ${textColor} 40%, transparent)`,
|
marginTop: '12px',
|
||||||
fontSize: '14px',
|
paddingTop: '12px',
|
||||||
color: textColor,
|
borderTop: `1px solid color-mix(in srgb, ${textColor} 40%, transparent)`,
|
||||||
opacity: 0.9
|
fontSize: '14px',
|
||||||
}}>
|
color: textColor,
|
||||||
Ментор: {lesson.mentor.first_name} {lesson.mentor.last_name}
|
opacity: 0.9
|
||||||
</div>
|
}}>
|
||||||
)}
|
Ментор: {lesson.mentor.first_name} {lesson.mentor.last_name}
|
||||||
|
</div>
|
||||||
{(showClient && lesson.client) && (
|
)}
|
||||||
<div style={{
|
|
||||||
marginTop: '12px',
|
{(showClient && lesson.client) && (
|
||||||
paddingTop: '12px',
|
<div style={{
|
||||||
borderTop: `1px solid color-mix(in srgb, ${textColor} 40%, transparent)`,
|
marginTop: '12px',
|
||||||
fontSize: '14px',
|
paddingTop: '12px',
|
||||||
color: textColor,
|
borderTop: `1px solid color-mix(in srgb, ${textColor} 40%, transparent)`,
|
||||||
opacity: 0.9
|
fontSize: '14px',
|
||||||
}}>
|
color: textColor,
|
||||||
Ученик: {lesson.client.first_name} {lesson.client.last_name}
|
opacity: 0.9
|
||||||
</div>
|
}}>
|
||||||
)}
|
Ученик: {lesson.client.first_name} {lesson.client.last_name}
|
||||||
|
</div>
|
||||||
{canJoin && (lesson.status === 'scheduled' || lesson.status === 'in_progress') && (
|
)}
|
||||||
<button
|
|
||||||
type="button"
|
{canJoin && (lesson.status === 'scheduled' || lesson.status === 'in_progress') && (
|
||||||
onClick={handleConnect}
|
<button
|
||||||
disabled={connectLoading}
|
type="button"
|
||||||
style={{
|
onClick={handleConnect}
|
||||||
marginTop: '12px',
|
disabled={connectLoading}
|
||||||
width: '100%',
|
style={{
|
||||||
padding: '10px 16px',
|
marginTop: '12px',
|
||||||
borderRadius: '12px',
|
width: '100%',
|
||||||
border: 'none',
|
padding: '10px 16px',
|
||||||
background: 'rgba(255, 255, 255, 0.9)',
|
borderRadius: '12px',
|
||||||
color: statusColor,
|
border: 'none',
|
||||||
fontSize: '14px',
|
background: 'rgba(255, 255, 255, 0.9)',
|
||||||
fontWeight: '600',
|
color: statusColor,
|
||||||
cursor: connectLoading ? 'wait' : 'pointer',
|
fontSize: '14px',
|
||||||
display: 'flex',
|
fontWeight: '600',
|
||||||
alignItems: 'center',
|
cursor: connectLoading ? 'wait' : 'pointer',
|
||||||
justifyContent: 'center',
|
display: 'flex',
|
||||||
gap: '8px',
|
alignItems: 'center',
|
||||||
}}
|
justifyContent: 'center',
|
||||||
>
|
gap: '8px',
|
||||||
{connectLoading ? (
|
}}
|
||||||
<>
|
>
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 18, animation: 'spin 1s linear infinite' }}>
|
{connectLoading ? (
|
||||||
progress_activity
|
<>
|
||||||
</span>
|
<span className="material-symbols-outlined" style={{ fontSize: 18, animation: 'spin 1s linear infinite' }}>
|
||||||
Подключение...
|
progress_activity
|
||||||
</>
|
</span>
|
||||||
) : (
|
Подключение...
|
||||||
<>
|
</>
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>
|
) : (
|
||||||
videocam
|
<>
|
||||||
</span>
|
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>
|
||||||
Подключиться к уроку
|
videocam
|
||||||
</>
|
</span>
|
||||||
)}
|
Подключиться к уроку
|
||||||
</button>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</button>
|
||||||
);
|
)}
|
||||||
};
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,391 +1,404 @@
|
||||||
/**
|
/**
|
||||||
* Календарь занятий для Dashboard ментора
|
* Календарь занятий для Dashboard ментора
|
||||||
* Реализация “с нуля” на Material UI (M3) в iOS-стиле:
|
* Реализация “с нуля” на Material UI (M3) в iOS-стиле:
|
||||||
* - сетка месяца 7×6 с тонкими разделителями
|
* - сетка месяца 7×6 с тонкими разделителями
|
||||||
* - число дня в правом верхнем углу
|
* - число дня в правом верхнем углу
|
||||||
* - занятия плашками внутри ячейки (лимит + “+ ещё N”)
|
* - занятия плашками внутри ячейки (лимит + “+ ещё N”)
|
||||||
* - без внутреннего скролла
|
* - без внутреннего скролла
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useMemo, useState, useEffect, useCallback } from 'react';
|
import React, { useMemo, useState, useEffect, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
addDays,
|
addDays,
|
||||||
addMonths,
|
addMonths,
|
||||||
endOfMonth,
|
endOfMonth,
|
||||||
format,
|
format,
|
||||||
isSameDay,
|
isSameDay,
|
||||||
isSameMonth,
|
isSameMonth,
|
||||||
startOfDay,
|
startOfDay,
|
||||||
startOfMonth,
|
startOfMonth,
|
||||||
startOfWeek,
|
startOfWeek,
|
||||||
subMonths,
|
subMonths,
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
import { ru } from 'date-fns/locale';
|
import { ru } from 'date-fns/locale';
|
||||||
import { Box, IconButton, Typography } from '@mui/material';
|
import { Box, IconButton, Typography } from '@mui/material';
|
||||||
import { ChevronLeft, ChevronRight } from '@mui/icons-material';
|
import { ChevronLeft, ChevronRight } from '@mui/icons-material';
|
||||||
|
import { parseISOToUserTimezone } from '@/utils/timezone';
|
||||||
interface Lesson {
|
|
||||||
id: string;
|
interface Lesson {
|
||||||
title: string;
|
id: string;
|
||||||
start_time: string;
|
title: string;
|
||||||
end_time: string;
|
start_time: string;
|
||||||
status?: 'scheduled' | 'in_progress' | 'completed' | 'cancelled';
|
end_time: string;
|
||||||
client?: {
|
status?: 'scheduled' | 'in_progress' | 'completed' | 'cancelled';
|
||||||
id: string;
|
client?: {
|
||||||
name?: string;
|
id: string;
|
||||||
first_name?: string;
|
name?: string;
|
||||||
last_name?: string;
|
first_name?: string;
|
||||||
};
|
last_name?: string;
|
||||||
}
|
};
|
||||||
|
}
|
||||||
interface LessonsCalendarProps {
|
|
||||||
lessons: Lesson[];
|
interface LessonsCalendarProps {
|
||||||
onSelectEvent?: (lesson: Lesson) => void;
|
lessons: Lesson[];
|
||||||
onSelectSlot?: (date: Date) => void;
|
onSelectEvent?: (lesson: Lesson) => void;
|
||||||
onMonthChange?: (start: Date, end: Date) => void;
|
onSelectSlot?: (date: Date) => void;
|
||||||
selectedDate?: Date;
|
onMonthChange?: (start: Date, end: Date) => void;
|
||||||
}
|
selectedDate?: Date;
|
||||||
|
userTimezone?: string;
|
||||||
export const LessonsCalendar: React.FC<LessonsCalendarProps> = ({
|
}
|
||||||
lessons,
|
|
||||||
onSelectEvent,
|
export const LessonsCalendar: React.FC<LessonsCalendarProps> = ({
|
||||||
onSelectSlot,
|
lessons,
|
||||||
onMonthChange,
|
onSelectEvent,
|
||||||
selectedDate,
|
onSelectSlot,
|
||||||
}) => {
|
onMonthChange,
|
||||||
const safeSelectedDate = useMemo(() => {
|
selectedDate,
|
||||||
if (selectedDate && !Number.isNaN(selectedDate.getTime())) return startOfDay(selectedDate);
|
userTimezone,
|
||||||
return startOfDay(new Date());
|
}) => {
|
||||||
}, [selectedDate]);
|
const safeSelectedDate = useMemo(() => {
|
||||||
|
if (selectedDate && !Number.isNaN(selectedDate.getTime())) return startOfDay(selectedDate);
|
||||||
const [currentMonth, setCurrentMonth] = useState<Date>(() => startOfMonth(safeSelectedDate));
|
return startOfDay(new Date());
|
||||||
const [slideDir, setSlideDir] = useState<'prev' | 'next' | 'today' | null>(null);
|
}, [selectedDate]);
|
||||||
|
|
||||||
useEffect(() => {
|
const [currentMonth, setCurrentMonth] = useState<Date>(() => startOfMonth(safeSelectedDate));
|
||||||
setCurrentMonth(startOfMonth(safeSelectedDate));
|
const [slideDir, setSlideDir] = useState<'prev' | 'next' | 'today' | null>(null);
|
||||||
}, [safeSelectedDate]);
|
|
||||||
|
useEffect(() => {
|
||||||
useEffect(() => {
|
setCurrentMonth(startOfMonth(safeSelectedDate));
|
||||||
if (!slideDir) return;
|
}, [safeSelectedDate]);
|
||||||
const t = window.setTimeout(() => setSlideDir(null), 240);
|
|
||||||
return () => window.clearTimeout(t);
|
useEffect(() => {
|
||||||
}, [slideDir]);
|
if (!slideDir) return;
|
||||||
|
const t = window.setTimeout(() => setSlideDir(null), 240);
|
||||||
useEffect(() => {
|
return () => window.clearTimeout(t);
|
||||||
const start = startOfMonth(currentMonth);
|
}, [slideDir]);
|
||||||
const end = endOfMonth(currentMonth);
|
|
||||||
onMonthChange?.(start, end);
|
useEffect(() => {
|
||||||
}, [currentMonth, onMonthChange]);
|
const start = startOfMonth(currentMonth);
|
||||||
|
const end = endOfMonth(currentMonth);
|
||||||
// Группируем занятия по дате (ключ YYYY-MM-DD)
|
onMonthChange?.(start, end);
|
||||||
const lessonsByDay = useMemo(() => {
|
}, [currentMonth, onMonthChange]);
|
||||||
const map = new Map<string, Lesson[]>();
|
|
||||||
if (!lessons || lessons.length === 0) return map;
|
// Группируем занятия по дате (ключ YYYY-MM-DD) с учётом timezone пользователя
|
||||||
|
const lessonsByDay = useMemo(() => {
|
||||||
lessons.forEach((lesson) => {
|
const map = new Map<string, Lesson[]>();
|
||||||
try {
|
if (!lessons || lessons.length === 0) return map;
|
||||||
const day = startOfDay(new Date(lesson.start_time));
|
|
||||||
if (isNaN(day.getTime())) return;
|
lessons.forEach((lesson) => {
|
||||||
const key = format(day, 'yyyy-MM-dd');
|
try {
|
||||||
const existing = map.get(key) || [];
|
// Используем timezone пользователя для определения дня
|
||||||
existing.push(lesson);
|
const parsed = parseISOToUserTimezone(lesson.start_time, userTimezone);
|
||||||
map.set(key, existing);
|
const key = parsed.date; // уже в формате 'yyyy-MM-dd'
|
||||||
} catch {
|
const existing = map.get(key) || [];
|
||||||
/* ignore invalid date */
|
existing.push(lesson);
|
||||||
}
|
map.set(key, existing);
|
||||||
});
|
} catch {
|
||||||
|
/* ignore invalid date */
|
||||||
return map;
|
}
|
||||||
}, [lessons]);
|
});
|
||||||
|
|
||||||
const monthLabel = useMemo(() => {
|
// Сортируем занятия внутри каждого дня по времени
|
||||||
const label = format(currentMonth, 'LLLL yyyy', { locale: ru });
|
map.forEach((dayLessons, key) => {
|
||||||
return label.charAt(0).toUpperCase() + label.slice(1);
|
dayLessons.sort((a, b) =>
|
||||||
}, [currentMonth]);
|
new Date(a.start_time).getTime() - new Date(b.start_time).getTime()
|
||||||
|
);
|
||||||
const weekdayLabels = useMemo(() => {
|
map.set(key, dayLessons);
|
||||||
const start = startOfWeek(new Date(), { weekStartsOn: 1, locale: ru });
|
});
|
||||||
return Array.from({ length: 7 }).map((_, idx) => {
|
|
||||||
const d = addDays(start, idx);
|
return map;
|
||||||
return format(d, 'EE', { locale: ru }).substring(0, 2).toUpperCase();
|
}, [lessons, userTimezone]);
|
||||||
});
|
|
||||||
}, []);
|
const monthLabel = useMemo(() => {
|
||||||
|
const label = format(currentMonth, 'LLLL yyyy', { locale: ru });
|
||||||
const daysGrid = useMemo(() => {
|
return label.charAt(0).toUpperCase() + label.slice(1);
|
||||||
const monthStart = startOfMonth(currentMonth);
|
}, [currentMonth]);
|
||||||
const gridStart = startOfWeek(monthStart, { weekStartsOn: 1, locale: ru });
|
|
||||||
return Array.from({ length: 42 }).map((_, i) => addDays(gridStart, i));
|
const weekdayLabels = useMemo(() => {
|
||||||
}, [currentMonth]);
|
const start = startOfWeek(new Date(), { weekStartsOn: 1, locale: ru });
|
||||||
|
return Array.from({ length: 7 }).map((_, idx) => {
|
||||||
const handleDayClick = useCallback(
|
const d = addDays(start, idx);
|
||||||
(day: Date) => {
|
return format(d, 'EE', { locale: ru }).substring(0, 2).toUpperCase();
|
||||||
onSelectSlot?.(startOfDay(day));
|
});
|
||||||
},
|
}, []);
|
||||||
[onSelectSlot]
|
|
||||||
);
|
const daysGrid = useMemo(() => {
|
||||||
|
const monthStart = startOfMonth(currentMonth);
|
||||||
const handleLessonClick = useCallback(
|
const gridStart = startOfWeek(monthStart, { weekStartsOn: 1, locale: ru });
|
||||||
(day: Date, lesson: Lesson, e: React.MouseEvent) => {
|
return Array.from({ length: 42 }).map((_, i) => addDays(gridStart, i));
|
||||||
e.stopPropagation();
|
}, [currentMonth]);
|
||||||
onSelectSlot?.(startOfDay(day));
|
|
||||||
onSelectEvent?.(lesson);
|
const handleDayClick = useCallback(
|
||||||
},
|
(day: Date) => {
|
||||||
[onSelectEvent, onSelectSlot]
|
onSelectSlot?.(startOfDay(day));
|
||||||
);
|
},
|
||||||
|
[onSelectSlot]
|
||||||
const goToday = useCallback(() => {
|
);
|
||||||
const today = startOfDay(new Date());
|
|
||||||
const curr = startOfMonth(currentMonth).getTime();
|
const handleLessonClick = useCallback(
|
||||||
const target = startOfMonth(today).getTime();
|
(day: Date, lesson: Lesson, e: React.MouseEvent) => {
|
||||||
if (target < curr) setSlideDir('prev');
|
e.stopPropagation();
|
||||||
else if (target > curr) setSlideDir('next');
|
onSelectSlot?.(startOfDay(day));
|
||||||
else setSlideDir('today');
|
onSelectEvent?.(lesson);
|
||||||
setCurrentMonth(startOfMonth(today));
|
},
|
||||||
onSelectSlot?.(today);
|
[onSelectEvent, onSelectSlot]
|
||||||
}, [currentMonth, onSelectSlot]);
|
);
|
||||||
|
|
||||||
const goPrevMonth = useCallback(() => {
|
const goToday = useCallback(() => {
|
||||||
setSlideDir('prev');
|
const today = startOfDay(new Date());
|
||||||
setCurrentMonth((m) => subMonths(m, 1));
|
const curr = startOfMonth(currentMonth).getTime();
|
||||||
}, []);
|
const target = startOfMonth(today).getTime();
|
||||||
|
if (target < curr) setSlideDir('prev');
|
||||||
const goNextMonth = useCallback(() => {
|
else if (target > curr) setSlideDir('next');
|
||||||
setSlideDir('next');
|
else setSlideDir('today');
|
||||||
setCurrentMonth((m) => addMonths(m, 1));
|
setCurrentMonth(startOfMonth(today));
|
||||||
}, []);
|
onSelectSlot?.(today);
|
||||||
|
}, [currentMonth, onSelectSlot]);
|
||||||
return (
|
|
||||||
<Box sx={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column' }}>
|
const goPrevMonth = useCallback(() => {
|
||||||
{/* Header как в iOS */}
|
setSlideDir('prev');
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
|
setCurrentMonth((m) => subMonths(m, 1));
|
||||||
<Typography sx={{ fontSize: 18, fontWeight: 700, color: 'var(--md-sys-color-on-surface)' }}>
|
}, []);
|
||||||
{monthLabel}
|
|
||||||
</Typography>
|
const goNextMonth = useCallback(() => {
|
||||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
setSlideDir('next');
|
||||||
<IconButton
|
setCurrentMonth((m) => addMonths(m, 1));
|
||||||
onClick={goPrevMonth}
|
}, []);
|
||||||
size="small"
|
|
||||||
sx={{
|
return (
|
||||||
borderRadius: 2,
|
<Box sx={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||||
border: '1px solid var(--md-sys-color-outline-variant)',
|
{/* Header как в iOS */}
|
||||||
backgroundColor: 'var(--md-sys-color-surface)',
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
|
||||||
}}
|
<Typography sx={{ fontSize: 18, fontWeight: 700, color: 'var(--md-sys-color-on-surface)' }}>
|
||||||
>
|
{monthLabel}
|
||||||
<ChevronLeft fontSize="small" />
|
</Typography>
|
||||||
</IconButton>
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={goToday}
|
onClick={goPrevMonth}
|
||||||
size="small"
|
size="small"
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
px: 1.25,
|
border: '1px solid var(--md-sys-color-outline-variant)',
|
||||||
border: '1px solid var(--md-sys-color-outline-variant)',
|
backgroundColor: 'var(--md-sys-color-surface)',
|
||||||
backgroundColor: 'var(--md-sys-color-surface)',
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<ChevronLeft fontSize="small" />
|
||||||
<Typography sx={{ fontSize: 12, fontWeight: 700, color: 'var(--md-sys-color-on-surface)' }}>
|
</IconButton>
|
||||||
Сегодня
|
<IconButton
|
||||||
</Typography>
|
onClick={goToday}
|
||||||
</IconButton>
|
size="small"
|
||||||
<IconButton
|
sx={{
|
||||||
onClick={goNextMonth}
|
borderRadius: 2,
|
||||||
size="small"
|
px: 1.25,
|
||||||
sx={{
|
border: '1px solid var(--md-sys-color-outline-variant)',
|
||||||
borderRadius: 2,
|
backgroundColor: 'var(--md-sys-color-surface)',
|
||||||
border: '1px solid var(--md-sys-color-outline-variant)',
|
}}
|
||||||
backgroundColor: 'var(--md-sys-color-surface)',
|
>
|
||||||
}}
|
<Typography sx={{ fontSize: 12, fontWeight: 700, color: 'var(--md-sys-color-on-surface)' }}>
|
||||||
>
|
Сегодня
|
||||||
<ChevronRight fontSize="small" />
|
</Typography>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
<IconButton
|
||||||
</Box>
|
onClick={goNextMonth}
|
||||||
|
size="small"
|
||||||
{/* Grid */}
|
sx={{
|
||||||
<Box
|
borderRadius: 2,
|
||||||
sx={{
|
border: '1px solid var(--md-sys-color-outline-variant)',
|
||||||
flex: 1,
|
backgroundColor: 'var(--md-sys-color-surface)',
|
||||||
minHeight: 0,
|
}}
|
||||||
borderRadius: 2,
|
>
|
||||||
border: '1px solid var(--md-sys-color-outline-variant)',
|
<ChevronRight fontSize="small" />
|
||||||
backgroundColor: 'var(--md-sys-color-surface)',
|
</IconButton>
|
||||||
overflow: 'hidden',
|
</Box>
|
||||||
display: 'flex',
|
</Box>
|
||||||
flexDirection: 'column',
|
|
||||||
}}
|
{/* Grid */}
|
||||||
>
|
<Box
|
||||||
{/* Weekdays */}
|
sx={{
|
||||||
<Box
|
flex: 1,
|
||||||
sx={{
|
minHeight: 0,
|
||||||
display: 'grid',
|
borderRadius: 2,
|
||||||
gridTemplateColumns: 'repeat(7, 1fr)',
|
border: '1px solid var(--md-sys-color-outline-variant)',
|
||||||
borderBottom: '1px solid var(--md-sys-color-outline-variant)',
|
backgroundColor: 'var(--md-sys-color-surface)',
|
||||||
}}
|
overflow: 'hidden',
|
||||||
>
|
display: 'flex',
|
||||||
{weekdayLabels.map((label, idx) => (
|
flexDirection: 'column',
|
||||||
<Box
|
}}
|
||||||
key={`${label}-${idx}`}
|
>
|
||||||
sx={{
|
{/* Weekdays */}
|
||||||
py: 1,
|
<Box
|
||||||
textAlign: 'center',
|
sx={{
|
||||||
fontSize: 11,
|
display: 'grid',
|
||||||
fontWeight: 800,
|
gridTemplateColumns: 'repeat(7, 1fr)',
|
||||||
letterSpacing: '0.04em',
|
borderBottom: '1px solid var(--md-sys-color-outline-variant)',
|
||||||
color: 'var(--md-sys-color-on-surface-variant)',
|
}}
|
||||||
}}
|
>
|
||||||
>
|
{weekdayLabels.map((label, idx) => (
|
||||||
{label}
|
<Box
|
||||||
</Box>
|
key={`${label}-${idx}`}
|
||||||
))}
|
sx={{
|
||||||
</Box>
|
py: 1,
|
||||||
|
textAlign: 'center',
|
||||||
{/* Days */}
|
fontSize: 11,
|
||||||
<Box
|
fontWeight: 800,
|
||||||
sx={{
|
letterSpacing: '0.04em',
|
||||||
flex: 1,
|
color: 'var(--md-sys-color-on-surface-variant)',
|
||||||
minHeight: 0,
|
}}
|
||||||
display: 'grid',
|
>
|
||||||
gridTemplateColumns: 'repeat(7, 1fr)',
|
{label}
|
||||||
gridTemplateRows: 'repeat(6, minmax(110px, 1fr))',
|
</Box>
|
||||||
// слайдер при переключении месяцев
|
))}
|
||||||
'@keyframes iosCalSlideNext': {
|
</Box>
|
||||||
from: { transform: 'translateX(16px)', opacity: 0.2 },
|
|
||||||
to: { transform: 'translateX(0)', opacity: 1 },
|
{/* Days */}
|
||||||
},
|
<Box
|
||||||
'@keyframes iosCalSlidePrev': {
|
sx={{
|
||||||
from: { transform: 'translateX(-16px)', opacity: 0.2 },
|
flex: 1,
|
||||||
to: { transform: 'translateX(0)', opacity: 1 },
|
minHeight: 0,
|
||||||
},
|
display: 'grid',
|
||||||
'@keyframes iosCalFade': {
|
gridTemplateColumns: 'repeat(7, 1fr)',
|
||||||
from: { opacity: 0.4 },
|
gridTemplateRows: 'repeat(6, minmax(110px, 1fr))',
|
||||||
to: { opacity: 1 },
|
// слайдер при переключении месяцев
|
||||||
},
|
'@keyframes iosCalSlideNext': {
|
||||||
animation:
|
from: { transform: 'translateX(16px)', opacity: 0.2 },
|
||||||
slideDir === 'next'
|
to: { transform: 'translateX(0)', opacity: 1 },
|
||||||
? 'iosCalSlideNext 220ms ease'
|
},
|
||||||
: slideDir === 'prev'
|
'@keyframes iosCalSlidePrev': {
|
||||||
? 'iosCalSlidePrev 220ms ease'
|
from: { transform: 'translateX(-16px)', opacity: 0.2 },
|
||||||
: slideDir === 'today'
|
to: { transform: 'translateX(0)', opacity: 1 },
|
||||||
? 'iosCalFade 180ms ease'
|
},
|
||||||
: 'none',
|
'@keyframes iosCalFade': {
|
||||||
}}
|
from: { opacity: 0.4 },
|
||||||
key={`${currentMonth.getFullYear()}-${currentMonth.getMonth()}`}
|
to: { opacity: 1 },
|
||||||
>
|
},
|
||||||
{daysGrid.map((day) => {
|
animation:
|
||||||
const dayKey = format(startOfDay(day), 'yyyy-MM-dd');
|
slideDir === 'next'
|
||||||
const dayLessons = lessonsByDay.get(dayKey) || [];
|
? 'iosCalSlideNext 220ms ease'
|
||||||
|
: slideDir === 'prev'
|
||||||
const inMonth = isSameMonth(day, currentMonth);
|
? 'iosCalSlidePrev 220ms ease'
|
||||||
const isToday = isSameDay(day, new Date());
|
: slideDir === 'today'
|
||||||
const isSelected = isSameDay(startOfDay(day), safeSelectedDate);
|
? 'iosCalFade 180ms ease'
|
||||||
|
: 'none',
|
||||||
const bg = !inMonth
|
}}
|
||||||
? 'var(--md-sys-color-surface-variant)'
|
key={`${currentMonth.getFullYear()}-${currentMonth.getMonth()}`}
|
||||||
: isSelected
|
>
|
||||||
? 'rgba(116, 68, 253, 0.10)'
|
{daysGrid.map((day) => {
|
||||||
: 'var(--md-sys-color-surface)';
|
const dayKey = format(startOfDay(day), 'yyyy-MM-dd');
|
||||||
|
const dayLessons = lessonsByDay.get(dayKey) || [];
|
||||||
return (
|
|
||||||
<Box
|
const inMonth = isSameMonth(day, currentMonth);
|
||||||
key={dayKey}
|
const isToday = isSameDay(day, new Date());
|
||||||
role="button"
|
const isSelected = isSameDay(startOfDay(day), safeSelectedDate);
|
||||||
tabIndex={0}
|
|
||||||
onClick={() => handleDayClick(day)}
|
const bg = !inMonth
|
||||||
onKeyDown={(e) => {
|
? 'var(--md-sys-color-surface-variant)'
|
||||||
if (e.key === 'Enter' || e.key === ' ') handleDayClick(day);
|
: isSelected
|
||||||
}}
|
? 'rgba(116, 68, 253, 0.10)'
|
||||||
sx={{
|
: 'var(--md-sys-color-surface)';
|
||||||
position: 'relative',
|
|
||||||
minHeight: '110px',
|
return (
|
||||||
px: 1,
|
<Box
|
||||||
pt: 1,
|
key={dayKey}
|
||||||
pb: 0.75,
|
role="button"
|
||||||
borderRight: '1px solid var(--md-sys-color-outline-variant)',
|
tabIndex={0}
|
||||||
borderBottom: '1px solid var(--md-sys-color-outline-variant)',
|
onClick={() => handleDayClick(day)}
|
||||||
backgroundColor: bg,
|
onKeyDown={(e) => {
|
||||||
overflow: 'hidden',
|
if (e.key === 'Enter' || e.key === ' ') handleDayClick(day);
|
||||||
cursor: 'pointer',
|
}}
|
||||||
opacity: inMonth ? 1 : 0.55,
|
sx={{
|
||||||
transition: 'background-color 120ms ease',
|
position: 'relative',
|
||||||
'&:hover': {
|
minHeight: '110px',
|
||||||
backgroundColor: isSelected ? 'rgba(116, 68, 253, 0.13)' : 'rgba(116, 68, 253, 0.06)',
|
px: 1,
|
||||||
},
|
pt: 1,
|
||||||
}}
|
pb: 0.75,
|
||||||
>
|
borderRight: '1px solid var(--md-sys-color-outline-variant)',
|
||||||
{/* Day number */}
|
borderBottom: '1px solid var(--md-sys-color-outline-variant)',
|
||||||
<Box
|
backgroundColor: bg,
|
||||||
sx={{
|
overflow: 'hidden',
|
||||||
position: 'absolute',
|
cursor: 'pointer',
|
||||||
top: 8,
|
opacity: inMonth ? 1 : 0.55,
|
||||||
right: 10,
|
transition: 'background-color 120ms ease',
|
||||||
fontSize: 12,
|
'&:hover': {
|
||||||
fontWeight: 800,
|
backgroundColor: isSelected ? 'rgba(116, 68, 253, 0.13)' : 'rgba(116, 68, 253, 0.06)',
|
||||||
color: isToday ? 'var(--md-sys-color-primary)' : 'var(--md-sys-color-on-surface-variant)',
|
},
|
||||||
lineHeight: 1,
|
}}
|
||||||
}}
|
>
|
||||||
>
|
{/* Day number */}
|
||||||
{format(day, 'd')}
|
<Box
|
||||||
</Box>
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
{/* Lessons (no scroll) */}
|
top: 8,
|
||||||
<Box
|
right: 10,
|
||||||
sx={{
|
fontSize: 12,
|
||||||
mt: 2.5,
|
fontWeight: 800,
|
||||||
display: 'flex',
|
color: isToday ? 'var(--md-sys-color-primary)' : 'var(--md-sys-color-on-surface-variant)',
|
||||||
flexDirection: 'column',
|
lineHeight: 1,
|
||||||
gap: 0.5,
|
}}
|
||||||
height: 'calc(100% - 28px)',
|
>
|
||||||
overflow: 'hidden',
|
{format(day, 'd')}
|
||||||
}}
|
</Box>
|
||||||
>
|
|
||||||
{dayLessons.slice(0, 2).map((lesson) => {
|
{/* Lessons (no scroll) */}
|
||||||
const timeStr = (() => {
|
<Box
|
||||||
try {
|
sx={{
|
||||||
return format(new Date(lesson.start_time), 'HH:mm', { locale: ru });
|
mt: 2.5,
|
||||||
} catch {
|
display: 'flex',
|
||||||
return '';
|
flexDirection: 'column',
|
||||||
}
|
gap: 0.5,
|
||||||
})();
|
height: 'calc(100% - 28px)',
|
||||||
const baseTitle = lesson.client?.first_name || lesson.client?.name || lesson.title;
|
overflow: 'hidden',
|
||||||
const title =
|
}}
|
||||||
baseTitle && baseTitle.length > 18 ? baseTitle.substring(0, 16) + '…' : baseTitle;
|
>
|
||||||
|
{dayLessons.slice(0, 2).map((lesson) => {
|
||||||
return (
|
const timeStr = (() => {
|
||||||
<Box
|
try {
|
||||||
key={lesson.id}
|
// Используем timezone пользователя для отображения времени
|
||||||
onClick={(e) => handleLessonClick(day, lesson, e)}
|
const parsed = parseISOToUserTimezone(lesson.start_time, userTimezone);
|
||||||
sx={{
|
return parsed.time;
|
||||||
px: 0.75,
|
} catch {
|
||||||
py: 0.25,
|
return '';
|
||||||
borderRadius: 1.25,
|
}
|
||||||
backgroundColor: 'rgba(116, 68, 253, 0.14)',
|
})();
|
||||||
color: 'var(--md-sys-color-on-surface)',
|
const baseTitle = lesson.client?.first_name || lesson.client?.name || lesson.title;
|
||||||
fontSize: 11,
|
const title =
|
||||||
fontWeight: 700,
|
baseTitle && baseTitle.length > 18 ? baseTitle.substring(0, 16) + '…' : baseTitle;
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
overflow: 'hidden',
|
return (
|
||||||
textOverflow: 'ellipsis',
|
<Box
|
||||||
border: '1px solid rgba(116, 68, 253, 0.20)',
|
key={lesson.id}
|
||||||
}}
|
onClick={(e) => handleLessonClick(day, lesson, e)}
|
||||||
>
|
sx={{
|
||||||
{timeStr ? `${timeStr} ${title}` : title}
|
px: 0.75,
|
||||||
</Box>
|
py: 0.25,
|
||||||
);
|
borderRadius: 1.25,
|
||||||
})}
|
backgroundColor: 'rgba(116, 68, 253, 0.14)',
|
||||||
|
color: 'var(--md-sys-color-on-surface)',
|
||||||
{dayLessons.length > 2 && (
|
fontSize: 11,
|
||||||
<Box sx={{ mt: 0.25, fontSize: 11, fontWeight: 800, color: 'var(--md-sys-color-primary)' }}>
|
fontWeight: 700,
|
||||||
+ ещё {dayLessons.length - 2}
|
whiteSpace: 'nowrap',
|
||||||
</Box>
|
overflow: 'hidden',
|
||||||
)}
|
textOverflow: 'ellipsis',
|
||||||
</Box>
|
border: '1px solid rgba(116, 68, 253, 0.20)',
|
||||||
</Box>
|
}}
|
||||||
);
|
>
|
||||||
})}
|
{timeStr ? `${timeStr} ${title}` : title}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
);
|
||||||
</Box>
|
})}
|
||||||
);
|
|
||||||
};
|
{dayLessons.length > 2 && (
|
||||||
|
<Box sx={{ mt: 0.25, fontSize: 11, fontWeight: 800, color: 'var(--md-sys-color-primary)' }}>
|
||||||
|
+ ещё {dayLessons.length - 2}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,718 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
updateHomework,
|
||||||
|
publishHomework,
|
||||||
|
type Homework,
|
||||||
|
type HomeworkFileItem,
|
||||||
|
} from '@/api/homework';
|
||||||
|
import { getMyMaterials } from '@/api/materials';
|
||||||
|
import type { Material } from '@/api/materials';
|
||||||
|
import apiClient from '@/lib/api-client';
|
||||||
|
|
||||||
|
const MAX_FILE_SIZE_MB = 10;
|
||||||
|
const MAX_FILES = 10;
|
||||||
|
|
||||||
|
interface EditHomeworkDraftModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
homework: Homework | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileUrl(file: HomeworkFileItem | null): string {
|
||||||
|
if (!file?.file) return '';
|
||||||
|
if (file.file.startsWith('http')) return file.file;
|
||||||
|
const base = typeof window !== 'undefined' ? `${window.location.protocol}//${window.location.hostname}:8123` : '';
|
||||||
|
return file.file.startsWith('/') ? `${base}${file.file}` : `${base}/${file.file}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditHomeworkDraftModal({
|
||||||
|
isOpen,
|
||||||
|
homework,
|
||||||
|
onClose,
|
||||||
|
onSuccess,
|
||||||
|
}: EditHomeworkDraftModalProps) {
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [deadline, setDeadline] = useState('');
|
||||||
|
const [existingFiles, setExistingFiles] = useState<HomeworkFileItem[]>([]);
|
||||||
|
const [newFiles, setNewFiles] = useState<File[]>([]);
|
||||||
|
const [uploadingFiles, setUploadingFiles] = useState<Set<string>>(new Set());
|
||||||
|
const [materials, setMaterials] = useState<Material[]>([]);
|
||||||
|
const [materialsLoading, setMaterialsLoading] = useState(false);
|
||||||
|
const [selectedMaterialIds, setSelectedMaterialIds] = useState<Set<string>>(new Set());
|
||||||
|
const [materialsSearch, setMaterialsSearch] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [publishing, setPublishing] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || !homework) return;
|
||||||
|
setTitle(homework.title || '');
|
||||||
|
setDescription(homework.description || '');
|
||||||
|
setDeadline(homework.deadline ? homework.deadline.slice(0, 16) : '');
|
||||||
|
setExistingFiles(homework.files?.filter(f => f.file_type === 'assignment') || []);
|
||||||
|
setNewFiles([]);
|
||||||
|
setSelectedMaterialIds(new Set());
|
||||||
|
setError(null);
|
||||||
|
}, [isOpen, homework]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
setMaterialsLoading(true);
|
||||||
|
getMyMaterials()
|
||||||
|
.then((list) => setMaterials(Array.isArray(list) ? list : []))
|
||||||
|
.catch(() => setMaterials([]))
|
||||||
|
.finally(() => setMaterialsLoading(false));
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = Array.from(e.target.files || []);
|
||||||
|
if (!files.length || !homework) return;
|
||||||
|
|
||||||
|
const validFiles: File[] = [];
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.size > MAX_FILE_SIZE_MB * 1024 * 1024) {
|
||||||
|
setError(`Файл "${file.name}" больше ${MAX_FILE_SIZE_MB} МБ`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (existingFiles.length + newFiles.length + validFiles.length >= MAX_FILES) {
|
||||||
|
setError(`Максимум ${MAX_FILES} файлов`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
validFiles.push(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of validFiles) {
|
||||||
|
const fileKey = `${file.name}-${Date.now()}`;
|
||||||
|
setUploadingFiles((prev) => new Set(prev).add(fileKey));
|
||||||
|
setNewFiles((prev) => [...prev, file]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('homework', String(homework.id));
|
||||||
|
formData.append('file_type', 'assignment');
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const res = await apiClient.post<HomeworkFileItem>('/homework/files/', formData);
|
||||||
|
setExistingFiles((prev) => [...prev, res.data]);
|
||||||
|
setNewFiles((prev) => prev.filter((f) => f !== file));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Ошибка загрузки файла');
|
||||||
|
setNewFiles((prev) => prev.filter((f) => f !== file));
|
||||||
|
} finally {
|
||||||
|
setUploadingFiles((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(fileKey);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveFile = async (fileId: number) => {
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/homework/files/${fileId}/`);
|
||||||
|
setExistingFiles((prev) => prev.filter((f) => f.id !== fileId));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Ошибка удаления файла');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMaterialToggle = (materialId: string) => {
|
||||||
|
setSelectedMaterialIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(materialId)) {
|
||||||
|
next.delete(materialId);
|
||||||
|
} else {
|
||||||
|
next.add(materialId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const attachMaterialFiles = async () => {
|
||||||
|
if (!homework || selectedMaterialIds.size === 0) return;
|
||||||
|
|
||||||
|
for (const materialId of selectedMaterialIds) {
|
||||||
|
const material = materials.find((m) => String(m.id) === materialId);
|
||||||
|
if (!material?.file) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(material.file);
|
||||||
|
const blob = await response.blob();
|
||||||
|
const filename = material.title || material.file.split('/').pop() || 'material';
|
||||||
|
const file = new File([blob], filename, { type: blob.type });
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('homework', String(homework.id));
|
||||||
|
formData.append('file_type', 'assignment');
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const res = await apiClient.post<HomeworkFileItem>('/homework/files/', formData);
|
||||||
|
setExistingFiles((prev) => [...prev, res.data]);
|
||||||
|
} catch {
|
||||||
|
// Ignore material attach errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSelectedMaterialIds(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!homework) return;
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
setSaving(true);
|
||||||
|
await attachMaterialFiles();
|
||||||
|
await updateHomework(homework.id, {
|
||||||
|
title: title.trim() || homework.title,
|
||||||
|
description: description.trim(),
|
||||||
|
deadline: deadline ? new Date(deadline).toISOString() : null,
|
||||||
|
});
|
||||||
|
onSuccess();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Ошибка сохранения');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePublish = async () => {
|
||||||
|
if (!homework) return;
|
||||||
|
if (!title.trim()) {
|
||||||
|
setError('Укажите название задания');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!description.trim()) {
|
||||||
|
setError('Укажите текст задания');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
setPublishing(true);
|
||||||
|
await attachMaterialFiles();
|
||||||
|
await updateHomework(homework.id, {
|
||||||
|
title: title.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
deadline: deadline ? new Date(deadline).toISOString() : null,
|
||||||
|
});
|
||||||
|
await publishHomework(homework.id);
|
||||||
|
onSuccess();
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Ошибка публикации');
|
||||||
|
} finally {
|
||||||
|
setPublishing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen || !homework) return null;
|
||||||
|
|
||||||
|
const isLoading = saving || publishing || uploadingFiles.size > 0;
|
||||||
|
|
||||||
|
const filteredMaterials = materials.filter((m) => {
|
||||||
|
if (!materialsSearch.trim()) return true;
|
||||||
|
const q = materialsSearch.toLowerCase();
|
||||||
|
return (
|
||||||
|
(m.title || '').toLowerCase().includes(q) ||
|
||||||
|
(m.description || '').toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
background: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
zIndex: 999,
|
||||||
|
}}
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="ios26-panel"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: '90vw',
|
||||||
|
maxWidth: 600,
|
||||||
|
background: 'var(--md-sys-color-surface)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
zIndex: 1001,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 12,
|
||||||
|
padding: '20px 24px',
|
||||||
|
borderBottom: '1px solid var(--md-sys-color-outline-variant)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 600,
|
||||||
|
margin: 0,
|
||||||
|
color: 'var(--md-sys-color-on-surface)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Заполнить домашнее задание
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 12,
|
||||||
|
border: 'none',
|
||||||
|
background: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--md-sys-color-on-surface-variant)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 24 }}>
|
||||||
|
close
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '24px',
|
||||||
|
paddingBottom: 'max(24px, env(safe-area-inset-bottom, 0px) + 100px)',
|
||||||
|
overflowY: 'auto',
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||||
|
{/* Title */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
marginBottom: 8,
|
||||||
|
color: 'var(--md-sys-color-on-surface-variant)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Название задания *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Введите название"
|
||||||
|
disabled={isLoading}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderRadius: 12,
|
||||||
|
border: '1px solid var(--md-sys-color-outline)',
|
||||||
|
background: 'var(--md-sys-color-surface)',
|
||||||
|
fontSize: 15,
|
||||||
|
color: 'var(--md-sys-color-on-surface)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
marginBottom: 8,
|
||||||
|
color: 'var(--md-sys-color-on-surface-variant)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Текст задания *
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
placeholder="Опишите задание, шаги, ссылки..."
|
||||||
|
disabled={isLoading}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderRadius: 12,
|
||||||
|
border: '1px solid var(--md-sys-color-outline)',
|
||||||
|
background: 'var(--md-sys-color-surface)',
|
||||||
|
fontSize: 15,
|
||||||
|
color: 'var(--md-sys-color-on-surface)',
|
||||||
|
resize: 'vertical',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Deadline */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
marginBottom: 8,
|
||||||
|
color: 'var(--md-sys-color-on-surface-variant)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Дедлайн (опционально)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={deadline}
|
||||||
|
onChange={(e) => setDeadline(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
style={{
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderRadius: 12,
|
||||||
|
border: '1px solid var(--md-sys-color-outline)',
|
||||||
|
background: 'var(--md-sys-color-surface)',
|
||||||
|
fontSize: 15,
|
||||||
|
color: 'var(--md-sys-color-on-surface)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Files */}
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
marginBottom: 8,
|
||||||
|
color: 'var(--md-sys-color-on-surface-variant)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Файлы и материалы к ДЗ
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File upload */}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
ref={fileInputRef}
|
||||||
|
className="hidden"
|
||||||
|
id="edit-homework-file"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
disabled={isLoading}
|
||||||
|
accept=".pdf,.doc,.docx,.txt,.jpg,.jpeg,.png,.zip,.rar"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="edit-homework-file"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 8,
|
||||||
|
padding: '14px 20px',
|
||||||
|
borderRadius: 12,
|
||||||
|
border: '2px dashed var(--md-sys-color-outline)',
|
||||||
|
background: 'var(--md-sys-color-surface-variant)',
|
||||||
|
color: 'var(--md-sys-color-on-surface-variant)',
|
||||||
|
fontSize: 14,
|
||||||
|
cursor: isLoading ? 'not-allowed' : 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>
|
||||||
|
upload_file
|
||||||
|
</span>
|
||||||
|
{uploadingFiles.size > 0
|
||||||
|
? `Загрузка ${uploadingFiles.size}…`
|
||||||
|
: 'Загрузить файлы'}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Existing files */}
|
||||||
|
{existingFiles.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 8,
|
||||||
|
marginTop: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{existingFiles.map((file) => {
|
||||||
|
const url = getFileUrl(file);
|
||||||
|
const isImage = /\.(jpe?g|png|gif|webp|bmp)$/i.test(
|
||||||
|
file.filename || ''
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={file.id}
|
||||||
|
style={{
|
||||||
|
width: 80,
|
||||||
|
aspectRatio: '1',
|
||||||
|
borderRadius: 12,
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: '2px solid var(--md-sys-color-outline-variant)',
|
||||||
|
background: 'var(--md-sys-color-surface-variant)',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isImage && url ? (
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt=""
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined"
|
||||||
|
style={{
|
||||||
|
fontSize: 28,
|
||||||
|
color: 'var(--md-sys-color-primary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
description
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
fontSize: 10,
|
||||||
|
padding: 4,
|
||||||
|
background: 'rgba(0,0,0,0.6)',
|
||||||
|
color: '#fff',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{file.filename}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemoveFile(file.id)}
|
||||||
|
disabled={isLoading}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 2,
|
||||||
|
right: 2,
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: '50%',
|
||||||
|
border: 'none',
|
||||||
|
background: 'var(--md-sys-color-error)',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 14,
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Materials */}
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
marginBottom: 8,
|
||||||
|
color: 'var(--md-sys-color-on-surface-variant)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Прикрепить из моих материалов
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={materialsSearch}
|
||||||
|
onChange={(e) => setMaterialsSearch(e.target.value)}
|
||||||
|
placeholder="Поиск материалов..."
|
||||||
|
disabled={isLoading}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '10px 14px',
|
||||||
|
borderRadius: 10,
|
||||||
|
border: '1px solid var(--md-sys-color-outline)',
|
||||||
|
background: 'var(--md-sys-color-surface)',
|
||||||
|
fontSize: 14,
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{materialsLoading ? (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: 'var(--md-sys-color-on-surface-variant)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Загрузка материалов…
|
||||||
|
</p>
|
||||||
|
) : filteredMaterials.length === 0 ? (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: 'var(--md-sys-color-on-surface-variant)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Нет материалов
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 8,
|
||||||
|
maxHeight: 160,
|
||||||
|
overflowY: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filteredMaterials.slice(0, 20).map((m) => {
|
||||||
|
const materialId = String(m.id);
|
||||||
|
const isSelected = selectedMaterialIds.has(materialId);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={materialId}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleMaterialToggle(materialId)}
|
||||||
|
disabled={isLoading}
|
||||||
|
style={{
|
||||||
|
padding: '8px 14px',
|
||||||
|
borderRadius: 10,
|
||||||
|
border: `2px solid ${
|
||||||
|
isSelected
|
||||||
|
? 'var(--md-sys-color-primary)'
|
||||||
|
: 'var(--md-sys-color-outline-variant)'
|
||||||
|
}`,
|
||||||
|
background: isSelected
|
||||||
|
? 'var(--md-sys-color-primary-container)'
|
||||||
|
: 'var(--md-sys-color-surface-variant)',
|
||||||
|
color: isSelected
|
||||||
|
? 'var(--md-sys-color-on-primary-container)'
|
||||||
|
: 'var(--md-sys-color-on-surface)',
|
||||||
|
fontSize: 13,
|
||||||
|
cursor: 'pointer',
|
||||||
|
maxWidth: 200,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{m.title || 'Без названия'}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: 16,
|
||||||
|
background: 'rgba(186,26,26,0.1)',
|
||||||
|
borderRadius: 12,
|
||||||
|
color: 'var(--md-sys-color-error)',
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 12,
|
||||||
|
paddingTop: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handlePublish}
|
||||||
|
disabled={isLoading}
|
||||||
|
style={{
|
||||||
|
padding: '14px 28px',
|
||||||
|
borderRadius: 14,
|
||||||
|
border: 'none',
|
||||||
|
background: 'var(--md-sys-color-primary)',
|
||||||
|
color: 'var(--md-sys-color-on-primary)',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: isLoading ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isLoading ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{publishing ? 'Публикация...' : 'Опубликовать ДЗ'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isLoading}
|
||||||
|
style={{
|
||||||
|
padding: '14px 28px',
|
||||||
|
borderRadius: 14,
|
||||||
|
border: '1px solid var(--md-sys-color-outline)',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--md-sys-color-on-surface)',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: isLoading ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isLoading ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{saving ? 'Сохранение...' : 'Сохранить черновик'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
} from '@/api/homework';
|
} from '@/api/homework';
|
||||||
import { getBackendOrigin } from '@/lib/api-client';
|
import { getBackendOrigin } from '@/lib/api-client';
|
||||||
import { SubmitHomeworkModal } from './SubmitHomeworkModal';
|
import { SubmitHomeworkModal } from './SubmitHomeworkModal';
|
||||||
|
import { EditHomeworkDraftModal } from './EditHomeworkDraftModal';
|
||||||
|
|
||||||
interface HomeworkDetailsModalProps {
|
interface HomeworkDetailsModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -303,6 +304,9 @@ export function HomeworkDetailsModal({ isOpen, homework, userRole, childId, onCl
|
||||||
const [imagePreviewUrl, setImagePreviewUrl] = useState<string | null>(null);
|
const [imagePreviewUrl, setImagePreviewUrl] = useState<string | null>(null);
|
||||||
const [documentViewer, setDocumentViewer] = useState<{ url: string; filename: string; type: 'pdf' | 'text' } | null>(null);
|
const [documentViewer, setDocumentViewer] = useState<{ url: string; filename: string; type: 'pdf' | 'text' } | null>(null);
|
||||||
|
|
||||||
|
// Модальное окно редактирования черновика ДЗ
|
||||||
|
const [editDraftOpen, setEditDraftOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen || !homework) return;
|
if (!isOpen || !homework) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
@ -484,6 +488,39 @@ export function HomeworkDetailsModal({ isOpen, homework, userRole, childId, onCl
|
||||||
flex: 1,
|
flex: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Черновик fill_later — показываем кнопку редактирования */}
|
||||||
|
{userRole === 'mentor' && homework.fill_later && (
|
||||||
|
<div style={{ marginBottom: 28, padding: 20, background: 'var(--md-sys-color-tertiary-container)', borderRadius: 16 }}>
|
||||||
|
<h4 style={{ fontSize: 16, fontWeight: 600, marginBottom: 12, color: 'var(--md-sys-color-on-tertiary-container)' }}>
|
||||||
|
Черновик — требуется заполнение
|
||||||
|
</h4>
|
||||||
|
<p style={{ fontSize: 14, color: 'var(--md-sys-color-on-tertiary-container)', marginBottom: 16, opacity: 0.8 }}>
|
||||||
|
Это домашнее задание было создано с пометкой «заполнить позже». Заполните детали задания и опубликуйте его для студента.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditDraftOpen(true)}
|
||||||
|
style={{
|
||||||
|
padding: '14px 28px',
|
||||||
|
borderRadius: 14,
|
||||||
|
border: 'none',
|
||||||
|
background: 'var(--md-sys-color-primary)',
|
||||||
|
color: 'var(--md-sys-color-on-primary)',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>edit</span>
|
||||||
|
Заполнить задание
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Обычное отображение для опубликованных заданий */}
|
||||||
{homework.description && (
|
{homework.description && (
|
||||||
<div style={{ marginBottom: 28 }}>
|
<div style={{ marginBottom: 28 }}>
|
||||||
<h4 style={{ fontSize: 16, fontWeight: 600, marginBottom: 12 }}>Описание</h4>
|
<h4 style={{ fontSize: 16, fontWeight: 600, marginBottom: 12 }}>Описание</h4>
|
||||||
|
|
@ -1358,6 +1395,16 @@ export function HomeworkDetailsModal({ isOpen, homework, userRole, childId, onCl
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<EditHomeworkDraftModal
|
||||||
|
isOpen={editDraftOpen}
|
||||||
|
homework={homework}
|
||||||
|
onClose={() => setEditDraftOpen(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
setEditDraftOpen(false);
|
||||||
|
onSuccess();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,327 +1,339 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import { completeLesson, type Lesson } from '@/api/schedule';
|
import { completeLesson, type Lesson } from '@/api/schedule';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
interface FeedbackModalProps {
|
import { parseISOToUserTimezone } from '@/utils/timezone';
|
||||||
isOpen: boolean;
|
|
||||||
lesson: Lesson | null;
|
interface FeedbackModalProps {
|
||||||
onClose: () => void;
|
isOpen: boolean;
|
||||||
onSuccess: () => void;
|
lesson: Lesson | null;
|
||||||
}
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
export function FeedbackModal({ isOpen, lesson, onClose, onSuccess }: FeedbackModalProps) {
|
}
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
mentor_grade: '',
|
export function FeedbackModal({ isOpen, lesson, onClose, onSuccess }: FeedbackModalProps) {
|
||||||
school_grade: '',
|
const { user } = useAuth();
|
||||||
mentor_notes: '',
|
const [formData, setFormData] = useState({
|
||||||
});
|
mentor_grade: '',
|
||||||
const [loading, setLoading] = useState(false);
|
school_grade: '',
|
||||||
const [error, setError] = useState<string | null>(null);
|
mentor_notes: '',
|
||||||
|
});
|
||||||
useEffect(() => {
|
const [loading, setLoading] = useState(false);
|
||||||
if (isOpen && lesson) {
|
const [error, setError] = useState<string | null>(null);
|
||||||
setFormData({
|
|
||||||
mentor_grade: lesson.mentor_grade?.toString() || '',
|
// Парсим время с учётом timezone пользователя
|
||||||
school_grade: lesson.school_grade?.toString() || '',
|
const parsedTimes = useMemo(() => {
|
||||||
mentor_notes: lesson.mentor_notes || '',
|
if (!lesson) return null;
|
||||||
});
|
return {
|
||||||
}
|
start: parseISOToUserTimezone(lesson.start_time, user?.timezone),
|
||||||
}, [isOpen, lesson]);
|
end: parseISOToUserTimezone(lesson.end_time, user?.timezone),
|
||||||
|
};
|
||||||
if (!lesson) return null;
|
}, [lesson, user?.timezone]);
|
||||||
|
|
||||||
const visible = isOpen;
|
useEffect(() => {
|
||||||
|
if (isOpen && lesson) {
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
setFormData({
|
||||||
e.preventDefault();
|
mentor_grade: lesson.mentor_grade?.toString() || '',
|
||||||
setError(null);
|
school_grade: lesson.school_grade?.toString() || '',
|
||||||
setLoading(true);
|
mentor_notes: lesson.mentor_notes || '',
|
||||||
try {
|
});
|
||||||
await completeLesson(
|
}
|
||||||
lesson.id,
|
}, [isOpen, lesson]);
|
||||||
formData.mentor_notes.trim(),
|
|
||||||
formData.mentor_grade ? parseInt(formData.mentor_grade) : undefined,
|
if (!lesson || !parsedTimes) return null;
|
||||||
formData.school_grade ? parseInt(formData.school_grade) : undefined
|
|
||||||
);
|
const visible = isOpen;
|
||||||
onSuccess();
|
|
||||||
onClose();
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
} catch (err: unknown) {
|
e.preventDefault();
|
||||||
const errMsg = err instanceof Error ? err.message : 'Ошибка сохранения обратной связи';
|
setError(null);
|
||||||
setError(String(errMsg));
|
setLoading(true);
|
||||||
} finally {
|
try {
|
||||||
setLoading(false);
|
await completeLesson(
|
||||||
}
|
lesson.id,
|
||||||
};
|
formData.mentor_notes.trim(),
|
||||||
|
formData.mentor_grade ? parseInt(formData.mentor_grade) : undefined,
|
||||||
const handleClose = () => {
|
formData.school_grade ? parseInt(formData.school_grade) : undefined
|
||||||
if (!loading) {
|
);
|
||||||
setError(null);
|
onSuccess();
|
||||||
onClose();
|
onClose();
|
||||||
}
|
} catch (err: unknown) {
|
||||||
};
|
const errMsg = err instanceof Error ? err.message : 'Ошибка сохранения обратной связи';
|
||||||
|
setError(String(errMsg));
|
||||||
const clientName =
|
} finally {
|
||||||
typeof lesson.client === 'object' && lesson.client?.user
|
setLoading(false);
|
||||||
? `${lesson.client.user.first_name} ${lesson.client.user.last_name}`
|
}
|
||||||
: (lesson as { client_name?: string }).client_name || 'Студент';
|
};
|
||||||
|
|
||||||
const subjectName =
|
const handleClose = () => {
|
||||||
typeof lesson.subject === 'string'
|
if (!loading) {
|
||||||
? lesson.subject
|
setError(null);
|
||||||
: (lesson.subject as { name?: string } | null | undefined)?.name || 'Занятие';
|
onClose();
|
||||||
|
}
|
||||||
return (
|
};
|
||||||
<>
|
|
||||||
{/* Затемнённый фон — клик закрывает панель */}
|
const clientName =
|
||||||
<div
|
typeof lesson.client === 'object' && lesson.client?.user
|
||||||
role="presentation"
|
? `${lesson.client.user.first_name} ${lesson.client.user.last_name}`
|
||||||
style={{
|
: (lesson as { client_name?: string }).client_name || 'Студент';
|
||||||
position: 'fixed',
|
|
||||||
inset: 0,
|
const subjectName =
|
||||||
background: 'rgba(0,0,0,0.4)',
|
typeof lesson.subject === 'string'
|
||||||
zIndex: 999,
|
? lesson.subject
|
||||||
opacity: visible ? 1 : 0,
|
: (lesson.subject as { name?: string } | null | undefined)?.name || 'Занятие';
|
||||||
pointerEvents: visible ? 'auto' : 'none',
|
|
||||||
transition: 'opacity 0.25s ease',
|
return (
|
||||||
}}
|
<>
|
||||||
onClick={handleClose}
|
{/* Затемнённый фон — клик закрывает панель */}
|
||||||
/>
|
<div
|
||||||
{/* Панель справа */}
|
role="presentation"
|
||||||
<div
|
style={{
|
||||||
className="ios26-panel"
|
position: 'fixed',
|
||||||
style={{
|
inset: 0,
|
||||||
position: 'fixed',
|
background: 'rgba(0,0,0,0.4)',
|
||||||
top: 0,
|
zIndex: 999,
|
||||||
right: 0,
|
opacity: visible ? 1 : 0,
|
||||||
bottom: 0,
|
pointerEvents: visible ? 'auto' : 'none',
|
||||||
width: '100%',
|
transition: 'opacity 0.25s ease',
|
||||||
maxWidth: 480,
|
}}
|
||||||
background: 'var(--md-sys-color-surface)',
|
onClick={handleClose}
|
||||||
boxShadow: '-4px 0 24px rgba(0,0,0,0.15)',
|
/>
|
||||||
overflow: 'hidden',
|
{/* Панель справа */}
|
||||||
display: 'flex',
|
<div
|
||||||
flexDirection: 'column',
|
className="ios26-panel"
|
||||||
zIndex: 1000,
|
style={{
|
||||||
transform: visible ? 'translateX(0)' : 'translateX(100%)',
|
position: 'fixed',
|
||||||
transition: 'transform 0.3s ease',
|
top: 0,
|
||||||
}}
|
right: 0,
|
||||||
>
|
bottom: 0,
|
||||||
<div
|
width: '100%',
|
||||||
style={{
|
maxWidth: 480,
|
||||||
display: 'flex',
|
background: 'var(--md-sys-color-surface)',
|
||||||
alignItems: 'flex-start',
|
boxShadow: '-4px 0 24px rgba(0,0,0,0.15)',
|
||||||
justifyContent: 'space-between',
|
overflow: 'hidden',
|
||||||
padding: 24,
|
display: 'flex',
|
||||||
borderBottom: '1px solid var(--md-sys-color-outline-variant)',
|
flexDirection: 'column',
|
||||||
flexShrink: 0,
|
zIndex: 1000,
|
||||||
}}
|
transform: visible ? 'translateX(0)' : 'translateX(100%)',
|
||||||
>
|
transition: 'transform 0.3s ease',
|
||||||
<div style={{ minWidth: 0, flex: 1 }}>
|
}}
|
||||||
<h2 style={{ fontSize: 20, fontWeight: 600, color: 'var(--md-sys-color-on-surface)', margin: 0 }}>
|
>
|
||||||
Обратная связь
|
<div
|
||||||
</h2>
|
style={{
|
||||||
<p style={{ fontSize: 14, color: 'var(--md-sys-color-on-surface-variant)', marginTop: 4 }}>
|
display: 'flex',
|
||||||
{lesson.title} — {subjectName}
|
alignItems: 'flex-start',
|
||||||
</p>
|
justifyContent: 'space-between',
|
||||||
</div>
|
padding: 24,
|
||||||
<button
|
borderBottom: '1px solid var(--md-sys-color-outline-variant)',
|
||||||
type="button"
|
flexShrink: 0,
|
||||||
onClick={handleClose}
|
}}
|
||||||
disabled={loading}
|
>
|
||||||
style={{
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
background: 'none',
|
<h2 style={{ fontSize: 20, fontWeight: 600, color: 'var(--md-sys-color-on-surface)', margin: 0 }}>
|
||||||
border: 'none',
|
Обратная связь
|
||||||
cursor: loading ? 'not-allowed' : 'pointer',
|
</h2>
|
||||||
padding: 8,
|
<p style={{ fontSize: 14, color: 'var(--md-sys-color-on-surface-variant)', marginTop: 4 }}>
|
||||||
color: 'var(--md-sys-color-on-surface-variant)',
|
{lesson.title} — {subjectName}
|
||||||
flexShrink: 0,
|
</p>
|
||||||
}}
|
</div>
|
||||||
>
|
<button
|
||||||
<span className="material-symbols-outlined">close</span>
|
type="button"
|
||||||
</button>
|
onClick={handleClose}
|
||||||
</div>
|
disabled={loading}
|
||||||
|
style={{
|
||||||
<form onSubmit={handleSubmit} style={{ padding: 24, overflowY: 'auto', flex: 1 }}>
|
background: 'none',
|
||||||
<div
|
border: 'none',
|
||||||
style={{
|
cursor: loading ? 'not-allowed' : 'pointer',
|
||||||
background: 'var(--md-sys-color-primary-container)',
|
padding: 8,
|
||||||
borderRadius: 12,
|
color: 'var(--md-sys-color-on-surface-variant)',
|
||||||
padding: 16,
|
flexShrink: 0,
|
||||||
marginBottom: 20,
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<span className="material-symbols-outlined">close</span>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, fontSize: 14 }}>
|
</button>
|
||||||
<div>
|
</div>
|
||||||
<span style={{ color: 'var(--md-sys-color-on-surface-variant)' }}>Дата: </span>
|
|
||||||
<span style={{ color: 'var(--md-sys-color-on-surface)', fontWeight: 500 }}>
|
<form onSubmit={handleSubmit} style={{ padding: 24, overflowY: 'auto', flex: 1 }}>
|
||||||
{new Date(lesson.start_time).toLocaleDateString('ru-RU')}
|
<div
|
||||||
</span>
|
style={{
|
||||||
</div>
|
background: 'var(--md-sys-color-primary-container)',
|
||||||
<div>
|
borderRadius: 12,
|
||||||
<span style={{ color: 'var(--md-sys-color-on-surface-variant)' }}>Время: </span>
|
padding: 16,
|
||||||
<span style={{ color: 'var(--md-sys-color-on-surface)', fontWeight: 500 }}>
|
marginBottom: 20,
|
||||||
{new Date(lesson.start_time).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
|
}}
|
||||||
{' — '}
|
>
|
||||||
{new Date(lesson.end_time).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, fontSize: 14 }}>
|
||||||
</span>
|
<div>
|
||||||
</div>
|
<span style={{ color: 'var(--md-sys-color-on-surface-variant)' }}>Дата: </span>
|
||||||
<div>
|
<span style={{ color: 'var(--md-sys-color-on-surface)', fontWeight: 500 }}>
|
||||||
<span style={{ color: 'var(--md-sys-color-on-surface-variant)' }}>Студент: </span>
|
{parsedTimes.start.dateObj.toLocaleDateString('ru-RU')}
|
||||||
<span style={{ color: 'var(--md-sys-color-on-surface)', fontWeight: 500 }}>{clientName}</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span style={{ color: 'var(--md-sys-color-on-surface-variant)' }}>Длительность: </span>
|
<span style={{ color: 'var(--md-sys-color-on-surface-variant)' }}>Время: </span>
|
||||||
<span style={{ color: 'var(--md-sys-color-on-surface)', fontWeight: 500 }}>
|
<span style={{ color: 'var(--md-sys-color-on-surface)', fontWeight: 500 }}>
|
||||||
{(lesson as { duration?: number }).duration || 60} мин
|
{parsedTimes.start.time}
|
||||||
</span>
|
{' — '}
|
||||||
</div>
|
{parsedTimes.end.time}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
<div style={{ marginBottom: 20 }}>
|
<span style={{ color: 'var(--md-sys-color-on-surface-variant)' }}>Студент: </span>
|
||||||
<h3 style={{ fontSize: 16, fontWeight: 600, marginBottom: 12, color: 'var(--md-sys-color-on-surface)' }}>
|
<span style={{ color: 'var(--md-sys-color-on-surface)', fontWeight: 500 }}>{clientName}</span>
|
||||||
Оценки
|
</div>
|
||||||
</h3>
|
<div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
<span style={{ color: 'var(--md-sys-color-on-surface-variant)' }}>Длительность: </span>
|
||||||
<div>
|
<span style={{ color: 'var(--md-sys-color-on-surface)', fontWeight: 500 }}>
|
||||||
<label
|
{(lesson as { duration?: number }).duration || 60} мин
|
||||||
htmlFor="mentor_grade"
|
</span>
|
||||||
style={{ display: 'block', fontSize: 13, fontWeight: 500, color: 'var(--md-sys-color-on-surface-variant)', marginBottom: 6 }}
|
</div>
|
||||||
>
|
</div>
|
||||||
Оценка за занятие (1–5)
|
</div>
|
||||||
</label>
|
|
||||||
<input
|
<div style={{ marginBottom: 20 }}>
|
||||||
id="mentor_grade"
|
<h3 style={{ fontSize: 16, fontWeight: 600, marginBottom: 12, color: 'var(--md-sys-color-on-surface)' }}>
|
||||||
type="number"
|
Оценки
|
||||||
min={1}
|
</h3>
|
||||||
max={5}
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||||||
value={formData.mentor_grade}
|
<div>
|
||||||
onChange={(e) => setFormData((p) => ({ ...p, mentor_grade: e.target.value }))}
|
<label
|
||||||
style={{
|
htmlFor="mentor_grade"
|
||||||
width: '100%',
|
style={{ display: 'block', fontSize: 13, fontWeight: 500, color: 'var(--md-sys-color-on-surface-variant)', marginBottom: 6 }}
|
||||||
padding: '12px 16px',
|
>
|
||||||
borderRadius: 12,
|
Оценка за занятие (1–5)
|
||||||
border: '1px solid var(--md-sys-color-outline)',
|
</label>
|
||||||
background: 'var(--md-sys-color-surface)',
|
<input
|
||||||
fontSize: 15,
|
id="mentor_grade"
|
||||||
color: 'var(--md-sys-color-on-surface)',
|
type="number"
|
||||||
}}
|
min={1}
|
||||||
placeholder="5"
|
max={5}
|
||||||
disabled={loading}
|
value={formData.mentor_grade}
|
||||||
/>
|
onChange={(e) => setFormData((p) => ({ ...p, mentor_grade: e.target.value }))}
|
||||||
</div>
|
style={{
|
||||||
<div>
|
width: '100%',
|
||||||
<label
|
padding: '12px 16px',
|
||||||
htmlFor="school_grade"
|
borderRadius: 12,
|
||||||
style={{ display: 'block', fontSize: 13, fontWeight: 500, color: 'var(--md-sys-color-on-surface-variant)', marginBottom: 6 }}
|
border: '1px solid var(--md-sys-color-outline)',
|
||||||
>
|
background: 'var(--md-sys-color-surface)',
|
||||||
Оценка в школе (1–5)
|
fontSize: 15,
|
||||||
</label>
|
color: 'var(--md-sys-color-on-surface)',
|
||||||
<input
|
}}
|
||||||
id="school_grade"
|
placeholder="5"
|
||||||
type="number"
|
disabled={loading}
|
||||||
min={1}
|
/>
|
||||||
max={5}
|
</div>
|
||||||
value={formData.school_grade}
|
<div>
|
||||||
onChange={(e) => setFormData((p) => ({ ...p, school_grade: e.target.value }))}
|
<label
|
||||||
style={{
|
htmlFor="school_grade"
|
||||||
width: '100%',
|
style={{ display: 'block', fontSize: 13, fontWeight: 500, color: 'var(--md-sys-color-on-surface-variant)', marginBottom: 6 }}
|
||||||
padding: '12px 16px',
|
>
|
||||||
borderRadius: 12,
|
Оценка в школе (1–5)
|
||||||
border: '1px solid var(--md-sys-color-outline)',
|
</label>
|
||||||
background: 'var(--md-sys-color-surface)',
|
<input
|
||||||
fontSize: 15,
|
id="school_grade"
|
||||||
color: 'var(--md-sys-color-on-surface)',
|
type="number"
|
||||||
}}
|
min={1}
|
||||||
placeholder="4"
|
max={5}
|
||||||
disabled={loading}
|
value={formData.school_grade}
|
||||||
/>
|
onChange={(e) => setFormData((p) => ({ ...p, school_grade: e.target.value }))}
|
||||||
</div>
|
style={{
|
||||||
</div>
|
width: '100%',
|
||||||
</div>
|
padding: '12px 16px',
|
||||||
|
borderRadius: 12,
|
||||||
<div style={{ marginBottom: 20 }}>
|
border: '1px solid var(--md-sys-color-outline)',
|
||||||
<label
|
background: 'var(--md-sys-color-surface)',
|
||||||
htmlFor="mentor_notes"
|
fontSize: 15,
|
||||||
style={{ display: 'block', fontSize: 13, fontWeight: 500, color: 'var(--md-sys-color-on-surface-variant)', marginBottom: 6 }}
|
color: 'var(--md-sys-color-on-surface)',
|
||||||
>
|
}}
|
||||||
Комментарий к занятию
|
placeholder="4"
|
||||||
</label>
|
disabled={loading}
|
||||||
<textarea
|
/>
|
||||||
id="mentor_notes"
|
</div>
|
||||||
value={formData.mentor_notes}
|
</div>
|
||||||
onChange={(e) => setFormData((p) => ({ ...p, mentor_notes: e.target.value }))}
|
</div>
|
||||||
rows={4}
|
|
||||||
style={{
|
<div style={{ marginBottom: 20 }}>
|
||||||
width: '100%',
|
<label
|
||||||
padding: '12px 16px',
|
htmlFor="mentor_notes"
|
||||||
borderRadius: 12,
|
style={{ display: 'block', fontSize: 13, fontWeight: 500, color: 'var(--md-sys-color-on-surface-variant)', marginBottom: 6 }}
|
||||||
border: '1px solid var(--md-sys-color-outline)',
|
>
|
||||||
background: 'var(--md-sys-color-surface)',
|
Комментарий к занятию
|
||||||
fontSize: 15,
|
</label>
|
||||||
color: 'var(--md-sys-color-on-surface)',
|
<textarea
|
||||||
resize: 'vertical',
|
id="mentor_notes"
|
||||||
}}
|
value={formData.mentor_notes}
|
||||||
placeholder="Что прошли на занятии, успехи студента, рекомендации..."
|
onChange={(e) => setFormData((p) => ({ ...p, mentor_notes: e.target.value }))}
|
||||||
disabled={loading}
|
rows={4}
|
||||||
/>
|
style={{
|
||||||
</div>
|
width: '100%',
|
||||||
|
padding: '12px 16px',
|
||||||
{error && (
|
borderRadius: 12,
|
||||||
<div
|
border: '1px solid var(--md-sys-color-outline)',
|
||||||
style={{
|
background: 'var(--md-sys-color-surface)',
|
||||||
background: 'rgba(186,26,26,0.1)',
|
fontSize: 15,
|
||||||
border: '1px solid var(--md-sys-color-error)',
|
color: 'var(--md-sys-color-on-surface)',
|
||||||
borderRadius: 12,
|
resize: 'vertical',
|
||||||
padding: 12,
|
}}
|
||||||
marginBottom: 20,
|
placeholder="Что прошли на занятии, успехи студента, рекомендации..."
|
||||||
}}
|
disabled={loading}
|
||||||
>
|
/>
|
||||||
<p style={{ fontSize: 14, color: 'var(--md-sys-color-error)' }}>{error}</p>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
{error && (
|
||||||
|
<div
|
||||||
<div style={{ display: 'flex', gap: 12 }}>
|
style={{
|
||||||
<button
|
background: 'rgba(186,26,26,0.1)',
|
||||||
type="button"
|
border: '1px solid var(--md-sys-color-error)',
|
||||||
onClick={handleClose}
|
borderRadius: 12,
|
||||||
disabled={loading}
|
padding: 12,
|
||||||
style={{
|
marginBottom: 20,
|
||||||
flex: 1,
|
}}
|
||||||
padding: '14px 24px',
|
>
|
||||||
borderRadius: 14,
|
<p style={{ fontSize: 14, color: 'var(--md-sys-color-error)' }}>{error}</p>
|
||||||
border: '1px solid var(--md-sys-color-outline)',
|
</div>
|
||||||
background: 'transparent',
|
)}
|
||||||
color: 'var(--md-sys-color-on-surface-variant)',
|
|
||||||
fontSize: 15,
|
<div style={{ display: 'flex', gap: 12 }}>
|
||||||
fontWeight: 500,
|
<button
|
||||||
cursor: loading ? 'not-allowed' : 'pointer',
|
type="button"
|
||||||
}}
|
onClick={handleClose}
|
||||||
>
|
disabled={loading}
|
||||||
Отмена
|
style={{
|
||||||
</button>
|
flex: 1,
|
||||||
<button
|
padding: '14px 24px',
|
||||||
type="submit"
|
borderRadius: 14,
|
||||||
disabled={loading}
|
border: '1px solid var(--md-sys-color-outline)',
|
||||||
style={{
|
background: 'transparent',
|
||||||
flex: 1,
|
color: 'var(--md-sys-color-on-surface-variant)',
|
||||||
padding: '14px 24px',
|
fontSize: 15,
|
||||||
borderRadius: 14,
|
fontWeight: 500,
|
||||||
border: 'none',
|
cursor: loading ? 'not-allowed' : 'pointer',
|
||||||
background: 'var(--md-sys-color-primary)',
|
}}
|
||||||
color: 'var(--md-sys-color-on-primary)',
|
>
|
||||||
fontSize: 15,
|
Отмена
|
||||||
fontWeight: 600,
|
</button>
|
||||||
cursor: loading ? 'not-allowed' : 'pointer',
|
<button
|
||||||
opacity: loading ? 0.7 : 1,
|
type="submit"
|
||||||
}}
|
disabled={loading}
|
||||||
>
|
style={{
|
||||||
{loading ? 'Сохранение...' : 'Сохранить'}
|
flex: 1,
|
||||||
</button>
|
padding: '14px 24px',
|
||||||
</div>
|
borderRadius: 14,
|
||||||
</form>
|
border: 'none',
|
||||||
</div>
|
background: 'var(--md-sys-color-primary)',
|
||||||
</>
|
color: 'var(--md-sys-color-on-primary)',
|
||||||
);
|
fontSize: 15,
|
||||||
}
|
fontWeight: 600,
|
||||||
|
cursor: loading ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: loading ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? 'Сохранение...' : 'Сохранить'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
export const MOBILE_BREAKPOINT = 767;
|
export const MOBILE_BREAKPOINT = 767;
|
||||||
|
|
||||||
export function useIsMobile(breakpoint: number = MOBILE_BREAKPOINT) {
|
export function useIsMobile(breakpoint: number = MOBILE_BREAKPOINT) {
|
||||||
const [isMobile, setIsMobile] = useState(false);
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const mq = window.matchMedia(`(max-width: ${breakpoint}px)`);
|
const mq = window.matchMedia(`(max-width: ${breakpoint}px)`);
|
||||||
setIsMobile(mq.matches);
|
setIsMobile(mq.matches);
|
||||||
const listener = () => setIsMobile(mq.matches);
|
const listener = () => setIsMobile(mq.matches);
|
||||||
mq.addEventListener('change', listener);
|
mq.addEventListener('change', listener);
|
||||||
return () => mq.removeEventListener('change', listener);
|
return () => mq.removeEventListener('change', listener);
|
||||||
}, [breakpoint]);
|
}, [breakpoint]);
|
||||||
return isMobile;
|
return isMobile;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -1,4 +1,4 @@
|
||||||
<svg width="512" height="512" viewBox="0 8 130 130" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="512" height="512" viewBox="0 8 130 130" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<rect y="8" width="130" height="130" rx="20" fill="#7444FD"/>
|
<rect y="8" width="130" height="130" rx="20" fill="#7444FD"/>
|
||||||
<path d="M31.8908 90.7V38H46.1708V87.555C46.1708 91.8617 47.2192 95.7717 49.3158 99.285C51.4125 102.798 54.2175 105.603 57.7308 107.7C61.3008 109.74 65.2108 110.76 69.4608 110.76C73.7675 110.76 77.6492 109.74 81.1058 107.7C84.6192 105.603 87.4242 102.798 89.5208 99.285C91.6175 95.7717 92.6658 91.8617 92.6658 87.555V38H106.946L107.031 123H92.7508L92.6658 112.205C89.6625 116.172 85.8658 119.345 81.2758 121.725C76.6858 124.048 71.7275 125.21 66.4008 125.21C60.0542 125.21 54.2458 123.68 48.9758 120.62C43.7625 117.503 39.5975 113.338 36.4808 108.125C33.4208 102.912 31.8908 97.1033 31.8908 90.7Z" fill="white"/>
|
<path d="M31.8908 90.7V38H46.1708V87.555C46.1708 91.8617 47.2192 95.7717 49.3158 99.285C51.4125 102.798 54.2175 105.603 57.7308 107.7C61.3008 109.74 65.2108 110.76 69.4608 110.76C73.7675 110.76 77.6492 109.74 81.1058 107.7C84.6192 105.603 87.4242 102.798 89.5208 99.285C91.6175 95.7717 92.6658 91.8617 92.6658 87.555V38H106.946L107.031 123H92.7508L92.6658 112.205C89.6625 116.172 85.8658 119.345 81.2758 121.725C76.6858 124.048 71.7275 125.21 66.4008 125.21C60.0542 125.21 54.2458 123.68 48.9758 120.62C43.7625 117.503 39.5975 113.338 36.4808 108.125C33.4208 102.912 31.8908 97.1033 31.8908 90.7Z" fill="white"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 782 B After Width: | Height: | Size: 786 B |
|
|
@ -1,19 +1,19 @@
|
||||||
{
|
{
|
||||||
"name": "Uchill Platform",
|
"name": "Uchill Platform",
|
||||||
"short_name": "Uchill",
|
"short_name": "Uchill",
|
||||||
"description": "Образовательная платформа",
|
"description": "Образовательная платформа",
|
||||||
"start_url": "/dashboard",
|
"start_url": "/dashboard",
|
||||||
"scope": "/",
|
"scope": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"orientation": "any",
|
"orientation": "any",
|
||||||
"background_color": "#ffffff",
|
"background_color": "#ffffff",
|
||||||
"theme_color": "#7444FD",
|
"theme_color": "#7444FD",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/icon.svg",
|
"src": "/icon.svg",
|
||||||
"sizes": "any",
|
"sizes": "any",
|
||||||
"type": "image/svg+xml",
|
"type": "image/svg+xml",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,32 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap');
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200');
|
|
||||||
|
/* Material Symbols — локальный шрифт для быстрой загрузки */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Material Symbols Outlined';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 100 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts/material-symbols-outlined.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-symbols-outlined {
|
||||||
|
font-family: 'Material Symbols Outlined';
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: normal;
|
||||||
|
text-transform: none;
|
||||||
|
display: inline-block;
|
||||||
|
white-space: nowrap;
|
||||||
|
word-wrap: normal;
|
||||||
|
direction: ltr;
|
||||||
|
-webkit-font-feature-settings: 'liga';
|
||||||
|
font-feature-settings: 'liga';
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
/* CSS Variables для темизации (быстрее чем JS темы) */
|
/* CSS Variables для темизации (быстрее чем JS темы) */
|
||||||
:root {
|
:root {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,201 @@
|
||||||
|
/**
|
||||||
|
* Утилиты для работы с часовыми поясами.
|
||||||
|
*
|
||||||
|
* Поддерживаемые форматы timezone:
|
||||||
|
* - UTC+X, UTC-X (например, "UTC+8", "UTC-5")
|
||||||
|
* - GMT+X, GMT-X
|
||||||
|
* - IANA названия (например, "Europe/Moscow", "Asia/Irkutsk")
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Парсить timezone и получить смещение в минутах.
|
||||||
|
*
|
||||||
|
* Примеры:
|
||||||
|
* - "UTC+8" -> 480 (8 * 60)
|
||||||
|
* - "UTC-5" -> -300 (-5 * 60)
|
||||||
|
* - "UTC+5:30" -> 330 (5 * 60 + 30)
|
||||||
|
*
|
||||||
|
* @returns смещение в минутах или null если не удалось распарсить
|
||||||
|
*/
|
||||||
|
export function parseTimezoneOffset(timezone: string | undefined): number | null {
|
||||||
|
if (!timezone) return null;
|
||||||
|
|
||||||
|
const trimmed = timezone.trim();
|
||||||
|
|
||||||
|
// Парсим формат UTC+X, UTC-X, GMT+X, GMT-X
|
||||||
|
const match = trimmed.match(/^(?:UTC|GMT)([+-])(\d{1,2})(?::(\d{2}))?$/i);
|
||||||
|
if (match) {
|
||||||
|
const sign = match[1] === '+' ? 1 : -1;
|
||||||
|
const hours = parseInt(match[2], 10);
|
||||||
|
const minutes = match[3] ? parseInt(match[3], 10) : 0;
|
||||||
|
return sign * (hours * 60 + minutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить смещение часового пояса в минутах.
|
||||||
|
*
|
||||||
|
* Поддерживает:
|
||||||
|
* - UTC+X формат (парсит напрямую)
|
||||||
|
* - IANA названия (использует Intl API)
|
||||||
|
*
|
||||||
|
* @returns смещение в минутах (положительное = восток от UTC)
|
||||||
|
*/
|
||||||
|
export function getTimezoneOffsetMinutes(timezone: string | undefined): number {
|
||||||
|
if (!timezone) {
|
||||||
|
// Браузерный timezone
|
||||||
|
return -new Date().getTimezoneOffset();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сначала пробуем распарсить UTC+X формат
|
||||||
|
const parsedOffset = parseTimezoneOffset(timezone);
|
||||||
|
if (parsedOffset !== null) {
|
||||||
|
return parsedOffset;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Для IANA названий используем Intl API
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
const utcDate = new Date(now.toLocaleString('en-US', { timeZone: 'UTC' }));
|
||||||
|
const tzDate = new Date(now.toLocaleString('en-US', { timeZone: timezone }));
|
||||||
|
return Math.round((tzDate.getTime() - utcDate.getTime()) / 60000);
|
||||||
|
} catch {
|
||||||
|
// Fallback на браузерный timezone
|
||||||
|
return -new Date().getTimezoneOffset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создать ISO строку даты/времени с учетом часового пояса пользователя.
|
||||||
|
*
|
||||||
|
* Пример: Если пользователь в Улан-Удэ (UTC+8) вводит 18:00,
|
||||||
|
* то нужно отправить на сервер 10:00 UTC (18:00 - 8 часов).
|
||||||
|
*
|
||||||
|
* @param dateStr - дата в формате 'YYYY-MM-DD'
|
||||||
|
* @param timeStr - время в формате 'HH:mm'
|
||||||
|
* @param userTimezone - часовой пояс пользователя (например, 'UTC+8', 'Europe/Moscow')
|
||||||
|
* @returns ISO строка в UTC
|
||||||
|
*/
|
||||||
|
export function createDateTimeInUserTimezone(
|
||||||
|
dateStr: string,
|
||||||
|
timeStr: string,
|
||||||
|
userTimezone: string | undefined
|
||||||
|
): string {
|
||||||
|
// Парсим дату и время
|
||||||
|
const [year, month, day] = dateStr.split('-').map(Number);
|
||||||
|
const [hours, minutes] = timeStr.split(':').map(Number);
|
||||||
|
|
||||||
|
// Создаем дату как будто она в UTC
|
||||||
|
const utcDate = new Date(Date.UTC(year, month - 1, day, hours, minutes, 0, 0));
|
||||||
|
|
||||||
|
// Получаем смещение timezone пользователя
|
||||||
|
const offsetMinutes = getTimezoneOffsetMinutes(userTimezone);
|
||||||
|
|
||||||
|
// Корректируем: вычитаем смещение, чтобы получить UTC
|
||||||
|
// Например: 18:00 в UTC+8 = 10:00 UTC, значит вычитаем 8 часов (480 минут)
|
||||||
|
utcDate.setMinutes(utcDate.getMinutes() - offsetMinutes);
|
||||||
|
|
||||||
|
return utcDate.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Парсить ISO дату и получить локальную дату/время в часовом поясе пользователя.
|
||||||
|
*
|
||||||
|
* Работает с любым форматом timezone:
|
||||||
|
* - UTC+8: добавляет 8 часов к UTC
|
||||||
|
* - Europe/Moscow: использует Intl API
|
||||||
|
*
|
||||||
|
* @param isoString - ISO строка даты (например, '2026-02-21T10:00:00Z' для UTC)
|
||||||
|
* @param userTimezone - часовой пояс пользователя (например, 'UTC+8')
|
||||||
|
* @returns объект с date и time в часовом поясе пользователя
|
||||||
|
*/
|
||||||
|
export function parseISOToUserTimezone(
|
||||||
|
isoString: string,
|
||||||
|
userTimezone: string | undefined
|
||||||
|
): { date: string; time: string; dateObj: Date } {
|
||||||
|
// Парсим ISO строку в UTC timestamp
|
||||||
|
const utcDate = new Date(isoString);
|
||||||
|
const utcMs = utcDate.getTime();
|
||||||
|
|
||||||
|
// Получаем смещение timezone пользователя в минутах
|
||||||
|
const offsetMinutes = getTimezoneOffsetMinutes(userTimezone);
|
||||||
|
|
||||||
|
// Применяем смещение: UTC + offset = локальное время
|
||||||
|
// Например: 10:00 UTC + 8 часов = 18:00 в UTC+8
|
||||||
|
const localMs = utcMs + offsetMinutes * 60 * 1000;
|
||||||
|
const localDate = new Date(localMs);
|
||||||
|
|
||||||
|
// Извлекаем компоненты даты/времени в UTC (потому что мы уже добавили offset)
|
||||||
|
const year = localDate.getUTCFullYear();
|
||||||
|
const month = String(localDate.getUTCMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(localDate.getUTCDate()).padStart(2, '0');
|
||||||
|
const hours = String(localDate.getUTCHours()).padStart(2, '0');
|
||||||
|
const minutes = String(localDate.getUTCMinutes()).padStart(2, '0');
|
||||||
|
|
||||||
|
const dateStr = `${year}-${month}-${day}`;
|
||||||
|
const timeStr = `${hours}:${minutes}`;
|
||||||
|
|
||||||
|
// Создаем Date объект для использования в UI (в локальном времени браузера)
|
||||||
|
const displayDate = new Date(`${dateStr}T${timeStr}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
date: dateStr,
|
||||||
|
time: timeStr,
|
||||||
|
dateObj: displayDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Форматировать дату для отображения в часовом поясе пользователя.
|
||||||
|
*
|
||||||
|
* @param isoString - ISO строка даты
|
||||||
|
* @param userTimezone - часовой пояс пользователя (например, 'UTC+8')
|
||||||
|
* @param options - опции форматирования Intl.DateTimeFormat
|
||||||
|
*/
|
||||||
|
export function formatDateInUserTimezone(
|
||||||
|
isoString: string,
|
||||||
|
userTimezone: string | undefined,
|
||||||
|
options: Intl.DateTimeFormatOptions = {}
|
||||||
|
): string {
|
||||||
|
// Получаем локальное время в timezone пользователя
|
||||||
|
const parsed = parseISOToUserTimezone(isoString, userTimezone);
|
||||||
|
|
||||||
|
// Форматируем используя Intl (dateObj уже в правильном времени)
|
||||||
|
return new Intl.DateTimeFormat('ru-RU', options).format(parsed.dateObj);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить текущую дату/время в часовом поясе пользователя.
|
||||||
|
*/
|
||||||
|
export function getNowInUserTimezone(userTimezone: string | undefined): Date {
|
||||||
|
const now = new Date();
|
||||||
|
const parsed = parseISOToUserTimezone(now.toISOString(), userTimezone);
|
||||||
|
return parsed.dateObj;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить название часового пояса с offset.
|
||||||
|
* Например: 'Europe/Moscow' -> 'Europe/Moscow (UTC+3)'
|
||||||
|
* Для 'UTC+8' -> 'UTC+8'
|
||||||
|
*/
|
||||||
|
export function getTimezoneDisplayName(timezone: string): string {
|
||||||
|
if (!timezone) return '';
|
||||||
|
|
||||||
|
// Если уже в формате UTC+X, возвращаем как есть
|
||||||
|
if (/^(?:UTC|GMT)[+-]\d/i.test(timezone)) {
|
||||||
|
return timezone;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const offsetMinutes = getTimezoneOffsetMinutes(timezone);
|
||||||
|
const hours = Math.floor(Math.abs(offsetMinutes) / 60);
|
||||||
|
const mins = Math.abs(offsetMinutes) % 60;
|
||||||
|
const sign = offsetMinutes >= 0 ? '+' : '-';
|
||||||
|
const offsetStr = mins > 0 ? `${hours}:${mins.toString().padStart(2, '0')}` : `${hours}`;
|
||||||
|
return `${timezone} (UTC${sign}${offsetStr})`;
|
||||||
|
} catch {
|
||||||
|
return timezone;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue