673 lines
22 KiB
Python
673 lines
22 KiB
Python
"""
|
||
Модели пользователей платформы.
|
||
"""
|
||
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()})" |