""" API views для управления профилями. """ from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated, AllowAny from django.db import models from django.utils import timezone from django.core.cache import cache from datetime import datetime from zoneinfo import available_timezones, ZoneInfo from .models import User, Client, Parent, MentorStudentConnection from apps.notifications.models import Notification from apps.notifications.services import NotificationService from .serializers import UserSerializer, ClientSerializer, ParentSerializer from .geo_utils import get_countries, search_cities from .utils import normalize_phone from .tasks import send_student_welcome_email_task, send_mentor_invitation_email_task from django.conf import settings import secrets import csv import io import requests from django.core.cache import cache POPULAR_CITIES = [ # Россия {"country_code": "RU", "country_name": "Россия", "city": "Москва", "timezone": "Europe/Moscow"}, {"country_code": "RU", "country_name": "Россия", "city": "Санкт-Петербург", "timezone": "Europe/Moscow"}, {"country_code": "RU", "country_name": "Россия", "city": "Новосибирск", "timezone": "Asia/Novosibirsk"}, {"country_code": "RU", "country_name": "Россия", "city": "Екатеринбург", "timezone": "Asia/Yekaterinburg"}, {"country_code": "RU", "country_name": "Россия", "city": "Казань", "timezone": "Europe/Moscow"}, {"country_code": "RU", "country_name": "Россия", "city": "Нижний Новгород", "timezone": "Europe/Moscow"}, {"country_code": "RU", "country_name": "Россия", "city": "Самара", "timezone": "Europe/Samara"}, {"country_code": "RU", "country_name": "Россия", "city": "Красноярск", "timezone": "Asia/Krasnoyarsk"}, {"country_code": "RU", "country_name": "Россия", "city": "Владивосток", "timezone": "Asia/Vladivostok"}, {"country_code": "RU", "country_name": "Россия", "city": "Улан-Удэ", "timezone": "Asia/Irkutsk"}, {"country_code": "RU", "country_name": "Россия", "city": "Иркутск", "timezone": "Asia/Irkutsk"}, {"country_code": "RU", "country_name": "Россия", "city": "Чита", "timezone": "Asia/Chita"}, {"country_code": "RU", "country_name": "Россия", "city": "Хабаровск", "timezone": "Asia/Vladivostok"}, {"country_code": "RU", "country_name": "Россия", "city": "Омск", "timezone": "Asia/Omsk"}, {"country_code": "RU", "country_name": "Россия", "city": "Челябинск", "timezone": "Asia/Yekaterinburg"}, {"country_code": "RU", "country_name": "Россия", "city": "Уфа", "timezone": "Asia/Yekaterinburg"}, {"country_code": "RU", "country_name": "Россия", "city": "Ростов-на-Дону", "timezone": "Europe/Moscow"}, {"country_code": "RU", "country_name": "Россия", "city": "Пермь", "timezone": "Asia/Yekaterinburg"}, {"country_code": "RU", "country_name": "Россия", "city": "Воронеж", "timezone": "Europe/Moscow"}, {"country_code": "RU", "country_name": "Россия", "city": "Волгоград", "timezone": "Europe/Moscow"}, {"country_code": "RU", "country_name": "Россия", "city": "Краснодар", "timezone": "Europe/Moscow"}, {"country_code": "RU", "country_name": "Россия", "city": "Барнаул", "timezone": "Asia/Barnaul"}, {"country_code": "RU", "country_name": "Россия", "city": "Томск", "timezone": "Asia/Tomsk"}, {"country_code": "RU", "country_name": "Россия", "city": "Якутск", "timezone": "Asia/Yakutsk"}, {"country_code": "RU", "country_name": "Россия", "city": "Калининград", "timezone": "Europe/Kaliningrad"}, # Казахстан {"country_code": "KZ", "country_name": "Казахстан", "city": "Алматы", "timezone": "Asia/Almaty"}, {"country_code": "KZ", "country_name": "Казахстан", "city": "Астана", "timezone": "Asia/Almaty"}, # Беларусь {"country_code": "BY", "country_name": "Беларусь", "city": "Минск", "timezone": "Europe/Minsk"}, # Украина {"country_code": "UA", "country_name": "Украина", "city": "Киев", "timezone": "Europe/Kyiv"}, ] class ProfileViewSet(viewsets.ViewSet): """ ViewSet для управления профилем. me: Текущий пользователь update_profile: Обновить профиль change_password: Сменить пароль settings: Настройки профиля update_settings: Обновить настройки """ permission_classes = [IsAuthenticated] @action(detail=False, methods=['get']) def me(self, request): """ Получить данные текущего пользователя. GET /api/users/profile/me/ """ user = request.user # User.save() автоматически создаст universal_code, если он отсутствует if not user.universal_code or len(str(user.universal_code).strip()) != 8: user.save(update_fields=['universal_code']) serializer = UserSerializer(user, context={'request': request}) # Добавляем дополнительную информацию data = serializer.data # Оптимизация: используем select_related для избежания дополнительных запросов # Если клиент - добавляем данные клиента if user.role == 'client': # Используем select_related для оптимизации client = Client.objects.select_related('user').filter(user=user).first() if client: data['client_info'] = ClientSerializer(client, context={'request': request}).data # Если родитель - добавляем данные родителя elif user.role == 'parent': # Используем select_related и prefetch_related для оптимизации # Важно: prefetch_related для children__mentors, чтобы менторы загружались parent = Parent.objects.select_related('user').prefetch_related( 'children', 'children__user', 'children__mentors' ).filter(user=user).first() if parent: data['parent_info'] = ParentSerializer(parent, context={'request': request}).data return Response(data) @action(detail=False, methods=['post']) def load_telegram_avatar(self, request): """ Загрузить аватар из Telegram. POST /api/users/profile/load_telegram_avatar/ Примечание: Telegram API имеет ограничения на частоту запросов (rate limiting). Если вы получили ошибку "Flood control exceeded", подождите указанное время перед повторной попыткой. """ import requests import os from django.core.files.base import ContentFile from django.conf import settings from django.core.cache import cache user = request.user # Проверяем, что у пользователя есть telegram_id if not user.telegram_id: return Response( {'error': 'Telegram не подключен'}, status=status.HTTP_400_BAD_REQUEST ) # Проверяем кеш - если недавно уже пытались загрузить, не делаем повторный запрос cache_key = f'telegram_avatar_load_{user.telegram_id}' last_attempt = cache.get(cache_key) if last_attempt: # Если прошло меньше 60 секунд с последней попытки, возвращаем ошибку from django.utils import timezone from datetime import timedelta time_passed = (timezone.now() - last_attempt).total_seconds() wait_time = 60 - int(time_passed) if wait_time > 0: minutes = wait_time // 60 seconds = wait_time % 60 if minutes > 0: time_str = f'{minutes} {minutes == 1 and "минуту" or "минут"}' if seconds > 0: time_str += f' {seconds} {seconds == 1 and "секунду" or "секунд"}' else: time_str = f'{seconds} {seconds == 1 and "секунду" or "секунд"}' return Response( { 'error': f'Пожалуйста, подождите {time_str} перед повторной попыткой загрузки аватара из Telegram', 'retry_after': wait_time, 'last_attempt': last_attempt.isoformat() if hasattr(last_attempt, 'isoformat') else str(last_attempt) }, status=status.HTTP_429_TOO_MANY_REQUESTS ) # Сохраняем время попытки в кеш на 60 секунд from django.utils import timezone cache.set(cache_key, timezone.now(), 60) try: import asyncio from telegram import Bot from telegram.error import RetryAfter, TelegramError from django.conf import settings as django_settings if not django_settings.TELEGRAM_BOT_TOKEN: return Response( {'error': 'Telegram бот не настроен'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) # Асинхронная функция для получения фото async def get_telegram_photo(): bot = Bot(token=django_settings.TELEGRAM_BOT_TOKEN) try: import logging logger = logging.getLogger(__name__) # Сначала проверяем, что пользователь существует и бот может получить информацию о нем try: chat_member = await bot.get_chat_member(user.telegram_id, user.telegram_id) logger.info(f"User chat member info retrieved: {chat_member.status if hasattr(chat_member, 'status') else 'N/A'}") except Exception as e: logger.warning(f"Could not get chat member info: {str(e)}. User may not have started a conversation with the bot.") # Получаем фото профиля пользователя logger.info(f"Attempting to get profile photos for user {user.telegram_id}") photos = await bot.get_user_profile_photos(user.telegram_id, limit=1) # Получаем total_count и количество фото total_count = getattr(photos, 'total_count', 0) if photos else 0 photos_list = getattr(photos, 'photos', []) if photos else [] photos_count = len(photos_list) if photos_list else 0 logger.info(f"Profile photos response: total_count={total_count}, photos_list_length={photos_count}") # Если total_count > 0, но photos пустой - значит фото есть, но доступ ограничен if total_count > 0 and photos_count == 0: return None, None, 'Фото профиля есть, но доступ к нему ограничен настройками приватности Telegram. Пожалуйста, разрешите боту доступ к фото профиля в настройках приватности Telegram (Настройки → Конфиденциальность → Фото профиля → Все).' # Если total_count = 0 и photos пустой - фото нет if total_count == 0 and photos_count == 0: return None, None, 'У вас нет фото профиля в Telegram или оно недоступно. Установите фото профиля в Telegram и убедитесь, что в настройках приватности (Настройки → Конфиденциальность → Фото профиля) выбрано "Все".' # Проверяем, что список фото не пустой if not photos_list or photos_count == 0: return None, None, 'Не удалось получить фото профиля. Убедитесь, что:\n1. Вы начали диалог с ботом (@advent_dk_testing_bot)\n2. В настройках приватности Telegram (Настройки → Конфиденциальность → Фото профиля) выбрано "Все"\n3. Фото профиля установлено в Telegram' # Берем самое большое фото (последний элемент в массиве размеров) photo_sizes = photos_list[0] if not photo_sizes or len(photo_sizes) == 0: logger.error("Photo sizes list is empty") return None, None, 'Не удалось получить размеры фото профиля.' photo = photo_sizes[-1] # Последний элемент - самое большое фото if not photo or not hasattr(photo, 'file_id'): logger.error("Photo object is invalid or missing file_id") return None, None, 'Не удалось получить идентификатор файла фото.' logger.info(f"Found photo with file_id: {photo.file_id}") # Получаем файл фото file = await bot.get_file(photo.file_id) logger.info(f"Got file path: {file.file_path}") if not file.file_path: logger.error("File path is None or empty") return None, None, 'Не удалось получить путь к файлу фото.' # Скачиваем фото используя метод download_file из библиотеки try: from io import BytesIO photo_buffer = BytesIO() await file.download_to_memory(out=photo_buffer) photo_content_bytes = photo_buffer.getvalue() if not photo_content_bytes or len(photo_content_bytes) == 0: logger.error("Downloaded photo is empty") return None, None, 'Загруженное фото пустое' logger.info(f"Successfully downloaded photo: {len(photo_content_bytes)} bytes") return photo_content_bytes, file.file_path, None except Exception as download_error: logger.error(f"Error downloading photo file: {str(download_error)}", exc_info=True) # Fallback: попробуем через прямой запрос к API try: photo_url = f"https://api.telegram.org/file/bot{django_settings.TELEGRAM_BOT_TOKEN}/{file.file_path}" photo_response = requests.get(photo_url, timeout=10) if photo_response.status_code != 200: logger.error(f"Failed to download photo via direct API: status_code={photo_response.status_code}") return None, None, f'Не удалось загрузить фото: HTTP {photo_response.status_code}. Убедитесь, что файл доступен.' if not photo_response.content or len(photo_response.content) == 0: logger.error("Downloaded photo is empty (via direct API)") return None, None, 'Загруженное фото пустое' logger.info(f"Successfully downloaded photo via direct API: {len(photo_response.content)} bytes") return photo_response.content, file.file_path, None except Exception as fallback_error: logger.error(f"Fallback download also failed: {str(fallback_error)}", exc_info=True) return None, None, f'Не удалось загрузить фото: {str(fallback_error)}' except RetryAfter as e: # Обработка ошибки Flood control retry_after = int(e.retry_after) if hasattr(e, 'retry_after') else None return None, None, f'Слишком много запросов. Попробуйте через {retry_after} секунд' if retry_after else 'Слишком много запросов. Попробуйте позже' except TelegramError as e: import logging logger = logging.getLogger(__name__) logger.error(f"Telegram API error: {str(e)}", exc_info=True) error_msg = str(e) # Более понятные сообщения об ошибках if 'user not found' in error_msg.lower(): return None, None, 'Пользователь не найден в Telegram. Убедитесь, что вы связали аккаунт с ботом.' elif 'forbidden' in error_msg.lower() or 'access denied' in error_msg.lower(): return None, None, 'Доступ запрещен. Убедитесь, что вы начали диалог с ботом и разрешили доступ к фото профиля в настройках приватности Telegram.' return None, None, f'Ошибка Telegram API: {error_msg}' except Exception as e: import logging logger = logging.getLogger(__name__) logger.error(f"Unexpected error getting Telegram photo: {str(e)}", exc_info=True) return None, None, f'Неожиданная ошибка: {str(e)}' finally: try: await bot.close() except: pass # Запускаем асинхронную функцию loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: photo_content, file_path, error_msg = loop.run_until_complete(get_telegram_photo()) finally: loop.close() if error_msg: # Определяем статус код в зависимости от типа ошибки if 'Слишком много запросов' in error_msg: status_code = status.HTTP_429_TOO_MANY_REQUESTS elif 'не найден' in error_msg.lower() or 'forbidden' in error_msg.lower() or 'доступ запрещен' in error_msg.lower() or 'доступ ограничен' in error_msg.lower(): status_code = status.HTTP_403_FORBIDDEN else: status_code = status.HTTP_500_INTERNAL_SERVER_ERROR return Response( {'error': error_msg}, status=status_code ) if not photo_content: # Если error_msg уже был установлен, он уже был возвращен выше # Это означает, что photo_content = None, но error_msg тоже None # Значит, проблема в том, что фото не было найдено без конкретной ошибки import logging logger = logging.getLogger(__name__) logger.warning(f"Photo content is None for user {user.telegram_id}, but no error message was set. This should not happen.") return Response( {'error': 'Не удалось загрузить фото профиля. Убедитесь, что:\n1. У вас установлено фото профиля в Telegram\n2. В настройках приватности Telegram (Настройки → Конфиденциальность → Фото профиля) выбрано "Все" - фото должно быть доступно для всех\n3. Вы начали диалог с ботом (@advent_dk_testing_bot) - отправьте команду /start\n4. Попробуйте перезапустить бота или подождите несколько минут'}, status=status.HTTP_404_NOT_FOUND ) # Определяем расширение файла file_extension = file_path.split('.')[-1] if file_path and '.' in file_path else 'jpg' if file_extension not in ['jpg', 'jpeg', 'png', 'webp']: file_extension = 'jpg' # Удаляем старый аватар, если есть if user.avatar: try: if os.path.isfile(user.avatar.path): os.remove(user.avatar.path) except Exception as e: import logging logger = logging.getLogger(__name__) logger.warning(f'Не удалось удалить старый аватар: {e}') # Сохраняем новое фото как аватар filename = f'telegram_avatar_{user.telegram_id}.{file_extension}' user.avatar.save( filename, ContentFile(photo_content), save=True ) serializer = UserSerializer(user, context={'request': request}) return Response({ 'success': True, 'message': 'Аватар успешно загружен из Telegram', 'user': serializer.data }) except Exception as e: import logging logger = logging.getLogger(__name__) logger.error(f'Ошибка загрузки аватара из Telegram: {e}', exc_info=True) return Response( {'error': f'Не удалось загрузить аватар из Telegram: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) @action(detail=False, methods=['patch']) def update_profile(self, request): """ Обновить профиль. PATCH /api/users/profile/update_profile/ Поддерживает: - Обновление текстовых полей (first_name, last_name, phone, bio, timezone, language) - Загрузку аватара (avatar - файл изображения) - Удаление аватара (avatar = null или пустая строка) """ import os try: from PIL import Image except ImportError: Image = None user = request.user # Обработка удаления аватара if 'avatar' in request.data: avatar_value = request.data.get('avatar') # Проверяем, является ли это запросом на удаление if avatar_value is None or avatar_value == '' or avatar_value == 'null' or (isinstance(avatar_value, str) and avatar_value.strip() == ''): # Удаляем аватар if user.avatar: try: # Удаляем файл с диска if os.path.isfile(user.avatar.path): os.remove(user.avatar.path) except Exception as e: # Логируем ошибку, но продолжаем удаление из БД import logging logger = logging.getLogger(__name__) logger.warning(f'Не удалось удалить файл аватара: {e}') user.avatar = None # Оптимизация: используем update_fields для частичного обновления user.save(update_fields=['avatar']) serializer = UserSerializer(user, context={'request': request}) return Response(serializer.data) elif hasattr(avatar_value, 'read'): # Это файл # Валидация размера файла (макс 5MB) if avatar_value.size > 5 * 1024 * 1024: return Response( {'error': 'Размер файла не должен превышать 5MB'}, status=status.HTTP_400_BAD_REQUEST ) # Валидация формата файла allowed_formats = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif'] if avatar_value.content_type not in allowed_formats: return Response( {'error': 'Поддерживаются только форматы: JPEG, PNG, WebP, GIF'}, status=status.HTTP_400_BAD_REQUEST ) # Валидация размеров изображения (если Pillow установлен) if Image: try: # Сохраняем временно для проверки avatar_value.seek(0) img = Image.open(avatar_value) width, height = img.size # Минимальный размер if width < 50 or height < 50: return Response( {'error': 'Минимальный размер изображения: 50x50 пикселей'}, status=status.HTTP_400_BAD_REQUEST ) # Максимальный размер (опционально, для оптимизации) if width > 2000 or height > 2000: # Можно автоматически уменьшить, но пока просто предупреждаем pass # Возвращаем указатель в начало avatar_value.seek(0) except Exception as e: return Response( {'error': f'Не удалось обработать изображение: {str(e)}'}, status=status.HTTP_400_BAD_REQUEST ) # Обновляем основные данные allowed_fields = [ 'email', 'first_name', 'last_name', 'phone', 'bio', 'avatar', 'timezone', 'language', 'birth_date' ] for field in allowed_fields: if field in request.data: value = request.data[field] # Специальная обработка для email if field == 'email': value = str(value).lower().strip() if not value: continue if User.objects.filter(email=value).exclude(pk=user.pk).exists(): return Response({'error': 'Пользователь с таким email уже существует'}, status=400) user.email = value continue # Пропускаем пустые строки для опциональных полей if field in ['phone', 'bio', 'timezone', 'language'] and value == '': setattr(user, field, '') elif value is not None: if field == 'phone': value = normalize_phone(str(value)) setattr(user, field, value) user.save() # Обновляем сериализатор с контекстом request для правильного формирования URL serializer = UserSerializer(user, context={'request': request}) return Response(serializer.data) @action(detail=False, methods=['post']) def change_password(self, request): """ Сменить пароль. POST /api/users/profile/change_password/ Body: { "old_password": "...", "new_password": "..." } """ user = request.user old_password = request.data.get('old_password') new_password = request.data.get('new_password') if not old_password or not new_password: return Response( {'error': 'Необходимо указать старый и новый пароли'}, status=status.HTTP_400_BAD_REQUEST ) # Проверяем старый пароль if not user.check_password(old_password): return Response( {'error': 'Неверный старый пароль'}, status=status.HTTP_400_BAD_REQUEST ) # Устанавливаем новый пароль user.set_password(new_password) user.save() return Response({'message': 'Пароль успешно изменен'}) @action(detail=False, methods=['get'], url_path='settings') def get_settings(self, request): """ Получить настройки профиля. GET /api/users/profile/settings/ """ user = request.user # Настройки из модели User settings = { 'notifications': { 'email_notifications': getattr(user, 'email_notifications', True), 'push_notifications': getattr(user, 'push_notifications', True), 'sms_notifications': getattr(user, 'sms_notifications', False) }, 'preferences': { 'timezone': user.timezone, 'language': user.language, 'theme': getattr(user, 'theme', 'light'), 'country': getattr(user, 'country', ''), 'city': getattr(user, 'city', ''), }, 'privacy': { 'show_online_status': getattr(user, 'show_online_status', True), 'allow_messages': getattr(user, 'allow_messages', True) } } # Настройки ментора: доверие AI при проверке ДЗ if getattr(user, 'role', None) == 'mentor': settings['mentor_homework_ai'] = { 'ai_trust_draft': getattr(user, 'ai_trust_draft', False), 'ai_trust_publish': getattr(user, 'ai_trust_publish', False), } return Response(settings) @action(detail=False, methods=['get'], url_path='countries') def get_countries_action(self, request): """ Получить список стран. GET /api/profile/countries/ """ return Response(get_countries()) @action(detail=False, methods=['get'], url_path='timezones') def get_timezones_action(self, request): """ Получить список часовых поясов. GET /api/profile/timezones/ """ now = datetime.utcnow() timezones_data = [] for name in sorted(available_timezones()): try: tz = ZoneInfo(name) offset = now.astimezone(tz).utcoffset() if offset is None: continue total_minutes = int(offset.total_seconds() // 60) sign = '+' if total_minutes >= 0 else '-' hours, minutes = divmod(abs(total_minutes), 60) offset_str = f"{sign}{hours:02d}:{minutes:02d}" timezones_data.append({ "name": name, "offset": offset_str, }) except Exception: continue return Response(timezones_data) @action(detail=False, methods=['get'], url_path='cities/search', permission_classes=[AllowAny]) def search_cities_from_csv(self, request): """ Поиск городов из city.csv по запросу. Публичный endpoint (доступен без аутентификации). GET /api/profile/cities/search/?q=москва """ query = request.query_params.get('q', '').strip() limit = int(request.query_params.get('limit', 20)) if not query: return Response([]) # Нормализуем запрос: приводим к lowercase после проверки на пустоту query_lower = query.lower().strip() import os possible_paths = [ os.path.join(settings.BASE_DIR, '..', '..', 'city.csv'), os.path.join(settings.BASE_DIR, '..', 'city.csv'), os.path.join(settings.BASE_DIR, 'city.csv'), '/app/city.csv', '/code/city.csv', ] csv_path = next((p for p in possible_paths if os.path.exists(p)), None) # Ключ кеша по mtime — после загрузки нового city.csv подхватятся свежие данные if csv_path: cache_key = 'cities_csv_data_%s' % int(os.path.getmtime(csv_path)) cities_data = cache.get(cache_key) else: cache_key = None cities_data = None if cities_data is None and csv_path: try: cities_data = [] with open(csv_path, 'r', encoding='utf-8') as f: reader = csv.DictReader(f) for row in reader: city_name = row.get('city', '').strip() city_type = row.get('city_type', '').strip() timezone = row.get('timezone', '').strip() region = row.get('region', '').strip() if not city_name and region: city_name = region if city_name and timezone: full_city_name = f"{city_type} {city_name}".strip() if city_type else city_name cities_data.append({ 'name': city_name, 'full_search_name': full_city_name.lower(), 'timezone': timezone, 'region': region, 'city_type': city_type, 'full_name': f"{city_name}" + (f", {region}" if region and region != city_name else ""), }) cache.set(cache_key, cities_data, 24 * 60 * 60) import logging logger = logging.getLogger(__name__) logger.info(f"Loaded {len(cities_data)} cities from CSV") except Exception as e: # В случае ошибки возвращаем пустой список import logging logger = logging.getLogger(__name__) logger.error(f"Error loading city.csv: {e}") import traceback logger.error(traceback.format_exc()) return Response({'error': str(e)}, status=500) # Если CSV пустой или не найден — используем cities_ru.json (geo_utils) if not cities_data: import logging logger = logging.getLogger(__name__) logger.info("city.csv empty or missing, using cities_ru.json fallback") from .geo_utils import search_cities as search_cities_json json_cities = search_cities_json(country="RU", query=query_lower) results = [] for c in json_cities[:limit]: results.append({ 'name': c.get('city', ''), 'timezone': c.get('timezone', ''), 'region': c.get('country_name', ''), 'full_name': f"{c.get('city', '')}, {c.get('country_name', '')}".strip(', '), }) return Response(results) # Ищем города по запросу в данных из CSV results = [] # Префиксы типов населенных пунктов, которые нужно убирать при поиске settlement_prefixes = ['г ', 'пгт ', 'пос ', 'с ', 'д ', 'дер ', 'село ', 'п ', 'рп ', 'ст ', 'ст-ца ', 'х ', 'клх ', 'снт ', 'днп '] # Нормализуем запрос: убираем префиксы, если они есть normalized_query = query_lower for prefix in settlement_prefixes: if normalized_query.startswith(prefix): normalized_query = normalized_query[len(prefix):].strip() break # Дополнительно: убираем все префиксы из начала запроса для более гибкого поиска for city in cities_data: city_name = city['name'] city_name_lower = city_name.lower() full_search_name = city.get('full_search_name', city_name_lower) region_lower = city.get('region', '').lower() # Нормализуем название города для поиска (убираем префиксы) normalized_city_name = city_name_lower for prefix in settlement_prefixes: if normalized_city_name.startswith(prefix): normalized_city_name = normalized_city_name[len(prefix):].strip() break # Нормализуем полное имя с типом normalized_full_name = full_search_name for prefix in settlement_prefixes: if normalized_full_name.startswith(prefix): normalized_full_name = normalized_full_name[len(prefix):].strip() break # Ищем совпадения: # 1. В оригинальном названии города (без типа) # 2. В полном названии с типом (г Москва) # 3. В нормализованном названии города # 4. В нормализованном полном названии # 5. В регионе matches = ( query_lower in city_name_lower or query_lower in full_search_name or normalized_query in normalized_city_name or normalized_query in normalized_full_name or query_lower in region_lower ) if matches: # Определяем приоритет: точное совпадение в начале идет первым priority = 0 if normalized_city_name.startswith(normalized_query): priority = 1 # Точное совпадение в начале без типа elif city_name_lower.startswith(query_lower): priority = 2 # Совпадение в начале с оригинальным названием elif full_search_name.startswith(query_lower): priority = 3 # Совпадение в начале с полным именем (г Москва) elif normalized_query in normalized_city_name: priority = 4 # Совпадение в любом месте без типа elif normalized_query in normalized_full_name: priority = 5 # Совпадение в любом месте с типом elif query_lower in city_name_lower: priority = 6 # Совпадение в любом месте оригинала else: priority = 7 # Совпадение в регионе results.append({ 'name': city['name'], 'timezone': city['timezone'], 'region': city.get('region', ''), 'full_name': city['full_name'], 'priority': priority, }) # Сортируем результаты по приоритету (лучшие совпадения первыми) results.sort(key=lambda x: x['priority']) # Убираем поле priority из результата for result in results: result.pop('priority', None) return Response(results[:limit]) @action(detail=False, methods=['get'], url_path='cities') def get_cities(self, request): """ Получить список популярных городов и их часовых поясов. GET /api/profile/cities/ Параметры: - country (опционально): ISO-код страны (например, RU, KZ, BY) или название страны. """ country = request.query_params.get('country') cities = POPULAR_CITIES if country: country_lower = country.lower() cities = [ c for c in POPULAR_CITIES if c["country_code"].lower() == country_lower or c["country_name"].lower().startswith(country_lower) ] return Response(cities) @action(detail=False, methods=['patch']) def update_settings(self, request): """ Обновить настройки профиля. PATCH /api/users/profile/update_settings/ Body: { "notifications": {...}, "preferences": {...}, "privacy": {...} } """ user = request.user # Уведомления if 'notifications' in request.data: notifications = request.data['notifications'] for key, value in notifications.items(): if hasattr(user, key): setattr(user, key, value) # Предпочтения if 'preferences' in request.data: preferences = request.data['preferences'] for key, value in preferences.items(): if hasattr(user, key): setattr(user, key, value) # Приватность if 'privacy' in request.data: privacy = request.data['privacy'] for key, value in privacy.items(): if hasattr(user, key): setattr(user, key, value) # Настройки ментора: доверие AI при проверке ДЗ if getattr(user, 'role', None) == 'mentor' and 'mentor_homework_ai' in request.data: mentor_ai = request.data['mentor_homework_ai'] if isinstance(mentor_ai, dict): if 'ai_trust_draft' in mentor_ai: user.ai_trust_draft = bool(mentor_ai['ai_trust_draft']) if 'ai_trust_publish' in mentor_ai: user.ai_trust_publish = bool(mentor_ai['ai_trust_publish']) user.save() return Response({'message': 'Настройки успешно обновлены'}) @action(detail=False, methods=['delete']) def delete_account(self, request): """ Удалить аккаунт. DELETE /api/users/profile/delete_account/ Body: { "password": "..." } """ user = request.user password = request.data.get('password') if not password: return Response( {'error': 'Необходимо указать пароль'}, status=status.HTTP_400_BAD_REQUEST ) # Проверяем пароль if not user.check_password(password): return Response( {'error': 'Неверный пароль'}, status=status.HTTP_400_BAD_REQUEST ) # Деактивируем аккаунт user.is_active = False user.save() return Response({'message': 'Аккаунт успешно удален'}) def _apply_mentor_connection(conn): """После полного подтверждения связи: добавить ментора к студенту, создать доску.""" from apps.users.mentorship_views import _apply_connection _apply_connection(conn) class ClientManagementViewSet(viewsets.ViewSet): """ ViewSet для управления клиентами (для менторов). list: Список клиентов check_user: Проверить пользователя по email (существует ли, является ли клиентом) add_client: Отправить приглашение (по email или 8-символьному коду) remove_client: Удалить клиента client_details: Детали клиента """ permission_classes = [IsAuthenticated] def list(self, request): """ Список клиентов ментора. GET /api/users/manage/clients/?page=1&page_size=20 """ user = request.user if user.role != 'mentor': return Response( {'error': 'Только для менторов'}, status=status.HTTP_403_FORBIDDEN ) # Кеширование: кеш на 5 минут для каждого пользователя и страницы # Увеличено с 2 до 5 минут для ускорения повторных загрузок страницы "Студенты" page = int(request.query_params.get('page', 1)) page_size = int(request.query_params.get('page_size', 20)) cache_key = f'manage_clients_{user.id}_{page}_{page_size}' cached_data = cache.get(cache_key) if cached_data is not None: return Response(cached_data) # ВАЖНО: оптимизация страницы "Студенты" # Раньше ClientSerializer считал статистику занятий через 3 отдельных запроса на каждого клиента (N+1). # Здесь заранее аннотируем эти значения одним SQL-запросом. from django.db.models import Count, Q # Получаем ID студентов из принятых связей (MentorStudentConnection) # Это нужно для случаев, когда связь создана, но ментор ещё не добавлен в client.mentors accepted_connections = MentorStudentConnection.objects.filter( mentor=user, status=MentorStudentConnection.STATUS_ACCEPTED ).values_list('student_id', flat=True) # Получаем ID клиентов, у которых ментор уже добавлен в client.mentors clients_with_mentor = Client.objects.filter(mentors=user).values_list('id', flat=True) # Получаем ID клиентов из принятых связей, у которых ментор ещё не добавлен clients_to_sync = Client.objects.filter( user_id__in=accepted_connections ).exclude(id__in=clients_with_mentor).values_list('id', flat=True) # Синхронизация: добавляем ментора в client.mentors для клиентов с принятыми связями # Используем bulk операцию для эффективности if clients_to_sync: clients_to_update = Client.objects.filter(id__in=clients_to_sync) for client in clients_to_update: client.mentors.add(user) # Фильтруем клиентов: либо ментор в client.mentors, либо есть принятая связь clients = ( Client.objects.filter( Q(mentors=user) | Q(user_id__in=accepted_connections) ) .select_related('user') .prefetch_related('mentors') .distinct() .annotate( # Поля с суффиксом _annotated читает ClientSerializer (если присутствуют) scheduled_lessons_annotated=Count( 'lessons', filter=Q(lessons__mentor=user, lessons__status='scheduled'), distinct=True, ), completed_lessons_annotated=Count( 'lessons', filter=Q(lessons__mentor=user, lessons__status='completed'), distinct=True, ), total_lessons_annotated=Count( 'lessons', filter=Q(lessons__mentor=user) & ~Q(lessons__status='cancelled'), distinct=True, ), ) ) # Пагинация from rest_framework.pagination import PageNumberPagination paginator = PageNumberPagination() paginator.page_size = page_size paginated_clients = paginator.paginate_queryset(clients, request) serializer = ClientSerializer(paginated_clients, many=True, context={'request': request}) response_data = paginator.get_paginated_response(serializer.data) # Ожидающие подтверждения приглашения (студент/родитель ещё не подтвердили) pending = MentorStudentConnection.objects.filter( mentor=user, initiator=MentorStudentConnection.INITIATOR_MENTOR, status__in=[MentorStudentConnection.STATUS_PENDING_STUDENT, MentorStudentConnection.STATUS_PENDING_PARENT], ).select_related('student').order_by('-created_at') def _client_id(u): try: return u.client_profile.id except (Client.DoesNotExist, AttributeError): return None response_data.data['pending_invitations'] = [ { 'id': inv.id, 'invitation_id': inv.id, 'status': inv.status, 'created_at': inv.created_at.isoformat() if inv.created_at else None, 'student': { 'id': _client_id(inv.student), 'email': inv.student.email, 'first_name': inv.student.first_name or '', 'last_name': inv.student.last_name or '', }, 'is_pending_invitation': True, } for inv in pending ] # Сохраняем в кеш на 5 минут (300 секунд) для ускорения повторных загрузок cache.set(cache_key, response_data.data, 300) return response_data @action(detail=False, methods=['get'], url_path='check-user') def check_user(self, request): """ Проверить пользователя по email: зарегистрирован ли, является ли клиентом. GET /api/manage/clients/check-user/?email=... Ответ: { "exists": bool, "is_client": bool } """ if request.user.role != 'mentor': return Response({'error': 'Только для менторов'}, status=status.HTTP_403_FORBIDDEN) email = (request.query_params.get('email') or '').strip().lower() if not email: return Response({'error': 'Укажите email'}, status=status.HTTP_400_BAD_REQUEST) try: u = User.objects.get(email=email) return Response({'exists': True, 'is_client': u.role == 'client'}) except User.DoesNotExist: return Response({'exists': False, 'is_client': False}) @action(detail=False, methods=['post']) def add_client(self, request): """ Отправить приглашение студенту. Взаимодействие разрешено только после подтверждения студентом (и родителем, если привязан). POST /api/manage/clients/add_client/ Body: либо { "email": "..." } — для незарегистрированных или по email; либо { "universal_code": "A1B2C3D4" } — 8-символьный код (цифры и латинские буквы) зарегистрированного ученика. Ответ: { "status": "invitation_sent", "message": "...", "invitation_id": id } """ mentor = request.user if mentor.role != 'mentor': return Response({'error': 'Только для менторов'}, status=status.HTTP_403_FORBIDDEN) for page in range(1, 10): for size in [10, 20, 50, 100, 1000]: cache.delete(f'manage_clients_{mentor.id}_{page}_{size}') email = (request.data.get('email') or '').strip().lower() universal_code = (request.data.get('universal_code') or '').strip() if not email and not universal_code: return Response( {'error': 'Укажите email (для нового ученика) или 8-символьный универсальный код (для зарегистрированного)'}, status=status.HTTP_400_BAD_REQUEST ) if email and universal_code: return Response( {'error': 'Укажите только email или только универсальный код'}, status=status.HTTP_400_BAD_REQUEST ) client_user = None client = None is_new_user = False set_password_url = None if universal_code: universal_code = universal_code.upper().strip() allowed = set('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') # Допускаем старый формат (6 цифр) и новый (8 символов: цифры + латинские буквы) valid_8 = len(universal_code) == 8 and all(c in allowed for c in universal_code) valid_6_legacy = len(universal_code) == 6 and universal_code.isdigit() if not (valid_8 or valid_6_legacy): return Response( {'error': 'Универсальный код: 8 символов (цифры и латинские буквы) или 6 цифр (старый формат)'}, status=status.HTTP_400_BAD_REQUEST ) try: client_user = User.objects.get(universal_code=universal_code) except User.DoesNotExist: return Response( {'error': 'Пользователь с таким кодом не найден'}, status=status.HTTP_404_NOT_FOUND ) if client_user.role != 'client': return Response( {'error': 'Пользователь с этим кодом не является учеником'}, status=status.HTTP_400_BAD_REQUEST ) client, _ = Client.objects.get_or_create(user=client_user) else: try: client_user = User.objects.get(email=email) if client_user.role != 'client': return Response( {'error': 'Пользователь с таким email зарегистрирован с другой ролью'}, status=status.HTTP_400_BAD_REQUEST ) client, _ = Client.objects.get_or_create(user=client_user) except User.DoesNotExist: temp_password = secrets.token_urlsafe(12) client_user = User.objects.create_user( email=email, password=temp_password, first_name='', last_name='', role='client', email_verified=False, ) client = Client.objects.create(user=client_user) is_new_user = True reset_token = secrets.token_urlsafe(32) client_user.email_verification_token = reset_token client_user.save() set_password_url = f"{getattr(settings, 'FRONTEND_URL', '')}/reset-password?token={reset_token}" if mentor in client.mentors.all(): return Response( {'error': 'Этот ученик уже добавлен к вам'}, status=status.HTTP_400_BAD_REQUEST ) conn, created = MentorStudentConnection.objects.get_or_create( mentor=mentor, student=client_user, defaults={ 'status': MentorStudentConnection.STATUS_PENDING_STUDENT, 'initiator': MentorStudentConnection.INITIATOR_MENTOR, 'confirm_token': secrets.token_urlsafe(32) if is_new_user or True else None, } ) if not created: if conn.status == MentorStudentConnection.STATUS_ACCEPTED: return Response({'error': 'Ученик уже добавлен'}, status=status.HTTP_400_BAD_REQUEST) if conn.status == MentorStudentConnection.STATUS_PENDING_STUDENT: return Response({ 'status': 'invitation_sent', 'message': 'Приглашение уже отправлено, ожидайте подтверждения', 'invitation_id': conn.id, }, status=status.HTTP_200_OK) if not conn.confirm_token: conn.confirm_token = secrets.token_urlsafe(32) conn.save(update_fields=['confirm_token']) confirm_url = f"{getattr(settings, 'FRONTEND_URL', '')}/invitation/confirm?token={conn.confirm_token}" if is_new_user and set_password_url: set_password_url = f"{set_password_url}&invitation_token={conn.confirm_token}" send_mentor_invitation_email_task.delay( conn.id, set_password_url=set_password_url if is_new_user else None, ) # Уведомление студенту (in-app, telegram) NotificationService.create_notification_with_telegram( recipient=client_user, notification_type='mentor_invitation_new', title='Вас пригласили в качестве ученика', message=f'{mentor.get_full_name() or mentor.email} приглашает вас стать учеником. Подтвердите приглашение во вкладке «Входящие приглашения».', action_url='/request-mentor', data={'connection_id': conn.id, 'invitation_id': conn.id}, ) return Response({ 'status': 'invitation_sent', 'message': 'Приглашение отправлено. После подтверждения учеником (и родителем при необходимости) взаимодействие будет разрешено.', 'invitation_id': conn.id, }, status=status.HTTP_201_CREATED) @action(detail=True, methods=['delete']) def remove_client(self, request, pk=None): """ Удалить клиента. DELETE /api/users/clients/{id}/remove_client/ При удалении студента из списка ментора: - Удаляются все будущие занятия с этим учеником - Автоматически запрещается доступ ко всем материалам (через связь mentors) """ from django.utils import timezone from apps.schedule.models import Lesson user = request.user if user.role != 'mentor': return Response( {'error': 'Только для менторов'}, status=status.HTTP_403_FORBIDDEN ) try: client = Client.objects.get(id=pk, mentors=user) except Client.DoesNotExist: return Response( {'error': 'Клиент не найден'}, status=status.HTTP_404_NOT_FOUND ) # Удаляем все будущие занятия с этим учеником now = timezone.now() future_lessons = Lesson.objects.filter( mentor=user, client=client, start_time__gt=now, status='scheduled' ) future_lessons_count = future_lessons.count() future_lessons.delete() # Удаляем ментора (это автоматически запретит доступ ко всем материалам) client.mentors.remove(user) # Инвалидируем кеш списка клиентов для этого ментора # Удаляем все варианты кеша для этого пользователя (разные страницы и размеры) for page in range(1, 10): for size in [10, 20, 50, 100, 1000]: cache.delete(f'manage_clients_{user.id}_{page}_{size}') return Response({ 'message': 'Клиент успешно удален', 'future_lessons_deleted': future_lessons_count }) @action(detail=True, methods=['get']) def client_details(self, request, pk=None): # ... existing code ... return Response(data) @action(detail=False, methods=['post'], url_path='generate-invitation-link') def generate_invitation_link(self, request): """ Сгенерировать или обновить токен ссылки-приглашения. POST /api/users/manage/clients/generate-invitation-link/ """ user = request.user if user.role != 'mentor': return Response({'error': 'Только для менторов'}, status=status.HTTP_403_FORBIDDEN) user.invitation_link_token = secrets.token_urlsafe(32) user.save(update_fields=['invitation_link_token']) from django.conf import settings frontend_url = getattr(settings, 'FRONTEND_URL', '').rstrip('/') link = f"{frontend_url}/invite/{user.invitation_link_token}" return Response({ 'invitation_link_token': user.invitation_link_token, 'invitation_link': link }) class InvitationViewSet(viewsets.ViewSet): """ Подтверждение приглашений ментор—студент. confirm_by_token: подтвердить по токену из письма (студент) my_invitations: список приглашений для текущего пользователя (студент/родитель) confirm_as_student: подтвердить как студент (по invitation_id, авторизованный) confirm_as_parent: подтвердить как родитель (по invitation_id) """ permission_classes = [IsAuthenticated] @action(detail=False, methods=['post'], url_path='confirm-by-token', permission_classes=[AllowAny]) def confirm_by_token(self, request): """ Подтвердить приглашение по токену из письма (для студента). POST /api/invitation/confirm-by-token/ { "token": "..." } """ token = (request.data.get('token') or request.query_params.get('token') or '').strip() if not token: return Response({'error': 'Укажите токен'}, status=status.HTTP_400_BAD_REQUEST) try: conn = MentorStudentConnection.objects.select_related('mentor', 'student').get( confirm_token=token, status=MentorStudentConnection.STATUS_PENDING_STUDENT, initiator=MentorStudentConnection.INITIATOR_MENTOR, ) except MentorStudentConnection.DoesNotExist: return Response({'error': 'Приглашение не найдено или уже обработано'}, status=status.HTTP_404_NOT_FOUND) conn.student_confirmed_at = timezone.now() if conn.requires_parent_confirmation(): conn.status = MentorStudentConnection.STATUS_PENDING_PARENT else: conn.status = MentorStudentConnection.STATUS_ACCEPTED conn.save(update_fields=['student_confirmed_at', 'status', 'updated_at']) if conn.status == MentorStudentConnection.STATUS_ACCEPTED: _apply_mentor_connection(conn) return Response({ 'status': 'student_confirmed', 'message': 'Приглашение подтверждено. Родитель также должен подтвердить, если он привязан к вашему аккаунту.', 'requires_parent': conn.requires_parent_confirmation(), }) @action(detail=False, methods=['get'], url_path='my-invitations') def my_invitations(self, request): """ Список приглашений для текущего пользователя (студент — свои, родитель — для своих детей). GET /api/invitation/my-invitations/ """ user = request.user if user.role == 'client': invitations = MentorStudentConnection.objects.filter( student=user, initiator=MentorStudentConnection.INITIATOR_MENTOR, status__in=[MentorStudentConnection.STATUS_PENDING_STUDENT, MentorStudentConnection.STATUS_PENDING_PARENT], ).select_related('mentor').order_by('-created_at') elif user.role == 'parent': try: parent = user.parent_profile children = parent.children.all() child_users = [c.user_id for c in children] except Parent.DoesNotExist: child_users = [] invitations = MentorStudentConnection.objects.filter( student_id__in=child_users, initiator=MentorStudentConnection.INITIATOR_MENTOR, status=MentorStudentConnection.STATUS_PENDING_PARENT, ).select_related('mentor', 'student').order_by('-created_at') else: invitations = MentorStudentConnection.objects.none() data = [ { 'id': inv.id, 'mentor': {'id': inv.mentor.id, 'email': inv.mentor.email, 'first_name': inv.mentor.first_name, 'last_name': inv.mentor.last_name}, 'student': {'id': inv.student.id, 'email': inv.student.email} if inv.student_id else None, 'status': inv.status, 'created_at': inv.created_at.isoformat() if inv.created_at else None, 'student_confirmed_at': inv.student_confirmed_at.isoformat() if inv.student_confirmed_at else None, } for inv in invitations ] return Response(data) @action(detail=False, methods=['post'], url_path='confirm-as-student') def confirm_as_student(self, request): """ Подтвердить приглашение как студент (авторизованный). POST /api/invitation/confirm-as-student/ { "invitation_id": id } """ user = request.user if user.role != 'client': return Response({'error': 'Только для учеников'}, status=status.HTTP_403_FORBIDDEN) inv_id = request.data.get('invitation_id') if inv_id is None: return Response({'error': 'Укажите invitation_id'}, status=status.HTTP_400_BAD_REQUEST) try: inv_id = int(inv_id) except (TypeError, ValueError): return Response({'error': 'Некорректный invitation_id'}, status=status.HTTP_400_BAD_REQUEST) try: conn = MentorStudentConnection.objects.select_related('mentor').get( id=inv_id, student=user, status=MentorStudentConnection.STATUS_PENDING_STUDENT, initiator=MentorStudentConnection.INITIATOR_MENTOR, ) except MentorStudentConnection.DoesNotExist: conn_any = MentorStudentConnection.objects.filter(id=inv_id, student=user).first() if conn_any: if conn_any.initiator != MentorStudentConnection.INITIATOR_MENTOR: return Response({'error': 'Это запрос от вас, не приглашение. Принимает ментор.'}, status=status.HTTP_400_BAD_REQUEST) if conn_any.status == MentorStudentConnection.STATUS_ACCEPTED: return Response({'error': 'Приглашение уже принято'}, status=status.HTTP_400_BAD_REQUEST) if conn_any.status == MentorStudentConnection.STATUS_PENDING_PARENT: return Response({'error': 'Вы уже подтвердили. Ожидается подтверждение родителя.'}, status=status.HTTP_400_BAD_REQUEST) return Response({'error': 'Приглашение не найдено'}, status=status.HTTP_404_NOT_FOUND) conn.student_confirmed_at = timezone.now() if conn.requires_parent_confirmation(): conn.status = MentorStudentConnection.STATUS_PENDING_PARENT else: conn.status = MentorStudentConnection.STATUS_ACCEPTED conn.save(update_fields=['student_confirmed_at', 'status', 'updated_at']) if conn.status == MentorStudentConnection.STATUS_ACCEPTED: _apply_mentor_connection(conn) # Уведомление ментору: приглашение принято (in-app, email, telegram) NotificationService.create_notification_with_telegram( recipient=conn.mentor, notification_type='mentor_invitation_accepted', title='Приглашение принято', message=f'{user.get_full_name() or user.email} принял(а) ваше приглашение.', action_url='/students', data={'connection_id': conn.id}, ) return Response({'status': 'student_confirmed', 'requires_parent': conn.requires_parent_confirmation()}) @action(detail=False, methods=['post'], url_path='reject-as-student') def reject_as_student(self, request): """ Отклонить приглашение как студент (авторизованный). POST /api/invitation/reject-as-student/ { "invitation_id": id } """ user = request.user if user.role != 'client': return Response({'error': 'Только для учеников'}, status=status.HTTP_403_FORBIDDEN) inv_id = request.data.get('invitation_id') if inv_id is None: return Response({'error': 'Укажите invitation_id'}, status=status.HTTP_400_BAD_REQUEST) try: inv_id = int(inv_id) except (TypeError, ValueError): return Response({'error': 'Некорректный invitation_id'}, status=status.HTTP_400_BAD_REQUEST) try: conn = MentorStudentConnection.objects.select_related('mentor').get( id=inv_id, student=user, status=MentorStudentConnection.STATUS_PENDING_STUDENT, initiator=MentorStudentConnection.INITIATOR_MENTOR, ) except MentorStudentConnection.DoesNotExist: return Response({'error': 'Приглашение не найдено'}, status=status.HTTP_404_NOT_FOUND) conn.status = MentorStudentConnection.STATUS_REJECTED conn.save(update_fields=['status', 'updated_at']) # Уведомление ментору: приглашение отклонено (in-app, email, telegram) NotificationService.create_notification_with_telegram( recipient=conn.mentor, notification_type='mentor_invitation_rejected', title='Приглашение отклонено', message=f'{user.get_full_name() or user.email} отклонил(а) ваше приглашение.', action_url='/students', data={'connection_id': conn.id}, ) return Response({'status': 'rejected', 'message': 'Приглашение отклонено'}) @action(detail=False, methods=['post'], url_path='confirm-as-parent') def confirm_as_parent(self, request): # ... existing code ... return Response({'status': 'confirmed'}) @action(detail=False, methods=['get'], url_path='info-by-token', permission_classes=[AllowAny]) def info_by_token(self, request): """ Получить информацию о менторе по токену ссылки-приглашения. GET /api/invitation/info-by-token/?token=... """ token = request.query_params.get('token') if not token: return Response({'error': 'Токен не указан'}, status=status.HTTP_400_BAD_REQUEST) try: mentor = User.objects.get(invitation_link_token=token, role='mentor') return Response({ 'mentor_name': mentor.get_full_name(), 'mentor_id': mentor.id, 'avatar_url': request.build_absolute_uri(mentor.avatar.url) if mentor.avatar else None, }) except User.DoesNotExist: return Response({'error': 'Недействительная ссылка'}, status=status.HTTP_404_NOT_FOUND) @action(detail=False, methods=['post'], url_path='register-by-link', permission_classes=[AllowAny]) def register_by_link(self, request): """ Регистрация ученика по ссылке-приглашению. POST /api/invitation/register-by-link/ Body: { "token": "...", "first_name": "...", "last_name": "...", "email": "..." (optional), "password": "..." (optional), "timezone": "...", "city": "..." } """ token = request.data.get('token') first_name = request.data.get('first_name') last_name = request.data.get('last_name') email = request.data.get('email', '').strip().lower() password = request.data.get('password') timezone_name = request.data.get('timezone', 'Europe/Moscow') city = request.data.get('city', '') if not all([token, first_name, last_name]): return Response({'error': 'Имя, фамилия и токен обязательны'}, status=status.HTTP_400_BAD_REQUEST) try: mentor = User.objects.get(invitation_link_token=token, role='mentor') except User.DoesNotExist: return Response({'error': 'Недействительная ссылка'}, status=status.HTTP_404_NOT_FOUND) # Если email указан, проверяем его уникальность if email: if User.objects.filter(email=email).exists(): return Response({'error': 'Пользователь с таким email уже существует'}, status=status.HTTP_400_BAD_REQUEST) else: # Если email не указан, генерируем временный уникальный email email = f"student_{secrets.token_hex(8)}@platform.local" # Создаем пользователя temp_password = password or secrets.token_urlsafe(12) student_user = User.objects.create_user( email=email, password=temp_password, first_name=first_name, last_name=last_name, role='client', email_verified=True, # Аккаунт по ссылке считается верифицированным timezone=timezone_name, city=city ) # Генерируем персональный токен для входа student_user.login_token = secrets.token_urlsafe(32) student_user.save(update_fields=['login_token']) # Создаем профиль клиента client = Client.objects.create(user=student_user) # Создаем связь с ментором conn = MentorStudentConnection.objects.create( mentor=mentor, student=student_user, status=MentorStudentConnection.STATUS_ACCEPTED, initiator=MentorStudentConnection.INITIATOR_STUDENT, student_confirmed_at=timezone.now() ) # Применяем связь (добавляем ментора к клиенту, создаем доску и т.д.) _apply_mentor_connection(conn) # Если был указан реальный email, но не указан пароль, отправляем пароль if email and not email.endswith('@platform.local') and not password: # Генерируем токен для сброса пароля, чтобы пользователь мог установить свой reset_token = secrets.token_urlsafe(32) student_user.email_verification_token = reset_token student_user.save(update_fields=['email_verification_token']) # Отправляем приветственное письмо send_student_welcome_email_task.delay(student_user.id, reset_token) # Генерируем JWT токены для автоматического входа 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), 'user': UserSerializer(student_user, context={'request': request}).data, 'message': 'Регистрация успешна' }, status=status.HTTP_201_CREATED) class ParentManagementViewSet(viewsets.ViewSet): """ ViewSet для управления родителями. add_child: Добавить ребенка remove_child: Удалить ребенка """ permission_classes = [IsAuthenticated] @action(detail=False, methods=['post']) def add_child(self, request): """ Добавить ребенка (для родителей). Создает нового пользователя если не существует (аналогично add_client для ментора). POST /api/users/manage/parents/add_child/ Body: { "email": "...", "first_name": "...", "last_name": "...", "phone": "..." (optional), "grade": "..." (optional), "school": "..." (optional), "learning_goals": "..." (optional) } ИЛИ (для обратной совместимости): { "child_email": "..." } """ user = request.user if user.role != 'parent': return Response( {'error': 'Только для родителей'}, status=status.HTTP_403_FORBIDDEN ) # Поддержка старого формата (child_email) и нового (email + данные) child_email = request.data.get('child_email') or request.data.get('email') if not child_email: return Response( {'error': 'Необходимо указать email ребенка'}, status=status.HTTP_400_BAD_REQUEST ) # Нормализуем email child_email = child_email.lower().strip() # Получаем или создаем профиль родителя parent, created = Parent.objects.get_or_create(user=user) # Ищем пользователя created = False try: child_user = User.objects.get(email=child_email) # Если пользователь существует, проверяем что это клиент if child_user.role != 'client': return Response( {'error': 'Пользователь с таким email уже существует, но не является клиентом'}, status=status.HTTP_400_BAD_REQUEST ) except User.DoesNotExist: created = True # Создаем нового пользователя-клиента first_name = request.data.get('first_name', '').strip() last_name = request.data.get('last_name', '').strip() phone = request.data.get('phone', '').strip() if not first_name or not last_name: return Response( {'error': 'Для создания нового пользователя необходимо указать имя и фамилию'}, status=status.HTTP_400_BAD_REQUEST ) # Создаем пользователя с временным паролем temp_password = secrets.token_urlsafe(12) child_user = User.objects.create_user( email=child_email, password=temp_password, first_name=first_name, last_name=last_name, phone=normalize_phone(phone) if phone else '', role='client', email_verified=True, # Email автоматически подтвержден при добавлении родителем ) # Генерируем токен для установки пароля reset_token = secrets.token_urlsafe(32) child_user.email_verification_token = reset_token child_user.save() # Отправляем приветственное письмо со ссылкой на установку пароля send_student_welcome_email_task.delay(child_user.id, reset_token) # Получаем или создаем профиль клиента if created: # Если пользователь был только что создан child = Client.objects.create( user=child_user, grade=request.data.get('grade', ''), school=request.data.get('school', ''), learning_goals=request.data.get('learning_goals', ''), ) else: child, _ = Client.objects.get_or_create(user=child_user) # Обновляем дополнительные поля если они переданы if 'grade' in request.data: child.grade = request.data.get('grade') if 'school' in request.data: child.school = request.data.get('school') if 'learning_goals' in request.data: child.learning_goals = request.data.get('learning_goals') if 'phone' in request.data and not child_user.phone: child_user.phone = normalize_phone(str(request.data.get('phone', '') or '')) child_user.save() child.save() # Добавляем ребенка (если еще не добавлен) if child not in parent.children.all(): parent.children.add(child) # Возвращаем данные ребенка в формате ClientSerializer from .serializers import ClientSerializer child_serializer = ClientSerializer(child, context={'request': request}) return Response({ 'id': str(child.id), 'user': { 'id': str(child_user.id), 'email': child_user.email, 'first_name': child_user.first_name, 'last_name': child_user.last_name, 'avatar': child_user.avatar.url if child_user.avatar else None, 'avatar_url': request.build_absolute_uri(child_user.avatar.url) if child_user.avatar else None, }, 'grade': child.grade, 'school': child.school, 'learning_goals': child.learning_goals, }) @action(detail=True, methods=['delete']) def remove_child(self, request, pk=None): """ Удалить ребенка. DELETE /api/users/parents/{child_id}/remove_child/ """ user = request.user if user.role != 'parent': return Response( {'error': 'Только для родителей'}, status=status.HTTP_403_FORBIDDEN ) try: parent = Parent.objects.get(user=user) except Parent.DoesNotExist: return Response( {'error': 'Профиль родителя не найден'}, status=status.HTTP_404_NOT_FOUND ) try: child = Client.objects.get(id=pk) except Client.DoesNotExist: return Response( {'error': 'Клиент не найден'}, status=status.HTTP_404_NOT_FOUND ) # Удаляем ребенка parent.children.remove(child) return Response({'message': 'Ребенок успешно удален'})