575 lines
16 KiB
Python
575 lines
16 KiB
Python
"""
|
||
Модели для интерактивной доски.
|
||
"""
|
||
from django.db import models
|
||
from django.utils import timezone
|
||
import uuid
|
||
|
||
|
||
class Board(models.Model):
|
||
"""
|
||
Модель интерактивной доски.
|
||
Miro-подобная доска для совместной работы.
|
||
"""
|
||
|
||
ACCESS_CHOICES = [
|
||
('private', 'Приватная'),
|
||
('mentor_student', 'Для пары ментор-студент'),
|
||
('public', 'Публичная'),
|
||
]
|
||
|
||
# Уникальный идентификатор
|
||
board_id = models.UUIDField(
|
||
default=uuid.uuid4,
|
||
unique=True,
|
||
editable=False,
|
||
verbose_name='ID доски'
|
||
)
|
||
|
||
# Основная информация
|
||
title = models.CharField(
|
||
max_length=255,
|
||
verbose_name='Название'
|
||
)
|
||
|
||
description = models.TextField(
|
||
blank=True,
|
||
verbose_name='Описание'
|
||
)
|
||
|
||
# Владелец
|
||
owner = models.ForeignKey(
|
||
'users.User',
|
||
on_delete=models.CASCADE,
|
||
related_name='owned_boards',
|
||
verbose_name='Владелец'
|
||
)
|
||
|
||
# Связь ментор-студент (для персональной доски)
|
||
mentor = models.ForeignKey(
|
||
'users.User',
|
||
on_delete=models.CASCADE,
|
||
related_name='mentor_boards',
|
||
null=True,
|
||
blank=True,
|
||
verbose_name='Ментор'
|
||
)
|
||
|
||
student = models.ForeignKey(
|
||
'users.User',
|
||
on_delete=models.CASCADE,
|
||
related_name='student_boards',
|
||
null=True,
|
||
blank=True,
|
||
verbose_name='Студент'
|
||
)
|
||
|
||
# Поле lesson удалено - доска привязана к паре mentor-student, а не к уроку
|
||
# Доска доступна и вне занятия
|
||
|
||
# Доступ
|
||
access_type = models.CharField(
|
||
max_length=20,
|
||
choices=ACCESS_CHOICES,
|
||
default='private',
|
||
verbose_name='Тип доступа',
|
||
db_index=True
|
||
)
|
||
|
||
# Участники (для приватных досок)
|
||
participants = models.ManyToManyField(
|
||
'users.User',
|
||
related_name='boards',
|
||
blank=True,
|
||
verbose_name='Участники'
|
||
)
|
||
|
||
# Настройки доски
|
||
background_color = models.CharField(
|
||
max_length=7,
|
||
default='#FFFFFF',
|
||
verbose_name='Цвет фона'
|
||
)
|
||
|
||
grid_enabled = models.BooleanField(
|
||
default=True,
|
||
verbose_name='Сетка включена'
|
||
)
|
||
|
||
width = models.IntegerField(
|
||
default=5000,
|
||
verbose_name='Ширина (px)'
|
||
)
|
||
|
||
height = models.IntegerField(
|
||
default=5000,
|
||
verbose_name='Высота (px)'
|
||
)
|
||
|
||
# Статус
|
||
is_active = models.BooleanField(
|
||
default=True,
|
||
verbose_name='Активна',
|
||
db_index=True
|
||
)
|
||
|
||
is_template = models.BooleanField(
|
||
default=False,
|
||
verbose_name='Шаблон'
|
||
)
|
||
|
||
# Тип доски
|
||
BOARD_TYPE_CHOICES = [
|
||
('tldraw', 'Tldraw'),
|
||
('whiteboard', 'Whiteboard'),
|
||
('spacedeck', 'Spacedeck'),
|
||
]
|
||
|
||
board_type = models.CharField(
|
||
max_length=20,
|
||
choices=BOARD_TYPE_CHOICES,
|
||
default='whiteboard',
|
||
verbose_name='Тип доски',
|
||
db_index=True
|
||
)
|
||
|
||
# Tldraw данные
|
||
tldraw_snapshot = models.JSONField(
|
||
default=dict,
|
||
blank=True,
|
||
verbose_name='Tldraw состояние',
|
||
help_text='Полное состояние Tldraw доски'
|
||
)
|
||
|
||
# Spacedeck данные
|
||
spacedeck_id = models.CharField(
|
||
max_length=255,
|
||
blank=True,
|
||
null=True,
|
||
verbose_name='Spacedeck Space ID'
|
||
)
|
||
|
||
spacedeck_edit_hash = models.CharField(
|
||
max_length=255,
|
||
blank=True,
|
||
null=True,
|
||
unique=True,
|
||
verbose_name='Spacedeck Edit Hash'
|
||
)
|
||
|
||
# Статистика
|
||
views_count = models.IntegerField(
|
||
default=0,
|
||
verbose_name='Количество просмотров'
|
||
)
|
||
|
||
elements_count = models.IntegerField(
|
||
default=0,
|
||
verbose_name='Количество элементов'
|
||
)
|
||
|
||
last_edited_by = models.ForeignKey(
|
||
'users.User',
|
||
on_delete=models.SET_NULL,
|
||
related_name='last_edited_boards',
|
||
null=True,
|
||
blank=True,
|
||
verbose_name='Последний редактор'
|
||
)
|
||
|
||
last_edited_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='Дата обновления'
|
||
)
|
||
|
||
class Meta:
|
||
db_table = 'boards'
|
||
verbose_name = 'Доска'
|
||
verbose_name_plural = 'Доски'
|
||
ordering = ['-last_edited_at', '-created_at']
|
||
unique_together = [('mentor', 'student')]
|
||
indexes = [
|
||
models.Index(fields=['board_id']),
|
||
models.Index(fields=['owner', 'is_active']),
|
||
models.Index(fields=['mentor', 'student']),
|
||
models.Index(fields=['access_type', 'is_active']),
|
||
]
|
||
|
||
def __str__(self):
|
||
return self.title
|
||
|
||
def increment_views(self):
|
||
"""Увеличить счетчик просмотров."""
|
||
self.views_count += 1
|
||
self.save(update_fields=['views_count'])
|
||
|
||
def update_elements_count(self):
|
||
"""Обновить количество элементов."""
|
||
self.elements_count = self.elements.filter(is_deleted=False).count()
|
||
self.save(update_fields=['elements_count'])
|
||
|
||
def get_files_count(self):
|
||
"""Получить количество файлов изображений из tldraw_snapshot."""
|
||
if not self.tldraw_snapshot or not isinstance(self.tldraw_snapshot, dict):
|
||
return 0
|
||
|
||
files = self.tldraw_snapshot.get('files', {})
|
||
if not isinstance(files, dict):
|
||
return 0
|
||
|
||
# Подсчитываем только файлы с dataURL (валидные изображения)
|
||
count = 0
|
||
for file_id, file_data in files.items():
|
||
if isinstance(file_data, dict) and file_data.get('dataURL'):
|
||
count += 1
|
||
|
||
return count
|
||
|
||
def get_elements_count_from_snapshot(self):
|
||
"""Получить количество элементов из tldraw_snapshot."""
|
||
if not self.tldraw_snapshot or not isinstance(self.tldraw_snapshot, dict):
|
||
return 0
|
||
|
||
elements = self.tldraw_snapshot.get('elements', [])
|
||
if not isinstance(elements, list):
|
||
return 0
|
||
|
||
return len(elements)
|
||
|
||
def mark_edited(self, user):
|
||
"""Отметить изменение доски."""
|
||
self.last_edited_by = user
|
||
self.last_edited_at = timezone.now()
|
||
self.save(update_fields=['last_edited_by', 'last_edited_at'])
|
||
|
||
def has_access(self, user):
|
||
"""Проверка доступа пользователя к доске."""
|
||
# Владелец всегда имеет доступ
|
||
if self.owner == user:
|
||
return True
|
||
|
||
# Публичные доски доступны всем
|
||
if self.access_type == 'public':
|
||
return True
|
||
|
||
# Участники имеют доступ
|
||
if self.participants.filter(id=user.id).exists():
|
||
return True
|
||
|
||
# Для досок пары ментор-студент проверяем, что пользователь - ментор или студент
|
||
if self.access_type == 'mentor_student':
|
||
if self.mentor and self.student:
|
||
return user in [self.mentor, self.student]
|
||
|
||
return False
|
||
|
||
|
||
class BoardElement(models.Model):
|
||
"""
|
||
Элемент доски (текст, фигура, изображение, рисунок).
|
||
"""
|
||
|
||
TYPE_CHOICES = [
|
||
('text', 'Текст'),
|
||
('shape', 'Фигура'),
|
||
('image', 'Изображение'),
|
||
('drawing', 'Рисунок'),
|
||
('sticky', 'Стикер'),
|
||
('arrow', 'Стрелка'),
|
||
('line', 'Линия'),
|
||
]
|
||
|
||
SHAPE_CHOICES = [
|
||
('rectangle', 'Прямоугольник'),
|
||
('circle', 'Круг'),
|
||
('triangle', 'Треугольник'),
|
||
('star', 'Звезда'),
|
||
]
|
||
|
||
# Доска
|
||
board = models.ForeignKey(
|
||
Board,
|
||
on_delete=models.CASCADE,
|
||
related_name='elements',
|
||
verbose_name='Доска'
|
||
)
|
||
|
||
# Тип элемента
|
||
element_type = models.CharField(
|
||
max_length=20,
|
||
choices=TYPE_CHOICES,
|
||
verbose_name='Тип элемента',
|
||
db_index=True
|
||
)
|
||
|
||
# Позиция и размер
|
||
x = models.FloatField(
|
||
verbose_name='Позиция X'
|
||
)
|
||
|
||
y = models.FloatField(
|
||
verbose_name='Позиция Y'
|
||
)
|
||
|
||
width = models.FloatField(
|
||
default=100,
|
||
verbose_name='Ширина'
|
||
)
|
||
|
||
height = models.FloatField(
|
||
default=100,
|
||
verbose_name='Высота'
|
||
)
|
||
|
||
rotation = models.FloatField(
|
||
default=0,
|
||
verbose_name='Поворот (градусы)'
|
||
)
|
||
|
||
z_index = models.IntegerField(
|
||
default=0,
|
||
verbose_name='Z-индекс (слой)'
|
||
)
|
||
|
||
# Содержимое (для текста)
|
||
content = models.TextField(
|
||
blank=True,
|
||
verbose_name='Содержимое'
|
||
)
|
||
|
||
# Стиль текста
|
||
font_size = models.IntegerField(
|
||
default=16,
|
||
verbose_name='Размер шрифта'
|
||
)
|
||
|
||
font_family = models.CharField(
|
||
max_length=100,
|
||
default='Arial',
|
||
verbose_name='Шрифт'
|
||
)
|
||
|
||
font_weight = models.CharField(
|
||
max_length=20,
|
||
default='normal',
|
||
verbose_name='Жирность'
|
||
)
|
||
|
||
text_align = models.CharField(
|
||
max_length=20,
|
||
default='left',
|
||
verbose_name='Выравнивание'
|
||
)
|
||
|
||
text_color = models.CharField(
|
||
max_length=7,
|
||
default='#000000',
|
||
verbose_name='Цвет текста'
|
||
)
|
||
|
||
# Стиль фигуры
|
||
shape_type = models.CharField(
|
||
max_length=20,
|
||
choices=SHAPE_CHOICES,
|
||
blank=True,
|
||
verbose_name='Тип фигуры'
|
||
)
|
||
|
||
fill_color = models.CharField(
|
||
max_length=7,
|
||
default='#FFFFFF',
|
||
verbose_name='Цвет заливки'
|
||
)
|
||
|
||
stroke_color = models.CharField(
|
||
max_length=7,
|
||
default='#000000',
|
||
verbose_name='Цвет границы'
|
||
)
|
||
|
||
stroke_width = models.FloatField(
|
||
default=1,
|
||
verbose_name='Толщина границы'
|
||
)
|
||
|
||
opacity = models.FloatField(
|
||
default=1.0,
|
||
verbose_name='Прозрачность (0-1)'
|
||
)
|
||
|
||
# Для изображений
|
||
image_url = models.URLField(
|
||
blank=True,
|
||
max_length=500,
|
||
verbose_name='URL изображения'
|
||
)
|
||
|
||
# Для рисунков (SVG path или JSON)
|
||
drawing_data = models.TextField(
|
||
blank=True,
|
||
verbose_name='Данные рисунка'
|
||
)
|
||
|
||
# Связь со стрелкой
|
||
arrow_start_element = models.ForeignKey(
|
||
'self',
|
||
on_delete=models.SET_NULL,
|
||
related_name='arrow_targets',
|
||
null=True,
|
||
blank=True,
|
||
verbose_name='Начало стрелки'
|
||
)
|
||
|
||
arrow_end_element = models.ForeignKey(
|
||
'self',
|
||
on_delete=models.SET_NULL,
|
||
related_name='arrow_sources',
|
||
null=True,
|
||
blank=True,
|
||
verbose_name='Конец стрелки'
|
||
)
|
||
|
||
# Автор
|
||
created_by = models.ForeignKey(
|
||
'users.User',
|
||
on_delete=models.SET_NULL,
|
||
related_name='board_elements',
|
||
null=True,
|
||
verbose_name='Автор'
|
||
)
|
||
|
||
# Блокировка редактирования
|
||
locked = models.BooleanField(
|
||
default=False,
|
||
verbose_name='Заблокирован'
|
||
)
|
||
|
||
locked_by = models.ForeignKey(
|
||
'users.User',
|
||
on_delete=models.SET_NULL,
|
||
related_name='locked_elements',
|
||
null=True,
|
||
blank=True,
|
||
verbose_name='Заблокировал'
|
||
)
|
||
|
||
# Удаление (мягкое)
|
||
is_deleted = models.BooleanField(
|
||
default=False,
|
||
verbose_name='Удален',
|
||
db_index=True
|
||
)
|
||
|
||
deleted_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='Дата обновления'
|
||
)
|
||
|
||
class Meta:
|
||
db_table = 'board_elements'
|
||
verbose_name = 'Элемент доски'
|
||
verbose_name_plural = 'Элементы доски'
|
||
ordering = ['z_index', 'created_at']
|
||
indexes = [
|
||
models.Index(fields=['board', 'is_deleted']),
|
||
models.Index(fields=['element_type']),
|
||
models.Index(fields=['z_index']),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"{self.get_element_type_display()} - {self.board.title}"
|
||
|
||
def soft_delete(self):
|
||
"""Мягкое удаление элемента."""
|
||
self.is_deleted = True
|
||
self.deleted_at = timezone.now()
|
||
self.save()
|
||
|
||
# Обновляем счетчик элементов доски
|
||
self.board.update_elements_count()
|
||
|
||
def lock(self, user):
|
||
"""Заблокировать элемент для редактирования."""
|
||
self.locked = True
|
||
self.locked_by = user
|
||
self.save(update_fields=['locked', 'locked_by'])
|
||
|
||
def unlock(self):
|
||
"""Разблокировать элемент."""
|
||
self.locked = False
|
||
self.locked_by = None
|
||
self.save(update_fields=['locked', 'locked_by'])
|
||
|
||
|
||
class BoardSnapshot(models.Model):
|
||
"""
|
||
Снимок доски (для истории изменений).
|
||
"""
|
||
|
||
board = models.ForeignKey(
|
||
Board,
|
||
on_delete=models.CASCADE,
|
||
related_name='snapshots',
|
||
verbose_name='Доска'
|
||
)
|
||
|
||
# Снимок данных (JSON)
|
||
snapshot_data = models.JSONField(
|
||
verbose_name='Данные снимка'
|
||
)
|
||
|
||
# Автор изменений
|
||
created_by = models.ForeignKey(
|
||
'users.User',
|
||
on_delete=models.SET_NULL,
|
||
related_name='board_snapshots',
|
||
null=True,
|
||
verbose_name='Автор'
|
||
)
|
||
|
||
# Описание изменений
|
||
description = models.CharField(
|
||
max_length=255,
|
||
blank=True,
|
||
verbose_name='Описание'
|
||
)
|
||
|
||
# Временная метка
|
||
created_at = models.DateTimeField(
|
||
auto_now_add=True,
|
||
verbose_name='Дата создания'
|
||
)
|
||
|
||
class Meta:
|
||
db_table = 'board_snapshots'
|
||
verbose_name = 'Снимок доски'
|
||
verbose_name_plural = 'Снимки досок'
|
||
ordering = ['-created_at']
|
||
|
||
def __str__(self):
|
||
return f"Снимок {self.board.title} - {self.created_at}"
|