Răsfoiți Sursa

Merge pull request #2103 from digitalocean/132-taggit

132-taggit
Jeremy Stretch 7 ani în urmă
părinte
comite
63100b683d
88 a modificat fișierele cu 772 adăugiri și 50 ștergeri
  1. 6 3
      netbox/circuits/api/serializers.py
  2. 6 0
      netbox/circuits/filters.py
  3. 5 2
      netbox/circuits/forms.py
  4. 5 0
      netbox/circuits/models.py
  5. 12 6
      netbox/dcim/api/serializers.py
  6. 12 0
      netbox/dcim/filters.py
  7. 13 5
      netbox/dcim/forms.py
  8. 6 0
      netbox/dcim/models.py
  9. 13 0
      netbox/extras/api/serializers.py
  10. 3 0
      netbox/extras/api/urls.py
  11. 12 0
      netbox/extras/api/views.py
  12. 21 0
      netbox/extras/filters.py
  13. 23 1
      netbox/extras/forms.py
  14. 28 0
      netbox/extras/tables.py
  15. 94 0
      netbox/extras/tests/test_api.py
  16. 6 0
      netbox/extras/urls.py
  17. 39 3
      netbox/extras/views.py
  18. 16 7
      netbox/ipam/api/serializers.py
  19. 15 0
      netbox/ipam/filters.py
  20. 14 5
      netbox/ipam/forms.py
  21. 9 0
      netbox/ipam/models.py
  22. 1 0
      netbox/netbox/settings.py
  23. 4 2
      netbox/secrets/api/serializers.py
  24. 3 0
      netbox/secrets/filters.py
  25. 3 1
      netbox/secrets/forms.py
  26. 3 0
      netbox/secrets/models.py
  27. 10 0
      netbox/templates/circuits/circuit.html
  28. 6 0
      netbox/templates/circuits/circuit_edit.html
  29. 1 0
      netbox/templates/circuits/circuit_list.html
  30. 10 0
      netbox/templates/circuits/provider.html
  31. 6 0
      netbox/templates/circuits/provider_edit.html
  32. 1 0
      netbox/templates/circuits/provider_list.html
  33. 10 0
      netbox/templates/dcim/device.html
  34. 6 0
      netbox/templates/dcim/device_edit.html
  35. 1 0
      netbox/templates/dcim/device_list.html
  36. 10 0
      netbox/templates/dcim/devicetype.html
  37. 6 0
      netbox/templates/dcim/devicetype_edit.html
  38. 1 0
      netbox/templates/dcim/devicetype_list.html
  39. 10 0
      netbox/templates/dcim/rack.html
  40. 6 0
      netbox/templates/dcim/rack_edit.html
  41. 1 0
      netbox/templates/dcim/rack_list.html
  42. 10 0
      netbox/templates/dcim/site.html
  43. 6 0
      netbox/templates/dcim/site_edit.html
  44. 1 0
      netbox/templates/dcim/site_list.html
  45. 11 0
      netbox/templates/extras/tag_list.html
  46. 4 1
      netbox/templates/inc/nav_menu.html
  47. 13 0
      netbox/templates/inc/tags_panel.html
  48. 11 0
      netbox/templates/ipam/aggregate.html
  49. 6 0
      netbox/templates/ipam/aggregate_edit.html
  50. 1 0
      netbox/templates/ipam/aggregate_list.html
  51. 11 0
      netbox/templates/ipam/ipaddress.html
  52. 6 0
      netbox/templates/ipam/ipaddress_edit.html
  53. 1 0
      netbox/templates/ipam/ipaddress_list.html
  54. 10 0
      netbox/templates/ipam/prefix.html
  55. 6 0
      netbox/templates/ipam/prefix_edit.html
  56. 1 0
      netbox/templates/ipam/prefix_list.html
  57. 11 0
      netbox/templates/ipam/vlan.html
  58. 6 0
      netbox/templates/ipam/vlan_edit.html
  59. 1 0
      netbox/templates/ipam/vlan_list.html
  60. 11 0
      netbox/templates/ipam/vrf.html
  61. 6 0
      netbox/templates/ipam/vrf_edit.html
  62. 1 0
      netbox/templates/ipam/vrf_list.html
  63. 11 0
      netbox/templates/secrets/secret.html
  64. 6 0
      netbox/templates/secrets/secret_edit.html
  65. 1 0
      netbox/templates/secrets/secret_list.html
  66. 10 0
      netbox/templates/tenancy/tenant.html
  67. 6 0
      netbox/templates/tenancy/tenant_edit.html
  68. 1 0
      netbox/templates/tenancy/tenant_list.html
  69. 1 0
      netbox/templates/utilities/templatetags/tag.html
  70. 10 0
      netbox/templates/virtualization/cluster.html
  71. 34 0
      netbox/templates/virtualization/cluster_edit.html
  72. 1 0
      netbox/templates/virtualization/cluster_list.html
  73. 10 0
      netbox/templates/virtualization/virtualmachine.html
  74. 6 0
      netbox/templates/virtualization/virtualmachine_edit.html
  75. 1 0
      netbox/templates/virtualization/virtualmachine_list.html
  76. 7 2
      netbox/tenancy/api/serializers.py
  77. 3 0
      netbox/tenancy/filters.py
  78. 3 1
      netbox/tenancy/forms.py
  79. 3 0
      netbox/tenancy/models.py
  80. 16 1
      netbox/utilities/api.py
  81. 11 1
      netbox/utilities/templatetags/helpers.py
  82. 13 3
      netbox/utilities/views.py
  83. 9 4
      netbox/virtualization/api/serializers.py
  84. 6 0
      netbox/virtualization/filters.py
  85. 5 2
      netbox/virtualization/forms.py
  86. 5 0
      netbox/virtualization/models.py
  87. 1 0
      netbox/virtualization/views.py
  88. 1 0
      requirements.txt

+ 6 - 3
netbox/circuits/api/serializers.py

@@ -1,13 +1,14 @@
 from __future__ import unicode_literals
 from __future__ import unicode_literals
 
 
 from rest_framework import serializers
 from rest_framework import serializers
+from taggit.models import Tag
 
 
 from circuits.constants import CIRCUIT_STATUS_CHOICES
 from circuits.constants import CIRCUIT_STATUS_CHOICES
 from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
 from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
 from dcim.api.serializers import NestedSiteSerializer, InterfaceSerializer
 from dcim.api.serializers import NestedSiteSerializer, InterfaceSerializer
 from extras.api.customfields import CustomFieldModelSerializer
 from extras.api.customfields import CustomFieldModelSerializer
 from tenancy.api.serializers import NestedTenantSerializer
 from tenancy.api.serializers import NestedTenantSerializer
-from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer, WritableNestedSerializer
+from utilities.api import ChoiceFieldSerializer, TagField, ValidatedModelSerializer, WritableNestedSerializer
 
 
 
 
 #
 #
@@ -15,11 +16,12 @@ from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer, Writa
 #
 #
 
 
 class ProviderSerializer(CustomFieldModelSerializer):
 class ProviderSerializer(CustomFieldModelSerializer):
+    tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
 
 
     class Meta:
     class Meta:
         model = Provider
         model = Provider
         fields = [
         fields = [
-            'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
+            'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'tags',
             'custom_fields', 'created', 'last_updated',
             'custom_fields', 'created', 'last_updated',
         ]
         ]
 
 
@@ -60,12 +62,13 @@ class CircuitSerializer(CustomFieldModelSerializer):
     status = ChoiceFieldSerializer(choices=CIRCUIT_STATUS_CHOICES, required=False)
     status = ChoiceFieldSerializer(choices=CIRCUIT_STATUS_CHOICES, required=False)
     type = NestedCircuitTypeSerializer()
     type = NestedCircuitTypeSerializer()
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
+    tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
 
 
     class Meta:
     class Meta:
         model = Circuit
         model = Circuit
         fields = [
         fields = [
             'id', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description',
             'id', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description',
-            'comments', 'custom_fields', 'created', 'last_updated',
+            'comments', 'tags', 'custom_fields', 'created', 'last_updated',
         ]
         ]
 
 
 
 

+ 6 - 0
netbox/circuits/filters.py

@@ -28,6 +28,9 @@ class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Site (slug)',
         label='Site (slug)',
     )
     )
+    tag = django_filters.CharFilter(
+        name='tags__slug',
+    )
 
 
     class Meta:
     class Meta:
         model = Provider
         model = Provider
@@ -103,6 +106,9 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Site (slug)',
         label='Site (slug)',
     )
     )
+    tag = django_filters.CharFilter(
+        name='tags__slug',
+    )
 
 
     class Meta:
     class Meta:
         model = Circuit
         model = Circuit

+ 5 - 2
netbox/circuits/forms.py

@@ -2,6 +2,7 @@ from __future__ import unicode_literals
 
 
 from django import forms
 from django import forms
 from django.db.models import Count
 from django.db.models import Count
+from taggit.forms import TagField
 
 
 from dcim.models import Site, Device, Interface, Rack
 from dcim.models import Site, Device, Interface, Rack
 from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
 from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
@@ -22,10 +23,11 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider
 class ProviderForm(BootstrapMixin, CustomFieldForm):
 class ProviderForm(BootstrapMixin, CustomFieldForm):
     slug = SlugField()
     slug = SlugField()
     comments = CommentField()
     comments = CommentField()
+    tags = TagField(required=False)
 
 
     class Meta:
     class Meta:
         model = Provider
         model = Provider
-        fields = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']
+        fields = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'tags']
         widgets = {
         widgets = {
             'noc_contact': SmallTextarea(attrs={'rows': 5}),
             'noc_contact': SmallTextarea(attrs={'rows': 5}),
             'admin_contact': SmallTextarea(attrs={'rows': 5}),
             'admin_contact': SmallTextarea(attrs={'rows': 5}),
@@ -102,12 +104,13 @@ class CircuitTypeCSVForm(forms.ModelForm):
 
 
 class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm):
 class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm):
     comments = CommentField()
     comments = CommentField()
+    tags = TagField(required=False)
 
 
     class Meta:
     class Meta:
         model = Circuit
         model = Circuit
         fields = [
         fields = [
             'cid', 'type', 'provider', 'status', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant',
             'cid', 'type', 'provider', 'status', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant',
-            'comments',
+            'comments', 'tags',
         ]
         ]
         help_texts = {
         help_texts = {
             'cid': "Unique circuit ID",
             'cid': "Unique circuit ID",

+ 5 - 0
netbox/circuits/models.py

@@ -4,6 +4,7 @@ from django.contrib.contenttypes.fields import GenericRelation
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.encoding import python_2_unicode_compatible
 from django.utils.encoding import python_2_unicode_compatible
+from taggit.managers import TaggableManager
 
 
 from dcim.constants import STATUS_CLASSES
 from dcim.constants import STATUS_CLASSES
 from dcim.fields import ASNField
 from dcim.fields import ASNField
@@ -56,6 +57,8 @@ class Provider(CreatedUpdatedModel, CustomFieldModel):
         object_id_field='obj_id'
         object_id_field='obj_id'
     )
     )
 
 
+    tags = TaggableManager()
+
     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']
 
 
     class Meta:
     class Meta:
@@ -166,6 +169,8 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
         object_id_field='obj_id'
         object_id_field='obj_id'
     )
     )
 
 
+    tags = TaggableManager()
+
     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',
     ]
     ]

+ 12 - 6
netbox/dcim/api/serializers.py

@@ -4,6 +4,7 @@ from collections import OrderedDict
 
 
 from rest_framework import serializers
 from rest_framework import serializers
 from rest_framework.validators import UniqueTogetherValidator
 from rest_framework.validators import UniqueTogetherValidator
+from taggit.models import Tag
 
 
 from circuits.models import Circuit, CircuitTermination
 from circuits.models import Circuit, CircuitTermination
 from dcim.constants import (
 from dcim.constants import (
@@ -21,7 +22,8 @@ from ipam.models import IPAddress, VLAN
 from tenancy.api.serializers import NestedTenantSerializer
 from tenancy.api.serializers import NestedTenantSerializer
 from users.api.serializers import NestedUserSerializer
 from users.api.serializers import NestedUserSerializer
 from utilities.api import (
 from utilities.api import (
-    ChoiceFieldSerializer, SerializedPKRelatedField, TimeZoneField, ValidatedModelSerializer, WritableNestedSerializer,
+    ChoiceFieldSerializer, SerializedPKRelatedField, TagField, TimeZoneField, ValidatedModelSerializer,
+    WritableNestedSerializer,
 )
 )
 from virtualization.models import Cluster
 from virtualization.models import Cluster
 
 
@@ -55,14 +57,15 @@ class SiteSerializer(CustomFieldModelSerializer):
     region = NestedRegionSerializer(required=False, allow_null=True)
     region = NestedRegionSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     time_zone = TimeZoneField(required=False)
     time_zone = TimeZoneField(required=False)
+    tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
 
 
     class Meta:
     class Meta:
         model = Site
         model = Site
         fields = [
         fields = [
             'id', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description',
             'id', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description',
             'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
             'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
-            'custom_fields', 'created', 'last_updated', 'count_prefixes', 'count_vlans', 'count_racks', 'count_devices',
-            'count_circuits',
+            'tags', 'custom_fields', 'created', 'last_updated', 'count_prefixes', 'count_vlans', 'count_racks',
+            'count_devices', 'count_circuits',
         ]
         ]
 
 
 
 
@@ -124,12 +127,13 @@ class RackSerializer(CustomFieldModelSerializer):
     role = NestedRackRoleSerializer(required=False, allow_null=True)
     role = NestedRackRoleSerializer(required=False, allow_null=True)
     type = ChoiceFieldSerializer(choices=RACK_TYPE_CHOICES, required=False)
     type = ChoiceFieldSerializer(choices=RACK_TYPE_CHOICES, required=False)
     width = ChoiceFieldSerializer(choices=RACK_WIDTH_CHOICES, required=False)
     width = ChoiceFieldSerializer(choices=RACK_WIDTH_CHOICES, required=False)
+    tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
 
 
     class Meta:
     class Meta:
         model = Rack
         model = Rack
         fields = [
         fields = [
             'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'serial', 'type', 'width',
             'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'serial', 'type', 'width',
-            'u_height', 'desc_units', 'comments', 'custom_fields', 'created', 'last_updated',
+            'u_height', 'desc_units', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
         ]
         ]
         # Omit the UniqueTogetherValidator that would be automatically added to validate (site, facility_id). This
         # Omit the UniqueTogetherValidator that would be automatically added to validate (site, facility_id). This
         # prevents facility_id from being interpreted as a required field.
         # prevents facility_id from being interpreted as a required field.
@@ -223,12 +227,13 @@ class DeviceTypeSerializer(CustomFieldModelSerializer):
     interface_ordering = ChoiceFieldSerializer(choices=IFACE_ORDERING_CHOICES, required=False)
     interface_ordering = ChoiceFieldSerializer(choices=IFACE_ORDERING_CHOICES, required=False)
     subdevice_role = ChoiceFieldSerializer(choices=SUBDEVICE_ROLE_CHOICES, required=False)
     subdevice_role = ChoiceFieldSerializer(choices=SUBDEVICE_ROLE_CHOICES, required=False)
     instance_count = serializers.IntegerField(source='instances.count', read_only=True)
     instance_count = serializers.IntegerField(source='instances.count', read_only=True)
+    tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
 
 
     class Meta:
     class Meta:
         model = DeviceType
         model = DeviceType
         fields = [
         fields = [
             'id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'interface_ordering',
             'id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'interface_ordering',
-            'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments', 'custom_fields',
+            'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments', 'tags', 'custom_fields',
             'instance_count',
             'instance_count',
         ]
         ]
 
 
@@ -401,13 +406,14 @@ class DeviceSerializer(CustomFieldModelSerializer):
     parent_device = serializers.SerializerMethodField()
     parent_device = serializers.SerializerMethodField()
     cluster = NestedClusterSerializer(required=False, allow_null=True)
     cluster = NestedClusterSerializer(required=False, allow_null=True)
     virtual_chassis = DeviceVirtualChassisSerializer(required=False, allow_null=True)
     virtual_chassis = DeviceVirtualChassisSerializer(required=False, allow_null=True)
+    tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
 
 
     class Meta:
     class Meta:
         model = Device
         model = Device
         fields = [
         fields = [
             'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
             'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
             'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6',
             'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6',
-            'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'custom_fields', 'created',
+            'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags', 'custom_fields', 'created',
             'last_updated',
             'last_updated',
         ]
         ]
         validators = []
         validators = []

+ 12 - 0
netbox/dcim/filters.py

@@ -82,6 +82,9 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Tenant (slug)',
         label='Tenant (slug)',
     )
     )
+    tag = django_filters.CharFilter(
+        name='tags__slug',
+    )
 
 
     class Meta:
     class Meta:
         model = Site
         model = Site
@@ -179,6 +182,9 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Role (slug)',
         label='Role (slug)',
     )
     )
+    tag = django_filters.CharFilter(
+        name='tags__slug',
+    )
 
 
     class Meta:
     class Meta:
         model = Rack
         model = Rack
@@ -286,6 +292,9 @@ class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Manufacturer (slug)',
         label='Manufacturer (slug)',
     )
     )
+    tag = django_filters.CharFilter(
+        name='tags__slug',
+    )
 
 
     class Meta:
     class Meta:
         model = DeviceType
         model = DeviceType
@@ -497,6 +506,9 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
         queryset=VirtualChassis.objects.all(),
         queryset=VirtualChassis.objects.all(),
         label='Virtual chassis (ID)',
         label='Virtual chassis (ID)',
     )
     )
+    tag = django_filters.CharFilter(
+        name='tags__slug',
+    )
 
 
     class Meta:
     class Meta:
         model = Device
         model = Device

+ 13 - 5
netbox/dcim/forms.py

@@ -7,6 +7,7 @@ from django.contrib.auth.models import User
 from django.contrib.postgres.forms.array import SimpleArrayField
 from django.contrib.postgres.forms.array import SimpleArrayField
 from django.db.models import Count, Q
 from django.db.models import Count, Q
 from mptt.forms import TreeNodeChoiceField
 from mptt.forms import TreeNodeChoiceField
+from taggit.forms import TagField
 from timezone_field import TimeZoneFormField
 from timezone_field import TimeZoneFormField
 
 
 from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
 from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
@@ -108,12 +109,14 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
     region = TreeNodeChoiceField(queryset=Region.objects.all(), required=False)
     region = TreeNodeChoiceField(queryset=Region.objects.all(), required=False)
     slug = SlugField()
     slug = SlugField()
     comments = CommentField()
     comments = CommentField()
+    tags = TagField(required=False)
 
 
     class Meta:
     class Meta:
         model = Site
         model = Site
         fields = [
         fields = [
             'name', 'slug', 'status', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'time_zone', 'description',
             'name', 'slug', 'status', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'time_zone', 'description',
             'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
             'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
+            'tags',
         ]
         ]
         widgets = {
         widgets = {
             'physical_address': SmallTextarea(attrs={'rows': 3}),
             'physical_address': SmallTextarea(attrs={'rows': 3}),
@@ -274,12 +277,13 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldForm):
         )
         )
     )
     )
     comments = CommentField()
     comments = CommentField()
+    tags = TagField(required=False)
 
 
     class Meta:
     class Meta:
         model = Rack
         model = Rack
         fields = [
         fields = [
             'site', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'role', 'serial', 'type', 'width',
             'site', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'role', 'serial', 'type', 'width',
-            'u_height', 'desc_units', 'comments',
+            'u_height', 'desc_units', 'comments', 'tags',
         ]
         ]
         help_texts = {
         help_texts = {
             'site': "The site at which the rack exists",
             'site': "The site at which the rack exists",
@@ -485,11 +489,14 @@ class ManufacturerCSVForm(forms.ModelForm):
 
 
 class DeviceTypeForm(BootstrapMixin, CustomFieldForm):
 class DeviceTypeForm(BootstrapMixin, CustomFieldForm):
     slug = SlugField(slug_source='model')
     slug = SlugField(slug_source='model')
+    tags = TagField(required=False)
 
 
     class Meta:
     class Meta:
         model = DeviceType
         model = DeviceType
-        fields = ['manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server',
-                  'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering', 'comments']
+        fields = [
+            'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu',
+            'is_network_device', 'subdevice_role', 'interface_ordering', 'comments', 'tags',
+        ]
         labels = {
         labels = {
             'interface_ordering': 'Order interfaces by',
             'interface_ordering': 'Order interfaces by',
         }
         }
@@ -772,12 +779,13 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
         )
         )
     )
     )
     comments = CommentField()
     comments = CommentField()
+    tags = TagField(required=False)
 
 
     class Meta:
     class Meta:
         model = Device
         model = Device
         fields = [
         fields = [
-            'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face', 'status',
-            'platform', 'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant', 'comments',
+            'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face',
+            'status', 'platform', 'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant', 'comments', 'tags',
         ]
         ]
         help_texts = {
         help_texts = {
             'device_role': "The function this device serves",
             'device_role': "The function this device serves",

+ 6 - 0
netbox/dcim/models.py

@@ -14,6 +14,7 @@ from django.db.models import Count, Q, ObjectDoesNotExist
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.encoding import python_2_unicode_compatible
 from django.utils.encoding import python_2_unicode_compatible
 from mptt.models import MPTTModel, TreeForeignKey
 from mptt.models import MPTTModel, TreeForeignKey
+from taggit.managers import TaggableManager
 from timezone_field import TimeZoneField
 from timezone_field import TimeZoneField
 
 
 from circuits.models import Circuit
 from circuits.models import Circuit
@@ -161,6 +162,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
     )
     )
 
 
     objects = SiteManager()
     objects = SiteManager()
+    tags = TaggableManager()
 
 
     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',
@@ -388,6 +390,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
     )
     )
 
 
     objects = RackManager()
     objects = RackManager()
+    tags = TaggableManager()
 
 
     csv_headers = [
     csv_headers = [
         'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'serial', 'width', 'u_height',
         'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'serial', 'width', 'u_height',
@@ -746,6 +749,8 @@ class DeviceType(models.Model, CustomFieldModel):
         object_id_field='obj_id'
         object_id_field='obj_id'
     )
     )
 
 
+    tags = TaggableManager()
+
     csv_headers = [
     csv_headers = [
         'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server',
         'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server',
         'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering', 'comments',
         'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering', 'comments',
@@ -1231,6 +1236,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
     )
     )
 
 
     objects = DeviceManager()
     objects = DeviceManager()
+    tags = TaggableManager()
 
 
     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',

+ 13 - 0
netbox/extras/api/serializers.py

@@ -2,6 +2,7 @@ from __future__ import unicode_literals
 
 
 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.serializers import NestedDeviceSerializer, NestedRackSerializer, NestedSiteSerializer
 from dcim.api.serializers import NestedDeviceSerializer, NestedRackSerializer, NestedSiteSerializer
 from dcim.models import Device, Rack, Site
 from dcim.models import Device, Rack, Site
@@ -62,6 +63,18 @@ class TopologyMapSerializer(ValidatedModelSerializer):
         fields = ['id', 'name', 'slug', 'site', 'device_patterns', 'description']
         fields = ['id', 'name', 'slug', 'site', 'device_patterns', 'description']
 
 
 
 
+#
+# Tags
+#
+
+class TagSerializer(ValidatedModelSerializer):
+    tagged_items = serializers.IntegerField(read_only=True)
+
+    class Meta:
+        model = Tag
+        fields = ['id', 'name', 'slug', 'tagged_items']
+
+
 #
 #
 # Image attachments
 # Image attachments
 #
 #

+ 3 - 0
netbox/extras/api/urls.py

@@ -28,6 +28,9 @@ router.register(r'export-templates', views.ExportTemplateViewSet)
 # Topology maps
 # Topology maps
 router.register(r'topology-maps', views.TopologyMapViewSet)
 router.register(r'topology-maps', views.TopologyMapViewSet)
 
 
+# Tags
+router.register(r'tags', views.TagViewSet)
+
 # Image attachments
 # Image attachments
 router.register(r'image-attachments', views.ImageAttachmentViewSet)
 router.register(r'image-attachments', views.ImageAttachmentViewSet)
 
 

+ 12 - 0
netbox/extras/api/views.py

@@ -1,12 +1,14 @@
 from __future__ import unicode_literals
 from __future__ import unicode_literals
 
 
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
+from django.db.models import Count
 from django.http import Http404, HttpResponse
 from django.http import Http404, HttpResponse
 from django.shortcuts import get_object_or_404
 from django.shortcuts import get_object_or_404
 from rest_framework.decorators import detail_route
 from rest_framework.decorators import detail_route
 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 CustomField, ExportTemplate, Graph, ImageAttachment, ReportResult, TopologyMap, UserAction
 from extras.models import CustomField, ExportTemplate, Graph, ImageAttachment, ReportResult, TopologyMap, UserAction
@@ -109,6 +111,16 @@ class TopologyMapViewSet(ModelViewSet):
         return response
         return response
 
 
 
 
+#
+# Tags
+#
+
+class TagViewSet(ModelViewSet):
+    queryset = Tag.objects.annotate(tagged_items=Count('taggit_taggeditem_items'))
+    serializer_class = serializers.TagSerializer
+    filter_class = filters.TagFilter
+
+
 #
 #
 # Image attachments
 # Image attachments
 #
 #

+ 21 - 0
netbox/extras/filters.py

@@ -3,6 +3,8 @@ from __future__ import unicode_literals
 import django_filters
 import django_filters
 from django.contrib.auth.models import User
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
+from django.db.models import Q
+from taggit.models import Tag
 
 
 from dcim.models import Site
 from dcim.models import Site
 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
@@ -85,6 +87,25 @@ class ExportTemplateFilter(django_filters.FilterSet):
         fields = ['content_type', 'name']
         fields = ['content_type', 'name']
 
 
 
 
+class TagFilter(django_filters.FilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label='Search',
+    )
+
+    class Meta:
+        model = Tag
+        fields = ['name', 'slug']
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(name__icontains=value) |
+            Q(slug__icontains=value)
+        )
+
+
 class TopologyMapFilter(django_filters.FilterSet):
 class TopologyMapFilter(django_filters.FilterSet):
     site_id = django_filters.ModelMultipleChoiceFilter(
     site_id = django_filters.ModelMultipleChoiceFilter(
         name='site',
         name='site',

+ 23 - 1
netbox/extras/forms.py

@@ -4,12 +4,17 @@ from collections import OrderedDict
 
 
 from django import forms
 from django import forms
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
+from taggit.models import Tag
 
 
-from utilities.forms import BootstrapMixin, BulkEditForm, LaxURLField
+from utilities.forms import BootstrapMixin, BulkEditForm, LaxURLField, SlugField
 from .constants import CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL
 from .constants import CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL
 from .models import CustomField, CustomFieldValue, ImageAttachment
 from .models import CustomField, CustomFieldValue, ImageAttachment
 
 
 
 
+#
+# Custom fields
+#
+
 def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=False):
 def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=False):
     """
     """
     Retrieve all CustomFields applicable to the given ContentType
     Retrieve all CustomFields applicable to the given ContentType
@@ -162,6 +167,23 @@ class CustomFieldFilterForm(forms.Form):
             self.fields[name] = field
             self.fields[name] = field
 
 
 
 
+#
+# Tags
+#
+#
+
+class TagForm(BootstrapMixin, forms.ModelForm):
+    slug = SlugField()
+
+    class Meta:
+        model = Tag
+        fields = ['name', 'slug']
+
+
+#
+# Image attachments
+#
+
 class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):
 class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):
 
 
     class Meta:
     class Meta:

+ 28 - 0
netbox/extras/tables.py

@@ -0,0 +1,28 @@
+from __future__ import unicode_literals
+
+import django_tables2 as tables
+from taggit.models import Tag
+
+from utilities.tables import BaseTable, ToggleColumn
+
+TAG_ACTIONS = """
+{% if perms.taggit.change_tag %}
+    <a href="{% url 'extras:tag_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
+{% endif %}
+{% if perms.taggit.delete_tag %}
+    <a href="{% url 'extras:tag_delete' slug=record.slug %}" class="btn btn-xs btn-danger"><i class="glyphicon glyphicon-trash" aria-hidden="true"></i></a>
+{% endif %}
+"""
+
+
+class TagTable(BaseTable):
+    pk = ToggleColumn()
+    actions = tables.TemplateColumn(
+        template_code=TAG_ACTIONS,
+        attrs={'td': {'class': 'text-right'}},
+        verbose_name=''
+    )
+
+    class Meta(BaseTable.Meta):
+        model = Tag
+        fields = ('pk', 'name', 'items')

+ 94 - 0
netbox/extras/tests/test_api.py

@@ -5,6 +5,7 @@ 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 rest_framework.test import APITestCase
 from rest_framework.test import APITestCase
+from taggit.models import Tag
 
 
 from dcim.models import Device
 from dcim.models import Device
 from extras.constants import GRAPH_TYPE_SITE
 from extras.constants import GRAPH_TYPE_SITE
@@ -226,3 +227,96 @@ class ExportTemplateTest(HttpStatusMixin, APITestCase):
 
 
         self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
         self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
         self.assertEqual(ExportTemplate.objects.count(), 2)
         self.assertEqual(ExportTemplate.objects.count(), 2)
+
+
+class TagTest(HttpStatusMixin, APITestCase):
+
+    def setUp(self):
+
+        user = User.objects.create(username='testuser', is_superuser=True)
+        token = Token.objects.create(user=user)
+        self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
+
+        self.tag1 = Tag.objects.create(name='Test Tag 1', slug='test-tag-1')
+        self.tag2 = Tag.objects.create(name='Test Tag 2', slug='test-tag-2')
+        self.tag3 = Tag.objects.create(name='Test Tag 3', slug='test-tag-3')
+
+    def test_get_tag(self):
+
+        url = reverse('extras-api:tag-detail', kwargs={'pk': self.tag1.pk})
+        response = self.client.get(url, **self.header)
+
+        self.assertEqual(response.data['name'], self.tag1.name)
+
+    def test_list_tags(self):
+
+        url = reverse('extras-api:tag-list')
+        response = self.client.get(url, **self.header)
+
+        self.assertEqual(response.data['count'], 3)
+
+    def test_create_tag(self):
+
+        data = {
+            'name': 'Test Tag 4',
+            'slug': 'test-tag-4',
+        }
+
+        url = reverse('extras-api:tag-list')
+        response = self.client.post(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(Tag.objects.count(), 4)
+        tag4 = Tag.objects.get(pk=response.data['id'])
+        self.assertEqual(tag4.name, data['name'])
+        self.assertEqual(tag4.slug, data['slug'])
+
+    def test_create_tag_bulk(self):
+
+        data = [
+            {
+                'name': 'Test Tag 4',
+                'slug': 'test-tag-4',
+            },
+            {
+                'name': 'Test Tag 5',
+                'slug': 'test-tag-5',
+            },
+            {
+                'name': 'Test Tag 6',
+                'slug': 'test-tag-6',
+            },
+        ]
+
+        url = reverse('extras-api:tag-list')
+        response = self.client.post(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(Tag.objects.count(), 6)
+        self.assertEqual(response.data[0]['name'], data[0]['name'])
+        self.assertEqual(response.data[1]['name'], data[1]['name'])
+        self.assertEqual(response.data[2]['name'], data[2]['name'])
+
+    def test_update_tag(self):
+
+        data = {
+            'name': 'Test Tag X',
+            'slug': 'test-tag-x',
+        }
+
+        url = reverse('extras-api:tag-detail', kwargs={'pk': self.tag1.pk})
+        response = self.client.put(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(Tag.objects.count(), 3)
+        tag1 = Tag.objects.get(pk=response.data['id'])
+        self.assertEqual(tag1.name, data['name'])
+        self.assertEqual(tag1.slug, data['slug'])
+
+    def test_delete_tag(self):
+
+        url = reverse('extras-api:tag-detail', kwargs={'pk': self.tag1.pk})
+        response = self.client.delete(url, **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
+        self.assertEqual(Tag.objects.count(), 2)

+ 6 - 0
netbox/extras/urls.py

@@ -7,6 +7,12 @@ from extras import views
 app_name = 'extras'
 app_name = 'extras'
 urlpatterns = [
 urlpatterns = [
 
 
+    # Tags
+    url(r'^tags/$', views.TagListView.as_view(), name='tag_list'),
+    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/delete/$', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'),
+
     # Image attachments
     # Image attachments
     url(r'^image-attachments/(?P<pk>\d+)/edit/$', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),
     url(r'^image-attachments/(?P<pk>\d+)/edit/$', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),
     url(r'^image-attachments/(?P<pk>\d+)/delete/$', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'),
     url(r'^image-attachments/(?P<pk>\d+)/delete/$', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'),

+ 39 - 3
netbox/extras/views.py

@@ -2,16 +2,52 @@ from __future__ import unicode_literals
 
 
 from django.contrib import messages
 from django.contrib import messages
 from django.contrib.auth.mixins import PermissionRequiredMixin
 from django.contrib.auth.mixins import PermissionRequiredMixin
+from django.db.models import Count
 from django.http import Http404
 from django.http import Http404
-from django.shortcuts import get_object_or_404, redirect, render
+from django.shortcuts import get_object_or_404, redirect, render, reverse
 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 taggit.models import Tag
 
 
 from utilities.forms import ConfirmationForm
 from utilities.forms import ConfirmationForm
-from utilities.views import ObjectDeleteView, ObjectEditView
-from .forms import ImageAttachmentForm
+from utilities.views import BulkDeleteView, ObjectDeleteView, ObjectEditView, ObjectListView
+from .forms import ImageAttachmentForm, TagForm
 from .models import ImageAttachment, ReportResult, UserAction
 from .models import ImageAttachment, ReportResult, UserAction
 from .reports import get_report, get_reports
 from .reports import get_report, get_reports
+from .tables import TagTable
+
+
+#
+# Tags
+#
+
+class TagListView(ObjectListView):
+    queryset = Tag.objects.annotate(items=Count('taggit_taggeditem_items')).order_by('name')
+    table = TagTable
+    template_name = 'extras/tag_list.html'
+
+
+class TagEditView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'taggit.change_tag'
+    model = Tag
+    model_form = TagForm
+
+    def get_return_url(self, request, obj):
+        return reverse('extras:tag', kwargs={'slug': obj.slug})
+
+
+class TagDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+    permission_required = 'taggit.delete_tag'
+    model = Tag
+    default_return_url = 'extras:tag_list'
+
+
+class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
+    permission_required = 'circuits.delete_circuittype'
+    cls = Tag
+    queryset = Tag.objects.annotate(items=Count('taggit_taggeditem_items')).order_by('name')
+    table = TagTable
+    default_return_url = 'extras:tag_list'
 
 
 
 
 #
 #

+ 16 - 7
netbox/ipam/api/serializers.py

@@ -5,6 +5,7 @@ from collections import OrderedDict
 from rest_framework import serializers
 from rest_framework import serializers
 from rest_framework.reverse import reverse
 from rest_framework.reverse import reverse
 from rest_framework.validators import UniqueTogetherValidator
 from rest_framework.validators import UniqueTogetherValidator
+from taggit.models import Tag
 
 
 from dcim.api.serializers import NestedDeviceSerializer, InterfaceSerializer, NestedSiteSerializer
 from dcim.api.serializers import NestedDeviceSerializer, InterfaceSerializer, NestedSiteSerializer
 from dcim.models import Interface
 from dcim.models import Interface
@@ -14,7 +15,9 @@ from ipam.constants import (
 )
 )
 from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
 from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
 from tenancy.api.serializers import NestedTenantSerializer
 from tenancy.api.serializers import NestedTenantSerializer
-from utilities.api import ChoiceFieldSerializer, SerializedPKRelatedField, ValidatedModelSerializer, WritableNestedSerializer
+from utilities.api import (
+    ChoiceFieldSerializer, SerializedPKRelatedField, TagField, ValidatedModelSerializer, WritableNestedSerializer,
+)
 from virtualization.api.serializers import NestedVirtualMachineSerializer
 from virtualization.api.serializers import NestedVirtualMachineSerializer
 
 
 
 
@@ -24,12 +27,13 @@ from virtualization.api.serializers import NestedVirtualMachineSerializer
 
 
 class VRFSerializer(CustomFieldModelSerializer):
 class VRFSerializer(CustomFieldModelSerializer):
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
+    tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
 
 
     class Meta:
     class Meta:
         model = VRF
         model = VRF
         fields = [
         fields = [
-            'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'display_name', 'custom_fields', 'created',
-            'last_updated',
+            'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'tags', 'display_name', 'custom_fields',
+            'created', 'last_updated',
         ]
         ]
 
 
 
 
@@ -85,11 +89,13 @@ class NestedRIRSerializer(WritableNestedSerializer):
 
 
 class AggregateSerializer(CustomFieldModelSerializer):
 class AggregateSerializer(CustomFieldModelSerializer):
     rir = NestedRIRSerializer()
     rir = NestedRIRSerializer()
+    tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
 
 
     class Meta:
     class Meta:
         model = Aggregate
         model = Aggregate
         fields = [
         fields = [
-            'id', 'family', 'prefix', 'rir', 'date_added', 'description', 'custom_fields', 'created', 'last_updated',
+            'id', 'family', 'prefix', 'rir', 'date_added', 'description', 'tags', 'custom_fields', 'created',
+            'last_updated',
         ]
         ]
         read_only_fields = ['family']
         read_only_fields = ['family']
 
 
@@ -147,11 +153,12 @@ class VLANSerializer(CustomFieldModelSerializer):
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     status = ChoiceFieldSerializer(choices=VLAN_STATUS_CHOICES, required=False)
     status = ChoiceFieldSerializer(choices=VLAN_STATUS_CHOICES, required=False)
     role = NestedRoleSerializer(required=False, allow_null=True)
     role = NestedRoleSerializer(required=False, allow_null=True)
+    tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
 
 
     class Meta:
     class Meta:
         model = VLAN
         model = VLAN
         fields = [
         fields = [
-            'id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'display_name',
+            'id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'tags', 'display_name',
             'custom_fields', 'created', 'last_updated',
             'custom_fields', 'created', 'last_updated',
         ]
         ]
         validators = []
         validators = []
@@ -190,12 +197,13 @@ class PrefixSerializer(CustomFieldModelSerializer):
     vlan = NestedVLANSerializer(required=False, allow_null=True)
     vlan = NestedVLANSerializer(required=False, allow_null=True)
     status = ChoiceFieldSerializer(choices=PREFIX_STATUS_CHOICES, required=False)
     status = ChoiceFieldSerializer(choices=PREFIX_STATUS_CHOICES, required=False)
     role = NestedRoleSerializer(required=False, allow_null=True)
     role = NestedRoleSerializer(required=False, allow_null=True)
+    tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
 
 
     class Meta:
     class Meta:
         model = Prefix
         model = Prefix
         fields = [
         fields = [
             'id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description',
             'id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description',
-            'custom_fields', 'created', 'last_updated',
+            'tags', 'custom_fields', 'created', 'last_updated',
         ]
         ]
         read_only_fields = ['family']
         read_only_fields = ['family']
 
 
@@ -252,12 +260,13 @@ class IPAddressSerializer(CustomFieldModelSerializer):
     status = ChoiceFieldSerializer(choices=IPADDRESS_STATUS_CHOICES, required=False)
     status = ChoiceFieldSerializer(choices=IPADDRESS_STATUS_CHOICES, required=False)
     role = ChoiceFieldSerializer(choices=IPADDRESS_ROLE_CHOICES, required=False)
     role = ChoiceFieldSerializer(choices=IPADDRESS_ROLE_CHOICES, required=False)
     interface = IPAddressInterfaceSerializer(required=False, allow_null=True)
     interface = IPAddressInterfaceSerializer(required=False, allow_null=True)
+    tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
 
 
     class Meta:
     class Meta:
         model = IPAddress
         model = IPAddress
         fields = [
         fields = [
             'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'description', 'nat_inside',
             'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'description', 'nat_inside',
-            'nat_outside', 'custom_fields', 'created', 'last_updated',
+            'nat_outside', 'tags', 'custom_fields', 'created', 'last_updated',
         ]
         ]
         read_only_fields = ['family']
         read_only_fields = ['family']
 
 

+ 15 - 0
netbox/ipam/filters.py

@@ -30,6 +30,9 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Tenant (slug)',
         label='Tenant (slug)',
     )
     )
+    tag = django_filters.CharFilter(
+        name='tags__slug',
+    )
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -69,6 +72,9 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='RIR (slug)',
         label='RIR (slug)',
     )
     )
+    tag = django_filters.CharFilter(
+        name='tags__slug',
+    )
 
 
     class Meta:
     class Meta:
         model = Aggregate
         model = Aggregate
@@ -167,6 +173,9 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
         choices=PREFIX_STATUS_CHOICES,
         choices=PREFIX_STATUS_CHOICES,
         null_value=None
         null_value=None
     )
     )
+    tag = django_filters.CharFilter(
+        name='tags__slug',
+    )
 
 
     class Meta:
     class Meta:
         model = Prefix
         model = Prefix
@@ -289,6 +298,9 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
     role = django_filters.MultipleChoiceFilter(
     role = django_filters.MultipleChoiceFilter(
         choices=IPADDRESS_ROLE_CHOICES
         choices=IPADDRESS_ROLE_CHOICES
     )
     )
+    tag = django_filters.CharFilter(
+        name='tags__slug',
+    )
 
 
     class Meta:
     class Meta:
         model = IPAddress
         model = IPAddress
@@ -394,6 +406,9 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
         choices=VLAN_STATUS_CHOICES,
         choices=VLAN_STATUS_CHOICES,
         null_value=None
         null_value=None
     )
     )
+    tag = django_filters.CharFilter(
+        name='tags__slug',
+    )
 
 
     class Meta:
     class Meta:
         model = VLAN
         model = VLAN

+ 14 - 5
netbox/ipam/forms.py

@@ -3,6 +3,7 @@ from __future__ import unicode_literals
 from django import forms
 from django import forms
 from django.core.exceptions import MultipleObjectsReturned
 from django.core.exceptions import MultipleObjectsReturned
 from django.db.models import Count
 from django.db.models import Count
+from taggit.forms import TagField
 
 
 from dcim.models import Site, Rack, Device, Interface
 from dcim.models import Site, Rack, Device, Interface
 from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
 from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
@@ -32,10 +33,11 @@ IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([(i, i) for i in range(1, 129)]
 #
 #
 
 
 class VRFForm(BootstrapMixin, TenancyForm, CustomFieldForm):
 class VRFForm(BootstrapMixin, TenancyForm, CustomFieldForm):
+    tags = TagField(required=False)
 
 
     class Meta:
     class Meta:
         model = VRF
         model = VRF
-        fields = ['name', 'rd', 'enforce_unique', 'description', 'tenant_group', 'tenant']
+        fields = ['name', 'rd', 'enforce_unique', 'description', 'tenant_group', 'tenant', 'tags']
         labels = {
         labels = {
             'rd': "RD",
             'rd': "RD",
         }
         }
@@ -121,10 +123,11 @@ class RIRFilterForm(BootstrapMixin, forms.Form):
 #
 #
 
 
 class AggregateForm(BootstrapMixin, CustomFieldForm):
 class AggregateForm(BootstrapMixin, CustomFieldForm):
+    tags = TagField(required=False)
 
 
     class Meta:
     class Meta:
         model = Aggregate
         model = Aggregate
-        fields = ['prefix', 'rir', 'date_added', 'description']
+        fields = ['prefix', 'rir', 'date_added', 'description', 'tags']
         help_texts = {
         help_texts = {
             'prefix': "IPv4 or IPv6 network",
             'prefix': "IPv4 or IPv6 network",
             'rir': "Regional Internet Registry responsible for this prefix",
             'rir': "Regional Internet Registry responsible for this prefix",
@@ -228,10 +231,14 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm):
             api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}', display_field='display_name'
             api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}', display_field='display_name'
         )
         )
     )
     )
+    tags = TagField(required=False)
 
 
     class Meta:
     class Meta:
         model = Prefix
         model = Prefix
-        fields = ['prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'description', 'tenant_group', 'tenant']
+        fields = [
+            'prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'description', 'tenant_group', 'tenant',
+            'tags',
+        ]
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
 
 
@@ -455,12 +462,13 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
         )
         )
     )
     )
     primary_for_parent = forms.BooleanField(required=False, label='Make this the primary IP for the device/VM')
     primary_for_parent = forms.BooleanField(required=False, label='Make this the primary IP for the device/VM')
+    tags = TagField(required=False)
 
 
     class Meta:
     class Meta:
         model = IPAddress
         model = IPAddress
         fields = [
         fields = [
             'address', 'vrf', 'status', 'role', 'description', 'interface', 'primary_for_parent', 'nat_site',
             'address', 'vrf', 'status', 'role', 'description', 'interface', 'primary_for_parent', 'nat_site',
-            'nat_rack', 'nat_inside', 'tenant_group', 'tenant',
+            'nat_rack', 'nat_inside', 'tenant_group', 'tenant', 'tags',
         ]
         ]
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
@@ -780,10 +788,11 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm):
             api_url='/api/ipam/vlan-groups/?site_id={{site}}',
             api_url='/api/ipam/vlan-groups/?site_id={{site}}',
         )
         )
     )
     )
+    tags = TagField(required=False)
 
 
     class Meta:
     class Meta:
         model = VLAN
         model = VLAN
-        fields = ['site', 'group', 'vid', 'name', 'status', 'role', 'description', 'tenant_group', 'tenant']
+        fields = ['site', 'group', 'vid', 'name', 'status', 'role', 'description', 'tenant_group', 'tenant', 'tags']
         help_texts = {
         help_texts = {
             'site': "Leave blank if this VLAN spans multiple sites",
             'site': "Leave blank if this VLAN spans multiple sites",
             'group': "VLAN group (optional)",
             'group': "VLAN group (optional)",

+ 9 - 0
netbox/ipam/models.py

@@ -10,6 +10,7 @@ from django.db.models import Q
 from django.db.models.expressions import RawSQL
 from django.db.models.expressions import RawSQL
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.encoding import python_2_unicode_compatible
 from django.utils.encoding import python_2_unicode_compatible
+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
@@ -56,6 +57,8 @@ class VRF(CreatedUpdatedModel, CustomFieldModel):
         object_id_field='obj_id'
         object_id_field='obj_id'
     )
     )
 
 
+    tags = TaggableManager()
+
     csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
     csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
 
 
     class Meta:
     class Meta:
@@ -155,6 +158,8 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel):
         object_id_field='obj_id'
         object_id_field='obj_id'
     )
     )
 
 
+    tags = TaggableManager()
+
     csv_headers = ['prefix', 'rir', 'date_added', 'description']
     csv_headers = ['prefix', 'rir', 'date_added', 'description']
 
 
     class Meta:
     class Meta:
@@ -325,6 +330,7 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
     )
     )
 
 
     objects = PrefixQuerySet.as_manager()
     objects = PrefixQuerySet.as_manager()
+    tags = TaggableManager()
 
 
     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',
@@ -564,6 +570,7 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
     )
     )
 
 
     objects = IPAddressManager()
     objects = IPAddressManager()
+    tags = TaggableManager()
 
 
     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',
@@ -759,6 +766,8 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
         object_id_field='obj_id'
         object_id_field='obj_id'
     )
     )
 
 
+    tags = TaggableManager()
+
     csv_headers = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description']
     csv_headers = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description']
 
 
     class Meta:
     class Meta:

+ 1 - 0
netbox/netbox/settings.py

@@ -133,6 +133,7 @@ INSTALLED_APPS = (
     'django_tables2',
     'django_tables2',
     'mptt',
     'mptt',
     'rest_framework',
     'rest_framework',
+    'taggit',
     'timezone_field',
     'timezone_field',
     'circuits',
     'circuits',
     'dcim',
     'dcim',

+ 4 - 2
netbox/secrets/api/serializers.py

@@ -2,10 +2,11 @@ from __future__ import unicode_literals
 
 
 from rest_framework import serializers
 from rest_framework import serializers
 from rest_framework.validators import UniqueTogetherValidator
 from rest_framework.validators import UniqueTogetherValidator
+from taggit.models import Tag
 
 
 from dcim.api.serializers import NestedDeviceSerializer
 from dcim.api.serializers import NestedDeviceSerializer
 from secrets.models import Secret, SecretRole
 from secrets.models import Secret, SecretRole
-from utilities.api import ValidatedModelSerializer, WritableNestedSerializer
+from utilities.api import TagField, ValidatedModelSerializer, WritableNestedSerializer
 
 
 
 
 #
 #
@@ -35,10 +36,11 @@ class SecretSerializer(ValidatedModelSerializer):
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
     role = NestedSecretRoleSerializer()
     role = NestedSecretRoleSerializer()
     plaintext = serializers.CharField()
     plaintext = serializers.CharField()
+    tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
 
 
     class Meta:
     class Meta:
         model = Secret
         model = Secret
-        fields = ['id', 'device', 'role', 'name', 'plaintext', 'hash', 'created', 'last_updated']
+        fields = ['id', 'device', 'role', 'name', 'plaintext', 'hash', 'tags', 'created', 'last_updated']
         validators = []
         validators = []
 
 
     def validate(self, data):
     def validate(self, data):

+ 3 - 0
netbox/secrets/filters.py

@@ -41,6 +41,9 @@ class SecretFilter(django_filters.FilterSet):
         to_field_name='name',
         to_field_name='name',
         label='Device (name)',
         label='Device (name)',
     )
     )
+    tag = django_filters.CharFilter(
+        name='tags__slug',
+    )
 
 
     class Meta:
     class Meta:
         model = Secret
         model = Secret

+ 3 - 1
netbox/secrets/forms.py

@@ -4,6 +4,7 @@ from Crypto.Cipher import PKCS1_OAEP
 from Crypto.PublicKey import RSA
 from Crypto.PublicKey import RSA
 from django import forms
 from django import forms
 from django.db.models import Count
 from django.db.models import Count
+from taggit.forms import TagField
 
 
 from dcim.models import Device
 from dcim.models import Device
 from utilities.forms import BootstrapMixin, BulkEditForm, FilterChoiceField, FlexibleModelChoiceField, SlugField
 from utilities.forms import BootstrapMixin, BulkEditForm, FilterChoiceField, FlexibleModelChoiceField, SlugField
@@ -70,10 +71,11 @@ class SecretForm(BootstrapMixin, forms.ModelForm):
         label='Plaintext (verify)',
         label='Plaintext (verify)',
         widget=forms.PasswordInput()
         widget=forms.PasswordInput()
     )
     )
+    tags = TagField(required=False)
 
 
     class Meta:
     class Meta:
         model = Secret
         model = Secret
-        fields = ['role', 'name', 'plaintext', 'plaintext2']
+        fields = ['role', 'name', 'plaintext', 'plaintext2', 'tags']
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
 
 

+ 3 - 0
netbox/secrets/models.py

@@ -12,6 +12,7 @@ from django.core.exceptions import ValidationError
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.encoding import force_bytes, python_2_unicode_compatible
 from django.utils.encoding import force_bytes, python_2_unicode_compatible
+from taggit.managers import TaggableManager
 
 
 from utilities.models import CreatedUpdatedModel
 from utilities.models import CreatedUpdatedModel
 from .exceptions import InvalidKey
 from .exceptions import InvalidKey
@@ -336,6 +337,8 @@ class Secret(CreatedUpdatedModel):
         editable=False
         editable=False
     )
     )
 
 
+    tags = TaggableManager()
+
     plaintext = None
     plaintext = None
     csv_headers = ['device', 'role', 'name', 'plaintext']
     csv_headers = ['device', 'role', 'name', 'plaintext']
 
 

+ 10 - 0
netbox/templates/circuits/circuit.html

@@ -110,6 +110,16 @@
                         {% endif %}
                         {% endif %}
                     </td>
                     </td>
                 </tr>
                 </tr>
+                <tr>
+                    <td>Tags</td>
+                    <td>
+                        {% for tag in circuit.tags.all %}
+                            {% tag 'circuits:circuit_list' tag %}
+                        {% empty %}
+                            <span class="text-muted">N/A</span>
+                        {% endfor %}
+                    </td>
+                </tr>
             </table>
             </table>
         </div>
         </div>
         {% with circuit.get_custom_fields as custom_fields %}
         {% with circuit.get_custom_fields as custom_fields %}

+ 6 - 0
netbox/templates/circuits/circuit_edit.html

@@ -44,6 +44,12 @@
             {% render_field form.comments %}
             {% render_field form.comments %}
         </div>
         </div>
     </div>
     </div>
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Tags</strong></div>
+        <div class="panel-body">
+            {% render_field form.tags %}
+        </div>
+    </div>
 {% endblock %}
 {% endblock %}
 
 
 {% block javascript %}
 {% block javascript %}

+ 1 - 0
netbox/templates/circuits/circuit_list.html

@@ -16,6 +16,7 @@
     </div>
     </div>
     <div class="col-md-3">
     <div class="col-md-3">
 		{% include 'inc/search_panel.html' %}
 		{% include 'inc/search_panel.html' %}
+		{% include 'inc/tags_panel.html' %}
     </div>
     </div>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

+ 10 - 0
netbox/templates/circuits/provider.html

@@ -102,6 +102,16 @@
                         {% endif %}
                         {% endif %}
                     </td>
                     </td>
                 </tr>
                 </tr>
+                <tr>
+                    <td>Tags</td>
+                    <td>
+                        {% for tag in provider.tags.all %}
+                            {% tag 'circuits:provider_list' tag %}
+                        {% empty %}
+                            <span class="text-muted">N/A</span>
+                        {% endfor %}
+                    </td>
+                </tr>
                 <tr>
                 <tr>
                     <td>Circuits</td>
                     <td>Circuits</td>
                     <td>
                     <td>

+ 6 - 0
netbox/templates/circuits/provider_edit.html

@@ -33,4 +33,10 @@
             {% render_field form.comments %}
             {% render_field form.comments %}
         </div>
         </div>
     </div>
     </div>
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Tags</strong></div>
+        <div class="panel-body">
+            {% render_field form.tags %}
+        </div>
+    </div>
 {% endblock %}
 {% endblock %}

+ 1 - 0
netbox/templates/circuits/provider_list.html

@@ -16,6 +16,7 @@
     </div>
     </div>
     <div class="col-md-3">
     <div class="col-md-3">
 		{% include 'inc/search_panel.html' %}
 		{% include 'inc/search_panel.html' %}
+		{% include 'inc/tags_panel.html' %}
     </div>
     </div>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

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

@@ -96,6 +96,16 @@
                         {% endif %}
                         {% endif %}
                     </td>
                     </td>
                 </tr>
                 </tr>
+                <tr>
+                    <td>Tags</td>
+                    <td>
+                        {% for tag in device.tags.all %}
+                            {% tag 'dcim:device_list' tag %}
+                        {% empty %}
+                            <span class="text-muted">N/A</span>
+                        {% endfor %}
+                    </td>
+                </tr>
             </table>
             </table>
         </div>
         </div>
         {% if vc_members %}
         {% if vc_members %}

+ 6 - 0
netbox/templates/dcim/device_edit.html

@@ -83,4 +83,10 @@
             {% render_field form.comments %}
             {% render_field form.comments %}
         </div>
         </div>
     </div>
     </div>
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Tags</strong></div>
+        <div class="panel-body">
+            {% render_field form.tags %}
+        </div>
+    </div>
 {% endblock %}
 {% endblock %}

+ 1 - 0
netbox/templates/dcim/device_list.html

@@ -16,6 +16,7 @@
     </div>
     </div>
     <div class="col-md-3">
     <div class="col-md-3">
 		{% include 'inc/search_panel.html' %}
 		{% include 'inc/search_panel.html' %}
+		{% include 'inc/tags_panel.html' %}
     </div>
     </div>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

+ 10 - 0
netbox/templates/dcim/devicetype.html

@@ -73,6 +73,16 @@
                     <td>Interface Ordering</td>
                     <td>Interface Ordering</td>
                     <td>{{ devicetype.get_interface_ordering_display }}</td>
                     <td>{{ devicetype.get_interface_ordering_display }}</td>
                 </tr>
                 </tr>
+                <tr>
+                    <td>Tags</td>
+                    <td>
+                        {% for tag in devicetype.tags.all %}
+                            {% tag 'dcim:devicetype_list' tag %}
+                        {% empty %}
+                            <span class="text-muted">N/A</span>
+                        {% endfor %}
+                    </td>
+                </tr>
                 <tr>
                 <tr>
                     <td>Instances</td>
                     <td>Instances</td>
                     <td><a href="{% url 'dcim:device_list' %}?device_type_id={{ devicetype.pk }}">{{ devicetype.instances.count }}</a></td>
                     <td><a href="{% url 'dcim:device_list' %}?device_type_id={{ devicetype.pk }}">{{ devicetype.instances.count }}</a></td>

+ 6 - 0
netbox/templates/dcim/devicetype_edit.html

@@ -37,4 +37,10 @@
             {% render_field form.comments %}
             {% render_field form.comments %}
         </div>
         </div>
     </div>
     </div>
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Tags</strong></div>
+        <div class="panel-body">
+            {% render_field form.tags %}
+        </div>
+    </div>
 {% endblock %}
 {% endblock %}

+ 1 - 0
netbox/templates/dcim/devicetype_list.html

@@ -16,6 +16,7 @@
     </div>
     </div>
     <div class="col-md-3">
     <div class="col-md-3">
 		{% include 'inc/search_panel.html' %}
 		{% include 'inc/search_panel.html' %}
+		{% include 'inc/tags_panel.html' %}
     </div>
     </div>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

+ 10 - 0
netbox/templates/dcim/rack.html

@@ -114,6 +114,16 @@
                         {% endif %}
                         {% endif %}
                     </td>
                     </td>
                 </tr>
                 </tr>
+                <tr>
+                    <td>Tags</td>
+                    <td>
+                        {% for tag in rack.tags.all %}
+                            {% tag 'dcim:rack_list' tag %}
+                        {% empty %}
+                            <span class="text-muted">N/A</span>
+                        {% endfor %}
+                    </td>
+                </tr>
                 <tr>
                 <tr>
                     <td>Devices</td>
                     <td>Devices</td>
                     <td>
                     <td>

+ 6 - 0
netbox/templates/dcim/rack_edit.html

@@ -43,4 +43,10 @@
             {% render_field form.comments %}
             {% render_field form.comments %}
         </div>
         </div>
     </div>
     </div>
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Tags</strong></div>
+        <div class="panel-body">
+            {% render_field form.tags %}
+        </div>
+    </div>
 {% endblock %}
 {% endblock %}

+ 1 - 0
netbox/templates/dcim/rack_list.html

@@ -16,6 +16,7 @@
     </div>
     </div>
     <div class="col-md-3">
     <div class="col-md-3">
 		{% include 'inc/search_panel.html' %}
 		{% include 'inc/search_panel.html' %}
+		{% include 'inc/tags_panel.html' %}
     </div>
     </div>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

+ 10 - 0
netbox/templates/dcim/site.html

@@ -133,6 +133,16 @@
                         {% endif %}
                         {% endif %}
                     </td>
                     </td>
                 </tr>
                 </tr>
+                <tr>
+                    <td>Tags</td>
+                    <td>
+                        {% for tag in site.tags.all %}
+                            {% tag 'dcim:site_list' tag %}
+                        {% empty %}
+                            <span class="text-muted">N/A</span>
+                        {% endfor %}
+                    </td>
+                </tr>
             </table>
             </table>
         </div>
         </div>
         <div class="panel panel-default">
         <div class="panel panel-default">

+ 6 - 0
netbox/templates/dcim/site_edit.html

@@ -46,4 +46,10 @@
             {% render_field form.comments %}
             {% render_field form.comments %}
         </div>
         </div>
     </div>
     </div>
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Tags</strong></div>
+        <div class="panel-body">
+            {% render_field form.tags %}
+        </div>
+    </div>
 {% endblock %}
 {% endblock %}

+ 1 - 0
netbox/templates/dcim/site_list.html

@@ -16,6 +16,7 @@
     </div>
     </div>
     <div class="col-md-3">
     <div class="col-md-3">
 		{% include 'inc/search_panel.html' %}
 		{% include 'inc/search_panel.html' %}
+		{% include 'inc/tags_panel.html' %}
     </div>
     </div>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

+ 11 - 0
netbox/templates/extras/tag_list.html

@@ -0,0 +1,11 @@
+{% extends '_base.html' %}
+{% load buttons %}
+
+{% block content %}
+<h1>{% block title %}Tags{% endblock %}</h1>
+<div class="row">
+	<div class="col-md-12">
+        {% include 'utilities/obj_table.html' with bulk_delete_url='extras:tag_bulk_delete' %}
+    </div>
+</div>
+{% endblock %}

+ 4 - 1
netbox/templates/inc/nav_menu.html

@@ -16,7 +16,7 @@
         <div id="navbar" class="navbar-collapse collapse">
         <div id="navbar" class="navbar-collapse collapse">
             {% if request.user.is_authenticated or not settings.LOGIN_REQUIRED %}
             {% if request.user.is_authenticated or not settings.LOGIN_REQUIRED %}
             <ul class="nav navbar-nav">
             <ul class="nav navbar-nav">
-                <li class="dropdown{% if request.path|contains:'/dcim/sites/,/dcim/regions/,/tenancy/,/extras/reports/' %} active{% endif %}">
+                <li class="dropdown{% if request.path|contains:'/dcim/sites/,/dcim/regions/,/tenancy/,/extras/tags/,/extras/reports/' %} active{% endif %}">
                     <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Organization <span class="caret"></span></a>
                     <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Organization <span class="caret"></span></a>
                     <ul class="dropdown-menu">
                     <ul class="dropdown-menu">
                         <li class="dropdown-header">Sites</li>
                         <li class="dropdown-header">Sites</li>
@@ -60,6 +60,9 @@
                         </li>
                         </li>
                         <li class="divider"></li>
                         <li class="divider"></li>
                         <li class="dropdown-header">Miscellaneous</li>
                         <li class="dropdown-header">Miscellaneous</li>
+                        <li>
+                            <a href="{% url 'extras:tag_list' %}">Tags</a>
+                        </li>
                         <li>
                         <li>
                             <a href="{% url 'extras:report_list' %}">Reports</a>
                             <a href="{% url 'extras:report_list' %}">Reports</a>
                         </li>
                         </li>

+ 13 - 0
netbox/templates/inc/tags_panel.html

@@ -0,0 +1,13 @@
+{% load helpers %}
+
+<div class="panel panel-default">
+    <div class="panel-heading">
+        <span class="fa fa-tags" aria-hidden="true"></span>
+        <strong>Tags</strong>
+    </div>
+    <div class="panel-body text-center">
+        {% for tag in tags %}
+            <a href="{% querystring request tag=tag.slug %}" class="btn btn-sm {% if tag.slug in request.GET.tag %}btn-primary{% else %}btn-link{% endif %}">{{ tag }} <span class="badge">{{ tag.count }}</span></a>
+        {% endfor %}
+    </div>
+</div>

+ 11 - 0
netbox/templates/ipam/aggregate.html

@@ -1,4 +1,5 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
+{% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="row">
 <div class="row">
@@ -81,6 +82,16 @@
                         {% endif %}
                         {% endif %}
                     </td>
                     </td>
                 </tr>
                 </tr>
+                <tr>
+                    <td>Tags</td>
+                    <td>
+                        {% for tag in aggregate.tags.all %}
+                            {% tag 'ipam:aggregate_list' tag %}
+                        {% empty %}
+                            <span class="text-muted">N/A</span>
+                        {% endfor %}
+                    </td>
+                </tr>
             </table>
             </table>
         </div>
         </div>
     </div>
     </div>

+ 6 - 0
netbox/templates/ipam/aggregate_edit.html

@@ -19,4 +19,10 @@
             </div>
             </div>
         </div>
         </div>
     {% endif %}
     {% endif %}
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Tags</strong></div>
+        <div class="panel-body">
+            {% render_field form.tags %}
+        </div>
+    </div>
 {% endblock %}
 {% endblock %}

+ 1 - 0
netbox/templates/ipam/aggregate_list.html

@@ -17,6 +17,7 @@
 	</div>
 	</div>
 	<div class="col-md-3">
 	<div class="col-md-3">
 		{% include 'inc/search_panel.html' %}
 		{% include 'inc/search_panel.html' %}
+		{% include 'inc/tags_panel.html' %}
         <div class="panel panel-default">
         <div class="panel panel-default">
             <div class="panel-heading">
             <div class="panel-heading">
                 <strong><i class="fa fa-bar-chart"></i> Statistics</strong>
                 <strong><i class="fa fa-bar-chart"></i> Statistics</strong>

+ 11 - 0
netbox/templates/ipam/ipaddress.html

@@ -1,4 +1,5 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
+{% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="row">
 <div class="row">
@@ -133,6 +134,16 @@
                         {% endif %}
                         {% endif %}
                     </td>
                     </td>
                 </tr>
                 </tr>
+                <tr>
+                    <td>Tags</td>
+                    <td>
+                        {% for tag in ipaddress.tags.all %}
+                            {% tag 'ipam:ipaddress_list' tag %}
+                        {% empty %}
+                            <span class="text-muted">N/A</span>
+                        {% endfor %}
+                    </td>
+                </tr>
             </table>
             </table>
         </div>
         </div>
         {% with ipaddress.get_custom_fields as custom_fields %}
         {% with ipaddress.get_custom_fields as custom_fields %}

+ 6 - 0
netbox/templates/ipam/ipaddress_edit.html

@@ -66,6 +66,12 @@
             {% render_field form.nat_inside %}
             {% render_field form.nat_inside %}
         </div>
         </div>
     </div>
     </div>
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Tags</strong></div>
+        <div class="panel-body">
+            {% render_field form.tags %}
+        </div>
+    </div>
     {% if form.custom_fields %}
     {% if form.custom_fields %}
         <div class="panel panel-default">
         <div class="panel panel-default">
             <div class="panel-heading"><strong>Custom Fields</strong></div>
             <div class="panel-heading"><strong>Custom Fields</strong></div>

+ 1 - 0
netbox/templates/ipam/ipaddress_list.html

@@ -16,6 +16,7 @@
 	</div>
 	</div>
 	<div class="col-md-3">
 	<div class="col-md-3">
 		{% include 'inc/search_panel.html' %}
 		{% include 'inc/search_panel.html' %}
+		{% include 'inc/tags_panel.html' %}
 	</div>
 	</div>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

+ 10 - 0
netbox/templates/ipam/prefix.html

@@ -121,6 +121,16 @@
                         {% endif %}
                         {% endif %}
                     </td>
                     </td>
                 </tr>
                 </tr>
+                <tr>
+                    <td>Tags</td>
+                    <td>
+                        {% for tag in prefix.tags.all %}
+                            {% tag 'ipam:prefix_list' tag %}
+                        {% empty %}
+                            <span class="text-muted">N/A</span>
+                        {% endfor %}
+                    </td>
+                </tr>
                 <tr>
                 <tr>
                     <td>Utilization</td>
                     <td>Utilization</td>
                     <td>{% utilization_graph prefix.get_utilization %}</td>
                     <td>{% utilization_graph prefix.get_utilization %}</td>

+ 6 - 0
netbox/templates/ipam/prefix_edit.html

@@ -28,6 +28,12 @@
             {% render_field form.tenant %}
             {% render_field form.tenant %}
         </div>
         </div>
     </div>
     </div>
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Tags</strong></div>
+        <div class="panel-body">
+            {% render_field form.tags %}
+        </div>
+    </div>
     {% if form.custom_fields %}
     {% if form.custom_fields %}
         <div class="panel panel-default">
         <div class="panel panel-default">
             <div class="panel-heading"><strong>Custom Fields</strong></div>
             <div class="panel-heading"><strong>Custom Fields</strong></div>

+ 1 - 0
netbox/templates/ipam/prefix_list.html

@@ -21,6 +21,7 @@
 	</div>
 	</div>
 	<div class="col-md-3">
 	<div class="col-md-3">
 		{% include 'inc/search_panel.html' %}
 		{% include 'inc/search_panel.html' %}
+		{% include 'inc/tags_panel.html' %}
 	</div>
 	</div>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

+ 11 - 0
netbox/templates/ipam/vlan.html

@@ -1,4 +1,5 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
+{% load helpers %}
 
 
 {% block content %}
 {% block content %}
 {% include 'ipam/inc/vlan_header.html' with active_tab='vlan' %}
 {% include 'ipam/inc/vlan_header.html' with active_tab='vlan' %}
@@ -80,6 +81,16 @@
                             <span class="text-muted">N/A</span>
                             <span class="text-muted">N/A</span>
                         {% endif %}
                         {% endif %}
                     </td>
                     </td>
+                </tr>
+                <tr>
+                    <td>Tags</td>
+                    <td>
+                        {% for tag in vlan.tags.all %}
+                            {% tag 'ipam:vlan_list' tag %}
+                        {% empty %}
+                            <span class="text-muted">N/A</span>
+                        {% endfor %}
+                    </td>
                 </tr>
                 </tr>
 		    </table>
 		    </table>
         </div>
         </div>

+ 6 - 0
netbox/templates/ipam/vlan_edit.html

@@ -21,6 +21,12 @@
             {% render_field form.tenant %}
             {% render_field form.tenant %}
         </div>
         </div>
     </div>
     </div>
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Tags</strong></div>
+        <div class="panel-body">
+            {% render_field form.tags %}
+        </div>
+    </div>
     {% if form.custom_fields %}
     {% if form.custom_fields %}
         <div class="panel panel-default">
         <div class="panel panel-default">
             <div class="panel-heading"><strong>Custom Fields</strong></div>
             <div class="panel-heading"><strong>Custom Fields</strong></div>

+ 1 - 0
netbox/templates/ipam/vlan_list.html

@@ -16,6 +16,7 @@
 	</div>
 	</div>
 	<div class="col-md-3">
 	<div class="col-md-3">
 		{% include 'inc/search_panel.html' %}
 		{% include 'inc/search_panel.html' %}
+		{% include 'inc/tags_panel.html' %}
 	</div>
 	</div>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

+ 11 - 0
netbox/templates/ipam/vrf.html

@@ -1,4 +1,5 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
+{% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="row">
 <div class="row">
@@ -77,6 +78,16 @@
                             <span class="text-muted">N/A</span>
                             <span class="text-muted">N/A</span>
                         {% endif %}
                         {% endif %}
                     </td>
                     </td>
+                </tr>
+                <tr>
+                    <td>Tags</td>
+                    <td>
+                        {% for tag in vrf.tags.all %}
+                            {% tag 'ipam:vrf_list' tag %}
+                        {% empty %}
+                            <span class="text-muted">N/A</span>
+                        {% endfor %}
+                    </td>
                 </tr>
                 </tr>
 		    </table>
 		    </table>
         </div>
         </div>

+ 6 - 0
netbox/templates/ipam/vrf_edit.html

@@ -18,6 +18,12 @@
             {% render_field form.tenant %}
             {% render_field form.tenant %}
         </div>
         </div>
     </div>
     </div>
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Tags</strong></div>
+        <div class="panel-body">
+            {% render_field form.tags %}
+        </div>
+    </div>
     {% if form.custom_fields %}
     {% if form.custom_fields %}
         <div class="panel panel-default">
         <div class="panel panel-default">
             <div class="panel-heading"><strong>Custom Fields</strong></div>
             <div class="panel-heading"><strong>Custom Fields</strong></div>

+ 1 - 0
netbox/templates/ipam/vrf_list.html

@@ -16,6 +16,7 @@
 	</div>
 	</div>
 	<div class="col-md-3">
 	<div class="col-md-3">
 		{% include 'inc/search_panel.html' %}
 		{% include 'inc/search_panel.html' %}
+		{% include 'inc/tags_panel.html' %}
 	</div>
 	</div>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

+ 11 - 0
netbox/templates/secrets/secret.html

@@ -1,5 +1,6 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
 {% load static from staticfiles %}
 {% load static from staticfiles %}
+{% load helpers %}
 {% load secret_helpers %}
 {% load secret_helpers %}
 
 
 {% block content %}
 {% block content %}
@@ -55,6 +56,16 @@
                         {% endif %}
                         {% endif %}
                     </td>
                     </td>
                 </tr>
                 </tr>
+                <tr>
+                    <td>Tags</td>
+                    <td>
+                        {% for tag in secret.tags.all %}
+                            {% tag 'secrets:secret_list' tag %}
+                        {% empty %}
+                            <span class="text-muted">N/A</span>
+                        {% endfor %}
+                    </td>
+                </tr>
             </table>
             </table>
         </div>
         </div>
 	</div>
 	</div>

+ 6 - 0
netbox/templates/secrets/secret_edit.html

@@ -54,6 +54,12 @@
                     {% render_field form.plaintext2 %}
                     {% render_field form.plaintext2 %}
                 </div>
                 </div>
             </div>
             </div>
+            <div class="panel panel-default">
+                <div class="panel-heading"><strong>Tags</strong></div>
+                <div class="panel-body">
+                    {% render_field form.tags %}
+                </div>
+            </div>
         </div>
         </div>
     </div>
     </div>
     <div class="row">
     <div class="row">

+ 1 - 0
netbox/templates/secrets/secret_list.html

@@ -14,6 +14,7 @@
     </div>
     </div>
     <div class="col-md-3">
     <div class="col-md-3">
 		{% include 'inc/search_panel.html' %}
 		{% include 'inc/search_panel.html' %}
+		{% include 'inc/tags_panel.html' %}
     </div>
     </div>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

+ 10 - 0
netbox/templates/tenancy/tenant.html

@@ -68,6 +68,16 @@
                         {% endif %}
                         {% endif %}
                     </td>
                     </td>
                 </tr>
                 </tr>
+                <tr>
+                    <td>Tags</td>
+                    <td>
+                        {% for tag in tenant.tags.all %}
+                            {% tag 'tenancy:tenant_list' tag %}
+                        {% empty %}
+                            <span class="text-muted">N/A</span>
+                        {% endfor %}
+                    </td>
+                </tr>
             </table>
             </table>
         </div>
         </div>
         {% with tenant.get_custom_fields as custom_fields %}
         {% with tenant.get_custom_fields as custom_fields %}

+ 6 - 0
netbox/templates/tenancy/tenant_edit.html

@@ -26,4 +26,10 @@
             {% render_field form.comments %}
             {% render_field form.comments %}
         </div>
         </div>
     </div>
     </div>
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Tags</strong></div>
+        <div class="panel-body">
+            {% render_field form.tags %}
+        </div>
+    </div>
 {% endblock %}
 {% endblock %}

+ 1 - 0
netbox/templates/tenancy/tenant_list.html

@@ -16,6 +16,7 @@
     </div>
     </div>
     <div class="col-md-3">
     <div class="col-md-3">
 		{% include 'inc/search_panel.html' %}
 		{% include 'inc/search_panel.html' %}
+		{% include 'inc/tags_panel.html' %}
     </div>
     </div>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

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

@@ -0,0 +1 @@
+<a href="{% url url_name %}?tag={{ tag.slug }}"><span class="label label-default">{{ tag }}</span></a>

+ 10 - 0
netbox/templates/virtualization/cluster.html

@@ -76,6 +76,16 @@
                         {% endif %}
                         {% endif %}
                     </td>
                     </td>
                 </tr>
                 </tr>
+                <tr>
+                    <td>Tags</td>
+                    <td>
+                        {% for tag in cluster.tags.all %}
+                            {% tag 'virtualization:cluster_list' tag %}
+                        {% empty %}
+                            <span class="text-muted">N/A</span>
+                        {% endfor %}
+                    </td>
+                </tr>
                 <tr>
                 <tr>
                     <td>Virtual Machines</td>
                     <td>Virtual Machines</td>
                     <td><a href="{% url 'virtualization:virtualmachine_list' %}?cluster_id={{ cluster.pk }}">{{ cluster.virtual_machines.count }}</a></td>
                     <td><a href="{% url 'virtualization:virtualmachine_list' %}?cluster_id={{ cluster.pk }}">{{ cluster.virtual_machines.count }}</a></td>

+ 34 - 0
netbox/templates/virtualization/cluster_edit.html

@@ -0,0 +1,34 @@
+{% extends 'utilities/obj_edit.html' %}
+{% load form_helpers %}
+
+{% block form %}
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Cluster</strong></div>
+        <div class="panel-body">
+            {% render_field form.name %}
+            {% render_field form.type %}
+            {% render_field form.group %}
+            {% render_field form.site %}
+        </div>
+    </div>
+    {% if form.custom_fields %}
+        <div class="panel panel-default">
+            <div class="panel-heading"><strong>Custom Fields</strong></div>
+            <div class="panel-body">
+                {% render_custom_fields form %}
+            </div>
+        </div>
+    {% endif %}
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Comments</strong></div>
+        <div class="panel-body">
+            {% render_field form.comments %}
+        </div>
+    </div>
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Tags</strong></div>
+        <div class="panel-body">
+            {% render_field form.tags %}
+        </div>
+    </div>
+{% endblock %}

+ 1 - 0
netbox/templates/virtualization/cluster_list.html

@@ -16,6 +16,7 @@
     </div>
     </div>
     <div class="col-md-3">
     <div class="col-md-3">
 		{% include 'inc/search_panel.html' %}
 		{% include 'inc/search_panel.html' %}
+		{% include 'inc/tags_panel.html' %}
     </div>
     </div>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

+ 10 - 0
netbox/templates/virtualization/virtualmachine.html

@@ -121,6 +121,16 @@
                         {% endif %}
                         {% endif %}
                     </td>
                     </td>
                 </tr>
                 </tr>
+                <tr>
+                    <td>Tags</td>
+                    <td>
+                        {% for tag in vm.tags.all %}
+                            {% tag 'virtualization:virtualmachine_list' tag %}
+                        {% empty %}
+                            <span class="text-muted">N/A</span>
+                        {% endfor %}
+                    </td>
+                </tr>
             </table>
             </table>
         </div>
         </div>
         {% include 'inc/custom_fields_panel.html' with custom_fields=vm.get_custom_fields %}
         {% include 'inc/custom_fields_panel.html' with custom_fields=vm.get_custom_fields %}

+ 6 - 0
netbox/templates/virtualization/virtualmachine_edit.html

@@ -54,4 +54,10 @@
             {% render_field form.comments %}
             {% render_field form.comments %}
         </div>
         </div>
     </div>
     </div>
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Tags</strong></div>
+        <div class="panel-body">
+            {% render_field form.tags %}
+        </div>
+    </div>
 {% endblock %}
 {% endblock %}

+ 1 - 0
netbox/templates/virtualization/virtualmachine_list.html

@@ -16,6 +16,7 @@
     </div>
     </div>
     <div class="col-md-3">
     <div class="col-md-3">
 		{% include 'inc/search_panel.html' %}
 		{% include 'inc/search_panel.html' %}
+		{% include 'inc/tags_panel.html' %}
     </div>
     </div>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

+ 7 - 2
netbox/tenancy/api/serializers.py

@@ -1,10 +1,11 @@
 from __future__ import unicode_literals
 from __future__ import unicode_literals
 
 
 from rest_framework import serializers
 from rest_framework import serializers
+from taggit.models import Tag
 
 
 from extras.api.customfields import CustomFieldModelSerializer
 from extras.api.customfields import CustomFieldModelSerializer
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
-from utilities.api import ValidatedModelSerializer, WritableNestedSerializer
+from utilities.api import TagField, ValidatedModelSerializer, WritableNestedSerializer
 
 
 
 
 #
 #
@@ -32,10 +33,14 @@ class NestedTenantGroupSerializer(WritableNestedSerializer):
 
 
 class TenantSerializer(CustomFieldModelSerializer):
 class TenantSerializer(CustomFieldModelSerializer):
     group = NestedTenantGroupSerializer(required=False)
     group = NestedTenantGroupSerializer(required=False)
+    tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
 
 
     class Meta:
     class Meta:
         model = Tenant
         model = Tenant
-        fields = ['id', 'name', 'slug', 'group', 'description', 'comments', 'custom_fields', 'created', 'last_updated']
+        fields = [
+            'id', 'name', 'slug', 'group', 'description', 'comments', 'tags', 'custom_fields', 'created',
+            'last_updated',
+        ]
 
 
 
 
 class NestedTenantSerializer(WritableNestedSerializer):
 class NestedTenantSerializer(WritableNestedSerializer):

+ 3 - 0
netbox/tenancy/filters.py

@@ -31,6 +31,9 @@ class TenantFilter(CustomFieldFilterSet, django_filters.FilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Group (slug)',
         label='Group (slug)',
     )
     )
+    tag = django_filters.CharFilter(
+        name='tags__slug',
+    )
 
 
     class Meta:
     class Meta:
         model = Tenant
         model = Tenant

+ 3 - 1
netbox/tenancy/forms.py

@@ -2,6 +2,7 @@ from __future__ import unicode_literals
 
 
 from django import forms
 from django import forms
 from django.db.models import Count
 from django.db.models import Count
+from taggit.forms import TagField
 
 
 from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
 from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
 from utilities.forms import (
 from utilities.forms import (
@@ -40,10 +41,11 @@ class TenantGroupCSVForm(forms.ModelForm):
 class TenantForm(BootstrapMixin, CustomFieldForm):
 class TenantForm(BootstrapMixin, CustomFieldForm):
     slug = SlugField()
     slug = SlugField()
     comments = CommentField()
     comments = CommentField()
+    tags = TagField(required=False)
 
 
     class Meta:
     class Meta:
         model = Tenant
         model = Tenant
-        fields = ['name', 'slug', 'group', 'description', 'comments']
+        fields = ['name', 'slug', 'group', 'description', 'comments', 'tags']
 
 
 
 
 class TenantCSVForm(forms.ModelForm):
 class TenantCSVForm(forms.ModelForm):

+ 3 - 0
netbox/tenancy/models.py

@@ -4,6 +4,7 @@ from django.contrib.contenttypes.fields import GenericRelation
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.encoding import python_2_unicode_compatible
 from django.utils.encoding import python_2_unicode_compatible
+from taggit.managers import TaggableManager
 
 
 from extras.models import CustomFieldModel
 from extras.models import CustomFieldModel
 from utilities.models import CreatedUpdatedModel
 from utilities.models import CreatedUpdatedModel
@@ -74,6 +75,8 @@ class Tenant(CreatedUpdatedModel, CustomFieldModel):
         object_id_field='obj_id'
         object_id_field='obj_id'
     )
     )
 
 
+    tags = TaggableManager()
+
     csv_headers = ['name', 'slug', 'group', 'description', 'comments']
     csv_headers = ['name', 'slug', 'group', 'description', 'comments']
 
 
     class Meta:
     class Meta:

+ 16 - 1
netbox/utilities/api.py

@@ -13,7 +13,7 @@ from rest_framework.exceptions import APIException
 from rest_framework.permissions import BasePermission
 from rest_framework.permissions import BasePermission
 from rest_framework.relations import PrimaryKeyRelatedField
 from rest_framework.relations import PrimaryKeyRelatedField
 from rest_framework.response import Response
 from rest_framework.response import Response
-from rest_framework.serializers import Field, ModelSerializer, ValidationError
+from rest_framework.serializers import Field, ModelSerializer, RelatedField, ValidationError
 from rest_framework.viewsets import GenericViewSet, ViewSet
 from rest_framework.viewsets import GenericViewSet, ViewSet
 
 
 WRITE_OPERATIONS = ['create', 'update', 'partial_update', 'delete']
 WRITE_OPERATIONS = ['create', 'update', 'partial_update', 'delete']
@@ -42,6 +42,21 @@ class IsAuthenticatedOrLoginNotRequired(BasePermission):
 # Fields
 # Fields
 #
 #
 
 
+class TagField(RelatedField):
+    """
+    Represent a writable list of Tags associated with an object (use with many=True).
+    """
+
+    def to_internal_value(self, data):
+        obj = self.parent.parent.instance
+        content_type = ContentType.objects.get_for_model(obj)
+        tag, _ = Tag.objects.get_or_create(content_type=content_type, object_id=obj.pk, name=data)
+        return tag
+
+    def to_representation(self, value):
+        return value.name
+
+
 class ChoiceFieldSerializer(Field):
 class ChoiceFieldSerializer(Field):
     """
     """
     Represent a ChoiceField as {'value': <DB value>, 'label': <string>}.
     Represent a ChoiceField as {'value': <DB value>, 'label': <string>}.

+ 11 - 1
netbox/utilities/templatetags/helpers.py

@@ -1,7 +1,6 @@
 from __future__ import unicode_literals
 from __future__ import unicode_literals
 
 
 import datetime
 import datetime
-import pytz
 
 
 from django import template
 from django import template
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
@@ -160,3 +159,14 @@ def utilization_graph(utilization, warning_threshold=75, danger_threshold=90):
         'warning_threshold': warning_threshold,
         'warning_threshold': warning_threshold,
         'danger_threshold': danger_threshold,
         'danger_threshold': danger_threshold,
     }
     }
+
+
+@register.inclusion_tag('utilities/templatetags/tag.html')
+def tag(url_name, tag):
+    """
+    Display a link to the given object list filtered by a specific Tag slug.
+    """
+    return {
+        'url_name': url_name,
+        'tag': tag,
+    }

+ 13 - 3
netbox/utilities/views.py

@@ -8,7 +8,7 @@ from django.contrib import messages
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.db import transaction, IntegrityError
 from django.db import transaction, IntegrityError
-from django.db.models import ProtectedError
+from django.db.models import Count, ProtectedError
 from django.forms import CharField, Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea
 from django.forms import CharField, Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea
 from django.shortcuts import get_object_or_404, redirect, render
 from django.shortcuts import get_object_or_404, redirect, render
 from django.template.exceptions import TemplateSyntaxError
 from django.template.exceptions import TemplateSyntaxError
@@ -119,6 +119,12 @@ class ObjectListView(View):
         if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']):
         if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']):
             table.columns.show('pk')
             table.columns.show('pk')
 
 
+        # Construct queryset for tags list
+        if hasattr(model, 'tags'):
+            tags = model.tags.annotate(count=Count('taggit_taggeditem_items')).order_by('-count', 'name')
+        else:
+            tags = None
+
         # Apply the request context
         # Apply the request context
         paginate = {
         paginate = {
             'klass': EnhancedPaginator,
             'klass': EnhancedPaginator,
@@ -131,6 +137,7 @@ class ObjectListView(View):
             'table': table,
             'table': table,
             'permissions': permissions,
             'permissions': permissions,
             'filter_form': self.filter_form(request.GET, label_suffix='') if self.filter_form else None,
             'filter_form': self.filter_form(request.GET, label_suffix='') if self.filter_form else None,
+            'tags': tags,
         }
         }
         context.update(self.extra_context())
         context.update(self.extra_context())
 
 
@@ -195,13 +202,16 @@ class ObjectEditView(GetReturnURLMixin, View):
             obj_created = not form.instance.pk
             obj_created = not form.instance.pk
             obj = form.save()
             obj = form.save()
 
 
-            msg = 'Created ' if obj_created else 'Modified '
-            msg += self.model._meta.verbose_name
+            msg = '{} {}'.format(
+                'Created' if obj_created else 'Modified',
+                self.model._meta.verbose_name
+            )
             if hasattr(obj, 'get_absolute_url'):
             if hasattr(obj, 'get_absolute_url'):
                 msg = '{} <a href="{}">{}</a>'.format(msg, obj.get_absolute_url(), escape(obj))
                 msg = '{} <a href="{}">{}</a>'.format(msg, obj.get_absolute_url(), escape(obj))
             else:
             else:
                 msg = '{} {}'.format(msg, escape(obj))
                 msg = '{} {}'.format(msg, escape(obj))
             messages.success(request, mark_safe(msg))
             messages.success(request, mark_safe(msg))
+
             if obj_created:
             if obj_created:
                 UserAction.objects.log_create(request.user, obj, msg)
                 UserAction.objects.log_create(request.user, obj, msg)
             else:
             else:

+ 9 - 4
netbox/virtualization/api/serializers.py

@@ -1,14 +1,15 @@
 from __future__ import unicode_literals
 from __future__ import unicode_literals
 
 
 from rest_framework import serializers
 from rest_framework import serializers
+from taggit.models import Tag
 
 
 from dcim.api.serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer
 from dcim.api.serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer
-from dcim.constants import IFACE_FF_VIRTUAL, IFACE_MODE_CHOICES
+from dcim.constants import IFACE_MODE_CHOICES
 from dcim.models import Interface
 from dcim.models import Interface
 from extras.api.customfields import CustomFieldModelSerializer
 from extras.api.customfields import CustomFieldModelSerializer
 from ipam.models import IPAddress, VLAN
 from ipam.models import IPAddress, VLAN
 from tenancy.api.serializers import NestedTenantSerializer
 from tenancy.api.serializers import NestedTenantSerializer
-from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer, WritableNestedSerializer
+from utilities.api import ChoiceFieldSerializer, TagField, ValidatedModelSerializer, WritableNestedSerializer
 from virtualization.constants import VM_STATUS_CHOICES
 from virtualization.constants import VM_STATUS_CHOICES
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 
 
@@ -59,10 +60,13 @@ class ClusterSerializer(CustomFieldModelSerializer):
     type = NestedClusterTypeSerializer()
     type = NestedClusterTypeSerializer()
     group = NestedClusterGroupSerializer(required=False, allow_null=True)
     group = NestedClusterGroupSerializer(required=False, allow_null=True)
     site = NestedSiteSerializer(required=False, allow_null=True)
     site = NestedSiteSerializer(required=False, allow_null=True)
+    tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
 
 
     class Meta:
     class Meta:
         model = Cluster
         model = Cluster
-        fields = ['id', 'name', 'type', 'group', 'site', 'comments', 'custom_fields', 'created', 'last_updated']
+        fields = [
+            'id', 'name', 'type', 'group', 'site', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
+        ]
 
 
 
 
 class NestedClusterSerializer(WritableNestedSerializer):
 class NestedClusterSerializer(WritableNestedSerializer):
@@ -95,12 +99,13 @@ class VirtualMachineSerializer(CustomFieldModelSerializer):
     primary_ip = VirtualMachineIPAddressSerializer(read_only=True)
     primary_ip = VirtualMachineIPAddressSerializer(read_only=True)
     primary_ip4 = VirtualMachineIPAddressSerializer(required=False, allow_null=True)
     primary_ip4 = VirtualMachineIPAddressSerializer(required=False, allow_null=True)
     primary_ip6 = VirtualMachineIPAddressSerializer(required=False, allow_null=True)
     primary_ip6 = VirtualMachineIPAddressSerializer(required=False, allow_null=True)
+    tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
 
 
     class Meta:
     class Meta:
         model = VirtualMachine
         model = VirtualMachine
         fields = [
         fields = [
             'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6',
             'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6',
-            'vcpus', 'memory', 'disk', 'comments', 'custom_fields', 'created', 'last_updated',
+            'vcpus', 'memory', 'disk', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
         ]
         ]
 
 
 
 

+ 6 - 0
netbox/virtualization/filters.py

@@ -63,6 +63,9 @@ class ClusterFilter(CustomFieldFilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Site (slug)',
         label='Site (slug)',
     )
     )
+    tag = django_filters.CharFilter(
+        name='tags__slug',
+    )
 
 
     class Meta:
     class Meta:
         model = Cluster
         model = Cluster
@@ -154,6 +157,9 @@ class VirtualMachineFilter(CustomFieldFilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Platform (slug)',
         label='Platform (slug)',
     )
     )
+    tag = django_filters.CharFilter(
+        name='tags__slug',
+    )
 
 
     class Meta:
     class Meta:
         model = VirtualMachine
         model = VirtualMachine

+ 5 - 2
netbox/virtualization/forms.py

@@ -4,6 +4,7 @@ from django import forms
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.db.models import Count
 from django.db.models import Count
 from mptt.forms import TreeNodeChoiceField
 from mptt.forms import TreeNodeChoiceField
+from taggit.forms import TagField
 
 
 from dcim.constants import IFACE_FF_VIRTUAL, IFACE_MODE_ACCESS, IFACE_MODE_TAGGED_ALL
 from dcim.constants import IFACE_FF_VIRTUAL, IFACE_MODE_ACCESS, IFACE_MODE_TAGGED_ALL
 from dcim.forms import INTERFACE_MODE_HELP_TEXT
 from dcim.forms import INTERFACE_MODE_HELP_TEXT
@@ -78,10 +79,11 @@ class ClusterGroupCSVForm(forms.ModelForm):
 
 
 class ClusterForm(BootstrapMixin, CustomFieldForm):
 class ClusterForm(BootstrapMixin, CustomFieldForm):
     comments = CommentField(widget=SmallTextarea)
     comments = CommentField(widget=SmallTextarea)
+    tags = TagField(required=False)
 
 
     class Meta:
     class Meta:
         model = Cluster
         model = Cluster
-        fields = ['name', 'type', 'group', 'site', 'comments']
+        fields = ['name', 'type', 'group', 'site', 'comments', 'tags']
 
 
 
 
 class ClusterCSVForm(forms.ModelForm):
 class ClusterCSVForm(forms.ModelForm):
@@ -244,12 +246,13 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm):
             api_url='/api/virtualization/clusters/?group_id={{cluster_group}}'
             api_url='/api/virtualization/clusters/?group_id={{cluster_group}}'
         )
         )
     )
     )
+    tags = TagField(required=False)
 
 
     class Meta:
     class Meta:
         model = VirtualMachine
         model = VirtualMachine
         fields = [
         fields = [
             'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
             'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
-            'vcpus', 'memory', 'disk', 'comments',
+            'vcpus', 'memory', 'disk', 'comments', 'tags',
         ]
         ]
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):

+ 5 - 0
netbox/virtualization/models.py

@@ -6,6 +6,7 @@ from django.core.exceptions import ValidationError
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.encoding import python_2_unicode_compatible
 from django.utils.encoding import python_2_unicode_compatible
+from taggit.managers import TaggableManager
 
 
 from dcim.models import Device
 from dcim.models import Device
 from extras.models import CustomFieldModel
 from extras.models import CustomFieldModel
@@ -124,6 +125,8 @@ class Cluster(CreatedUpdatedModel, CustomFieldModel):
         object_id_field='obj_id'
         object_id_field='obj_id'
     )
     )
 
 
+    tags = TaggableManager()
+
     csv_headers = ['name', 'type', 'group', 'site', 'comments']
     csv_headers = ['name', 'type', 'group', 'site', 'comments']
 
 
     class Meta:
     class Meta:
@@ -242,6 +245,8 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel):
         object_id_field='obj_id'
         object_id_field='obj_id'
     )
     )
 
 
+    tags = TaggableManager()
+
     csv_headers = [
     csv_headers = [
         'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
         'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
     ]
     ]

+ 1 - 0
netbox/virtualization/views.py

@@ -126,6 +126,7 @@ class ClusterView(View):
 
 
 class ClusterCreateView(PermissionRequiredMixin, ObjectEditView):
 class ClusterCreateView(PermissionRequiredMixin, ObjectEditView):
     permission_required = 'virtualization.add_cluster'
     permission_required = 'virtualization.add_cluster'
+    template_name = 'virtualization/cluster_edit.html'
     model = Cluster
     model = Cluster
     model_form = forms.ClusterForm
     model_form = forms.ClusterForm
 
 

+ 1 - 0
requirements.txt

@@ -4,6 +4,7 @@ django-debug-toolbar>=1.9.0
 django-filter>=1.1.0
 django-filter>=1.1.0
 django-mptt>=0.9.0
 django-mptt>=0.9.0
 django-tables2>=1.19.0
 django-tables2>=1.19.0
+django-taggit>=0.22.2
 django-timezone-field>=2.0
 django-timezone-field>=2.0
 djangorestframework>=3.7.7,<3.8.2
 djangorestframework>=3.7.7,<3.8.2
 drf-yasg[validation]>=1.4.4
 drf-yasg[validation]>=1.4.4