1147 lines
37 KiB
Python
1147 lines
37 KiB
Python
"""
|
||
Модели расписания занятий.
|
||
"""
|
||
from django.db import models
|
||
from django.core.validators import MinValueValidator, MaxValueValidator, FileExtensionValidator
|
||
from django.utils.translation import gettext_lazy as _
|
||
from django.utils import timezone
|
||
from datetime import timedelta
|
||
import uuid
|
||
import os
|
||
|
||
|
||
class Subject(models.Model):
|
||
"""
|
||
Модель предмета обучения.
|
||
Общие предметы, доступные всем менторам.
|
||
"""
|
||
name = models.CharField(
|
||
max_length=100,
|
||
unique=True,
|
||
verbose_name='Название предмета',
|
||
help_text='Название предмета обучения'
|
||
)
|
||
|
||
is_active = models.BooleanField(
|
||
default=True,
|
||
verbose_name='Активен',
|
||
help_text='Доступен ли предмет для использования'
|
||
)
|
||
|
||
created_at = models.DateTimeField(
|
||
auto_now_add=True,
|
||
verbose_name='Дата создания'
|
||
)
|
||
|
||
updated_at = models.DateTimeField(
|
||
auto_now=True,
|
||
verbose_name='Дата обновления'
|
||
)
|
||
|
||
class Meta:
|
||
db_table = 'subjects'
|
||
verbose_name = 'Предмет'
|
||
verbose_name_plural = 'Предметы'
|
||
ordering = ['name']
|
||
indexes = [
|
||
models.Index(fields=['is_active', 'name']),
|
||
]
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
|
||
class MentorSubject(models.Model):
|
||
"""
|
||
Кастомный предмет ментора.
|
||
Используется для предметов, которые еще не добавлены в общую модель Subject.
|
||
Если предмет используется более чем 10 менторами, он должен быть перенесен в Subject.
|
||
"""
|
||
mentor = models.ForeignKey(
|
||
'users.User',
|
||
on_delete=models.CASCADE,
|
||
related_name='mentor_subjects',
|
||
limit_choices_to={'role': 'mentor'},
|
||
verbose_name='Ментор'
|
||
)
|
||
|
||
name = models.CharField(
|
||
max_length=100,
|
||
verbose_name='Название предмета',
|
||
help_text='Название кастомного предмета'
|
||
)
|
||
|
||
usage_count = models.IntegerField(
|
||
default=0,
|
||
verbose_name='Количество использований',
|
||
help_text='Сколько раз этот предмет был использован в занятиях'
|
||
)
|
||
|
||
created_at = models.DateTimeField(
|
||
auto_now_add=True,
|
||
verbose_name='Дата создания'
|
||
)
|
||
|
||
updated_at = models.DateTimeField(
|
||
auto_now=True,
|
||
verbose_name='Дата обновления'
|
||
)
|
||
|
||
class Meta:
|
||
db_table = 'mentor_subjects'
|
||
verbose_name = 'Предмет ментора'
|
||
verbose_name_plural = 'Предметы менторов'
|
||
ordering = ['mentor', 'name']
|
||
indexes = [
|
||
models.Index(fields=['mentor', 'name']),
|
||
models.Index(fields=['name', 'usage_count']),
|
||
]
|
||
constraints = [
|
||
models.UniqueConstraint(
|
||
fields=['mentor', 'name'],
|
||
name='unique_mentor_subject'
|
||
),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"{self.name} ({self.mentor.get_full_name()})"
|
||
|
||
def increment_usage(self):
|
||
"""Увеличить счетчик использования."""
|
||
self.usage_count += 1
|
||
self.save(update_fields=['usage_count', 'updated_at'])
|
||
|
||
|
||
class Lesson(models.Model):
|
||
"""
|
||
Модель занятия.
|
||
Представляет конкретное занятие в определенное время.
|
||
"""
|
||
|
||
STATUS_CHOICES = [
|
||
('scheduled', 'Запланировано'),
|
||
('in_progress', 'В процессе'),
|
||
('completed', 'Завершено'),
|
||
('cancelled', 'Отменено'),
|
||
('rescheduled', 'Перенесено'),
|
||
]
|
||
|
||
# Участники
|
||
mentor = models.ForeignKey(
|
||
'users.User',
|
||
on_delete=models.CASCADE,
|
||
related_name='mentor_lessons',
|
||
limit_choices_to={'role': 'mentor'},
|
||
verbose_name='Ментор'
|
||
)
|
||
|
||
client = models.ForeignKey(
|
||
'users.Client',
|
||
on_delete=models.CASCADE,
|
||
related_name='lessons',
|
||
verbose_name='Клиент'
|
||
)
|
||
|
||
# Время и продолжительность
|
||
start_time = models.DateTimeField(
|
||
verbose_name='Время начала',
|
||
db_index=True
|
||
)
|
||
|
||
end_time = models.DateTimeField(
|
||
verbose_name='Время окончания',
|
||
db_index=True
|
||
)
|
||
|
||
duration = models.IntegerField(
|
||
validators=[MinValueValidator(15), MaxValueValidator(480)],
|
||
default=60,
|
||
verbose_name='Длительность (минуты)',
|
||
help_text='Длительность занятия в минутах (15-480)'
|
||
)
|
||
|
||
# Информация о занятии
|
||
title = models.CharField(
|
||
max_length=200,
|
||
verbose_name='Название',
|
||
help_text='Краткое название занятия'
|
||
)
|
||
|
||
description = models.TextField(
|
||
blank=True,
|
||
verbose_name='Описание',
|
||
help_text='Подробное описание занятия'
|
||
)
|
||
|
||
# Предмет (может быть общим или кастомным)
|
||
subject = models.ForeignKey(
|
||
'Subject',
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name='lessons',
|
||
verbose_name='Предмет',
|
||
help_text='Общий предмет обучения'
|
||
)
|
||
|
||
# Кастомный предмет ментора (если не выбран общий предмет)
|
||
mentor_subject = models.ForeignKey(
|
||
'MentorSubject',
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name='lessons',
|
||
verbose_name='Кастомный предмет',
|
||
help_text='Кастомный предмет ментора (если не выбран общий предмет)'
|
||
)
|
||
|
||
# Для обратной совместимости - храним название предмета как строку
|
||
subject_name = models.CharField(
|
||
max_length=100,
|
||
blank=True,
|
||
verbose_name='Название предмета (legacy)',
|
||
help_text='Название предмета для обратной совместимости'
|
||
)
|
||
|
||
# Статус
|
||
status = models.CharField(
|
||
max_length=20,
|
||
choices=STATUS_CHOICES,
|
||
default='scheduled',
|
||
verbose_name='Статус',
|
||
db_index=True
|
||
)
|
||
|
||
# Связь с шаблоном (если создано из шаблона)
|
||
template = models.ForeignKey(
|
||
'LessonTemplate',
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name='lessons',
|
||
verbose_name='Шаблон'
|
||
)
|
||
|
||
# Отмена
|
||
cancelled_by = models.ForeignKey(
|
||
'users.User',
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name='cancelled_lessons',
|
||
verbose_name='Отменено пользователем'
|
||
)
|
||
|
||
cancellation_reason = models.TextField(
|
||
blank=True,
|
||
verbose_name='Причина отмены'
|
||
)
|
||
|
||
cancelled_at = models.DateTimeField(
|
||
null=True,
|
||
blank=True,
|
||
verbose_name='Дата отмены'
|
||
)
|
||
|
||
# Перенос
|
||
rescheduled_from = models.ForeignKey(
|
||
'self',
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name='rescheduled_to',
|
||
verbose_name='Перенесено из'
|
||
)
|
||
|
||
# Ссылки
|
||
meeting_url = models.URLField(
|
||
blank=True,
|
||
max_length=500,
|
||
verbose_name='Ссылка на встречу',
|
||
help_text='Ссылка на видеоконференцию'
|
||
)
|
||
|
||
# Заметки
|
||
mentor_notes = models.TextField(
|
||
blank=True,
|
||
verbose_name='Заметки ментора',
|
||
help_text='Приватные заметки ментора о занятии'
|
||
)
|
||
|
||
# Домашнее задание
|
||
homework_text = models.TextField(
|
||
blank=True,
|
||
verbose_name='Домашнее задание',
|
||
help_text='Описание домашнего задания, выданного по результатам занятия'
|
||
)
|
||
|
||
# Оценки
|
||
mentor_grade = models.IntegerField(
|
||
null=True,
|
||
blank=True,
|
||
verbose_name='Оценка ментора',
|
||
help_text='Оценка работы студента на занятии (0-100)'
|
||
)
|
||
|
||
school_grade = models.IntegerField(
|
||
null=True,
|
||
blank=True,
|
||
verbose_name='Оценка в школе',
|
||
help_text='Текущая оценка студента в школе (0-100)'
|
||
)
|
||
|
||
# Стоимость
|
||
price = models.DecimalField(
|
||
max_digits=10,
|
||
decimal_places=2,
|
||
null=True,
|
||
blank=True,
|
||
verbose_name='Стоимость',
|
||
help_text='Стоимость занятия в рублях'
|
||
)
|
||
|
||
# Группа (для групповых занятий)
|
||
group = models.ForeignKey(
|
||
'users.Group',
|
||
on_delete=models.SET_NULL,
|
||
related_name='lessons',
|
||
null=True,
|
||
blank=True,
|
||
verbose_name='Группа',
|
||
help_text='Учебная группа, если занятие групповое'
|
||
)
|
||
|
||
# Напоминания
|
||
reminder_sent = models.BooleanField(
|
||
default=False,
|
||
verbose_name='Напоминание отправлено',
|
||
help_text='Устаревшее поле, используйте reminder_24h_sent, reminder_1h_sent, reminder_15m_sent'
|
||
)
|
||
|
||
reminder_24h_sent = models.BooleanField(
|
||
default=False,
|
||
verbose_name='Напоминание за 24 часа отправлено',
|
||
help_text='Отправлено ли напоминание за 24 часа до занятия'
|
||
)
|
||
|
||
reminder_1h_sent = models.BooleanField(
|
||
default=False,
|
||
verbose_name='Напоминание за 1 час отправлено',
|
||
help_text='Отправлено ли напоминание за 1 час до занятия'
|
||
)
|
||
|
||
reminder_15m_sent = models.BooleanField(
|
||
default=False,
|
||
verbose_name='Напоминание за 15 минут отправлено',
|
||
help_text='Отправлено ли напоминание за 15 минут до занятия'
|
||
)
|
||
|
||
# Подтверждение присутствия
|
||
attendance_confirmation_sent = models.BooleanField(
|
||
default=False,
|
||
verbose_name='Запрос подтверждения присутствия отправлен',
|
||
help_text='Отправлен ли запрос о подтверждении присутствия за 3 часа до занятия'
|
||
)
|
||
|
||
attendance_confirmed = models.BooleanField(
|
||
null=True,
|
||
blank=True,
|
||
verbose_name='Присутствие подтверждено',
|
||
help_text='Ответ студента на запрос о присутствии (True - будет, False - не будет, None - не ответил)'
|
||
)
|
||
|
||
attendance_response_at = models.DateTimeField(
|
||
null=True,
|
||
blank=True,
|
||
verbose_name='Время ответа о присутствии'
|
||
)
|
||
|
||
# reminder_sent_at временно удалено из модели, так как было удалено в миграции 0004
|
||
# TODO: Добавить обратно при необходимости
|
||
# reminder_sent_at = models.DateTimeField(
|
||
# null=True,
|
||
# blank=True,
|
||
# verbose_name='Время отправки напоминания'
|
||
# )
|
||
|
||
# Метрики подключения к видеокомнате (заполняются при подключении ментора/студента)
|
||
mentor_connected_at = models.DateTimeField(
|
||
null=True,
|
||
blank=True,
|
||
verbose_name='Ментор подключился',
|
||
help_text='Время подключения ментора к видеокомнате'
|
||
)
|
||
client_connected_at = models.DateTimeField(
|
||
null=True,
|
||
blank=True,
|
||
verbose_name='Студент подключился',
|
||
help_text='Время подключения студента к видеокомнате'
|
||
)
|
||
|
||
# Фактическое время завершения (если занятие завершено досрочно)
|
||
completed_at = models.DateTimeField(
|
||
null=True,
|
||
blank=True,
|
||
verbose_name='Фактическое время завершения',
|
||
help_text='Время, когда занятие было фактически завершено (может отличаться от end_time)'
|
||
)
|
||
|
||
# LiveKit видеоконференция
|
||
livekit_room_name = models.CharField(
|
||
max_length=255,
|
||
blank=True,
|
||
verbose_name='Название LiveKit комнаты',
|
||
help_text='Уникальное название комнаты LiveKit для этого занятия',
|
||
db_index=True
|
||
)
|
||
|
||
livekit_access_token = models.TextField(
|
||
blank=True,
|
||
verbose_name='Токен доступа LiveKit',
|
||
help_text='JWT токен для подключения к LiveKit комнате (действителен 24 часа)'
|
||
)
|
||
|
||
# Повторяющиеся занятия
|
||
is_recurring = models.BooleanField(
|
||
default=False,
|
||
verbose_name='Постоянное время занятия',
|
||
help_text='Если отмечено, занятие будет повторяться каждую неделю в этот день и время'
|
||
)
|
||
|
||
recurring_series_id = models.UUIDField(
|
||
null=True,
|
||
blank=True,
|
||
verbose_name='ID серии повторяющихся занятий',
|
||
help_text='Уникальный ID для группировки занятий одной серии',
|
||
db_index=True
|
||
)
|
||
|
||
parent_lesson = models.ForeignKey(
|
||
'self',
|
||
on_delete=models.CASCADE,
|
||
null=True,
|
||
blank=True,
|
||
related_name='recurring_lessons',
|
||
verbose_name='Родительское занятие',
|
||
help_text='Ссылка на первое занятие в серии повторяющихся занятий'
|
||
)
|
||
|
||
# Временные метки
|
||
created_at = models.DateTimeField(
|
||
auto_now_add=True,
|
||
null=True,
|
||
blank=True,
|
||
verbose_name='Дата создания'
|
||
)
|
||
|
||
updated_at = models.DateTimeField(
|
||
auto_now=True,
|
||
verbose_name='Дата обновления'
|
||
)
|
||
|
||
class Meta:
|
||
db_table = 'lessons'
|
||
verbose_name = 'Занятие'
|
||
verbose_name_plural = 'Занятия'
|
||
ordering = ['start_time']
|
||
indexes = [
|
||
models.Index(fields=['mentor', 'start_time']),
|
||
models.Index(fields=['client', 'start_time']),
|
||
models.Index(fields=['status', 'start_time']),
|
||
models.Index(fields=['start_time', 'end_time']),
|
||
models.Index(fields=['recurring_series_id']),
|
||
models.Index(fields=['mentor', 'status']),
|
||
models.Index(fields=['subject']),
|
||
models.Index(fields=['created_at']),
|
||
]
|
||
constraints = [
|
||
models.CheckConstraint(
|
||
check=models.Q(end_time__gt=models.F('start_time')),
|
||
name='lesson_end_after_start'
|
||
),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"{self.title} - {self.client.user.get_full_name()} ({self.start_time.strftime('%d.%m.%Y %H:%M')})"
|
||
|
||
def save(self, *args, **kwargs):
|
||
"""Автоматический расчет end_time на основе start_time и duration."""
|
||
if self.start_time and self.duration:
|
||
self.end_time = self.start_time + timedelta(minutes=self.duration)
|
||
super().save(*args, **kwargs)
|
||
|
||
@property
|
||
def is_upcoming(self):
|
||
"""Проверка, является ли занятие предстоящим."""
|
||
return self.start_time > timezone.now() and self.status == 'scheduled'
|
||
|
||
@property
|
||
def is_past(self):
|
||
"""Проверка, прошло ли занятие."""
|
||
return self.end_time < timezone.now()
|
||
|
||
@property
|
||
def is_in_progress(self):
|
||
"""Проверка, идет ли занятие сейчас."""
|
||
now = timezone.now()
|
||
return self.start_time <= now <= self.end_time and self.status == 'in_progress'
|
||
|
||
@property
|
||
def can_be_cancelled(self):
|
||
"""Можно ли отменить занятие."""
|
||
return self.status in ['scheduled'] and self.start_time > timezone.now()
|
||
|
||
@property
|
||
def can_be_rescheduled(self):
|
||
"""Можно ли перенести занятие."""
|
||
return self.status in ['scheduled'] and self.start_time > timezone.now()
|
||
|
||
def cancel(self, user, reason=''):
|
||
"""Отменить занятие."""
|
||
if not self.can_be_cancelled:
|
||
raise ValueError('Занятие нельзя отменить')
|
||
|
||
self.status = 'cancelled'
|
||
self.cancelled_by = user
|
||
self.cancellation_reason = reason
|
||
self.cancelled_at = timezone.now()
|
||
self.save()
|
||
|
||
def reschedule(self, new_start_time):
|
||
"""Перенести занятие на новое время."""
|
||
if not self.can_be_rescheduled:
|
||
raise ValueError('Занятие нельзя перенести')
|
||
|
||
# Создаем новое занятие
|
||
new_lesson = Lesson.objects.create(
|
||
mentor=self.mentor,
|
||
client=self.client,
|
||
start_time=new_start_time,
|
||
duration=self.duration,
|
||
title=self.title,
|
||
description=self.description,
|
||
subject=self.subject,
|
||
mentor_subject=self.mentor_subject,
|
||
subject_name=self.subject_name or (self.subject.name if self.subject else '') or (self.mentor_subject.name if self.mentor_subject else ''),
|
||
template=self.template,
|
||
meeting_url=self.meeting_url,
|
||
rescheduled_from=self,
|
||
)
|
||
|
||
# Отмечаем старое как перенесенное
|
||
self.status = 'rescheduled'
|
||
self.save()
|
||
|
||
return new_lesson
|
||
|
||
|
||
class LessonTemplate(models.Model):
|
||
"""
|
||
Шаблон занятия.
|
||
Используется для быстрого создания повторяющихся занятий.
|
||
"""
|
||
|
||
# Владелец шаблона
|
||
mentor = models.ForeignKey(
|
||
'users.User',
|
||
on_delete=models.CASCADE,
|
||
related_name='lesson_templates',
|
||
limit_choices_to={'role': 'mentor'},
|
||
verbose_name='Ментор'
|
||
)
|
||
|
||
# Информация о шаблоне
|
||
title = models.CharField(
|
||
max_length=200,
|
||
verbose_name='Название'
|
||
)
|
||
|
||
description = models.TextField(
|
||
blank=True,
|
||
verbose_name='Описание'
|
||
)
|
||
|
||
subject = models.ForeignKey(
|
||
'Subject',
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name='templates',
|
||
verbose_name='Предмет'
|
||
)
|
||
|
||
mentor_subject = models.ForeignKey(
|
||
'MentorSubject',
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name='templates',
|
||
verbose_name='Кастомный предмет'
|
||
)
|
||
|
||
subject_name = models.CharField(
|
||
max_length=100,
|
||
blank=True,
|
||
verbose_name='Название предмета (legacy)'
|
||
)
|
||
|
||
duration = models.IntegerField(
|
||
validators=[MinValueValidator(15), MaxValueValidator(480)],
|
||
default=60,
|
||
verbose_name='Длительность (минуты)'
|
||
)
|
||
|
||
# Настройки
|
||
is_active = models.BooleanField(
|
||
default=True,
|
||
verbose_name='Активен'
|
||
)
|
||
|
||
meeting_url = models.URLField(
|
||
blank=True,
|
||
max_length=500,
|
||
verbose_name='Ссылка на встречу'
|
||
)
|
||
|
||
# Цвет для календаря
|
||
color = models.CharField(
|
||
max_length=7,
|
||
default='#3B82F6',
|
||
verbose_name='Цвет',
|
||
help_text='HEX код цвета для отображения в календаре'
|
||
)
|
||
|
||
# Временные метки
|
||
created_at = models.DateTimeField(
|
||
auto_now_add=True,
|
||
null=True,
|
||
blank=True,
|
||
verbose_name='Дата создания'
|
||
)
|
||
|
||
updated_at = models.DateTimeField(
|
||
auto_now=True,
|
||
verbose_name='Дата обновления'
|
||
)
|
||
|
||
class Meta:
|
||
db_table = 'lesson_templates'
|
||
verbose_name = 'Шаблон занятия'
|
||
verbose_name_plural = 'Шаблоны занятий'
|
||
ordering = ['mentor', 'title']
|
||
|
||
def __str__(self):
|
||
return f"{self.title} ({self.mentor.get_full_name()})"
|
||
|
||
def create_lesson(self, client, start_time):
|
||
"""Создать занятие из шаблона."""
|
||
return Lesson.objects.create(
|
||
mentor=self.mentor,
|
||
client=client,
|
||
start_time=start_time,
|
||
duration=self.duration,
|
||
title=self.title,
|
||
description=self.description,
|
||
subject=self.subject,
|
||
mentor_subject=self.mentor_subject,
|
||
subject_name=self.subject_name or (self.subject.name if self.subject else '') or (self.mentor_subject.name if self.mentor_subject else ''),
|
||
template=self,
|
||
meeting_url=self.meeting_url,
|
||
)
|
||
|
||
|
||
class TimeSlot(models.Model):
|
||
"""
|
||
Временной слот.
|
||
Представляет конкретный временной интервал для бронирования.
|
||
"""
|
||
|
||
# Ментор
|
||
mentor = models.ForeignKey(
|
||
'users.User',
|
||
on_delete=models.CASCADE,
|
||
related_name='time_slots',
|
||
limit_choices_to={'role': 'mentor'},
|
||
verbose_name='Ментор'
|
||
)
|
||
|
||
# Время
|
||
start_time = models.DateTimeField(
|
||
verbose_name='Время начала',
|
||
db_index=True
|
||
)
|
||
|
||
end_time = models.DateTimeField(
|
||
verbose_name='Время окончания',
|
||
db_index=True
|
||
)
|
||
|
||
# Статус
|
||
is_available = models.BooleanField(
|
||
default=True,
|
||
verbose_name='Доступен',
|
||
help_text='Свободен ли слот для бронирования'
|
||
)
|
||
|
||
is_booked = models.BooleanField(
|
||
default=False,
|
||
verbose_name='Забронирован'
|
||
)
|
||
|
||
# Связь с занятием (если забронирован)
|
||
lesson = models.OneToOneField(
|
||
'Lesson',
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name='time_slot',
|
||
verbose_name='Занятие'
|
||
)
|
||
|
||
# Повторяющийся слот
|
||
is_recurring = models.BooleanField(
|
||
default=False,
|
||
verbose_name='Повторяющийся',
|
||
help_text='Повторяется ли этот слот еженедельно'
|
||
)
|
||
|
||
recurring_day = models.IntegerField(
|
||
null=True,
|
||
blank=True,
|
||
validators=[MinValueValidator(0), MaxValueValidator(6)],
|
||
verbose_name='День недели',
|
||
help_text='0=Понедельник, 6=Воскресенье'
|
||
)
|
||
|
||
# Временные метки
|
||
created_at = models.DateTimeField(
|
||
auto_now_add=True,
|
||
null=True,
|
||
blank=True,
|
||
verbose_name='Дата создания'
|
||
)
|
||
|
||
updated_at = models.DateTimeField(
|
||
auto_now=True,
|
||
verbose_name='Дата обновления'
|
||
)
|
||
|
||
class Meta:
|
||
db_table = 'time_slots'
|
||
verbose_name = 'Временной слот'
|
||
verbose_name_plural = 'Временные слоты'
|
||
ordering = ['start_time']
|
||
indexes = [
|
||
models.Index(fields=['mentor', 'start_time']),
|
||
models.Index(fields=['is_available', 'is_booked']),
|
||
models.Index(fields=['start_time', 'end_time']),
|
||
]
|
||
constraints = [
|
||
models.CheckConstraint(
|
||
check=models.Q(end_time__gt=models.F('start_time')),
|
||
name='timeslot_end_after_start'
|
||
),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"{self.mentor.get_full_name()} - {self.start_time.strftime('%d.%m.%Y %H:%M')}-{self.end_time.strftime('%H:%M')}"
|
||
|
||
def book(self, lesson):
|
||
"""Забронировать слот для занятия."""
|
||
if self.is_booked:
|
||
raise ValueError('Слот уже забронирован')
|
||
if not self.is_available:
|
||
raise ValueError('Слот недоступен')
|
||
|
||
self.is_booked = True
|
||
self.lesson = lesson
|
||
self.save()
|
||
|
||
def release(self):
|
||
"""Освободить слот."""
|
||
self.is_booked = False
|
||
self.lesson = None
|
||
self.save()
|
||
|
||
|
||
class Availability(models.Model):
|
||
"""
|
||
Доступность ментора.
|
||
Определяет когда ментор доступен для занятий.
|
||
"""
|
||
|
||
DAY_CHOICES = [
|
||
(0, 'Понедельник'),
|
||
(1, 'Вторник'),
|
||
(2, 'Среда'),
|
||
(3, 'Четверг'),
|
||
(4, 'Пятница'),
|
||
(5, 'Суббота'),
|
||
(6, 'Воскресенье'),
|
||
]
|
||
|
||
# Ментор
|
||
mentor = models.ForeignKey(
|
||
'users.User',
|
||
on_delete=models.CASCADE,
|
||
related_name='availabilities',
|
||
limit_choices_to={'role': 'mentor'},
|
||
verbose_name='Ментор'
|
||
)
|
||
|
||
# День недели (для повторяющейся доступности)
|
||
day_of_week = models.IntegerField(
|
||
choices=DAY_CHOICES,
|
||
null=True,
|
||
blank=True,
|
||
verbose_name='День недели',
|
||
help_text='Для еженедельного повторения'
|
||
)
|
||
|
||
# Конкретная дата (для разовой доступности)
|
||
specific_date = models.DateField(
|
||
null=True,
|
||
blank=True,
|
||
verbose_name='Конкретная дата',
|
||
help_text='Для разовой доступности'
|
||
)
|
||
|
||
# Время
|
||
start_time = models.TimeField(
|
||
verbose_name='Время начала'
|
||
)
|
||
|
||
end_time = models.TimeField(
|
||
verbose_name='Время окончания'
|
||
)
|
||
|
||
# Тип
|
||
is_recurring = models.BooleanField(
|
||
default=True,
|
||
verbose_name='Повторяющаяся',
|
||
help_text='Повторяется ли еженедельно'
|
||
)
|
||
|
||
# Статус
|
||
is_active = models.BooleanField(
|
||
default=True,
|
||
verbose_name='Активна'
|
||
)
|
||
|
||
# Исключения (даты, когда доступность не действует)
|
||
exception_dates = models.JSONField(
|
||
default=list,
|
||
blank=True,
|
||
verbose_name='Даты исключений',
|
||
help_text='Список дат в формате YYYY-MM-DD'
|
||
)
|
||
|
||
# Заметки
|
||
notes = models.TextField(
|
||
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 = 'availabilities'
|
||
verbose_name = 'Доступность'
|
||
verbose_name_plural = 'Доступность'
|
||
ordering = ['mentor', 'day_of_week', 'start_time']
|
||
indexes = [
|
||
models.Index(fields=['mentor', 'is_active']),
|
||
models.Index(fields=['day_of_week', 'start_time']),
|
||
models.Index(fields=['specific_date']),
|
||
]
|
||
constraints = [
|
||
models.CheckConstraint(
|
||
check=models.Q(end_time__gt=models.F('start_time')),
|
||
name='availability_end_after_start'
|
||
),
|
||
]
|
||
|
||
def __str__(self):
|
||
if self.is_recurring:
|
||
day_name = dict(self.DAY_CHOICES)[self.day_of_week]
|
||
return f"{self.mentor.get_full_name()} - {day_name} {self.start_time.strftime('%H:%M')}-{self.end_time.strftime('%H:%M')}"
|
||
else:
|
||
return f"{self.mentor.get_full_name()} - {self.specific_date} {self.start_time.strftime('%H:%M')}-{self.end_time.strftime('%H:%M')}"
|
||
|
||
def is_available_on(self, date):
|
||
"""Проверка доступности на конкретную дату."""
|
||
if not self.is_active:
|
||
return False
|
||
|
||
# Проверка исключений
|
||
date_str = date.strftime('%Y-%m-%d')
|
||
if date_str in self.exception_dates:
|
||
return False
|
||
|
||
# Проверка для повторяющейся доступности
|
||
if self.is_recurring:
|
||
return date.weekday() == self.day_of_week
|
||
|
||
# Проверка для разовой доступности
|
||
return date == self.specific_date
|
||
|
||
|
||
def lesson_file_upload_path(instance, filename):
|
||
"""Путь для загрузки файлов уроков."""
|
||
ext = filename.split('.')[-1]
|
||
new_filename = f"{uuid.uuid4()}.{ext}"
|
||
return os.path.join('lessons', str(instance.lesson.id), new_filename)
|
||
|
||
|
||
class LessonFile(models.Model):
|
||
"""
|
||
Файлы, прикрепленные к уроку.
|
||
Могут быть загружены при завершении урока или выбраны из учебных материалов.
|
||
"""
|
||
|
||
SOURCE_CHOICES = [
|
||
('uploaded', 'Загружен при завершении'),
|
||
('material', 'Из учебных материалов'),
|
||
]
|
||
|
||
lesson = models.ForeignKey(
|
||
Lesson,
|
||
on_delete=models.CASCADE,
|
||
related_name='files',
|
||
verbose_name='Урок'
|
||
)
|
||
|
||
# Файл (если загружен)
|
||
file = models.FileField(
|
||
upload_to=lesson_file_upload_path,
|
||
blank=True,
|
||
null=True,
|
||
max_length=500,
|
||
validators=[FileExtensionValidator(allowed_extensions=['pdf', 'doc', 'docx', 'txt', 'jpg', 'jpeg', 'png', 'zip', 'rar'])],
|
||
verbose_name='Файл'
|
||
)
|
||
|
||
# Ссылка на материал (если выбран из материалов)
|
||
material = models.ForeignKey(
|
||
'materials.Material',
|
||
on_delete=models.SET_NULL,
|
||
related_name='lesson_files',
|
||
null=True,
|
||
blank=True,
|
||
verbose_name='Учебный материал'
|
||
)
|
||
|
||
source = models.CharField(
|
||
max_length=20,
|
||
choices=SOURCE_CHOICES,
|
||
default='uploaded',
|
||
verbose_name='Источник'
|
||
)
|
||
|
||
filename = models.CharField(
|
||
max_length=255,
|
||
verbose_name='Название файла'
|
||
)
|
||
|
||
file_size = models.BigIntegerField(
|
||
null=True,
|
||
blank=True,
|
||
verbose_name='Размер файла (bytes)'
|
||
)
|
||
|
||
description = models.TextField(
|
||
blank=True,
|
||
verbose_name='Описание'
|
||
)
|
||
|
||
uploaded_by = models.ForeignKey(
|
||
'users.User',
|
||
on_delete=models.SET_NULL,
|
||
related_name='uploaded_lesson_files',
|
||
null=True,
|
||
verbose_name='Загрузил'
|
||
)
|
||
|
||
created_at = models.DateTimeField(
|
||
auto_now_add=True,
|
||
verbose_name='Дата создания'
|
||
)
|
||
|
||
class Meta:
|
||
db_table = 'lesson_files'
|
||
verbose_name = 'Файл урока'
|
||
verbose_name_plural = 'Файлы уроков'
|
||
ordering = ['-created_at']
|
||
indexes = [
|
||
models.Index(fields=['lesson']),
|
||
models.Index(fields=['material']),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"{self.filename} - {self.lesson.title}"
|
||
|
||
def get_file_url(self):
|
||
"""Получить URL файла."""
|
||
if self.file:
|
||
return self.file.url
|
||
elif self.material and self.material.file:
|
||
return self.material.file.url
|
||
return None
|
||
|
||
def get_file_size_display(self):
|
||
"""Отформатированный размер файла."""
|
||
if not self.file_size:
|
||
return '0 B'
|
||
|
||
for unit in ['B', 'KB', 'MB', 'GB']:
|
||
if self.file_size < 1024.0:
|
||
return f"{self.file_size:.1f} {unit}"
|
||
self.file_size /= 1024.0
|
||
return f"{self.file_size:.1f} TB"
|
||
|
||
|
||
def lesson_homework_submission_file_path(instance, filename):
|
||
"""Путь для загрузки файлов ответов на ДЗ по уроку."""
|
||
ext = filename.split('.')[-1]
|
||
new_filename = f"{uuid.uuid4()}.{ext}"
|
||
return os.path.join('lessons', str(instance.lesson.id), 'homework_submissions', new_filename)
|
||
|
||
|
||
class LessonHomeworkSubmission(models.Model):
|
||
"""
|
||
Ответ ученика на домашнее задание по уроку.
|
||
"""
|
||
|
||
STATUS_CHOICES = [
|
||
('pending', 'Ожидает проверки'),
|
||
('graded', 'Проверено'),
|
||
('returned', 'Возвращено на доработку'),
|
||
]
|
||
|
||
lesson = models.ForeignKey(
|
||
Lesson,
|
||
on_delete=models.CASCADE,
|
||
related_name='homework_submissions',
|
||
verbose_name='Урок'
|
||
)
|
||
|
||
student = models.ForeignKey(
|
||
'users.User',
|
||
on_delete=models.CASCADE,
|
||
related_name='lesson_homework_submissions',
|
||
limit_choices_to={'role': 'client'},
|
||
verbose_name='Ученик'
|
||
)
|
||
|
||
# Содержимое ответа
|
||
content = models.TextField(
|
||
blank=True,
|
||
verbose_name='Текст ответа',
|
||
help_text='Текстовый ответ на домашнее задание'
|
||
)
|
||
|
||
# Файл ответа
|
||
attachment = models.FileField(
|
||
upload_to=lesson_homework_submission_file_path,
|
||
blank=True,
|
||
null=True,
|
||
max_length=500,
|
||
validators=[FileExtensionValidator(allowed_extensions=['pdf', 'doc', 'docx', 'txt', 'jpg', 'jpeg', 'png', 'zip', 'rar'])],
|
||
verbose_name='Файл ответа'
|
||
)
|
||
|
||
# Статус и проверка
|
||
status = models.CharField(
|
||
max_length=20,
|
||
choices=STATUS_CHOICES,
|
||
default='pending',
|
||
verbose_name='Статус',
|
||
db_index=True
|
||
)
|
||
|
||
# Оценка
|
||
score = models.IntegerField(
|
||
null=True,
|
||
blank=True,
|
||
validators=[MinValueValidator(0), MaxValueValidator(100)],
|
||
verbose_name='Оценка',
|
||
help_text='Оценка от 0 до 100'
|
||
)
|
||
|
||
# Отзыв ментора
|
||
feedback = models.TextField(
|
||
blank=True,
|
||
verbose_name='Отзыв ментора'
|
||
)
|
||
|
||
checked_by = models.ForeignKey(
|
||
'users.User',
|
||
on_delete=models.SET_NULL,
|
||
related_name='checked_lesson_homework_submissions',
|
||
null=True,
|
||
blank=True,
|
||
limit_choices_to={'role': 'mentor'},
|
||
verbose_name='Проверил'
|
||
)
|
||
|
||
checked_at = models.DateTimeField(
|
||
null=True,
|
||
blank=True,
|
||
verbose_name='Дата проверки'
|
||
)
|
||
|
||
# Временные метки
|
||
submitted_at = models.DateTimeField(
|
||
auto_now_add=True,
|
||
verbose_name='Дата отправки'
|
||
)
|
||
|
||
updated_at = models.DateTimeField(
|
||
auto_now=True,
|
||
verbose_name='Дата обновления'
|
||
)
|
||
|
||
class Meta:
|
||
db_table = 'lesson_homework_submissions'
|
||
verbose_name = 'Ответ на ДЗ по уроку'
|
||
verbose_name_plural = 'Ответы на ДЗ по урокам'
|
||
ordering = ['-submitted_at']
|
||
indexes = [
|
||
models.Index(fields=['lesson', 'student']),
|
||
models.Index(fields=['student', 'status']),
|
||
models.Index(fields=['status', 'submitted_at']),
|
||
]
|
||
constraints = [
|
||
models.UniqueConstraint(
|
||
fields=['lesson', 'student'],
|
||
name='unique_lesson_student_submission'
|
||
),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"{self.student.get_full_name()} - {self.lesson.title} ({self.get_status_display()})"
|
||
|
||
def grade(self, score, feedback, checked_by):
|
||
"""Выставить оценку."""
|
||
self.score = score
|
||
self.feedback = feedback
|
||
self.checked_by = checked_by
|
||
self.checked_at = timezone.now()
|
||
self.status = 'graded'
|
||
self.save()
|
||
|
||
def return_for_revision(self, feedback):
|
||
"""Вернуть на доработку."""
|
||
self.feedback = feedback
|
||
self.status = 'returned'
|
||
self.save()
|