extensions-website/files/models.py
Dalai Felinto d00bcc585a Draft: Make sure extensions always have a user (#54)
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>
2024-03-14 18:36:03 +01:00

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()