Closes #17841 Allows Tags to be displayed in specified order (#18930)

This commit is contained in:
Jason Novinger 2025-03-19 12:17:35 -05:00 committed by GitHub
parent d25605c261
commit 6b7d23d684
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 110 additions and 18 deletions

View File

@ -16,6 +16,12 @@ A unique URL-friendly identifier. (This value will be used for filtering.) This
The color to use when displaying the tag in the NetBox UI. The color to use when displaying the tag in the NetBox UI.
### Weight
A numeric weight employed to influence the ordering of tags. Tags with a lower weight will be listed before those with higher weights. Values must be within the range **0** to **32767**.
!!! info "This field was introduced in NetBox v4.3."
### Object Types ### Object Types
The assignment of a tag may be limited to a prescribed set of objects. For example, it may be desirable to limit the application of a specific tag to only devices and virtual machines. The assignment of a tag may be limited to a prescribed set of objects. For example, it may be desirable to limit the application of a specific tag to only devices and virtual machines.

View File

@ -27,8 +27,8 @@ class TagSerializer(ValidatedModelSerializer):
class Meta: class Meta:
model = Tag model = Tag
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'object_types', 'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'weight',
'tagged_items', 'created', 'last_updated', 'object_types', 'tagged_items', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'color', 'description') brief_fields = ('id', 'url', 'display', 'name', 'slug', 'color', 'description')

View File

@ -450,7 +450,7 @@ class TagFilterSet(ChangeLoggedModelFilterSet):
class Meta: class Meta:
model = Tag model = Tag
fields = ('id', 'name', 'slug', 'color', 'description', 'object_types') fields = ('id', 'name', 'slug', 'color', 'weight', 'description', 'object_types')
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():

View File

@ -275,6 +275,10 @@ class TagBulkEditForm(BulkEditForm):
max_length=200, max_length=200,
required=False required=False
) )
weight = forms.IntegerField(
label=_('Weight'),
required=False
)
nullable_fields = ('description',) nullable_fields = ('description',)

View File

@ -232,10 +232,14 @@ class EventRuleImportForm(NetBoxModelImportForm):
class TagImportForm(CSVModelForm): class TagImportForm(CSVModelForm):
slug = SlugField() slug = SlugField()
weight = forms.IntegerField(
label=_('Weight'),
required=False
)
class Meta: class Meta:
model = Tag model = Tag
fields = ('name', 'slug', 'color', 'description') fields = ('name', 'slug', 'color', 'weight', 'description')
class JournalEntryImportForm(NetBoxModelImportForm): class JournalEntryImportForm(NetBoxModelImportForm):

View File

@ -490,15 +490,19 @@ class TagForm(forms.ModelForm):
queryset=ObjectType.objects.with_feature('tags'), queryset=ObjectType.objects.with_feature('tags'),
required=False required=False
) )
weight = forms.IntegerField(
label=_('Weight'),
required=False
)
fieldsets = ( fieldsets = (
FieldSet('name', 'slug', 'color', 'description', 'object_types', name=_('Tag')), FieldSet('name', 'slug', 'color', 'weight', 'description', 'object_types', name=_('Tag')),
) )
class Meta: class Meta:
model = Tag model = Tag
fields = [ fields = [
'name', 'slug', 'color', 'description', 'object_types', 'name', 'slug', 'color', 'weight', 'description', 'object_types',
] ]

View File

@ -0,0 +1,22 @@
# Generated by Django 5.2b1 on 2025-03-17 14:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0123_remove_staging'),
]
operations = [
migrations.AlterModelOptions(
name='tag',
options={'ordering': ('weight', 'name')},
),
migrations.AddField(
model_name='tag',
name='weight',
field=models.PositiveSmallIntegerField(default=0),
),
]

View File

@ -40,13 +40,17 @@ class Tag(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel, TagBase):
blank=True, blank=True,
help_text=_("The object type(s) to which this tag can be applied.") help_text=_("The object type(s) to which this tag can be applied.")
) )
weight = models.PositiveSmallIntegerField(
verbose_name=_('weight'),
default=0,
)
clone_fields = ( clone_fields = (
'color', 'description', 'object_types', 'color', 'description', 'object_types',
) )
class Meta: class Meta:
ordering = ['name'] ordering = ('weight', 'name')
verbose_name = _('tag') verbose_name = _('tag')
verbose_name_plural = _('tags') verbose_name_plural = _('tags')

View File

@ -449,8 +449,8 @@ class TagTable(NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = Tag model = Tag
fields = ( fields = (
'pk', 'id', 'name', 'items', 'slug', 'color', 'description', 'object_types', 'created', 'last_updated', 'pk', 'id', 'name', 'items', 'slug', 'color', 'weight', 'description', 'object_types',
'actions', 'created', 'last_updated', 'actions',
) )
default_columns = ('pk', 'name', 'items', 'slug', 'color', 'description') default_columns = ('pk', 'name', 'items', 'slug', 'color', 'description')

View File

@ -513,6 +513,7 @@ class TagTest(APIViewTestCases.APIViewTestCase):
{ {
'name': 'Tag 4', 'name': 'Tag 4',
'slug': 'tag-4', 'slug': 'tag-4',
'weight': 1000,
}, },
{ {
'name': 'Tag 5', 'name': 'Tag 5',
@ -533,7 +534,7 @@ class TagTest(APIViewTestCases.APIViewTestCase):
tags = ( tags = (
Tag(name='Tag 1', slug='tag-1'), Tag(name='Tag 1', slug='tag-1'),
Tag(name='Tag 2', slug='tag-2'), Tag(name='Tag 2', slug='tag-2'),
Tag(name='Tag 3', slug='tag-3'), Tag(name='Tag 3', slug='tag-3', weight=26),
) )
Tag.objects.bulk_create(tags) Tag.objects.bulk_create(tags)

View File

@ -1196,7 +1196,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
tags = ( tags = (
Tag(name='Tag 1', slug='tag-1', color='ff0000', description='foobar1'), Tag(name='Tag 1', slug='tag-1', color='ff0000', description='foobar1'),
Tag(name='Tag 2', slug='tag-2', color='00ff00', description='foobar2'), Tag(name='Tag 2', slug='tag-2', color='00ff00', description='foobar2'),
Tag(name='Tag 3', slug='tag-3', color='0000ff'), Tag(name='Tag 3', slug='tag-3', color='0000ff', weight=1000),
) )
Tag.objects.bulk_create(tags) Tag.objects.bulk_create(tags)
tags[0].object_types.add(object_types['site']) tags[0].object_types.add(object_types['site'])
@ -1249,6 +1249,13 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
['Tag 2', 'Tag 3'] ['Tag 2', 'Tag 3']
) )
def test_weight(self):
params = {'weight': [1000]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'weight': [0]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
class TaggedItemFilterSetTestCase(TestCase): class TaggedItemFilterSetTestCase(TestCase):
queryset = TaggedItem.objects.all() queryset = TaggedItem.objects.all()

View File

@ -10,6 +10,40 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMac
class TagTest(TestCase): class TagTest(TestCase):
def test_default_ordering_weight_then_name_is_set(self):
Tag.objects.create(name='Tag 1', slug='tag-1', weight=100)
Tag.objects.create(name='Tag 2', slug='tag-2')
Tag.objects.create(name='Tag 3', slug='tag-3', weight=10)
Tag.objects.create(name='Tag 4', slug='tag-4', weight=10)
tags = Tag.objects.all()
self.assertEqual(tags[0].slug, 'tag-2')
self.assertEqual(tags[1].slug, 'tag-3')
self.assertEqual(tags[2].slug, 'tag-4')
self.assertEqual(tags[3].slug, 'tag-1')
def test_tag_related_manager_ordering_weight_then_name(self):
tags = [
Tag.objects.create(name='Tag 1', slug='tag-1', weight=100),
Tag.objects.create(name='Tag 2', slug='tag-2'),
Tag.objects.create(name='Tag 3', slug='tag-3', weight=10),
Tag.objects.create(name='Tag 4', slug='tag-4', weight=10),
]
site = Site.objects.create(name='Site 1')
for tag in tags:
site.tags.add(tag)
site.save()
site = Site.objects.first()
tags = site.tags.all()
self.assertEqual(tags[0].slug, 'tag-2')
self.assertEqual(tags[1].slug, 'tag-3')
self.assertEqual(tags[2].slug, 'tag-4')
self.assertEqual(tags[3].slug, 'tag-1')
def test_create_tag_unicode(self): def test_create_tag_unicode(self):
tag = Tag(name='Testing Unicode: 台灣') tag = Tag(name='Testing Unicode: 台灣')
tag.save() tag.save()

View File

@ -441,8 +441,8 @@ class TagTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
tags = ( tags = (
Tag(name='Tag 1', slug='tag-1'), Tag(name='Tag 1', slug='tag-1'),
Tag(name='Tag 2', slug='tag-2'), Tag(name='Tag 2', slug='tag-2', weight=1),
Tag(name='Tag 3', slug='tag-3'), Tag(name='Tag 3', slug='tag-3', weight=32767),
) )
Tag.objects.bulk_create(tags) Tag.objects.bulk_create(tags)
@ -451,13 +451,14 @@ class TagTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
'slug': 'tag-x', 'slug': 'tag-x',
'color': 'c0c0c0', 'color': 'c0c0c0',
'comments': 'Some comments', 'comments': 'Some comments',
'weight': 11,
} }
cls.csv_data = ( cls.csv_data = (
"name,slug,color,description", "name,slug,color,description,weight",
"Tag 4,tag-4,ff0000,Fourth tag", "Tag 4,tag-4,ff0000,Fourth tag,0",
"Tag 5,tag-5,00ff00,Fifth tag", "Tag 5,tag-5,00ff00,Fifth tag,1111",
"Tag 6,tag-6,0000ff,Sixth tag", "Tag 6,tag-6,0000ff,Sixth tag,0",
) )
cls.csv_update_data = ( cls.csv_update_data = (

View File

@ -455,7 +455,8 @@ class TagsMixin(models.Model):
which is a `TaggableManager` instance. which is a `TaggableManager` instance.
""" """
tags = TaggableManager( tags = TaggableManager(
through='extras.TaggedItem' through='extras.TaggedItem',
ordering=('weight', 'name'),
) )
class Meta: class Meta:

View File

@ -28,6 +28,10 @@
<span class="color-label" style="background-color: #{{ object.color }}">&nbsp;</span> <span class="color-label" style="background-color: #{{ object.color }}">&nbsp;</span>
</td> </td>
</tr> </tr>
<tr>
<th scope="row">{% trans "Weight" %}</th>
<td>{{ object.weight }}</td>
</tr>
<tr> <tr>
<th scope="row">{% trans "Tagged Items" %}</th> <th scope="row">{% trans "Tagged Items" %}</th>
<td> <td>