Tags: Make tags dependent on type, and remove taggit #43

Manually merged
Dalai Felinto merged 1 commits from dfelinto/extensions-website:tags into main 2024-02-29 15:53:31 +01:00
20 changed files with 264 additions and 258 deletions

View File

@ -1,13 +1,13 @@
import random
from django.core.management.base import BaseCommand
from taggit.models import Tag
from common.tests.factories.extensions import create_approved_version, create_version
from common.tests.factories.files import FileFactory
from common.tests.factories.teams import TeamFactory
from files.models import File
from constants.version_permissions import VERSION_PERMISSION_FILE, VERSION_PERMISSION_NETWORK
from extensions.models import Extension, Tag
FILE_SOURCES = {
"blender-kitsu": {
@ -59,14 +59,18 @@ class Command(BaseCommand):
help = 'Generate fake data with extensions, users and versions using test factories.'
def handle(self, *args, **options):
tags = list(Tag.objects.values_list('name', flat=True))
tags = {
type_id: list(Tag.objects.filter(type=type_id).values_list('name', flat=True))
for type_id, _ in Extension.TYPES
}
# Create a fixed example
example_version = create_approved_version(
file__status=File.STATUSES.APPROVED,
# extension__status=Extension.STATUSES.APPROVED,
extension__name='Blender Kitsu',
extension__extension_id='blender_kitsu',
tags=['Generic'],
tags=['Development'],
extension__description=EXAMPLE_DESCRIPTION,
extension__support='https://developer.blender.org/',
extension__website='https://studio.blender.org/',
@ -93,11 +97,12 @@ class Command(BaseCommand):
# Create a few publicly listed extensions
for i in range(10):
extension__type = random.choice(Extension.TYPES)[0]
create_approved_version(
file__status=File.STATUSES.APPROVED,
# extension__status=Extension.STATUSES.APPROVED,
extension__type=random.choice((File.TYPES.BPY, File.TYPES.THEME)),
tags=random.sample(tags, k=1),
extension__type=extension__type,
tags=random.sample(tags[extension__type], k=1),
extension__previews=[
FileFactory(
type=File.TYPES.IMAGE,
@ -112,11 +117,12 @@ class Command(BaseCommand):
# Create a few unlisted extension versions
for i in range(5):
extension__type = random.choice(Extension.TYPES)[0]
create_version(
file__status=random.choice(
(File.STATUSES.DISABLED, File.STATUSES.DISABLED_BY_AUTHOR)
),
tags=random.sample(tags, k=1),
tags=random.sample(tags[extension__type], k=1),
)
example_version.extension.average_score = 5.0

View File

@ -10,12 +10,12 @@ from django.template.defaultfilters import stringfilter
from django.utils.safestring import mark_safe
from markupsafe import Markup
from taggit.models import Tag
from common.markdown import (
render as render_markdown,
render_as_text as render_markdown_as_text,
)
from extensions.models import Tag
import utils

View File

@ -6,7 +6,7 @@ from mdgen import MarkdownPostProvider
import factory
import factory.fuzzy
from extensions.models import Extension, Version
from extensions.models import Extension, Version, Tag
from ratings.models import Rating
fake_markdown = Faker()
@ -77,8 +77,11 @@ class VersionFactory(DjangoModelFactory):
if not create:
return
if extracted:
self.tags.add(*extracted)
if not extracted:
return
tags = Tag.objects.filter(name__in=extracted)
self.tags.add(*tags)
def create_version(**kwargs) -> 'Version':

View File

@ -1,202 +0,0 @@
[
{
"model": "taggit.tag",
"pk": 1,
"fields": {
"name": "3D View",
"slug": "3d-view"
}
},
{
"model": "taggit.tag",
"pk": 2,
"fields": {
"name": "Add Mesh",
"slug": "add-mesh"
}
},
{
"model": "taggit.tag",
"pk": 3,
"fields": {
"name": "Add Curve",
"slug": "add-curve"
}
},
{
"model": "taggit.tag",
"pk": 4,
"fields": {
"name": "Animation",
"slug": "animation"
}
},
{
"model": "taggit.tag",
"pk": 5,
"fields": {
"name": "Compositing",
"slug": "compositing"
}
},
{
"model": "taggit.tag",
"pk": 6,
"fields": {
"name": "Development",
"slug": "development"
}
},
{
"model": "taggit.tag",
"pk": 7,
"fields": {
"name": "Game Engine",
"slug": "game-engine"
}
},
{
"model": "taggit.tag",
"pk": 8,
"fields": {
"name": "Import-Export",
"slug": "import-export"
}
},
{
"model": "taggit.tag",
"pk": 9,
"fields": {
"name": "Lighting",
"slug": "lighting"
}
},
{
"model": "taggit.tag",
"pk": 10,
"fields": {
"name": "Material",
"slug": "material"
}
},
{
"model": "taggit.tag",
"pk": 11,
"fields": {
"name": "Mesh",
"slug": "mesh"
}
},
{
"model": "taggit.tag",
"pk": 12,
"fields": {
"name": "Node",
"slug": "node"
}
},
{
"model": "taggit.tag",
"pk": 13,
"fields": {
"name": "Object",
"slug": "object"
}
},
{
"model": "taggit.tag",
"pk": 14,
"fields": {
"name": "Paint",
"slug": "paint"
}
},
{
"model": "taggit.tag",
"pk": 15,
"fields": {
"name": "Physics",
"slug": "physics"
}
},
{
"model": "taggit.tag",
"pk": 16,
"fields": {
"name": "Render",
"slug": "render"
}
},
{
"model": "taggit.tag",
"pk": 17,
"fields": {
"name": "Rigging",
"slug": "rigging"
}
},
{
"model": "taggit.tag",
"pk": 18,
"fields": {
"name": "Scene",
"slug": "scene"
}
},
{
"model": "taggit.tag",
"pk": 19,
"fields": {
"name": "Sequencer",
"slug": "sequencer"
}
},
{
"model": "taggit.tag",
"pk": 20,
"fields": {
"name": "System",
"slug": "system"
}
},
{
"model": "taggit.tag",
"pk": 21,
"fields": {
"name": "Text Editor",
"slug": "text-editor"
}
},
{
"model": "taggit.tag",
"pk": 22,
"fields": {
"name": "UV",
"slug": "uv"
}
},
{
"model": "taggit.tag",
"pk": 23,
"fields": {
"name": "User Interface",
"slug": "user-interface"
}
},
{
"model": "taggit.tag",
"pk": 24,
"fields": {
"name": "Modeling",
"slug": "modeling"
}
},
{
"model": "taggit.tag",
"pk": 25,
"fields": {
"name": "Pipeline",
"slug": "pipeline"
}
}
]

View File

@ -0,0 +1,125 @@
# Generated by Django 4.0.6 on 2024-02-27 17:34
from django.db import migrations, models
from taggit.models import TaggedItem
import logging
from constants.base import EXTENSION_TYPE_CHOICES
from utils import slugify
logger = logging.getLogger(__name__)
themes_tags = (
'Dark',
'Light',
'Print',
'Accessibility',
'High Contrast',
)
addons_tags = (
'3D View',
'Add Mesh',
'Add Curve',
'Animation',
'Compositing',
'Development',
'Game Engine',
'Import-Export',
'Lighting',
'Material',
'Modeling',
'Mesh',
'Node',
'Object',
'Paint',
'Pipeline',
'Physics',
'Render',
'Rigging',
'Scene',
'Sequencer',
'System',
'Text Editor',
'UV',
'User Interface',
)
def populate_tags_database(apps, schema_editor):
Tag = apps.get_model("extensions", "Tag")
for tag_name in themes_tags:
tag = Tag(
name=tag_name,
slug=slugify(tag_name),
type=EXTENSION_TYPE_CHOICES.THEME,
)
tag.save()
for tag_name in addons_tags:
tag = Tag(
name=tag_name,
slug=slugify(tag_name),
type=EXTENSION_TYPE_CHOICES.BPY,
)
tag.save()
def migrate_tags(apps, schema_editor):
model = apps.get_model('extensions', 'version')
Tag = apps.get_model("extensions", "Tag")
TagsOld = apps.get_model('taggit', 'Tag')
all_tags = TagsOld.objects.all()
for tag_old in all_tags:
for item in tag_old.taggit_taggeditem_items.all():
version = model.objects.filter(id=item.object_id).first()
tag = Tag.objects.filter(name=tag_old.name)
if not tag:
logger.warning(f'Tag not supported anymore: {tag_old.name}')
continue
version.tags.add(tag.first())
version.save()
class Migration(migrations.Migration):
dependencies = [
('extensions', '0023_apply_new_licenses'),
]
operations = [
migrations.RenameField(
model_name='version',
old_name='tags',
new_name='tags_old',
),
migrations.CreateModel(
name='Tag',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date_created', models.DateTimeField(auto_now_add=True)),
('date_modified', models.DateTimeField(auto_now=True)),
('name', models.CharField(max_length=128)),
('slug', models.SlugField(blank=False, null=False)),
('type', models.PositiveSmallIntegerField(choices=[(1, 'Add-on'), (2, 'Theme')], editable=False, null=False, blank=False)),
],
options={
'abstract': False,
'unique_together': {('name', 'type')}
},
),
migrations.RunPython(populate_tags_database),
migrations.AddField(
model_name='version',
name='tags',
field=models.ManyToManyField(blank=True, related_name='versions', to='extensions.tag'),
),
migrations.RunPython(migrate_tags),
migrations.RemoveField(
model_name='version',
name='tags_old',
),
]

View File

@ -8,8 +8,6 @@ from django.core.exceptions import ObjectDoesNotExist, BadRequest, ValidationErr
from django.db import models, transaction
from django.db.models import F, Q, Count
from django.urls import reverse
from taggit.managers import TaggableManager
from taggit.models import Tag
from common.fields import FilterableManyToManyField
from common.model_mixins import CreatedModifiedMixin, TrackChangesMixin, SoftDeleteMixin
@ -398,6 +396,20 @@ class VersionPermission(CreatedModifiedMixin, models.Model):
return cls.objects.filter(slug__startswith=slug).first()
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=False, null=False, blank=False)
class Meta:
unique_together = ('name', 'type')
def __str__(self) -> str:
return f'{self.name}'
class VersionManager(models.Manager):
@property
def exclude_deleted(self):
@ -476,7 +488,7 @@ class Version(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, SoftDeleteMi
)
licenses = models.ManyToManyField(License, related_name='versions', blank=False)
tags = TaggableManager()
tags = models.ManyToManyField(Tag, related_name='versions', blank=True)
schema_version = extensions.fields.VersionStringField(
max_length=64,
@ -542,8 +554,9 @@ class Version(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, SoftDeleteMi
return
for tag_name in _tags:
tag = Tag.objects.filter(name__startswith=tag_name).first()
if not tag:
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)

View File

@ -5,13 +5,14 @@
{% block content %}
<div class="row">
{% if tags %}
<div class="col-md-2">
<aside class="is-sticky pt-3">
<div class="list-filters">
<h3>Tags</h3>
<ul>
{% for list_tag in all_tags %}
{% if list_tag.taggit_taggeditem_items.all|length %}
{% for list_tag in tags %}
{% if list_tag.versions.all|length %}
<li class="{% if tag == list_tag %}is-active{% endif %}">
<a href="{% url "extensions:by-tag" tag_slug=list_tag.slug %}" title="{{ list_tag.name }}">
{{ list_tag.name }}
@ -23,8 +24,9 @@
</div>
</aside>
</div>
{% endif %}
<div class="col-md-10 my-4">
<div class="col-md-{% if tags %}10{% else %}12{% endif %} my-4">
{% if author %}
<h2>{% blocktranslate %}Extensions by{% endblocktranslate %} <em class="search-highlight">{{ author }}</em></h2>
{% endif %}

View File

@ -4,7 +4,7 @@ from common.tests.factories.extensions import create_version
class ApproveExtensionTest(TestCase):
fixtures = ['tags', 'licenses']
fixtures = ['licenses']
def test_approve_extension(self): # TODO
create_version().extension

View File

@ -10,7 +10,7 @@ from constants.licenses import LICENSE_GPL3
from constants.base import EXTENSION_TYPE_CHOICES, EXTENSION_TYPE_SLUGS_SINGULAR
from extensions.models import Extension, Version, VersionPermission
from files.models import File
from files.validators import ManifestValidator
from files.validators import ManifestValidator, TagsAddonsValidator, TagsThemesValidator
import tempfile
import shutil
@ -236,7 +236,7 @@ class ValidateManifestTest(CreateFileTest):
class ValidateManifestFields(TestCase):
fixtures = ['licenses', 'version_permissions', 'tags']
fixtures = ['licenses', 'version_permissions']
def setUp(self):
self.mandatory_fields = {
@ -369,11 +369,12 @@ class ValidateManifestFields(TestCase):
ManifestValidator(data)
self.assertEqual(1, len(e.exception.messages))
def test_tags(self):
def test_tags_addons(self):
data = {
**self.mandatory_fields,
**self.optional_fields,
}
data['type'] = 'add-on'
data['tags'] = ['Render']
ManifestValidator(data)
@ -382,9 +383,36 @@ class ValidateManifestFields(TestCase):
with self.assertRaises(ValidationError) as e:
ManifestValidator(data)
message_begin = "Manifest value error: tags expects a list of supported tags, e.g.: ['Animation', 'Sequencer']. Visit"
message_begin = "Manifest value error: tags expects a list of supported add-on tags, e.g.: ['Animation', 'Sequencer']. Visit"
self.assertIn(message_begin, e.exception.messages[0])
data['tags'] = ['Dark']
with self.assertRaises(ValidationError) as e:
ManifestValidator(data)
self.assertEqual(1, len(e.exception.messages))
def test_tags_themes(self):
data = {
**self.mandatory_fields,
**self.optional_fields,
}
data['type'] = 'theme'
data['tags'] = ['Light']
ManifestValidator(data)
data['tags'] = ['UnsupportedTag']
with self.assertRaises(ValidationError) as e:
ManifestValidator(data)
message_begin = "Manifest value error: tags expects a list of supported theme tags, e.g.: ['Dark', 'Accessibility']. Visit"
self.assertIn(message_begin, e.exception.messages[0])
data['tags'] = ['Render']
with self.assertRaises(ValidationError) as e:
ManifestValidator(data)
self.assertEqual(1, len(e.exception.messages))
def test_permissions(self):
data = {
**self.mandatory_fields,
@ -457,9 +485,11 @@ class ValidateManifestFields(TestCase):
# Good cops.
data['type'] = EXTENSION_TYPE_SLUGS_SINGULAR[EXTENSION_TYPE_CHOICES.BPY]
data['tags'] = TagsAddonsValidator.example
ManifestValidator(data)
data['type'] = EXTENSION_TYPE_SLUGS_SINGULAR[EXTENSION_TYPE_CHOICES.THEME]
data['tags'] = TagsThemesValidator.example
ManifestValidator(data)
# Bad cops.

View File

@ -11,7 +11,7 @@ from extensions.models import Extension
class ExtensionTest(TestCase):
maxDiff = None
fixtures = ['dev', 'tags', 'licenses']
fixtures = ['dev', 'licenses']
def setUp(self):
super().setUp()
@ -79,7 +79,7 @@ class ExtensionTest(TestCase):
class VersionTest(TestCase):
maxDiff = None
fixtures = ['dev', 'tags', 'licenses']
fixtures = ['dev', 'licenses']
def setUp(self):
super().setUp()

View File

@ -63,7 +63,7 @@ EXPECTED_EXTENSION_DATA = {
class SubmitFileTest(TestCase):
maxDiff = None
fixtures = ['tags', 'licenses']
fixtures = ['licenses']
url = reverse_lazy('extensions:submit')
def _test_submit_addon(
@ -211,7 +211,7 @@ for file_name, data in EXPECTED_EXTENSION_DATA.items():
class SubmitFinaliseTest(TestCase):
maxDiff = None
fixtures = ['tags', 'licenses']
fixtures = ['licenses']
def setUp(self):
super().setUp()
@ -322,7 +322,7 @@ class SubmitFinaliseTest(TestCase):
class NewVersionTest(TestCase):
fixtures = ['tags', 'licenses']
fixtures = ['licenses']
def setUp(self):
self.version = create_version(extension__extension_id='amaranth')

View File

@ -34,7 +34,7 @@ POST_DATA = {
class UpdateTest(TestCase):
fixtures = ['dev', 'tags', 'licenses']
fixtures = ['dev', 'licenses']
def test_get_manage_page(self):
extension = create_approved_version().extension

View File

@ -21,7 +21,7 @@ def _create_extension():
class _BaseTestCase(TestCase):
fixtures = ['dev', 'tags', 'licenses']
fixtures = ['dev', 'licenses']
def _check_detail_page(self, response, extension):
pass

View File

@ -6,9 +6,7 @@ from django.shortcuts import get_object_or_404, redirect
from django.views.generic.list import ListView
from taggit.models import Tag
from extensions.models import Extension, Version
from extensions.models import Extension, Version, Tag
from constants.base import (
EXTENSION_TYPE_SLUGS,
EXTENSION_TYPE_PLURAL,
@ -98,7 +96,6 @@ class SearchView(ListedExtensionsView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['all_tags'] = Tag.objects.all()
if self.kwargs.get('user_id'):
context['author'] = get_object_or_404(User, pk=self.kwargs['user_id'])
@ -108,4 +105,14 @@ class SearchView(ListedExtensionsView):
context['type'] = self._get_type_by_slug()
if self.kwargs.get('team_slug'):
context['team'] = get_object_or_404(teams.models.Team, slug=self.kwargs['team_slug'])
# Determine which tags to list depending on the context.
if context.get('type'):
tag_type_id = self._get_type_id_by_slug()
context['tags'] = Tag.objects.filter(type=tag_type_id)
elif context.get('tag'):
tag_type_id = context['tag'].type
context['tags'] = Tag.objects.filter(type=tag_type_id)
else:
pass
return context

View File

@ -8,8 +8,6 @@ from django.contrib.auth import get_user_model
from django.db import models
from django.urls import reverse
# from taggit.models import Tag
from common.model_mixins import CreatedModifiedMixin, TrackChangesMixin, SoftDeleteMixin
from files.utils import get_sha256
from constants.base import (

View File

@ -13,7 +13,7 @@ User = get_user_model()
class FileTest(TestCase):
maxDiff = None
fixtures = ['dev', 'tags', 'licenses']
fixtures = ['dev', 'licenses']
def setUp(self):
super().setUp()

View File

@ -5,10 +5,9 @@ import logging
from django.utils.translation import gettext_lazy as _
from django.core.exceptions import ValidationError
from django.core.validators import FileExtensionValidator, validate_unicode_slug
from taggit.models import Tag
from extensions.models import Extension, License, VersionPermission
from constants.base import EXTENSION_TYPE_SLUGS_SINGULAR
from extensions.models import Extension, License, VersionPermission, Tag
from constants.base import EXTENSION_TYPE_SLUGS_SINGULAR, EXTENSION_TYPE_CHOICES
logger = logging.getLogger(__name__)
@ -176,40 +175,65 @@ class LicenseValidator(ListValidator):
return error_message
class TagsValidator:
example = ['Animation', 'Sequencer']
class TagsValidatorBase:
@classmethod
def validate(cls, *, name: str, value: list[str], manifest: dict) -> str:
"""Return error message if there is no tag, or if tag is not a valid one"""
is_error = False
type_name = EXTENSION_TYPE_SLUGS_SINGULAR[cls.type]
if type(value) != list:
is_error = True
else:
for tag in value:
if Tag.objects.filter(name=tag):
if Tag.objects.filter(name=tag, type=cls.type):
continue
is_error = True
logger.info(f'Tag unavailable: {tag}')
for tag in value:
if Tag.objects.filter(name=tag):
continue
is_error = True
break
type_slug = manifest.get('type')
logger.info(f'Tag unavailable for {type_slug}: {tag}')
if not is_error:
return
error_message = (
f'Manifest value error: {name} expects a list of supported tags, e.g.: {cls.example}. '
f'Manifest value error: {name} expects a list of supported {type_name} '
f'tags, e.g.: {cls.example}. '
'Visit https://docs.blender.org/manual/en/dev/extensions/tags.html to learn more.'
)
return error_message
class TagsAddonsValidator(TagsValidatorBase):
example = ['Animation', 'Sequencer']
type = EXTENSION_TYPE_CHOICES.BPY
class TagsThemesValidator(TagsValidatorBase):
example = ['Dark', 'Accessibility']
type = EXTENSION_TYPE_CHOICES.THEME
class TagsValidator:
example = ['User Interface']
@classmethod
def validate(cls, *, name: str, value: list[str], manifest: dict) -> str:
"""Return error message if there is no tag, or if tag is not a valid one"""
tags_lookup = {
EXTENSION_TYPE_SLUGS_SINGULAR[EXTENSION_TYPE_CHOICES.BPY]: TagsAddonsValidator,
EXTENSION_TYPE_SLUGS_SINGULAR[EXTENSION_TYPE_CHOICES.THEME]: TagsThemesValidator,
}
type_slug = manifest.get('type')
meta_class = tags_lookup.get(type_slug)
if meta_class is None:
return
return meta_class.validate(name=name, value=value, manifest=manifest)
class TypeValidator:
example = 'add-on'

View File

@ -6,7 +6,7 @@ from ratings.models import Rating
class RatingsViewTest(TestCase):
fixtures = ['dev', 'tags', 'licenses']
fixtures = ['dev', 'licenses']
def test_get_anonymous(self):
version = create_approved_version(ratings=[])
@ -76,7 +76,7 @@ class RatingsViewTest(TestCase):
class AddRatingViewTest(TestCase):
fixtures = ['dev', 'tags', 'licenses']
fixtures = ['dev', 'licenses']
def test_get_anonymous_redirects_to_login(self):
version = create_approved_version(ratings=[])

View File

@ -10,7 +10,7 @@ from stats.models import ExtensionView, ExtensionDownload, ExtensionCountedStat
class WriteStatsCommandTest(TestCase):
fixtures = ['dev', 'tags', 'licenses']
fixtures = ['dev', 'licenses']
def test_command_updates_extensions_view_counters(self):
out = StringIO()

View File

@ -12,7 +12,7 @@ User = get_user_model()
class TestTasks(TestCase):
fixtures = ['dev', 'licenses', 'tags']
fixtures = ['dev', 'licenses']
def test_handle_deletion_request_anonymized(self):
now = timezone.now()