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

Merge branch 'develop-2.6' of github.com:digitalocean/netbox into develop-2.6

John Anderson 7 лет назад
Родитель
Сommit
5991bd368c
47 измененных файлов с 810 добавлено и 137 удалено
  1. 27 0
      CHANGELOG.md
  2. 25 0
      netbox/circuits/migrations/0015_custom_tag_models.py
  3. 3 3
      netbox/circuits/models.py
  4. 9 9
      netbox/dcim/api/serializers.py
  5. 11 4
      netbox/dcim/api/views.py
  6. 12 10
      netbox/dcim/filters.py
  7. 53 5
      netbox/dcim/forms.py
  8. 85 0
      netbox/dcim/migrations/0070_custom_tag_models.py
  9. 38 0
      netbox/dcim/migrations/0071_device_components_add_description.py
  10. 29 36
      netbox/dcim/models.py
  11. 29 5
      netbox/dcim/tests/test_api.py
  12. 2 2
      netbox/extras/api/serializers.py
  13. 2 2
      netbox/extras/api/views.py
  14. 1 2
      netbox/extras/filters.py
  15. 4 4
      netbox/extras/forms.py
  16. 43 0
      netbox/extras/migrations/0017_tag_taggeditem.py
  17. 65 0
      netbox/extras/migrations/0018_tag_data.py
  18. 34 0
      netbox/extras/migrations/0019_add_color_comments_changelog_to_tag.py
  19. 36 0
      netbox/extras/models.py
  20. 7 4
      netbox/extras/tables.py
  21. 1 2
      netbox/extras/tests/test_api.py
  22. 1 2
      netbox/extras/tests/test_views.py
  23. 3 0
      netbox/extras/urls.py
  24. 7 7
      netbox/extras/views.py
  25. 45 0
      netbox/ipam/migrations/0025_custom_tag_models.py
  26. 7 7
      netbox/ipam/models.py
  27. 12 3
      netbox/netbox/admin.py
  28. 1 1
      netbox/netbox/settings.py
  29. 20 0
      netbox/secrets/migrations/0006_custom_tag_models.py
  30. 2 2
      netbox/secrets/models.py
  31. 3 0
      netbox/templates/dcim/device.html
  32. 5 0
      netbox/templates/dcim/inc/consoleport.html
  33. 8 1
      netbox/templates/dcim/inc/consoleserverport.html
  34. 25 7
      netbox/templates/dcim/inc/devicebay.html
  35. 8 1
      netbox/templates/dcim/inc/poweroutlet.html
  36. 5 0
      netbox/templates/dcim/inc/powerport.html
  37. 27 0
      netbox/templates/extras/tag.html
  38. 19 0
      netbox/templates/extras/tag_edit.html
  39. 3 1
      netbox/templates/utilities/templatetags/tag.html
  40. 20 0
      netbox/tenancy/migrations/0006_custom_tag_models.py
  41. 2 2
      netbox/tenancy/models.py
  42. 2 1
      netbox/utilities/filters.py
  43. 1 1
      netbox/utilities/views.py
  44. 11 4
      netbox/virtualization/api/views.py
  45. 25 0
      netbox/virtualization/migrations/0009_custom_tag_models.py
  46. 3 3
      netbox/virtualization/models.py
  47. 29 6
      netbox/virtualization/tests/test_api.py

+ 27 - 0
CHANGELOG.md

@@ -1,3 +1,30 @@
+v2.6.0 (FUTURE)
+
+## Changes
+
+### API Device/VM Config Context Included by Default ([#2350](https://github.com/digitalocean/netbox/issues/2350))
+
+The rendered Config Context for Devices and VMs is now included by default in all API results (list and detail views).
+Previously the rendered Config Context was only available in the detail view for objects. Users with large amounts of
+context data may observe a performance drop when returning multiple objects. To combat this, in cases where the rendered
+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.
+
+### Tag Permissions Changed
+
+NetBox now makes use of its own `Tag` model instead of the vanilla model which ships with django-taggit. This new model
+lives in the `extras` app and thus any permissions that you may have configured using "Taggit | Tag" should be changed
+to now use "Extras | Tag."
+
+## Enhancements
+
+* [#2324](https://github.com/digitalocean/netbox/issues/2324) - Add color option for tags
+* [#2643](https://github.com/digitalocean/netbox/issues/2643) - Add `description` field to console/power components and device bays
+* [#2791](https://github.com/digitalocean/netbox/issues/2791) - Add a comment field for tags
+* [#2926](https://github.com/digitalocean/netbox/issues/2926) - Add changelog to the Tag model
+
+---
+
 v2.5.8 (FUTURE)
 
 ## Bug Fixes

+ 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', '0017_tag_taggeditem'),
+    ]
+
+    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',

+ 9 - 9
netbox/dcim/api/serializers.py

@@ -346,8 +346,8 @@ class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer)
     class Meta:
         model = ConsoleServerPort
         fields = [
-            'id', 'device', 'name', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable',
-            'tags',
+            'id', 'device', 'name', 'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status',
+            'cable', 'tags',
         ]
 
 
@@ -359,8 +359,8 @@ class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
     class Meta:
         model = ConsolePort
         fields = [
-            'id', 'device', 'name', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable',
-            'tags',
+            'id', 'device', 'name', 'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status',
+            'cable', 'tags',
         ]
 
 
@@ -372,8 +372,8 @@ class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer):
     class Meta:
         model = PowerOutlet
         fields = [
-            'id', 'device', 'name', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable',
-            'tags',
+            'id', 'device', 'name', 'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status',
+            'cable', 'tags',
         ]
 
 
@@ -385,8 +385,8 @@ class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
     class Meta:
         model = PowerPort
         fields = [
-            'id', 'device', 'name', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable',
-            'tags',
+            'id', 'device', 'name', 'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status',
+            'cable', 'tags',
         ]
 
 
@@ -475,7 +475,7 @@ class DeviceBaySerializer(TaggitSerializer, ValidatedModelSerializer):
 
     class Meta:
         model = DeviceBay
-        fields = ['id', 'device', 'name', 'installed_device', 'tags']
+        fields = ['id', 'device', 'name', 'description', 'installed_device', 'tags']
 
 
 #

+ 11 - 4
netbox/dcim/api/views.py

@@ -291,16 +291,23 @@ class DeviceViewSet(CustomFieldModelViewSet):
 
     def get_serializer_class(self):
         """
-        Include rendered config context when retrieving a single Device.
+        Select the specific serializer based on the request context.
+
+        If the `brief` query param equates to True, return the NestedDeviceSerializer
+
+        If the `exclude` query param includes `config_context` as a value, return the DeviceSerializer
+
+        Else, return the DeviceWithConfigContextSerializer
         """
-        if self.action == 'retrieve':
-            return serializers.DeviceWithConfigContextSerializer
 
         request = self.get_serializer_context()['request']
         if request.query_params.get('brief', False):
             return serializers.NestedDeviceSerializer
 
-        return serializers.DeviceSerializer
+        elif 'config_context' in request.query_params.get('exclude', []):
+            return serializers.DeviceSerializer
+
+        return serializers.DeviceWithConfigContextSerializer
 
     @action(detail=True, url_path='napalm')
     def napalm(self, request, pk):

+ 12 - 10
netbox/dcim/filters.py

@@ -693,7 +693,8 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
         if not value.strip():
             return queryset
         return queryset.filter(
-            Q(name__icontains=value)
+            Q(name__icontains=value) |
+            Q(description__icontains=value)
         )
 
 
@@ -706,7 +707,7 @@ class ConsolePortFilter(DeviceComponentFilterSet):
 
     class Meta:
         model = ConsolePort
-        fields = ['name', 'connection_status']
+        fields = ['name', 'description', 'connection_status']
 
 
 class ConsoleServerPortFilter(DeviceComponentFilterSet):
@@ -718,7 +719,7 @@ class ConsoleServerPortFilter(DeviceComponentFilterSet):
 
     class Meta:
         model = ConsoleServerPort
-        fields = ['name', 'connection_status']
+        fields = ['name', 'description', 'connection_status']
 
 
 class PowerPortFilter(DeviceComponentFilterSet):
@@ -730,7 +731,7 @@ class PowerPortFilter(DeviceComponentFilterSet):
 
     class Meta:
         model = PowerPort
-        fields = ['name', 'connection_status']
+        fields = ['name', 'description', 'connection_status']
 
 
 class PowerOutletFilter(DeviceComponentFilterSet):
@@ -742,7 +743,7 @@ class PowerOutletFilter(DeviceComponentFilterSet):
 
     class Meta:
         model = PowerOutlet
-        fields = ['name', 'connection_status']
+        fields = ['name', 'description', 'connection_status']
 
 
 class InterfaceFilter(django_filters.FilterSet):
@@ -797,13 +798,14 @@ class InterfaceFilter(django_filters.FilterSet):
 
     class Meta:
         model = Interface
-        fields = ['name', 'connection_status', 'form_factor', 'enabled', 'mtu', 'mgmt_only']
+        fields = ['name', 'connection_status', 'form_factor', 'enabled', 'mtu', 'mgmt_only', 'description']
 
     def search(self, queryset, name, value):
         if not value.strip():
             return queryset
         return queryset.filter(
-            Q(name__icontains=value)
+            Q(name__icontains=value) |
+            Q(description__icontains=value)
         ).distinct()
 
     def filter_device(self, queryset, name, value):
@@ -861,7 +863,7 @@ class FrontPortFilter(DeviceComponentFilterSet):
 
     class Meta:
         model = FrontPort
-        fields = ['name', 'type']
+        fields = ['name', 'type', 'description']
 
 
 class RearPortFilter(DeviceComponentFilterSet):
@@ -873,14 +875,14 @@ class RearPortFilter(DeviceComponentFilterSet):
 
     class Meta:
         model = RearPort
-        fields = ['name', 'type']
+        fields = ['name', 'type', 'description']
 
 
 class DeviceBayFilter(DeviceComponentFilterSet):
 
     class Meta:
         model = DeviceBay
-        fields = ['name']
+        fields = ['name', 'description']
 
 
 class InventoryItemFilter(DeviceComponentFilterSet):

+ 53 - 5
netbox/dcim/forms.py

@@ -1854,7 +1854,7 @@ class ConsolePortForm(BootstrapMixin, forms.ModelForm):
     class Meta:
         model = ConsolePort
         fields = [
-            'device', 'name', 'tags',
+            'device', 'name', 'description', 'tags',
         ]
         widgets = {
             'device': forms.HiddenInput(),
@@ -1865,6 +1865,10 @@ class ConsolePortCreateForm(ComponentForm):
     name_pattern = ExpandableNameField(
         label='Name'
     )
+    description = forms.CharField(
+        max_length=100,
+        required=False
+    )
     tags = TagField(
         required=False
     )
@@ -1882,7 +1886,7 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm):
     class Meta:
         model = ConsoleServerPort
         fields = [
-            'device', 'name', 'tags',
+            'device', 'name', 'description', 'tags',
         ]
         widgets = {
             'device': forms.HiddenInput(),
@@ -1893,11 +1897,31 @@ class ConsoleServerPortCreateForm(ComponentForm):
     name_pattern = ExpandableNameField(
         label='Name'
     )
+    description = forms.CharField(
+        max_length=100,
+        required=False
+    )
     tags = TagField(
         required=False
     )
 
 
+class ConsoleServerPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=ConsoleServerPort.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    description = forms.CharField(
+        max_length=100,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = [
+            'description',
+        ]
+
+
 class ConsoleServerPortBulkRenameForm(BulkRenameForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=ConsoleServerPort.objects.all(),
@@ -1924,7 +1948,7 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm):
     class Meta:
         model = PowerPort
         fields = [
-            'device', 'name', 'tags',
+            'device', 'name', 'description', 'tags',
         ]
         widgets = {
             'device': forms.HiddenInput(),
@@ -1935,6 +1959,10 @@ class PowerPortCreateForm(ComponentForm):
     name_pattern = ExpandableNameField(
         label='Name'
     )
+    description = forms.CharField(
+        max_length=100,
+        required=False
+    )
     tags = TagField(
         required=False
     )
@@ -1952,7 +1980,7 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm):
     class Meta:
         model = PowerOutlet
         fields = [
-            'device', 'name', 'tags',
+            'device', 'name', 'description', 'tags',
         ]
         widgets = {
             'device': forms.HiddenInput(),
@@ -1963,11 +1991,31 @@ class PowerOutletCreateForm(ComponentForm):
     name_pattern = ExpandableNameField(
         label='Name'
     )
+    description = forms.CharField(
+        max_length=100,
+        required=False
+    )
     tags = TagField(
         required=False
     )
 
 
+class PowerOutletBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=PowerOutlet.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    description = forms.CharField(
+        max_length=100,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = [
+            'description',
+        ]
+
+
 class PowerOutletBulkRenameForm(BulkRenameForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=PowerOutlet.objects.all(),
@@ -2781,7 +2829,7 @@ class DeviceBayForm(BootstrapMixin, forms.ModelForm):
     class Meta:
         model = DeviceBay
         fields = [
-            'device', 'name', 'tags',
+            'device', 'name', 'description', 'tags',
         ]
         widgets = {
             'device': forms.HiddenInput(),

+ 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', '0017_tag_taggeditem'),
+    ]
+
+    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'),
+        ),
+    ]

+ 38 - 0
netbox/dcim/migrations/0071_device_components_add_description.py

@@ -0,0 +1,38 @@
+# Generated by Django 2.1.7 on 2019-02-20 18:50
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0070_custom_tag_models'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='consoleport',
+            name='description',
+            field=models.CharField(blank=True, max_length=100),
+        ),
+        migrations.AddField(
+            model_name='consoleserverport',
+            name='description',
+            field=models.CharField(blank=True, max_length=100),
+        ),
+        migrations.AddField(
+            model_name='devicebay',
+            name='description',
+            field=models.CharField(blank=True, max_length=100),
+        ),
+        migrations.AddField(
+            model_name='poweroutlet',
+            name='description',
+            field=models.CharField(blank=True, max_length=100),
+        ),
+        migrations.AddField(
+            model_name='powerport',
+            name='description',
+            field=models.CharField(blank=True, max_length=100),
+        ),
+    ]

+ 29 - 36
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
@@ -46,6 +46,10 @@ class ComponentTemplateModel(models.Model):
 
 
 class ComponentModel(models.Model):
+    description = models.CharField(
+        max_length=100,
+        blank=True
+    )
 
     class Meta:
         abstract = True
@@ -319,7 +323,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 +570,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 +918,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 +1459,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,9 +1747,9 @@ class ConsolePort(CableTermination, ComponentModel):
     )
 
     objects = DeviceComponentManager()
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
-    csv_headers = ['device', 'name']
+    csv_headers = ['device', 'name', 'description']
 
     class Meta:
         ordering = ['device', 'name']
@@ -1761,6 +1765,7 @@ class ConsolePort(CableTermination, ComponentModel):
         return (
             self.device.identifier,
             self.name,
+            self.description,
         )
 
 
@@ -1786,9 +1791,9 @@ class ConsoleServerPort(CableTermination, ComponentModel):
     )
 
     objects = DeviceComponentManager()
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
-    csv_headers = ['device', 'name']
+    csv_headers = ['device', 'name', 'description']
 
     class Meta:
         unique_together = ['device', 'name']
@@ -1803,6 +1808,7 @@ class ConsoleServerPort(CableTermination, ComponentModel):
         return (
             self.device.identifier,
             self.name,
+            self.description,
         )
 
 
@@ -1835,13 +1841,13 @@ class PowerPort(CableTermination, ComponentModel):
     )
 
     objects = DeviceComponentManager()
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
     csv_headers = ['device', 'name']
 
     class Meta:
         ordering = ['device', 'name']
-        unique_together = ['device', 'name']
+        unique_together = ['device', 'name', 'description']
 
     def __str__(self):
         return self.name
@@ -1853,6 +1859,7 @@ class PowerPort(CableTermination, ComponentModel):
         return (
             self.device.identifier,
             self.name,
+            self.description,
         )
 
 
@@ -1878,12 +1885,12 @@ class PowerOutlet(CableTermination, ComponentModel):
     )
 
     objects = DeviceComponentManager()
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
     csv_headers = ['device', 'name']
 
     class Meta:
-        unique_together = ['device', 'name']
+        unique_together = ['device', 'name', 'description']
 
     def __str__(self):
         return self.name
@@ -1895,6 +1902,7 @@ class PowerOutlet(CableTermination, ComponentModel):
         return (
             self.device.identifier,
             self.name,
+            self.description,
         )
 
 
@@ -1973,10 +1981,6 @@ class Interface(CableTermination, ComponentModel):
         verbose_name='OOB Management',
         help_text='This interface is used only for out-of-band management'
     )
-    description = models.CharField(
-        max_length=100,
-        blank=True
-    )
     mode = models.PositiveSmallIntegerField(
         choices=IFACE_MODE_CHOICES,
         blank=True,
@@ -1998,7 +2002,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',
@@ -2193,13 +2197,9 @@ class FrontPort(CableTermination, ComponentModel):
         default=1,
         validators=[MinValueValidator(1), MaxValueValidator(64)]
     )
-    description = models.CharField(
-        max_length=100,
-        blank=True
-    )
 
     objects = DeviceComponentManager()
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
     csv_headers = ['device', 'name', 'type', 'rear_port', 'rear_port_position', 'description']
 
@@ -2259,13 +2259,9 @@ class RearPort(CableTermination, ComponentModel):
         default=1,
         validators=[MinValueValidator(1), MaxValueValidator(64)]
     )
-    description = models.CharField(
-        max_length=100,
-        blank=True
-    )
 
     objects = DeviceComponentManager()
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
     csv_headers = ['device', 'name', 'type', 'positions', 'description']
 
@@ -2312,9 +2308,9 @@ class DeviceBay(ComponentModel):
     )
 
     objects = DeviceComponentManager()
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
-    csv_headers = ['device', 'name', 'installed_device']
+    csv_headers = ['device', 'name', 'installed_device', 'description']
 
     class Meta:
         ordering = ['device', 'name']
@@ -2331,6 +2327,7 @@ class DeviceBay(ComponentModel):
             self.device.identifier,
             self.name,
             self.installed_device.identifier if self.installed_device else None,
+            self.description,
         )
 
     def clean(self):
@@ -2400,12 +2397,8 @@ class InventoryItem(ComponentModel):
         default=False,
         verbose_name='Discovered'
     )
-    description = models.CharField(
-        max_length=100,
-        blank=True
-    )
 
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
     csv_headers = [
         'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description',
@@ -2452,7 +2445,7 @@ class VirtualChassis(ChangeLoggedModel):
         blank=True
     )
 
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
     csv_headers = ['master', 'domain']
 

+ 29 - 5
netbox/dcim/tests/test_api.py

@@ -1791,6 +1791,16 @@ class DeviceTest(APITestCase):
             site=self.site1,
             cluster=self.cluster1
         )
+        self.device_with_context_data = Device.objects.create(
+            device_type=self.devicetype1,
+            device_role=self.devicerole1,
+            name='Device with context data',
+            site=self.site1,
+            local_context_data={
+                'A': 1,
+                'B': 2
+            }
+        )
 
     def test_get_device(self):
 
@@ -1806,7 +1816,7 @@ class DeviceTest(APITestCase):
         url = reverse('dcim-api:device-list')
         response = self.client.get(url, **self.header)
 
-        self.assertEqual(response.data['count'], 3)
+        self.assertEqual(response.data['count'], 4)
 
     def test_list_devices_brief(self):
 
@@ -1832,7 +1842,7 @@ class DeviceTest(APITestCase):
         response = self.client.post(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
-        self.assertEqual(Device.objects.count(), 4)
+        self.assertEqual(Device.objects.count(), 5)
         device4 = Device.objects.get(pk=response.data['id'])
         self.assertEqual(device4.device_type_id, data['device_type'])
         self.assertEqual(device4.device_role_id, data['device_role'])
@@ -1867,7 +1877,7 @@ class DeviceTest(APITestCase):
         response = self.client.post(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
-        self.assertEqual(Device.objects.count(), 6)
+        self.assertEqual(Device.objects.count(), 7)
         self.assertEqual(response.data[0]['name'], data[0]['name'])
         self.assertEqual(response.data[1]['name'], data[1]['name'])
         self.assertEqual(response.data[2]['name'], data[2]['name'])
@@ -1891,7 +1901,7 @@ class DeviceTest(APITestCase):
         response = self.client.put(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_200_OK)
-        self.assertEqual(Device.objects.count(), 3)
+        self.assertEqual(Device.objects.count(), 4)
         device1 = Device.objects.get(pk=response.data['id'])
         self.assertEqual(device1.device_type_id, data['device_type'])
         self.assertEqual(device1.device_role_id, data['device_role'])
@@ -1906,7 +1916,21 @@ class DeviceTest(APITestCase):
         response = self.client.delete(url, **self.header)
 
         self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
-        self.assertEqual(Device.objects.count(), 2)
+        self.assertEqual(Device.objects.count(), 3)
+
+    def test_config_context_included_by_default_in_list_view(self):
+
+        url = reverse('dcim-api:device-list') + '?slug=device-with-context-data'
+        response = self.client.get(url, **self.header)
+
+        self.assertEqual(response.data['results'][0].get('config_context', {}).get('A'), 1)
+
+    def test_config_context_excluded(self):
+
+        url = reverse('dcim-api:device-list') + '?exclude=config_context'
+        response = self.client.get(url, **self.header)
+
+        self.assertFalse('config_context' in response.data['results'][0])
 
 
 class ConsolePortTest(APITestCase):

+ 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'
         ]
 
 

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

@@ -0,0 +1,43 @@
+# 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'),
+    ]
+
+    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,
+            },
+        ),
+        migrations.AlterIndexTogether(
+            name='taggeditem',
+            index_together={('content_type', 'object_id')},
+        ),
+    ]

+ 65 - 0
netbox/extras/migrations/0018_tag_data.py

@@ -0,0 +1,65 @@
+# 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
+
+
+def copy_tags(apps, schema_editor):
+    """
+    Copy data from taggit_tag to extras_tag
+    """
+    TaggitTag = apps.get_model('taggit', 'Tag')
+    ExtrasTag = apps.get_model('extras', 'Tag')
+
+    tags_values = TaggitTag.objects.all().values('id', 'name', 'slug')
+    tags = [ExtrasTag(**tag) for tag in tags_values]
+    ExtrasTag.objects.bulk_create(tags)
+
+
+def copy_taggeditems(apps, schema_editor):
+    """
+    Copy data from taggit_taggeditem to extras_taggeditem
+    """
+    TaggitTaggedItem = apps.get_model('taggit', 'TaggedItem')
+    ExtrasTaggedItem = apps.get_model('extras', 'TaggedItem')
+
+    tagged_items_values = TaggitTaggedItem.objects.all().values('id', 'object_id', 'content_type_id', 'tag_id')
+    tagged_items = [ExtrasTaggedItem(**tagged_item) for tagged_item in tagged_items_values]
+    ExtrasTaggedItem.objects.bulk_create(tagged_items)
+
+
+def delete_taggit_taggeditems(apps, schema_editor):
+    """
+    Delete all TaggedItem instances from taggit_taggeditem
+    """
+    TaggitTaggedItem = apps.get_model('taggit', 'TaggedItem')
+    TaggitTaggedItem.objects.all().delete()
+
+
+def delete_taggit_tags(apps, schema_editor):
+    """
+    Delete all Tag instances from taggit_tag
+    """
+    TaggitTag = apps.get_model('taggit', 'Tag')
+    TaggitTag.objects.all().delete()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0017_tag_taggeditem'),
+        ('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'),
+    ]
+
+    operations = [
+        migrations.RunPython(copy_tags),
+        migrations.RunPython(copy_taggeditems),
+        migrations.RunPython(delete_taggit_taggeditems),
+        migrations.RunPython(delete_taggit_tags),
+    ]

+ 34 - 0
netbox/extras/migrations/0019_add_color_comments_changelog_to_tag.py

@@ -0,0 +1,34 @@
+# 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', '0018_tag_data'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='tag',
+            name='color',
+            field=utilities.fields.ColorField(max_length=6, default='9e9e9e'),
+        ),
+        migrations.AddField(
+            model_name='tag',
+            name='comments',
+            field=models.TextField(blank=True, default=''),
+        ),
+        migrations.AddField(
+            model_name='tag',
+            name='created',
+            field=models.DateField(auto_now_add=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='tag',
+            name='last_updated',
+            field=models.DateTimeField(auto_now=True, null=True),
+        ),
+    ]

+ 36 - 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,37 @@ class ObjectChange(models.Model):
             self.object_repr,
             self.object_data,
         )
+
+
+#
+# Tags
+#
+
+# TODO: figure out a way around this circular import for ObjectChange
+from utilities.models import ChangeLoggedModel  # noqa: E402
+
+
+class Tag(TagBase, ChangeLoggedModel):
+    color = ColorField(
+        default='9e9e9e'
+    )
+    comments = models.TextField(
+        blank=True,
+        default=''
+    )
+
+    def get_absolute_url(self):
+        return reverse('extras:tag', args=[self.slug])
+
+
+class TaggedItem(GenericTaggedItemBase):
+    tag = models.ForeignKey(
+        to=Tag,
+        related_name="%(app_label)s_%(class)s_items",
+        on_delete=models.CASCADE
+    )
+
+    class Meta:
+        index_together = (
+            ("content_type", "object_id")
+        )

+ 7 - 4
netbox/extras/tables.py

@@ -1,11 +1,13 @@
 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 = """
+<a href="{% url 'extras:tag_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
+    <i class="fa fa-history"></i>
+</a>
 {% if perms.taggit.change_tag %}
     <a href="{% url 'extras:tag_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
 {% endif %}
@@ -71,10 +73,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):

+ 3 - 0
netbox/extras/urls.py

@@ -1,6 +1,8 @@
 from django.conf.urls import url
 
 from extras import views
+from extras.models import Tag
+
 
 app_name = 'extras'
 urlpatterns = [
@@ -11,6 +13,7 @@ urlpatterns = [
     url(r'^tags/(?P<slug>[\w-]+)/$', views.TagView.as_view(), name='tag'),
     url(r'^tags/(?P<slug>[\w-]+)/edit/$', views.TagEditView.as_view(), name='tag_edit'),
     url(r'^tags/(?P<slug>[\w-]+)/delete/$', views.TagDeleteView.as_view(), name='tag_delete'),
+    url(r'^tags/(?P<slug>[\w-]+)/changelog/$', views.ObjectChangeLogView.as_view(), name='tag_changelog', kwargs={'model': Tag}),
 
     # Config contexts
     url(r'^config-contexts/$', views.ConfigContextListView.as_view(), name='configcontext_list'),

+ 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', '0017_tag_taggeditem'),
+    ]
+
+    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:

+ 1 - 1
netbox/netbox/settings.py

@@ -22,7 +22,7 @@ except ImportError:
     )
 
 
-VERSION = '2.5.8-dev'
+VERSION = '2.6.0-dev'
 
 BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 

+ 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', '0017_tag_taggeditem'),
+    ]
+
+    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']

+ 3 - 0
netbox/templates/dcim/device.html

@@ -445,6 +445,7 @@
                                 {% endif %}
                                 <th>Name</th>
                                 <th>Status</th>
+                                <th>Description</th>
                                 <th colspan="2">Installed Device</th>
                                 <th></th>
                             </tr>
@@ -570,6 +571,7 @@
                                     <th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
                                 {% endif %}
                                 <th>Name</th>
+                                <th>Description</th>
                                 <th>Cable</th>
                                 <th colspan="2">Connection</th>
                                 <th></th>
@@ -625,6 +627,7 @@
                                     <th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
                                 {% endif %}
                                 <th>Name</th>
+                                <th>Description</th>
                                 <th>Cable</th>
                                 <th colspan="2">Connection</th>
                                 <th></th>

+ 5 - 0
netbox/templates/dcim/inc/consoleport.html

@@ -5,6 +5,11 @@
         <i class="fa fa-fw fa-keyboard-o"></i> {{ cp }}
     </td>
 
+    {# Description #}
+    <td>
+        {{ cp.description }}
+    </td>
+
     {# Cable #}
     <td>
         {% if cp.cable %}

+ 8 - 1
netbox/templates/dcim/inc/consoleserverport.html

@@ -1,3 +1,5 @@
+{% load helpers %}
+
 <tr class="consoleserverport{% if csp.cable.status %} success{% elif csp.cable %} info{% endif %}">
 
     {# Checkbox #}
@@ -12,12 +14,17 @@
         <i class="fa fa-fw fa-keyboard-o"></i> {{ csp }}
     </td>
 
+    {# Description #}
+    <td>
+        {{ csp.description|placeholder }}
+    </td>
+
     {# Cable #}
     <td>
         {% if csp.cable %}
             <a href="{{ csp.cable.get_absolute_url }}">{{ csp.cable }}</a>
         {% else %}
-            &mdash;
+            <span class="text-muted">&mdash;</span>
         {% endif %}
     </td>
 

+ 25 - 7
netbox/templates/dcim/inc/devicebay.html

@@ -1,16 +1,35 @@
+{% load helpers %}
+
 <tr class="devicebay">
     {% if perms.dcim.change_devicebay or perms.dcim.delete_devicebay %}
         <td class="pk">
             <input name="pk" type="checkbox" value="{{ devicebay.pk }}" />
         </td>
     {% endif %}
+
+    {# Name #}
     <td>
         <i class="fa fa-fw fa-{% if devicebay.installed_device %}dot-circle-o{% else %}circle-o{% endif %}"></i> {{ devicebay.name }}
     </td>
+
+    {# Status #}
+    <td>
+        {% if devicebay.installed_device %}
+            <span class="label label-{{ devicebay.installed_device.get_status_class }}">
+                {{ devicebay.installed_device.get_status_display }}
+            </span>
+        {% else %}
+            <span class="label label-default">Vacant</span>
+        {% endif %}
+    </td>
+
+    {# Description #}
+    <td>
+        {{ devicebay.description|placeholder }}
+    </td>
+
+    {# Installed device #}
     {% if devicebay.installed_device %}
-        <td>
-            <span class="label label-{{ devicebay.installed_device.get_status_class }}">{{ devicebay.installed_device.get_status_display }}</span>
-        </td>
         <td>
             <a href="{% url 'dcim:device' pk=devicebay.installed_device.pk %}">{{ devicebay.installed_device }}</a>
         </td>
@@ -18,11 +37,10 @@
             <span>{{ devicebay.installed_device.device_type.display_name }}</span>
         </td>
     {% else %}
-        <td></td>
-        <td colspan="2">
-            <span class="text-muted">Vacant</span>
-        </td>
+        <td colspan="2"></td>
     {% endif %}
+
+    {# Actions #}
     <td class="text-right">
         {% if perms.dcim.change_devicebay %}
             {% if devicebay.installed_device %}

+ 8 - 1
netbox/templates/dcim/inc/poweroutlet.html

@@ -1,3 +1,5 @@
+{% load helpers %}
+
 <tr class="poweroutlet{% if po.cable.status %} success{% elif po.cable %} info{% endif %}">
 
     {# Checkbox #}
@@ -12,12 +14,17 @@
         <i class="fa fa-fw fa-bolt"></i> {{ po }}
     </td>
 
+    {# Description #}
+    <td>
+        {{ po.description|placeholder }}
+    </td>
+
     {# Cable #}
     <td>
         {% if po.cable %}
             <a href="{{ po.cable.get_absolute_url }}">{{ po.cable }}</a>
         {% else %}
-            &mdash;
+            <span class="text-muted">&mdash;</span>
         {% endif %}
     </td>
 

+ 5 - 0
netbox/templates/dcim/inc/powerport.html

@@ -5,6 +5,11 @@
         <i class="fa fa-fw fa-bolt"></i> {{ pp }}
     </td>
 
+    {# Description #}
+    <td>
+        {{ pp.description }}
+    </td>
+
     {# Cable #}
     <td>
         {% if pp.cable %}

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

@@ -31,6 +31,15 @@
         {% endif %}
     </div>
     <h1>{% block title %}Tag: {{ tag }}{% endblock %}</h1>
+    {% include 'inc/created_updated.html' with obj=tag %}
+    <ul class="nav nav-tabs">
+        <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
+            <a href="{{ tag.get_absolute_url }}">Tag</a>
+        </li>
+        <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
+            <a href="{% url 'extras:tag_changelog' slug=tag.slug %}">Changelog</a>
+        </li>
+    </ul>
 {% endblock %}
 
 {% block content %}
@@ -59,8 +68,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', '0017_tag_taggeditem'),
+    ]
+
+    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
 

+ 11 - 4
netbox/virtualization/api/views.py

@@ -50,16 +50,23 @@ class VirtualMachineViewSet(CustomFieldModelViewSet):
 
     def get_serializer_class(self):
         """
-        Include rendered config context when retrieving a single VirtualMachine.
+        Select the specific serializer based on the request context.
+
+        If the `brief` query param equates to True, return the NestedVirtualMachineSerializer
+
+        If the `exclude` query param includes `config_context` as a value, return the VirtualMachineSerializer
+
+        Else, return the VirtualMachineWithConfigContextSerializer
         """
-        if self.action == 'retrieve':
-            return serializers.VirtualMachineWithConfigContextSerializer
 
         request = self.get_serializer_context()['request']
         if request.query_params.get('brief', False):
             return serializers.NestedVirtualMachineSerializer
 
-        return serializers.VirtualMachineSerializer
+        elif 'config_context' in request.query_params.get('exclude', []):
+            return serializers.VirtualMachineSerializer
+
+        return serializers.VirtualMachineWithConfigContextSerializer
 
 
 class InterfaceViewSet(ModelViewSet):

+ 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', '0017_tag_taggeditem'),
+    ]
+
+    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',

+ 29 - 6
netbox/virtualization/tests/test_api.py

@@ -337,6 +337,14 @@ class VirtualMachineTest(APITestCase):
         self.virtualmachine1 = VirtualMachine.objects.create(name='Test Virtual Machine 1', cluster=self.cluster1)
         self.virtualmachine2 = VirtualMachine.objects.create(name='Test Virtual Machine 2', cluster=self.cluster1)
         self.virtualmachine3 = VirtualMachine.objects.create(name='Test Virtual Machine 3', cluster=self.cluster1)
+        self.virtualmachine_with_context_data = VirtualMachine.objects.create(
+            name='VM with context data',
+            cluster=self.cluster1,
+            local_context_data={
+                'A': 1,
+                'B': 2
+            }
+        )
 
     def test_get_virtualmachine(self):
 
@@ -350,7 +358,7 @@ class VirtualMachineTest(APITestCase):
         url = reverse('virtualization-api:virtualmachine-list')
         response = self.client.get(url, **self.header)
 
-        self.assertEqual(response.data['count'], 3)
+        self.assertEqual(response.data['count'], 4)
 
     def test_list_virtualmachines_brief(self):
 
@@ -373,7 +381,7 @@ class VirtualMachineTest(APITestCase):
         response = self.client.post(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
-        self.assertEqual(VirtualMachine.objects.count(), 4)
+        self.assertEqual(VirtualMachine.objects.count(), 5)
         virtualmachine4 = VirtualMachine.objects.get(pk=response.data['id'])
         self.assertEqual(virtualmachine4.name, data['name'])
         self.assertEqual(virtualmachine4.cluster.pk, data['cluster'])
@@ -388,7 +396,7 @@ class VirtualMachineTest(APITestCase):
         response = self.client.post(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
-        self.assertEqual(VirtualMachine.objects.count(), 3)
+        self.assertEqual(VirtualMachine.objects.count(), 4)
 
     def test_create_virtualmachine_bulk(self):
 
@@ -411,7 +419,7 @@ class VirtualMachineTest(APITestCase):
         response = self.client.post(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
-        self.assertEqual(VirtualMachine.objects.count(), 6)
+        self.assertEqual(VirtualMachine.objects.count(), 7)
         self.assertEqual(response.data[0]['name'], data[0]['name'])
         self.assertEqual(response.data[1]['name'], data[1]['name'])
         self.assertEqual(response.data[2]['name'], data[2]['name'])
@@ -438,7 +446,7 @@ class VirtualMachineTest(APITestCase):
         response = self.client.put(url, data, format='json', **self.header)
 
         self.assertHttpStatus(response, status.HTTP_200_OK)
-        self.assertEqual(VirtualMachine.objects.count(), 3)
+        self.assertEqual(VirtualMachine.objects.count(), 4)
         virtualmachine1 = VirtualMachine.objects.get(pk=response.data['id'])
         self.assertEqual(virtualmachine1.name, data['name'])
         self.assertEqual(virtualmachine1.cluster.pk, data['cluster'])
@@ -451,7 +459,22 @@ class VirtualMachineTest(APITestCase):
         response = self.client.delete(url, **self.header)
 
         self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
-        self.assertEqual(VirtualMachine.objects.count(), 2)
+        self.assertEqual(VirtualMachine.objects.count(), 3)
+
+    def test_config_context_included_by_default_in_list_view(self):
+
+        url = reverse('virtualization-api:virtualmachine-list')
+        url = '{}?id__in={}'.format(url, self.virtualmachine_with_context_data.pk)
+        response = self.client.get(url, **self.header)
+
+        self.assertEqual(response.data['results'][0].get('config_context', {}).get('A'), 1)
+
+    def test_config_context_excluded(self):
+
+        url = reverse('virtualization-api:virtualmachine-list') + '?exclude=config_context'
+        response = self.client.get(url, **self.header)
+
+        self.assertFalse('config_context' in response.data['results'][0])
 
 
 class InterfaceTest(APITestCase):