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

View File

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

View File

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

View File

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

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.urls import reverse
from django.utils.encoding import python_2_unicode_compatible
from taggit.managers import TaggableManager
from dcim.constants import STATUS_CLASSES
from dcim.fields import ASNField
from extras.models import CustomFieldModel, CustomFieldValue
from tenancy.models import Tenant
from extras.models import CustomFieldModel
from utilities.models import CreatedUpdatedModel
from .constants import CIRCUIT_STATUS_ACTIVE, CIRCUIT_STATUS_CHOICES, TERM_SIDE_CHOICES
@ -19,15 +19,45 @@ class Provider(CreatedUpdatedModel, CustomFieldModel):
Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model
stores information pertinent to the user's relationship with the Provider.
"""
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
asn = ASNField(blank=True, null=True, verbose_name='ASN')
account = models.CharField(max_length=30, blank=True, verbose_name='Account number')
portal_url = models.URLField(blank=True, verbose_name='Portal')
noc_contact = models.TextField(blank=True, verbose_name='NOC contact')
admin_contact = models.TextField(blank=True, verbose_name='Admin contact')
comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
name = models.CharField(
max_length=50,
unique=True
)
slug = models.SlugField(
unique=True
)
asn = ASNField(
blank=True,
null=True,
verbose_name='ASN'
)
account = models.CharField(
max_length=30,
blank=True,
verbose_name='Account number'
)
portal_url = models.URLField(
blank=True,
verbose_name='Portal'
)
noc_contact = models.TextField(
blank=True,
verbose_name='NOC contact'
)
admin_contact = models.TextField(
blank=True,
verbose_name='Admin contact'
)
comments = models.TextField(
blank=True
)
custom_field_values = GenericRelation(
to='extras.CustomFieldValue',
content_type_field='obj_type',
object_id_field='obj_id'
)
tags = TaggableManager()
csv_headers = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']
@ -63,8 +93,13 @@ class CircuitType(models.Model):
Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named
"Long Haul," "Metro," or "Out-of-Band".
"""
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
name = models.CharField(
max_length=50,
unique=True
)
slug = models.SlugField(
unique=True
)
csv_headers = ['name', 'slug']
@ -91,16 +126,54 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
circuits. Each circuit is also assigned a CircuitType and a Site. A Circuit may be terminated to a specific device
interface, but this is not required. Circuit port speed and commit rate are measured in Kbps.
"""
cid = models.CharField(max_length=50, verbose_name='Circuit ID')
provider = models.ForeignKey('Provider', related_name='circuits', on_delete=models.PROTECT)
type = models.ForeignKey('CircuitType', related_name='circuits', on_delete=models.PROTECT)
status = models.PositiveSmallIntegerField(choices=CIRCUIT_STATUS_CHOICES, default=CIRCUIT_STATUS_ACTIVE)
tenant = models.ForeignKey(Tenant, related_name='circuits', blank=True, null=True, on_delete=models.PROTECT)
install_date = models.DateField(blank=True, null=True, verbose_name='Date installed')
commit_rate = models.PositiveIntegerField(blank=True, null=True, verbose_name='Commit rate (Kbps)')
description = models.CharField(max_length=100, blank=True)
comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
cid = models.CharField(
max_length=50,
verbose_name='Circuit ID'
)
provider = models.ForeignKey(
to='circuits.Provider',
on_delete=models.PROTECT,
related_name='circuits'
)
type = models.ForeignKey(
to='CircuitType',
on_delete=models.PROTECT,
related_name='circuits'
)
status = models.PositiveSmallIntegerField(
choices=CIRCUIT_STATUS_CHOICES,
default=CIRCUIT_STATUS_ACTIVE
)
tenant = models.ForeignKey(
to='tenancy.Tenant',
on_delete=models.PROTECT,
related_name='circuits',
blank=True,
null=True
)
install_date = models.DateField(
blank=True,
null=True,
verbose_name='Date installed'
)
commit_rate = models.PositiveIntegerField(
blank=True,
null=True,
verbose_name='Commit rate (Kbps)')
description = models.CharField(
max_length=100,
blank=True
)
comments = models.TextField(
blank=True
)
custom_field_values = GenericRelation(
to='extras.CustomFieldValue',
content_type_field='obj_type',
object_id_field='obj_id'
)
tags = TaggableManager()
csv_headers = [
'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
@ -153,19 +226,47 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
@python_2_unicode_compatible
class CircuitTermination(models.Model):
circuit = models.ForeignKey('Circuit', related_name='terminations', on_delete=models.CASCADE)
term_side = models.CharField(max_length=1, choices=TERM_SIDE_CHOICES, verbose_name='Termination')
site = models.ForeignKey('dcim.Site', related_name='circuit_terminations', on_delete=models.PROTECT)
interface = models.OneToOneField(
'dcim.Interface', related_name='circuit_termination', blank=True, null=True, on_delete=models.PROTECT
circuit = models.ForeignKey(
to='circuits.Circuit',
on_delete=models.CASCADE,
related_name='terminations'
)
term_side = models.CharField(
max_length=1,
choices=TERM_SIDE_CHOICES,
verbose_name='Termination'
)
site = models.ForeignKey(
to='dcim.Site',
on_delete=models.PROTECT,
related_name='circuit_terminations'
)
interface = models.OneToOneField(
to='dcim.Interface',
on_delete=models.PROTECT,
related_name='circuit_termination',
blank=True,
null=True
)
port_speed = models.PositiveIntegerField(
verbose_name='Port speed (Kbps)'
)
port_speed = models.PositiveIntegerField(verbose_name='Port speed (Kbps)')
upstream_speed = models.PositiveIntegerField(
blank=True, null=True, verbose_name='Upstream speed (Kbps)',
blank=True,
null=True,
verbose_name='Upstream speed (Kbps)',
help_text='Upstream speed, if different from port speed'
)
xconnect_id = models.CharField(max_length=50, blank=True, verbose_name='Cross-connect ID')
pp_info = models.CharField(max_length=100, blank=True, verbose_name='Patch panel/port(s)')
xconnect_id = models.CharField(
max_length=50,
blank=True,
verbose_name='Cross-connect ID'
)
pp_info = models.CharField(
max_length=100,
blank=True,
verbose_name='Patch panel/port(s)'
)
class Meta:
ordering = ['circuit', 'term_side']

View File

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

View File

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

View File

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

View File

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

View File

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

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.utils.encoding import python_2_unicode_compatible
from mptt.models import MPTTModel, TreeForeignKey
from taggit.managers import TaggableManager
from timezone_field import TimeZoneField
from circuits.models import Circuit
from extras.models import CustomFieldModel, CustomFieldValue, ImageAttachment
from extras.models import CustomFieldModel
from extras.rpc import RPC_CLIENTS
from tenancy.models import Tenant
from utilities.fields import ColorField, NullableCharField
from utilities.managers import NaturalOrderByManager
from utilities.models import CreatedUpdatedModel
@ -38,10 +38,20 @@ class Region(MPTTModel):
Sites can be grouped within geographic Regions.
"""
parent = TreeForeignKey(
'self', null=True, blank=True, related_name='children', db_index=True, on_delete=models.CASCADE
to='self',
on_delete=models.CASCADE,
related_name='children',
blank=True,
null=True,
db_index=True
)
name = models.CharField(
max_length=50,
unique=True
)
slug = models.SlugField(
unique=True
)
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
csv_headers = ['name', 'slug', 'parent']
@ -78,25 +88,81 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
A Site represents a geographic location within a network; typically a building or campus. The optional facility
field can be used to include an external designation, such as a data center name (e.g. Equinix SV6).
"""
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
status = models.PositiveSmallIntegerField(choices=SITE_STATUS_CHOICES, default=SITE_STATUS_ACTIVE)
region = models.ForeignKey('Region', related_name='sites', blank=True, null=True, on_delete=models.SET_NULL)
tenant = models.ForeignKey(Tenant, related_name='sites', blank=True, null=True, on_delete=models.PROTECT)
facility = models.CharField(max_length=50, blank=True)
asn = ASNField(blank=True, null=True, verbose_name='ASN')
time_zone = TimeZoneField(blank=True)
description = models.CharField(max_length=100, blank=True)
physical_address = models.CharField(max_length=200, blank=True)
shipping_address = models.CharField(max_length=200, blank=True)
contact_name = models.CharField(max_length=50, blank=True)
contact_phone = models.CharField(max_length=20, blank=True)
contact_email = models.EmailField(blank=True, verbose_name="Contact E-mail")
comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
images = GenericRelation(ImageAttachment)
name = models.CharField(
max_length=50,
unique=True
)
slug = models.SlugField(
unique=True
)
status = models.PositiveSmallIntegerField(
choices=SITE_STATUS_CHOICES,
default=SITE_STATUS_ACTIVE
)
region = models.ForeignKey(
to='dcim.Region',
on_delete=models.SET_NULL,
related_name='sites',
blank=True,
null=True
)
tenant = models.ForeignKey(
to='tenancy.Tenant',
on_delete=models.PROTECT,
related_name='sites',
blank=True,
null=True
)
facility = models.CharField(
max_length=50,
blank=True
)
asn = ASNField(
blank=True,
null=True,
verbose_name='ASN'
)
time_zone = TimeZoneField(
blank=True
)
description = models.CharField(
max_length=100,
blank=True
)
physical_address = models.CharField(
max_length=200,
blank=True
)
shipping_address = models.CharField(
max_length=200,
blank=True
)
contact_name = models.CharField(
max_length=50,
blank=True
)
contact_phone = models.CharField(
max_length=20,
blank=True
)
contact_email = models.EmailField(
blank=True,
verbose_name='Contact E-mail'
)
comments = models.TextField(
blank=True
)
custom_field_values = GenericRelation(
to='extras.CustomFieldValue',
content_type_field='obj_type',
object_id_field='obj_id'
)
images = GenericRelation(
to='extras.ImageAttachment'
)
objects = SiteManager()
tags = TaggableManager()
csv_headers = [
'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
@ -175,9 +241,15 @@ class RackGroup(models.Model):
example, if a Site spans a corporate campus, a RackGroup might be defined to represent each building within that
campus. If a Site instead represents a single building, a RackGroup might represent a single room or floor.
"""
name = models.CharField(max_length=50)
name = models.CharField(
max_length=50
)
slug = models.SlugField()
site = models.ForeignKey('Site', related_name='rack_groups', on_delete=models.CASCADE)
site = models.ForeignKey(
to='dcim.Site',
on_delete=models.CASCADE,
related_name='rack_groups'
)
csv_headers = ['site', 'name', 'slug']
@ -211,8 +283,13 @@ class RackRole(models.Model):
"""
Racks can be organized by functional role, similar to Devices.
"""
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
name = models.CharField(
max_length=50,
unique=True
)
slug = models.SlugField(
unique=True
)
color = ColorField()
csv_headers = ['name', 'slug', 'color']
@ -246,25 +323,82 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
Each Rack is assigned to a Site and (optionally) a RackGroup.
"""
name = models.CharField(max_length=50)
facility_id = NullableCharField(max_length=50, blank=True, null=True, verbose_name='Facility ID')
site = models.ForeignKey('Site', related_name='racks', on_delete=models.PROTECT)
group = models.ForeignKey('RackGroup', related_name='racks', blank=True, null=True, on_delete=models.SET_NULL)
tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='racks', on_delete=models.PROTECT)
role = models.ForeignKey('RackRole', related_name='racks', blank=True, null=True, on_delete=models.PROTECT)
serial = models.CharField(max_length=50, blank=True, verbose_name='Serial number')
type = models.PositiveSmallIntegerField(choices=RACK_TYPE_CHOICES, blank=True, null=True, verbose_name='Type')
width = models.PositiveSmallIntegerField(choices=RACK_WIDTH_CHOICES, default=RACK_WIDTH_19IN, verbose_name='Width',
help_text='Rail-to-rail width')
u_height = models.PositiveSmallIntegerField(default=42, verbose_name='Height (U)',
validators=[MinValueValidator(1), MaxValueValidator(100)])
desc_units = models.BooleanField(default=False, verbose_name='Descending units',
help_text='Units are numbered top-to-bottom')
comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
images = GenericRelation(ImageAttachment)
name = models.CharField(
max_length=50
)
facility_id = NullableCharField(
max_length=50,
blank=True,
null=True,
verbose_name='Facility ID'
)
site = models.ForeignKey(
to='dcim.Site',
on_delete=models.PROTECT,
related_name='racks'
)
group = models.ForeignKey(
to='dcim.RackGroup',
on_delete=models.SET_NULL,
related_name='racks',
blank=True,
null=True
)
tenant = models.ForeignKey(
to='tenancy.Tenant',
on_delete=models.PROTECT,
related_name='racks',
blank=True,
null=True
)
role = models.ForeignKey(
to='dcim.RackRole',
on_delete=models.PROTECT,
related_name='racks',
blank=True,
null=True
)
serial = models.CharField(
max_length=50,
blank=True,
verbose_name='Serial number'
)
type = models.PositiveSmallIntegerField(
choices=RACK_TYPE_CHOICES,
blank=True,
null=True,
verbose_name='Type'
)
width = models.PositiveSmallIntegerField(
choices=RACK_WIDTH_CHOICES,
default=RACK_WIDTH_19IN,
verbose_name='Width',
help_text='Rail-to-rail width'
)
u_height = models.PositiveSmallIntegerField(
default=42,
verbose_name='Height (U)',
validators=[MinValueValidator(1), MaxValueValidator(100)]
)
desc_units = models.BooleanField(
default=False,
verbose_name='Descending units',
help_text='Units are numbered top-to-bottom'
)
comments = models.TextField(
blank=True
)
custom_field_values = GenericRelation(
to='extras.CustomFieldValue',
content_type_field='obj_type',
object_id_field='obj_id'
)
images = GenericRelation(
to='extras.ImageAttachment'
)
objects = RackManager()
tags = TaggableManager()
csv_headers = [
'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'serial', 'width', 'u_height',
@ -272,10 +406,10 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
]
class Meta:
ordering = ['site', 'name']
ordering = ['site', 'group', 'name']
unique_together = [
['site', 'name'],
['site', 'facility_id'],
['group', 'name'],
['group', 'facility_id'],
]
def __str__(self):
@ -450,12 +584,31 @@ class RackReservation(models.Model):
"""
One or more reserved units within a Rack.
"""
rack = models.ForeignKey('Rack', related_name='reservations', on_delete=models.CASCADE)
units = ArrayField(models.PositiveSmallIntegerField())
created = models.DateTimeField(auto_now_add=True)
tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='rackreservations', on_delete=models.PROTECT)
user = models.ForeignKey(User, on_delete=models.PROTECT)
description = models.CharField(max_length=100)
rack = models.ForeignKey(
to='dcim.Rack',
on_delete=models.CASCADE,
related_name='reservations'
)
units = ArrayField(
base_field=models.PositiveSmallIntegerField()
)
created = models.DateTimeField(
auto_now_add=True
)
tenant = models.ForeignKey(
to='tenancy.Tenant',
on_delete=models.PROTECT,
related_name='rackreservations',
blank=True,
null=True
)
user = models.ForeignKey(
to=User,
on_delete=models.PROTECT
)
description = models.CharField(
max_length=100
)
class Meta:
ordering = ['created']
@ -508,8 +661,13 @@ class Manufacturer(models.Model):
"""
A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell.
"""
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
name = models.CharField(
max_length=50,
unique=True
)
slug = models.SlugField(
unique=True
)
csv_headers = ['name', 'slug']
@ -545,27 +703,65 @@ class DeviceType(models.Model, CustomFieldModel):
When a new Device of this type is created, the appropriate console, power, and interface objects (as defined by the
DeviceType) are automatically created as well.
"""
manufacturer = models.ForeignKey('Manufacturer', related_name='device_types', on_delete=models.PROTECT)
model = models.CharField(max_length=50)
manufacturer = models.ForeignKey(
to='dcim.Manufacturer',
on_delete=models.PROTECT,
related_name='device_types'
)
model = models.CharField(
max_length=50
)
slug = models.SlugField()
part_number = models.CharField(max_length=50, blank=True, help_text="Discrete part number (optional)")
u_height = models.PositiveSmallIntegerField(verbose_name='Height (U)', default=1)
is_full_depth = models.BooleanField(default=True, verbose_name="Is full depth",
help_text="Device consumes both front and rear rack faces")
interface_ordering = models.PositiveSmallIntegerField(choices=IFACE_ORDERING_CHOICES,
default=IFACE_ORDERING_POSITION)
is_console_server = models.BooleanField(default=False, verbose_name='Is a console server',
help_text="This type of device has console server ports")
is_pdu = models.BooleanField(default=False, verbose_name='Is a PDU',
help_text="This type of device has power outlets")
is_network_device = models.BooleanField(default=True, verbose_name='Is a network device',
help_text="This type of device has network interfaces")
subdevice_role = models.NullBooleanField(default=None, verbose_name='Parent/child status',
part_number = models.CharField(
max_length=50,
blank=True,
help_text='Discrete part number (optional)'
)
u_height = models.PositiveSmallIntegerField(
default=1,
verbose_name='Height (U)'
)
is_full_depth = models.BooleanField(
default=True,
verbose_name='Is full depth',
help_text='Device consumes both front and rear rack faces'
)
interface_ordering = models.PositiveSmallIntegerField(
choices=IFACE_ORDERING_CHOICES,
default=IFACE_ORDERING_POSITION
)
is_console_server = models.BooleanField(
default=False,
verbose_name='Is a console server',
help_text='This type of device has console server ports'
)
is_pdu = models.BooleanField(
default=False,
verbose_name='Is a PDU',
help_text='This type of device has power outlets'
)
is_network_device = models.BooleanField(
default=True,
verbose_name='Is a network device',
help_text='This type of device has network interfaces'
)
subdevice_role = models.NullBooleanField(
default=None,
verbose_name='Parent/child status',
choices=SUBDEVICE_ROLE_CHOICES,
help_text="Parent devices house child devices in device bays. Select "
"\"None\" if this device type is neither a parent nor a child.")
comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
help_text='Parent devices house child devices in device bays. Select '
'"None" if this device type is neither a parent nor a child.'
)
comments = models.TextField(
blank=True
)
custom_field_values = GenericRelation(
to='extras.CustomFieldValue',
content_type_field='obj_type',
object_id_field='obj_id'
)
tags = TaggableManager()
csv_headers = [
'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server',
@ -670,8 +866,14 @@ class ConsolePortTemplate(models.Model):
"""
A template for a ConsolePort to be created for a new Device.
"""
device_type = models.ForeignKey('DeviceType', related_name='console_port_templates', on_delete=models.CASCADE)
name = models.CharField(max_length=50)
device_type = models.ForeignKey(
to='dcim.DeviceType',
on_delete=models.CASCADE,
related_name='console_port_templates'
)
name = models.CharField(
max_length=50
)
class Meta:
ordering = ['device_type', 'name']
@ -686,8 +888,14 @@ class ConsoleServerPortTemplate(models.Model):
"""
A template for a ConsoleServerPort to be created for a new Device.
"""
device_type = models.ForeignKey('DeviceType', related_name='cs_port_templates', on_delete=models.CASCADE)
name = models.CharField(max_length=50)
device_type = models.ForeignKey(
to='dcim.DeviceType',
on_delete=models.CASCADE,
related_name='cs_port_templates'
)
name = models.CharField(
max_length=50
)
class Meta:
ordering = ['device_type', 'name']
@ -702,8 +910,14 @@ class PowerPortTemplate(models.Model):
"""
A template for a PowerPort to be created for a new Device.
"""
device_type = models.ForeignKey('DeviceType', related_name='power_port_templates', on_delete=models.CASCADE)
name = models.CharField(max_length=50)
device_type = models.ForeignKey(
to='dcim.DeviceType',
on_delete=models.CASCADE,
related_name='power_port_templates'
)
name = models.CharField(
max_length=50
)
class Meta:
ordering = ['device_type', 'name']
@ -718,8 +932,14 @@ class PowerOutletTemplate(models.Model):
"""
A template for a PowerOutlet to be created for a new Device.
"""
device_type = models.ForeignKey('DeviceType', related_name='power_outlet_templates', on_delete=models.CASCADE)
name = models.CharField(max_length=50)
device_type = models.ForeignKey(
to='dcim.DeviceType',
on_delete=models.CASCADE,
related_name='power_outlet_templates'
)
name = models.CharField(
max_length=50
)
class Meta:
ordering = ['device_type', 'name']
@ -734,10 +954,22 @@ class InterfaceTemplate(models.Model):
"""
A template for a physical data interface on a new Device.
"""
device_type = models.ForeignKey('DeviceType', related_name='interface_templates', on_delete=models.CASCADE)
name = models.CharField(max_length=64)
form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_10GE_SFP_PLUS)
mgmt_only = models.BooleanField(default=False, verbose_name='Management only')
device_type = models.ForeignKey(
to='dcim.DeviceType',
on_delete=models.CASCADE,
related_name='interface_templates'
)
name = models.CharField(
max_length=64
)
form_factor = models.PositiveSmallIntegerField(
choices=IFACE_FF_CHOICES,
default=IFACE_FF_10GE_SFP_PLUS
)
mgmt_only = models.BooleanField(
default=False,
verbose_name='Management only'
)
objects = InterfaceQuerySet.as_manager()
@ -754,8 +986,14 @@ class DeviceBayTemplate(models.Model):
"""
A template for a DeviceBay to be created for a new parent Device.
"""
device_type = models.ForeignKey('DeviceType', related_name='device_bay_templates', on_delete=models.CASCADE)
name = models.CharField(max_length=50)
device_type = models.ForeignKey(
to='dcim.DeviceType',
on_delete=models.CASCADE,
related_name='device_bay_templates'
)
name = models.CharField(
max_length=50
)
class Meta:
ordering = ['device_type', 'name']
@ -776,13 +1014,18 @@ class DeviceRole(models.Model):
color to be used when displaying rack elevations. The vm_role field determines whether the role is applicable to
virtual machines as well.
"""
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
name = models.CharField(
max_length=50,
unique=True
)
slug = models.SlugField(
unique=True
)
color = ColorField()
vm_role = models.BooleanField(
default=True,
verbose_name="VM Role",
help_text="Virtual machines may be assigned to this role"
verbose_name='VM Role',
help_text='Virtual machines may be assigned to this role'
)
csv_headers = ['name', 'slug', 'color', 'vm_role']
@ -812,26 +1055,32 @@ class Platform(models.Model):
NetBox uses Platforms to determine how to interact with devices when pulling inventory data or other information by
specifying a NAPALM driver.
"""
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
name = models.CharField(
max_length=50,
unique=True
)
slug = models.SlugField(
unique=True
)
manufacturer = models.ForeignKey(
to='Manufacturer',
to='dcim.Manufacturer',
on_delete=models.PROTECT,
related_name='platforms',
blank=True,
null=True,
help_text="Optionally limit this platform to devices of a certain manufacturer"
help_text='Optionally limit this platform to devices of a certain manufacturer'
)
napalm_driver = models.CharField(
max_length=50,
blank=True,
verbose_name='NAPALM driver',
help_text="The name of the NAPALM driver to use when interacting with devices"
help_text='The name of the NAPALM driver to use when interacting with devices'
)
rpc_client = models.CharField(
max_length=30,
choices=RPC_CLIENT_CHOICES,
blank=True,
verbose_name="Legacy RPC client"
verbose_name='Legacy RPC client'
)
csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver']
@ -873,30 +1122,93 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
by the component templates assigned to its DeviceType. Components can also be added, modified, or deleted after the
creation of a Device.
"""
device_type = models.ForeignKey('DeviceType', related_name='instances', on_delete=models.PROTECT)
device_role = models.ForeignKey('DeviceRole', related_name='devices', on_delete=models.PROTECT)
tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='devices', on_delete=models.PROTECT)
platform = models.ForeignKey('Platform', related_name='devices', blank=True, null=True, on_delete=models.SET_NULL)
name = NullableCharField(max_length=64, blank=True, null=True, unique=True)
serial = models.CharField(max_length=50, blank=True, verbose_name='Serial number')
device_type = models.ForeignKey(
to='dcim.DeviceType',
on_delete=models.PROTECT,
related_name='instances'
)
device_role = models.ForeignKey(
to='dcim.DeviceRole',
on_delete=models.PROTECT,
related_name='devices'
)
tenant = models.ForeignKey(
to='tenancy.Tenant',
on_delete=models.PROTECT,
related_name='devices',
blank=True,
null=True
)
platform = models.ForeignKey(
to='dcim.Platform',
on_delete=models.SET_NULL,
related_name='devices',
blank=True,
null=True
)
name = NullableCharField(
max_length=64,
blank=True,
null=True,
unique=True
)
serial = models.CharField(
max_length=50,
blank=True,
verbose_name='Serial number'
)
asset_tag = NullableCharField(
max_length=50, blank=True, null=True, unique=True, verbose_name='Asset tag',
max_length=50,
blank=True,
null=True,
unique=True,
verbose_name='Asset tag',
help_text='A unique tag used to identify this device'
)
site = models.ForeignKey('Site', related_name='devices', on_delete=models.PROTECT)
rack = models.ForeignKey('Rack', related_name='devices', blank=True, null=True, on_delete=models.PROTECT)
site = models.ForeignKey(
to='dcim.Site',
on_delete=models.PROTECT,
related_name='devices'
)
rack = models.ForeignKey(
to='dcim.Rack',
on_delete=models.PROTECT,
related_name='devices',
blank=True,
null=True
)
position = models.PositiveSmallIntegerField(
blank=True, null=True, validators=[MinValueValidator(1)], verbose_name='Position (U)',
blank=True,
null=True,
validators=[MinValueValidator(1)],
verbose_name='Position (U)',
help_text='The lowest-numbered unit occupied by the device'
)
face = models.PositiveSmallIntegerField(blank=True, null=True, choices=RACK_FACE_CHOICES, verbose_name='Rack face')
status = models.PositiveSmallIntegerField(choices=DEVICE_STATUS_CHOICES, default=DEVICE_STATUS_ACTIVE, verbose_name='Status')
face = models.PositiveSmallIntegerField(
blank=True,
null=True,
choices=RACK_FACE_CHOICES,
verbose_name='Rack face'
)
status = models.PositiveSmallIntegerField(
choices=DEVICE_STATUS_CHOICES,
default=DEVICE_STATUS_ACTIVE,
verbose_name='Status'
)
primary_ip4 = models.OneToOneField(
'ipam.IPAddress', related_name='primary_ip4_for', on_delete=models.SET_NULL, blank=True, null=True,
to='ipam.IPAddress',
on_delete=models.SET_NULL,
related_name='primary_ip4_for',
blank=True,
null=True,
verbose_name='Primary IPv4'
)
primary_ip6 = models.OneToOneField(
'ipam.IPAddress', related_name='primary_ip6_for', on_delete=models.SET_NULL, blank=True, null=True,
to='ipam.IPAddress',
on_delete=models.SET_NULL,
related_name='primary_ip6_for',
blank=True,
null=True,
verbose_name='Primary IPv6'
)
cluster = models.ForeignKey(
@ -923,11 +1235,20 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
null=True,
validators=[MaxValueValidator(255)]
)
comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
images = GenericRelation(ImageAttachment)
comments = models.TextField(
blank=True
)
custom_field_values = GenericRelation(
to='extras.CustomFieldValue',
content_type_field='obj_type',
object_id_field='obj_id'
)
images = GenericRelation(
to='extras.ImageAttachment'
)
objects = DeviceManager()
tags = TaggableManager()
csv_headers = [
'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
@ -1184,11 +1505,26 @@ class ConsolePort(models.Model):
"""
A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
"""
device = models.ForeignKey('Device', related_name='console_ports', on_delete=models.CASCADE)
name = models.CharField(max_length=50)
cs_port = models.OneToOneField('ConsoleServerPort', related_name='connected_console', on_delete=models.SET_NULL,
verbose_name='Console server port', blank=True, null=True)
connection_status = models.NullBooleanField(choices=CONNECTION_STATUS_CHOICES, default=CONNECTION_STATUS_CONNECTED)
device = models.ForeignKey(
to='dcim.Device',
on_delete=models.CASCADE,
related_name='console_ports'
)
name = models.CharField(
max_length=50
)
cs_port = models.OneToOneField(
to='dcim.ConsoleServerPort',
on_delete=models.SET_NULL,
related_name='connected_console',
verbose_name='Console server port',
blank=True,
null=True
)
connection_status = models.NullBooleanField(
choices=CONNECTION_STATUS_CHOICES,
default=CONNECTION_STATUS_CONNECTED
)
csv_headers = ['console_server', 'cs_port', 'device', 'console_port', 'connection_status']
@ -1231,8 +1567,14 @@ class ConsoleServerPort(models.Model):
"""
A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
"""
device = models.ForeignKey('Device', related_name='cs_ports', on_delete=models.CASCADE)
name = models.CharField(max_length=50)
device = models.ForeignKey(
to='dcim.Device',
on_delete=models.CASCADE,
related_name='cs_ports'
)
name = models.CharField(
max_length=50
)
objects = ConsoleServerPortManager()
@ -1266,11 +1608,25 @@ class PowerPort(models.Model):
"""
A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
"""
device = models.ForeignKey('Device', related_name='power_ports', on_delete=models.CASCADE)
name = models.CharField(max_length=50)
power_outlet = models.OneToOneField('PowerOutlet', related_name='connected_port', on_delete=models.SET_NULL,
blank=True, null=True)
connection_status = models.NullBooleanField(choices=CONNECTION_STATUS_CHOICES, default=CONNECTION_STATUS_CONNECTED)
device = models.ForeignKey(
to='dcim.Device',
on_delete=models.CASCADE,
related_name='power_ports'
)
name = models.CharField(
max_length=50
)
power_outlet = models.OneToOneField(
to='dcim.PowerOutlet',
on_delete=models.SET_NULL,
related_name='connected_port',
blank=True,
null=True
)
connection_status = models.NullBooleanField(
choices=CONNECTION_STATUS_CHOICES,
default=CONNECTION_STATUS_CONNECTED
)
csv_headers = ['pdu', 'power_outlet', 'device', 'power_port', 'connection_status']
@ -1313,8 +1669,14 @@ class PowerOutlet(models.Model):
"""
A physical power outlet (output) within a Device which provides power to a PowerPort.
"""
device = models.ForeignKey('Device', related_name='power_outlets', on_delete=models.CASCADE)
name = models.CharField(max_length=50)
device = models.ForeignKey(
to='dcim.Device',
on_delete=models.CASCADE,
related_name='power_outlets'
)
name = models.CharField(
max_length=50
)
objects = PowerOutletManager()
@ -1371,17 +1733,35 @@ class Interface(models.Model):
blank=True,
verbose_name='Parent LAG'
)
name = models.CharField(max_length=64)
form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_10GE_SFP_PLUS)
enabled = models.BooleanField(default=True)
mac_address = MACAddressField(null=True, blank=True, verbose_name='MAC Address')
mtu = models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='MTU')
name = models.CharField(
max_length=64
)
form_factor = models.PositiveSmallIntegerField(
choices=IFACE_FF_CHOICES,
default=IFACE_FF_10GE_SFP_PLUS
)
enabled = models.BooleanField(
default=True
)
mac_address = MACAddressField(
null=True,
blank=True,
verbose_name='MAC Address'
)
mtu = models.PositiveSmallIntegerField(
blank=True,
null=True,
verbose_name='MTU'
)
mgmt_only = models.BooleanField(
default=False,
verbose_name='OOB Management',
help_text="This interface is used only for out-of-band management"
help_text='This interface is used only for out-of-band management'
)
description = models.CharField(
max_length=100,
blank=True
)
description = models.CharField(max_length=100, blank=True)
mode = models.PositiveSmallIntegerField(
choices=IFACE_MODE_CHOICES,
blank=True,
@ -1389,16 +1769,17 @@ class Interface(models.Model):
)
untagged_vlan = models.ForeignKey(
to='ipam.VLAN',
on_delete=models.SET_NULL,
related_name='interfaces_as_untagged',
null=True,
blank=True,
verbose_name='Untagged VLAN',
related_name='interfaces_as_untagged'
verbose_name='Untagged VLAN'
)
tagged_vlans = models.ManyToManyField(
to='ipam.VLAN',
related_name='interfaces_as_tagged',
blank=True,
verbose_name='Tagged VLANs',
related_name='interfaces_as_tagged'
verbose_name='Tagged VLANs'
)
objects = InterfaceQuerySet.as_manager()
@ -1543,10 +1924,21 @@ class InterfaceConnection(models.Model):
An InterfaceConnection represents a symmetrical, one-to-one connection between two Interfaces. There is no
significant difference between the interface_a and interface_b fields.
"""
interface_a = models.OneToOneField('Interface', related_name='connected_as_a', on_delete=models.CASCADE)
interface_b = models.OneToOneField('Interface', related_name='connected_as_b', on_delete=models.CASCADE)
connection_status = models.BooleanField(choices=CONNECTION_STATUS_CHOICES, default=CONNECTION_STATUS_CONNECTED,
verbose_name='Status')
interface_a = models.OneToOneField(
to='dcim.Interface',
on_delete=models.CASCADE,
related_name='connected_as_a'
)
interface_b = models.OneToOneField(
to='dcim.Interface',
on_delete=models.CASCADE,
related_name='connected_as_b'
)
connection_status = models.BooleanField(
choices=CONNECTION_STATUS_CHOICES,
default=CONNECTION_STATUS_CONNECTED,
verbose_name='Status'
)
csv_headers = ['device_a', 'interface_a', 'device_b', 'interface_b', 'connection_status']
@ -1578,10 +1970,22 @@ class DeviceBay(models.Model):
"""
An empty space within a Device which can house a child device
"""
device = models.ForeignKey('Device', related_name='device_bays', on_delete=models.CASCADE)
name = models.CharField(max_length=50, verbose_name='Name')
installed_device = models.OneToOneField('Device', related_name='parent_bay', on_delete=models.SET_NULL, blank=True,
null=True)
device = models.ForeignKey(
to='dcim.Device',
on_delete=models.CASCADE,
related_name='device_bays'
)
name = models.CharField(
max_length=50,
verbose_name='Name'
)
installed_device = models.OneToOneField(
to='dcim.Device',
on_delete=models.SET_NULL,
related_name='parent_bay',
blank=True,
null=True
)
class Meta:
ordering = ['device', 'name']
@ -1616,20 +2020,55 @@ class InventoryItem(models.Model):
An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply.
InventoryItems are used only for inventory purposes.
"""
device = models.ForeignKey('Device', related_name='inventory_items', on_delete=models.CASCADE)
parent = models.ForeignKey('self', related_name='child_items', blank=True, null=True, on_delete=models.CASCADE)
name = models.CharField(max_length=50, verbose_name='Name')
manufacturer = models.ForeignKey(
'Manufacturer', models.PROTECT, related_name='inventory_items', blank=True, null=True
device = models.ForeignKey(
to='dcim.Device',
on_delete=models.CASCADE,
related_name='inventory_items'
)
parent = models.ForeignKey(
to='self',
on_delete=models.CASCADE,
related_name='child_items',
blank=True,
null=True
)
name = models.CharField(
max_length=50,
verbose_name='Name'
)
manufacturer = models.ForeignKey(
to='dcim.Manufacturer',
on_delete=models.PROTECT,
related_name='inventory_items',
blank=True,
null=True
)
part_id = models.CharField(
max_length=50,
verbose_name='Part ID',
blank=True
)
serial = models.CharField(
max_length=50,
verbose_name='Serial number',
blank=True
)
part_id = models.CharField(max_length=50, verbose_name='Part ID', blank=True)
serial = models.CharField(max_length=50, verbose_name='Serial number', blank=True)
asset_tag = NullableCharField(
max_length=50, blank=True, null=True, unique=True, verbose_name='Asset tag',
max_length=50,
unique=True,
blank=True,
null=True,
verbose_name='Asset tag',
help_text='A unique tag used to identify this item'
)
discovered = models.BooleanField(default=False, verbose_name='Discovered')
description = models.CharField(max_length=100, blank=True)
discovered = models.BooleanField(
default=False,
verbose_name='Discovered'
)
description = models.CharField(
max_length=100,
blank=True
)
csv_headers = [
'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description',

View File

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

View File

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

View File

@ -2,6 +2,7 @@ from __future__ import unicode_literals
from django.core.exceptions import ObjectDoesNotExist
from rest_framework import serializers
from taggit.models import Tag
from dcim.api.serializers import NestedDeviceSerializer, NestedRackSerializer, NestedSiteSerializer
from dcim.models import Device, Rack, Site
@ -16,7 +17,7 @@ from extras.constants import *
# Graphs
#
class GraphSerializer(serializers.ModelSerializer):
class GraphSerializer(ValidatedModelSerializer):
type = ChoiceFieldSerializer(choices=GRAPH_TYPE_CHOICES)
class Meta:
@ -24,13 +25,6 @@ class GraphSerializer(serializers.ModelSerializer):
fields = ['id', 'type', 'weight', 'name', 'source', 'link']
class WritableGraphSerializer(serializers.ModelSerializer):
class Meta:
model = Graph
fields = ['id', 'type', 'weight', 'name', 'source', 'link']
class RenderedGraphSerializer(serializers.ModelSerializer):
embed_url = serializers.SerializerMethodField()
embed_link = serializers.SerializerMethodField()
@ -51,7 +45,7 @@ class RenderedGraphSerializer(serializers.ModelSerializer):
# Export templates
#
class ExportTemplateSerializer(serializers.ModelSerializer):
class ExportTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = ExportTemplate
@ -62,7 +56,7 @@ class ExportTemplateSerializer(serializers.ModelSerializer):
# Topology maps
#
class TopologyMapSerializer(serializers.ModelSerializer):
class TopologyMapSerializer(ValidatedModelSerializer):
site = NestedSiteSerializer()
class Meta:
@ -70,23 +64,46 @@ class TopologyMapSerializer(serializers.ModelSerializer):
fields = ['id', 'name', 'slug', 'site', 'device_patterns', 'description']
class WritableTopologyMapSerializer(serializers.ModelSerializer):
#
# Tags
#
class TagSerializer(ValidatedModelSerializer):
tagged_items = serializers.IntegerField(read_only=True)
class Meta:
model = TopologyMap
fields = ['id', 'name', 'slug', 'site', 'device_patterns', 'description']
model = Tag
fields = ['id', 'name', 'slug', 'tagged_items']
#
# Image attachments
#
class ImageAttachmentSerializer(serializers.ModelSerializer):
parent = serializers.SerializerMethodField()
class ImageAttachmentSerializer(ValidatedModelSerializer):
content_type = ContentTypeFieldSerializer()
parent = serializers.SerializerMethodField(read_only=True)
class Meta:
model = ImageAttachment
fields = ['id', 'parent', 'name', 'image', 'image_height', 'image_width', 'created']
fields = [
'id', 'content_type', 'object_id', 'parent', 'name', 'image', 'image_height', 'image_width', 'created',
]
def validate(self, data):
# Validate that the parent object exists
try:
data['content_type'].get_object_for_this_type(id=data['object_id'])
except ObjectDoesNotExist:
raise serializers.ValidationError(
"Invalid parent object: {} ID {}".format(data['content_type'], data['object_id'])
)
# Enforce model validation
super(ImageAttachmentSerializer, self).validate(data)
return data
def get_parent(self, obj):
@ -103,29 +120,6 @@ class ImageAttachmentSerializer(serializers.ModelSerializer):
return serializer(obj.parent, context={'request': self.context['request']}).data
class WritableImageAttachmentSerializer(ValidatedModelSerializer):
content_type = ContentTypeFieldSerializer()
class Meta:
model = ImageAttachment
fields = ['id', 'content_type', 'object_id', 'name', 'image']
def validate(self, data):
# Validate that the parent object exists
try:
data['content_type'].get_object_for_this_type(id=data['object_id'])
except ObjectDoesNotExist:
raise serializers.ValidationError(
"Invalid parent object: {} ID {}".format(data['content_type'], data['object_id'])
)
# Enforce model validation
super(WritableImageAttachmentSerializer, self).validate(data)
return data
#
# Reports
#

View File

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

View File

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

View File

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

View File

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

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(
max_length=50,
blank=True,
help_text='Name of the field as displayed to users (if not provided, the field\'s name will be used)'
help_text='Name of the field as displayed to users (if not provided, '
'the field\'s name will be used)'
)
description = models.CharField(
max_length=100,
@ -130,17 +131,20 @@ class CustomField(models.Model):
)
required = models.BooleanField(
default=False,
help_text='If true, this field is required when creating new objects or editing an existing object.'
help_text='If true, this field is required when creating new objects '
'or editing an existing object.'
)
filter_logic = models.PositiveSmallIntegerField(
choices=CF_FILTER_CHOICES,
default=CF_FILTER_LOOSE,
help_text="Loose matches any instance of a given string; exact matches the entire field."
help_text='Loose matches any instance of a given string; exact '
'matches the entire field.'
)
default = models.CharField(
max_length=100,
blank=True,
help_text='Default value for the field. Use "true" or "false" for booleans. N/A for selection fields.'
help_text='Default value for the field. Use "true" or "false" for '
'booleans. N/A for selection fields.'
)
weight = models.PositiveSmallIntegerField(
default=100,
@ -192,11 +196,24 @@ class CustomField(models.Model):
@python_2_unicode_compatible
class CustomFieldValue(models.Model):
field = models.ForeignKey('CustomField', related_name='values', on_delete=models.CASCADE)
obj_type = models.ForeignKey(ContentType, related_name='+', on_delete=models.PROTECT)
field = models.ForeignKey(
to='extras.CustomField',
on_delete=models.CASCADE,
related_name='values'
)
obj_type = models.ForeignKey(
to=ContentType,
on_delete=models.PROTECT,
related_name='+'
)
obj_id = models.PositiveIntegerField()
obj = GenericForeignKey('obj_type', 'obj_id')
serialized_value = models.CharField(max_length=255)
obj = GenericForeignKey(
ct_field='obj_type',
fk_field='obj_id'
)
serialized_value = models.CharField(
max_length=255
)
class Meta:
ordering = ['obj_type', 'obj_id']
@ -223,10 +240,19 @@ class CustomFieldValue(models.Model):
@python_2_unicode_compatible
class CustomFieldChoice(models.Model):
field = models.ForeignKey('CustomField', related_name='choices', limit_choices_to={'type': CF_TYPE_SELECT},
on_delete=models.CASCADE)
value = models.CharField(max_length=100)
weight = models.PositiveSmallIntegerField(default=100, help_text="Higher weights appear lower in the list")
field = models.ForeignKey(
to='extras.CustomField',
on_delete=models.CASCADE,
related_name='choices',
limit_choices_to={'type': CF_TYPE_SELECT}
)
value = models.CharField(
max_length=100
)
weight = models.PositiveSmallIntegerField(
default=100,
help_text='Higher weights appear lower in the list'
)
class Meta:
ordering = ['field', 'weight', 'value']
@ -252,11 +278,24 @@ class CustomFieldChoice(models.Model):
@python_2_unicode_compatible
class Graph(models.Model):
type = models.PositiveSmallIntegerField(choices=GRAPH_TYPE_CHOICES)
weight = models.PositiveSmallIntegerField(default=1000)
name = models.CharField(max_length=100, verbose_name='Name')
source = models.CharField(max_length=500, verbose_name='Source URL')
link = models.URLField(verbose_name='Link URL', blank=True)
type = models.PositiveSmallIntegerField(
choices=GRAPH_TYPE_CHOICES
)
weight = models.PositiveSmallIntegerField(
default=1000
)
name = models.CharField(
max_length=100,
verbose_name='Name'
)
source = models.CharField(
max_length=500,
verbose_name='Source URL'
)
link = models.URLField(
blank=True,
verbose_name='Link URL'
)
class Meta:
ordering = ['type', 'weight', 'name']
@ -282,13 +321,26 @@ class Graph(models.Model):
@python_2_unicode_compatible
class ExportTemplate(models.Model):
content_type = models.ForeignKey(
ContentType, limit_choices_to={'model__in': EXPORTTEMPLATE_MODELS}, on_delete=models.CASCADE
to=ContentType,
on_delete=models.CASCADE,
limit_choices_to={'model__in': EXPORTTEMPLATE_MODELS}
)
name = models.CharField(
max_length=100
)
description = models.CharField(
max_length=200,
blank=True
)
name = models.CharField(max_length=100)
description = models.CharField(max_length=200, blank=True)
template_code = models.TextField()
mime_type = models.CharField(max_length=15, blank=True)
file_extension = models.CharField(max_length=15, blank=True)
mime_type = models.CharField(
max_length=15,
blank=True
)
file_extension = models.CharField(
max_length=15,
blank=True
)
class Meta:
ordering = ['content_type', 'name']
@ -327,25 +379,35 @@ class ExportTemplate(models.Model):
@python_2_unicode_compatible
class TopologyMap(models.Model):
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
name = models.CharField(
max_length=50,
unique=True
)
slug = models.SlugField(
unique=True
)
type = models.PositiveSmallIntegerField(
choices=TOPOLOGYMAP_TYPE_CHOICES,
default=TOPOLOGYMAP_TYPE_NETWORK
)
site = models.ForeignKey(
to='dcim.Site',
on_delete=models.CASCADE,
related_name='topology_maps',
blank=True,
null=True,
on_delete=models.CASCADE
null=True
)
device_patterns = models.TextField(
help_text="Identify devices to include in the diagram using regular expressions, one per line. Each line will "
"result in a new tier of the drawing. Separate multiple regexes within a line using semicolons. "
"Devices will be rendered in the order they are defined."
help_text='Identify devices to include in the diagram using regular '
'expressions, one per line. Each line will result in a new '
'tier of the drawing. Separate multiple regexes within a '
'line using semicolons. Devices will be rendered in the '
'order they are defined.'
)
description = models.CharField(
max_length=100,
blank=True
)
description = models.CharField(max_length=100, blank=True)
class Meta:
ordering = ['name']
@ -481,14 +543,29 @@ class ImageAttachment(models.Model):
"""
An uploaded image which is associated with an object.
"""
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
content_type = models.ForeignKey(
to=ContentType,
on_delete=models.CASCADE
)
object_id = models.PositiveIntegerField()
parent = GenericForeignKey('content_type', 'object_id')
image = models.ImageField(upload_to=image_upload, height_field='image_height', width_field='image_width')
parent = GenericForeignKey(
ct_field='content_type',
fk_field='object_id'
)
image = models.ImageField(
upload_to=image_upload,
height_field='image_height',
width_field='image_width'
)
image_height = models.PositiveSmallIntegerField()
image_width = models.PositiveSmallIntegerField()
name = models.CharField(max_length=50, blank=True)
created = models.DateTimeField(auto_now_add=True)
name = models.CharField(
max_length=50,
blank=True
)
created = models.DateTimeField(
auto_now_add=True
)
class Meta:
ordering = ['name']
@ -531,9 +608,20 @@ class ReportResult(models.Model):
"""
This model stores the results from running a user-defined report.
"""
report = models.CharField(max_length=255, unique=True)
created = models.DateTimeField(auto_now_add=True)
user = models.ForeignKey(User, on_delete=models.SET_NULL, related_name='+', blank=True, null=True)
report = models.CharField(
max_length=255,
unique=True
)
created = models.DateTimeField(
auto_now_add=True
)
user = models.ForeignKey(
to=User,
on_delete=models.SET_NULL,
related_name='+',
blank=True,
null=True
)
failed = models.BooleanField()
data = JSONField()
@ -593,12 +681,29 @@ class UserAction(models.Model):
"""
A record of an action (add, edit, or delete) performed on an object by a User.
"""
time = models.DateTimeField(auto_now_add=True, editable=False)
user = models.ForeignKey(User, related_name='actions', on_delete=models.CASCADE)
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField(blank=True, null=True)
action = models.PositiveSmallIntegerField(choices=ACTION_CHOICES)
message = models.TextField(blank=True)
time = models.DateTimeField(
auto_now_add=True,
editable=False
)
user = models.ForeignKey(
to=User,
on_delete=models.CASCADE,
related_name='actions'
)
content_type = models.ForeignKey(
to=ContentType,
on_delete=models.CASCADE
)
object_id = models.PositiveIntegerField(
blank=True,
null=True
)
action = models.PositiveSmallIntegerField(
choices=ACTION_CHOICES
)
message = models.TextField(
blank=True
)
objects = UserActionManager()

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

View File

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

View File

@ -7,6 +7,12 @@ from extras import views
app_name = 'extras'
urlpatterns = [
# Tags
url(r'^tags/$', views.TagListView.as_view(), name='tag_list'),
url(r'^tags/(?P<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
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'),

View File

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

View File

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

View File

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

View File

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

View File

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

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.urls import reverse
from django.utils.encoding import python_2_unicode_compatible
from taggit.managers import TaggableManager
from dcim.models import Interface
from extras.models import CustomFieldModel, CustomFieldValue
from tenancy.models import Tenant
from extras.models import CustomFieldModel
from utilities.models import CreatedUpdatedModel
from .constants import *
from .fields import IPNetworkField, IPAddressField
@ -27,13 +27,37 @@ class VRF(CreatedUpdatedModel, CustomFieldModel):
table). Prefixes and IPAddresses can optionally be assigned to VRFs. (Prefixes and IPAddresses not assigned to a VRF
are said to exist in the "global" table.)
"""
name = models.CharField(max_length=50)
rd = models.CharField(max_length=21, unique=True, verbose_name='Route distinguisher')
tenant = models.ForeignKey(Tenant, related_name='vrfs', blank=True, null=True, on_delete=models.PROTECT)
enforce_unique = models.BooleanField(default=True, verbose_name='Enforce unique space',
help_text="Prevent duplicate prefixes/IP addresses within this VRF")
description = models.CharField(max_length=100, blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
name = models.CharField(
max_length=50
)
rd = models.CharField(
max_length=21,
unique=True,
verbose_name='Route distinguisher'
)
tenant = models.ForeignKey(
to='tenancy.Tenant',
on_delete=models.PROTECT,
related_name='vrfs',
blank=True,
null=True
)
enforce_unique = models.BooleanField(
default=True,
verbose_name='Enforce unique space',
help_text='Prevent duplicate prefixes/IP addresses within this VRF'
)
description = models.CharField(
max_length=100,
blank=True
)
custom_field_values = GenericRelation(
to='extras.CustomFieldValue',
content_type_field='obj_type',
object_id_field='obj_id'
)
tags = TaggableManager()
csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
@ -74,10 +98,18 @@ class RIR(models.Model):
A Regional Internet Registry (RIR) is responsible for the allocation of a large portion of the global IP address
space. This can be an organization like ARIN or RIPE, or a governing standard such as RFC 1918.
"""
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
is_private = models.BooleanField(default=False, verbose_name='Private',
help_text='IP space managed by this RIR is considered private')
name = models.CharField(
max_length=50,
unique=True
)
slug = models.SlugField(
unique=True
)
is_private = models.BooleanField(
default=False,
verbose_name='Private',
help_text='IP space managed by this RIR is considered private'
)
csv_headers = ['name', 'slug', 'is_private']
@ -106,12 +138,31 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel):
An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize
the hierarchy and track the overall utilization of available address space. Each Aggregate is assigned to a RIR.
"""
family = models.PositiveSmallIntegerField(choices=AF_CHOICES)
family = models.PositiveSmallIntegerField(
choices=AF_CHOICES
)
prefix = IPNetworkField()
rir = models.ForeignKey('RIR', related_name='aggregates', on_delete=models.PROTECT, verbose_name='RIR')
date_added = models.DateField(blank=True, null=True)
description = models.CharField(max_length=100, blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
rir = models.ForeignKey(
to='ipam.RIR',
on_delete=models.PROTECT,
related_name='aggregates',
verbose_name='RIR'
)
date_added = models.DateField(
blank=True,
null=True
)
description = models.CharField(
max_length=100,
blank=True
)
custom_field_values = GenericRelation(
to='extras.CustomFieldValue',
content_type_field='obj_type',
object_id_field='obj_id'
)
tags = TaggableManager()
csv_headers = ['prefix', 'rir', 'date_added', 'description']
@ -186,9 +237,16 @@ class Role(models.Model):
A Role represents the functional role of a Prefix or VLAN; for example, "Customer," "Infrastructure," or
"Management."
"""
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
weight = models.PositiveSmallIntegerField(default=1000)
name = models.CharField(
max_length=50,
unique=True
)
slug = models.SlugField(
unique=True
)
weight = models.PositiveSmallIntegerField(
default=1000
)
csv_headers = ['name', 'slug', 'weight']
@ -213,24 +271,74 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
VRFs. A Prefix must be assigned a status and may optionally be assigned a used-define Role. A Prefix can also be
assigned to a VLAN where appropriate.
"""
family = models.PositiveSmallIntegerField(choices=AF_CHOICES, editable=False)
prefix = IPNetworkField(help_text="IPv4 or IPv6 network with mask")
site = models.ForeignKey('dcim.Site', related_name='prefixes', on_delete=models.PROTECT, blank=True, null=True)
vrf = models.ForeignKey('VRF', related_name='prefixes', on_delete=models.PROTECT, blank=True, null=True,
verbose_name='VRF')
tenant = models.ForeignKey(Tenant, related_name='prefixes', blank=True, null=True, on_delete=models.PROTECT)
vlan = models.ForeignKey('VLAN', related_name='prefixes', on_delete=models.PROTECT, blank=True, null=True,
verbose_name='VLAN')
status = models.PositiveSmallIntegerField('Status', choices=PREFIX_STATUS_CHOICES, default=PREFIX_STATUS_ACTIVE,
help_text="Operational status of this prefix")
role = models.ForeignKey('Role', related_name='prefixes', on_delete=models.SET_NULL, blank=True, null=True,
help_text="The primary function of this prefix")
is_pool = models.BooleanField(verbose_name='Is a pool', default=False,
help_text="All IP addresses within this prefix are considered usable")
description = models.CharField(max_length=100, blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
family = models.PositiveSmallIntegerField(
choices=AF_CHOICES,
editable=False
)
prefix = IPNetworkField(
help_text='IPv4 or IPv6 network with mask'
)
site = models.ForeignKey(
to='dcim.Site',
on_delete=models.PROTECT,
related_name='prefixes',
blank=True,
null=True
)
vrf = models.ForeignKey(
to='ipam.VRF',
on_delete=models.PROTECT,
related_name='prefixes',
blank=True,
null=True,
verbose_name='VRF'
)
tenant = models.ForeignKey(
to='tenancy.Tenant',
on_delete=models.PROTECT,
related_name='prefixes',
blank=True,
null=True
)
vlan = models.ForeignKey(
to='ipam.VLAN',
on_delete=models.PROTECT,
related_name='prefixes',
blank=True,
null=True,
verbose_name='VLAN'
)
status = models.PositiveSmallIntegerField(
choices=PREFIX_STATUS_CHOICES,
default=PREFIX_STATUS_ACTIVE,
verbose_name='Status',
help_text='Operational status of this prefix'
)
role = models.ForeignKey(
to='ipam.Role',
on_delete=models.SET_NULL,
related_name='prefixes',
blank=True,
null=True,
help_text='The primary function of this prefix'
)
is_pool = models.BooleanField(
verbose_name='Is a pool',
default=False,
help_text='All IP addresses within this prefix are considered usable'
)
description = models.CharField(
max_length=100,
blank=True
)
custom_field_values = GenericRelation(
to='extras.CustomFieldValue',
content_type_field='obj_type',
object_id_field='obj_id'
)
objects = PrefixQuerySet.as_manager()
tags = TaggableManager()
csv_headers = [
'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description',
@ -422,27 +530,69 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
for example, when mapping public addresses to private addresses. When an Interface has been assigned an IPAddress
which has a NAT outside IP, that Interface's Device can use either the inside or outside IP as its primary IP.
"""
family = models.PositiveSmallIntegerField(choices=AF_CHOICES, editable=False)
address = IPAddressField(help_text="IPv4 or IPv6 address (with mask)")
vrf = models.ForeignKey('VRF', related_name='ip_addresses', on_delete=models.PROTECT, blank=True, null=True,
verbose_name='VRF')
tenant = models.ForeignKey(Tenant, related_name='ip_addresses', blank=True, null=True, on_delete=models.PROTECT)
family = models.PositiveSmallIntegerField(
choices=AF_CHOICES,
editable=False
)
address = IPAddressField(
help_text='IPv4 or IPv6 address (with mask)'
)
vrf = models.ForeignKey(
to='ipam.VRF',
on_delete=models.PROTECT,
related_name='ip_addresses',
blank=True,
null=True,
verbose_name='VRF'
)
tenant = models.ForeignKey(
to='tenancy.Tenant',
on_delete=models.PROTECT,
related_name='ip_addresses',
blank=True,
null=True
)
status = models.PositiveSmallIntegerField(
'Status', choices=IPADDRESS_STATUS_CHOICES, default=IPADDRESS_STATUS_ACTIVE,
choices=IPADDRESS_STATUS_CHOICES,
default=IPADDRESS_STATUS_ACTIVE,
verbose_name='Status',
help_text='The operational status of this IP'
)
role = models.PositiveSmallIntegerField(
'Role', choices=IPADDRESS_ROLE_CHOICES, blank=True, null=True, help_text='The functional role of this IP'
verbose_name='Role',
choices=IPADDRESS_ROLE_CHOICES,
blank=True,
null=True,
help_text='The functional role of this IP'
)
interface = models.ForeignKey(
to='dcim.Interface',
on_delete=models.CASCADE,
related_name='ip_addresses',
blank=True,
null=True
)
nat_inside = models.OneToOneField(
to='self',
on_delete=models.SET_NULL,
related_name='nat_outside',
blank=True,
null=True,
verbose_name='NAT (Inside)',
help_text='The IP for which this address is the "outside" IP'
)
description = models.CharField(
max_length=100,
blank=True
)
custom_field_values = GenericRelation(
to='extras.CustomFieldValue',
content_type_field='obj_type',
object_id_field='obj_id'
)
interface = models.ForeignKey(Interface, related_name='ip_addresses', on_delete=models.CASCADE, blank=True,
null=True)
nat_inside = models.OneToOneField('self', related_name='nat_outside', on_delete=models.SET_NULL, blank=True,
null=True, verbose_name='NAT (Inside)',
help_text="The IP for which this address is the \"outside\" IP")
description = models.CharField(max_length=100, blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
objects = IPAddressManager()
tags = TaggableManager()
csv_headers = [
'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface_name', 'is_primary',
@ -535,9 +685,17 @@ class VLANGroup(models.Model):
"""
A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique.
"""
name = models.CharField(max_length=50)
name = models.CharField(
max_length=50
)
slug = models.SlugField()
site = models.ForeignKey('dcim.Site', related_name='vlan_groups', on_delete=models.PROTECT, blank=True, null=True)
site = models.ForeignKey(
to='dcim.Site',
on_delete=models.PROTECT,
related_name='vlan_groups',
blank=True,
null=True
)
csv_headers = ['name', 'slug', 'site']
@ -588,18 +746,57 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
Like Prefixes, each VLAN is assigned an operational status and optionally a user-defined Role. A VLAN can have zero
or more Prefixes assigned to it.
"""
site = models.ForeignKey('dcim.Site', related_name='vlans', on_delete=models.PROTECT, blank=True, null=True)
group = models.ForeignKey('VLANGroup', related_name='vlans', blank=True, null=True, on_delete=models.PROTECT)
vid = models.PositiveSmallIntegerField(verbose_name='ID', validators=[
MinValueValidator(1),
MaxValueValidator(4094)
])
name = models.CharField(max_length=64)
tenant = models.ForeignKey(Tenant, related_name='vlans', blank=True, null=True, on_delete=models.PROTECT)
status = models.PositiveSmallIntegerField('Status', choices=VLAN_STATUS_CHOICES, default=1)
role = models.ForeignKey('Role', related_name='vlans', on_delete=models.SET_NULL, blank=True, null=True)
description = models.CharField(max_length=100, blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
site = models.ForeignKey(
to='dcim.Site',
on_delete=models.PROTECT,
related_name='vlans',
blank=True,
null=True
)
group = models.ForeignKey(
to='ipam.VLANGroup',
on_delete=models.PROTECT,
related_name='vlans',
blank=True,
null=True
)
vid = models.PositiveSmallIntegerField(
verbose_name='ID',
validators=[MinValueValidator(1), MaxValueValidator(4094)]
)
name = models.CharField(
max_length=64
)
tenant = models.ForeignKey(
to='tenancy.Tenant',
on_delete=models.PROTECT,
related_name='vlans',
blank=True,
null=True
)
status = models.PositiveSmallIntegerField(
choices=VLAN_STATUS_CHOICES,
default=1,
verbose_name='Status'
)
role = models.ForeignKey(
to='ipam.Role',
on_delete=models.SET_NULL,
related_name='vlans',
blank=True,
null=True
)
description = models.CharField(
max_length=100,
blank=True
)
custom_field_values = GenericRelation(
to='extras.CustomFieldValue',
content_type_field='obj_type',
object_id_field='obj_id'
)
tags = TaggableManager()
csv_headers = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description']

View File

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

View File

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

View File

@ -51,7 +51,6 @@ class SecretViewSet(ModelViewSet):
'role__users', 'role__groups',
)
serializer_class = serializers.SecretSerializer
write_serializer_class = serializers.WritableSecretSerializer
filter_class = filters.SecretFilter
master_key = None
@ -68,7 +67,7 @@ class SecretViewSet(ModelViewSet):
super(SecretViewSet, self).initial(request, *args, **kwargs)
if request.user.is_authenticated():
if request.user.is_authenticated:
# Read session key from HTTP cookie or header if it has been provided. The session key must be provided in
# order to encrypt/decrypt secrets.

View File

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

View File

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

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.urls import reverse
from django.utils.encoding import force_bytes, python_2_unicode_compatible
from taggit.managers import TaggableManager
from dcim.models import Device
from utilities.models import CreatedUpdatedModel
from .exceptions import InvalidKey
from .hashers import SecretValidationHasher
@ -54,9 +54,21 @@ class UserKey(CreatedUpdatedModel):
copy of the master encryption key. The encrypted instance of the master key can be decrypted only with the user's
matching (private) decryption key.
"""
user = models.OneToOneField(User, related_name='user_key', editable=False, on_delete=models.CASCADE)
public_key = models.TextField(verbose_name='RSA public key')
master_key_cipher = models.BinaryField(max_length=512, blank=True, null=True, editable=False)
user = models.OneToOneField(
to=User,
on_delete=models.CASCADE,
related_name='user_key',
editable=False
)
public_key = models.TextField(
verbose_name='RSA public key'
)
master_key_cipher = models.BinaryField(
max_length=512,
blank=True,
null=True,
editable=False
)
objects = UserKeyQuerySet.as_manager()
@ -172,10 +184,23 @@ class SessionKey(models.Model):
"""
A SessionKey stores a User's temporary key to be used for the encryption and decryption of secrets.
"""
userkey = models.OneToOneField(UserKey, related_name='session_key', on_delete=models.CASCADE, editable=False)
cipher = models.BinaryField(max_length=512, editable=False)
hash = models.CharField(max_length=128, editable=False)
created = models.DateTimeField(auto_now_add=True)
userkey = models.OneToOneField(
to='secrets.UserKey',
on_delete=models.CASCADE,
related_name='session_key',
editable=False
)
cipher = models.BinaryField(
max_length=512,
editable=False
)
hash = models.CharField(
max_length=128,
editable=False
)
created = models.DateTimeField(
auto_now_add=True
)
key = None
@ -234,10 +259,23 @@ class SecretRole(models.Model):
By default, only superusers will have access to decrypt Secrets. To allow other users to decrypt Secrets, grant them
access to the appropriate SecretRoles either individually or by group.
"""
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
users = models.ManyToManyField(User, related_name='secretroles', blank=True)
groups = models.ManyToManyField(Group, related_name='secretroles', blank=True)
name = models.CharField(
max_length=50,
unique=True
)
slug = models.SlugField(
unique=True
)
users = models.ManyToManyField(
to=User,
related_name='secretroles',
blank=True
)
groups = models.ManyToManyField(
to=Group,
related_name='secretroles',
blank=True
)
csv_headers = ['name', 'slug']
@ -276,11 +314,30 @@ class Secret(CreatedUpdatedModel):
A Secret can be up to 65,536 bytes (64KB) in length. Each secret string will be padded with random data to a minimum
of 64 bytes during encryption in order to protect short strings from ciphertext analysis.
"""
device = models.ForeignKey(Device, related_name='secrets', on_delete=models.CASCADE)
role = models.ForeignKey('SecretRole', related_name='secrets', on_delete=models.PROTECT)
name = models.CharField(max_length=100, blank=True)
ciphertext = models.BinaryField(editable=False, max_length=65568) # 16B IV + 2B pad length + {62-65550}B padded
hash = models.CharField(max_length=128, editable=False)
device = models.ForeignKey(
to='dcim.Device',
on_delete=models.CASCADE,
related_name='secrets'
)
role = models.ForeignKey(
to='secrets.SecretRole',
on_delete=models.PROTECT,
related_name='secrets'
)
name = models.CharField(
max_length=100,
blank=True
)
ciphertext = models.BinaryField(
max_length=65568, # 16B IV + 2B pad length + {62-65550}B padded
editable=False
)
hash = models.CharField(
max_length=128,
editable=False
)
tags = TaggableManager()
plaintext = None
csv_headers = ['device', 'role', 'name', 'plaintext']

View File

@ -110,6 +110,16 @@
{% endif %}
</td>
</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>
</div>
{% with circuit.get_custom_fields as custom_fields %}

View File

@ -44,6 +44,12 @@
{% 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 %}
{% block javascript %}

View File

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

View File

@ -102,6 +102,16 @@
{% endif %}
</td>
</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>
<td>Circuits</td>
<td>

View File

@ -33,4 +33,10 @@
{% 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 class="col-md-3">
{% include 'inc/search_panel.html' %}
{% include 'inc/tags_panel.html' %}
</div>
</div>
{% endblock %}

View File

@ -96,6 +96,16 @@
{% endif %}
</td>
</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>
</div>
{% if vc_members %}

View File

@ -83,4 +83,10 @@
{% 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 class="col-md-3">
{% include 'inc/search_panel.html' %}
{% include 'inc/tags_panel.html' %}
</div>
</div>
{% endblock %}

View File

@ -73,6 +73,16 @@
<td>Interface Ordering</td>
<td>{{ devicetype.get_interface_ordering_display }}</td>
</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>
<td>Instances</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 %}
</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 class="col-md-3">
{% include 'inc/search_panel.html' %}
{% include 'inc/tags_panel.html' %}
</div>
</div>
{% endblock %}

View File

@ -114,6 +114,16 @@
{% endif %}
</td>
</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>
<td>Devices</td>
<td>

View File

@ -43,4 +43,10 @@
{% 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 class="col-md-3">
{% include 'inc/search_panel.html' %}
{% include 'inc/tags_panel.html' %}
</div>
</div>
{% endblock %}

View File

@ -133,6 +133,16 @@
{% endif %}
</td>
</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>
</div>
<div class="panel panel-default">

View File

@ -46,4 +46,10 @@
{% 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 class="col-md-3">
{% include 'inc/search_panel.html' %}
{% include 'inc/tags_panel.html' %}
</div>
</div>
{% 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">
{% if request.user.is_authenticated or not settings.LOGIN_REQUIRED %}
<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>
<ul class="dropdown-menu">
<li class="dropdown-header">Sites</li>
@ -60,6 +60,9 @@
</li>
<li class="divider"></li>
<li class="dropdown-header">Miscellaneous</li>
<li>
<a href="{% url 'extras:tag_list' %}">Tags</a>
</li>
<li>
<a href="{% url 'extras:report_list' %}">Reports</a>
</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' %}
{% load helpers %}
{% block content %}
<div class="row">
@ -81,6 +82,16 @@
{% endif %}
</td>
</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>
</div>
</div>

View File

@ -19,4 +19,10 @@
</div>
</div>
{% 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 %}

View File

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

View File

@ -1,4 +1,5 @@
{% extends '_base.html' %}
{% load helpers %}
{% block content %}
<div class="row">
@ -133,6 +134,16 @@
{% endif %}
</td>
</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>
</div>
{% with ipaddress.get_custom_fields as custom_fields %}

View File

@ -66,6 +66,12 @@
{% render_field form.nat_inside %}
</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 %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div>

View File

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

View File

@ -121,6 +121,16 @@
{% endif %}
</td>
</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>
<td>Utilization</td>
<td>{% utilization_graph prefix.get_utilization %}</td>

View File

@ -28,6 +28,12 @@
{% render_field form.tenant %}
</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 %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div>

View File

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

View File

@ -1,4 +1,5 @@
{% extends '_base.html' %}
{% load helpers %}
{% block content %}
{% include 'ipam/inc/vlan_header.html' with active_tab='vlan' %}
@ -80,6 +81,16 @@
<span class="text-muted">N/A</span>
{% endif %}
</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>
</table>
</div>

View File

@ -21,6 +21,12 @@
{% render_field form.tenant %}
</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 %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div>

View File

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

View File

@ -1,4 +1,5 @@
{% extends '_base.html' %}
{% load helpers %}
{% block content %}
<div class="row">
@ -77,6 +78,16 @@
<span class="text-muted">N/A</span>
{% endif %}
</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>
</table>
</div>

View File

@ -18,6 +18,12 @@
{% render_field form.tenant %}
</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 %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div>

View File

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

View File

@ -1,5 +1,6 @@
{% extends '_base.html' %}
{% load static from staticfiles %}
{% load helpers %}
{% load secret_helpers %}
{% block content %}
@ -55,6 +56,16 @@
{% endif %}
</td>
</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>
</div>
</div>

View File

@ -54,6 +54,12 @@
{% render_field form.plaintext2 %}
</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 class="row">

View File

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

View File

@ -68,6 +68,16 @@
{% endif %}
</td>
</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>
</div>
{% with tenant.get_custom_fields as custom_fields %}

View File

@ -26,4 +26,10 @@
{% 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 class="col-md-3">
{% include 'inc/search_panel.html' %}
{% include 'inc/tags_panel.html' %}
</div>
</div>
{% 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 %}
</td>
</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>
<td>Virtual Machines</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 class="col-md-3">
{% include 'inc/search_panel.html' %}
{% include 'inc/tags_panel.html' %}
</div>
</div>
{% endblock %}

View File

@ -121,6 +121,16 @@
{% endif %}
</td>
</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>
</div>
{% include 'inc/custom_fields_panel.html' with custom_fields=vm.get_custom_fields %}

View File

@ -54,4 +54,10 @@
{% 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 class="col-md-3">
{% include 'inc/search_panel.html' %}
{% include 'inc/tags_panel.html' %}
</div>
</div>
{% endblock %}

View File

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

View File

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

View File

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

View File

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

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

View File

@ -1,10 +1,11 @@
from __future__ import unicode_literals
from django.contrib.auth.models import User
from rest_framework import serializers
from utilities.api import WritableNestedSerializer
class NestedUserSerializer(serializers.ModelSerializer):
class NestedUserSerializer(WritableNestedSerializer):
class Meta:
model = User

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

View File

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

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