Merge branch 'develop-2.4' into develop

This commit is contained in:
Jeremy Stretch
2018-08-06 12:28:23 -04:00
258 changed files with 11548 additions and 4070 deletions

View File

@@ -1,30 +1,32 @@
from __future__ import unicode_literals
from rest_framework import serializers
from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
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 ChoiceField, ValidatedModelSerializer, WritableNestedSerializer
#
# Providers
#
class ProviderSerializer(CustomFieldModelSerializer):
class ProviderSerializer(TaggitSerializer, CustomFieldModelSerializer):
tags = TagListSerializerField(required=False)
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:
@@ -65,21 +57,22 @@ class NestedCircuitTypeSerializer(serializers.ModelSerializer):
# Circuits
#
class CircuitSerializer(CustomFieldModelSerializer):
class CircuitSerializer(TaggitSerializer, CustomFieldModelSerializer):
provider = NestedProviderSerializer()
status = ChoiceFieldSerializer(choices=CIRCUIT_STATUS_CHOICES)
status = ChoiceField(choices=CIRCUIT_STATUS_CHOICES, required=False)
type = NestedCircuitTypeSerializer()
tenant = NestedTenantSerializer()
tenant = NestedTenantSerializer(required=False, allow_null=True)
tags = TagListSerializerField(required=False)
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

@@ -1,7 +1,7 @@
from __future__ import unicode_literals
from django.shortcuts import get_object_or_404
from rest_framework.decorators import detail_route
from rest_framework.decorators import action
from rest_framework.response import Response
from circuits import filters
@@ -31,10 +31,9 @@ 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()
@action(detail=True)
def graphs(self, request, pk=None):
"""
A convenience method for rendering graphs for a particular provider.
@@ -62,7 +61,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
@@ -73,5 +71,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,9 +2,10 @@ 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
from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
from tenancy.forms import TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
@@ -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}),
@@ -53,7 +55,7 @@ class ProviderCSVForm(forms.ModelForm):
}
class ProviderBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
class ProviderBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Provider.objects.all(), widget=forms.MultipleHiddenInput)
asn = forms.IntegerField(required=False, label='ASN')
account = forms.CharField(max_length=30, required=False, label='Account number')
@@ -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",
@@ -155,7 +158,7 @@ class CircuitCSVForm(forms.ModelForm):
]
class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput)
type = forms.ModelChoiceField(queryset=CircuitType.objects.all(), required=False)
provider = forms.ModelChoiceField(queryset=Provider.objects.all(), required=False)

View File

@@ -0,0 +1,96 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.14 on 2018-07-31 02:25
from __future__ import unicode_literals
import dcim.fields
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
replaces = [('circuits', '0001_initial'), ('circuits', '0002_auto_20160622_1821'), ('circuits', '0003_provider_32bit_asn_support'), ('circuits', '0004_circuit_add_tenant'), ('circuits', '0005_circuit_add_upstream_speed'), ('circuits', '0006_terminations'), ('circuits', '0007_circuit_add_description'), ('circuits', '0008_circuittermination_interface_protect_on_delete'), ('circuits', '0009_unicode_literals'), ('circuits', '0010_circuit_status')]
dependencies = [
('dcim', '0001_initial'),
('dcim', '0022_color_names_to_rgb'),
('tenancy', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Provider',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateField(auto_now_add=True)),
('last_updated', models.DateTimeField(auto_now=True)),
('name', models.CharField(max_length=50, unique=True)),
('slug', models.SlugField(unique=True)),
('asn', dcim.fields.ASNField(blank=True, null=True, verbose_name='ASN')),
('account', models.CharField(blank=True, max_length=30, 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)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='CircuitType',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50, unique=True)),
('slug', models.SlugField(unique=True)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Circuit',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateField(auto_now_add=True)),
('last_updated', models.DateTimeField(auto_now=True)),
('cid', models.CharField(max_length=50, verbose_name='Circuit ID')),
('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)')),
('comments', models.TextField(blank=True)),
('provider', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='circuits', to='circuits.Provider')),
('type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='circuits', to='circuits.CircuitType')),
('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='circuits', to='tenancy.Tenant')),
('description', models.CharField(blank=True, max_length=100)),
('status', models.PositiveSmallIntegerField(choices=[[2, 'Planned'], [3, 'Provisioning'], [1, 'Active'], [4, 'Offline'], [0, 'Deprovisioning'], [5, 'Decommissioned']], default=1))
],
options={
'ordering': ['provider', 'cid'],
},
),
migrations.AlterUniqueTogether(
name='circuit',
unique_together=set([('provider', 'cid')]),
),
migrations.CreateModel(
name='CircuitTermination',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('term_side', models.CharField(choices=[('A', 'A'), ('Z', 'Z')], max_length=1, verbose_name='Termination')),
('port_speed', models.PositiveIntegerField(verbose_name='Port speed (Kbps)')),
('upstream_speed', models.PositiveIntegerField(blank=True, help_text='Upstream speed, if different from port speed', null=True, verbose_name='Upstream speed (Kbps)')),
('xconnect_id', models.CharField(blank=True, max_length=50, verbose_name='Cross-connect ID')),
('pp_info', models.CharField(blank=True, max_length=100, verbose_name='Patch panel/port(s)')),
('circuit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations', to='circuits.Circuit')),
('interface', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='circuit_termination', to='dcim.Interface')),
('site', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='circuit_terminations', to='dcim.Site')),
],
options={
'ordering': ['circuit', 'term_side'],
},
),
migrations.AlterUniqueTogether(
name='circuittermination',
unique_together=set([('circuit', 'term_side')]),
),
]

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

@@ -0,0 +1,45 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.12 on 2018-06-13 17:14
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('circuits', '0011_tags'),
]
operations = [
migrations.AddField(
model_name='circuittype',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name='circuittype',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AlterField(
model_name='circuit',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AlterField(
model_name='circuit',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AlterField(
model_name='provider',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AlterField(
model_name='provider',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
]

View File

@@ -4,30 +4,61 @@ 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 utilities.models import CreatedUpdatedModel
from extras.models import CustomFieldModel, ObjectChange
from utilities.models import ChangeLoggedModel
from utilities.utils import serialize_object
from .constants import CIRCUIT_STATUS_ACTIVE, CIRCUIT_STATUS_CHOICES, TERM_SIDE_CHOICES
@python_2_unicode_compatible
class Provider(CreatedUpdatedModel, CustomFieldModel):
class Provider(ChangeLoggedModel, 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']
@@ -54,13 +85,18 @@ class Provider(CreatedUpdatedModel, CustomFieldModel):
@python_2_unicode_compatible
class CircuitType(models.Model):
class CircuitType(ChangeLoggedModel):
"""
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']
@@ -81,22 +117,60 @@ class CircuitType(models.Model):
@python_2_unicode_compatible
class Circuit(CreatedUpdatedModel, CustomFieldModel):
class Circuit(ChangeLoggedModel, CustomFieldModel):
"""
A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple
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',
@@ -145,19 +219,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']
@@ -166,6 +268,19 @@ class CircuitTermination(models.Model):
def __str__(self):
return '{} (Side {})'.format(self.circuit, self.get_term_side_display())
def log_change(self, user, request_id, action):
"""
Reference the parent circuit when recording the change.
"""
ObjectChange(
user=user,
request_id=request_id,
changed_object=self,
related_object=self.circuit,
action=action,
object_data=serialize_object(self)
).save()
def get_peer_termination(self):
peer_side = 'Z' if self.term_side == 'A' else 'A'
try:

View File

@@ -9,6 +9,9 @@ from utilities.tables import BaseTable, ToggleColumn
from .models import Circuit, CircuitType, Provider
CIRCUITTYPE_ACTIONS = """
<a href="{% url 'circuits:circuittype_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
<i class="fa fa-history"></i>
</a>
{% if perms.circuit.change_circuittype %}
<a href="{% url 'circuits:circuittype_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}

View File

@@ -1,26 +1,21 @@
from __future__ import unicode_literals
from django.contrib.auth.models import User
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
from extras.models import Graph
from users.models import Token
from utilities.tests import HttpStatusMixin
from utilities.testing import APITestCase
class ProviderTest(HttpStatusMixin, APITestCase):
class ProviderTest(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)}
super(ProviderTest, self).setUp()
self.provider1 = Provider.objects.create(name='Test Provider 1', slug='test-provider-1')
self.provider2 = Provider.objects.create(name='Test Provider 2', slug='test-provider-2')
@@ -128,13 +123,11 @@ class ProviderTest(HttpStatusMixin, APITestCase):
self.assertEqual(Provider.objects.count(), 2)
class CircuitTypeTest(HttpStatusMixin, APITestCase):
class CircuitTypeTest(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)}
super(CircuitTypeTest, self).setUp()
self.circuittype1 = CircuitType.objects.create(name='Test Circuit Type 1', slug='test-circuit-type-1')
self.circuittype2 = CircuitType.objects.create(name='Test Circuit Type 2', slug='test-circuit-type-2')
@@ -195,13 +188,11 @@ class CircuitTypeTest(HttpStatusMixin, APITestCase):
self.assertEqual(CircuitType.objects.count(), 2)
class CircuitTest(HttpStatusMixin, APITestCase):
class CircuitTest(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)}
super(CircuitTest, self).setUp()
self.provider1 = Provider.objects.create(name='Test Provider 1', slug='test-provider-1')
self.provider2 = Provider.objects.create(name='Test Provider 2', slug='test-provider-2')
@@ -231,6 +222,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 +242,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,
},
]
@@ -299,13 +294,11 @@ class CircuitTest(HttpStatusMixin, APITestCase):
self.assertEqual(Circuit.objects.count(), 2)
class CircuitTerminationTest(HttpStatusMixin, APITestCase):
class CircuitTerminationTest(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)}
super(CircuitTerminationTest, self).setUp()
provider = Provider.objects.create(name='Test Provider', slug='test-provider')
circuittype = CircuitType.objects.create(name='Test Circuit Type', slug='test-circuit-type')

View File

@@ -2,7 +2,9 @@ from __future__ import unicode_literals
from django.conf.urls import url
from extras.views import ObjectChangeLogView
from . import views
from .models import Circuit, CircuitType, Provider
app_name = 'circuits'
urlpatterns = [
@@ -16,6 +18,7 @@ urlpatterns = [
url(r'^providers/(?P<slug>[\w-]+)/$', views.ProviderView.as_view(), name='provider'),
url(r'^providers/(?P<slug>[\w-]+)/edit/$', views.ProviderEditView.as_view(), name='provider_edit'),
url(r'^providers/(?P<slug>[\w-]+)/delete/$', views.ProviderDeleteView.as_view(), name='provider_delete'),
url(r'^providers/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}),
# Circuit types
url(r'^circuit-types/$', views.CircuitTypeListView.as_view(), name='circuittype_list'),
@@ -23,6 +26,7 @@ urlpatterns = [
url(r'^circuit-types/import/$', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'),
url(r'^circuit-types/delete/$', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'),
url(r'^circuit-types/(?P<slug>[\w-]+)/edit/$', views.CircuitTypeEditView.as_view(), name='circuittype_edit'),
url(r'^circuit-types/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}),
# Circuits
url(r'^circuits/$', views.CircuitListView.as_view(), name='circuit_list'),
@@ -33,6 +37,7 @@ urlpatterns = [
url(r'^circuits/(?P<pk>\d+)/$', views.CircuitView.as_view(), name='circuit'),
url(r'^circuits/(?P<pk>\d+)/edit/$', views.CircuitEditView.as_view(), name='circuit_edit'),
url(r'^circuits/(?P<pk>\d+)/delete/$', views.CircuitDeleteView.as_view(), name='circuit_delete'),
url(r'^circuits/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}),
url(r'^circuits/(?P<pk>\d+)/terminations/swap/$', views.circuit_terminations_swap, name='circuit_terminations_swap'),
# Circuit terminations

View File

@@ -6,7 +6,6 @@ from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db import transaction
from django.db.models import Count
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.views.generic import View
from extras.models import Graph, GRAPH_TYPE_PROVIDER
@@ -77,7 +76,7 @@ class ProviderBulkImportView(PermissionRequiredMixin, BulkImportView):
class ProviderBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'circuits.change_provider'
cls = Provider
queryset = Provider.objects.all()
filter = filters.ProviderFilter
table = tables.ProviderTable
form = forms.ProviderBulkEditForm
@@ -86,7 +85,7 @@ class ProviderBulkEditView(PermissionRequiredMixin, BulkEditView):
class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'circuits.delete_provider'
cls = Provider
queryset = Provider.objects.all()
filter = filters.ProviderFilter
table = tables.ProviderTable
default_return_url = 'circuits:provider_list'
@@ -106,9 +105,7 @@ class CircuitTypeCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'circuits.add_circuittype'
model = CircuitType
model_form = forms.CircuitTypeForm
def get_return_url(self, request, obj):
return reverse('circuits:circuittype_list')
default_return_url = 'circuits:circuittype_list'
class CircuitTypeEditView(CircuitTypeCreateView):
@@ -124,7 +121,6 @@ class CircuitTypeBulkImportView(PermissionRequiredMixin, BulkImportView):
class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'circuits.delete_circuittype'
cls = CircuitType
queryset = CircuitType.objects.annotate(circuit_count=Count('circuits'))
table = tables.CircuitTypeTable
default_return_url = 'circuits:circuittype_list'
@@ -196,7 +192,6 @@ class CircuitBulkImportView(PermissionRequiredMixin, BulkImportView):
class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'circuits.change_circuit'
cls = Circuit
queryset = Circuit.objects.select_related('provider', 'type', 'tenant').prefetch_related('terminations__site')
filter = filters.CircuitFilter
table = tables.CircuitTable
@@ -206,7 +201,6 @@ class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView):
class CircuitBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'circuits.delete_circuit'
cls = Circuit
queryset = Circuit.objects.select_related('provider', 'type', 'tenant').prefetch_related('terminations__site')
filter = filters.CircuitFilter
table = tables.CircuitTable

View File

@@ -1,9 +1,8 @@
from __future__ import unicode_literals
from collections import OrderedDict
from rest_framework import serializers
from rest_framework.validators import UniqueTogetherValidator
from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
from circuits.models import Circuit, CircuitTermination
from dcim.constants import (
@@ -20,7 +19,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 (
ChoiceField, SerializedPKRelatedField, TimeZoneField, ValidatedModelSerializer,
WritableNestedSerializer,
)
from virtualization.models import Cluster
@@ -28,7 +30,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 +39,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
@@ -55,23 +50,24 @@ class WritableRegionSerializer(ValidatedModelSerializer):
# Sites
#
class SiteSerializer(CustomFieldModelSerializer):
status = ChoiceFieldSerializer(choices=SITE_STATUS_CHOICES)
region = NestedRegionSerializer()
tenant = NestedTenantSerializer()
class SiteSerializer(TaggitSerializer, CustomFieldModelSerializer):
status = ChoiceField(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 = TagListSerializerField(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', 'count_prefixes', 'count_vlans', 'count_racks', 'count_devices',
'count_circuits',
'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone',
'contact_email', 'comments', '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 +75,11 @@ class NestedSiteSerializer(serializers.ModelSerializer):
fields = ['id', 'url', 'name', 'slug']
class WritableSiteSerializer(CustomFieldModelSerializer):
time_zone = TimeZoneField(required=False, allow_null=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',
]
#
# Rack groups
#
class RackGroupSerializer(serializers.ModelSerializer):
class RackGroupSerializer(ValidatedModelSerializer):
site = NestedSiteSerializer()
class Meta:
@@ -103,7 +87,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 +95,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 +106,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:
@@ -141,23 +118,42 @@ class NestedRackRoleSerializer(serializers.ModelSerializer):
# Racks
#
class RackSerializer(CustomFieldModelSerializer):
class RackSerializer(TaggitSerializer, 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 = ChoiceField(choices=RACK_TYPE_CHOICES, required=False)
width = ChoiceField(choices=RACK_WIDTH_CHOICES, required=False)
tags = TagListSerializerField(required=False)
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 +161,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 +187,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', 'tenant', 'description']
#
# Manufacturers
#
@@ -247,7 +208,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:
@@ -259,46 +220,36 @@ class NestedManufacturerSerializer(serializers.ModelSerializer):
# Device types
#
class DeviceTypeSerializer(CustomFieldModelSerializer):
class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer):
manufacturer = NestedManufacturerSerializer()
interface_ordering = ChoiceFieldSerializer(choices=IFACE_ORDERING_CHOICES)
subdevice_role = ChoiceFieldSerializer(choices=SUBDEVICE_ROLE_CHOICES)
interface_ordering = ChoiceField(choices=IFACE_ORDERING_CHOICES, required=False)
subdevice_role = ChoiceField(choices=SUBDEVICE_ROLE_CHOICES, required=False)
instance_count = serializers.IntegerField(source='instances.count', read_only=True)
tags = TagListSerializerField(required=False)
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',
'instance_count',
'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments', 'tags', 'custom_fields',
'created', 'last_updated', '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):
subdevice_role = ChoiceFieldSerializer(choices=SUBDEVICE_ROLE_CHOICES, allow_null=True, required=False)
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:
@@ -306,18 +257,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:
@@ -325,18 +269,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:
@@ -344,18 +281,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:
@@ -363,27 +293,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 = ChoiceField(choices=IFACE_FF_CHOICES, required=False)
class Meta:
model = InterfaceTemplate
@@ -394,7 +310,7 @@ class WritableInterfaceTemplateSerializer(ValidatedModelSerializer):
# Device bay templates
#
class DeviceBayTemplateSerializer(serializers.ModelSerializer):
class DeviceBayTemplateSerializer(ValidatedModelSerializer):
device_type = NestedDeviceTypeSerializer()
class Meta:
@@ -402,13 +318,6 @@ class DeviceBayTemplateSerializer(serializers.ModelSerializer):
fields = ['id', 'device_type', 'name']
class WritableDeviceBayTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = DeviceBayTemplate
fields = ['id', 'device_type', 'name']
#
# Device roles
#
@@ -420,7 +329,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:
@@ -432,15 +341,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']
fields = ['id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'rpc_client']
class NestedPlatformSerializer(serializers.ModelSerializer):
class NestedPlatformSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail')
class Meta:
@@ -448,13 +357,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
#
@@ -487,51 +389,31 @@ class DeviceVirtualChassisSerializer(serializers.ModelSerializer):
fields = ['id', 'url', 'master']
class DeviceSerializer(CustomFieldModelSerializer):
class DeviceSerializer(TaggitSerializer, 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 = ChoiceField(choices=RACK_FACE_CHOICES, required=False)
status = ChoiceField(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 = TagListSerializerField(required=False)
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):
@@ -543,101 +425,121 @@ 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
class DeviceWithConfigContextSerializer(DeviceSerializer):
config_context = serializers.SerializerMethodField()
class Meta(DeviceSerializer.Meta):
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', 'tags', 'custom_fields',
'config_context', 'created', 'last_updated',
]
def get_config_context(self, obj):
return obj.get_config_context()
#
# Console server ports
#
class ConsoleServerPortSerializer(serializers.ModelSerializer):
class ConsoleServerPortSerializer(TaggitSerializer, ValidatedModelSerializer):
device = NestedDeviceSerializer()
tags = TagListSerializerField(required=False)
class Meta:
model = ConsoleServerPort
fields = ['id', 'device', 'name', 'connected_console']
fields = ['id', 'device', 'name', 'connected_console', 'tags']
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(TaggitSerializer, ValidatedModelSerializer):
device = NestedDeviceSerializer()
cs_port = ConsoleServerPortSerializer()
cs_port = NestedConsoleServerPortSerializer(required=False, allow_null=True)
tags = TagListSerializerField(required=False)
class Meta:
model = ConsolePort
fields = ['id', 'device', 'name', 'cs_port', 'connection_status']
class WritableConsolePortSerializer(ValidatedModelSerializer):
class Meta:
model = ConsolePort
fields = ['id', 'device', 'name', 'cs_port', 'connection_status']
fields = ['id', 'device', 'name', 'cs_port', 'connection_status', 'tags']
#
# Power outlets
#
class PowerOutletSerializer(serializers.ModelSerializer):
class PowerOutletSerializer(TaggitSerializer, ValidatedModelSerializer):
device = NestedDeviceSerializer()
tags = TagListSerializerField(required=False)
class Meta:
model = PowerOutlet
fields = ['id', 'device', 'name', 'connected_port']
fields = ['id', 'device', 'name', 'connected_port', 'tags']
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(TaggitSerializer, ValidatedModelSerializer):
device = NestedDeviceSerializer()
power_outlet = PowerOutletSerializer()
power_outlet = NestedPowerOutletSerializer(required=False, allow_null=True)
tags = TagListSerializerField(required=False)
class Meta:
model = PowerPort
fields = ['id', 'device', 'name', 'power_outlet', 'connection_status']
class WritablePowerPortSerializer(ValidatedModelSerializer):
class Meta:
model = PowerPort
fields = ['id', 'device', 'name', 'power_outlet', 'connection_status']
fields = ['id', 'device', 'name', 'power_outlet', 'connection_status', 'tags']
#
# 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):
@@ -648,8 +550,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
@@ -659,7 +561,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:
@@ -667,67 +569,29 @@ class InterfaceVLANSerializer(serializers.ModelSerializer):
fields = ['id', 'url', 'vid', 'name', 'display_name']
class InterfaceSerializer(serializers.ModelSerializer):
class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer):
device = NestedDeviceSerializer()
form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES)
lag = NestedInterfaceSerializer()
form_factor = ChoiceField(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 = ChoiceField(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
)
tags = TagListSerializerField(required=False)
class Meta:
model = Interface
fields = [
'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
'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',
'tags',
]
def validate(self, data):
@@ -747,23 +611,46 @@ 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(TaggitSerializer, ValidatedModelSerializer):
device = NestedDeviceSerializer()
installed_device = NestedDeviceSerializer()
installed_device = NestedDeviceSerializer(required=False, allow_null=True)
tags = TagListSerializerField(required=False)
class Meta:
model = DeviceBay
fields = ['id', 'device', 'name', 'installed_device']
fields = ['id', 'device', 'name', 'installed_device', 'tags']
class NestedDeviceBaySerializer(serializers.ModelSerializer):
class NestedDeviceBaySerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail')
class Meta:
@@ -771,38 +658,22 @@ 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(TaggitSerializer, 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()
tags = TagListSerializerField(required=False)
class Meta:
model = InventoryItem
fields = [
'id', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered',
'description',
'description', 'tags',
]
@@ -810,17 +681,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 = ChoiceField(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:
@@ -828,35 +699,37 @@ 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 = ChoiceField(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(TaggitSerializer, ValidatedModelSerializer):
master = NestedDeviceSerializer()
tags = TagListSerializerField(required=False)
class Meta:
model = VirtualChassis
fields = ['id', 'master', 'domain']
fields = ['id', 'master', 'domain', 'tags']
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

@@ -8,7 +8,7 @@ from django.shortcuts import get_object_or_404
from drf_yasg import openapi
from drf_yasg.openapi import Parameter
from drf_yasg.utils import swagger_auto_schema
from rest_framework.decorators import detail_route
from rest_framework.decorators import action
from rest_framework.mixins import ListModelMixin
from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet, ViewSet
@@ -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,10 +62,9 @@ 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()
@action(detail=True)
def graphs(self, request, pk=None):
"""
A convenience method for rendering graphs for a particular site.
@@ -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,10 +102,9 @@ 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()
@action(detail=True)
def units(self, request, pk=None):
"""
List rack units (by rack)
@@ -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
@@ -243,11 +230,17 @@ class DeviceViewSet(CustomFieldModelViewSet):
).prefetch_related(
'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')
def get_serializer_class(self):
"""
Include rendered config context when retrieving a single Device.
"""
if self.action == 'retrieve':
return serializers.DeviceWithConfigContextSerializer
return serializers.DeviceSerializer
@action(detail=True, url_path='napalm')
def napalm(self, request, pk):
"""
Execute a NAPALM method on a Device
@@ -285,12 +278,15 @@ class DeviceViewSet(CustomFieldModelViewSet):
napalm_methods = request.GET.getlist('method')
response = OrderedDict([(m, None) for m in napalm_methods])
ip_address = str(device.primary_ip.address.ip)
optional_args = settings.NAPALM_ARGS.copy()
if device.platform.napalm_args is not None:
optional_args.update(device.platform.napalm_args)
d = driver(
hostname=ip_address,
username=settings.NAPALM_USERNAME,
password=settings.NAPALM_PASSWORD,
timeout=settings.NAPALM_TIMEOUT,
optional_args=settings.NAPALM_ARGS
optional_args=optional_args
)
try:
d.open()
@@ -321,38 +317,33 @@ 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()
@action(detail=True)
def graphs(self, request, pk=None):
"""
A convenience method for rendering graphs for a particular interface.
@@ -366,14 +357,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
@@ -396,7 +385,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
@@ -407,7 +395,6 @@ class InterfaceConnectionViewSet(ModelViewSet):
class VirtualChassisViewSet(ModelViewSet):
queryset = VirtualChassis.objects.all()
serializer_class = serializers.VirtualChassisSerializer
write_serializer_class = serializers.WritableVirtualChassisSerializer
#

View File

@@ -8,4 +8,5 @@ class DCIMConfig(AppConfig):
verbose_name = "DCIM"
def ready(self):
import dcim.signals

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
@@ -546,6 +558,9 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
to_field_name='name',
label='Device (name)',
)
tag = django_filters.CharFilter(
name='tags__slug',
)
class ConsolePortFilter(DeviceComponentFilterSet):
@@ -604,6 +619,9 @@ class InterfaceFilter(django_filters.FilterSet):
method='_mac_address',
label='MAC address',
)
tag = django_filters.CharFilter(
name='tags__slug',
)
class Meta:
model = Interface
@@ -710,6 +728,9 @@ class VirtualChassisFilter(django_filters.FilterSet):
to_field_name='slug',
label='Tenant (slug)',
)
tag = django_filters.CharFilter(
name='tags__slug',
)
class Meta:
model = VirtualChassis

View File

@@ -70,6 +70,8 @@
"model": "dcim.devicetype",
"pk": 1,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"manufacturer": 1,
"model": "MX960",
"slug": "mx960",
@@ -84,6 +86,8 @@
"model": "dcim.devicetype",
"pk": 2,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"manufacturer": 1,
"model": "EX9214",
"slug": "ex9214",
@@ -98,6 +102,8 @@
"model": "dcim.devicetype",
"pk": 3,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"manufacturer": 1,
"model": "QFX5100-24Q",
"slug": "qfx5100-24q",
@@ -112,6 +118,8 @@
"model": "dcim.devicetype",
"pk": 4,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"manufacturer": 1,
"model": "QFX5100-48S",
"slug": "qfx5100-48s",
@@ -126,6 +134,8 @@
"model": "dcim.devicetype",
"pk": 5,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"manufacturer": 2,
"model": "CM4148",
"slug": "cm4148",
@@ -140,6 +150,8 @@
"model": "dcim.devicetype",
"pk": 6,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"manufacturer": 3,
"model": "CWG-24VYM415C9",
"slug": "cwg-24vym415c9",

View File

@@ -7,9 +7,10 @@ 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
from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
from ipam.models import IPAddress, VLAN, VLANGroup
from tenancy.forms import TenancyForm
from tenancy.models import Tenant
@@ -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',
'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone',
'contact_email', 'comments', 'tags',
]
widgets = {
'physical_address': SmallTextarea(attrs={'rows': 3}),
@@ -126,7 +129,9 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
'time_zone': "Local time zone",
'description': "Short description (will appear in sites list)",
'physical_address': "Physical location of the building (e.g. for GPS)",
'shipping_address': "If different from the physical address"
'shipping_address': "If different from the physical address",
'latitude': "Latitude in decimal format (xx.yyyyyy)",
'longitude': "Longitude in decimal format (xx.yyyyyy)"
}
@@ -165,7 +170,7 @@ class SiteCSVForm(forms.ModelForm):
}
class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Site.objects.all(),
widget=forms.MultipleHiddenInput
@@ -298,12 +303,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",
@@ -374,6 +380,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:
@@ -382,8 +390,20 @@ 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)
)
class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
# 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, AddRemoveTagsForm, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput)
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site')
group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), required=False, label='Group')
@@ -509,11 +529,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',
}
@@ -549,7 +572,7 @@ class DeviceTypeCSVForm(forms.ModelForm):
}
class DeviceTypeBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
class DeviceTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=DeviceType.objects.all(), widget=forms.MultipleHiddenInput)
manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), required=False)
u_height = forms.IntegerField(min_value=1, required=False)
@@ -723,7 +746,10 @@ class PlatformForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = Platform
fields = ['name', 'slug', 'manufacturer', 'napalm_driver', 'rpc_client']
fields = ['name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'rpc_client']
widgets = {
'napalm_args': SmallTextarea(),
}
class PlatformCSVForm(forms.ModelForm):
@@ -796,12 +822,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",
@@ -1063,7 +1090,7 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm):
raise forms.ValidationError("Parent device/bay ({} {}) not found".format(parent, device_bay_name))
class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput)
device_type = forms.ModelChoiceField(queryset=DeviceType.objects.all(), required=False, label='Type')
device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), required=False, label='Role')
@@ -1153,10 +1180,11 @@ class DeviceBulkAddInterfaceForm(DeviceBulkAddComponentForm):
#
class ConsolePortForm(BootstrapMixin, forms.ModelForm):
tags = TagField(required=False)
class Meta:
model = ConsolePort
fields = ['device', 'name']
fields = ['device', 'name', 'tags']
widgets = {
'device': forms.HiddenInput(),
}
@@ -1322,10 +1350,11 @@ class ConsolePortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelF
#
class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm):
tags = TagField(required=False)
class Meta:
model = ConsoleServerPort
fields = ['device', 'name']
fields = ['device', 'name', 'tags']
widgets = {
'device': forms.HiddenInput(),
}
@@ -1418,10 +1447,11 @@ class ConsoleServerPortBulkDisconnectForm(ConfirmationForm):
#
class PowerPortForm(BootstrapMixin, forms.ModelForm):
tags = TagField(required=False)
class Meta:
model = PowerPort
fields = ['device', 'name']
fields = ['device', 'name', 'tags']
widgets = {
'device': forms.HiddenInput(),
}
@@ -1587,10 +1617,11 @@ class PowerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFor
#
class PowerOutletForm(BootstrapMixin, forms.ModelForm):
tags = TagField(required=False)
class Meta:
model = PowerOutlet
fields = ['device', 'name']
fields = ['device', 'name', 'tags']
widgets = {
'device': forms.HiddenInput(),
}
@@ -1683,12 +1714,13 @@ class PowerOutletBulkDisconnectForm(ConfirmationForm):
#
class InterfaceForm(BootstrapMixin, forms.ModelForm):
tags = TagField(required=False)
class Meta:
model = Interface
fields = [
'device', 'name', 'form_factor', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description',
'mode', 'untagged_vlan', 'tagged_vlans',
'mode', 'untagged_vlan', 'tagged_vlans', 'tags',
]
widgets = {
'device': forms.HiddenInput(),
@@ -1849,7 +1881,7 @@ class InterfaceCreateForm(ComponentForm, forms.Form):
self.fields['lag'].queryset = Interface.objects.none()
class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False)
enabled = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect)
@@ -2056,10 +2088,11 @@ class InterfaceConnectionCSVForm(forms.ModelForm):
#
class DeviceBayForm(BootstrapMixin, forms.ModelForm):
tags = TagField(required=False)
class Meta:
model = DeviceBay
fields = ['device', 'name']
fields = ['device', 'name', 'tags']
widgets = {
'device': forms.HiddenInput(),
}
@@ -2117,10 +2150,11 @@ class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form):
#
class InventoryItemForm(BootstrapMixin, forms.ModelForm):
tags = TagField(required=False)
class Meta:
model = InventoryItem
fields = ['name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description']
fields = ['name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'tags']
class InventoryItemCSVForm(forms.ModelForm):
@@ -2176,10 +2210,11 @@ class DeviceSelectionForm(forms.Form):
class VirtualChassisForm(BootstrapMixin, forms.ModelForm):
tags = TagField(required=False)
class Meta:
model = VirtualChassis
fields = ['master', 'domain']
fields = ['master', 'domain', 'tags']
widgets = {
'master': SelectWithPK,
}

View File

@@ -0,0 +1,261 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.14 on 2018-07-31 02:06
from __future__ import unicode_literals
import dcim.fields
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import utilities.fields
class Migration(migrations.Migration):
replaces = [('dcim', '0002_auto_20160622_1821'), ('dcim', '0003_auto_20160628_1721'), ('dcim', '0004_auto_20160701_2049'), ('dcim', '0005_auto_20160706_1722'), ('dcim', '0006_add_device_primary_ip4_ip6'), ('dcim', '0007_device_copy_primary_ip'), ('dcim', '0008_device_remove_primary_ip'), ('dcim', '0009_site_32bit_asn_support'), ('dcim', '0010_devicebay_installed_device_set_null'), ('dcim', '0011_devicetype_part_number'), ('dcim', '0012_site_rack_device_add_tenant'), ('dcim', '0013_add_interface_form_factors'), ('dcim', '0014_rack_add_type_width'), ('dcim', '0015_rack_add_u_height_validator'), ('dcim', '0016_module_add_manufacturer'), ('dcim', '0017_rack_add_role'), ('dcim', '0018_device_add_asset_tag'), ('dcim', '0019_new_iface_form_factors'), ('dcim', '0020_rack_desc_units'), ('dcim', '0021_add_ff_flexstack'), ('dcim', '0022_color_names_to_rgb')]
dependencies = [
('dcim', '0001_initial'),
('ipam', '0001_initial'),
('tenancy', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='device',
name='rack',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='dcim.Rack'),
),
migrations.AddField(
model_name='consoleserverporttemplate',
name='device_type',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cs_port_templates', to='dcim.DeviceType'),
),
migrations.AddField(
model_name='consoleserverport',
name='device',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cs_ports', to='dcim.Device'),
),
migrations.AddField(
model_name='consoleporttemplate',
name='device_type',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='console_port_templates', to='dcim.DeviceType'),
),
migrations.AddField(
model_name='consoleport',
name='cs_port',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='connected_console', to='dcim.ConsoleServerPort', verbose_name=b'Console server port'),
),
migrations.AddField(
model_name='consoleport',
name='device',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='console_ports', to='dcim.Device'),
),
migrations.AlterUniqueTogether(
name='rackgroup',
unique_together=set([('site', 'name'), ('site', 'slug')]),
),
migrations.AlterUniqueTogether(
name='rack',
unique_together=set([('site', 'facility_id'), ('site', 'name')]),
),
migrations.AlterUniqueTogether(
name='powerporttemplate',
unique_together=set([('device_type', 'name')]),
),
migrations.AlterUniqueTogether(
name='powerport',
unique_together=set([('device', 'name')]),
),
migrations.AlterUniqueTogether(
name='poweroutlettemplate',
unique_together=set([('device_type', 'name')]),
),
migrations.AlterUniqueTogether(
name='poweroutlet',
unique_together=set([('device', 'name')]),
),
migrations.AlterUniqueTogether(
name='module',
unique_together=set([('device', 'parent', 'name')]),
),
migrations.AlterUniqueTogether(
name='interfacetemplate',
unique_together=set([('device_type', 'name')]),
),
migrations.AddField(
model_name='interface',
name='mac_address',
field=dcim.fields.MACAddressField(blank=True, null=True, verbose_name=b'MAC Address'),
),
migrations.AlterUniqueTogether(
name='interface',
unique_together=set([('device', 'name')]),
),
migrations.AddField(
model_name='devicetype',
name='subdevice_role',
field=models.NullBooleanField(choices=[(None, b'None'), (True, b'Parent'), (False, b'Child')], default=None, help_text=b'Parent devices house child devices in device bays. Select "None" if this device type is neither a parent nor a child.', verbose_name=b'Parent/child status'),
),
migrations.AlterUniqueTogether(
name='devicetype',
unique_together=set([('manufacturer', 'slug'), ('manufacturer', 'model')]),
),
migrations.AlterUniqueTogether(
name='device',
unique_together=set([('rack', 'position', 'face')]),
),
migrations.AlterUniqueTogether(
name='consoleserverporttemplate',
unique_together=set([('device_type', 'name')]),
),
migrations.AlterUniqueTogether(
name='consoleserverport',
unique_together=set([('device', 'name')]),
),
migrations.AlterUniqueTogether(
name='consoleporttemplate',
unique_together=set([('device_type', 'name')]),
),
migrations.AlterUniqueTogether(
name='consoleport',
unique_together=set([('device', 'name')]),
),
migrations.CreateModel(
name='DeviceBay',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50, verbose_name=b'Name')),
('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='device_bays', to='dcim.Device')),
('installed_device', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='parent_bay', to='dcim.Device')),
],
options={
'ordering': ['device', 'name'],
},
),
migrations.CreateModel(
name='DeviceBayTemplate',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=30)),
('device_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='device_bay_templates', to='dcim.DeviceType')),
],
options={
'ordering': ['device_type', 'name'],
},
),
migrations.AlterUniqueTogether(
name='devicebaytemplate',
unique_together=set([('device_type', 'name')]),
),
migrations.AlterUniqueTogether(
name='devicebay',
unique_together=set([('device', 'name')]),
),
migrations.AddField(
model_name='device',
name='primary_ip4',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='primary_ip4_for', to='ipam.IPAddress', verbose_name=b'Primary IPv4'),
),
migrations.AddField(
model_name='device',
name='primary_ip6',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='primary_ip6_for', to='ipam.IPAddress', verbose_name=b'Primary IPv6'),
),
migrations.AlterField(
model_name='site',
name='asn',
field=dcim.fields.ASNField(blank=True, null=True, verbose_name=b'ASN'),
),
migrations.AlterField(
model_name='devicebay',
name='installed_device',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='parent_bay', to='dcim.Device'),
),
migrations.AddField(
model_name='devicetype',
name='part_number',
field=models.CharField(blank=True, help_text=b'Discrete part number (optional)', max_length=50),
),
migrations.AddField(
model_name='device',
name='tenant',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='tenancy.Tenant'),
),
migrations.AddField(
model_name='rack',
name='tenant',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='racks', to='tenancy.Tenant'),
),
migrations.AddField(
model_name='site',
name='tenant',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='sites', to='tenancy.Tenant'),
),
migrations.AddField(
model_name='rack',
name='type',
field=models.PositiveSmallIntegerField(blank=True, choices=[(100, b'2-post frame'), (200, b'4-post frame'), (300, b'4-post cabinet'), (1000, b'Wall-mounted frame'), (1100, b'Wall-mounted cabinet')], null=True, verbose_name=b'Type'),
),
migrations.AddField(
model_name='rack',
name='width',
field=models.PositiveSmallIntegerField(choices=[(19, b'19 inches'), (23, b'23 inches')], default=19, help_text=b'Rail-to-rail width', verbose_name=b'Width'),
),
migrations.AlterField(
model_name='rack',
name='u_height',
field=models.PositiveSmallIntegerField(default=42, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)], verbose_name=b'Height (U)'),
),
migrations.AddField(
model_name='module',
name='manufacturer',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='modules', to='dcim.Manufacturer'),
),
migrations.CreateModel(
name='RackRole',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50, unique=True)),
('slug', models.SlugField(unique=True)),
('color', utilities.fields.ColorField(max_length=6)),
],
options={
'ordering': ['name'],
},
),
migrations.AddField(
model_name='rack',
name='role',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='racks', to='dcim.RackRole'),
),
migrations.AddField(
model_name='device',
name='asset_tag',
field=utilities.fields.NullableCharField(blank=True, help_text=b'A unique tag used to identify this device', max_length=50, null=True, unique=True, verbose_name=b'Asset tag'),
),
migrations.AddField(
model_name='rack',
name='desc_units',
field=models.BooleanField(default=False, help_text=b'Units are numbered top-to-bottom', verbose_name=b'Descending units'),
),
migrations.AlterField(
model_name='device',
name='position',
field=models.PositiveSmallIntegerField(blank=True, help_text=b'The lowest-numbered unit occupied by the device', null=True, validators=[django.core.validators.MinValueValidator(1)], verbose_name=b'Position (U)'),
),
migrations.AlterField(
model_name='interface',
name='form_factor',
field=models.PositiveSmallIntegerField(choices=[[b'Virtual interfaces', [[0, b'Virtual']]], [b'Ethernet (fixed)', [[800, b'100BASE-TX (10/100ME)'], [1000, b'1000BASE-T (1GE)'], [1150, b'10GBASE-T (10GE)']]], [b'Ethernet (modular)', [[1050, b'GBIC (1GE)'], [1100, b'SFP (1GE)'], [1200, b'SFP+ (10GE)'], [1300, b'XFP (10GE)'], [1310, b'XENPAK (10GE)'], [1320, b'X2 (10GE)'], [1350, b'SFP28 (25GE)'], [1400, b'QSFP+ (40GE)'], [1500, b'CFP (100GE)'], [1600, b'QSFP28 (100GE)']]], [b'FibreChannel', [[3010, b'SFP (1GFC)'], [3020, b'SFP (2GFC)'], [3040, b'SFP (4GFC)'], [3080, b'SFP+ (8GFC)'], [3160, b'SFP+ (16GFC)']]], [b'Serial', [[4000, b'T1 (1.544 Mbps)'], [4010, b'E1 (2.048 Mbps)'], [4040, b'T3 (45 Mbps)'], [4050, b'E3 (34 Mbps)']]], [b'Stacking', [[5000, b'Cisco StackWise'], [5050, b'Cisco StackWise Plus'], [5100, b'Cisco FlexStack'], [5150, b'Cisco FlexStack Plus']]], [b'Other', [[32767, b'Other']]]], default=1200),
),
migrations.AlterField(
model_name='interfacetemplate',
name='form_factor',
field=models.PositiveSmallIntegerField(choices=[[b'Virtual interfaces', [[0, b'Virtual']]], [b'Ethernet (fixed)', [[800, b'100BASE-TX (10/100ME)'], [1000, b'1000BASE-T (1GE)'], [1150, b'10GBASE-T (10GE)']]], [b'Ethernet (modular)', [[1050, b'GBIC (1GE)'], [1100, b'SFP (1GE)'], [1200, b'SFP+ (10GE)'], [1300, b'XFP (10GE)'], [1310, b'XENPAK (10GE)'], [1320, b'X2 (10GE)'], [1350, b'SFP28 (25GE)'], [1400, b'QSFP+ (40GE)'], [1500, b'CFP (100GE)'], [1600, b'QSFP28 (100GE)']]], [b'FibreChannel', [[3010, b'SFP (1GFC)'], [3020, b'SFP (2GFC)'], [3040, b'SFP (4GFC)'], [3080, b'SFP+ (8GFC)'], [3160, b'SFP+ (16GFC)']]], [b'Serial', [[4000, b'T1 (1.544 Mbps)'], [4010, b'E1 (2.048 Mbps)'], [4040, b'T3 (45 Mbps)'], [4050, b'E3 (34 Mbps)']]], [b'Stacking', [[5000, b'Cisco StackWise'], [5050, b'Cisco StackWise Plus'], [5100, b'Cisco FlexStack'], [5150, b'Cisco FlexStack Plus']]], [b'Other', [[32767, b'Other']]]], default=1200),
),
migrations.AlterField(
model_name='devicerole',
name='color',
field=utilities.fields.ColorField(max_length=6),
),
]

View File

@@ -0,0 +1,436 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.14 on 2018-07-31 02:13
from __future__ import unicode_literals
import dcim.fields
from django.conf import settings
import django.contrib.postgres.fields
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import mptt.fields
import utilities.fields
def copy_site_from_rack(apps, schema_editor):
Device = apps.get_model('dcim', 'Device')
for device in Device.objects.all():
device.site = device.rack.site
device.save()
def rpc_client_to_napalm_driver(apps, schema_editor):
"""
Migrate legacy RPC clients to their respective NAPALM drivers
"""
Platform = apps.get_model('dcim', 'Platform')
Platform.objects.filter(rpc_client='juniper-junos').update(napalm_driver='junos')
Platform.objects.filter(rpc_client='cisco-ios').update(napalm_driver='ios')
class Migration(migrations.Migration):
replaces = [('dcim', '0023_devicetype_comments'), ('dcim', '0024_site_add_contact_fields'), ('dcim', '0025_devicetype_add_interface_ordering'), ('dcim', '0026_add_rack_reservations'), ('dcim', '0027_device_add_site'), ('dcim', '0028_device_copy_rack_to_site'), ('dcim', '0029_allow_rackless_devices'), ('dcim', '0030_interface_add_lag'), ('dcim', '0031_regions'), ('dcim', '0032_device_increase_name_length'), ('dcim', '0033_rackreservation_rack_editable'), ('dcim', '0034_rename_module_to_inventoryitem'), ('dcim', '0035_device_expand_status_choices'), ('dcim', '0036_add_ff_juniper_vcp'), ('dcim', '0037_unicode_literals'), ('dcim', '0038_wireless_interfaces'), ('dcim', '0039_interface_add_enabled_mtu'), ('dcim', '0040_inventoryitem_add_asset_tag_description'), ('dcim', '0041_napalm_integration'), ('dcim', '0042_interface_ff_10ge_cx4'), ('dcim', '0043_device_component_name_lengths')]
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('dcim', '0022_color_names_to_rgb'),
]
operations = [
migrations.AddField(
model_name='devicetype',
name='comments',
field=models.TextField(blank=True),
),
migrations.AddField(
model_name='site',
name='contact_email',
field=models.EmailField(blank=True, max_length=254, verbose_name=b'Contact E-mail'),
),
migrations.AddField(
model_name='site',
name='contact_name',
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='site',
name='contact_phone',
field=models.CharField(blank=True, max_length=20),
),
migrations.AddField(
model_name='devicetype',
name='interface_ordering',
field=models.PositiveSmallIntegerField(choices=[[1, b'Slot/position'], [2, b'Name (alphabetically)']], default=1),
),
migrations.CreateModel(
name='RackReservation',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('units', django.contrib.postgres.fields.ArrayField(base_field=models.PositiveSmallIntegerField(), size=None)),
('created', models.DateTimeField(auto_now_add=True)),
('description', models.CharField(max_length=100)),
('rack', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='dcim.Rack')),
('user', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['created'],
},
),
migrations.AddField(
model_name='device',
name='site',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='dcim.Site'),
),
migrations.RunPython(
code=copy_site_from_rack,
),
migrations.AlterField(
model_name='device',
name='rack',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='dcim.Rack'),
),
migrations.AlterField(
model_name='device',
name='site',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='dcim.Site'),
),
migrations.AddField(
model_name='interface',
name='lag',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='member_interfaces', to='dcim.Interface', verbose_name='Parent LAG'),
),
migrations.CreateModel(
name='Region',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50, unique=True)),
('slug', models.SlugField(unique=True)),
('lft', models.PositiveIntegerField(db_index=True, editable=False)),
('rght', models.PositiveIntegerField(db_index=True, editable=False)),
('tree_id', models.PositiveIntegerField(db_index=True, editable=False)),
('level', models.PositiveIntegerField(db_index=True, editable=False)),
('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='dcim.Region')),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='site',
name='region',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sites', to='dcim.Region'),
),
migrations.AlterField(
model_name='device',
name='name',
field=utilities.fields.NullableCharField(blank=True, max_length=64, null=True, unique=True),
),
migrations.AlterField(
model_name='rackreservation',
name='rack',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='dcim.Rack'),
),
migrations.RenameModel(
old_name='Module',
new_name='InventoryItem',
),
migrations.AlterField(
model_name='inventoryitem',
name='device',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inventory_items', to='dcim.Device'),
),
migrations.AlterField(
model_name='inventoryitem',
name='parent',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='child_items', to='dcim.InventoryItem'),
),
migrations.AlterField(
model_name='inventoryitem',
name='manufacturer',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='inventory_items', to='dcim.Manufacturer'),
),
migrations.AlterField(
model_name='device',
name='status',
field=models.PositiveIntegerField(choices=[[1, b'Active'], [0, b'Offline'], [2, b'Planned'], [3, b'Staged'], [4, b'Failed'], [5, b'Inventory']], default=1, verbose_name=b'Status'),
),
migrations.AlterField(
model_name='device',
name='status',
field=models.PositiveSmallIntegerField(choices=[[1, 'Active'], [0, 'Offline'], [2, 'Planned'], [3, 'Staged'], [4, 'Failed'], [5, 'Inventory']], default=1, verbose_name='Status'),
),
migrations.AlterField(
model_name='consoleport',
name='connection_status',
field=models.NullBooleanField(choices=[[False, 'Planned'], [True, 'Connected']], default=True),
),
migrations.AlterField(
model_name='consoleport',
name='cs_port',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='connected_console', to='dcim.ConsoleServerPort', verbose_name='Console server port'),
),
migrations.AlterField(
model_name='device',
name='asset_tag',
field=utilities.fields.NullableCharField(blank=True, help_text='A unique tag used to identify this device', max_length=50, null=True, unique=True, verbose_name='Asset tag'),
),
migrations.AlterField(
model_name='device',
name='face',
field=models.PositiveSmallIntegerField(blank=True, choices=[[0, 'Front'], [1, 'Rear']], null=True, verbose_name='Rack face'),
),
migrations.AlterField(
model_name='device',
name='position',
field=models.PositiveSmallIntegerField(blank=True, help_text='The lowest-numbered unit occupied by the device', null=True, validators=[django.core.validators.MinValueValidator(1)], verbose_name='Position (U)'),
),
migrations.AlterField(
model_name='device',
name='primary_ip4',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='primary_ip4_for', to='ipam.IPAddress', verbose_name='Primary IPv4'),
),
migrations.AlterField(
model_name='device',
name='primary_ip6',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='primary_ip6_for', to='ipam.IPAddress', verbose_name='Primary IPv6'),
),
migrations.AlterField(
model_name='device',
name='serial',
field=models.CharField(blank=True, max_length=50, verbose_name='Serial number'),
),
migrations.AlterField(
model_name='devicebay',
name='name',
field=models.CharField(max_length=50, verbose_name='Name'),
),
migrations.AlterField(
model_name='devicetype',
name='interface_ordering',
field=models.PositiveSmallIntegerField(choices=[[1, 'Slot/position'], [2, 'Name (alphabetically)']], default=1),
),
migrations.AlterField(
model_name='devicetype',
name='is_console_server',
field=models.BooleanField(default=False, help_text='This type of device has console server ports', verbose_name='Is a console server'),
),
migrations.AlterField(
model_name='devicetype',
name='is_full_depth',
field=models.BooleanField(default=True, help_text='Device consumes both front and rear rack faces', verbose_name='Is full depth'),
),
migrations.AlterField(
model_name='devicetype',
name='is_network_device',
field=models.BooleanField(default=True, help_text='This type of device has network interfaces', verbose_name='Is a network device'),
),
migrations.AlterField(
model_name='devicetype',
name='is_pdu',
field=models.BooleanField(default=False, help_text='This type of device has power outlets', verbose_name='Is a PDU'),
),
migrations.AlterField(
model_name='devicetype',
name='part_number',
field=models.CharField(blank=True, help_text='Discrete part number (optional)', max_length=50),
),
migrations.AlterField(
model_name='devicetype',
name='subdevice_role',
field=models.NullBooleanField(choices=[(None, 'None'), (True, 'Parent'), (False, 'Child')], default=None, help_text='Parent devices house child devices in device bays. Select "None" if this device type is neither a parent nor a child.', verbose_name='Parent/child status'),
),
migrations.AlterField(
model_name='devicetype',
name='u_height',
field=models.PositiveSmallIntegerField(default=1, verbose_name='Height (U)'),
),
migrations.AlterField(
model_name='interface',
name='mac_address',
field=dcim.fields.MACAddressField(blank=True, null=True, verbose_name='MAC Address'),
),
migrations.AlterField(
model_name='interface',
name='mgmt_only',
field=models.BooleanField(default=False, help_text='This interface is used only for out-of-band management', verbose_name='OOB Management'),
),
migrations.AlterField(
model_name='interfaceconnection',
name='connection_status',
field=models.BooleanField(choices=[[False, 'Planned'], [True, 'Connected']], default=True, verbose_name='Status'),
),
migrations.AlterField(
model_name='interfacetemplate',
name='mgmt_only',
field=models.BooleanField(default=False, verbose_name='Management only'),
),
migrations.AlterField(
model_name='inventoryitem',
name='discovered',
field=models.BooleanField(default=False, verbose_name='Discovered'),
),
migrations.AlterField(
model_name='inventoryitem',
name='name',
field=models.CharField(max_length=50, verbose_name='Name'),
),
migrations.AlterField(
model_name='inventoryitem',
name='part_id',
field=models.CharField(blank=True, max_length=50, verbose_name='Part ID'),
),
migrations.AlterField(
model_name='inventoryitem',
name='serial',
field=models.CharField(blank=True, max_length=50, verbose_name='Serial number'),
),
migrations.AlterField(
model_name='platform',
name='rpc_client',
field=models.CharField(blank=True, choices=[['juniper-junos', 'Juniper Junos (NETCONF)'], ['cisco-ios', 'Cisco IOS (SSH)'], ['opengear', 'Opengear (SSH)']], max_length=30, verbose_name='RPC client'),
),
migrations.AlterField(
model_name='powerport',
name='connection_status',
field=models.NullBooleanField(choices=[[False, 'Planned'], [True, 'Connected']], default=True),
),
migrations.AlterField(
model_name='rack',
name='desc_units',
field=models.BooleanField(default=False, help_text='Units are numbered top-to-bottom', verbose_name='Descending units'),
),
migrations.AlterField(
model_name='rack',
name='facility_id',
field=utilities.fields.NullableCharField(blank=True, max_length=30, null=True, verbose_name='Facility ID'),
),
migrations.AlterField(
model_name='rack',
name='type',
field=models.PositiveSmallIntegerField(blank=True, choices=[(100, '2-post frame'), (200, '4-post frame'), (300, '4-post cabinet'), (1000, 'Wall-mounted frame'), (1100, 'Wall-mounted cabinet')], null=True, verbose_name='Type'),
),
migrations.AlterField(
model_name='rack',
name='u_height',
field=models.PositiveSmallIntegerField(default=42, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)], verbose_name='Height (U)'),
),
migrations.AlterField(
model_name='rack',
name='width',
field=models.PositiveSmallIntegerField(choices=[(19, '19 inches'), (23, '23 inches')], default=19, help_text='Rail-to-rail width', verbose_name='Width'),
),
migrations.AlterField(
model_name='site',
name='asn',
field=dcim.fields.ASNField(blank=True, null=True, verbose_name='ASN'),
),
migrations.AlterField(
model_name='site',
name='contact_email',
field=models.EmailField(blank=True, max_length=254, verbose_name='Contact E-mail'),
),
migrations.AddField(
model_name='interface',
name='enabled',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='interface',
name='mtu',
field=models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='MTU'),
),
migrations.AddField(
model_name='inventoryitem',
name='asset_tag',
field=utilities.fields.NullableCharField(blank=True, help_text='A unique tag used to identify this item', max_length=50, null=True, unique=True, verbose_name='Asset tag'),
),
migrations.AddField(
model_name='inventoryitem',
name='description',
field=models.CharField(blank=True, max_length=100),
),
migrations.AlterModelOptions(
name='device',
options={'ordering': ['name'], 'permissions': (('napalm_read', 'Read-only access to devices via NAPALM'), ('napalm_write', 'Read/write access to devices via NAPALM'))},
),
migrations.AddField(
model_name='platform',
name='napalm_driver',
field=models.CharField(blank=True, help_text='The name of the NAPALM driver to use when interacting with devices.', max_length=50, verbose_name='NAPALM driver'),
),
migrations.AlterField(
model_name='platform',
name='rpc_client',
field=models.CharField(blank=True, choices=[['juniper-junos', 'Juniper Junos (NETCONF)'], ['cisco-ios', 'Cisco IOS (SSH)'], ['opengear', 'Opengear (SSH)']], max_length=30, verbose_name='Legacy RPC client'),
),
migrations.RunPython(
code=rpc_client_to_napalm_driver,
),
migrations.AlterField(
model_name='interface',
name='form_factor',
field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)'], [1170, '10GBASE-CX4 (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1600, 'QSFP28 (100GE)']]], ['Wireless', [[2600, 'IEEE 802.11a'], [2610, 'IEEE 802.11b/g'], [2620, 'IEEE 802.11n'], [2630, 'IEEE 802.11ac'], [2640, 'IEEE 802.11ad']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP']]], ['Other', [[32767, 'Other']]]], default=1200),
),
migrations.AlterField(
model_name='interfacetemplate',
name='form_factor',
field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)'], [1170, '10GBASE-CX4 (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1600, 'QSFP28 (100GE)']]], ['Wireless', [[2600, 'IEEE 802.11a'], [2610, 'IEEE 802.11b/g'], [2620, 'IEEE 802.11n'], [2630, 'IEEE 802.11ac'], [2640, 'IEEE 802.11ad']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP']]], ['Other', [[32767, 'Other']]]], default=1200),
),
migrations.AlterField(
model_name='consoleport',
name='name',
field=models.CharField(max_length=50),
),
migrations.AlterField(
model_name='consoleporttemplate',
name='name',
field=models.CharField(max_length=50),
),
migrations.AlterField(
model_name='consoleserverport',
name='name',
field=models.CharField(max_length=50),
),
migrations.AlterField(
model_name='consoleserverporttemplate',
name='name',
field=models.CharField(max_length=50),
),
migrations.AlterField(
model_name='devicebaytemplate',
name='name',
field=models.CharField(max_length=50),
),
migrations.AlterField(
model_name='interface',
name='name',
field=models.CharField(max_length=64),
),
migrations.AlterField(
model_name='interfacetemplate',
name='name',
field=models.CharField(max_length=64),
),
migrations.AlterField(
model_name='poweroutlet',
name='name',
field=models.CharField(max_length=50),
),
migrations.AlterField(
model_name='poweroutlettemplate',
name='name',
field=models.CharField(max_length=50),
),
migrations.AlterField(
model_name='powerport',
name='name',
field=models.CharField(max_length=50),
),
migrations.AlterField(
model_name='powerporttemplate',
name='name',
field=models.CharField(max_length=50),
),
]

View File

@@ -0,0 +1,146 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.14 on 2018-07-31 02:17
from __future__ import unicode_literals
from django.conf import settings
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import timezone_field.fields
import utilities.fields
class Migration(migrations.Migration):
replaces = [('dcim', '0044_virtualization'), ('dcim', '0045_devicerole_vm_role'), ('dcim', '0046_rack_lengthen_facility_id'), ('dcim', '0047_more_100ge_form_factors'), ('dcim', '0048_rack_serial'), ('dcim', '0049_rackreservation_change_user'), ('dcim', '0050_interface_vlan_tagging'), ('dcim', '0051_rackreservation_tenant'), ('dcim', '0052_virtual_chassis'), ('dcim', '0053_platform_manufacturer'), ('dcim', '0054_site_status_timezone_description'), ('dcim', '0055_virtualchassis_ordering')]
dependencies = [
('dcim', '0043_device_component_name_lengths'),
('ipam', '0020_ipaddress_add_role_carp'),
('virtualization', '0001_virtualization'),
('tenancy', '0003_unicode_literals'),
]
operations = [
migrations.AddField(
model_name='device',
name='cluster',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='devices', to='virtualization.Cluster'),
),
migrations.AddField(
model_name='interface',
name='virtual_machine',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interfaces', to='virtualization.VirtualMachine'),
),
migrations.AlterField(
model_name='interface',
name='device',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interfaces', to='dcim.Device'),
),
migrations.AddField(
model_name='devicerole',
name='vm_role',
field=models.BooleanField(default=True, help_text='Virtual machines may be assigned to this role', verbose_name='VM Role'),
),
migrations.AlterField(
model_name='rack',
name='facility_id',
field=utilities.fields.NullableCharField(blank=True, max_length=50, null=True, verbose_name='Facility ID'),
),
migrations.AlterField(
model_name='interface',
name='form_factor',
field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)'], [1170, '10GBASE-CX4 (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1510, 'CFP2 (100GE)'], [1520, 'CFP4 (100GE)'], [1550, 'Cisco CPAK (100GE)'], [1600, 'QSFP28 (100GE)']]], ['Wireless', [[2600, 'IEEE 802.11a'], [2610, 'IEEE 802.11b/g'], [2620, 'IEEE 802.11n'], [2630, 'IEEE 802.11ac'], [2640, 'IEEE 802.11ad']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP']]], ['Other', [[32767, 'Other']]]], default=1200),
),
migrations.AlterField(
model_name='interfacetemplate',
name='form_factor',
field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)'], [1170, '10GBASE-CX4 (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1510, 'CFP2 (100GE)'], [1520, 'CFP4 (100GE)'], [1550, 'Cisco CPAK (100GE)'], [1600, 'QSFP28 (100GE)']]], ['Wireless', [[2600, 'IEEE 802.11a'], [2610, 'IEEE 802.11b/g'], [2620, 'IEEE 802.11n'], [2630, 'IEEE 802.11ac'], [2640, 'IEEE 802.11ad']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP']]], ['Other', [[32767, 'Other']]]], default=1200),
),
migrations.AddField(
model_name='rack',
name='serial',
field=models.CharField(blank=True, max_length=50, verbose_name='Serial number'),
),
migrations.AlterField(
model_name='rackreservation',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='interface',
name='mode',
field=models.PositiveSmallIntegerField(blank=True, choices=[[100, 'Access'], [200, 'Tagged'], [300, 'Tagged All']], null=True),
),
migrations.AddField(
model_name='interface',
name='tagged_vlans',
field=models.ManyToManyField(blank=True, related_name='interfaces_as_tagged', to='ipam.VLAN', verbose_name='Tagged VLANs'),
),
migrations.AddField(
model_name='interface',
name='untagged_vlan',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interfaces_as_untagged', to='ipam.VLAN', verbose_name='Untagged VLAN'),
),
migrations.AddField(
model_name='rackreservation',
name='tenant',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='rackreservations', to='tenancy.Tenant'),
),
migrations.CreateModel(
name='VirtualChassis',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('domain', models.CharField(blank=True, max_length=30)),
('master', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='vc_master_for', to='dcim.Device')),
],
options={
'verbose_name_plural': 'virtual chassis',
'ordering': ['master'],
},
),
migrations.AddField(
model_name='device',
name='virtual_chassis',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='members', to='dcim.VirtualChassis'),
),
migrations.AddField(
model_name='device',
name='vc_position',
field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MaxValueValidator(255)]),
),
migrations.AddField(
model_name='device',
name='vc_priority',
field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MaxValueValidator(255)]),
),
migrations.AlterUniqueTogether(
name='device',
unique_together=set([('rack', 'position', 'face'), ('virtual_chassis', 'vc_position')]),
),
migrations.AddField(
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.CASCADE, related_name='platforms', to='dcim.Manufacturer'),
),
migrations.AlterField(
model_name='platform',
name='napalm_driver',
field=models.CharField(blank=True, help_text='The name of the NAPALM driver to use when interacting with devices', max_length=50, verbose_name='NAPALM driver'),
),
migrations.AddField(
model_name='site',
name='description',
field=models.CharField(blank=True, max_length=100),
),
migrations.AddField(
model_name='site',
name='status',
field=models.PositiveSmallIntegerField(choices=[[1, 'Active'], [2, 'Planned'], [4, 'Retired']], default=1),
),
migrations.AddField(
model_name='site',
name='time_zone',
field=timezone_field.fields.TimeZoneField(blank=True),
),
]

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,77 @@
# -*- 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'),
),
migrations.AddField(
model_name='consoleport',
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='consoleserverport',
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='devicebay',
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='interface',
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='inventoryitem',
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='poweroutlet',
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='powerport',
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='virtualchassis',
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

@@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.12 on 2018-06-21 18:45
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0058_relax_rack_naming_constraints'),
]
operations = [
migrations.AddField(
model_name='site',
name='latitude',
field=models.DecimalField(blank=True, decimal_places=6, max_digits=8, null=True),
),
migrations.AddField(
model_name='site',
name='longitude',
field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True),
),
]

View File

@@ -0,0 +1,135 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.12 on 2018-06-13 17:14
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0059_site_latitude_longitude'),
]
operations = [
migrations.AddField(
model_name='devicerole',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name='devicerole',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AddField(
model_name='devicetype',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name='devicetype',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AddField(
model_name='manufacturer',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name='manufacturer',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AddField(
model_name='platform',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name='platform',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AddField(
model_name='rackgroup',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name='rackgroup',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AddField(
model_name='rackreservation',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AddField(
model_name='rackrole',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name='rackrole',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AddField(
model_name='region',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name='region',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AddField(
model_name='virtualchassis',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name='virtualchassis',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AlterField(
model_name='device',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AlterField(
model_name='device',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AlterField(
model_name='rack',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AlterField(
model_name='rack',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AlterField(
model_name='rackreservation',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AlterField(
model_name='site',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AlterField(
model_name='site',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 2.0.6 on 2018-06-29 15:02
import django.contrib.postgres.fields.jsonb
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('dcim', '0060_change_logging'),
]
operations = [
migrations.AddField(
model_name='platform',
name='napalm_args',
field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Additional arguments to pass when initiating the NAPALM driver (JSON format)', null=True, verbose_name='NAPALM arguments'),
),
]

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@ import django_tables2 as tables
from django_tables2.utils import Accessor
from tenancy.tables import COL_TENANT
from utilities.tables import BaseTable, ToggleColumn
from utilities.tables import BaseTable, BooleanColumn, ToggleColumn
from .models import (
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, InventoryItem,
@@ -41,12 +41,18 @@ DEVICE_LINK = """
"""
REGION_ACTIONS = """
<a href="{% url 'dcim:region_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Changelog">
<i class="fa fa-history"></i>
</a>
{% if perms.dcim.change_region %}
<a href="{% url 'dcim:region_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
"""
RACKGROUP_ACTIONS = """
<a href="{% url 'dcim:rackgroup_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Changelog">
<i class="fa fa-history"></i>
</a>
<a href="{% url 'dcim:rack_elevation_list' %}?site={{ record.site.slug }}&group_id={{ record.pk }}" class="btn btn-xs btn-primary" title="View elevations">
<i class="fa fa-eye"></i>
</a>
@@ -58,6 +64,9 @@ RACKGROUP_ACTIONS = """
"""
RACKROLE_ACTIONS = """
<a href="{% url 'dcim:rackrole_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Changelog">
<i class="fa fa-history"></i>
</a>
{% if perms.dcim.change_rackrole %}
<a href="{% url 'dcim:rackrole_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
@@ -76,20 +85,29 @@ RACK_DEVICE_COUNT = """
"""
RACKRESERVATION_ACTIONS = """
<a href="{% url 'dcim:rackreservation_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Changelog">
<i class="fa fa-history"></i>
</a>
{% if perms.dcim.change_rackreservation %}
<a href="{% url 'dcim:rackreservation_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
"""
DEVICEROLE_ACTIONS = """
{% if perms.dcim.change_devicerole %}
<a href="{% url 'dcim:devicerole_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
MANUFACTURER_ACTIONS = """
<a href="{% url 'dcim:manufacturer_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
<i class="fa fa-history"></i>
</a>
{% if perms.dcim.change_manufacturer %}
<a href="{% url 'dcim:manufacturer_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
"""
MANUFACTURER_ACTIONS = """
{% if perms.dcim.change_manufacturer %}
<a href="{% url 'dcim:manufacturer_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
DEVICEROLE_ACTIONS = """
<a href="{% url 'dcim:devicerole_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
<i class="fa fa-history"></i>
</a>
{% if perms.dcim.change_devicerole %}
<a href="{% url 'dcim:devicerole_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
"""
@@ -110,6 +128,9 @@ PLATFORM_VM_COUNT = """
"""
PLATFORM_ACTIONS = """
<a href="{% url 'dcim:platform_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
<i class="fa fa-history"></i>
</a>
{% if perms.dcim.change_platform %}
<a href="{% url 'dcim:platform_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
@@ -143,6 +164,9 @@ UTILIZATION_GRAPH = """
"""
VIRTUALCHASSIS_ACTIONS = """
<a href="{% url 'dcim:virtualchassis_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Changelog">
<i class="fa fa-history"></i>
</a>
{% if perms.dcim.change_virtualchassis %}
<a href="{% url 'dcim:virtualchassis_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
@@ -175,7 +199,7 @@ class RegionTable(BaseTable):
class SiteTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn()
name = tables.LinkColumn(order_by=('_nat1', '_nat2', '_nat3'))
status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status')
region = tables.TemplateColumn(template_code=SITE_REGION_LINK)
tenant = tables.TemplateColumn(template_code=COL_TENANT)
@@ -236,7 +260,7 @@ class RackRoleTable(BaseTable):
class RackTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn()
name = tables.LinkColumn(order_by=('_nat1', '_nat2', '_nat3'))
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
tenant = tables.TemplateColumn(template_code=COL_TENANT)
@@ -322,10 +346,10 @@ class DeviceTypeTable(BaseTable):
args=[Accessor('pk')],
verbose_name='Device Type'
)
is_full_depth = tables.BooleanColumn(verbose_name='Full Depth')
is_console_server = tables.BooleanColumn(verbose_name='CS')
is_pdu = tables.BooleanColumn(verbose_name='PDU')
is_network_device = tables.BooleanColumn(verbose_name='Net')
is_full_depth = BooleanColumn(verbose_name='Full Depth')
is_console_server = BooleanColumn(verbose_name='CS')
is_pdu = BooleanColumn(verbose_name='PDU')
is_network_device = BooleanColumn(verbose_name='Net')
subdevice_role = tables.TemplateColumn(
template_code=SUBDEVICE_ROLE_TEMPLATE,
verbose_name='Subdevice Role'
@@ -468,7 +492,10 @@ class PlatformTable(BaseTable):
class DeviceTable(BaseTable):
pk = ToggleColumn()
name = tables.TemplateColumn(template_code=DEVICE_LINK)
name = tables.TemplateColumn(
order_by=('_nat1', '_nat2', '_nat3'),
template_code=DEVICE_LINK
)
status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status')
tenant = tables.TemplateColumn(template_code=COL_TENANT)
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])

View File

@@ -1,12 +1,11 @@
from __future__ import unicode_literals
from django.contrib.auth.models import User
from django.urls import reverse
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,
@@ -16,17 +15,14 @@ from dcim.models import (
)
from ipam.models import VLAN
from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
from users.models import Token
from utilities.tests import HttpStatusMixin
from utilities.testing import APITestCase
class RegionTest(HttpStatusMixin, APITestCase):
class RegionTest(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)}
super(RegionTest, self).setUp()
self.region1 = Region.objects.create(name='Test Region 1', slug='test-region-1')
self.region2 = Region.objects.create(name='Test Region 2', slug='test-region-2')
@@ -113,13 +109,11 @@ class RegionTest(HttpStatusMixin, APITestCase):
self.assertEqual(Region.objects.count(), 2)
class SiteTest(HttpStatusMixin, APITestCase):
class SiteTest(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)}
super(SiteTest, self).setUp()
self.region1 = Region.objects.create(name='Test Region 1', slug='test-region-1')
self.region2 = Region.objects.create(name='Test Region 2', slug='test-region-2')
@@ -168,6 +162,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 +182,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,
},
]
@@ -236,13 +234,11 @@ class SiteTest(HttpStatusMixin, APITestCase):
self.assertEqual(Site.objects.count(), 2)
class RackGroupTest(HttpStatusMixin, APITestCase):
class RackGroupTest(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)}
super(RackGroupTest, self).setUp()
self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2')
@@ -338,13 +334,11 @@ class RackGroupTest(HttpStatusMixin, APITestCase):
self.assertEqual(RackGroup.objects.count(), 2)
class RackRoleTest(HttpStatusMixin, APITestCase):
class RackRoleTest(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)}
super(RackRoleTest, self).setUp()
self.rackrole1 = RackRole.objects.create(name='Test Rack Role 1', slug='test-rack-role-1', color='ff0000')
self.rackrole2 = RackRole.objects.create(name='Test Rack Role 2', slug='test-rack-role-2', color='00ff00')
@@ -438,13 +432,11 @@ class RackRoleTest(HttpStatusMixin, APITestCase):
self.assertEqual(RackRole.objects.count(), 2)
class RackTest(HttpStatusMixin, APITestCase):
class RackTest(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)}
super(RackTest, self).setUp()
self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2')
@@ -564,25 +556,22 @@ class RackTest(HttpStatusMixin, APITestCase):
self.assertEqual(Rack.objects.count(), 2)
class RackReservationTest(HttpStatusMixin, APITestCase):
class RackReservationTest(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)}
super(RackReservationTest, self).setUp()
self.user1 = user
self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
self.rack1 = Rack.objects.create(site=self.site1, name='Test Rack 1')
self.rackreservation1 = RackReservation.objects.create(
rack=self.rack1, units=[1, 2, 3], user=user, description='Reservation #1',
rack=self.rack1, units=[1, 2, 3], user=self.user, description='Reservation #1',
)
self.rackreservation2 = RackReservation.objects.create(
rack=self.rack1, units=[4, 5, 6], user=user, description='Reservation #2',
rack=self.rack1, units=[4, 5, 6], user=self.user, description='Reservation #2',
)
self.rackreservation3 = RackReservation.objects.create(
rack=self.rack1, units=[7, 8, 9], user=user, description='Reservation #3',
rack=self.rack1, units=[7, 8, 9], user=self.user, description='Reservation #3',
)
def test_get_rackreservation(self):
@@ -604,7 +593,7 @@ class RackReservationTest(HttpStatusMixin, APITestCase):
data = {
'rack': self.rack1.pk,
'units': [10, 11, 12],
'user': self.user1.pk,
'user': self.user.pk,
'description': 'Fourth reservation',
}
@@ -625,19 +614,19 @@ class RackReservationTest(HttpStatusMixin, APITestCase):
{
'rack': self.rack1.pk,
'units': [10, 11, 12],
'user': self.user1.pk,
'user': self.user.pk,
'description': 'Reservation #4',
},
{
'rack': self.rack1.pk,
'units': [13, 14, 15],
'user': self.user1.pk,
'user': self.user.pk,
'description': 'Reservation #5',
},
{
'rack': self.rack1.pk,
'units': [16, 17, 18],
'user': self.user1.pk,
'user': self.user.pk,
'description': 'Reservation #6',
},
]
@@ -656,7 +645,7 @@ class RackReservationTest(HttpStatusMixin, APITestCase):
data = {
'rack': self.rack1.pk,
'units': [10, 11, 12],
'user': self.user1.pk,
'user': self.user.pk,
'description': 'Modified reservation',
}
@@ -678,13 +667,11 @@ class RackReservationTest(HttpStatusMixin, APITestCase):
self.assertEqual(RackReservation.objects.count(), 2)
class ManufacturerTest(HttpStatusMixin, APITestCase):
class ManufacturerTest(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)}
super(ManufacturerTest, self).setUp()
self.manufacturer1 = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
self.manufacturer2 = Manufacturer.objects.create(name='Test Manufacturer 2', slug='test-manufacturer-2')
@@ -771,13 +758,11 @@ class ManufacturerTest(HttpStatusMixin, APITestCase):
self.assertEqual(Manufacturer.objects.count(), 2)
class DeviceTypeTest(HttpStatusMixin, APITestCase):
class DeviceTypeTest(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)}
super(DeviceTypeTest, self).setUp()
self.manufacturer1 = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
self.manufacturer2 = Manufacturer.objects.create(name='Test Manufacturer 2', slug='test-manufacturer-2')
@@ -879,13 +864,11 @@ class DeviceTypeTest(HttpStatusMixin, APITestCase):
self.assertEqual(DeviceType.objects.count(), 2)
class ConsolePortTemplateTest(HttpStatusMixin, APITestCase):
class ConsolePortTemplateTest(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)}
super(ConsolePortTemplateTest, self).setUp()
self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
self.devicetype = DeviceType.objects.create(
@@ -981,13 +964,11 @@ class ConsolePortTemplateTest(HttpStatusMixin, APITestCase):
self.assertEqual(ConsolePortTemplate.objects.count(), 2)
class ConsoleServerPortTemplateTest(HttpStatusMixin, APITestCase):
class ConsoleServerPortTemplateTest(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)}
super(ConsoleServerPortTemplateTest, self).setUp()
self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
self.devicetype = DeviceType.objects.create(
@@ -1083,13 +1064,11 @@ class ConsoleServerPortTemplateTest(HttpStatusMixin, APITestCase):
self.assertEqual(ConsoleServerPortTemplate.objects.count(), 2)
class PowerPortTemplateTest(HttpStatusMixin, APITestCase):
class PowerPortTemplateTest(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)}
super(PowerPortTemplateTest, self).setUp()
self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
self.devicetype = DeviceType.objects.create(
@@ -1185,13 +1164,11 @@ class PowerPortTemplateTest(HttpStatusMixin, APITestCase):
self.assertEqual(PowerPortTemplate.objects.count(), 2)
class PowerOutletTemplateTest(HttpStatusMixin, APITestCase):
class PowerOutletTemplateTest(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)}
super(PowerOutletTemplateTest, self).setUp()
self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
self.devicetype = DeviceType.objects.create(
@@ -1287,13 +1264,11 @@ class PowerOutletTemplateTest(HttpStatusMixin, APITestCase):
self.assertEqual(PowerOutletTemplate.objects.count(), 2)
class InterfaceTemplateTest(HttpStatusMixin, APITestCase):
class InterfaceTemplateTest(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)}
super(InterfaceTemplateTest, self).setUp()
self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
self.devicetype = DeviceType.objects.create(
@@ -1389,13 +1364,11 @@ class InterfaceTemplateTest(HttpStatusMixin, APITestCase):
self.assertEqual(InterfaceTemplate.objects.count(), 2)
class DeviceBayTemplateTest(HttpStatusMixin, APITestCase):
class DeviceBayTemplateTest(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)}
super(DeviceBayTemplateTest, self).setUp()
self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
self.devicetype = DeviceType.objects.create(
@@ -1491,13 +1464,11 @@ class DeviceBayTemplateTest(HttpStatusMixin, APITestCase):
self.assertEqual(DeviceBayTemplate.objects.count(), 2)
class DeviceRoleTest(HttpStatusMixin, APITestCase):
class DeviceRoleTest(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)}
super(DeviceRoleTest, self).setUp()
self.devicerole1 = DeviceRole.objects.create(
name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
@@ -1597,13 +1568,11 @@ class DeviceRoleTest(HttpStatusMixin, APITestCase):
self.assertEqual(DeviceRole.objects.count(), 2)
class PlatformTest(HttpStatusMixin, APITestCase):
class PlatformTest(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)}
super(PlatformTest, self).setUp()
self.platform1 = Platform.objects.create(name='Test Platform 1', slug='test-platform-1')
self.platform2 = Platform.objects.create(name='Test Platform 2', slug='test-platform-2')
@@ -1690,13 +1659,11 @@ class PlatformTest(HttpStatusMixin, APITestCase):
self.assertEqual(Platform.objects.count(), 2)
class DeviceTest(HttpStatusMixin, APITestCase):
class DeviceTest(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)}
super(DeviceTest, self).setUp()
self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2')
@@ -1818,13 +1785,11 @@ class DeviceTest(HttpStatusMixin, APITestCase):
self.assertEqual(Device.objects.count(), 2)
class ConsolePortTest(HttpStatusMixin, APITestCase):
class ConsolePortTest(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)}
super(ConsolePortTest, self).setUp()
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
@@ -1925,13 +1890,11 @@ class ConsolePortTest(HttpStatusMixin, APITestCase):
self.assertEqual(ConsolePort.objects.count(), 2)
class ConsoleServerPortTest(HttpStatusMixin, APITestCase):
class ConsoleServerPortTest(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)}
super(ConsoleServerPortTest, self).setUp()
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
@@ -2028,13 +1991,11 @@ class ConsoleServerPortTest(HttpStatusMixin, APITestCase):
self.assertEqual(ConsoleServerPort.objects.count(), 2)
class PowerPortTest(HttpStatusMixin, APITestCase):
class PowerPortTest(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)}
super(PowerPortTest, self).setUp()
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
@@ -2135,13 +2096,11 @@ class PowerPortTest(HttpStatusMixin, APITestCase):
self.assertEqual(PowerPort.objects.count(), 2)
class PowerOutletTest(HttpStatusMixin, APITestCase):
class PowerOutletTest(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)}
super(PowerOutletTest, self).setUp()
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
@@ -2238,13 +2197,11 @@ class PowerOutletTest(HttpStatusMixin, APITestCase):
self.assertEqual(PowerOutlet.objects.count(), 2)
class InterfaceTest(HttpStatusMixin, APITestCase):
class InterfaceTest(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)}
super(InterfaceTest, self).setUp()
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
@@ -2322,8 +2279,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 +2288,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 +2326,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 +2350,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):
@@ -2434,13 +2385,11 @@ class InterfaceTest(HttpStatusMixin, APITestCase):
self.assertEqual(Interface.objects.count(), 2)
class DeviceBayTest(HttpStatusMixin, APITestCase):
class DeviceBayTest(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)}
super(DeviceBayTest, self).setUp()
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
@@ -2549,13 +2498,11 @@ class DeviceBayTest(HttpStatusMixin, APITestCase):
self.assertEqual(DeviceBay.objects.count(), 2)
class InventoryItemTest(HttpStatusMixin, APITestCase):
class InventoryItemTest(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)}
super(InventoryItemTest, self).setUp()
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
@@ -2667,13 +2614,11 @@ class InventoryItemTest(HttpStatusMixin, APITestCase):
self.assertEqual(InventoryItem.objects.count(), 2)
class ConsoleConnectionTest(HttpStatusMixin, APITestCase):
class ConsoleConnectionTest(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)}
super(ConsoleConnectionTest, self).setUp()
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
@@ -2710,13 +2655,11 @@ class ConsoleConnectionTest(HttpStatusMixin, APITestCase):
self.assertEqual(response.data['count'], 3)
class PowerConnectionTest(HttpStatusMixin, APITestCase):
class PowerConnectionTest(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)}
super(PowerConnectionTest, self).setUp()
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
@@ -2753,13 +2696,11 @@ class PowerConnectionTest(HttpStatusMixin, APITestCase):
self.assertEqual(response.data['count'], 3)
class InterfaceConnectionTest(HttpStatusMixin, APITestCase):
class InterfaceConnectionTest(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)}
super(InterfaceConnectionTest, self).setUp()
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
@@ -2847,9 +2788,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):
@@ -2880,13 +2821,11 @@ class InterfaceConnectionTest(HttpStatusMixin, APITestCase):
self.assertEqual(InterfaceConnection.objects.count(), 2)
class ConnectedDeviceTest(HttpStatusMixin, APITestCase):
class ConnectedDeviceTest(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)}
super(ConnectedDeviceTest, self).setUp()
self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2')
@@ -2922,13 +2861,11 @@ class ConnectedDeviceTest(HttpStatusMixin, APITestCase):
self.assertEqual(response.data['name'], self.device1.name)
class VirtualChassisTest(HttpStatusMixin, APITestCase):
class VirtualChassisTest(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)}
super(VirtualChassisTest, self).setUp()
site = Site.objects.create(name='Test Site', slug='test-site')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer', slug='test-manufacturer')
@@ -3047,12 +2984,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,11 +2,14 @@ from __future__ import unicode_literals
from django.conf.urls import url
from extras.views import ImageAttachmentEditView
from extras.views import ObjectChangeLogView, ImageAttachmentEditView
from ipam.views import ServiceCreateView
from secrets.views import secret_add
from . import views
from .models import Device, Rack, Site
from .models import (
Device, DeviceRole, DeviceType, Interface, Manufacturer, Platform, Rack, RackGroup, RackReservation, RackRole,
Region, Site, VirtualChassis,
)
app_name = 'dcim'
urlpatterns = [
@@ -17,6 +20,7 @@ urlpatterns = [
url(r'^regions/import/$', views.RegionBulkImportView.as_view(), name='region_import'),
url(r'^regions/delete/$', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'),
url(r'^regions/(?P<pk>\d+)/edit/$', views.RegionEditView.as_view(), name='region_edit'),
url(r'^regions/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='region_changelog', kwargs={'model': Region}),
# Sites
url(r'^sites/$', views.SiteListView.as_view(), name='site_list'),
@@ -26,6 +30,7 @@ urlpatterns = [
url(r'^sites/(?P<slug>[\w-]+)/$', views.SiteView.as_view(), name='site'),
url(r'^sites/(?P<slug>[\w-]+)/edit/$', views.SiteEditView.as_view(), name='site_edit'),
url(r'^sites/(?P<slug>[\w-]+)/delete/$', views.SiteDeleteView.as_view(), name='site_delete'),
url(r'^sites/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}),
url(r'^sites/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}),
# Rack groups
@@ -34,6 +39,7 @@ urlpatterns = [
url(r'^rack-groups/import/$', views.RackGroupBulkImportView.as_view(), name='rackgroup_import'),
url(r'^rack-groups/delete/$', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'),
url(r'^rack-groups/(?P<pk>\d+)/edit/$', views.RackGroupEditView.as_view(), name='rackgroup_edit'),
url(r'^rack-groups/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='rackgroup_changelog', kwargs={'model': RackGroup}),
# Rack roles
url(r'^rack-roles/$', views.RackRoleListView.as_view(), name='rackrole_list'),
@@ -41,6 +47,7 @@ urlpatterns = [
url(r'^rack-roles/import/$', views.RackRoleBulkImportView.as_view(), name='rackrole_import'),
url(r'^rack-roles/delete/$', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'),
url(r'^rack-roles/(?P<pk>\d+)/edit/$', views.RackRoleEditView.as_view(), name='rackrole_edit'),
url(r'^rack-roles/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='rackrole_changelog', kwargs={'model': RackRole}),
# Rack reservations
url(r'^rack-reservations/$', views.RackReservationListView.as_view(), name='rackreservation_list'),
@@ -48,6 +55,7 @@ urlpatterns = [
url(r'^rack-reservations/delete/$', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'),
url(r'^rack-reservations/(?P<pk>\d+)/edit/$', views.RackReservationEditView.as_view(), name='rackreservation_edit'),
url(r'^rack-reservations/(?P<pk>\d+)/delete/$', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'),
url(r'^rack-reservations/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='rackreservation_changelog', kwargs={'model': RackReservation}),
# Racks
url(r'^racks/$', views.RackListView.as_view(), name='rack_list'),
@@ -59,6 +67,7 @@ urlpatterns = [
url(r'^racks/(?P<pk>\d+)/$', views.RackView.as_view(), name='rack'),
url(r'^racks/(?P<pk>\d+)/edit/$', views.RackEditView.as_view(), name='rack_edit'),
url(r'^racks/(?P<pk>\d+)/delete/$', views.RackDeleteView.as_view(), name='rack_delete'),
url(r'^racks/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}),
url(r'^racks/(?P<rack>\d+)/reservations/add/$', views.RackReservationCreateView.as_view(), name='rack_add_reservation'),
url(r'^racks/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}),
@@ -68,6 +77,7 @@ urlpatterns = [
url(r'^manufacturers/import/$', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'),
url(r'^manufacturers/delete/$', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'),
url(r'^manufacturers/(?P<slug>[\w-]+)/edit/$', views.ManufacturerEditView.as_view(), name='manufacturer_edit'),
url(r'^manufacturers/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}),
# Device types
url(r'^device-types/$', views.DeviceTypeListView.as_view(), name='devicetype_list'),
@@ -78,6 +88,7 @@ urlpatterns = [
url(r'^device-types/(?P<pk>\d+)/$', views.DeviceTypeView.as_view(), name='devicetype'),
url(r'^device-types/(?P<pk>\d+)/edit/$', views.DeviceTypeEditView.as_view(), name='devicetype_edit'),
url(r'^device-types/(?P<pk>\d+)/delete/$', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'),
url(r'^device-types/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}),
# Console port templates
url(r'^device-types/(?P<pk>\d+)/console-ports/add/$', views.ConsolePortTemplateCreateView.as_view(), name='devicetype_add_consoleport'),
@@ -110,6 +121,7 @@ urlpatterns = [
url(r'^device-roles/import/$', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'),
url(r'^device-roles/delete/$', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'),
url(r'^device-roles/(?P<slug>[\w-]+)/edit/$', views.DeviceRoleEditView.as_view(), name='devicerole_edit'),
url(r'^device-roles/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}),
# Platforms
url(r'^platforms/$', views.PlatformListView.as_view(), name='platform_list'),
@@ -117,6 +129,7 @@ urlpatterns = [
url(r'^platforms/import/$', views.PlatformBulkImportView.as_view(), name='platform_import'),
url(r'^platforms/delete/$', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'),
url(r'^platforms/(?P<slug>[\w-]+)/edit/$', views.PlatformEditView.as_view(), name='platform_edit'),
url(r'^platforms/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}),
# Devices
url(r'^devices/$', views.DeviceListView.as_view(), name='device_list'),
@@ -128,6 +141,8 @@ urlpatterns = [
url(r'^devices/(?P<pk>\d+)/$', views.DeviceView.as_view(), name='device'),
url(r'^devices/(?P<pk>\d+)/edit/$', views.DeviceEditView.as_view(), name='device_edit'),
url(r'^devices/(?P<pk>\d+)/delete/$', views.DeviceDeleteView.as_view(), name='device_delete'),
url(r'^devices/(?P<pk>\d+)/config-context/$', views.DeviceConfigContextView.as_view(), name='device_configcontext'),
url(r'^devices/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}),
url(r'^devices/(?P<pk>\d+)/inventory/$', views.DeviceInventoryView.as_view(), name='device_inventory'),
url(r'^devices/(?P<pk>\d+)/status/$', views.DeviceStatusView.as_view(), name='device_status'),
url(r'^devices/(?P<pk>\d+)/lldp-neighbors/$', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
@@ -184,9 +199,11 @@ urlpatterns = [
url(r'^devices/(?P<pk>\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
url(r'^devices/(?P<pk>\d+)/interface-connections/add/$', views.InterfaceConnectionAddView.as_view(), name='interfaceconnection_add'),
url(r'^interface-connections/(?P<pk>\d+)/delete/$', views.InterfaceConnectionDeleteView.as_view(), name='interfaceconnection_delete'),
url(r'^interfaces/(?P<pk>\d+)/$', views.InterfaceView.as_view(), name='interface'),
url(r'^interfaces/(?P<pk>\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'),
url(r'^interfaces/(?P<pk>\d+)/assign-vlans/$', views.InterfaceAssignVLANsView.as_view(), name='interface_assign_vlans'),
url(r'^interfaces/(?P<pk>\d+)/delete/$', views.InterfaceDeleteView.as_view(), name='interface_delete'),
url(r'^interfaces/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}),
url(r'^interfaces/rename/$', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'),
# Device bays
@@ -221,6 +238,7 @@ urlpatterns = [
url(r'^virtual-chassis/add/$', views.VirtualChassisCreateView.as_view(), name='virtualchassis_add'),
url(r'^virtual-chassis/(?P<pk>\d+)/edit/$', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'),
url(r'^virtual-chassis/(?P<pk>\d+)/delete/$', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'),
url(r'^virtual-chassis/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='virtualchassis_changelog', kwargs={'model': VirtualChassis}),
url(r'^virtual-chassis/(?P<pk>\d+)/add-member/$', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'),
url(r'^virtual-chassis-members/(?P<pk>\d+)/delete/$', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'),

View File

@@ -12,14 +12,16 @@ from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.html import escape
from django.utils.http import is_safe_url, urlencode
from django.utils.http import urlencode
from django.utils.safestring import mark_safe
from django.views.generic import View
from natsort import natsorted
from circuits.models import Circuit
from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE, UserAction
from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
from extras.views import ObjectConfigContextView
from ipam.models import Prefix, Service, VLAN
from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator
from utilities.views import (
@@ -37,7 +39,7 @@ from .models import (
)
class BulkRenameView(View):
class BulkRenameView(GetReturnURLMixin, View):
"""
An extendable view for renaming device components in bulk.
"""
@@ -49,10 +51,6 @@ class BulkRenameView(View):
model = self.queryset.model
return_url = request.GET.get('return_url')
if not return_url or not is_safe_url(url=return_url, host=request.get_host()):
return_url = 'home'
if '_preview' in request.POST or '_apply' in request.POST:
form = self.form(request.POST, initial={'pk': request.POST.getlist('pk')})
selected_objects = self.queryset.filter(pk__in=form.initial['pk'])
@@ -69,7 +67,7 @@ class BulkRenameView(View):
len(selected_objects),
model._meta.verbose_name_plural
))
return redirect(return_url)
return redirect(self.get_return_url(request))
else:
form = self.form(initial={'pk': request.POST.getlist('pk')})
@@ -79,7 +77,7 @@ class BulkRenameView(View):
'form': form,
'obj_type_plural': model._meta.verbose_name_plural,
'selected_objects': selected_objects,
'return_url': return_url,
'return_url': self.get_return_url(request),
})
@@ -137,9 +135,7 @@ class RegionCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.add_region'
model = Region
model_form = forms.RegionForm
def get_return_url(self, request, obj):
return reverse('dcim:region_list')
default_return_url = 'dcim:region_list'
class RegionEditView(RegionCreateView):
@@ -155,7 +151,6 @@ class RegionBulkImportView(PermissionRequiredMixin, BulkImportView):
class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_region'
cls = Region
queryset = Region.objects.annotate(site_count=Count('sites'))
filter = filters.RegionFilter
table = tables.RegionTable
@@ -227,7 +222,6 @@ class SiteBulkImportView(PermissionRequiredMixin, BulkImportView):
class SiteBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_site'
cls = Site
queryset = Site.objects.select_related('region', 'tenant')
filter = filters.SiteFilter
table = tables.SiteTable
@@ -251,9 +245,7 @@ class RackGroupCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.add_rackgroup'
model = RackGroup
model_form = forms.RackGroupForm
def get_return_url(self, request, obj):
return reverse('dcim:rackgroup_list')
default_return_url = 'dcim:rackgroup_list'
class RackGroupEditView(RackGroupCreateView):
@@ -269,7 +261,6 @@ class RackGroupBulkImportView(PermissionRequiredMixin, BulkImportView):
class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_rackgroup'
cls = RackGroup
queryset = RackGroup.objects.select_related('site').annotate(rack_count=Count('racks'))
filter = filters.RackGroupFilter
table = tables.RackGroupTable
@@ -290,9 +281,7 @@ class RackRoleCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.add_rackrole'
model = RackRole
model_form = forms.RackRoleForm
def get_return_url(self, request, obj):
return reverse('dcim:rackrole_list')
default_return_url = 'dcim:rackrole_list'
class RackRoleEditView(RackRoleCreateView):
@@ -308,7 +297,6 @@ class RackRoleBulkImportView(PermissionRequiredMixin, BulkImportView):
class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_rackrole'
cls = RackRole
queryset = RackRole.objects.annotate(rack_count=Count('racks'))
table = tables.RackRoleTable
default_return_url = 'dcim:rackrole_list'
@@ -423,7 +411,6 @@ class RackBulkImportView(PermissionRequiredMixin, BulkImportView):
class RackBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_rack'
cls = Rack
queryset = Rack.objects.select_related('site', 'group', 'tenant', 'role')
filter = filters.RackFilter
table = tables.RackTable
@@ -433,7 +420,6 @@ class RackBulkEditView(PermissionRequiredMixin, BulkEditView):
class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_rack'
cls = Rack
queryset = Rack.objects.select_related('site', 'group', 'tenant', 'role')
filter = filters.RackFilter
table = tables.RackTable
@@ -481,7 +467,6 @@ class RackReservationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class RackReservationBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_rackreservation'
cls = RackReservation
queryset = RackReservation.objects.select_related('rack', 'user')
filter = filters.RackReservationFilter
table = tables.RackReservationTable
@@ -491,7 +476,7 @@ class RackReservationBulkEditView(PermissionRequiredMixin, BulkEditView):
class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_rackreservation'
cls = RackReservation
queryset = RackReservation.objects.select_related('rack', 'user')
filter = filters.RackReservationFilter
table = tables.RackReservationTable
default_return_url = 'dcim:rackreservation_list'
@@ -514,9 +499,7 @@ class ManufacturerCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.add_manufacturer'
model = Manufacturer
model_form = forms.ManufacturerForm
def get_return_url(self, request, obj):
return reverse('dcim:manufacturer_list')
default_return_url = 'dcim:manufacturer_list'
class ManufacturerEditView(ManufacturerCreateView):
@@ -532,7 +515,6 @@ class ManufacturerBulkImportView(PermissionRequiredMixin, BulkImportView):
class ManufacturerBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_manufacturer'
cls = Manufacturer
queryset = Manufacturer.objects.annotate(devicetype_count=Count('device_types'))
table = tables.ManufacturerTable
default_return_url = 'dcim:manufacturer_list'
@@ -629,7 +611,6 @@ class DeviceTypeBulkImportView(PermissionRequiredMixin, BulkImportView):
class DeviceTypeBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_devicetype'
cls = DeviceType
queryset = DeviceType.objects.select_related('manufacturer').annotate(instance_count=Count('instances'))
filter = filters.DeviceTypeFilter
table = tables.DeviceTypeTable
@@ -639,7 +620,6 @@ class DeviceTypeBulkEditView(PermissionRequiredMixin, BulkEditView):
class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_devicetype'
cls = DeviceType
queryset = DeviceType.objects.select_related('manufacturer').annotate(instance_count=Count('instances'))
filter = filters.DeviceTypeFilter
table = tables.DeviceTypeTable
@@ -662,10 +642,8 @@ class ConsolePortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView
class ConsolePortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_consoleporttemplate'
queryset = ConsolePortTemplate.objects.all()
parent_model = DeviceType
parent_field = 'device_type'
cls = ConsolePortTemplate
parent_cls = DeviceType
table = tables.ConsolePortTemplateTable
@@ -681,8 +659,8 @@ class ConsoleServerPortTemplateCreateView(PermissionRequiredMixin, ComponentCrea
class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_consoleserverporttemplate'
cls = ConsoleServerPortTemplate
parent_cls = DeviceType
queryset = ConsoleServerPortTemplate.objects.all()
parent_model = DeviceType
table = tables.ConsoleServerPortTemplateTable
@@ -698,8 +676,8 @@ class PowerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_powerporttemplate'
cls = PowerPortTemplate
parent_cls = DeviceType
queryset = PowerPortTemplate.objects.all()
parent_model = DeviceType
table = tables.PowerPortTemplateTable
@@ -715,8 +693,8 @@ class PowerOutletTemplateCreateView(PermissionRequiredMixin, ComponentCreateView
class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_poweroutlettemplate'
cls = PowerOutletTemplate
parent_cls = DeviceType
queryset = PowerOutletTemplate.objects.all()
parent_model = DeviceType
table = tables.PowerOutletTemplateTable
@@ -732,16 +710,16 @@ class InterfaceTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_interfacetemplate'
cls = InterfaceTemplate
parent_cls = DeviceType
queryset = InterfaceTemplate.objects.all()
parent_model = DeviceType
table = tables.InterfaceTemplateTable
form = forms.InterfaceTemplateBulkEditForm
class InterfaceTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_interfacetemplate'
cls = InterfaceTemplate
parent_cls = DeviceType
queryset = InterfaceTemplate.objects.all()
parent_model = DeviceType
table = tables.InterfaceTemplateTable
@@ -757,8 +735,8 @@ class DeviceBayTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_devicebaytemplate'
cls = DeviceBayTemplate
parent_cls = DeviceType
queryset = DeviceBayTemplate.objects.all()
parent_model = DeviceType
table = tables.DeviceBayTemplateTable
@@ -776,9 +754,7 @@ class DeviceRoleCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.add_devicerole'
model = DeviceRole
model_form = forms.DeviceRoleForm
def get_return_url(self, request, obj):
return reverse('dcim:devicerole_list')
default_return_url = 'dcim:devicerole_list'
class DeviceRoleEditView(DeviceRoleCreateView):
@@ -794,8 +770,7 @@ class DeviceRoleBulkImportView(PermissionRequiredMixin, BulkImportView):
class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_devicerole'
cls = DeviceRole
queryset = DeviceRole.objects.annotate(device_count=Count('devices'))
queryset = DeviceRole.objects.all()
table = tables.DeviceRoleTable
default_return_url = 'dcim:devicerole_list'
@@ -814,9 +789,7 @@ class PlatformCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.add_platform'
model = Platform
model_form = forms.PlatformForm
def get_return_url(self, request, obj):
return reverse('dcim:platform_list')
default_return_url = 'dcim:platform_list'
class PlatformEditView(PlatformCreateView):
@@ -832,8 +805,7 @@ class PlatformBulkImportView(PermissionRequiredMixin, BulkImportView):
class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_platform'
cls = Platform
queryset = Platform.objects.annotate(device_count=Count('devices'))
queryset = Platform.objects.all()
table = tables.PlatformTable
default_return_url = 'dcim:platform_list'
@@ -945,6 +917,7 @@ class DeviceInventoryView(View):
return render(request, 'dcim/device_inventory.html', {
'device': device,
'inventory_items': inventory_items,
'active_tab': 'inventory',
})
@@ -957,6 +930,7 @@ class DeviceStatusView(PermissionRequiredMixin, View):
return render(request, 'dcim/device_status.html', {
'device': device,
'active_tab': 'status',
})
@@ -975,6 +949,7 @@ class DeviceLLDPNeighborsView(PermissionRequiredMixin, View):
return render(request, 'dcim/device_lldp_neighbors.html', {
'device': device,
'interfaces': interfaces,
'active_tab': 'lldp-neighbors',
})
@@ -987,9 +962,15 @@ class DeviceConfigView(PermissionRequiredMixin, View):
return render(request, 'dcim/device_config.html', {
'device': device,
'active_tab': 'config',
})
class DeviceConfigContextView(ObjectConfigContextView):
object_class = Device
base_template = 'dcim/device.html'
class DeviceCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.add_device'
model = Device
@@ -1037,7 +1018,6 @@ class ChildDeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_device'
cls = Device
queryset = Device.objects.select_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer')
filter = filters.DeviceFilter
table = tables.DeviceTable
@@ -1047,7 +1027,6 @@ class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView):
class DeviceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_device'
cls = Device
queryset = Device.objects.select_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer')
filter = filters.DeviceFilter
table = tables.DeviceTable
@@ -1104,7 +1083,6 @@ class ConsolePortConnectView(PermissionRequiredMixin, View):
escape(consoleport.cs_port.name),
)
messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, consoleport, msg)
return redirect('dcim:device', pk=consoleport.device.pk)
@@ -1155,7 +1133,6 @@ class ConsolePortDisconnectView(PermissionRequiredMixin, View):
escape(cs_port.name),
)
messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, consoleport, msg)
return redirect('dcim:device', pk=consoleport.device.pk)
@@ -1179,8 +1156,8 @@ class ConsolePortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_consoleport'
cls = ConsolePort
parent_cls = Device
queryset = ConsolePort.objects.all()
parent_model = Device
table = tables.ConsolePortTable
@@ -1244,7 +1221,6 @@ class ConsoleServerPortConnectView(PermissionRequiredMixin, View):
escape(consoleserverport.name),
)
messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, consoleport, msg)
return redirect('dcim:device', pk=consoleserverport.device.pk)
@@ -1296,7 +1272,6 @@ class ConsoleServerPortDisconnectView(PermissionRequiredMixin, View):
escape(consoleserverport.name),
)
messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, consoleport, msg)
return redirect('dcim:device', pk=consoleserverport.device.pk)
@@ -1335,8 +1310,8 @@ class ConsoleServerPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnec
class ConsoleServerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_consoleserverport'
cls = ConsoleServerPort
parent_cls = Device
queryset = ConsoleServerPort.objects.all()
parent_model = Device
table = tables.ConsoleServerPortTable
@@ -1390,7 +1365,6 @@ class PowerPortConnectView(PermissionRequiredMixin, View):
escape(powerport.power_outlet.name),
)
messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, powerport, msg)
return redirect('dcim:device', pk=powerport.device.pk)
@@ -1441,7 +1415,6 @@ class PowerPortDisconnectView(PermissionRequiredMixin, View):
escape(power_outlet.name),
)
messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, powerport, msg)
return redirect('dcim:device', pk=powerport.device.pk)
@@ -1465,8 +1438,8 @@ class PowerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_powerport'
cls = PowerPort
parent_cls = Device
queryset = PowerPort.objects.all()
parent_model = Device
table = tables.PowerPortTable
@@ -1529,7 +1502,6 @@ class PowerOutletConnectView(PermissionRequiredMixin, View):
escape(poweroutlet.name),
)
messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, powerport, msg)
return redirect('dcim:device', pk=poweroutlet.device.pk)
@@ -1580,7 +1552,6 @@ class PowerOutletDisconnectView(PermissionRequiredMixin, View):
escape(poweroutlet.name),
)
messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, powerport, msg)
return redirect('dcim:device', pk=poweroutlet.device.pk)
@@ -1621,8 +1592,8 @@ class PowerOutletBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView)
class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_poweroutlet'
cls = PowerOutlet
parent_cls = Device
queryset = PowerOutlet.objects.all()
parent_model = Device
table = tables.PowerOutletTable
@@ -1630,6 +1601,47 @@ class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Interfaces
#
class InterfaceView(View):
def get(self, request, pk):
interface = get_object_or_404(Interface, pk=pk)
# Get connected interface
connected_interface = interface.connected_interface
if connected_interface is None and hasattr(interface, 'circuit_termination'):
peer_termination = interface.circuit_termination.get_peer_termination()
if peer_termination is not None:
connected_interface = peer_termination.interface
# Get assigned IP addresses
ipaddress_table = InterfaceIPAddressTable(
data=interface.ip_addresses.select_related('vrf', 'tenant'),
orderable=False
)
# Get assigned VLANs and annotate whether each is tagged or untagged
vlans = []
if interface.untagged_vlan is not None:
vlans.append(interface.untagged_vlan)
vlans[0].tagged = False
for vlan in interface.tagged_vlans.select_related('site', 'group', 'tenant', 'role'):
vlan.tagged = True
vlans.append(vlan)
vlan_table = InterfaceVLANTable(
interface=interface,
data=vlans,
orderable=False
)
return render(request, 'dcim/interface.html', {
'interface': interface,
'connected_interface': connected_interface,
'ipaddress_table': ipaddress_table,
'vlan_table': vlan_table,
})
class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_interface'
parent_model = Device
@@ -1672,8 +1684,8 @@ class InterfaceBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_interface'
cls = Interface
parent_cls = Device
queryset = Interface.objects.all()
parent_model = Device
table = tables.InterfaceTable
form = forms.InterfaceBulkEditForm
@@ -1686,8 +1698,8 @@ class InterfaceBulkRenameView(PermissionRequiredMixin, BulkRenameView):
class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_interface'
cls = Interface
parent_cls = Device
queryset = Interface.objects.all()
parent_model = Device
table = tables.InterfaceTable
@@ -1793,8 +1805,8 @@ class DeviceBayBulkRenameView(PermissionRequiredMixin, BulkRenameView):
class DeviceBayBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_devicebay'
cls = DeviceBay
parent_cls = Device
queryset = DeviceBay.objects.all()
parent_model = Device
table = tables.DeviceBayTable
@@ -1910,7 +1922,6 @@ class InterfaceConnectionAddView(PermissionRequiredMixin, GetReturnURLMixin, Vie
escape(interfaceconnection.interface_b.name),
)
messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, interfaceconnection, msg)
if '_addanother' in request.POST:
base_url = reverse('dcim:interfaceconnection_add', kwargs={'pk': device.pk})
@@ -1961,7 +1972,6 @@ class InterfaceConnectionDeleteView(PermissionRequiredMixin, GetReturnURLMixin,
escape(interfaceconnection.interface_b.name),
)
messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, interfaceconnection, msg)
return redirect(self.get_return_url(request, interfaceconnection))
@@ -2053,7 +2063,6 @@ class InventoryItemBulkImportView(PermissionRequiredMixin, BulkImportView):
class InventoryItemBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_inventoryitem'
cls = InventoryItem
queryset = InventoryItem.objects.select_related('device', 'manufacturer')
filter = filters.InventoryItemFilter
table = tables.InventoryItemTable
@@ -2063,7 +2072,6 @@ class InventoryItemBulkEditView(PermissionRequiredMixin, BulkEditView):
class InventoryItemBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_inventoryitem'
cls = InventoryItem
queryset = InventoryItem.objects.select_related('device', 'manufacturer')
table = tables.InventoryItemTable
template_name = 'dcim/inventoryitem_bulk_delete.html'
@@ -2241,7 +2249,6 @@ class VirtualChassisAddMemberView(PermissionRequiredMixin, GetReturnURLMixin, Vi
membership_form.save()
msg = 'Added member <a href="{}">{}</a>'.format(device.get_absolute_url(), escape(device))
messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, device, msg)
if '_addanother' in request.POST:
return redirect(request.get_full_path())
@@ -2296,7 +2303,6 @@ class VirtualChassisRemoveMemberView(PermissionRequiredMixin, GetReturnURLMixin,
msg = 'Removed {} from virtual chassis {}'.format(device, device.virtual_chassis)
messages.success(request, msg)
UserAction.objects.log_edit(request.user, device, msg)
return redirect(self.get_return_url(request, device))

View File

@@ -0,0 +1,15 @@
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
default_app_config = 'extras.apps.ExtrasConfig'
# check that django-rq is installed and we can connect to redis
if settings.WEBHOOKS_ENABLED:
try:
import django_rq
except ImportError:
raise ImproperlyConfigured(
"django-rq is not installed! You must install this package per "
"the documentation to use the webhook backend."
)

View File

@@ -4,7 +4,12 @@ from django import forms
from django.contrib import admin
from django.utils.safestring import mark_safe
from .models import CustomField, CustomFieldChoice, Graph, ExportTemplate, TopologyMap, UserAction
from utilities.forms import LaxURLField
from .constants import OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE
from .models import (
ConfigContext, CustomField, CustomFieldChoice, Graph, ExportTemplate, ObjectChange, TopologyMap, UserAction,
Webhook,
)
def order_content_types(field):
@@ -15,6 +20,37 @@ def order_content_types(field):
field.choices = [(ct.pk, '{} > {}'.format(ct.app_label, ct.name)) for ct in queryset]
#
# Webhooks
#
class WebhookForm(forms.ModelForm):
payload_url = LaxURLField(
label='URL'
)
class Meta:
model = Webhook
exclude = []
def __init__(self, *args, **kwargs):
super(WebhookForm, self).__init__(*args, **kwargs)
order_content_types(self.fields['obj_type'])
@admin.register(Webhook)
class WebhookAdmin(admin.ModelAdmin):
list_display = [
'name', 'models', 'payload_url', 'http_content_type', 'enabled', 'type_create', 'type_update',
'type_delete', 'ssl_verification',
]
form = WebhookForm
def models(self, obj):
return ', '.join([ct.name for ct in obj.obj_type.all()])
#
# Custom fields
#

View File

@@ -2,28 +2,31 @@ 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
from extras.constants import ACTION_CHOICES, GRAPH_TYPE_CHOICES
from extras.models import ExportTemplate, Graph, ImageAttachment, ReportResult, TopologyMap, UserAction
from dcim.api.serializers import (
NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedRackSerializer,
NestedRegionSerializer, NestedSiteSerializer,
)
from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site
from extras.models import (
ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap, UserAction,
)
from extras.constants import *
from tenancy.api.serializers import NestedTenantSerializer, NestedTenantGroupSerializer
from tenancy.models import Tenant, TenantGroup
from users.api.serializers import NestedUserSerializer
from utilities.api import ChoiceFieldSerializer, ContentTypeFieldSerializer, ValidatedModelSerializer
from utilities.api import (
ChoiceField, ContentTypeField, get_serializer_for_model, SerializedPKRelatedField, ValidatedModelSerializer,
)
#
# Graphs
#
class GraphSerializer(serializers.ModelSerializer):
type = ChoiceFieldSerializer(choices=GRAPH_TYPE_CHOICES)
class Meta:
model = Graph
fields = ['id', 'type', 'weight', 'name', 'source', 'link']
class WritableGraphSerializer(serializers.ModelSerializer):
class GraphSerializer(ValidatedModelSerializer):
type = ChoiceField(choices=GRAPH_TYPE_CHOICES)
class Meta:
model = Graph
@@ -33,7 +36,7 @@ class WritableGraphSerializer(serializers.ModelSerializer):
class RenderedGraphSerializer(serializers.ModelSerializer):
embed_url = serializers.SerializerMethodField()
embed_link = serializers.SerializerMethodField()
type = ChoiceFieldSerializer(choices=GRAPH_TYPE_CHOICES)
type = ChoiceField(choices=GRAPH_TYPE_CHOICES)
class Meta:
model = Graph
@@ -50,7 +53,7 @@ class RenderedGraphSerializer(serializers.ModelSerializer):
# Export templates
#
class ExportTemplateSerializer(serializers.ModelSerializer):
class ExportTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = ExportTemplate
@@ -61,7 +64,7 @@ class ExportTemplateSerializer(serializers.ModelSerializer):
# Topology maps
#
class TopologyMapSerializer(serializers.ModelSerializer):
class TopologyMapSerializer(ValidatedModelSerializer):
site = NestedSiteSerializer()
class Meta:
@@ -69,23 +72,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 = ContentTypeField()
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):
@@ -102,27 +128,54 @@ class ImageAttachmentSerializer(serializers.ModelSerializer):
return serializer(obj.parent, context={'request': self.context['request']}).data
class WritableImageAttachmentSerializer(ValidatedModelSerializer):
content_type = ContentTypeFieldSerializer()
#
# Config contexts
#
class ConfigContextSerializer(ValidatedModelSerializer):
regions = SerializedPKRelatedField(
queryset=Region.objects.all(),
serializer=NestedRegionSerializer,
required=False,
many=True
)
sites = SerializedPKRelatedField(
queryset=Site.objects.all(),
serializer=NestedSiteSerializer,
required=False,
many=True
)
roles = SerializedPKRelatedField(
queryset=DeviceRole.objects.all(),
serializer=NestedDeviceRoleSerializer,
required=False,
many=True
)
platforms = SerializedPKRelatedField(
queryset=Platform.objects.all(),
serializer=NestedPlatformSerializer,
required=False,
many=True
)
tenant_groups = SerializedPKRelatedField(
queryset=TenantGroup.objects.all(),
serializer=NestedTenantGroupSerializer,
required=False,
many=True
)
tenants = SerializedPKRelatedField(
queryset=Tenant.objects.all(),
serializer=NestedTenantSerializer,
required=False,
many=True
)
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
model = ConfigContext
fields = [
'id', 'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms',
'tenant_groups', 'tenants', 'data',
]
#
@@ -160,13 +213,42 @@ class ReportDetailSerializer(ReportSerializer):
result = ReportResultSerializer()
#
# Change logging
#
class ObjectChangeSerializer(serializers.ModelSerializer):
user = NestedUserSerializer(read_only=True)
content_type = ContentTypeField(read_only=True)
changed_object = serializers.SerializerMethodField(read_only=True)
class Meta:
model = ObjectChange
fields = [
'id', 'time', 'user', 'user_name', 'request_id', 'action', 'content_type', 'changed_object', 'object_data',
]
def get_changed_object(self, obj):
"""
Serialize a nested representation of the changed object.
"""
if obj.changed_object is None:
return None
serializer = get_serializer_for_model(obj.changed_object, prefix='Nested')
if serializer is None:
return obj.object_repr
context = {'request': self.context['request']}
data = serializer(obj.changed_object, context=context).data
return data
#
# User actions
#
class UserActionSerializer(serializers.ModelSerializer):
user = NestedUserSerializer()
action = ChoiceFieldSerializer(choices=ACTION_CHOICES)
action = ChoiceField(choices=ACTION_CHOICES)
class Meta:
model = UserAction

View File

@@ -28,12 +28,21 @@ 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)
# Config contexts
router.register(r'config-contexts', views.ConfigContextViewSet)
# Reports
router.register(r'reports', views.ReportViewSet, base_name='report')
# Change logging
router.register(r'object-changes', views.ObjectChangeViewSet)
# Recent activity
router.register(r'recent-activity', views.RecentActivityViewSet)

View File

@@ -1,15 +1,20 @@
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.decorators import action
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
from extras.models import (
ConfigContext, CustomField, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap,
UserAction,
)
from extras.reports import get_report, get_reports
from utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, ModelViewSet
from . import serializers
@@ -67,7 +72,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,10 +92,9 @@ 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()
@action(detail=True)
def render(self, request, pk):
tmap = get_object_or_404(TopologyMap, pk=pk)
@@ -111,6 +114,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 +131,15 @@ class TopologyMapViewSet(ModelViewSet):
class ImageAttachmentViewSet(ModelViewSet):
queryset = ImageAttachment.objects.all()
serializer_class = serializers.ImageAttachmentSerializer
write_serializer_class = serializers.WritableImageAttachmentSerializer
#
# Config contexts
#
class ConfigContextViewSet(ModelViewSet):
queryset = ConfigContext.objects.prefetch_related('regions', 'sites', 'roles', 'platforms', 'tenants')
serializer_class = serializers.ConfigContextSerializer
#
@@ -178,7 +199,7 @@ class ReportViewSet(ViewSet):
return Response(serializer.data)
@detail_route(methods=['post'])
@action(detail=True, methods=['post'])
def run(self, request, pk):
"""
Run a Report and create a new ReportResult, overwriting any previous result for the Report.
@@ -197,13 +218,26 @@ class ReportViewSet(ViewSet):
return Response(serializer.data)
#
# Change logging
#
class ObjectChangeViewSet(ReadOnlyModelViewSet):
"""
Retrieve a list of recent changes.
"""
queryset = ObjectChange.objects.select_related('user')
serializer_class = serializers.ObjectChangeSerializer
filter_class = filters.ObjectChangeFilter
#
# User activity
#
class RecentActivityViewSet(ReadOnlyModelViewSet):
"""
List all UserActions to provide a log of recent activity.
DEPRECATED: List all UserActions to provide a log of recent activity.
"""
queryset = UserAction.objects.all()
serializer_class = serializers.UserActionSerializer

33
netbox/extras/apps.py Normal file
View File

@@ -0,0 +1,33 @@
from __future__ import unicode_literals
from django.apps import AppConfig
from django.core.exceptions import ImproperlyConfigured
from django.conf import settings
class ExtrasConfig(AppConfig):
name = "extras"
def ready(self):
# Check that we can connect to the configured Redis database if webhooks are enabled.
if settings.WEBHOOKS_ENABLED:
try:
import redis
except ImportError:
raise ImproperlyConfigured(
"WEBHOOKS_ENABLED is True but the redis Python package is not installed. (Try 'pip install "
"redis'.)"
)
try:
rs = redis.Redis(
host=settings.REDIS_HOST,
port=settings.REDIS_PORT,
db=settings.REDIS_DATABASE,
password=settings.REDIS_PASSWORD or None,
)
rs.ping()
except redis.exceptions.ConnectionError:
raise ImproperlyConfigured(
"Unable to connect to the Redis database. Check that the Redis configuration has been defined in "
"configuration.py."
)

View File

@@ -3,11 +3,12 @@ from __future__ import unicode_literals
# Models which support custom fields
CUSTOMFIELD_MODELS = (
'provider', 'circuit', # Circuits
'site', 'rack', 'devicetype', 'device', # DCIM
'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', # IPAM
'tenant', # Tenancy
'cluster', 'virtualmachine', # Virtualization
'provider', 'circuit', # Circuits
'site', 'rack', 'devicetype', 'device', # DCIM
'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'service', # IPAM
'secret', # Secrets
'tenant', # Tenancy
'cluster', 'virtualmachine', # Virtualization
)
# Custom field types
@@ -50,8 +51,9 @@ GRAPH_TYPE_CHOICES = (
EXPORTTEMPLATE_MODELS = [
'provider', 'circuit', # Circuits
'site', 'region', 'rack', 'rackgroup', 'manufacturer', 'devicetype', 'device', # DCIM
'consoleport', 'powerport', 'interfaceconnection', # DCIM
'aggregate', 'prefix', 'ipaddress', 'vlan', # IPAM
'consoleport', 'powerport', 'interfaceconnection', 'virtualchassis', # DCIM
'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'service', # IPAM
'secret', # Secrets
'tenant', # Tenancy
'cluster', 'virtualmachine', # Virtualization
]
@@ -66,6 +68,16 @@ TOPOLOGYMAP_TYPE_CHOICES = (
(TOPOLOGYMAP_TYPE_POWER, 'Power'),
)
# Change log actions
OBJECTCHANGE_ACTION_CREATE = 1
OBJECTCHANGE_ACTION_UPDATE = 2
OBJECTCHANGE_ACTION_DELETE = 3
OBJECTCHANGE_ACTION_CHOICES = (
(OBJECTCHANGE_ACTION_CREATE, 'Created'),
(OBJECTCHANGE_ACTION_UPDATE, 'Updated'),
(OBJECTCHANGE_ACTION_DELETE, 'Deleted'),
)
# User action types
ACTION_CREATE = 1
ACTION_IMPORT = 2
@@ -97,3 +109,23 @@ LOG_LEVEL_CODES = {
LOG_WARNING: 'warning',
LOG_FAILURE: 'failure',
}
# webhook content types
WEBHOOK_CT_JSON = 1
WEBHOOK_CT_X_WWW_FORM_ENCODED = 2
WEBHOOK_CT_CHOICES = (
(WEBHOOK_CT_JSON, 'application/json'),
(WEBHOOK_CT_X_WWW_FORM_ENCODED, 'application/x-www-form-urlencoded'),
)
# Models which support registered webhooks
WEBHOOK_MODELS = (
'provider', 'circuit', # Circuits
'site', 'rack', 'devicetype', 'device', 'virtualchassis', # DCIM
'consoleport', 'consoleserverport', 'powerport', 'poweroutlet',
'interface', 'devicebay', 'inventoryitem',
'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'service', # IPAM
'secret', # Secrets
'tenant', # Tenancy
'cluster', 'virtualmachine', # Virtualization
)

View File

@@ -3,10 +3,12 @@ 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
from .models import CustomField, Graph, ExportTemplate, TopologyMap, UserAction
from .models import CustomField, Graph, ExportTemplate, ObjectChange, TopologyMap, UserAction
class CustomFieldFilter(django_filters.Filter):
@@ -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',
@@ -103,6 +124,26 @@ class TopologyMapFilter(django_filters.FilterSet):
fields = ['name', 'slug']
class ObjectChangeFilter(django_filters.FilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
time = django_filters.DateTimeFromToRangeFilter()
class Meta:
model = ObjectChange
fields = ['user', 'user_name', 'request_id', 'action', 'changed_object_type', 'object_repr']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(user_name__icontains=value) |
Q(object_repr__icontains=value)
)
class UserActionFilter(django_filters.FilterSet):
username = django_filters.ModelMultipleChoiceFilter(
name='user__username',

View File

@@ -3,14 +3,26 @@ from __future__ import unicode_literals
from collections import OrderedDict
from django import forms
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from mptt.forms import TreeNodeMultipleChoiceField
from taggit.forms import TagField
from taggit.models import Tag
from utilities.forms import BootstrapMixin, BulkEditForm, LaxURLField
from .constants import CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL
from .models import CustomField, CustomFieldValue, ImageAttachment
from dcim.models import Region
from utilities.forms import add_blank_choice, BootstrapMixin, BulkEditForm, LaxURLField, JSONField, SlugField
from .constants import (
CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL,
OBJECTCHANGE_ACTION_CHOICES,
)
from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange
#
# Custom fields
#
def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=False):
"""
Retrieve all CustomFields applicable to the given ContentType
@@ -170,8 +182,88 @@ class CustomFieldFilterForm(forms.Form):
self.fields[name] = field
#
# Tags
#
class TagForm(BootstrapMixin, forms.ModelForm):
slug = SlugField()
class Meta:
model = Tag
fields = ['name', 'slug']
class AddRemoveTagsForm(forms.Form):
def __init__(self, *args, **kwargs):
super(AddRemoveTagsForm, self).__init__(*args, **kwargs)
# Add add/remove tags fields
self.fields['add_tags'] = TagField(required=False)
self.fields['remove_tags'] = TagField(required=False)
#
# Config contexts
#
class ConfigContextForm(BootstrapMixin, forms.ModelForm):
regions = TreeNodeMultipleChoiceField(
queryset=Region.objects.all(),
required=False
)
data = JSONField()
class Meta:
model = ConfigContext
fields = [
'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'tenant_groups',
'tenants', 'data',
]
#
# Image attachments
#
class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = ImageAttachment
fields = ['name', 'image']
#
# Change logging
#
class ObjectChangeFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = ObjectChange
q = forms.CharField(
required=False,
label='Search'
)
# TODO: Change time_0 and time_1 to time_after and time_before for django-filter==2.0
time_0 = forms.DateTimeField(
label='After',
required=False,
widget=forms.TextInput(
attrs={'placeholder': 'YYYY-MM-DD hh:mm:ss'}
)
)
time_1 = forms.DateTimeField(
label='Before',
required=False,
widget=forms.TextInput(
attrs={'placeholder': 'YYYY-MM-DD hh:mm:ss'}
)
)
action = forms.ChoiceField(
choices=add_blank_choice(OBJECTCHANGE_ACTION_CHOICES),
required=False
)
user = forms.ModelChoiceField(
queryset=User.objects.order_by('username'),
required=False
)

View File

@@ -0,0 +1,94 @@
from __future__ import unicode_literals
from datetime import timedelta
import random
import threading
import uuid
from django.conf import settings
from django.db.models.signals import post_delete, post_save
from django.utils import timezone
from django.utils.functional import curry
from extras.webhooks import enqueue_webhooks
from .constants import (
OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE,
)
from .models import ObjectChange
_thread_locals = threading.local()
def cache_changed_object(instance, **kwargs):
action = OBJECTCHANGE_ACTION_CREATE if kwargs['created'] else OBJECTCHANGE_ACTION_UPDATE
# Cache the object for further processing was the response has completed.
_thread_locals.changed_objects.append(
(instance, action)
)
def _record_object_deleted(request, instance, **kwargs):
# Record that the object was deleted.
if hasattr(instance, 'log_change'):
instance.log_change(request.user, request.id, OBJECTCHANGE_ACTION_DELETE)
enqueue_webhooks(instance, OBJECTCHANGE_ACTION_DELETE)
class ObjectChangeMiddleware(object):
"""
This middleware performs two functions in response to an object being created, updated, or deleted:
1. Create an ObjectChange to reflect the modification to the object in the changelog.
2. Enqueue any relevant webhooks.
The post_save and pre_delete signals are employed to catch object modifications, however changes are recorded a bit
differently for each. Objects being saved are cached into thread-local storage for action *after* the response has
completed. This ensures that serialization of the object is performed only after any related objects (e.g. tags)
have been created. Conversely, deletions are acted upon immediately, so that the serialized representation of the
object is recorded before it (and any related objects) are actually deleted from the database.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
# Initialize an empty list to cache objects being saved.
_thread_locals.changed_objects = []
# Assign a random unique ID to the request. This will be used to associate multiple object changes made during
# the same request.
request.id = uuid.uuid4()
# Signals don't include the request context, so we're currying it into the pre_delete function ahead of time.
record_object_deleted = curry(_record_object_deleted, request)
# Connect our receivers to the post_save and pre_delete signals.
post_save.connect(cache_changed_object, dispatch_uid='record_object_saved')
post_delete.connect(record_object_deleted, dispatch_uid='record_object_deleted')
# Process the request
response = self.get_response(request)
# Create records for any cached objects that were created/updated.
for obj, action in _thread_locals.changed_objects:
# Record the change
if hasattr(obj, 'log_change'):
obj.log_change(request.user, request.id, action)
# Enqueue webhooks
enqueue_webhooks(obj, action)
# Housekeeping: 1% chance of clearing out expired ObjectChanges
if _thread_locals.changed_objects and settings.CHANGELOG_RETENTION and random.randint(1, 100) == 1:
cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION)
purged_count, _ = ObjectChange.objects.filter(
time__lt=cutoff
).delete()
return response

View File

@@ -0,0 +1,193 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.14 on 2018-07-31 02:19
from __future__ import unicode_literals
import re
from distutils.version import StrictVersion
from django.conf import settings
import django.contrib.postgres.fields.jsonb
from django.db import connection, migrations, models
import django.db.models.deletion
import extras.models
from django.db.utils import OperationalError
from extras.constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_FILTER_LOOSE, CF_TYPE_SELECT
def verify_postgresql_version(apps, schema_editor):
"""
Verify that PostgreSQL is version 9.4 or higher.
"""
try:
with connection.cursor() as cursor:
cursor.execute("SELECT VERSION()")
row = cursor.fetchone()
pg_version = re.match(r'^PostgreSQL (\d+\.\d+(\.\d+)?)', row[0]).group(1)
if StrictVersion(pg_version) < StrictVersion('9.4.0'):
raise Exception("PostgreSQL 9.4.0 or higher is required ({} found). Upgrade PostgreSQL and then run migrations again.".format(pg_version))
# Skip if the database is missing (e.g. for CI testing) or misconfigured.
except OperationalError:
pass
class Migration(migrations.Migration):
replaces = [('extras', '0001_initial'), ('extras', '0002_custom_fields'), ('extras', '0003_exporttemplate_add_description'), ('extras', '0004_topologymap_change_comma_to_semicolon'), ('extras', '0005_useraction_add_bulk_create'), ('extras', '0006_add_imageattachments'), ('extras', '0007_unicode_literals'), ('extras', '0008_reports'), ('extras', '0009_topologymap_type'), ('extras', '0010_customfield_filter_logic')]
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('dcim', '0002_auto_20160622_1821'),
('contenttypes', '0002_remove_content_type_name'),
]
operations = [
migrations.CreateModel(
name='ExportTemplate',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('template_code', models.TextField()),
('mime_type', models.CharField(blank=True, max_length=15)),
('file_extension', models.CharField(blank=True, max_length=15)),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')),
('description', models.CharField(blank=True, max_length=200)),
],
options={
'ordering': ['content_type', 'name'],
},
),
migrations.CreateModel(
name='Graph',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('type', models.PositiveSmallIntegerField(choices=[(100, 'Interface'), (200, 'Provider'), (300, 'Site')])),
('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')),
],
options={
'ordering': ['type', 'weight', 'name'],
},
),
migrations.CreateModel(
name='TopologyMap',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50, unique=True)),
('slug', models.SlugField(unique=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.')),
('description', models.CharField(blank=True, max_length=100)),
('site', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='topology_maps', to='dcim.Site')),
('type', models.PositiveSmallIntegerField(choices=[(1, 'Network'), (2, 'Console'), (3, 'Power')], default=1)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='UserAction',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('time', models.DateTimeField(auto_now_add=True)),
('object_id', models.PositiveIntegerField(blank=True, null=True)),
('action', models.PositiveSmallIntegerField(choices=[(1, 'created'), (7, 'bulk created'), (2, 'imported'), (3, 'modified'), (4, 'bulk edited'), (5, 'deleted'), (6, 'bulk deleted')])),
('message', models.TextField(blank=True)),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='actions', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-time'],
},
),
migrations.AlterUniqueTogether(
name='exporttemplate',
unique_together=set([('content_type', 'name')]),
),
migrations.CreateModel(
name='CustomField',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('type', models.PositiveSmallIntegerField(choices=[(100, 'Text'), (200, 'Integer'), (300, 'Boolean (true/false)'), (400, 'Date'), (500, 'URL'), (600, 'Selection')], default=100)),
('name', models.CharField(max_length=50, unique=True)),
('label', models.CharField(blank=True, help_text="Name of the field as displayed to users (if not provided, the field's name will be used)", max_length=50)),
('description', models.CharField(blank=True, max_length=100)),
('required', models.BooleanField(default=False, help_text='If true, this field is required when creating new objects or editing an existing object.')),
('default', models.CharField(blank=True, help_text='Default value for the field. Use "true" or "false" for booleans.', max_length=100)),
('weight', models.PositiveSmallIntegerField(default=100, help_text='Fields with higher weights appear lower in a form.')),
('obj_type', models.ManyToManyField(help_text='The object(s) to which this field applies.', related_name='custom_fields', to='contenttypes.ContentType', verbose_name='Object(s)')),
('filter_logic', models.PositiveSmallIntegerField(choices=[(0, 'Disabled'), (1, 'Loose'), (2, 'Exact')], default=1, help_text='Loose matches any instance of a given string; exact matches the entire field.')),
],
options={
'ordering': ['weight', 'name'],
},
),
migrations.CreateModel(
name='CustomFieldChoice',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('value', models.CharField(max_length=100)),
('weight', models.PositiveSmallIntegerField(default=100, help_text='Higher weights appear lower in the list')),
('field', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='choices', to='extras.CustomField')),
],
options={
'ordering': ['field', 'weight', 'value'],
},
),
migrations.CreateModel(
name='CustomFieldValue',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('obj_id', models.PositiveIntegerField()),
('serialized_value', models.CharField(max_length=255)),
('field', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='values', to='extras.CustomField')),
('obj_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType')),
],
options={
'ordering': ['obj_type', 'obj_id'],
},
),
migrations.AlterUniqueTogether(
name='customfieldvalue',
unique_together=set([('field', 'obj_type', 'obj_id')]),
),
migrations.AlterUniqueTogether(
name='customfieldchoice',
unique_together=set([('field', 'value')]),
),
migrations.CreateModel(
name='ImageAttachment',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('object_id', models.PositiveIntegerField()),
('image', models.ImageField(height_field='image_height', upload_to=extras.models.image_upload, width_field='image_width')),
('image_height', models.PositiveSmallIntegerField()),
('image_width', models.PositiveSmallIntegerField()),
('name', models.CharField(blank=True, max_length=50)),
('created', models.DateTimeField(auto_now_add=True)),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')),
],
options={
'ordering': ['name'],
},
),
migrations.RunPython(
code=verify_postgresql_version,
),
migrations.CreateModel(
name='ReportResult',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('report', models.CharField(max_length=255, unique=True)),
('created', models.DateTimeField(auto_now_add=True)),
('failed', models.BooleanField()),
('data', django.contrib.postgres.fields.jsonb.JSONField()),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['report'],
},
),
]

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

@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.12 on 2018-05-30 17:55
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('extras', '0011_django2'),
]
operations = [
migrations.CreateModel(
name='Webhook',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=150, unique=True)),
('type_create', models.BooleanField(default=False, help_text='Call this webhook when a matching object is created.')),
('type_update', models.BooleanField(default=False, help_text='Call this webhook when a matching object is updated.')),
('type_delete', models.BooleanField(default=False, help_text='Call this webhook when a matching object is deleted.')),
('payload_url', models.CharField(help_text='A POST will be sent to this URL when the webhook is called.', max_length=500, verbose_name='URL')),
('http_content_type', models.PositiveSmallIntegerField(choices=[(1, 'application/json'), (2, 'application/x-www-form-urlencoded')], default=1, verbose_name='HTTP content type')),
('secret', models.CharField(blank=True, help_text="When provided, the request will include a 'X-Hook-Signature' header containing a HMAC hex digest of the payload body using the secret as the key. The secret is not transmitted in the request.", max_length=255)),
('enabled', models.BooleanField(default=True)),
('ssl_verification', models.BooleanField(default=True, help_text='Enable SSL certificate verification. Disable with caution!', verbose_name='SSL verification')),
('obj_type', models.ManyToManyField(help_text='The object(s) to which this Webhook applies.', related_name='webhooks', to='contenttypes.ContentType', verbose_name='Object types')),
],
),
migrations.AlterUniqueTogether(
name='webhook',
unique_together=set([('payload_url', 'type_create', 'type_update', 'type_delete')]),
),
]

View File

@@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.12 on 2018-06-22 18:13
from __future__ import unicode_literals
from django.conf import settings
import django.contrib.postgres.fields.jsonb
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('contenttypes', '0002_remove_content_type_name'),
('extras', '0012_webhooks'),
]
operations = [
migrations.CreateModel(
name='ObjectChange',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('time', models.DateTimeField(auto_now_add=True)),
('user_name', models.CharField(editable=False, max_length=150)),
('request_id', models.UUIDField(editable=False)),
('action', models.PositiveSmallIntegerField(choices=[(1, 'Created'), (2, 'Updated'), (3, 'Deleted')])),
('changed_object_id', models.PositiveIntegerField()),
('related_object_id', models.PositiveIntegerField(blank=True, null=True)),
('object_repr', models.CharField(editable=False, max_length=200)),
('object_data', django.contrib.postgres.fields.jsonb.JSONField(editable=False)),
('changed_object_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType')),
('related_object_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='changes', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-time'],
},
),
]

View File

@@ -0,0 +1,52 @@
# Generated by Django 2.0.7 on 2018-07-27 19:44
import django.contrib.postgres.fields.jsonb
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('tenancy', '0005_change_logging'),
('dcim', '0061_platform_napalm_args'),
('extras', '0013_objectchange'),
]
operations = [
migrations.CreateModel(
name='ConfigContext',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, unique=True)),
('weight', models.PositiveSmallIntegerField(default=1000)),
('description', models.CharField(blank=True, max_length=100)),
('is_active', models.BooleanField(default=True)),
('data', django.contrib.postgres.fields.jsonb.JSONField()),
('platforms', models.ManyToManyField(blank=True, related_name='_configcontext_platforms_+', to='dcim.Platform')),
('regions', models.ManyToManyField(blank=True, related_name='_configcontext_regions_+', to='dcim.Region')),
('roles', models.ManyToManyField(blank=True, related_name='_configcontext_roles_+', to='dcim.DeviceRole')),
('sites', models.ManyToManyField(blank=True, related_name='_configcontext_sites_+', to='dcim.Site')),
('tenant_groups', models.ManyToManyField(blank=True, related_name='_configcontext_tenant_groups_+', to='tenancy.TenantGroup')),
('tenants', models.ManyToManyField(blank=True, related_name='_configcontext_tenants_+', to='tenancy.Tenant')),
],
options={
'ordering': ['weight', 'name'],
},
),
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', 'service', 'secret', 'tenant', 'cluster', 'virtualmachine')}, related_name='custom_fields', to='contenttypes.ContentType', verbose_name='Object(s)'),
),
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', 'virtualchassis', 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'service', 'secret', 'tenant', 'cluster', 'virtualmachine']}, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'),
),
migrations.AlterField(
model_name='webhook',
name='obj_type',
field=models.ManyToManyField(help_text='The object(s) to which this Webhook applies.', limit_choices_to={'model__in': ('provider', 'circuit', 'site', 'rack', 'devicetype', 'device', 'virtualchassis', 'consoleport', 'consoleserverport', 'powerport', 'poweroutlet', 'interface', 'devicebay', 'inventoryitem', 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'service', 'secret', 'tenant', 'cluster', 'virtualmachine')}, related_name='webhooks', to='contenttypes.ContentType', verbose_name='Object types'),
),
]

View File

@@ -13,19 +13,102 @@ from django.db import models
from django.db.models import Q
from django.http import HttpResponse
from django.template import Template, Context
from django.urls import reverse
from django.utils.encoding import python_2_unicode_compatible
from django.utils.safestring import mark_safe
from dcim.constants import CONNECTION_STATUS_CONNECTED
from utilities.utils import foreground_color
from .constants import *
from .querysets import ConfigContextQuerySet
#
# Webhooks
#
@python_2_unicode_compatible
class Webhook(models.Model):
"""
A Webhook defines a request that will be sent to a remote application when an object is created, updated, and/or
delete in NetBox. The request will contain a representation of the object, which the remote application can act on.
Each Webhook can be limited to firing only on certain actions or certain object types.
"""
obj_type = models.ManyToManyField(
to=ContentType,
related_name='webhooks',
verbose_name='Object types',
limit_choices_to={'model__in': WEBHOOK_MODELS},
help_text="The object(s) to which this Webhook applies."
)
name = models.CharField(
max_length=150,
unique=True
)
type_create = models.BooleanField(
default=False,
help_text="Call this webhook when a matching object is created."
)
type_update = models.BooleanField(
default=False,
help_text="Call this webhook when a matching object is updated."
)
type_delete = models.BooleanField(
default=False,
help_text="Call this webhook when a matching object is deleted."
)
payload_url = models.CharField(
max_length=500,
verbose_name='URL',
help_text="A POST will be sent to this URL when the webhook is called."
)
http_content_type = models.PositiveSmallIntegerField(
choices=WEBHOOK_CT_CHOICES,
default=WEBHOOK_CT_JSON,
verbose_name='HTTP content type'
)
secret = models.CharField(
max_length=255,
blank=True,
help_text="When provided, the request will include a 'X-Hook-Signature' "
"header containing a HMAC hex digest of the payload body using "
"the secret as the key. The secret is not transmitted in "
"the request."
)
enabled = models.BooleanField(
default=True
)
ssl_verification = models.BooleanField(
default=True,
verbose_name='SSL verification',
help_text="Enable SSL certificate verification. Disable with caution!"
)
class Meta:
unique_together = ('payload_url', 'type_create', 'type_update', 'type_delete',)
def __str__(self):
return self.name
def clean(self):
"""
Validate model
"""
if not self.type_create and not self.type_delete and not self.type_update:
raise ValidationError(
"You must select at least one type: create, update, and/or delete."
)
#
# Custom fields
#
class CustomFieldModel(object):
class CustomFieldModel(models.Model):
class Meta:
abstract = True
def cf(self):
"""
@@ -73,7 +156,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,
@@ -81,12 +165,14 @@ 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,
@@ -143,11 +229,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']
@@ -174,10 +273,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']
@@ -203,11 +311,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']
@@ -233,13 +354,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']
@@ -278,25 +412,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']
@@ -432,14 +576,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']
@@ -474,6 +633,92 @@ class ImageAttachment(models.Model):
return None
#
# Config contexts
#
class ConfigContext(models.Model):
"""
A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned
qualifiers (region, site, etc.). For example, the data stored in a ConfigContext assigned to site A and tenant B
will be available to a Device in site A assigned to tenant B. Data is stored in JSON format.
"""
name = models.CharField(
max_length=100,
unique=True
)
weight = models.PositiveSmallIntegerField(
default=1000
)
description = models.CharField(
max_length=100,
blank=True
)
is_active = models.BooleanField(
default=True,
)
regions = models.ManyToManyField(
to='dcim.Region',
related_name='+',
blank=True
)
sites = models.ManyToManyField(
to='dcim.Site',
related_name='+',
blank=True
)
roles = models.ManyToManyField(
to='dcim.DeviceRole',
related_name='+',
blank=True
)
platforms = models.ManyToManyField(
to='dcim.Platform',
related_name='+',
blank=True
)
tenant_groups = models.ManyToManyField(
to='tenancy.TenantGroup',
related_name='+',
blank=True
)
tenants = models.ManyToManyField(
to='tenancy.Tenant',
related_name='+',
blank=True
)
data = JSONField()
objects = ConfigContextQuerySet.as_manager()
class Meta:
ordering = ['weight', 'name']
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('extras:configcontext', kwargs={'pk': self.pk})
class ConfigContextModel(models.Model):
class Meta:
abstract = True
def get_config_context(self):
"""
Return the rendered configuration context for a device or VM.
"""
# Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs
data = OrderedDict()
for context in ConfigContext.objects.get_for_object(self):
data.update(context.data)
return data
#
# Report results
#
@@ -482,9 +727,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()
@@ -492,6 +748,114 @@ class ReportResult(models.Model):
ordering = ['report']
#
# Change logging
#
@python_2_unicode_compatible
class ObjectChange(models.Model):
"""
Record a change to an object and the user account associated with that change. A change record may optionally
indicate an object related to the one being changed. For example, a change to an interface may also indicate the
parent device. This will ensure changes made to component models appear in the parent model's changelog.
"""
time = models.DateTimeField(
auto_now_add=True,
editable=False
)
user = models.ForeignKey(
to=User,
on_delete=models.SET_NULL,
related_name='changes',
blank=True,
null=True
)
user_name = models.CharField(
max_length=150,
editable=False
)
request_id = models.UUIDField(
editable=False
)
action = models.PositiveSmallIntegerField(
choices=OBJECTCHANGE_ACTION_CHOICES
)
changed_object_type = models.ForeignKey(
to=ContentType,
on_delete=models.PROTECT,
related_name='+'
)
changed_object_id = models.PositiveIntegerField()
changed_object = GenericForeignKey(
ct_field='changed_object_type',
fk_field='changed_object_id'
)
related_object_type = models.ForeignKey(
to=ContentType,
on_delete=models.PROTECT,
related_name='+',
blank=True,
null=True
)
related_object_id = models.PositiveIntegerField(
blank=True,
null=True
)
related_object = GenericForeignKey(
ct_field='related_object_type',
fk_field='related_object_id'
)
object_repr = models.CharField(
max_length=200,
editable=False
)
object_data = JSONField(
editable=False
)
csv_headers = [
'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object_id',
'related_object_type', 'related_object_id', 'object_repr', 'object_data',
]
class Meta:
ordering = ['-time']
def __str__(self):
return '{} {} {} by {}'.format(
self.changed_object_type,
self.object_repr,
self.get_action_display().lower(),
self.user_name
)
def save(self, *args, **kwargs):
# Record the user's name and the object's representation as static strings
self.user_name = self.user.username
self.object_repr = str(self.changed_object)
return super(ObjectChange, self).save(*args, **kwargs)
def get_absolute_url(self):
return reverse('extras:objectchange', args=[self.pk])
def to_csv(self):
return (
self.time,
self.user,
self.user_name,
self.request_id,
self.get_action_display(),
self.changed_object_type,
self.changed_object_id,
self.related_object_type,
self.related_object_id,
self.object_repr,
self.object_data,
)
#
# User actions
#
@@ -539,17 +903,35 @@ class UserActionManager(models.Manager):
self.log_bulk_action(user, content_type, ACTION_BULK_DELETE, message)
# TODO: Remove UserAction, which has been replaced by ObjectChange.
@python_2_unicode_compatible
class UserAction(models.Model):
"""
A record of an action (add, edit, or delete) performed on an object by a User.
DEPRECATED: 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()

View File

@@ -0,0 +1,34 @@
from __future__ import unicode_literals
from django.db.models import Q, QuerySet
class ConfigContextQuerySet(QuerySet):
def get_for_object(self, obj):
"""
Return all applicable ConfigContexts for a given object. Only active ConfigContexts will be included.
"""
# `device_role` for Device; `role` for VirtualMachine
role = getattr(obj, 'device_role', None) or obj.role
# Get the group of the assigned tenant, if any
tenant_group = obj.tenant.group if obj.tenant else None
# Match against the directly assigned region as well as any parent regions.
region = getattr(obj.site, 'region', None)
if region:
regions = region.get_ancestors(include_self=True)
else:
regions = []
return self.filter(
Q(regions__in=regions) | Q(regions=None),
Q(sites=obj.site) | Q(sites=None),
Q(roles=role) | Q(roles=None),
Q(platforms=obj.platform) | Q(platforms=None),
Q(tenant_groups=tenant_group) | Q(tenant_groups=None),
Q(tenants=obj.tenant) | Q(tenants=None),
is_active=True,
).order_by('weight', 'name')

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

@@ -0,0 +1,107 @@
from __future__ import unicode_literals
import django_tables2 as tables
from taggit.models import Tag
from utilities.tables import BaseTable, BooleanColumn, ToggleColumn
from .models import ConfigContext, ObjectChange
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 %}
"""
CONFIGCONTEXT_ACTIONS = """
{% if perms.extras.change_configcontext %}
<a href="{% url 'extras:configcontext_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
{% if perms.extras.delete_configcontext %}
<a href="{% url 'extras:configcontext_delete' pk=record.pk %}" class="btn btn-xs btn-danger"><i class="glyphicon glyphicon-trash" aria-hidden="true"></i></a>
{% endif %}
"""
OBJECTCHANGE_TIME = """
<a href="{{ record.get_absolute_url }}">{{ value|date:"SHORT_DATETIME_FORMAT" }}</a>
"""
OBJECTCHANGE_ACTION = """
{% if record.action == 1 %}
<span class="label label-success">Created</span>
{% elif record.action == 2 %}
<span class="label label-primary">Updated</span>
{% elif record.action == 3 %}
<span class="label label-danger">Deleted</span>
{% endif %}
"""
OBJECTCHANGE_OBJECT = """
{% if record.action != 3 and record.changed_object.get_absolute_url %}
<a href="{{ record.changed_object.get_absolute_url }}">{{ record.object_repr }}</a>
{% elif record.action != 3 and record.related_object.get_absolute_url %}
<a href="{{ record.related_object.get_absolute_url }}">{{ record.object_repr }}</a>
{% else %}
{{ record.object_repr }}
{% endif %}
"""
OBJECTCHANGE_REQUEST_ID = """
<a href="{% url 'extras:objectchange_list' %}?request_id={{ value }}">{{ value }}</a>
"""
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', 'slug', 'actions')
class ConfigContextTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn()
is_active = BooleanColumn(
verbose_name='Active'
)
actions = tables.TemplateColumn(
template_code=CONFIGCONTEXT_ACTIONS,
attrs={'td': {'class': 'text-right'}},
verbose_name=''
)
class Meta(BaseTable.Meta):
model = ConfigContext
fields = ('pk', 'name', 'weight', 'is_active', 'description', 'actions')
class ObjectChangeTable(BaseTable):
time = tables.TemplateColumn(
template_code=OBJECTCHANGE_TIME
)
action = tables.TemplateColumn(
template_code=OBJECTCHANGE_ACTION
)
changed_object_type = tables.Column(
verbose_name='Type'
)
object_repr = tables.TemplateColumn(
template_code=OBJECTCHANGE_OBJECT,
verbose_name='Object'
)
request_id = tables.TemplateColumn(
template_code=OBJECTCHANGE_REQUEST_ID,
verbose_name='Request ID'
)
class Meta(BaseTable.Meta):
model = ObjectChange
fields = ('time', 'user_name', 'action', 'changed_object_type', 'object_repr', 'request_id')

View File

@@ -1,25 +1,22 @@
from __future__ import unicode_literals
from django.contrib.auth.models import User
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 dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Region, Site
from extras.constants import GRAPH_TYPE_SITE
from extras.models import Graph, ExportTemplate
from users.models import Token
from utilities.tests import HttpStatusMixin
from extras.models import ConfigContext, Graph, ExportTemplate
from tenancy.models import Tenant, TenantGroup
from utilities.testing import APITestCase
class GraphTest(HttpStatusMixin, APITestCase):
class GraphTest(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)}
super(GraphTest, self).setUp()
self.graph1 = Graph.objects.create(
type=GRAPH_TYPE_SITE, name='Test Graph 1', source='http://example.com/graphs.py?site={{ obj.name }}&foo=1'
@@ -119,13 +116,11 @@ class GraphTest(HttpStatusMixin, APITestCase):
self.assertEqual(Graph.objects.count(), 2)
class ExportTemplateTest(HttpStatusMixin, APITestCase):
class ExportTemplateTest(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)}
super(ExportTemplateTest, self).setUp()
self.content_type = ContentType.objects.get_for_model(Device)
self.exporttemplate1 = ExportTemplate.objects.create(
@@ -226,3 +221,305 @@ class ExportTemplateTest(HttpStatusMixin, APITestCase):
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(ExportTemplate.objects.count(), 2)
class TagTest(APITestCase):
def setUp(self):
super(TagTest, self).setUp()
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)
class ConfigContextTest(APITestCase):
def setUp(self):
super(ConfigContextTest, self).setUp()
self.configcontext1 = ConfigContext.objects.create(
name='Test Config Context 1',
weight=100,
data={'foo': 123}
)
self.configcontext2 = ConfigContext.objects.create(
name='Test Config Context 2',
weight=200,
data={'bar': 456}
)
self.configcontext3 = ConfigContext.objects.create(
name='Test Config Context 3',
weight=300,
data={'baz': 789}
)
def test_get_configcontext(self):
url = reverse('extras-api:configcontext-detail', kwargs={'pk': self.configcontext1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.configcontext1.name)
self.assertEqual(response.data['data'], self.configcontext1.data)
def test_list_configcontexts(self):
url = reverse('extras-api:configcontext-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
def test_create_configcontext(self):
region1 = Region.objects.create(name='Test Region 1', slug='test-region-1')
region2 = Region.objects.create(name='Test Region 2', slug='test-region-2')
site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
site2 = Site.objects.create(name='Test Site 2', slug='test-site-2')
role1 = DeviceRole.objects.create(name='Test Role 1', slug='test-role-1')
role2 = DeviceRole.objects.create(name='Test Role 2', slug='test-role-2')
platform1 = Platform.objects.create(name='Test Platform 1', slug='test-platform-1')
platform2 = Platform.objects.create(name='Test Platform 2', slug='test-platform-2')
tenantgroup1 = TenantGroup.objects.create(name='Test Tenant Group 1', slug='test-tenant-group-1')
tenantgroup2 = TenantGroup.objects.create(name='Test Tenant Group 2', slug='test-tenant-group-2')
tenant1 = Tenant.objects.create(name='Test Tenant 1', slug='test-tenant-1')
tenant2 = Tenant.objects.create(name='Test Tenant 2', slug='test-tenant-2')
data = {
'name': 'Test Config Context 4',
'weight': 1000,
'regions': [region1.pk, region2.pk],
'sites': [site1.pk, site2.pk],
'roles': [role1.pk, role2.pk],
'platforms': [platform1.pk, platform2.pk],
'tenant_groups': [tenantgroup1.pk, tenantgroup2.pk],
'tenants': [tenant1.pk, tenant2.pk],
'data': {'foo': 'XXX'}
}
url = reverse('extras-api:configcontext-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(ConfigContext.objects.count(), 4)
configcontext4 = ConfigContext.objects.get(pk=response.data['id'])
self.assertEqual(configcontext4.name, data['name'])
self.assertEqual(region1.pk, data['regions'][0])
self.assertEqual(region2.pk, data['regions'][1])
self.assertEqual(site1.pk, data['sites'][0])
self.assertEqual(site2.pk, data['sites'][1])
self.assertEqual(role1.pk, data['roles'][0])
self.assertEqual(role2.pk, data['roles'][1])
self.assertEqual(platform1.pk, data['platforms'][0])
self.assertEqual(platform2.pk, data['platforms'][1])
self.assertEqual(tenantgroup1.pk, data['tenant_groups'][0])
self.assertEqual(tenantgroup2.pk, data['tenant_groups'][1])
self.assertEqual(tenant1.pk, data['tenants'][0])
self.assertEqual(tenant2.pk, data['tenants'][1])
self.assertEqual(configcontext4.data, data['data'])
def test_create_configcontext_bulk(self):
data = [
{
'name': 'Test Config Context 4',
'data': {'more_foo': True},
},
{
'name': 'Test Config Context 5',
'data': {'more_bar': False},
},
{
'name': 'Test Config Context 6',
'data': {'more_baz': None},
},
]
url = reverse('extras-api:configcontext-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(ConfigContext.objects.count(), 6)
for i in range(0, 3):
self.assertEqual(response.data[i]['name'], data[i]['name'])
self.assertEqual(response.data[i]['data'], data[i]['data'])
def test_update_configcontext(self):
region1 = Region.objects.create(name='Test Region 1', slug='test-region-1')
region2 = Region.objects.create(name='Test Region 2', slug='test-region-2')
data = {
'name': 'Test Config Context X',
'weight': 999,
'regions': [region1.pk, region2.pk],
'data': {'foo': 'XXX'}
}
url = reverse('extras-api:configcontext-detail', kwargs={'pk': self.configcontext1.pk})
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(ConfigContext.objects.count(), 3)
configcontext1 = ConfigContext.objects.get(pk=response.data['id'])
self.assertEqual(configcontext1.name, data['name'])
self.assertEqual(configcontext1.weight, data['weight'])
self.assertEqual(sorted([r.pk for r in configcontext1.regions.all()]), sorted(data['regions']))
self.assertEqual(configcontext1.data, data['data'])
def test_delete_configcontext(self):
url = reverse('extras-api:configcontext-detail', kwargs={'pk': self.configcontext1.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(ConfigContext.objects.count(), 2)
def test_render_configcontext_for_object(self):
# Create a Device for which we'll render a config context
manufacturer = Manufacturer.objects.create(
name='Test Manufacturer',
slug='test-manufacturer'
)
device_type = DeviceType.objects.create(
manufacturer=manufacturer,
model='Test Device Type'
)
device_role = DeviceRole.objects.create(
name='Test Role',
slug='test-role'
)
site = Site.objects.create(
name='Test Site',
slug='test-site'
)
device = Device.objects.create(
name='Test Device',
device_type=device_type,
device_role=device_role,
site=site
)
# Test default config contexts (created at test setup)
rendered_context = device.get_config_context()
self.assertEqual(rendered_context['foo'], 123)
self.assertEqual(rendered_context['bar'], 456)
self.assertEqual(rendered_context['baz'], 789)
# Add another context specific to the site
configcontext4 = ConfigContext(
name='Test Config Context 4',
data={'site_data': 'ABC'}
)
configcontext4.save()
configcontext4.sites.add(site)
rendered_context = device.get_config_context()
self.assertEqual(rendered_context['site_data'], 'ABC')
# Override one of the default contexts
configcontext5 = ConfigContext(
name='Test Config Context 5',
weight=2000,
data={'foo': 999}
)
configcontext5.save()
configcontext5.sites.add(site)
rendered_context = device.get_config_context()
self.assertEqual(rendered_context['foo'], 999)
# Add a context which does NOT match our device and ensure it does not apply
site2 = Site.objects.create(
name='Test Site 2',
slug='test-site-2'
)
configcontext6 = ConfigContext(
name='Test Config Context 6',
weight=2000,
data={'bar': 999}
)
configcontext6.save()
configcontext6.sites.add(site2)
rendered_context = device.get_config_context()
self.assertEqual(rendered_context['bar'], 456)

View File

@@ -2,18 +2,15 @@ from __future__ import unicode_literals
from datetime import date
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase
from dcim.models import Site
from extras.constants import CF_TYPE_TEXT, CF_TYPE_INTEGER, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_SELECT, CF_TYPE_URL
from extras.models import CustomField, CustomFieldValue, CustomFieldChoice
from users.models import Token
from utilities.tests import HttpStatusMixin
from utilities.testing import APITestCase
class CustomFieldTest(TestCase):
@@ -45,7 +42,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 +70,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
@@ -102,50 +99,48 @@ class CustomFieldTest(TestCase):
cf.delete()
class CustomFieldAPITest(HttpStatusMixin, APITestCase):
class CustomFieldAPITest(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)}
super(CustomFieldAPITest, self).setUp()
content_type = ContentType.objects.get_for_model(Site)
# 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

@@ -0,0 +1,55 @@
from __future__ import unicode_literals
from django.urls import reverse
from rest_framework import status
from dcim.models import Site
from utilities.testing import APITestCase
class TaggedItemTest(APITestCase):
"""
Test the application of Tags to and item (a Site, for example) upon creation (POST) and modification (PATCH).
"""
def setUp(self):
super(TaggedItemTest, self).setUp()
def test_create_tagged_item(self):
data = {
'name': 'Test Site',
'slug': 'test-site',
'tags': ['Foo', 'Bar', 'Baz']
}
url = reverse('dcim-api:site-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(sorted(response.data['tags']), sorted(data['tags']))
site = Site.objects.get(pk=response.data['id'])
tags = [tag.name for tag in site.tags.all()]
self.assertEqual(sorted(tags), sorted(data['tags']))
def test_update_tagged_item(self):
site = Site.objects.create(
name='Test Site',
slug='test-site',
tags=['Foo', 'Bar', 'Baz']
)
data = {
'tags': ['Foo', 'Bar', 'New Tag']
}
url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(sorted(response.data['tags']), sorted(data['tags']))
site = Site.objects.get(pk=response.data['id'])
tags = [tag.name for tag in site.tags.all()]
self.assertEqual(sorted(tags), sorted(data['tags']))

View File

@@ -7,6 +7,20 @@ 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'),
# Config contexts
url(r'^config-contexts/$', views.ConfigContextListView.as_view(), name='configcontext_list'),
url(r'^config-contexts/add/$', views.ConfigContextCreateView.as_view(), name='configcontext_add'),
url(r'^config-contexts/(?P<pk>\d+)/$', views.ConfigContextView.as_view(), name='configcontext'),
url(r'^config-contexts/(?P<pk>\d+)/edit/$', views.ConfigContextEditView.as_view(), name='configcontext_edit'),
url(r'^config-contexts/(?P<pk>\d+)/delete/$', views.ConfigContextDeleteView.as_view(), name='configcontext_delete'),
url(r'^config-contexts/delete/$', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_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'),
@@ -16,4 +30,8 @@ urlpatterns = [
url(r'^reports/(?P<name>[^/]+\.[^/]+)/$', views.ReportView.as_view(), name='report'),
url(r'^reports/(?P<name>[^/]+\.[^/]+)/run/$', views.ReportRunView.as_view(), name='report_run'),
# Change logging
url(r'^changelog/$', views.ObjectChangeListView.as_view(), name='objectchange_list'),
url(r'^changelog/(?P<pk>\d+)/$', views.ObjectChangeView.as_view(), name='objectchange'),
]

View File

@@ -1,17 +1,188 @@
from __future__ import unicode_literals
from django import template
from django.contrib import messages
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.contrib.contenttypes.models import ContentType
from django.db.models import Count, Q
from django.http import Http404
from django.shortcuts import get_object_or_404, redirect, render
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 .models import ImageAttachment, ReportResult, UserAction
from utilities.views import BulkDeleteView, ObjectDeleteView, ObjectEditView, ObjectListView
from . import filters
from .forms import ConfigContextForm, ImageAttachmentForm, ObjectChangeFilterForm, TagForm
from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult
from .reports import get_report, get_reports
from .tables import ConfigContextTable, ObjectChangeTable, 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
default_return_url = 'extras:tag_list'
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'
queryset = Tag.objects.annotate(items=Count('taggit_taggeditem_items')).order_by('name')
table = TagTable
default_return_url = 'extras:tag_list'
#
# Config contexts
#
class ConfigContextListView(ObjectListView):
queryset = ConfigContext.objects.all()
table = ConfigContextTable
template_name = 'extras/configcontext_list.html'
class ConfigContextView(View):
def get(self, request, pk):
configcontext = get_object_or_404(ConfigContext, pk=pk)
return render(request, 'extras/configcontext.html', {
'configcontext': configcontext,
})
class ConfigContextCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'extras.add_configcontext'
model = ConfigContext
model_form = ConfigContextForm
default_return_url = 'extras:configcontext_list'
template_name = 'extras/configcontext_edit.html'
class ConfigContextEditView(ConfigContextCreateView):
permission_required = 'extras.change_configcontext'
class ConfigContextDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'extras.delete_configcontext'
model = ConfigContext
default_return_url = 'extras:configcontext_list'
class ConfigContextBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'extras.delete_cconfigcontext'
queryset = ConfigContext.objects.all()
table = ConfigContextTable
default_return_url = 'extras:configcontext_list'
class ObjectConfigContextView(View):
object_class = None
base_template = None
def get(self, request, pk):
obj = get_object_or_404(self.object_class, pk=pk)
source_contexts = ConfigContext.objects.get_for_object(obj)
return render(request, 'extras/object_configcontext.html', {
self.object_class._meta.model_name: obj,
'rendered_context': obj.get_config_context(),
'source_contexts': source_contexts,
'base_template': self.base_template,
'active_tab': 'config-context',
})
#
# Change logging
#
class ObjectChangeListView(ObjectListView):
queryset = ObjectChange.objects.select_related('user', 'changed_object_type')
filter = filters.ObjectChangeFilter
filter_form = ObjectChangeFilterForm
table = ObjectChangeTable
template_name = 'extras/objectchange_list.html'
class ObjectChangeView(View):
def get(self, request, pk):
objectchange = get_object_or_404(ObjectChange, pk=pk)
related_changes = ObjectChange.objects.filter(request_id=objectchange.request_id).exclude(pk=objectchange.pk)
related_changes_table = ObjectChangeTable(
data=related_changes[:50],
orderable=False
)
return render(request, 'extras/objectchange.html', {
'objectchange': objectchange,
'related_changes_table': related_changes_table,
'related_changes_count': related_changes.count()
})
class ObjectChangeLogView(View):
"""
Present a history of changes made to a particular object.
"""
def get(self, request, model, **kwargs):
# Get object my model and kwargs (e.g. slug='foo')
obj = get_object_or_404(model, **kwargs)
# Gather all changes for this object (and its related objects)
content_type = ContentType.objects.get_for_model(model)
objectchanges = ObjectChange.objects.select_related(
'user', 'changed_object_type'
).filter(
Q(changed_object_type=content_type, changed_object_id=obj.pk) |
Q(related_object_type=content_type, related_object_id=obj.pk)
)
objectchanges_table = ObjectChangeTable(
data=objectchanges,
orderable=False
)
# Check whether a header template exists for this model
base_template = '{}/{}.html'.format(model._meta.app_label, model._meta.model_name)
try:
template.loader.get_template(base_template)
object_var = model._meta.model_name
except template.TemplateDoesNotExist:
base_template = '_base.html'
object_var = 'obj'
return render(request, 'extras/object_changelog.html', {
object_var: obj,
'objectchanges_table': objectchanges_table,
'base_template': base_template,
'active_tab': 'changelog',
})
#
@@ -113,6 +284,5 @@ class ReportRunView(PermissionRequiredMixin, View):
result = 'failed' if report.failed else 'passed'
msg = "Ran report {} ({})".format(report.full_name, result)
messages.success(request, mark_safe(msg))
UserAction.objects.log_create(request.user, report.result, msg)
return redirect('extras:report', name=report.full_name)

59
netbox/extras/webhooks.py Normal file
View File

@@ -0,0 +1,59 @@
import datetime
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from extras.models import Webhook
from extras.constants import OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE
from utilities.api import get_serializer_for_model
from .constants import WEBHOOK_MODELS
def enqueue_webhooks(instance, action):
"""
Find Webhook(s) assigned to this instance + action and enqueue them
to be processed
"""
if not settings.WEBHOOKS_ENABLED or instance._meta.model_name not in WEBHOOK_MODELS:
return
type_create = action == OBJECTCHANGE_ACTION_CREATE
type_update = action == OBJECTCHANGE_ACTION_UPDATE
type_delete = action == OBJECTCHANGE_ACTION_DELETE
# Find assigned webhooks
obj_type = ContentType.objects.get_for_model(instance.__class__)
webhooks = Webhook.objects.filter(
Q(enabled=True) &
(
Q(type_create=type_create) |
Q(type_update=type_update) |
Q(type_delete=type_delete)
) &
Q(obj_type=obj_type)
)
if webhooks:
# Get the Model's API serializer class and serialize the object
serializer_class = get_serializer_for_model(instance.__class__)
serializer_context = {
'request': None,
}
serializer = serializer_class(instance, context=serializer_context)
# We must only import django_rq if the Webhooks feature is enabled.
# Only if we have gotten to ths point, is the feature enabled
from django_rq import get_queue
webhook_queue = get_queue('default')
# enqueue the webhooks:
for webhook in webhooks:
webhook_queue.enqueue(
"extras.webhooks_worker.process_webhook",
webhook,
serializer.data,
instance.__class__,
action,
str(datetime.datetime.now())
)

View File

@@ -0,0 +1,53 @@
import hashlib
import hmac
import requests
import json
from django_rq import job
from rest_framework.utils.encoders import JSONEncoder
from extras.constants import WEBHOOK_CT_JSON, WEBHOOK_CT_X_WWW_FORM_ENCODED, OBJECTCHANGE_ACTION_CHOICES
@job('default')
def process_webhook(webhook, data, model_class, event, timestamp):
"""
Make a POST request to the defined Webhook
"""
payload = {
'event': dict(OBJECTCHANGE_ACTION_CHOICES)[event].lower(),
'timestamp': timestamp,
'model': model_class._meta.model_name,
'data': data
}
headers = {
'Content-Type': webhook.get_http_content_type_display(),
}
params = {
'method': 'POST',
'url': webhook.payload_url,
'headers': headers
}
if webhook.http_content_type == WEBHOOK_CT_JSON:
params.update({'data': json.dumps(payload, cls=JSONEncoder)})
elif webhook.http_content_type == WEBHOOK_CT_X_WWW_FORM_ENCODED:
params.update({'data': payload})
prepared_request = requests.Request(**params).prepare()
if webhook.secret != '':
# sign the request with the secret
hmac_prep = hmac.new(bytearray(webhook.secret, 'utf8'), prepared_request.body, digestmod=hashlib.sha512)
prepared_request.headers['X-Hook-Signature'] = hmac_prep.hexdigest()
with requests.Session() as session:
session.verify = webhook.ssl_verification
response = session.send(prepared_request)
if response.status_code >= 200 and response.status_code <= 299:
return 'Status {} returned, webhook successfully processed.'.format(response.status_code)
else:
raise requests.exceptions.RequestException(
"Status {} returned, webhook FAILED to process.".format(response.status_code)
)

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_serializer.serializers import TaggitSerializer, TagListSerializerField
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 (
ChoiceField, SerializedPKRelatedField, ValidatedModelSerializer, WritableNestedSerializer,
)
from virtualization.api.serializers import NestedVirtualMachineSerializer
@@ -22,18 +25,19 @@ from virtualization.api.serializers import NestedVirtualMachineSerializer
# VRFs
#
class VRFSerializer(CustomFieldModelSerializer):
tenant = NestedTenantSerializer()
class VRFSerializer(TaggitSerializer, CustomFieldModelSerializer):
tenant = NestedTenantSerializer(required=False, allow_null=True)
tags = TagListSerializerField(required=False)
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:
@@ -92,17 +87,20 @@ class NestedRIRSerializer(serializers.ModelSerializer):
# Aggregates
#
class AggregateSerializer(CustomFieldModelSerializer):
class AggregateSerializer(TaggitSerializer, CustomFieldModelSerializer):
rir = NestedRIRSerializer()
tags = TagListSerializerField(required=False)
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()
class VLANSerializer(TaggitSerializer, CustomFieldModelSerializer):
site = NestedSiteSerializer(required=False, allow_null=True)
group = NestedVLANGroupSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True)
status = ChoiceField(choices=VLAN_STATUS_CHOICES, required=False)
role = NestedRoleSerializer(required=False, allow_null=True)
tags = TagListSerializerField(required=False)
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()
class PrefixSerializer(TaggitSerializer, CustomFieldModelSerializer):
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 = ChoiceField(choices=PREFIX_STATUS_CHOICES, required=False)
role = NestedRoleSerializer(required=False, allow_null=True)
tags = TagListSerializerField(required=False)
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):
@@ -267,10 +234,10 @@ class AvailablePrefixSerializer(serializers.Serializer):
# IP addresses
#
class IPAddressInterfaceSerializer(serializers.ModelSerializer):
class IPAddressInterfaceSerializer(WritableNestedSerializer):
url = serializers.SerializerMethodField() # We're imitating a HyperlinkedIdentityField here
device = NestedDeviceSerializer()
virtual_machine = NestedVirtualMachineSerializer()
device = NestedDeviceSerializer(read_only=True)
virtual_machine = NestedVirtualMachineSerializer(read_only=True)
class Meta(InterfaceSerializer.Meta):
model = Interface
@@ -287,22 +254,24 @@ class IPAddressInterfaceSerializer(serializers.ModelSerializer):
return reverse(url_name, kwargs={'pk': obj.pk}, request=self.context['request'])
class IPAddressSerializer(CustomFieldModelSerializer):
vrf = NestedVRFSerializer()
tenant = NestedTenantSerializer()
status = ChoiceFieldSerializer(choices=IPADDRESS_STATUS_CHOICES)
role = ChoiceFieldSerializer(choices=IPADDRESS_ROLE_CHOICES)
interface = IPAddressInterfaceSerializer()
class IPAddressSerializer(TaggitSerializer, CustomFieldModelSerializer):
vrf = NestedVRFSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True)
status = ChoiceField(choices=IPADDRESS_STATUS_CHOICES, required=False)
role = ChoiceField(choices=IPADDRESS_ROLE_CHOICES, required=False)
interface = IPAddressInterfaceSerializer(required=False, allow_null=True)
tags = TagListSerializerField(required=False)
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,26 +301,20 @@ class AvailableIPSerializer(serializers.Serializer):
# Services
#
class ServiceSerializer(serializers.ModelSerializer):
device = NestedDeviceSerializer()
virtual_machine = NestedVirtualMachineSerializer()
protocol = ChoiceFieldSerializer(choices=IP_PROTOCOL_CHOICES)
ipaddresses = NestedIPAddressSerializer(many=True)
class ServiceSerializer(CustomFieldModelSerializer):
device = NestedDeviceSerializer(required=False, allow_null=True)
virtual_machine = NestedVirtualMachineSerializer(required=False, allow_null=True)
protocol = ChoiceField(choices=IP_PROTOCOL_CHOICES)
ipaddresses = SerializedPKRelatedField(
queryset=IPAddress.objects.all(),
serializer=NestedIPAddressSerializer,
required=False,
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):
class Meta:
model = Service
fields = [
'id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description', 'created',
'last_updated',
'id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description',
'custom_fields', 'created', 'last_updated',
]

View File

@@ -3,8 +3,8 @@ from __future__ import unicode_literals
from django.conf import settings
from django.shortcuts import get_object_or_404
from rest_framework import status
from rest_framework.decorators import detail_route
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
from rest_framework.response import Response
from extras.api.views import CustomFieldModelViewSet
@@ -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,10 +75,9 @@ 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'])
@action(detail=True, url_path='available-prefixes', methods=['get', 'post'])
def available_prefixes(self, request, pk=None):
"""
A convenience method for returning available child prefixes within a parent.
@@ -144,9 +141,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():
@@ -164,7 +161,7 @@ class PrefixViewSet(CustomFieldModelViewSet):
return Response(serializer.data)
@detail_route(url_path='available-ips', methods=['get', 'post'])
@action(detail=True, url_path='available-ips', methods=['get', 'post'])
def available_ips(self, request, pk=None):
"""
A convenience method for returning available IP addresses within a prefix. By default, the number of IPs
@@ -203,9 +200,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():
@@ -249,7 +246,6 @@ class IPAddressViewSet(CustomFieldModelViewSet):
'nat_outside'
)
serializer_class = serializers.IPAddressSerializer
write_serializer_class = serializers.WritableIPAddressSerializer
filter_class = filters.IPAddressFilter
@@ -260,7 +256,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
@@ -271,7 +266,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
@@ -282,5 +276,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

@@ -31,6 +31,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():
@@ -70,6 +73,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
@@ -168,6 +174,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
@@ -294,6 +303,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
@@ -410,6 +422,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
@@ -427,6 +442,10 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
class ServiceFilter(django_filters.FilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
device_id = django_filters.ModelMultipleChoiceFilter(
queryset=Device.objects.all(),
label='Device (ID)',
@@ -447,7 +466,16 @@ class ServiceFilter(django_filters.FilterSet):
to_field_name='name',
label='Virtual machine (name)',
)
tag = django_filters.CharFilter(
name='tags__slug',
)
class Meta:
model = Service
fields = ['name', 'protocol', 'port']
def search(self, queryset, name, value):
if not value.strip():
return queryset
qs_filter = Q(name__icontains=value) | Q(description__icontains=value)
return queryset.filter(qs_filter)

View File

@@ -2,10 +2,12 @@ from __future__ import unicode_literals
from django import forms
from django.core.exceptions import MultipleObjectsReturned
from django.core.validators import MaxValueValidator, MinValueValidator
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
from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
from tenancy.forms import TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
@@ -14,7 +16,9 @@ from utilities.forms import (
SlugField, add_blank_choice,
)
from virtualization.models import VirtualMachine
from .constants import IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, PREFIX_STATUS_CHOICES, VLAN_STATUS_CHOICES
from .constants import (
IP_PROTOCOL_CHOICES, IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, PREFIX_STATUS_CHOICES, VLAN_STATUS_CHOICES,
)
from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
IP_FAMILY_CHOICES = [
@@ -32,10 +36,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",
}
@@ -63,7 +68,7 @@ class VRFCSVForm(forms.ModelForm):
}
class VRFBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
class VRFBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=VRF.objects.all(), widget=forms.MultipleHiddenInput)
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
enforce_unique = forms.NullBooleanField(
@@ -121,10 +126,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",
@@ -147,7 +153,7 @@ class AggregateCSVForm(forms.ModelForm):
fields = Aggregate.csv_headers
class AggregateBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
class AggregateBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Aggregate.objects.all(), widget=forms.MultipleHiddenInput)
rir = forms.ModelChoiceField(queryset=RIR.objects.all(), required=False, label='RIR')
date_added = forms.DateField(required=False)
@@ -228,10 +234,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):
@@ -336,7 +346,7 @@ class PrefixCSVForm(forms.ModelForm):
raise forms.ValidationError("Multiple VLANs with VID {} found".format(vlan_vid))
class PrefixBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Prefix.objects.all(), widget=forms.MultipleHiddenInput)
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False)
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF')
@@ -455,12 +465,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):
@@ -667,7 +678,7 @@ class IPAddressCSVForm(forms.ModelForm):
return ipaddress
class IPAddressBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=IPAddress.objects.all(), widget=forms.MultipleHiddenInput)
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF')
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
@@ -780,10 +791,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)",
@@ -857,7 +869,7 @@ class VLANCSVForm(forms.ModelForm):
raise forms.ValidationError("Global VLAN group {} not found".format(group_name))
class VLANBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=VLAN.objects.all(), widget=forms.MultipleHiddenInput)
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False)
group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False)
@@ -905,11 +917,12 @@ class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm):
# Services
#
class ServiceForm(BootstrapMixin, forms.ModelForm):
class ServiceForm(BootstrapMixin, CustomFieldForm):
tags = TagField(required=False)
class Meta:
model = Service
fields = ['name', 'protocol', 'port', 'ipaddresses', 'description']
fields = ['name', 'protocol', 'port', 'ipaddresses', 'description', 'tags']
help_texts = {
'ipaddresses': "IP address assignment is optional. If no IPs are selected, the service is assumed to be "
"reachable via all IPs assigned to the device.",
@@ -931,3 +944,28 @@ class ServiceForm(BootstrapMixin, forms.ModelForm):
)
else:
self.fields['ipaddresses'].choices = []
class ServiceFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Service
q = forms.CharField(
required=False,
label='Search'
)
protocol = forms.ChoiceField(
choices=add_blank_choice(IP_PROTOCOL_CHOICES),
required=False
)
port = forms.IntegerField(
required=False
)
class ServiceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Service.objects.all(), widget=forms.MultipleHiddenInput)
protocol = forms.ChoiceField(choices=add_blank_choice(IP_PROTOCOL_CHOICES), required=False)
port = forms.IntegerField(validators=[MinValueValidator(1), MaxValueValidator(65535)], required=False)
description = forms.CharField(max_length=100, required=False)
class Meta:
nullable_fields = ['site', 'group', 'tenant', 'role', 'description']

View File

@@ -0,0 +1,240 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.14 on 2018-07-31 02:12
from __future__ import unicode_literals
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import ipam.fields
class Migration(migrations.Migration):
replaces = [('ipam', '0002_vrf_add_enforce_unique'), ('ipam', '0003_ipam_add_vlangroups'), ('ipam', '0004_ipam_vlangroup_uniqueness'), ('ipam', '0005_auto_20160725_1842'), ('ipam', '0006_vrf_vlan_add_tenant'), ('ipam', '0007_prefix_ipaddress_add_tenant'), ('ipam', '0008_prefix_change_order'), ('ipam', '0009_ipaddress_add_status'), ('ipam', '0010_ipaddress_help_texts'), ('ipam', '0011_rir_add_is_private'), ('ipam', '0012_services'), ('ipam', '0013_prefix_add_is_pool'), ('ipam', '0014_ipaddress_status_add_deprecated'), ('ipam', '0015_global_vlans'), ('ipam', '0016_unicode_literals'), ('ipam', '0017_ipaddress_roles'), ('ipam', '0018_remove_service_uniqueness_constraint')]
dependencies = [
('dcim', '0010_devicebay_installed_device_set_null'),
('dcim', '0022_color_names_to_rgb'),
('tenancy', '0001_initial'),
('ipam', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='vrf',
name='enforce_unique',
field=models.BooleanField(default=True, help_text='Prevent duplicate prefixes/IP addresses within this VRF', verbose_name='Enforce unique space'),
),
migrations.CreateModel(
name='VLANGroup',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50)),
('slug', models.SlugField()),
('site', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vlan_groups', to='dcim.Site')),
],
options={
'ordering': ['site', 'name'],
},
),
migrations.AddField(
model_name='vlan',
name='group',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vlans', to='ipam.VLANGroup'),
),
migrations.AlterUniqueTogether(
name='vlangroup',
unique_together=set([('site', 'slug'), ('site', 'name')]),
),
migrations.AlterModelOptions(
name='vlan',
options={'ordering': ['site', 'group', 'vid'], 'verbose_name': 'VLAN', 'verbose_name_plural': 'VLANs'},
),
migrations.AlterModelOptions(
name='vlangroup',
options={'ordering': ['site', 'name'], 'verbose_name': 'VLAN group', 'verbose_name_plural': 'VLAN groups'},
),
migrations.AddField(
model_name='vlan',
name='description',
field=models.CharField(blank=True, max_length=100),
),
migrations.AlterUniqueTogether(
name='vlan',
unique_together=set([('group', 'vid'), ('group', 'name')]),
),
migrations.AlterField(
model_name='vlan',
name='name',
field=models.CharField(max_length=64),
),
migrations.AddField(
model_name='vlan',
name='tenant',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vlans', to='tenancy.Tenant'),
),
migrations.AddField(
model_name='vrf',
name='tenant',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vrfs', to='tenancy.Tenant'),
),
migrations.AddField(
model_name='ipaddress',
name='tenant',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='ip_addresses', to='tenancy.Tenant'),
),
migrations.AddField(
model_name='prefix',
name='tenant',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='prefixes', to='tenancy.Tenant'),
),
migrations.AlterModelOptions(
name='prefix',
options={'ordering': ['vrf', 'family', 'prefix'], 'verbose_name_plural': 'prefixes'},
),
migrations.AddField(
model_name='ipaddress',
name='status',
field=models.PositiveSmallIntegerField(choices=[(1, 'Active'), (2, 'Reserved'), (3, 'Deprecated'), (5, 'DHCP')], default=1, help_text='The operational status of this IP', verbose_name='Status'),
),
migrations.AlterField(
model_name='ipaddress',
name='address',
field=ipam.fields.IPAddressField(help_text=b'IPv4 or IPv6 address (with mask)'),
),
migrations.AlterField(
model_name='ipaddress',
name='nat_inside',
field=models.OneToOneField(blank=True, help_text=b'The IP for which this address is the "outside" IP', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='nat_outside', to='ipam.IPAddress', verbose_name=b'NAT (Inside)'),
),
migrations.AddField(
model_name='rir',
name='is_private',
field=models.BooleanField(default=False, help_text='IP space managed by this RIR is considered private', verbose_name='Private'),
),
migrations.CreateModel(
name='Service',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateField(auto_now_add=True)),
('last_updated', models.DateTimeField(auto_now=True)),
('name', models.CharField(max_length=30)),
('protocol', models.PositiveSmallIntegerField(choices=[(6, 'TCP'), (17, 'UDP')])),
('port', models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(65535)], verbose_name='Port number')),
('description', models.CharField(blank=True, max_length=100)),
('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='services', to='dcim.Device', verbose_name='device')),
('ipaddresses', models.ManyToManyField(blank=True, related_name='services', to='ipam.IPAddress', verbose_name='IP addresses')),
],
options={
'ordering': ['device', 'protocol', 'port'],
},
),
migrations.AlterUniqueTogether(
name='service',
unique_together=set([('device', 'protocol', 'port')]),
),
migrations.AddField(
model_name='prefix',
name='is_pool',
field=models.BooleanField(default=False, help_text='All IP addresses within this prefix are considered usable', verbose_name='Is a pool'),
),
migrations.AlterField(
model_name='prefix',
name='prefix',
field=ipam.fields.IPNetworkField(help_text=b'IPv4 or IPv6 network with mask'),
),
migrations.AlterField(
model_name='prefix',
name='role',
field=models.ForeignKey(blank=True, help_text=b'The primary function of this prefix', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='prefixes', to='ipam.Role'),
),
migrations.AlterField(
model_name='vlan',
name='site',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vlans', to='dcim.Site'),
),
migrations.AlterField(
model_name='aggregate',
name='family',
field=models.PositiveSmallIntegerField(choices=[(4, 'IPv4'), (6, 'IPv6')]),
),
migrations.AlterField(
model_name='aggregate',
name='rir',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='aggregates', to='ipam.RIR', verbose_name='RIR'),
),
migrations.AlterField(
model_name='ipaddress',
name='address',
field=ipam.fields.IPAddressField(help_text='IPv4 or IPv6 address (with mask)'),
),
migrations.AlterField(
model_name='ipaddress',
name='family',
field=models.PositiveSmallIntegerField(choices=[(4, 'IPv4'), (6, 'IPv6')], editable=False),
),
migrations.AlterField(
model_name='ipaddress',
name='nat_inside',
field=models.OneToOneField(blank=True, help_text='The IP for which this address is the "outside" IP', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='nat_outside', to='ipam.IPAddress', verbose_name='NAT (Inside)'),
),
migrations.AlterField(
model_name='ipaddress',
name='vrf',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='ip_addresses', to='ipam.VRF', verbose_name='VRF'),
),
migrations.AlterField(
model_name='prefix',
name='family',
field=models.PositiveSmallIntegerField(choices=[(4, 'IPv4'), (6, 'IPv6')], editable=False),
),
migrations.AlterField(
model_name='prefix',
name='prefix',
field=ipam.fields.IPNetworkField(help_text='IPv4 or IPv6 network with mask'),
),
migrations.AlterField(
model_name='prefix',
name='role',
field=models.ForeignKey(blank=True, help_text='The primary function of this prefix', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='prefixes', to='ipam.Role'),
),
migrations.AlterField(
model_name='prefix',
name='status',
field=models.PositiveSmallIntegerField(choices=[(0, 'Container'), (1, 'Active'), (2, 'Reserved'), (3, 'Deprecated')], default=1, help_text='Operational status of this prefix', verbose_name='Status'),
),
migrations.AlterField(
model_name='prefix',
name='vlan',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='prefixes', to='ipam.VLAN', verbose_name='VLAN'),
),
migrations.AlterField(
model_name='prefix',
name='vrf',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='prefixes', to='ipam.VRF', verbose_name='VRF'),
),
migrations.AlterField(
model_name='vlan',
name='status',
field=models.PositiveSmallIntegerField(choices=[(1, 'Active'), (2, 'Reserved'), (3, 'Deprecated')], default=1, verbose_name='Status'),
),
migrations.AlterField(
model_name='vlan',
name='vid',
field=models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(4094)], verbose_name='ID'),
),
migrations.AlterField(
model_name='vrf',
name='rd',
field=models.CharField(max_length=21, unique=True, verbose_name='Route distinguisher'),
),
migrations.AddField(
model_name='ipaddress',
name='role',
field=models.PositiveSmallIntegerField(blank=True, choices=[(10, 'Loopback'), (20, 'Secondary'), (30, 'Anycast'), (40, 'VIP'), (41, 'VRRP'), (42, 'HSRP'), (43, 'GLBP')], help_text='The functional role of this IP', null=True, verbose_name='Role'),
),
migrations.AlterUniqueTogether(
name='service',
unique_together=set([]),
),
]

View File

@@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.14 on 2018-07-31 02:14
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
replaces = [('ipam', '0019_virtualization'), ('ipam', '0020_ipaddress_add_role_carp')]
dependencies = [
('ipam', '0018_remove_service_uniqueness_constraint'),
('virtualization', '0001_virtualization'),
]
operations = [
migrations.AlterModelOptions(
name='service',
options={'ordering': ['protocol', 'port']},
),
migrations.AddField(
model_name='service',
name='virtual_machine',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='services', to='virtualization.VirtualMachine'),
),
migrations.AlterField(
model_name='service',
name='device',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='services', to='dcim.Device', verbose_name='device'),
),
migrations.AlterField(
model_name='ipaddress',
name='role',
field=models.PositiveSmallIntegerField(blank=True, choices=[(10, 'Loopback'), (20, 'Secondary'), (30, 'Anycast'), (40, 'VIP'), (41, 'VRRP'), (42, 'HSRP'), (43, 'GLBP'), (44, 'CARP')], help_text='The functional role of this IP', null=True, verbose_name='Role'),
),
]

View File

@@ -0,0 +1,47 @@
# -*- 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='service',
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

@@ -0,0 +1,105 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.12 on 2018-06-13 17:14
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ipam', '0022_tags'),
]
operations = [
migrations.AddField(
model_name='rir',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name='rir',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AddField(
model_name='role',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name='role',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AddField(
model_name='vlangroup',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name='vlangroup',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AlterField(
model_name='aggregate',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AlterField(
model_name='aggregate',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AlterField(
model_name='ipaddress',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AlterField(
model_name='ipaddress',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AlterField(
model_name='prefix',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AlterField(
model_name='prefix',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AlterField(
model_name='service',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AlterField(
model_name='service',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AlterField(
model_name='vlan',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AlterField(
model_name='vlan',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AlterField(
model_name='vrf',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AlterField(
model_name='vrf',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
]

View File

@@ -10,30 +10,54 @@ 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 utilities.models import CreatedUpdatedModel
from extras.models import CustomFieldModel
from utilities.models import ChangeLoggedModel
from .constants import *
from .fields import IPNetworkField, IPAddressField
from .querysets import PrefixQuerySet
@python_2_unicode_compatible
class VRF(CreatedUpdatedModel, CustomFieldModel):
class VRF(ChangeLoggedModel, CustomFieldModel):
"""
A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing
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']
@@ -65,15 +89,23 @@ class VRF(CreatedUpdatedModel, CustomFieldModel):
@python_2_unicode_compatible
class RIR(models.Model):
class RIR(ChangeLoggedModel):
"""
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']
@@ -97,17 +129,36 @@ class RIR(models.Model):
@python_2_unicode_compatible
class Aggregate(CreatedUpdatedModel, CustomFieldModel):
class Aggregate(ChangeLoggedModel, 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']
@@ -173,14 +224,21 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel):
@python_2_unicode_compatible
class Role(models.Model):
class Role(ChangeLoggedModel):
"""
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']
@@ -199,30 +257,80 @@ class Role(models.Model):
@python_2_unicode_compatible
class Prefix(CreatedUpdatedModel, CustomFieldModel):
class Prefix(ChangeLoggedModel, CustomFieldModel):
"""
A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and
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',
@@ -389,7 +497,7 @@ class IPAddressManager(models.Manager):
@python_2_unicode_compatible
class IPAddress(CreatedUpdatedModel, CustomFieldModel):
class IPAddress(ChangeLoggedModel, CustomFieldModel):
"""
An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is
configured in the real world. (Typically, only loopback interfaces are configured with /32 or /128 masks.) Like
@@ -400,27 +508,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',
@@ -505,13 +655,21 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
@python_2_unicode_compatible
class VLANGroup(models.Model):
class VLANGroup(ChangeLoggedModel):
"""
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']
@@ -549,7 +707,7 @@ class VLANGroup(models.Model):
@python_2_unicode_compatible
class VLAN(CreatedUpdatedModel, CustomFieldModel):
class VLAN(ChangeLoggedModel, CustomFieldModel):
"""
A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned
to a Site, however VLAN IDs need not be unique within a Site. A VLAN may optionally be assigned to a VLANGroup,
@@ -558,18 +716,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']
@@ -626,7 +823,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
@python_2_unicode_compatible
class Service(CreatedUpdatedModel):
class Service(ChangeLoggedModel, CustomFieldModel):
"""
A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may
optionally be tied to one or more specific IPAddresses belonging to its parent.
@@ -666,6 +863,15 @@ class Service(CreatedUpdatedModel):
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 = ['device', 'virtual_machine', 'name', 'protocol', 'description']
class Meta:
ordering = ['protocol', 'port']
@@ -673,6 +879,9 @@ class Service(CreatedUpdatedModel):
def __str__(self):
return '{} ({}/{})'.format(self.name, self.port, self.get_protocol_display())
def get_absolute_url(self):
return reverse('ipam:service', args=[self.pk])
@property
def parent(self):
return self.device or self.virtual_machine
@@ -684,3 +893,13 @@ class Service(CreatedUpdatedModel):
raise ValidationError("A service cannot be associated with both a device and a virtual machine.")
if not self.device and not self.virtual_machine:
raise ValidationError("A service must be associated with either a device or a virtual machine.")
def to_csv(self):
return (
self.device.name if self.device else None,
self.virtual_machine.name if self.virtual_machine else None,
self.name,
self.get_protocol_display(),
self.port,
self.description,
)

View File

@@ -5,8 +5,8 @@ from django_tables2.utils import Accessor
from dcim.models import Interface
from tenancy.tables import COL_TENANT
from utilities.tables import BaseTable, ToggleColumn
from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF
from utilities.tables import BaseTable, BooleanColumn, ToggleColumn
from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
RIR_UTILIZATION = """
<div class="progress">
@@ -28,6 +28,9 @@ RIR_UTILIZATION = """
"""
RIR_ACTIONS = """
<a href="{% url 'ipam:rir_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
<i class="fa fa-history"></i>
</a>
{% if perms.ipam.change_rir %}
<a href="{% url 'ipam:rir_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
@@ -47,6 +50,9 @@ ROLE_VLAN_COUNT = """
"""
ROLE_ACTIONS = """
<a href="{% url 'ipam:role_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
<i class="fa fa-history"></i>
</a>
{% if perms.ipam.change_role %}
<a href="{% url 'ipam:role_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
@@ -137,6 +143,9 @@ VLAN_ROLE_LINK = """
"""
VLANGROUP_ACTIONS = """
<a href="{% url 'ipam:vlangroup_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Changelog">
<i class="fa fa-history"></i>
</a>
{% with next_vid=record.get_next_available_vid %}
{% if next_vid and perms.ipam.add_vlan %}
<a href="{% url 'ipam:vlan_add' %}?site={{ record.site_id }}&group={{ record.pk }}&vid={{ next_vid }}" title="Add VLAN" class="btn btn-xs btn-success">
@@ -194,7 +203,7 @@ class VRFTable(BaseTable):
class RIRTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn(verbose_name='Name')
is_private = tables.BooleanColumn(verbose_name='Private')
is_private = BooleanColumn(verbose_name='Private')
aggregate_count = tables.Column(verbose_name='Aggregates')
actions = tables.TemplateColumn(template_code=RIR_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='')
@@ -361,6 +370,20 @@ class IPAddressAssignTable(BaseTable):
orderable = False
class InterfaceIPAddressTable(BaseTable):
"""
List IP addresses assigned to a specific Interface.
"""
address = tables.TemplateColumn(IPADDRESS_ASSIGN_LINK, verbose_name='IP Address')
vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
status = tables.TemplateColumn(STATUS_LABEL)
tenant = tables.TemplateColumn(template_code=TENANT_LINK)
class Meta(BaseTable.Meta):
model = IPAddress
fields = ('address', 'vrf', 'status', 'role', 'tenant', 'description')
#
# VLAN groups
#
@@ -423,3 +446,40 @@ class VLANMemberTable(BaseTable):
class Meta(BaseTable.Meta):
model = Interface
fields = ('parent', 'name', 'untagged', 'actions')
class InterfaceVLANTable(BaseTable):
"""
List VLANs assigned to a specific Interface.
"""
vid = tables.LinkColumn('ipam:vlan', args=[Accessor('pk')], verbose_name='ID')
tagged = BooleanColumn()
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
tenant = tables.TemplateColumn(template_code=COL_TENANT)
status = tables.TemplateColumn(STATUS_LABEL)
role = tables.TemplateColumn(VLAN_ROLE_LINK)
class Meta(BaseTable.Meta):
model = VLAN
fields = ('vid', 'tagged', 'site', 'group', 'name', 'tenant', 'status', 'role', 'description')
def __init__(self, interface, *args, **kwargs):
self.interface = interface
super(InterfaceVLANTable, self).__init__(*args, **kwargs)
#
# Services
#
class ServiceTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn(
viewname='ipam:service',
args=[Accessor('pk')]
)
class Meta(BaseTable.Meta):
model = Service
fields = ('pk', 'name', 'parent', 'protocol', 'port', 'description')

View File

@@ -1,25 +1,20 @@
from __future__ import unicode_literals
from django.contrib.auth.models import User
from django.urls import reverse
from netaddr import IPNetwork
from rest_framework import status
from rest_framework.test import APITestCase
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
from ipam.constants import IP_PROTOCOL_TCP, IP_PROTOCOL_UDP
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
from users.models import Token
from utilities.tests import HttpStatusMixin
from utilities.testing import APITestCase
class VRFTest(HttpStatusMixin, APITestCase):
class VRFTest(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)}
super(VRFTest, self).setUp()
self.vrf1 = VRF.objects.create(name='Test VRF 1', rd='65000:1')
self.vrf2 = VRF.objects.create(name='Test VRF 2', rd='65000:2')
@@ -106,13 +101,11 @@ class VRFTest(HttpStatusMixin, APITestCase):
self.assertEqual(VRF.objects.count(), 2)
class RIRTest(HttpStatusMixin, APITestCase):
class RIRTest(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)}
super(RIRTest, self).setUp()
self.rir1 = RIR.objects.create(name='Test RIR 1', slug='test-rir-1')
self.rir2 = RIR.objects.create(name='Test RIR 2', slug='test-rir-2')
@@ -199,13 +192,11 @@ class RIRTest(HttpStatusMixin, APITestCase):
self.assertEqual(RIR.objects.count(), 2)
class AggregateTest(HttpStatusMixin, APITestCase):
class AggregateTest(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)}
super(AggregateTest, self).setUp()
self.rir1 = RIR.objects.create(name='Test RIR 1', slug='test-rir-1')
self.rir2 = RIR.objects.create(name='Test RIR 2', slug='test-rir-2')
@@ -294,13 +285,11 @@ class AggregateTest(HttpStatusMixin, APITestCase):
self.assertEqual(Aggregate.objects.count(), 2)
class RoleTest(HttpStatusMixin, APITestCase):
class RoleTest(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)}
super(RoleTest, self).setUp()
self.role1 = Role.objects.create(name='Test Role 1', slug='test-role-1')
self.role2 = Role.objects.create(name='Test Role 2', slug='test-role-2')
@@ -387,13 +376,11 @@ class RoleTest(HttpStatusMixin, APITestCase):
self.assertEqual(Role.objects.count(), 2)
class PrefixTest(HttpStatusMixin, APITestCase):
class PrefixTest(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)}
super(PrefixTest, self).setUp()
self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
self.vrf1 = VRF.objects.create(name='Test VRF 1', rd='65000:1')
@@ -614,13 +601,11 @@ class PrefixTest(HttpStatusMixin, APITestCase):
self.assertEqual(len(response.data), 8)
class IPAddressTest(HttpStatusMixin, APITestCase):
class IPAddressTest(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)}
super(IPAddressTest, self).setUp()
self.vrf1 = VRF.objects.create(name='Test VRF 1', rd='65000:1')
self.ipaddress1 = IPAddress.objects.create(address=IPNetwork('192.168.0.1/24'))
@@ -705,13 +690,11 @@ class IPAddressTest(HttpStatusMixin, APITestCase):
self.assertEqual(IPAddress.objects.count(), 2)
class VLANGroupTest(HttpStatusMixin, APITestCase):
class VLANGroupTest(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)}
super(VLANGroupTest, self).setUp()
self.vlangroup1 = VLANGroup.objects.create(name='Test VLAN Group 1', slug='test-vlan-group-1')
self.vlangroup2 = VLANGroup.objects.create(name='Test VLAN Group 2', slug='test-vlan-group-2')
@@ -798,13 +781,11 @@ class VLANGroupTest(HttpStatusMixin, APITestCase):
self.assertEqual(VLANGroup.objects.count(), 2)
class VLANTest(HttpStatusMixin, APITestCase):
class VLANTest(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)}
super(VLANTest, self).setUp()
self.vlan1 = VLAN.objects.create(vid=1, name='Test VLAN 1')
self.vlan2 = VLAN.objects.create(vid=2, name='Test VLAN 2')
@@ -891,13 +872,11 @@ class VLANTest(HttpStatusMixin, APITestCase):
self.assertEqual(VLAN.objects.count(), 2)
class ServiceTest(HttpStatusMixin, APITestCase):
class ServiceTest(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)}
super(ServiceTest, self).setUp()
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')

View File

@@ -2,7 +2,9 @@ from __future__ import unicode_literals
from django.conf.urls import url
from extras.views import ObjectChangeLogView
from . import views
from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
app_name = 'ipam'
@@ -17,6 +19,7 @@ urlpatterns = [
url(r'^vrfs/(?P<pk>\d+)/$', views.VRFView.as_view(), name='vrf'),
url(r'^vrfs/(?P<pk>\d+)/edit/$', views.VRFEditView.as_view(), name='vrf_edit'),
url(r'^vrfs/(?P<pk>\d+)/delete/$', views.VRFDeleteView.as_view(), name='vrf_delete'),
url(r'^vrfs/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='vrf_changelog', kwargs={'model': VRF}),
# RIRs
url(r'^rirs/$', views.RIRListView.as_view(), name='rir_list'),
@@ -24,6 +27,7 @@ urlpatterns = [
url(r'^rirs/import/$', views.RIRBulkImportView.as_view(), name='rir_import'),
url(r'^rirs/delete/$', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'),
url(r'^rirs/(?P<slug>[\w-]+)/edit/$', views.RIREditView.as_view(), name='rir_edit'),
url(r'^vrfs/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='rir_changelog', kwargs={'model': RIR}),
# Aggregates
url(r'^aggregates/$', views.AggregateListView.as_view(), name='aggregate_list'),
@@ -34,6 +38,7 @@ urlpatterns = [
url(r'^aggregates/(?P<pk>\d+)/$', views.AggregateView.as_view(), name='aggregate'),
url(r'^aggregates/(?P<pk>\d+)/edit/$', views.AggregateEditView.as_view(), name='aggregate_edit'),
url(r'^aggregates/(?P<pk>\d+)/delete/$', views.AggregateDeleteView.as_view(), name='aggregate_delete'),
url(r'^aggregates/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='aggregate_changelog', kwargs={'model': Aggregate}),
# Roles
url(r'^roles/$', views.RoleListView.as_view(), name='role_list'),
@@ -41,6 +46,7 @@ urlpatterns = [
url(r'^roles/import/$', views.RoleBulkImportView.as_view(), name='role_import'),
url(r'^roles/delete/$', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'),
url(r'^roles/(?P<slug>[\w-]+)/edit/$', views.RoleEditView.as_view(), name='role_edit'),
url(r'^roles/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='role_changelog', kwargs={'model': Role}),
# Prefixes
url(r'^prefixes/$', views.PrefixListView.as_view(), name='prefix_list'),
@@ -51,6 +57,7 @@ urlpatterns = [
url(r'^prefixes/(?P<pk>\d+)/$', views.PrefixView.as_view(), name='prefix'),
url(r'^prefixes/(?P<pk>\d+)/edit/$', views.PrefixEditView.as_view(), name='prefix_edit'),
url(r'^prefixes/(?P<pk>\d+)/delete/$', views.PrefixDeleteView.as_view(), name='prefix_delete'),
url(r'^prefixes/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='prefix_changelog', kwargs={'model': Prefix}),
url(r'^prefixes/(?P<pk>\d+)/prefixes/$', views.PrefixPrefixesView.as_view(), name='prefix_prefixes'),
url(r'^prefixes/(?P<pk>\d+)/ip-addresses/$', views.PrefixIPAddressesView.as_view(), name='prefix_ipaddresses'),
@@ -61,6 +68,7 @@ urlpatterns = [
url(r'^ip-addresses/import/$', views.IPAddressBulkImportView.as_view(), name='ipaddress_import'),
url(r'^ip-addresses/edit/$', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'),
url(r'^ip-addresses/delete/$', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'),
url(r'^ip-addresses/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='ipaddress_changelog', kwargs={'model': IPAddress}),
url(r'^ip-addresses/assign/$', views.IPAddressAssignView.as_view(), name='ipaddress_assign'),
url(r'^ip-addresses/(?P<pk>\d+)/$', views.IPAddressView.as_view(), name='ipaddress'),
url(r'^ip-addresses/(?P<pk>\d+)/edit/$', views.IPAddressEditView.as_view(), name='ipaddress_edit'),
@@ -73,6 +81,7 @@ urlpatterns = [
url(r'^vlan-groups/delete/$', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'),
url(r'^vlan-groups/(?P<pk>\d+)/edit/$', views.VLANGroupEditView.as_view(), name='vlangroup_edit'),
url(r'^vlan-groups/(?P<pk>\d+)/vlans/$', views.VLANGroupVLANsView.as_view(), name='vlangroup_vlans'),
url(r'^vlan-groups/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='vlangroup_changelog', kwargs={'model': VLANGroup}),
# VLANs
url(r'^vlans/$', views.VLANListView.as_view(), name='vlan_list'),
@@ -84,9 +93,15 @@ urlpatterns = [
url(r'^vlans/(?P<pk>\d+)/members/$', views.VLANMembersView.as_view(), name='vlan_members'),
url(r'^vlans/(?P<pk>\d+)/edit/$', views.VLANEditView.as_view(), name='vlan_edit'),
url(r'^vlans/(?P<pk>\d+)/delete/$', views.VLANDeleteView.as_view(), name='vlan_delete'),
url(r'^vlans/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}),
# Services
url(r'^services/$', views.ServiceListView.as_view(), name='service_list'),
url(r'^services/edit/$', views.ServiceBulkEditView.as_view(), name='service_bulk_edit'),
url(r'^services/delete/$', views.ServiceBulkDeleteView.as_view(), name='service_bulk_delete'),
url(r'^services/(?P<pk>\d+)/$', views.ServiceView.as_view(), name='service'),
url(r'^services/(?P<pk>\d+)/edit/$', views.ServiceEditView.as_view(), name='service_edit'),
url(r'^services/(?P<pk>\d+)/delete/$', views.ServiceDeleteView.as_view(), name='service_delete'),
url(r'^services/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}),
]

View File

@@ -5,7 +5,6 @@ from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db.models import Count, Q
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.views.generic import View
from django_tables2 import RequestConfig
@@ -167,7 +166,6 @@ class VRFBulkImportView(PermissionRequiredMixin, BulkImportView):
class VRFBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'ipam.change_vrf'
cls = VRF
queryset = VRF.objects.select_related('tenant')
filter = filters.VRFFilter
table = tables.VRFTable
@@ -177,7 +175,6 @@ class VRFBulkEditView(PermissionRequiredMixin, BulkEditView):
class VRFBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_vrf'
cls = VRF
queryset = VRF.objects.select_related('tenant')
filter = filters.VRFFilter
table = tables.VRFTable
@@ -268,9 +265,7 @@ class RIRCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.add_rir'
model = RIR
model_form = forms.RIRForm
def get_return_url(self, request, obj):
return reverse('ipam:rir_list')
default_return_url = 'ipam:rir_list'
class RIREditView(RIRCreateView):
@@ -286,7 +281,6 @@ class RIRBulkImportView(PermissionRequiredMixin, BulkImportView):
class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_rir'
cls = RIR
queryset = RIR.objects.annotate(aggregate_count=Count('aggregates'))
filter = filters.RIRFilter
table = tables.RIRTable
@@ -390,7 +384,6 @@ class AggregateBulkImportView(PermissionRequiredMixin, BulkImportView):
class AggregateBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'ipam.change_aggregate'
cls = Aggregate
queryset = Aggregate.objects.select_related('rir')
filter = filters.AggregateFilter
table = tables.AggregateTable
@@ -400,7 +393,6 @@ class AggregateBulkEditView(PermissionRequiredMixin, BulkEditView):
class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_aggregate'
cls = Aggregate
queryset = Aggregate.objects.select_related('rir')
filter = filters.AggregateFilter
table = tables.AggregateTable
@@ -421,9 +413,7 @@ class RoleCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.add_role'
model = Role
model_form = forms.RoleForm
def get_return_url(self, request, obj):
return reverse('ipam:role_list')
default_return_url = 'ipam:role_list'
class RoleEditView(RoleCreateView):
@@ -439,7 +429,7 @@ class RoleBulkImportView(PermissionRequiredMixin, BulkImportView):
class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_role'
cls = Role
queryset = Role.objects.all()
table = tables.RoleTable
default_return_url = 'ipam:role_list'
@@ -542,6 +532,7 @@ class PrefixPrefixesView(View):
'prefix_table': prefix_table,
'permissions': permissions,
'bulk_querystring': 'vrf_id={}&within={}'.format(prefix.vrf.pk if prefix.vrf else '0', prefix.prefix),
'active_tab': 'prefixes',
})
@@ -580,6 +571,7 @@ class PrefixIPAddressesView(View):
'ip_table': ip_table,
'permissions': permissions,
'bulk_querystring': 'vrf_id={}&parent={}'.format(prefix.vrf.pk if prefix.vrf else '0', prefix.prefix),
'active_tab': 'ip-addresses',
})
@@ -611,7 +603,6 @@ class PrefixBulkImportView(PermissionRequiredMixin, BulkImportView):
class PrefixBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'ipam.change_prefix'
cls = Prefix
queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
filter = filters.PrefixFilter
table = tables.PrefixTable
@@ -621,7 +612,6 @@ class PrefixBulkEditView(PermissionRequiredMixin, BulkEditView):
class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_prefix'
cls = Prefix
queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
filter = filters.PrefixFilter
table = tables.PrefixTable
@@ -784,7 +774,6 @@ class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView):
class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'ipam.change_ipaddress'
cls = IPAddress
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant').prefetch_related('interface__device')
filter = filters.IPAddressFilter
table = tables.IPAddressTable
@@ -794,7 +783,6 @@ class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView):
class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_ipaddress'
cls = IPAddress
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant').prefetch_related('interface__device')
filter = filters.IPAddressFilter
table = tables.IPAddressTable
@@ -817,9 +805,7 @@ class VLANGroupCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.add_vlangroup'
model = VLANGroup
model_form = forms.VLANGroupForm
def get_return_url(self, request, obj):
return reverse('ipam:vlangroup_list')
default_return_url = 'ipam:vlangroup_list'
class VLANGroupEditView(VLANGroupCreateView):
@@ -835,7 +821,6 @@ class VLANGroupBulkImportView(PermissionRequiredMixin, BulkImportView):
class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_vlangroup'
cls = VLANGroup
queryset = VLANGroup.objects.select_related('site').annotate(vlan_count=Count('vlans'))
filter = filters.VLANGroupFilter
table = tables.VLANGroupTable
@@ -914,8 +899,6 @@ class VLANMembersView(View):
members = vlan.get_members().select_related('device', 'virtual_machine')
members_table = tables.VLANMemberTable(members)
# if request.user.has_perm('dcim.change_interface'):
# members_table.columns.show('pk')
paginate = {
'klass': EnhancedPaginator,
@@ -923,18 +906,10 @@ class VLANMembersView(View):
}
RequestConfig(request, paginate).configure(members_table)
# Compile permissions list for rendering the object table
# permissions = {
# 'add': request.user.has_perm('ipam.add_ipaddress'),
# 'change': request.user.has_perm('ipam.change_ipaddress'),
# 'delete': request.user.has_perm('ipam.delete_ipaddress'),
# }
return render(request, 'ipam/vlan_members.html', {
'vlan': vlan,
'members_table': members_table,
# 'permissions': permissions,
# 'bulk_querystring': 'vrf_id={}&parent={}'.format(prefix.vrf.pk if prefix.vrf else '0', prefix.prefix),
'active_tab': 'members',
})
@@ -965,7 +940,6 @@ class VLANBulkImportView(PermissionRequiredMixin, BulkImportView):
class VLANBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'ipam.change_vlan'
cls = VLAN
queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')
filter = filters.VLANFilter
table = tables.VLANTable
@@ -975,7 +949,6 @@ class VLANBulkEditView(PermissionRequiredMixin, BulkEditView):
class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_vlan'
cls = VLAN
queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')
filter = filters.VLANFilter
table = tables.VLANTable
@@ -986,6 +959,25 @@ class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Services
#
class ServiceListView(ObjectListView):
queryset = Service.objects.select_related('device', 'virtual_machine')
filter = filters.ServiceFilter
filter_form = forms.ServiceFilterForm
table = tables.ServiceTable
template_name = 'ipam/service_list.html'
class ServiceView(View):
def get(self, request, pk):
service = get_object_or_404(Service, pk=pk)
return render(request, 'ipam/service.html', {
'service': service,
})
class ServiceCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.add_service'
model = Service
@@ -999,9 +991,6 @@ class ServiceCreateView(PermissionRequiredMixin, ObjectEditView):
obj.virtual_machine = get_object_or_404(VirtualMachine, pk=url_kwargs['virtualmachine'])
return obj
def get_return_url(self, request, obj):
return obj.parent.get_absolute_url()
class ServiceEditView(ServiceCreateView):
permission_required = 'ipam.change_service'
@@ -1010,3 +999,20 @@ class ServiceEditView(ServiceCreateView):
class ServiceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_service'
model = Service
class ServiceBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'ipam.change_service'
queryset = Service.objects.select_related('device', 'virtual_machine')
filter = filters.ServiceFilter
table = tables.ServiceTable
form = forms.ServiceBulkEditForm
default_return_url = 'ipam:service_list'
class ServiceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_service'
queryset = Service.objects.select_related('device', 'virtual_machine')
filter = filters.ServiceFilter
table = tables.ServiceTable
default_return_url = 'ipam:service_list'

View File

@@ -50,6 +50,9 @@ BANNER_LOGIN = ''
# BASE_PATH = 'netbox/'
BASE_PATH = ''
# Maximum number of days to retain logged changes. Set to 0 to retain changes indefinitely. (Default: 90)
CHANGELOG_RETENTION = 90
# API Cross-Origin Resource Sharing (CORS) settings. If CORS_ORIGIN_ALLOW_ALL is set to True, all origins will be
# allowed. Otherwise, define a list of allowed origins using either CORS_ORIGIN_WHITELIST or
# CORS_ORIGIN_REGEX_WHITELIST. For more information, see https://github.com/ottoyiu/django-cors-headers
@@ -118,6 +121,19 @@ PAGINATE_COUNT = 50
# prefer IPv4 instead.
PREFER_IPV4 = False
# The Webhook event backend is disabled by default. Set this to True to enable it. Note that this requires a Redis
# database be configured and accessible by NetBox (see `REDIS` below).
WEBHOOKS_ENABLED = False
# Redis database settings (optional). A Redis database is required only if the webhooks backend is enabled.
REDIS = {
'HOST': 'localhost',
'PORT': 6379,
'PASSWORD': '',
'DATABASE': 0,
'DEFAULT_TIMEOUT': 300,
}
# The file path where custom reports will be stored. A trailing slash is not needed. Note that the default value of
# this setting is derived from the installed location.
# REPORTS_ROOT = '/opt/netbox/netbox/reports'

View File

@@ -22,7 +22,7 @@ if sys.version_info[0] < 3:
DeprecationWarning
)
VERSION = '2.3.8-dev'
VERSION = '2.4.0'
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -44,6 +44,7 @@ BANNER_TOP = getattr(configuration, 'BANNER_TOP', '')
BASE_PATH = getattr(configuration, 'BASE_PATH', '')
if BASE_PATH:
BASE_PATH = BASE_PATH.strip('/') + '/' # Enforce trailing slash only
CHANGELOG_RETENTION = getattr(configuration, 'CHANGELOG_RETENTION', 90)
CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False)
CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', [])
CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', [])
@@ -64,11 +65,13 @@ NAPALM_ARGS = getattr(configuration, 'NAPALM_ARGS', {})
PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50)
PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False)
REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
REDIS = getattr(configuration, 'REDIS', {})
SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d')
SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i')
SHORT_TIME_FORMAT = getattr(configuration, 'SHORT_TIME_FORMAT', 'H:i:s')
TIME_FORMAT = getattr(configuration, 'TIME_FORMAT', 'g:i a')
TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC')
WEBHOOKS_ENABLED = getattr(configuration, 'WEBHOOKS_ENABLED', False)
CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS
@@ -109,6 +112,13 @@ DATABASES = {
'default': configuration.DATABASE,
}
# Redis
REDIS_HOST = REDIS.get('HOST', 'localhost')
REDIS_PORT = REDIS.get('PORT', 6379)
REDIS_PASSWORD = REDIS.get('PASSWORD', '')
REDIS_DATABASE = REDIS.get('DATABASE', 0)
REDIS_DEFAULT_TIMEOUT = REDIS.get('DEFAULT_TIMEOUT', 300)
# Email
EMAIL_HOST = EMAIL.get('SERVER')
EMAIL_PORT = EMAIL.get('PORT', 25)
@@ -119,7 +129,7 @@ SERVER_EMAIL = EMAIL.get('FROM_EMAIL')
EMAIL_SUBJECT_PREFIX = '[NetBox] '
# Installed applications
INSTALLED_APPS = (
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
@@ -133,6 +143,8 @@ INSTALLED_APPS = (
'django_tables2',
'mptt',
'rest_framework',
'taggit',
'taggit_serializer',
'timezone_field',
'circuits',
'dcim',
@@ -144,7 +156,11 @@ INSTALLED_APPS = (
'utilities',
'virtualization',
'drf_yasg',
)
]
# Only load django-rq if the webhook backend is enabled
if WEBHOOKS_ENABLED:
INSTALLED_APPS.append('django_rq')
# Middleware
MIDDLEWARE = (
@@ -154,13 +170,13 @@ 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',
'utilities.middleware.ExceptionHandlingMiddleware',
'utilities.middleware.LoginRequiredMiddleware',
'utilities.middleware.APIVersionMiddleware',
'extras.middleware.ObjectChangeMiddleware',
)
ROOT_URLCONF = 'netbox.urls'
@@ -246,6 +262,18 @@ REST_FRAMEWORK = {
'VIEW_NAME_FUNCTION': 'netbox.api.get_view_name',
}
# Django RQ (Webhooks backend)
RQ_QUEUES = {
'default': {
'HOST': REDIS_HOST,
'PORT': REDIS_PORT,
'DB': REDIS_DATABASE,
'PASSWORD': REDIS_PASSWORD,
'DEFAULT_TIMEOUT': REDIS_DEFAULT_TIMEOUT,
}
}
RQ_SHOW_ADMIN_LINK = True
# drf_yasg settings for Swagger
SWAGGER_SETTINGS = {
'DEFAULT_FIELD_INSPECTORS': [

View File

@@ -64,6 +64,12 @@ _patterns = [
]
if settings.WEBHOOKS_ENABLED:
_patterns += [
url(r'^admin/webhook-backend-status/', include('django_rq.urls')),
]
if settings.DEBUG:
import debug_toolbar
_patterns += [

View File

@@ -15,7 +15,7 @@ from circuits.tables import CircuitTable, ProviderTable
from dcim.filters import DeviceFilter, DeviceTypeFilter, RackFilter, SiteFilter, VirtualChassisFilter
from dcim.models import ConsolePort, Device, DeviceType, InterfaceConnection, PowerPort, Rack, Site, VirtualChassis
from dcim.tables import DeviceDetailTable, DeviceTypeTable, RackTable, SiteTable, VirtualChassisTable
from extras.models import ReportResult, TopologyMap, UserAction
from extras.models import ObjectChange, ReportResult, TopologyMap
from ipam.filters import AggregateFilter, IPAddressFilter, PrefixFilter, VLANFilter, VRFFilter
from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF
from ipam.tables import AggregateTable, IPAddressTable, PrefixTable, VLANTable, VRFTable
@@ -184,7 +184,7 @@ class HomeView(View):
'stats': stats,
'topology_maps': TopologyMap.objects.filter(site__isnull=True),
'report_results': ReportResult.objects.order_by('-created')[:10],
'recent_activity': UserAction.objects.select_related('user')[:50]
'changelog': ObjectChange.objects.select_related('user')[:50]
})

View File

@@ -366,6 +366,10 @@ table.component-list td.subtable td {
padding-bottom: 6px;
padding-top: 6px;
}
table.interface-ips th {
font-size: 80%;
font-weight: normal;
}
/* Reports */
table.reports td.method {

View File

@@ -127,4 +127,54 @@ $(document).ready(function() {
});
});
// Auto-complete tags
function split_tags(val) {
return val.split(/,\s*/);
}
$("#id_tags")
.on("keydown", function(event) {
if (event.keyCode === $.ui.keyCode.TAB &&
$(this).autocomplete("instance").menu.active) {
event.preventDefault();
}
})
.autocomplete({
source: function(request, response) {
$.ajax({
type: 'GET',
url: netbox_api_path + 'extras/tags/',
data: 'q=' + split_tags(request.term).pop(),
success: function(data) {
var choices = [];
$.each(data.results, function (index, choice) {
choices.push(choice.name);
});
response(choices);
}
});
},
search: function() {
// Need 3 or more characters to begin searching
var term = split_tags(this.value).pop();
if (term.length < 3) {
return false;
}
},
focus: function() {
// prevent value inserted on focus
return false;
},
select: function(event, ui) {
var terms = split_tags(this.value);
// remove the current input
terms.pop();
// add the selected item
terms.push(ui.item.value);
// add placeholder to get the comma-and-space at the end
terms.push("");
this.value = terms.join(", ");
return false;
}
});
});

View File

@@ -2,10 +2,12 @@ from __future__ import unicode_literals
from rest_framework import serializers
from rest_framework.validators import UniqueTogetherValidator
from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
from dcim.api.serializers import NestedDeviceSerializer
from extras.api.customfields import CustomFieldModelSerializer
from secrets.models import Secret, SecretRole
from utilities.api import ValidatedModelSerializer
from utilities.api import ValidatedModelSerializer, WritableNestedSerializer
#
@@ -19,7 +21,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 +33,17 @@ class NestedSecretRoleSerializer(serializers.ModelSerializer):
# Secrets
#
class SecretSerializer(serializers.ModelSerializer):
class SecretSerializer(TaggitSerializer, CustomFieldModelSerializer):
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 = TagListSerializerField(required=False)
class Meta:
model = Secret
fields = ['id', 'device', 'role', 'name', 'plaintext', 'hash', 'created', 'last_updated']
fields = [
'id', 'device', 'role', 'name', 'plaintext', 'hash', 'tags', 'custom_fields', 'created', 'last_updated',
]
validators = []
def validate(self, data):
@@ -64,6 +62,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

@@ -4,6 +4,7 @@ import django_filters
from django.db.models import Q
from dcim.models import Device
from extras.filters import CustomFieldFilterSet
from utilities.filters import NumericInFilter
from .models import Secret, SecretRole
@@ -15,7 +16,7 @@ class SecretRoleFilter(django_filters.FilterSet):
fields = ['name', 'slug']
class SecretFilter(django_filters.FilterSet):
class SecretFilter(CustomFieldFilterSet, django_filters.FilterSet):
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter(
method='search',
@@ -41,6 +42,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,9 +4,11 @@ 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
from extras.forms import AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldForm
from utilities.forms import BootstrapMixin, FilterChoiceField, FlexibleModelChoiceField, SlugField
from .models import Secret, SecretRole, UserKey
@@ -57,7 +59,7 @@ class SecretRoleCSVForm(forms.ModelForm):
# Secrets
#
class SecretForm(BootstrapMixin, forms.ModelForm):
class SecretForm(BootstrapMixin, CustomFieldForm):
plaintext = forms.CharField(
max_length=65535,
required=False,
@@ -70,10 +72,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):
@@ -126,7 +129,7 @@ class SecretCSVForm(forms.ModelForm):
return s
class SecretBulkEditForm(BootstrapMixin, BulkEditForm):
class SecretBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Secret.objects.all(), widget=forms.MultipleHiddenInput)
role = forms.ModelChoiceField(queryset=SecretRole.objects.all(), required=False)
name = forms.CharField(max_length=100, required=False)
@@ -135,7 +138,8 @@ class SecretBulkEditForm(BootstrapMixin, BulkEditForm):
nullable_fields = ['name']
class SecretFilterForm(BootstrapMixin, forms.Form):
class SecretFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Secret
q = forms.CharField(required=False, label='Search')
role = FilterChoiceField(
queryset=SecretRole.objects.annotate(filter_count=Count('secrets')),

View File

@@ -0,0 +1,82 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.14 on 2018-08-01 17:45
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
replaces = [('secrets', '0001_initial'), ('secrets', '0002_userkey_add_session_key'), ('secrets', '0003_unicode_literals')]
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('dcim', '0002_auto_20160622_1821'),
('auth', '0007_alter_validators_add_error_messages'),
]
operations = [
migrations.CreateModel(
name='SecretRole',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50, unique=True)),
('slug', models.SlugField(unique=True)),
('groups', models.ManyToManyField(blank=True, related_name='secretroles', to='auth.Group')),
('users', models.ManyToManyField(blank=True, related_name='secretroles', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Secret',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateField(auto_now_add=True)),
('last_updated', models.DateTimeField(auto_now=True)),
('name', models.CharField(blank=True, max_length=100)),
('ciphertext', models.BinaryField(max_length=65568)),
('hash', models.CharField(editable=False, max_length=128)),
('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='secrets', to='dcim.Device')),
('role', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='secrets', to='secrets.SecretRole')),
],
options={
'ordering': ['device', 'role', 'name'],
},
),
migrations.CreateModel(
name='UserKey',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateField(auto_now_add=True)),
('last_updated', models.DateTimeField(auto_now=True)),
('public_key', models.TextField(verbose_name='RSA public key')),
('master_key_cipher', models.BinaryField(blank=True, max_length=512, null=True)),
('user', models.OneToOneField(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='user_key', to=settings.AUTH_USER_MODEL)),
],
options={
'permissions': (('activate_userkey', 'Can activate user keys for decryption'),),
'ordering': ['user__username'],
},
),
migrations.AlterUniqueTogether(
name='secret',
unique_together=set([('device', 'role', 'name')]),
),
migrations.CreateModel(
name='SessionKey',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('cipher', models.BinaryField(max_length=512)),
('hash', models.CharField(editable=False, max_length=128)),
('created', models.DateTimeField(auto_now_add=True)),
('userkey', models.OneToOneField(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='session_key', to='secrets.UserKey')),
],
options={
'ordering': ['userkey__user__username'],
},
),
]

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

@@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.12 on 2018-06-13 17:29
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('secrets', '0004_tags'),
]
operations = [
migrations.AddField(
model_name='secretrole',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name='secretrole',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AlterField(
model_name='secret',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AlterField(
model_name='secret',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
]

View File

@@ -8,13 +8,15 @@ from Crypto.Util import strxor
from django.conf import settings
from django.contrib.auth.hashers import make_password, check_password
from django.contrib.auth.models import Group, User
from django.contrib.contenttypes.fields import GenericRelation
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 extras.models import CustomFieldModel
from utilities.models import ChangeLoggedModel
from .exceptions import InvalidKey
from .hashers import SecretValidationHasher
from .querysets import UserKeyQuerySet
@@ -48,15 +50,33 @@ def decrypt_master_key(master_key_cipher, private_key):
@python_2_unicode_compatible
class UserKey(CreatedUpdatedModel):
class UserKey(models.Model):
"""
A UserKey stores a user's personal RSA (public) encryption key, which is used to generate their unique encrypted
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)
created = models.DateField(
auto_now_add=True
)
last_updated = models.DateTimeField(
auto_now=True
)
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 +192,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
@@ -226,7 +259,7 @@ class SessionKey(models.Model):
@python_2_unicode_compatible
class SecretRole(models.Model):
class SecretRole(ChangeLoggedModel):
"""
A SecretRole represents an arbitrary functional classification of Secrets. For example, a user might define roles
such as "Login Credentials" or "SNMP Communities."
@@ -234,10 +267,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']
@@ -266,7 +312,7 @@ class SecretRole(models.Model):
@python_2_unicode_compatible
class Secret(CreatedUpdatedModel):
class Secret(ChangeLoggedModel, CustomFieldModel):
"""
A Secret stores an AES256-encrypted copy of sensitive data, such as passwords or secret keys. An irreversible
SHA-256 hash is stored along with the ciphertext for validation upon decryption. Each Secret is assigned to a
@@ -276,11 +322,35 @@ 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
)
custom_field_values = GenericRelation(
to='extras.CustomFieldValue',
content_type_field='obj_type',
object_id_field='obj_id'
)
tags = TaggableManager()
plaintext = None
csv_headers = ['device', 'role', 'name', 'plaintext']
@@ -304,6 +374,14 @@ class Secret(CreatedUpdatedModel):
def get_absolute_url(self):
return reverse('secrets:secret', args=[self.pk])
def to_csv(self):
return (
self.device,
self.role,
self.name,
self.plaintext or '',
)
def _pad(self, s):
"""
Prepend the length of the plaintext (2B) and pad with garbage to a multiple of 16B (minimum of 64B).

View File

@@ -6,6 +6,9 @@ from utilities.tables import BaseTable, ToggleColumn
from .models import SecretRole, Secret
SECRETROLE_ACTIONS = """
<a href="{% url 'secrets:secretrole_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
<i class="fa fa-history"></i>
</a>
{% if perms.secrets.change_secretrole %}
<a href="{% url 'secrets:secretrole_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}

View File

@@ -2,15 +2,12 @@ from __future__ import unicode_literals
import base64
from django.contrib.auth.models import User
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
from secrets.models import Secret, SecretRole, SessionKey, UserKey
from users.models import Token
from utilities.tests import HttpStatusMixin
from utilities.testing import APITestCase
# Dummy RSA key pair for testing use only
PRIVATE_KEY = """-----BEGIN RSA PRIVATE KEY-----
@@ -52,13 +49,11 @@ qQIDAQAB
-----END PUBLIC KEY-----"""
class SecretRoleTest(HttpStatusMixin, APITestCase):
class SecretRoleTest(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)}
super(SecretRoleTest, self).setUp()
self.secretrole1 = SecretRole.objects.create(name='Test Secret Role 1', slug='test-secret-role-1')
self.secretrole2 = SecretRole.objects.create(name='Test Secret Role 2', slug='test-secret-role-2')
@@ -145,21 +140,20 @@ class SecretRoleTest(HttpStatusMixin, APITestCase):
self.assertEqual(SecretRole.objects.count(), 2)
class SecretTest(HttpStatusMixin, APITestCase):
class SecretTest(APITestCase):
def setUp(self):
user = User.objects.create(username='testuser', is_superuser=True)
token = Token.objects.create(user=user)
super(SecretTest, self).setUp()
userkey = UserKey(user=user, public_key=PUBLIC_KEY)
userkey = UserKey(user=self.user, public_key=PUBLIC_KEY)
userkey.save()
self.master_key = userkey.get_master_key(PRIVATE_KEY)
session_key = SessionKey(userkey=userkey)
session_key.save(self.master_key)
self.header = {
'HTTP_AUTHORIZATION': 'Token {}'.format(token.key),
'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key),
'HTTP_X_SESSION_KEY': base64.b64encode(session_key.key),
}
@@ -288,21 +282,20 @@ class SecretTest(HttpStatusMixin, APITestCase):
self.assertEqual(Secret.objects.count(), 2)
class GetSessionKeyTest(HttpStatusMixin, APITestCase):
class GetSessionKeyTest(APITestCase):
def setUp(self):
user = User.objects.create(username='testuser', is_superuser=True)
token = Token.objects.create(user=user)
super(GetSessionKeyTest, self).setUp()
userkey = UserKey(user=user, public_key=PUBLIC_KEY)
userkey = UserKey(user=self.user, public_key=PUBLIC_KEY)
userkey.save()
master_key = userkey.get_master_key(PRIVATE_KEY)
self.session_key = SessionKey(userkey=userkey)
self.session_key.save(master_key)
self.header = {
'HTTP_AUTHORIZATION': 'Token {}'.format(token.key),
'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key),
}
def test_get_session_key(self):

View File

@@ -2,7 +2,9 @@ from __future__ import unicode_literals
from django.conf.urls import url
from extras.views import ObjectChangeLogView
from . import views
from .models import Secret, SecretRole
app_name = 'secrets'
urlpatterns = [
@@ -13,6 +15,7 @@ urlpatterns = [
url(r'^secret-roles/import/$', views.SecretRoleBulkImportView.as_view(), name='secretrole_import'),
url(r'^secret-roles/delete/$', views.SecretRoleBulkDeleteView.as_view(), name='secretrole_bulk_delete'),
url(r'^secret-roles/(?P<slug>[\w-]+)/edit/$', views.SecretRoleEditView.as_view(), name='secretrole_edit'),
url(r'^secret-roles/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='secretrole_changelog', kwargs={'model': SecretRole}),
# Secrets
url(r'^secrets/$', views.SecretListView.as_view(), name='secret_list'),
@@ -22,5 +25,6 @@ urlpatterns = [
url(r'^secrets/(?P<pk>\d+)/$', views.SecretView.as_view(), name='secret'),
url(r'^secrets/(?P<pk>\d+)/edit/$', views.secret_edit, name='secret_edit'),
url(r'^secrets/(?P<pk>\d+)/delete/$', views.SecretDeleteView.as_view(), name='secret_delete'),
url(r'^secrets/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='secret_changelog', kwargs={'model': Secret}),
]

View File

@@ -44,9 +44,7 @@ class SecretRoleCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'secrets.add_secretrole'
model = SecretRole
model_form = forms.SecretRoleForm
def get_return_url(self, request, obj):
return reverse('secrets:secretrole_list')
default_return_url = 'secrets:secretrole_list'
class SecretRoleEditView(SecretRoleCreateView):
@@ -62,7 +60,6 @@ class SecretRoleBulkImportView(PermissionRequiredMixin, BulkImportView):
class SecretRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'secrets.delete_secretrole'
cls = SecretRole
queryset = SecretRole.objects.annotate(secret_count=Count('secrets'))
table = tables.SecretRoleTable
default_return_url = 'secrets:secretrole_list'
@@ -244,13 +241,12 @@ class SecretBulkImportView(BulkImportView):
'form': self._import_form(request.POST),
'fields': self.model_form().fields,
'obj_type': self.model_form._meta.model._meta.verbose_name,
'return_url': self.default_return_url,
'return_url': self.get_return_url(request),
})
class SecretBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'secrets.change_secret'
cls = Secret
queryset = Secret.objects.select_related('role', 'device')
filter = filters.SecretFilter
table = tables.SecretTable
@@ -260,7 +256,6 @@ class SecretBulkEditView(PermissionRequiredMixin, BulkEditView):
class SecretBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'secrets.delete_secret'
cls = Secret
queryset = Secret.objects.select_related('role', 'device')
filter = filters.SecretFilter
table = tables.SecretTable

View File

@@ -34,6 +34,7 @@
{{ message }}
</div>
{% endfor %}
{% block header %}{% endblock %}
{% block content %}{% endblock %}
<div class="push"></div>
{% if settings.BANNER_BOTTOM %}

View File

@@ -1,44 +1,57 @@
{% extends '_base.html' %}
{% load helpers %}
{% block title %}{{ circuit }}{% endblock %}
{% block header %}
<div class="row">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{% url 'circuits:circuit_list' %}">Circuits</a></li>
<li><a href="{% url 'circuits:circuit_list' %}?provider={{ circuit.provider.slug }}">{{ circuit.provider }}</a></li>
<li>{{ circuit.cid }}</li>
</ol>
</div>
<div class="col-sm-4 col-md-3">
<form action="{% url 'circuits:circuit_list' %}" method="get">
<div class="input-group">
<input type="text" name="q" class="form-control" />
<span class="input-group-btn">
<button type="submit" class="btn btn-primary">
<span class="fa fa-search" aria-hidden="true"></span>
</button>
</span>
</div>
</form>
</div>
</div>
<div class="pull-right">
{% if perms.circuits.change_circuit %}
<a href="{% url 'circuits:circuit_edit' pk=circuit.pk %}" class="btn btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span>
Edit this circuit
</a>
{% endif %}
{% if perms.circuits.delete_circuit %}
<a href="{% url 'circuits:circuit_delete' pk=circuit.pk %}" class="btn btn-danger">
<span class="fa fa-trash" aria-hidden="true"></span>
Delete this circuit
</a>
{% endif %}
</div>
<h1>{{ circuit }}</h1>
{% include 'inc/created_updated.html' with obj=circuit %}
<ul class="nav nav-tabs">
<li role="presentation"{% if not active_tab %} class="active"{% endif %}>
<a href="{{ circuit.get_absolute_url }}">Circuit</a>
</li>
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
<a href="{% url 'circuits:circuit_changelog' pk=circuit.pk %}">Changelog</a>
</li>
</ul>
{% endblock %}
{% block content %}
<div class="row">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{% url 'circuits:circuit_list' %}">Circuits</a></li>
<li><a href="{% url 'circuits:circuit_list' %}?provider={{ circuit.provider.slug }}">{{ circuit.provider }}</a></li>
<li>{{ circuit.cid }}</li>
</ol>
</div>
<div class="col-sm-4 col-md-3">
<form action="{% url 'circuits:circuit_list' %}" method="get">
<div class="input-group">
<input type="text" name="q" class="form-control" />
<span class="input-group-btn">
<button type="submit" class="btn btn-primary">
<span class="fa fa-search" aria-hidden="true"></span>
</button>
</span>
</div>
</form>
</div>
</div>
<div class="pull-right">
{% if perms.circuits.change_circuit %}
<a href="{% url 'circuits:circuit_edit' pk=circuit.pk %}" class="btn btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span>
Edit this circuit
</a>
{% endif %}
{% if perms.circuits.delete_circuit %}
<a href="{% url 'circuits:circuit_delete' pk=circuit.pk %}" class="btn btn-danger">
<span class="fa fa-trash" aria-hidden="true"></span>
Delete this circuit
</a>
{% endif %}
</div>
<h1>{% block title %}{{ circuit.provider }} - {{ circuit.cid }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=circuit %}
<div class="row">
<div class="col-md-6">
<div class="panel panel-default">
@@ -112,9 +125,8 @@
</tr>
</table>
</div>
{% with circuit.get_custom_fields as custom_fields %}
{% include 'inc/custom_fields_panel.html' %}
{% endwith %}
{% include 'inc/custom_fields_panel.html' with obj=circuit %}
{% include 'extras/inc/tags_panel.html' with tags=circuit.tags.all url='circuits:circuit_list' %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>Comments</strong>

View File

@@ -38,6 +38,12 @@
</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>
<div class="panel panel-default">
<div class="panel-heading"><strong>Comments</strong></div>
<div class="panel-body">

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

@@ -2,49 +2,62 @@
{% load static from staticfiles %}
{% load helpers %}
{% block title %}{{ provider }}{% endblock %}
{% block header %}
<div class="row">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{% url 'circuits:provider_list' %}">Providers</a></li>
<li>{{ provider }}</li>
</ol>
</div>
<div class="col-sm-4 col-md-3">
<form action="{% url 'circuits:provider_list' %}" method="get">
<div class="input-group">
<input type="text" name="q" class="form-control" />
<span class="input-group-btn">
<button type="submit" class="btn btn-primary">
<span class="fa fa-search" aria-hidden="true"></span>
</button>
</span>
</div>
</form>
</div>
</div>
<div class="pull-right">
{% if show_graphs %}
<button type="button" class="btn btn-primary" data-toggle="modal" data-target="#graphs_modal" data-obj="{{ provider.name }}" data-url="{% url 'circuits-api:provider-graphs' pk=provider.pk %}" title="Show graphs">
<i class="fa fa-signal" aria-hidden="true"></i>
Graphs
</button>
{% endif %}
{% if perms.circuits.change_provider %}
<a href="{% url 'circuits:provider_edit' slug=provider.slug %}" class="btn btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span>
Edit this provider
</a>
{% endif %}
{% if perms.circuits.delete_provider %}
<a href="{% url 'circuits:provider_delete' slug=provider.slug %}" class="btn btn-danger">
<span class="fa fa-trash" aria-hidden="true"></span>
Delete this provider
</a>
{% endif %}
</div>
<h1>{{ provider }}</h1>
{% include 'inc/created_updated.html' with obj=provider %}
<ul class="nav nav-tabs">
<li role="presentation"{% if not active_tab %} class="active"{% endif %}>
<a href="{{ provider.get_absolute_url }}">Provider</a>
</li>
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
<a href="{% url 'circuits:provider_changelog' slug=provider.slug %}">Changelog</a>
</li>
</ul>
{% endblock %}
{% block content %}
<div class="row">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{% url 'circuits:provider_list' %}">Providers</a></li>
<li>{{ provider }}</li>
</ol>
</div>
<div class="col-sm-4 col-md-3">
<form action="{% url 'circuits:provider_list' %}" method="get">
<div class="input-group">
<input type="text" name="q" class="form-control" />
<span class="input-group-btn">
<button type="submit" class="btn btn-primary">
<span class="fa fa-search" aria-hidden="true"></span>
</button>
</span>
</div>
</form>
</div>
</div>
<div class="pull-right">
{% if show_graphs %}
<button type="button" class="btn btn-primary" data-toggle="modal" data-target="#graphs_modal" data-obj="{{ provider.name }}" data-url="{% url 'circuits-api:provider-graphs' pk=provider.pk %}" title="Show graphs">
<i class="fa fa-signal" aria-hidden="true"></i>
Graphs
</button>
{% endif %}
{% if perms.circuits.change_provider %}
<a href="{% url 'circuits:provider_edit' slug=provider.slug %}" class="btn btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span>
Edit this provider
</a>
{% endif %}
{% if perms.circuits.delete_provider %}
<a href="{% url 'circuits:provider_delete' slug=provider.slug %}" class="btn btn-danger">
<span class="fa fa-trash" aria-hidden="true"></span>
Delete this provider
</a>
{% endif %}
</div>
<h1>{% block title %}{{ provider }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=provider %}
<div class="row">
<div class="col-md-4">
<div class="panel panel-default">
@@ -110,9 +123,8 @@
</tr>
</table>
</div>
{% with provider.get_custom_fields as custom_fields %}
{% include 'inc/custom_fields_panel.html' %}
{% endwith %}
{% include 'inc/custom_fields_panel.html' with obj=provider %}
{% include 'extras/inc/tags_panel.html' with tags=provider.tags.all url='circuits:provider_list' %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>Comments</strong>

View File

@@ -27,6 +27,12 @@
</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>
<div class="panel panel-default">
<div class="panel-heading"><strong>Comments</strong></div>
<div class="panel-body">

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 %}

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,10 @@
{% extends '_base.html' %}
{% extends 'dcim/device.html' %}
{% load staticfiles %}
{% block title %}{{ device }} - Config{% endblock %}
{% block content %}
{% include 'inc/ajax_loader.html' %}
{% include 'dcim/inc/device_header.html' with active_tab='config' %}
<div class="row">
<div class="col-md-10 col-md-offset-1">
<div class="panel panel-default">

View File

@@ -77,6 +77,12 @@
</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>
<div class="panel panel-default">
<div class="panel-heading"><strong>Comments</strong></div>
<div class="panel-body">

View File

@@ -1,77 +1,76 @@
{% extends '_base.html' %}
{% extends 'dcim/device.html' %}
{% block title %}{{ device }} - Inventory{% endblock %}
{% block content %}
{% include 'dcim/inc/device_header.html' with active_tab='inventory' %}
<div class="row">
<div class="col-md-4">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Chassis</strong>
</div>
<table class="table table-hover panel-body attr-table">
<tr>
<td>Model</td>
<td>{{ device.device_type.full_name }}</td>
</tr>
<tr>
<td>Serial Number</td>
<td>
{% if device.serial %}
<span>{{ device.serial }}</span>
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
<tr>
<td>Asset Tag</td>
<td>
{% if device.asset_tag %}
<span>{{ device.asset_tag }}</span>
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
</table>
</div>
</div>
<div class="col-md-8">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Hardware</strong>
</div>
<table class="table table-hover table-condensed panel-body" id="hardware">
<thead>
<tr>
<th>Name</th>
<th></th>
<th>Manufacturer</th>
<th>Part Number</th>
<th>Serial Number</th>
<th>Asset Tag</th>
<th>Description</th>
<th></th>
</tr>
</thead>
<tbody>
{% for item in inventory_items %}
{% with template_name='dcim/inc/inventoryitem.html' indent=0 %}
{% include template_name %}
{% endwith %}
{% endfor %}
</tbody>
</table>
{% if perms.dcim.add_inventoryitem %}
<div class="panel-footer text-right">
<a href="{% url 'dcim:inventoryitem_add' device=device.pk %}" class="btn btn-primary btn-xs">
<span class="fa fa-plus" aria-hidden="true"></span> Add Inventory Item
</a>
<div class="row">
<div class="col-md-4">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Chassis</strong>
</div>
{% endif %}
<table class="table table-hover panel-body attr-table">
<tr>
<td>Model</td>
<td>{{ device.device_type.full_name }}</td>
</tr>
<tr>
<td>Serial Number</td>
<td>
{% if device.serial %}
<span>{{ device.serial }}</span>
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
<tr>
<td>Asset Tag</td>
<td>
{% if device.asset_tag %}
<span>{{ device.asset_tag }}</span>
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
</table>
</div>
</div>
<div class="col-md-8">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Hardware</strong>
</div>
<table class="table table-hover table-condensed panel-body" id="hardware">
<thead>
<tr>
<th>Name</th>
<th></th>
<th>Manufacturer</th>
<th>Part Number</th>
<th>Serial Number</th>
<th>Asset Tag</th>
<th>Description</th>
<th></th>
</tr>
</thead>
<tbody>
{% for item in inventory_items %}
{% with template_name='dcim/inc/inventoryitem.html' indent=0 %}
{% include template_name %}
{% endwith %}
{% endfor %}
</tbody>
</table>
{% if perms.dcim.add_inventoryitem %}
<div class="panel-footer text-right">
<a href="{% url 'dcim:inventoryitem_add' device=device.pk %}" class="btn btn-primary btn-xs">
<span class="fa fa-plus" aria-hidden="true"></span> Add Inventory Item
</a>
</div>
{% endif %}
</div>
</div>
</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 %}

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