diff --git a/backend/apps/users/middleware/activity.py b/backend/apps/users/middleware/activity.py index 079e915..ff20373 100644 --- a/backend/apps/users/middleware/activity.py +++ b/backend/apps/users/middleware/activity.py @@ -99,9 +99,19 @@ class UpdateLastActivityMiddleware: cache.set(cache_key, now, timeout=self.UPDATE_INTERVAL * 2) # Обновляем объект пользователя в запросе для текущего запроса - # Это позволяет использовать обновленное значение в текущем запросе user.last_activity = now + # Учёт дня активности для реферальной программы (не чаще 1 раза в день на пользователя) + today = now.date() + day_cache_key = f'referral_activity_day:{user.id}:{today}' + if not cache.get(day_cache_key): + try: + from apps.referrals.models import UserActivityDay + UserActivityDay.objects.get_or_create(user=user, date=today) + cache.set(day_cache_key, 1, timeout=86400 * 2) + except Exception: + pass + except Exception as e: # Логируем ошибку, но не прерываем выполнение запроса logger.error(f"Ошибка при обновлении last_activity для пользователя {user.id}: {e}", exc_info=True) diff --git a/backend/apps/users/models.py b/backend/apps/users/models.py index e25ad6f..fed4304 100644 --- a/backend/apps/users/models.py +++ b/backend/apps/users/models.py @@ -309,9 +309,37 @@ class User(AbstractUser): def __str__(self): return f"{self.get_full_name()} ({self.email})" + def _generate_universal_code(self): + """Генерация уникального 8-символьного кода (цифры + латинские буквы A–Z).""" + 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') + def save(self, *args, **kwargs): if self.phone: self.phone = normalize_phone(self.phone) + + # Автоматическая генерация 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 + + # Гарантируем 8-символьный код (universal_code) + if not self.universal_code: + try: + self.universal_code = self._generate_universal_code() + except Exception: + # Если не удалось сгенерировать, не прерываем сохранение + pass + super().save(*args, **kwargs) @@ -364,36 +392,8 @@ class Mentor(User): def can_access_admin(self): """Может ли пользователь получить доступ к админ-панели.""" return self.is_staff or self.is_superuser or self.role == 'admin' - - def _generate_universal_code(self): - """Генерация уникального 8-символьного кода (цифры + латинские буквы A–Z).""" - 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') - def save(self, *args, **kwargs): - """Переопределение save для автоматической генерации username и universal_code.""" - if not self.username: - # Генерируем username из 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 not self.universal_code: - try: - self.universal_code = self._generate_universal_code() - except Exception: - # Если не удалось сгенерировать, не прерываем сохранение - # Код будет сгенерирован при следующем запросе профиля или в RegisterView - pass - - super().save(*args, **kwargs) + # Мы удалили Mentor.save и _generate_universal_code, так как они теперь в User class Client(models.Model): diff --git a/backend/apps/users/profile_views.py b/backend/apps/users/profile_views.py index 784de7e..cd6bc35 100644 --- a/backend/apps/users/profile_views.py +++ b/backend/apps/users/profile_views.py @@ -1505,6 +1505,27 @@ 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']) @@ -1538,6 +1559,8 @@ class InvitationViewSet(viewsets.ViewSet): from rest_framework_simplejwt.tokens import RefreshToken refresh = RefreshToken.for_user(student_user) + # Обновляем из БД, чтобы в ответе был актуальный universal_code + student_user.refresh_from_db() return Response({ 'refresh': str(refresh), 'access': str(refresh.access_token), diff --git a/backend/apps/users/serializers.py b/backend/apps/users/serializers.py index abd9b28..a062b5a 100644 --- a/backend/apps/users/serializers.py +++ b/backend/apps/users/serializers.py @@ -1,558 +1,558 @@ -""" -Сериализаторы для пользователей. -""" -import re -from urllib.parse import unquote -from rest_framework import serializers -from django.contrib.auth.password_validation import validate_password -from django.contrib.auth import authenticate -from .models import User, Client, Parent, Group - - -def _decode_if_url_encoded(value: str) -> str: - """Если строка в формате URL-encoded (%XX), декодирует в UTF-8.""" - if not value or not isinstance(value, str): - return value - if re.search(r'%[0-9A-Fa-f]{2}', value): - try: - return unquote(value, encoding='utf-8') - except Exception: - pass - return value - - -class UserSerializer(serializers.ModelSerializer): - """Базовый сериализатор пользователя.""" - - avatar_url = serializers.SerializerMethodField() - invitation_link = serializers.SerializerMethodField() - login_link = serializers.SerializerMethodField() - - class Meta: - model = User - fields = [ - 'id', 'email', 'first_name', 'last_name', 'role', - 'phone', 'avatar', 'avatar_url', 'birth_date', 'bio', - 'telegram_id', 'telegram_username', - 'timezone', 'language', - 'country', 'city', - 'email_verified', 'is_active', - 'universal_code', # 8-символьный код (цифры + латинские буквы) для добавления ментором - 'invitation_link_token', 'invitation_link', - 'login_token', 'login_link', - 'notifications_enabled', 'email_notifications', 'telegram_notifications', - 'created_at', 'last_activity' - ] - read_only_fields = ['id', 'email_verified', 'universal_code', 'invitation_link_token', 'login_token', 'created_at', 'last_activity'] - - def get_avatar_url(self, obj): - """Получить полный URL аватара.""" - if obj.avatar: - request = self.context.get('request') - if request: - return request.build_absolute_uri(obj.avatar.url) - return obj.avatar.url - return None - - def get_invitation_link(self, obj): - """Получить полную ссылку-приглашение (только для менторов).""" - if obj.role == 'mentor' and obj.invitation_link_token: - from django.conf import settings - frontend_url = getattr(settings, 'FRONTEND_URL', '').rstrip('/') - return f"{frontend_url}/invite/{obj.invitation_link_token}" - return None - - def get_login_link(self, obj): - """Получить персональную ссылку для входа (для учеников).""" - if obj.login_token: - from django.conf import settings - frontend_url = getattr(settings, 'FRONTEND_URL', '').rstrip('/') - return f"{frontend_url}/login/token/{obj.login_token}" - return None - - def to_representation(self, instance): - """Декодируем first_name и last_name, если они пришли в БД в формате URL-encoded.""" - data = super().to_representation(instance) - if 'first_name' in data and data['first_name']: - data['first_name'] = _decode_if_url_encoded(data['first_name']) - if 'last_name' in data and data['last_name']: - data['last_name'] = _decode_if_url_encoded(data['last_name']) - return data - - -class UserDetailSerializer(UserSerializer): - """Детальный сериализатор пользователя с дополнительной информацией.""" - - full_name = serializers.CharField(source='get_full_name', read_only=True) - short_name = serializers.CharField(source='get_short_name', read_only=True) - is_mentor = serializers.BooleanField(read_only=True) - is_client = serializers.BooleanField(read_only=True) - is_parent = serializers.BooleanField(read_only=True) - - class Meta(UserSerializer.Meta): - fields = UserSerializer.Meta.fields + [ - 'full_name', 'short_name', 'is_mentor', 'is_client', 'is_parent' - ] - - -class RegisterSerializer(serializers.ModelSerializer): - """Сериализатор для регистрации пользователя.""" - - password = serializers.CharField( - write_only=True, - required=True, - validators=[validate_password], - style={'input_type': 'password'} - ) - password_confirm = serializers.CharField( - write_only=True, - required=True, - style={'input_type': 'password'} - ) - - class Meta: - model = User - fields = [ - 'email', 'password', 'password_confirm', - 'first_name', 'last_name', 'role', - 'phone', 'birth_date', 'timezone', 'language', - 'country', 'city', - ] - extra_kwargs = { - 'first_name': {'required': True}, - 'last_name': {'required': True}, - 'city': {'required': True}, - 'timezone': {'required': True}, - } - - def validate_email(self, value): - """Нормализация email в нижний регистр.""" - return value.lower().strip() if value else value - - def validate(self, attrs): - """Проверка совпадения паролей.""" - if attrs.get('password') != attrs.get('password_confirm'): - raise serializers.ValidationError({ - 'password_confirm': 'Пароли не совпадают' - }) - return attrs - - def validate_role(self, value): - """Проверка допустимых ролей при регистрации.""" - allowed_roles = ['client', 'mentor', 'parent'] - if value not in allowed_roles: - raise serializers.ValidationError( - f'Недопустимая роль. Доступные роли: {", ".join(allowed_roles)}' - ) - return value - - def create(self, validated_data): - """Создание пользователя.""" - validated_data.pop('password_confirm') - password = validated_data.pop('password') - - user = User.objects.create_user( - password=password, - **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) - elif user.role == 'parent': - Parent.objects.create(user=user) - - return user - - -class TelegramAuthSerializer(serializers.Serializer): - """Сериализатор для авторизации через Telegram.""" - - id = serializers.IntegerField(required=True) - first_name = serializers.CharField(required=True) - last_name = serializers.CharField(required=False, allow_blank=True) - username = serializers.CharField(required=False, allow_blank=True) - photo_url = serializers.URLField(required=False, allow_blank=True) - auth_date = serializers.IntegerField(required=True) - hash = serializers.CharField(required=True) - role = serializers.ChoiceField( - choices=['mentor', 'client'], - required=False, - default='client', - help_text='Роль пользователя при регистрации' - ) - - def validate(self, attrs): - """Валидация данных Telegram.""" - from django.conf import settings - from .telegram_auth import validate_telegram_data - - # Восстанавливаем hash для валидации - telegram_data = { - 'id': attrs['id'], - 'first_name': attrs['first_name'], - 'last_name': attrs.get('last_name', ''), - 'username': attrs.get('username', ''), - 'photo_url': attrs.get('photo_url', ''), - 'auth_date': attrs['auth_date'], - 'hash': attrs['hash'], - } - - # Удаляем пустые поля для валидации - telegram_data = {k: v for k, v in telegram_data.items() if v} - - bot_token = settings.TELEGRAM_BOT_TOKEN - if not bot_token: - raise serializers.ValidationError("Telegram бот не настроен") - - if not validate_telegram_data(telegram_data.copy(), bot_token): - raise serializers.ValidationError("Неверные данные Telegram") - - return attrs - - -class LoginSerializer(serializers.Serializer): - """Сериализатор для входа пользователя.""" - - email = serializers.EmailField(required=True) - password = serializers.CharField( - required=True, - write_only=True, - style={'input_type': 'password'} - ) - - def validate_email(self, value): - """Нормализация email в нижний регистр.""" - return value.lower().strip() if value else value - - def validate(self, attrs): - """Проверка учетных данных.""" - email = attrs.get('email') - password = attrs.get('password') - - if email and password: - # Проверяем существование пользователя - try: - user = User.objects.get(email=email) - except User.DoesNotExist: - raise serializers.ValidationError({ - 'email': 'Пользователь с таким email не найден' - }) - - # Проверяем пароль - if not user.check_password(password): - raise serializers.ValidationError({ - 'password': 'Неверный пароль' - }) - - # Проверяем активность пользователя - if not user.is_active: - raise serializers.ValidationError({ - 'email': 'Аккаунт неактивен' - }) - - # Проверяем блокировку - if user.is_blocked: - raise serializers.ValidationError({ - 'email': f'Аккаунт заблокирован. Причина: {user.blocked_reason}' - }) - - attrs['user'] = user - else: - raise serializers.ValidationError({ - 'email': 'Email и пароль обязательны' - }) - - return attrs - - -class ChangePasswordSerializer(serializers.Serializer): - """Сериализатор для смены пароля.""" - - old_password = serializers.CharField( - required=True, - write_only=True, - style={'input_type': 'password'} - ) - new_password = serializers.CharField( - required=True, - write_only=True, - validators=[validate_password], - style={'input_type': 'password'} - ) - new_password_confirm = serializers.CharField( - required=True, - write_only=True, - style={'input_type': 'password'} - ) - - def validate(self, attrs): - """Проверка совпадения новых паролей.""" - if attrs.get('new_password') != attrs.get('new_password_confirm'): - raise serializers.ValidationError({ - 'new_password_confirm': 'Пароли не совпадают' - }) - return attrs - - def validate_old_password(self, value): - """Проверка старого пароля.""" - user = self.context['request'].user - if not user.check_password(value): - raise serializers.ValidationError('Неверный старый пароль') - return value - - -class PasswordResetRequestSerializer(serializers.Serializer): - """Сериализатор для запроса восстановления пароля.""" - - email = serializers.EmailField(required=True) - - def validate_email(self, value): - """Нормализация email в нижний регистр и проверка существования.""" - # Нормализуем email в нижний регистр - normalized_email = value.lower().strip() if value else value - try: - User.objects.get(email=normalized_email) - except User.DoesNotExist: - # Не раскрываем информацию о существовании email - pass - return normalized_email - - -class PasswordResetConfirmSerializer(serializers.Serializer): - """Сериализатор для подтверждения восстановления пароля.""" - - token = serializers.CharField(required=True) - new_password = serializers.CharField( - required=True, - write_only=True, - validators=[validate_password], - style={'input_type': 'password'} - ) - new_password_confirm = serializers.CharField( - required=True, - write_only=True, - style={'input_type': 'password'} - ) - - def validate(self, attrs): - """Проверка совпадения паролей.""" - if attrs.get('new_password') != attrs.get('new_password_confirm'): - raise serializers.ValidationError({ - 'new_password_confirm': 'Пароли не совпадают' - }) - return attrs - - -class EmailVerificationSerializer(serializers.Serializer): - """Сериализатор для подтверждения email.""" - - token = serializers.CharField(required=True) - - -class ClientSerializer(serializers.ModelSerializer): - """Сериализатор для клиента.""" - - user = UserSerializer(read_only=True) - mentors = UserSerializer(many=True, read_only=True) # Добавляем менторов - scheduled_lessons = serializers.SerializerMethodField() - total_lessons = serializers.SerializerMethodField() - completed_lessons = serializers.SerializerMethodField() - - class Meta: - model = Client - fields = [ - 'id', 'user', 'mentors', 'grade', 'school', 'learning_goals', - 'total_lessons', 'completed_lessons', 'scheduled_lessons', - 'enrollment_date', 'created_at' - ] - read_only_fields = [ - 'id', 'total_lessons', 'completed_lessons', 'scheduled_lessons', - 'enrollment_date', 'created_at' - ] - - def get_scheduled_lessons(self, obj): - """Количество запланированных занятий.""" - # Оптимизация: если queryset был заранее аннотирован, не делаем отдельные запросы в БД - if hasattr(obj, 'scheduled_lessons_annotated'): - return int(getattr(obj, 'scheduled_lessons_annotated') or 0) - from apps.schedule.models import Lesson - request = self.context.get('request') - if request and request.user and request.user.role == 'mentor': - # Считаем только занятия этого ментора - return Lesson.objects.filter( - client=obj, - mentor=request.user, - status='scheduled' - ).count() - # Если нет контекста, считаем все занятия - return Lesson.objects.filter( - client=obj, - status='scheduled' - ).count() - - def get_total_lessons(self, obj): - """Общее количество занятий (все статусы кроме отмененных).""" - # Оптимизация: если queryset был заранее аннотирован, не делаем отдельные запросы в БД - if hasattr(obj, 'total_lessons_annotated'): - return int(getattr(obj, 'total_lessons_annotated') or 0) - from apps.schedule.models import Lesson - request = self.context.get('request') - if request and request.user and request.user.role == 'mentor': - # Считаем только занятия этого ментора - return Lesson.objects.filter( - client=obj, - mentor=request.user - ).exclude(status='cancelled').count() - # Если нет контекста, считаем все занятия - return Lesson.objects.filter( - client=obj - ).exclude(status='cancelled').count() - - def get_completed_lessons(self, obj): - """Количество завершенных занятий.""" - # Оптимизация: если queryset был заранее аннотирован, не делаем отдельные запросы в БД - if hasattr(obj, 'completed_lessons_annotated'): - return int(getattr(obj, 'completed_lessons_annotated') or 0) - from apps.schedule.models import Lesson - request = self.context.get('request') - if request and request.user and request.user.role == 'mentor': - # Считаем только занятия этого ментора - return Lesson.objects.filter( - client=obj, - mentor=request.user, - status='completed' - ).count() - # Если нет контекста, считаем все занятия - return Lesson.objects.filter( - client=obj, - status='completed' - ).count() - - -class ParentSerializer(serializers.ModelSerializer): - """Сериализатор для родителя.""" - - user = UserSerializer(read_only=True) - children = ClientSerializer(many=True, read_only=True) - - class Meta: - model = Parent - fields = [ - 'id', 'user', 'children', 'relation_type', - 'can_view_progress', 'can_view_schedule', 'can_receive_reports', - 'created_at' - ] - read_only_fields = ['id', 'created_at'] - - -class GroupSerializer(serializers.ModelSerializer): - """Сериализатор учебной группы.""" - - mentor = UserSerializer(read_only=True) - students = ClientSerializer(many=True, read_only=True) - students_ids = serializers.ListField( - child=serializers.IntegerField(), - write_only=True, - required=False, - allow_empty=True, - ) - students_count = serializers.SerializerMethodField() - scheduled_lessons = serializers.SerializerMethodField() - completed_lessons = serializers.SerializerMethodField() - - class Meta: - model = Group - fields = [ - 'id', - 'mentor', - 'name', - 'description', - 'students', - 'students_ids', - 'students_count', - 'scheduled_lessons', - 'completed_lessons', - 'created_at', - 'updated_at', - ] - read_only_fields = ['id', 'mentor', 'students', 'students_count', 'scheduled_lessons', 'completed_lessons', 'created_at', 'updated_at'] - - def get_students_count(self, obj): - # Используем аннотацию, если она есть (для списка) - if hasattr(obj, 'students_count_annotated'): - return obj.students_count_annotated - return obj.students.count() - - def _base_queryset(self, obj): - """ - Базовый queryset занятий, относящихся к этой группе. - Считаем ТОЛЬКО те уроки, которые явно привязаны к группе через поле Lesson.group, - чтобы не путать общую историю ученика с его занятиями в составе конкретной группы. - """ - from apps.schedule.models import Lesson - - return Lesson.objects.filter( - mentor=obj.mentor, - group=obj, - ).exclude(status='cancelled') - - def get_scheduled_lessons(self, obj): - """ - Количество запланированных занятий для текущей группы. - Статусы: scheduled, in_progress. - """ - # Используем аннотацию, если она есть (для списка) - if hasattr(obj, 'scheduled_lessons_annotated'): - return obj.scheduled_lessons_annotated - qs = self._base_queryset(obj) - return qs.filter(status__in=['scheduled', 'in_progress']).count() - - def get_completed_lessons(self, obj): - """ - Количество проведённых (завершённых) занятий для текущей группы. - Статус: completed. - """ - # Используем аннотацию, если она есть (для списка) - if hasattr(obj, 'completed_lessons_annotated'): - return obj.completed_lessons_annotated - qs = self._base_queryset(obj) - return qs.filter(status='completed').count() - - def create(self, validated_data): - students_ids = validated_data.pop('students_ids', []) - request = self.context.get('request') - mentor = request.user if request else None - - group = Group.objects.create( - mentor=mentor, - **validated_data, - ) - - if students_ids: - students = Client.objects.filter(id__in=students_ids) - group.students.set(students) - - return group - - def update(self, instance, validated_data): - students_ids = validated_data.pop('students_ids', None) - - for attr, value in validated_data.items(): - setattr(instance, attr, value) - instance.save() - - if students_ids is not None: - students = Client.objects.filter(id__in=students_ids) - instance.students.set(students) - - return instance +""" +Сериализаторы для пользователей. +""" +import re +from urllib.parse import unquote +from rest_framework import serializers +from django.contrib.auth.password_validation import validate_password +from django.contrib.auth import authenticate +from .models import User, Client, Parent, Group + + +def _decode_if_url_encoded(value: str) -> str: + """Если строка в формате URL-encoded (%XX), декодирует в UTF-8.""" + if not value or not isinstance(value, str): + return value + if re.search(r'%[0-9A-Fa-f]{2}', value): + try: + return unquote(value, encoding='utf-8') + except Exception: + pass + return value + + +class UserSerializer(serializers.ModelSerializer): + """Базовый сериализатор пользователя.""" + + avatar_url = serializers.SerializerMethodField() + invitation_link = serializers.SerializerMethodField() + login_link = serializers.SerializerMethodField() + + class Meta: + model = User + fields = [ + 'id', 'email', 'first_name', 'last_name', 'role', + 'phone', 'avatar', 'avatar_url', 'birth_date', 'bio', + 'telegram_id', 'telegram_username', + 'timezone', 'language', + 'country', 'city', + 'email_verified', 'is_active', + 'universal_code', # 8-символьный код (цифры + латинские буквы) для добавления ментором + 'invitation_link_token', 'invitation_link', + 'login_token', 'login_link', + 'notifications_enabled', 'email_notifications', 'telegram_notifications', + 'created_at', 'last_activity' + ] + read_only_fields = ['id', 'email_verified', 'universal_code', 'invitation_link_token', 'login_token', 'created_at', 'last_activity'] + + def get_avatar_url(self, obj): + """Получить полный URL аватара.""" + if obj.avatar: + request = self.context.get('request') + if request: + return request.build_absolute_uri(obj.avatar.url) + return obj.avatar.url + return None + + def get_invitation_link(self, obj): + """Получить полную ссылку-приглашение (только для менторов).""" + if obj.role == 'mentor' and obj.invitation_link_token: + from django.conf import settings + frontend_url = getattr(settings, 'FRONTEND_URL', '').rstrip('/') + return f"{frontend_url}/invite/{obj.invitation_link_token}" + return None + + def get_login_link(self, obj): + """Получить персональную ссылку для входа (для учеников).""" + if obj.login_token: + from django.conf import settings + frontend_url = getattr(settings, 'FRONTEND_URL', '').rstrip('/') + return f"{frontend_url}/login/token/{obj.login_token}" + return None + + def to_representation(self, instance): + """Декодируем first_name и last_name, если они пришли в БД в формате URL-encoded.""" + data = super().to_representation(instance) + if 'first_name' in data and data['first_name']: + data['first_name'] = _decode_if_url_encoded(data['first_name']) + if 'last_name' in data and data['last_name']: + data['last_name'] = _decode_if_url_encoded(data['last_name']) + return data + + +class UserDetailSerializer(UserSerializer): + """Детальный сериализатор пользователя с дополнительной информацией.""" + + full_name = serializers.CharField(source='get_full_name', read_only=True) + short_name = serializers.CharField(source='get_short_name', read_only=True) + is_mentor = serializers.BooleanField(read_only=True) + is_client = serializers.BooleanField(read_only=True) + is_parent = serializers.BooleanField(read_only=True) + + class Meta(UserSerializer.Meta): + fields = UserSerializer.Meta.fields + [ + 'full_name', 'short_name', 'is_mentor', 'is_client', 'is_parent' + ] + + +class RegisterSerializer(serializers.ModelSerializer): + """Сериализатор для регистрации пользователя.""" + + password = serializers.CharField( + write_only=True, + required=True, + validators=[validate_password], + style={'input_type': 'password'} + ) + password_confirm = serializers.CharField( + write_only=True, + required=True, + style={'input_type': 'password'} + ) + + class Meta: + model = User + fields = [ + 'email', 'password', 'password_confirm', + 'first_name', 'last_name', 'role', + 'phone', 'birth_date', 'timezone', 'language', + 'country', 'city', + ] + extra_kwargs = { + 'first_name': {'required': True}, + 'last_name': {'required': True}, + 'city': {'required': True}, + 'timezone': {'required': True}, + } + + def validate_email(self, value): + """Нормализация email в нижний регистр.""" + return value.lower().strip() if value else value + + def validate(self, attrs): + """Проверка совпадения паролей.""" + if attrs.get('password') != attrs.get('password_confirm'): + raise serializers.ValidationError({ + 'password_confirm': 'Пароли не совпадают' + }) + return attrs + + def validate_role(self, value): + """Проверка допустимых ролей при регистрации.""" + allowed_roles = ['client', 'mentor', 'parent'] + if value not in allowed_roles: + raise serializers.ValidationError( + f'Недопустимая роль. Доступные роли: {", ".join(allowed_roles)}' + ) + return value + + def create(self, validated_data): + """Создание пользователя.""" + validated_data.pop('password_confirm') + password = validated_data.pop('password') + + user = User.objects.create_user( + password=password, + **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) + elif user.role == 'parent': + Parent.objects.create(user=user) + + return user + + +class TelegramAuthSerializer(serializers.Serializer): + """Сериализатор для авторизации через Telegram.""" + + id = serializers.IntegerField(required=True) + first_name = serializers.CharField(required=True) + last_name = serializers.CharField(required=False, allow_blank=True) + username = serializers.CharField(required=False, allow_blank=True) + photo_url = serializers.URLField(required=False, allow_blank=True) + auth_date = serializers.IntegerField(required=True) + hash = serializers.CharField(required=True) + role = serializers.ChoiceField( + choices=['mentor', 'client'], + required=False, + default='client', + help_text='Роль пользователя при регистрации' + ) + + def validate(self, attrs): + """Валидация данных Telegram.""" + from django.conf import settings + from .telegram_auth import validate_telegram_data + + # Восстанавливаем hash для валидации + telegram_data = { + 'id': attrs['id'], + 'first_name': attrs['first_name'], + 'last_name': attrs.get('last_name', ''), + 'username': attrs.get('username', ''), + 'photo_url': attrs.get('photo_url', ''), + 'auth_date': attrs['auth_date'], + 'hash': attrs['hash'], + } + + # Удаляем пустые поля для валидации + telegram_data = {k: v for k, v in telegram_data.items() if v} + + bot_token = settings.TELEGRAM_BOT_TOKEN + if not bot_token: + raise serializers.ValidationError("Telegram бот не настроен") + + if not validate_telegram_data(telegram_data.copy(), bot_token): + raise serializers.ValidationError("Неверные данные Telegram") + + return attrs + + +class LoginSerializer(serializers.Serializer): + """Сериализатор для входа пользователя.""" + + email = serializers.EmailField(required=True) + password = serializers.CharField( + required=True, + write_only=True, + style={'input_type': 'password'} + ) + + def validate_email(self, value): + """Нормализация email в нижний регистр.""" + return value.lower().strip() if value else value + + def validate(self, attrs): + """Проверка учетных данных.""" + email = attrs.get('email') + password = attrs.get('password') + + if email and password: + # Проверяем существование пользователя + try: + user = User.objects.get(email=email) + except User.DoesNotExist: + raise serializers.ValidationError({ + 'email': 'Пользователь с таким email не найден' + }) + + # Проверяем пароль + if not user.check_password(password): + raise serializers.ValidationError({ + 'password': 'Неверный пароль' + }) + + # Проверяем активность пользователя + if not user.is_active: + raise serializers.ValidationError({ + 'email': 'Аккаунт неактивен' + }) + + # Проверяем блокировку + if user.is_blocked: + raise serializers.ValidationError({ + 'email': f'Аккаунт заблокирован. Причина: {user.blocked_reason}' + }) + + attrs['user'] = user + else: + raise serializers.ValidationError({ + 'email': 'Email и пароль обязательны' + }) + + return attrs + + +class ChangePasswordSerializer(serializers.Serializer): + """Сериализатор для смены пароля.""" + + old_password = serializers.CharField( + required=True, + write_only=True, + style={'input_type': 'password'} + ) + new_password = serializers.CharField( + required=True, + write_only=True, + validators=[validate_password], + style={'input_type': 'password'} + ) + new_password_confirm = serializers.CharField( + required=True, + write_only=True, + style={'input_type': 'password'} + ) + + def validate(self, attrs): + """Проверка совпадения новых паролей.""" + if attrs.get('new_password') != attrs.get('new_password_confirm'): + raise serializers.ValidationError({ + 'new_password_confirm': 'Пароли не совпадают' + }) + return attrs + + def validate_old_password(self, value): + """Проверка старого пароля.""" + user = self.context['request'].user + if not user.check_password(value): + raise serializers.ValidationError('Неверный старый пароль') + return value + + +class PasswordResetRequestSerializer(serializers.Serializer): + """Сериализатор для запроса восстановления пароля.""" + + email = serializers.EmailField(required=True) + + def validate_email(self, value): + """Нормализация email в нижний регистр и проверка существования.""" + # Нормализуем email в нижний регистр + normalized_email = value.lower().strip() if value else value + try: + User.objects.get(email=normalized_email) + except User.DoesNotExist: + # Не раскрываем информацию о существовании email + pass + return normalized_email + + +class PasswordResetConfirmSerializer(serializers.Serializer): + """Сериализатор для подтверждения восстановления пароля.""" + + token = serializers.CharField(required=True) + new_password = serializers.CharField( + required=True, + write_only=True, + validators=[validate_password], + style={'input_type': 'password'} + ) + new_password_confirm = serializers.CharField( + required=True, + write_only=True, + style={'input_type': 'password'} + ) + + def validate(self, attrs): + """Проверка совпадения паролей.""" + if attrs.get('new_password') != attrs.get('new_password_confirm'): + raise serializers.ValidationError({ + 'new_password_confirm': 'Пароли не совпадают' + }) + return attrs + + +class EmailVerificationSerializer(serializers.Serializer): + """Сериализатор для подтверждения email.""" + + token = serializers.CharField(required=True) + + +class ClientSerializer(serializers.ModelSerializer): + """Сериализатор для клиента.""" + + user = UserSerializer(read_only=True) + mentors = UserSerializer(many=True, read_only=True) # Добавляем менторов + scheduled_lessons = serializers.SerializerMethodField() + total_lessons = serializers.SerializerMethodField() + completed_lessons = serializers.SerializerMethodField() + + class Meta: + model = Client + fields = [ + 'id', 'user', 'mentors', 'grade', 'school', 'learning_goals', + 'total_lessons', 'completed_lessons', 'scheduled_lessons', + 'enrollment_date', 'created_at' + ] + read_only_fields = [ + 'id', 'total_lessons', 'completed_lessons', 'scheduled_lessons', + 'enrollment_date', 'created_at' + ] + + def get_scheduled_lessons(self, obj): + """Количество запланированных занятий.""" + # Оптимизация: если queryset был заранее аннотирован, не делаем отдельные запросы в БД + if hasattr(obj, 'scheduled_lessons_annotated'): + return int(getattr(obj, 'scheduled_lessons_annotated') or 0) + from apps.schedule.models import Lesson + request = self.context.get('request') + if request and request.user and request.user.role == 'mentor': + # Считаем только занятия этого ментора + return Lesson.objects.filter( + client=obj, + mentor=request.user, + status='scheduled' + ).count() + # Если нет контекста, считаем все занятия + return Lesson.objects.filter( + client=obj, + status='scheduled' + ).count() + + def get_total_lessons(self, obj): + """Общее количество занятий (все статусы кроме отмененных).""" + # Оптимизация: если queryset был заранее аннотирован, не делаем отдельные запросы в БД + if hasattr(obj, 'total_lessons_annotated'): + return int(getattr(obj, 'total_lessons_annotated') or 0) + from apps.schedule.models import Lesson + request = self.context.get('request') + if request and request.user and request.user.role == 'mentor': + # Считаем только занятия этого ментора + return Lesson.objects.filter( + client=obj, + mentor=request.user + ).exclude(status='cancelled').count() + # Если нет контекста, считаем все занятия + return Lesson.objects.filter( + client=obj + ).exclude(status='cancelled').count() + + def get_completed_lessons(self, obj): + """Количество завершенных занятий.""" + # Оптимизация: если queryset был заранее аннотирован, не делаем отдельные запросы в БД + if hasattr(obj, 'completed_lessons_annotated'): + return int(getattr(obj, 'completed_lessons_annotated') or 0) + from apps.schedule.models import Lesson + request = self.context.get('request') + if request and request.user and request.user.role == 'mentor': + # Считаем только занятия этого ментора + return Lesson.objects.filter( + client=obj, + mentor=request.user, + status='completed' + ).count() + # Если нет контекста, считаем все занятия + return Lesson.objects.filter( + client=obj, + status='completed' + ).count() + + +class ParentSerializer(serializers.ModelSerializer): + """Сериализатор для родителя.""" + + user = UserSerializer(read_only=True) + children = ClientSerializer(many=True, read_only=True) + + class Meta: + model = Parent + fields = [ + 'id', 'user', 'children', 'relation_type', + 'can_view_progress', 'can_view_schedule', 'can_receive_reports', + 'created_at' + ] + read_only_fields = ['id', 'created_at'] + + +class GroupSerializer(serializers.ModelSerializer): + """Сериализатор учебной группы.""" + + mentor = UserSerializer(read_only=True) + students = ClientSerializer(many=True, read_only=True) + students_ids = serializers.ListField( + child=serializers.IntegerField(), + write_only=True, + required=False, + allow_empty=True, + ) + students_count = serializers.SerializerMethodField() + scheduled_lessons = serializers.SerializerMethodField() + completed_lessons = serializers.SerializerMethodField() + + class Meta: + model = Group + fields = [ + 'id', + 'mentor', + 'name', + 'description', + 'students', + 'students_ids', + 'students_count', + 'scheduled_lessons', + 'completed_lessons', + 'created_at', + 'updated_at', + ] + read_only_fields = ['id', 'mentor', 'students', 'students_count', 'scheduled_lessons', 'completed_lessons', 'created_at', 'updated_at'] + + def get_students_count(self, obj): + # Используем аннотацию, если она есть (для списка) + if hasattr(obj, 'students_count_annotated'): + return obj.students_count_annotated + return obj.students.count() + + def _base_queryset(self, obj): + """ + Базовый queryset занятий, относящихся к этой группе. + Считаем ТОЛЬКО те уроки, которые явно привязаны к группе через поле Lesson.group, + чтобы не путать общую историю ученика с его занятиями в составе конкретной группы. + """ + from apps.schedule.models import Lesson + + return Lesson.objects.filter( + mentor=obj.mentor, + group=obj, + ).exclude(status='cancelled') + + def get_scheduled_lessons(self, obj): + """ + Количество запланированных занятий для текущей группы. + Статусы: scheduled, in_progress. + """ + # Используем аннотацию, если она есть (для списка) + if hasattr(obj, 'scheduled_lessons_annotated'): + return obj.scheduled_lessons_annotated + qs = self._base_queryset(obj) + return qs.filter(status__in=['scheduled', 'in_progress']).count() + + def get_completed_lessons(self, obj): + """ + Количество проведённых (завершённых) занятий для текущей группы. + Статус: completed. + """ + # Используем аннотацию, если она есть (для списка) + if hasattr(obj, 'completed_lessons_annotated'): + return obj.completed_lessons_annotated + qs = self._base_queryset(obj) + return qs.filter(status='completed').count() + + def create(self, validated_data): + students_ids = validated_data.pop('students_ids', []) + request = self.context.get('request') + mentor = request.user if request else None + + group = Group.objects.create( + mentor=mentor, + **validated_data, + ) + + if students_ids: + students = Client.objects.filter(id__in=students_ids) + group.students.set(students) + + return group + + def update(self, instance, validated_data): + students_ids = validated_data.pop('students_ids', None) + + for attr, value in validated_data.items(): + setattr(instance, attr, value) + instance.save() + + if students_ids is not None: + students = Client.objects.filter(id__in=students_ids) + instance.students.set(students) + + return instance diff --git a/backend/config/settings.py b/backend/config/settings.py index 09ce960..6c323be 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -391,10 +391,10 @@ REST_FRAMEWORK = { 'rest_framework.throttling.UserRateThrottle', ], 'DEFAULT_THROTTLE_RATES': { - 'anon': '100/hour', # Для неавторизованных пользователей - 'user': '1000/hour', # Для авторизованных пользователей + 'anon': '200/hour', # Для неавторизованных пользователей + 'user': '5000/hour', # Для авторизованных пользователей 'burst': '60/minute', # Для критичных endpoints (login, register) - 'upload': '20/hour', # Для загрузки файлов + 'upload': '60/hour', # Для загрузки файлов }, } diff --git a/front_material/Dockerfile b/front_material/Dockerfile index 997872f..58b931c 100644 --- a/front_material/Dockerfile +++ b/front_material/Dockerfile @@ -84,28 +84,41 @@ RUN mkdir -p public ENV NODE_ENV=production RUN npm run build +# Проверяем, что standalone создался (выводим структуру для отладки) +RUN echo "=== Checking standalone output ===" && \ + ls -la /app/.next/ || echo "No .next directory" && \ + ls -la /app/.next/standalone/ 2>/dev/null || echo "No standalone directory" && \ + test -f /app/.next/standalone/server.js || (echo "ERROR: server.js not found in standalone" && ls -la /app/.next/standalone/ && exit 1) + # Production stage FROM node:20-alpine AS production WORKDIR /app +ENV NODE_ENV=production +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + # Копируем собранное приложение (standalone mode) -COPY --from=production-build /app/.next/standalone ./ -COPY --from=production-build /app/.next/static ./.next/static -COPY --from=production-build /app/public ./public +# В Next.js standalone структура: /app/.next/standalone/ содержит server.js, .next/server/, node_modules/ +# Копируем всё содержимое standalone в корень /app +COPY --from=production-build --chown=nextjs:nodejs /app/.next/standalone ./ +# Статические файлы (standalone их не включает автоматически) +COPY --from=production-build --chown=nextjs:nodejs /app/.next/static ./.next/static +# Public файлы (standalone их не включает автоматически) +COPY --from=production-build --chown=nextjs:nodejs /app/public ./public + +# Проверяем, что server.js существует +RUN test -f /app/server.js || (echo "ERROR: server.js not found after copy" && ls -la /app/ && exit 1) # Создаем непривилегированного пользователя -RUN addgroup --system --gid 1001 nodejs -RUN adduser --system --uid 1001 nextjs -RUN chown -R nextjs:nodejs /app +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nextjs + USER nextjs # Открываем порт EXPOSE 3000 -ENV NODE_ENV=production -ENV PORT=3000 -ENV HOSTNAME="0.0.0.0" - # Запускаем production server CMD ["node", "server.js"] diff --git a/front_material/api/subscriptions.ts b/front_material/api/subscriptions.ts index 5042766..020a6c7 100644 --- a/front_material/api/subscriptions.ts +++ b/front_material/api/subscriptions.ts @@ -1,38 +1,38 @@ -/** - * API для подписок и оплаты - */ - -import apiClient from '@/lib/api-client'; - -export interface Subscription { - id: number; - plan: { id: number; name: string }; - start_date: string; - end_date: string; - student_count?: number; -} - -export async function getActiveSubscription(): Promise { - try { - const response = await apiClient.get('/subscriptions/subscriptions/active/'); - return response.data; - } catch { - return null; - } -} - -export interface ActivateFreeParams { - plan_id: number; - duration_days?: number; - student_count?: number; -} - -/** Активировать бесплатный тариф (цена 0) без создания платежа */ -export async function activateFreeSubscription(params: ActivateFreeParams): Promise<{ success: boolean; subscription: Subscription }> { - const url = '/subscriptions/subscriptions/activate_free/'; - if (typeof window !== 'undefined') { - console.log('[API] POST', url, params); - } - const response = await apiClient.post<{ success: boolean; subscription: Subscription }>(url, params); - return response.data; -} +/** + * API для подписок и оплаты + */ + +import apiClient from '@/lib/api-client'; + +export interface Subscription { + id: number; + plan: { id: number; name: string }; + start_date: string; + end_date: string; + student_count?: number; +} + +export async function getActiveSubscription(): Promise { + try { + const response = await apiClient.get('/subscriptions/subscriptions/active/'); + return response.data; + } catch { + return null; + } +} + +export interface ActivateFreeParams { + plan_id: number; + duration_days?: number; + student_count?: number; +} + +/** Активировать бесплатный тариф (цена 0) без создания платежа */ +export async function activateFreeSubscription(params: ActivateFreeParams): Promise<{ success: boolean; subscription: Subscription }> { + const url = '/subscriptions/subscriptions/activate_free/'; + if (typeof window !== 'undefined') { + console.log('[API] POST', url, params); + } + const response = await apiClient.post<{ success: boolean; subscription: Subscription }>(url, params); + return response.data; +} diff --git a/front_material/app/(auth)/layout.tsx b/front_material/app/(auth)/layout.tsx index b741839..c5d6d2e 100644 --- a/front_material/app/(auth)/layout.tsx +++ b/front_material/app/(auth)/layout.tsx @@ -8,6 +8,7 @@ export default function AuthLayout({ return (
{/* Левая колонка — пустая, фон как у body */}
([]); - const [selected, setSelected] = React.useState(null); - const [error, setError] = React.useState(null); - const [hasMore, setHasMore] = React.useState(false); - const [page, setPage] = React.useState(1); - const [loadingMore, setLoadingMore] = React.useState(false); - usePresenceWebSocket({ enabled: true }); - const refreshNavBadges = useNavBadgesRefresh(); - - // На странице чата не должно быть общего скролла (скроллим только панели внутри) - React.useEffect(() => { - const prevHtml = document.documentElement.style.overflow; - const prevBody = document.body.style.overflow; - document.documentElement.style.overflow = 'hidden'; - document.body.style.overflow = 'hidden'; - return () => { - document.documentElement.style.overflow = prevHtml; - document.body.style.overflow = prevBody; - }; - }, []); - - const normalizeChat = React.useCallback((c: any) => { - const otherName = - c?.other_participant?.full_name || - [c?.other_participant?.first_name, c?.other_participant?.last_name].filter(Boolean).join(' ') || - c?.participant_name || - 'Чат'; - const avatarUrl = c?.other_participant?.avatar_url || c?.other_participant?.avatar || null; - const otherId = c?.other_participant?.id ?? null; - const otherOnline = !!c?.other_participant?.is_online; - const otherLast = c?.other_participant?.last_activity ?? null; - const lastText = c?.last_message?.content || c?.last_message?.text || c?.last_message || ''; - const unread = c?.my_participant?.unread_count ?? c?.unread_count ?? 0; - return { - id: c.id, - uuid: c.uuid, - participant_name: otherName, - avatar_url: avatarUrl, - other_user_id: otherId, - other_is_online: otherOnline, - other_last_activity: otherLast, - last_message: lastText, - unread_count: unread, - created_at: c.created_at, - }; - }, []); - - React.useEffect(() => { - (async () => { - try { - setLoading(true); - setError(null); - const resp = await getConversations({ page: 1, page_size: 30 }); - const normalized = (resp.results || []).map((c: any) => normalizeChat(c)); - setChats(normalized as any); - setHasMore(!!(resp as any).next); - setPage(1); - } catch (e: any) { - console.error('[ChatPage] Ошибка загрузки чатов:', e); - const msg = - e?.response?.data?.detail || - e?.response?.data?.error || - e?.message || - 'Не удалось загрузить чаты'; - setError(String(msg)); - } finally { - setLoading(false); - } - })(); - }, [normalizeChat]); - - const restoredForUuidRef = React.useRef(null); - - // Восстановить выбранный чат из URL после загрузки списка (или по uuid) - React.useEffect(() => { - if (loading || error || !uuidFromUrl) return; - if (restoredForUuidRef.current === uuidFromUrl) return; - const found = chats.find((c) => (c as any).uuid === uuidFromUrl); - if (found) { - setSelected(found as Chat); - restoredForUuidRef.current = uuidFromUrl; - return; - } - (async () => { - try { - const c = await getChatById(uuidFromUrl); - const normalized = normalizeChat(c) as any; - setSelected(normalized as Chat); - restoredForUuidRef.current = uuidFromUrl; - } catch (e: any) { - console.warn('[ChatPage] Чат по uuid из URL не найден:', uuidFromUrl, e); - restoredForUuidRef.current = null; - router.replace(pathname ?? '/chat'); - } - })(); - }, [loading, error, uuidFromUrl, chats, normalizeChat, router, pathname]); - - React.useEffect(() => { - if (!uuidFromUrl) restoredForUuidRef.current = null; - }, [uuidFromUrl]); - - const handleSelectChat = React.useCallback( - (c: Chat) => { - setSelected(c); - const u = (c as any).uuid; - if (u) { - const base = pathname ?? '/chat'; - router.replace(`${base}?uuid=${encodeURIComponent(u)}`); - } - }, - [router, pathname] - ); - - const loadMore = React.useCallback(async () => { - if (loadingMore || !hasMore) return; - try { - setLoadingMore(true); - const next = page + 1; - const resp = await getConversations({ page: next, page_size: 30 }); - const normalized = (resp.results || []).map((c: any) => normalizeChat(c)); - setChats((prev) => [...prev, ...(normalized as any)]); - setHasMore(!!(resp as any).next); - setPage(next); - } catch (e: any) { - console.error('[ChatPage] Ошибка загрузки чатов:', e); - } finally { - setLoadingMore(false); - } - }, [page, hasMore, loadingMore, normalizeChat]); - - const refreshChatListUnread = React.useCallback(async () => { - try { - const resp = await getConversations({ page: 1, page_size: 30 }); - const fresh = (resp.results || []).map((c: any) => normalizeChat(c)) as Chat[]; - const freshByUuid = new Map(fresh.map((c: any) => [(c as any).uuid, c])); - setChats((prev) => - prev.map((c: any) => { - const updated = freshByUuid.get(c.uuid); - return updated ? (updated as Chat) : c; - }) - ); - await refreshNavBadges?.(); - } catch { - // ignore - } - }, [normalizeChat, refreshNavBadges]); - - return ( -
- - {loading ? ( - - Загрузка… - - ) : error ? ( - - {error} - - ) : ( - - )} - - - -
- ); -} +'use client'; + +import React from 'react'; +import { useRouter, usePathname, useSearchParams } from 'next/navigation'; +import { Box, Typography } from '@mui/material'; +import { getConversations, getChatById } from '@/api/chat'; +import type { Chat } from '@/api/chat'; +import { ChatList } from '@/components/chat/ChatList'; +import { ChatWindow } from '@/components/chat/ChatWindow'; +import { usePresenceWebSocket } from '@/hooks/usePresenceWebSocket'; +import { useAuth } from '@/contexts/AuthContext'; +import { useNavBadgesRefresh } from '@/contexts/NavBadgesContext'; +import { useIsMobile } from '@/hooks/useIsMobile'; + +export default function ChatPage() { + const { user } = useAuth(); + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const uuidFromUrl = searchParams.get('uuid'); + const isMobile = useIsMobile(); + + const [loading, setLoading] = React.useState(true); + const [chats, setChats] = React.useState([]); + const [selected, setSelected] = React.useState(null); + const [error, setError] = React.useState(null); + const [hasMore, setHasMore] = React.useState(false); + const [page, setPage] = React.useState(1); + const [loadingMore, setLoadingMore] = React.useState(false); + usePresenceWebSocket({ enabled: true }); + const refreshNavBadges = useNavBadgesRefresh(); + + // На странице чата не должно быть общего скролла (скроллим только панели внутри) + React.useEffect(() => { + const prevHtml = document.documentElement.style.overflow; + const prevBody = document.body.style.overflow; + document.documentElement.style.overflow = 'hidden'; + document.body.style.overflow = 'hidden'; + return () => { + document.documentElement.style.overflow = prevHtml; + document.body.style.overflow = prevBody; + }; + }, []); + + const normalizeChat = React.useCallback((c: any) => { + const otherName = + c?.other_participant?.full_name || + [c?.other_participant?.first_name, c?.other_participant?.last_name].filter(Boolean).join(' ') || + c?.participant_name || + 'Чат'; + const avatarUrl = c?.other_participant?.avatar_url || c?.other_participant?.avatar || null; + const otherId = c?.other_participant?.id ?? null; + const otherOnline = !!c?.other_participant?.is_online; + const otherLast = c?.other_participant?.last_activity ?? null; + const lastText = c?.last_message?.content || c?.last_message?.text || c?.last_message || ''; + const unread = c?.my_participant?.unread_count ?? c?.unread_count ?? 0; + return { + id: c.id, + uuid: c.uuid, + participant_name: otherName, + avatar_url: avatarUrl, + other_user_id: otherId, + other_is_online: otherOnline, + other_last_activity: otherLast, + last_message: lastText, + unread_count: unread, + created_at: c.created_at, + }; + }, []); + + React.useEffect(() => { + (async () => { + try { + setLoading(true); + setError(null); + const resp = await getConversations({ page: 1, page_size: 30 }); + const normalized = (resp.results || []).map((c: any) => normalizeChat(c)); + setChats(normalized as any); + setHasMore(!!(resp as any).next); + setPage(1); + } catch (e: any) { + console.error('[ChatPage] Ошибка загрузки чатов:', e); + const msg = + e?.response?.data?.detail || + e?.response?.data?.error || + e?.message || + 'Не удалось загрузить чаты'; + setError(String(msg)); + } finally { + setLoading(false); + } + })(); + }, [normalizeChat]); + + const restoredForUuidRef = React.useRef(null); + + // Восстановить выбранный чат из URL после загрузки списка (или по uuid) + React.useEffect(() => { + if (loading || error || !uuidFromUrl) return; + if (restoredForUuidRef.current === uuidFromUrl) return; + const found = chats.find((c) => (c as any).uuid === uuidFromUrl); + if (found) { + setSelected(found as Chat); + restoredForUuidRef.current = uuidFromUrl; + return; + } + (async () => { + try { + const c = await getChatById(uuidFromUrl); + const normalized = normalizeChat(c) as any; + setSelected(normalized as Chat); + restoredForUuidRef.current = uuidFromUrl; + } catch (e: any) { + console.warn('[ChatPage] Чат по uuid из URL не найден:', uuidFromUrl, e); + restoredForUuidRef.current = null; + router.replace(pathname ?? '/chat'); + } + })(); + }, [loading, error, uuidFromUrl, chats, normalizeChat, router, pathname]); + + React.useEffect(() => { + if (!uuidFromUrl) restoredForUuidRef.current = null; + }, [uuidFromUrl]); + + const handleSelectChat = React.useCallback( + (c: Chat) => { + setSelected(c); + const u = (c as any).uuid; + if (u) { + const base = pathname ?? '/chat'; + router.replace(`${base}?uuid=${encodeURIComponent(u)}`); + } + }, + [router, pathname] + ); + + const loadMore = React.useCallback(async () => { + if (loadingMore || !hasMore) return; + try { + setLoadingMore(true); + const next = page + 1; + const resp = await getConversations({ page: next, page_size: 30 }); + const normalized = (resp.results || []).map((c: any) => normalizeChat(c)); + setChats((prev) => [...prev, ...(normalized as any)]); + setHasMore(!!(resp as any).next); + setPage(next); + } catch (e: any) { + console.error('[ChatPage] Ошибка загрузки чатов:', e); + } finally { + setLoadingMore(false); + } + }, [page, hasMore, loadingMore, normalizeChat]); + + const refreshChatListUnread = React.useCallback(async () => { + try { + const resp = await getConversations({ page: 1, page_size: 30 }); + const fresh = (resp.results || []).map((c: any) => normalizeChat(c)) as Chat[]; + const freshByUuid = new Map(fresh.map((c: any) => [(c as any).uuid, c])); + setChats((prev) => + prev.map((c: any) => { + const updated = freshByUuid.get(c.uuid); + return updated ? (updated as Chat) : c; + }) + ); + await refreshNavBadges?.(); + } catch { + // ignore + } + }, [normalizeChat, refreshNavBadges]); + + const handleBackToList = React.useCallback(() => { + setSelected(null); + router.replace(pathname ?? '/chat'); + }, [router, pathname]); + + // Mobile: show only list or only chat + const mobileShowChat = isMobile && selected != null; + + // Hide bottom navigation when a chat is open on mobile + React.useEffect(() => { + if (mobileShowChat) { + document.documentElement.classList.add('mobile-chat-open'); + } else { + document.documentElement.classList.remove('mobile-chat-open'); + } + return () => { + document.documentElement.classList.remove('mobile-chat-open'); + }; + }, [mobileShowChat]); + + return ( +
+ + {/* Chat list: hidden on mobile when a chat is selected */} + {!mobileShowChat && ( + <> + {loading ? ( + + Загрузка… + + ) : error ? ( + + {error} + + ) : ( + + )} + + )} + + {/* Chat window: on mobile only visible when a chat is selected */} + {(!isMobile || mobileShowChat) && ( + + )} + +
+ ); +} diff --git a/front_material/app/(protected)/layout.tsx b/front_material/app/(protected)/layout.tsx index c506ab6..6bbb167 100644 --- a/front_material/app/(protected)/layout.tsx +++ b/front_material/app/(protected)/layout.tsx @@ -1,19 +1,7 @@ 'use client'; import { useEffect, useState, useCallback, Suspense } from 'react'; - -const MOBILE_BREAKPOINT = 767; -function useIsMobile() { - const [isMobile, setIsMobile] = useState(false); - useEffect(() => { - const mq = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`); - setIsMobile(mq.matches); - const listener = () => setIsMobile(mq.matches); - mq.addEventListener('change', listener); - return () => mq.removeEventListener('change', listener); - }, []); - return isMobile; -} +import { useIsMobile } from '@/hooks/useIsMobile'; import { useRouter, usePathname } from 'next/navigation'; import { BottomNavigationBar } from '@/components/navigation/BottomNavigationBar'; import { TopNavigationBar } from '@/components/navigation/TopNavigationBar'; @@ -91,9 +79,9 @@ export default function ProtectedLayout({ console.log('[ProtectedLayout] Auth state:', { user: !!user, loading, hasToken: !!token, pathname }); - if (!loading && !user && !token) { - console.log('[ProtectedLayout] Redirecting to login'); - router.push('/login'); + if (!loading && !user) { + console.log('[ProtectedLayout] No user found, redirecting to login'); + router.replace('/login'); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [user, loading]); @@ -114,7 +102,14 @@ export default function ProtectedLayout({ } if (!user) { - return null; + return ( +
+
+ +

Проверка авторизации...

+
+
+ ); } // Ментор не на /payment: ждём результат проверки подписки, чтобы не показывать контент перед редиректом @@ -153,24 +148,13 @@ export default function ProtectedLayout({ return ( -
+
{!isFullWidthPage && }
= { - image: 'image', - video: 'videocam', - audio: 'audiotrack', - document: 'description', - presentation: 'slideshow', - archive: 'folder_zip', - other: 'insert_drive_file', -}; - -// Определить тип для иконки по material_type, MIME и расширению файла -function getMaterialTypeForIcon(material: any): string { - const type = material?.material_type; - if (type && type !== 'other' && MATERIAL_ICONS[type]) return type; - const mime = (material?.file_type || '').toLowerCase(); - const name = material?.file_name || material?.file || ''; - if (mime.startsWith('image/') || /\.(jpe?g|png|gif|webp|bmp|svg)(\?|$)/i.test(name)) return 'image'; - if (mime.startsWith('video/') || /\.(mp4|webm|ogg|mov|avi)(\?|$)/i.test(name)) return 'video'; - if (mime.startsWith('audio/') || /\.(mp3|wav|ogg|m4a)(\?|$)/i.test(name)) return 'audio'; - if (mime.includes('pdf') || /\.pdf(\?|$)/i.test(name) || mime.includes('document') || /\.(docx?|odt)(\?|$)/i.test(name)) return 'document'; - if (mime.includes('presentation') || mime.includes('powerpoint') || /\.(pptx?|odp)(\?|$)/i.test(name)) return 'presentation'; - if (mime.includes('zip') || mime.includes('rar') || mime.includes('archive') || /\.(zip|rar|7z|tar|gz)(\?|$)/i.test(name)) return 'archive'; - return 'other'; -} - -function getMaterialIcon(material: any): string { - const type = getMaterialTypeForIcon(material); - return MATERIAL_ICONS[type] || MATERIAL_ICONS.other; -} - -// Базовый URL медиа (тот же хост, что и API) -function getMediaBaseUrl(): string { - if (typeof window === 'undefined') return ''; - const protocol = window.location.protocol; - const hostname = window.location.hostname; - return `${protocol}//${hostname}:8123`; -} - -// Получить URL медиа для превью: собираем на фронте, чтобы хост совпадал с API -function getMediaUrl(material: any): string | null { - if (!material) return null; - const base = getMediaBaseUrl(); - if (material.file) { - const f = String(material.file).trim(); - if (f.startsWith('http')) return f; - // Бэкенд отдаёт путь вида /media/materials/... или materials/... - const path = f.startsWith('/') ? f : `/${f}`; - return `${base}${path}`; - } - if (material.file_url) return material.file_url; - return material.url || null; -} - -const IMAGE_MIME_PREFIX = 'image/'; -const IMAGE_EXT = /\.(jpe?g|png|gif|webp|bmp|svg)(\?|$)/i; -const VIDEO_MIME_PREFIX = 'video/'; -const VIDEO_EXT = /\.(mp4|webm|ogg|mov|avi)(\?|$)/i; - -// Сократить строку до 8 символов (с многоточием при обрезке) -function truncateTo8(s: string): string { - const t = (s || '').trim(); - if (t.length <= 8) return t; - return t.slice(0, 8) + '…'; -} - -// Ключ категории типа файла (для фильтра) -type FileTypeCategory = 'image' | 'video' | 'audio' | 'document' | 'presentation' | 'archive' | 'other'; - -function getFileTypeCategory(material: any): FileTypeCategory { - const name = (material?.file_name || material?.file || '').toLowerCase(); - const mime = (material?.file_type || '').toLowerCase(); - const ext = name.match(/\.([a-z0-9]+)(\?|$)/i)?.[1]?.toLowerCase() || ''; - if (mime.startsWith('image/') || ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'].includes(ext)) return 'image'; - if (mime.startsWith('video/') || ['mp4', 'webm', 'ogg', 'mov', 'avi', 'mkv'].includes(ext)) return 'video'; - if (mime.startsWith('audio/') || ['mp3', 'wav', 'ogg', 'm4a', 'flac'].includes(ext)) return 'audio'; - if (mime.includes('pdf') || mime.includes('document') || ['pdf', 'doc', 'docx', 'odt', 'txt', 'rtf'].includes(ext)) return 'document'; - if (mime.includes('presentation') || mime.includes('powerpoint') || ['ppt', 'pptx', 'odp'].includes(ext)) return 'presentation'; - if (mime.includes('zip') || mime.includes('rar') || ['zip', 'rar', '7z', 'tar', 'gz'].includes(ext)) return 'archive'; - return 'other'; -} - -// Подпись типа файла для отображения: Картинка, Документ, Аудио, Видео и т.д. -const FILE_TYPE_LABELS: Record = { - image: 'Картинка', - video: 'Видео', - audio: 'Аудио', - document: 'Документ', - presentation: 'Презентация', - archive: 'Архив', - other: 'Файл', -}; - -function getFileTypeLabel(material: any): string { - return FILE_TYPE_LABELS[getFileTypeCategory(material)]; -} - -// Расширение файла для сообщения «Не удалось открыть файл .xxx» -function getFileExtension(material: any): string { - const name = (material?.file_name || material?.file || '').toLowerCase(); - const m = name.match(/\.([a-z0-9]+)(\?|$)/i); - return m ? `.${m[1]}` : ''; -} - -// Типы, которые браузер не может отобразить — вместо iframe показываем модальное сообщение -const BROWSER_CANNOT_DISPLAY = [ - 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp', - 'zip', 'rar', '7z', 'tar', 'gz', 'exe', 'dmg', 'msi', -]; -function cannotDisplayInBrowser(material: any): boolean { - const ext = getFileExtension(material).replace(/^\./, '').toLowerCase(); - return BROWSER_CANNOT_DISPLAY.includes(ext); -} - -function isImageMaterial(material: any): boolean { - if (material.material_type === 'image') return true; - const mime = (material.file_type || '').toLowerCase(); - if (mime.startsWith(IMAGE_MIME_PREFIX)) return true; - const name = material.file_name || material.file || ''; - return IMAGE_EXT.test(name); -} - -function isVideoMaterial(material: any): boolean { - if (material.material_type === 'video') return true; - const mime = (material.file_type || '').toLowerCase(); - if (mime.startsWith(VIDEO_MIME_PREFIX)) return true; - const name = material.file_name || material.file || ''; - return VIDEO_EXT.test(name); -} - -const PDF_EXT = /\.pdf(\?|$)/i; -const TEXT_EXT = /\.(txt|md|py|php|js|ts|jsx|tsx|vue|json|html|htm|css|scss|less|xml|csv|rtf|log|yml|yaml|env|sql|sh|bat|cmd|ini|cfg|conf)(\?|$)/i; - -function isPdfMaterial(material: any): boolean { - const mime = (material?.file_type || '').toLowerCase(); - if (mime.includes('pdf')) return true; - const name = (material?.file_name || material?.file || '').toLowerCase(); - return PDF_EXT.test(name); -} - -function isTextPreviewMaterial(material: any): boolean { - const mime = (material?.file_type || '').toLowerCase(); - if (mime.startsWith('text/') || mime.includes('json') || mime.includes('javascript') || mime.includes('xml')) return true; - const name = (material?.file_name || material?.file || '').toLowerCase(); - return TEXT_EXT.test(name); -} - -const FILE_TYPE_CHIPS: { value: FileTypeCategory | null; label: string }[] = [ - { value: 'image', label: 'Картинка' }, - { value: 'document', label: 'Документ' }, - { value: 'audio', label: 'Аудио' }, - { value: 'video', label: 'Видео' }, - { value: 'presentation', label: 'Презентация' }, - { value: 'archive', label: 'Архив' }, - { value: 'other', label: 'Файл' }, -]; - -const SEARCH_DEBOUNCE_MS = 400; - -const TEXT_PREVIEW_MAX_CHARS = 1200; -const TEXT_PREVIEW_LINES = 18; - -function MaterialTextPreview({ url }: { url: string }) { - const [text, setText] = useState(null); - const [failed, setFailed] = useState(false); - useEffect(() => { - let cancelled = false; - setFailed(false); - setText(null); - fetch(url) - .then((r) => { - if (!r.ok) throw new Error('fetch failed'); - return r.text(); - }) - .then((t) => { - if (!cancelled) setText(t.slice(0, TEXT_PREVIEW_MAX_CHARS)); - }) - .catch(() => { - if (!cancelled) setFailed(true); - }); - return () => { cancelled = true; }; - }, [url]); - if (failed) { - return ( -
- description -
- ); - } - if (text === null) { - return ( -
Загрузка…
- ); - } - const lines = text.split(/\r?\n/).slice(0, TEXT_PREVIEW_LINES); - const display = lines.join('\n') + (text.length >= TEXT_PREVIEW_MAX_CHARS ? '\n…' : ''); - return ( -
-      {display || ' (пусто)'}
-    
- ); -} - -const TEXT_FULL_MAX_CHARS = 500000; - -function MaterialTextPreviewFull({ url }: { url: string }) { - const [text, setText] = useState(null); - const [failed, setFailed] = useState(false); - useEffect(() => { - let cancelled = false; - setFailed(false); - setText(null); - fetch(url) - .then((r) => { - if (!r.ok) throw new Error('fetch failed'); - return r.text(); - }) - .then((t) => { - if (!cancelled) setText(t.length > TEXT_FULL_MAX_CHARS ? t.slice(0, TEXT_FULL_MAX_CHARS) + '\n\n… (файл обрезан)' : t); - }) - .catch(() => { - if (!cancelled) setFailed(true); - }); - return () => { cancelled = true; }; - }, [url]); - if (failed) { - return ( -
- Не удалось загрузить содержимое -
- ); - } - if (text === null) { - return ( -
Загрузка…
- ); - } - return ( -
-      {text || ' (пусто)'}
-    
- ); -} - -export default function MaterialsPage() { - const { user } = useAuth(); - const isClient = user?.role === 'client'; - - const [componentsLoaded, setComponentsLoaded] = useState(false); - const [searchQuery, setSearchQuery] = useState(''); - const [searchQueryDebounced, setSearchQueryDebounced] = useState(''); - const [fileTypeFilter, setFileTypeFilter] = useState(null); - const [mentorFilter, setMentorFilter] = useState(null); - const [openMenuId, setOpenMenuId] = useState(null); - - // Состояние для редактирования материала - const [editingMaterial, setEditingMaterial] = useState(null); - const [editFormData, setEditFormData] = useState({ title: '', description: '' }); - const [editFile, setEditFile] = useState(null); - const [editLoading, setEditLoading] = useState(false); - const [editError, setEditError] = useState(null); - - // Состояние для выбора учеников - const [students, setStudents] = useState([]); - const [studentsLoading, setStudentsLoading] = useState(false); - const [selectedStudentIds, setSelectedStudentIds] = useState([]); - const [studentSearch, setStudentSearch] = useState(''); - - // Состояние для просмотра материала - const [previewMaterial, setPreviewMaterial] = useState(null); - - // Панель «Добавить материал» (выдвижная справа) - const [addPanelOpen, setAddPanelOpen] = useState(false); - const [addTitle, setAddTitle] = useState(''); - const [addDescription, setAddDescription] = useState(''); - const [addFile, setAddFile] = useState(null); - const [addFilePreviewUrl, setAddFilePreviewUrl] = useState(null); - const [addShareStudentIds, setAddShareStudentIds] = useState([]); - const [addStudentSearch, setAddStudentSearch] = useState(''); - const [addLoading, setAddLoading] = useState(false); - const [addError, setAddError] = useState(null); - const [addStudentsLoaded, setAddStudentsLoaded] = useState(false); - const [addStudentsList, setAddStudentsList] = useState([]); - const [addStudentSelectOpen, setAddStudentSelectOpen] = useState(false); - const addStudentSelectRef = useRef(null); - - useEffect(() => { - Promise.all([ - loadComponent('elevated-card'), - loadComponent('filled-text-field'), - loadComponent('filled-button'), - loadComponent('icon'), - ]).then(() => { - setComponentsLoaded(true); - }); - }, []); - - // Debounce поиска — уменьшаем количество запросов при вводе - useEffect(() => { - const timer = setTimeout(() => { - setSearchQueryDebounced(searchQuery); - }, SEARCH_DEBOUNCE_MS); - return () => clearTimeout(timer); - }, [searchQuery]); - - // Список материалов с подгрузкой по скроллу: первые 10, затем страницами - const PAGE_SIZE = 10; - const [materialsList, setMaterialsList] = useState([]); - const [materialsPage, setMaterialsPage] = useState(1); - const [materialsHasMore, setMaterialsHasMore] = useState(true); - const [materialsLoading, setMaterialsLoading] = useState(true); - const [materialsLoadingMore, setMaterialsLoadingMore] = useState(false); - const [materialsError, setMaterialsError] = useState(null); - const loadMoreSentinelRef = useRef(null); - - const searchForRef = useRef(''); - const loadMaterialsPage = useCallback(async (page: number, append: boolean) => { - const search = searchQueryDebounced.trim() || undefined; - searchForRef.current = search ?? ''; - const isFirst = page === 1; - if (isFirst) { - setMaterialsLoading(true); - } else { - setMaterialsLoadingMore(true); - } - setMaterialsError(null); - try { - const data = await getMaterials({ - page, - page_size: PAGE_SIZE, - search, - }); - if (searchForRef.current !== (search ?? '')) return; - const list = data.results || []; - setMaterialsList((prev) => { - if (!append) return list; - const prevIds = new Set(prev.map((m: any) => m.id)); - const newItems = list.filter((m: any) => !prevIds.has(m.id)); - return newItems.length === 0 ? prev : [...prev, ...newItems]; - }); - setMaterialsHasMore(!!data.next); - setMaterialsPage(page); - } catch (err: any) { - if (searchForRef.current !== (search ?? '')) return; - setMaterialsError(err instanceof Error ? err : new Error(err?.message || 'Ошибка загрузки')); - } finally { - if (searchForRef.current === (search ?? '')) { - setMaterialsLoading(false); - setMaterialsLoadingMore(false); - } - } - }, [searchQueryDebounced]); - - // Первая загрузка и сброс при смене поиска - useEffect(() => { - setMaterialsList([]); - setMaterialsPage(1); - setMaterialsHasMore(true); - loadMaterialsPage(1, false); - }, [searchQueryDebounced]); - - // Подгрузка по скроллу (IntersectionObserver) - useEffect(() => { - if (!materialsHasMore || materialsLoadingMore || materialsLoading) return; - const el = loadMoreSentinelRef.current; - if (!el) return; - const observer = new IntersectionObserver( - (entries) => { - if (!entries[0]?.isIntersecting) return; - loadMaterialsPage(materialsPage + 1, true); - }, - { rootMargin: '200px', threshold: 0.1 } - ); - observer.observe(el); - return () => observer.disconnect(); - }, [materialsHasMore, materialsLoadingMore, materialsLoading, materialsPage, loadMaterialsPage]); - - const refetch = useCallback(() => { - setMaterialsList([]); - setMaterialsPage(1); - setMaterialsHasMore(true); - loadMaterialsPage(1, false); - }, [loadMaterialsPage]); - - const mutate = useCallback((updater: (prev: any[]) => any[]) => { - setMaterialsList(updater); - }, []); - - const materials = materialsList; - - // Определяем какие категории имеют файлы - const availableCategories = useMemo(() => { - const categories = new Set(); - materials.forEach((m: any) => { - categories.add(getFileTypeCategory(m)); - }); - return categories; - }, [materials]); - - // Фильтруем чипы - показываем только те, для которых есть файлы - const visibleChips = useMemo(() => { - return FILE_TYPE_CHIPS.filter(({ value }) => value && availableCategories.has(value)); - }, [availableCategories]); - - // Уникальные менторы (владельцы материалов) — для чипов у студента - const mentorChips = useMemo(() => { - const seen = new Set(); - const list: { id: number; name: string }[] = []; - materials.forEach((m: any) => { - const owner = m.owner; - if (!owner?.id) return; - if (seen.has(owner.id)) return; - seen.add(owner.id); - const name = [owner.first_name, owner.last_name].filter(Boolean).join(' ') || owner.email || `Ментор ${owner.id}`; - list.push({ id: owner.id, name: name.trim() || 'Без имени' }); - }); - return list.sort((a, b) => a.name.localeCompare(b.name)); - }, [materials]); - - const filteredMaterials = useMemo( - () => - materials.filter((m: any) => { - const matchesType = !fileTypeFilter || getFileTypeCategory(m) === fileTypeFilter; - const matchesMentor = !mentorFilter || (m.owner?.id === mentorFilter); - return matchesType && matchesMentor; - }), - [materials, fileTypeFilter, mentorFilter] - ); - - // Закрытие выпадающего списка учеников по клику снаружи - useEffect(() => { - if (!addStudentSelectOpen) return; - const handle = (e: MouseEvent) => { - if (addStudentSelectRef.current && !addStudentSelectRef.current.contains(e.target as Node)) { - setAddStudentSelectOpen(false); - } - }; - document.addEventListener('mousedown', handle); - return () => document.removeEventListener('mousedown', handle); - }, [addStudentSelectOpen]); - - // Загрузка списка учеников при открытии панели добавления - useEffect(() => { - if (!addPanelOpen) return; - setAddStudentsLoaded(false); - getStudents({ page_size: 1000 }) - .then((res) => { - setAddStudentsList(res.results || []); - }) - .catch(() => setAddStudentsList([])) - .finally(() => setAddStudentsLoaded(true)); - }, [addPanelOpen]); - - const closeAddPanel = () => { - if (addLoading) return; - setAddPanelOpen(false); - setAddTitle(''); - setAddDescription(''); - setAddFile(null); - if (addFilePreviewUrl) { - URL.revokeObjectURL(addFilePreviewUrl); - setAddFilePreviewUrl(null); - } - setAddShareStudentIds([]); - setAddStudentSearch(''); - setAddError(null); - }; - - // Превью выбранного файла: object URL для изображений - useEffect(() => { - if (!addFile) { - if (addFilePreviewUrl) { - URL.revokeObjectURL(addFilePreviewUrl); - setAddFilePreviewUrl(null); - } - return; - } - if (addFile.type.startsWith('image/')) { - const url = URL.createObjectURL(addFile); - setAddFilePreviewUrl(url); - return () => { - URL.revokeObjectURL(url); - setAddFilePreviewUrl(null); - }; - } - if (addFilePreviewUrl) { - URL.revokeObjectURL(addFilePreviewUrl); - setAddFilePreviewUrl(null); - } - }, [addFile]); - - const handleAddSubmit = async (e: FormEvent) => { - e.preventDefault(); - setAddError(null); - if (!addTitle.trim()) { - setAddError('Укажите название материала'); - return; - } - if (!addFile) { - setAddError('Выберите файл для загрузки'); - return; - } - setAddLoading(true); - try { - const created = await createMaterial({ - title: addTitle.trim(), - description: addDescription.trim() || undefined, - file: addFile, - }); - if (addShareStudentIds.length > 0) { - try { - await shareMaterial(created.id, addShareStudentIds); - } catch { - // материал уже создан - } - } - refetch(); - closeAddPanel(); - } catch (err: any) { - setAddError(err?.message || 'Ошибка при создании материала'); - } finally { - setAddLoading(false); - } - }; - - if (!componentsLoaded) { - return ( -
-
Загрузка...
-
- ); - } - - return ( -
-
-
- {!isClient && ( - - )} -
-
{ - const input = (e.currentTarget as HTMLElement).querySelector('input'); - input?.focus(); - }} - > - setSearchQuery(e.target.value)} - placeholder="Поиск по названию, описанию или имени файла..." - style={{ - width: '100%', - padding: '12px 16px 12px 44px', - fontSize: 16, - border: '1px solid var(--md-sys-color-outline)', - borderRadius: 12, - background: 'var(--md-sys-color-surface)', - color: 'var(--md-sys-color-on-surface)', - outline: 'none', - boxSizing: 'border-box', - }} - /> - - search - -
- - {/* Чипы «Ментор» — только для студента: фильтр по тому, кто дал материал */} - {isClient && mentorChips.length > 0 && ( -
- - {mentorChips.map(({ id, name }) => { - const isSelected = mentorFilter === id; - return ( - - ); - })} -
- )} - - {/* Кастомные чипы типов файлов - показываем только если есть файлы */} - {visibleChips.length > 0 && ( -
- {visibleChips.map(({ value, label }) => { - const isSelected = fileTypeFilter === value; - return ( - - ); - })} -
- )} - - {materialsError && materialsError.message !== 'canceled' && ( - - )} - - {/* Прогресс поиска — тонкая полоска под полем поиска */} - {materialsLoading && ( -
-
-
- )} - - {materialsLoading && materialsList.length === 0 ? ( - - ) : filteredMaterials.length === 0 ? ( - - - folder - -

- {searchQuery ? 'Материалы не найдены' : 'Нет материалов'} -

-
- ) : ( -
- {filteredMaterials.map((material: any) => { - const mediaUrl = getMediaUrl(material); - const isImage = isImageMaterial(material) && mediaUrl; - const isVideo = isVideoMaterial(material) && mediaUrl; - const isPdf = isPdfMaterial(material) && mediaUrl; - const isText = isTextPreviewMaterial(material) && mediaUrl; - const iconName = getMaterialIcon(material); - const ownerName = material.owner - ? [material.owner.first_name, material.owner.last_name].filter(Boolean).join(' ') || material.owner.email - : ''; - - return ( - { - e.currentTarget.style.transform = 'translateY(-4px)'; - e.currentTarget.style.boxShadow = 'var(--ios26-shadow-hover)'; - }} - onMouseLeave={(e: any) => { - e.currentTarget.style.transform = 'translateY(0)'; - e.currentTarget.style.boxShadow = 'var(--ios26-shadow)'; - }} - > - {/* Шапка карточки: заголовок; меню Редактировать/Удалить только для ментора */} -
-
-
- {truncateTo8(material.title || material.file_name || 'Без названия')} -
-
- {!isClient && ( -
- - {openMenuId === material.id && ( - <> -
setOpenMenuId(null)} - onKeyDown={(e) => e.key === 'Escape' && setOpenMenuId(null)} - aria-label="Закрыть меню" - /> -
- - -
- - )} -
- )} -
- - {/* Превью: фото, видео, PDF, текст или иконка — максимальная область */} -
- {isImage && ( - // eslint-disable-next-line @next/next/no-img-element - {material.title} - )} - {isVideo && ( - <> -