UI: Improve multi OS display #205
@ -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={
|
||||
|
@ -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',),
|
||||
|
@ -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)
|
||||
|
@ -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]
|
||||
|
@ -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)),
|
||||
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
@ -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',
|
||||
|
@ -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),
|
||||
|
@ -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')
|
||||
|
@ -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']
|
||||
|
@ -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(),
|
||||
|
@ -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/'
|
||||
|
@ -5,7 +5,7 @@
|
||||
<section>
|
||||
<div class="card pb-3 pt-4 px-4 mb-3 ext-detail-download-danger">
|
||||
<h3>⚠ {% 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 #}
|
||||
|
@ -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',
|
||||
)
|
||||
|
@ -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)
|
||||
|
@ -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')),
|
||||
|
Loading…
Reference in New Issue
Block a user