extensions-website/extensions/models.py
Márton Lente 5ace0a8251 UI: Improve multi OS display (#205)
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
2024-07-16 07:24:06 +02:00

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}'