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

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

View File

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

View File

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

View File

@ -0,0 +1,111 @@
"""
Сервисы для системы чата.
Централизованная логика создания и управления чатами.
"""
from django.db import transaction
from django.db.models import Q
from .models import Chat, ChatParticipant
class ChatService:
"""
Сервис для работы с чатами.
Обеспечивает атомарность операций и предотвращает создание дубликатов.
"""
@staticmethod
def get_or_create_direct_chat(user1, user2, created_by=None):
"""
Получить или создать личный чат между двумя пользователями.
Использует блокировку для предотвращения race condition
при одновременных запросах на создание чата.
Args:
user1: Первый пользователь (User)
user2: Второй пользователь (User)
created_by: Кто создает чат (по умолчанию user1)
Returns:
tuple: (chat, created) - объект чата и флаг создания
"""
if user1.id == user2.id:
raise ValueError("Нельзя создать чат с самим собой")
# Нормализуем порядок пользователей для консистентного поиска
users = sorted([user1, user2], key=lambda u: u.id)
with transaction.atomic():
# Ищем существующий чат между пользователями
# Используем select_for_update для блокировки найденных записей
existing_chat = Chat.objects.select_for_update().filter(
chat_type='direct',
participants__user=users[0]
).filter(
participants__user=users[1]
).distinct().first()
if existing_chat:
return existing_chat, False
# Чата нет - создаем новый
creator = created_by or users[0]
chat = Chat.objects.create(
chat_type='direct',
created_by=creator
)
# Определяем роли участников
# Создатель становится админом
ChatParticipant.objects.create(
chat=chat,
user=users[0],
role='admin' if users[0] == creator else 'member'
)
ChatParticipant.objects.create(
chat=chat,
user=users[1],
role='admin' if users[1] == creator else 'member'
)
return chat, True
@staticmethod
def get_direct_chat(user1, user2):
"""
Получить существующий личный чат между двумя пользователями.
Args:
user1: Первый пользователь
user2: Второй пользователь
Returns:
Chat или None
"""
return Chat.objects.filter(
chat_type='direct',
participants__user=user1
).filter(
participants__user=user2
).distinct().first()
@staticmethod
def ensure_participant(chat, user, role='member'):
"""
Убедиться что пользователь является участником чата.
Если нет - добавить его.
Args:
chat: Чат
user: Пользователь
role: Роль (по умолчанию 'member')
Returns:
tuple: (participant, created)
"""
return ChatParticipant.objects.get_or_create(
chat=chat,
user=user,
defaults={'role': role}
)

View File

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

View File

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

View File

@ -77,6 +77,7 @@ def duplicate_notification_to_chat(sender, instance, created, **kwargs):
try:
from apps.chat.models import Chat, Message, ChatParticipant
from apps.chat.services import ChatService
from apps.users.models import User, Client, Parent
recipient = instance.recipient
@ -132,22 +133,12 @@ def duplicate_notification_to_chat(sender, instance, created, **kwargs):
if not mentor:
return
# Находим или создаем личный чат между ментором и получателем
chat = Chat.objects.filter(
chat_type='direct',
participants__user=mentor
).filter(
participants__user=recipient
).first()
if not chat:
# Создаем чат если его нет
chat = Chat.objects.create(
chat_type='direct',
# Используем сервис для атомарного создания/получения чата
chat, _ = ChatService.get_or_create_direct_chat(
user1=mentor,
user2=recipient,
created_by=mentor
)
ChatParticipant.objects.create(chat=chat, user=mentor, role='admin')
ChatParticipant.objects.create(chat=chat, user=recipient, role='member')
# Создаем системное сообщение в чате (без HTML-тегов, чтобы в чате не отображались теги)
title_plain = strip_tags(instance.title or '')

View File

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

View File

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

View File

@ -0,0 +1,81 @@
"""
Обработка отложенных бонусов за рефералов.
Начисление возможно только при выполнении одного из условий:
- Прошло 30+ дней с приглашения И реферал был активен 20+ дней;
- Реферал был активен 21+ день (независимо от срока).
Запуск: python manage.py process_pending_referral_bonuses
Рекомендуется добавить в cron (ежедневно).
"""
from django.core.management.base import BaseCommand
from django.utils import timezone
from django.db import transaction
from apps.referrals.models import (
PendingReferralBonus,
UserActivityDay,
UserReferralProfile,
)
class Command(BaseCommand):
help = 'Начислить бонусы за рефералов, выполнивших условия по активности (20+ дней за 30 дней или 21+ день всего)'
def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
help='Только показать, что было бы начислено, без изменений в БД',
)
def handle(self, *args, **options):
dry_run = options['dry_run']
now = timezone.now()
paid_count = 0
for pending in PendingReferralBonus.objects.filter(status=PendingReferralBonus.STATUS_PENDING).select_related(
'referrer', 'referred_user'
):
referred_at = pending.referred_at
referred_date = referred_at.date()
# Дней активности реферала с даты приглашения
active_days = UserActivityDay.objects.filter(
user=pending.referred_user,
date__gte=referred_date,
).count()
days_since_referral = (now - referred_at).days
past_30 = days_since_referral >= 30
# Условие: (30+ дней и 20+ активных) ИЛИ (21+ активных дней)
if (past_30 and active_days >= 20) or (active_days >= 21):
if dry_run:
self.stdout.write(
f'[dry-run] Начислили бы {pending.points} очков {pending.referrer.email} '
f'за реферала {pending.referred_user.email} (активных дней: {active_days}, прошло дней: {days_since_referral})'
)
paid_count += 1
continue
try:
with transaction.atomic():
referrer_profile = pending.referrer.referral_profile
referrer_profile.add_points(pending.points, reason=pending.reason or f'Реферал {pending.referred_user.email} выполнил условия активности')
pending.status = PendingReferralBonus.STATUS_PAID
pending.paid_at = now
pending.save(update_fields=['status', 'paid_at'])
paid_count += 1
self.stdout.write(
self.style.SUCCESS(
f'Начислено {pending.points} очков {pending.referrer.email} за {pending.referred_user.email} (активных дней: {active_days})'
)
)
except UserReferralProfile.DoesNotExist:
self.stdout.write(
self.style.WARNING(f'Пропуск {pending.id}: у реферера нет профиля')
)
except Exception as e:
self.stdout.write(
self.style.ERROR(f'Ошибка при начислении {pending.id}: {e}')
)
if dry_run:
self.stdout.write(self.style.SUCCESS(f'[dry-run] Всего к начислению: {paid_count}'))
else:
self.stdout.write(self.style.SUCCESS(f'Начислено бонусов: {paid_count}'))

View File

@ -0,0 +1,78 @@
# Generated migration for referral antifraud: backlog, activity days, pending bonuses
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.core.validators
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('referrals', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='ReferralInvitedEmail',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('email', models.EmailField(db_index=True, max_length=254, unique=True, verbose_name='Email приглашённого')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата')),
('referrer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invited_emails', to=settings.AUTH_USER_MODEL, verbose_name='Реферер')),
('referred_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Приглашённый пользователь')),
],
options={
'verbose_name': 'Приглашённый email',
'verbose_name_plural': 'Бэклог приглашённых email',
'db_table': 'referrals_invited_emails',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='UserActivityDay',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField(db_index=True, verbose_name='Дата')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activity_days', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
],
options={
'verbose_name': 'День активности',
'verbose_name_plural': 'Дни активности',
'db_table': 'referrals_user_activity_days',
'ordering': ['-date'],
'unique_together': {('user', 'date')},
},
),
migrations.CreateModel(
name='PendingReferralBonus',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('referred_at', models.DateTimeField(db_index=True, verbose_name='Дата приглашения')),
('points', models.IntegerField(validators=[django.core.validators.MinValueValidator(0)], verbose_name='Очки к начислению')),
('level', models.IntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(2)], verbose_name='Уровень (1 — прямой, 2 — непрямой)')),
('reason', models.CharField(blank=True, max_length=255, verbose_name='Причина')),
('status', models.CharField(choices=[('pending', 'Ожидает'), ('paid', 'Начислено'), ('cancelled', 'Отменено')], db_index=True, default='pending', max_length=20, verbose_name='Статус')),
('paid_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата начисления')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')),
('referrer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pending_referral_bonuses', to=settings.AUTH_USER_MODEL, verbose_name='Реферер')),
('referred_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pending_bonuses_for_me', to=settings.AUTH_USER_MODEL, verbose_name='Реферал')),
],
options={
'verbose_name': 'Ожидающий бонус за реферала',
'verbose_name_plural': 'Ожидающие бонусы за рефералов',
'db_table': 'referrals_pending_referral_bonus',
'ordering': ['referred_at'],
},
),
migrations.AddIndex(
model_name='useractivityday',
index=models.Index(fields=['user', 'date'], name='referrals_u_user_id_8b0b0d_idx'),
),
migrations.AddIndex(
model_name='pendingreferralbonus',
index=models.Index(fields=['status', 'referred_at'], name='referrals_p_status_9c2e2a_idx'),
),
]

View File

@ -0,0 +1,31 @@
# Data migration: fill ReferralInvitedEmail from existing UserReferralProfile (referred_by is not null)
from django.db import migrations
def backfill_invited_emails(apps, schema_editor):
UserReferralProfile = apps.get_model('referrals', 'UserReferralProfile')
ReferralInvitedEmail = apps.get_model('referrals', 'ReferralInvitedEmail')
for profile in UserReferralProfile.objects.filter(referred_by__isnull=False).select_related('user', 'referred_by'):
email_lower = profile.user.email.lower().strip()
if not ReferralInvitedEmail.objects.filter(email=email_lower).exists():
ReferralInvitedEmail.objects.create(
email=email_lower,
referrer=profile.referred_by,
referred_user=profile.user,
)
def noop(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
('referrals', '0002_add_referral_antifraud_models'),
]
operations = [
migrations.RunPython(backfill_invited_emails, noop),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,18 @@
#!/usr/bin/env python3
"""Проверка доступности портов SMTP (465 и 2525) для smtp.mail.ru."""
import socket
host = "smtp.mail.ru"
ports = [465, 2525]
timeout = 10
for port in ports:
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(timeout)
err = s.connect_ex((host, port))
s.close()
status = "доступен" if err == 0 else "недоступен"
print(f" {host}:{port}{status}")
except Exception as e:
print(f" {host}:{port} — ошибка: {e}")

View File

@ -40,6 +40,10 @@ export interface User {
universal_code?: string;
invitation_link?: string;
invitation_link_token?: string;
timezone?: string;
language?: string;
city?: string;
country?: string;
}
/**

View File

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

View File

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

View File

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

View File

@ -18,6 +18,7 @@ export interface CalendarLesson {
status?: string;
client?: number;
client_name?: string;
mentor_name?: string;
subject?: string;
}
@ -34,6 +35,10 @@ export interface CalendarProps {
onSelectEvent?: (lesson: { id: string }) => void;
/** Смена видимого месяца (start/end месяца) */
onMonthChange?: (start: Date, end: Date) => void;
/** Ментор — показывает ученика; студент — показывает предмет и ментора */
isMentor?: boolean;
/** Часовой пояс пользователя (например, 'UTC+8') */
userTimezone?: string;
}
export const Calendar: React.FC<CalendarProps> = ({
@ -43,25 +48,40 @@ export const Calendar: React.FC<CalendarProps> = ({
onSelectSlot,
onSelectEvent,
onMonthChange,
isMentor = true,
userTimezone,
}) => {
const mappedLessons = React.useMemo(
() =>
lessons.map((lesson) => ({
lessons.map((lesson) => {
if (isMentor && lesson.client_name) {
return {
id: String(lesson.id),
title: lesson.title || 'Занятие',
start_time: lesson.start_time,
end_time: lesson.end_time,
status: (lesson.status || 'scheduled') as 'scheduled' | 'in_progress' | 'completed' | 'cancelled',
client: lesson.client_name
? {
client: {
id: String(lesson.client ?? ''),
name: lesson.client_name,
first_name: lesson.client_name.split(' ')[0] || lesson.client_name,
last_name: lesson.client_name.split(' ').slice(1).join(' ') || '',
},
};
}
: undefined,
})),
[lessons]
const subject = lesson.subject || 'Занятие';
const mentorName = lesson.mentor_name || '';
const displayTitle = mentorName ? `${subject}${mentorName}` : subject;
return {
id: String(lesson.id),
title: displayTitle,
start_time: lesson.start_time,
end_time: lesson.end_time,
status: (lesson.status || 'scheduled') as 'scheduled' | 'in_progress' | 'completed' | 'cancelled',
client: undefined,
};
}),
[lessons, isMentor]
);
return (
@ -82,6 +102,7 @@ export const Calendar: React.FC<CalendarProps> = ({
<LessonsCalendar
lessons={mappedLessons}
selectedDate={selectedDate}
userTimezone={userTimezone}
onSelectSlot={(date) => {
try {
const d = startOfDay(date);

View File

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

View File

@ -4,10 +4,12 @@
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { LessonPreview } from '@/api/dashboard';
import { createLiveKitRoom } from '@/api/livekit';
import { useAuth } from '@/contexts/AuthContext';
import { parseISOToUserTimezone } from '@/utils/timezone';
interface LessonCardProps {
lesson: LessonPreview;
@ -35,6 +37,7 @@ export const LessonCard: React.FC<LessonCardProps> = ({
onClick,
}) => {
const router = useRouter();
const { user } = useAuth();
const [connectLoading, setConnectLoading] = useState(false);
const [canJoin, setCanJoin] = useState(false);
@ -64,8 +67,13 @@ export const LessonCard: React.FC<LessonCardProps> = ({
[canJoin, connectLoading, lesson.id, router]
);
const startTime = new Date(lesson.start_time);
const endTime = new Date(lesson.end_time);
// Парсим время с учётом timezone пользователя
const { startParsed, endParsed } = useMemo(() => {
return {
startParsed: parseISOToUserTimezone(lesson.start_time, user?.timezone),
endParsed: parseISOToUserTimezone(lesson.end_time, user?.timezone),
};
}, [lesson.start_time, lesson.end_time, user?.timezone]);
const getStatusColor = (status: string) => {
switch (status) {
@ -188,20 +196,14 @@ export const LessonCard: React.FC<LessonCardProps> = ({
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
<span>
{startTime.toLocaleDateString('ru-RU', {
{startParsed.dateObj.toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'short'
})}
{' в '}
{startTime.toLocaleTimeString('ru-RU', {
hour: '2-digit',
minute: '2-digit'
})}
{startParsed.time}
{' - '}
{endTime.toLocaleTimeString('ru-RU', {
hour: '2-digit',
minute: '2-digit'
})}
{endParsed.time}
</span>
</div>
</div>

View File

@ -25,6 +25,7 @@ import {
import { ru } from 'date-fns/locale';
import { Box, IconButton, Typography } from '@mui/material';
import { ChevronLeft, ChevronRight } from '@mui/icons-material';
import { parseISOToUserTimezone } from '@/utils/timezone';
interface Lesson {
id: string;
@ -46,6 +47,7 @@ interface LessonsCalendarProps {
onSelectSlot?: (date: Date) => void;
onMonthChange?: (start: Date, end: Date) => void;
selectedDate?: Date;
userTimezone?: string;
}
export const LessonsCalendar: React.FC<LessonsCalendarProps> = ({
@ -54,6 +56,7 @@ export const LessonsCalendar: React.FC<LessonsCalendarProps> = ({
onSelectSlot,
onMonthChange,
selectedDate,
userTimezone,
}) => {
const safeSelectedDate = useMemo(() => {
if (selectedDate && !Number.isNaN(selectedDate.getTime())) return startOfDay(selectedDate);
@ -79,16 +82,16 @@ export const LessonsCalendar: React.FC<LessonsCalendarProps> = ({
onMonthChange?.(start, end);
}, [currentMonth, onMonthChange]);
// Группируем занятия по дате (ключ YYYY-MM-DD)
// Группируем занятия по дате (ключ YYYY-MM-DD) с учётом timezone пользователя
const lessonsByDay = useMemo(() => {
const map = new Map<string, Lesson[]>();
if (!lessons || lessons.length === 0) return map;
lessons.forEach((lesson) => {
try {
const day = startOfDay(new Date(lesson.start_time));
if (isNaN(day.getTime())) return;
const key = format(day, 'yyyy-MM-dd');
// Используем timezone пользователя для определения дня
const parsed = parseISOToUserTimezone(lesson.start_time, userTimezone);
const key = parsed.date; // уже в формате 'yyyy-MM-dd'
const existing = map.get(key) || [];
existing.push(lesson);
map.set(key, existing);
@ -97,8 +100,16 @@ export const LessonsCalendar: React.FC<LessonsCalendarProps> = ({
}
});
// Сортируем занятия внутри каждого дня по времени
map.forEach((dayLessons, key) => {
dayLessons.sort((a, b) =>
new Date(a.start_time).getTime() - new Date(b.start_time).getTime()
);
map.set(key, dayLessons);
});
return map;
}, [lessons]);
}, [lessons, userTimezone]);
const monthLabel = useMemo(() => {
const label = format(currentMonth, 'LLLL yyyy', { locale: ru });
@ -343,7 +354,9 @@ export const LessonsCalendar: React.FC<LessonsCalendarProps> = ({
{dayLessons.slice(0, 2).map((lesson) => {
const timeStr = (() => {
try {
return format(new Date(lesson.start_time), 'HH:mm', { locale: ru });
// Используем timezone пользователя для отображения времени
const parsed = parseISOToUserTimezone(lesson.start_time, userTimezone);
return parsed.time;
} catch {
return '';
}

View File

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

View File

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

View File

@ -1,7 +1,9 @@
'use client';
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useMemo } from 'react';
import { completeLesson, type Lesson } from '@/api/schedule';
import { useAuth } from '@/contexts/AuthContext';
import { parseISOToUserTimezone } from '@/utils/timezone';
interface FeedbackModalProps {
isOpen: boolean;
@ -11,6 +13,7 @@ interface FeedbackModalProps {
}
export function FeedbackModal({ isOpen, lesson, onClose, onSuccess }: FeedbackModalProps) {
const { user } = useAuth();
const [formData, setFormData] = useState({
mentor_grade: '',
school_grade: '',
@ -19,6 +22,15 @@ export function FeedbackModal({ isOpen, lesson, onClose, onSuccess }: FeedbackMo
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Парсим время с учётом timezone пользователя
const parsedTimes = useMemo(() => {
if (!lesson) return null;
return {
start: parseISOToUserTimezone(lesson.start_time, user?.timezone),
end: parseISOToUserTimezone(lesson.end_time, user?.timezone),
};
}, [lesson, user?.timezone]);
useEffect(() => {
if (isOpen && lesson) {
setFormData({
@ -29,7 +41,7 @@ export function FeedbackModal({ isOpen, lesson, onClose, onSuccess }: FeedbackMo
}
}, [isOpen, lesson]);
if (!lesson) return null;
if (!lesson || !parsedTimes) return null;
const visible = isOpen;
@ -155,15 +167,15 @@ export function FeedbackModal({ isOpen, lesson, onClose, onSuccess }: FeedbackMo
<div>
<span style={{ color: 'var(--md-sys-color-on-surface-variant)' }}>Дата: </span>
<span style={{ color: 'var(--md-sys-color-on-surface)', fontWeight: 500 }}>
{new Date(lesson.start_time).toLocaleDateString('ru-RU')}
{parsedTimes.start.dateObj.toLocaleDateString('ru-RU')}
</span>
</div>
<div>
<span style={{ color: 'var(--md-sys-color-on-surface-variant)' }}>Время: </span>
<span style={{ color: 'var(--md-sys-color-on-surface)', fontWeight: 500 }}>
{new Date(lesson.start_time).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
{parsedTimes.start.time}
{' — '}
{new Date(lesson.end_time).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
{parsedTimes.end.time}
</span>
</div>
<div>

View File

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

View File

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