231 lines
7.6 KiB
Python
231 lines
7.6 KiB
Python
from pathlib import Path
|
|
from typing import Dict, Any
|
|
import logging
|
|
|
|
from django.contrib.auth import get_user_model
|
|
from django.db import models
|
|
|
|
from common.model_mixins import CreatedModifiedMixin, TrackChangesMixin
|
|
from files.utils import get_sha256, guess_mimetype_from_ext, get_thumbnail_upload_to
|
|
from constants.base import FILE_STATUS_CHOICES, FILE_TYPE_CHOICES
|
|
import utils
|
|
|
|
User = get_user_model()
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
class FileManager(models.Manager):
|
|
@property
|
|
def listed(self):
|
|
return self.filter(status=self.model.STATUSES.APPROVED)
|
|
|
|
|
|
def file_upload_to(instance, filename):
|
|
prefix = 'files/'
|
|
if instance.is_image:
|
|
prefix = 'images/'
|
|
elif instance.is_video:
|
|
prefix = 'videos/'
|
|
|
|
_hash = instance.hash.split(':')[-1]
|
|
extension = Path(filename).suffix
|
|
path = Path(prefix, _hash[:2], _hash).with_suffix(extension)
|
|
return path
|
|
|
|
|
|
def thumbnail_upload_to(instance, filename):
|
|
return get_thumbnail_upload_to(instance.hash)
|
|
|
|
|
|
class File(CreatedModifiedMixin, TrackChangesMixin, models.Model):
|
|
track_changes_to_fields = {'status', 'size_bytes', 'hash', 'thumbnail', 'metadata'}
|
|
|
|
TYPES = FILE_TYPE_CHOICES
|
|
STATUSES = FILE_STATUS_CHOICES
|
|
|
|
date_approved = models.DateTimeField(null=True, blank=True, editable=False)
|
|
date_status_changed = models.DateTimeField(null=True, blank=True, editable=False)
|
|
|
|
source = models.FileField(null=False, blank=False, upload_to=file_upload_to)
|
|
thumbnail = models.ImageField(
|
|
upload_to=thumbnail_upload_to,
|
|
null=True,
|
|
blank=True,
|
|
max_length=256,
|
|
help_text='Thumbnail generated from uploaded image or video source file',
|
|
editable=False,
|
|
)
|
|
content_type = models.CharField(max_length=256, null=True, blank=True)
|
|
type = models.PositiveSmallIntegerField(
|
|
choices=TYPES,
|
|
null=True,
|
|
blank=True,
|
|
help_text=(
|
|
'Indicates that this is an image, a video '
|
|
'or a type of extension this file appears to match, '
|
|
'as guessed from its contents.'
|
|
),
|
|
)
|
|
status = models.PositiveSmallIntegerField(choices=STATUSES, default=STATUSES.AWAITING_REVIEW)
|
|
|
|
user = models.ForeignKey(
|
|
User, related_name='files', null=False, blank=False, on_delete=models.CASCADE
|
|
)
|
|
size_bytes = models.PositiveBigIntegerField(default=0, editable=False)
|
|
hash = models.CharField(max_length=255, null=False, blank=True, unique=True, editable=False)
|
|
original_name = models.CharField(max_length=255, blank=True, null=False)
|
|
original_hash = models.CharField(
|
|
max_length=255,
|
|
null=False,
|
|
blank=True,
|
|
editable=False,
|
|
unique=True,
|
|
help_text='The original hash of the file before we repackage it any way.',
|
|
)
|
|
|
|
metadata = models.JSONField(
|
|
null=False,
|
|
default=dict,
|
|
blank=True,
|
|
# TODO add link to the manifest file user manual page.
|
|
help_text=('Meta information that was parsed from the `manifest file.'),
|
|
)
|
|
|
|
objects = FileManager()
|
|
|
|
def __str__(self) -> str:
|
|
return f'{self.original_name} ({self.get_status_display()})'
|
|
|
|
@property
|
|
def has_been_validated(self):
|
|
try:
|
|
self.validation
|
|
except FileValidation.DoesNotExist:
|
|
return False
|
|
else:
|
|
return True
|
|
|
|
@classmethod
|
|
def generate_hash(self, source):
|
|
"""Generate a hash for the File"""
|
|
return f'sha256:{get_sha256(source)}'
|
|
|
|
@property
|
|
def file_path(self):
|
|
return self.source.path if self.source else ''
|
|
|
|
@property
|
|
def filename(self):
|
|
return self.source.name if self.source else ''
|
|
|
|
def save(self, *args, **kwargs):
|
|
"""Set values that depend of the source file (size, original hash and name)."""
|
|
if not self.size_bytes:
|
|
self.size_bytes = self.source.size
|
|
if not self.original_name:
|
|
self.original_name = self.source.name
|
|
|
|
if not self.content_type:
|
|
self.content_type = guess_mimetype_from_ext(self.original_name)
|
|
if self.content_type:
|
|
if 'image' in self.content_type:
|
|
self.type = self.TYPES.IMAGE
|
|
if 'video' in self.content_type:
|
|
self.type = self.TYPES.VIDEO
|
|
|
|
if not self.original_hash or not self.hash:
|
|
_hash = self.generate_hash(self.source)
|
|
self.original_hash = _hash or self.original_hash
|
|
self.hash = _hash or self.hash
|
|
|
|
return super().save(*args, **kwargs)
|
|
|
|
@property
|
|
def is_listed(self) -> bool:
|
|
return self.status == self.STATUSES.APPROVED
|
|
|
|
@property
|
|
def is_image(self) -> bool:
|
|
return self.type == self.TYPES.IMAGE
|
|
|
|
@property
|
|
def is_video(self) -> bool:
|
|
return self.type == self.TYPES.VIDEO
|
|
|
|
@property
|
|
def suffix(self) -> str:
|
|
path = Path(self.source.path)
|
|
return ''.join(path.suffixes)
|
|
|
|
@property
|
|
def parsed_extension_fields(self) -> Dict[str, Any]:
|
|
"""Return Extension-related data that was parsed from file's content."""
|
|
data = self.metadata
|
|
|
|
extension_id = data.get('id')
|
|
name = data.get('name', self.original_name)
|
|
return {
|
|
'extension_id': extension_id,
|
|
'name': name,
|
|
'slug': utils.slugify(extension_id),
|
|
'type': self.type,
|
|
'website': data.get('website'),
|
|
}
|
|
|
|
def get_platforms(self):
|
|
return self.parse_platforms_from_manifest(self.metadata)
|
|
|
|
@classmethod
|
|
def parse_platforms_from_manifest(self, data):
|
|
build_generated_platforms = None
|
|
if 'build' in data and 'generated' in data['build']:
|
|
build_generated_platforms = data['build']['generated'].get('platforms')
|
|
return build_generated_platforms or data.get('platforms')
|
|
|
|
@property
|
|
def parsed_version_fields(self) -> Dict[str, Any]:
|
|
"""Return Version-related data that was parsed from file's content."""
|
|
data = self.metadata
|
|
return {
|
|
'version': data.get('version'),
|
|
'tagline': data.get('tagline'),
|
|
'blender_version_min': data.get('blender_version_min'),
|
|
'schema_version': data.get('schema_version'),
|
|
'licenses': data.get('license'),
|
|
'permissions': data.get('permissions'),
|
|
'platforms': self.get_platforms(),
|
|
'tags': data.get('tags'),
|
|
}
|
|
|
|
def get_thumbnail_of_size(self, size_key: str) -> str:
|
|
"""Return absolute path portion of the URL of a thumbnail of this file.
|
|
|
|
Fall back to the source file, if no thumbnail is stored.
|
|
Log absence of the thumbnail file instead of exploding somewhere in the templates.
|
|
"""
|
|
# We don't (yet?) have thumbnails for anything other than images and videos.
|
|
assert self.is_image or self.is_video, f'File pk={self.pk} is neither image nor video'
|
|
|
|
try:
|
|
path = self.metadata['thumbnails'][size_key]['path']
|
|
return self.thumbnail.storage.url(path)
|
|
except (KeyError, TypeError):
|
|
log.warning(f'File pk={self.pk} is missing thumbnail "{size_key}": {self.metadata}')
|
|
return self.source.url
|
|
|
|
@property
|
|
def thumbnail_1080p_url(self) -> str:
|
|
return self.get_thumbnail_of_size('1080p')
|
|
|
|
@property
|
|
def thumbnail_360p_url(self) -> str:
|
|
return self.get_thumbnail_of_size('360p')
|
|
|
|
|
|
class FileValidation(CreatedModifiedMixin, TrackChangesMixin, models.Model):
|
|
track_changes_to_fields = {'is_ok', 'results'}
|
|
|
|
file = models.OneToOneField(File, related_name='validation', on_delete=models.CASCADE)
|
|
is_ok = models.BooleanField(default=False)
|
|
results = models.JSONField()
|