Explorar el Código

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

John Anderson hace 7 años
padre
commit
5991bd368c
Se han modificado 47 ficheros con 810 adiciones y 137 borrados
  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)
 v2.5.8 (FUTURE)
 
 
 ## Bug Fixes
 ## 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.constants import CONNECTION_STATUS_CHOICES, STATUS_CLASSES
 from dcim.fields import ASNField
 from dcim.fields import ASNField
 from dcim.models import CableTermination
 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.models import ChangeLoggedModel
 from utilities.utils import serialize_object
 from utilities.utils import serialize_object
 from .constants import CIRCUIT_STATUS_ACTIVE, CIRCUIT_STATUS_CHOICES, TERM_SIDE_CHOICES
 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'
         object_id_field='obj_id'
     )
     )
 
 
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']
     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'
         object_id_field='obj_id'
     )
     )
 
 
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = [
     csv_headers = [
         'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
         '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:
     class Meta:
         model = ConsoleServerPort
         model = ConsoleServerPort
         fields = [
         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:
     class Meta:
         model = ConsolePort
         model = ConsolePort
         fields = [
         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:
     class Meta:
         model = PowerOutlet
         model = PowerOutlet
         fields = [
         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:
     class Meta:
         model = PowerPort
         model = PowerPort
         fields = [
         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:
     class Meta:
         model = DeviceBay
         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):
     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']
         request = self.get_serializer_context()['request']
         if request.query_params.get('brief', False):
         if request.query_params.get('brief', False):
             return serializers.NestedDeviceSerializer
             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')
     @action(detail=True, url_path='napalm')
     def napalm(self, request, pk):
     def napalm(self, request, pk):

+ 12 - 10
netbox/dcim/filters.py

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

+ 53 - 5
netbox/dcim/forms.py

@@ -1854,7 +1854,7 @@ class ConsolePortForm(BootstrapMixin, forms.ModelForm):
     class Meta:
     class Meta:
         model = ConsolePort
         model = ConsolePort
         fields = [
         fields = [
-            'device', 'name', 'tags',
+            'device', 'name', 'description', 'tags',
         ]
         ]
         widgets = {
         widgets = {
             'device': forms.HiddenInput(),
             'device': forms.HiddenInput(),
@@ -1865,6 +1865,10 @@ class ConsolePortCreateForm(ComponentForm):
     name_pattern = ExpandableNameField(
     name_pattern = ExpandableNameField(
         label='Name'
         label='Name'
     )
     )
+    description = forms.CharField(
+        max_length=100,
+        required=False
+    )
     tags = TagField(
     tags = TagField(
         required=False
         required=False
     )
     )
@@ -1882,7 +1886,7 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm):
     class Meta:
     class Meta:
         model = ConsoleServerPort
         model = ConsoleServerPort
         fields = [
         fields = [
-            'device', 'name', 'tags',
+            'device', 'name', 'description', 'tags',
         ]
         ]
         widgets = {
         widgets = {
             'device': forms.HiddenInput(),
             'device': forms.HiddenInput(),
@@ -1893,11 +1897,31 @@ class ConsoleServerPortCreateForm(ComponentForm):
     name_pattern = ExpandableNameField(
     name_pattern = ExpandableNameField(
         label='Name'
         label='Name'
     )
     )
+    description = forms.CharField(
+        max_length=100,
+        required=False
+    )
     tags = TagField(
     tags = TagField(
         required=False
         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):
 class ConsoleServerPortBulkRenameForm(BulkRenameForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=ConsoleServerPort.objects.all(),
         queryset=ConsoleServerPort.objects.all(),
@@ -1924,7 +1948,7 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm):
     class Meta:
     class Meta:
         model = PowerPort
         model = PowerPort
         fields = [
         fields = [
-            'device', 'name', 'tags',
+            'device', 'name', 'description', 'tags',
         ]
         ]
         widgets = {
         widgets = {
             'device': forms.HiddenInput(),
             'device': forms.HiddenInput(),
@@ -1935,6 +1959,10 @@ class PowerPortCreateForm(ComponentForm):
     name_pattern = ExpandableNameField(
     name_pattern = ExpandableNameField(
         label='Name'
         label='Name'
     )
     )
+    description = forms.CharField(
+        max_length=100,
+        required=False
+    )
     tags = TagField(
     tags = TagField(
         required=False
         required=False
     )
     )
@@ -1952,7 +1980,7 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm):
     class Meta:
     class Meta:
         model = PowerOutlet
         model = PowerOutlet
         fields = [
         fields = [
-            'device', 'name', 'tags',
+            'device', 'name', 'description', 'tags',
         ]
         ]
         widgets = {
         widgets = {
             'device': forms.HiddenInput(),
             'device': forms.HiddenInput(),
@@ -1963,11 +1991,31 @@ class PowerOutletCreateForm(ComponentForm):
     name_pattern = ExpandableNameField(
     name_pattern = ExpandableNameField(
         label='Name'
         label='Name'
     )
     )
+    description = forms.CharField(
+        max_length=100,
+        required=False
+    )
     tags = TagField(
     tags = TagField(
         required=False
         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):
 class PowerOutletBulkRenameForm(BulkRenameForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=PowerOutlet.objects.all(),
         queryset=PowerOutlet.objects.all(),
@@ -2781,7 +2829,7 @@ class DeviceBayForm(BootstrapMixin, forms.ModelForm):
     class Meta:
     class Meta:
         model = DeviceBay
         model = DeviceBay
         fields = [
         fields = [
-            'device', 'name', 'tags',
+            'device', 'name', 'description', 'tags',
         ]
         ]
         widgets = {
         widgets = {
             'device': forms.HiddenInput(),
             '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 taggit.managers import TaggableManager
 from timezone_field import TimeZoneField
 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.fields import ColorField
 from utilities.managers import NaturalOrderingManager
 from utilities.managers import NaturalOrderingManager
 from utilities.models import ChangeLoggedModel
 from utilities.models import ChangeLoggedModel
@@ -46,6 +46,10 @@ class ComponentTemplateModel(models.Model):
 
 
 
 
 class ComponentModel(models.Model):
 class ComponentModel(models.Model):
+    description = models.CharField(
+        max_length=100,
+        blank=True
+    )
 
 
     class Meta:
     class Meta:
         abstract = True
         abstract = True
@@ -319,7 +323,7 @@ class Site(ChangeLoggedModel, CustomFieldModel):
     )
     )
 
 
     objects = NaturalOrderingManager()
     objects = NaturalOrderingManager()
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = [
     csv_headers = [
         'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
         'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
@@ -566,7 +570,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
     )
     )
 
 
     objects = NaturalOrderingManager()
     objects = NaturalOrderingManager()
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = [
     csv_headers = [
         'site', 'group_name', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', 'width',
         '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'
         object_id_field='obj_id'
     )
     )
 
 
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = [
     csv_headers = [
         'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'comments',
         'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'comments',
@@ -1455,7 +1459,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
     )
     )
 
 
     objects = NaturalOrderingManager()
     objects = NaturalOrderingManager()
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = [
     csv_headers = [
         'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
         'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
@@ -1743,9 +1747,9 @@ class ConsolePort(CableTermination, ComponentModel):
     )
     )
 
 
     objects = DeviceComponentManager()
     objects = DeviceComponentManager()
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
-    csv_headers = ['device', 'name']
+    csv_headers = ['device', 'name', 'description']
 
 
     class Meta:
     class Meta:
         ordering = ['device', 'name']
         ordering = ['device', 'name']
@@ -1761,6 +1765,7 @@ class ConsolePort(CableTermination, ComponentModel):
         return (
         return (
             self.device.identifier,
             self.device.identifier,
             self.name,
             self.name,
+            self.description,
         )
         )
 
 
 
 
@@ -1786,9 +1791,9 @@ class ConsoleServerPort(CableTermination, ComponentModel):
     )
     )
 
 
     objects = DeviceComponentManager()
     objects = DeviceComponentManager()
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
-    csv_headers = ['device', 'name']
+    csv_headers = ['device', 'name', 'description']
 
 
     class Meta:
     class Meta:
         unique_together = ['device', 'name']
         unique_together = ['device', 'name']
@@ -1803,6 +1808,7 @@ class ConsoleServerPort(CableTermination, ComponentModel):
         return (
         return (
             self.device.identifier,
             self.device.identifier,
             self.name,
             self.name,
+            self.description,
         )
         )
 
 
 
 
@@ -1835,13 +1841,13 @@ class PowerPort(CableTermination, ComponentModel):
     )
     )
 
 
     objects = DeviceComponentManager()
     objects = DeviceComponentManager()
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = ['device', 'name']
     csv_headers = ['device', 'name']
 
 
     class Meta:
     class Meta:
         ordering = ['device', 'name']
         ordering = ['device', 'name']
-        unique_together = ['device', 'name']
+        unique_together = ['device', 'name', 'description']
 
 
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name
@@ -1853,6 +1859,7 @@ class PowerPort(CableTermination, ComponentModel):
         return (
         return (
             self.device.identifier,
             self.device.identifier,
             self.name,
             self.name,
+            self.description,
         )
         )
 
 
 
 
@@ -1878,12 +1885,12 @@ class PowerOutlet(CableTermination, ComponentModel):
     )
     )
 
 
     objects = DeviceComponentManager()
     objects = DeviceComponentManager()
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = ['device', 'name']
     csv_headers = ['device', 'name']
 
 
     class Meta:
     class Meta:
-        unique_together = ['device', 'name']
+        unique_together = ['device', 'name', 'description']
 
 
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name
@@ -1895,6 +1902,7 @@ class PowerOutlet(CableTermination, ComponentModel):
         return (
         return (
             self.device.identifier,
             self.device.identifier,
             self.name,
             self.name,
+            self.description,
         )
         )
 
 
 
 
@@ -1973,10 +1981,6 @@ class Interface(CableTermination, ComponentModel):
         verbose_name='OOB Management',
         verbose_name='OOB Management',
         help_text='This interface is used only for out-of-band management'
         help_text='This interface is used only for out-of-band management'
     )
     )
-    description = models.CharField(
-        max_length=100,
-        blank=True
-    )
     mode = models.PositiveSmallIntegerField(
     mode = models.PositiveSmallIntegerField(
         choices=IFACE_MODE_CHOICES,
         choices=IFACE_MODE_CHOICES,
         blank=True,
         blank=True,
@@ -1998,7 +2002,7 @@ class Interface(CableTermination, ComponentModel):
     )
     )
 
 
     objects = InterfaceManager()
     objects = InterfaceManager()
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = [
     csv_headers = [
         'device', 'virtual_machine', 'name', 'lag', 'form_factor', 'enabled', 'mac_address', 'mtu', 'mgmt_only',
         'device', 'virtual_machine', 'name', 'lag', 'form_factor', 'enabled', 'mac_address', 'mtu', 'mgmt_only',
@@ -2193,13 +2197,9 @@ class FrontPort(CableTermination, ComponentModel):
         default=1,
         default=1,
         validators=[MinValueValidator(1), MaxValueValidator(64)]
         validators=[MinValueValidator(1), MaxValueValidator(64)]
     )
     )
-    description = models.CharField(
-        max_length=100,
-        blank=True
-    )
 
 
     objects = DeviceComponentManager()
     objects = DeviceComponentManager()
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = ['device', 'name', 'type', 'rear_port', 'rear_port_position', 'description']
     csv_headers = ['device', 'name', 'type', 'rear_port', 'rear_port_position', 'description']
 
 
@@ -2259,13 +2259,9 @@ class RearPort(CableTermination, ComponentModel):
         default=1,
         default=1,
         validators=[MinValueValidator(1), MaxValueValidator(64)]
         validators=[MinValueValidator(1), MaxValueValidator(64)]
     )
     )
-    description = models.CharField(
-        max_length=100,
-        blank=True
-    )
 
 
     objects = DeviceComponentManager()
     objects = DeviceComponentManager()
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = ['device', 'name', 'type', 'positions', 'description']
     csv_headers = ['device', 'name', 'type', 'positions', 'description']
 
 
@@ -2312,9 +2308,9 @@ class DeviceBay(ComponentModel):
     )
     )
 
 
     objects = DeviceComponentManager()
     objects = DeviceComponentManager()
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
-    csv_headers = ['device', 'name', 'installed_device']
+    csv_headers = ['device', 'name', 'installed_device', 'description']
 
 
     class Meta:
     class Meta:
         ordering = ['device', 'name']
         ordering = ['device', 'name']
@@ -2331,6 +2327,7 @@ class DeviceBay(ComponentModel):
             self.device.identifier,
             self.device.identifier,
             self.name,
             self.name,
             self.installed_device.identifier if self.installed_device else None,
             self.installed_device.identifier if self.installed_device else None,
+            self.description,
         )
         )
 
 
     def clean(self):
     def clean(self):
@@ -2400,12 +2397,8 @@ class InventoryItem(ComponentModel):
         default=False,
         default=False,
         verbose_name='Discovered'
         verbose_name='Discovered'
     )
     )
-    description = models.CharField(
-        max_length=100,
-        blank=True
-    )
 
 
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = [
     csv_headers = [
         'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description',
         'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description',
@@ -2452,7 +2445,7 @@ class VirtualChassis(ChangeLoggedModel):
         blank=True
         blank=True
     )
     )
 
 
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = ['master', 'domain']
     csv_headers = ['master', 'domain']
 
 

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

@@ -1791,6 +1791,16 @@ class DeviceTest(APITestCase):
             site=self.site1,
             site=self.site1,
             cluster=self.cluster1
             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):
     def test_get_device(self):
 
 
@@ -1806,7 +1816,7 @@ class DeviceTest(APITestCase):
         url = reverse('dcim-api:device-list')
         url = reverse('dcim-api:device-list')
         response = self.client.get(url, **self.header)
         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):
     def test_list_devices_brief(self):
 
 
@@ -1832,7 +1842,7 @@ class DeviceTest(APITestCase):
         response = self.client.post(url, data, format='json', **self.header)
         response = self.client.post(url, data, format='json', **self.header)
 
 
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         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'])
         device4 = Device.objects.get(pk=response.data['id'])
         self.assertEqual(device4.device_type_id, data['device_type'])
         self.assertEqual(device4.device_type_id, data['device_type'])
         self.assertEqual(device4.device_role_id, data['device_role'])
         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)
         response = self.client.post(url, data, format='json', **self.header)
 
 
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         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[0]['name'], data[0]['name'])
         self.assertEqual(response.data[1]['name'], data[1]['name'])
         self.assertEqual(response.data[1]['name'], data[1]['name'])
         self.assertEqual(response.data[2]['name'], data[2]['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)
         response = self.client.put(url, data, format='json', **self.header)
 
 
         self.assertHttpStatus(response, status.HTTP_200_OK)
         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'])
         device1 = Device.objects.get(pk=response.data['id'])
         self.assertEqual(device1.device_type_id, data['device_type'])
         self.assertEqual(device1.device_type_id, data['device_type'])
         self.assertEqual(device1.device_role_id, data['device_role'])
         self.assertEqual(device1.device_role_id, data['device_role'])
@@ -1906,7 +1916,21 @@ class DeviceTest(APITestCase):
         response = self.client.delete(url, **self.header)
         response = self.client.delete(url, **self.header)
 
 
         self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
         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):
 class ConsolePortTest(APITestCase):

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

@@ -1,6 +1,5 @@
 from django.core.exceptions import ObjectDoesNotExist
 from django.core.exceptions import ObjectDoesNotExist
 from rest_framework import serializers
 from rest_framework import serializers
-from taggit.models import Tag
 
 
 from dcim.api.nested_serializers import (
 from dcim.api.nested_serializers import (
     NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedRackSerializer,
     NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedRackSerializer,
@@ -10,6 +9,7 @@ from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site
 from extras.constants import *
 from extras.constants import *
 from extras.models import (
 from extras.models import (
     ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap,
     ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap,
+    Tag
 )
 )
 from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer
 from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
@@ -80,7 +80,7 @@ class TagSerializer(ValidatedModelSerializer):
 
 
     class Meta:
     class Meta:
         model = Tag
         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.exceptions import PermissionDenied
 from rest_framework.response import Response
 from rest_framework.response import Response
 from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
 from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
-from taggit.models import Tag
 
 
 from extras import filters
 from extras import filters
 from extras.models import (
 from extras.models import (
     ConfigContext, CustomField, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap,
     ConfigContext, CustomField, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap,
+    Tag
 )
 )
 from extras.reports import get_report, get_reports
 from extras.reports import get_report, get_reports
 from utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, ModelViewSet
 from utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, ModelViewSet
@@ -115,7 +115,7 @@ class TopologyMapViewSet(ModelViewSet):
 #
 #
 
 
 class TagViewSet(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
     serializer_class = serializers.TagSerializer
     filterset_class = filters.TagFilter
     filterset_class = filters.TagFilter
 
 

+ 1 - 2
netbox/extras/filters.py

@@ -1,12 +1,11 @@
 import django_filters
 import django_filters
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.db.models import Q
 from django.db.models import Q
-from taggit.models import Tag
 
 
 from dcim.models import DeviceRole, Platform, Region, Site
 from dcim.models import DeviceRole, Platform, Region, Site
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
 from .constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_TYPE_BOOLEAN, CF_TYPE_SELECT
 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):
 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 django.core.exceptions import ObjectDoesNotExist
 from mptt.forms import TreeNodeMultipleChoiceField
 from mptt.forms import TreeNodeMultipleChoiceField
 from taggit.forms import TagField
 from taggit.forms import TagField
-from taggit.models import Tag
 
 
 from dcim.models import DeviceRole, Platform, Region, Site
 from dcim.models import DeviceRole, Platform, Region, Site
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
 from utilities.forms import (
 from utilities.forms import (
     add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ContentTypeSelect,
     add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ContentTypeSelect,
-    FilterChoiceField, FilterTreeNodeMultipleChoiceField, LaxURLField, JSONField, SlugField,
+    FilterChoiceField, FilterTreeNodeMultipleChoiceField, LaxURLField, JSONField, SlugField, CommentField
 )
 )
 from .constants import (
 from .constants import (
     CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL,
     CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL,
     OBJECTCHANGE_ACTION_CHOICES,
     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):
 class TagForm(BootstrapMixin, forms.ModelForm):
     slug = SlugField()
     slug = SlugField()
+    comments = CommentField()
 
 
     class Meta:
     class Meta:
         model = Tag
         model = Tag
         fields = [
         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.http import HttpResponse
 from django.template import Template, Context
 from django.template import Template, Context
 from django.urls import reverse
 from django.urls import reverse
+from taggit.models import TagBase, GenericTaggedItemBase
 
 
 from dcim.constants import CONNECTION_STATUS_CONNECTED
 from dcim.constants import CONNECTION_STATUS_CONNECTED
+from utilities.fields import ColorField
 from utilities.utils import deepmerge, foreground_color
 from utilities.utils import deepmerge, foreground_color
 from .constants import *
 from .constants import *
 from .querysets import ConfigContextQuerySet
 from .querysets import ConfigContextQuerySet
@@ -860,3 +862,37 @@ class ObjectChange(models.Model):
             self.object_repr,
             self.object_repr,
             self.object_data,
             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
 import django_tables2 as tables
 from django_tables2.utils import Accessor
 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 = """
 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 %}
 {% 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>
     <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 %}
 {% endif %}
@@ -71,10 +73,11 @@ class TagTable(BaseTable):
         attrs={'td': {'class': 'text-right'}},
         attrs={'td': {'class': 'text-right'}},
         verbose_name=''
         verbose_name=''
     )
     )
+    color = ColorColumn()
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Tag
         model = Tag
-        fields = ('pk', 'name', 'items', 'slug', 'actions')
+        fields = ('pk', 'name', 'items', 'slug', 'color', 'actions')
 
 
 
 
 class TaggedItemTable(BaseTable):
 class TaggedItemTable(BaseTable):

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

@@ -1,11 +1,10 @@
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.urls import reverse
 from django.urls import reverse
 from rest_framework import status
 from rest_framework import status
-from taggit.models import Tag
 
 
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Region, Site
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Region, Site
 from extras.constants import GRAPH_TYPE_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 tenancy.models import Tenant, TenantGroup
 from utilities.testing import APITestCase
 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.contrib.auth.models import User
 from django.test import Client, TestCase
 from django.test import Client, TestCase
 from django.urls import reverse
 from django.urls import reverse
-from taggit.models import Tag
 
 
 from dcim.models import Site
 from dcim.models import Site
-from extras.models import ConfigContext, ObjectChange
+from extras.models import ConfigContext, ObjectChange, Tag
 
 
 
 
 class TagTestCase(TestCase):
 class TagTestCase(TestCase):

+ 3 - 0
netbox/extras/urls.py

@@ -1,6 +1,8 @@
 from django.conf.urls import url
 from django.conf.urls import url
 
 
 from extras import views
 from extras import views
+from extras.models import Tag
+
 
 
 app_name = 'extras'
 app_name = 'extras'
 urlpatterns = [
 urlpatterns = [
@@ -11,6 +13,7 @@ urlpatterns = [
     url(r'^tags/(?P<slug>[\w-]+)/$', views.TagView.as_view(), name='tag'),
     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-]+)/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-]+)/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
     # Config contexts
     url(r'^config-contexts/$', views.ConfigContextListView.as_view(), name='configcontext_list'),
     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.utils.safestring import mark_safe
 from django.views.generic import View
 from django.views.generic import View
 from django_tables2 import RequestConfig
 from django_tables2 import RequestConfig
-from taggit.models import Tag, TaggedItem
 
 
 from utilities.forms import ConfirmationForm
 from utilities.forms import ConfirmationForm
 from utilities.paginator import EnhancedPaginator
 from utilities.paginator import EnhancedPaginator
@@ -19,7 +18,7 @@ from .forms import (
     ConfigContextForm, ConfigContextBulkEditForm, ConfigContextFilterForm, ImageAttachmentForm, ObjectChangeFilterForm,
     ConfigContextForm, ConfigContextBulkEditForm, ConfigContextFilterForm, ImageAttachmentForm, ObjectChangeFilterForm,
     TagFilterForm, TagForm,
     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 .reports import get_report, get_reports
 from .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemTable
 from .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemTable
 
 
@@ -30,7 +29,7 @@ from .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemT
 
 
 class TagListView(ObjectListView):
 class TagListView(ObjectListView):
     queryset = Tag.objects.annotate(
     queryset = Tag.objects.annotate(
-        items=Count('taggit_taggeditem_items')
+        items=Count('extras_taggeditem_items')
     ).order_by(
     ).order_by(
         'name'
         'name'
     )
     )
@@ -69,22 +68,23 @@ class TagView(View):
 
 
 
 
 class TagEditView(PermissionRequiredMixin, ObjectEditView):
 class TagEditView(PermissionRequiredMixin, ObjectEditView):
-    permission_required = 'taggit.change_tag'
+    permission_required = 'extras.change_tag'
     model = Tag
     model = Tag
     model_form = TagForm
     model_form = TagForm
     default_return_url = 'extras:tag_list'
     default_return_url = 'extras:tag_list'
+    template_name = 'extras/tag_edit.html'
 
 
 
 
 class TagDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 class TagDeleteView(PermissionRequiredMixin, ObjectDeleteView):
-    permission_required = 'taggit.delete_tag'
+    permission_required = 'extras.delete_tag'
     model = Tag
     model = Tag
     default_return_url = 'extras:tag_list'
     default_return_url = 'extras:tag_list'
 
 
 
 
 class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
-    permission_required = 'taggit.delete_tag'
+    permission_required = 'extras.delete_tag'
     queryset = Tag.objects.annotate(
     queryset = Tag.objects.annotate(
-        items=Count('taggit_taggeditem_items')
+        items=Count('extras_taggeditem_items')
     ).order_by(
     ).order_by(
         'name'
         '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 taggit.managers import TaggableManager
 
 
 from dcim.models import Interface
 from dcim.models import Interface
-from extras.models import CustomFieldModel
+from extras.models import CustomFieldModel, TaggedItem
 from utilities.models import ChangeLoggedModel
 from utilities.models import ChangeLoggedModel
 from .constants import *
 from .constants import *
 from .fields import IPNetworkField, IPAddressField
 from .fields import IPNetworkField, IPAddressField
@@ -55,7 +55,7 @@ class VRF(ChangeLoggedModel, CustomFieldModel):
         object_id_field='obj_id'
         object_id_field='obj_id'
     )
     )
 
 
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
     csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
 
 
@@ -154,7 +154,7 @@ class Aggregate(ChangeLoggedModel, CustomFieldModel):
         object_id_field='obj_id'
         object_id_field='obj_id'
     )
     )
 
 
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = ['prefix', 'rir', 'date_added', 'description']
     csv_headers = ['prefix', 'rir', 'date_added', 'description']
 
 
@@ -324,7 +324,7 @@ class Prefix(ChangeLoggedModel, CustomFieldModel):
     )
     )
 
 
     objects = PrefixQuerySet.as_manager()
     objects = PrefixQuerySet.as_manager()
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = [
     csv_headers = [
         'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description',
         'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description',
@@ -583,7 +583,7 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
     )
     )
 
 
     objects = IPAddressManager()
     objects = IPAddressManager()
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = [
     csv_headers = [
         'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface_name', 'is_primary',
         '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'
         object_id_field='obj_id'
     )
     )
 
 
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description']
     csv_headers = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description']
 
 
@@ -892,7 +892,7 @@ class Service(ChangeLoggedModel, CustomFieldModel):
         object_id_field='obj_id'
         object_id_field='obj_id'
     )
     )
 
 
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = ['device', 'virtual_machine', 'name', 'protocol', 'description']
     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.admin import AdminSite
 from django.contrib.auth.admin import GroupAdmin, UserAdmin
 from django.contrib.auth.admin import GroupAdmin, UserAdmin
 from django.contrib.auth.models import Group, User
 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):
 class NetBoxAdminSite(AdminSite):
@@ -20,7 +29,7 @@ admin_site = NetBoxAdminSite(name='admin')
 # Register external models
 # Register external models
 admin_site.register(Group, GroupAdmin)
 admin_site.register(Group, GroupAdmin)
 admin_site.register(User, UserAdmin)
 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)
 # Modify the template to include an RQ link if django_rq is installed (see RQ_SHOW_ADMIN_LINK)
 if settings.WEBHOOKS_ENABLED:
 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__)))
 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 django.utils.encoding import force_bytes
 from taggit.managers import TaggableManager
 from taggit.managers import TaggableManager
 
 
-from extras.models import CustomFieldModel
+from extras.models import CustomFieldModel, TaggedItem
 from utilities.models import ChangeLoggedModel
 from utilities.models import ChangeLoggedModel
 from .exceptions import InvalidKey
 from .exceptions import InvalidKey
 from .hashers import SecretValidationHasher
 from .hashers import SecretValidationHasher
@@ -345,7 +345,7 @@ class Secret(ChangeLoggedModel, CustomFieldModel):
         object_id_field='obj_id'
         object_id_field='obj_id'
     )
     )
 
 
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
     plaintext = None
     plaintext = None
     csv_headers = ['device', 'role', 'name', 'plaintext']
     csv_headers = ['device', 'role', 'name', 'plaintext']

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

@@ -445,6 +445,7 @@
                                 {% endif %}
                                 {% endif %}
                                 <th>Name</th>
                                 <th>Name</th>
                                 <th>Status</th>
                                 <th>Status</th>
+                                <th>Description</th>
                                 <th colspan="2">Installed Device</th>
                                 <th colspan="2">Installed Device</th>
                                 <th></th>
                                 <th></th>
                             </tr>
                             </tr>
@@ -570,6 +571,7 @@
                                     <th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
                                     <th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
                                 {% endif %}
                                 {% endif %}
                                 <th>Name</th>
                                 <th>Name</th>
+                                <th>Description</th>
                                 <th>Cable</th>
                                 <th>Cable</th>
                                 <th colspan="2">Connection</th>
                                 <th colspan="2">Connection</th>
                                 <th></th>
                                 <th></th>
@@ -625,6 +627,7 @@
                                     <th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
                                     <th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
                                 {% endif %}
                                 {% endif %}
                                 <th>Name</th>
                                 <th>Name</th>
+                                <th>Description</th>
                                 <th>Cable</th>
                                 <th>Cable</th>
                                 <th colspan="2">Connection</th>
                                 <th colspan="2">Connection</th>
                                 <th></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 }}
         <i class="fa fa-fw fa-keyboard-o"></i> {{ cp }}
     </td>
     </td>
 
 
+    {# Description #}
+    <td>
+        {{ cp.description }}
+    </td>
+
     {# Cable #}
     {# Cable #}
     <td>
     <td>
         {% if cp.cable %}
         {% 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 %}">
 <tr class="consoleserverport{% if csp.cable.status %} success{% elif csp.cable %} info{% endif %}">
 
 
     {# Checkbox #}
     {# Checkbox #}
@@ -12,12 +14,17 @@
         <i class="fa fa-fw fa-keyboard-o"></i> {{ csp }}
         <i class="fa fa-fw fa-keyboard-o"></i> {{ csp }}
     </td>
     </td>
 
 
+    {# Description #}
+    <td>
+        {{ csp.description|placeholder }}
+    </td>
+
     {# Cable #}
     {# Cable #}
     <td>
     <td>
         {% if csp.cable %}
         {% if csp.cable %}
             <a href="{{ csp.cable.get_absolute_url }}">{{ csp.cable }}</a>
             <a href="{{ csp.cable.get_absolute_url }}">{{ csp.cable }}</a>
         {% else %}
         {% else %}
-            &mdash;
+            <span class="text-muted">&mdash;</span>
         {% endif %}
         {% endif %}
     </td>
     </td>
 
 

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

@@ -1,16 +1,35 @@
+{% load helpers %}
+
 <tr class="devicebay">
 <tr class="devicebay">
     {% if perms.dcim.change_devicebay or perms.dcim.delete_devicebay %}
     {% if perms.dcim.change_devicebay or perms.dcim.delete_devicebay %}
         <td class="pk">
         <td class="pk">
             <input name="pk" type="checkbox" value="{{ devicebay.pk }}" />
             <input name="pk" type="checkbox" value="{{ devicebay.pk }}" />
         </td>
         </td>
     {% endif %}
     {% endif %}
+
+    {# Name #}
     <td>
     <td>
         <i class="fa fa-fw fa-{% if devicebay.installed_device %}dot-circle-o{% else %}circle-o{% endif %}"></i> {{ devicebay.name }}
         <i class="fa fa-fw fa-{% if devicebay.installed_device %}dot-circle-o{% else %}circle-o{% endif %}"></i> {{ devicebay.name }}
     </td>
     </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 %}
     {% if devicebay.installed_device %}
-        <td>
-            <span class="label label-{{ devicebay.installed_device.get_status_class }}">{{ devicebay.installed_device.get_status_display }}</span>
-        </td>
         <td>
         <td>
             <a href="{% url 'dcim:device' pk=devicebay.installed_device.pk %}">{{ devicebay.installed_device }}</a>
             <a href="{% url 'dcim:device' pk=devicebay.installed_device.pk %}">{{ devicebay.installed_device }}</a>
         </td>
         </td>
@@ -18,11 +37,10 @@
             <span>{{ devicebay.installed_device.device_type.display_name }}</span>
             <span>{{ devicebay.installed_device.device_type.display_name }}</span>
         </td>
         </td>
     {% else %}
     {% else %}
-        <td></td>
-        <td colspan="2">
-            <span class="text-muted">Vacant</span>
-        </td>
+        <td colspan="2"></td>
     {% endif %}
     {% endif %}
+
+    {# Actions #}
     <td class="text-right">
     <td class="text-right">
         {% if perms.dcim.change_devicebay %}
         {% if perms.dcim.change_devicebay %}
             {% if devicebay.installed_device %}
             {% 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 %}">
 <tr class="poweroutlet{% if po.cable.status %} success{% elif po.cable %} info{% endif %}">
 
 
     {# Checkbox #}
     {# Checkbox #}
@@ -12,12 +14,17 @@
         <i class="fa fa-fw fa-bolt"></i> {{ po }}
         <i class="fa fa-fw fa-bolt"></i> {{ po }}
     </td>
     </td>
 
 
+    {# Description #}
+    <td>
+        {{ po.description|placeholder }}
+    </td>
+
     {# Cable #}
     {# Cable #}
     <td>
     <td>
         {% if po.cable %}
         {% if po.cable %}
             <a href="{{ po.cable.get_absolute_url }}">{{ po.cable }}</a>
             <a href="{{ po.cable.get_absolute_url }}">{{ po.cable }}</a>
         {% else %}
         {% else %}
-            &mdash;
+            <span class="text-muted">&mdash;</span>
         {% endif %}
         {% endif %}
     </td>
     </td>
 
 

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

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

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

@@ -31,6 +31,15 @@
         {% endif %}
         {% endif %}
     </div>
     </div>
     <h1>{% block title %}Tag: {{ tag }}{% endblock %}</h1>
     <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 %}
 {% endblock %}
 
 
 {% block content %}
 {% block content %}
@@ -59,8 +68,26 @@
                             {{ items_count }}
                             {{ items_count }}
                         </td>
                         </td>
                     </tr>
                     </tr>
+                    <tr>
+                        <td>Color</td>
+                        <td>
+                            <span class="label color-block" style="background-color: #{{ tag.color }}">&nbsp;</span>
+                        </td>
+                    </tr>
                 </table>
                 </table>
             </div>
             </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>
         <div class="col-md-6">
         <div class="col-md-6">
             {% include 'panel_table.html' with table=items_table heading='Tagged Objects' %}
             {% 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 %}
 {% 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 %}
 {% else %}
     <span class="label label-default">{{ tag }}</span>
     <span class="label label-default">{{ tag }}</span>
 {% endif %}
 {% 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 django.urls import reverse
 from taggit.managers import TaggableManager
 from taggit.managers import TaggableManager
 
 
-from extras.models import CustomFieldModel
+from extras.models import CustomFieldModel, TaggedItem
 from utilities.models import ChangeLoggedModel
 from utilities.models import ChangeLoggedModel
 
 
 
 
@@ -70,7 +70,7 @@ class Tenant(ChangeLoggedModel, CustomFieldModel):
         object_id_field='obj_id'
         object_id_field='obj_id'
     )
     )
 
 
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = ['name', 'slug', 'group', 'description', 'comments']
     csv_headers = ['name', 'slug', 'group', 'description', 'comments']
 
 

+ 2 - 1
netbox/utilities/filters.py

@@ -1,7 +1,8 @@
 import django_filters
 import django_filters
 from django.conf import settings
 from django.conf import settings
 from django.db.models import Q
 from django.db.models import Q
-from taggit.models import Tag
+
+from extras.models import Tag
 
 
 
 
 class NumericInFilter(django_filters.BaseInFilter, django_filters.NumberFilter):
 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
         # Construct queryset for tags list
         if hasattr(model, 'tags'):
         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:
         else:
             tags = None
             tags = None
 
 

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

@@ -50,16 +50,23 @@ class VirtualMachineViewSet(CustomFieldModelViewSet):
 
 
     def get_serializer_class(self):
     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']
         request = self.get_serializer_context()['request']
         if request.query_params.get('brief', False):
         if request.query_params.get('brief', False):
             return serializers.NestedVirtualMachineSerializer
             return serializers.NestedVirtualMachineSerializer
 
 
-        return serializers.VirtualMachineSerializer
+        elif 'config_context' in request.query_params.get('exclude', []):
+            return serializers.VirtualMachineSerializer
+
+        return serializers.VirtualMachineWithConfigContextSerializer
 
 
 
 
 class InterfaceViewSet(ModelViewSet):
 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 taggit.managers import TaggableManager
 
 
 from dcim.models import Device
 from dcim.models import Device
-from extras.models import ConfigContextModel, CustomFieldModel
+from extras.models import ConfigContextModel, CustomFieldModel, TaggedItem
 from utilities.models import ChangeLoggedModel
 from utilities.models import ChangeLoggedModel
 from .constants import DEVICE_STATUS_ACTIVE, VM_STATUS_CHOICES, VM_STATUS_CLASSES
 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'
         object_id_field='obj_id'
     )
     )
 
 
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = ['name', 'type', 'group', 'site', 'comments']
     csv_headers = ['name', 'type', 'group', 'site', 'comments']
 
 
@@ -238,7 +238,7 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
         object_id_field='obj_id'
         object_id_field='obj_id'
     )
     )
 
 
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = [
     csv_headers = [
         'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
         '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.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.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.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):
     def test_get_virtualmachine(self):
 
 
@@ -350,7 +358,7 @@ class VirtualMachineTest(APITestCase):
         url = reverse('virtualization-api:virtualmachine-list')
         url = reverse('virtualization-api:virtualmachine-list')
         response = self.client.get(url, **self.header)
         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):
     def test_list_virtualmachines_brief(self):
 
 
@@ -373,7 +381,7 @@ class VirtualMachineTest(APITestCase):
         response = self.client.post(url, data, format='json', **self.header)
         response = self.client.post(url, data, format='json', **self.header)
 
 
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         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'])
         virtualmachine4 = VirtualMachine.objects.get(pk=response.data['id'])
         self.assertEqual(virtualmachine4.name, data['name'])
         self.assertEqual(virtualmachine4.name, data['name'])
         self.assertEqual(virtualmachine4.cluster.pk, data['cluster'])
         self.assertEqual(virtualmachine4.cluster.pk, data['cluster'])
@@ -388,7 +396,7 @@ class VirtualMachineTest(APITestCase):
         response = self.client.post(url, data, format='json', **self.header)
         response = self.client.post(url, data, format='json', **self.header)
 
 
         self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
         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):
     def test_create_virtualmachine_bulk(self):
 
 
@@ -411,7 +419,7 @@ class VirtualMachineTest(APITestCase):
         response = self.client.post(url, data, format='json', **self.header)
         response = self.client.post(url, data, format='json', **self.header)
 
 
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         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[0]['name'], data[0]['name'])
         self.assertEqual(response.data[1]['name'], data[1]['name'])
         self.assertEqual(response.data[1]['name'], data[1]['name'])
         self.assertEqual(response.data[2]['name'], data[2]['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)
         response = self.client.put(url, data, format='json', **self.header)
 
 
         self.assertHttpStatus(response, status.HTTP_200_OK)
         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'])
         virtualmachine1 = VirtualMachine.objects.get(pk=response.data['id'])
         self.assertEqual(virtualmachine1.name, data['name'])
         self.assertEqual(virtualmachine1.name, data['name'])
         self.assertEqual(virtualmachine1.cluster.pk, data['cluster'])
         self.assertEqual(virtualmachine1.cluster.pk, data['cluster'])
@@ -451,7 +459,22 @@ class VirtualMachineTest(APITestCase):
         response = self.client.delete(url, **self.header)
         response = self.client.delete(url, **self.header)
 
 
         self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
         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):
 class InterfaceTest(APITestCase):