Merge remote-tracking branch 'upstream/develop-2.4' into feature/webhooks-backend

This commit is contained in:
John Anderson 2018-05-22 16:13:52 -07:00
commit 201b27e52c
114 changed files with 2733 additions and 1108 deletions

View File

@ -1,13 +1,14 @@
from __future__ import unicode_literals from __future__ import unicode_literals
from rest_framework import serializers from rest_framework import serializers
from taggit.models import Tag
from circuits.constants import CIRCUIT_STATUS_CHOICES from circuits.constants import CIRCUIT_STATUS_CHOICES
from circuits.models import Provider, Circuit, CircuitTermination, CircuitType from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
from dcim.api.serializers import NestedSiteSerializer, InterfaceSerializer from dcim.api.serializers import NestedSiteSerializer, InterfaceSerializer
from extras.api.customfields import CustomFieldModelSerializer from extras.api.customfields import CustomFieldModelSerializer
from tenancy.api.serializers import NestedTenantSerializer from tenancy.api.serializers import NestedTenantSerializer
from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer from utilities.api import ChoiceFieldSerializer, TagField, ValidatedModelSerializer, WritableNestedSerializer
# #
@ -15,16 +16,17 @@ from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
# #
class ProviderSerializer(CustomFieldModelSerializer): class ProviderSerializer(CustomFieldModelSerializer):
tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta: class Meta:
model = Provider model = Provider
fields = [ fields = [
'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'tags',
'custom_fields', 'created', 'last_updated', 'custom_fields', 'created', 'last_updated',
] ]
class NestedProviderSerializer(serializers.ModelSerializer): class NestedProviderSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail') url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail')
class Meta: class Meta:
@ -32,16 +34,6 @@ class NestedProviderSerializer(serializers.ModelSerializer):
fields = ['id', 'url', 'name', 'slug'] 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 # Circuit types
# #
@ -53,7 +45,7 @@ class CircuitTypeSerializer(ValidatedModelSerializer):
fields = ['id', 'name', 'slug'] fields = ['id', 'name', 'slug']
class NestedCircuitTypeSerializer(serializers.ModelSerializer): class NestedCircuitTypeSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail') url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail')
class Meta: class Meta:
@ -67,19 +59,20 @@ class NestedCircuitTypeSerializer(serializers.ModelSerializer):
class CircuitSerializer(CustomFieldModelSerializer): class CircuitSerializer(CustomFieldModelSerializer):
provider = NestedProviderSerializer() provider = NestedProviderSerializer()
status = ChoiceFieldSerializer(choices=CIRCUIT_STATUS_CHOICES) status = ChoiceFieldSerializer(choices=CIRCUIT_STATUS_CHOICES, required=False)
type = NestedCircuitTypeSerializer() type = NestedCircuitTypeSerializer()
tenant = NestedTenantSerializer() tenant = NestedTenantSerializer(required=False, allow_null=True)
tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta: class Meta:
model = Circuit model = Circuit
fields = [ fields = [
'id', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description',
'comments', 'custom_fields', 'created', 'last_updated', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
] ]
class NestedCircuitSerializer(serializers.ModelSerializer): class NestedCircuitSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail') url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail')
class Meta: class Meta:
@ -87,33 +80,14 @@ class NestedCircuitSerializer(serializers.ModelSerializer):
fields = ['id', 'url', 'cid'] 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 # Circuit Terminations
# #
class CircuitTerminationSerializer(serializers.ModelSerializer): class CircuitTerminationSerializer(ValidatedModelSerializer):
circuit = NestedCircuitSerializer() circuit = NestedCircuitSerializer()
site = NestedSiteSerializer() site = NestedSiteSerializer()
interface = InterfaceSerializer() interface = InterfaceSerializer(required=False, allow_null=True)
class Meta:
model = CircuitTermination
fields = [
'id', 'circuit', 'term_side', 'site', 'interface', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info',
]
class WritableCircuitTerminationSerializer(ValidatedModelSerializer):
class Meta: class Meta:
model = CircuitTermination model = CircuitTermination

View File

@ -30,7 +30,6 @@ class CircuitsFieldChoicesViewSet(FieldChoicesViewSet):
class ProviderViewSet(CustomFieldModelViewSet): class ProviderViewSet(CustomFieldModelViewSet):
queryset = Provider.objects.all() queryset = Provider.objects.all()
serializer_class = serializers.ProviderSerializer serializer_class = serializers.ProviderSerializer
write_serializer_class = serializers.WritableProviderSerializer
filter_class = filters.ProviderFilter filter_class = filters.ProviderFilter
@detail_route() @detail_route()
@ -61,7 +60,6 @@ class CircuitTypeViewSet(ModelViewSet):
class CircuitViewSet(CustomFieldModelViewSet): class CircuitViewSet(CustomFieldModelViewSet):
queryset = Circuit.objects.select_related('type', 'tenant', 'provider') queryset = Circuit.objects.select_related('type', 'tenant', 'provider')
serializer_class = serializers.CircuitSerializer serializer_class = serializers.CircuitSerializer
write_serializer_class = serializers.WritableCircuitSerializer
filter_class = filters.CircuitFilter filter_class = filters.CircuitFilter
@ -72,5 +70,4 @@ class CircuitViewSet(CustomFieldModelViewSet):
class CircuitTerminationViewSet(ModelViewSet): class CircuitTerminationViewSet(ModelViewSet):
queryset = CircuitTermination.objects.select_related('circuit', 'site', 'interface__device') queryset = CircuitTermination.objects.select_related('circuit', 'site', 'interface__device')
serializer_class = serializers.CircuitTerminationSerializer serializer_class = serializers.CircuitTerminationSerializer
write_serializer_class = serializers.WritableCircuitTerminationSerializer
filter_class = filters.CircuitTerminationFilter filter_class = filters.CircuitTerminationFilter

View File

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

View File

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

View File

@ -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'),
),
]

View File

@ -4,11 +4,11 @@ from django.contrib.contenttypes.fields import GenericRelation
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.encoding import python_2_unicode_compatible from django.utils.encoding import python_2_unicode_compatible
from taggit.managers import TaggableManager
from dcim.constants import STATUS_CLASSES from dcim.constants import STATUS_CLASSES
from dcim.fields import ASNField from dcim.fields import ASNField
from extras.models import CustomFieldModel, CustomFieldValue from extras.models import CustomFieldModel
from tenancy.models import Tenant
from utilities.models import CreatedUpdatedModel from utilities.models import CreatedUpdatedModel
from .constants import CIRCUIT_STATUS_ACTIVE, CIRCUIT_STATUS_CHOICES, TERM_SIDE_CHOICES 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 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. stores information pertinent to the user's relationship with the Provider.
""" """
name = models.CharField(max_length=50, unique=True) name = models.CharField(
slug = models.SlugField(unique=True) max_length=50,
asn = ASNField(blank=True, null=True, verbose_name='ASN') unique=True
account = models.CharField(max_length=30, blank=True, verbose_name='Account number') )
portal_url = models.URLField(blank=True, verbose_name='Portal') slug = models.SlugField(
noc_contact = models.TextField(blank=True, verbose_name='NOC contact') unique=True
admin_contact = models.TextField(blank=True, verbose_name='Admin contact') )
comments = models.TextField(blank=True) asn = ASNField(
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') 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'] 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 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". "Long Haul," "Metro," or "Out-of-Band".
""" """
name = models.CharField(max_length=50, unique=True) name = models.CharField(
slug = models.SlugField(unique=True) max_length=50,
unique=True
)
slug = models.SlugField(
unique=True
)
csv_headers = ['name', 'slug'] 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 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. 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') cid = models.CharField(
provider = models.ForeignKey('Provider', related_name='circuits', on_delete=models.PROTECT) max_length=50,
type = models.ForeignKey('CircuitType', related_name='circuits', on_delete=models.PROTECT) verbose_name='Circuit ID'
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) provider = models.ForeignKey(
install_date = models.DateField(blank=True, null=True, verbose_name='Date installed') to='circuits.Provider',
commit_rate = models.PositiveIntegerField(blank=True, null=True, verbose_name='Commit rate (Kbps)') on_delete=models.PROTECT,
description = models.CharField(max_length=100, blank=True) related_name='circuits'
comments = models.TextField(blank=True) )
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') 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 = [ csv_headers = [
'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
@ -153,19 +226,47 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
@python_2_unicode_compatible @python_2_unicode_compatible
class CircuitTermination(models.Model): class CircuitTermination(models.Model):
circuit = models.ForeignKey('Circuit', related_name='terminations', on_delete=models.CASCADE) circuit = models.ForeignKey(
term_side = models.CharField(max_length=1, choices=TERM_SIDE_CHOICES, verbose_name='Termination') to='circuits.Circuit',
site = models.ForeignKey('dcim.Site', related_name='circuit_terminations', on_delete=models.PROTECT) on_delete=models.CASCADE,
interface = models.OneToOneField( related_name='terminations'
'dcim.Interface', related_name='circuit_termination', blank=True, null=True, on_delete=models.PROTECT )
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( 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' help_text='Upstream speed, if different from port speed'
) )
xconnect_id = models.CharField(max_length=50, blank=True, verbose_name='Cross-connect ID') xconnect_id = models.CharField(
pp_info = models.CharField(max_length=100, blank=True, verbose_name='Patch panel/port(s)') 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: class Meta:
ordering = ['circuit', 'term_side'] ordering = ['circuit', 'term_side']

View File

@ -5,7 +5,7 @@ from django.urls import reverse
from rest_framework import status from rest_framework import status
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from 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 circuits.models import Circuit, CircuitTermination, CircuitType, Provider
from dcim.models import Site from dcim.models import Site
from extras.constants import GRAPH_TYPE_PROVIDER from extras.constants import GRAPH_TYPE_PROVIDER
@ -231,6 +231,7 @@ class CircuitTest(HttpStatusMixin, APITestCase):
'cid': 'TEST0004', 'cid': 'TEST0004',
'provider': self.provider1.pk, 'provider': self.provider1.pk,
'type': self.circuittype1.pk, 'type': self.circuittype1.pk,
'status': CIRCUIT_STATUS_ACTIVE,
} }
url = reverse('circuits-api:circuit-list') url = reverse('circuits-api:circuit-list')
@ -250,16 +251,19 @@ class CircuitTest(HttpStatusMixin, APITestCase):
'cid': 'TEST0004', 'cid': 'TEST0004',
'provider': self.provider1.pk, 'provider': self.provider1.pk,
'type': self.circuittype1.pk, 'type': self.circuittype1.pk,
'status': CIRCUIT_STATUS_ACTIVE,
}, },
{ {
'cid': 'TEST0005', 'cid': 'TEST0005',
'provider': self.provider1.pk, 'provider': self.provider1.pk,
'type': self.circuittype1.pk, 'type': self.circuittype1.pk,
'status': CIRCUIT_STATUS_ACTIVE,
}, },
{ {
'cid': 'TEST0006', 'cid': 'TEST0006',
'provider': self.provider1.pk, 'provider': self.provider1.pk,
'type': self.circuittype1.pk, 'type': self.circuittype1.pk,
'status': CIRCUIT_STATUS_ACTIVE,
}, },
] ]

View File

@ -4,6 +4,7 @@ from collections import OrderedDict
from rest_framework import serializers from rest_framework import serializers
from rest_framework.validators import UniqueTogetherValidator from rest_framework.validators import UniqueTogetherValidator
from taggit.models import Tag
from circuits.models import Circuit, CircuitTermination from circuits.models import Circuit, CircuitTermination
from dcim.constants import ( from dcim.constants import (
@ -20,7 +21,10 @@ from extras.api.customfields import CustomFieldModelSerializer
from ipam.models import IPAddress, VLAN from ipam.models import IPAddress, VLAN
from tenancy.api.serializers import NestedTenantSerializer from tenancy.api.serializers import NestedTenantSerializer
from users.api.serializers import NestedUserSerializer 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 from virtualization.models import Cluster
@ -28,7 +32,7 @@ from virtualization.models import Cluster
# Regions # Regions
# #
class NestedRegionSerializer(serializers.ModelSerializer): class NestedRegionSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail')
class Meta: class Meta:
@ -37,14 +41,7 @@ class NestedRegionSerializer(serializers.ModelSerializer):
class RegionSerializer(serializers.ModelSerializer): class RegionSerializer(serializers.ModelSerializer):
parent = NestedRegionSerializer() parent = NestedRegionSerializer(required=False, allow_null=True)
class Meta:
model = Region
fields = ['id', 'name', 'slug', 'parent']
class WritableRegionSerializer(ValidatedModelSerializer):
class Meta: class Meta:
model = Region model = Region
@ -56,22 +53,23 @@ class WritableRegionSerializer(ValidatedModelSerializer):
# #
class SiteSerializer(CustomFieldModelSerializer): class SiteSerializer(CustomFieldModelSerializer):
status = ChoiceFieldSerializer(choices=SITE_STATUS_CHOICES) status = ChoiceFieldSerializer(choices=SITE_STATUS_CHOICES, required=False)
region = NestedRegionSerializer() region = NestedRegionSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer() tenant = NestedTenantSerializer(required=False, allow_null=True)
time_zone = TimeZoneField(required=False) time_zone = TimeZoneField(required=False)
tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta: class Meta:
model = Site model = Site
fields = [ fields = [
'id', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'id', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description',
'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments', 'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
'custom_fields', 'created', 'last_updated', 'count_prefixes', 'count_vlans', 'count_racks', 'count_devices', 'tags', 'custom_fields', 'created', 'last_updated', 'count_prefixes', 'count_vlans', 'count_racks',
'count_circuits', 'count_devices', 'count_circuits',
] ]
class NestedSiteSerializer(serializers.ModelSerializer): class NestedSiteSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail')
class Meta: class Meta:
@ -79,23 +77,11 @@ class NestedSiteSerializer(serializers.ModelSerializer):
fields = ['id', 'url', 'name', 'slug'] 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 # Rack groups
# #
class RackGroupSerializer(serializers.ModelSerializer): class RackGroupSerializer(ValidatedModelSerializer):
site = NestedSiteSerializer() site = NestedSiteSerializer()
class Meta: class Meta:
@ -103,7 +89,7 @@ class RackGroupSerializer(serializers.ModelSerializer):
fields = ['id', 'name', 'slug', 'site'] fields = ['id', 'name', 'slug', 'site']
class NestedRackGroupSerializer(serializers.ModelSerializer): class NestedRackGroupSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackgroup-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackgroup-detail')
class Meta: class Meta:
@ -111,13 +97,6 @@ class NestedRackGroupSerializer(serializers.ModelSerializer):
fields = ['id', 'url', 'name', 'slug'] fields = ['id', 'url', 'name', 'slug']
class WritableRackGroupSerializer(ValidatedModelSerializer):
class Meta:
model = RackGroup
fields = ['id', 'name', 'slug', 'site']
# #
# Rack roles # Rack roles
# #
@ -129,7 +108,7 @@ class RackRoleSerializer(ValidatedModelSerializer):
fields = ['id', 'name', 'slug', 'color'] fields = ['id', 'name', 'slug', 'color']
class NestedRackRoleSerializer(serializers.ModelSerializer): class NestedRackRoleSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail')
class Meta: class Meta:
@ -143,21 +122,40 @@ class NestedRackRoleSerializer(serializers.ModelSerializer):
class RackSerializer(CustomFieldModelSerializer): class RackSerializer(CustomFieldModelSerializer):
site = NestedSiteSerializer() site = NestedSiteSerializer()
group = NestedRackGroupSerializer() group = NestedRackGroupSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer() tenant = NestedTenantSerializer(required=False, allow_null=True)
role = NestedRackRoleSerializer() role = NestedRackRoleSerializer(required=False, allow_null=True)
type = ChoiceFieldSerializer(choices=RACK_TYPE_CHOICES) type = ChoiceFieldSerializer(choices=RACK_TYPE_CHOICES, required=False)
width = ChoiceFieldSerializer(choices=RACK_WIDTH_CHOICES) width = ChoiceFieldSerializer(choices=RACK_WIDTH_CHOICES, required=False)
tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta: class Meta:
model = Rack model = Rack
fields = [ fields = [
'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'serial', 'type', 'width', 'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'serial', 'type', 'width',
'u_height', 'desc_units', 'comments', 'custom_fields', 'created', 'last_updated', 'u_height', 'desc_units', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
]
# Omit the UniqueTogetherValidator that would be automatically added to validate (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') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail')
class Meta: class Meta:
@ -165,39 +163,11 @@ class NestedRackSerializer(serializers.ModelSerializer):
fields = ['id', 'url', 'name', 'display_name'] 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 # Rack units
# #
class NestedDeviceSerializer(serializers.ModelSerializer): class NestedDeviceSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
class Meta: class Meta:
@ -219,23 +189,16 @@ class RackUnitSerializer(serializers.Serializer):
# Rack reservations # Rack reservations
# #
class RackReservationSerializer(serializers.ModelSerializer): class RackReservationSerializer(ValidatedModelSerializer):
rack = NestedRackSerializer() rack = NestedRackSerializer()
user = NestedUserSerializer() user = NestedUserSerializer()
tenant = NestedTenantSerializer() tenant = NestedTenantSerializer(required=False, allow_null=True)
class Meta: class Meta:
model = RackReservation model = RackReservation
fields = ['id', 'rack', 'units', 'created', 'user', 'tenant', 'description'] fields = ['id', 'rack', 'units', 'created', 'user', 'tenant', 'description']
class WritableRackReservationSerializer(ValidatedModelSerializer):
class Meta:
model = RackReservation
fields = ['id', 'rack', 'units', 'user', 'description']
# #
# Manufacturers # Manufacturers
# #
@ -247,7 +210,7 @@ class ManufacturerSerializer(ValidatedModelSerializer):
fields = ['id', 'name', 'slug'] fields = ['id', 'name', 'slug']
class NestedManufacturerSerializer(serializers.ModelSerializer): class NestedManufacturerSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail')
class Meta: class Meta:
@ -261,43 +224,34 @@ class NestedManufacturerSerializer(serializers.ModelSerializer):
class DeviceTypeSerializer(CustomFieldModelSerializer): class DeviceTypeSerializer(CustomFieldModelSerializer):
manufacturer = NestedManufacturerSerializer() manufacturer = NestedManufacturerSerializer()
interface_ordering = ChoiceFieldSerializer(choices=IFACE_ORDERING_CHOICES) interface_ordering = ChoiceFieldSerializer(choices=IFACE_ORDERING_CHOICES, required=False)
subdevice_role = ChoiceFieldSerializer(choices=SUBDEVICE_ROLE_CHOICES) subdevice_role = ChoiceFieldSerializer(choices=SUBDEVICE_ROLE_CHOICES, required=False)
instance_count = serializers.IntegerField(source='instances.count', read_only=True) instance_count = serializers.IntegerField(source='instances.count', read_only=True)
tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta: class Meta:
model = DeviceType model = DeviceType
fields = [ fields = [
'id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'interface_ordering', 'id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'interface_ordering',
'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments', 'custom_fields', 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments', 'tags', 'custom_fields',
'instance_count', 'instance_count',
] ]
class NestedDeviceTypeSerializer(serializers.ModelSerializer): class NestedDeviceTypeSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail')
manufacturer = NestedManufacturerSerializer() manufacturer = NestedManufacturerSerializer(read_only=True)
class Meta: class Meta:
model = DeviceType model = DeviceType
fields = ['id', 'url', 'manufacturer', 'model', 'slug'] 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 # Console port templates
# #
class ConsolePortTemplateSerializer(serializers.ModelSerializer): class ConsolePortTemplateSerializer(ValidatedModelSerializer):
device_type = NestedDeviceTypeSerializer() device_type = NestedDeviceTypeSerializer()
class Meta: class Meta:
@ -305,18 +259,11 @@ class ConsolePortTemplateSerializer(serializers.ModelSerializer):
fields = ['id', 'device_type', 'name'] fields = ['id', 'device_type', 'name']
class WritableConsolePortTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = ConsolePortTemplate
fields = ['id', 'device_type', 'name']
# #
# Console server port templates # Console server port templates
# #
class ConsoleServerPortTemplateSerializer(serializers.ModelSerializer): class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
device_type = NestedDeviceTypeSerializer() device_type = NestedDeviceTypeSerializer()
class Meta: class Meta:
@ -324,18 +271,11 @@ class ConsoleServerPortTemplateSerializer(serializers.ModelSerializer):
fields = ['id', 'device_type', 'name'] fields = ['id', 'device_type', 'name']
class WritableConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = ConsoleServerPortTemplate
fields = ['id', 'device_type', 'name']
# #
# Power port templates # Power port templates
# #
class PowerPortTemplateSerializer(serializers.ModelSerializer): class PowerPortTemplateSerializer(ValidatedModelSerializer):
device_type = NestedDeviceTypeSerializer() device_type = NestedDeviceTypeSerializer()
class Meta: class Meta:
@ -343,18 +283,11 @@ class PowerPortTemplateSerializer(serializers.ModelSerializer):
fields = ['id', 'device_type', 'name'] fields = ['id', 'device_type', 'name']
class WritablePowerPortTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = PowerPortTemplate
fields = ['id', 'device_type', 'name']
# #
# Power outlet templates # Power outlet templates
# #
class PowerOutletTemplateSerializer(serializers.ModelSerializer): class PowerOutletTemplateSerializer(ValidatedModelSerializer):
device_type = NestedDeviceTypeSerializer() device_type = NestedDeviceTypeSerializer()
class Meta: class Meta:
@ -362,27 +295,13 @@ class PowerOutletTemplateSerializer(serializers.ModelSerializer):
fields = ['id', 'device_type', 'name'] fields = ['id', 'device_type', 'name']
class WritablePowerOutletTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = PowerOutletTemplate
fields = ['id', 'device_type', 'name']
# #
# Interface templates # Interface templates
# #
class InterfaceTemplateSerializer(serializers.ModelSerializer): class InterfaceTemplateSerializer(ValidatedModelSerializer):
device_type = NestedDeviceTypeSerializer() device_type = NestedDeviceTypeSerializer()
form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES) form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES, required=False)
class Meta:
model = InterfaceTemplate
fields = ['id', 'device_type', 'name', 'form_factor', 'mgmt_only']
class WritableInterfaceTemplateSerializer(ValidatedModelSerializer):
class Meta: class Meta:
model = InterfaceTemplate model = InterfaceTemplate
@ -393,7 +312,7 @@ class WritableInterfaceTemplateSerializer(ValidatedModelSerializer):
# Device bay templates # Device bay templates
# #
class DeviceBayTemplateSerializer(serializers.ModelSerializer): class DeviceBayTemplateSerializer(ValidatedModelSerializer):
device_type = NestedDeviceTypeSerializer() device_type = NestedDeviceTypeSerializer()
class Meta: class Meta:
@ -401,13 +320,6 @@ class DeviceBayTemplateSerializer(serializers.ModelSerializer):
fields = ['id', 'device_type', 'name'] fields = ['id', 'device_type', 'name']
class WritableDeviceBayTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = DeviceBayTemplate
fields = ['id', 'device_type', 'name']
# #
# Device roles # Device roles
# #
@ -419,7 +331,7 @@ class DeviceRoleSerializer(ValidatedModelSerializer):
fields = ['id', 'name', 'slug', 'color', 'vm_role'] fields = ['id', 'name', 'slug', 'color', 'vm_role']
class NestedDeviceRoleSerializer(serializers.ModelSerializer): class NestedDeviceRoleSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail')
class Meta: class Meta:
@ -431,15 +343,15 @@ class NestedDeviceRoleSerializer(serializers.ModelSerializer):
# Platforms # Platforms
# #
class PlatformSerializer(serializers.ModelSerializer): class PlatformSerializer(ValidatedModelSerializer):
manufacturer = NestedManufacturerSerializer() manufacturer = NestedManufacturerSerializer(required=False, allow_null=True)
class Meta: class Meta:
model = Platform model = Platform
fields = ['id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'rpc_client'] 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') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail')
class Meta: class Meta:
@ -447,13 +359,6 @@ class NestedPlatformSerializer(serializers.ModelSerializer):
fields = ['id', 'url', 'name', 'slug'] fields = ['id', 'url', 'name', 'slug']
class WritablePlatformSerializer(ValidatedModelSerializer):
class Meta:
model = Platform
fields = ['id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'rpc_client']
# #
# Devices # Devices
# #
@ -489,48 +394,28 @@ class DeviceVirtualChassisSerializer(serializers.ModelSerializer):
class DeviceSerializer(CustomFieldModelSerializer): class DeviceSerializer(CustomFieldModelSerializer):
device_type = NestedDeviceTypeSerializer() device_type = NestedDeviceTypeSerializer()
device_role = NestedDeviceRoleSerializer() device_role = NestedDeviceRoleSerializer()
tenant = NestedTenantSerializer() tenant = NestedTenantSerializer(required=False, allow_null=True)
platform = NestedPlatformSerializer() platform = NestedPlatformSerializer(required=False, allow_null=True)
site = NestedSiteSerializer() site = NestedSiteSerializer()
rack = NestedRackSerializer() rack = NestedRackSerializer(required=False, allow_null=True)
face = ChoiceFieldSerializer(choices=RACK_FACE_CHOICES) face = ChoiceFieldSerializer(choices=RACK_FACE_CHOICES, required=False)
status = ChoiceFieldSerializer(choices=DEVICE_STATUS_CHOICES) status = ChoiceFieldSerializer(choices=DEVICE_STATUS_CHOICES, required=False)
primary_ip = DeviceIPAddressSerializer() primary_ip = DeviceIPAddressSerializer(read_only=True)
primary_ip4 = DeviceIPAddressSerializer() primary_ip4 = DeviceIPAddressSerializer(required=False, allow_null=True)
primary_ip6 = DeviceIPAddressSerializer() primary_ip6 = DeviceIPAddressSerializer(required=False, allow_null=True)
parent_device = serializers.SerializerMethodField() parent_device = serializers.SerializerMethodField()
cluster = NestedClusterSerializer() cluster = NestedClusterSerializer(required=False, allow_null=True)
virtual_chassis = DeviceVirtualChassisSerializer() virtual_chassis = DeviceVirtualChassisSerializer(required=False, allow_null=True)
tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta: class Meta:
model = Device model = Device
fields = [ fields = [
'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6', 'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6',
'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'custom_fields', 'created', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags', 'custom_fields', 'created',
'last_updated', 'last_updated',
] ]
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 = [] validators = []
def validate(self, data): def validate(self, data):
@ -542,16 +427,26 @@ class WritableDeviceSerializer(CustomFieldModelSerializer):
validator(data) validator(data)
# Enforce model validation # Enforce model validation
super(WritableDeviceSerializer, self).validate(data) super(DeviceSerializer, self).validate(data)
return 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 # Console server ports
# #
class ConsoleServerPortSerializer(serializers.ModelSerializer): class ConsoleServerPortSerializer(ValidatedModelSerializer):
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
class Meta: class Meta:
@ -560,27 +455,22 @@ class ConsoleServerPortSerializer(serializers.ModelSerializer):
read_only_fields = ['connected_console'] 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: class Meta:
model = ConsoleServerPort model = ConsoleServerPort
fields = ['id', 'device', 'name'] fields = ['id', 'url', 'device', 'name']
# #
# Console ports # Console ports
# #
class ConsolePortSerializer(serializers.ModelSerializer): class ConsolePortSerializer(ValidatedModelSerializer):
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
cs_port = ConsoleServerPortSerializer() cs_port = NestedConsoleServerPortSerializer(required=False, allow_null=True)
class Meta:
model = ConsolePort
fields = ['id', 'device', 'name', 'cs_port', 'connection_status']
class WritableConsolePortSerializer(ValidatedModelSerializer):
class Meta: class Meta:
model = ConsolePort model = ConsolePort
@ -591,7 +481,7 @@ class WritableConsolePortSerializer(ValidatedModelSerializer):
# Power outlets # Power outlets
# #
class PowerOutletSerializer(serializers.ModelSerializer): class PowerOutletSerializer(ValidatedModelSerializer):
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
class Meta: class Meta:
@ -600,27 +490,22 @@ class PowerOutletSerializer(serializers.ModelSerializer):
read_only_fields = ['connected_port'] 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: class Meta:
model = PowerOutlet model = PowerOutlet
fields = ['id', 'device', 'name'] fields = ['id', 'url', 'device', 'name']
# #
# Power ports # Power ports
# #
class PowerPortSerializer(serializers.ModelSerializer): class PowerPortSerializer(ValidatedModelSerializer):
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
power_outlet = PowerOutletSerializer() power_outlet = NestedPowerOutletSerializer(required=False, allow_null=True)
class Meta:
model = PowerPort
fields = ['id', 'device', 'name', 'power_outlet', 'connection_status']
class WritablePowerPortSerializer(ValidatedModelSerializer):
class Meta: class Meta:
model = PowerPort model = PowerPort
@ -631,12 +516,13 @@ class WritablePowerPortSerializer(ValidatedModelSerializer):
# Interfaces # Interfaces
# #
class NestedInterfaceSerializer(serializers.ModelSerializer): class NestedInterfaceSerializer(WritableNestedSerializer):
device = NestedDeviceSerializer(read_only=True)
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
class Meta: class Meta:
model = Interface model = Interface
fields = ['id', 'url', 'name'] fields = ['id', 'url', 'device', 'name']
class InterfaceNestedCircuitSerializer(serializers.ModelSerializer): class InterfaceNestedCircuitSerializer(serializers.ModelSerializer):
@ -647,8 +533,8 @@ class InterfaceNestedCircuitSerializer(serializers.ModelSerializer):
fields = ['id', 'url', 'cid'] fields = ['id', 'url', 'cid']
class InterfaceCircuitTerminationSerializer(serializers.ModelSerializer): class InterfaceCircuitTerminationSerializer(WritableNestedSerializer):
circuit = InterfaceNestedCircuitSerializer() circuit = InterfaceNestedCircuitSerializer(read_only=True)
class Meta: class Meta:
model = CircuitTermination model = CircuitTermination
@ -658,7 +544,7 @@ class InterfaceCircuitTerminationSerializer(serializers.ModelSerializer):
# Cannot import ipam.api.NestedVLANSerializer due to circular dependency # 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') url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail')
class Meta: class Meta:
@ -666,16 +552,21 @@ class InterfaceVLANSerializer(serializers.ModelSerializer):
fields = ['id', 'url', 'vid', 'name', 'display_name'] fields = ['id', 'url', 'vid', 'name', 'display_name']
class InterfaceSerializer(serializers.ModelSerializer): class InterfaceSerializer(ValidatedModelSerializer):
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES) form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES, required=False)
lag = NestedInterfaceSerializer() lag = NestedInterfaceSerializer(required=False, allow_null=True)
is_connected = serializers.SerializerMethodField(read_only=True) is_connected = serializers.SerializerMethodField(read_only=True)
interface_connection = serializers.SerializerMethodField(read_only=True) interface_connection = serializers.SerializerMethodField(read_only=True)
circuit_termination = InterfaceCircuitTerminationSerializer() circuit_termination = InterfaceCircuitTerminationSerializer(read_only=True)
untagged_vlan = InterfaceVLANSerializer() mode = ChoiceFieldSerializer(choices=IFACE_MODE_CHOICES, required=False)
mode = ChoiceFieldSerializer(choices=IFACE_MODE_CHOICES) untagged_vlan = InterfaceVLANSerializer(required=False, allow_null=True)
tagged_vlans = InterfaceVLANSerializer(many=True) tagged_vlans = SerializedPKRelatedField(
queryset=VLAN.objects.all(),
serializer=InterfaceVLANSerializer,
required=False,
many=True
)
class Meta: class Meta:
model = Interface model = Interface
@ -684,51 +575,6 @@ class InterfaceSerializer(serializers.ModelSerializer):
'is_connected', 'interface_connection', 'circuit_termination', 'mode', 'untagged_vlan', 'tagged_vlans', '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): def validate(self, data):
# All associated VLANs be global or assigned to the parent device's site. # All associated VLANs be global or assigned to the parent device's site.
@ -746,23 +592,45 @@ class WritableInterfaceSerializer(ValidatedModelSerializer):
"be global.".format(vlan) "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 # Device bays
# #
class DeviceBaySerializer(serializers.ModelSerializer): class DeviceBaySerializer(ValidatedModelSerializer):
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
installed_device = NestedDeviceSerializer() installed_device = NestedDeviceSerializer(required=False, allow_null=True)
class Meta: class Meta:
model = DeviceBay model = DeviceBay
fields = ['id', 'device', 'name', 'installed_device'] fields = ['id', 'device', 'name', 'installed_device']
class NestedDeviceBaySerializer(serializers.ModelSerializer): class NestedDeviceBaySerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail')
class Meta: class Meta:
@ -770,32 +638,15 @@ class NestedDeviceBaySerializer(serializers.ModelSerializer):
fields = ['id', 'url', 'name'] fields = ['id', 'url', 'name']
class WritableDeviceBaySerializer(ValidatedModelSerializer):
class Meta:
model = DeviceBay
fields = ['id', 'device', 'name', 'installed_device']
# #
# Inventory items # Inventory items
# #
class InventoryItemSerializer(serializers.ModelSerializer): class InventoryItemSerializer(ValidatedModelSerializer):
device = NestedDeviceSerializer() 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 # Provide a default value to satisfy UniqueTogetherValidator
parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None) parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None)
manufacturer = NestedManufacturerSerializer()
class Meta: class Meta:
model = InventoryItem model = InventoryItem
@ -809,17 +660,17 @@ class WritableInventoryItemSerializer(ValidatedModelSerializer):
# Interface connections # Interface connections
# #
class InterfaceConnectionSerializer(serializers.ModelSerializer): class InterfaceConnectionSerializer(ValidatedModelSerializer):
interface_a = PeerInterfaceSerializer() interface_a = NestedInterfaceSerializer()
interface_b = PeerInterfaceSerializer() interface_b = NestedInterfaceSerializer()
connection_status = ChoiceFieldSerializer(choices=CONNECTION_STATUS_CHOICES) connection_status = ChoiceFieldSerializer(choices=CONNECTION_STATUS_CHOICES, required=False)
class Meta: class Meta:
model = InterfaceConnection model = InterfaceConnection
fields = ['id', 'interface_a', 'interface_b', 'connection_status'] fields = ['id', 'interface_a', 'interface_b', 'connection_status']
class NestedInterfaceConnectionSerializer(serializers.ModelSerializer): class NestedInterfaceConnectionSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interfaceconnection-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interfaceconnection-detail')
class Meta: class Meta:
@ -827,18 +678,26 @@ class NestedInterfaceConnectionSerializer(serializers.ModelSerializer):
fields = ['id', 'url', 'connection_status'] 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: class Meta:
model = InterfaceConnection 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 # Virtual chassis
# #
class VirtualChassisSerializer(serializers.ModelSerializer): class VirtualChassisSerializer(ValidatedModelSerializer):
master = NestedDeviceSerializer() master = NestedDeviceSerializer()
class Meta: class Meta:
@ -846,16 +705,9 @@ class VirtualChassisSerializer(serializers.ModelSerializer):
fields = ['id', 'master', 'domain'] fields = ['id', 'master', 'domain']
class NestedVirtualChassisSerializer(serializers.ModelSerializer): class NestedVirtualChassisSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
class Meta: class Meta:
model = VirtualChassis model = VirtualChassis
fields = ['id', 'url'] fields = ['id', 'url']
class WritableVirtualChassisSerializer(ValidatedModelSerializer):
class Meta:
model = VirtualChassis
fields = ['id', 'master', 'domain']

View File

@ -52,7 +52,6 @@ class DCIMFieldChoicesViewSet(FieldChoicesViewSet):
class RegionViewSet(ModelViewSet): class RegionViewSet(ModelViewSet):
queryset = Region.objects.all() queryset = Region.objects.all()
serializer_class = serializers.RegionSerializer serializer_class = serializers.RegionSerializer
write_serializer_class = serializers.WritableRegionSerializer
filter_class = filters.RegionFilter filter_class = filters.RegionFilter
@ -63,7 +62,6 @@ class RegionViewSet(ModelViewSet):
class SiteViewSet(CustomFieldModelViewSet): class SiteViewSet(CustomFieldModelViewSet):
queryset = Site.objects.select_related('region', 'tenant') queryset = Site.objects.select_related('region', 'tenant')
serializer_class = serializers.SiteSerializer serializer_class = serializers.SiteSerializer
write_serializer_class = serializers.WritableSiteSerializer
filter_class = filters.SiteFilter filter_class = filters.SiteFilter
@detail_route() @detail_route()
@ -84,7 +82,6 @@ class SiteViewSet(CustomFieldModelViewSet):
class RackGroupViewSet(ModelViewSet): class RackGroupViewSet(ModelViewSet):
queryset = RackGroup.objects.select_related('site') queryset = RackGroup.objects.select_related('site')
serializer_class = serializers.RackGroupSerializer serializer_class = serializers.RackGroupSerializer
write_serializer_class = serializers.WritableRackGroupSerializer
filter_class = filters.RackGroupFilter filter_class = filters.RackGroupFilter
@ -105,7 +102,6 @@ class RackRoleViewSet(ModelViewSet):
class RackViewSet(CustomFieldModelViewSet): class RackViewSet(CustomFieldModelViewSet):
queryset = Rack.objects.select_related('site', 'group__site', 'tenant') queryset = Rack.objects.select_related('site', 'group__site', 'tenant')
serializer_class = serializers.RackSerializer serializer_class = serializers.RackSerializer
write_serializer_class = serializers.WritableRackSerializer
filter_class = filters.RackFilter filter_class = filters.RackFilter
@detail_route() @detail_route()
@ -136,7 +132,6 @@ class RackViewSet(CustomFieldModelViewSet):
class RackReservationViewSet(ModelViewSet): class RackReservationViewSet(ModelViewSet):
queryset = RackReservation.objects.select_related('rack', 'user', 'tenant') queryset = RackReservation.objects.select_related('rack', 'user', 'tenant')
serializer_class = serializers.RackReservationSerializer serializer_class = serializers.RackReservationSerializer
write_serializer_class = serializers.WritableRackReservationSerializer
filter_class = filters.RackReservationFilter filter_class = filters.RackReservationFilter
# Assign user from request # Assign user from request
@ -161,7 +156,6 @@ class ManufacturerViewSet(ModelViewSet):
class DeviceTypeViewSet(CustomFieldModelViewSet): class DeviceTypeViewSet(CustomFieldModelViewSet):
queryset = DeviceType.objects.select_related('manufacturer') queryset = DeviceType.objects.select_related('manufacturer')
serializer_class = serializers.DeviceTypeSerializer serializer_class = serializers.DeviceTypeSerializer
write_serializer_class = serializers.WritableDeviceTypeSerializer
filter_class = filters.DeviceTypeFilter filter_class = filters.DeviceTypeFilter
@ -172,42 +166,36 @@ class DeviceTypeViewSet(CustomFieldModelViewSet):
class ConsolePortTemplateViewSet(ModelViewSet): class ConsolePortTemplateViewSet(ModelViewSet):
queryset = ConsolePortTemplate.objects.select_related('device_type__manufacturer') queryset = ConsolePortTemplate.objects.select_related('device_type__manufacturer')
serializer_class = serializers.ConsolePortTemplateSerializer serializer_class = serializers.ConsolePortTemplateSerializer
write_serializer_class = serializers.WritableConsolePortTemplateSerializer
filter_class = filters.ConsolePortTemplateFilter filter_class = filters.ConsolePortTemplateFilter
class ConsoleServerPortTemplateViewSet(ModelViewSet): class ConsoleServerPortTemplateViewSet(ModelViewSet):
queryset = ConsoleServerPortTemplate.objects.select_related('device_type__manufacturer') queryset = ConsoleServerPortTemplate.objects.select_related('device_type__manufacturer')
serializer_class = serializers.ConsoleServerPortTemplateSerializer serializer_class = serializers.ConsoleServerPortTemplateSerializer
write_serializer_class = serializers.WritableConsoleServerPortTemplateSerializer
filter_class = filters.ConsoleServerPortTemplateFilter filter_class = filters.ConsoleServerPortTemplateFilter
class PowerPortTemplateViewSet(ModelViewSet): class PowerPortTemplateViewSet(ModelViewSet):
queryset = PowerPortTemplate.objects.select_related('device_type__manufacturer') queryset = PowerPortTemplate.objects.select_related('device_type__manufacturer')
serializer_class = serializers.PowerPortTemplateSerializer serializer_class = serializers.PowerPortTemplateSerializer
write_serializer_class = serializers.WritablePowerPortTemplateSerializer
filter_class = filters.PowerPortTemplateFilter filter_class = filters.PowerPortTemplateFilter
class PowerOutletTemplateViewSet(ModelViewSet): class PowerOutletTemplateViewSet(ModelViewSet):
queryset = PowerOutletTemplate.objects.select_related('device_type__manufacturer') queryset = PowerOutletTemplate.objects.select_related('device_type__manufacturer')
serializer_class = serializers.PowerOutletTemplateSerializer serializer_class = serializers.PowerOutletTemplateSerializer
write_serializer_class = serializers.WritablePowerOutletTemplateSerializer
filter_class = filters.PowerOutletTemplateFilter filter_class = filters.PowerOutletTemplateFilter
class InterfaceTemplateViewSet(ModelViewSet): class InterfaceTemplateViewSet(ModelViewSet):
queryset = InterfaceTemplate.objects.select_related('device_type__manufacturer') queryset = InterfaceTemplate.objects.select_related('device_type__manufacturer')
serializer_class = serializers.InterfaceTemplateSerializer serializer_class = serializers.InterfaceTemplateSerializer
write_serializer_class = serializers.WritableInterfaceTemplateSerializer
filter_class = filters.InterfaceTemplateFilter filter_class = filters.InterfaceTemplateFilter
class DeviceBayTemplateViewSet(ModelViewSet): class DeviceBayTemplateViewSet(ModelViewSet):
queryset = DeviceBayTemplate.objects.select_related('device_type__manufacturer') queryset = DeviceBayTemplate.objects.select_related('device_type__manufacturer')
serializer_class = serializers.DeviceBayTemplateSerializer serializer_class = serializers.DeviceBayTemplateSerializer
write_serializer_class = serializers.WritableDeviceBayTemplateSerializer
filter_class = filters.DeviceBayTemplateFilter filter_class = filters.DeviceBayTemplateFilter
@ -228,7 +216,6 @@ class DeviceRoleViewSet(ModelViewSet):
class PlatformViewSet(ModelViewSet): class PlatformViewSet(ModelViewSet):
queryset = Platform.objects.all() queryset = Platform.objects.all()
serializer_class = serializers.PlatformSerializer serializer_class = serializers.PlatformSerializer
write_serializer_class = serializers.WritablePlatformSerializer
filter_class = filters.PlatformFilter filter_class = filters.PlatformFilter
@ -244,7 +231,6 @@ class DeviceViewSet(CustomFieldModelViewSet):
'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside',
) )
serializer_class = serializers.DeviceSerializer serializer_class = serializers.DeviceSerializer
write_serializer_class = serializers.WritableDeviceSerializer
filter_class = filters.DeviceFilter filter_class = filters.DeviceFilter
@detail_route(url_path='napalm') @detail_route(url_path='napalm')
@ -318,35 +304,30 @@ class DeviceViewSet(CustomFieldModelViewSet):
class ConsolePortViewSet(ModelViewSet): class ConsolePortViewSet(ModelViewSet):
queryset = ConsolePort.objects.select_related('device', 'cs_port__device') queryset = ConsolePort.objects.select_related('device', 'cs_port__device')
serializer_class = serializers.ConsolePortSerializer serializer_class = serializers.ConsolePortSerializer
write_serializer_class = serializers.WritableConsolePortSerializer
filter_class = filters.ConsolePortFilter filter_class = filters.ConsolePortFilter
class ConsoleServerPortViewSet(ModelViewSet): class ConsoleServerPortViewSet(ModelViewSet):
queryset = ConsoleServerPort.objects.select_related('device', 'connected_console__device') queryset = ConsoleServerPort.objects.select_related('device', 'connected_console__device')
serializer_class = serializers.ConsoleServerPortSerializer serializer_class = serializers.ConsoleServerPortSerializer
write_serializer_class = serializers.WritableConsoleServerPortSerializer
filter_class = filters.ConsoleServerPortFilter filter_class = filters.ConsoleServerPortFilter
class PowerPortViewSet(ModelViewSet): class PowerPortViewSet(ModelViewSet):
queryset = PowerPort.objects.select_related('device', 'power_outlet__device') queryset = PowerPort.objects.select_related('device', 'power_outlet__device')
serializer_class = serializers.PowerPortSerializer serializer_class = serializers.PowerPortSerializer
write_serializer_class = serializers.WritablePowerPortSerializer
filter_class = filters.PowerPortFilter filter_class = filters.PowerPortFilter
class PowerOutletViewSet(ModelViewSet): class PowerOutletViewSet(ModelViewSet):
queryset = PowerOutlet.objects.select_related('device', 'connected_port__device') queryset = PowerOutlet.objects.select_related('device', 'connected_port__device')
serializer_class = serializers.PowerOutletSerializer serializer_class = serializers.PowerOutletSerializer
write_serializer_class = serializers.WritablePowerOutletSerializer
filter_class = filters.PowerOutletFilter filter_class = filters.PowerOutletFilter
class InterfaceViewSet(ModelViewSet): class InterfaceViewSet(ModelViewSet):
queryset = Interface.objects.select_related('device') queryset = Interface.objects.select_related('device')
serializer_class = serializers.InterfaceSerializer serializer_class = serializers.InterfaceSerializer
write_serializer_class = serializers.WritableInterfaceSerializer
filter_class = filters.InterfaceFilter filter_class = filters.InterfaceFilter
@detail_route() @detail_route()
@ -363,14 +344,12 @@ class InterfaceViewSet(ModelViewSet):
class DeviceBayViewSet(ModelViewSet): class DeviceBayViewSet(ModelViewSet):
queryset = DeviceBay.objects.select_related('installed_device') queryset = DeviceBay.objects.select_related('installed_device')
serializer_class = serializers.DeviceBaySerializer serializer_class = serializers.DeviceBaySerializer
write_serializer_class = serializers.WritableDeviceBaySerializer
filter_class = filters.DeviceBayFilter filter_class = filters.DeviceBayFilter
class InventoryItemViewSet(ModelViewSet): class InventoryItemViewSet(ModelViewSet):
queryset = InventoryItem.objects.select_related('device', 'manufacturer') queryset = InventoryItem.objects.select_related('device', 'manufacturer')
serializer_class = serializers.InventoryItemSerializer serializer_class = serializers.InventoryItemSerializer
write_serializer_class = serializers.WritableInventoryItemSerializer
filter_class = filters.InventoryItemFilter filter_class = filters.InventoryItemFilter
@ -393,7 +372,6 @@ class PowerConnectionViewSet(ListModelMixin, GenericViewSet):
class InterfaceConnectionViewSet(ModelViewSet): class InterfaceConnectionViewSet(ModelViewSet):
queryset = InterfaceConnection.objects.select_related('interface_a__device', 'interface_b__device') queryset = InterfaceConnection.objects.select_related('interface_a__device', 'interface_b__device')
serializer_class = serializers.InterfaceConnectionSerializer serializer_class = serializers.InterfaceConnectionSerializer
write_serializer_class = serializers.WritableInterfaceConnectionSerializer
filter_class = filters.InterfaceConnectionFilter filter_class = filters.InterfaceConnectionFilter
@ -404,7 +382,6 @@ class InterfaceConnectionViewSet(ModelViewSet):
class VirtualChassisViewSet(ModelViewSet): class VirtualChassisViewSet(ModelViewSet):
queryset = VirtualChassis.objects.all() queryset = VirtualChassis.objects.all()
serializer_class = serializers.VirtualChassisSerializer serializer_class = serializers.VirtualChassisSerializer
write_serializer_class = serializers.WritableVirtualChassisSerializer
# #

View File

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

View File

@ -7,6 +7,7 @@ from django.contrib.auth.models import User
from django.contrib.postgres.forms.array import SimpleArrayField from django.contrib.postgres.forms.array import SimpleArrayField
from django.db.models import Count, Q from django.db.models import Count, Q
from mptt.forms import TreeNodeChoiceField from mptt.forms import TreeNodeChoiceField
from taggit.forms import TagField
from timezone_field import TimeZoneFormField from timezone_field import TimeZoneFormField
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
@ -108,12 +109,14 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
region = TreeNodeChoiceField(queryset=Region.objects.all(), required=False) region = TreeNodeChoiceField(queryset=Region.objects.all(), required=False)
slug = SlugField() slug = SlugField()
comments = CommentField() comments = CommentField()
tags = TagField(required=False)
class Meta: class Meta:
model = Site model = Site
fields = [ fields = [
'name', 'slug', 'status', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'name', 'slug', 'status', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'time_zone', 'description',
'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments', 'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
'tags',
] ]
widgets = { widgets = {
'physical_address': SmallTextarea(attrs={'rows': 3}), 'physical_address': SmallTextarea(attrs={'rows': 3}),
@ -274,12 +277,13 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldForm):
) )
) )
comments = CommentField() comments = CommentField()
tags = TagField(required=False)
class Meta: class Meta:
model = Rack model = Rack
fields = [ fields = [
'site', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'role', 'serial', 'type', 'width', 'site', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'role', 'serial', 'type', 'width',
'u_height', 'desc_units', 'comments', 'u_height', 'desc_units', 'comments', 'tags',
] ]
help_texts = { help_texts = {
'site': "The site at which the rack exists", 'site': "The site at which the rack exists",
@ -350,6 +354,8 @@ class RackCSVForm(forms.ModelForm):
site = self.cleaned_data.get('site') site = self.cleaned_data.get('site')
group_name = self.cleaned_data.get('group_name') 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 # Validate rack group
if group_name: if group_name:
@ -358,6 +364,18 @@ class RackCSVForm(forms.ModelForm):
except RackGroup.DoesNotExist: except RackGroup.DoesNotExist:
raise forms.ValidationError("Rack group {} not found for site {}".format(group_name, site)) 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): class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput) pk = forms.ModelMultipleChoiceField(queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput)
@ -485,11 +503,14 @@ class ManufacturerCSVForm(forms.ModelForm):
class DeviceTypeForm(BootstrapMixin, CustomFieldForm): class DeviceTypeForm(BootstrapMixin, CustomFieldForm):
slug = SlugField(slug_source='model') slug = SlugField(slug_source='model')
tags = TagField(required=False)
class Meta: class Meta:
model = DeviceType model = DeviceType
fields = ['manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', fields = [
'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering', 'comments'] 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu',
'is_network_device', 'subdevice_role', 'interface_ordering', 'comments', 'tags',
]
labels = { labels = {
'interface_ordering': 'Order interfaces by', 'interface_ordering': 'Order interfaces by',
} }
@ -706,7 +727,7 @@ class PlatformCSVForm(forms.ModelForm):
slug = SlugField() slug = SlugField()
manufacturer = forms.ModelChoiceField( manufacturer = forms.ModelChoiceField(
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.all(),
required=True, required=False,
to_field_name='name', to_field_name='name',
help_text='Manufacturer name', help_text='Manufacturer name',
error_messages={ error_messages={
@ -772,12 +793,13 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
) )
) )
comments = CommentField() comments = CommentField()
tags = TagField(required=False)
class Meta: class Meta:
model = Device model = Device
fields = [ fields = [
'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face', 'status', 'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face',
'platform', 'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant', 'comments', 'status', 'platform', 'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant', 'comments', 'tags',
] ]
help_texts = { help_texts = {
'device_role': "The function this device serves", 'device_role': "The function this device serves",

View File

@ -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'),
),
]

View File

@ -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'),
),
]

View File

@ -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')]),
),
]

View File

@ -14,12 +14,12 @@ from django.db.models import Count, Q, ObjectDoesNotExist
from django.urls import reverse from django.urls import reverse
from django.utils.encoding import python_2_unicode_compatible from django.utils.encoding import python_2_unicode_compatible
from mptt.models import MPTTModel, TreeForeignKey from mptt.models import MPTTModel, TreeForeignKey
from taggit.managers import TaggableManager
from timezone_field import TimeZoneField from timezone_field import TimeZoneField
from circuits.models import Circuit from circuits.models import Circuit
from extras.models import CustomFieldModel, CustomFieldValue, ImageAttachment from extras.models import CustomFieldModel
from extras.rpc import RPC_CLIENTS from extras.rpc import RPC_CLIENTS
from tenancy.models import Tenant
from utilities.fields import ColorField, NullableCharField from utilities.fields import ColorField, NullableCharField
from utilities.managers import NaturalOrderByManager from utilities.managers import NaturalOrderByManager
from utilities.models import CreatedUpdatedModel from utilities.models import CreatedUpdatedModel
@ -38,10 +38,20 @@ class Region(MPTTModel):
Sites can be grouped within geographic Regions. Sites can be grouped within geographic Regions.
""" """
parent = TreeForeignKey( 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'] 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 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). 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) name = models.CharField(
slug = models.SlugField(unique=True) max_length=50,
status = models.PositiveSmallIntegerField(choices=SITE_STATUS_CHOICES, default=SITE_STATUS_ACTIVE) unique=True
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) slug = models.SlugField(
facility = models.CharField(max_length=50, blank=True) unique=True
asn = ASNField(blank=True, null=True, verbose_name='ASN') )
time_zone = TimeZoneField(blank=True) status = models.PositiveSmallIntegerField(
description = models.CharField(max_length=100, blank=True) choices=SITE_STATUS_CHOICES,
physical_address = models.CharField(max_length=200, blank=True) default=SITE_STATUS_ACTIVE
shipping_address = models.CharField(max_length=200, blank=True) )
contact_name = models.CharField(max_length=50, blank=True) region = models.ForeignKey(
contact_phone = models.CharField(max_length=20, blank=True) to='dcim.Region',
contact_email = models.EmailField(blank=True, verbose_name="Contact E-mail") on_delete=models.SET_NULL,
comments = models.TextField(blank=True) related_name='sites',
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') blank=True,
images = GenericRelation(ImageAttachment) 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() objects = SiteManager()
tags = TaggableManager()
csv_headers = [ csv_headers = [
'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
@ -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 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. 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() 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'] csv_headers = ['site', 'name', 'slug']
@ -211,8 +283,13 @@ class RackRole(models.Model):
""" """
Racks can be organized by functional role, similar to Devices. Racks can be organized by functional role, similar to Devices.
""" """
name = models.CharField(max_length=50, unique=True) name = models.CharField(
slug = models.SlugField(unique=True) max_length=50,
unique=True
)
slug = models.SlugField(
unique=True
)
color = ColorField() color = ColorField()
csv_headers = ['name', 'slug', 'color'] 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. 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. Each Rack is assigned to a Site and (optionally) a RackGroup.
""" """
name = models.CharField(max_length=50) name = models.CharField(
facility_id = NullableCharField(max_length=50, blank=True, null=True, verbose_name='Facility ID') max_length=50
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) facility_id = NullableCharField(
tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='racks', on_delete=models.PROTECT) max_length=50,
role = models.ForeignKey('RackRole', related_name='racks', blank=True, null=True, on_delete=models.PROTECT) blank=True,
serial = models.CharField(max_length=50, blank=True, verbose_name='Serial number') null=True,
type = models.PositiveSmallIntegerField(choices=RACK_TYPE_CHOICES, blank=True, null=True, verbose_name='Type') verbose_name='Facility ID'
width = models.PositiveSmallIntegerField(choices=RACK_WIDTH_CHOICES, default=RACK_WIDTH_19IN, verbose_name='Width', )
help_text='Rail-to-rail width') site = models.ForeignKey(
u_height = models.PositiveSmallIntegerField(default=42, verbose_name='Height (U)', to='dcim.Site',
validators=[MinValueValidator(1), MaxValueValidator(100)]) on_delete=models.PROTECT,
desc_units = models.BooleanField(default=False, verbose_name='Descending units', related_name='racks'
help_text='Units are numbered top-to-bottom') )
comments = models.TextField(blank=True) group = models.ForeignKey(
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') to='dcim.RackGroup',
images = GenericRelation(ImageAttachment) 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() objects = RackManager()
tags = TaggableManager()
csv_headers = [ csv_headers = [
'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'serial', 'width', 'u_height', 'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'serial', 'width', 'u_height',
@ -272,10 +406,10 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
] ]
class Meta: class Meta:
ordering = ['site', 'name'] ordering = ['site', 'group', 'name']
unique_together = [ unique_together = [
['site', 'name'], ['group', 'name'],
['site', 'facility_id'], ['group', 'facility_id'],
] ]
def __str__(self): def __str__(self):
@ -450,12 +584,31 @@ class RackReservation(models.Model):
""" """
One or more reserved units within a Rack. One or more reserved units within a Rack.
""" """
rack = models.ForeignKey('Rack', related_name='reservations', on_delete=models.CASCADE) rack = models.ForeignKey(
units = ArrayField(models.PositiveSmallIntegerField()) to='dcim.Rack',
created = models.DateTimeField(auto_now_add=True) on_delete=models.CASCADE,
tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='rackreservations', on_delete=models.PROTECT) related_name='reservations'
user = models.ForeignKey(User, on_delete=models.PROTECT) )
description = models.CharField(max_length=100) 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: class Meta:
ordering = ['created'] ordering = ['created']
@ -508,8 +661,13 @@ class Manufacturer(models.Model):
""" """
A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell. A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell.
""" """
name = models.CharField(max_length=50, unique=True) name = models.CharField(
slug = models.SlugField(unique=True) max_length=50,
unique=True
)
slug = models.SlugField(
unique=True
)
csv_headers = ['name', 'slug'] 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 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. DeviceType) are automatically created as well.
""" """
manufacturer = models.ForeignKey('Manufacturer', related_name='device_types', on_delete=models.PROTECT) manufacturer = models.ForeignKey(
model = models.CharField(max_length=50) to='dcim.Manufacturer',
on_delete=models.PROTECT,
related_name='device_types'
)
model = models.CharField(
max_length=50
)
slug = models.SlugField() slug = models.SlugField()
part_number = models.CharField(max_length=50, blank=True, help_text="Discrete part number (optional)") part_number = models.CharField(
u_height = models.PositiveSmallIntegerField(verbose_name='Height (U)', default=1) max_length=50,
is_full_depth = models.BooleanField(default=True, verbose_name="Is full depth", blank=True,
help_text="Device consumes both front and rear rack faces") help_text='Discrete part number (optional)'
interface_ordering = models.PositiveSmallIntegerField(choices=IFACE_ORDERING_CHOICES, )
default=IFACE_ORDERING_POSITION) u_height = models.PositiveSmallIntegerField(
is_console_server = models.BooleanField(default=False, verbose_name='Is a console server', default=1,
help_text="This type of device has console server ports") verbose_name='Height (U)'
is_pdu = models.BooleanField(default=False, verbose_name='Is a PDU', )
help_text="This type of device has power outlets") is_full_depth = models.BooleanField(
is_network_device = models.BooleanField(default=True, verbose_name='Is a network device', default=True,
help_text="This type of device has network interfaces") verbose_name='Is full depth',
subdevice_role = models.NullBooleanField(default=None, verbose_name='Parent/child status', help_text='Device consumes both front and rear rack faces'
choices=SUBDEVICE_ROLE_CHOICES, )
help_text="Parent devices house child devices in device bays. Select " interface_ordering = models.PositiveSmallIntegerField(
"\"None\" if this device type is neither a parent nor a child.") choices=IFACE_ORDERING_CHOICES,
comments = models.TextField(blank=True) default=IFACE_ORDERING_POSITION
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') )
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 = [ csv_headers = [
'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server',
@ -670,8 +866,14 @@ class ConsolePortTemplate(models.Model):
""" """
A template for a ConsolePort to be created for a new Device. 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) device_type = models.ForeignKey(
name = models.CharField(max_length=50) to='dcim.DeviceType',
on_delete=models.CASCADE,
related_name='console_port_templates'
)
name = models.CharField(
max_length=50
)
class Meta: class Meta:
ordering = ['device_type', 'name'] ordering = ['device_type', 'name']
@ -686,8 +888,14 @@ class ConsoleServerPortTemplate(models.Model):
""" """
A template for a ConsoleServerPort to be created for a new Device. 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) device_type = models.ForeignKey(
name = models.CharField(max_length=50) to='dcim.DeviceType',
on_delete=models.CASCADE,
related_name='cs_port_templates'
)
name = models.CharField(
max_length=50
)
class Meta: class Meta:
ordering = ['device_type', 'name'] ordering = ['device_type', 'name']
@ -702,8 +910,14 @@ class PowerPortTemplate(models.Model):
""" """
A template for a PowerPort to be created for a new Device. 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) device_type = models.ForeignKey(
name = models.CharField(max_length=50) to='dcim.DeviceType',
on_delete=models.CASCADE,
related_name='power_port_templates'
)
name = models.CharField(
max_length=50
)
class Meta: class Meta:
ordering = ['device_type', 'name'] ordering = ['device_type', 'name']
@ -718,8 +932,14 @@ class PowerOutletTemplate(models.Model):
""" """
A template for a PowerOutlet to be created for a new Device. 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) device_type = models.ForeignKey(
name = models.CharField(max_length=50) to='dcim.DeviceType',
on_delete=models.CASCADE,
related_name='power_outlet_templates'
)
name = models.CharField(
max_length=50
)
class Meta: class Meta:
ordering = ['device_type', 'name'] ordering = ['device_type', 'name']
@ -734,10 +954,22 @@ class InterfaceTemplate(models.Model):
""" """
A template for a physical data interface on a new Device. A template for a physical data interface on a new Device.
""" """
device_type = models.ForeignKey('DeviceType', related_name='interface_templates', on_delete=models.CASCADE) device_type = models.ForeignKey(
name = models.CharField(max_length=64) to='dcim.DeviceType',
form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_10GE_SFP_PLUS) on_delete=models.CASCADE,
mgmt_only = models.BooleanField(default=False, verbose_name='Management only') 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() 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. 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) device_type = models.ForeignKey(
name = models.CharField(max_length=50) to='dcim.DeviceType',
on_delete=models.CASCADE,
related_name='device_bay_templates'
)
name = models.CharField(
max_length=50
)
class Meta: class Meta:
ordering = ['device_type', 'name'] 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 color to be used when displaying rack elevations. The vm_role field determines whether the role is applicable to
virtual machines as well. virtual machines as well.
""" """
name = models.CharField(max_length=50, unique=True) name = models.CharField(
slug = models.SlugField(unique=True) max_length=50,
unique=True
)
slug = models.SlugField(
unique=True
)
color = ColorField() color = ColorField()
vm_role = models.BooleanField( vm_role = models.BooleanField(
default=True, default=True,
verbose_name="VM Role", verbose_name='VM Role',
help_text="Virtual machines may be assigned to this role" help_text='Virtual machines may be assigned to this role'
) )
csv_headers = ['name', 'slug', 'color', 'vm_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 NetBox uses Platforms to determine how to interact with devices when pulling inventory data or other information by
specifying a NAPALM driver. specifying a NAPALM driver.
""" """
name = models.CharField(max_length=50, unique=True) name = models.CharField(
slug = models.SlugField(unique=True) max_length=50,
unique=True
)
slug = models.SlugField(
unique=True
)
manufacturer = models.ForeignKey( manufacturer = models.ForeignKey(
to='Manufacturer', to='dcim.Manufacturer',
on_delete=models.PROTECT,
related_name='platforms', related_name='platforms',
blank=True, blank=True,
null=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( napalm_driver = models.CharField(
max_length=50, max_length=50,
blank=True, blank=True,
verbose_name='NAPALM driver', 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( rpc_client = models.CharField(
max_length=30, max_length=30,
choices=RPC_CLIENT_CHOICES, choices=RPC_CLIENT_CHOICES,
blank=True, blank=True,
verbose_name="Legacy RPC client" verbose_name='Legacy RPC client'
) )
csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver'] 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 by the component templates assigned to its DeviceType. Components can also be added, modified, or deleted after the
creation of a Device. creation of a Device.
""" """
device_type = models.ForeignKey('DeviceType', related_name='instances', on_delete=models.PROTECT) device_type = models.ForeignKey(
device_role = models.ForeignKey('DeviceRole', related_name='devices', on_delete=models.PROTECT) to='dcim.DeviceType',
tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='devices', on_delete=models.PROTECT) on_delete=models.PROTECT,
platform = models.ForeignKey('Platform', related_name='devices', blank=True, null=True, on_delete=models.SET_NULL) related_name='instances'
name = NullableCharField(max_length=64, blank=True, null=True, unique=True) )
serial = models.CharField(max_length=50, blank=True, verbose_name='Serial number') 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( 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' help_text='A unique tag used to identify this device'
) )
site = models.ForeignKey('Site', related_name='devices', on_delete=models.PROTECT) site = models.ForeignKey(
rack = models.ForeignKey('Rack', related_name='devices', blank=True, null=True, on_delete=models.PROTECT) 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( 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' 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') face = models.PositiveSmallIntegerField(
status = models.PositiveSmallIntegerField(choices=DEVICE_STATUS_CHOICES, default=DEVICE_STATUS_ACTIVE, verbose_name='Status') 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( 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' verbose_name='Primary IPv4'
) )
primary_ip6 = models.OneToOneField( 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' verbose_name='Primary IPv6'
) )
cluster = models.ForeignKey( cluster = models.ForeignKey(
@ -923,11 +1235,20 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
null=True, null=True,
validators=[MaxValueValidator(255)] validators=[MaxValueValidator(255)]
) )
comments = models.TextField(blank=True) comments = models.TextField(
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') blank=True
images = GenericRelation(ImageAttachment) )
custom_field_values = GenericRelation(
to='extras.CustomFieldValue',
content_type_field='obj_type',
object_id_field='obj_id'
)
images = GenericRelation(
to='extras.ImageAttachment'
)
objects = DeviceManager() objects = DeviceManager()
tags = TaggableManager()
csv_headers = [ csv_headers = [
'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status', 'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
@ -1184,11 +1505,26 @@ class ConsolePort(models.Model):
""" """
A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts. A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
""" """
device = models.ForeignKey('Device', related_name='console_ports', on_delete=models.CASCADE) device = models.ForeignKey(
name = models.CharField(max_length=50) to='dcim.Device',
cs_port = models.OneToOneField('ConsoleServerPort', related_name='connected_console', on_delete=models.SET_NULL, on_delete=models.CASCADE,
verbose_name='Console server port', blank=True, null=True) related_name='console_ports'
connection_status = models.NullBooleanField(choices=CONNECTION_STATUS_CHOICES, default=CONNECTION_STATUS_CONNECTED) )
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'] 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. 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) device = models.ForeignKey(
name = models.CharField(max_length=50) to='dcim.Device',
on_delete=models.CASCADE,
related_name='cs_ports'
)
name = models.CharField(
max_length=50
)
objects = ConsoleServerPortManager() objects = ConsoleServerPortManager()
@ -1266,11 +1608,25 @@ class PowerPort(models.Model):
""" """
A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets. 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) device = models.ForeignKey(
name = models.CharField(max_length=50) to='dcim.Device',
power_outlet = models.OneToOneField('PowerOutlet', related_name='connected_port', on_delete=models.SET_NULL, on_delete=models.CASCADE,
blank=True, null=True) related_name='power_ports'
connection_status = models.NullBooleanField(choices=CONNECTION_STATUS_CHOICES, default=CONNECTION_STATUS_CONNECTED) )
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'] 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. 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) device = models.ForeignKey(
name = models.CharField(max_length=50) to='dcim.Device',
on_delete=models.CASCADE,
related_name='power_outlets'
)
name = models.CharField(
max_length=50
)
objects = PowerOutletManager() objects = PowerOutletManager()
@ -1371,17 +1733,35 @@ class Interface(models.Model):
blank=True, blank=True,
verbose_name='Parent LAG' verbose_name='Parent LAG'
) )
name = models.CharField(max_length=64) name = models.CharField(
form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_10GE_SFP_PLUS) max_length=64
enabled = models.BooleanField(default=True) )
mac_address = MACAddressField(null=True, blank=True, verbose_name='MAC Address') form_factor = models.PositiveSmallIntegerField(
mtu = models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='MTU') 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( mgmt_only = models.BooleanField(
default=False, default=False,
verbose_name='OOB Management', verbose_name='OOB Management',
help_text="This interface is used only for out-of-band management" help_text='This interface is used only for out-of-band management'
)
description = models.CharField(
max_length=100,
blank=True
) )
description = models.CharField(max_length=100, blank=True)
mode = models.PositiveSmallIntegerField( mode = models.PositiveSmallIntegerField(
choices=IFACE_MODE_CHOICES, choices=IFACE_MODE_CHOICES,
blank=True, blank=True,
@ -1389,16 +1769,17 @@ class Interface(models.Model):
) )
untagged_vlan = models.ForeignKey( untagged_vlan = models.ForeignKey(
to='ipam.VLAN', to='ipam.VLAN',
on_delete=models.SET_NULL,
related_name='interfaces_as_untagged',
null=True, null=True,
blank=True, blank=True,
verbose_name='Untagged VLAN', verbose_name='Untagged VLAN'
related_name='interfaces_as_untagged'
) )
tagged_vlans = models.ManyToManyField( tagged_vlans = models.ManyToManyField(
to='ipam.VLAN', to='ipam.VLAN',
related_name='interfaces_as_tagged',
blank=True, blank=True,
verbose_name='Tagged VLANs', verbose_name='Tagged VLANs'
related_name='interfaces_as_tagged'
) )
objects = InterfaceQuerySet.as_manager() 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 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. 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_a = models.OneToOneField(
interface_b = models.OneToOneField('Interface', related_name='connected_as_b', on_delete=models.CASCADE) to='dcim.Interface',
connection_status = models.BooleanField(choices=CONNECTION_STATUS_CHOICES, default=CONNECTION_STATUS_CONNECTED, on_delete=models.CASCADE,
verbose_name='Status') 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'] 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 An empty space within a Device which can house a child device
""" """
device = models.ForeignKey('Device', related_name='device_bays', on_delete=models.CASCADE) device = models.ForeignKey(
name = models.CharField(max_length=50, verbose_name='Name') to='dcim.Device',
installed_device = models.OneToOneField('Device', related_name='parent_bay', on_delete=models.SET_NULL, blank=True, on_delete=models.CASCADE,
null=True) 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: class Meta:
ordering = ['device', 'name'] 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. 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. InventoryItems are used only for inventory purposes.
""" """
device = models.ForeignKey('Device', related_name='inventory_items', on_delete=models.CASCADE) device = models.ForeignKey(
parent = models.ForeignKey('self', related_name='child_items', blank=True, null=True, on_delete=models.CASCADE) to='dcim.Device',
name = models.CharField(max_length=50, verbose_name='Name') on_delete=models.CASCADE,
manufacturer = models.ForeignKey( related_name='inventory_items'
'Manufacturer', models.PROTECT, related_name='inventory_items', blank=True, null=True )
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( 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' help_text='A unique tag used to identify this item'
) )
discovered = models.BooleanField(default=False, verbose_name='Discovered') discovered = models.BooleanField(
description = models.CharField(max_length=100, blank=True) default=False,
verbose_name='Discovered'
)
description = models.CharField(
max_length=100,
blank=True
)
csv_headers = [ csv_headers = [
'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description', 'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description',

View File

@ -43,13 +43,13 @@ class InterfaceQuerySet(QuerySet):
}[method] }[method]
TYPE_RE = r"SUBSTRING({} FROM '^([^0-9]+)')" TYPE_RE = r"SUBSTRING({} FROM '^([^0-9]+)')"
ID_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)([0-9]+)$') AS integer)" ID_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)(\d{{1,9}})$') AS integer)"
SLOT_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?([0-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]+)?(?:[0-9]+\/)([0-9]+)') AS integer), 0)" 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]+)?(?:[0-9]+\/){{2}}([0-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]+)?(?:[0-9]+\/){{3}}([0-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 ':([0-9]+)(\.[0-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 '\.([0-9]+)$') AS integer), 0)" VC_RE = r"COALESCE(CAST(SUBSTRING({} FROM '\.(\d{{1,9}})$') AS integer), 0)"
fields = { fields = {
'_type': RawSQL(TYPE_RE.format(sql_col), []), '_type': RawSQL(TYPE_RE.format(sql_col), []),

View File

@ -6,7 +6,8 @@ from rest_framework import status
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from dcim.constants import ( 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 ( from dcim.models import (
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
@ -168,6 +169,7 @@ class SiteTest(HttpStatusMixin, APITestCase):
'name': 'Test Site 4', 'name': 'Test Site 4',
'slug': 'test-site-4', 'slug': 'test-site-4',
'region': self.region1.pk, 'region': self.region1.pk,
'status': SITE_STATUS_ACTIVE,
} }
url = reverse('dcim-api:site-list') url = reverse('dcim-api:site-list')
@ -187,16 +189,19 @@ class SiteTest(HttpStatusMixin, APITestCase):
'name': 'Test Site 4', 'name': 'Test Site 4',
'slug': 'test-site-4', 'slug': 'test-site-4',
'region': self.region1.pk, 'region': self.region1.pk,
'status': SITE_STATUS_ACTIVE,
}, },
{ {
'name': 'Test Site 5', 'name': 'Test Site 5',
'slug': 'test-site-5', 'slug': 'test-site-5',
'region': self.region1.pk, 'region': self.region1.pk,
'status': SITE_STATUS_ACTIVE,
}, },
{ {
'name': 'Test Site 6', 'name': 'Test Site 6',
'slug': 'test-site-6', 'slug': 'test-site-6',
'region': self.region1.pk, 'region': self.region1.pk,
'status': SITE_STATUS_ACTIVE,
}, },
] ]
@ -2322,8 +2327,8 @@ class InterfaceTest(HttpStatusMixin, APITestCase):
'device': self.device.pk, 'device': self.device.pk,
'name': 'Test Interface 4', 'name': 'Test Interface 4',
'mode': IFACE_MODE_TAGGED, 'mode': IFACE_MODE_TAGGED,
'untagged_vlan': self.vlan3.id,
'tagged_vlans': [self.vlan1.id, self.vlan2.id], 'tagged_vlans': [self.vlan1.id, self.vlan2.id],
'untagged_vlan': self.vlan3.id
} }
url = reverse('dcim-api:interface-list') url = reverse('dcim-api:interface-list')
@ -2331,11 +2336,10 @@ class InterfaceTest(HttpStatusMixin, APITestCase):
self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Interface.objects.count(), 4) self.assertEqual(Interface.objects.count(), 4)
interface5 = Interface.objects.get(pk=response.data['id']) self.assertEqual(response.data['device']['id'], data['device'])
self.assertEqual(interface5.device_id, data['device']) self.assertEqual(response.data['name'], data['name'])
self.assertEqual(interface5.name, data['name']) self.assertEqual(response.data['untagged_vlan']['id'], data['untagged_vlan'])
self.assertEqual(interface5.tagged_vlans.count(), 2) self.assertEqual([v['id'] for v in response.data['tagged_vlans']], data['tagged_vlans'])
self.assertEqual(interface5.untagged_vlan.id, data['untagged_vlan'])
def test_create_interface_bulk(self): def test_create_interface_bulk(self):
@ -2370,22 +2374,22 @@ class InterfaceTest(HttpStatusMixin, APITestCase):
'device': self.device.pk, 'device': self.device.pk,
'name': 'Test Interface 4', 'name': 'Test Interface 4',
'mode': IFACE_MODE_TAGGED, 'mode': IFACE_MODE_TAGGED,
'tagged_vlans': [self.vlan1.id],
'untagged_vlan': self.vlan2.id, 'untagged_vlan': self.vlan2.id,
'tagged_vlans': [self.vlan1.id],
}, },
{ {
'device': self.device.pk, 'device': self.device.pk,
'name': 'Test Interface 5', 'name': 'Test Interface 5',
'mode': IFACE_MODE_TAGGED, 'mode': IFACE_MODE_TAGGED,
'tagged_vlans': [self.vlan1.id],
'untagged_vlan': self.vlan2.id, 'untagged_vlan': self.vlan2.id,
'tagged_vlans': [self.vlan1.id],
}, },
{ {
'device': self.device.pk, 'device': self.device.pk,
'name': 'Test Interface 6', 'name': 'Test Interface 6',
'mode': IFACE_MODE_TAGGED, 'mode': IFACE_MODE_TAGGED,
'tagged_vlans': [self.vlan1.id],
'untagged_vlan': self.vlan2.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.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Interface.objects.count(), 6) self.assertEqual(Interface.objects.count(), 6)
self.assertEqual(response.data[0]['name'], data[0]['name']) for i in range(0, 3):
self.assertEqual(response.data[1]['name'], data[1]['name']) self.assertEqual(response.data[i]['name'], data[i]['name'])
self.assertEqual(response.data[2]['name'], data[2]['name']) self.assertEqual([v['id'] for v in response.data[i]['tagged_vlans']], data[i]['tagged_vlans'])
self.assertEqual(len(response.data[0]['tagged_vlans']), 1) self.assertEqual(response.data[i]['untagged_vlan']['id'], data[i]['untagged_vlan'])
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)
def test_update_interface(self): def test_update_interface(self):
@ -2847,9 +2846,9 @@ class InterfaceConnectionTest(HttpStatusMixin, APITestCase):
self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(InterfaceConnection.objects.count(), 6) self.assertEqual(InterfaceConnection.objects.count(), 6)
self.assertEqual(response.data[0]['interface_a'], data[0]['interface_a']) for i in range(0, 3):
self.assertEqual(response.data[1]['interface_a'], data[1]['interface_a']) self.assertEqual(response.data[i]['interface_a']['id'], data[i]['interface_a'])
self.assertEqual(response.data[2]['interface_a'], data[2]['interface_a']) self.assertEqual(response.data[i]['interface_b']['id'], data[i]['interface_b'])
def test_update_interfaceconnection(self): def test_update_interfaceconnection(self):
@ -3047,12 +3046,9 @@ class VirtualChassisTest(HttpStatusMixin, APITestCase):
response = self.client.post(url, data, format='json', **self.header) response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(VirtualChassis.objects.count(), 5) self.assertEqual(VirtualChassis.objects.count(), 5)
self.assertEqual(response.data[0]['master'], data[0]['master']) for i in range(0, 3):
self.assertEqual(response.data[0]['domain'], data[0]['domain']) self.assertEqual(response.data[i]['master']['id'], data[i]['master'])
self.assertEqual(response.data[1]['master'], data[1]['master']) self.assertEqual(response.data[i]['domain'], data[i]['domain'])
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'])
def test_update_virtualchassis(self): def test_update_virtualchassis(self):

View File

@ -2,6 +2,7 @@ from __future__ import unicode_literals
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from rest_framework import serializers from rest_framework import serializers
from taggit.models import Tag
from dcim.api.serializers import NestedDeviceSerializer, NestedRackSerializer, NestedSiteSerializer from dcim.api.serializers import NestedDeviceSerializer, NestedRackSerializer, NestedSiteSerializer
from dcim.models import Device, Rack, Site from dcim.models import Device, Rack, Site
@ -16,7 +17,7 @@ from extras.constants import *
# Graphs # Graphs
# #
class GraphSerializer(serializers.ModelSerializer): class GraphSerializer(ValidatedModelSerializer):
type = ChoiceFieldSerializer(choices=GRAPH_TYPE_CHOICES) type = ChoiceFieldSerializer(choices=GRAPH_TYPE_CHOICES)
class Meta: class Meta:
@ -24,13 +25,6 @@ class GraphSerializer(serializers.ModelSerializer):
fields = ['id', 'type', 'weight', 'name', 'source', 'link'] 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): class RenderedGraphSerializer(serializers.ModelSerializer):
embed_url = serializers.SerializerMethodField() embed_url = serializers.SerializerMethodField()
embed_link = serializers.SerializerMethodField() embed_link = serializers.SerializerMethodField()
@ -51,7 +45,7 @@ class RenderedGraphSerializer(serializers.ModelSerializer):
# Export templates # Export templates
# #
class ExportTemplateSerializer(serializers.ModelSerializer): class ExportTemplateSerializer(ValidatedModelSerializer):
class Meta: class Meta:
model = ExportTemplate model = ExportTemplate
@ -62,7 +56,7 @@ class ExportTemplateSerializer(serializers.ModelSerializer):
# Topology maps # Topology maps
# #
class TopologyMapSerializer(serializers.ModelSerializer): class TopologyMapSerializer(ValidatedModelSerializer):
site = NestedSiteSerializer() site = NestedSiteSerializer()
class Meta: class Meta:
@ -70,23 +64,46 @@ class TopologyMapSerializer(serializers.ModelSerializer):
fields = ['id', 'name', 'slug', 'site', 'device_patterns', 'description'] 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: class Meta:
model = TopologyMap model = Tag
fields = ['id', 'name', 'slug', 'site', 'device_patterns', 'description'] fields = ['id', 'name', 'slug', 'tagged_items']
# #
# Image attachments # Image attachments
# #
class ImageAttachmentSerializer(serializers.ModelSerializer): class ImageAttachmentSerializer(ValidatedModelSerializer):
parent = serializers.SerializerMethodField() content_type = ContentTypeFieldSerializer()
parent = serializers.SerializerMethodField(read_only=True)
class Meta: class Meta:
model = ImageAttachment 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): def get_parent(self, obj):
@ -103,29 +120,6 @@ class ImageAttachmentSerializer(serializers.ModelSerializer):
return serializer(obj.parent, context={'request': self.context['request']}).data 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 # Reports
# #

View File

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

View File

@ -1,12 +1,14 @@
from __future__ import unicode_literals from __future__ import unicode_literals
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models import Count
from django.http import Http404, HttpResponse from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from rest_framework.decorators import detail_route from rest_framework.decorators import detail_route
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
from taggit.models import Tag
from extras import filters from extras import filters
from extras.models import CustomField, ExportTemplate, Graph, ImageAttachment, ReportResult, TopologyMap, UserAction from extras.models import CustomField, ExportTemplate, Graph, ImageAttachment, ReportResult, TopologyMap, UserAction
@ -67,7 +69,6 @@ class CustomFieldModelViewSet(ModelViewSet):
class GraphViewSet(ModelViewSet): class GraphViewSet(ModelViewSet):
queryset = Graph.objects.all() queryset = Graph.objects.all()
serializer_class = serializers.GraphSerializer serializer_class = serializers.GraphSerializer
write_serializer_class = serializers.WritableGraphSerializer
filter_class = filters.GraphFilter filter_class = filters.GraphFilter
@ -88,7 +89,6 @@ class ExportTemplateViewSet(ModelViewSet):
class TopologyMapViewSet(ModelViewSet): class TopologyMapViewSet(ModelViewSet):
queryset = TopologyMap.objects.select_related('site') queryset = TopologyMap.objects.select_related('site')
serializer_class = serializers.TopologyMapSerializer serializer_class = serializers.TopologyMapSerializer
write_serializer_class = serializers.WritableTopologyMapSerializer
filter_class = filters.TopologyMapFilter filter_class = filters.TopologyMapFilter
@detail_route() @detail_route()
@ -111,6 +111,16 @@ class TopologyMapViewSet(ModelViewSet):
return response return response
#
# Tags
#
class TagViewSet(ModelViewSet):
queryset = Tag.objects.annotate(tagged_items=Count('taggit_taggeditem_items'))
serializer_class = serializers.TagSerializer
filter_class = filters.TagFilter
# #
# Image attachments # Image attachments
# #
@ -118,7 +128,6 @@ class TopologyMapViewSet(ModelViewSet):
class ImageAttachmentViewSet(ModelViewSet): class ImageAttachmentViewSet(ModelViewSet):
queryset = ImageAttachment.objects.all() queryset = ImageAttachment.objects.all()
serializer_class = serializers.ImageAttachmentSerializer serializer_class = serializers.ImageAttachmentSerializer
write_serializer_class = serializers.WritableImageAttachmentSerializer
# #

View File

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

View File

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

View File

@ -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'),
),
]

View File

@ -122,7 +122,8 @@ class CustomField(models.Model):
label = models.CharField( label = models.CharField(
max_length=50, max_length=50,
blank=True, 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( description = models.CharField(
max_length=100, max_length=100,
@ -130,17 +131,20 @@ class CustomField(models.Model):
) )
required = models.BooleanField( required = models.BooleanField(
default=False, 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( filter_logic = models.PositiveSmallIntegerField(
choices=CF_FILTER_CHOICES, choices=CF_FILTER_CHOICES,
default=CF_FILTER_LOOSE, 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( default = models.CharField(
max_length=100, max_length=100,
blank=True, 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( weight = models.PositiveSmallIntegerField(
default=100, default=100,
@ -192,11 +196,24 @@ class CustomField(models.Model):
@python_2_unicode_compatible @python_2_unicode_compatible
class CustomFieldValue(models.Model): class CustomFieldValue(models.Model):
field = models.ForeignKey('CustomField', related_name='values', on_delete=models.CASCADE) field = models.ForeignKey(
obj_type = models.ForeignKey(ContentType, related_name='+', on_delete=models.PROTECT) 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_id = models.PositiveIntegerField()
obj = GenericForeignKey('obj_type', 'obj_id') obj = GenericForeignKey(
serialized_value = models.CharField(max_length=255) ct_field='obj_type',
fk_field='obj_id'
)
serialized_value = models.CharField(
max_length=255
)
class Meta: class Meta:
ordering = ['obj_type', 'obj_id'] ordering = ['obj_type', 'obj_id']
@ -223,10 +240,19 @@ class CustomFieldValue(models.Model):
@python_2_unicode_compatible @python_2_unicode_compatible
class CustomFieldChoice(models.Model): class CustomFieldChoice(models.Model):
field = models.ForeignKey('CustomField', related_name='choices', limit_choices_to={'type': CF_TYPE_SELECT}, field = models.ForeignKey(
on_delete=models.CASCADE) to='extras.CustomField',
value = models.CharField(max_length=100) on_delete=models.CASCADE,
weight = models.PositiveSmallIntegerField(default=100, help_text="Higher weights appear lower in the list") 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: class Meta:
ordering = ['field', 'weight', 'value'] ordering = ['field', 'weight', 'value']
@ -252,11 +278,24 @@ class CustomFieldChoice(models.Model):
@python_2_unicode_compatible @python_2_unicode_compatible
class Graph(models.Model): class Graph(models.Model):
type = models.PositiveSmallIntegerField(choices=GRAPH_TYPE_CHOICES) type = models.PositiveSmallIntegerField(
weight = models.PositiveSmallIntegerField(default=1000) choices=GRAPH_TYPE_CHOICES
name = models.CharField(max_length=100, verbose_name='Name') )
source = models.CharField(max_length=500, verbose_name='Source URL') weight = models.PositiveSmallIntegerField(
link = models.URLField(verbose_name='Link URL', blank=True) 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: class Meta:
ordering = ['type', 'weight', 'name'] ordering = ['type', 'weight', 'name']
@ -282,13 +321,26 @@ class Graph(models.Model):
@python_2_unicode_compatible @python_2_unicode_compatible
class ExportTemplate(models.Model): class ExportTemplate(models.Model):
content_type = models.ForeignKey( 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() template_code = models.TextField()
mime_type = models.CharField(max_length=15, blank=True) mime_type = models.CharField(
file_extension = models.CharField(max_length=15, blank=True) max_length=15,
blank=True
)
file_extension = models.CharField(
max_length=15,
blank=True
)
class Meta: class Meta:
ordering = ['content_type', 'name'] ordering = ['content_type', 'name']
@ -327,25 +379,35 @@ class ExportTemplate(models.Model):
@python_2_unicode_compatible @python_2_unicode_compatible
class TopologyMap(models.Model): class TopologyMap(models.Model):
name = models.CharField(max_length=50, unique=True) name = models.CharField(
slug = models.SlugField(unique=True) max_length=50,
unique=True
)
slug = models.SlugField(
unique=True
)
type = models.PositiveSmallIntegerField( type = models.PositiveSmallIntegerField(
choices=TOPOLOGYMAP_TYPE_CHOICES, choices=TOPOLOGYMAP_TYPE_CHOICES,
default=TOPOLOGYMAP_TYPE_NETWORK default=TOPOLOGYMAP_TYPE_NETWORK
) )
site = models.ForeignKey( site = models.ForeignKey(
to='dcim.Site', to='dcim.Site',
on_delete=models.CASCADE,
related_name='topology_maps', related_name='topology_maps',
blank=True, blank=True,
null=True, null=True
on_delete=models.CASCADE
) )
device_patterns = models.TextField( device_patterns = models.TextField(
help_text="Identify devices to include in the diagram using regular expressions, one per line. Each line will " help_text='Identify devices to include in the diagram using regular '
"result in a new tier of the drawing. Separate multiple regexes within a line using semicolons. " 'expressions, one per line. Each line will result in a new '
"Devices will be rendered in the order they are defined." '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: class Meta:
ordering = ['name'] ordering = ['name']
@ -481,14 +543,29 @@ class ImageAttachment(models.Model):
""" """
An uploaded image which is associated with an object. 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() object_id = models.PositiveIntegerField()
parent = GenericForeignKey('content_type', 'object_id') parent = GenericForeignKey(
image = models.ImageField(upload_to=image_upload, height_field='image_height', width_field='image_width') 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_height = models.PositiveSmallIntegerField()
image_width = models.PositiveSmallIntegerField() image_width = models.PositiveSmallIntegerField()
name = models.CharField(max_length=50, blank=True) name = models.CharField(
created = models.DateTimeField(auto_now_add=True) max_length=50,
blank=True
)
created = models.DateTimeField(
auto_now_add=True
)
class Meta: class Meta:
ordering = ['name'] ordering = ['name']
@ -531,9 +608,20 @@ class ReportResult(models.Model):
""" """
This model stores the results from running a user-defined report. This model stores the results from running a user-defined report.
""" """
report = models.CharField(max_length=255, unique=True) report = models.CharField(
created = models.DateTimeField(auto_now_add=True) max_length=255,
user = models.ForeignKey(User, on_delete=models.SET_NULL, related_name='+', blank=True, null=True) 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() failed = models.BooleanField()
data = JSONField() 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. 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) time = models.DateTimeField(
user = models.ForeignKey(User, related_name='actions', on_delete=models.CASCADE) auto_now_add=True,
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) editable=False
object_id = models.PositiveIntegerField(blank=True, null=True) )
action = models.PositiveSmallIntegerField(choices=ACTION_CHOICES) user = models.ForeignKey(
message = models.TextField(blank=True) 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() objects = UserActionManager()

28
netbox/extras/tables.py Normal file
View File

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

View File

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

View File

@ -45,7 +45,7 @@ class CustomFieldTest(TestCase):
# Create a custom field # Create a custom field
cf = CustomField(type=data['field_type'], name='my_field', required=False) cf = CustomField(type=data['field_type'], name='my_field', required=False)
cf.save() cf.save()
cf.obj_type = [obj_type] cf.obj_type.set([obj_type])
cf.save() cf.save()
# Assign a value to the first Site # Assign a value to the first Site
@ -73,7 +73,7 @@ class CustomFieldTest(TestCase):
# Create a custom field # Create a custom field
cf = CustomField(type=CF_TYPE_SELECT, name='my_field', required=False) cf = CustomField(type=CF_TYPE_SELECT, name='my_field', required=False)
cf.save() cf.save()
cf.obj_type = [obj_type] cf.obj_type.set([obj_type])
cf.save() cf.save()
# Create some choices for the field # Create some choices for the field
@ -115,37 +115,37 @@ class CustomFieldAPITest(HttpStatusMixin, APITestCase):
# Text custom field # Text custom field
self.cf_text = CustomField(type=CF_TYPE_TEXT, name='magic_word') self.cf_text = CustomField(type=CF_TYPE_TEXT, name='magic_word')
self.cf_text.save() self.cf_text.save()
self.cf_text.obj_type = [content_type] self.cf_text.obj_type.set([content_type])
self.cf_text.save() self.cf_text.save()
# Integer custom field # Integer custom field
self.cf_integer = CustomField(type=CF_TYPE_INTEGER, name='magic_number') self.cf_integer = CustomField(type=CF_TYPE_INTEGER, name='magic_number')
self.cf_integer.save() self.cf_integer.save()
self.cf_integer.obj_type = [content_type] self.cf_integer.obj_type.set([content_type])
self.cf_integer.save() self.cf_integer.save()
# Boolean custom field # Boolean custom field
self.cf_boolean = CustomField(type=CF_TYPE_BOOLEAN, name='is_magic') self.cf_boolean = CustomField(type=CF_TYPE_BOOLEAN, name='is_magic')
self.cf_boolean.save() self.cf_boolean.save()
self.cf_boolean.obj_type = [content_type] self.cf_boolean.obj_type.set([content_type])
self.cf_boolean.save() self.cf_boolean.save()
# Date custom field # Date custom field
self.cf_date = CustomField(type=CF_TYPE_DATE, name='magic_date') self.cf_date = CustomField(type=CF_TYPE_DATE, name='magic_date')
self.cf_date.save() self.cf_date.save()
self.cf_date.obj_type = [content_type] self.cf_date.obj_type.set([content_type])
self.cf_date.save() self.cf_date.save()
# URL custom field # URL custom field
self.cf_url = CustomField(type=CF_TYPE_URL, name='magic_url') self.cf_url = CustomField(type=CF_TYPE_URL, name='magic_url')
self.cf_url.save() self.cf_url.save()
self.cf_url.obj_type = [content_type] self.cf_url.obj_type.set([content_type])
self.cf_url.save() self.cf_url.save()
# Select custom field # Select custom field
self.cf_select = CustomField(type=CF_TYPE_SELECT, name='magic_choice') self.cf_select = CustomField(type=CF_TYPE_SELECT, name='magic_choice')
self.cf_select.save() 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.save()
self.cf_select_choice1 = CustomFieldChoice(field=self.cf_select, value='Foo') self.cf_select_choice1 = CustomFieldChoice(field=self.cf_select, value='Foo')
self.cf_select_choice1.save() self.cf_select_choice1.save()

View File

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

View File

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

View File

@ -5,6 +5,7 @@ from collections import OrderedDict
from rest_framework import serializers from rest_framework import serializers
from rest_framework.reverse import reverse from rest_framework.reverse import reverse
from rest_framework.validators import UniqueTogetherValidator from rest_framework.validators import UniqueTogetherValidator
from taggit.models import Tag
from dcim.api.serializers import NestedDeviceSerializer, InterfaceSerializer, NestedSiteSerializer from dcim.api.serializers import NestedDeviceSerializer, InterfaceSerializer, NestedSiteSerializer
from dcim.models import Interface from dcim.models import Interface
@ -14,7 +15,9 @@ from ipam.constants import (
) )
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
from tenancy.api.serializers import NestedTenantSerializer from tenancy.api.serializers import NestedTenantSerializer
from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer from utilities.api import (
ChoiceFieldSerializer, SerializedPKRelatedField, TagField, ValidatedModelSerializer, WritableNestedSerializer,
)
from virtualization.api.serializers import NestedVirtualMachineSerializer from virtualization.api.serializers import NestedVirtualMachineSerializer
@ -23,17 +26,18 @@ from virtualization.api.serializers import NestedVirtualMachineSerializer
# #
class VRFSerializer(CustomFieldModelSerializer): class VRFSerializer(CustomFieldModelSerializer):
tenant = NestedTenantSerializer() tenant = NestedTenantSerializer(required=False, allow_null=True)
tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta: class Meta:
model = VRF model = VRF
fields = [ fields = [
'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'display_name', 'custom_fields', 'created', 'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'tags', 'display_name', 'custom_fields',
'last_updated', 'created', 'last_updated',
] ]
class NestedVRFSerializer(serializers.ModelSerializer): class NestedVRFSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail') url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail')
class Meta: class Meta:
@ -41,15 +45,6 @@ class NestedVRFSerializer(serializers.ModelSerializer):
fields = ['id', 'url', 'name', 'rd'] 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 # Roles
# #
@ -61,7 +56,7 @@ class RoleSerializer(ValidatedModelSerializer):
fields = ['id', 'name', 'slug', 'weight'] fields = ['id', 'name', 'slug', 'weight']
class NestedRoleSerializer(serializers.ModelSerializer): class NestedRoleSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail') url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail')
class Meta: class Meta:
@ -80,7 +75,7 @@ class RIRSerializer(ValidatedModelSerializer):
fields = ['id', 'name', 'slug', 'is_private'] fields = ['id', 'name', 'slug', 'is_private']
class NestedRIRSerializer(serializers.ModelSerializer): class NestedRIRSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail') url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail')
class Meta: class Meta:
@ -94,15 +89,18 @@ class NestedRIRSerializer(serializers.ModelSerializer):
class AggregateSerializer(CustomFieldModelSerializer): class AggregateSerializer(CustomFieldModelSerializer):
rir = NestedRIRSerializer() rir = NestedRIRSerializer()
tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta: class Meta:
model = Aggregate model = Aggregate
fields = [ fields = [
'id', 'family', 'prefix', 'rir', 'date_added', 'description', 'custom_fields', 'created', 'last_updated', 'id', 'family', 'prefix', 'rir', 'date_added', 'description', 'tags', 'custom_fields', 'created',
'last_updated',
] ]
read_only_fields = ['family']
class NestedAggregateSerializer(serializers.ModelSerializer): class NestedAggregateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail') url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail')
class Meta(AggregateSerializer.Meta): class Meta(AggregateSerializer.Meta):
@ -110,34 +108,12 @@ class NestedAggregateSerializer(serializers.ModelSerializer):
fields = ['id', 'url', 'family', 'prefix'] 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 # VLAN groups
# #
class VLANGroupSerializer(serializers.ModelSerializer): class VLANGroupSerializer(ValidatedModelSerializer):
site = NestedSiteSerializer() site = NestedSiteSerializer(required=False, allow_null=True)
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 Meta: class Meta:
model = VLANGroup model = VLANGroup
@ -154,46 +130,37 @@ class WritableVLANGroupSerializer(serializers.ModelSerializer):
validator(data) validator(data)
# Enforce model validation # Enforce model validation
super(WritableVLANGroupSerializer, self).validate(data) super(VLANGroupSerializer, self).validate(data)
return 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 # VLANs
# #
class VLANSerializer(CustomFieldModelSerializer): class VLANSerializer(CustomFieldModelSerializer):
site = NestedSiteSerializer() site = NestedSiteSerializer(required=False, allow_null=True)
group = NestedVLANGroupSerializer() group = NestedVLANGroupSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer() tenant = NestedTenantSerializer(required=False, allow_null=True)
status = ChoiceFieldSerializer(choices=VLAN_STATUS_CHOICES) status = ChoiceFieldSerializer(choices=VLAN_STATUS_CHOICES, required=False)
role = NestedRoleSerializer() role = NestedRoleSerializer(required=False, allow_null=True)
tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta: class Meta:
model = VLAN model = VLAN
fields = [ fields = [
'id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'display_name', 'id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'tags', 'display_name',
'custom_fields', 'created', 'last_updated', 'custom_fields', 'created', 'last_updated',
] ]
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 = [] validators = []
def validate(self, data): def validate(self, data):
@ -206,32 +173,42 @@ class WritableVLANSerializer(CustomFieldModelSerializer):
validator(data) validator(data)
# Enforce model validation # Enforce model validation
super(WritableVLANSerializer, self).validate(data) super(VLANSerializer, self).validate(data)
return 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 # Prefixes
# #
class PrefixSerializer(CustomFieldModelSerializer): class PrefixSerializer(CustomFieldModelSerializer):
site = NestedSiteSerializer() site = NestedSiteSerializer(required=False, allow_null=True)
vrf = NestedVRFSerializer() vrf = NestedVRFSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer() tenant = NestedTenantSerializer(required=False, allow_null=True)
vlan = NestedVLANSerializer() vlan = NestedVLANSerializer(required=False, allow_null=True)
status = ChoiceFieldSerializer(choices=PREFIX_STATUS_CHOICES) status = ChoiceFieldSerializer(choices=PREFIX_STATUS_CHOICES, required=False)
role = NestedRoleSerializer() role = NestedRoleSerializer(required=False, allow_null=True)
tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta: class Meta:
model = Prefix model = Prefix
fields = [ fields = [
'id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description', 'id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description',
'custom_fields', 'created', 'last_updated', 'tags', 'custom_fields', 'created', 'last_updated',
] ]
read_only_fields = ['family']
class NestedPrefixSerializer(serializers.ModelSerializer): class NestedPrefixSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail') url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail')
class Meta: class Meta:
@ -239,16 +216,6 @@ class NestedPrefixSerializer(serializers.ModelSerializer):
fields = ['id', 'url', 'family', 'prefix'] 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): class AvailablePrefixSerializer(serializers.Serializer):
def to_representation(self, instance): def to_representation(self, instance):
@ -288,21 +255,23 @@ class IPAddressInterfaceSerializer(serializers.ModelSerializer):
class IPAddressSerializer(CustomFieldModelSerializer): class IPAddressSerializer(CustomFieldModelSerializer):
vrf = NestedVRFSerializer() vrf = NestedVRFSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer() tenant = NestedTenantSerializer(required=False, allow_null=True)
status = ChoiceFieldSerializer(choices=IPADDRESS_STATUS_CHOICES) status = ChoiceFieldSerializer(choices=IPADDRESS_STATUS_CHOICES, required=False)
role = ChoiceFieldSerializer(choices=IPADDRESS_ROLE_CHOICES) role = ChoiceFieldSerializer(choices=IPADDRESS_ROLE_CHOICES, required=False)
interface = IPAddressInterfaceSerializer() interface = IPAddressInterfaceSerializer(required=False, allow_null=True)
tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta: class Meta:
model = IPAddress model = IPAddress
fields = [ fields = [
'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'description', 'nat_inside', 'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'description', 'nat_inside',
'nat_outside', 'custom_fields', 'created', 'last_updated', 'nat_outside', 'tags', 'custom_fields', 'created', 'last_updated',
] ]
read_only_fields = ['family']
class NestedIPAddressSerializer(serializers.ModelSerializer): class NestedIPAddressSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail') url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail')
class Meta: class Meta:
@ -310,18 +279,8 @@ class NestedIPAddressSerializer(serializers.ModelSerializer):
fields = ['id', 'url', 'family', 'address'] fields = ['id', 'url', 'family', 'address']
IPAddressSerializer._declared_fields['nat_inside'] = NestedIPAddressSerializer() IPAddressSerializer._declared_fields['nat_inside'] = NestedIPAddressSerializer(required=False, allow_null=True)
IPAddressSerializer._declared_fields['nat_outside'] = NestedIPAddressSerializer() IPAddressSerializer._declared_fields['nat_outside'] = NestedIPAddressSerializer(read_only=True)
class WritableIPAddressSerializer(CustomFieldModelSerializer):
class Meta:
model = IPAddress
fields = [
'id', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'description', 'nat_inside',
'custom_fields', 'created', 'last_updated',
]
class AvailableIPSerializer(serializers.Serializer): class AvailableIPSerializer(serializers.Serializer):
@ -342,22 +301,16 @@ class AvailableIPSerializer(serializers.Serializer):
# Services # Services
# #
class ServiceSerializer(serializers.ModelSerializer): class ServiceSerializer(ValidatedModelSerializer):
device = NestedDeviceSerializer() device = NestedDeviceSerializer(required=False, allow_null=True)
virtual_machine = NestedVirtualMachineSerializer() virtual_machine = NestedVirtualMachineSerializer(required=False, allow_null=True)
protocol = ChoiceFieldSerializer(choices=IP_PROTOCOL_CHOICES) protocol = ChoiceFieldSerializer(choices=IP_PROTOCOL_CHOICES)
ipaddresses = NestedIPAddressSerializer(many=True) ipaddresses = SerializedPKRelatedField(
queryset=IPAddress.objects.all(),
class Meta: serializer=NestedIPAddressSerializer,
model = Service required=False,
fields = [ many=True
'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):
class Meta: class Meta:
model = Service model = Service

View File

@ -35,7 +35,6 @@ class IPAMFieldChoicesViewSet(FieldChoicesViewSet):
class VRFViewSet(CustomFieldModelViewSet): class VRFViewSet(CustomFieldModelViewSet):
queryset = VRF.objects.select_related('tenant') queryset = VRF.objects.select_related('tenant')
serializer_class = serializers.VRFSerializer serializer_class = serializers.VRFSerializer
write_serializer_class = serializers.WritableVRFSerializer
filter_class = filters.VRFFilter filter_class = filters.VRFFilter
@ -56,7 +55,6 @@ class RIRViewSet(ModelViewSet):
class AggregateViewSet(CustomFieldModelViewSet): class AggregateViewSet(CustomFieldModelViewSet):
queryset = Aggregate.objects.select_related('rir') queryset = Aggregate.objects.select_related('rir')
serializer_class = serializers.AggregateSerializer serializer_class = serializers.AggregateSerializer
write_serializer_class = serializers.WritableAggregateSerializer
filter_class = filters.AggregateFilter filter_class = filters.AggregateFilter
@ -77,7 +75,6 @@ class RoleViewSet(ModelViewSet):
class PrefixViewSet(CustomFieldModelViewSet): class PrefixViewSet(CustomFieldModelViewSet):
queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
serializer_class = serializers.PrefixSerializer serializer_class = serializers.PrefixSerializer
write_serializer_class = serializers.WritablePrefixSerializer
filter_class = filters.PrefixFilter filter_class = filters.PrefixFilter
@detail_route(url_path='available-prefixes', methods=['get', 'post']) @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 # Initialize the serializer with a list or a single object depending on what was requested
if isinstance(request.data, list): if isinstance(request.data, list):
serializer = serializers.WritablePrefixSerializer(data=requested_prefixes, many=True) serializer = serializers.PrefixSerializer(data=requested_prefixes, many=True)
else: else:
serializer = serializers.WritablePrefixSerializer(data=requested_prefixes[0]) serializer = serializers.PrefixSerializer(data=requested_prefixes[0])
# Create the new Prefix(es) # Create the new Prefix(es)
if serializer.is_valid(): 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 # Initialize the serializer with a list or a single object depending on what was requested
if isinstance(request.data, list): if isinstance(request.data, list):
serializer = serializers.WritableIPAddressSerializer(data=requested_ips, many=True) serializer = serializers.IPAddressSerializer(data=requested_ips, many=True)
else: else:
serializer = serializers.WritableIPAddressSerializer(data=requested_ips[0]) serializer = serializers.IPAddressSerializer(data=requested_ips[0])
# Create the new IP address(es) # Create the new IP address(es)
if serializer.is_valid(): if serializer.is_valid():
@ -223,7 +220,6 @@ class IPAddressViewSet(CustomFieldModelViewSet):
'nat_outside' 'nat_outside'
) )
serializer_class = serializers.IPAddressSerializer serializer_class = serializers.IPAddressSerializer
write_serializer_class = serializers.WritableIPAddressSerializer
filter_class = filters.IPAddressFilter filter_class = filters.IPAddressFilter
@ -234,7 +230,6 @@ class IPAddressViewSet(CustomFieldModelViewSet):
class VLANGroupViewSet(ModelViewSet): class VLANGroupViewSet(ModelViewSet):
queryset = VLANGroup.objects.select_related('site') queryset = VLANGroup.objects.select_related('site')
serializer_class = serializers.VLANGroupSerializer serializer_class = serializers.VLANGroupSerializer
write_serializer_class = serializers.WritableVLANGroupSerializer
filter_class = filters.VLANGroupFilter filter_class = filters.VLANGroupFilter
@ -245,7 +240,6 @@ class VLANGroupViewSet(ModelViewSet):
class VLANViewSet(CustomFieldModelViewSet): class VLANViewSet(CustomFieldModelViewSet):
queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role') queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')
serializer_class = serializers.VLANSerializer serializer_class = serializers.VLANSerializer
write_serializer_class = serializers.WritableVLANSerializer
filter_class = filters.VLANFilter filter_class = filters.VLANFilter
@ -256,5 +250,4 @@ class VLANViewSet(CustomFieldModelViewSet):
class ServiceViewSet(ModelViewSet): class ServiceViewSet(ModelViewSet):
queryset = Service.objects.select_related('device') queryset = Service.objects.select_related('device')
serializer_class = serializers.ServiceSerializer serializer_class = serializers.ServiceSerializer
write_serializer_class = serializers.WritableServiceSerializer
filter_class = filters.ServiceFilter filter_class = filters.ServiceFilter

View File

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

View File

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

View File

@ -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'),
),
]

View File

@ -10,10 +10,10 @@ from django.db.models import Q
from django.db.models.expressions import RawSQL from django.db.models.expressions import RawSQL
from django.urls import reverse from django.urls import reverse
from django.utils.encoding import python_2_unicode_compatible from django.utils.encoding import python_2_unicode_compatible
from taggit.managers import TaggableManager
from dcim.models import Interface from dcim.models import Interface
from extras.models import CustomFieldModel, CustomFieldValue from extras.models import CustomFieldModel
from tenancy.models import Tenant
from utilities.models import CreatedUpdatedModel from utilities.models import CreatedUpdatedModel
from .constants import * from .constants import *
from .fields import IPNetworkField, IPAddressField 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 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.) are said to exist in the "global" table.)
""" """
name = models.CharField(max_length=50) name = models.CharField(
rd = models.CharField(max_length=21, unique=True, verbose_name='Route distinguisher') max_length=50
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', rd = models.CharField(
help_text="Prevent duplicate prefixes/IP addresses within this VRF") max_length=21,
description = models.CharField(max_length=100, blank=True) unique=True,
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') 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'] 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 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. 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) name = models.CharField(
slug = models.SlugField(unique=True) max_length=50,
is_private = models.BooleanField(default=False, verbose_name='Private', unique=True
help_text='IP space managed by this RIR is considered private') )
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'] 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 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. 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() prefix = IPNetworkField()
rir = models.ForeignKey('RIR', related_name='aggregates', on_delete=models.PROTECT, verbose_name='RIR') rir = models.ForeignKey(
date_added = models.DateField(blank=True, null=True) to='ipam.RIR',
description = models.CharField(max_length=100, blank=True) on_delete=models.PROTECT,
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') 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'] 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 A Role represents the functional role of a Prefix or VLAN; for example, "Customer," "Infrastructure," or
"Management." "Management."
""" """
name = models.CharField(max_length=50, unique=True) name = models.CharField(
slug = models.SlugField(unique=True) max_length=50,
weight = models.PositiveSmallIntegerField(default=1000) unique=True
)
slug = models.SlugField(
unique=True
)
weight = models.PositiveSmallIntegerField(
default=1000
)
csv_headers = ['name', 'slug', 'weight'] 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 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. assigned to a VLAN where appropriate.
""" """
family = models.PositiveSmallIntegerField(choices=AF_CHOICES, editable=False) family = models.PositiveSmallIntegerField(
prefix = IPNetworkField(help_text="IPv4 or IPv6 network with mask") choices=AF_CHOICES,
site = models.ForeignKey('dcim.Site', related_name='prefixes', on_delete=models.PROTECT, blank=True, null=True) editable=False
vrf = models.ForeignKey('VRF', related_name='prefixes', on_delete=models.PROTECT, blank=True, null=True, )
verbose_name='VRF') prefix = IPNetworkField(
tenant = models.ForeignKey(Tenant, related_name='prefixes', blank=True, null=True, on_delete=models.PROTECT) help_text='IPv4 or IPv6 network with mask'
vlan = models.ForeignKey('VLAN', related_name='prefixes', on_delete=models.PROTECT, blank=True, null=True, )
verbose_name='VLAN') site = models.ForeignKey(
status = models.PositiveSmallIntegerField('Status', choices=PREFIX_STATUS_CHOICES, default=PREFIX_STATUS_ACTIVE, to='dcim.Site',
help_text="Operational status of this prefix") on_delete=models.PROTECT,
role = models.ForeignKey('Role', related_name='prefixes', on_delete=models.SET_NULL, blank=True, null=True, related_name='prefixes',
help_text="The primary function of this prefix") blank=True,
is_pool = models.BooleanField(verbose_name='Is a pool', default=False, null=True
help_text="All IP addresses within this prefix are considered usable") )
description = models.CharField(max_length=100, blank=True) vrf = models.ForeignKey(
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') 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() objects = PrefixQuerySet.as_manager()
tags = TaggableManager()
csv_headers = [ csv_headers = [
'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description', 'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description',
@ -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 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. 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) family = models.PositiveSmallIntegerField(
address = IPAddressField(help_text="IPv4 or IPv6 address (with mask)") choices=AF_CHOICES,
vrf = models.ForeignKey('VRF', related_name='ip_addresses', on_delete=models.PROTECT, blank=True, null=True, editable=False
verbose_name='VRF') )
tenant = models.ForeignKey(Tenant, related_name='ip_addresses', blank=True, null=True, on_delete=models.PROTECT) 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 = 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' help_text='The operational status of this IP'
) )
role = models.PositiveSmallIntegerField( 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() objects = IPAddressManager()
tags = TaggableManager()
csv_headers = [ csv_headers = [
'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface_name', 'is_primary', 'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface_name', 'is_primary',
@ -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. 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() 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'] 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 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. or more Prefixes assigned to it.
""" """
site = models.ForeignKey('dcim.Site', related_name='vlans', on_delete=models.PROTECT, blank=True, null=True) site = models.ForeignKey(
group = models.ForeignKey('VLANGroup', related_name='vlans', blank=True, null=True, on_delete=models.PROTECT) to='dcim.Site',
vid = models.PositiveSmallIntegerField(verbose_name='ID', validators=[ on_delete=models.PROTECT,
MinValueValidator(1), related_name='vlans',
MaxValueValidator(4094) blank=True,
]) null=True
name = models.CharField(max_length=64) )
tenant = models.ForeignKey(Tenant, related_name='vlans', blank=True, null=True, on_delete=models.PROTECT) group = models.ForeignKey(
status = models.PositiveSmallIntegerField('Status', choices=VLAN_STATUS_CHOICES, default=1) to='ipam.VLANGroup',
role = models.ForeignKey('Role', related_name='vlans', on_delete=models.SET_NULL, blank=True, null=True) on_delete=models.PROTECT,
description = models.CharField(max_length=100, blank=True) related_name='vlans',
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') 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'] csv_headers = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description']

View File

@ -22,7 +22,7 @@ if sys.version_info[0] < 3:
DeprecationWarning DeprecationWarning
) )
VERSION = '2.3.3-dev' VERSION = '2.4-dev'
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@ -139,6 +139,7 @@ INSTALLED_APPS = [
'django_tables2', 'django_tables2',
'mptt', 'mptt',
'rest_framework', 'rest_framework',
'taggit',
'timezone_field', 'timezone_field',
'circuits', 'circuits',
'dcim', 'dcim',
@ -164,7 +165,6 @@ MIDDLEWARE = (
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',

View File

@ -2,10 +2,11 @@ from __future__ import unicode_literals
from rest_framework import serializers from rest_framework import serializers
from rest_framework.validators import UniqueTogetherValidator from rest_framework.validators import UniqueTogetherValidator
from taggit.models import Tag
from dcim.api.serializers import NestedDeviceSerializer from dcim.api.serializers import NestedDeviceSerializer
from secrets.models import Secret, SecretRole from secrets.models import Secret, SecretRole
from utilities.api import ValidatedModelSerializer from utilities.api import TagField, ValidatedModelSerializer, WritableNestedSerializer
# #
@ -19,7 +20,7 @@ class SecretRoleSerializer(ValidatedModelSerializer):
fields = ['id', 'name', 'slug'] fields = ['id', 'name', 'slug']
class NestedSecretRoleSerializer(serializers.ModelSerializer): class NestedSecretRoleSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='secrets-api:secretrole-detail') url = serializers.HyperlinkedIdentityField(view_name='secrets-api:secretrole-detail')
class Meta: class Meta:
@ -31,21 +32,15 @@ class NestedSecretRoleSerializer(serializers.ModelSerializer):
# Secrets # Secrets
# #
class SecretSerializer(serializers.ModelSerializer): class SecretSerializer(ValidatedModelSerializer):
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
role = NestedSecretRoleSerializer() role = NestedSecretRoleSerializer()
class Meta:
model = Secret
fields = ['id', 'device', 'role', 'name', 'plaintext', 'hash', 'created', 'last_updated']
class WritableSecretSerializer(serializers.ModelSerializer):
plaintext = serializers.CharField() plaintext = serializers.CharField()
tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta: class Meta:
model = Secret model = Secret
fields = ['id', 'device', 'role', 'name', 'plaintext', 'hash', 'created', 'last_updated'] fields = ['id', 'device', 'role', 'name', 'plaintext', 'hash', 'tags', 'created', 'last_updated']
validators = [] validators = []
def validate(self, data): def validate(self, data):
@ -64,6 +59,6 @@ class WritableSecretSerializer(serializers.ModelSerializer):
validator(data) validator(data)
# Enforce model validation # Enforce model validation
super(WritableSecretSerializer, self).validate(data) super(SecretSerializer, self).validate(data)
return data return data

View File

@ -51,7 +51,6 @@ class SecretViewSet(ModelViewSet):
'role__users', 'role__groups', 'role__users', 'role__groups',
) )
serializer_class = serializers.SecretSerializer serializer_class = serializers.SecretSerializer
write_serializer_class = serializers.WritableSecretSerializer
filter_class = filters.SecretFilter filter_class = filters.SecretFilter
master_key = None master_key = None
@ -68,7 +67,7 @@ class SecretViewSet(ModelViewSet):
super(SecretViewSet, self).initial(request, *args, **kwargs) 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 # 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. # order to encrypt/decrypt secrets.

View File

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

View File

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

View File

@ -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'),
),
]

View File

@ -12,8 +12,8 @@ from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.encoding import force_bytes, python_2_unicode_compatible from django.utils.encoding import force_bytes, python_2_unicode_compatible
from taggit.managers import TaggableManager
from dcim.models import Device
from utilities.models import CreatedUpdatedModel from utilities.models import CreatedUpdatedModel
from .exceptions import InvalidKey from .exceptions import InvalidKey
from .hashers import SecretValidationHasher 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 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. matching (private) decryption key.
""" """
user = models.OneToOneField(User, related_name='user_key', editable=False, on_delete=models.CASCADE) user = models.OneToOneField(
public_key = models.TextField(verbose_name='RSA public key') to=User,
master_key_cipher = models.BinaryField(max_length=512, blank=True, null=True, editable=False) 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() 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. 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) userkey = models.OneToOneField(
cipher = models.BinaryField(max_length=512, editable=False) to='secrets.UserKey',
hash = models.CharField(max_length=128, editable=False) on_delete=models.CASCADE,
created = models.DateTimeField(auto_now_add=True) 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 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 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. access to the appropriate SecretRoles either individually or by group.
""" """
name = models.CharField(max_length=50, unique=True) name = models.CharField(
slug = models.SlugField(unique=True) max_length=50,
users = models.ManyToManyField(User, related_name='secretroles', blank=True) unique=True
groups = models.ManyToManyField(Group, related_name='secretroles', blank=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'] 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 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. 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) device = models.ForeignKey(
role = models.ForeignKey('SecretRole', related_name='secrets', on_delete=models.PROTECT) to='dcim.Device',
name = models.CharField(max_length=100, blank=True) on_delete=models.CASCADE,
ciphertext = models.BinaryField(editable=False, max_length=65568) # 16B IV + 2B pad length + {62-65550}B padded related_name='secrets'
hash = models.CharField(max_length=128, editable=False) )
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 plaintext = None
csv_headers = ['device', 'role', 'name', 'plaintext'] csv_headers = ['device', 'role', 'name', 'plaintext']

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,10 +1,11 @@
from __future__ import unicode_literals from __future__ import unicode_literals
from rest_framework import serializers from rest_framework import serializers
from taggit.models import Tag
from extras.api.customfields import CustomFieldModelSerializer from extras.api.customfields import CustomFieldModelSerializer
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from utilities.api import ValidatedModelSerializer from utilities.api import TagField, ValidatedModelSerializer, WritableNestedSerializer
# #
@ -18,7 +19,7 @@ class TenantGroupSerializer(ValidatedModelSerializer):
fields = ['id', 'name', 'slug'] fields = ['id', 'name', 'slug']
class NestedTenantGroupSerializer(serializers.ModelSerializer): class NestedTenantGroupSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenantgroup-detail') url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenantgroup-detail')
class Meta: class Meta:
@ -31,23 +32,20 @@ class NestedTenantGroupSerializer(serializers.ModelSerializer):
# #
class TenantSerializer(CustomFieldModelSerializer): class TenantSerializer(CustomFieldModelSerializer):
group = NestedTenantGroupSerializer() group = NestedTenantGroupSerializer(required=False)
tags = TagField(queryset=Tag.objects.all(), required=False, many=True)
class Meta: class Meta:
model = Tenant model = Tenant
fields = ['id', 'name', 'slug', 'group', 'description', 'comments', 'custom_fields', 'created', 'last_updated'] fields = [
'id', 'name', 'slug', 'group', 'description', 'comments', 'tags', 'custom_fields', 'created',
'last_updated',
]
class NestedTenantSerializer(serializers.ModelSerializer): class NestedTenantSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenant-detail') url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenant-detail')
class Meta: class Meta:
model = Tenant model = Tenant
fields = ['id', 'url', 'name', 'slug'] fields = ['id', 'url', 'name', 'slug']
class WritableTenantSerializer(CustomFieldModelSerializer):
class Meta:
model = Tenant
fields = ['id', 'name', 'slug', 'group', 'description', 'comments', 'custom_fields', 'created', 'last_updated']

View File

@ -32,5 +32,4 @@ class TenantGroupViewSet(ModelViewSet):
class TenantViewSet(CustomFieldModelViewSet): class TenantViewSet(CustomFieldModelViewSet):
queryset = Tenant.objects.select_related('group') queryset = Tenant.objects.select_related('group')
serializer_class = serializers.TenantSerializer serializer_class = serializers.TenantSerializer
write_serializer_class = serializers.WritableTenantSerializer
filter_class = filters.TenantFilter filter_class = filters.TenantFilter

View File

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

View File

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

View File

@ -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'),
),
]

View File

@ -4,8 +4,9 @@ from django.contrib.contenttypes.fields import GenericRelation
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.encoding import python_2_unicode_compatible from django.utils.encoding import python_2_unicode_compatible
from taggit.managers import TaggableManager
from extras.models import CustomFieldModel, CustomFieldValue from extras.models import CustomFieldModel
from utilities.models import CreatedUpdatedModel from utilities.models import CreatedUpdatedModel
@ -14,8 +15,13 @@ class TenantGroup(models.Model):
""" """
An arbitrary collection of Tenants. An arbitrary collection of Tenants.
""" """
name = models.CharField(max_length=50, unique=True) name = models.CharField(
slug = models.SlugField(unique=True) max_length=50,
unique=True
)
slug = models.SlugField(
unique=True
)
csv_headers = ['name', 'slug'] 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 A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal
department. department.
""" """
name = models.CharField(max_length=30, unique=True) name = models.CharField(
slug = models.SlugField(unique=True) max_length=30,
group = models.ForeignKey('TenantGroup', related_name='tenants', blank=True, null=True, on_delete=models.SET_NULL) unique=True
description = models.CharField(max_length=100, blank=True, help_text="Long-form name (optional)") )
comments = models.TextField(blank=True) slug = models.SlugField(
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') 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'] csv_headers = ['name', 'slug', 'group', 'description', 'comments']

View File

@ -1,10 +1,11 @@
from __future__ import unicode_literals from __future__ import unicode_literals
from django.contrib.auth.models import User 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: class Meta:
model = User model = User

View File

@ -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. 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. It also supports setting an expiration time and toggling write ability.
""" """
user = models.ForeignKey(User, related_name='tokens', on_delete=models.CASCADE) user = models.ForeignKey(
created = models.DateTimeField(auto_now_add=True) to=User,
expires = models.DateTimeField(blank=True, null=True) on_delete=models.CASCADE,
key = models.CharField(max_length=40, unique=True, validators=[MinLengthValidator(40)]) related_name='tokens'
write_enabled = models.BooleanField(default=True, help_text="Permit create/update/delete operations using this key") )
description = models.CharField(max_length=100, blank=True) 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: class Meta:
default_permissions = [] default_permissions = []

View File

@ -5,13 +5,15 @@ import pytz
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import ManyToManyField from django.db.models import ManyToManyField
from django.http import Http404 from django.http import Http404
from rest_framework import mixins from rest_framework import mixins
from rest_framework.exceptions import APIException from rest_framework.exceptions import APIException
from rest_framework.permissions import BasePermission from rest_framework.permissions import BasePermission
from rest_framework.relations import PrimaryKeyRelatedField
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.serializers import Field, ModelSerializer, ValidationError from rest_framework.serializers import Field, ModelSerializer, RelatedField, ValidationError
from rest_framework.viewsets import GenericViewSet, ViewSet from rest_framework.viewsets import GenericViewSet, ViewSet
WRITE_OPERATIONS = ['create', 'update', 'partial_update', 'delete'] WRITE_OPERATIONS = ['create', 'update', 'partial_update', 'delete']
@ -33,7 +35,93 @@ class IsAuthenticatedOrLoginNotRequired(BasePermission):
def has_permission(self, request, view): def has_permission(self, request, view):
if not settings.LOGIN_REQUIRED: if not settings.LOGIN_REQUIRED:
return True 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': <DB value>, 'label': <string>}.
"""
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 '<app_label>.<model>'
"""
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 return data
class ChoiceFieldSerializer(Field): class WritableNestedSerializer(ModelSerializer):
""" """
Represent a ChoiceField as {'value': <DB value>, 'label': <string>}. 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): def to_internal_value(self, data):
return self._choices.get(data) if data is None:
return None
class ContentTypeFieldSerializer(Field):
"""
Represent a ContentType as '<app_label>.<model>'
"""
def to_representation(self, obj):
return "{}.{}".format(obj.app_label, obj.model)
def to_internal_value(self, data):
app_label, model = data.split('.')
try: try:
return ContentType.objects.get_by_natural_key(app_label=app_label, model=model) return self.Meta.model.objects.get(pk=data)
except ContentType.DoesNotExist: except ObjectDoesNotExist:
raise ValidationError("Invalid content type") raise ValidationError("Invalid ID")
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))
# #
@ -132,16 +179,8 @@ class ModelViewSet(mixins.CreateModelMixin,
mixins.ListModelMixin, mixins.ListModelMixin,
GenericViewSet): GenericViewSet):
""" """
Substitute DRF's built-in ModelViewSet for our own, which introduces a bit of additional functionality: Accept either a single object or a list of objects to create.
1. Use an alternate serializer (if provided) for write operations
2. 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): def get_serializer(self, *args, **kwargs):
# If a list of objects has been provided, initialize the serializer with many=True # If a list of objects has been provided, initialize the serializer with many=True
if isinstance(kwargs.get('data', {}), list): if isinstance(kwargs.get('data', {}), list):

Some files were not shown because too many files have changed in this diff Show More