\d+)/delete/$', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'),
diff --git a/netbox/extras/views.py b/netbox/extras/views.py
index 3f7c0435b..130437356 100644
--- a/netbox/extras/views.py
+++ b/netbox/extras/views.py
@@ -2,16 +2,52 @@ from __future__ import unicode_literals
from django.contrib import messages
from django.contrib.auth.mixins import PermissionRequiredMixin
+from django.db.models import Count
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.views.generic import View
+from taggit.models import Tag
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 .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'
#
diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py
index 2eca51895..f7969fbc3 100644
--- a/netbox/ipam/api/serializers.py
+++ b/netbox/ipam/api/serializers.py
@@ -5,6 +5,7 @@ from collections import OrderedDict
from rest_framework import serializers
from rest_framework.reverse import reverse
from rest_framework.validators import UniqueTogetherValidator
+from taggit.models import Tag
from dcim.api.serializers import NestedDeviceSerializer, InterfaceSerializer, NestedSiteSerializer
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 tenancy.api.serializers import NestedTenantSerializer
-from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
+from utilities.api import (
+ ChoiceFieldSerializer, SerializedPKRelatedField, TagField, ValidatedModelSerializer, WritableNestedSerializer,
+)
from virtualization.api.serializers import NestedVirtualMachineSerializer
@@ -23,17 +26,18 @@ from virtualization.api.serializers import NestedVirtualMachineSerializer
#
class VRFSerializer(CustomFieldModelSerializer):
- tenant = NestedTenantSerializer()
+ tenant = NestedTenantSerializer(required=False, allow_null=True)
+ tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta:
model = VRF
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',
]
-class NestedVRFSerializer(serializers.ModelSerializer):
+class NestedVRFSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail')
class Meta:
@@ -41,15 +45,6 @@ class NestedVRFSerializer(serializers.ModelSerializer):
fields = ['id', 'url', 'name', 'rd']
-class WritableVRFSerializer(CustomFieldModelSerializer):
-
- class Meta:
- model = VRF
- fields = [
- 'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'custom_fields', 'created', 'last_updated',
- ]
-
-
#
# Roles
#
@@ -61,7 +56,7 @@ class RoleSerializer(ValidatedModelSerializer):
fields = ['id', 'name', 'slug', 'weight']
-class NestedRoleSerializer(serializers.ModelSerializer):
+class NestedRoleSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail')
class Meta:
@@ -80,7 +75,7 @@ class RIRSerializer(ValidatedModelSerializer):
fields = ['id', 'name', 'slug', 'is_private']
-class NestedRIRSerializer(serializers.ModelSerializer):
+class NestedRIRSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail')
class Meta:
@@ -94,15 +89,18 @@ class NestedRIRSerializer(serializers.ModelSerializer):
class AggregateSerializer(CustomFieldModelSerializer):
rir = NestedRIRSerializer()
+ tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta:
model = Aggregate
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']
-class NestedAggregateSerializer(serializers.ModelSerializer):
+class NestedAggregateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail')
class Meta(AggregateSerializer.Meta):
@@ -110,34 +108,12 @@ class NestedAggregateSerializer(serializers.ModelSerializer):
fields = ['id', 'url', 'family', 'prefix']
-class WritableAggregateSerializer(CustomFieldModelSerializer):
-
- class Meta:
- model = Aggregate
- fields = ['id', 'prefix', 'rir', 'date_added', 'description', 'custom_fields', 'created', 'last_updated']
-
-
#
# VLAN groups
#
-class VLANGroupSerializer(serializers.ModelSerializer):
- site = NestedSiteSerializer()
-
- class Meta:
- model = VLANGroup
- fields = ['id', 'name', 'slug', 'site']
-
-
-class NestedVLANGroupSerializer(serializers.ModelSerializer):
- url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail')
-
- class Meta:
- model = VLANGroup
- fields = ['id', 'url', 'name', 'slug']
-
-
-class WritableVLANGroupSerializer(serializers.ModelSerializer):
+class VLANGroupSerializer(ValidatedModelSerializer):
+ site = NestedSiteSerializer(required=False, allow_null=True)
class Meta:
model = VLANGroup
@@ -154,46 +130,37 @@ class WritableVLANGroupSerializer(serializers.ModelSerializer):
validator(data)
# Enforce model validation
- super(WritableVLANGroupSerializer, self).validate(data)
+ super(VLANGroupSerializer, self).validate(data)
return data
+class NestedVLANGroupSerializer(WritableNestedSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail')
+
+ class Meta:
+ model = VLANGroup
+ fields = ['id', 'url', 'name', 'slug']
+
+
#
# VLANs
#
class VLANSerializer(CustomFieldModelSerializer):
- site = NestedSiteSerializer()
- group = NestedVLANGroupSerializer()
- tenant = NestedTenantSerializer()
- status = ChoiceFieldSerializer(choices=VLAN_STATUS_CHOICES)
- role = NestedRoleSerializer()
+ site = NestedSiteSerializer(required=False, allow_null=True)
+ group = NestedVLANGroupSerializer(required=False, allow_null=True)
+ tenant = NestedTenantSerializer(required=False, allow_null=True)
+ status = ChoiceFieldSerializer(choices=VLAN_STATUS_CHOICES, required=False)
+ role = NestedRoleSerializer(required=False, allow_null=True)
+ tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta:
model = VLAN
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',
]
-
-
-class NestedVLANSerializer(serializers.ModelSerializer):
- url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail')
-
- class Meta:
- model = VLAN
- fields = ['id', 'url', 'vid', 'name', 'display_name']
-
-
-class WritableVLANSerializer(CustomFieldModelSerializer):
-
- class Meta:
- model = VLAN
- fields = [
- 'id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'custom_fields', 'created',
- 'last_updated',
- ]
validators = []
def validate(self, data):
@@ -206,32 +173,42 @@ class WritableVLANSerializer(CustomFieldModelSerializer):
validator(data)
# Enforce model validation
- super(WritableVLANSerializer, self).validate(data)
+ super(VLANSerializer, self).validate(data)
return data
+class NestedVLANSerializer(WritableNestedSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail')
+
+ class Meta:
+ model = VLAN
+ fields = ['id', 'url', 'vid', 'name', 'display_name']
+
+
#
# Prefixes
#
class PrefixSerializer(CustomFieldModelSerializer):
- site = NestedSiteSerializer()
- vrf = NestedVRFSerializer()
- tenant = NestedTenantSerializer()
- vlan = NestedVLANSerializer()
- status = ChoiceFieldSerializer(choices=PREFIX_STATUS_CHOICES)
- role = NestedRoleSerializer()
+ site = NestedSiteSerializer(required=False, allow_null=True)
+ vrf = NestedVRFSerializer(required=False, allow_null=True)
+ tenant = NestedTenantSerializer(required=False, allow_null=True)
+ vlan = NestedVLANSerializer(required=False, allow_null=True)
+ status = ChoiceFieldSerializer(choices=PREFIX_STATUS_CHOICES, required=False)
+ role = NestedRoleSerializer(required=False, allow_null=True)
+ tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta:
model = Prefix
fields = [
'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']
-class NestedPrefixSerializer(serializers.ModelSerializer):
+class NestedPrefixSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail')
class Meta:
@@ -239,16 +216,6 @@ class NestedPrefixSerializer(serializers.ModelSerializer):
fields = ['id', 'url', 'family', 'prefix']
-class WritablePrefixSerializer(CustomFieldModelSerializer):
-
- class Meta:
- model = Prefix
- fields = [
- 'id', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description',
- 'custom_fields', 'created', 'last_updated',
- ]
-
-
class AvailablePrefixSerializer(serializers.Serializer):
def to_representation(self, instance):
@@ -288,21 +255,23 @@ class IPAddressInterfaceSerializer(serializers.ModelSerializer):
class IPAddressSerializer(CustomFieldModelSerializer):
- vrf = NestedVRFSerializer()
- tenant = NestedTenantSerializer()
- status = ChoiceFieldSerializer(choices=IPADDRESS_STATUS_CHOICES)
- role = ChoiceFieldSerializer(choices=IPADDRESS_ROLE_CHOICES)
- interface = IPAddressInterfaceSerializer()
+ vrf = NestedVRFSerializer(required=False, allow_null=True)
+ tenant = NestedTenantSerializer(required=False, allow_null=True)
+ status = ChoiceFieldSerializer(choices=IPADDRESS_STATUS_CHOICES, required=False)
+ role = ChoiceFieldSerializer(choices=IPADDRESS_ROLE_CHOICES, required=False)
+ interface = IPAddressInterfaceSerializer(required=False, allow_null=True)
+ tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta:
model = IPAddress
fields = [
'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']
-class NestedIPAddressSerializer(serializers.ModelSerializer):
+class NestedIPAddressSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail')
class Meta:
@@ -310,18 +279,8 @@ class NestedIPAddressSerializer(serializers.ModelSerializer):
fields = ['id', 'url', 'family', 'address']
-IPAddressSerializer._declared_fields['nat_inside'] = NestedIPAddressSerializer()
-IPAddressSerializer._declared_fields['nat_outside'] = NestedIPAddressSerializer()
-
-
-class WritableIPAddressSerializer(CustomFieldModelSerializer):
-
- class Meta:
- model = IPAddress
- fields = [
- 'id', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'description', 'nat_inside',
- 'custom_fields', 'created', 'last_updated',
- ]
+IPAddressSerializer._declared_fields['nat_inside'] = NestedIPAddressSerializer(required=False, allow_null=True)
+IPAddressSerializer._declared_fields['nat_outside'] = NestedIPAddressSerializer(read_only=True)
class AvailableIPSerializer(serializers.Serializer):
@@ -342,22 +301,16 @@ class AvailableIPSerializer(serializers.Serializer):
# Services
#
-class ServiceSerializer(serializers.ModelSerializer):
- device = NestedDeviceSerializer()
- virtual_machine = NestedVirtualMachineSerializer()
+class ServiceSerializer(ValidatedModelSerializer):
+ device = NestedDeviceSerializer(required=False, allow_null=True)
+ virtual_machine = NestedVirtualMachineSerializer(required=False, allow_null=True)
protocol = ChoiceFieldSerializer(choices=IP_PROTOCOL_CHOICES)
- ipaddresses = NestedIPAddressSerializer(many=True)
-
- class Meta:
- model = Service
- fields = [
- 'id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description', 'created',
- 'last_updated',
- ]
-
-
-# TODO: Figure out how to use model validation with ManyToManyFields. Calling clean() yields a ValueError.
-class WritableServiceSerializer(serializers.ModelSerializer):
+ ipaddresses = SerializedPKRelatedField(
+ queryset=IPAddress.objects.all(),
+ serializer=NestedIPAddressSerializer,
+ required=False,
+ many=True
+ )
class Meta:
model = Service
diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py
index f6a55b618..abbe6e2b1 100644
--- a/netbox/ipam/api/views.py
+++ b/netbox/ipam/api/views.py
@@ -35,7 +35,6 @@ class IPAMFieldChoicesViewSet(FieldChoicesViewSet):
class VRFViewSet(CustomFieldModelViewSet):
queryset = VRF.objects.select_related('tenant')
serializer_class = serializers.VRFSerializer
- write_serializer_class = serializers.WritableVRFSerializer
filter_class = filters.VRFFilter
@@ -56,7 +55,6 @@ class RIRViewSet(ModelViewSet):
class AggregateViewSet(CustomFieldModelViewSet):
queryset = Aggregate.objects.select_related('rir')
serializer_class = serializers.AggregateSerializer
- write_serializer_class = serializers.WritableAggregateSerializer
filter_class = filters.AggregateFilter
@@ -77,7 +75,6 @@ class RoleViewSet(ModelViewSet):
class PrefixViewSet(CustomFieldModelViewSet):
queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
serializer_class = serializers.PrefixSerializer
- write_serializer_class = serializers.WritablePrefixSerializer
filter_class = filters.PrefixFilter
@detail_route(url_path='available-prefixes', methods=['get', 'post'])
@@ -120,9 +117,9 @@ class PrefixViewSet(CustomFieldModelViewSet):
# Initialize the serializer with a list or a single object depending on what was requested
if isinstance(request.data, list):
- serializer = serializers.WritablePrefixSerializer(data=requested_prefixes, many=True)
+ serializer = serializers.PrefixSerializer(data=requested_prefixes, many=True)
else:
- serializer = serializers.WritablePrefixSerializer(data=requested_prefixes[0])
+ serializer = serializers.PrefixSerializer(data=requested_prefixes[0])
# Create the new Prefix(es)
if serializer.is_valid():
@@ -177,9 +174,9 @@ class PrefixViewSet(CustomFieldModelViewSet):
# Initialize the serializer with a list or a single object depending on what was requested
if isinstance(request.data, list):
- serializer = serializers.WritableIPAddressSerializer(data=requested_ips, many=True)
+ serializer = serializers.IPAddressSerializer(data=requested_ips, many=True)
else:
- serializer = serializers.WritableIPAddressSerializer(data=requested_ips[0])
+ serializer = serializers.IPAddressSerializer(data=requested_ips[0])
# Create the new IP address(es)
if serializer.is_valid():
@@ -223,7 +220,6 @@ class IPAddressViewSet(CustomFieldModelViewSet):
'nat_outside'
)
serializer_class = serializers.IPAddressSerializer
- write_serializer_class = serializers.WritableIPAddressSerializer
filter_class = filters.IPAddressFilter
@@ -234,7 +230,6 @@ class IPAddressViewSet(CustomFieldModelViewSet):
class VLANGroupViewSet(ModelViewSet):
queryset = VLANGroup.objects.select_related('site')
serializer_class = serializers.VLANGroupSerializer
- write_serializer_class = serializers.WritableVLANGroupSerializer
filter_class = filters.VLANGroupFilter
@@ -245,7 +240,6 @@ class VLANGroupViewSet(ModelViewSet):
class VLANViewSet(CustomFieldModelViewSet):
queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')
serializer_class = serializers.VLANSerializer
- write_serializer_class = serializers.WritableVLANSerializer
filter_class = filters.VLANFilter
@@ -256,5 +250,4 @@ class VLANViewSet(CustomFieldModelViewSet):
class ServiceViewSet(ModelViewSet):
queryset = Service.objects.select_related('device')
serializer_class = serializers.ServiceSerializer
- write_serializer_class = serializers.WritableServiceSerializer
filter_class = filters.ServiceFilter
diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py
index 005d44a84..db2806b77 100644
--- a/netbox/ipam/filters.py
+++ b/netbox/ipam/filters.py
@@ -30,6 +30,9 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug',
label='Tenant (slug)',
)
+ tag = django_filters.CharFilter(
+ name='tags__slug',
+ )
def search(self, queryset, name, value):
if not value.strip():
@@ -69,6 +72,9 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug',
label='RIR (slug)',
)
+ tag = django_filters.CharFilter(
+ name='tags__slug',
+ )
class Meta:
model = Aggregate
@@ -167,6 +173,9 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
choices=PREFIX_STATUS_CHOICES,
null_value=None
)
+ tag = django_filters.CharFilter(
+ name='tags__slug',
+ )
class Meta:
model = Prefix
@@ -289,6 +298,9 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
role = django_filters.MultipleChoiceFilter(
choices=IPADDRESS_ROLE_CHOICES
)
+ tag = django_filters.CharFilter(
+ name='tags__slug',
+ )
class Meta:
model = IPAddress
@@ -394,6 +406,9 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
choices=VLAN_STATUS_CHOICES,
null_value=None
)
+ tag = django_filters.CharFilter(
+ name='tags__slug',
+ )
class Meta:
model = VLAN
diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py
index 5b2c6e672..82ebfe724 100644
--- a/netbox/ipam/forms.py
+++ b/netbox/ipam/forms.py
@@ -3,6 +3,7 @@ from __future__ import unicode_literals
from django import forms
from django.core.exceptions import MultipleObjectsReturned
from django.db.models import Count
+from taggit.forms import TagField
from dcim.models import Site, Rack, Device, Interface
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):
+ tags = TagField(required=False)
class Meta:
model = VRF
- fields = ['name', 'rd', 'enforce_unique', 'description', 'tenant_group', 'tenant']
+ fields = ['name', 'rd', 'enforce_unique', 'description', 'tenant_group', 'tenant', 'tags']
labels = {
'rd': "RD",
}
@@ -121,10 +123,11 @@ class RIRFilterForm(BootstrapMixin, forms.Form):
#
class AggregateForm(BootstrapMixin, CustomFieldForm):
+ tags = TagField(required=False)
class Meta:
model = Aggregate
- fields = ['prefix', 'rir', 'date_added', 'description']
+ fields = ['prefix', 'rir', 'date_added', 'description', 'tags']
help_texts = {
'prefix': "IPv4 or IPv6 network",
'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'
)
)
+ tags = TagField(required=False)
class Meta:
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):
@@ -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')
+ tags = TagField(required=False)
class Meta:
model = IPAddress
fields = [
'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):
@@ -508,7 +516,7 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
ipaddress = super(IPAddressForm, self).save(*args, **kwargs)
- # Assign this IPAddress as the primary for the associated Device.
+ # Assign/clear this IPAddress as the primary for the associated Device/VirtualMachine.
if self.cleaned_data['primary_for_parent']:
parent = self.cleaned_data['interface'].parent
if ipaddress.address.version == 4:
@@ -516,14 +524,12 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
else:
parent.primary_ip6 = ipaddress
parent.save()
-
- # Clear assignment as primary for device if set.
elif self.cleaned_data['interface']:
parent = self.cleaned_data['interface'].parent
- if ipaddress.address.version == 4 and parent.primary_ip4 == self:
+ if ipaddress.address.version == 4 and parent.primary_ip4 == ipaddress:
parent.primary_ip4 = None
parent.save()
- elif ipaddress.address.version == 6 and parent.primary_ip6 == self:
+ elif ipaddress.address.version == 6 and parent.primary_ip6 == ipaddress:
parent.primary_ip6 = None
parent.save()
@@ -782,10 +788,11 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm):
api_url='/api/ipam/vlan-groups/?site_id={{site}}',
)
)
+ tags = TagField(required=False)
class Meta:
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 = {
'site': "Leave blank if this VLAN spans multiple sites",
'group': "VLAN group (optional)",
diff --git a/netbox/ipam/migrations/0022_tags.py b/netbox/ipam/migrations/0022_tags.py
new file mode 100644
index 000000000..fe5c113b1
--- /dev/null
+++ b/netbox/ipam/migrations/0022_tags.py
@@ -0,0 +1,42 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.12 on 2018-05-22 19:04
+from __future__ import unicode_literals
+
+from django.db import migrations
+import taggit.managers
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('taggit', '0002_auto_20150616_2121'),
+ ('ipam', '0021_vrf_ordering'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='aggregate',
+ name='tags',
+ field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'),
+ ),
+ migrations.AddField(
+ model_name='ipaddress',
+ name='tags',
+ field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'),
+ ),
+ migrations.AddField(
+ model_name='prefix',
+ name='tags',
+ field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'),
+ ),
+ migrations.AddField(
+ model_name='vlan',
+ name='tags',
+ field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'),
+ ),
+ migrations.AddField(
+ model_name='vrf',
+ name='tags',
+ field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'),
+ ),
+ ]
diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py
index d6b7e3599..222228b97 100644
--- a/netbox/ipam/models.py
+++ b/netbox/ipam/models.py
@@ -10,10 +10,10 @@ from django.db.models import Q
from django.db.models.expressions import RawSQL
from django.urls import reverse
from django.utils.encoding import python_2_unicode_compatible
+from taggit.managers import TaggableManager
from dcim.models import Interface
-from extras.models import CustomFieldModel, CustomFieldValue
-from tenancy.models import Tenant
+from extras.models import CustomFieldModel
from utilities.models import CreatedUpdatedModel
from .constants import *
from .fields import IPNetworkField, IPAddressField
@@ -27,13 +27,37 @@ class VRF(CreatedUpdatedModel, CustomFieldModel):
table). Prefixes and IPAddresses can optionally be assigned to VRFs. (Prefixes and IPAddresses not assigned to a VRF
are said to exist in the "global" table.)
"""
- name = models.CharField(max_length=50)
- rd = models.CharField(max_length=21, unique=True, verbose_name='Route distinguisher')
- tenant = models.ForeignKey(Tenant, related_name='vrfs', blank=True, null=True, on_delete=models.PROTECT)
- enforce_unique = models.BooleanField(default=True, verbose_name='Enforce unique space',
- help_text="Prevent duplicate prefixes/IP addresses within this VRF")
- description = models.CharField(max_length=100, blank=True)
- custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
+ name = models.CharField(
+ max_length=50
+ )
+ rd = models.CharField(
+ max_length=21,
+ unique=True,
+ verbose_name='Route distinguisher'
+ )
+ tenant = models.ForeignKey(
+ to='tenancy.Tenant',
+ on_delete=models.PROTECT,
+ related_name='vrfs',
+ blank=True,
+ null=True
+ )
+ enforce_unique = models.BooleanField(
+ default=True,
+ verbose_name='Enforce unique space',
+ help_text='Prevent duplicate prefixes/IP addresses within this VRF'
+ )
+ description = models.CharField(
+ max_length=100,
+ blank=True
+ )
+ custom_field_values = GenericRelation(
+ to='extras.CustomFieldValue',
+ content_type_field='obj_type',
+ object_id_field='obj_id'
+ )
+
+ tags = TaggableManager()
csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
@@ -74,10 +98,18 @@ class RIR(models.Model):
A Regional Internet Registry (RIR) is responsible for the allocation of a large portion of the global IP address
space. This can be an organization like ARIN or RIPE, or a governing standard such as RFC 1918.
"""
- name = models.CharField(max_length=50, unique=True)
- slug = models.SlugField(unique=True)
- is_private = models.BooleanField(default=False, verbose_name='Private',
- help_text='IP space managed by this RIR is considered private')
+ name = models.CharField(
+ max_length=50,
+ unique=True
+ )
+ slug = models.SlugField(
+ unique=True
+ )
+ is_private = models.BooleanField(
+ default=False,
+ verbose_name='Private',
+ help_text='IP space managed by this RIR is considered private'
+ )
csv_headers = ['name', 'slug', 'is_private']
@@ -106,12 +138,31 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel):
An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize
the hierarchy and track the overall utilization of available address space. Each Aggregate is assigned to a RIR.
"""
- family = models.PositiveSmallIntegerField(choices=AF_CHOICES)
+ family = models.PositiveSmallIntegerField(
+ choices=AF_CHOICES
+ )
prefix = IPNetworkField()
- rir = models.ForeignKey('RIR', related_name='aggregates', on_delete=models.PROTECT, verbose_name='RIR')
- date_added = models.DateField(blank=True, null=True)
- description = models.CharField(max_length=100, blank=True)
- custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
+ rir = models.ForeignKey(
+ to='ipam.RIR',
+ on_delete=models.PROTECT,
+ related_name='aggregates',
+ verbose_name='RIR'
+ )
+ date_added = models.DateField(
+ blank=True,
+ null=True
+ )
+ description = models.CharField(
+ max_length=100,
+ blank=True
+ )
+ custom_field_values = GenericRelation(
+ to='extras.CustomFieldValue',
+ content_type_field='obj_type',
+ object_id_field='obj_id'
+ )
+
+ tags = TaggableManager()
csv_headers = ['prefix', 'rir', 'date_added', 'description']
@@ -186,9 +237,16 @@ class Role(models.Model):
A Role represents the functional role of a Prefix or VLAN; for example, "Customer," "Infrastructure," or
"Management."
"""
- name = models.CharField(max_length=50, unique=True)
- slug = models.SlugField(unique=True)
- weight = models.PositiveSmallIntegerField(default=1000)
+ name = models.CharField(
+ max_length=50,
+ unique=True
+ )
+ slug = models.SlugField(
+ unique=True
+ )
+ weight = models.PositiveSmallIntegerField(
+ default=1000
+ )
csv_headers = ['name', 'slug', 'weight']
@@ -213,24 +271,74 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
VRFs. A Prefix must be assigned a status and may optionally be assigned a used-define Role. A Prefix can also be
assigned to a VLAN where appropriate.
"""
- family = models.PositiveSmallIntegerField(choices=AF_CHOICES, editable=False)
- prefix = IPNetworkField(help_text="IPv4 or IPv6 network with mask")
- site = models.ForeignKey('dcim.Site', related_name='prefixes', on_delete=models.PROTECT, blank=True, null=True)
- vrf = models.ForeignKey('VRF', related_name='prefixes', on_delete=models.PROTECT, blank=True, null=True,
- verbose_name='VRF')
- tenant = models.ForeignKey(Tenant, related_name='prefixes', blank=True, null=True, on_delete=models.PROTECT)
- vlan = models.ForeignKey('VLAN', related_name='prefixes', on_delete=models.PROTECT, blank=True, null=True,
- verbose_name='VLAN')
- status = models.PositiveSmallIntegerField('Status', choices=PREFIX_STATUS_CHOICES, default=PREFIX_STATUS_ACTIVE,
- help_text="Operational status of this prefix")
- role = models.ForeignKey('Role', related_name='prefixes', on_delete=models.SET_NULL, blank=True, null=True,
- help_text="The primary function of this prefix")
- is_pool = models.BooleanField(verbose_name='Is a pool', default=False,
- help_text="All IP addresses within this prefix are considered usable")
- description = models.CharField(max_length=100, blank=True)
- custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
+ family = models.PositiveSmallIntegerField(
+ choices=AF_CHOICES,
+ editable=False
+ )
+ prefix = IPNetworkField(
+ help_text='IPv4 or IPv6 network with mask'
+ )
+ site = models.ForeignKey(
+ to='dcim.Site',
+ on_delete=models.PROTECT,
+ related_name='prefixes',
+ blank=True,
+ null=True
+ )
+ vrf = models.ForeignKey(
+ to='ipam.VRF',
+ on_delete=models.PROTECT,
+ related_name='prefixes',
+ blank=True,
+ null=True,
+ verbose_name='VRF'
+ )
+ tenant = models.ForeignKey(
+ to='tenancy.Tenant',
+ on_delete=models.PROTECT,
+ related_name='prefixes',
+ blank=True,
+ null=True
+ )
+ vlan = models.ForeignKey(
+ to='ipam.VLAN',
+ on_delete=models.PROTECT,
+ related_name='prefixes',
+ blank=True,
+ null=True,
+ verbose_name='VLAN'
+ )
+ status = models.PositiveSmallIntegerField(
+ choices=PREFIX_STATUS_CHOICES,
+ default=PREFIX_STATUS_ACTIVE,
+ verbose_name='Status',
+ help_text='Operational status of this prefix'
+ )
+ role = models.ForeignKey(
+ to='ipam.Role',
+ on_delete=models.SET_NULL,
+ related_name='prefixes',
+ blank=True,
+ null=True,
+ help_text='The primary function of this prefix'
+ )
+ is_pool = models.BooleanField(
+ verbose_name='Is a pool',
+ default=False,
+ help_text='All IP addresses within this prefix are considered usable'
+ )
+ description = models.CharField(
+ max_length=100,
+ blank=True
+ )
+ custom_field_values = GenericRelation(
+ to='extras.CustomFieldValue',
+ content_type_field='obj_type',
+ object_id_field='obj_id'
+ )
objects = PrefixQuerySet.as_manager()
+ tags = TaggableManager()
csv_headers = [
'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description',
@@ -422,27 +530,69 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
for example, when mapping public addresses to private addresses. When an Interface has been assigned an IPAddress
which has a NAT outside IP, that Interface's Device can use either the inside or outside IP as its primary IP.
"""
- family = models.PositiveSmallIntegerField(choices=AF_CHOICES, editable=False)
- address = IPAddressField(help_text="IPv4 or IPv6 address (with mask)")
- vrf = models.ForeignKey('VRF', related_name='ip_addresses', on_delete=models.PROTECT, blank=True, null=True,
- verbose_name='VRF')
- tenant = models.ForeignKey(Tenant, related_name='ip_addresses', blank=True, null=True, on_delete=models.PROTECT)
+ family = models.PositiveSmallIntegerField(
+ choices=AF_CHOICES,
+ editable=False
+ )
+ address = IPAddressField(
+ help_text='IPv4 or IPv6 address (with mask)'
+ )
+ vrf = models.ForeignKey(
+ to='ipam.VRF',
+ on_delete=models.PROTECT,
+ related_name='ip_addresses',
+ blank=True,
+ null=True,
+ verbose_name='VRF'
+ )
+ tenant = models.ForeignKey(
+ to='tenancy.Tenant',
+ on_delete=models.PROTECT,
+ related_name='ip_addresses',
+ blank=True,
+ null=True
+ )
status = models.PositiveSmallIntegerField(
- 'Status', choices=IPADDRESS_STATUS_CHOICES, default=IPADDRESS_STATUS_ACTIVE,
+ choices=IPADDRESS_STATUS_CHOICES,
+ default=IPADDRESS_STATUS_ACTIVE,
+ verbose_name='Status',
help_text='The operational status of this IP'
)
role = models.PositiveSmallIntegerField(
- 'Role', choices=IPADDRESS_ROLE_CHOICES, blank=True, null=True, help_text='The functional role of this IP'
+ verbose_name='Role',
+ choices=IPADDRESS_ROLE_CHOICES,
+ blank=True,
+ null=True,
+ help_text='The functional role of this IP'
+ )
+ interface = models.ForeignKey(
+ to='dcim.Interface',
+ on_delete=models.CASCADE,
+ related_name='ip_addresses',
+ blank=True,
+ null=True
+ )
+ nat_inside = models.OneToOneField(
+ to='self',
+ on_delete=models.SET_NULL,
+ related_name='nat_outside',
+ blank=True,
+ null=True,
+ verbose_name='NAT (Inside)',
+ help_text='The IP for which this address is the "outside" IP'
+ )
+ description = models.CharField(
+ max_length=100,
+ blank=True
+ )
+ custom_field_values = GenericRelation(
+ to='extras.CustomFieldValue',
+ content_type_field='obj_type',
+ object_id_field='obj_id'
)
- interface = models.ForeignKey(Interface, related_name='ip_addresses', on_delete=models.CASCADE, blank=True,
- null=True)
- nat_inside = models.OneToOneField('self', related_name='nat_outside', on_delete=models.SET_NULL, blank=True,
- null=True, verbose_name='NAT (Inside)',
- help_text="The IP for which this address is the \"outside\" IP")
- description = models.CharField(max_length=100, blank=True)
- custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
objects = IPAddressManager()
+ tags = TaggableManager()
csv_headers = [
'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface_name', 'is_primary',
@@ -535,9 +685,17 @@ class VLANGroup(models.Model):
"""
A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique.
"""
- name = models.CharField(max_length=50)
+ name = models.CharField(
+ max_length=50
+ )
slug = models.SlugField()
- site = models.ForeignKey('dcim.Site', related_name='vlan_groups', on_delete=models.PROTECT, blank=True, null=True)
+ site = models.ForeignKey(
+ to='dcim.Site',
+ on_delete=models.PROTECT,
+ related_name='vlan_groups',
+ blank=True,
+ null=True
+ )
csv_headers = ['name', 'slug', 'site']
@@ -588,18 +746,57 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
Like Prefixes, each VLAN is assigned an operational status and optionally a user-defined Role. A VLAN can have zero
or more Prefixes assigned to it.
"""
- site = models.ForeignKey('dcim.Site', related_name='vlans', on_delete=models.PROTECT, blank=True, null=True)
- group = models.ForeignKey('VLANGroup', related_name='vlans', blank=True, null=True, on_delete=models.PROTECT)
- vid = models.PositiveSmallIntegerField(verbose_name='ID', validators=[
- MinValueValidator(1),
- MaxValueValidator(4094)
- ])
- name = models.CharField(max_length=64)
- tenant = models.ForeignKey(Tenant, related_name='vlans', blank=True, null=True, on_delete=models.PROTECT)
- status = models.PositiveSmallIntegerField('Status', choices=VLAN_STATUS_CHOICES, default=1)
- role = models.ForeignKey('Role', related_name='vlans', on_delete=models.SET_NULL, blank=True, null=True)
- description = models.CharField(max_length=100, blank=True)
- custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
+ site = models.ForeignKey(
+ to='dcim.Site',
+ on_delete=models.PROTECT,
+ related_name='vlans',
+ blank=True,
+ null=True
+ )
+ group = models.ForeignKey(
+ to='ipam.VLANGroup',
+ on_delete=models.PROTECT,
+ related_name='vlans',
+ blank=True,
+ null=True
+ )
+ vid = models.PositiveSmallIntegerField(
+ verbose_name='ID',
+ validators=[MinValueValidator(1), MaxValueValidator(4094)]
+ )
+ name = models.CharField(
+ max_length=64
+ )
+ tenant = models.ForeignKey(
+ to='tenancy.Tenant',
+ on_delete=models.PROTECT,
+ related_name='vlans',
+ blank=True,
+ null=True
+ )
+ status = models.PositiveSmallIntegerField(
+ choices=VLAN_STATUS_CHOICES,
+ default=1,
+ verbose_name='Status'
+ )
+ role = models.ForeignKey(
+ to='ipam.Role',
+ on_delete=models.SET_NULL,
+ related_name='vlans',
+ blank=True,
+ null=True
+ )
+ description = models.CharField(
+ max_length=100,
+ blank=True
+ )
+ custom_field_values = GenericRelation(
+ to='extras.CustomFieldValue',
+ content_type_field='obj_type',
+ object_id_field='obj_id'
+ )
+
+ tags = TaggableManager()
csv_headers = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description']
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index 00ca5deee..784e89f86 100644
--- a/netbox/netbox/settings.py
+++ b/netbox/netbox/settings.py
@@ -22,7 +22,7 @@ if sys.version_info[0] < 3:
DeprecationWarning
)
-VERSION = '2.3.3-dev'
+VERSION = '2.4-dev'
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -139,6 +139,7 @@ INSTALLED_APPS = [
'django_tables2',
'mptt',
'rest_framework',
+ 'taggit',
'timezone_field',
'circuits',
'dcim',
@@ -164,7 +165,6 @@ MIDDLEWARE = (
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
- 'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'django.middleware.security.SecurityMiddleware',
diff --git a/netbox/secrets/api/serializers.py b/netbox/secrets/api/serializers.py
index a4e61a018..0e24281bb 100644
--- a/netbox/secrets/api/serializers.py
+++ b/netbox/secrets/api/serializers.py
@@ -2,10 +2,11 @@ from __future__ import unicode_literals
from rest_framework import serializers
from rest_framework.validators import UniqueTogetherValidator
+from taggit.models import Tag
from dcim.api.serializers import NestedDeviceSerializer
from secrets.models import Secret, SecretRole
-from utilities.api import ValidatedModelSerializer
+from utilities.api import TagField, ValidatedModelSerializer, WritableNestedSerializer
#
@@ -19,7 +20,7 @@ class SecretRoleSerializer(ValidatedModelSerializer):
fields = ['id', 'name', 'slug']
-class NestedSecretRoleSerializer(serializers.ModelSerializer):
+class NestedSecretRoleSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='secrets-api:secretrole-detail')
class Meta:
@@ -31,21 +32,15 @@ class NestedSecretRoleSerializer(serializers.ModelSerializer):
# Secrets
#
-class SecretSerializer(serializers.ModelSerializer):
+class SecretSerializer(ValidatedModelSerializer):
device = NestedDeviceSerializer()
role = NestedSecretRoleSerializer()
-
- class Meta:
- model = Secret
- fields = ['id', 'device', 'role', 'name', 'plaintext', 'hash', 'created', 'last_updated']
-
-
-class WritableSecretSerializer(serializers.ModelSerializer):
plaintext = serializers.CharField()
+ tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta:
model = Secret
- fields = ['id', 'device', 'role', 'name', 'plaintext', 'hash', 'created', 'last_updated']
+ fields = ['id', 'device', 'role', 'name', 'plaintext', 'hash', 'tags', 'created', 'last_updated']
validators = []
def validate(self, data):
@@ -64,6 +59,6 @@ class WritableSecretSerializer(serializers.ModelSerializer):
validator(data)
# Enforce model validation
- super(WritableSecretSerializer, self).validate(data)
+ super(SecretSerializer, self).validate(data)
return data
diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py
index d2fb2ef00..9bc52f9f0 100644
--- a/netbox/secrets/api/views.py
+++ b/netbox/secrets/api/views.py
@@ -51,7 +51,6 @@ class SecretViewSet(ModelViewSet):
'role__users', 'role__groups',
)
serializer_class = serializers.SecretSerializer
- write_serializer_class = serializers.WritableSecretSerializer
filter_class = filters.SecretFilter
master_key = None
@@ -68,7 +67,7 @@ class SecretViewSet(ModelViewSet):
super(SecretViewSet, self).initial(request, *args, **kwargs)
- if request.user.is_authenticated():
+ if request.user.is_authenticated:
# Read session key from HTTP cookie or header if it has been provided. The session key must be provided in
# order to encrypt/decrypt secrets.
diff --git a/netbox/secrets/filters.py b/netbox/secrets/filters.py
index 6578eb4b8..2499fa2bb 100644
--- a/netbox/secrets/filters.py
+++ b/netbox/secrets/filters.py
@@ -41,6 +41,9 @@ class SecretFilter(django_filters.FilterSet):
to_field_name='name',
label='Device (name)',
)
+ tag = django_filters.CharFilter(
+ name='tags__slug',
+ )
class Meta:
model = Secret
diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py
index 8f8107805..863d1dfde 100644
--- a/netbox/secrets/forms.py
+++ b/netbox/secrets/forms.py
@@ -4,6 +4,7 @@ from Crypto.Cipher import PKCS1_OAEP
from Crypto.PublicKey import RSA
from django import forms
from django.db.models import Count
+from taggit.forms import TagField
from dcim.models import Device
from utilities.forms import BootstrapMixin, BulkEditForm, FilterChoiceField, FlexibleModelChoiceField, SlugField
@@ -70,10 +71,11 @@ class SecretForm(BootstrapMixin, forms.ModelForm):
label='Plaintext (verify)',
widget=forms.PasswordInput()
)
+ tags = TagField(required=False)
class Meta:
model = Secret
- fields = ['role', 'name', 'plaintext', 'plaintext2']
+ fields = ['role', 'name', 'plaintext', 'plaintext2', 'tags']
def __init__(self, *args, **kwargs):
diff --git a/netbox/secrets/migrations/0004_tags.py b/netbox/secrets/migrations/0004_tags.py
new file mode 100644
index 000000000..ac952dc92
--- /dev/null
+++ b/netbox/secrets/migrations/0004_tags.py
@@ -0,0 +1,22 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.12 on 2018-05-22 19:04
+from __future__ import unicode_literals
+
+from django.db import migrations
+import taggit.managers
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('taggit', '0002_auto_20150616_2121'),
+ ('secrets', '0003_unicode_literals'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='secret',
+ name='tags',
+ field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'),
+ ),
+ ]
diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py
index e1f367d03..dcb38db70 100644
--- a/netbox/secrets/models.py
+++ b/netbox/secrets/models.py
@@ -12,8 +12,8 @@ from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils.encoding import force_bytes, python_2_unicode_compatible
+from taggit.managers import TaggableManager
-from dcim.models import Device
from utilities.models import CreatedUpdatedModel
from .exceptions import InvalidKey
from .hashers import SecretValidationHasher
@@ -54,9 +54,21 @@ class UserKey(CreatedUpdatedModel):
copy of the master encryption key. The encrypted instance of the master key can be decrypted only with the user's
matching (private) decryption key.
"""
- user = models.OneToOneField(User, related_name='user_key', editable=False, on_delete=models.CASCADE)
- public_key = models.TextField(verbose_name='RSA public key')
- master_key_cipher = models.BinaryField(max_length=512, blank=True, null=True, editable=False)
+ user = models.OneToOneField(
+ to=User,
+ on_delete=models.CASCADE,
+ related_name='user_key',
+ editable=False
+ )
+ public_key = models.TextField(
+ verbose_name='RSA public key'
+ )
+ master_key_cipher = models.BinaryField(
+ max_length=512,
+ blank=True,
+ null=True,
+ editable=False
+ )
objects = UserKeyQuerySet.as_manager()
@@ -172,10 +184,23 @@ class SessionKey(models.Model):
"""
A SessionKey stores a User's temporary key to be used for the encryption and decryption of secrets.
"""
- userkey = models.OneToOneField(UserKey, related_name='session_key', on_delete=models.CASCADE, editable=False)
- cipher = models.BinaryField(max_length=512, editable=False)
- hash = models.CharField(max_length=128, editable=False)
- created = models.DateTimeField(auto_now_add=True)
+ userkey = models.OneToOneField(
+ to='secrets.UserKey',
+ on_delete=models.CASCADE,
+ related_name='session_key',
+ editable=False
+ )
+ cipher = models.BinaryField(
+ max_length=512,
+ editable=False
+ )
+ hash = models.CharField(
+ max_length=128,
+ editable=False
+ )
+ created = models.DateTimeField(
+ auto_now_add=True
+ )
key = None
@@ -234,10 +259,23 @@ class SecretRole(models.Model):
By default, only superusers will have access to decrypt Secrets. To allow other users to decrypt Secrets, grant them
access to the appropriate SecretRoles either individually or by group.
"""
- name = models.CharField(max_length=50, unique=True)
- slug = models.SlugField(unique=True)
- users = models.ManyToManyField(User, related_name='secretroles', blank=True)
- groups = models.ManyToManyField(Group, related_name='secretroles', blank=True)
+ name = models.CharField(
+ max_length=50,
+ unique=True
+ )
+ slug = models.SlugField(
+ unique=True
+ )
+ users = models.ManyToManyField(
+ to=User,
+ related_name='secretroles',
+ blank=True
+ )
+ groups = models.ManyToManyField(
+ to=Group,
+ related_name='secretroles',
+ blank=True
+ )
csv_headers = ['name', 'slug']
@@ -276,11 +314,30 @@ class Secret(CreatedUpdatedModel):
A Secret can be up to 65,536 bytes (64KB) in length. Each secret string will be padded with random data to a minimum
of 64 bytes during encryption in order to protect short strings from ciphertext analysis.
"""
- device = models.ForeignKey(Device, related_name='secrets', on_delete=models.CASCADE)
- role = models.ForeignKey('SecretRole', related_name='secrets', on_delete=models.PROTECT)
- name = models.CharField(max_length=100, blank=True)
- ciphertext = models.BinaryField(editable=False, max_length=65568) # 16B IV + 2B pad length + {62-65550}B padded
- hash = models.CharField(max_length=128, editable=False)
+ device = models.ForeignKey(
+ to='dcim.Device',
+ on_delete=models.CASCADE,
+ related_name='secrets'
+ )
+ role = models.ForeignKey(
+ to='secrets.SecretRole',
+ on_delete=models.PROTECT,
+ related_name='secrets'
+ )
+ name = models.CharField(
+ max_length=100,
+ blank=True
+ )
+ ciphertext = models.BinaryField(
+ max_length=65568, # 16B IV + 2B pad length + {62-65550}B padded
+ editable=False
+ )
+ hash = models.CharField(
+ max_length=128,
+ editable=False
+ )
+
+ tags = TaggableManager()
plaintext = None
csv_headers = ['device', 'role', 'name', 'plaintext']
diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html
index 1133f41f3..509c6da89 100644
--- a/netbox/templates/circuits/circuit.html
+++ b/netbox/templates/circuits/circuit.html
@@ -110,6 +110,16 @@
{% endif %}
+
+ Tags |
+
+ {% for tag in circuit.tags.all %}
+ {% tag 'circuits:circuit_list' tag %}
+ {% empty %}
+ N/A
+ {% endfor %}
+ |
+
{% with circuit.get_custom_fields as custom_fields %}
diff --git a/netbox/templates/circuits/circuit_edit.html b/netbox/templates/circuits/circuit_edit.html
index 8503e68f6..06ad65241 100644
--- a/netbox/templates/circuits/circuit_edit.html
+++ b/netbox/templates/circuits/circuit_edit.html
@@ -44,6 +44,12 @@
{% render_field form.comments %}
+
+
Tags
+
+ {% render_field form.tags %}
+
+
{% endblock %}
{% block javascript %}
diff --git a/netbox/templates/circuits/circuit_list.html b/netbox/templates/circuits/circuit_list.html
index f05552f7d..81e09c32b 100644
--- a/netbox/templates/circuits/circuit_list.html
+++ b/netbox/templates/circuits/circuit_list.html
@@ -16,6 +16,7 @@
{% include 'inc/search_panel.html' %}
+ {% include 'inc/tags_panel.html' %}
{% endblock %}
diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html
index 6dcccfd8d..e19175c7f 100644
--- a/netbox/templates/circuits/provider.html
+++ b/netbox/templates/circuits/provider.html
@@ -102,6 +102,16 @@
{% endif %}
+
+ Tags |
+
+ {% for tag in provider.tags.all %}
+ {% tag 'circuits:provider_list' tag %}
+ {% empty %}
+ N/A
+ {% endfor %}
+ |
+
Circuits |
diff --git a/netbox/templates/circuits/provider_edit.html b/netbox/templates/circuits/provider_edit.html
index 4fb3889b1..dfa239e40 100644
--- a/netbox/templates/circuits/provider_edit.html
+++ b/netbox/templates/circuits/provider_edit.html
@@ -33,4 +33,10 @@
{% render_field form.comments %}
+
+ Tags
+
+ {% render_field form.tags %}
+
+
{% endblock %}
diff --git a/netbox/templates/circuits/provider_list.html b/netbox/templates/circuits/provider_list.html
index cb7aab406..a0036f46c 100644
--- a/netbox/templates/circuits/provider_list.html
+++ b/netbox/templates/circuits/provider_list.html
@@ -16,6 +16,7 @@
{% include 'inc/search_panel.html' %}
+ {% include 'inc/tags_panel.html' %}
{% endblock %}
diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html
index e2253d4f4..1b1d3d23a 100644
--- a/netbox/templates/dcim/device.html
+++ b/netbox/templates/dcim/device.html
@@ -96,6 +96,16 @@
{% endif %}
|
+
+ Tags |
+
+ {% for tag in device.tags.all %}
+ {% tag 'dcim:device_list' tag %}
+ {% empty %}
+ N/A
+ {% endfor %}
+ |
+
{% if vc_members %}
diff --git a/netbox/templates/dcim/device_edit.html b/netbox/templates/dcim/device_edit.html
index 07206ca27..d39c01482 100644
--- a/netbox/templates/dcim/device_edit.html
+++ b/netbox/templates/dcim/device_edit.html
@@ -83,4 +83,10 @@
{% render_field form.comments %}
+
+
Tags
+
+ {% render_field form.tags %}
+
+
{% endblock %}
diff --git a/netbox/templates/dcim/device_list.html b/netbox/templates/dcim/device_list.html
index f96b27309..4bae11781 100644
--- a/netbox/templates/dcim/device_list.html
+++ b/netbox/templates/dcim/device_list.html
@@ -16,6 +16,7 @@
{% include 'inc/search_panel.html' %}
+ {% include 'inc/tags_panel.html' %}
{% endblock %}
diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html
index 12281734b..27d2e3694 100644
--- a/netbox/templates/dcim/devicetype.html
+++ b/netbox/templates/dcim/devicetype.html
@@ -73,6 +73,16 @@
Interface Ordering |
{{ devicetype.get_interface_ordering_display }} |
+
+ Tags |
+
+ {% for tag in devicetype.tags.all %}
+ {% tag 'dcim:devicetype_list' tag %}
+ {% empty %}
+ N/A
+ {% endfor %}
+ |
+
Instances |
{{ devicetype.instances.count }} |
diff --git a/netbox/templates/dcim/devicetype_edit.html b/netbox/templates/dcim/devicetype_edit.html
index d2a107607..e69077ad9 100644
--- a/netbox/templates/dcim/devicetype_edit.html
+++ b/netbox/templates/dcim/devicetype_edit.html
@@ -37,4 +37,10 @@
{% render_field form.comments %}
+
+
Tags
+
+ {% render_field form.tags %}
+
+
{% endblock %}
diff --git a/netbox/templates/dcim/devicetype_list.html b/netbox/templates/dcim/devicetype_list.html
index 91745082a..eb901f5a0 100644
--- a/netbox/templates/dcim/devicetype_list.html
+++ b/netbox/templates/dcim/devicetype_list.html
@@ -16,6 +16,7 @@
{% include 'inc/search_panel.html' %}
+ {% include 'inc/tags_panel.html' %}
{% endblock %}
diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html
index 28a9dfb6f..82348e6fe 100644
--- a/netbox/templates/dcim/rack.html
+++ b/netbox/templates/dcim/rack.html
@@ -114,6 +114,16 @@
{% endif %}
+
+ Tags |
+
+ {% for tag in rack.tags.all %}
+ {% tag 'dcim:rack_list' tag %}
+ {% empty %}
+ N/A
+ {% endfor %}
+ |
+
Devices |
diff --git a/netbox/templates/dcim/rack_edit.html b/netbox/templates/dcim/rack_edit.html
index 4ab129a1d..b9526a3ac 100644
--- a/netbox/templates/dcim/rack_edit.html
+++ b/netbox/templates/dcim/rack_edit.html
@@ -43,4 +43,10 @@
{% render_field form.comments %}
+
+ Tags
+
+ {% render_field form.tags %}
+
+
{% endblock %}
diff --git a/netbox/templates/dcim/rack_list.html b/netbox/templates/dcim/rack_list.html
index d5734ee2b..e61f4eadf 100644
--- a/netbox/templates/dcim/rack_list.html
+++ b/netbox/templates/dcim/rack_list.html
@@ -16,6 +16,7 @@
{% include 'inc/search_panel.html' %}
+ {% include 'inc/tags_panel.html' %}
{% endblock %}
diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html
index b14c2019d..a882d77c8 100644
--- a/netbox/templates/dcim/site.html
+++ b/netbox/templates/dcim/site.html
@@ -133,6 +133,16 @@
{% endif %}
|
+
+ Tags |
+
+ {% for tag in site.tags.all %}
+ {% tag 'dcim:site_list' tag %}
+ {% empty %}
+ N/A
+ {% endfor %}
+ |
+
diff --git a/netbox/templates/dcim/site_edit.html b/netbox/templates/dcim/site_edit.html
index 399551434..ad7932642 100644
--- a/netbox/templates/dcim/site_edit.html
+++ b/netbox/templates/dcim/site_edit.html
@@ -46,4 +46,10 @@
{% render_field form.comments %}
+
+
Tags
+
+ {% render_field form.tags %}
+
+
{% endblock %}
diff --git a/netbox/templates/dcim/site_list.html b/netbox/templates/dcim/site_list.html
index 7baa76dad..50066186d 100644
--- a/netbox/templates/dcim/site_list.html
+++ b/netbox/templates/dcim/site_list.html
@@ -16,6 +16,7 @@
{% include 'inc/search_panel.html' %}
+ {% include 'inc/tags_panel.html' %}
{% endblock %}
diff --git a/netbox/templates/extras/tag_list.html b/netbox/templates/extras/tag_list.html
new file mode 100644
index 000000000..3136991a0
--- /dev/null
+++ b/netbox/templates/extras/tag_list.html
@@ -0,0 +1,11 @@
+{% extends '_base.html' %}
+{% load buttons %}
+
+{% block content %}
+{% block title %}Tags{% endblock %}
+
+
+ {% include 'utilities/obj_table.html' with bulk_delete_url='extras:tag_bulk_delete' %}
+
+
+{% endblock %}
diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html
index a85647993..2c47ad85b 100644
--- a/netbox/templates/inc/nav_menu.html
+++ b/netbox/templates/inc/nav_menu.html
@@ -16,7 +16,7 @@
{% if request.user.is_authenticated or not settings.LOGIN_REQUIRED %}
-
+ -
+ Tags
+
-
Reports
diff --git a/netbox/templates/inc/tags_panel.html b/netbox/templates/inc/tags_panel.html
new file mode 100644
index 000000000..a7923fbed
--- /dev/null
+++ b/netbox/templates/inc/tags_panel.html
@@ -0,0 +1,13 @@
+{% load helpers %}
+
+
diff --git a/netbox/templates/ipam/aggregate.html b/netbox/templates/ipam/aggregate.html
index 63731755c..a7711354d 100644
--- a/netbox/templates/ipam/aggregate.html
+++ b/netbox/templates/ipam/aggregate.html
@@ -1,4 +1,5 @@
{% extends '_base.html' %}
+{% load helpers %}
{% block content %}
@@ -81,6 +82,16 @@
{% endif %}
+
+ Tags |
+
+ {% for tag in aggregate.tags.all %}
+ {% tag 'ipam:aggregate_list' tag %}
+ {% empty %}
+ N/A
+ {% endfor %}
+ |
+
diff --git a/netbox/templates/ipam/aggregate_edit.html b/netbox/templates/ipam/aggregate_edit.html
index be499a509..3cb83ab54 100644
--- a/netbox/templates/ipam/aggregate_edit.html
+++ b/netbox/templates/ipam/aggregate_edit.html
@@ -19,4 +19,10 @@
{% endif %}
+
+
Tags
+
+ {% render_field form.tags %}
+
+
{% endblock %}
diff --git a/netbox/templates/ipam/aggregate_list.html b/netbox/templates/ipam/aggregate_list.html
index 73da9695d..33db74e5c 100644
--- a/netbox/templates/ipam/aggregate_list.html
+++ b/netbox/templates/ipam/aggregate_list.html
@@ -17,6 +17,7 @@
{% include 'inc/search_panel.html' %}
+ {% include 'inc/tags_panel.html' %}
Statistics
diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html
index 1509f35cb..da0fc6923 100644
--- a/netbox/templates/ipam/ipaddress.html
+++ b/netbox/templates/ipam/ipaddress.html
@@ -1,4 +1,5 @@
{% extends '_base.html' %}
+{% load helpers %}
{% block content %}
@@ -133,6 +134,16 @@
{% endif %}
+
+ Tags |
+
+ {% for tag in ipaddress.tags.all %}
+ {% tag 'ipam:ipaddress_list' tag %}
+ {% empty %}
+ N/A
+ {% endfor %}
+ |
+
{% with ipaddress.get_custom_fields as custom_fields %}
diff --git a/netbox/templates/ipam/ipaddress_edit.html b/netbox/templates/ipam/ipaddress_edit.html
index d0dad69ee..72fc02a1e 100644
--- a/netbox/templates/ipam/ipaddress_edit.html
+++ b/netbox/templates/ipam/ipaddress_edit.html
@@ -66,6 +66,12 @@
{% render_field form.nat_inside %}
+
+
Tags
+
+ {% render_field form.tags %}
+
+
{% if form.custom_fields %}
Custom Fields
diff --git a/netbox/templates/ipam/ipaddress_list.html b/netbox/templates/ipam/ipaddress_list.html
index 5f8fdeb88..418b807bd 100644
--- a/netbox/templates/ipam/ipaddress_list.html
+++ b/netbox/templates/ipam/ipaddress_list.html
@@ -16,6 +16,7 @@
{% include 'inc/search_panel.html' %}
+ {% include 'inc/tags_panel.html' %}
{% endblock %}
diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html
index 11c5fc405..29e9c07a0 100644
--- a/netbox/templates/ipam/prefix.html
+++ b/netbox/templates/ipam/prefix.html
@@ -121,6 +121,16 @@
{% endif %}
+
+ Tags |
+
+ {% for tag in prefix.tags.all %}
+ {% tag 'ipam:prefix_list' tag %}
+ {% empty %}
+ N/A
+ {% endfor %}
+ |
+
Utilization |
{% utilization_graph prefix.get_utilization %} |
diff --git a/netbox/templates/ipam/prefix_edit.html b/netbox/templates/ipam/prefix_edit.html
index 938a75da3..333cf1229 100644
--- a/netbox/templates/ipam/prefix_edit.html
+++ b/netbox/templates/ipam/prefix_edit.html
@@ -28,6 +28,12 @@
{% render_field form.tenant %}
+
+
Tags
+
+ {% render_field form.tags %}
+
+
{% if form.custom_fields %}
Custom Fields
diff --git a/netbox/templates/ipam/prefix_list.html b/netbox/templates/ipam/prefix_list.html
index d65904595..3ce9d4a9c 100644
--- a/netbox/templates/ipam/prefix_list.html
+++ b/netbox/templates/ipam/prefix_list.html
@@ -21,6 +21,7 @@
{% include 'inc/search_panel.html' %}
+ {% include 'inc/tags_panel.html' %}
{% endblock %}
diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html
index 971c3359f..ac874282f 100644
--- a/netbox/templates/ipam/vlan.html
+++ b/netbox/templates/ipam/vlan.html
@@ -1,4 +1,5 @@
{% extends '_base.html' %}
+{% load helpers %}
{% block content %}
{% include 'ipam/inc/vlan_header.html' with active_tab='vlan' %}
@@ -80,6 +81,16 @@
N/A
{% endif %}
+
+
+ Tags |
+
+ {% for tag in vlan.tags.all %}
+ {% tag 'ipam:vlan_list' tag %}
+ {% empty %}
+ N/A
+ {% endfor %}
+ |
diff --git a/netbox/templates/ipam/vlan_edit.html b/netbox/templates/ipam/vlan_edit.html
index 3bfb7783e..7862d4de9 100644
--- a/netbox/templates/ipam/vlan_edit.html
+++ b/netbox/templates/ipam/vlan_edit.html
@@ -21,6 +21,12 @@
{% render_field form.tenant %}
+
+
Tags
+
+ {% render_field form.tags %}
+
+
{% if form.custom_fields %}
Custom Fields
diff --git a/netbox/templates/ipam/vlan_list.html b/netbox/templates/ipam/vlan_list.html
index 24e12595b..d734db8d2 100644
--- a/netbox/templates/ipam/vlan_list.html
+++ b/netbox/templates/ipam/vlan_list.html
@@ -16,6 +16,7 @@
{% include 'inc/search_panel.html' %}
+ {% include 'inc/tags_panel.html' %}
{% endblock %}
diff --git a/netbox/templates/ipam/vrf.html b/netbox/templates/ipam/vrf.html
index e041ce73a..fa51a18f8 100644
--- a/netbox/templates/ipam/vrf.html
+++ b/netbox/templates/ipam/vrf.html
@@ -1,4 +1,5 @@
{% extends '_base.html' %}
+{% load helpers %}
{% block content %}
@@ -77,6 +78,16 @@
N/A
{% endif %}
+
+
+ Tags |
+
+ {% for tag in vrf.tags.all %}
+ {% tag 'ipam:vrf_list' tag %}
+ {% empty %}
+ N/A
+ {% endfor %}
+ |
diff --git a/netbox/templates/ipam/vrf_edit.html b/netbox/templates/ipam/vrf_edit.html
index 63052129c..95a89a6ca 100644
--- a/netbox/templates/ipam/vrf_edit.html
+++ b/netbox/templates/ipam/vrf_edit.html
@@ -18,6 +18,12 @@
{% render_field form.tenant %}
+
+
Tags
+
+ {% render_field form.tags %}
+
+
{% if form.custom_fields %}
Custom Fields
diff --git a/netbox/templates/ipam/vrf_list.html b/netbox/templates/ipam/vrf_list.html
index 23bd16495..670f0ee5d 100644
--- a/netbox/templates/ipam/vrf_list.html
+++ b/netbox/templates/ipam/vrf_list.html
@@ -16,6 +16,7 @@
{% include 'inc/search_panel.html' %}
+ {% include 'inc/tags_panel.html' %}
{% endblock %}
diff --git a/netbox/templates/secrets/secret.html b/netbox/templates/secrets/secret.html
index 66c844ebf..4863fdeb1 100644
--- a/netbox/templates/secrets/secret.html
+++ b/netbox/templates/secrets/secret.html
@@ -1,5 +1,6 @@
{% extends '_base.html' %}
{% load static from staticfiles %}
+{% load helpers %}
{% load secret_helpers %}
{% block content %}
@@ -55,6 +56,16 @@
{% endif %}
+
+ Tags |
+
+ {% for tag in secret.tags.all %}
+ {% tag 'secrets:secret_list' tag %}
+ {% empty %}
+ N/A
+ {% endfor %}
+ |
+
diff --git a/netbox/templates/secrets/secret_edit.html b/netbox/templates/secrets/secret_edit.html
index 920409177..87ee3b426 100644
--- a/netbox/templates/secrets/secret_edit.html
+++ b/netbox/templates/secrets/secret_edit.html
@@ -54,6 +54,12 @@
{% render_field form.plaintext2 %}
+
+
Tags
+
+ {% render_field form.tags %}
+
+
diff --git a/netbox/templates/secrets/secret_list.html b/netbox/templates/secrets/secret_list.html
index 6dd92cd89..0a70e1087 100644
--- a/netbox/templates/secrets/secret_list.html
+++ b/netbox/templates/secrets/secret_list.html
@@ -14,6 +14,7 @@
{% include 'inc/search_panel.html' %}
+ {% include 'inc/tags_panel.html' %}
{% endblock %}
diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html
index d5eb7df98..fbbac175a 100644
--- a/netbox/templates/tenancy/tenant.html
+++ b/netbox/templates/tenancy/tenant.html
@@ -68,6 +68,16 @@
{% endif %}
+
+ Tags |
+
+ {% for tag in tenant.tags.all %}
+ {% tag 'tenancy:tenant_list' tag %}
+ {% empty %}
+ N/A
+ {% endfor %}
+ |
+
{% with tenant.get_custom_fields as custom_fields %}
diff --git a/netbox/templates/tenancy/tenant_edit.html b/netbox/templates/tenancy/tenant_edit.html
index b2c472a1c..9cc0aa53b 100644
--- a/netbox/templates/tenancy/tenant_edit.html
+++ b/netbox/templates/tenancy/tenant_edit.html
@@ -26,4 +26,10 @@
{% render_field form.comments %}
+
+
Tags
+
+ {% render_field form.tags %}
+
+
{% endblock %}
diff --git a/netbox/templates/tenancy/tenant_list.html b/netbox/templates/tenancy/tenant_list.html
index e6fd61c37..176231507 100644
--- a/netbox/templates/tenancy/tenant_list.html
+++ b/netbox/templates/tenancy/tenant_list.html
@@ -16,6 +16,7 @@
{% include 'inc/search_panel.html' %}
+ {% include 'inc/tags_panel.html' %}
{% endblock %}
diff --git a/netbox/templates/utilities/templatetags/tag.html b/netbox/templates/utilities/templatetags/tag.html
new file mode 100644
index 000000000..79e1627db
--- /dev/null
+++ b/netbox/templates/utilities/templatetags/tag.html
@@ -0,0 +1 @@
+{{ tag }}
diff --git a/netbox/templates/virtualization/cluster.html b/netbox/templates/virtualization/cluster.html
index 08251e2fa..9b1621530 100644
--- a/netbox/templates/virtualization/cluster.html
+++ b/netbox/templates/virtualization/cluster.html
@@ -76,6 +76,16 @@
{% endif %}
+
+ Tags |
+
+ {% for tag in cluster.tags.all %}
+ {% tag 'virtualization:cluster_list' tag %}
+ {% empty %}
+ N/A
+ {% endfor %}
+ |
+
Virtual Machines |
{{ cluster.virtual_machines.count }} |
diff --git a/netbox/templates/virtualization/cluster_edit.html b/netbox/templates/virtualization/cluster_edit.html
new file mode 100644
index 000000000..93fe197ec
--- /dev/null
+++ b/netbox/templates/virtualization/cluster_edit.html
@@ -0,0 +1,34 @@
+{% extends 'utilities/obj_edit.html' %}
+{% load form_helpers %}
+
+{% block form %}
+
+
Cluster
+
+ {% render_field form.name %}
+ {% render_field form.type %}
+ {% render_field form.group %}
+ {% render_field form.site %}
+
+
+ {% if form.custom_fields %}
+
+
Custom Fields
+
+ {% render_custom_fields form %}
+
+
+ {% endif %}
+
+
Comments
+
+ {% render_field form.comments %}
+
+
+
+
Tags
+
+ {% render_field form.tags %}
+
+
+{% endblock %}
diff --git a/netbox/templates/virtualization/cluster_list.html b/netbox/templates/virtualization/cluster_list.html
index 08f62e6ba..84513dbb1 100644
--- a/netbox/templates/virtualization/cluster_list.html
+++ b/netbox/templates/virtualization/cluster_list.html
@@ -16,6 +16,7 @@
{% include 'inc/search_panel.html' %}
+ {% include 'inc/tags_panel.html' %}
{% endblock %}
diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html
index 944792705..3d8d0d05a 100644
--- a/netbox/templates/virtualization/virtualmachine.html
+++ b/netbox/templates/virtualization/virtualmachine.html
@@ -121,6 +121,16 @@
{% endif %}
+
+ Tags |
+
+ {% for tag in vm.tags.all %}
+ {% tag 'virtualization:virtualmachine_list' tag %}
+ {% empty %}
+ N/A
+ {% endfor %}
+ |
+
{% include 'inc/custom_fields_panel.html' with custom_fields=vm.get_custom_fields %}
diff --git a/netbox/templates/virtualization/virtualmachine_edit.html b/netbox/templates/virtualization/virtualmachine_edit.html
index 706591ab4..0fa7e07fb 100644
--- a/netbox/templates/virtualization/virtualmachine_edit.html
+++ b/netbox/templates/virtualization/virtualmachine_edit.html
@@ -54,4 +54,10 @@
{% render_field form.comments %}
+
+
Tags
+
+ {% render_field form.tags %}
+
+
{% endblock %}
diff --git a/netbox/templates/virtualization/virtualmachine_list.html b/netbox/templates/virtualization/virtualmachine_list.html
index 30ed76dae..bf2961fd8 100644
--- a/netbox/templates/virtualization/virtualmachine_list.html
+++ b/netbox/templates/virtualization/virtualmachine_list.html
@@ -16,6 +16,7 @@
{% include 'inc/search_panel.html' %}
+ {% include 'inc/tags_panel.html' %}
{% endblock %}
diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py
index 454e41c52..c7b94e7e9 100644
--- a/netbox/tenancy/api/serializers.py
+++ b/netbox/tenancy/api/serializers.py
@@ -1,10 +1,11 @@
from __future__ import unicode_literals
from rest_framework import serializers
+from taggit.models import Tag
from extras.api.customfields import CustomFieldModelSerializer
from tenancy.models import Tenant, TenantGroup
-from utilities.api import ValidatedModelSerializer
+from utilities.api import TagField, ValidatedModelSerializer, WritableNestedSerializer
#
@@ -18,7 +19,7 @@ class TenantGroupSerializer(ValidatedModelSerializer):
fields = ['id', 'name', 'slug']
-class NestedTenantGroupSerializer(serializers.ModelSerializer):
+class NestedTenantGroupSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenantgroup-detail')
class Meta:
@@ -31,23 +32,20 @@ class NestedTenantGroupSerializer(serializers.ModelSerializer):
#
class TenantSerializer(CustomFieldModelSerializer):
- group = NestedTenantGroupSerializer()
+ group = NestedTenantGroupSerializer(required=False)
+ tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta:
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(serializers.ModelSerializer):
+class NestedTenantSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenant-detail')
class Meta:
model = Tenant
fields = ['id', 'url', 'name', 'slug']
-
-
-class WritableTenantSerializer(CustomFieldModelSerializer):
-
- class Meta:
- model = Tenant
- fields = ['id', 'name', 'slug', 'group', 'description', 'comments', 'custom_fields', 'created', 'last_updated']
diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py
index 26f9bc71e..1ebd95500 100644
--- a/netbox/tenancy/api/views.py
+++ b/netbox/tenancy/api/views.py
@@ -32,5 +32,4 @@ class TenantGroupViewSet(ModelViewSet):
class TenantViewSet(CustomFieldModelViewSet):
queryset = Tenant.objects.select_related('group')
serializer_class = serializers.TenantSerializer
- write_serializer_class = serializers.WritableTenantSerializer
filter_class = filters.TenantFilter
diff --git a/netbox/tenancy/filters.py b/netbox/tenancy/filters.py
index 330ab7f56..7eccff5d3 100644
--- a/netbox/tenancy/filters.py
+++ b/netbox/tenancy/filters.py
@@ -31,6 +31,9 @@ class TenantFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug',
label='Group (slug)',
)
+ tag = django_filters.CharFilter(
+ name='tags__slug',
+ )
class Meta:
model = Tenant
diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py
index 4ea6c57ba..123b2bc24 100644
--- a/netbox/tenancy/forms.py
+++ b/netbox/tenancy/forms.py
@@ -2,6 +2,7 @@ from __future__ import unicode_literals
from django import forms
from django.db.models import Count
+from taggit.forms import TagField
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
from utilities.forms import (
@@ -40,10 +41,11 @@ class TenantGroupCSVForm(forms.ModelForm):
class TenantForm(BootstrapMixin, CustomFieldForm):
slug = SlugField()
comments = CommentField()
+ tags = TagField(required=False)
class Meta:
model = Tenant
- fields = ['name', 'slug', 'group', 'description', 'comments']
+ fields = ['name', 'slug', 'group', 'description', 'comments', 'tags']
class TenantCSVForm(forms.ModelForm):
diff --git a/netbox/tenancy/migrations/0004_tags.py b/netbox/tenancy/migrations/0004_tags.py
new file mode 100644
index 000000000..5cb9398b5
--- /dev/null
+++ b/netbox/tenancy/migrations/0004_tags.py
@@ -0,0 +1,22 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.12 on 2018-05-22 19:04
+from __future__ import unicode_literals
+
+from django.db import migrations
+import taggit.managers
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('taggit', '0002_auto_20150616_2121'),
+ ('tenancy', '0003_unicode_literals'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='tenant',
+ name='tags',
+ field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'),
+ ),
+ ]
diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py
index 10855ea15..79af5791f 100644
--- a/netbox/tenancy/models.py
+++ b/netbox/tenancy/models.py
@@ -4,8 +4,9 @@ from django.contrib.contenttypes.fields import GenericRelation
from django.db import models
from django.urls import reverse
from django.utils.encoding import python_2_unicode_compatible
+from taggit.managers import TaggableManager
-from extras.models import CustomFieldModel, CustomFieldValue
+from extras.models import CustomFieldModel
from utilities.models import CreatedUpdatedModel
@@ -14,8 +15,13 @@ class TenantGroup(models.Model):
"""
An arbitrary collection of Tenants.
"""
- name = models.CharField(max_length=50, unique=True)
- slug = models.SlugField(unique=True)
+ name = models.CharField(
+ max_length=50,
+ unique=True
+ )
+ slug = models.SlugField(
+ unique=True
+ )
csv_headers = ['name', 'slug']
@@ -45,12 +51,35 @@ class Tenant(CreatedUpdatedModel, CustomFieldModel):
A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal
department.
"""
- name = models.CharField(max_length=30, unique=True)
- slug = models.SlugField(unique=True)
- group = models.ForeignKey('TenantGroup', related_name='tenants', blank=True, null=True, on_delete=models.SET_NULL)
- description = models.CharField(max_length=100, blank=True, help_text="Long-form name (optional)")
- comments = models.TextField(blank=True)
- custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
+ name = models.CharField(
+ max_length=30,
+ unique=True
+ )
+ slug = models.SlugField(
+ unique=True
+ )
+ group = models.ForeignKey(
+ to='tenancy.TenantGroup',
+ on_delete=models.SET_NULL,
+ related_name='tenants',
+ blank=True,
+ null=True
+ )
+ description = models.CharField(
+ max_length=100,
+ blank=True,
+ help_text='Long-form name (optional)'
+ )
+ comments = models.TextField(
+ blank=True
+ )
+ custom_field_values = GenericRelation(
+ to='extras.CustomFieldValue',
+ content_type_field='obj_type',
+ object_id_field='obj_id'
+ )
+
+ tags = TaggableManager()
csv_headers = ['name', 'slug', 'group', 'description', 'comments']
diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py
index 80f79516c..861bdade9 100644
--- a/netbox/users/api/serializers.py
+++ b/netbox/users/api/serializers.py
@@ -1,10 +1,11 @@
from __future__ import unicode_literals
from django.contrib.auth.models import User
-from rest_framework import serializers
+
+from utilities.api import WritableNestedSerializer
-class NestedUserSerializer(serializers.ModelSerializer):
+class NestedUserSerializer(WritableNestedSerializer):
class Meta:
model = User
diff --git a/netbox/users/models.py b/netbox/users/models.py
index 02f5bc0a0..b3698d925 100644
--- a/netbox/users/models.py
+++ b/netbox/users/models.py
@@ -16,12 +16,31 @@ class Token(models.Model):
An API token used for user authentication. This extends the stock model to allow each user to have multiple tokens.
It also supports setting an expiration time and toggling write ability.
"""
- user = models.ForeignKey(User, related_name='tokens', on_delete=models.CASCADE)
- created = models.DateTimeField(auto_now_add=True)
- expires = models.DateTimeField(blank=True, null=True)
- key = models.CharField(max_length=40, unique=True, validators=[MinLengthValidator(40)])
- write_enabled = models.BooleanField(default=True, help_text="Permit create/update/delete operations using this key")
- description = models.CharField(max_length=100, blank=True)
+ user = models.ForeignKey(
+ to=User,
+ on_delete=models.CASCADE,
+ related_name='tokens'
+ )
+ created = models.DateTimeField(
+ auto_now_add=True
+ )
+ expires = models.DateTimeField(
+ blank=True,
+ null=True
+ )
+ key = models.CharField(
+ max_length=40,
+ unique=True,
+ validators=[MinLengthValidator(40)]
+ )
+ write_enabled = models.BooleanField(
+ default=True,
+ help_text='Permit create/update/delete operations using this key'
+ )
+ description = models.CharField(
+ max_length=100,
+ blank=True
+ )
class Meta:
default_permissions = []
diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py
index 5c78dacc4..61be3bc63 100644
--- a/netbox/utilities/api.py
+++ b/netbox/utilities/api.py
@@ -5,13 +5,15 @@ import pytz
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import ObjectDoesNotExist
from django.db.models import ManyToManyField
from django.http import Http404
from rest_framework import mixins
from rest_framework.exceptions import APIException
from rest_framework.permissions import BasePermission
+from rest_framework.relations import PrimaryKeyRelatedField
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
WRITE_OPERATIONS = ['create', 'update', 'partial_update', 'delete']
@@ -33,7 +35,93 @@ class IsAuthenticatedOrLoginNotRequired(BasePermission):
def has_permission(self, request, view):
if not settings.LOGIN_REQUIRED:
return True
- return request.user.is_authenticated()
+ return request.user.is_authenticated
+
+
+#
+# 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):
+ """
+ Represent a ChoiceField as {'value': , 'label': }.
+ """
+ def __init__(self, choices, **kwargs):
+ self._choices = dict()
+ for k, v in choices:
+ # Unpack grouped choices
+ if type(v) in [list, tuple]:
+ for k2, v2 in v:
+ self._choices[k2] = v2
+ else:
+ self._choices[k] = v
+ super(ChoiceFieldSerializer, self).__init__(**kwargs)
+
+ def to_representation(self, obj):
+ return {'value': obj, 'label': self._choices[obj]}
+
+ def to_internal_value(self, data):
+ return data
+
+
+class ContentTypeFieldSerializer(Field):
+ """
+ Represent a ContentType as '.'
+ """
+ def to_representation(self, obj):
+ return "{}.{}".format(obj.app_label, obj.model)
+
+ def to_internal_value(self, data):
+ app_label, model = data.split('.')
+ try:
+ return ContentType.objects.get_by_natural_key(app_label=app_label, model=model)
+ except ContentType.DoesNotExist:
+ raise ValidationError("Invalid content type")
+
+
+class TimeZoneField(Field):
+ """
+ Represent a pytz time zone.
+ """
+ def to_representation(self, obj):
+ return obj.zone if obj else None
+
+ def to_internal_value(self, data):
+ if not data:
+ return ""
+ try:
+ return pytz.timezone(str(data))
+ except pytz.exceptions.UnknownTimeZoneError:
+ raise ValidationError('Invalid time zone "{}"'.format(data))
+
+
+class SerializedPKRelatedField(PrimaryKeyRelatedField):
+ """
+ Extends PrimaryKeyRelatedField to return a serialized object on read. This is useful for representing related
+ objects in a ManyToManyField while still allowing a set of primary keys to be written.
+ """
+ def __init__(self, serializer, **kwargs):
+ self.serializer = serializer
+ self.pk_field = kwargs.pop('pk_field', None)
+ super(SerializedPKRelatedField, self).__init__(**kwargs)
+
+ def to_representation(self, value):
+ return self.serializer(value, context={'request': self.context['request']}).data
#
@@ -67,58 +155,17 @@ class ValidatedModelSerializer(ModelSerializer):
return data
-class ChoiceFieldSerializer(Field):
+class WritableNestedSerializer(ModelSerializer):
"""
- Represent a ChoiceField as {'value': , 'label': }.
+ Returns a nested representation of an object on read, but accepts only a primary key on write.
"""
- def __init__(self, choices, **kwargs):
- self._choices = dict()
- for k, v in choices:
- # Unpack grouped choices
- if type(v) in [list, tuple]:
- for k2, v2 in v:
- self._choices[k2] = v2
- else:
- self._choices[k] = v
- super(ChoiceFieldSerializer, self).__init__(**kwargs)
-
- def to_representation(self, obj):
- return {'value': obj, 'label': self._choices[obj]}
-
def to_internal_value(self, data):
- return self._choices.get(data)
-
-
-class ContentTypeFieldSerializer(Field):
- """
- Represent a ContentType as '.'
- """
- def to_representation(self, obj):
- return "{}.{}".format(obj.app_label, obj.model)
-
- def to_internal_value(self, data):
- app_label, model = data.split('.')
+ if data is None:
+ return None
try:
- return ContentType.objects.get_by_natural_key(app_label=app_label, model=model)
- except ContentType.DoesNotExist:
- raise ValidationError("Invalid content type")
-
-
-class TimeZoneField(Field):
- """
- Represent a pytz time zone.
- """
-
- def to_representation(self, obj):
- return obj.zone if obj else None
-
- def to_internal_value(self, data):
- if not data:
- return ""
- try:
- return pytz.timezone(str(data))
- except pytz.exceptions.UnknownTimeZoneError:
- raise ValidationError('Invalid time zone "{}"'.format(data))
+ return self.Meta.model.objects.get(pk=data)
+ except ObjectDoesNotExist:
+ raise ValidationError("Invalid ID")
#
@@ -132,16 +179,8 @@ class ModelViewSet(mixins.CreateModelMixin,
mixins.ListModelMixin,
GenericViewSet):
"""
- Substitute DRF's built-in ModelViewSet for our own, which introduces a bit of additional functionality:
- 1. Use an alternate serializer (if provided) for write operations
- 2. Accept either a single object or a list of objects to create
+ Accept either a single object or a list of objects to create.
"""
- def get_serializer_class(self):
- # Check for a different serializer to use for write operations
- if self.action in WRITE_OPERATIONS and hasattr(self, 'write_serializer_class'):
- return self.write_serializer_class
- return self.serializer_class
-
def get_serializer(self, *args, **kwargs):
# If a list of objects has been provided, initialize the serializer with many=True
if isinstance(kwargs.get('data', {}), list):
diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py
index 15fb69f7f..0992a3460 100644
--- a/netbox/utilities/forms.py
+++ b/netbox/utilities/forms.py
@@ -205,7 +205,8 @@ class ArrayFieldSelectMultiple(SelectWithDisabled, forms.SelectMultiple):
def optgroups(self, name, value, attrs=None):
# Split the delimited string of values into a list
- value = value[0].split(self.delimiter)
+ if value:
+ value = value[0].split(self.delimiter)
return super(ArrayFieldSelectMultiple, self).optgroups(name, value, attrs)
def value_from_datadict(self, data, files, name):
@@ -325,7 +326,7 @@ class CSVChoiceField(forms.ChoiceField):
"""
def __init__(self, choices, *args, **kwargs):
- super(CSVChoiceField, self).__init__(choices, *args, **kwargs)
+ super(CSVChoiceField, self).__init__(choices=choices, *args, **kwargs)
self.choices = [(label, label) for value, label in choices]
self.choice_values = {label: value for value, label in choices}
diff --git a/netbox/utilities/middleware.py b/netbox/utilities/middleware.py
index 64fb70a07..47fa48c90 100644
--- a/netbox/utilities/middleware.py
+++ b/netbox/utilities/middleware.py
@@ -20,7 +20,7 @@ class LoginRequiredMiddleware(object):
self.get_response = get_response
def __call__(self, request):
- if LOGIN_REQUIRED and not request.user.is_authenticated():
+ if LOGIN_REQUIRED and not request.user.is_authenticated:
# Redirect unauthenticated requests to the login page. API requests are exempt from redirection as the API
# performs its own authentication.
api_path = reverse('api-root')
diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py
index 7d79a5f2a..1380941b3 100644
--- a/netbox/utilities/templatetags/helpers.py
+++ b/netbox/utilities/templatetags/helpers.py
@@ -1,7 +1,6 @@
from __future__ import unicode_literals
import datetime
-import pytz
from django import template
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,
'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,
+ }
diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py
index a8dc13a89..e995c5580 100644
--- a/netbox/utilities/utils.py
+++ b/netbox/utilities/utils.py
@@ -14,7 +14,7 @@ def csv_format(data):
for value in data:
# Represent None or False with empty string
- if value in [None, False]:
+ if value is None or value is False:
csv.append('')
continue
diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py
index b2a8b007c..4f70e6215 100644
--- a/netbox/utilities/views.py
+++ b/netbox/utilities/views.py
@@ -8,7 +8,7 @@ from django.contrib import messages
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
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.shortcuts import get_object_or_404, redirect, render
from django.template.exceptions import TemplateSyntaxError
@@ -120,6 +120,12 @@ class ObjectListView(View):
if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']):
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
paginate = {
'klass': EnhancedPaginator,
@@ -132,6 +138,7 @@ class ObjectListView(View):
'table': table,
'permissions': permissions,
'filter_form': self.filter_form(request.GET, label_suffix='') if self.filter_form else None,
+ 'tags': tags,
}
context.update(self.extra_context())
@@ -196,13 +203,16 @@ class ObjectEditView(GetReturnURLMixin, View):
obj_created = not form.instance.pk
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'):
msg = '{} {}'.format(msg, obj.get_absolute_url(), escape(obj))
else:
msg = '{} {}'.format(msg, escape(obj))
messages.success(request, mark_safe(msg))
+
if obj_created:
UserAction.objects.log_create(request.user, obj, msg)
else:
diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py
index 7e2ec1690..15ed39abf 100644
--- a/netbox/virtualization/api/serializers.py
+++ b/netbox/virtualization/api/serializers.py
@@ -1,14 +1,15 @@
from __future__ import unicode_literals
from rest_framework import serializers
+from taggit.models import Tag
from dcim.api.serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer
-from dcim.constants import IFACE_FF_VIRTUAL
+from dcim.constants import IFACE_MODE_CHOICES
from dcim.models import Interface
from extras.api.customfields import CustomFieldModelSerializer
-from ipam.models import IPAddress
+from ipam.models import IPAddress, VLAN
from tenancy.api.serializers import NestedTenantSerializer
-from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
+from utilities.api import ChoiceFieldSerializer, TagField, ValidatedModelSerializer, WritableNestedSerializer
from virtualization.constants import VM_STATUS_CHOICES
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@@ -24,7 +25,7 @@ class ClusterTypeSerializer(ValidatedModelSerializer):
fields = ['id', 'name', 'slug']
-class NestedClusterTypeSerializer(serializers.ModelSerializer):
+class NestedClusterTypeSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustertype-detail')
class Meta:
@@ -43,7 +44,7 @@ class ClusterGroupSerializer(ValidatedModelSerializer):
fields = ['id', 'name', 'slug']
-class NestedClusterGroupSerializer(serializers.ModelSerializer):
+class NestedClusterGroupSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustergroup-detail')
class Meta:
@@ -57,15 +58,18 @@ class NestedClusterGroupSerializer(serializers.ModelSerializer):
class ClusterSerializer(CustomFieldModelSerializer):
type = NestedClusterTypeSerializer()
- group = NestedClusterGroupSerializer()
- site = NestedSiteSerializer()
+ group = NestedClusterGroupSerializer(required=False, allow_null=True)
+ site = NestedSiteSerializer(required=False, allow_null=True)
+ tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta:
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(serializers.ModelSerializer):
+class NestedClusterSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail')
class Meta:
@@ -73,13 +77,6 @@ class NestedClusterSerializer(serializers.ModelSerializer):
fields = ['id', 'url', 'name']
-class WritableClusterSerializer(CustomFieldModelSerializer):
-
- class Meta:
- model = Cluster
- fields = ['id', 'name', 'type', 'group', 'site', 'comments', 'custom_fields', 'created', 'last_updated']
-
-
#
# Virtual machines
#
@@ -94,24 +91,25 @@ class VirtualMachineIPAddressSerializer(serializers.ModelSerializer):
class VirtualMachineSerializer(CustomFieldModelSerializer):
- status = ChoiceFieldSerializer(choices=VM_STATUS_CHOICES)
- cluster = NestedClusterSerializer()
- role = NestedDeviceRoleSerializer()
- tenant = NestedTenantSerializer()
- platform = NestedPlatformSerializer()
- primary_ip = VirtualMachineIPAddressSerializer()
- primary_ip4 = VirtualMachineIPAddressSerializer()
- primary_ip6 = VirtualMachineIPAddressSerializer()
+ status = ChoiceFieldSerializer(choices=VM_STATUS_CHOICES, required=False)
+ cluster = NestedClusterSerializer(required=False, allow_null=True)
+ role = NestedDeviceRoleSerializer(required=False, allow_null=True)
+ tenant = NestedTenantSerializer(required=False, allow_null=True)
+ platform = NestedPlatformSerializer(required=False, allow_null=True)
+ primary_ip = VirtualMachineIPAddressSerializer(read_only=True)
+ primary_ip4 = 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:
model = VirtualMachine
fields = [
'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',
]
-class NestedVirtualMachineSerializer(serializers.ModelSerializer):
+class NestedVirtualMachineSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualmachine-detail')
class Meta:
@@ -119,43 +117,36 @@ class NestedVirtualMachineSerializer(serializers.ModelSerializer):
fields = ['id', 'url', 'name']
-class WritableVirtualMachineSerializer(CustomFieldModelSerializer):
-
- class Meta:
- model = VirtualMachine
- fields = [
- 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'vcpus',
- 'memory', 'disk', 'comments', 'custom_fields', 'created', 'last_updated',
- ]
-
-
#
# VM interfaces
#
+# Cannot import ipam.api.serializers.NestedVLANSerializer due to circular dependency
+class InterfaceVLANSerializer(serializers.ModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail')
+
+ class Meta:
+ model = VLAN
+ fields = ['id', 'url', 'vid', 'name', 'display_name']
+
+
class InterfaceSerializer(serializers.ModelSerializer):
virtual_machine = NestedVirtualMachineSerializer()
+ mode = ChoiceFieldSerializer(choices=IFACE_MODE_CHOICES)
+ untagged_vlan = InterfaceVLANSerializer()
+ tagged_vlans = InterfaceVLANSerializer(many=True)
class Meta:
model = Interface
fields = [
- 'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'description',
+ 'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'untagged_vlan', 'tagged_vlans',
+ 'description',
]
-class NestedInterfaceSerializer(serializers.ModelSerializer):
+class NestedInterfaceSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:interface-detail')
class Meta:
model = Interface
fields = ['id', 'url', 'name']
-
-
-class WritableInterfaceSerializer(ValidatedModelSerializer):
- form_factor = serializers.IntegerField(default=IFACE_FF_VIRTUAL)
-
- class Meta:
- model = Interface
- fields = [
- 'id', 'name', 'virtual_machine', 'form_factor', 'enabled', 'mac_address', 'mtu', 'description',
- ]
diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py
index 149bb3145..fae8b9232 100644
--- a/netbox/virtualization/api/views.py
+++ b/netbox/virtualization/api/views.py
@@ -37,7 +37,6 @@ class ClusterGroupViewSet(ModelViewSet):
class ClusterViewSet(CustomFieldModelViewSet):
queryset = Cluster.objects.select_related('type', 'group')
serializer_class = serializers.ClusterSerializer
- write_serializer_class = serializers.WritableClusterSerializer
filter_class = filters.ClusterFilter
@@ -48,12 +47,10 @@ class ClusterViewSet(CustomFieldModelViewSet):
class VirtualMachineViewSet(CustomFieldModelViewSet):
queryset = VirtualMachine.objects.all()
serializer_class = serializers.VirtualMachineSerializer
- write_serializer_class = serializers.WritableVirtualMachineSerializer
filter_class = filters.VirtualMachineFilter
class InterfaceViewSet(ModelViewSet):
queryset = Interface.objects.filter(virtual_machine__isnull=False).select_related('virtual_machine')
serializer_class = serializers.InterfaceSerializer
- write_serializer_class = serializers.WritableInterfaceSerializer
filter_class = filters.InterfaceFilter
diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filters.py
index 53c3f18d9..6af4e4a22 100644
--- a/netbox/virtualization/filters.py
+++ b/netbox/virtualization/filters.py
@@ -63,6 +63,9 @@ class ClusterFilter(CustomFieldFilterSet):
to_field_name='slug',
label='Site (slug)',
)
+ tag = django_filters.CharFilter(
+ name='tags__slug',
+ )
class Meta:
model = Cluster
@@ -154,6 +157,9 @@ class VirtualMachineFilter(CustomFieldFilterSet):
to_field_name='slug',
label='Platform (slug)',
)
+ tag = django_filters.CharFilter(
+ name='tags__slug',
+ )
class Meta:
model = VirtualMachine
diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py
index 4dfea1b42..b973ed5cb 100644
--- a/netbox/virtualization/forms.py
+++ b/netbox/virtualization/forms.py
@@ -4,6 +4,7 @@ from django import forms
from django.core.exceptions import ValidationError
from django.db.models import Count
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.forms import INTERFACE_MODE_HELP_TEXT
@@ -78,10 +79,11 @@ class ClusterGroupCSVForm(forms.ModelForm):
class ClusterForm(BootstrapMixin, CustomFieldForm):
comments = CommentField(widget=SmallTextarea)
+ tags = TagField(required=False)
class Meta:
model = Cluster
- fields = ['name', 'type', 'group', 'site', 'comments']
+ fields = ['name', 'type', 'group', 'site', 'comments', 'tags']
class ClusterCSVForm(forms.ModelForm):
@@ -244,12 +246,13 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm):
api_url='/api/virtualization/clusters/?group_id={{cluster_group}}'
)
)
+ tags = TagField(required=False)
class Meta:
model = VirtualMachine
fields = [
'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):
diff --git a/netbox/virtualization/migrations/0005_django2.py b/netbox/virtualization/migrations/0005_django2.py
new file mode 100644
index 000000000..e79a55350
--- /dev/null
+++ b/netbox/virtualization/migrations/0005_django2.py
@@ -0,0 +1,19 @@
+# Generated by Django 2.0.3 on 2018-03-30 14:18
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('virtualization', '0004_virtualmachine_add_role'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='virtualmachine',
+ name='role',
+ field=models.ForeignKey(blank=True, limit_choices_to={'vm_role': True}, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='dcim.DeviceRole'),
+ ),
+ ]
diff --git a/netbox/virtualization/migrations/0006_tags.py b/netbox/virtualization/migrations/0006_tags.py
new file mode 100644
index 000000000..eed800852
--- /dev/null
+++ b/netbox/virtualization/migrations/0006_tags.py
@@ -0,0 +1,27 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.12 on 2018-05-22 19:04
+from __future__ import unicode_literals
+
+from django.db import migrations
+import taggit.managers
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('taggit', '0002_auto_20150616_2121'),
+ ('virtualization', '0005_django2'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='cluster',
+ name='tags',
+ field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'),
+ ),
+ migrations.AddField(
+ model_name='virtualmachine',
+ name='tags',
+ field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'),
+ ),
+ ]
diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py
index 3582568ef..6890afbf9 100644
--- a/netbox/virtualization/models.py
+++ b/netbox/virtualization/models.py
@@ -6,9 +6,10 @@ from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils.encoding import python_2_unicode_compatible
+from taggit.managers import TaggableManager
from dcim.models import Device
-from extras.models import CustomFieldModel, CustomFieldValue
+from extras.models import CustomFieldModel
from utilities.models import CreatedUpdatedModel
from .constants import DEVICE_STATUS_ACTIVE, VM_STATUS_CHOICES, VM_STATUS_CLASSES
@@ -123,11 +124,13 @@ class Cluster(CreatedUpdatedModel, CustomFieldModel):
blank=True
)
custom_field_values = GenericRelation(
- to=CustomFieldValue,
+ to='extras.CustomFieldValue',
content_type_field='obj_type',
object_id_field='obj_id'
)
+ tags = TaggableManager()
+
csv_headers = ['name', 'type', 'group', 'site', 'comments']
class Meta:
@@ -175,7 +178,7 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel):
A virtual machine which runs inside a Cluster.
"""
cluster = models.ForeignKey(
- to=Cluster,
+ to='virtualization.Cluster',
on_delete=models.PROTECT,
related_name='virtual_machines'
)
@@ -204,9 +207,9 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel):
)
role = models.ForeignKey(
to='dcim.DeviceRole',
- limit_choices_to={'vm_role': True},
on_delete=models.PROTECT,
related_name='virtual_machines',
+ limit_choices_to={'vm_role': True},
blank=True,
null=True
)
@@ -245,11 +248,13 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel):
blank=True
)
custom_field_values = GenericRelation(
- to=CustomFieldValue,
+ to='extras.CustomFieldValue',
content_type_field='obj_type',
object_id_field='obj_id'
)
+ tags = TaggableManager()
+
csv_headers = [
'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
]
diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py
index 6de6b86c7..96c57c29b 100644
--- a/netbox/virtualization/views.py
+++ b/netbox/virtualization/views.py
@@ -126,6 +126,7 @@ class ClusterView(View):
class ClusterCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'virtualization.add_cluster'
+ template_name = 'virtualization/cluster_edit.html'
model = Cluster
model_form = forms.ClusterForm
diff --git a/requirements.txt b/requirements.txt
index 5b7b3e73e..147b42bc8 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,11 +1,12 @@
-Django>=1.11,<2.0
+Django>=1.11
django-cors-headers>=2.1.0
django-debug-toolbar>=1.9.0
django-filter>=1.1.0
django-mptt>=0.9.0
django-tables2>=1.19.0
+django-taggit>=0.22.2
django-timezone-field>=2.0
-djangorestframework>=3.7.7
+djangorestframework>=3.7.7,<3.8.2
drf-yasg[validation]>=1.4.4
graphviz>=0.8.2
Markdown>=2.6.11