1808 lines
91 KiB
Python
1808 lines
91 KiB
Python
"""
|
||
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),
|
||
}
|
||
settings['onboarding_tours_seen'] = getattr(user, 'onboarding_tours_seen', {}) or {}
|
||
|
||
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'])
|
||
|
||
# Онбординг: отметка просмотренных туров
|
||
if 'onboarding_tours_seen' in request.data:
|
||
tours = request.data['onboarding_tours_seen']
|
||
if isinstance(tours, dict):
|
||
current = getattr(user, 'onboarding_tours_seen', None) or {}
|
||
merged = {**current, **{k: bool(v) for k, v in tours.items()}}
|
||
user.onboarding_tours_seen = merged
|
||
|
||
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):
|
||
"""
|
||
Создать новую ссылку-приглашение (12 часов, 1 использование).
|
||
Старые ссылки остаются действительными.
|
||
POST /api/manage/clients/generate-invitation-link/
|
||
"""
|
||
from django.utils import timezone as tz
|
||
from datetime import timedelta
|
||
from .models import InvitationLink
|
||
|
||
user = request.user
|
||
if user.role != 'mentor':
|
||
return Response({'error': 'Только для менторов'}, status=status.HTTP_403_FORBIDDEN)
|
||
|
||
# Истекаем просроченные ссылки этого ментора
|
||
expire_before = tz.now() - timedelta(hours=12)
|
||
InvitationLink.objects.filter(
|
||
mentor=user,
|
||
is_banned=False,
|
||
used_by__isnull=True,
|
||
created_at__lt=expire_before,
|
||
).update(is_banned=True)
|
||
|
||
token = secrets.token_urlsafe(32)
|
||
inv = InvitationLink.objects.create(mentor=user, token=token)
|
||
|
||
from django.conf import settings
|
||
frontend_url = getattr(settings, 'FRONTEND_URL', '').rstrip('/')
|
||
link = f"{frontend_url}/invite/{token}"
|
||
|
||
return Response({
|
||
'invitation_link_token': token,
|
||
'invitation_link': link,
|
||
'expires_at': (inv.created_at + timedelta(hours=12)).isoformat(),
|
||
})
|
||
|
||
|
||
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=...
|
||
"""
|
||
from django.utils import timezone as tz
|
||
from datetime import timedelta
|
||
from .models import InvitationLink
|
||
|
||
token = request.query_params.get('token')
|
||
if not token:
|
||
return Response({'error': 'Токен не указан'}, status=status.HTTP_400_BAD_REQUEST)
|
||
|
||
try:
|
||
inv = InvitationLink.objects.select_related('mentor').get(token=token)
|
||
except InvitationLink.DoesNotExist:
|
||
return Response({'error': 'Недействительная ссылка'}, status=status.HTTP_404_NOT_FOUND)
|
||
|
||
if inv.is_banned:
|
||
return Response({'error': 'Ссылка заблокирована'}, status=status.HTTP_410_GONE)
|
||
if inv.is_expired:
|
||
inv.is_banned = True
|
||
inv.save(update_fields=['is_banned'])
|
||
return Response({'error': 'Ссылка истекла'}, status=status.HTTP_410_GONE)
|
||
if inv.used_by_id is not None:
|
||
return Response({'error': 'Ссылка уже использована'}, status=status.HTTP_410_GONE)
|
||
|
||
mentor = inv.mentor
|
||
expires_at = inv.created_at + timedelta(hours=12)
|
||
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,
|
||
'expires_at': expires_at.isoformat(),
|
||
})
|
||
|
||
@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": "..."
|
||
}
|
||
"""
|
||
from .models import InvitationLink
|
||
|
||
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:
|
||
inv = InvitationLink.objects.select_related('mentor').get(token=token)
|
||
except InvitationLink.DoesNotExist:
|
||
return Response({'error': 'Недействительная ссылка'}, status=status.HTTP_404_NOT_FOUND)
|
||
|
||
if inv.is_banned:
|
||
return Response({'error': 'Ссылка заблокирована'}, status=status.HTTP_410_GONE)
|
||
if inv.is_expired:
|
||
inv.is_banned = True
|
||
inv.save(update_fields=['is_banned'])
|
||
return Response({'error': 'Срок действия ссылки истёк'}, status=status.HTTP_410_GONE)
|
||
if inv.used_by_id is not None:
|
||
return Response({'error': 'Ссылка уже была использована'}, status=status.HTTP_410_GONE)
|
||
|
||
mentor = inv.mentor
|
||
|
||
# Если 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'])
|
||
|
||
# Помечаем ссылку как использованную
|
||
inv.used_by = student_user
|
||
inv.save(update_fields=['used_by'])
|
||
|
||
# Создаем профиль клиента
|
||
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
|
||
)
|
||
|
||
# Поддержка: universal_code (8 симв.) / child_email / email
|
||
universal_code = (request.data.get('universal_code') or '').strip().upper()
|
||
child_email = request.data.get('child_email') or request.data.get('email')
|
||
|
||
if not universal_code and not child_email:
|
||
return Response(
|
||
{'error': 'Необходимо указать 8-значный код ребенка или его email'},
|
||
status=status.HTTP_400_BAD_REQUEST
|
||
)
|
||
|
||
# Получаем или создаем профиль родителя
|
||
parent, _ = Parent.objects.get_or_create(user=user)
|
||
|
||
created = False
|
||
|
||
if universal_code:
|
||
# --- Поиск по universal_code ---
|
||
allowed = set('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789')
|
||
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 символов (буквы и цифры)'},
|
||
status=status.HTTP_400_BAD_REQUEST
|
||
)
|
||
try:
|
||
child_user = User.objects.get(universal_code=universal_code)
|
||
except User.DoesNotExist:
|
||
return Response(
|
||
{'error': 'Пользователь с таким кодом не найден'},
|
||
status=status.HTTP_404_NOT_FOUND
|
||
)
|
||
if child_user.role != 'client':
|
||
return Response(
|
||
{'error': 'Пользователь с этим кодом не является учеником (client)'},
|
||
status=status.HTTP_400_BAD_REQUEST
|
||
)
|
||
else:
|
||
# --- Поиск / создание по email ---
|
||
child_email = child_email.lower().strip()
|
||
try:
|
||
child_user = User.objects.get(email=child_email)
|
||
if child_user.role != 'client':
|
||
return Response(
|
||
{'error': 'Пользователь с таким email не является учеником (client)'},
|
||
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,
|
||
)
|
||
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': 'Ребенок успешно удален'})
|
||
|