Dalai Felinto
d00bcc585a
When submitting a file the created extension gets a user right away. This makes sure there are no orphans extensions. The extension is then incomplete until the draft is saved once (which means finalizing the extension upload process, by adding a description and thumbnails). Any attempt to edit or submit a new extension will lead the user to the editing draft page. Note: We could add a new option to [x] Send for Review. The front-end even has a half-baked code for that. But should be tackled separately. ------------ Patch notes: * Originally when trying to finish an upload as a different user we woudl get a 403, now we get a 404. Reviewed-on: #54 Reviewed-by: Francesco Siddi <fsiddi@noreply.localhost>
223 lines
7.0 KiB
Python
223 lines
7.0 KiB
Python
from pathlib import Path
|
|
from typing import Dict, Any
|
|
import logging
|
|
import mimetypes
|
|
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
|
|
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:
|
|
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:
|
|
content_type, _ = mimetypes.guess_type(self.original_name)
|
|
self.content_type = content_type
|
|
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()
|