uchill/backend/apps/users/models.py

743 lines
25 KiB
Python
Raw Permalink 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
import secrets
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=[], # Валидация длины выполняется в коде, чтобы не блокировать сохранение при null
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='Последняя активность'
)
# Прогресс онбординга (какие страницы уже показывали подсказки)
# Формат: {"dashboard": true, "schedule": true, "students": false, ...}
onboarding_tours_seen = models.JSONField(
default=dict,
blank=True,
verbose_name='Просмотренные туры онбординга',
help_text='Страницы, для которых уже показан приветственный тур',
)
# Настройки уведомлений
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 _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
# Если не удалось сгенерировать за 100 попыток, используем более длинный fallback
return secrets.token_hex(4).upper()
def save(self, *args, **kwargs):
# 1. Нормализация телефона
if self.phone:
self.phone = normalize_phone(self.phone)
# 2. Генерация username из email
if not self.username and self.email:
self.username = self.email.split('@')[0]
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 kwargs.get('update_fields') is not None:
fields = set(kwargs['update_fields'])
fields.add('username')
kwargs['update_fields'] = list(fields)
# 3. Гарантируем 8-символьный код (universal_code)
if not self.universal_code or len(str(self.universal_code).strip()) != 8:
self.universal_code = self._generate_universal_code()
if kwargs.get('update_fields') is not None:
fields = set(kwargs['update_fields'])
fields.add('universal_code')
kwargs['update_fields'] = list(fields)
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'
# Мы удалили Mentor.save и _generate_universal_code, так как они теперь в User
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_REMOVED = 'removed'
STATUS_CHOICES = [
(STATUS_PENDING_MENTOR, 'Ожидает ответа ментора'),
(STATUS_PENDING_STUDENT, 'Ожидает подтверждения студента'),
(STATUS_PENDING_PARENT, 'Ожидает подтверждения родителя'),
(STATUS_ACCEPTED, 'Принято'),
(STATUS_REJECTED, 'Отклонено'),
(STATUS_REMOVED, 'Удалено'),
]
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()})"
class InvitationLink(models.Model):
"""
Ссылка-приглашение от ментора для регистрации ученика.
- Каждая ссылка действует 12 часов с момента создания
- Одна ссылка — один ученик (used_by)
- Несколько ссылок могут быть активны одновременно
- По истечении 12 часов ссылка помечается is_banned=True и не может быть использована
"""
token = models.CharField(max_length=64, unique=True, db_index=True, verbose_name='Токен')
mentor = models.ForeignKey(
'User',
on_delete=models.CASCADE,
related_name='invitation_links',
verbose_name='Ментор',
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name='Создана')
used_by = models.OneToOneField(
'User',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='registered_via_link',
verbose_name='Использована пользователем',
)
is_banned = models.BooleanField(default=False, verbose_name='Забанена')
class Meta:
db_table = 'invitation_links'
verbose_name = 'Ссылка-приглашение'
verbose_name_plural = 'Ссылки-приглашения'
ordering = ['-created_at']
def __str__(self):
return f"InvitationLink({self.mentor.email}, {self.token[:8]}...)"
@property
def is_expired(self):
from django.utils import timezone as tz
from datetime import timedelta
return tz.now() > self.created_at + timedelta(hours=12)
@property
def is_valid(self):
return not self.is_banned and not self.is_expired and self.used_by_id is None