extensions-website/files/models.py

232 lines
7.5 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)
@property
def unlisted(self):
return self.exclude(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)
original_name = models.CharField(max_length=255, blank=True, null=False)
original_hash = models.CharField(
max_length=255,
null=False,
blank=True,
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
self.full_clean()
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 extension(self):
return self.version.extension
@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 {
'name': name,
'slug': utils.slugify(extension_id),
'extension_id': extension_id,
'website': data.get('website'),
}
@property
def parsed_version_fields(self) -> Dict[str, Any]:
"""Return Version-related data that was parsed from file's content."""
# Currently, the content of the manifest file is the only
# kind of file metadata that is supported.
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'),
'tags': data.get('tags'),
}
def get_submit_url(self) -> str:
return self.extension.get_draft_url()
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.exception(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()