extensions-website/extensions/models.py
Anna Sirota b0bb4905b2 Reuse files as previews, icons or featured images (#161)
Now it should be be possible to:

* upload the same image as a preview or featured image on different extensions;
* upload the same image as an icon on different extensions;
* select the same video/image multiple times while adding previews on Draft or Edit page: first one will be saved, the rest of the duplicates will be ignored.

If all extensions referencing the file in any way are deleted, the file remains in the database: no thumbnail generating or scanning will happen if/when the file gets re-uploaded as a preview or featured image.

In all cases of re-upload `File.user` will not change: this shouldn't be a problem because currently there's no code relying on image ownership.

Version files will remain the only exception from this changed behaviour: it will only be possible to re-upload a version file once the version itself is deleted (which also deletes its file).

As a consequence of this change `File.extension_id` is dropped, because it is no longer possible to choose which extension should be saved there.

Should take care of #157

Reviewed-on: #161
Reviewed-by: Oleg-Komarov <oleg-komarov@noreply.localhost>
2024-06-04 12:23:25 +02:00

768 lines
26 KiB
Python

from typing import List
from statistics import median
import logging
from django.contrib.auth import get_user_model
from django.core.exceptions import ObjectDoesNotExist, BadRequest
from django.db import models, transaction
from django.db.models import Q, Count
from django.urls import reverse
from common.fields import FilterableManyToManyField
from common.model_mixins import CreatedModifiedMixin, RecordDeletionMixin, TrackChangesMixin
from constants.base import (
AUTHOR_ROLE_CHOICES,
AUTHOR_ROLE_DEV,
EXTENSION_STATUS_CHOICES,
EXTENSION_TYPE_CHOICES,
EXTENSION_TYPE_SLUGS,
EXTENSION_TYPE_SLUGS_SINGULAR,
FILE_STATUS_CHOICES,
)
from files.models import File
from reviewers.models import ApprovalActivity
import common.help_texts
import extensions.fields
import utils
User = get_user_model()
log = logging.getLogger(__name__)
class RatingMixin:
@property
def text_ratings_count(self) -> int:
return len(
[
r
for r in self.ratings.all()
if r.text is not None and r.is_listed and r.reply_to is None
]
)
@property
def total_ratings_count(self) -> int:
return self.ratings.listed.count()
@property
def ratings_by_score(self):
total_count = self.ratings.listed.count()
counts_q = self.ratings.listed.values('score').annotate(count=Count('score'))
counts = {
**{score: 0 for score in self.ratings.model.SCORES.values},
**{_['score']: _['count'] for _ in counts_q},
}
return sorted(
[
{
'score': score,
'count': count,
'percent': 100 / (total_count / count) if count else 0,
}
for score, count in counts.items()
],
key=lambda _: _['score'],
reverse=True,
)
def recalculate_average_score(self) -> int:
old_average_score = self.average_score
rating_values = self.ratings.listed.values_list('score', flat=True)
if rating_values:
self.average_score = median(rating_values)
elif self.average_score:
self.average_score = 0
self.save(update_fields={'average_score'})
log.info(
'Average score of %s pk=%s changed from %s to %s',
self.__class__.__name__,
self.pk,
old_average_score,
self.average_score,
)
return self.average_score
class License(CreatedModifiedMixin, models.Model):
name = models.CharField(max_length=128, null=False, blank=False, unique=True)
slug = models.SlugField(
blank=False,
null=False,
help_text='Should be taken from https://spdx.org/licenses/',
unique=True,
)
url = models.URLField(blank=False, null=False)
def __str__(self) -> str:
return f'{self.name}'
@classmethod
def get_by_slug(cls, slug: str):
return cls.objects.filter(slug__startswith=slug).first()
class Platform(CreatedModifiedMixin, models.Model):
name = models.CharField(max_length=128, null=False, blank=False, unique=True)
slug = models.SlugField(
blank=False,
null=False,
help_text='A platform tag, see https://packaging.python.org/en/latest/specifications/platform-compatibility-tags/', # noqa
unique=True,
)
def __str__(self) -> str:
return f'{self.name}'
@classmethod
def get_by_slug(cls, slug: str):
return cls.objects.filter(slug__startswith=slug).first()
class ExtensionManager(models.Manager):
@property
def listed(self):
return self.filter(
status=self.model.STATUSES.APPROVED,
is_listed=True,
)
@property
def unlisted(self):
return self.exclude(status=self.model.STATUSES.APPROVED)
def _authored_by_filter(self, user):
filter = Q(maintainer__user_id=user.pk)
user_teams = user.teams.all()
if user_teams:
filter = filter | Q(team__in=[t.pk for t in user_teams])
return filter
def authored_by(self, user):
return self.filter(self._authored_by_filter(user)).distinct()
def listed_or_authored_by(self, user):
return self.filter(
Q(status=self.model.STATUSES.APPROVED) | self._authored_by_filter(user)
).distinct()
class Extension(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Model):
track_changes_to_fields = {
'status',
'name',
'description',
'support',
'website',
}
TYPES = EXTENSION_TYPE_CHOICES
STATUSES = EXTENSION_STATUS_CHOICES
date_approved = models.DateTimeField(null=True, blank=True, editable=False)
date_status_changed = models.DateTimeField(null=True, blank=True, editable=False)
type = models.PositiveSmallIntegerField(choices=TYPES, default=TYPES.BPY, editable=False)
slug = models.SlugField(unique=True, null=False, blank=False, editable=False)
extension_id = models.CharField(max_length=255, unique=True, null=False, blank=False)
name = models.CharField(max_length=255, unique=True, null=False, blank=False)
description = models.TextField(help_text=common.help_texts.markdown)
is_listed = models.BooleanField(
help_text='Whether the extension should be listed. It is kept in sync via signals.',
default=False,
)
latest_version = models.ForeignKey(
'Version',
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='latest_version_of',
)
featured_image = models.ForeignKey(
'files.File',
related_name='featured_image_of',
null=True,
blank=False,
on_delete=models.PROTECT,
help_text=(
"Shown by social networks when this extension is shared"
" (used as `og:image` metadata field)."
"Should have resolution of at least 1920 x 1080 and aspect ratio of 16:9."
),
)
icon = models.ForeignKey(
'files.File',
related_name='icon_of',
null=True,
blank=False,
on_delete=models.PROTECT,
help_text="A 256 x 256 PNG icon representing this extension.",
)
previews = FilterableManyToManyField('files.File', through='Preview', related_name='extensions')
status = models.PositiveSmallIntegerField(choices=STATUSES, default=STATUSES.DRAFT)
support = models.URLField(
help_text='URL for reporting issues or contact details for support.', null=True, blank=True
)
website = models.URLField(help_text='External URL for the extension.', null=True, blank=True)
authors = FilterableManyToManyField(
User,
through='Maintainer',
related_name='extensions',
)
team = models.ForeignKey('teams.Team', null=True, blank=True, on_delete=models.SET_NULL)
average_score = models.FloatField(max_length=255, default=0, null=False)
download_count = models.PositiveIntegerField(default=0)
view_count = models.PositiveIntegerField(default=0)
objects = ExtensionManager()
class Meta:
indexes = [
models.Index(fields=['is_listed', 'type', 'average_score']),
models.Index(fields=['is_listed', 'type', 'date_approved']),
models.Index(fields=['is_listed', 'type', 'download_count']),
models.Index(fields=['is_listed', 'type', 'name']),
]
ordering = ['-average_score', '-date_created', 'name']
def __str__(self):
return f'{self.get_type_display()} "{self.name}"'
@property
def type_slug(self) -> str:
return EXTENSION_TYPE_SLUGS[self.type]
@property
def type_slug_singular(self) -> str:
return EXTENSION_TYPE_SLUGS_SINGULAR[self.type]
@property
def status_slug(self) -> str:
return utils.slugify(EXTENSION_STATUS_CHOICES[self.status - 1][1])
def update_metadata_from_version(self, version):
update_fields = set()
metadata = version.file.metadata
# check if we can also update name
# if we are uploading a new version, we have just validated and don't expect a clash,
# but if a version is being deleted, we want to rollback to a name from an older version,
# which may by clashing now, and we can't do anything about it
name = metadata.get('name')
if not self.__class__.objects.filter(name=name).exclude(pk=self.pk).exists():
update_fields.add('name')
for field in ['support', 'website']:
# if a field is missing from manifest, don't reset the corresponding extension field
if metadata.get(field):
update_fields.add(field)
for field in update_fields:
setattr(self, field, metadata.get(field))
self.save(update_fields=update_fields)
@transaction.atomic
def approve(self, reviewer=None):
"""TODO: Approve an extension which is currently in review."""
# TODO: require notes and reviewer, do extra checks before cascading to version/file.
# TODO: check that latest version is currently in review (whatever that means in data?)
latest_version = self.latest_version
file = latest_version.file
file.status = FILE_STATUS_CHOICES.APPROVED
file.save()
# TODO: check that this extension is currently in review (whatever that means in data?)
self.previews.update(status=FILE_STATUS_CHOICES.APPROVED)
self.status = self.STATUSES.APPROVED
self.save()
if self.featured_image:
self.featured_image.status = FILE_STATUS_CHOICES.APPROVED
self.save()
if self.icon:
self.icon.status = FILE_STATUS_CHOICES.APPROVED
self.icon.save()
@property
def cannot_be_deleted_reasons(self) -> List[str]:
"""Return a list of reasons why this extension cannot be deleted."""
reasons = []
if self.is_listed:
reasons.append('is_listed')
if self.ratings.count() > 0:
reasons.append('has_ratings')
if self.abusereport_set.count() > 0:
reasons.append('has_abuse_reports')
for v in self.versions.all():
reasons.extend(v.cannot_be_deleted_reasons)
return reasons
def get_absolute_url(self):
return reverse('extensions:detail', args=[self.type_slug, self.slug])
def get_draft_url(self):
return reverse('extensions:draft', args=[self.type_slug, self.slug])
def get_manage_url(self):
return reverse('extensions:manage', args=[self.type_slug, self.slug])
def get_manage_versions_url(self):
return reverse('extensions:manage-versions', args=[self.type_slug, self.slug])
def get_delete_url(self):
return reverse('extensions:delete', args=[self.type_slug, self.slug])
def get_new_version_url(self):
return reverse('extensions:new-version', args=[self.type_slug, self.slug])
def get_versions_url(self):
return reverse('extensions:versions', args=[self.type_slug, self.slug])
def get_ratings_url(self):
return reverse('ratings:for-extension', args=[self.type_slug, self.slug])
def get_rate_url(self):
return reverse('ratings:new', args=[self.type_slug, self.slug])
def get_report_url(self):
return reverse('abuse:report-extension', args=[self.type_slug, self.slug])
def get_review_url(self):
return reverse('reviewers:approval-detail', args=[self.slug])
def get_previews(self) -> List['Preview']:
"""Get all preview files, sorted by Preview.position.
Avoid triggering additional querysets, rely on prefetch_related in the view.
"""
return [p for p in self.preview_set.all()]
def get_previews_listed(self) -> List['Preview']:
"""Get publicly listed preview files, sorted by Preview.position."""
return [p for p in self.get_previews() if p.file.is_listed]
@property
def valid_file_statuses(self) -> List[int]:
if self.status == self.STATUSES.APPROVED:
return [FILE_STATUS_CHOICES.APPROVED]
return [FILE_STATUS_CHOICES.AWAITING_REVIEW, FILE_STATUS_CHOICES.APPROVED]
def can_request_review(self):
"""Return whether an add-on can request a review or not."""
if self.is_disabled or self.status in (
self.STATUSES.APPROVED,
self.STATUSES.AWAITING_REVIEW,
):
return False
latest_version = self.latest_version
return latest_version is not None and not latest_version.file.reviewed
@property
def is_approved(self) -> bool:
return self.status == self.STATUSES.APPROVED
@property
def is_disabled(self):
"""True if this Extension is disabled.
It could be disabled by an admin or disabled by the developer
"""
return self.status in (self.STATUSES.DISABLED_BY_AUTHOR, self.STATUSES.DISABLED)
def should_redirect_to_submit_flow(self):
return (
self.status == self.STATUSES.DRAFT
and not self.has_complete_metadata()
and self.latest_version is not None
)
def has_maintainer(self, user) -> bool:
"""Return True if given user is listed as a maintainer or is a member of the team."""
if user is None or user.is_anonymous:
return False
if user in self.authors.all():
return True
if self.team and user in self.team.users.all():
return True
return False
def can_rate(self, user) -> bool:
"""Return True if given user can rate this extension.
Allow leaving a rating once and don't allow maintainers rate their own extensions.
"""
return (
not self.has_maintainer(user)
and not self.ratings.filter(
reply_to=None,
user_id=user.pk,
).exists()
)
def suspicious_files(self):
versions = self.versions.all()
return [v.file for v in versions if not v.file.validation.is_ok]
@classmethod
def get_lookup_field(cls, identifier):
lookup_field = 'pk'
if identifier and not str(identifier).isdigit():
lookup_field = 'slug'
return lookup_field
@transaction.atomic
def update_latest_version(self, skip_version=None):
versions = self.versions.select_related('file').order_by('-date_created')
latest_version = None
for version in versions:
if skip_version and version == skip_version:
continue
if version.file.status not in self.valid_file_statuses:
continue
latest_version = version
break
self.latest_version = latest_version
self.save(update_fields={'latest_version'})
if latest_version:
self.update_metadata_from_version(latest_version)
def update_is_listed(self):
should_be_listed = (
self.status == self.STATUSES.APPROVED
and self.versions.filter(file__status=File.STATUSES.APPROVED).count() > 0
)
# this method is called from post_save signal, this early return should prevent a loop
if self.is_listed == should_be_listed:
return
self.is_listed = should_be_listed
update_fields = {'is_listed'}
if self.status == self.STATUSES.APPROVED and not should_be_listed:
self.status = self.STATUSES.DRAFT
update_fields.add('status')
self.save(update_fields=update_fields)
class VersionPermission(CreatedModifiedMixin, models.Model):
name = models.CharField(max_length=128, null=False, blank=False, unique=True)
slug = models.SlugField(
blank=False,
null=False,
help_text='Permissions add-ons are expected to need.',
unique=True,
)
help = models.CharField(max_length=128, null=False, blank=False, unique=True)
def __str__(self) -> str:
return f'{self.name}'
@classmethod
def get_by_slug(cls, slug: str):
return cls.objects.get(slug=slug)
class Tag(CreatedModifiedMixin, models.Model):
TYPES = EXTENSION_TYPE_CHOICES
name = models.CharField(max_length=128, null=False, blank=False)
slug = models.SlugField(blank=False, null=False)
type = models.PositiveSmallIntegerField(choices=TYPES, editable=True, null=False, blank=False)
class Meta:
unique_together = ('name', 'type')
def __str__(self) -> str:
return f'{self.name}'
class VersionManager(models.Manager):
@property
def listed(self):
return self.filter(file__status=FILE_STATUS_CHOICES.APPROVED)
@property
def unlisted(self):
return self.exclude(file__status=FILE_STATUS_CHOICES.APPROVED)
def update_or_create(self, *args, **kwargs):
# Stash the ManyToMany to be created after the Version has a valid ID already
licenses = kwargs.pop('licenses', [])
permissions = kwargs.pop('permissions', [])
platforms = kwargs.pop('platforms', [])
tags = kwargs.pop('tags', [])
version, result = super().update_or_create(*args, **kwargs)
# Add the ManyToMany to the already initialized Version
version.set_initial_licenses(licenses)
version.set_initial_permissions(permissions)
version.set_initial_platforms(platforms)
version.set_initial_tags(tags)
return version, result
class Version(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Model):
track_changes_to_fields = {
'blender_version_min',
'blender_version_max',
'permissions',
'version',
'licenses',
'file',
'tagline',
}
extension = models.ForeignKey(
'extensions.Extension', related_name='versions', on_delete=models.CASCADE
)
file = models.OneToOneField(
'files.File', related_name='version', on_delete=models.CASCADE, null=False, blank=False
)
version = extensions.fields.VersionStringField(
max_length=255,
default='0.1.0',
null=False,
blank=False,
help_text=(
'Current (latest) version of extension. '
'The value is taken from <code>"version"</code> in the manifest.'
),
)
blender_version_min = extensions.fields.VersionStringField(
max_length=64,
default='2.93.0',
null=False,
blank=False,
help_text=(
'Minimum version of Blender this extension is compatible with. '
'The value is taken from <code>"blender_version_min"</code> in the manifest file.'
),
)
blender_version_max = extensions.fields.VersionStringField(
max_length=64,
null=True,
blank=True,
help_text=(
'Maximum version of Blender this extension was tested and is compatible with. '
'The value is taken from <code>"blender_version_max"</code> in the manifest file.'
),
)
licenses = models.ManyToManyField(License, related_name='versions', blank=False)
tags = models.ManyToManyField(Tag, related_name='versions', blank=True)
schema_version = extensions.fields.VersionStringField(
max_length=64,
null=False,
blank=False,
default='1.0.0',
help_text=('Specification version the manifest file is following.'),
)
tagline = models.CharField(
max_length=64, null=False, blank=False, help_text='A very short description.'
)
permissions = models.ManyToManyField(VersionPermission, related_name='versions', blank=True)
platforms = models.ManyToManyField(Platform, related_name='versions', blank=True)
release_notes = models.TextField(help_text=common.help_texts.markdown, blank=True)
average_score = models.FloatField(max_length=255, default=0, null=False)
download_count = models.PositiveIntegerField(default=0)
objects = VersionManager()
class Meta:
ordering = ['-date_created']
constraints = [
models.UniqueConstraint(
fields=('extension', 'version'),
name='version_extension_version_key',
),
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def set_initial_permissions(self, _permissions):
if not _permissions:
return
for permission_name in _permissions:
permission = VersionPermission.get_by_slug(permission_name)
self.permissions.add(permission)
def set_initial_platforms(self, _platforms):
if not _platforms:
return
for slug in _platforms:
platform = Platform.get_by_slug(slug)
self.platforms.add(platform)
def set_initial_licenses(self, _licenses):
if not _licenses:
return
for license_slug in _licenses:
try:
license = License.get_by_slug(license_slug)
except models.ObjectDoesNotExist:
error_message = f'Unsupported license in manifest file: {license_slug}'
log.error(error_message)
raise BadRequest(error_message)
self.licenses.add(license)
def set_initial_tags(self, _tags):
if not _tags:
return
for tag_name in _tags:
try:
tag = Tag.objects.filter(name=tag_name).first()
except ObjectDoesNotExist:
error_message = f'Unsupported tag in manifest file: {tag_name}'
log.error(error_message)
raise BadRequest(error_message)
self.tags.add(tag)
def __str__(self) -> str:
return f'{self.extension} v{self.version}'
@property
def is_listed(self):
# To be public, version file must have a public status.
return self.file.status == self.file.STATUSES.APPROVED
@property
def cannot_be_deleted_reasons(self) -> List[str]:
"""Return a list of reasons why this version cannot be deleted."""
reasons = []
if self.is_listed:
reasons.append('version_is_listed')
if self.ratings.count() > 0:
reasons.append('version_has_ratings')
return reasons
@property
def download_name(self) -> str:
"""Return a file name for downloads."""
replace_char = f'{self}'.replace('.', '-')
return f'{utils.slugify(replace_char)}{self.file.suffix}'
@property
def downloadable_signed_url(self) -> str:
# TODO: actual signed URLs?
return self.file.source.url
def download_url(self, append_repository=True) -> str:
filename = f'{self.extension.type_slug_singular}-{self.extension.slug}-v{self.version}.zip'
download_url = reverse(
'extensions:version-download',
kwargs={
'type_slug': self.extension.type_slug,
'slug': self.extension.slug,
'version': self.version,
'filename': filename,
},
)
if append_repository:
download_url += '?repository=/api/v1/extensions/'
return download_url
def get_delete_url(self) -> str:
return reverse(
'extensions:version-delete',
kwargs={
'type_slug': self.extension.type_slug,
'slug': self.extension.slug,
'pk': self.pk,
},
)
@property
def update_url(self) -> str:
return reverse(
'extensions:version-update',
kwargs={
'type_slug': self.extension.type_slug,
'slug': self.extension.slug,
'pk': self.pk,
},
)
@transaction.atomic
def save(self, *args, **kwargs):
is_new = self.pk is None
update_fields = kwargs.get('update_fields', None)
was_changed, old_state = self.pre_save_record(update_fields=update_fields)
self.record_status_change(was_changed, old_state, **kwargs)
super().save(*args, **kwargs)
if not is_new:
return
# auto approving our file if extension is already listed (i.e. have been approved)
if self.extension.is_listed:
args = {'f_id': self.file.pk, 'pk': self.pk, 's': self.file.source.name}
log.info('Auto-approving file pk=%(f_id)s of Version pk=%(pk)s source=%(s)s', args)
self.file.status = File.STATUSES.APPROVED
self.file.save(update_fields={'status', 'date_modified'})
ApprovalActivity(
type=ApprovalActivity.ActivityType.UPLOADED_NEW_VERSION,
user=self.file.user,
extension=self.extension,
message=f'uploaded new version: {self.version}',
).save()
self.extension.update_latest_version()
@transaction.atomic
def delete(self, *args, **kwargs):
if self == self.extension.latest_version:
# make sure self is not a candidate for latest_version, since it's being deleted
self.extension.update_latest_version(skip_version=self)
super().delete(*args, **kwargs)
class Maintainer(CreatedModifiedMixin, models.Model):
extension = models.ForeignKey(Extension, on_delete=models.CASCADE)
user = models.ForeignKey(User, on_delete=models.CASCADE)
role = models.SmallIntegerField(default=AUTHOR_ROLE_DEV, choices=AUTHOR_ROLE_CHOICES)
position = models.IntegerField(default=0)
class Meta:
constraints = [
models.UniqueConstraint(fields=('extension', 'user'), name='maintainer_extension_user'),
]
class Preview(CreatedModifiedMixin, RecordDeletionMixin, models.Model):
extension = models.ForeignKey(Extension, on_delete=models.CASCADE)
# Files can potentially be referenced by different extensions
file = models.ForeignKey('files.File', on_delete=models.PROTECT)
caption = models.CharField(max_length=255, default='', null=False, blank=True)
position = models.IntegerField(default=0)
class Meta:
ordering = ('position', 'date_created')
# We don't want to have duplicate previews on the same extension
unique_together = [['extension', 'file']]
@property
def cannot_be_deleted_reasons(self) -> List[str]:
"""Return a list of reasons why this preview cannot be deleted."""
return []
def __str__(self) -> str:
return f'Preview {self.pk} of extension {self.extension_id}: {self.file}'