diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index db550a63b..c42edb5ae 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -1,13 +1,14 @@ from __future__ import unicode_literals from rest_framework import serializers +from taggit.models import Tag from circuits.constants import CIRCUIT_STATUS_CHOICES from circuits.models import Provider, Circuit, CircuitTermination, CircuitType from dcim.api.serializers import NestedSiteSerializer, InterfaceSerializer from extras.api.customfields import CustomFieldModelSerializer from tenancy.api.serializers import NestedTenantSerializer -from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer +from utilities.api import ChoiceFieldSerializer, TagField, ValidatedModelSerializer, WritableNestedSerializer # @@ -15,16 +16,17 @@ from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer # class ProviderSerializer(CustomFieldModelSerializer): + tags = TagField(queryset=Tag.objects.all(), required=False, many=True) class Meta: model = Provider fields = [ - 'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', + 'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] -class NestedProviderSerializer(serializers.ModelSerializer): +class NestedProviderSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail') class Meta: @@ -32,16 +34,6 @@ class NestedProviderSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'name', 'slug'] -class WritableProviderSerializer(CustomFieldModelSerializer): - - class Meta: - model = Provider - fields = [ - 'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', - 'custom_fields', 'created', 'last_updated', - ] - - # # Circuit types # @@ -53,7 +45,7 @@ class CircuitTypeSerializer(ValidatedModelSerializer): fields = ['id', 'name', 'slug'] -class NestedCircuitTypeSerializer(serializers.ModelSerializer): +class NestedCircuitTypeSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail') class Meta: @@ -67,19 +59,20 @@ class NestedCircuitTypeSerializer(serializers.ModelSerializer): class CircuitSerializer(CustomFieldModelSerializer): provider = NestedProviderSerializer() - status = ChoiceFieldSerializer(choices=CIRCUIT_STATUS_CHOICES) + status = ChoiceFieldSerializer(choices=CIRCUIT_STATUS_CHOICES, required=False) type = NestedCircuitTypeSerializer() - tenant = NestedTenantSerializer() + tenant = NestedTenantSerializer(required=False, allow_null=True) + tags = TagField(queryset=Tag.objects.all(), required=False, many=True) class Meta: model = Circuit fields = [ 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', - 'comments', 'custom_fields', 'created', 'last_updated', + 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] -class NestedCircuitSerializer(serializers.ModelSerializer): +class NestedCircuitSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail') class Meta: @@ -87,33 +80,14 @@ class NestedCircuitSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'cid'] -class WritableCircuitSerializer(CustomFieldModelSerializer): - - class Meta: - model = Circuit - fields = [ - 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', - 'comments', 'custom_fields', 'created', 'last_updated', - ] - - # # Circuit Terminations # -class CircuitTerminationSerializer(serializers.ModelSerializer): +class CircuitTerminationSerializer(ValidatedModelSerializer): circuit = NestedCircuitSerializer() site = NestedSiteSerializer() - interface = InterfaceSerializer() - - class Meta: - model = CircuitTermination - fields = [ - 'id', 'circuit', 'term_side', 'site', 'interface', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', - ] - - -class WritableCircuitTerminationSerializer(ValidatedModelSerializer): + interface = InterfaceSerializer(required=False, allow_null=True) class Meta: model = CircuitTermination diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 9b75bc184..d70a0596c 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -30,7 +30,6 @@ class CircuitsFieldChoicesViewSet(FieldChoicesViewSet): class ProviderViewSet(CustomFieldModelViewSet): queryset = Provider.objects.all() serializer_class = serializers.ProviderSerializer - write_serializer_class = serializers.WritableProviderSerializer filter_class = filters.ProviderFilter @detail_route() @@ -61,7 +60,6 @@ class CircuitTypeViewSet(ModelViewSet): class CircuitViewSet(CustomFieldModelViewSet): queryset = Circuit.objects.select_related('type', 'tenant', 'provider') serializer_class = serializers.CircuitSerializer - write_serializer_class = serializers.WritableCircuitSerializer filter_class = filters.CircuitFilter @@ -72,5 +70,4 @@ class CircuitViewSet(CustomFieldModelViewSet): class CircuitTerminationViewSet(ModelViewSet): queryset = CircuitTermination.objects.select_related('circuit', 'site', 'interface__device') serializer_class = serializers.CircuitTerminationSerializer - write_serializer_class = serializers.WritableCircuitTerminationSerializer filter_class = filters.CircuitTerminationFilter diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index ca66be406..79efdc950 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -28,6 +28,9 @@ class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='slug', label='Site (slug)', ) + tag = django_filters.CharFilter( + name='tags__slug', + ) class Meta: model = Provider @@ -103,6 +106,9 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='slug', label='Site (slug)', ) + tag = django_filters.CharFilter( + name='tags__slug', + ) class Meta: model = Circuit diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index bfcfa7187..7207e7648 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/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 dcim.models import Site, Device, Interface, Rack from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm @@ -22,10 +23,11 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider class ProviderForm(BootstrapMixin, CustomFieldForm): slug = SlugField() comments = CommentField() + tags = TagField(required=False) class Meta: model = Provider - fields = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments'] + fields = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'tags'] widgets = { 'noc_contact': SmallTextarea(attrs={'rows': 5}), 'admin_contact': SmallTextarea(attrs={'rows': 5}), @@ -102,12 +104,13 @@ class CircuitTypeCSVForm(forms.ModelForm): class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm): comments = CommentField() + tags = TagField(required=False) class Meta: model = Circuit fields = [ 'cid', 'type', 'provider', 'status', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant', - 'comments', + 'comments', 'tags', ] help_texts = { 'cid': "Unique circuit ID", diff --git a/netbox/circuits/migrations/0011_tags.py b/netbox/circuits/migrations/0011_tags.py new file mode 100644 index 000000000..b3510f8f4 --- /dev/null +++ b/netbox/circuits/migrations/0011_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'), + ('circuits', '0010_circuit_status'), + ] + + operations = [ + migrations.AddField( + model_name='circuit', + 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='provider', + 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/circuits/models.py b/netbox/circuits/models.py index c05f74071..933da9f68 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -4,11 +4,11 @@ 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 dcim.constants import STATUS_CLASSES from dcim.fields import ASNField -from extras.models import CustomFieldModel, CustomFieldValue -from tenancy.models import Tenant +from extras.models import CustomFieldModel from utilities.models import CreatedUpdatedModel from .constants import CIRCUIT_STATUS_ACTIVE, CIRCUIT_STATUS_CHOICES, TERM_SIDE_CHOICES @@ -19,15 +19,45 @@ class Provider(CreatedUpdatedModel, CustomFieldModel): Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model stores information pertinent to the user's relationship with the Provider. """ - name = models.CharField(max_length=50, unique=True) - slug = models.SlugField(unique=True) - asn = ASNField(blank=True, null=True, verbose_name='ASN') - account = models.CharField(max_length=30, blank=True, verbose_name='Account number') - portal_url = models.URLField(blank=True, verbose_name='Portal') - noc_contact = models.TextField(blank=True, verbose_name='NOC contact') - admin_contact = models.TextField(blank=True, verbose_name='Admin contact') - 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=50, + unique=True + ) + slug = models.SlugField( + unique=True + ) + asn = ASNField( + blank=True, + null=True, + verbose_name='ASN' + ) + account = models.CharField( + max_length=30, + blank=True, + verbose_name='Account number' + ) + portal_url = models.URLField( + blank=True, + verbose_name='Portal' + ) + noc_contact = models.TextField( + blank=True, + verbose_name='NOC contact' + ) + admin_contact = models.TextField( + blank=True, + verbose_name='Admin contact' + ) + 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', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments'] @@ -63,8 +93,13 @@ class CircuitType(models.Model): Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named "Long Haul," "Metro," or "Out-of-Band". """ - 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'] @@ -91,16 +126,54 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel): circuits. Each circuit is also assigned a CircuitType and a Site. A Circuit may be terminated to a specific device interface, but this is not required. Circuit port speed and commit rate are measured in Kbps. """ - cid = models.CharField(max_length=50, verbose_name='Circuit ID') - provider = models.ForeignKey('Provider', related_name='circuits', on_delete=models.PROTECT) - type = models.ForeignKey('CircuitType', related_name='circuits', on_delete=models.PROTECT) - status = models.PositiveSmallIntegerField(choices=CIRCUIT_STATUS_CHOICES, default=CIRCUIT_STATUS_ACTIVE) - tenant = models.ForeignKey(Tenant, related_name='circuits', blank=True, null=True, on_delete=models.PROTECT) - install_date = models.DateField(blank=True, null=True, verbose_name='Date installed') - commit_rate = models.PositiveIntegerField(blank=True, null=True, verbose_name='Commit rate (Kbps)') - description = models.CharField(max_length=100, blank=True) - comments = models.TextField(blank=True) - custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') + cid = models.CharField( + max_length=50, + verbose_name='Circuit ID' + ) + provider = models.ForeignKey( + to='circuits.Provider', + on_delete=models.PROTECT, + related_name='circuits' + ) + type = models.ForeignKey( + to='CircuitType', + on_delete=models.PROTECT, + related_name='circuits' + ) + status = models.PositiveSmallIntegerField( + choices=CIRCUIT_STATUS_CHOICES, + default=CIRCUIT_STATUS_ACTIVE + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='circuits', + blank=True, + null=True + ) + install_date = models.DateField( + blank=True, + null=True, + verbose_name='Date installed' + ) + commit_rate = models.PositiveIntegerField( + blank=True, + null=True, + verbose_name='Commit rate (Kbps)') + description = models.CharField( + max_length=100, + blank=True + ) + 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 = [ 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments', @@ -153,19 +226,47 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel): @python_2_unicode_compatible class CircuitTermination(models.Model): - circuit = models.ForeignKey('Circuit', related_name='terminations', on_delete=models.CASCADE) - term_side = models.CharField(max_length=1, choices=TERM_SIDE_CHOICES, verbose_name='Termination') - site = models.ForeignKey('dcim.Site', related_name='circuit_terminations', on_delete=models.PROTECT) - interface = models.OneToOneField( - 'dcim.Interface', related_name='circuit_termination', blank=True, null=True, on_delete=models.PROTECT + circuit = models.ForeignKey( + to='circuits.Circuit', + on_delete=models.CASCADE, + related_name='terminations' + ) + term_side = models.CharField( + max_length=1, + choices=TERM_SIDE_CHOICES, + verbose_name='Termination' + ) + site = models.ForeignKey( + to='dcim.Site', + on_delete=models.PROTECT, + related_name='circuit_terminations' + ) + interface = models.OneToOneField( + to='dcim.Interface', + on_delete=models.PROTECT, + related_name='circuit_termination', + blank=True, + null=True + ) + port_speed = models.PositiveIntegerField( + verbose_name='Port speed (Kbps)' ) - port_speed = models.PositiveIntegerField(verbose_name='Port speed (Kbps)') upstream_speed = models.PositiveIntegerField( - blank=True, null=True, verbose_name='Upstream speed (Kbps)', + blank=True, + null=True, + verbose_name='Upstream speed (Kbps)', help_text='Upstream speed, if different from port speed' ) - xconnect_id = models.CharField(max_length=50, blank=True, verbose_name='Cross-connect ID') - pp_info = models.CharField(max_length=100, blank=True, verbose_name='Patch panel/port(s)') + xconnect_id = models.CharField( + max_length=50, + blank=True, + verbose_name='Cross-connect ID' + ) + pp_info = models.CharField( + max_length=100, + blank=True, + verbose_name='Patch panel/port(s)' + ) class Meta: ordering = ['circuit', 'term_side'] diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index 1228aafaa..39a2d69f2 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -5,7 +5,7 @@ from django.urls import reverse from rest_framework import status from rest_framework.test import APITestCase -from circuits.constants import TERM_SIDE_A, TERM_SIDE_Z +from circuits.constants import CIRCUIT_STATUS_ACTIVE, TERM_SIDE_A, TERM_SIDE_Z from circuits.models import Circuit, CircuitTermination, CircuitType, Provider from dcim.models import Site from extras.constants import GRAPH_TYPE_PROVIDER @@ -231,6 +231,7 @@ class CircuitTest(HttpStatusMixin, APITestCase): 'cid': 'TEST0004', 'provider': self.provider1.pk, 'type': self.circuittype1.pk, + 'status': CIRCUIT_STATUS_ACTIVE, } url = reverse('circuits-api:circuit-list') @@ -250,16 +251,19 @@ class CircuitTest(HttpStatusMixin, APITestCase): 'cid': 'TEST0004', 'provider': self.provider1.pk, 'type': self.circuittype1.pk, + 'status': CIRCUIT_STATUS_ACTIVE, }, { 'cid': 'TEST0005', 'provider': self.provider1.pk, 'type': self.circuittype1.pk, + 'status': CIRCUIT_STATUS_ACTIVE, }, { 'cid': 'TEST0006', 'provider': self.provider1.pk, 'type': self.circuittype1.pk, + 'status': CIRCUIT_STATUS_ACTIVE, }, ] diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index d458bc646..763ea038c 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -4,6 +4,7 @@ from collections import OrderedDict from rest_framework import serializers from rest_framework.validators import UniqueTogetherValidator +from taggit.models import Tag from circuits.models import Circuit, CircuitTermination from dcim.constants import ( @@ -20,7 +21,10 @@ from extras.api.customfields import CustomFieldModelSerializer from ipam.models import IPAddress, VLAN from tenancy.api.serializers import NestedTenantSerializer from users.api.serializers import NestedUserSerializer -from utilities.api import ChoiceFieldSerializer, TimeZoneField, ValidatedModelSerializer +from utilities.api import ( + ChoiceFieldSerializer, SerializedPKRelatedField, TagField, TimeZoneField, ValidatedModelSerializer, + WritableNestedSerializer, +) from virtualization.models import Cluster @@ -28,7 +32,7 @@ from virtualization.models import Cluster # Regions # -class NestedRegionSerializer(serializers.ModelSerializer): +class NestedRegionSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail') class Meta: @@ -37,14 +41,7 @@ class NestedRegionSerializer(serializers.ModelSerializer): class RegionSerializer(serializers.ModelSerializer): - parent = NestedRegionSerializer() - - class Meta: - model = Region - fields = ['id', 'name', 'slug', 'parent'] - - -class WritableRegionSerializer(ValidatedModelSerializer): + parent = NestedRegionSerializer(required=False, allow_null=True) class Meta: model = Region @@ -56,22 +53,23 @@ class WritableRegionSerializer(ValidatedModelSerializer): # class SiteSerializer(CustomFieldModelSerializer): - status = ChoiceFieldSerializer(choices=SITE_STATUS_CHOICES) - region = NestedRegionSerializer() - tenant = NestedTenantSerializer() + status = ChoiceFieldSerializer(choices=SITE_STATUS_CHOICES, required=False) + region = NestedRegionSerializer(required=False, allow_null=True) + tenant = NestedTenantSerializer(required=False, allow_null=True) time_zone = TimeZoneField(required=False) + tags = TagField(queryset=Tag.objects.all(), required=False, many=True) class Meta: model = Site fields = [ 'id', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments', - 'custom_fields', 'created', 'last_updated', 'count_prefixes', 'count_vlans', 'count_racks', 'count_devices', - 'count_circuits', + 'tags', 'custom_fields', 'created', 'last_updated', 'count_prefixes', 'count_vlans', 'count_racks', + 'count_devices', 'count_circuits', ] -class NestedSiteSerializer(serializers.ModelSerializer): +class NestedSiteSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail') class Meta: @@ -79,23 +77,11 @@ class NestedSiteSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'name', 'slug'] -class WritableSiteSerializer(CustomFieldModelSerializer): - time_zone = TimeZoneField(required=False) - - class Meta: - model = Site - fields = [ - 'id', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', - 'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments', - 'custom_fields', 'created', 'last_updated', - ] - - # # Rack groups # -class RackGroupSerializer(serializers.ModelSerializer): +class RackGroupSerializer(ValidatedModelSerializer): site = NestedSiteSerializer() class Meta: @@ -103,7 +89,7 @@ class RackGroupSerializer(serializers.ModelSerializer): fields = ['id', 'name', 'slug', 'site'] -class NestedRackGroupSerializer(serializers.ModelSerializer): +class NestedRackGroupSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackgroup-detail') class Meta: @@ -111,13 +97,6 @@ class NestedRackGroupSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'name', 'slug'] -class WritableRackGroupSerializer(ValidatedModelSerializer): - - class Meta: - model = RackGroup - fields = ['id', 'name', 'slug', 'site'] - - # # Rack roles # @@ -129,7 +108,7 @@ class RackRoleSerializer(ValidatedModelSerializer): fields = ['id', 'name', 'slug', 'color'] -class NestedRackRoleSerializer(serializers.ModelSerializer): +class NestedRackRoleSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail') class Meta: @@ -143,21 +122,40 @@ class NestedRackRoleSerializer(serializers.ModelSerializer): class RackSerializer(CustomFieldModelSerializer): site = NestedSiteSerializer() - group = NestedRackGroupSerializer() - tenant = NestedTenantSerializer() - role = NestedRackRoleSerializer() - type = ChoiceFieldSerializer(choices=RACK_TYPE_CHOICES) - width = ChoiceFieldSerializer(choices=RACK_WIDTH_CHOICES) + group = NestedRackGroupSerializer(required=False, allow_null=True) + tenant = NestedTenantSerializer(required=False, allow_null=True) + role = NestedRackRoleSerializer(required=False, allow_null=True) + type = ChoiceFieldSerializer(choices=RACK_TYPE_CHOICES, required=False) + width = ChoiceFieldSerializer(choices=RACK_WIDTH_CHOICES, required=False) + tags = TagField(queryset=Tag.objects.all(), required=False, many=True) class Meta: model = Rack fields = [ 'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'serial', 'type', 'width', - 'u_height', 'desc_units', 'comments', 'custom_fields', 'created', 'last_updated', + 'u_height', 'desc_units', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', + ] + # Omit the UniqueTogetherValidator that would be automatically added to validate (group, facility_id). This + # prevents facility_id from being interpreted as a required field. + validators = [ + UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('group', 'name')) ] + def validate(self, data): -class NestedRackSerializer(serializers.ModelSerializer): + # Validate uniqueness of (group, facility_id) since we omitted the automatically-created validator from Meta. + if data.get('facility_id', None): + validator = UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('group', 'facility_id')) + validator.set_context(self) + validator(data) + + # Enforce model validation + super(RackSerializer, self).validate(data) + + return data + + +class NestedRackSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail') class Meta: @@ -165,39 +163,11 @@ class NestedRackSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'name', 'display_name'] -class WritableRackSerializer(CustomFieldModelSerializer): - - class Meta: - model = Rack - fields = [ - 'id', 'name', 'facility_id', 'site', 'group', 'tenant', 'role', 'serial', 'type', 'width', 'u_height', - 'desc_units', 'comments', 'custom_fields', 'created', 'last_updated', - ] - # Omit the UniqueTogetherValidator that would be automatically added to validate (site, facility_id). This - # prevents facility_id from being interpreted as a required field. - validators = [ - UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('site', 'name')) - ] - - def validate(self, data): - - # Validate uniqueness of (site, facility_id) since we omitted the automatically-created validator from Meta. - if data.get('facility_id', None): - validator = UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('site', 'facility_id')) - validator.set_context(self) - validator(data) - - # Enforce model validation - super(WritableRackSerializer, self).validate(data) - - return data - - # # Rack units # -class NestedDeviceSerializer(serializers.ModelSerializer): +class NestedDeviceSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail') class Meta: @@ -219,23 +189,16 @@ class RackUnitSerializer(serializers.Serializer): # Rack reservations # -class RackReservationSerializer(serializers.ModelSerializer): +class RackReservationSerializer(ValidatedModelSerializer): rack = NestedRackSerializer() user = NestedUserSerializer() - tenant = NestedTenantSerializer() + tenant = NestedTenantSerializer(required=False, allow_null=True) class Meta: model = RackReservation fields = ['id', 'rack', 'units', 'created', 'user', 'tenant', 'description'] -class WritableRackReservationSerializer(ValidatedModelSerializer): - - class Meta: - model = RackReservation - fields = ['id', 'rack', 'units', 'user', 'description'] - - # # Manufacturers # @@ -247,7 +210,7 @@ class ManufacturerSerializer(ValidatedModelSerializer): fields = ['id', 'name', 'slug'] -class NestedManufacturerSerializer(serializers.ModelSerializer): +class NestedManufacturerSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail') class Meta: @@ -261,43 +224,34 @@ class NestedManufacturerSerializer(serializers.ModelSerializer): class DeviceTypeSerializer(CustomFieldModelSerializer): manufacturer = NestedManufacturerSerializer() - interface_ordering = ChoiceFieldSerializer(choices=IFACE_ORDERING_CHOICES) - subdevice_role = ChoiceFieldSerializer(choices=SUBDEVICE_ROLE_CHOICES) + interface_ordering = ChoiceFieldSerializer(choices=IFACE_ORDERING_CHOICES, required=False) + subdevice_role = ChoiceFieldSerializer(choices=SUBDEVICE_ROLE_CHOICES, required=False) instance_count = serializers.IntegerField(source='instances.count', read_only=True) + tags = TagField(queryset=Tag.objects.all(), required=False, many=True) class Meta: model = DeviceType fields = [ 'id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'interface_ordering', - 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments', 'custom_fields', + 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments', 'tags', 'custom_fields', 'instance_count', ] -class NestedDeviceTypeSerializer(serializers.ModelSerializer): +class NestedDeviceTypeSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail') - manufacturer = NestedManufacturerSerializer() + manufacturer = NestedManufacturerSerializer(read_only=True) class Meta: model = DeviceType fields = ['id', 'url', 'manufacturer', 'model', 'slug'] -class WritableDeviceTypeSerializer(CustomFieldModelSerializer): - - class Meta: - model = DeviceType - fields = [ - 'id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'interface_ordering', - 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments', 'custom_fields', - ] - - # # Console port templates # -class ConsolePortTemplateSerializer(serializers.ModelSerializer): +class ConsolePortTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() class Meta: @@ -305,18 +259,11 @@ class ConsolePortTemplateSerializer(serializers.ModelSerializer): fields = ['id', 'device_type', 'name'] -class WritableConsolePortTemplateSerializer(ValidatedModelSerializer): - - class Meta: - model = ConsolePortTemplate - fields = ['id', 'device_type', 'name'] - - # # Console server port templates # -class ConsoleServerPortTemplateSerializer(serializers.ModelSerializer): +class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() class Meta: @@ -324,18 +271,11 @@ class ConsoleServerPortTemplateSerializer(serializers.ModelSerializer): fields = ['id', 'device_type', 'name'] -class WritableConsoleServerPortTemplateSerializer(ValidatedModelSerializer): - - class Meta: - model = ConsoleServerPortTemplate - fields = ['id', 'device_type', 'name'] - - # # Power port templates # -class PowerPortTemplateSerializer(serializers.ModelSerializer): +class PowerPortTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() class Meta: @@ -343,18 +283,11 @@ class PowerPortTemplateSerializer(serializers.ModelSerializer): fields = ['id', 'device_type', 'name'] -class WritablePowerPortTemplateSerializer(ValidatedModelSerializer): - - class Meta: - model = PowerPortTemplate - fields = ['id', 'device_type', 'name'] - - # # Power outlet templates # -class PowerOutletTemplateSerializer(serializers.ModelSerializer): +class PowerOutletTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() class Meta: @@ -362,27 +295,13 @@ class PowerOutletTemplateSerializer(serializers.ModelSerializer): fields = ['id', 'device_type', 'name'] -class WritablePowerOutletTemplateSerializer(ValidatedModelSerializer): - - class Meta: - model = PowerOutletTemplate - fields = ['id', 'device_type', 'name'] - - # # Interface templates # -class InterfaceTemplateSerializer(serializers.ModelSerializer): +class InterfaceTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() - form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES) - - class Meta: - model = InterfaceTemplate - fields = ['id', 'device_type', 'name', 'form_factor', 'mgmt_only'] - - -class WritableInterfaceTemplateSerializer(ValidatedModelSerializer): + form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES, required=False) class Meta: model = InterfaceTemplate @@ -393,7 +312,7 @@ class WritableInterfaceTemplateSerializer(ValidatedModelSerializer): # Device bay templates # -class DeviceBayTemplateSerializer(serializers.ModelSerializer): +class DeviceBayTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() class Meta: @@ -401,13 +320,6 @@ class DeviceBayTemplateSerializer(serializers.ModelSerializer): fields = ['id', 'device_type', 'name'] -class WritableDeviceBayTemplateSerializer(ValidatedModelSerializer): - - class Meta: - model = DeviceBayTemplate - fields = ['id', 'device_type', 'name'] - - # # Device roles # @@ -419,7 +331,7 @@ class DeviceRoleSerializer(ValidatedModelSerializer): fields = ['id', 'name', 'slug', 'color', 'vm_role'] -class NestedDeviceRoleSerializer(serializers.ModelSerializer): +class NestedDeviceRoleSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail') class Meta: @@ -431,15 +343,15 @@ class NestedDeviceRoleSerializer(serializers.ModelSerializer): # Platforms # -class PlatformSerializer(serializers.ModelSerializer): - manufacturer = NestedManufacturerSerializer() +class PlatformSerializer(ValidatedModelSerializer): + manufacturer = NestedManufacturerSerializer(required=False, allow_null=True) class Meta: model = Platform fields = ['id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'rpc_client'] -class NestedPlatformSerializer(serializers.ModelSerializer): +class NestedPlatformSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail') class Meta: @@ -447,13 +359,6 @@ class NestedPlatformSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'name', 'slug'] -class WritablePlatformSerializer(ValidatedModelSerializer): - - class Meta: - model = Platform - fields = ['id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'rpc_client'] - - # # Devices # @@ -489,48 +394,28 @@ class DeviceVirtualChassisSerializer(serializers.ModelSerializer): class DeviceSerializer(CustomFieldModelSerializer): device_type = NestedDeviceTypeSerializer() device_role = NestedDeviceRoleSerializer() - tenant = NestedTenantSerializer() - platform = NestedPlatformSerializer() + tenant = NestedTenantSerializer(required=False, allow_null=True) + platform = NestedPlatformSerializer(required=False, allow_null=True) site = NestedSiteSerializer() - rack = NestedRackSerializer() - face = ChoiceFieldSerializer(choices=RACK_FACE_CHOICES) - status = ChoiceFieldSerializer(choices=DEVICE_STATUS_CHOICES) - primary_ip = DeviceIPAddressSerializer() - primary_ip4 = DeviceIPAddressSerializer() - primary_ip6 = DeviceIPAddressSerializer() + rack = NestedRackSerializer(required=False, allow_null=True) + face = ChoiceFieldSerializer(choices=RACK_FACE_CHOICES, required=False) + status = ChoiceFieldSerializer(choices=DEVICE_STATUS_CHOICES, required=False) + primary_ip = DeviceIPAddressSerializer(read_only=True) + primary_ip4 = DeviceIPAddressSerializer(required=False, allow_null=True) + primary_ip6 = DeviceIPAddressSerializer(required=False, allow_null=True) parent_device = serializers.SerializerMethodField() - cluster = NestedClusterSerializer() - virtual_chassis = DeviceVirtualChassisSerializer() + cluster = NestedClusterSerializer(required=False, allow_null=True) + virtual_chassis = DeviceVirtualChassisSerializer(required=False, allow_null=True) + tags = TagField(queryset=Tag.objects.all(), required=False, many=True) class Meta: model = Device fields = [ 'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6', - 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'custom_fields', 'created', + 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] - - def get_parent_device(self, obj): - try: - device_bay = obj.parent_bay - except DeviceBay.DoesNotExist: - return None - context = {'request': self.context['request']} - data = NestedDeviceSerializer(instance=device_bay.device, context=context).data - data['device_bay'] = NestedDeviceBaySerializer(instance=device_bay, context=context).data - return data - - -class WritableDeviceSerializer(CustomFieldModelSerializer): - - class Meta: - model = Device - fields = [ - 'id', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'site', 'rack', - 'position', 'face', 'status', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', - 'vc_priority', 'comments', 'custom_fields', 'created', 'last_updated', - ] validators = [] def validate(self, data): @@ -542,16 +427,26 @@ class WritableDeviceSerializer(CustomFieldModelSerializer): validator(data) # Enforce model validation - super(WritableDeviceSerializer, self).validate(data) + super(DeviceSerializer, self).validate(data) return data + def get_parent_device(self, obj): + try: + device_bay = obj.parent_bay + except DeviceBay.DoesNotExist: + return None + context = {'request': self.context['request']} + data = NestedDeviceSerializer(instance=device_bay.device, context=context).data + data['device_bay'] = NestedDeviceBaySerializer(instance=device_bay, context=context).data + return data + # # Console server ports # -class ConsoleServerPortSerializer(serializers.ModelSerializer): +class ConsoleServerPortSerializer(ValidatedModelSerializer): device = NestedDeviceSerializer() class Meta: @@ -560,27 +455,22 @@ class ConsoleServerPortSerializer(serializers.ModelSerializer): read_only_fields = ['connected_console'] -class WritableConsoleServerPortSerializer(ValidatedModelSerializer): +class NestedConsoleServerPortSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail') + device = NestedDeviceSerializer(read_only=True) class Meta: model = ConsoleServerPort - fields = ['id', 'device', 'name'] + fields = ['id', 'url', 'device', 'name'] # # Console ports # -class ConsolePortSerializer(serializers.ModelSerializer): +class ConsolePortSerializer(ValidatedModelSerializer): device = NestedDeviceSerializer() - cs_port = ConsoleServerPortSerializer() - - class Meta: - model = ConsolePort - fields = ['id', 'device', 'name', 'cs_port', 'connection_status'] - - -class WritableConsolePortSerializer(ValidatedModelSerializer): + cs_port = NestedConsoleServerPortSerializer(required=False, allow_null=True) class Meta: model = ConsolePort @@ -591,7 +481,7 @@ class WritableConsolePortSerializer(ValidatedModelSerializer): # Power outlets # -class PowerOutletSerializer(serializers.ModelSerializer): +class PowerOutletSerializer(ValidatedModelSerializer): device = NestedDeviceSerializer() class Meta: @@ -600,27 +490,22 @@ class PowerOutletSerializer(serializers.ModelSerializer): read_only_fields = ['connected_port'] -class WritablePowerOutletSerializer(ValidatedModelSerializer): +class NestedPowerOutletSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail') + device = NestedDeviceSerializer(read_only=True) class Meta: model = PowerOutlet - fields = ['id', 'device', 'name'] + fields = ['id', 'url', 'device', 'name'] # # Power ports # -class PowerPortSerializer(serializers.ModelSerializer): +class PowerPortSerializer(ValidatedModelSerializer): device = NestedDeviceSerializer() - power_outlet = PowerOutletSerializer() - - class Meta: - model = PowerPort - fields = ['id', 'device', 'name', 'power_outlet', 'connection_status'] - - -class WritablePowerPortSerializer(ValidatedModelSerializer): + power_outlet = NestedPowerOutletSerializer(required=False, allow_null=True) class Meta: model = PowerPort @@ -631,12 +516,13 @@ class WritablePowerPortSerializer(ValidatedModelSerializer): # Interfaces # -class NestedInterfaceSerializer(serializers.ModelSerializer): +class NestedInterfaceSerializer(WritableNestedSerializer): + device = NestedDeviceSerializer(read_only=True) url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') class Meta: model = Interface - fields = ['id', 'url', 'name'] + fields = ['id', 'url', 'device', 'name'] class InterfaceNestedCircuitSerializer(serializers.ModelSerializer): @@ -647,8 +533,8 @@ class InterfaceNestedCircuitSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'cid'] -class InterfaceCircuitTerminationSerializer(serializers.ModelSerializer): - circuit = InterfaceNestedCircuitSerializer() +class InterfaceCircuitTerminationSerializer(WritableNestedSerializer): + circuit = InterfaceNestedCircuitSerializer(read_only=True) class Meta: model = CircuitTermination @@ -658,7 +544,7 @@ class InterfaceCircuitTerminationSerializer(serializers.ModelSerializer): # Cannot import ipam.api.NestedVLANSerializer due to circular dependency -class InterfaceVLANSerializer(serializers.ModelSerializer): +class InterfaceVLANSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail') class Meta: @@ -666,16 +552,21 @@ class InterfaceVLANSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'vid', 'name', 'display_name'] -class InterfaceSerializer(serializers.ModelSerializer): +class InterfaceSerializer(ValidatedModelSerializer): device = NestedDeviceSerializer() - form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES) - lag = NestedInterfaceSerializer() + form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES, required=False) + lag = NestedInterfaceSerializer(required=False, allow_null=True) is_connected = serializers.SerializerMethodField(read_only=True) interface_connection = serializers.SerializerMethodField(read_only=True) - circuit_termination = InterfaceCircuitTerminationSerializer() - untagged_vlan = InterfaceVLANSerializer() - mode = ChoiceFieldSerializer(choices=IFACE_MODE_CHOICES) - tagged_vlans = InterfaceVLANSerializer(many=True) + circuit_termination = InterfaceCircuitTerminationSerializer(read_only=True) + mode = ChoiceFieldSerializer(choices=IFACE_MODE_CHOICES, required=False) + untagged_vlan = InterfaceVLANSerializer(required=False, allow_null=True) + tagged_vlans = SerializedPKRelatedField( + queryset=VLAN.objects.all(), + serializer=InterfaceVLANSerializer, + required=False, + many=True + ) class Meta: model = Interface @@ -684,51 +575,6 @@ class InterfaceSerializer(serializers.ModelSerializer): 'is_connected', 'interface_connection', 'circuit_termination', 'mode', 'untagged_vlan', 'tagged_vlans', ] - def get_is_connected(self, obj): - """ - Return True if the interface has a connected interface or circuit termination. - """ - if obj.connection: - return True - try: - circuit_termination = obj.circuit_termination - return True - except CircuitTermination.DoesNotExist: - pass - return False - - def get_interface_connection(self, obj): - if obj.connection: - return OrderedDict(( - ('interface', PeerInterfaceSerializer(obj.connected_interface, context=self.context).data), - ('status', obj.connection.connection_status), - )) - return None - - -class PeerInterfaceSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') - device = NestedDeviceSerializer() - form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES) - lag = NestedInterfaceSerializer() - - class Meta: - model = Interface - fields = [ - 'id', 'url', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', - 'description', - ] - - -class WritableInterfaceSerializer(ValidatedModelSerializer): - - class Meta: - model = Interface - fields = [ - 'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description', - 'mode', 'untagged_vlan', 'tagged_vlans', - ] - def validate(self, data): # All associated VLANs be global or assigned to the parent device's site. @@ -746,23 +592,45 @@ class WritableInterfaceSerializer(ValidatedModelSerializer): "be global.".format(vlan) }) - return super(WritableInterfaceSerializer, self).validate(data) + return super(InterfaceSerializer, self).validate(data) + + def get_is_connected(self, obj): + """ + Return True if the interface has a connected interface or circuit termination. + """ + if obj.connection: + return True + try: + circuit_termination = obj.circuit_termination + return True + except CircuitTermination.DoesNotExist: + pass + return False + + def get_interface_connection(self, obj): + if obj.connection: + context = { + 'request': self.context['request'], + 'interface': obj.connected_interface, + } + return ContextualInterfaceConnectionSerializer(obj.connection, context=context).data + return None # # Device bays # -class DeviceBaySerializer(serializers.ModelSerializer): +class DeviceBaySerializer(ValidatedModelSerializer): device = NestedDeviceSerializer() - installed_device = NestedDeviceSerializer() + installed_device = NestedDeviceSerializer(required=False, allow_null=True) class Meta: model = DeviceBay fields = ['id', 'device', 'name', 'installed_device'] -class NestedDeviceBaySerializer(serializers.ModelSerializer): +class NestedDeviceBaySerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail') class Meta: @@ -770,32 +638,15 @@ class NestedDeviceBaySerializer(serializers.ModelSerializer): fields = ['id', 'url', 'name'] -class WritableDeviceBaySerializer(ValidatedModelSerializer): - - class Meta: - model = DeviceBay - fields = ['id', 'device', 'name', 'installed_device'] - - # # Inventory items # -class InventoryItemSerializer(serializers.ModelSerializer): +class InventoryItemSerializer(ValidatedModelSerializer): device = NestedDeviceSerializer() - manufacturer = NestedManufacturerSerializer() - - class Meta: - model = InventoryItem - fields = [ - 'id', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', - 'description', - ] - - -class WritableInventoryItemSerializer(ValidatedModelSerializer): # Provide a default value to satisfy UniqueTogetherValidator parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None) + manufacturer = NestedManufacturerSerializer() class Meta: model = InventoryItem @@ -809,17 +660,17 @@ class WritableInventoryItemSerializer(ValidatedModelSerializer): # Interface connections # -class InterfaceConnectionSerializer(serializers.ModelSerializer): - interface_a = PeerInterfaceSerializer() - interface_b = PeerInterfaceSerializer() - connection_status = ChoiceFieldSerializer(choices=CONNECTION_STATUS_CHOICES) +class InterfaceConnectionSerializer(ValidatedModelSerializer): + interface_a = NestedInterfaceSerializer() + interface_b = NestedInterfaceSerializer() + connection_status = ChoiceFieldSerializer(choices=CONNECTION_STATUS_CHOICES, required=False) class Meta: model = InterfaceConnection fields = ['id', 'interface_a', 'interface_b', 'connection_status'] -class NestedInterfaceConnectionSerializer(serializers.ModelSerializer): +class NestedInterfaceConnectionSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interfaceconnection-detail') class Meta: @@ -827,18 +678,26 @@ class NestedInterfaceConnectionSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'connection_status'] -class WritableInterfaceConnectionSerializer(ValidatedModelSerializer): +class ContextualInterfaceConnectionSerializer(serializers.ModelSerializer): + """ + A read-only representation of an InterfaceConnection from the perspective of either of its two connected Interfaces. + """ + interface = serializers.SerializerMethodField(read_only=True) + connection_status = ChoiceFieldSerializer(choices=CONNECTION_STATUS_CHOICES, read_only=True) class Meta: model = InterfaceConnection - fields = ['id', 'interface_a', 'interface_b', 'connection_status'] + fields = ['id', 'interface', 'connection_status'] + + def get_interface(self, obj): + return NestedInterfaceSerializer(self.context['interface'], context=self.context).data # # Virtual chassis # -class VirtualChassisSerializer(serializers.ModelSerializer): +class VirtualChassisSerializer(ValidatedModelSerializer): master = NestedDeviceSerializer() class Meta: @@ -846,16 +705,9 @@ class VirtualChassisSerializer(serializers.ModelSerializer): fields = ['id', 'master', 'domain'] -class NestedVirtualChassisSerializer(serializers.ModelSerializer): +class NestedVirtualChassisSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail') class Meta: model = VirtualChassis fields = ['id', 'url'] - - -class WritableVirtualChassisSerializer(ValidatedModelSerializer): - - class Meta: - model = VirtualChassis - fields = ['id', 'master', 'domain'] diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 13f68639f..5ef4b1de7 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -52,7 +52,6 @@ class DCIMFieldChoicesViewSet(FieldChoicesViewSet): class RegionViewSet(ModelViewSet): queryset = Region.objects.all() serializer_class = serializers.RegionSerializer - write_serializer_class = serializers.WritableRegionSerializer filter_class = filters.RegionFilter @@ -63,7 +62,6 @@ class RegionViewSet(ModelViewSet): class SiteViewSet(CustomFieldModelViewSet): queryset = Site.objects.select_related('region', 'tenant') serializer_class = serializers.SiteSerializer - write_serializer_class = serializers.WritableSiteSerializer filter_class = filters.SiteFilter @detail_route() @@ -84,7 +82,6 @@ class SiteViewSet(CustomFieldModelViewSet): class RackGroupViewSet(ModelViewSet): queryset = RackGroup.objects.select_related('site') serializer_class = serializers.RackGroupSerializer - write_serializer_class = serializers.WritableRackGroupSerializer filter_class = filters.RackGroupFilter @@ -105,7 +102,6 @@ class RackRoleViewSet(ModelViewSet): class RackViewSet(CustomFieldModelViewSet): queryset = Rack.objects.select_related('site', 'group__site', 'tenant') serializer_class = serializers.RackSerializer - write_serializer_class = serializers.WritableRackSerializer filter_class = filters.RackFilter @detail_route() @@ -136,7 +132,6 @@ class RackViewSet(CustomFieldModelViewSet): class RackReservationViewSet(ModelViewSet): queryset = RackReservation.objects.select_related('rack', 'user', 'tenant') serializer_class = serializers.RackReservationSerializer - write_serializer_class = serializers.WritableRackReservationSerializer filter_class = filters.RackReservationFilter # Assign user from request @@ -161,7 +156,6 @@ class ManufacturerViewSet(ModelViewSet): class DeviceTypeViewSet(CustomFieldModelViewSet): queryset = DeviceType.objects.select_related('manufacturer') serializer_class = serializers.DeviceTypeSerializer - write_serializer_class = serializers.WritableDeviceTypeSerializer filter_class = filters.DeviceTypeFilter @@ -172,42 +166,36 @@ class DeviceTypeViewSet(CustomFieldModelViewSet): class ConsolePortTemplateViewSet(ModelViewSet): queryset = ConsolePortTemplate.objects.select_related('device_type__manufacturer') serializer_class = serializers.ConsolePortTemplateSerializer - write_serializer_class = serializers.WritableConsolePortTemplateSerializer filter_class = filters.ConsolePortTemplateFilter class ConsoleServerPortTemplateViewSet(ModelViewSet): queryset = ConsoleServerPortTemplate.objects.select_related('device_type__manufacturer') serializer_class = serializers.ConsoleServerPortTemplateSerializer - write_serializer_class = serializers.WritableConsoleServerPortTemplateSerializer filter_class = filters.ConsoleServerPortTemplateFilter class PowerPortTemplateViewSet(ModelViewSet): queryset = PowerPortTemplate.objects.select_related('device_type__manufacturer') serializer_class = serializers.PowerPortTemplateSerializer - write_serializer_class = serializers.WritablePowerPortTemplateSerializer filter_class = filters.PowerPortTemplateFilter class PowerOutletTemplateViewSet(ModelViewSet): queryset = PowerOutletTemplate.objects.select_related('device_type__manufacturer') serializer_class = serializers.PowerOutletTemplateSerializer - write_serializer_class = serializers.WritablePowerOutletTemplateSerializer filter_class = filters.PowerOutletTemplateFilter class InterfaceTemplateViewSet(ModelViewSet): queryset = InterfaceTemplate.objects.select_related('device_type__manufacturer') serializer_class = serializers.InterfaceTemplateSerializer - write_serializer_class = serializers.WritableInterfaceTemplateSerializer filter_class = filters.InterfaceTemplateFilter class DeviceBayTemplateViewSet(ModelViewSet): queryset = DeviceBayTemplate.objects.select_related('device_type__manufacturer') serializer_class = serializers.DeviceBayTemplateSerializer - write_serializer_class = serializers.WritableDeviceBayTemplateSerializer filter_class = filters.DeviceBayTemplateFilter @@ -228,7 +216,6 @@ class DeviceRoleViewSet(ModelViewSet): class PlatformViewSet(ModelViewSet): queryset = Platform.objects.all() serializer_class = serializers.PlatformSerializer - write_serializer_class = serializers.WritablePlatformSerializer filter_class = filters.PlatformFilter @@ -244,7 +231,6 @@ class DeviceViewSet(CustomFieldModelViewSet): 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', ) serializer_class = serializers.DeviceSerializer - write_serializer_class = serializers.WritableDeviceSerializer filter_class = filters.DeviceFilter @detail_route(url_path='napalm') @@ -318,35 +304,30 @@ class DeviceViewSet(CustomFieldModelViewSet): class ConsolePortViewSet(ModelViewSet): queryset = ConsolePort.objects.select_related('device', 'cs_port__device') serializer_class = serializers.ConsolePortSerializer - write_serializer_class = serializers.WritableConsolePortSerializer filter_class = filters.ConsolePortFilter class ConsoleServerPortViewSet(ModelViewSet): queryset = ConsoleServerPort.objects.select_related('device', 'connected_console__device') serializer_class = serializers.ConsoleServerPortSerializer - write_serializer_class = serializers.WritableConsoleServerPortSerializer filter_class = filters.ConsoleServerPortFilter class PowerPortViewSet(ModelViewSet): queryset = PowerPort.objects.select_related('device', 'power_outlet__device') serializer_class = serializers.PowerPortSerializer - write_serializer_class = serializers.WritablePowerPortSerializer filter_class = filters.PowerPortFilter class PowerOutletViewSet(ModelViewSet): queryset = PowerOutlet.objects.select_related('device', 'connected_port__device') serializer_class = serializers.PowerOutletSerializer - write_serializer_class = serializers.WritablePowerOutletSerializer filter_class = filters.PowerOutletFilter class InterfaceViewSet(ModelViewSet): queryset = Interface.objects.select_related('device') serializer_class = serializers.InterfaceSerializer - write_serializer_class = serializers.WritableInterfaceSerializer filter_class = filters.InterfaceFilter @detail_route() @@ -363,14 +344,12 @@ class InterfaceViewSet(ModelViewSet): class DeviceBayViewSet(ModelViewSet): queryset = DeviceBay.objects.select_related('installed_device') serializer_class = serializers.DeviceBaySerializer - write_serializer_class = serializers.WritableDeviceBaySerializer filter_class = filters.DeviceBayFilter class InventoryItemViewSet(ModelViewSet): queryset = InventoryItem.objects.select_related('device', 'manufacturer') serializer_class = serializers.InventoryItemSerializer - write_serializer_class = serializers.WritableInventoryItemSerializer filter_class = filters.InventoryItemFilter @@ -393,7 +372,6 @@ class PowerConnectionViewSet(ListModelMixin, GenericViewSet): class InterfaceConnectionViewSet(ModelViewSet): queryset = InterfaceConnection.objects.select_related('interface_a__device', 'interface_b__device') serializer_class = serializers.InterfaceConnectionSerializer - write_serializer_class = serializers.WritableInterfaceConnectionSerializer filter_class = filters.InterfaceConnectionFilter @@ -404,7 +382,6 @@ class InterfaceConnectionViewSet(ModelViewSet): class VirtualChassisViewSet(ModelViewSet): queryset = VirtualChassis.objects.all() serializer_class = serializers.VirtualChassisSerializer - write_serializer_class = serializers.WritableVirtualChassisSerializer # diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 0d5455aa0..63091c2a8 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -82,6 +82,9 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='slug', label='Tenant (slug)', ) + tag = django_filters.CharFilter( + name='tags__slug', + ) class Meta: model = Site @@ -179,6 +182,9 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='slug', label='Role (slug)', ) + tag = django_filters.CharFilter( + name='tags__slug', + ) class Meta: model = Rack @@ -286,6 +292,9 @@ class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='slug', label='Manufacturer (slug)', ) + tag = django_filters.CharFilter( + name='tags__slug', + ) class Meta: model = DeviceType @@ -497,6 +506,9 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): queryset=VirtualChassis.objects.all(), label='Virtual chassis (ID)', ) + tag = django_filters.CharFilter( + name='tags__slug', + ) class Meta: model = Device diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index b360108bf..49d9500a0 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -7,6 +7,7 @@ from django.contrib.auth.models import User from django.contrib.postgres.forms.array import SimpleArrayField from django.db.models import Count, Q from mptt.forms import TreeNodeChoiceField +from taggit.forms import TagField from timezone_field import TimeZoneFormField from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm @@ -108,12 +109,14 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm): region = TreeNodeChoiceField(queryset=Region.objects.all(), required=False) slug = SlugField() comments = CommentField() + tags = TagField(required=False) class Meta: model = Site fields = [ 'name', 'slug', 'status', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments', + 'tags', ] widgets = { 'physical_address': SmallTextarea(attrs={'rows': 3}), @@ -274,12 +277,13 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldForm): ) ) comments = CommentField() + tags = TagField(required=False) class Meta: model = Rack fields = [ 'site', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'role', 'serial', 'type', 'width', - 'u_height', 'desc_units', 'comments', + 'u_height', 'desc_units', 'comments', 'tags', ] help_texts = { 'site': "The site at which the rack exists", @@ -350,6 +354,8 @@ class RackCSVForm(forms.ModelForm): site = self.cleaned_data.get('site') group_name = self.cleaned_data.get('group_name') + name = self.cleaned_data.get('name') + facility_id = self.cleaned_data.get('facility_id') # Validate rack group if group_name: @@ -358,6 +364,18 @@ class RackCSVForm(forms.ModelForm): except RackGroup.DoesNotExist: raise forms.ValidationError("Rack group {} not found for site {}".format(group_name, site)) + # Validate uniqueness of rack name within group + if Rack.objects.filter(group=self.instance.group, name=name).exists(): + raise forms.ValidationError( + "A rack named {} already exists within group {}".format(name, group_name) + ) + + # Validate uniqueness of facility ID within group + if facility_id and Rack.objects.filter(group=self.instance.group, facility_id=facility_id).exists(): + raise forms.ValidationError( + "A rack with the facility ID {} already exists within group {}".format(facility_id, group_name) + ) + class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput) @@ -485,11 +503,14 @@ class ManufacturerCSVForm(forms.ModelForm): class DeviceTypeForm(BootstrapMixin, CustomFieldForm): slug = SlugField(slug_source='model') + tags = TagField(required=False) class Meta: model = DeviceType - fields = ['manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', - 'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering', 'comments'] + fields = [ + 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu', + 'is_network_device', 'subdevice_role', 'interface_ordering', 'comments', 'tags', + ] labels = { 'interface_ordering': 'Order interfaces by', } @@ -706,7 +727,7 @@ class PlatformCSVForm(forms.ModelForm): slug = SlugField() manufacturer = forms.ModelChoiceField( queryset=Manufacturer.objects.all(), - required=True, + required=False, to_field_name='name', help_text='Manufacturer name', error_messages={ @@ -772,12 +793,13 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm): ) ) comments = CommentField() + tags = TagField(required=False) class Meta: model = Device fields = [ - 'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face', 'status', - 'platform', 'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant', 'comments', + 'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face', + 'status', 'platform', 'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant', 'comments', 'tags', ] help_texts = { 'device_role': "The function this device serves", diff --git a/netbox/dcim/migrations/0056_django2.py b/netbox/dcim/migrations/0056_django2.py new file mode 100644 index 000000000..bb7af920e --- /dev/null +++ b/netbox/dcim/migrations/0056_django2.py @@ -0,0 +1,24 @@ +# 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 = [ + ('dcim', '0055_virtualchassis_ordering'), + ] + + operations = [ + migrations.AlterField( + model_name='interface', + name='untagged_vlan', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='interfaces_as_untagged', to='ipam.VLAN', verbose_name='Untagged VLAN'), + ), + migrations.AlterField( + model_name='platform', + name='manufacturer', + field=models.ForeignKey(blank=True, help_text='Optionally limit this platform to devices of a certain manufacturer', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='platforms', to='dcim.Manufacturer'), + ), + ] diff --git a/netbox/dcim/migrations/0057_tags.py b/netbox/dcim/migrations/0057_tags.py new file mode 100644 index 000000000..04db38d5d --- /dev/null +++ b/netbox/dcim/migrations/0057_tags.py @@ -0,0 +1,37 @@ +# -*- 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'), + ('dcim', '0056_django2'), + ] + + operations = [ + migrations.AddField( + model_name='device', + 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='devicetype', + 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='rack', + 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='site', + 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/dcim/migrations/0058_relax_rack_naming_constraints.py b/netbox/dcim/migrations/0058_relax_rack_naming_constraints.py new file mode 100644 index 000000000..e4974be2f --- /dev/null +++ b/netbox/dcim/migrations/0058_relax_rack_naming_constraints.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.12 on 2018-05-22 19:27 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0057_tags'), + ] + + operations = [ + migrations.AlterModelOptions( + name='rack', + options={'ordering': ['site', 'group', 'name']}, + ), + migrations.AlterUniqueTogether( + name='rack', + unique_together=set([('group', 'name'), ('group', 'facility_id')]), + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index a46cd677f..9c13a144a 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -14,12 +14,12 @@ from django.db.models import Count, Q, ObjectDoesNotExist from django.urls import reverse from django.utils.encoding import python_2_unicode_compatible from mptt.models import MPTTModel, TreeForeignKey +from taggit.managers import TaggableManager from timezone_field import TimeZoneField from circuits.models import Circuit -from extras.models import CustomFieldModel, CustomFieldValue, ImageAttachment +from extras.models import CustomFieldModel from extras.rpc import RPC_CLIENTS -from tenancy.models import Tenant from utilities.fields import ColorField, NullableCharField from utilities.managers import NaturalOrderByManager from utilities.models import CreatedUpdatedModel @@ -38,10 +38,20 @@ class Region(MPTTModel): Sites can be grouped within geographic Regions. """ parent = TreeForeignKey( - 'self', null=True, blank=True, related_name='children', db_index=True, on_delete=models.CASCADE + to='self', + on_delete=models.CASCADE, + related_name='children', + blank=True, + null=True, + db_index=True + ) + 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', 'parent'] @@ -78,25 +88,81 @@ class Site(CreatedUpdatedModel, CustomFieldModel): A Site represents a geographic location within a network; typically a building or campus. The optional facility field can be used to include an external designation, such as a data center name (e.g. Equinix SV6). """ - name = models.CharField(max_length=50, unique=True) - slug = models.SlugField(unique=True) - status = models.PositiveSmallIntegerField(choices=SITE_STATUS_CHOICES, default=SITE_STATUS_ACTIVE) - region = models.ForeignKey('Region', related_name='sites', blank=True, null=True, on_delete=models.SET_NULL) - tenant = models.ForeignKey(Tenant, related_name='sites', blank=True, null=True, on_delete=models.PROTECT) - facility = models.CharField(max_length=50, blank=True) - asn = ASNField(blank=True, null=True, verbose_name='ASN') - time_zone = TimeZoneField(blank=True) - description = models.CharField(max_length=100, blank=True) - physical_address = models.CharField(max_length=200, blank=True) - shipping_address = models.CharField(max_length=200, blank=True) - contact_name = models.CharField(max_length=50, blank=True) - contact_phone = models.CharField(max_length=20, blank=True) - contact_email = models.EmailField(blank=True, verbose_name="Contact E-mail") - comments = models.TextField(blank=True) - custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') - images = GenericRelation(ImageAttachment) + name = models.CharField( + max_length=50, + unique=True + ) + slug = models.SlugField( + unique=True + ) + status = models.PositiveSmallIntegerField( + choices=SITE_STATUS_CHOICES, + default=SITE_STATUS_ACTIVE + ) + region = models.ForeignKey( + to='dcim.Region', + on_delete=models.SET_NULL, + related_name='sites', + blank=True, + null=True + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='sites', + blank=True, + null=True + ) + facility = models.CharField( + max_length=50, + blank=True + ) + asn = ASNField( + blank=True, + null=True, + verbose_name='ASN' + ) + time_zone = TimeZoneField( + blank=True + ) + description = models.CharField( + max_length=100, + blank=True + ) + physical_address = models.CharField( + max_length=200, + blank=True + ) + shipping_address = models.CharField( + max_length=200, + blank=True + ) + contact_name = models.CharField( + max_length=50, + blank=True + ) + contact_phone = models.CharField( + max_length=20, + blank=True + ) + contact_email = models.EmailField( + blank=True, + verbose_name='Contact E-mail' + ) + comments = models.TextField( + blank=True + ) + custom_field_values = GenericRelation( + to='extras.CustomFieldValue', + content_type_field='obj_type', + object_id_field='obj_id' + ) + images = GenericRelation( + to='extras.ImageAttachment' + ) objects = SiteManager() + tags = TaggableManager() csv_headers = [ 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address', @@ -175,9 +241,15 @@ class RackGroup(models.Model): example, if a Site spans a corporate campus, a RackGroup might be defined to represent each building within that campus. If a Site instead represents a single building, a RackGroup might represent a single room or floor. """ - name = models.CharField(max_length=50) + name = models.CharField( + max_length=50 + ) slug = models.SlugField() - site = models.ForeignKey('Site', related_name='rack_groups', on_delete=models.CASCADE) + site = models.ForeignKey( + to='dcim.Site', + on_delete=models.CASCADE, + related_name='rack_groups' + ) csv_headers = ['site', 'name', 'slug'] @@ -211,8 +283,13 @@ class RackRole(models.Model): """ Racks can be organized by functional role, similar to Devices. """ - 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 + ) color = ColorField() csv_headers = ['name', 'slug', 'color'] @@ -246,25 +323,82 @@ class Rack(CreatedUpdatedModel, CustomFieldModel): Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face. Each Rack is assigned to a Site and (optionally) a RackGroup. """ - name = models.CharField(max_length=50) - facility_id = NullableCharField(max_length=50, blank=True, null=True, verbose_name='Facility ID') - site = models.ForeignKey('Site', related_name='racks', on_delete=models.PROTECT) - group = models.ForeignKey('RackGroup', related_name='racks', blank=True, null=True, on_delete=models.SET_NULL) - tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='racks', on_delete=models.PROTECT) - role = models.ForeignKey('RackRole', related_name='racks', blank=True, null=True, on_delete=models.PROTECT) - serial = models.CharField(max_length=50, blank=True, verbose_name='Serial number') - type = models.PositiveSmallIntegerField(choices=RACK_TYPE_CHOICES, blank=True, null=True, verbose_name='Type') - width = models.PositiveSmallIntegerField(choices=RACK_WIDTH_CHOICES, default=RACK_WIDTH_19IN, verbose_name='Width', - help_text='Rail-to-rail width') - u_height = models.PositiveSmallIntegerField(default=42, verbose_name='Height (U)', - validators=[MinValueValidator(1), MaxValueValidator(100)]) - desc_units = models.BooleanField(default=False, verbose_name='Descending units', - help_text='Units are numbered top-to-bottom') - comments = models.TextField(blank=True) - custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') - images = GenericRelation(ImageAttachment) + name = models.CharField( + max_length=50 + ) + facility_id = NullableCharField( + max_length=50, + blank=True, + null=True, + verbose_name='Facility ID' + ) + site = models.ForeignKey( + to='dcim.Site', + on_delete=models.PROTECT, + related_name='racks' + ) + group = models.ForeignKey( + to='dcim.RackGroup', + on_delete=models.SET_NULL, + related_name='racks', + blank=True, + null=True + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='racks', + blank=True, + null=True + ) + role = models.ForeignKey( + to='dcim.RackRole', + on_delete=models.PROTECT, + related_name='racks', + blank=True, + null=True + ) + serial = models.CharField( + max_length=50, + blank=True, + verbose_name='Serial number' + ) + type = models.PositiveSmallIntegerField( + choices=RACK_TYPE_CHOICES, + blank=True, + null=True, + verbose_name='Type' + ) + width = models.PositiveSmallIntegerField( + choices=RACK_WIDTH_CHOICES, + default=RACK_WIDTH_19IN, + verbose_name='Width', + help_text='Rail-to-rail width' + ) + u_height = models.PositiveSmallIntegerField( + default=42, + verbose_name='Height (U)', + validators=[MinValueValidator(1), MaxValueValidator(100)] + ) + desc_units = models.BooleanField( + default=False, + verbose_name='Descending units', + help_text='Units are numbered top-to-bottom' + ) + comments = models.TextField( + blank=True + ) + custom_field_values = GenericRelation( + to='extras.CustomFieldValue', + content_type_field='obj_type', + object_id_field='obj_id' + ) + images = GenericRelation( + to='extras.ImageAttachment' + ) objects = RackManager() + tags = TaggableManager() csv_headers = [ 'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'serial', 'width', 'u_height', @@ -272,10 +406,10 @@ class Rack(CreatedUpdatedModel, CustomFieldModel): ] class Meta: - ordering = ['site', 'name'] + ordering = ['site', 'group', 'name'] unique_together = [ - ['site', 'name'], - ['site', 'facility_id'], + ['group', 'name'], + ['group', 'facility_id'], ] def __str__(self): @@ -450,12 +584,31 @@ class RackReservation(models.Model): """ One or more reserved units within a Rack. """ - rack = models.ForeignKey('Rack', related_name='reservations', on_delete=models.CASCADE) - units = ArrayField(models.PositiveSmallIntegerField()) - created = models.DateTimeField(auto_now_add=True) - tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='rackreservations', on_delete=models.PROTECT) - user = models.ForeignKey(User, on_delete=models.PROTECT) - description = models.CharField(max_length=100) + rack = models.ForeignKey( + to='dcim.Rack', + on_delete=models.CASCADE, + related_name='reservations' + ) + units = ArrayField( + base_field=models.PositiveSmallIntegerField() + ) + created = models.DateTimeField( + auto_now_add=True + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='rackreservations', + blank=True, + null=True + ) + user = models.ForeignKey( + to=User, + on_delete=models.PROTECT + ) + description = models.CharField( + max_length=100 + ) class Meta: ordering = ['created'] @@ -508,8 +661,13 @@ class Manufacturer(models.Model): """ A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell. """ - 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'] @@ -545,27 +703,65 @@ class DeviceType(models.Model, CustomFieldModel): When a new Device of this type is created, the appropriate console, power, and interface objects (as defined by the DeviceType) are automatically created as well. """ - manufacturer = models.ForeignKey('Manufacturer', related_name='device_types', on_delete=models.PROTECT) - model = models.CharField(max_length=50) + manufacturer = models.ForeignKey( + to='dcim.Manufacturer', + on_delete=models.PROTECT, + related_name='device_types' + ) + model = models.CharField( + max_length=50 + ) slug = models.SlugField() - part_number = models.CharField(max_length=50, blank=True, help_text="Discrete part number (optional)") - u_height = models.PositiveSmallIntegerField(verbose_name='Height (U)', default=1) - is_full_depth = models.BooleanField(default=True, verbose_name="Is full depth", - help_text="Device consumes both front and rear rack faces") - interface_ordering = models.PositiveSmallIntegerField(choices=IFACE_ORDERING_CHOICES, - default=IFACE_ORDERING_POSITION) - is_console_server = models.BooleanField(default=False, verbose_name='Is a console server', - help_text="This type of device has console server ports") - is_pdu = models.BooleanField(default=False, verbose_name='Is a PDU', - help_text="This type of device has power outlets") - is_network_device = models.BooleanField(default=True, verbose_name='Is a network device', - help_text="This type of device has network interfaces") - subdevice_role = models.NullBooleanField(default=None, verbose_name='Parent/child status', - choices=SUBDEVICE_ROLE_CHOICES, - help_text="Parent devices house child devices in device bays. Select " - "\"None\" if this device type is neither a parent nor a child.") - comments = models.TextField(blank=True) - custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') + part_number = models.CharField( + max_length=50, + blank=True, + help_text='Discrete part number (optional)' + ) + u_height = models.PositiveSmallIntegerField( + default=1, + verbose_name='Height (U)' + ) + is_full_depth = models.BooleanField( + default=True, + verbose_name='Is full depth', + help_text='Device consumes both front and rear rack faces' + ) + interface_ordering = models.PositiveSmallIntegerField( + choices=IFACE_ORDERING_CHOICES, + default=IFACE_ORDERING_POSITION + ) + is_console_server = models.BooleanField( + default=False, + verbose_name='Is a console server', + help_text='This type of device has console server ports' + ) + is_pdu = models.BooleanField( + default=False, + verbose_name='Is a PDU', + help_text='This type of device has power outlets' + ) + is_network_device = models.BooleanField( + default=True, + verbose_name='Is a network device', + help_text='This type of device has network interfaces' + ) + subdevice_role = models.NullBooleanField( + default=None, + verbose_name='Parent/child status', + choices=SUBDEVICE_ROLE_CHOICES, + help_text='Parent devices house child devices in device bays. Select ' + '"None" if this device type is neither a parent nor a child.' + ) + 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 = [ 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', @@ -670,8 +866,14 @@ class ConsolePortTemplate(models.Model): """ A template for a ConsolePort to be created for a new Device. """ - device_type = models.ForeignKey('DeviceType', related_name='console_port_templates', on_delete=models.CASCADE) - name = models.CharField(max_length=50) + device_type = models.ForeignKey( + to='dcim.DeviceType', + on_delete=models.CASCADE, + related_name='console_port_templates' + ) + name = models.CharField( + max_length=50 + ) class Meta: ordering = ['device_type', 'name'] @@ -686,8 +888,14 @@ class ConsoleServerPortTemplate(models.Model): """ A template for a ConsoleServerPort to be created for a new Device. """ - device_type = models.ForeignKey('DeviceType', related_name='cs_port_templates', on_delete=models.CASCADE) - name = models.CharField(max_length=50) + device_type = models.ForeignKey( + to='dcim.DeviceType', + on_delete=models.CASCADE, + related_name='cs_port_templates' + ) + name = models.CharField( + max_length=50 + ) class Meta: ordering = ['device_type', 'name'] @@ -702,8 +910,14 @@ class PowerPortTemplate(models.Model): """ A template for a PowerPort to be created for a new Device. """ - device_type = models.ForeignKey('DeviceType', related_name='power_port_templates', on_delete=models.CASCADE) - name = models.CharField(max_length=50) + device_type = models.ForeignKey( + to='dcim.DeviceType', + on_delete=models.CASCADE, + related_name='power_port_templates' + ) + name = models.CharField( + max_length=50 + ) class Meta: ordering = ['device_type', 'name'] @@ -718,8 +932,14 @@ class PowerOutletTemplate(models.Model): """ A template for a PowerOutlet to be created for a new Device. """ - device_type = models.ForeignKey('DeviceType', related_name='power_outlet_templates', on_delete=models.CASCADE) - name = models.CharField(max_length=50) + device_type = models.ForeignKey( + to='dcim.DeviceType', + on_delete=models.CASCADE, + related_name='power_outlet_templates' + ) + name = models.CharField( + max_length=50 + ) class Meta: ordering = ['device_type', 'name'] @@ -734,10 +954,22 @@ class InterfaceTemplate(models.Model): """ A template for a physical data interface on a new Device. """ - device_type = models.ForeignKey('DeviceType', related_name='interface_templates', on_delete=models.CASCADE) - name = models.CharField(max_length=64) - form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_10GE_SFP_PLUS) - mgmt_only = models.BooleanField(default=False, verbose_name='Management only') + device_type = models.ForeignKey( + to='dcim.DeviceType', + on_delete=models.CASCADE, + related_name='interface_templates' + ) + name = models.CharField( + max_length=64 + ) + form_factor = models.PositiveSmallIntegerField( + choices=IFACE_FF_CHOICES, + default=IFACE_FF_10GE_SFP_PLUS + ) + mgmt_only = models.BooleanField( + default=False, + verbose_name='Management only' + ) objects = InterfaceQuerySet.as_manager() @@ -754,8 +986,14 @@ class DeviceBayTemplate(models.Model): """ A template for a DeviceBay to be created for a new parent Device. """ - device_type = models.ForeignKey('DeviceType', related_name='device_bay_templates', on_delete=models.CASCADE) - name = models.CharField(max_length=50) + device_type = models.ForeignKey( + to='dcim.DeviceType', + on_delete=models.CASCADE, + related_name='device_bay_templates' + ) + name = models.CharField( + max_length=50 + ) class Meta: ordering = ['device_type', 'name'] @@ -776,13 +1014,18 @@ class DeviceRole(models.Model): color to be used when displaying rack elevations. The vm_role field determines whether the role is applicable to virtual machines as well. """ - 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 + ) color = ColorField() vm_role = models.BooleanField( default=True, - verbose_name="VM Role", - help_text="Virtual machines may be assigned to this role" + verbose_name='VM Role', + help_text='Virtual machines may be assigned to this role' ) csv_headers = ['name', 'slug', 'color', 'vm_role'] @@ -812,26 +1055,32 @@ class Platform(models.Model): NetBox uses Platforms to determine how to interact with devices when pulling inventory data or other information by specifying a NAPALM driver. """ - 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 + ) manufacturer = models.ForeignKey( - to='Manufacturer', + to='dcim.Manufacturer', + on_delete=models.PROTECT, related_name='platforms', blank=True, null=True, - help_text="Optionally limit this platform to devices of a certain manufacturer" + help_text='Optionally limit this platform to devices of a certain manufacturer' ) napalm_driver = models.CharField( max_length=50, blank=True, verbose_name='NAPALM driver', - help_text="The name of the NAPALM driver to use when interacting with devices" + help_text='The name of the NAPALM driver to use when interacting with devices' ) rpc_client = models.CharField( max_length=30, choices=RPC_CLIENT_CHOICES, blank=True, - verbose_name="Legacy RPC client" + verbose_name='Legacy RPC client' ) csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver'] @@ -873,30 +1122,93 @@ class Device(CreatedUpdatedModel, CustomFieldModel): by the component templates assigned to its DeviceType. Components can also be added, modified, or deleted after the creation of a Device. """ - device_type = models.ForeignKey('DeviceType', related_name='instances', on_delete=models.PROTECT) - device_role = models.ForeignKey('DeviceRole', related_name='devices', on_delete=models.PROTECT) - tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='devices', on_delete=models.PROTECT) - platform = models.ForeignKey('Platform', related_name='devices', blank=True, null=True, on_delete=models.SET_NULL) - name = NullableCharField(max_length=64, blank=True, null=True, unique=True) - serial = models.CharField(max_length=50, blank=True, verbose_name='Serial number') + device_type = models.ForeignKey( + to='dcim.DeviceType', + on_delete=models.PROTECT, + related_name='instances' + ) + device_role = models.ForeignKey( + to='dcim.DeviceRole', + on_delete=models.PROTECT, + related_name='devices' + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='devices', + blank=True, + null=True + ) + platform = models.ForeignKey( + to='dcim.Platform', + on_delete=models.SET_NULL, + related_name='devices', + blank=True, + null=True + ) + name = NullableCharField( + max_length=64, + blank=True, + null=True, + unique=True + ) + serial = models.CharField( + max_length=50, + blank=True, + verbose_name='Serial number' + ) asset_tag = NullableCharField( - max_length=50, blank=True, null=True, unique=True, verbose_name='Asset tag', + max_length=50, + blank=True, + null=True, + unique=True, + verbose_name='Asset tag', help_text='A unique tag used to identify this device' ) - site = models.ForeignKey('Site', related_name='devices', on_delete=models.PROTECT) - rack = models.ForeignKey('Rack', related_name='devices', blank=True, null=True, on_delete=models.PROTECT) + site = models.ForeignKey( + to='dcim.Site', + on_delete=models.PROTECT, + related_name='devices' + ) + rack = models.ForeignKey( + to='dcim.Rack', + on_delete=models.PROTECT, + related_name='devices', + blank=True, + null=True + ) position = models.PositiveSmallIntegerField( - blank=True, null=True, validators=[MinValueValidator(1)], verbose_name='Position (U)', + blank=True, + null=True, + validators=[MinValueValidator(1)], + verbose_name='Position (U)', help_text='The lowest-numbered unit occupied by the device' ) - face = models.PositiveSmallIntegerField(blank=True, null=True, choices=RACK_FACE_CHOICES, verbose_name='Rack face') - status = models.PositiveSmallIntegerField(choices=DEVICE_STATUS_CHOICES, default=DEVICE_STATUS_ACTIVE, verbose_name='Status') + face = models.PositiveSmallIntegerField( + blank=True, + null=True, + choices=RACK_FACE_CHOICES, + verbose_name='Rack face' + ) + status = models.PositiveSmallIntegerField( + choices=DEVICE_STATUS_CHOICES, + default=DEVICE_STATUS_ACTIVE, + verbose_name='Status' + ) primary_ip4 = models.OneToOneField( - 'ipam.IPAddress', related_name='primary_ip4_for', on_delete=models.SET_NULL, blank=True, null=True, + to='ipam.IPAddress', + on_delete=models.SET_NULL, + related_name='primary_ip4_for', + blank=True, + null=True, verbose_name='Primary IPv4' ) primary_ip6 = models.OneToOneField( - 'ipam.IPAddress', related_name='primary_ip6_for', on_delete=models.SET_NULL, blank=True, null=True, + to='ipam.IPAddress', + on_delete=models.SET_NULL, + related_name='primary_ip6_for', + blank=True, + null=True, verbose_name='Primary IPv6' ) cluster = models.ForeignKey( @@ -923,11 +1235,20 @@ class Device(CreatedUpdatedModel, CustomFieldModel): null=True, validators=[MaxValueValidator(255)] ) - comments = models.TextField(blank=True) - custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') - images = GenericRelation(ImageAttachment) + comments = models.TextField( + blank=True + ) + custom_field_values = GenericRelation( + to='extras.CustomFieldValue', + content_type_field='obj_type', + object_id_field='obj_id' + ) + images = GenericRelation( + to='extras.ImageAttachment' + ) objects = DeviceManager() + tags = TaggableManager() csv_headers = [ 'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status', @@ -1184,11 +1505,26 @@ class ConsolePort(models.Model): """ A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts. """ - device = models.ForeignKey('Device', related_name='console_ports', on_delete=models.CASCADE) - name = models.CharField(max_length=50) - cs_port = models.OneToOneField('ConsoleServerPort', related_name='connected_console', on_delete=models.SET_NULL, - verbose_name='Console server port', blank=True, null=True) - connection_status = models.NullBooleanField(choices=CONNECTION_STATUS_CHOICES, default=CONNECTION_STATUS_CONNECTED) + device = models.ForeignKey( + to='dcim.Device', + on_delete=models.CASCADE, + related_name='console_ports' + ) + name = models.CharField( + max_length=50 + ) + cs_port = models.OneToOneField( + to='dcim.ConsoleServerPort', + on_delete=models.SET_NULL, + related_name='connected_console', + verbose_name='Console server port', + blank=True, + null=True + ) + connection_status = models.NullBooleanField( + choices=CONNECTION_STATUS_CHOICES, + default=CONNECTION_STATUS_CONNECTED + ) csv_headers = ['console_server', 'cs_port', 'device', 'console_port', 'connection_status'] @@ -1231,8 +1567,14 @@ class ConsoleServerPort(models.Model): """ A physical port within a Device (typically a designated console server) which provides access to ConsolePorts. """ - device = models.ForeignKey('Device', related_name='cs_ports', on_delete=models.CASCADE) - name = models.CharField(max_length=50) + device = models.ForeignKey( + to='dcim.Device', + on_delete=models.CASCADE, + related_name='cs_ports' + ) + name = models.CharField( + max_length=50 + ) objects = ConsoleServerPortManager() @@ -1266,11 +1608,25 @@ class PowerPort(models.Model): """ A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets. """ - device = models.ForeignKey('Device', related_name='power_ports', on_delete=models.CASCADE) - name = models.CharField(max_length=50) - power_outlet = models.OneToOneField('PowerOutlet', related_name='connected_port', on_delete=models.SET_NULL, - blank=True, null=True) - connection_status = models.NullBooleanField(choices=CONNECTION_STATUS_CHOICES, default=CONNECTION_STATUS_CONNECTED) + device = models.ForeignKey( + to='dcim.Device', + on_delete=models.CASCADE, + related_name='power_ports' + ) + name = models.CharField( + max_length=50 + ) + power_outlet = models.OneToOneField( + to='dcim.PowerOutlet', + on_delete=models.SET_NULL, + related_name='connected_port', + blank=True, + null=True + ) + connection_status = models.NullBooleanField( + choices=CONNECTION_STATUS_CHOICES, + default=CONNECTION_STATUS_CONNECTED + ) csv_headers = ['pdu', 'power_outlet', 'device', 'power_port', 'connection_status'] @@ -1313,8 +1669,14 @@ class PowerOutlet(models.Model): """ A physical power outlet (output) within a Device which provides power to a PowerPort. """ - device = models.ForeignKey('Device', related_name='power_outlets', on_delete=models.CASCADE) - name = models.CharField(max_length=50) + device = models.ForeignKey( + to='dcim.Device', + on_delete=models.CASCADE, + related_name='power_outlets' + ) + name = models.CharField( + max_length=50 + ) objects = PowerOutletManager() @@ -1371,17 +1733,35 @@ class Interface(models.Model): blank=True, verbose_name='Parent LAG' ) - name = models.CharField(max_length=64) - form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_10GE_SFP_PLUS) - enabled = models.BooleanField(default=True) - mac_address = MACAddressField(null=True, blank=True, verbose_name='MAC Address') - mtu = models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='MTU') + name = models.CharField( + max_length=64 + ) + form_factor = models.PositiveSmallIntegerField( + choices=IFACE_FF_CHOICES, + default=IFACE_FF_10GE_SFP_PLUS + ) + enabled = models.BooleanField( + default=True + ) + mac_address = MACAddressField( + null=True, + blank=True, + verbose_name='MAC Address' + ) + mtu = models.PositiveSmallIntegerField( + blank=True, + null=True, + verbose_name='MTU' + ) mgmt_only = models.BooleanField( default=False, verbose_name='OOB Management', - help_text="This interface is used only for out-of-band management" + help_text='This interface is used only for out-of-band management' + ) + description = models.CharField( + max_length=100, + blank=True ) - description = models.CharField(max_length=100, blank=True) mode = models.PositiveSmallIntegerField( choices=IFACE_MODE_CHOICES, blank=True, @@ -1389,16 +1769,17 @@ class Interface(models.Model): ) untagged_vlan = models.ForeignKey( to='ipam.VLAN', + on_delete=models.SET_NULL, + related_name='interfaces_as_untagged', null=True, blank=True, - verbose_name='Untagged VLAN', - related_name='interfaces_as_untagged' + verbose_name='Untagged VLAN' ) tagged_vlans = models.ManyToManyField( to='ipam.VLAN', + related_name='interfaces_as_tagged', blank=True, - verbose_name='Tagged VLANs', - related_name='interfaces_as_tagged' + verbose_name='Tagged VLANs' ) objects = InterfaceQuerySet.as_manager() @@ -1543,10 +1924,21 @@ class InterfaceConnection(models.Model): An InterfaceConnection represents a symmetrical, one-to-one connection between two Interfaces. There is no significant difference between the interface_a and interface_b fields. """ - interface_a = models.OneToOneField('Interface', related_name='connected_as_a', on_delete=models.CASCADE) - interface_b = models.OneToOneField('Interface', related_name='connected_as_b', on_delete=models.CASCADE) - connection_status = models.BooleanField(choices=CONNECTION_STATUS_CHOICES, default=CONNECTION_STATUS_CONNECTED, - verbose_name='Status') + interface_a = models.OneToOneField( + to='dcim.Interface', + on_delete=models.CASCADE, + related_name='connected_as_a' + ) + interface_b = models.OneToOneField( + to='dcim.Interface', + on_delete=models.CASCADE, + related_name='connected_as_b' + ) + connection_status = models.BooleanField( + choices=CONNECTION_STATUS_CHOICES, + default=CONNECTION_STATUS_CONNECTED, + verbose_name='Status' + ) csv_headers = ['device_a', 'interface_a', 'device_b', 'interface_b', 'connection_status'] @@ -1578,10 +1970,22 @@ class DeviceBay(models.Model): """ An empty space within a Device which can house a child device """ - device = models.ForeignKey('Device', related_name='device_bays', on_delete=models.CASCADE) - name = models.CharField(max_length=50, verbose_name='Name') - installed_device = models.OneToOneField('Device', related_name='parent_bay', on_delete=models.SET_NULL, blank=True, - null=True) + device = models.ForeignKey( + to='dcim.Device', + on_delete=models.CASCADE, + related_name='device_bays' + ) + name = models.CharField( + max_length=50, + verbose_name='Name' + ) + installed_device = models.OneToOneField( + to='dcim.Device', + on_delete=models.SET_NULL, + related_name='parent_bay', + blank=True, + null=True + ) class Meta: ordering = ['device', 'name'] @@ -1616,20 +2020,55 @@ class InventoryItem(models.Model): An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply. InventoryItems are used only for inventory purposes. """ - device = models.ForeignKey('Device', related_name='inventory_items', on_delete=models.CASCADE) - parent = models.ForeignKey('self', related_name='child_items', blank=True, null=True, on_delete=models.CASCADE) - name = models.CharField(max_length=50, verbose_name='Name') - manufacturer = models.ForeignKey( - 'Manufacturer', models.PROTECT, related_name='inventory_items', blank=True, null=True + device = models.ForeignKey( + to='dcim.Device', + on_delete=models.CASCADE, + related_name='inventory_items' + ) + parent = models.ForeignKey( + to='self', + on_delete=models.CASCADE, + related_name='child_items', + blank=True, + null=True + ) + name = models.CharField( + max_length=50, + verbose_name='Name' + ) + manufacturer = models.ForeignKey( + to='dcim.Manufacturer', + on_delete=models.PROTECT, + related_name='inventory_items', + blank=True, + null=True + ) + part_id = models.CharField( + max_length=50, + verbose_name='Part ID', + blank=True + ) + serial = models.CharField( + max_length=50, + verbose_name='Serial number', + blank=True ) - part_id = models.CharField(max_length=50, verbose_name='Part ID', blank=True) - serial = models.CharField(max_length=50, verbose_name='Serial number', blank=True) asset_tag = NullableCharField( - max_length=50, blank=True, null=True, unique=True, verbose_name='Asset tag', + max_length=50, + unique=True, + blank=True, + null=True, + verbose_name='Asset tag', help_text='A unique tag used to identify this item' ) - discovered = models.BooleanField(default=False, verbose_name='Discovered') - description = models.CharField(max_length=100, blank=True) + discovered = models.BooleanField( + default=False, + verbose_name='Discovered' + ) + description = models.CharField( + max_length=100, + blank=True + ) csv_headers = [ 'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description', diff --git a/netbox/dcim/querysets.py b/netbox/dcim/querysets.py index 3e977ddc6..32275ce01 100644 --- a/netbox/dcim/querysets.py +++ b/netbox/dcim/querysets.py @@ -43,13 +43,13 @@ class InterfaceQuerySet(QuerySet): }[method] TYPE_RE = r"SUBSTRING({} FROM '^([^0-9]+)')" - ID_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)([0-9]+)$') AS integer)" - SLOT_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?([0-9]+)\/') AS integer)" - SUBSLOT_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:[0-9]+\/)([0-9]+)') AS integer), 0)" - POSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:[0-9]+\/){{2}}([0-9]+)') AS integer), 0)" - SUBPOSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:[0-9]+\/){{3}}([0-9]+)') AS integer), 0)" - CHANNEL_RE = r"COALESCE(CAST(SUBSTRING({} FROM ':([0-9]+)(\.[0-9]+)?$') AS integer), 0)" - VC_RE = r"COALESCE(CAST(SUBSTRING({} FROM '\.([0-9]+)$') AS integer), 0)" + ID_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)(\d{{1,9}})$') AS integer)" + SLOT_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(\d{{1,9}})\/') AS integer)" + SUBSLOT_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:\d{{1,9}}\/)(\d{{1,9}})') AS integer), 0)" + POSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:\d{{1,9}}\/){{2}}(\d{{1,9}})') AS integer), 0)" + SUBPOSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:\d{{1,9}}\/){{3}}(\d{{1,9}})') AS integer), 0)" + CHANNEL_RE = r"COALESCE(CAST(SUBSTRING({} FROM ':(\d{{1,9}})(\.\d{{1,9}})?$') AS integer), 0)" + VC_RE = r"COALESCE(CAST(SUBSTRING({} FROM '\.(\d{{1,9}})$') AS integer), 0)" fields = { '_type': RawSQL(TYPE_RE.format(sql_col), []), diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 37743b499..6614f8068 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -6,7 +6,8 @@ from rest_framework import status from rest_framework.test import APITestCase from dcim.constants import ( - IFACE_FF_1GE_FIXED, IFACE_FF_LAG, IFACE_MODE_TAGGED, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT, + IFACE_FF_1GE_FIXED, IFACE_FF_LAG, IFACE_MODE_TAGGED, SITE_STATUS_ACTIVE, SUBDEVICE_ROLE_CHILD, + SUBDEVICE_ROLE_PARENT, ) from dcim.models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, @@ -168,6 +169,7 @@ class SiteTest(HttpStatusMixin, APITestCase): 'name': 'Test Site 4', 'slug': 'test-site-4', 'region': self.region1.pk, + 'status': SITE_STATUS_ACTIVE, } url = reverse('dcim-api:site-list') @@ -187,16 +189,19 @@ class SiteTest(HttpStatusMixin, APITestCase): 'name': 'Test Site 4', 'slug': 'test-site-4', 'region': self.region1.pk, + 'status': SITE_STATUS_ACTIVE, }, { 'name': 'Test Site 5', 'slug': 'test-site-5', 'region': self.region1.pk, + 'status': SITE_STATUS_ACTIVE, }, { 'name': 'Test Site 6', 'slug': 'test-site-6', 'region': self.region1.pk, + 'status': SITE_STATUS_ACTIVE, }, ] @@ -2322,8 +2327,8 @@ class InterfaceTest(HttpStatusMixin, APITestCase): 'device': self.device.pk, 'name': 'Test Interface 4', 'mode': IFACE_MODE_TAGGED, + 'untagged_vlan': self.vlan3.id, 'tagged_vlans': [self.vlan1.id, self.vlan2.id], - 'untagged_vlan': self.vlan3.id } url = reverse('dcim-api:interface-list') @@ -2331,11 +2336,10 @@ class InterfaceTest(HttpStatusMixin, APITestCase): self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(Interface.objects.count(), 4) - interface5 = Interface.objects.get(pk=response.data['id']) - self.assertEqual(interface5.device_id, data['device']) - self.assertEqual(interface5.name, data['name']) - self.assertEqual(interface5.tagged_vlans.count(), 2) - self.assertEqual(interface5.untagged_vlan.id, data['untagged_vlan']) + self.assertEqual(response.data['device']['id'], data['device']) + self.assertEqual(response.data['name'], data['name']) + self.assertEqual(response.data['untagged_vlan']['id'], data['untagged_vlan']) + self.assertEqual([v['id'] for v in response.data['tagged_vlans']], data['tagged_vlans']) def test_create_interface_bulk(self): @@ -2370,22 +2374,22 @@ class InterfaceTest(HttpStatusMixin, APITestCase): 'device': self.device.pk, 'name': 'Test Interface 4', 'mode': IFACE_MODE_TAGGED, - 'tagged_vlans': [self.vlan1.id], 'untagged_vlan': self.vlan2.id, + 'tagged_vlans': [self.vlan1.id], }, { 'device': self.device.pk, 'name': 'Test Interface 5', 'mode': IFACE_MODE_TAGGED, - 'tagged_vlans': [self.vlan1.id], 'untagged_vlan': self.vlan2.id, + 'tagged_vlans': [self.vlan1.id], }, { 'device': self.device.pk, 'name': 'Test Interface 6', 'mode': IFACE_MODE_TAGGED, - 'tagged_vlans': [self.vlan1.id], 'untagged_vlan': self.vlan2.id, + 'tagged_vlans': [self.vlan1.id], }, ] @@ -2394,15 +2398,10 @@ class InterfaceTest(HttpStatusMixin, APITestCase): self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(Interface.objects.count(), 6) - self.assertEqual(response.data[0]['name'], data[0]['name']) - self.assertEqual(response.data[1]['name'], data[1]['name']) - self.assertEqual(response.data[2]['name'], data[2]['name']) - self.assertEqual(len(response.data[0]['tagged_vlans']), 1) - self.assertEqual(len(response.data[1]['tagged_vlans']), 1) - self.assertEqual(len(response.data[2]['tagged_vlans']), 1) - self.assertEqual(response.data[0]['untagged_vlan'], self.vlan2.id) - self.assertEqual(response.data[1]['untagged_vlan'], self.vlan2.id) - self.assertEqual(response.data[2]['untagged_vlan'], self.vlan2.id) + for i in range(0, 3): + self.assertEqual(response.data[i]['name'], data[i]['name']) + self.assertEqual([v['id'] for v in response.data[i]['tagged_vlans']], data[i]['tagged_vlans']) + self.assertEqual(response.data[i]['untagged_vlan']['id'], data[i]['untagged_vlan']) def test_update_interface(self): @@ -2847,9 +2846,9 @@ class InterfaceConnectionTest(HttpStatusMixin, APITestCase): self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(InterfaceConnection.objects.count(), 6) - self.assertEqual(response.data[0]['interface_a'], data[0]['interface_a']) - self.assertEqual(response.data[1]['interface_a'], data[1]['interface_a']) - self.assertEqual(response.data[2]['interface_a'], data[2]['interface_a']) + for i in range(0, 3): + self.assertEqual(response.data[i]['interface_a']['id'], data[i]['interface_a']) + self.assertEqual(response.data[i]['interface_b']['id'], data[i]['interface_b']) def test_update_interfaceconnection(self): @@ -3047,12 +3046,9 @@ class VirtualChassisTest(HttpStatusMixin, APITestCase): response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(VirtualChassis.objects.count(), 5) - self.assertEqual(response.data[0]['master'], data[0]['master']) - self.assertEqual(response.data[0]['domain'], data[0]['domain']) - self.assertEqual(response.data[1]['master'], data[1]['master']) - self.assertEqual(response.data[1]['domain'], data[1]['domain']) - self.assertEqual(response.data[2]['master'], data[2]['master']) - self.assertEqual(response.data[2]['domain'], data[2]['domain']) + for i in range(0, 3): + self.assertEqual(response.data[i]['master']['id'], data[i]['master']) + self.assertEqual(response.data[i]['domain'], data[i]['domain']) def test_update_virtualchassis(self): diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index a2c71165b..6a3c6256f 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals from django.core.exceptions import ObjectDoesNotExist from rest_framework import serializers +from taggit.models import Tag from dcim.api.serializers import NestedDeviceSerializer, NestedRackSerializer, NestedSiteSerializer from dcim.models import Device, Rack, Site @@ -16,7 +17,7 @@ from extras.constants import * # Graphs # -class GraphSerializer(serializers.ModelSerializer): +class GraphSerializer(ValidatedModelSerializer): type = ChoiceFieldSerializer(choices=GRAPH_TYPE_CHOICES) class Meta: @@ -24,13 +25,6 @@ class GraphSerializer(serializers.ModelSerializer): fields = ['id', 'type', 'weight', 'name', 'source', 'link'] -class WritableGraphSerializer(serializers.ModelSerializer): - - class Meta: - model = Graph - fields = ['id', 'type', 'weight', 'name', 'source', 'link'] - - class RenderedGraphSerializer(serializers.ModelSerializer): embed_url = serializers.SerializerMethodField() embed_link = serializers.SerializerMethodField() @@ -51,7 +45,7 @@ class RenderedGraphSerializer(serializers.ModelSerializer): # Export templates # -class ExportTemplateSerializer(serializers.ModelSerializer): +class ExportTemplateSerializer(ValidatedModelSerializer): class Meta: model = ExportTemplate @@ -62,7 +56,7 @@ class ExportTemplateSerializer(serializers.ModelSerializer): # Topology maps # -class TopologyMapSerializer(serializers.ModelSerializer): +class TopologyMapSerializer(ValidatedModelSerializer): site = NestedSiteSerializer() class Meta: @@ -70,23 +64,46 @@ class TopologyMapSerializer(serializers.ModelSerializer): fields = ['id', 'name', 'slug', 'site', 'device_patterns', 'description'] -class WritableTopologyMapSerializer(serializers.ModelSerializer): +# +# Tags +# + +class TagSerializer(ValidatedModelSerializer): + tagged_items = serializers.IntegerField(read_only=True) class Meta: - model = TopologyMap - fields = ['id', 'name', 'slug', 'site', 'device_patterns', 'description'] + model = Tag + fields = ['id', 'name', 'slug', 'tagged_items'] # # Image attachments # -class ImageAttachmentSerializer(serializers.ModelSerializer): - parent = serializers.SerializerMethodField() +class ImageAttachmentSerializer(ValidatedModelSerializer): + content_type = ContentTypeFieldSerializer() + parent = serializers.SerializerMethodField(read_only=True) class Meta: model = ImageAttachment - fields = ['id', 'parent', 'name', 'image', 'image_height', 'image_width', 'created'] + fields = [ + 'id', 'content_type', 'object_id', 'parent', 'name', 'image', 'image_height', 'image_width', 'created', + ] + + def validate(self, data): + + # Validate that the parent object exists + try: + data['content_type'].get_object_for_this_type(id=data['object_id']) + except ObjectDoesNotExist: + raise serializers.ValidationError( + "Invalid parent object: {} ID {}".format(data['content_type'], data['object_id']) + ) + + # Enforce model validation + super(ImageAttachmentSerializer, self).validate(data) + + return data def get_parent(self, obj): @@ -103,29 +120,6 @@ class ImageAttachmentSerializer(serializers.ModelSerializer): return serializer(obj.parent, context={'request': self.context['request']}).data -class WritableImageAttachmentSerializer(ValidatedModelSerializer): - content_type = ContentTypeFieldSerializer() - - class Meta: - model = ImageAttachment - fields = ['id', 'content_type', 'object_id', 'name', 'image'] - - def validate(self, data): - - # Validate that the parent object exists - try: - data['content_type'].get_object_for_this_type(id=data['object_id']) - except ObjectDoesNotExist: - raise serializers.ValidationError( - "Invalid parent object: {} ID {}".format(data['content_type'], data['object_id']) - ) - - # Enforce model validation - super(WritableImageAttachmentSerializer, self).validate(data) - - return data - - # # Reports # diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index cc278644d..4e1f9d2ef 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -28,6 +28,9 @@ router.register(r'export-templates', views.ExportTemplateViewSet) # Topology maps router.register(r'topology-maps', views.TopologyMapViewSet) +# Tags +router.register(r'tags', views.TagViewSet) + # Image attachments router.register(r'image-attachments', views.ImageAttachmentViewSet) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 252c2d12c..37d07060b 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -1,12 +1,14 @@ from __future__ import unicode_literals from django.contrib.contenttypes.models import ContentType +from django.db.models import Count from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404 from rest_framework.decorators import detail_route from rest_framework.exceptions import PermissionDenied from rest_framework.response import Response from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet +from taggit.models import Tag from extras import filters from extras.models import CustomField, ExportTemplate, Graph, ImageAttachment, ReportResult, TopologyMap, UserAction @@ -67,7 +69,6 @@ class CustomFieldModelViewSet(ModelViewSet): class GraphViewSet(ModelViewSet): queryset = Graph.objects.all() serializer_class = serializers.GraphSerializer - write_serializer_class = serializers.WritableGraphSerializer filter_class = filters.GraphFilter @@ -88,7 +89,6 @@ class ExportTemplateViewSet(ModelViewSet): class TopologyMapViewSet(ModelViewSet): queryset = TopologyMap.objects.select_related('site') serializer_class = serializers.TopologyMapSerializer - write_serializer_class = serializers.WritableTopologyMapSerializer filter_class = filters.TopologyMapFilter @detail_route() @@ -111,6 +111,16 @@ class TopologyMapViewSet(ModelViewSet): return response +# +# Tags +# + +class TagViewSet(ModelViewSet): + queryset = Tag.objects.annotate(tagged_items=Count('taggit_taggeditem_items')) + serializer_class = serializers.TagSerializer + filter_class = filters.TagFilter + + # # Image attachments # @@ -118,7 +128,6 @@ class TopologyMapViewSet(ModelViewSet): class ImageAttachmentViewSet(ModelViewSet): queryset = ImageAttachment.objects.all() serializer_class = serializers.ImageAttachmentSerializer - write_serializer_class = serializers.WritableImageAttachmentSerializer # diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index 4a991471b..bb1202e28 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -3,6 +3,8 @@ from __future__ import unicode_literals import django_filters from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType +from django.db.models import Q +from taggit.models import Tag from dcim.models import Site from .constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_TYPE_BOOLEAN, CF_TYPE_SELECT @@ -85,6 +87,25 @@ class ExportTemplateFilter(django_filters.FilterSet): fields = ['content_type', 'name'] +class TagFilter(django_filters.FilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + + class Meta: + model = Tag + fields = ['name', 'slug'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(slug__icontains=value) + ) + + class TopologyMapFilter(django_filters.FilterSet): site_id = django_filters.ModelMultipleChoiceFilter( name='site', diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index a923ae596..9088d1b3d 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -4,12 +4,17 @@ from collections import OrderedDict from django import forms from django.contrib.contenttypes.models import ContentType +from taggit.models import Tag -from utilities.forms import BootstrapMixin, BulkEditForm, LaxURLField +from utilities.forms import BootstrapMixin, BulkEditForm, LaxURLField, SlugField from .constants import CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL from .models import CustomField, CustomFieldValue, ImageAttachment +# +# Custom fields +# + def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=False): """ Retrieve all CustomFields applicable to the given ContentType @@ -162,6 +167,23 @@ class CustomFieldFilterForm(forms.Form): self.fields[name] = field +# +# Tags +# +# + +class TagForm(BootstrapMixin, forms.ModelForm): + slug = SlugField() + + class Meta: + model = Tag + fields = ['name', 'slug'] + + +# +# Image attachments +# + class ImageAttachmentForm(BootstrapMixin, forms.ModelForm): class Meta: diff --git a/netbox/extras/migrations/0011_django2.py b/netbox/extras/migrations/0011_django2.py new file mode 100644 index 000000000..f8e0954d6 --- /dev/null +++ b/netbox/extras/migrations/0011_django2.py @@ -0,0 +1,29 @@ +# 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 = [ + ('extras', '0010_customfield_filter_logic'), + ] + + operations = [ + migrations.AlterField( + model_name='customfield', + name='obj_type', + field=models.ManyToManyField(help_text='The object(s) to which this field applies.', limit_choices_to={'model__in': ('provider', 'circuit', 'site', 'rack', 'devicetype', 'device', 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'tenant', 'cluster', 'virtualmachine')}, related_name='custom_fields', to='contenttypes.ContentType', verbose_name='Object(s)'), + ), + migrations.AlterField( + model_name='customfieldchoice', + name='field', + field=models.ForeignKey(limit_choices_to={'type': 600}, on_delete=django.db.models.deletion.CASCADE, related_name='choices', to='extras.CustomField'), + ), + migrations.AlterField( + model_name='exporttemplate', + name='content_type', + field=models.ForeignKey(limit_choices_to={'model__in': ['provider', 'circuit', 'site', 'region', 'rack', 'rackgroup', 'manufacturer', 'devicetype', 'device', 'consoleport', 'powerport', 'interfaceconnection', 'aggregate', 'prefix', 'ipaddress', 'vlan', 'tenant', 'cluster', 'virtualmachine']}, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'), + ), + ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index fa5c5ef9f..dcd26582c 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -122,7 +122,8 @@ class CustomField(models.Model): label = models.CharField( max_length=50, blank=True, - help_text='Name of the field as displayed to users (if not provided, the field\'s name will be used)' + help_text='Name of the field as displayed to users (if not provided, ' + 'the field\'s name will be used)' ) description = models.CharField( max_length=100, @@ -130,17 +131,20 @@ class CustomField(models.Model): ) required = models.BooleanField( default=False, - help_text='If true, this field is required when creating new objects or editing an existing object.' + help_text='If true, this field is required when creating new objects ' + 'or editing an existing object.' ) filter_logic = models.PositiveSmallIntegerField( choices=CF_FILTER_CHOICES, default=CF_FILTER_LOOSE, - help_text="Loose matches any instance of a given string; exact matches the entire field." + help_text='Loose matches any instance of a given string; exact ' + 'matches the entire field.' ) default = models.CharField( max_length=100, blank=True, - help_text='Default value for the field. Use "true" or "false" for booleans. N/A for selection fields.' + help_text='Default value for the field. Use "true" or "false" for ' + 'booleans. N/A for selection fields.' ) weight = models.PositiveSmallIntegerField( default=100, @@ -192,11 +196,24 @@ class CustomField(models.Model): @python_2_unicode_compatible class CustomFieldValue(models.Model): - field = models.ForeignKey('CustomField', related_name='values', on_delete=models.CASCADE) - obj_type = models.ForeignKey(ContentType, related_name='+', on_delete=models.PROTECT) + field = models.ForeignKey( + to='extras.CustomField', + on_delete=models.CASCADE, + related_name='values' + ) + obj_type = models.ForeignKey( + to=ContentType, + on_delete=models.PROTECT, + related_name='+' + ) obj_id = models.PositiveIntegerField() - obj = GenericForeignKey('obj_type', 'obj_id') - serialized_value = models.CharField(max_length=255) + obj = GenericForeignKey( + ct_field='obj_type', + fk_field='obj_id' + ) + serialized_value = models.CharField( + max_length=255 + ) class Meta: ordering = ['obj_type', 'obj_id'] @@ -223,10 +240,19 @@ class CustomFieldValue(models.Model): @python_2_unicode_compatible class CustomFieldChoice(models.Model): - field = models.ForeignKey('CustomField', related_name='choices', limit_choices_to={'type': CF_TYPE_SELECT}, - on_delete=models.CASCADE) - value = models.CharField(max_length=100) - weight = models.PositiveSmallIntegerField(default=100, help_text="Higher weights appear lower in the list") + field = models.ForeignKey( + to='extras.CustomField', + on_delete=models.CASCADE, + related_name='choices', + limit_choices_to={'type': CF_TYPE_SELECT} + ) + value = models.CharField( + max_length=100 + ) + weight = models.PositiveSmallIntegerField( + default=100, + help_text='Higher weights appear lower in the list' + ) class Meta: ordering = ['field', 'weight', 'value'] @@ -252,11 +278,24 @@ class CustomFieldChoice(models.Model): @python_2_unicode_compatible class Graph(models.Model): - type = models.PositiveSmallIntegerField(choices=GRAPH_TYPE_CHOICES) - weight = models.PositiveSmallIntegerField(default=1000) - name = models.CharField(max_length=100, verbose_name='Name') - source = models.CharField(max_length=500, verbose_name='Source URL') - link = models.URLField(verbose_name='Link URL', blank=True) + type = models.PositiveSmallIntegerField( + choices=GRAPH_TYPE_CHOICES + ) + weight = models.PositiveSmallIntegerField( + default=1000 + ) + name = models.CharField( + max_length=100, + verbose_name='Name' + ) + source = models.CharField( + max_length=500, + verbose_name='Source URL' + ) + link = models.URLField( + blank=True, + verbose_name='Link URL' + ) class Meta: ordering = ['type', 'weight', 'name'] @@ -282,13 +321,26 @@ class Graph(models.Model): @python_2_unicode_compatible class ExportTemplate(models.Model): content_type = models.ForeignKey( - ContentType, limit_choices_to={'model__in': EXPORTTEMPLATE_MODELS}, on_delete=models.CASCADE + to=ContentType, + on_delete=models.CASCADE, + limit_choices_to={'model__in': EXPORTTEMPLATE_MODELS} + ) + name = models.CharField( + max_length=100 + ) + description = models.CharField( + max_length=200, + blank=True ) - name = models.CharField(max_length=100) - description = models.CharField(max_length=200, blank=True) template_code = models.TextField() - mime_type = models.CharField(max_length=15, blank=True) - file_extension = models.CharField(max_length=15, blank=True) + mime_type = models.CharField( + max_length=15, + blank=True + ) + file_extension = models.CharField( + max_length=15, + blank=True + ) class Meta: ordering = ['content_type', 'name'] @@ -327,25 +379,35 @@ class ExportTemplate(models.Model): @python_2_unicode_compatible class TopologyMap(models.Model): - 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 + ) type = models.PositiveSmallIntegerField( choices=TOPOLOGYMAP_TYPE_CHOICES, default=TOPOLOGYMAP_TYPE_NETWORK ) site = models.ForeignKey( to='dcim.Site', + on_delete=models.CASCADE, related_name='topology_maps', blank=True, - null=True, - on_delete=models.CASCADE + null=True ) device_patterns = models.TextField( - help_text="Identify devices to include in the diagram using regular expressions, one per line. Each line will " - "result in a new tier of the drawing. Separate multiple regexes within a line using semicolons. " - "Devices will be rendered in the order they are defined." + help_text='Identify devices to include in the diagram using regular ' + 'expressions, one per line. Each line will result in a new ' + 'tier of the drawing. Separate multiple regexes within a ' + 'line using semicolons. Devices will be rendered in the ' + 'order they are defined.' + ) + description = models.CharField( + max_length=100, + blank=True ) - description = models.CharField(max_length=100, blank=True) class Meta: ordering = ['name'] @@ -481,14 +543,29 @@ class ImageAttachment(models.Model): """ An uploaded image which is associated with an object. """ - content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + content_type = models.ForeignKey( + to=ContentType, + on_delete=models.CASCADE + ) object_id = models.PositiveIntegerField() - parent = GenericForeignKey('content_type', 'object_id') - image = models.ImageField(upload_to=image_upload, height_field='image_height', width_field='image_width') + parent = GenericForeignKey( + ct_field='content_type', + fk_field='object_id' + ) + image = models.ImageField( + upload_to=image_upload, + height_field='image_height', + width_field='image_width' + ) image_height = models.PositiveSmallIntegerField() image_width = models.PositiveSmallIntegerField() - name = models.CharField(max_length=50, blank=True) - created = models.DateTimeField(auto_now_add=True) + name = models.CharField( + max_length=50, + blank=True + ) + created = models.DateTimeField( + auto_now_add=True + ) class Meta: ordering = ['name'] @@ -531,9 +608,20 @@ class ReportResult(models.Model): """ This model stores the results from running a user-defined report. """ - report = models.CharField(max_length=255, unique=True) - created = models.DateTimeField(auto_now_add=True) - user = models.ForeignKey(User, on_delete=models.SET_NULL, related_name='+', blank=True, null=True) + report = models.CharField( + max_length=255, + unique=True + ) + created = models.DateTimeField( + auto_now_add=True + ) + user = models.ForeignKey( + to=User, + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True + ) failed = models.BooleanField() data = JSONField() @@ -593,12 +681,29 @@ class UserAction(models.Model): """ A record of an action (add, edit, or delete) performed on an object by a User. """ - time = models.DateTimeField(auto_now_add=True, editable=False) - user = models.ForeignKey(User, related_name='actions', on_delete=models.CASCADE) - content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) - object_id = models.PositiveIntegerField(blank=True, null=True) - action = models.PositiveSmallIntegerField(choices=ACTION_CHOICES) - message = models.TextField(blank=True) + time = models.DateTimeField( + auto_now_add=True, + editable=False + ) + user = models.ForeignKey( + to=User, + on_delete=models.CASCADE, + related_name='actions' + ) + content_type = models.ForeignKey( + to=ContentType, + on_delete=models.CASCADE + ) + object_id = models.PositiveIntegerField( + blank=True, + null=True + ) + action = models.PositiveSmallIntegerField( + choices=ACTION_CHOICES + ) + message = models.TextField( + blank=True + ) objects = UserActionManager() diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py new file mode 100644 index 000000000..921b9f273 --- /dev/null +++ b/netbox/extras/tables.py @@ -0,0 +1,28 @@ +from __future__ import unicode_literals + +import django_tables2 as tables +from taggit.models import Tag + +from utilities.tables import BaseTable, ToggleColumn + +TAG_ACTIONS = """ +{% if perms.taggit.change_tag %} + +{% endif %} +{% if perms.taggit.delete_tag %} + +{% endif %} +""" + + +class TagTable(BaseTable): + pk = ToggleColumn() + actions = tables.TemplateColumn( + template_code=TAG_ACTIONS, + attrs={'td': {'class': 'text-right'}}, + verbose_name='' + ) + + class Meta(BaseTable.Meta): + model = Tag + fields = ('pk', 'name', 'items') diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 036d8143c..f0cdb5dfe 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -5,6 +5,7 @@ from django.contrib.contenttypes.models import ContentType from django.urls import reverse from rest_framework import status from rest_framework.test import APITestCase +from taggit.models import Tag from dcim.models import Device from extras.constants import GRAPH_TYPE_SITE @@ -226,3 +227,96 @@ class ExportTemplateTest(HttpStatusMixin, APITestCase): self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(ExportTemplate.objects.count(), 2) + + +class TagTest(HttpStatusMixin, APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + self.tag1 = Tag.objects.create(name='Test Tag 1', slug='test-tag-1') + self.tag2 = Tag.objects.create(name='Test Tag 2', slug='test-tag-2') + self.tag3 = Tag.objects.create(name='Test Tag 3', slug='test-tag-3') + + def test_get_tag(self): + + url = reverse('extras-api:tag-detail', kwargs={'pk': self.tag1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.tag1.name) + + def test_list_tags(self): + + url = reverse('extras-api:tag-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_tag(self): + + data = { + 'name': 'Test Tag 4', + 'slug': 'test-tag-4', + } + + url = reverse('extras-api:tag-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(Tag.objects.count(), 4) + tag4 = Tag.objects.get(pk=response.data['id']) + self.assertEqual(tag4.name, data['name']) + self.assertEqual(tag4.slug, data['slug']) + + def test_create_tag_bulk(self): + + data = [ + { + 'name': 'Test Tag 4', + 'slug': 'test-tag-4', + }, + { + 'name': 'Test Tag 5', + 'slug': 'test-tag-5', + }, + { + 'name': 'Test Tag 6', + 'slug': 'test-tag-6', + }, + ] + + url = reverse('extras-api:tag-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(Tag.objects.count(), 6) + self.assertEqual(response.data[0]['name'], data[0]['name']) + self.assertEqual(response.data[1]['name'], data[1]['name']) + self.assertEqual(response.data[2]['name'], data[2]['name']) + + def test_update_tag(self): + + data = { + 'name': 'Test Tag X', + 'slug': 'test-tag-x', + } + + url = reverse('extras-api:tag-detail', kwargs={'pk': self.tag1.pk}) + response = self.client.put(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(Tag.objects.count(), 3) + tag1 = Tag.objects.get(pk=response.data['id']) + self.assertEqual(tag1.name, data['name']) + self.assertEqual(tag1.slug, data['slug']) + + def test_delete_tag(self): + + url = reverse('extras-api:tag-detail', kwargs={'pk': self.tag1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(Tag.objects.count(), 2) diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 84aaa76b2..b10db514e 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -45,7 +45,7 @@ class CustomFieldTest(TestCase): # Create a custom field cf = CustomField(type=data['field_type'], name='my_field', required=False) cf.save() - cf.obj_type = [obj_type] + cf.obj_type.set([obj_type]) cf.save() # Assign a value to the first Site @@ -73,7 +73,7 @@ class CustomFieldTest(TestCase): # Create a custom field cf = CustomField(type=CF_TYPE_SELECT, name='my_field', required=False) cf.save() - cf.obj_type = [obj_type] + cf.obj_type.set([obj_type]) cf.save() # Create some choices for the field @@ -115,37 +115,37 @@ class CustomFieldAPITest(HttpStatusMixin, APITestCase): # Text custom field self.cf_text = CustomField(type=CF_TYPE_TEXT, name='magic_word') self.cf_text.save() - self.cf_text.obj_type = [content_type] + self.cf_text.obj_type.set([content_type]) self.cf_text.save() # Integer custom field self.cf_integer = CustomField(type=CF_TYPE_INTEGER, name='magic_number') self.cf_integer.save() - self.cf_integer.obj_type = [content_type] + self.cf_integer.obj_type.set([content_type]) self.cf_integer.save() # Boolean custom field self.cf_boolean = CustomField(type=CF_TYPE_BOOLEAN, name='is_magic') self.cf_boolean.save() - self.cf_boolean.obj_type = [content_type] + self.cf_boolean.obj_type.set([content_type]) self.cf_boolean.save() # Date custom field self.cf_date = CustomField(type=CF_TYPE_DATE, name='magic_date') self.cf_date.save() - self.cf_date.obj_type = [content_type] + self.cf_date.obj_type.set([content_type]) self.cf_date.save() # URL custom field self.cf_url = CustomField(type=CF_TYPE_URL, name='magic_url') self.cf_url.save() - self.cf_url.obj_type = [content_type] + self.cf_url.obj_type.set([content_type]) self.cf_url.save() # Select custom field self.cf_select = CustomField(type=CF_TYPE_SELECT, name='magic_choice') self.cf_select.save() - self.cf_select.obj_type = [content_type] + self.cf_select.obj_type.set([content_type]) self.cf_select.save() self.cf_select_choice1 = CustomFieldChoice(field=self.cf_select, value='Foo') self.cf_select_choice1.save() diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index 13e50a229..d3c200334 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -7,6 +7,12 @@ from extras import views app_name = 'extras' urlpatterns = [ + # Tags + url(r'^tags/$', views.TagListView.as_view(), name='tag_list'), + url(r'^tags/(?P[\w-]+)/edit/$', views.TagEditView.as_view(), name='tag_edit'), + url(r'^tags/(?P[\w-]+)/delete/$', views.TagDeleteView.as_view(), name='tag_delete'), + url(r'^tags/delete/$', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'), + # Image attachments url(r'^image-attachments/(?P\d+)/edit/$', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'), url(r'^image-attachments/(?P\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 @@ 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