""" Модели расписания занятий. """ 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()