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_created', models.DateTimeField(auto_now_add=True)),
('date_modified', models.DateTimeField(auto_now=True)), ('date_modified', models.DateTimeField(auto_now=True)),
('date_deleted', models.DateTimeField(blank=True, editable=False, null=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)), ('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')), ('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)), ('status', models.PositiveSmallIntegerField(choices=[(1, 'Untriaged'), (2, 'Valid'), (3, 'Suspicious')], default=1)),
], ],
options={ options={

View File

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

View File

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

View File

@ -1,36 +1,16 @@
from django.core.exceptions import ValidationError 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 from semantic_version import Version
import json
class VersionStringField(SemanticVersionField): def validate_version_string(version_string):
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: try:
return str(Version(value)) Version.parse(version_string)
except Exception as e: except ValueError as e:
raise ValidationError(e) raise ValidationError(e)
def from_db_value(self, value, expression, connection):
return self.to_python(value)
def get_prep_value(self, value): class VersionStringField(CharField):
if value is None: description = "A field to store serializable semantic versions"
return value
return str(value)
def value_to_string(self, obj): default_validators = [validate_version_string]
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))

View File

@ -87,9 +87,9 @@ class Migration(migrations.Migration):
('date_created', models.DateTimeField(auto_now_add=True)), ('date_created', models.DateTimeField(auto_now_add=True)),
('date_modified', models.DateTimeField(auto_now=True)), ('date_modified', models.DateTimeField(auto_now=True)),
('date_deleted', models.DateTimeField(blank=True, editable=False, null=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)), ('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(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_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(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)), ('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')), ('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)), ('average_score', models.FloatField(default=0, max_length=255)),
('download_count', models.PositiveIntegerField(default=0)), ('download_count', models.PositiveIntegerField(default=0)),

View File

@ -14,6 +14,6 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='version', model_name='version',
name='schema_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( migrations.AlterField(
model_name='version', model_name='version',
name='blender_version_max', 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( migrations.AlterField(
model_name='version', model_name='version',

View File

@ -85,10 +85,6 @@ class ExtensionManager(models.Manager):
is_listed=True, is_listed=True,
) )
@property
def unlisted(self):
return self.exclude(status=self.model.STATUSES.APPROVED)
@property @property
def blocklisted(self): def blocklisted(self):
return self.filter(status=self.model.STATUSES.BLOCKLISTED) return self.filter(status=self.model.STATUSES.BLOCKLISTED)
@ -222,6 +218,8 @@ class Extension(CreatedModifiedMixin, TrackChangesMixin, models.Model):
tags = fields.pop('tags', []) tags = fields.pop('tags', [])
version = Version(**fields, extension=self, release_notes=release_notes) version = Version(**fields, extension=self, release_notes=release_notes)
with transaction.atomic(): with transaction.atomic():
# make sure to validate all fields passed from manifest using Version validators
version.full_clean()
version.save() version.save()
version.files.add(file) version.files.add(file)
version.set_initial_licenses(licenses) version.set_initial_licenses(licenses)
@ -374,18 +372,6 @@ class Extension(CreatedModifiedMixin, TrackChangesMixin, models.Model):
return [FILE_STATUS_CHOICES.APPROVED] return [FILE_STATUS_CHOICES.APPROVED]
return [FILE_STATUS_CHOICES.AWAITING_REVIEW, 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 @property
def is_approved(self) -> bool: def is_approved(self) -> bool:
return self.status == self.STATUSES.APPROVED 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) 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: def has_maintainer(self, user) -> bool:
"""Return True if given user is listed as a maintainer or is a member of the team.""" """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: if user is None or user.is_anonymous:
@ -438,7 +417,7 @@ class Extension(CreatedModifiedMixin, TrackChangesMixin, models.Model):
files = [] files = []
for version in self.versions.all(): for version in self.versions.all():
for file in version.files.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) files.append(file)
return files return files
@ -549,11 +528,7 @@ class Tag(CreatedModifiedMixin, models.Model):
class VersionManager(models.Manager): class VersionManager(models.Manager):
@property @property
def listed(self): def listed(self):
return self.filter(files__status=FILE_STATUS_CHOICES.APPROVED) return self.filter(files__status=FILE_STATUS_CHOICES.APPROVED).distinct()
@property
def unlisted(self):
return self.exclude(files__status=FILE_STATUS_CHOICES.APPROVED)
class Version(CreatedModifiedMixin, TrackChangesMixin, models.Model): 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): def set_initial_permissions(self, _permissions):
if not _permissions: if not _permissions:
return return
@ -785,7 +757,7 @@ class Version(CreatedModifiedMixin, TrackChangesMixin, models.Model):
permissions.append({'slug': slug, 'reason': reason, 'name': all_permission_names[slug]}) permissions.append({'slug': slug, 'reason': reason, 'name': all_permission_names[slug]})
return permissions return permissions
def _get_download_name(self, file) -> str: def get_download_name(self, file) -> str:
"""Return a file name for downloads.""" """Return a file name for downloads."""
parts = [self.extension.type_slug_singular, self.extension.slug, f'v{self.version}'] parts = [self.extension.type_slug_singular, self.extension.slug, f'v{self.version}']
if platforms := file.get_platforms(): if platforms := file.get_platforms():
@ -793,7 +765,7 @@ class Version(CreatedModifiedMixin, TrackChangesMixin, models.Model):
return f'{"-".join(parts)}.zip' return f'{"-".join(parts)}.zip'
def get_download_url(self, file, append_repository_and_compatibility=True) -> str: 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( download_url = reverse(
'extensions:download', 'extensions:download',
kwargs={ kwargs={
@ -820,7 +792,7 @@ class Version(CreatedModifiedMixin, TrackChangesMixin, models.Model):
file = files[0] file = files[0]
return [ return [
{ {
'name': self._get_download_name(file), 'name': self.get_download_name(file),
'size': file.size_bytes, 'size': file.size_bytes,
'url': self.get_download_url(file), '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()} all_platforms_by_slug = {p.slug: p for p in Platform.objects.all()}
return [ return [
{ {
'name': self._get_download_name(file), 'name': self.get_download_name(file),
'platform': all_platforms_by_slug.get(platform_slug), 'platform': all_platforms_by_slug.get(platform_slug),
'size': file.size_bytes, 'size': file.size_bytes,
'url': self.get_download_url(file), 'url': self.get_download_url(file),
@ -852,7 +824,7 @@ class Version(CreatedModifiedMixin, TrackChangesMixin, models.Model):
platforms = file.get_platforms() or [] platforms = file.get_platforms() or []
build_list.append( build_list.append(
{ {
'name': self._get_download_name(file), 'name': self.get_download_name(file),
'platforms': platforms, 'platforms': platforms,
'size': file.size_bytes, 'size': file.size_bytes,
'url': self.get_download_url(file), 'url': self.get_download_url(file),

View File

@ -64,21 +64,28 @@ class ResponseFormatTest(APITestCase):
json = response.json() json = response.json()
self.assertEqual(len(json['data']), 3) self.assertEqual(len(json['data']), 3)
for v in json['data']: 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('name', v)
self.assertIn('tagline', v) self.assertIn('tagline', v)
self.assertIn('version', v) self.assertIn('version', v)
self.assertIn('type', v) self.assertIn('type', v)
self.assertIn('archive_size', v) self.assertIn('archive_size', v)
self.assertIn('archive_hash', v)
self.assertIn('archive_url', v)
self.assertIn('blender_version_min', v) self.assertIn('blender_version_min', v)
self.assertIn('maintainer', v) self.assertIn('maintainer', v)
self.assertIn('license', v) self.assertIn('license', v)
self.assertIn('website', v)
self.assertIn('schema_version', v) self.assertIn('schema_version', v)
# Blender expects urls in HTML anchors to end with .zip to handle drag&drop # 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'][-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): def test_maintaner_is_team(self):
version = create_approved_version(metadata__blender_version_min='4.0.1') version = create_approved_version(metadata__blender_version_min='4.0.1')

View File

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

View File

@ -19,10 +19,6 @@ class FileManager(models.Manager):
def listed(self): def listed(self):
return self.filter(status=self.model.STATUSES.APPROVED) 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): def file_upload_to(instance, filename):
prefix = 'files/' prefix = 'files/'

View File

@ -5,7 +5,7 @@
<section> <section>
<div class="card pb-3 pt-4 px-4 mb-3 ext-detail-download-danger"> <div class="card pb-3 pt-4 px-4 mb-3 ext-detail-download-danger">
<h3>&nbsp;{% trans "Suspicious upload" %}</h3> <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> <h4>
{{ alert_text }} {{ alert_text }}
{% if perms.files.view_file %}{# Moderators don't necessarily have access to the admin #} {% 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( self.assertEqual(
validate_wheels(test_file_path, ['wheels/1.whl']).get('wheels/1.whl'), validate_wheels(test_file_path, ['wheels/1.whl']).get('wheels/1.whl'),
'digest in archive=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' 'sha256 in archive=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'
', digest on pypi=blahblah', ', sha256 on pypi=blahblah',
) )

View File

@ -18,10 +18,6 @@ class RatingManager(models.Manager):
def listed(self): def listed(self):
return self.filter(status=self.model.STATUSES.APPROVED) return self.filter(status=self.model.STATUSES.APPROVED)
@property
def unlisted(self):
return self.exclude(status=self.models.STATUSES.APPROVED)
@property @property
def listed_texts(self): def listed_texts(self):
return self.listed.filter(text__isnull=False) 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')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date_released_on', models.DateField(verbose_name='Released on')), ('date_released_on', models.DateField(verbose_name='Released on')),
('date_supported_until', models.DateField(verbose_name='Supported until')), ('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_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')), ('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')), ('release_notes_url', models.URLField(verbose_name='Release notes URL')),