""" Модели пользователей платформы. """ import random import string from django.contrib.auth.models import AbstractUser, BaseUserManager from django.db import models from django.utils.translation import gettext_lazy as _ from django.core.validators import RegexValidator, MinLengthValidator from .utils import normalize_phone class UserManager(BaseUserManager): """Менеджер для кастомной модели User.""" def create_user(self, email, password=None, **extra_fields): """Создать обычного пользователя.""" if not email: raise ValueError(_('Email обязателен')) email = self.normalize_email(email) user = self.model(email=email, **extra_fields) user.set_password(password) user.save(using=self._db) return user def create_superuser(self, email, password=None, **extra_fields): """Создать суперпользователя.""" extra_fields.setdefault('is_staff', True) extra_fields.setdefault('is_superuser', True) extra_fields.setdefault('is_active', True) extra_fields.setdefault('role', 'admin') if extra_fields.get('is_staff') is not True: raise ValueError(_('Суперпользователь должен иметь is_staff=True')) if extra_fields.get('is_superuser') is not True: raise ValueError(_('Суперпользователь должен иметь is_superuser=True')) return self.create_user(email, password, **extra_fields) class User(AbstractUser): """ Пользователь платформы с ролями. Роли: - mentor: Преподаватель/Ментор - client: Ученик/Клиент - parent: Родитель ученика - admin: Администратор системы """ ROLE_CHOICES = [ ('mentor', 'Ментор'), ('client', 'Клиент'), ('parent', 'Родитель'), ('admin', 'Администратор'), ] # Переопределяем username, делаем его необязательным username = models.CharField( max_length=150, unique=True, blank=True, null=True, verbose_name='Имя пользователя' ) # Email как основной идентификатор email = models.EmailField( unique=True, verbose_name='Email', help_text='Email используется для входа в систему' ) # Роль пользователя role = models.CharField( max_length=10, choices=ROLE_CHOICES, default='client', verbose_name='Роль', help_text='Роль определяет права доступа в системе' ) # Telegram интеграция telegram_id = models.BigIntegerField( null=True, blank=True, unique=True, verbose_name='Telegram ID', help_text='ID пользователя в Telegram для уведомлений' ) telegram_username = models.CharField( max_length=100, blank=True, verbose_name='Telegram Username' ) # Контактные данные phone_regex = RegexValidator( regex=r'^\+?1?\d{9,15}$', message="Телефон должен быть в формате: '+999999999'. До 15 цифр." ) phone = models.CharField( validators=[phone_regex], max_length=17, blank=True, verbose_name='Телефон' ) # Профиль avatar = models.ImageField( upload_to='avatars/%Y/%m/', null=True, blank=True, verbose_name='Аватар' ) birth_date = models.DateField( null=True, blank=True, verbose_name='Дата рождения' ) bio = models.TextField( blank=True, max_length=500, verbose_name='О себе' ) # Верификация email_verified = models.BooleanField( default=False, verbose_name='Email подтвержден' ) email_verification_token = models.CharField( max_length=100, blank=True, verbose_name='Токен подтверждения email' ) # Дополнительные поля timezone = models.CharField( max_length=50, default='Europe/Moscow', verbose_name='Часовой пояс' ) language = models.CharField( max_length=10, default='ru', verbose_name='Язык интерфейса' ) # Telegram интеграция telegram_id = models.BigIntegerField( null=True, blank=True, unique=True, verbose_name='Telegram ID', help_text='ID пользователя в Telegram для уведомлений' ) telegram_username = models.CharField( max_length=100, blank=True, verbose_name='Telegram username', help_text='Username в Telegram' ) country = models.CharField( max_length=100, blank=True, verbose_name='Страна', help_text='Страна проживания пользователя (для подбора часового пояса и аналитики)', ) city = models.CharField( max_length=100, blank=True, verbose_name='Город', help_text='Город проживания пользователя', ) language = models.CharField( max_length=10, default='ru', verbose_name='Язык', choices=[ ('ru', 'Русский'), ('en', 'English'), ] ) # Универсальный 8-символьный код (цифры + латинские буквы) для безопасного добавления ментором universal_code = models.CharField( max_length=8, unique=True, blank=True, null=True, validators=[MinLengthValidator(8)], verbose_name='Универсальный код', help_text='8-символьный код (цифры и латинские буквы) для добавления ученика ментором', ) # Статус is_blocked = models.BooleanField( default=False, verbose_name='Заблокирован' ) blocked_reason = models.TextField( blank=True, verbose_name='Причина блокировки' ) blocked_at = models.DateTimeField( null=True, blank=True, verbose_name='Дата блокировки' ) # Временные метки created_at = models.DateTimeField( auto_now_add=True, verbose_name='Дата создания' ) updated_at = models.DateTimeField( auto_now=True, verbose_name='Дата обновления' ) last_activity = models.DateTimeField( null=True, blank=True, verbose_name='Последняя активность' ) # Настройки уведомлений notifications_enabled = models.BooleanField( default=True, verbose_name='Уведомления включены' ) email_notifications = models.BooleanField( default=True, verbose_name='Email уведомления' ) telegram_notifications = models.BooleanField( default=False, verbose_name='Telegram уведомления' ) # Настройки ментора: доверие AI при проверке ДЗ (только для role=mentor) ai_trust_draft = models.BooleanField( default=False, verbose_name='Доверять AI (черновик)', help_text='AI выставит оценку и заполнит комментарий, сохранит как черновик' ) ai_trust_publish = models.BooleanField( default=False, verbose_name='Полностью доверять AI', help_text='AI выставит оценку и заполнит комментарий и опубликует' ) # Ссылка для приглашения учеников (только для менторов) invitation_link_token = models.CharField( max_length=64, blank=True, null=True, unique=True, verbose_name='Токен ссылки-приглашения', help_text='Токен для публичной ссылки, по которой ученики могут зарегистрироваться' ) # Токен для входа без пароля (для учеников, зарегистрированных по ссылке) login_token = models.CharField( max_length=64, blank=True, null=True, unique=True, verbose_name='Токен для входа', help_text='Токен, позволяющий войти в аккаунт по прямой ссылке' ) # Используем email для входа вместо username USERNAME_FIELD = 'email' REQUIRED_FIELDS = ['first_name', 'last_name'] objects = UserManager() class Meta: db_table = 'users' verbose_name = 'Пользователь' verbose_name_plural = 'Пользователи' ordering = ['-created_at'] indexes = [ models.Index(fields=['email']), models.Index(fields=['role']), models.Index(fields=['telegram_id']), models.Index(fields=['created_at']), ] def __str__(self): return f"{self.get_full_name()} ({self.email})" def save(self, *args, **kwargs): if self.phone: self.phone = normalize_phone(self.phone) super().save(*args, **kwargs) class MentorManager(UserManager): def get_queryset(self): return super().get_queryset().filter(role='mentor') class Mentor(User): """ Прокси-модель для отображения только менторов в админке. Использует ту же таблицу users, фильтр по role='mentor'. """ objects = MentorManager() class Meta: proxy = True verbose_name = 'Ментор' verbose_name_plural = 'Менторы' def get_full_name(self): """Получить полное имя пользователя.""" full_name = f"{self.first_name} {self.last_name}".strip() return full_name or self.email def get_short_name(self): """Получить короткое имя пользователя.""" return self.first_name or self.email.split('@')[0] @property def is_mentor(self): """Проверка, является ли пользователь ментором.""" return self.role == 'mentor' @property def is_client(self): """Проверка, является ли пользователь клиентом.""" return self.role == 'client' @property def is_parent(self): """Проверка, является ли пользователь родителем.""" return self.role == 'parent' @property def is_admin_role(self): """Проверка, является ли пользователь администратором по роли.""" return self.role == 'admin' def can_access_admin(self): """Может ли пользователь получить доступ к админ-панели.""" return self.is_staff or self.is_superuser or self.role == 'admin' def _generate_universal_code(self): """Генерация уникального 8-символьного кода (цифры + латинские буквы A–Z).""" alphabet = string.ascii_uppercase + string.digits for _ in range(100): code = ''.join(random.choices(alphabet, k=8)) if not User.objects.filter(universal_code=code).exclude(pk=self.pk).exists(): return code raise ValueError('Не удалось сгенерировать уникальный universal_code') def save(self, *args, **kwargs): """Переопределение save для автоматической генерации username и universal_code.""" if not self.username: # Генерируем username из email, если не задан self.username = self.email.split('@')[0] # Добавляем цифры, если username уже существует counter = 1 original_username = self.username while User.objects.filter(username=self.username).exclude(pk=self.pk).exists(): self.username = f"{original_username}{counter}" counter += 1 if not self.universal_code: self.universal_code = self._generate_universal_code() super().save(*args, **kwargs) class Client(models.Model): """ Модель клиента (ученика). Расширяет User дополнительными полями для учеников. """ user = models.OneToOneField( User, on_delete=models.CASCADE, related_name='client_profile', verbose_name='Пользователь' ) # Учебная информация grade = models.CharField( max_length=50, blank=True, verbose_name='Класс/Курс' ) school = models.CharField( max_length=200, blank=True, verbose_name='Школа/Учебное заведение' ) learning_goals = models.TextField( blank=True, verbose_name='Цели обучения' ) # Связь с менторами mentors = models.ManyToManyField( User, related_name='clients', limit_choices_to={'role': 'mentor'}, blank=True, verbose_name='Менторы' ) # Статистика total_lessons = models.IntegerField( default=0, verbose_name='Всего занятий' ) completed_lessons = models.IntegerField( default=0, verbose_name='Завершенных занятий' ) # Даты enrollment_date = models.DateField( auto_now_add=True, verbose_name='Дата регистрации' ) created_at = models.DateTimeField( auto_now_add=True, verbose_name='Дата создания' ) updated_at = models.DateTimeField( auto_now=True, verbose_name='Дата обновления' ) class Meta: db_table = 'clients' verbose_name = 'Клиент' verbose_name_plural = 'Клиенты' ordering = ['-created_at'] def __str__(self): return f"Клиент: {self.user.get_full_name()}" class Parent(models.Model): """ Модель родителя. Может быть привязан к нескольким ученикам. """ user = models.OneToOneField( User, on_delete=models.CASCADE, related_name='parent_profile', verbose_name='Пользователь' ) # Связь с детьми (клиентами) children = models.ManyToManyField( Client, related_name='parents', verbose_name='Дети' ) # Контактная информация relation_type = models.CharField( max_length=50, choices=[ ('mother', 'Мать'), ('father', 'Отец'), ('guardian', 'Опекун'), ('other', 'Другое'), ], default='other', verbose_name='Тип родства' ) # Доступ к информации can_view_progress = models.BooleanField( default=True, verbose_name='Может просматривать прогресс' ) can_view_schedule = models.BooleanField( default=True, verbose_name='Может просматривать расписание' ) can_receive_reports = models.BooleanField( default=True, verbose_name='Получает отчеты' ) # Даты created_at = models.DateTimeField( auto_now_add=True, verbose_name='Дата создания' ) updated_at = models.DateTimeField( auto_now=True, verbose_name='Дата обновления' ) class Meta: db_table = 'parents' verbose_name = 'Родитель' verbose_name_plural = 'Родители' ordering = ['-created_at'] def __str__(self): return f"Родитель: {self.user.get_full_name()}" class MentorStudentConnection(models.Model): """ Унифицированная модель связи ментор—студент и её статус. Объединяет запросы студента (MentorshipRequest) и приглашения ментора (MentorClientInvitation). """ STATUS_PENDING_MENTOR = 'pending_mentor' # студент отправил запрос, ждём ответа ментора STATUS_PENDING_STUDENT = 'pending_student' # ментор отправил приглашение, ждём студента STATUS_PENDING_PARENT = 'pending_parent' # студент подтвердил, ждём родителя STATUS_ACCEPTED = 'accepted' STATUS_REJECTED = 'rejected' STATUS_CHOICES = [ (STATUS_PENDING_MENTOR, 'Ожидает ответа ментора'), (STATUS_PENDING_STUDENT, 'Ожидает подтверждения студента'), (STATUS_PENDING_PARENT, 'Ожидает подтверждения родителя'), (STATUS_ACCEPTED, 'Принято'), (STATUS_REJECTED, 'Отклонено'), ] INITIATOR_STUDENT = 'student' INITIATOR_MENTOR = 'mentor' INITIATOR_CHOICES = [ (INITIATOR_STUDENT, 'Студент'), (INITIATOR_MENTOR, 'Ментор'), ] mentor = models.ForeignKey( User, on_delete=models.CASCADE, related_name='connections_as_mentor', limit_choices_to={'role': 'mentor'}, verbose_name='Ментор', ) student = models.ForeignKey( User, on_delete=models.CASCADE, related_name='connections_as_student', limit_choices_to={'role': 'client'}, verbose_name='Студент', ) status = models.CharField( max_length=20, choices=STATUS_CHOICES, db_index=True, verbose_name='Статус', ) initiator = models.CharField( max_length=10, choices=INITIATOR_CHOICES, verbose_name='Инициатор', ) student_confirmed_at = models.DateTimeField(null=True, blank=True, verbose_name='Подтверждено студентом') parent_confirmed_at = models.DateTimeField(null=True, blank=True, verbose_name='Подтверждено родителем') confirm_token = models.CharField( max_length=64, blank=True, unique=True, null=True, verbose_name='Токен подтверждения', ) created_at = models.DateTimeField(auto_now_add=True, verbose_name='Дата создания') updated_at = models.DateTimeField(auto_now=True, verbose_name='Дата обновления') class Meta: db_table = 'mentor_student_connections' verbose_name = 'Связь ментор—студент' verbose_name_plural = 'Связи ментор—студент' ordering = ['-created_at'] unique_together = [['mentor', 'student']] indexes = [ models.Index(fields=['status']), models.Index(fields=['mentor', 'status']), models.Index(fields=['student', 'status']), models.Index(fields=['confirm_token']), ] def __str__(self): return f'{self.mentor.get_full_name()} ↔ {self.student.get_full_name()} ({self.get_status_display()})' def requires_parent_confirmation(self): """Нужно ли подтверждение родителя. Отключено — студент подтверждает сам.""" return False class Group(models.Model): """ Учебная группа. Привязана к одному ментору и может включать неограниченное число учеников. Один ученик может состоять в нескольких группах. """ mentor = models.ForeignKey( User, on_delete=models.CASCADE, related_name='mentor_groups', limit_choices_to={'role': 'mentor'}, verbose_name='Ментор', ) name = models.CharField( max_length=255, verbose_name='Название группы', ) description = models.TextField( blank=True, verbose_name='Описание', ) students = models.ManyToManyField( Client, related_name='groups', blank=True, verbose_name='Ученики', ) created_at = models.DateTimeField( auto_now_add=True, verbose_name='Дата создания', ) updated_at = models.DateTimeField( auto_now=True, verbose_name='Дата обновления', ) class Meta: db_table = 'groups' verbose_name = 'Группа' verbose_name_plural = 'Группы' ordering = ['name'] indexes = [ models.Index(fields=['mentor', 'name']), ] def __str__(self): return f"{self.name} (ментор: {self.mentor.get_full_name()})"