Anna Sirota
caae613747
* removes all soft-deletion; * shows a "Delete extension" button on the draft page in case it can be deleted; * shows a "Delete version" button on the version page in case it can be deleted; * a version can be deleted if * its file isn't approved, and it doesn't have any ratings; * an extension can be deleted if * it's not listed, and doesn't have any ratings or abuse reports; * all it's versions can also be deleted; * changes default `File.status` from `APPROVED` to `AWAITING_REVIEW` With version's file status being `APPROVED` by default, a version can never be deleted, even when the extension is still a draft. This change doesn't affect the approval process because * when an extension is approved its latest version becomes approved automatically (no change here); * when a new version is uploaded to an approved extension, it's approved automatically (this is new). This allows authors to delete their drafts, freeing the extension slug and making it possible to re-upload the same file. This also makes it possible to easily fix mistakes during the drafting of a new extension (e.g. delete a version and re-upload it without bumping a version for each typo/mistake in packaging and so on). (see #78 and #63) Reviewed-on: #81
213 lines
6.6 KiB
Python
213 lines
6.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
|
|
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, models.Model):
|
|
track_changes_to_fields = {'status', 'size_bytes', 'hash'}
|
|
|
|
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.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)
|
|
|
|
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')
|
|
name = data.get('name', self.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_ok', 'results'}
|
|
|
|
file = models.OneToOneField(File, related_name='validation', on_delete=models.CASCADE)
|
|
is_ok = models.BooleanField(default=False)
|
|
results = models.JSONField()
|