extensions-website/files/models.py
Anna Sirota caae613747 Make it possible to fully delete unlisted/unrated extensions and versions (#81)
* 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
2024-04-19 11:00:13 +02:00

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