UI: Improve multi OS display #205

Merged
Oleg-Komarov merged 40 commits from ui/multi-os into main 2024-07-16 07:24:07 +02:00
16 changed files with 73 additions and 115 deletions
Showing only changes of commit d66c62939a - Show all commits

View File

@ -19,10 +19,10 @@ class Migration(migrations.Migration):
('date_created', models.DateTimeField(auto_now_add=True)),
('date_modified', models.DateTimeField(auto_now=True)),
('date_deleted', models.DateTimeField(blank=True, editable=False, null=True)),
('extension_version', extensions.fields.VersionStringField(blank=True, coerce=False, max_length=64, null=True, partial=False)),
('extension_version', extensions.fields.VersionStringField(blank=True, max_length=64, null=True)),
('message', models.TextField(blank=True)),
('reason', models.PositiveSmallIntegerField(choices=[(127, 'Other'), (1, 'Damages computer and/or data'), (2, 'Creates spam or advertising'), (3, "Doesn't work, breaks Blender, or slows it down"), (4, 'Hateful, violent, or illegal content'), (5, "Pretends to be something it's not")], default='Other')),
('version', extensions.fields.VersionStringField(blank=True, coerce=False, help_text='Version of Blender affected by this report, if applicable.', max_length=64, null=True, partial=False)),
('version', extensions.fields.VersionStringField(blank=True, help_text='Version of Blender affected by this report, if applicable.', max_length=64, null=True)),
('status', models.PositiveSmallIntegerField(choices=[(1, 'Untriaged'), (2, 'Valid'), (3, 'Suspicious')], default=1)),
],
options={

View File

@ -267,11 +267,6 @@ BLENDER_ID = {
TAGGIT_CASE_INSENSITIVE = True
ACTSTREAM_SETTINGS = {
'MANAGER': 'users.managers.CustomStreamManager',
'FETCH_RELATIONS': True,
}
REST_FRAMEWORK = {
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
'DEFAULT_AUTHENTICATION_CLASSES': ('apitokens.authentication.UserTokenAuthentication',),

View File

@ -28,7 +28,7 @@ class FileFactory(DjangoModelFactory):
class Meta:
model = File
hash = factory.Faker('lexify', text='fakehash:??????????????????', letters='deadbeef')
hash = factory.LazyAttribute(lambda x: x.original_hash)
metadata = factory.SubFactory(ManifestFactory)
original_hash = factory.Faker('lexify', text='fakehash:??????????????????', letters='deadbeef')
original_name = factory.LazyAttribute(lambda x: x.source)

View File

@ -1,36 +1,16 @@
from django.core.exceptions import ValidationError
from semantic_version.django_fields import VersionField as SemanticVersionField
from django.db.models import CharField
from semantic_version import Version
import json
class VersionStringField(SemanticVersionField):
def validate_version_string(version_string):
try:
Version.parse(version_string)
except ValueError as e:
raise ValidationError(e)
class VersionStringField(CharField):
description = "A field to store serializable semantic versions"
def to_python(self, value):
if isinstance(value, Version):
return value
if value is None:
return value
try:
return str(Version(value))
except Exception as e:
raise ValidationError(e)
def from_db_value(self, value, expression, connection):
return self.to_python(value)
def get_prep_value(self, value):
if value is None:
return value
return str(value)
def value_to_string(self, obj):
value = self.value_from_object(obj)
return self.get_prep_value(value)
def from_json(self, json_str):
return self.to_python(json.loads(json_str))
def to_json(self, value):
return json.dumps(str(value))
default_validators = [validate_version_string]

View File

@ -87,9 +87,9 @@ class Migration(migrations.Migration):
('date_created', models.DateTimeField(auto_now_add=True)),
('date_modified', models.DateTimeField(auto_now=True)),
('date_deleted', models.DateTimeField(blank=True, editable=False, null=True)),
('version', extensions.fields.VersionStringField(coerce=False, default='0.1.0', help_text='Current (latest) version of extension. The value is taken from <code>"version"</code> in the manifest.', max_length=255, partial=False)),
('blender_version_min', extensions.fields.VersionStringField(coerce=False, default='2.93.0', 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.', max_length=64, partial=False)),
('blender_version_max', extensions.fields.VersionStringField(coerce=False, 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.', max_length=64, null=True, partial=False)),
('version', extensions.fields.VersionStringField(default='0.1.0', help_text='Current (latest) version of extension. The value is taken from <code>"version"</code> in the manifest.', max_length=255)),
('blender_version_min', extensions.fields.VersionStringField(default='2.93.0', 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.', max_length=64)),
('blender_version_max', extensions.fields.VersionStringField(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.', max_length=64, null=True)),
('release_notes', models.TextField(help_text='\n<p><a href="https://commonmark.org/help/" rel="nofollow" target="_blank">Markdown</a>\nis supported.</p>\n')),
('average_score', models.FloatField(default=0, max_length=255)),
('download_count', models.PositiveIntegerField(default=0)),

View File

@ -14,6 +14,6 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='version',
name='schema_version',
field=extensions.fields.VersionStringField(coerce=False, default='1.0.0', help_text='Specification version the manifest file is following.', max_length=64, partial=False),
field=extensions.fields.VersionStringField(default='1.0.0', help_text='Specification version the manifest file is following.', max_length=64),
),
]

View File

@ -14,7 +14,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='version',
name='blender_version_max',
field=extensions.fields.VersionStringField(blank=True, coerce=False, 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.', max_length=64, null=True, partial=False),
field=extensions.fields.VersionStringField(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.', max_length=64, null=True),
),
migrations.AlterField(
model_name='version',

View File

@ -85,10 +85,6 @@ class ExtensionManager(models.Manager):
is_listed=True,
)
@property
def unlisted(self):
return self.exclude(status=self.model.STATUSES.APPROVED)
@property
def blocklisted(self):
return self.filter(status=self.model.STATUSES.BLOCKLISTED)
@ -222,6 +218,8 @@ class Extension(CreatedModifiedMixin, TrackChangesMixin, models.Model):
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)
@ -374,18 +372,6 @@ class Extension(CreatedModifiedMixin, TrackChangesMixin, models.Model):
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
@ -398,13 +384,6 @@ class Extension(CreatedModifiedMixin, TrackChangesMixin, models.Model):
"""
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:
@ -438,7 +417,7 @@ class Extension(CreatedModifiedMixin, TrackChangesMixin, models.Model):
files = []
for version in self.versions.all():
for file in version.files.all():
if not file.validation.is_ok:
if hasattr(file, 'validation') and not file.validation.is_ok:
files.append(file)
return files
@ -549,11 +528,7 @@ class Tag(CreatedModifiedMixin, models.Model):
class VersionManager(models.Manager):
@property
def listed(self):
return self.filter(files__status=FILE_STATUS_CHOICES.APPROVED)
@property
def unlisted(self):
return self.exclude(files__status=FILE_STATUS_CHOICES.APPROVED)
return self.filter(files__status=FILE_STATUS_CHOICES.APPROVED).distinct()
class Version(CreatedModifiedMixin, TrackChangesMixin, models.Model):
@ -637,9 +612,6 @@ class Version(CreatedModifiedMixin, TrackChangesMixin, models.Model):
),
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def set_initial_permissions(self, _permissions):
if not _permissions:
return
@ -785,7 +757,7 @@ class Version(CreatedModifiedMixin, TrackChangesMixin, models.Model):
permissions.append({'slug': slug, 'reason': reason, 'name': all_permission_names[slug]})
return permissions
def _get_download_name(self, file) -> str:
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():
@ -793,7 +765,7 @@ class Version(CreatedModifiedMixin, TrackChangesMixin, models.Model):
return f'{"-".join(parts)}.zip'
def get_download_url(self, file, append_repository_and_compatibility=True) -> str:
filename = self._get_download_name(file)
filename = self.get_download_name(file)
download_url = reverse(
'extensions:download',
kwargs={
@ -820,7 +792,7 @@ class Version(CreatedModifiedMixin, TrackChangesMixin, models.Model):
file = files[0]
return [
{
'name': self._get_download_name(file),
'name': self.get_download_name(file),
'size': file.size_bytes,
'url': self.get_download_url(file),
}
@ -838,7 +810,7 @@ class Version(CreatedModifiedMixin, TrackChangesMixin, models.Model):
all_platforms_by_slug = {p.slug: p for p in Platform.objects.all()}
return [
{
'name': self._get_download_name(file),
'name': self.get_download_name(file),
'platform': all_platforms_by_slug.get(platform_slug),
'size': file.size_bytes,
'url': self.get_download_url(file),
@ -852,7 +824,7 @@ class Version(CreatedModifiedMixin, TrackChangesMixin, models.Model):
platforms = file.get_platforms() or []
build_list.append(
{
'name': self._get_download_name(file),
'name': self.get_download_name(file),
'platforms': platforms,
'size': file.size_bytes,
'url': self.get_download_url(file),

View File

@ -64,21 +64,28 @@ class ResponseFormatTest(APITestCase):
json = response.json()
self.assertEqual(len(json['data']), 3)
for v in json['data']:
self.assertIn('id', v)
extension = Extension.objects.get(extension_id=v['id'])
file = extension.versions.first().files.first()
self.assertIn('name', v)
self.assertIn('tagline', v)
self.assertIn('version', v)
self.assertIn('type', v)
self.assertIn('archive_size', v)
self.assertIn('archive_hash', v)
self.assertIn('archive_url', v)
self.assertIn('blender_version_min', v)
self.assertIn('maintainer', v)
self.assertIn('license', v)
self.assertIn('website', v)
self.assertIn('schema_version', v)
# Blender expects urls in HTML anchors to end with .zip to handle drag&drop
self.assertEqual(v['archive_url'][-4:], '.zip')
self.assertEqual(
v['archive_url'],
'http://testserver'
+ extension.versions.first().get_download_url(
file, append_repository_and_compatibility=False
),
)
self.assertEqual(v['archive_hash'], file.hash)
self.assertEqual(v['website'], 'http://testserver' + extension.get_absolute_url())
def test_maintaner_is_team(self):
version = create_approved_version(metadata__blender_version_min='4.0.1')

View File

@ -1,5 +1,6 @@
import json
from django.core.exceptions import ValidationError
from django.test import TestCase
from common.admin import get_admin_change_path
@ -161,6 +162,26 @@ class VersionTest(TestCase):
self.assertIsNotNone(version2.get_file_for_platform(None))
self.assertIsNotNone(version2.get_file_for_platform('macos-x64'))
def test_version_validation(self):
with self.assertRaises(ValidationError):
create_version(
metadata__version='abc',
)
with self.assertRaises(ValidationError):
create_version(
metadata__version='1',
)
with self.assertRaises(ValidationError):
create_version(
metadata__version='1.2',
)
create_version(
metadata__version='0.0.5+win-x64',
)
class UpdateMetadataTest(TestCase):
fixtures = ['dev', 'licenses']

View File

@ -14,11 +14,6 @@ from extensions.models import Extension, Platform
from extensions.utils import clean_json_dictionary_from_optional_fields
from files.forms import FileFormSkipAgreed
from constants.base import (
EXTENSION_TYPE_SLUGS_SINGULAR,
)
log = logging.getLogger(__name__)
@ -35,9 +30,10 @@ class ListedExtensionsSerializer(serializers.ModelSerializer):
UNKNOWN_PLATFORM = 'unknown-platform-value'
def __init__(self, *args, **kwargs):
self.request = kwargs.pop('request', None)
self.blender_version = kwargs.pop('blender_version', None)
self.platform = kwargs.pop('platform', None)
self.request = kwargs.pop('request', None)
self.scheme_host = "{}://{}".format(self.request.scheme, self.request.get_host())
self._validate()
super().__init__(*args, **kwargs)
@ -48,7 +44,6 @@ class ListedExtensionsSerializer(serializers.ModelSerializer):
except ValidationError:
self.fail('invalid_blender_version')
if self.platform:
# FIXME change to an in-memory lookup?
try:
Platform.objects.get(slug=self.platform)
except Platform.DoesNotExist:
@ -80,9 +75,13 @@ class ListedExtensionsSerializer(serializers.ModelSerializer):
return ([], None)
def to_representation(self, instance):
# avoid triggering additional db queries, reuse the prefetched authors queryset
maintainer = instance.team and instance.team.name or str(instance.authors.all()[0])
matching_files, matching_version = self.find_matching_files_and_version(instance)
type_slug = instance.type_slug
result = []
for file in matching_files:
filename = matching_version.get_download_name(file)
data = {
'id': instance.extension_id,
'schema_version': matching_version.schema_version,
@ -91,20 +90,12 @@ class ListedExtensionsSerializer(serializers.ModelSerializer):
'tagline': matching_version.tagline,
'archive_hash': file.original_hash,
'archive_size': file.size_bytes,
'archive_url': self.request.build_absolute_uri(
matching_version.get_download_url(
file,
append_repository_and_compatibility=False,
)
),
'type': EXTENSION_TYPE_SLUGS_SINGULAR.get(instance.type),
'archive_url': f'{self.scheme_host}/download/{file.hash}/{filename}',
'type': instance.type_slug_singular,
'blender_version_min': matching_version.blender_version_min,
'blender_version_max': matching_version.blender_version_max,
'website': self.request.build_absolute_uri(instance.get_absolute_url()),
# avoid triggering additional db queries, reuse the prefetched queryset
'maintainer': (
instance.team and instance.team.name or str(instance.authors.all()[0])
),
'website': f'{self.scheme_host}/{type_slug}/{matching_version.extension.slug}/',
'maintainer': maintainer,
'license': [license_iter.slug for license_iter in matching_version.licenses.all()],
'permissions': file.metadata.get('permissions'),
'platforms': file.get_platforms(),

View File

@ -19,10 +19,6 @@ class FileManager(models.Manager):
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/'

View File

@ -5,7 +5,7 @@
<section>
<div class="card pb-3 pt-4 px-4 mb-3 ext-detail-download-danger">
<h3>&nbsp;{% trans "Suspicious upload" %}</h3>
{% blocktrans asvar alert_text %}Scan of the {{ suspicious_files.0 }} indicates malicious content.{% endblocktrans %}
{% blocktrans asvar alert_text with file=suspicious_files.0 %}Scan of the {{ file }} indicates malicious content.{% endblocktrans %}
<h4>
{{ alert_text }}
{% if perms.files.view_file %}{# Moderators don't necessarily have access to the admin #}

View File

@ -351,6 +351,6 @@ class UtilsTest(TestCase):
self.assertEqual(
validate_wheels(test_file_path, ['wheels/1.whl']).get('wheels/1.whl'),
'digest in archive=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'
', digest on pypi=blahblah',
'sha256 in archive=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'
', sha256 on pypi=blahblah',
)

View File

@ -18,10 +18,6 @@ class RatingManager(models.Manager):
def listed(self):
return self.filter(status=self.model.STATUSES.APPROVED)
@property
def unlisted(self):
return self.exclude(status=self.models.STATUSES.APPROVED)
@property
def listed_texts(self):
return self.listed.filter(text__isnull=False)

View File

@ -18,7 +18,7 @@ class Migration(migrations.Migration):
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date_released_on', models.DateField(verbose_name='Released on')),
('date_supported_until', models.DateField(verbose_name='Supported until')),
('version', extensions.fields.VersionStringField(coerce=False, max_length=64, partial=False, unique=True)),
('version', extensions.fields.VersionStringField(max_length=64, unique=True)),
('is_active', models.BooleanField(default=True, help_text='Is this release currently actively developed or supported?<br>Controls whether or not this release shows up in <code>blender_version_max</code> dropdown.')),
('is_lts', models.BooleanField(default=False, help_text='Is this a Long-term Support release?', verbose_name='Is LTS')),
('release_notes_url', models.URLField(verbose_name='Release notes URL')),