Márton Lente
5ace0a8251
This PR improves UI display of extensions with multiple platform variants. The following items are work in progress, and will be added later: - [x] Showing the item that is relevant to the current platform in the _Other versions_ dropdown, while fading out the non-active items - [ ] Detect macOS architecture if possible, and only show the relevant item on the platform - [x] Merging the display of platforms with different architectures in extensions details' _Supported Platforms_ list Co-authored-by: Oleg Komarov <oleg@blender.org> Co-authored-by: Dalai Felinto <dalai@blender.org> Reviewed-on: #205
918 lines
32 KiB
Python
918 lines
32 KiB
Python
from typing import List
|
|
from urllib.parse import urlencode
|
|
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 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',
|
|
unique=True,
|
|
)
|
|
|
|
def __str__(self) -> str:
|
|
return f'{self.name}'
|
|
|
|
@classmethod
|
|
def get_by_slug(cls, slug: str):
|
|
return cls.objects.filter(slug=slug).first()
|
|
|
|
@property
|
|
def name_first_word(self):
|
|
"""Used for presentation in download_list."""
|
|
return self.name.split(None, 1)[0]
|
|
|
|
@property
|
|
def name_rest(self):
|
|
"""Used for presentation in download_list."""
|
|
parts = self.name.split(None, 1)
|
|
if len(parts) > 1:
|
|
return parts[1]
|
|
else:
|
|
return ''
|
|
|
|
|
|
class ExtensionManager(models.Manager):
|
|
@property
|
|
def listed(self):
|
|
return self.filter(
|
|
status=self.model.STATUSES.APPROVED,
|
|
is_listed=True,
|
|
)
|
|
|
|
@property
|
|
def blocklisted(self):
|
|
return self.filter(status=self.model.STATUSES.BLOCKLISTED)
|
|
|
|
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))
|
|
.exclude(status=self.model.STATUSES.BLOCKLISTED)
|
|
.distinct()
|
|
)
|
|
|
|
def listed_or_authored_by(self, user):
|
|
return (
|
|
self.filter(Q(status=self.model.STATUSES.APPROVED) | self._authored_by_filter(user))
|
|
.exclude(status=self.model.STATUSES.BLOCKLISTED)
|
|
.distinct()
|
|
)
|
|
|
|
|
|
class Extension(CreatedModifiedMixin, 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)
|
|
rating_sortkey = 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', 'date_approved']),
|
|
models.Index(fields=['is_listed', 'type', 'download_count']),
|
|
models.Index(fields=['is_listed', 'type', 'name']),
|
|
models.Index(fields=['is_listed', 'type', 'rating_sortkey']),
|
|
]
|
|
ordering = ['-average_score', '-date_created', 'name']
|
|
|
|
@classmethod
|
|
def create_from_file(cls, file: File) -> 'Extension':
|
|
extension = cls(**file.parsed_extension_fields)
|
|
with transaction.atomic():
|
|
extension.save()
|
|
extension.authors.add(file.user)
|
|
return extension
|
|
|
|
def create_version_from_file(self, file: File, release_notes='') -> 'Version':
|
|
"""This should be the only method to create a new Version object.
|
|
|
|
Side effects:
|
|
- updates file status and adds an approval queue comment when extension is listed
|
|
- updates extension.latest_version
|
|
"""
|
|
fields = file.parsed_version_fields
|
|
licenses = fields.pop('licenses', [])
|
|
permissions = fields.pop('permissions', {})
|
|
if permissions:
|
|
permissions = list(permissions.keys())
|
|
platforms = fields.pop('platforms', [])
|
|
tags = fields.pop('tags', [])
|
|
version = Version(**fields, extension=self, release_notes=release_notes)
|
|
with transaction.atomic():
|
|
# make sure to validate all fields passed from manifest using Version validators
|
|
version.full_clean()
|
|
version.save()
|
|
version.files.add(file)
|
|
version.set_initial_licenses(licenses)
|
|
version.set_initial_permissions(permissions)
|
|
version.set_initial_platforms(platforms)
|
|
version.set_initial_tags(tags)
|
|
|
|
# auto approving our file if extension is already listed (i.e. have been approved)
|
|
if self.is_listed:
|
|
args = {'f_id': file.pk, 'pk': version.pk, 's': file.source.name}
|
|
log.info('Auto-approving file pk=%(f_id)s of Version pk=%(pk)s source=%(s)s', args)
|
|
file.status = File.STATUSES.APPROVED
|
|
file.save(update_fields={'status', 'date_modified'})
|
|
|
|
ApprovalActivity(
|
|
type=ApprovalActivity.ActivityType.UPLOADED_NEW_VERSION,
|
|
user=file.user,
|
|
extension=self,
|
|
message=f'uploaded new version: {version}',
|
|
).save()
|
|
|
|
self.update_latest_version()
|
|
|
|
return version
|
|
|
|
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()
|
|
files = list(version.files.all())
|
|
# picking the last uploaded file
|
|
metadata = files[-1].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
|
|
for file in latest_version.files.all():
|
|
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_detail_url(self):
|
|
if self.is_approved:
|
|
return self.get_absolute_url()
|
|
return self.get_review_url()
|
|
|
|
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]
|
|
|
|
@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 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 all_maintainers(self):
|
|
maintainers = set(self.authors.all())
|
|
if self.team:
|
|
for user in self.team.users.all():
|
|
maintainers.add(user)
|
|
return maintainers
|
|
|
|
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(
|
|
user_id=user.pk,
|
|
).exists()
|
|
)
|
|
|
|
def suspicious_files(self):
|
|
files = []
|
|
for version in self.versions.all():
|
|
for file in version.files.all():
|
|
if hasattr(file, 'validation') and not file.validation.is_ok:
|
|
files.append(file)
|
|
return files
|
|
|
|
@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.prefetch_related('files').order_by('-date_created')
|
|
latest_version = None
|
|
for version in versions:
|
|
if skip_version and version == skip_version:
|
|
continue
|
|
files = version.files.all()
|
|
if not any([file.status in self.valid_file_statuses for file in files]):
|
|
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(files__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)
|
|
|
|
@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])
|
|
|
|
@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,
|
|
)
|
|
|
|
|
|
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(files__status=FILE_STATUS_CHOICES.APPROVED).distinct()
|
|
|
|
|
|
class Version(CreatedModifiedMixin, TrackChangesMixin, models.Model):
|
|
track_changes_to_fields = {
|
|
'blender_version_min',
|
|
'blender_version_max',
|
|
'permissions',
|
|
'version',
|
|
'licenses',
|
|
'files',
|
|
'tagline',
|
|
}
|
|
|
|
extension = models.ForeignKey(Extension, related_name='versions', on_delete=models.CASCADE)
|
|
files = models.ManyToManyField(
|
|
'files.File',
|
|
through='VersionFile',
|
|
related_name='version',
|
|
)
|
|
|
|
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)
|
|
|
|
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 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 has_single_file(self):
|
|
return len(self.files.all()) == 1
|
|
|
|
def add_file(self, file: File):
|
|
current_platforms = set([p.slug for p in self.platforms.all()])
|
|
if not current_platforms:
|
|
raise ValueError(
|
|
f'add_file failed: Version pk={self.pk} is not platform-specific, '
|
|
f'no more files allowed'
|
|
)
|
|
file_platforms = set(file.get_platforms() or [])
|
|
if overlap := current_platforms & file_platforms:
|
|
raise ValueError(
|
|
f'add_file failed: File pk={file.pk} and Version pk={self.pk} have '
|
|
f'overlapping platforms: {overlap}'
|
|
)
|
|
self.files.add(file)
|
|
self.update_platforms()
|
|
|
|
@transaction.atomic
|
|
def update_platforms(self):
|
|
current_platforms = set([p.slug for p in self.platforms.all()])
|
|
files_platforms = set()
|
|
for file in self.files.all():
|
|
if file_platforms := file.get_platforms():
|
|
files_platforms.update(file_platforms)
|
|
else:
|
|
# we have a cross-platform file - version must not specify platforms
|
|
# other files should not exist (validation takes care of that),
|
|
# but here we won't double-check it (?)
|
|
if len(current_platforms):
|
|
self.platforms.delete()
|
|
return
|
|
if to_create := files_platforms - current_platforms:
|
|
for p in to_create:
|
|
self.platforms.add(Platform.objects.get(slug=p))
|
|
if to_delete := current_platforms - files_platforms:
|
|
for p in to_delete:
|
|
self.platforms.remove(Platform.objects.get(slug=p))
|
|
|
|
def get_remaining_platforms(self):
|
|
all_platforms = set(p.slug for p in Platform.objects.all())
|
|
for file in self.files.all():
|
|
platforms = file.get_platforms()
|
|
if not platforms:
|
|
# no platforms means any platform
|
|
return set()
|
|
all_platforms -= set(platforms)
|
|
return all_platforms
|
|
|
|
def get_file_for_platform(self, platform):
|
|
for file in self.files.all():
|
|
platforms = file.get_platforms()
|
|
# not platform-specific, matches all platforms
|
|
if not platforms:
|
|
return file
|
|
if platform is None or platform in platforms:
|
|
return file
|
|
return None
|
|
|
|
def get_supported_platforms(self):
|
|
supported_platforms = {}
|
|
for platform in self.platforms.all():
|
|
architectures = supported_platforms.get(platform.name_first_word, [])
|
|
architectures.append(platform.name_rest)
|
|
supported_platforms[platform.name_first_word] = architectures
|
|
result = []
|
|
for platform, architectures in sorted(supported_platforms.items()):
|
|
item = {'name': platform, 'architectures': sorted(architectures)}
|
|
result.append(item)
|
|
return result
|
|
|
|
@property
|
|
def is_listed(self):
|
|
# To be public, at least one version file must have a public status.
|
|
return any([file.status == File.STATUSES.APPROVED for file in self.files.all()])
|
|
|
|
@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 permissions_with_reasons(self) -> List[dict]:
|
|
"""Returns permissions with reasons, slugs, and names to be shown in templates.
|
|
|
|
It might make sense to not aggregate at the Version level, and query directly from File.
|
|
If we ever restructure multi-platform UI, this method should be revisited.
|
|
|
|
Normally we can expect that all files have the same permissions, but checking all files,
|
|
just in case.
|
|
"""
|
|
slug2reason = {}
|
|
for file in self.files.all():
|
|
if 'permissions' in file.metadata:
|
|
slug2reason.update(file.metadata['permissions'])
|
|
|
|
if not slug2reason:
|
|
return []
|
|
|
|
all_permission_names = {p.slug: p.name for p in VersionPermission.objects.all()}
|
|
permissions = []
|
|
for slug, reason in slug2reason.items():
|
|
permissions.append({'slug': slug, 'reason': reason, 'name': all_permission_names[slug]})
|
|
return permissions
|
|
|
|
def get_download_name(self, file) -> str:
|
|
"""Return a file name for downloads."""
|
|
parts = [self.extension.type_slug_singular, self.extension.slug, f'v{self.version}']
|
|
if platforms := file.get_platforms():
|
|
parts.extend(platforms)
|
|
return f'{"-".join(parts)}.zip'
|
|
|
|
def get_download_url(self, file, append_repository_and_compatibility=True) -> str:
|
|
filename = self.get_download_name(file)
|
|
download_url = reverse(
|
|
'extensions:download',
|
|
kwargs={
|
|
'filehash': file.hash,
|
|
'filename': filename,
|
|
},
|
|
)
|
|
if append_repository_and_compatibility:
|
|
params = {
|
|
'repository': '/api/v1/extensions/',
|
|
'blender_version_min': self.blender_version_min,
|
|
}
|
|
if self.blender_version_max:
|
|
params['blender_version_max'] = self.blender_version_max
|
|
if platforms := file.get_platforms():
|
|
params['platforms'] = ','.join(platforms)
|
|
query_string = urlencode(params)
|
|
download_url += f'?{query_string}'
|
|
return download_url
|
|
|
|
def get_download_list(self) -> List[dict]:
|
|
files = list(self.files.all())
|
|
if len(files) == 1:
|
|
file = files[0]
|
|
return [
|
|
{
|
|
'name': self.get_download_name(file),
|
|
'size': file.size_bytes,
|
|
'url': self.get_download_url(file),
|
|
}
|
|
]
|
|
platform_slug2file = {}
|
|
for file in files:
|
|
platforms = file.get_platforms()
|
|
if not platforms:
|
|
log.warning(
|
|
f'data error: Version pk={self.pk} has multiple files, but File pk={file.pk} '
|
|
f'is not platform-specific'
|
|
)
|
|
for platform_slug in platforms:
|
|
platform_slug2file[platform_slug] = file
|
|
all_platforms_by_slug = {p.slug: p for p in Platform.objects.all()}
|
|
return [
|
|
{
|
|
'name': self.get_download_name(file),
|
|
'platform': all_platforms_by_slug.get(platform_slug),
|
|
'size': file.size_bytes,
|
|
'url': self.get_download_url(file),
|
|
}
|
|
for platform_slug, file in sorted(platform_slug2file.items(), reverse=True)
|
|
]
|
|
|
|
def get_build_list(self) -> List[dict]:
|
|
build_list = []
|
|
for file in self.files.all():
|
|
platforms = file.get_platforms() or []
|
|
build_list.append(
|
|
{
|
|
'name': self.get_download_name(file),
|
|
'platforms': platforms,
|
|
'size': file.size_bytes,
|
|
'url': self.get_download_url(file),
|
|
}
|
|
)
|
|
return build_list
|
|
|
|
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):
|
|
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)
|
|
|
|
@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 VersionFile(CreatedModifiedMixin, models.Model):
|
|
version = models.ForeignKey(Version, on_delete=models.CASCADE)
|
|
file = models.OneToOneField(File, on_delete=models.CASCADE)
|
|
|
|
|
|
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}'
|