uchill/backend/apps/users/models.py

673 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Модели пользователей платформы.
"""
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-символьного кода (цифры + латинские буквы AZ)."""
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()})"