Просмотр исходного кода

initial pass on migrating to custom tag model with color and comments fields

John Anderson 7 лет назад
Родитель
Сommit
fc2bb724fa
32 измененных файлов с 524 добавлено и 63 удалено
  1. 5 0
      CHANGELOG.md
  2. 25 0
      netbox/circuits/migrations/0015_custom_tag_models.py
  3. 3 3
      netbox/circuits/models.py
  4. 85 0
      netbox/dcim/migrations/0070_custom_tag_models.py
  5. 15 15
      netbox/dcim/models.py
  6. 2 2
      netbox/extras/api/serializers.py
  7. 2 2
      netbox/extras/api/views.py
  8. 1 2
      netbox/extras/filters.py
  9. 4 4
      netbox/extras/forms.py
  10. 46 0
      netbox/extras/migrations/0017_tag_taggeditem.py
  11. 46 0
      netbox/extras/migrations/0018_rename_tag_tables.py
  12. 52 0
      netbox/extras/migrations/0019_delete_taggit_models.py
  13. 24 0
      netbox/extras/migrations/0020_add_color_comments_to_tag.py
  14. 22 0
      netbox/extras/models.py
  15. 4 4
      netbox/extras/tables.py
  16. 1 2
      netbox/extras/tests/test_api.py
  17. 1 2
      netbox/extras/tests/test_views.py
  18. 7 7
      netbox/extras/views.py
  19. 45 0
      netbox/ipam/migrations/0025_custom_tag_models.py
  20. 7 7
      netbox/ipam/models.py
  21. 12 3
      netbox/netbox/admin.py
  22. 20 0
      netbox/secrets/migrations/0006_custom_tag_models.py
  23. 2 2
      netbox/secrets/models.py
  24. 18 0
      netbox/templates/extras/tag.html
  25. 19 0
      netbox/templates/extras/tag_edit.html
  26. 3 1
      netbox/templates/utilities/templatetags/tag.html
  27. 20 0
      netbox/tenancy/migrations/0006_custom_tag_models.py
  28. 2 2
      netbox/tenancy/models.py
  29. 2 1
      netbox/utilities/filters.py
  30. 1 1
      netbox/utilities/views.py
  31. 25 0
      netbox/virtualization/migrations/0009_custom_tag_models.py
  32. 3 3
      netbox/virtualization/models.py

+ 5 - 0
CHANGELOG.md

@@ -10,6 +10,11 @@ context data may observe a performance drop when returning multiple objects. To
 Config Context is not needed, the query parameter `?exclude=config_context` may be added to the request as to remove
 the Config Context from being included in any results.
 
+## Enhancements
+
+* [#2324](https://github.com/digitalocean/netbox/issues/2324) - Add color option for tags
+* [#2791](https://github.com/digitalocean/netbox/issues/2791) - Add a comment field for tags
+
 ---
 
 v2.5.7 (FUTURE)

+ 25 - 0
netbox/circuits/migrations/0015_custom_tag_models.py

@@ -0,0 +1,25 @@
+# Generated by Django 2.1.4 on 2019-02-20 06:56
+
+from django.db import migrations
+import taggit.managers
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('circuits', '0014_circuittermination_description'),
+        ('extras', '0018_rename_tag_tables'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='circuit',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AlterField(
+            model_name='provider',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+    ]

+ 3 - 3
netbox/circuits/models.py

@@ -6,7 +6,7 @@ from taggit.managers import TaggableManager
 from dcim.constants import CONNECTION_STATUS_CHOICES, STATUS_CLASSES
 from dcim.fields import ASNField
 from dcim.models import CableTermination
-from extras.models import CustomFieldModel, ObjectChange
+from extras.models import CustomFieldModel, ObjectChange, TaggedItem
 from utilities.models import ChangeLoggedModel
 from utilities.utils import serialize_object
 from .constants import CIRCUIT_STATUS_ACTIVE, CIRCUIT_STATUS_CHOICES, TERM_SIDE_CHOICES
@@ -55,7 +55,7 @@ class Provider(ChangeLoggedModel, CustomFieldModel):
         object_id_field='obj_id'
     )
 
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
     csv_headers = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']
 
@@ -165,7 +165,7 @@ class Circuit(ChangeLoggedModel, CustomFieldModel):
         object_id_field='obj_id'
     )
 
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
     csv_headers = [
         'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',

+ 85 - 0
netbox/dcim/migrations/0070_custom_tag_models.py

@@ -0,0 +1,85 @@
+# Generated by Django 2.1.4 on 2019-02-20 06:56
+
+from django.db import migrations
+import taggit.managers
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0069_deprecate_nullablecharfield'),
+        ('extras', '0018_rename_tag_tables'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='consoleport',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AlterField(
+            model_name='consoleserverport',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AlterField(
+            model_name='device',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AlterField(
+            model_name='devicebay',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AlterField(
+            model_name='devicetype',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AlterField(
+            model_name='frontport',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AlterField(
+            model_name='interface',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AlterField(
+            model_name='inventoryitem',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AlterField(
+            model_name='poweroutlet',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AlterField(
+            model_name='powerport',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AlterField(
+            model_name='rack',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AlterField(
+            model_name='rearport',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AlterField(
+            model_name='site',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AlterField(
+            model_name='virtualchassis',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+    ]

+ 15 - 15
netbox/dcim/models.py

@@ -15,7 +15,7 @@ from mptt.models import MPTTModel, TreeForeignKey
 from taggit.managers import TaggableManager
 from timezone_field import TimeZoneField
 
-from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange
+from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange, TaggedItem
 from utilities.fields import ColorField
 from utilities.managers import NaturalOrderingManager
 from utilities.models import ChangeLoggedModel
@@ -319,7 +319,7 @@ class Site(ChangeLoggedModel, CustomFieldModel):
     )
 
     objects = NaturalOrderingManager()
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
     csv_headers = [
         'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
@@ -566,7 +566,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
     )
 
     objects = NaturalOrderingManager()
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
     csv_headers = [
         'site', 'group_name', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', 'width',
@@ -914,7 +914,7 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
         object_id_field='obj_id'
     )
 
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
     csv_headers = [
         'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'comments',
@@ -1455,7 +1455,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
     )
 
     objects = NaturalOrderingManager()
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
     csv_headers = [
         'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
@@ -1743,7 +1743,7 @@ class ConsolePort(CableTermination, ComponentModel):
     )
 
     objects = DeviceComponentManager()
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
     csv_headers = ['device', 'name']
 
@@ -1786,7 +1786,7 @@ class ConsoleServerPort(CableTermination, ComponentModel):
     )
 
     objects = DeviceComponentManager()
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
     csv_headers = ['device', 'name']
 
@@ -1835,7 +1835,7 @@ class PowerPort(CableTermination, ComponentModel):
     )
 
     objects = DeviceComponentManager()
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
     csv_headers = ['device', 'name']
 
@@ -1878,7 +1878,7 @@ class PowerOutlet(CableTermination, ComponentModel):
     )
 
     objects = DeviceComponentManager()
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
     csv_headers = ['device', 'name']
 
@@ -1998,7 +1998,7 @@ class Interface(CableTermination, ComponentModel):
     )
 
     objects = InterfaceManager()
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
     csv_headers = [
         'device', 'virtual_machine', 'name', 'lag', 'form_factor', 'enabled', 'mac_address', 'mtu', 'mgmt_only',
@@ -2199,7 +2199,7 @@ class FrontPort(CableTermination, ComponentModel):
     )
 
     objects = DeviceComponentManager()
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
     csv_headers = ['device', 'name', 'type', 'rear_port', 'rear_port_position', 'description']
 
@@ -2265,7 +2265,7 @@ class RearPort(CableTermination, ComponentModel):
     )
 
     objects = DeviceComponentManager()
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
     csv_headers = ['device', 'name', 'type', 'positions', 'description']
 
@@ -2312,7 +2312,7 @@ class DeviceBay(ComponentModel):
     )
 
     objects = DeviceComponentManager()
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
     csv_headers = ['device', 'name', 'installed_device']
 
@@ -2405,7 +2405,7 @@ class InventoryItem(ComponentModel):
         blank=True
     )
 
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
     csv_headers = [
         'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description',
@@ -2452,7 +2452,7 @@ class VirtualChassis(ChangeLoggedModel):
         blank=True
     )
 
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
     csv_headers = ['master', 'domain']
 

+ 2 - 2
netbox/extras/api/serializers.py

@@ -1,6 +1,5 @@
 from django.core.exceptions import ObjectDoesNotExist
 from rest_framework import serializers
-from taggit.models import Tag
 
 from dcim.api.nested_serializers import (
     NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedRackSerializer,
@@ -10,6 +9,7 @@ from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site
 from extras.constants import *
 from extras.models import (
     ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap,
+    Tag
 )
 from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer
 from tenancy.models import Tenant, TenantGroup
@@ -80,7 +80,7 @@ class TagSerializer(ValidatedModelSerializer):
 
     class Meta:
         model = Tag
-        fields = ['id', 'name', 'slug', 'tagged_items']
+        fields = ['id', 'name', 'slug', 'color', 'comments', 'tagged_items']
 
 
 #

+ 2 - 2
netbox/extras/api/views.py

@@ -6,11 +6,11 @@ from rest_framework.decorators import action
 from rest_framework.exceptions import PermissionDenied
 from rest_framework.response import Response
 from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
-from taggit.models import Tag
 
 from extras import filters
 from extras.models import (
     ConfigContext, CustomField, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap,
+    Tag
 )
 from extras.reports import get_report, get_reports
 from utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, ModelViewSet
@@ -115,7 +115,7 @@ class TopologyMapViewSet(ModelViewSet):
 #
 
 class TagViewSet(ModelViewSet):
-    queryset = Tag.objects.annotate(tagged_items=Count('taggit_taggeditem_items'))
+    queryset = Tag.objects.annotate(tagged_items=Count('extras_taggeditem_items'))
     serializer_class = serializers.TagSerializer
     filterset_class = filters.TagFilter
 

+ 1 - 2
netbox/extras/filters.py

@@ -1,12 +1,11 @@
 import django_filters
 from django.contrib.contenttypes.models import ContentType
 from django.db.models import Q
-from taggit.models import Tag
 
 from dcim.models import DeviceRole, Platform, Region, Site
 from tenancy.models import Tenant, TenantGroup
 from .constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_TYPE_BOOLEAN, CF_TYPE_SELECT
-from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, TopologyMap
+from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, Tag, TopologyMap
 
 
 class CustomFieldFilter(django_filters.Filter):

+ 4 - 4
netbox/extras/forms.py

@@ -6,19 +6,18 @@ from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ObjectDoesNotExist
 from mptt.forms import TreeNodeMultipleChoiceField
 from taggit.forms import TagField
-from taggit.models import Tag
 
 from dcim.models import DeviceRole, Platform, Region, Site
 from tenancy.models import Tenant, TenantGroup
 from utilities.forms import (
     add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ContentTypeSelect,
-    FilterChoiceField, FilterTreeNodeMultipleChoiceField, LaxURLField, JSONField, SlugField,
+    FilterChoiceField, FilterTreeNodeMultipleChoiceField, LaxURLField, JSONField, SlugField, CommentField
 )
 from .constants import (
     CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL,
     OBJECTCHANGE_ACTION_CHOICES,
 )
-from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange
+from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange, Tag
 
 
 #
@@ -190,11 +189,12 @@ class CustomFieldFilterForm(forms.Form):
 
 class TagForm(BootstrapMixin, forms.ModelForm):
     slug = SlugField()
+    comments = CommentField()
 
     class Meta:
         model = Tag
         fields = [
-            'name', 'slug',
+            'name', 'slug', 'color', 'comments'
         ]
 
 

+ 46 - 0
netbox/extras/migrations/0017_tag_taggeditem.py

@@ -0,0 +1,46 @@
+# Generated by Django 2.1.4 on 2019-02-20 06:56
+
+from django.db import migrations, models
+import django.db.models.deletion
+import utilities.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contenttypes', '0002_remove_content_type_name'),
+        ('extras', '0016_exporttemplate_add_cable'),
+    ]
+
+    state_operations = [
+        migrations.CreateModel(
+            name='Tag',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('name', models.CharField(max_length=100, unique=True)),
+                ('slug', models.SlugField(max_length=100, unique=True)),
+            ],
+            options={
+                'abstract': False,
+            },
+        ),
+        migrations.CreateModel(
+            name='TaggedItem',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('object_id', models.IntegerField(db_index=True)),
+                ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='extras_taggeditem_tagged_items', to='contenttypes.ContentType')),
+                ('tag', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='extras_taggeditem_items', to='extras.Tag')),
+            ],
+            options={
+                'abstract': False,
+            },
+        ),
+    ]
+
+    operations = [
+        migrations.SeparateDatabaseAndState(
+            database_operations=None,
+            state_operations=state_operations
+        )
+    ]

+ 46 - 0
netbox/extras/migrations/0018_rename_tag_tables.py

@@ -0,0 +1,46 @@
+# Generated by Django 2.1.4 on 2019-02-20 06:59
+
+from django.db import migrations
+
+
+class AppTaggitAlterModelTable(migrations.AlterModelTable):
+    """
+    A special subclass of AlterModelTable which hardcodes the app_label to 'taggit'
+
+    This is needed because the migration deals with models which belong to the taggit
+    app, however because taggit is a 3rd party app, we cannot create our own migrations
+    there.
+    """
+
+    def state_forwards(self, app_label, state):
+        super().state_forwards('taggit', state)
+
+    def database_forwards(self, app_label, schema_editor, from_state, to_state):
+        super().database_forwards('taggit', schema_editor, from_state, to_state)
+
+    def database_backwards(self, app_label, schema_editor, from_state, to_state):
+        super().database_backwards('taggit', schema_editor, from_state, to_state)
+
+    def reduce(self, operation, app_label=None):
+        if app_label:
+            app_label = 'taggit'
+        super().reduce(operation, app_label=app_label)
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('taggit', '0001_initial'),
+        ('extras', '0017_tag_taggeditem'),
+    ]
+
+    operations = [
+        AppTaggitAlterModelTable(
+            name='Tag',
+            table='extras_tag'
+        ),
+        AppTaggitAlterModelTable(
+            name='TaggedItem',
+            table='extras_taggeditem'
+        ),
+    ]

+ 52 - 0
netbox/extras/migrations/0019_delete_taggit_models.py

@@ -0,0 +1,52 @@
+# Generated by Django 2.1.4 on 2019-02-20 07:05
+
+from django.db import migrations
+
+
+class AppTaggitDeleteModel(migrations.DeleteModel):
+    """
+    A special subclass of DeleteModel which hardcodes the app_label to 'taggit'
+
+    This is needed because the migration deals with models which belong to the taggit
+    app, however because taggit is a 3rd party app, we cannot create our own migrations
+    there.
+    """
+
+    def state_forwards(self, app_label, state):
+        super().state_forwards('taggit', state)
+
+    def database_forwards(self, app_label, schema_editor, from_state, to_state):
+        super().database_forwards('taggit', schema_editor, from_state, to_state)
+
+    def database_backwards(self, app_label, schema_editor, from_state, to_state):
+        super().database_backwards('taggit', schema_editor, from_state, to_state)
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0018_rename_tag_tables'),
+        ('circuits', '0015_custom_tag_models'),
+        ('dcim', '0070_custom_tag_models'),
+        ('ipam', '0025_custom_tag_models'),
+        ('secrets', '0006_custom_tag_models'),
+        ('tenancy', '0006_custom_tag_models'),
+        ('virtualization', '0009_custom_tag_models'),
+    ]
+
+    state_operations = [
+        AppTaggitDeleteModel(
+            name='Tag',
+        ),
+        AppTaggitDeleteModel(
+            name='TaggedItem',
+        ),
+    ]
+
+    database_operations = []
+    operations = [
+        migrations.SeparateDatabaseAndState(
+            database_operations=None,
+            state_operations=state_operations
+        )
+    ]

+ 24 - 0
netbox/extras/migrations/0020_add_color_comments_to_tag.py

@@ -0,0 +1,24 @@
+# Generated by Django 2.1.4 on 2019-02-20 07:38
+
+from django.db import migrations, models
+import utilities.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0019_delete_taggit_models'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='tag',
+            name='color',
+            field=utilities.fields.ColorField(max_length=6),
+        ),
+        migrations.AddField(
+            model_name='tag',
+            name='comments',
+            field=models.TextField(blank=True),
+        ),
+    ]

+ 22 - 0
netbox/extras/models.py

@@ -12,8 +12,10 @@ from django.db.models import F, Q
 from django.http import HttpResponse
 from django.template import Template, Context
 from django.urls import reverse
+from taggit.models import TagBase, GenericTaggedItemBase
 
 from dcim.constants import CONNECTION_STATUS_CONNECTED
+from utilities.fields import ColorField
 from utilities.utils import deepmerge, foreground_color
 from .constants import *
 from .querysets import ConfigContextQuerySet
@@ -860,3 +862,23 @@ class ObjectChange(models.Model):
             self.object_repr,
             self.object_data,
         )
+
+
+#
+# Tags
+#
+
+
+class Tag(TagBase):
+    color = ColorField()
+    comments = models.TextField(
+        blank=True
+    )
+
+
+class TaggedItem(GenericTaggedItemBase):
+    tag = models.ForeignKey(
+        to=Tag,
+        related_name="%(app_label)s_%(class)s_items",
+        on_delete=models.CASCADE
+    )

+ 4 - 4
netbox/extras/tables.py

@@ -1,9 +1,8 @@
 import django_tables2 as tables
 from django_tables2.utils import Accessor
-from taggit.models import Tag, TaggedItem
 
-from utilities.tables import BaseTable, BooleanColumn, ToggleColumn
-from .models import ConfigContext, ObjectChange
+from utilities.tables import BaseTable, BooleanColumn, ColorColumn, ToggleColumn
+from .models import ConfigContext, ObjectChange, Tag, TaggedItem
 
 TAG_ACTIONS = """
 {% if perms.taggit.change_tag %}
@@ -71,10 +70,11 @@ class TagTable(BaseTable):
         attrs={'td': {'class': 'text-right'}},
         verbose_name=''
     )
+    color = ColorColumn()
 
     class Meta(BaseTable.Meta):
         model = Tag
-        fields = ('pk', 'name', 'items', 'slug', 'actions')
+        fields = ('pk', 'name', 'items', 'slug', 'color', 'actions')
 
 
 class TaggedItemTable(BaseTable):

+ 1 - 2
netbox/extras/tests/test_api.py

@@ -1,11 +1,10 @@
 from django.contrib.contenttypes.models import ContentType
 from django.urls import reverse
 from rest_framework import status
-from taggit.models import Tag
 
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Region, Site
 from extras.constants import GRAPH_TYPE_SITE
-from extras.models import ConfigContext, Graph, ExportTemplate
+from extras.models import ConfigContext, Graph, ExportTemplate, Tag
 from tenancy.models import Tenant, TenantGroup
 from utilities.testing import APITestCase
 

+ 1 - 2
netbox/extras/tests/test_views.py

@@ -4,10 +4,9 @@ import uuid
 from django.contrib.auth.models import User
 from django.test import Client, TestCase
 from django.urls import reverse
-from taggit.models import Tag
 
 from dcim.models import Site
-from extras.models import ConfigContext, ObjectChange
+from extras.models import ConfigContext, ObjectChange, Tag
 
 
 class TagTestCase(TestCase):

+ 7 - 7
netbox/extras/views.py

@@ -9,7 +9,6 @@ from django.shortcuts import get_object_or_404, redirect, render
 from django.utils.safestring import mark_safe
 from django.views.generic import View
 from django_tables2 import RequestConfig
-from taggit.models import Tag, TaggedItem
 
 from utilities.forms import ConfirmationForm
 from utilities.paginator import EnhancedPaginator
@@ -19,7 +18,7 @@ from .forms import (
     ConfigContextForm, ConfigContextBulkEditForm, ConfigContextFilterForm, ImageAttachmentForm, ObjectChangeFilterForm,
     TagFilterForm, TagForm,
 )
-from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult
+from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult, Tag, TaggedItem
 from .reports import get_report, get_reports
 from .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemTable
 
@@ -30,7 +29,7 @@ from .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemT
 
 class TagListView(ObjectListView):
     queryset = Tag.objects.annotate(
-        items=Count('taggit_taggeditem_items')
+        items=Count('extras_taggeditem_items')
     ).order_by(
         'name'
     )
@@ -69,22 +68,23 @@ class TagView(View):
 
 
 class TagEditView(PermissionRequiredMixin, ObjectEditView):
-    permission_required = 'taggit.change_tag'
+    permission_required = 'extras.change_tag'
     model = Tag
     model_form = TagForm
     default_return_url = 'extras:tag_list'
+    template_name = 'extras/tag_edit.html'
 
 
 class TagDeleteView(PermissionRequiredMixin, ObjectDeleteView):
-    permission_required = 'taggit.delete_tag'
+    permission_required = 'extras.delete_tag'
     model = Tag
     default_return_url = 'extras:tag_list'
 
 
 class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
-    permission_required = 'taggit.delete_tag'
+    permission_required = 'extras.delete_tag'
     queryset = Tag.objects.annotate(
-        items=Count('taggit_taggeditem_items')
+        items=Count('extras_taggeditem_items')
     ).order_by(
         'name'
     )

+ 45 - 0
netbox/ipam/migrations/0025_custom_tag_models.py

@@ -0,0 +1,45 @@
+# Generated by Django 2.1.4 on 2019-02-20 06:56
+
+from django.db import migrations
+import taggit.managers
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ipam', '0024_vrf_allow_null_rd'),
+        ('extras', '0018_rename_tag_tables'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='aggregate',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AlterField(
+            model_name='ipaddress',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AlterField(
+            model_name='prefix',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AlterField(
+            model_name='service',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AlterField(
+            model_name='vlan',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AlterField(
+            model_name='vrf',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+    ]

+ 7 - 7
netbox/ipam/models.py

@@ -10,7 +10,7 @@ from django.urls import reverse
 from taggit.managers import TaggableManager
 
 from dcim.models import Interface
-from extras.models import CustomFieldModel
+from extras.models import CustomFieldModel, TaggedItem
 from utilities.models import ChangeLoggedModel
 from .constants import *
 from .fields import IPNetworkField, IPAddressField
@@ -55,7 +55,7 @@ class VRF(ChangeLoggedModel, CustomFieldModel):
         object_id_field='obj_id'
     )
 
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
     csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
 
@@ -154,7 +154,7 @@ class Aggregate(ChangeLoggedModel, CustomFieldModel):
         object_id_field='obj_id'
     )
 
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
     csv_headers = ['prefix', 'rir', 'date_added', 'description']
 
@@ -324,7 +324,7 @@ class Prefix(ChangeLoggedModel, CustomFieldModel):
     )
 
     objects = PrefixQuerySet.as_manager()
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
     csv_headers = [
         'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description',
@@ -583,7 +583,7 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
     )
 
     objects = IPAddressManager()
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
     csv_headers = [
         'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface_name', 'is_primary',
@@ -790,7 +790,7 @@ class VLAN(ChangeLoggedModel, CustomFieldModel):
         object_id_field='obj_id'
     )
 
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
     csv_headers = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description']
 
@@ -892,7 +892,7 @@ class Service(ChangeLoggedModel, CustomFieldModel):
         object_id_field='obj_id'
     )
 
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
     csv_headers = ['device', 'virtual_machine', 'name', 'protocol', 'description']
 

+ 12 - 3
netbox/netbox/admin.py

@@ -2,8 +2,17 @@ from django.conf import settings
 from django.contrib.admin import AdminSite
 from django.contrib.auth.admin import GroupAdmin, UserAdmin
 from django.contrib.auth.models import Group, User
-from taggit.admin import TagAdmin
-from taggit.models import Tag
+from taggit.admin import TagAdmin, TaggedItemInline
+
+from extras.models import Tag, TaggedItem
+
+
+class NetBoxTaggedItemInline(TaggedItemInline):
+    model = TaggedItem
+
+
+class NetBoxTagAdmin(TagAdmin):
+    inlines = [NetBoxTaggedItemInline]
 
 
 class NetBoxAdminSite(AdminSite):
@@ -20,7 +29,7 @@ admin_site = NetBoxAdminSite(name='admin')
 # Register external models
 admin_site.register(Group, GroupAdmin)
 admin_site.register(User, UserAdmin)
-admin_site.register(Tag, TagAdmin)
+admin_site.register(Tag, NetBoxTagAdmin)
 
 # Modify the template to include an RQ link if django_rq is installed (see RQ_SHOW_ADMIN_LINK)
 if settings.WEBHOOKS_ENABLED:

+ 20 - 0
netbox/secrets/migrations/0006_custom_tag_models.py

@@ -0,0 +1,20 @@
+# Generated by Django 2.1.4 on 2019-02-20 06:56
+
+from django.db import migrations
+import taggit.managers
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('secrets', '0005_change_logging'),
+        ('extras', '0018_rename_tag_tables'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='secret',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+    ]

+ 2 - 2
netbox/secrets/models.py

@@ -14,7 +14,7 @@ from django.urls import reverse
 from django.utils.encoding import force_bytes
 from taggit.managers import TaggableManager
 
-from extras.models import CustomFieldModel
+from extras.models import CustomFieldModel, TaggedItem
 from utilities.models import ChangeLoggedModel
 from .exceptions import InvalidKey
 from .hashers import SecretValidationHasher
@@ -345,7 +345,7 @@ class Secret(ChangeLoggedModel, CustomFieldModel):
         object_id_field='obj_id'
     )
 
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
     plaintext = None
     csv_headers = ['device', 'role', 'name', 'plaintext']

+ 18 - 0
netbox/templates/extras/tag.html

@@ -59,8 +59,26 @@
                             {{ items_count }}
                         </td>
                     </tr>
+                    <tr>
+                        <td>Color</td>
+                        <td>
+                            <span class="label color-block" style="background-color: #{{ tag.color }}">&nbsp;</span>
+                        </td>
+                    </tr>
                 </table>
             </div>
+            <div class="panel panel-default">
+                <div class="panel-heading">
+                    <strong>Comments</strong>
+                </div>
+                <div class="panel-body rendered-markdown">
+                    {% if tag.comments %}
+                        {{ tag.comments|gfm }}
+                    {% else %}
+                        <span class="text-muted">None</span>
+                    {% endif %}
+                </div>
+            </div>
         </div>
         <div class="col-md-6">
             {% include 'panel_table.html' with table=items_table heading='Tagged Objects' %}

+ 19 - 0
netbox/templates/extras/tag_edit.html

@@ -0,0 +1,19 @@
+{% extends 'utilities/obj_edit.html' %}
+{% load form_helpers %}
+
+{% block form %}
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Tag</strong></div>
+        <div class="panel-body">
+            {% render_field form.name %}
+            {% render_field form.slug %}
+            {% render_field form.color %}
+        </div>
+    </div>
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Comments</strong></div>
+        <div class="panel-body">
+            {% render_field form.comments %}
+        </div>
+    </div>
+{% endblock %}

+ 3 - 1
netbox/templates/utilities/templatetags/tag.html

@@ -1,5 +1,7 @@
+{% load helpers %}
+
 {% if url_name %}
-    <a href="{% url url_name %}?tag={{ tag.slug }}"><span class="label label-default">{{ tag }}</span></a>
+    <a href="{% url url_name %}?tag={{ tag.slug }}"><span class="label label-default" style="color: {{ tag.color|fgcolor }}; background-color: #{{ tag.color }}">{{ tag }}</span></a>
 {% else %}
     <span class="label label-default">{{ tag }}</span>
 {% endif %}

+ 20 - 0
netbox/tenancy/migrations/0006_custom_tag_models.py

@@ -0,0 +1,20 @@
+# Generated by Django 2.1.4 on 2019-02-20 06:56
+
+from django.db import migrations
+import taggit.managers
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('tenancy', '0005_change_logging'),
+        ('extras', '0018_rename_tag_tables'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='tenant',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+    ]

+ 2 - 2
netbox/tenancy/models.py

@@ -3,7 +3,7 @@ from django.db import models
 from django.urls import reverse
 from taggit.managers import TaggableManager
 
-from extras.models import CustomFieldModel
+from extras.models import CustomFieldModel, TaggedItem
 from utilities.models import ChangeLoggedModel
 
 
@@ -70,7 +70,7 @@ class Tenant(ChangeLoggedModel, CustomFieldModel):
         object_id_field='obj_id'
     )
 
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
     csv_headers = ['name', 'slug', 'group', 'description', 'comments']
 

+ 2 - 1
netbox/utilities/filters.py

@@ -1,7 +1,8 @@
 import django_filters
 from django.conf import settings
 from django.db.models import Q
-from taggit.models import Tag
+
+from extras.models import Tag
 
 
 class NumericInFilter(django_filters.BaseInFilter, django_filters.NumberFilter):

+ 1 - 1
netbox/utilities/views.py

@@ -157,7 +157,7 @@ class ObjectListView(View):
 
         # Construct queryset for tags list
         if hasattr(model, 'tags'):
-            tags = model.tags.annotate(count=Count('taggit_taggeditem_items')).order_by('name')
+            tags = model.tags.annotate(count=Count('extras_taggeditem_items')).order_by('name')
         else:
             tags = None
 

+ 25 - 0
netbox/virtualization/migrations/0009_custom_tag_models.py

@@ -0,0 +1,25 @@
+# Generated by Django 2.1.4 on 2019-02-20 06:56
+
+from django.db import migrations
+import taggit.managers
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('virtualization', '0008_virtualmachine_local_context_data'),
+        ('extras', '0018_rename_tag_tables'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='cluster',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AlterField(
+            model_name='virtualmachine',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+    ]

+ 3 - 3
netbox/virtualization/models.py

@@ -6,7 +6,7 @@ from django.urls import reverse
 from taggit.managers import TaggableManager
 
 from dcim.models import Device
-from extras.models import ConfigContextModel, CustomFieldModel
+from extras.models import ConfigContextModel, CustomFieldModel, TaggedItem
 from utilities.models import ChangeLoggedModel
 from .constants import DEVICE_STATUS_ACTIVE, VM_STATUS_CHOICES, VM_STATUS_CLASSES
 
@@ -119,7 +119,7 @@ class Cluster(ChangeLoggedModel, CustomFieldModel):
         object_id_field='obj_id'
     )
 
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
     csv_headers = ['name', 'type', 'group', 'site', 'comments']
 
@@ -238,7 +238,7 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
         object_id_field='obj_id'
     )
 
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
     csv_headers = [
         'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',