extensions-website/files/models.py
Anna Sirota 89dfd4eab1 Add a suffix to "printed" representation of deleted extensions, versions and files
To make it easier to tell deleted objects from normal ones in admin
(and whereever repr/str gets used on them).
2024-04-11 10:43:25 +02:00

222 lines
7.1 KiB
Python

from pathlib import Path
from typing import Dict, Any
import logging
import re
from django.contrib.auth import get_user_model
from django.db import models
from common.model_mixins import CreatedModifiedMixin, TrackChangesMixin, SoftDeleteMixin
from files.utils import get_sha256, guess_mimetype_from_ext
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):
prefix = 'thumbnails/'
_hash = instance.hash.split(':')[-1]
extension = Path(filename).suffix
path = Path(prefix, _hash[:2], _hash).with_suffix(extension)
return path
class File(CreatedModifiedMixin, TrackChangesMixin, SoftDeleteMixin, models.Model):
track_changes_to_fields = {'status', 'size_bytes', 'hash', 'date_deleted'}
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='Image thumbnail in case file is a video',
)
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.APPROVED)
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:
label_deleted = f'{self.date_deleted and " (DELETED ❌)" or ""}'
return f'{self.original_name} ({self.get_status_display()}){label_deleted}'
@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)
def is_listed(self):
return self.status == self.model.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')
original_name = data.get('name', self.original_name)
name_as_path = Path(original_name)
for suffix in name_as_path.suffixes:
original_name = original_name.replace(suffix, '')
name = re.sub(r'[-_ ]+', ' ', original_name)
return {
'name': name,
'slug': utils.slugify(name),
'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()
class FileValidation(CreatedModifiedMixin, TrackChangesMixin, models.Model):
track_changes_to_fields = {'is_valid', 'errors', 'warnings', 'notices', 'validation'}
file = models.OneToOneField(File, related_name='validation', on_delete=models.CASCADE)
is_valid = models.BooleanField(default=False)
errors = models.IntegerField(default=0)
warnings = models.IntegerField(default=0)
notices = models.IntegerField(default=0)
validation = models.TextField()