576 lines
17 KiB
Python
576 lines
17 KiB
Python
"""
|
||
Модели для учебных материалов.
|
||
"""
|
||
from django.db import models
|
||
from django.core.validators import MinValueValidator
|
||
import uuid
|
||
import os
|
||
import mimetypes
|
||
|
||
|
||
def material_file_upload_path(instance, filename):
|
||
"""Путь для загрузки файлов материалов."""
|
||
ext = filename.split('.')[-1]
|
||
new_filename = f"{uuid.uuid4()}.{ext}"
|
||
return os.path.join('materials', str(instance.owner.id), new_filename)
|
||
|
||
|
||
class MaterialFolder(models.Model):
|
||
"""
|
||
Папка для организации материалов.
|
||
"""
|
||
|
||
name = 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='material_folders',
|
||
verbose_name='Владелец'
|
||
)
|
||
|
||
parent = models.ForeignKey(
|
||
'self',
|
||
on_delete=models.CASCADE,
|
||
related_name='subfolders',
|
||
null=True,
|
||
blank=True,
|
||
verbose_name='Родительская папка'
|
||
)
|
||
|
||
# Доступ
|
||
is_public = models.BooleanField(
|
||
default=False,
|
||
verbose_name='Публичная'
|
||
)
|
||
|
||
shared_with = models.ManyToManyField(
|
||
'users.User',
|
||
related_name='shared_folders',
|
||
blank=True,
|
||
verbose_name='Доступ предоставлен'
|
||
)
|
||
|
||
# Статистика
|
||
materials_count = models.IntegerField(
|
||
default=0,
|
||
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 = 'material_folders'
|
||
verbose_name = 'Папка материалов'
|
||
verbose_name_plural = 'Папки материалов'
|
||
ordering = ['name']
|
||
unique_together = ['owner', 'parent', 'name']
|
||
indexes = [
|
||
models.Index(fields=['owner', 'parent']),
|
||
models.Index(fields=['owner', 'is_public']),
|
||
]
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
def get_path(self):
|
||
"""Получить полный путь папки."""
|
||
if self.parent:
|
||
return f"{self.parent.get_path()}/{self.name}"
|
||
return self.name
|
||
|
||
def update_materials_count(self):
|
||
"""Обновить количество материалов."""
|
||
self.materials_count = self.materials.filter(is_deleted=False).count()
|
||
self.save(update_fields=['materials_count'])
|
||
|
||
|
||
class MaterialTag(models.Model):
|
||
"""
|
||
Тег для материалов.
|
||
"""
|
||
|
||
name = models.CharField(
|
||
max_length=50,
|
||
unique=True,
|
||
verbose_name='Название'
|
||
)
|
||
|
||
slug = models.SlugField(
|
||
max_length=50,
|
||
unique=True,
|
||
verbose_name='Слаг'
|
||
)
|
||
|
||
color = models.CharField(
|
||
max_length=7,
|
||
default='#007bff',
|
||
verbose_name='Цвет'
|
||
)
|
||
|
||
materials_count = models.IntegerField(
|
||
default=0,
|
||
verbose_name='Количество материалов'
|
||
)
|
||
|
||
created_at = models.DateTimeField(
|
||
auto_now_add=True,
|
||
verbose_name='Дата создания'
|
||
)
|
||
|
||
class Meta:
|
||
db_table = 'material_tags'
|
||
verbose_name = 'Тег материала'
|
||
verbose_name_plural = 'Теги материалов'
|
||
ordering = ['name']
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
|
||
class Material(models.Model):
|
||
"""
|
||
Модель учебного материала.
|
||
"""
|
||
|
||
TYPE_CHOICES = [
|
||
('document', 'Документ'),
|
||
('presentation', 'Презентация'),
|
||
('video', 'Видео'),
|
||
('audio', 'Аудио'),
|
||
('image', 'Изображение'),
|
||
('archive', 'Архив'),
|
||
('link', 'Ссылка'),
|
||
('other', 'Другое'),
|
||
]
|
||
|
||
ACCESS_CHOICES = [
|
||
('private', 'Приватный'),
|
||
('public', 'Публичный'),
|
||
('lesson', 'Для занятия'),
|
||
('clients', 'Для клиентов'),
|
||
]
|
||
|
||
# Основная информация
|
||
title = models.CharField(
|
||
max_length=255,
|
||
verbose_name='Название'
|
||
)
|
||
|
||
description = models.TextField(
|
||
blank=True,
|
||
verbose_name='Описание'
|
||
)
|
||
|
||
# Файл или ссылка
|
||
file = models.FileField(
|
||
upload_to=material_file_upload_path,
|
||
blank=True,
|
||
max_length=500,
|
||
verbose_name='Файл'
|
||
)
|
||
|
||
file_name = models.CharField(
|
||
max_length=255,
|
||
blank=True,
|
||
verbose_name='Имя файла'
|
||
)
|
||
|
||
file_size = models.BigIntegerField(
|
||
default=0,
|
||
verbose_name='Размер файла (bytes)'
|
||
)
|
||
|
||
file_type = models.CharField(
|
||
max_length=100,
|
||
blank=True,
|
||
verbose_name='MIME тип'
|
||
)
|
||
|
||
url = models.URLField(
|
||
blank=True,
|
||
max_length=500,
|
||
verbose_name='Ссылка'
|
||
)
|
||
|
||
material_type = models.CharField(
|
||
max_length=20,
|
||
choices=TYPE_CHOICES,
|
||
default='other',
|
||
verbose_name='Тип материала',
|
||
db_index=True
|
||
)
|
||
|
||
# Владелец и организация
|
||
owner = models.ForeignKey(
|
||
'users.User',
|
||
on_delete=models.CASCADE,
|
||
related_name='materials',
|
||
verbose_name='Владелец'
|
||
)
|
||
|
||
folder = models.ForeignKey(
|
||
MaterialFolder,
|
||
on_delete=models.SET_NULL,
|
||
related_name='materials',
|
||
null=True,
|
||
blank=True,
|
||
verbose_name='Папка'
|
||
)
|
||
|
||
tags = models.ManyToManyField(
|
||
MaterialTag,
|
||
related_name='materials',
|
||
blank=True,
|
||
verbose_name='Теги'
|
||
)
|
||
|
||
# Связи
|
||
lesson = models.ForeignKey(
|
||
'schedule.Lesson',
|
||
on_delete=models.SET_NULL,
|
||
related_name='materials',
|
||
null=True,
|
||
blank=True,
|
||
verbose_name='Занятие'
|
||
)
|
||
|
||
homework = models.ForeignKey(
|
||
'homework.Homework',
|
||
on_delete=models.SET_NULL,
|
||
related_name='materials',
|
||
null=True,
|
||
blank=True,
|
||
verbose_name='Домашнее задание'
|
||
)
|
||
|
||
# Доступ
|
||
access_type = models.CharField(
|
||
max_length=20,
|
||
choices=ACCESS_CHOICES,
|
||
default='private',
|
||
verbose_name='Тип доступа',
|
||
db_index=True
|
||
)
|
||
|
||
shared_with = models.ManyToManyField(
|
||
'users.User',
|
||
related_name='shared_materials',
|
||
blank=True,
|
||
verbose_name='Доступ предоставлен'
|
||
)
|
||
|
||
# Настройки
|
||
allow_download = models.BooleanField(
|
||
default=True,
|
||
verbose_name='Разрешить скачивание'
|
||
)
|
||
|
||
is_featured = models.BooleanField(
|
||
default=False,
|
||
verbose_name='Избранный'
|
||
)
|
||
|
||
# Статистика
|
||
views_count = models.IntegerField(
|
||
default=0,
|
||
verbose_name='Количество просмотров'
|
||
)
|
||
|
||
downloads_count = models.IntegerField(
|
||
default=0,
|
||
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 = 'materials'
|
||
verbose_name = 'Материал'
|
||
verbose_name_plural = 'Материалы'
|
||
ordering = ['-created_at']
|
||
indexes = [
|
||
models.Index(fields=['owner', 'is_deleted']),
|
||
models.Index(fields=['folder', 'is_deleted']),
|
||
models.Index(fields=['material_type']),
|
||
models.Index(fields=['access_type', 'is_deleted']),
|
||
models.Index(fields=['lesson']),
|
||
models.Index(fields=['homework']),
|
||
models.Index(fields=['owner', 'created_at']),
|
||
models.Index(fields=['access_type', 'material_type']),
|
||
]
|
||
|
||
def __str__(self):
|
||
return self.title
|
||
|
||
def save(self, *args, **kwargs):
|
||
"""Переопределяем save для автоматической обработки."""
|
||
# Определяем тип материала по MIME типу
|
||
if self.file and not self.material_type:
|
||
self.material_type = self.detect_material_type()
|
||
|
||
# Сохраняем имя файла
|
||
if self.file and not self.file_name:
|
||
self.file_name = os.path.basename(self.file.name)
|
||
|
||
super().save(*args, **kwargs)
|
||
|
||
# Обновляем счетчик папки
|
||
if self.folder:
|
||
self.folder.update_materials_count()
|
||
|
||
def detect_material_type(self):
|
||
"""Определить тип материала по MIME типу."""
|
||
if not self.file_type:
|
||
return 'other'
|
||
|
||
mime = self.file_type.lower()
|
||
|
||
if mime.startswith('image/'):
|
||
return 'image'
|
||
elif mime.startswith('video/'):
|
||
return 'video'
|
||
elif mime.startswith('audio/'):
|
||
return 'audio'
|
||
elif 'pdf' in mime or 'document' in mime or 'text' in mime:
|
||
return 'document'
|
||
elif 'presentation' in mime or 'powerpoint' in mime:
|
||
return 'presentation'
|
||
elif 'zip' in mime or 'rar' in mime or 'archive' in mime:
|
||
return 'archive'
|
||
|
||
return 'other'
|
||
|
||
def increment_views(self):
|
||
"""Увеличить счетчик просмотров."""
|
||
self.views_count += 1
|
||
self.save(update_fields=['views_count'])
|
||
|
||
def increment_downloads(self):
|
||
"""Увеличить счетчик скачиваний."""
|
||
self.downloads_count += 1
|
||
self.save(update_fields=['downloads_count'])
|
||
|
||
def soft_delete(self):
|
||
"""Мягкое удаление."""
|
||
from django.utils import timezone
|
||
self.is_deleted = True
|
||
self.deleted_at = timezone.now()
|
||
self.save()
|
||
|
||
# Обновляем счетчик папки
|
||
if self.folder:
|
||
self.folder.update_materials_count()
|
||
|
||
def has_access(self, user):
|
||
"""Проверка доступа пользователя к материалу."""
|
||
# Владелец всегда имеет доступ
|
||
if self.owner == user:
|
||
return True
|
||
|
||
# Публичные материалы доступны всем
|
||
if self.access_type == 'public':
|
||
return True
|
||
|
||
# Доступ предоставлен напрямую
|
||
if self.shared_with.filter(id=user.id).exists():
|
||
return True
|
||
|
||
# Материалы для клиентов
|
||
if self.access_type == 'clients':
|
||
# Проверяем что пользователь - клиент владельца
|
||
from apps.users.models import Client
|
||
try:
|
||
client = Client.objects.get(user=user)
|
||
if self.owner in client.mentors.all():
|
||
return True
|
||
except Client.DoesNotExist:
|
||
pass
|
||
|
||
# Материалы для занятия
|
||
if self.access_type == 'lesson' and self.lesson:
|
||
return user in [self.lesson.mentor, self.lesson.client]
|
||
|
||
return False
|
||
|
||
|
||
class MaterialAccess(models.Model):
|
||
"""
|
||
Лог доступа к материалам.
|
||
"""
|
||
|
||
ACTION_CHOICES = [
|
||
('view', 'Просмотр'),
|
||
('download', 'Скачивание'),
|
||
('share', 'Предоставление доступа'),
|
||
]
|
||
|
||
material = models.ForeignKey(
|
||
Material,
|
||
on_delete=models.CASCADE,
|
||
related_name='access_logs',
|
||
verbose_name='Материал'
|
||
)
|
||
|
||
user = models.ForeignKey(
|
||
'users.User',
|
||
on_delete=models.CASCADE,
|
||
related_name='material_accesses',
|
||
verbose_name='Пользователь'
|
||
)
|
||
|
||
action = models.CharField(
|
||
max_length=20,
|
||
choices=ACTION_CHOICES,
|
||
verbose_name='Действие'
|
||
)
|
||
|
||
ip_address = models.GenericIPAddressField(
|
||
null=True,
|
||
blank=True,
|
||
verbose_name='IP адрес'
|
||
)
|
||
|
||
user_agent = models.CharField(
|
||
max_length=500,
|
||
blank=True,
|
||
verbose_name='User Agent'
|
||
)
|
||
|
||
created_at = models.DateTimeField(
|
||
auto_now_add=True,
|
||
verbose_name='Дата',
|
||
db_index=True
|
||
)
|
||
|
||
class Meta:
|
||
db_table = 'material_access_logs'
|
||
verbose_name = 'Лог доступа к материалу'
|
||
verbose_name_plural = 'Логи доступа к материалам'
|
||
ordering = ['-created_at']
|
||
indexes = [
|
||
models.Index(fields=['material', 'created_at']),
|
||
models.Index(fields=['user', 'created_at']),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"{self.user.email} - {self.get_action_display()} - {self.material.title}"
|
||
|
||
|
||
class StorageQuota(models.Model):
|
||
"""
|
||
Квота хранилища для пользователя.
|
||
"""
|
||
|
||
user = models.OneToOneField(
|
||
'users.User',
|
||
on_delete=models.CASCADE,
|
||
related_name='storage_quota',
|
||
verbose_name='Пользователь'
|
||
)
|
||
|
||
# Лимиты в байтах
|
||
total_quota = models.BigIntegerField(
|
||
default=1073741824, # 1 GB
|
||
verbose_name='Общая квота (bytes)'
|
||
)
|
||
|
||
used_space = models.BigIntegerField(
|
||
default=0,
|
||
verbose_name='Использовано (bytes)'
|
||
)
|
||
|
||
# Временные метки
|
||
created_at = models.DateTimeField(
|
||
auto_now_add=True,
|
||
verbose_name='Дата создания'
|
||
)
|
||
|
||
updated_at = models.DateTimeField(
|
||
auto_now=True,
|
||
verbose_name='Дата обновления'
|
||
)
|
||
|
||
class Meta:
|
||
db_table = 'storage_quotas'
|
||
verbose_name = 'Квота хранилища'
|
||
verbose_name_plural = 'Квоты хранилища'
|
||
|
||
def __str__(self):
|
||
return f"{self.user.email} - {self.get_used_percentage():.1f}%"
|
||
|
||
def get_used_percentage(self):
|
||
"""Получить процент использования."""
|
||
if self.total_quota == 0:
|
||
return 0
|
||
return (self.used_space / self.total_quota) * 100
|
||
|
||
def get_available_space(self):
|
||
"""Получить доступное пространство."""
|
||
return self.total_quota - self.used_space
|
||
|
||
def has_space(self, size):
|
||
"""Проверить достаточно ли места."""
|
||
return self.get_available_space() >= size
|
||
|
||
def add_usage(self, size):
|
||
"""Добавить использование."""
|
||
self.used_space += size
|
||
self.save(update_fields=['used_space'])
|
||
|
||
def remove_usage(self, size):
|
||
"""Убрать использование."""
|
||
self.used_space = max(0, self.used_space - size)
|
||
self.save(update_fields=['used_space'])
|
||
|
||
def recalculate(self):
|
||
"""Пересчитать использование."""
|
||
total = Material.objects.filter(
|
||
owner=self.user,
|
||
is_deleted=False
|
||
).aggregate(
|
||
total=models.Sum('file_size')
|
||
)['total'] or 0
|
||
|
||
self.used_space = total
|
||
self.save(update_fields=['used_space'])
|