Initial push to public repo

This commit is contained in:
Jeremy Stretch
2016-03-01 11:23:03 -05:00
commit 27b289ee3b
281 changed files with 26061 additions and 0 deletions

View File

30
netbox/circuits/admin.py Normal file
View File

@@ -0,0 +1,30 @@
from django.contrib import admin
from .models import Provider, CircuitType, Circuit
@admin.register(Provider)
class ProviderAdmin(admin.ModelAdmin):
prepopulated_fields = {
'slug': ['name'],
}
list_display = ['name', 'slug', 'asn']
@admin.register(CircuitType)
class CircuitTypeAdmin(admin.ModelAdmin):
prepopulated_fields = {
'slug': ['name'],
}
list_display = ['name', 'slug']
@admin.register(Circuit)
class CircuitAdmin(admin.ModelAdmin):
list_display = ['cid', 'provider', 'type', 'site', 'install_date', 'port_speed', 'commit_rate', 'xconnect_id']
list_filter = ['provider']
exclude = ['interface']
def get_queryset(self, request):
qs = super(CircuitAdmin, self).get_queryset(request)
return qs.select_related('provider', 'type', 'site')

View File

View File

@@ -0,0 +1,60 @@
from rest_framework import serializers
from circuits.models import Provider, CircuitType, Circuit
from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer
#
# Providers
#
class ProviderSerializer(serializers.ModelSerializer):
class Meta:
model = Provider
fields = ['id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']
class ProviderNestedSerializer(ProviderSerializer):
class Meta(ProviderSerializer.Meta):
fields = ['id', 'name', 'slug']
#
# Circuit types
#
class CircuitTypeSerializer(serializers.ModelSerializer):
class Meta:
model = CircuitType
fields = ['id', 'name', 'slug']
class CircuitTypeNestedSerializer(CircuitTypeSerializer):
class Meta(CircuitTypeSerializer.Meta):
pass
#
# Circuits
#
class CircuitSerializer(serializers.ModelSerializer):
provider = ProviderNestedSerializer()
type = CircuitTypeNestedSerializer()
site = SiteNestedSerializer()
interface = InterfaceNestedSerializer()
class Meta:
model = Circuit
fields = ['id', 'cid', 'provider', 'type', 'site', 'interface', 'install_date', 'port_speed', 'commit_rate',
'xconnect_id', 'comments']
class CircuitNestedSerializer(CircuitSerializer):
class Meta(CircuitSerializer.Meta):
fields = ['id', 'cid']

View File

@@ -0,0 +1,24 @@
from django.conf.urls import url
from extras.models import GRAPH_TYPE_PROVIDER
from extras.api.views import GraphListView
from .views import *
urlpatterns = [
# Providers
url(r'^providers/$', ProviderListView.as_view(), name='provider_list'),
url(r'^providers/(?P<pk>\d+)/$', ProviderDetailView.as_view(), name='provider_detail'),
url(r'^providers/(?P<pk>\d+)/graphs/$', GraphListView.as_view(), {'type': GRAPH_TYPE_PROVIDER}, name='provider_graphs'),
# Circuit types
url(r'^circuit-types/$', CircuitTypeListView.as_view(), name='circuittype_list'),
url(r'^circuit-types/(?P<pk>\d+)/$', CircuitTypeDetailView.as_view(), name='circuittype_detail'),
# Circuits
url(r'^circuits/$', CircuitListView.as_view(), name='circuit_list'),
url(r'^circuits/(?P<pk>\d+)/$', CircuitDetailView.as_view(), name='circuit_detail'),
]

View File

@@ -0,0 +1,54 @@
from rest_framework import generics
from circuits.models import Provider, CircuitType, Circuit
from circuits.filters import CircuitFilter
from .serializers import ProviderSerializer, CircuitTypeSerializer, CircuitSerializer
class ProviderListView(generics.ListAPIView):
"""
List all providers
"""
queryset = Provider.objects.all()
serializer_class = ProviderSerializer
class ProviderDetailView(generics.RetrieveAPIView):
"""
Retrieve a single provider
"""
queryset = Provider.objects.all()
serializer_class = ProviderSerializer
class CircuitTypeListView(generics.ListAPIView):
"""
List all circuit types
"""
queryset = CircuitType.objects.all()
serializer_class = CircuitTypeSerializer
class CircuitTypeDetailView(generics.RetrieveAPIView):
"""
Retrieve a single circuit type
"""
queryset = CircuitType.objects.all()
serializer_class = CircuitTypeSerializer
class CircuitListView(generics.ListAPIView):
"""
List circuits (filterable)
"""
queryset = Circuit.objects.select_related('type', 'provider', 'site', 'interface__device')
serializer_class = CircuitSerializer
filter_class = CircuitFilter
class CircuitDetailView(generics.RetrieveAPIView):
"""
Retrieve a single circuit
"""
queryset = Circuit.objects.select_related('type', 'provider', 'site', 'interface__device')
serializer_class = CircuitSerializer

View File

@@ -0,0 +1,52 @@
import django_filters
from dcim.models import Site
from circuits.models import Provider, Circuit, CircuitType
class CircuitFilter(django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
label='Search',
)
provider_id = django_filters.ModelMultipleChoiceFilter(
name='provider',
queryset=Provider.objects.all(),
label='Provider (ID)',
)
provider = django_filters.ModelMultipleChoiceFilter(
name='provider',
queryset=Provider.objects.all(),
to_field_name='slug',
label='Provider (slug)',
)
type_id = django_filters.ModelMultipleChoiceFilter(
name='type',
queryset=CircuitType.objects.all(),
label='Circuit type (ID)',
)
type = django_filters.ModelMultipleChoiceFilter(
name='type',
queryset=CircuitType.objects.all(),
to_field_name='slug',
label='Circuit type (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter(
name='site',
queryset=Site.objects.all(),
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
name='site',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site (slug)',
)
class Meta:
model = Circuit
fields = ['q', 'provider_id', 'provider', 'type_id', 'type', 'site_id', 'site', 'interface', 'install_date']
def search(self, queryset, value):
value = value.strip()
return queryset.filter(cid__icontains=value)

191
netbox/circuits/forms.py Normal file
View File

@@ -0,0 +1,191 @@
from django import forms
from django.db.models import Count
from dcim.models import Site, Device, Interface, Rack, IFACE_FF_VIRTUAL
from utilities.forms import BootstrapMixin, SmallTextarea, ConfirmationForm, APISelect, Livesearch
from .models import PORT_SPEED_CHOICES, Circuit, Provider, CircuitType
from utilities.forms import CommentField, CSVDataField, BulkImportForm
#
# Providers
#
class ProviderForm(forms.ModelForm, BootstrapMixin):
comments = CommentField()
class Meta:
model = Provider
fields = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']
widgets = {
'noc_contact': SmallTextarea(attrs={'rows': 5}),
'admin_contact': SmallTextarea(attrs={'rows': 5}),
}
help_texts = {
'name': "Full name of the provider",
'slug': "URL-friendly unique shorthand (e.g. 'decix' for DE-CIX)",
'asn': "BGP autonomous system number (if applicable)",
'portal_url': "URL of the provider's customer support portal",
'noc_contact': "NOC email address and phone number",
'admin_contact': "Administrative contact email address and phone number",
}
class ProviderFromCSVForm(forms.ModelForm):
class Meta:
model = Provider
fields = ['name', 'slug', 'asn', 'account', 'portal_url']
class ProviderImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=ProviderFromCSVForm)
class ProviderBulkEditForm(forms.Form, BootstrapMixin):
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')
portal_url = forms.URLField(required=False, label='Portal')
noc_contact = forms.CharField(required=False, widget=SmallTextarea, label='NOC contact')
admin_contact = forms.CharField(required=False, widget=SmallTextarea, label='Admin contact')
comments = CommentField()
class ProviderBulkDeleteForm(ConfirmationForm):
pk = forms.ModelMultipleChoiceField(queryset=Provider.objects.all(), widget=forms.MultipleHiddenInput)
#
# Circuits
#
class CircuitForm(forms.ModelForm, BootstrapMixin):
site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.Select(attrs={'filter-for': 'rack'}))
rack = forms.ModelChoiceField(queryset=Rack.objects.all(), required=False, label='Rack',
widget=APISelect(api_url='/api/dcim/racks/?site_id={{site}}',
attrs={'filter-for': 'device'}))
device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, label='Device',
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}',
attrs={'filter-for': 'interface'}))
livesearch = forms.CharField(required=False, label='Device', widget=Livesearch(
query_key='q', query_url='dcim-api:device_list', field_to_update='device')
)
interface = forms.ModelChoiceField(queryset=Interface.objects.all(), label='Interface',
widget=APISelect(api_url='/api/dcim/devices/{{device}}/interfaces/?type=physical',
disabled_indicator='is_connected'))
comments = CommentField()
class Meta:
model = Circuit
fields = [
'cid', 'type', 'provider', 'site', 'rack', 'device', 'livesearch', 'interface', 'install_date',
'port_speed', 'commit_rate', 'xconnect_id', 'pp_info', 'comments'
]
help_texts = {
'cid': "Unique circuit ID",
'install_date': "Format: YYYY-MM-DD",
'port_speed': "Physical circuit speed",
'commit_rate': "Commited rate (in Mbps)",
'xconnect_id': "ID of the local cross-connect",
'pp_info': "Patch panel ID and port number(s)"
}
def __init__(self, *args, **kwargs):
super(CircuitForm, self).__init__(*args, **kwargs)
# If this circuit has been assigned to an interface, initialize rack and device
if self.instance.interface:
self.initial['rack'] = self.instance.interface.device.rack
self.initial['device'] = self.instance.interface.device
# Limit rack choices
if self.is_bound:
self.fields['rack'].queryset = Rack.objects.filter(site__pk=self.data['site'])
elif self.initial.get('site'):
self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site'])
else:
self.fields['rack'].choices = []
# Limit device choices
if self.is_bound and self.data.get('rack'):
self.fields['device'].queryset = Device.objects.filter(rack=self.data['rack'])
elif self.initial.get('rack'):
self.fields['device'].queryset = Device.objects.filter(rack=self.initial['rack'])
else:
self.fields['device'].choices = []
# Limit interface choices
if self.is_bound and self.data.get('device'):
interfaces = Interface.objects.filter(device=self.data['device'])\
.exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit', 'connected_as_a', 'connected_as_b')
self.fields['interface'].widget.attrs['initial'] = self.data.get('interface')
elif self.initial.get('device'):
interfaces = Interface.objects.filter(device=self.initial['device'])\
.exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit', 'connected_as_a', 'connected_as_b')
self.fields['interface'].widget.attrs['initial'] = self.initial.get('interface')
else:
interfaces = []
self.fields['interface'].choices = [
(iface.id, {
'label': iface.name,
'disabled': iface.is_connected and iface.id != self.fields['interface'].widget.attrs.get('initial'),
}) for iface in interfaces
]
class CircuitFromCSVForm(forms.ModelForm):
provider = forms.ModelChoiceField(Provider.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Provider not found.'})
type = forms.ModelChoiceField(CircuitType.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Invalid circuit type.'})
site = forms.ModelChoiceField(Site.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Site not found.'})
class Meta:
model = Circuit
fields = ['cid', 'provider', 'type', 'site', 'install_date', 'port_speed', 'commit_rate', 'xconnect_id',
'pp_info']
class CircuitImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=CircuitFromCSVForm)
class CircuitBulkEditForm(forms.Form, BootstrapMixin):
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)
port_speed = forms.ChoiceField(choices=[(None, '---------')] + PORT_SPEED_CHOICES, required=False,
label='Port speed')
commit_rate = forms.IntegerField(required=False, label='Commit rate (Mbps)')
comments = CommentField()
class CircuitBulkDeleteForm(ConfirmationForm):
pk = forms.ModelMultipleChoiceField(queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput)
def circuit_type_choices():
type_choices = CircuitType.objects.annotate(circuit_count=Count('circuits'))
return [(t.slug, '{} ({})'.format(t.name, t.circuit_count)) for t in type_choices]
def circuit_provider_choices():
provider_choices = Provider.objects.annotate(circuit_count=Count('circuits'))
return [(p.slug, '{} ({})'.format(p.name, p.circuit_count)) for p in provider_choices]
def circuit_site_choices():
site_choices = Site.objects.annotate(circuit_count=Count('circuits'))
return [(s.slug, '{} ({})'.format(s.name, s.circuit_count)) for s in site_choices]
class CircuitFilterForm(forms.Form, BootstrapMixin):
type = forms.MultipleChoiceField(required=False, choices=circuit_type_choices)
provider = forms.MultipleChoiceField(required=False, choices=circuit_provider_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
site = forms.MultipleChoiceField(required=False, choices=circuit_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))

View File

@@ -0,0 +1,82 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.1 on 2016-02-27 02:35
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('dcim', '__first__'),
]
operations = [
migrations.CreateModel(
name='Circuit',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('cid', models.CharField(max_length=50, verbose_name=b'Circuit ID')),
('install_date', models.DateField(blank=True, null=True, verbose_name=b'Date installed')),
('port_speed', models.PositiveSmallIntegerField(choices=[[100, b'100 Mbps'], [1000, b'1 Gbps'], [10000, b'10 Gbps'], [25000, b'25 Gbps'], [40000, b'40 Gbps'], [50000, b'50 Gbps'], [100000, b'100 Gbps']], verbose_name=b'Port speed')),
('commit_rate', models.PositiveIntegerField(blank=True, null=True, verbose_name=b'Commit rate (Mbps)')),
('xconnect_id', models.CharField(blank=True, max_length=50, verbose_name=b'Cross-connect ID')),
('pp_info', models.CharField(blank=True, max_length=100, verbose_name=b'Patch panel/port(s)')),
('comments', models.TextField(blank=True)),
('interface', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='circuit', to='dcim.Interface')),
],
options={
'ordering': ['provider', 'cid'],
},
),
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='Provider',
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)),
('asn', models.PositiveIntegerField(blank=True, null=True, verbose_name=b'ASN')),
('account', models.CharField(blank=True, max_length=30, verbose_name=b'Account number')),
('portal_url', models.URLField(blank=True, verbose_name=b'Portal')),
('noc_contact', models.TextField(blank=True, verbose_name=b'NOC Contact')),
('admin_contact', models.TextField(blank=True, verbose_name=b'Admin Contact')),
('comments', models.TextField(blank=True)),
],
options={
'ordering': ['name'],
},
),
migrations.AddField(
model_name='circuit',
name='provider',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='circuits', to='circuits.Provider'),
),
migrations.AddField(
model_name='circuit',
name='site',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='circuits', to='dcim.Site'),
),
migrations.AddField(
model_name='circuit',
name='type',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='circuits', to='circuits.CircuitType'),
),
migrations.AlterUniqueTogether(
name='circuit',
unique_together=set([('provider', 'cid')]),
),
]

View File

86
netbox/circuits/models.py Normal file
View File

@@ -0,0 +1,86 @@
from django.core.urlresolvers import reverse
from django.db import models
from dcim.models import Site, Interface
PORT_SPEED_100M = 100
PORT_SPEED_1G = 1000
PORT_SPEED_10G = 10000
PORT_SPEED_25G = 25000
PORT_SPEED_40G = 40000
PORT_SPEED_50G = 50000
PORT_SPEED_100G = 100000
PORT_SPEED_CHOICES = [
[PORT_SPEED_100M, '100 Mbps'],
[PORT_SPEED_1G, '1 Gbps'],
[PORT_SPEED_10G, '10 Gbps'],
[PORT_SPEED_25G, '25 Gbps'],
[PORT_SPEED_40G, '40 Gbps'],
[PORT_SPEED_50G, '50 Gbps'],
[PORT_SPEED_100G, '100 Gbps'],
]
class Provider(models.Model):
"""
A transit provider, IX, or direct peer
"""
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
asn = models.PositiveIntegerField(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)
class Meta:
ordering = ['name']
def __unicode__(self):
return self.name
def get_absolute_url(self):
return reverse('circuits:provider', args=[self.slug])
class CircuitType(models.Model):
"""
A type of circuit
"""
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
class Meta:
ordering = ['name']
def __unicode__(self):
return self.name
class Circuit(models.Model):
"""
A data circuit from a site to a provider (includes IX connections)
"""
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)
site = models.ForeignKey(Site, related_name='circuits', on_delete=models.PROTECT)
interface = models.OneToOneField(Interface, related_name='circuit', blank=True, null=True)
install_date = models.DateField(blank=True, null=True, verbose_name='Date installed')
port_speed = models.PositiveSmallIntegerField(choices=PORT_SPEED_CHOICES, verbose_name='Port speed')
commit_rate = models.PositiveIntegerField(blank=True, null=True, verbose_name='Commit rate (Mbps)')
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)')
comments = models.TextField(blank=True)
class Meta:
ordering = ['provider', 'cid']
unique_together = ['provider', 'cid']
def __unicode__(self):
return "{0} {1}".format(self.provider, self.cid)
def get_absolute_url(self):
return reverse('circuits:circuit', args=[self.pk])

59
netbox/circuits/tables.py Normal file
View File

@@ -0,0 +1,59 @@
import django_tables2 as tables
from django_tables2.utils import Accessor
from .models import Circuit, Provider
#
# Providers
#
class ProviderTable(tables.Table):
name = tables.LinkColumn('circuits:provider', args=[Accessor('slug')], verbose_name='Name')
asn = tables.Column(verbose_name='ASN')
circuit_count = tables.Column(accessor=Accessor('count_circuits'), verbose_name='Circuits')
class Meta:
model = Provider
fields = ('name', 'asn', 'circuit_count')
empty_text = "No providers found."
attrs = {
'class': 'table table-hover',
}
class ProviderBulkEditTable(ProviderTable):
pk = tables.CheckBoxColumn()
class Meta(ProviderTable.Meta):
model = None # django_tables2 bugfix
fields = ('pk', 'name', 'asn', 'circuit_count')
#
# Circuits
#
class CircuitTable(tables.Table):
cid = tables.LinkColumn('circuits:circuit', args=[Accessor('pk')], verbose_name='ID')
type = tables.Column(verbose_name='Type')
provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')], verbose_name='Provider')
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
port_speed = tables.Column(verbose_name='Port Speed')
commit_rate = tables.Column(verbose_name='Commit (Mbps)')
class Meta:
model = Circuit
fields = ('cid', 'type', 'provider', 'site', 'port_speed', 'commit_rate')
empty_text = "No circuits found."
attrs = {
'class': 'table table-hover',
}
class CircuitBulkEditTable(CircuitTable):
pk = tables.CheckBoxColumn()
class Meta(CircuitTable.Meta):
model = None # django_tables2 bugfix
fields = ('pk', 'cid', 'type', 'provider', 'site', 'port_speed', 'commit_rate')

3
netbox/circuits/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

23
netbox/circuits/urls.py Normal file
View File

@@ -0,0 +1,23 @@
from django.conf.urls import url
from . import views
urlpatterns = [
url(r'^circuits/$', views.circuit_list, name='circuit_list'),
url(r'^circuits/add/$', views.circuit_add, name='circuit_add'),
url(r'^circuits/import/$', views.CircuitBulkImportView.as_view(), name='circuit_import'),
url(r'^circuits/edit/$', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'),
url(r'^circuits/delete/$', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'),
url(r'^circuits/(?P<pk>\d+)/$', views.circuit, name='circuit'),
url(r'^circuits/(?P<pk>\d+)/edit/$', views.circuit_edit, name='circuit_edit'),
url(r'^circuits/(?P<pk>\d+)/delete/$', views.circuit_delete, name='circuit_delete'),
url(r'^providers/$', views.provider_list, name='provider_list'),
url(r'^providers/add/$', views.provider_add, name='provider_add'),
url(r'^providers/import/$', views.ProviderBulkImportView.as_view(), name='provider_import'),
url(r'^providers/edit/$', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'),
url(r'^providers/delete/$', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'),
url(r'^providers/(?P<slug>[\w-]+)/$', views.provider, name='provider'),
url(r'^providers/(?P<slug>[\w-]+)/edit/$', views.provider_edit, name='provider_edit'),
url(r'^providers/(?P<slug>[\w-]+)/delete/$', views.provider_delete, name='provider_delete'),
]

309
netbox/circuits/views.py Normal file
View File

@@ -0,0 +1,309 @@
from django_tables2 import RequestConfig
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import permission_required
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.urlresolvers import reverse
from django.db.models import Count, ProtectedError
from django.shortcuts import get_object_or_404, redirect, render
from extras.models import ExportTemplate
from utilities.error_handlers import handle_protectederror
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator
from utilities.views import BulkImportView, BulkEditView, BulkDeleteView
from .filters import CircuitFilter
from .forms import CircuitForm, CircuitImportForm, CircuitBulkEditForm, CircuitBulkDeleteForm, CircuitFilterForm, \
ProviderForm, ProviderImportForm, ProviderBulkEditForm, ProviderBulkDeleteForm
from .models import Circuit, Provider
from .tables import CircuitTable, CircuitBulkEditTable, ProviderTable, ProviderBulkEditTable
#
# Providers
#
def provider_list(request):
queryset = Provider.objects.annotate(count_circuits=Count('circuits'))
# Export
if 'export' in request.GET:
et = get_object_or_404(ExportTemplate, content_type__model='provider', name=request.GET.get('export'))
response = et.to_response(context_dict={'queryset': queryset}, filename='netbox_providers')
return response
if request.user.has_perm('circuits.change_provider') or request.user.has_perm('circuits.delete_provider'):
provider_table = ProviderBulkEditTable(queryset)
else:
provider_table = ProviderTable(queryset)
RequestConfig(request, paginate={'per_page': settings.PAGINATE_COUNT, 'klass': EnhancedPaginator}).configure(provider_table)
export_templates = ExportTemplate.objects.filter(content_type__model='provider')
return render(request, 'circuits/provider_list.html', {
'provider_table': provider_table,
'export_templates': export_templates,
})
def provider(request, slug):
provider = get_object_or_404(Provider, slug=slug)
circuits = Circuit.objects.filter(provider=provider).select_related('site', 'interface__device')
return render(request, 'circuits/provider.html', {
'provider': provider,
'circuits': circuits,
})
@permission_required('circuits.add_provider')
def provider_add(request):
if request.method == 'POST':
form = ProviderForm(request.POST)
if form.is_valid():
provider = form.save()
messages.success(request, "Added new provider: {0}".format(provider))
if '_addanother' in request.POST:
return redirect('circuits:provider_add')
else:
return redirect('circuits:provider', slug=provider.slug)
else:
form = ProviderForm()
return render(request, 'circuits/provider_edit.html', {
'form': form,
'cancel_url': reverse('circuits:provider_list'),
})
@permission_required('circuits.change_provider')
def provider_edit(request, slug):
provider = get_object_or_404(Provider, slug=slug)
if request.method == 'POST':
form = ProviderForm(request.POST, instance=provider)
if form.is_valid():
provider = form.save()
messages.success(request, "Modified provider {0}".format(provider))
return redirect('circuits:provider', slug=provider.slug)
else:
form = ProviderForm(instance=provider)
return render(request, 'circuits/provider_edit.html', {
'provider': provider,
'form': form,
'cancel_url': reverse('circuits:provider', kwargs={'slug': provider.slug}),
})
@permission_required('circuits.delete_provider')
def provider_delete(request, slug):
provider = get_object_or_404(Provider, slug=slug)
if request.method == 'POST':
form = ConfirmationForm(request.POST)
if form.is_valid():
try:
provider.delete()
messages.success(request, "Provider {0} has been deleted".format(provider))
return redirect('circuits:provider_list')
except ProtectedError, e:
handle_protectederror(provider, request, e)
return redirect('circuits:provider', slug=provider.slug)
else:
form = ConfirmationForm()
return render(request, 'circuits/provider_delete.html', {
'provider': provider,
'form': form,
'cancel_url': reverse('circuits:provider', kwargs={'slug': provider.slug})
})
class ProviderBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'circuits.add_provider'
form = ProviderImportForm
table = ProviderTable
template_name = 'circuits/provider_import.html'
obj_list_url = 'circuits:provider_list'
class ProviderBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'circuits.change_provider'
cls = Provider
form = ProviderBulkEditForm
template_name = 'circuits/provider_bulk_edit.html'
redirect_url = 'circuits:provider_list'
def update_objects(self, pk_list, form):
fields_to_update = {}
for field in ['asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']:
if form.cleaned_data[field]:
fields_to_update[field] = form.cleaned_data[field]
updated_count = self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
messages.success(self.request, "Updated {} providers".format(updated_count))
class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'circuits.delete_provider'
cls = Provider
form = ProviderBulkDeleteForm
template_name = 'circuits/provider_bulk_delete.html'
redirect_url = 'circuits:provider_list'
#
# Circuits
#
def circuit_list(request):
queryset = Circuit.objects.select_related('provider', 'type', 'site')
queryset = CircuitFilter(request.GET, queryset).qs
# Export
if 'export' in request.GET:
et = get_object_or_404(ExportTemplate, content_type__model='circuit', name=request.GET.get('export'))
response = et.to_response(context_dict={'queryset': queryset}, filename='netbox_circuits')
return response
if request.user.has_perm('circuits.change_circuit') or request.user.has_perm('circuits.delete_circuit'):
circuit_table = CircuitBulkEditTable(queryset)
else:
circuit_table = CircuitTable(queryset)
RequestConfig(request, paginate={'per_page': settings.PAGINATE_COUNT, 'klass': EnhancedPaginator}).configure(circuit_table)
export_templates = ExportTemplate.objects.filter(content_type__model='circuit')
return render(request, 'circuits/circuit_list.html', {
'circuit_table': circuit_table,
'export_templates': export_templates,
'filter_form': CircuitFilterForm(request.GET, label_suffix=''),
})
def circuit(request, pk):
circuit = get_object_or_404(Circuit, pk=pk)
return render(request, 'circuits/circuit.html', {
'circuit': circuit,
})
@permission_required('circuits.add_circuit')
def circuit_add(request):
if request.method == 'POST':
form = CircuitForm(request.POST)
if form.is_valid():
circuit = form.save()
messages.success(request, "Added new circuit: {0}".format(circuit))
if '_addanother' in request.POST:
return redirect('circuits:circuit_add')
else:
return redirect('circuits:circuit', pk=circuit.pk)
else:
form = CircuitForm(initial={
'site': request.GET.get('site'),
})
return render(request, 'circuits/circuit_edit.html', {
'form': form,
'cancel_url': reverse('circuits:circuit_list'),
})
@permission_required('circuits.change_circuit')
def circuit_edit(request, pk):
circuit = get_object_or_404(Circuit, pk=pk)
if request.method == 'POST':
form = CircuitForm(request.POST, instance=circuit)
if form.is_valid():
circuit = form.save()
messages.success(request, "Modified circuit {0}".format(circuit))
return redirect('circuits:circuit', pk=circuit.pk)
else:
form = CircuitForm(instance=circuit)
return render(request, 'circuits/circuit_edit.html', {
'circuit': circuit,
'form': form,
'cancel_url': reverse('circuits:circuit', kwargs={'pk': circuit.pk}),
})
@permission_required('circuits.delete_circuit')
def circuit_delete(request, pk):
circuit = get_object_or_404(Circuit, pk=pk)
if request.method == 'POST':
form = ConfirmationForm(request.POST)
if form.is_valid():
try:
circuit.delete()
messages.success(request, "Circuit {0} has been deleted".format(circuit))
return redirect('circuits:circuit_list')
except ProtectedError, e:
handle_protectederror(circuit, request, e)
return redirect('circuits:circuit', pk=circuit.pk)
else:
form = ConfirmationForm()
return render(request, 'circuits/circuit_delete.html', {
'circuit': circuit,
'form': form,
'cancel_url': reverse('circuits:circuit', kwargs={'pk': circuit.pk})
})
class CircuitBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'circuits.add_circuit'
form = CircuitImportForm
table = CircuitTable
template_name = 'circuits/circuit_import.html'
obj_list_url = 'circuits:circuit_list'
class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'circuits.change_circuit'
cls = Circuit
form = CircuitBulkEditForm
template_name = 'circuits/circuit_bulk_edit.html'
redirect_url = 'circuits:circuit_list'
def update_objects(self, pk_list, form):
fields_to_update = {}
for field in ['type', 'provider', 'port_speed', 'commit_rate', 'comments']:
if form.cleaned_data[field]:
fields_to_update[field] = form.cleaned_data[field]
updated_count = self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
messages.success(self.request, "Updated {} circuits".format(updated_count))
class CircuitBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'circuits.delete_circuit'
cls = Circuit
form = CircuitBulkDeleteForm
template_name = 'circuits/circuit_bulk_delete.html'
redirect_url = 'circuits:circuit_list'

1
netbox/dcim/__init__.py Normal file
View File

@@ -0,0 +1 @@
default_app_config = 'dcim.apps.IPAMConfig'

161
netbox/dcim/admin.py Normal file
View File

@@ -0,0 +1,161 @@
from django.contrib import admin
from django.db.models import Count
from .models import *
@admin.register(Site)
class SiteAdmin(admin.ModelAdmin):
list_display = ['name', 'slug', 'facility', 'asn']
prepopulated_fields = {
'slug': ['name'],
}
@admin.register(RackGroup)
class RackGroupAdmin(admin.ModelAdmin):
list_display = ['name', 'slug', 'site']
prepopulated_fields = {
'slug': ['name'],
}
@admin.register(Rack)
class RackAdmin(admin.ModelAdmin):
list_display = ['name', 'facility_id', 'site', 'u_height']
#
# Device types
#
@admin.register(Manufacturer)
class ManufacturerAdmin(admin.ModelAdmin):
prepopulated_fields = {
'slug': ['name'],
}
list_display = ['name', 'slug']
class ConsolePortTemplateAdmin(admin.TabularInline):
model = ConsolePortTemplate
class ConsoleServerPortTemplateAdmin(admin.TabularInline):
model = ConsoleServerPortTemplate
class PowerPortTemplateAdmin(admin.TabularInline):
model = PowerPortTemplate
class PowerOutletTemplateAdmin(admin.TabularInline):
model = PowerOutletTemplate
class InterfaceTemplateAdmin(admin.TabularInline):
model = InterfaceTemplate
@admin.register(DeviceType)
class DeviceTypeAdmin(admin.ModelAdmin):
prepopulated_fields = {
'slug': ['model'],
}
inlines = [
ConsolePortTemplateAdmin,
ConsoleServerPortTemplateAdmin,
PowerPortTemplateAdmin,
PowerOutletTemplateAdmin,
InterfaceTemplateAdmin,
]
list_display = ['model', 'manufacturer', 'slug', 'u_height', 'console_ports', 'console_server_ports', 'power_ports',
'power_outlets', 'interfaces']
list_filter = ['manufacturer']
def get_queryset(self, request):
return DeviceType.objects.annotate(
console_port_count=Count('console_port_templates', distinct=True),
cs_port_count=Count('cs_port_templates', distinct=True),
power_port_count=Count('power_port_templates', distinct=True),
power_outlet_count=Count('power_outlet_templates', distinct=True),
interface_count=Count('interface_templates', distinct=True),
)
def console_ports(self, instance):
return instance.console_port_count
def console_server_ports(self, instance):
return instance.cs_port_count
def power_ports(self, instance):
return instance.power_port_count
def power_outlets(self, instance):
return instance.power_outlet_count
def interfaces(self, instance):
return instance.interface_count
#
# Devices
#
@admin.register(DeviceRole)
class DeviceRoleAdmin(admin.ModelAdmin):
prepopulated_fields = {
'slug': ['name'],
}
list_display = ['name', 'slug', 'color']
@admin.register(Platform)
class PlatformAdmin(admin.ModelAdmin):
prepopulated_fields = {
'slug': ['name'],
}
list_display = ['name', 'rpc_client']
class ConsolePortAdmin(admin.TabularInline):
model = ConsolePort
readonly_fields = ['cs_port']
class ConsoleServerPortAdmin(admin.TabularInline):
model = ConsoleServerPort
class PowerPortAdmin(admin.TabularInline):
model = PowerPort
readonly_fields = ['power_outlet']
class PowerOutletAdmin(admin.TabularInline):
model = PowerOutlet
class InterfaceAdmin(admin.TabularInline):
model = Interface
class ModuleAdmin(admin.TabularInline):
model = Module
@admin.register(Device)
class DeviceAdmin(admin.ModelAdmin):
inlines = [
ConsolePortAdmin,
ConsoleServerPortAdmin,
PowerPortAdmin,
PowerOutletAdmin,
InterfaceAdmin,
ModuleAdmin,
]
list_display = ['display_name', 'device_type', 'device_role', 'primary_ip', 'rack', 'position', 'serial']
list_filter = ['device_role']
def get_queryset(self, request):
qs = super(DeviceAdmin, self).get_queryset(request)
return qs.select_related('device_type__manufacturer', 'device_role', 'primary_ip', 'rack')

View File

View File

@@ -0,0 +1,6 @@
from rest_framework.exceptions import APIException
class MissingFilterException(APIException):
status_code = 400
default_detail = "One or more required filters is missing from the request."

View File

@@ -0,0 +1,300 @@
from rest_framework import serializers
from ipam.models import IPAddress
from dcim.models import Site, Rack, RackGroup, Manufacturer, DeviceType, DeviceRole, Platform, Device, ConsolePort,\
ConsoleServerPort, PowerPort, PowerOutlet, Interface, InterfaceConnection, RACK_FACE_FRONT, RACK_FACE_REAR
#
# Sites
#
class SiteSerializer(serializers.ModelSerializer):
class Meta:
model = Site
fields = ['id', 'name', 'slug', 'facility', 'asn', 'physical_address', 'shipping_address', 'comments',
'count_prefixes', 'count_vlans', 'count_racks', 'count_devices', 'count_circuits']
class SiteNestedSerializer(SiteSerializer):
class Meta(SiteSerializer.Meta):
fields = ['id', 'name', 'slug']
#
# Rack groups
#
class RackGroupSerializer(serializers.ModelSerializer):
site = SiteNestedSerializer()
class Meta:
model = RackGroup
fields = ['id', 'name', 'slug', 'site']
class RackGroupNestedSerializer(SiteSerializer):
class Meta(SiteSerializer.Meta):
fields = ['id', 'name', 'slug']
#
# Racks
#
class RackSerializer(serializers.ModelSerializer):
display_name = serializers.SerializerMethodField()
site = SiteNestedSerializer()
group = RackGroupNestedSerializer()
class Meta:
model = Rack
fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'u_height', 'comments']
def get_display_name(self, obj):
return str(obj)
class RackNestedSerializer(RackSerializer):
class Meta(RackSerializer.Meta):
fields = ['id', 'name', 'facility_id', 'display_name']
class RackDetailSerializer(RackSerializer):
front_units = serializers.SerializerMethodField()
rear_units = serializers.SerializerMethodField()
class Meta(RackSerializer.Meta):
fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'u_height', 'comments', 'front_units',
'rear_units']
def get_front_units(self, obj):
units = obj.get_rack_units(face=RACK_FACE_FRONT)
for u in units:
u['device'] = DeviceNestedSerializer(u['device']).data if u['device'] else None
return units
def get_rear_units(self, obj):
units = obj.get_rack_units(face=RACK_FACE_REAR)
for u in units:
u['device'] = DeviceNestedSerializer(u['device']).data if u['device'] else None
return units
#
# Manufacturers
#
class ManufacturerSerializer(serializers.ModelSerializer):
class Meta:
model = Manufacturer
fields = ['id', 'name', 'slug']
class ManufacturerNestedSerializer(ManufacturerSerializer):
class Meta(ManufacturerSerializer.Meta):
pass
#
# Device types
#
class DeviceTypeSerializer(serializers.ModelSerializer):
manufacturer = ManufacturerNestedSerializer()
class Meta:
model = DeviceType
fields = ['id', 'manufacturer', 'model', 'slug', 'u_height', 'is_console_server', 'is_pdu', 'is_network_device']
class DeviceTypeNestedSerializer(DeviceTypeSerializer):
class Meta(DeviceTypeSerializer.Meta):
fields = ['id', 'manufacturer', 'model', 'slug']
#
# Device roles
#
class DeviceRoleSerializer(serializers.ModelSerializer):
class Meta:
model = DeviceRole
fields = ['id', 'name', 'slug', 'color']
class DeviceRoleNestedSerializer(DeviceRoleSerializer):
class Meta(DeviceRoleSerializer.Meta):
fields = ['id', 'name', 'slug']
#
# Platforms
#
class PlatformSerializer(serializers.ModelSerializer):
class Meta:
model = Platform
fields = ['id', 'name', 'slug', 'rpc_client']
class PlatformNestedSerializer(PlatformSerializer):
class Meta(PlatformSerializer.Meta):
fields = ['id', 'name', 'slug']
#
# Devices
#
# Cannot import ipam.api.IPAddressNestedSerializer due to circular dependency
class DeviceIPAddressNestedSerializer(serializers.ModelSerializer):
class Meta:
model = IPAddress
fields = ['id', 'family', 'address']
class DeviceSerializer(serializers.ModelSerializer):
device_type = DeviceTypeNestedSerializer()
device_role = DeviceRoleNestedSerializer()
platform = PlatformNestedSerializer()
rack = RackNestedSerializer()
primary_ip = DeviceIPAddressNestedSerializer()
class Meta:
model = Device
fields = ['id', 'name', 'display_name', 'device_type', 'device_role', 'platform', 'serial', 'rack', 'position',
'face', 'status', 'primary_ip', 'ro_snmp', 'comments']
class DeviceNestedSerializer(DeviceSerializer):
class Meta(DeviceSerializer.Meta):
model = Device
fields = ['id', 'name']
#
# Console server ports
#
class ConsoleServerPortSerializer(serializers.ModelSerializer):
device = DeviceNestedSerializer()
class Meta:
model = ConsoleServerPort
fields = ['id', 'device', 'name', 'connected_console']
class ConsoleServerPortNestedSerializer(ConsoleServerPortSerializer):
class Meta(ConsoleServerPortSerializer.Meta):
fields = ['id', 'device', 'name']
#
# Console ports
#
class ConsolePortSerializer(serializers.ModelSerializer):
device = DeviceNestedSerializer()
cs_port = ConsoleServerPortNestedSerializer()
class Meta:
model = ConsolePort
fields = ['id', 'device', 'name', 'cs_port', 'connection_status']
class ConsolePortNestedSerializer(ConsolePortSerializer):
class Meta(ConsolePortSerializer.Meta):
fields = ['id', 'device', 'name']
#
# Power outlets
#
class PowerOutletSerializer(serializers.ModelSerializer):
device = DeviceNestedSerializer()
class Meta:
model = PowerOutlet
fields = ['id', 'device', 'name', 'connected_port']
class PowerOutletNestedSerializer(PowerOutletSerializer):
class Meta(PowerOutletSerializer.Meta):
fields = ['id', 'device', 'name']
#
# Power ports
#
class PowerPortSerializer(serializers.ModelSerializer):
device = DeviceNestedSerializer()
power_outlet = PowerOutletNestedSerializer()
class Meta:
model = PowerPort
fields = ['id', 'device', 'name', 'power_outlet', 'connection_status']
class PowerPortNestedSerializer(PowerPortSerializer):
class Meta(PowerPortSerializer.Meta):
fields = ['id', 'device', 'name']
#
# Interfaces
#
class InterfaceSerializer(serializers.ModelSerializer):
device = DeviceNestedSerializer()
form_factor = serializers.ReadOnlyField(source='get_form_factor_display')
class Meta:
model = Interface
fields = ['id', 'device', 'name', 'form_factor', 'mgmt_only', 'description', 'is_connected']
class InterfaceNestedSerializer(InterfaceSerializer):
form_factor = serializers.ReadOnlyField(source='get_form_factor_display')
class Meta(InterfaceSerializer.Meta):
fields = ['id', 'device', 'name']
class InterfaceDetailSerializer(InterfaceSerializer):
connected_interface = InterfaceSerializer(source='get_connected_interface')
class Meta(InterfaceSerializer.Meta):
fields = ['id', 'device', 'name', 'form_factor', 'mgmt_only', 'description', 'is_connected',
'connected_interface']
#
# Interface connections
#
class InterfaceConnectionSerializer(serializers.ModelSerializer):
class Meta:
model = InterfaceConnection
fields = ['id', 'interface_a', 'interface_b', 'connection_status']

581
netbox/dcim/api/tests.py Normal file
View File

@@ -0,0 +1,581 @@
import json
from rest_framework import status
from rest_framework.test import APITestCase
class SiteTest(APITestCase):
fixtures = [
'dcim',
'ipam',
'extras',
]
standard_fields = [
'id',
'name',
'slug',
'facility',
'asn',
'physical_address',
'shipping_address',
'comments',
'count_prefixes',
'count_vlans',
'count_racks',
'count_devices',
'count_circuits'
]
nested_fields = [
'id',
'name',
'slug'
]
rack_fields = [
'id',
'name',
'facility_id',
'display_name',
'site',
'group',
'u_height',
'comments'
]
graph_fields = [
'name',
'embed_url',
'link',
]
def test_get_list(self, endpoint='/api/dcim/sites/'):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
sorted(i.keys()),
sorted(self.standard_fields),
)
def test_get_detail(self, endpoint='/api/dcim/sites/1/'):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
sorted(self.standard_fields),
)
def test_get_site_list_rack(self, endpoint='/api/dcim/sites/1/racks/'):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in json.loads(response.content):
self.assertEqual(
sorted(i.keys()),
sorted(self.rack_fields),
)
# Check Nested Serializer.
self.assertEqual(
sorted(i.get('site').keys()),
sorted(self.nested_fields),
)
def test_get_site_list_graphs(self, endpoint='/api/dcim/sites/1/graphs/'):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in json.loads(response.content):
self.assertEqual(
sorted(i.keys()),
sorted(self.graph_fields),
)
class RackTest(APITestCase):
fixtures = [
'dcim',
'ipam'
]
nested_fields = [
'id',
'name',
'facility_id',
'display_name'
]
standard_fields = [
'id',
'name',
'facility_id',
'display_name',
'site',
'group',
'u_height',
'comments'
]
detail_fields = [
'id',
'name',
'facility_id',
'display_name',
'site',
'group',
'u_height',
'comments',
'front_units',
'rear_units'
]
def test_get_list(self, endpoint='/api/dcim/racks/'):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
sorted(i.keys()),
sorted(self.standard_fields),
)
self.assertEqual(
sorted(i.get('site').keys()),
sorted(SiteTest.nested_fields),
)
def test_get_detail(self, endpoint='/api/dcim/racks/1/'):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
sorted(self.detail_fields),
)
self.assertEqual(
sorted(content.get('site').keys()),
sorted(SiteTest.nested_fields),
)
class ManufacturersTest(APITestCase):
fixtures = [
'dcim',
'ipam'
]
standard_fields = [
'id',
'name',
'slug',
]
nested_fields = standard_fields
def test_get_list(self, endpoint='/api/dcim/manufacturers/'):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
sorted(i.keys()),
sorted(self.standard_fields),
)
def test_get_detail(self, endpoint='/api/dcim/manufacturers/1/'):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
sorted(self.standard_fields),
)
class DeviceTypeTest(APITestCase):
fixtures = ['dcim', 'ipam']
standard_fields = [
'id',
'manufacturer',
'model',
'slug',
'u_height',
'is_console_server',
'is_pdu',
'is_network_device'
]
nested_fields = [
'id',
'manufacturer',
'model',
'slug'
]
def test_get_list(self, endpoint='/api/dcim/device-types/'):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
sorted(i.keys()),
sorted(self.standard_fields),
)
def test_detail_list(self, endpoint='/api/dcim/device-types/1/'):
# TODO: details returns list view.
# response = self.client.get(endpoint)
# content = json.loads(response.content)
# self.assertEqual(response.status_code, status.HTTP_200_OK)
# self.assertEqual(
# sorted(content.keys()),
# sorted(self.standard_fields),
# )
# self.assertEqual(
# sorted(content.get('manufacturer').keys()),
# sorted(ManufacturersTest.nested_fields),
# )
pass
class DeviceRolesTest(APITestCase):
fixtures = ['dcim', 'ipam']
standard_fields = ['id', 'name', 'slug', 'color']
nested_fields = ['id', 'name', 'slug']
def test_get_list(self, endpoint='/api/dcim/device-roles/'):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
sorted(i.keys()),
sorted(self.standard_fields),
)
def test_get_detail(self, endpoint='/api/dcim/device-roles/1/'):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
sorted(self.standard_fields),
)
class PlatformsTest(APITestCase):
fixtures = ['dcim', 'ipam']
standard_fields = ['id', 'name', 'slug', 'rpc_client']
nested_fields = ['id', 'name', 'slug']
def test_get_list(self, endpoint='/api/dcim/platforms/'):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
sorted(i.keys()),
sorted(self.standard_fields),
)
def test_get_detail(self, endpoint='/api/dcim/platforms/1/'):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
sorted(self.standard_fields),
)
class DeviceTest(APITestCase):
fixtures = ['dcim', 'ipam']
standard_fields = [
'id',
'name',
'display_name',
'device_type',
'device_role',
'platform',
'serial',
'rack',
'position',
'face',
'status',
'primary_ip',
'ro_snmp',
'comments',
]
nested_fields = ['id', 'name']
def test_get_list(self, endpoint='/api/dcim/devices/'):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
for device in content:
self.assertEqual(
sorted(device.keys()),
sorted(self.standard_fields),
)
self.assertEqual(
sorted(device.get('device_type')),
sorted(DeviceTypeTest.nested_fields),
)
self.assertEqual(
sorted(device.get('device_role')),
sorted(DeviceRolesTest.nested_fields),
)
if device.get('platform'):
self.assertEqual(
sorted(device.get('platform')),
sorted(PlatformsTest.nested_fields),
)
self.assertEqual(
sorted(device.get('rack')),
sorted(RackTest.nested_fields),
)
def test_get_detail(self, endpoint='/api/dcim/devices/1/'):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
sorted(self.standard_fields),
)
class ConsoleServerPortsTest(APITestCase):
fixtures = ['dcim', 'ipam']
standard_fields = ['id', 'device', 'name', 'connected_console']
nested_fields = ['id', 'device', 'name']
def test_get_list(self, endpoint='/api/dcim/devices/9/console-server-ports/'):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
for console_port in content:
self.assertEqual(
sorted(console_port.keys()),
sorted(self.standard_fields),
)
self.assertEqual(
sorted(console_port.get('device')),
sorted(DeviceTest.nested_fields),
)
class ConsolePortsTest(APITestCase):
fixtures = ['dcim', 'ipam']
standard_fields = ['id', 'device', 'name', 'cs_port', 'connection_status']
nested_fields = ['id', 'device', 'name']
def test_get_list(self, endpoint='/api/dcim/devices/1/console-ports/'):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
for console_port in content:
self.assertEqual(
sorted(console_port.keys()),
sorted(self.standard_fields),
)
self.assertEqual(
sorted(console_port.get('device')),
sorted(DeviceTest.nested_fields),
)
self.assertEqual(
sorted(console_port.get('cs_port')),
sorted(ConsoleServerPortsTest.nested_fields),
)
def test_get_detail(self, endpoint='/api/dcim/console-ports/1/'):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
sorted(self.standard_fields),
)
self.assertEqual(
sorted(content.get('device')),
sorted(DeviceTest.nested_fields),
)
class PowerPortsTest(APITestCase):
fixtures = ['dcim', 'ipam']
standard_fields = ['id', 'device', 'name', 'power_outlet', 'connection_status']
nested_fields = ['id', 'device', 'name']
def test_get_list(self, endpoint='/api/dcim/devices/1/power-ports/'):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
sorted(i.keys()),
sorted(self.standard_fields),
)
self.assertEqual(
sorted(i.get('device')),
sorted(DeviceTest.nested_fields),
)
def test_get_detail(self, endpoint='/api/dcim/power-ports/1/'):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
sorted(self.standard_fields),
)
self.assertEqual(
sorted(content.get('device')),
sorted(DeviceTest.nested_fields),
)
class PowerOutletsTest(APITestCase):
fixtures = ['dcim', 'ipam']
standard_fields = ['id', 'device', 'name', 'connected_port']
nested_fields = ['id', 'device', 'name']
def test_get_list(self, endpoint='/api/dcim/devices/11/power-outlets/'):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
sorted(i.keys()),
sorted(self.standard_fields),
)
self.assertEqual(
sorted(i.get('device')),
sorted(DeviceTest.nested_fields),
)
class InterfaceTest(APITestCase):
fixtures = ['dcim', 'ipam', 'extras']
standard_fields = [
'id',
'device',
'name',
'form_factor',
'mgmt_only',
'description',
'is_connected'
]
nested_fields = ['id', 'device', 'name']
detail_fields = [
'id',
'device',
'name',
'form_factor',
'mgmt_only',
'description',
'is_connected',
'connected_interface'
]
connection_fields = [
'id',
'interface_a',
'interface_b',
'connection_status',
]
def test_get_list(self, endpoint='/api/dcim/devices/1/interfaces/'):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
sorted(i.keys()),
sorted(self.standard_fields),
)
self.assertEqual(
sorted(i.get('device')),
sorted(DeviceTest.nested_fields),
)
def test_get_detail(self, endpoint='/api/dcim/interfaces/1/'):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
sorted(self.detail_fields),
)
self.assertEqual(
sorted(content.get('device')),
sorted(DeviceTest.nested_fields),
)
def test_get_graph_list(self, endpoint='/api/dcim/interfaces/1/graphs/'):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
sorted(i.keys()),
sorted(SiteTest.graph_fields),
)
def test_get_interface_connections(self, endpoint='/api/dcim/interface-connections/4/'):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
sorted(self.connection_fields),
)
class RelatedConnectionsTest(APITestCase):
fixtures = ['dcim', 'ipam']
standard_fields = [
'device',
'console-ports',
'power-ports',
'interfaces',
]
def test_get_list(self, endpoint=(
'/api/dcim/related-connections/'
'?peer-device=test1-edge1&peer-interface=xe-0/0/3')):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
sorted(self.standard_fields),
)

66
netbox/dcim/api/urls.py Normal file
View File

@@ -0,0 +1,66 @@
from django.conf.urls import url
from extras.models import GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
from extras.api.views import GraphListView
from .views import *
urlpatterns = [
# Sites
url(r'^sites/$', SiteListView.as_view(), name='site_list'),
url(r'^sites/(?P<pk>\d+)/$', SiteDetailView.as_view(), name='site_detail'),
url(r'^sites/(?P<pk>\d+)/graphs/$', GraphListView.as_view(), {'type': GRAPH_TYPE_SITE}, name='site_graphs'),
url(r'^sites/(?P<site>\d+)/racks/$', RackListView.as_view(), name='site_racks'),
# Rack groups
url(r'^rack-groups/$', RackGroupListView.as_view(), name='rackgroup_list'),
url(r'^rack-groups/(?P<pk>\d+)/$', RackGroupDetailView.as_view(), name='rackgroup_detail'),
# Racks
url(r'^racks/$', RackListView.as_view(), name='rack_list'),
url(r'^racks/(?P<pk>\d+)/$', RackDetailView.as_view(), name='rack_detail'),
url(r'^racks/(?P<pk>\d+)/rack-units/$', RackUnitListView.as_view(), name='rack_units'),
# Manufacturers
url(r'^manufacturers/$', ManufacturerListView.as_view(), name='manufacturer_list'),
url(r'^manufacturers/(?P<pk>\d+)/$', ManufacturerDetailView.as_view(), name='manufacturer_detail'),
# Device types
url(r'^device-types/$', DeviceTypeListView.as_view(), name='devicetype_list'),
url(r'^device-types/(?P<pk>\d+)/$', DeviceTypeDetailView.as_view(), name='devicetype_detail'),
# Device roles
url(r'^device-roles/$', DeviceRoleListView.as_view(), name='devicerole_list'),
url(r'^device-roles/(?P<pk>\d+)/$', DeviceRoleDetailView.as_view(), name='devicerole_detail'),
# Platforms
url(r'^platforms/$', PlatformListView.as_view(), name='platform_list'),
url(r'^platforms/(?P<pk>\d+)/$', PlatformDetailView.as_view(), name='platform_detail'),
# Devices
url(r'^devices/$', DeviceListView.as_view(), name='device_list'),
url(r'^devices/(?P<pk>\d+)/$', DeviceDetailView.as_view(), name='device_detail'),
url(r'^devices/(?P<pk>\d+)/lldp-neighbors/$', LLDPNeighborsView.as_view(), name='device_lldp-neighbors'),
url(r'^devices/(?P<pk>\d+)/console-ports/$', ConsolePortListView.as_view(), name='device_consoleports'),
url(r'^devices/(?P<pk>\d+)/console-server-ports/$', ConsoleServerPortListView.as_view(), name='device_consoleserverports'),
url(r'^devices/(?P<pk>\d+)/power-ports/$', PowerPortListView.as_view(), name='device_powerports'),
url(r'^devices/(?P<pk>\d+)/power-outlets/$', PowerOutletListView.as_view(), name='device_poweroutlets'),
url(r'^devices/(?P<pk>\d+)/interfaces/$', InterfaceListView.as_view(), name='device_interfaces'),
# Console ports
url(r'^console-ports/(?P<pk>\d+)/$', ConsolePortView.as_view(), name='consoleport'),
# Power ports
url(r'^power-ports/(?P<pk>\d+)/$', PowerPortView.as_view(), name='powerport'),
# Interfaces
url(r'^interfaces/(?P<pk>\d+)/$', InterfaceDetailView.as_view(), name='interface_detail'),
url(r'^interfaces/(?P<pk>\d+)/graphs/$', GraphListView.as_view(), {'type': GRAPH_TYPE_INTERFACE}, name='interface_graphs'),
url(r'^interface-connections/(?P<pk>\d+)/$', InterfaceConnectionView.as_view(), name='interfaceconnection'),
# Miscellaneous
url(r'^related-connections/$', RelatedConnectionsView.as_view(), name='related_connections'),
]

438
netbox/dcim/api/views.py Normal file
View File

@@ -0,0 +1,438 @@
from rest_framework import generics
from rest_framework.permissions import DjangoModelPermissionsOrAnonReadOnly
from rest_framework.response import Response
from rest_framework.settings import api_settings
from rest_framework.views import APIView
from django.conf import settings
from django.http import Http404
from django.shortcuts import get_object_or_404
from dcim.models import Site, Rack, RackGroup, Manufacturer, DeviceType, DeviceRole, Platform, Device, ConsolePort, \
ConsoleServerPort, PowerPort, PowerOutlet, Interface, InterfaceConnection, IFACE_FF_VIRTUAL
from dcim.filters import RackGroupFilter, RackFilter, DeviceTypeFilter, DeviceFilter, InterfaceFilter
from .exceptions import MissingFilterException
from .serializers import SiteSerializer, RackGroupSerializer, RackSerializer, RackDetailSerializer, \
ManufacturerSerializer, DeviceTypeSerializer, DeviceRoleSerializer, PlatformSerializer, DeviceSerializer, \
DeviceNestedSerializer, ConsolePortSerializer, ConsoleServerPortSerializer, PowerPortSerializer, \
PowerOutletSerializer, InterfaceSerializer, InterfaceDetailSerializer, InterfaceConnectionSerializer
from extras.api.renderers import BINDZoneRenderer
from utilities.api import ServiceUnavailable
#
# Sites
#
class SiteListView(generics.ListAPIView):
"""
List all sites
"""
queryset = Site.objects.all()
serializer_class = SiteSerializer
class SiteDetailView(generics.RetrieveAPIView):
"""
Retrieve a single site
"""
queryset = Site.objects.all()
serializer_class = SiteSerializer
#
# Rack groups
#
class RackGroupListView(generics.ListAPIView):
"""
List all rack groups
"""
queryset = RackGroup.objects.all()
serializer_class = RackGroupSerializer
filter_class = RackGroupFilter
class RackGroupDetailView(generics.RetrieveAPIView):
"""
Retrieve a single rack group
"""
queryset = RackGroup.objects.all()
serializer_class = RackGroupSerializer
#
# Racks
#
class RackListView(generics.ListAPIView):
"""
List racks (filterable)
"""
queryset = Rack.objects.select_related('site')
serializer_class = RackSerializer
filter_class = RackFilter
class RackDetailView(generics.RetrieveAPIView):
"""
Retrieve a single rack
"""
queryset = Rack.objects.select_related('site')
serializer_class = RackDetailSerializer
#
# Rack units
#
class RackUnitListView(APIView):
"""
List rack units (by rack)
"""
def get(self, request, pk):
rack = get_object_or_404(Rack, pk=pk)
face = request.GET.get('face', 0)
elevation = rack.get_rack_units(face)
# Serialize Devices within the rack elevation
for u in elevation:
if u['device']:
u['device'] = DeviceNestedSerializer(instance=u['device']).data
return Response(elevation)
#
# Manufacturers
#
class ManufacturerListView(generics.ListAPIView):
"""
List all hardware manufacturers
"""
queryset = Manufacturer.objects.all()
serializer_class = ManufacturerSerializer
class ManufacturerDetailView(generics.RetrieveAPIView):
"""
Retrieve a single hardware manufacturers
"""
queryset = Manufacturer.objects.all()
serializer_class = ManufacturerSerializer
#
# Device Types
#
class DeviceTypeListView(generics.ListAPIView):
"""
List device types (filterable)
"""
queryset = DeviceType.objects.select_related('manufacturer')
serializer_class = DeviceTypeSerializer
filter_class = DeviceTypeFilter
class DeviceTypeDetailView(generics.ListAPIView):
"""
Retrieve a single device type
"""
queryset = DeviceType.objects.select_related('manufacturer')
serializer_class = DeviceTypeSerializer
#
# Device roles
#
class DeviceRoleListView(generics.ListAPIView):
"""
List all device roles
"""
queryset = DeviceRole.objects.all()
serializer_class = DeviceRoleSerializer
class DeviceRoleDetailView(generics.RetrieveAPIView):
"""
Retrieve a single device role
"""
queryset = DeviceRole.objects.all()
serializer_class = DeviceRoleSerializer
#
# Platforms
#
class PlatformListView(generics.ListAPIView):
"""
List all platforms
"""
queryset = Platform.objects.all()
serializer_class = PlatformSerializer
class PlatformDetailView(generics.RetrieveAPIView):
"""
Retrieve a single platform
"""
queryset = Platform.objects.all()
serializer_class = PlatformSerializer
#
# Devices
#
class DeviceListView(generics.ListAPIView):
"""
List devices (filterable)
"""
queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'platform', 'rack__site')\
.prefetch_related('primary_ip__nat_outside')
serializer_class = DeviceSerializer
filter_class = DeviceFilter
renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES + [BINDZoneRenderer]
class DeviceDetailView(generics.RetrieveAPIView):
"""
Retrieve a single device
"""
queryset = Device.objects.all()
serializer_class = DeviceSerializer
#
# Console ports
#
class ConsolePortListView(generics.ListAPIView):
"""
List console ports (by device)
"""
serializer_class = ConsolePortSerializer
def get_queryset(self):
device = get_object_or_404(Device, pk=self.kwargs['pk'])
return ConsolePort.objects.filter(device=device).select_related('cs_port')
class ConsolePortView(generics.RetrieveUpdateDestroyAPIView):
permission_classes = [DjangoModelPermissionsOrAnonReadOnly]
serializer_class = ConsolePortSerializer
queryset = ConsolePort.objects.all()
#
# Console server ports
#
class ConsoleServerPortListView(generics.ListAPIView):
"""
List console server ports (by device)
"""
serializer_class = ConsoleServerPortSerializer
def get_queryset(self):
device = get_object_or_404(Device, pk=self.kwargs['pk'])
return ConsoleServerPort.objects.filter(device=device).select_related('connected_console')
#
# Power ports
#
class PowerPortListView(generics.ListAPIView):
"""
List power ports (by device)
"""
serializer_class = PowerPortSerializer
def get_queryset(self):
device = get_object_or_404(Device, pk=self.kwargs['pk'])
return PowerPort.objects.filter(device=device).select_related('power_outlet')
class PowerPortView(generics.RetrieveUpdateDestroyAPIView):
permission_classes = [DjangoModelPermissionsOrAnonReadOnly]
serializer_class = PowerPortSerializer
queryset = PowerPort.objects.all()
#
# Power outlets
#
class PowerOutletListView(generics.ListAPIView):
"""
List power outlets (by device)
"""
serializer_class = PowerOutletSerializer
def get_queryset(self):
device = get_object_or_404(Device, pk=self.kwargs['pk'])
return PowerOutlet.objects.filter(device=device).select_related('connected_port')
#
# Interfaces
#
class InterfaceListView(generics.ListAPIView):
"""
List interfaces (by device)
"""
serializer_class = InterfaceSerializer
filter_class = InterfaceFilter
def get_queryset(self):
device = get_object_or_404(Device, pk=self.kwargs['pk'])
queryset = Interface.objects.filter(device=device).select_related('connected_as_a', 'connected_as_b')
# Filter by type (physical or virtual)
iface_type = self.request.query_params.get('type')
if iface_type == 'physical':
queryset = queryset.exclude(form_factor=IFACE_FF_VIRTUAL)
elif iface_type == 'virtual':
queryset = queryset.filter(form_factor=IFACE_FF_VIRTUAL)
elif iface_type is not None:
queryset = queryset.empty()
return queryset
class InterfaceDetailView(generics.RetrieveAPIView):
"""
Retrieve a single interface
"""
queryset = Interface.objects.select_related('device')
serializer_class = InterfaceDetailSerializer
class InterfaceConnectionView(generics.RetrieveUpdateDestroyAPIView):
permission_classes = [DjangoModelPermissionsOrAnonReadOnly]
serializer_class = InterfaceConnectionSerializer
queryset = InterfaceConnection.objects.all()
#
# Live queries
#
class LLDPNeighborsView(APIView):
"""
Retrieve live LLDP neighbors of a device
"""
def get(self, request, pk):
device = get_object_or_404(Device, pk=pk)
if not device.primary_ip:
raise ServiceUnavailable(detail="No IP configured for this device.")
hostname = str(device.primary_ip.address.ip)
RPC = device.get_rpc_client()
if not RPC:
raise ServiceUnavailable(detail="No RPC client available for this platform ({}).".format(device.platform))
# Connect to device and retrieve inventory info
try:
with RPC(device, username=settings.NETBOX_USERNAME, password=settings.NETBOX_PASSWORD) as rpc_client:
lldp_neighbors = rpc_client.get_lldp_neighbors()
except:
raise ServiceUnavailable(detail="Error connecting to the remote device.")
return Response(lldp_neighbors)
#
# Miscellaneous
#
class RelatedConnectionsView(APIView):
"""
Retrieve all connections related to a given console/power/interface connection
"""
def get(self, request):
peer_device = request.GET.get('peer-device')
peer_interface = request.GET.get('peer-interface')
# Search by interface
if peer_device and peer_interface:
# Determine local interface from peer interface's connection
try:
peer_iface = Interface.objects.get(device__name=peer_device, name=peer_interface)
except Interface.DoesNotExist:
raise Http404()
local_iface = peer_iface.get_connected_interface()
if local_iface:
device = local_iface.device
else:
return Response()
else:
raise MissingFilterException(detail='Must specify search parameters (peer-device and peer-interface).')
# Initialize response skeleton
response = dict()
response['device'] = DeviceSerializer(device).data
response['console-ports'] = []
response['power-ports'] = []
response['interfaces'] = []
# Build console connections
console_ports = ConsolePort.objects.filter(device=device).select_related('cs_port__device')
for cp in console_ports:
cp_info = dict()
cp_info['name'] = cp.name
if cp.cs_port:
cp_info['console-server'] = cp.cs_port.device.name
cp_info['port'] = cp.cs_port.name
else:
cp_info['console-server'] = None
cp_info['port'] = None
response['console-ports'].append(cp_info)
# Build power connections
power_ports = PowerPort.objects.filter(device=device).select_related('power_outlet__device')
for pp in power_ports:
pp_info = dict()
pp_info['name'] = pp.name
if pp.power_outlet:
pp_info['pdu'] = pp.power_outlet.device.name
pp_info['outlet'] = pp.power_outlet.name
else:
pp_info['pdu'] = None
pp_info['outlet'] = None
response['power-ports'].append(pp_info)
# Built interface connections
interfaces = Interface.objects.filter(device=device)
for iface in interfaces:
iface_info = dict()
iface_info['name'] = iface.name
peer_interface = iface.get_connected_interface()
if peer_interface:
iface_info['device'] = peer_interface.device.name
iface_info['interface'] = peer_interface.name
else:
iface_info['device'] = None
iface_info['interface'] = None
response['interfaces'].append(iface_info)
return Response(response)

6
netbox/dcim/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class IPAMConfig(AppConfig):
name = "dcim"
verbose_name = "DCIM"

317
netbox/dcim/filters.py Normal file
View File

@@ -0,0 +1,317 @@
import django_filters
from django.db.models import Q
from .models import Site, RackGroup, Rack, Manufacturer, DeviceType, DeviceRole, Device, ConsolePort, \
ConsoleServerPort, Platform, PowerPort, PowerOutlet, Interface, InterfaceConnection
class RackGroupFilter(django_filters.FilterSet):
site_id = django_filters.ModelMultipleChoiceFilter(
name='site',
queryset=Site.objects.all(),
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
name='site',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site (slug)',
)
class Meta:
model = RackGroup
fields = ['site_id', 'site']
class RackFilter(django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
label='Search',
)
site_id = django_filters.ModelMultipleChoiceFilter(
name='site',
queryset=Site.objects.all(),
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
name='site',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site (slug)',
)
group_id = django_filters.ModelMultipleChoiceFilter(
name='group',
queryset=RackGroup.objects.all(),
label='Group (ID)',
)
group = django_filters.ModelMultipleChoiceFilter(
name='group',
queryset=RackGroup.objects.all(),
to_field_name='slug',
label='Group (slug)',
)
class Meta:
model = Rack
fields = ['q', 'site_id', 'site', 'u_height']
def search(self, queryset, value):
value = value.strip()
return queryset.filter(
Q(name__icontains=value) |
Q(facility_id__icontains=value)
)
class DeviceTypeFilter(django_filters.FilterSet):
manufacturer_id = django_filters.ModelChoiceFilter(
name='manufacturer',
queryset=Manufacturer.objects.all(),
label='Manufacturer (ID)',
)
manufacturer = django_filters.ModelChoiceFilter(
name='manufacturer',
queryset=Manufacturer.objects.all(),
to_field_name='slug',
label='Manufacturer (slug)',
)
class Meta:
model = DeviceType
fields = ['manufacturer_id', 'manufacturer', 'model', 'u_height', 'is_console_server', 'is_pdu',
'is_network_device']
class DeviceFilter(django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
label='Search',
)
site_id = django_filters.ModelMultipleChoiceFilter(
name='rack__site',
queryset=Site.objects.all(),
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
name='rack__site',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site name (slug)',
)
rack_id = django_filters.ModelMultipleChoiceFilter(
name='rack',
queryset=Rack.objects.all(),
label='Rack (ID)',
)
role_id = django_filters.ModelMultipleChoiceFilter(
name='device_role',
queryset=DeviceRole.objects.all(),
label='Role (ID)',
)
role = django_filters.ModelMultipleChoiceFilter(
name='device_role',
queryset=DeviceRole.objects.all(),
to_field_name='slug',
label='Role (slug)',
)
device_type = django_filters.ModelMultipleChoiceFilter(
name='device_type',
queryset=DeviceType.objects.all(),
label='Device type (ID)',
)
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
name='device_type__manufacturer',
queryset=Manufacturer.objects.all(),
label='Manufacturer (ID)',
)
manufacturer = django_filters.ModelMultipleChoiceFilter(
name='device_type__manufacturer',
queryset=Manufacturer.objects.all(),
to_field_name='slug',
label='Manufacturer (slug)',
)
model = django_filters.ModelMultipleChoiceFilter(
name='device_type',
queryset=DeviceType.objects.all(),
to_field_name='slug',
label='Device model (slug)',
)
platform_id = django_filters.ModelMultipleChoiceFilter(
name='platform',
queryset=Platform.objects.all(),
label='Platform (ID)',
)
platform = django_filters.ModelMultipleChoiceFilter(
name='platform',
queryset=Platform.objects.all(),
to_field_name='slug',
label='Platform (slug)',
)
is_console_server = django_filters.BooleanFilter(
name='device_type__is_console_server',
label='Is a console server',
)
is_pdu = django_filters.BooleanFilter(
name='device_type__is_pdu',
label='Is a PDU',
)
is_network_device = django_filters.BooleanFilter(
name='device_type__is_network_device',
label='Is a network device',
)
class Meta:
model = Device
fields = ['q', 'name', 'site_id', 'site', 'rack_id', 'role_id', 'role', 'device_type', 'manufacturer_id',
'manufacturer', 'model', 'platform_id', 'platform', 'is_console_server', 'is_pdu',
'is_network_device']
def search(self, queryset, value):
value = value.strip()
return queryset.filter(
Q(name__icontains=value) |
Q(serial__icontains=value) |
Q(modules__serial__icontains=value)
).distinct()
class ConsolePortFilter(django_filters.FilterSet):
device_id = django_filters.ModelMultipleChoiceFilter(
name='device',
queryset=Device.objects.all(),
label='Device (ID)',
)
device = django_filters.ModelMultipleChoiceFilter(
name='device',
queryset=Device.objects.all(),
to_field_name='name',
label='Device (name)',
)
class Meta:
model = ConsolePort
fields = ['device_id', 'device', 'name']
class ConsoleServerPortFilter(django_filters.FilterSet):
device_id = django_filters.ModelMultipleChoiceFilter(
name='device',
queryset=Device.objects.all(),
label='Device (ID)',
)
device = django_filters.ModelMultipleChoiceFilter(
name='device',
queryset=Device.objects.all(),
to_field_name='name',
label='Device (name)',
)
class Meta:
model = ConsoleServerPort
fields = ['device_id', 'device', 'name']
class PowerPortFilter(django_filters.FilterSet):
device_id = django_filters.ModelMultipleChoiceFilter(
name='device',
queryset=Device.objects.all(),
label='Device (ID)',
)
device = django_filters.ModelMultipleChoiceFilter(
name='device',
queryset=Device.objects.all(),
to_field_name='name',
label='Device (name)',
)
class Meta:
model = PowerPort
fields = ['device_id', 'device', 'name']
class PowerOutletFilter(django_filters.FilterSet):
device_id = django_filters.ModelMultipleChoiceFilter(
name='device',
queryset=Device.objects.all(),
label='Device (ID)',
)
device = django_filters.ModelMultipleChoiceFilter(
name='device',
queryset=Device.objects.all(),
to_field_name='name',
label='Device (name)',
)
class Meta:
model = PowerOutlet
fields = ['device_id', 'device', 'name']
class InterfaceFilter(django_filters.FilterSet):
device_id = django_filters.ModelMultipleChoiceFilter(
name='device',
queryset=Device.objects.all(),
label='Device (ID)',
)
device = django_filters.ModelMultipleChoiceFilter(
name='device',
queryset=Device.objects.all(),
to_field_name='name',
label='Device (name)',
)
class Meta:
model = Interface
fields = ['device_id', 'device', 'name']
class ConsoleConnectionFilter(django_filters.FilterSet):
site = django_filters.MethodFilter(
action='filter_site',
label='Site (slug)',
)
class Meta:
model = ConsoleServerPort
def filter_site(self, queryset, value):
value = value.strip()
if not value:
return queryset
return queryset.filter(cs_port__device__rack__site__slug=value)
class PowerConnectionFilter(django_filters.FilterSet):
site = django_filters.MethodFilter(
action='filter_site',
label='Site (slug)',
)
class Meta:
model = PowerOutlet
def filter_site(self, queryset, value):
value = value.strip()
if not value:
return queryset
return queryset.filter(power_outlet__device__rack__site__slug=value)
class InterfaceConnectionFilter(django_filters.FilterSet):
site = django_filters.MethodFilter(
action='filter_site',
label='Site (slug)',
)
class Meta:
model = InterfaceConnection
def filter_site(self, queryset, value):
value = value.strip()
if not value:
return queryset
return queryset.filter(
Q(interface_a__device__rack__site__slug=value) |
Q(interface_b__device__rack__site__slug=value)
)

File diff suppressed because it is too large Load Diff

953
netbox/dcim/forms.py Normal file
View File

@@ -0,0 +1,953 @@
import re
from django import forms
from django.db.models import Count, Q
from ipam.models import IPAddress
from utilities.forms import BootstrapMixin, SmallTextarea, SelectWithDisabled, ConfirmationForm, APISelect, \
Livesearch, CSVDataField, CommentField, BulkImportForm, FlexibleModelChoiceField, ExpandableNameField
from .models import Site, Rack, RackGroup, Device, Manufacturer, DeviceType, DeviceRole, Platform, ConsolePort, \
ConsoleServerPort, PowerPort, PowerOutlet, Interface, InterfaceConnection, CONNECTION_STATUS_CHOICES, \
CONNECTION_STATUS_PLANNED, CONNECTION_STATUS_CONNECTED, IFACE_FF_VIRTUAL, STATUS_CHOICES
BULK_STATUS_CHOICES = [
['', '---------'],
]
BULK_STATUS_CHOICES += STATUS_CHOICES
DEVICE_BY_PK_RE = '{\d+\}'
def get_device_by_name_or_pk(name):
"""
Attempt to retrieve a device by either its name or primary key ('{pk}').
"""
if re.match(DEVICE_BY_PK_RE, name):
pk = name.strip('{}')
device = Device.objects.get(pk=pk)
else:
device = Device.objects.get(name=name)
return device
#
# Sites
#
class SiteForm(forms.ModelForm, BootstrapMixin):
comments = CommentField()
class Meta:
model = Site
fields = ['name', 'slug', 'facility', 'asn', 'physical_address', 'shipping_address', 'comments']
widgets = {
'physical_address': SmallTextarea(attrs={'rows': 3}),
'shipping_address': SmallTextarea(attrs={'rows': 3}),
}
help_texts = {
'name': "Full name of the site",
'slug': "URL-friendly unique shorthand (e.g. 'nyc3' for NYC3)",
'facility': "Data center provider and facility (e.g. Equinix NY7)",
'asn': "BGP autonomous system number",
'physical_address': "Physical location of the building (e.g. for GPS)",
'shipping_address': "If different from the physical address"
}
class SiteFromCSVForm(forms.ModelForm):
class Meta:
model = Site
fields = ['name', 'slug', 'facility', 'asn']
class SiteImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=SiteFromCSVForm)
#
# Racks
#
class RackForm(forms.ModelForm, BootstrapMixin):
group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), required=False, label='Group', widget=APISelect(
api_url='/api/dcim/rack-groups/?site_id={{site}}',
))
comments = CommentField()
class Meta:
model = Rack
fields = ['site', 'group', 'name', 'facility_id', 'u_height', 'comments']
help_texts = {
'site': "The site at which the rack exists",
'name': "Organizational rack name",
'facility_id': "The unique rack ID assigned by the facility",
'u_height': "Height in rack units",
}
widgets = {
'site': forms.Select(attrs={'filter-for': 'group'}),
}
def __init__(self, *args, **kwargs):
super(RackForm, self).__init__(*args, **kwargs)
# Limit rack group choices
if self.is_bound and self.data.get('site'):
self.fields['group'].queryset = RackGroup.objects.filter(site__pk=self.data['site'])
elif self.initial.get('site'):
self.fields['group'].queryset = RackGroup.objects.filter(site=self.initial['site'])
else:
self.fields['group'].choices = []
class RackFromCSVForm(forms.ModelForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Site not found.'})
group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Group not found.'})
class Meta:
model = Rack
fields = ['site', 'group', 'name', 'facility_id', 'u_height']
def clean(self):
site = self.cleaned_data.get('site')
group = self.cleaned_data.get('group')
# Validate device type
if site and group:
try:
self.instance.group = RackGroup.objects.get(site=site, name=group)
except RackGroup.DoesNotExist:
self.add_error('group', "Invalid rack group ({})".format(group))
class RackImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=RackFromCSVForm)
class RackBulkEditForm(forms.Form, BootstrapMixin):
pk = forms.ModelMultipleChoiceField(queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput)
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False)
group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), required=False)
u_height = forms.IntegerField(required=False, label='Height (U)')
comments = CommentField()
class RackBulkDeleteForm(ConfirmationForm):
pk = forms.ModelMultipleChoiceField(queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput)
def rack_site_choices():
site_choices = Site.objects.annotate(rack_count=Count('racks'))
return [(s.slug, '{} ({})'.format(s.name, s.rack_count)) for s in site_choices]
def rack_group_choices():
group_choices = RackGroup.objects.select_related('site').annotate(rack_count=Count('racks'))
return [(g.slug, '{} > {} ({})'.format(g.site.name, g.name, g.rack_count)) for g in group_choices]
class RackFilterForm(forms.Form, BootstrapMixin):
site = forms.MultipleChoiceField(required=False, choices=rack_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
group = forms.MultipleChoiceField(required=False, choices=rack_group_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
#
# Devices
#
class DeviceForm(forms.ModelForm, BootstrapMixin):
site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.Select(attrs={'filter-for': 'rack'}))
rack = forms.ModelChoiceField(queryset=Rack.objects.all(), widget=APISelect(
api_url='/api/dcim/racks/?site_id={{site}}',
display_field='display_name',
attrs={'filter-for': 'position'}
))
position = forms.TypedChoiceField(required=False, empty_value=None, widget=APISelect(
api_url='/api/dcim/racks/{{rack}}/rack-units/?face={{face}}',
disabled_indicator='device',
))
manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(),
widget=forms.Select(attrs={'filter-for': 'device_type'}))
device_type = forms.ModelChoiceField(queryset=DeviceType.objects.all(), label='Model', widget=APISelect(
api_url='/api/dcim/device-types/?manufacturer_id={{manufacturer}}',
display_field='model'
))
comments = CommentField()
class Meta:
model = Device
fields = ['name', 'device_role', 'device_type', 'serial', 'site', 'rack', 'position', 'face', 'status',
'platform', 'primary_ip', 'ro_snmp', 'comments']
help_texts = {
'device_role': "The function this device serves",
'serial': "Chassis serial number",
'ro_snmp': "Read-only SNMP string",
}
widgets = {
'face': forms.Select(attrs={'filter-for': 'position'}),
'manufacturer': forms.Select(attrs={'filter-for': 'device_type'}),
}
def __init__(self, *args, **kwargs):
super(DeviceForm, self).__init__(*args, **kwargs)
if self.instance.pk:
# Initialize helper selections
self.initial['site'] = self.instance.rack.site
self.initial['manufacturer'] = self.instance.device_type.manufacturer
# Compile list of IPs assigned to this device
primary_ip_choices = []
interface_ips = IPAddress.objects.filter(interface__device=self.instance)
primary_ip_choices += [(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips]
nat_ips = IPAddress.objects.filter(nat_inside__interface__device=self.instance)\
.select_related('nat_inside__interface')
primary_ip_choices += [(ip.id, '{} ({} NAT)'.format(ip.address, ip.nat_inside.interface)) for ip in nat_ips]
self.fields['primary_ip'].choices = [(None, '---------')] + primary_ip_choices
else:
# An object that doesn't exist yet can't have any IPs assigned to it
self.fields['primary_ip'].choices = []
self.fields['primary_ip'].widget.attrs['readonly'] = True
# Limit rack choices
if self.is_bound:
self.fields['rack'].queryset = Rack.objects.filter(site__pk=self.data['site'])
elif self.initial.get('site'):
self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site'])
else:
self.fields['rack'].choices = []
# Rack position
try:
if self.is_bound and self.data.get('rack') and self.data.get('face') is not None:
position_choices = Rack.objects.get(pk=self.data['rack']).get_rack_units(face=self.data.get('face'))
elif self.initial.get('rack') and self.initial.get('face') is not None:
position_choices = Rack.objects.get(pk=self.initial['rack']).get_rack_units(face=self.initial.get('face'))
else:
position_choices = []
except Rack.DoesNotExist:
position_choices = []
self.fields['position'].choices = [('', '---------')] + [
(p['id'], {
'label': p['name'],
'disabled': bool(p['device'] and p['id'] != self.initial.get('position')),
}) for p in position_choices
]
# Limit device_type choices
if self.is_bound:
self.fields['device_type'].queryset = DeviceType.objects.filter(manufacturer__pk=self.data['manufacturer'])\
.select_related('manufacturer')
elif self.initial.get('manufacturer'):
self.fields['device_type'].queryset = DeviceType.objects.filter(manufacturer=self.initial['manufacturer'])\
.select_related('manufacturer')
else:
self.fields['device_type'].choices = []
class DeviceFromCSVForm(forms.ModelForm):
device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Invalid device role.'})
manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Invalid manufacturer.'})
model_name = forms.CharField()
platform = forms.ModelChoiceField(queryset=Platform.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'Invalid platform.'})
site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name', error_messages={
'invalid_choice': 'Invalid site name.',
})
rack_name = forms.CharField()
face = forms.ChoiceField(choices=[('front', 'Front'), ('rear', 'Rear')])
class Meta:
model = Device
fields = ['name', 'device_role', 'manufacturer', 'model_name', 'platform', 'serial', 'site', 'rack_name',
'position', 'face']
def clean(self):
manufacturer = self.cleaned_data.get('manufacturer')
model_name = self.cleaned_data.get('model_name')
site = self.cleaned_data.get('site')
rack_name = self.cleaned_data.get('rack_name')
# Validate device type
if manufacturer and model_name:
try:
self.instance.device_type = DeviceType.objects.get(manufacturer=manufacturer, model=model_name)
except DeviceType.DoesNotExist:
self.add_error('model_name', "Invalid device type ({})".format(model_name))
# Validate rack
if site and rack_name:
try:
self.instance.rack = Rack.objects.get(site=site, name=rack_name)
except Rack.DoesNotExist:
self.add_error('rack_name', "Invalid rack ({})".format(rack_name))
def clean_face(self):
face = self.cleaned_data['face']
if face.lower() == 'front':
return 0
if face.lower() == 'rear':
return 1
raise forms.ValidationError("Invalid rack face ({})".format(face))
class DeviceImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=DeviceFromCSVForm)
class DeviceBulkEditForm(forms.Form, BootstrapMixin):
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')
platform = forms.ModelChoiceField(queryset=Platform.objects.all(), required=False, label='Platform')
platform_delete = forms.BooleanField(required=False, label='Set platform to "none"')
status = forms.ChoiceField(choices=BULK_STATUS_CHOICES, required=False, initial='', label='Status')
serial = forms.CharField(max_length=50, required=False, label='Serial Number')
ro_snmp = forms.CharField(max_length=50, required=False, label='SNMP (RO)')
class DeviceBulkDeleteForm(ConfirmationForm):
pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput)
def device_site_choices():
site_choices = Site.objects.annotate(device_count=Count('racks__devices'))
return [(s.slug, '{} ({})'.format(s.name, s.device_count)) for s in site_choices]
def device_role_choices():
role_choices = DeviceRole.objects.annotate(device_count=Count('devices'))
return [(r.slug, '{} ({})'.format(r.name, r.device_count)) for r in role_choices]
def device_type_choices():
type_choices = DeviceType.objects.select_related('manufacturer').annotate(device_count=Count('instances'))
return [(t.slug, '{} ({})'.format(t, t.device_count)) for t in type_choices]
def device_platform_choices():
platform_choices = Platform.objects.annotate(device_count=Count('devices'))
return [(p.slug, '{} ({})'.format(p.name, p.device_count)) for p in platform_choices]
class DeviceFilterForm(forms.Form, BootstrapMixin):
site = forms.MultipleChoiceField(required=False, choices=device_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
role = forms.MultipleChoiceField(required=False, choices=device_role_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
type = forms.MultipleChoiceField(required=False, choices=device_type_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
platform = forms.MultipleChoiceField(required=False, choices=device_platform_choices)
#
# Console ports
#
class ConsolePortForm(forms.ModelForm, BootstrapMixin):
class Meta:
model = ConsolePort
fields = ['device', 'name']
widgets = {
'device': forms.HiddenInput(),
}
class ConsolePortCreateForm(forms.Form, BootstrapMixin):
name_pattern = ExpandableNameField(label='Name')
class ConsoleConnectionCSVForm(forms.Form):
console_server = FlexibleModelChoiceField(queryset=Device.objects.filter(device_type__is_console_server=True),
to_field_name='name',
error_messages={'invalid_choice': 'Console server not found'})
cs_port = forms.CharField()
device = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Device not found'})
console_port = forms.CharField()
status = forms.ChoiceField(choices=[('planned', 'Planned'), ('connected', 'Connected')])
def clean(self):
# Validate console server port
if self.cleaned_data.get('console_server'):
try:
cs_port = ConsoleServerPort.objects.get(device=self.cleaned_data['console_server'],
name=self.cleaned_data['cs_port'])
if ConsolePort.objects.filter(cs_port=cs_port):
raise forms.ValidationError("Console server port is already occupied (by {} {})"
.format(cs_port.connected_console.device, cs_port.connected_console))
except ConsoleServerPort.DoesNotExist:
raise forms.ValidationError("Invalid console server port ({} {})"
.format(self.cleaned_data['console_server'], self.cleaned_data['cs_port']))
# Validate console port
if self.cleaned_data.get('device'):
try:
console_port = ConsolePort.objects.get(device=self.cleaned_data['device'],
name=self.cleaned_data['console_port'])
if console_port.cs_port:
raise forms.ValidationError("Console port is already connected (to {} {})"
.format(console_port.cs_port.device, console_port.cs_port))
except ConsolePort.DoesNotExist:
raise forms.ValidationError("Invalid console port ({} {})"
.format(self.cleaned_data['device'], self.cleaned_data['console_port']))
class ConsoleConnectionImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=ConsoleConnectionCSVForm)
def clean(self):
records = self.cleaned_data.get('csv')
if not records:
return
connection_list = []
for i, record in enumerate(records, start=1):
form = self.fields['csv'].csv_form(data=record)
if form.is_valid():
console_port = ConsolePort.objects.get(device=form.cleaned_data['device'],
name=form.cleaned_data['console_port'])
console_port.cs_port = ConsoleServerPort.objects.get(device=form.cleaned_data['console_server'],
name=form.cleaned_data['cs_port'])
if form.cleaned_data['status'] == 'planned':
console_port.connection_status = CONNECTION_STATUS_PLANNED
else:
console_port.connection_status = CONNECTION_STATUS_CONNECTED
connection_list.append(console_port)
else:
for field, errors in form.errors.items():
for e in errors:
self.add_error('csv', "Record {} {}: {}".format(i, field, e))
self.cleaned_data['csv'] = connection_list
class ConsolePortConnectionForm(forms.ModelForm, BootstrapMixin):
rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False,
widget=forms.Select(attrs={'filter-for': 'console_server'}))
console_server = forms.ModelChoiceField(queryset=Device.objects.all(), label='Console Server', required=False,
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}&is_console_server=True',
attrs={'filter-for': 'cs_port'}))
livesearch = forms.CharField(required=False, label='Console Server', widget=Livesearch(
query_key='q', query_url='dcim-api:device_list', field_to_update='console_server')
)
cs_port = forms.ModelChoiceField(queryset=ConsoleServerPort.objects.all(), label='Port',
widget=APISelect(api_url='/api/dcim/devices/{{console_server}}/console-server-ports/',
disabled_indicator='connected_console'))
class Meta:
model = ConsolePort
fields = ['rack', 'console_server', 'livesearch', 'cs_port', 'connection_status']
labels = {
'cs_port': 'Port',
'connection_status': 'Status',
}
def __init__(self, *args, **kwargs):
super(ConsolePortConnectionForm, self).__init__(*args, **kwargs)
if not self.instance.pk:
raise RuntimeError("ConsolePortConnectionForm must be initialized with an existing ConsolePort instance.")
self.fields['rack'].queryset = Rack.objects.filter(site=self.instance.device.rack.site)
self.fields['cs_port'].required = True
self.fields['connection_status'].choices = CONNECTION_STATUS_CHOICES
# Initialize console server choices
if self.is_bound and self.data.get('rack'):
self.fields['console_server'].queryset = Device.objects.filter(rack=self.data['rack'], device_type__is_console_server=True)
elif self.initial.get('rack'):
self.fields['console_server'].queryset = Device.objects.filter(rack=self.initial['rack'], device_type__is_console_server=True)
else:
self.fields['console_server'].choices = []
# Initialize CS port choices
if self.is_bound:
self.fields['cs_port'].queryset = ConsoleServerPort.objects.filter(device__pk=self.data['console_server'])
elif self.initial.get('console_server', None):
self.fields['cs_port'].queryset = ConsoleServerPort.objects.filter(device__pk=self.initial['console_server'])
else:
self.fields['cs_port'].choices = []
#
# Console server ports
#
class ConsoleServerPortForm(forms.ModelForm, BootstrapMixin):
class Meta:
model = ConsoleServerPort
fields = ['device', 'name']
widgets = {
'device': forms.HiddenInput(),
}
class ConsoleServerPortCreateForm(forms.Form, BootstrapMixin):
name_pattern = ExpandableNameField(label='Name')
class ConsoleServerPortConnectionForm(forms.Form, BootstrapMixin):
rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False,
widget=forms.Select(attrs={'filter-for': 'device'}))
device = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False,
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}',
attrs={'filter-for': 'port'}))
livesearch = forms.CharField(required=False, label='Device', widget=Livesearch(
query_key='q', query_url='dcim-api:device_list', field_to_update='device')
)
port = forms.ModelChoiceField(queryset=ConsolePort.objects.all(), label='Port',
widget=APISelect(api_url='/api/dcim/devices/{{device}}/console-ports/',
disabled_indicator='cs_port'))
connection_status = forms.BooleanField(required=False, initial=CONNECTION_STATUS_CONNECTED, label='Status',
widget=forms.Select(choices=CONNECTION_STATUS_CHOICES))
class Meta:
fields = ['rack', 'device', 'livesearch', 'port', 'connection_status']
labels = {
'connection_status': 'Status',
}
def __init__(self, consoleserverport, *args, **kwargs):
super(ConsoleServerPortConnectionForm, self).__init__(*args, **kwargs)
self.fields['rack'].queryset = Rack.objects.filter(site=consoleserverport.device.rack.site)
# Initialize device choices
if self.is_bound and self.data.get('rack'):
self.fields['device'].queryset = Device.objects.filter(rack=self.data['rack'])
elif self.initial.get('rack', None):
self.fields['device'].queryset = Device.objects.filter(rack=self.initial['rack'])
else:
self.fields['device'].choices = []
# Initialize port choices
if self.is_bound:
self.fields['port'].queryset = ConsolePort.objects.filter(device__pk=self.data['device'])
elif self.initial.get('device', None):
self.fields['port'].queryset = ConsolePort.objects.filter(device_pk=self.initial['device'])
else:
self.fields['port'].choices = []
#
# Power ports
#
class PowerPortForm(forms.ModelForm, BootstrapMixin):
class Meta:
model = PowerPort
fields = ['device', 'name']
widgets = {
'device': forms.HiddenInput(),
}
class PowerPortCreateForm(forms.Form, BootstrapMixin):
name_pattern = ExpandableNameField(label='Name')
class PowerConnectionCSVForm(forms.Form):
pdu = FlexibleModelChoiceField(queryset=Device.objects.filter(device_type__is_pdu=True), to_field_name='name',
error_messages={'invalid_choice': 'PDU not found.'})
power_outlet = forms.CharField()
device = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Device not found'})
power_port = forms.CharField()
status = forms.ChoiceField(choices=[('planned', 'Planned'), ('connected', 'Connected')])
def clean(self):
# Validate power outlet
if self.cleaned_data.get('pdu'):
try:
power_outlet = PowerOutlet.objects.get(device=self.cleaned_data['pdu'],
name=self.cleaned_data['power_outlet'])
if PowerPort.objects.filter(power_outlet=power_outlet):
raise forms.ValidationError("Power outlet is already occupied (by {} {})"
.format(power_outlet.connected_console.device,
power_outlet.connected_console))
except PowerOutlet.DoesNotExist:
raise forms.ValidationError("Invalid PDU port ({} {})"
.format(self.cleaned_data['pdu'], self.cleaned_data['power_outlet']))
# Validate power port
if self.cleaned_data.get('device'):
try:
power_port = PowerPort.objects.get(device=self.cleaned_data['device'],
name=self.cleaned_data['power_port'])
if power_port.power_outlet:
raise forms.ValidationError("Power port is already connected (to {} {})"
.format(power_port.power_outlet.device, power_port.power_outlet))
except PowerPort.DoesNotExist:
raise forms.ValidationError("Invalid power port ({} {})"
.format(self.cleaned_data['device'], self.cleaned_data['power_port']))
class PowerConnectionImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=PowerConnectionCSVForm)
def clean(self):
records = self.cleaned_data.get('csv')
if not records:
return
connection_list = []
for i, record in enumerate(records, start=1):
form = self.fields['csv'].csv_form(data=record)
if form.is_valid():
power_port = PowerPort.objects.get(device=form.cleaned_data['device'],
name=form.cleaned_data['power_port'])
power_port.cs_port = PowerOutlet.objects.get(device=form.cleaned_data['pdu'],
name=form.cleaned_data['power_outlet'])
if form.cleaned_data['status'] == 'planned':
power_port.connection_status = CONNECTION_STATUS_PLANNED
else:
power_port.connection_status = CONNECTION_STATUS_CONNECTED
connection_list.append(power_port)
else:
for field, errors in form.errors.items():
for e in errors:
self.add_error('csv', "Record {} {}: {}".format(i, field, e))
self.cleaned_data['csv'] = connection_list
class PowerPortConnectionForm(forms.ModelForm, BootstrapMixin):
rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False,
widget=forms.Select(attrs={'filter-for': 'pdu'}))
pdu = forms.ModelChoiceField(queryset=Device.objects.all(), label='PDU', required=False,
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}&is_pdu=True',
attrs={'filter-for': 'power_outlet'}))
livesearch = forms.CharField(required=False, label='PDU', widget=Livesearch(
query_key='q', query_url='dcim-api:device_list', field_to_update='pdu')
)
power_outlet = forms.ModelChoiceField(queryset=PowerOutlet.objects.all(), label='Outlet',
widget=APISelect(api_url='/api/dcim/devices/{{pdu}}/power-outlets/',
disabled_indicator='connected_port'))
class Meta:
model = PowerPort
fields = ['rack', 'pdu', 'livesearch', 'power_outlet', 'connection_status']
labels = {
'power_outlet': 'Outlet',
'connection_status': 'Status',
}
def __init__(self, *args, **kwargs):
super(PowerPortConnectionForm, self).__init__(*args, **kwargs)
if not self.instance.pk:
raise RuntimeError("PowerPortConnectionForm must be initialized with an existing PowerPort instance.")
self.fields['rack'].queryset = Rack.objects.filter(site=self.instance.device.rack.site)
self.fields['power_outlet'].required = True
self.fields['connection_status'].choices = CONNECTION_STATUS_CHOICES
# Initialize PDU choices
if self.is_bound and self.data.get('rack'):
self.fields['pdu'].queryset = Device.objects.filter(rack=self.data['rack'], device_type__is_pdu=True)
elif self.initial.get('rack', None):
self.fields['pdu'].queryset = Device.objects.filter(rack=self.initial['rack'], device_type__is_pdu=True)
else:
self.fields['pdu'].choices = []
# Initialize power outlet choices
if self.is_bound:
self.fields['power_outlet'].queryset = PowerOutlet.objects.filter(device__pk=self.data['pdu'])
elif self.initial.get('pdu', None):
self.fields['power_outlet'].queryset = PowerOutlet.objects.filter(device__pk=self.initial['pdu'])
else:
self.fields['power_outlet'].choices = []
#
# Power outlets
#
class PowerOutletForm(forms.ModelForm, BootstrapMixin):
class Meta:
model = PowerOutlet
fields = ['device', 'name']
widgets = {
'device': forms.HiddenInput(),
}
class PowerOutletCreateForm(forms.Form, BootstrapMixin):
name_pattern = ExpandableNameField(label='Name')
class PowerOutletConnectionForm(forms.Form, BootstrapMixin):
rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False,
widget=forms.Select(attrs={'filter-for': 'device'}))
device = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False,
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}',
attrs={'filter-for': 'port'}))
livesearch = forms.CharField(required=False, label='Device', widget=Livesearch(
query_key='q', query_url='dcim-api:device_list', field_to_update='device')
)
port = forms.ModelChoiceField(queryset=PowerPort.objects.all(), label='Port',
widget=APISelect(api_url='/api/dcim/devices/{{device}}/power-ports/',
disabled_indicator='power_outlet'))
connection_status = forms.BooleanField(required=False, initial=CONNECTION_STATUS_CONNECTED, label='Status',
widget=forms.Select(choices=CONNECTION_STATUS_CHOICES))
class Meta:
fields = ['rack', 'device', 'livesearch', 'port', 'connection_status']
labels = {
'connection_status': 'Status',
}
def __init__(self, poweroutlet, *args, **kwargs):
super(PowerOutletConnectionForm, self).__init__(*args, **kwargs)
self.fields['rack'].queryset = Rack.objects.filter(site=poweroutlet.device.rack.site)
# Initialize device choices
if self.is_bound and self.data.get('rack'):
self.fields['device'].queryset = Device.objects.filter(rack=self.data['rack'])
elif self.initial.get('rack', None):
self.fields['device'].queryset = Device.objects.filter(rack=self.initial['rack'])
else:
self.fields['device'].choices = []
# Initialize port choices
if self.is_bound:
self.fields['port'].queryset = PowerPort.objects.filter(device__pk=self.data['device'])
elif self.initial.get('device', None):
self.fields['port'].queryset = PowerPort.objects.filter(device_pk=self.initial['device'])
else:
self.fields['port'].choices = []
#
# Interfaces
#
class InterfaceForm(forms.ModelForm, BootstrapMixin):
class Meta:
model = Interface
fields = ['device', 'name', 'form_factor', 'mgmt_only', 'description']
widgets = {
'device': forms.HiddenInput(),
}
class InterfaceCreateForm(forms.ModelForm, BootstrapMixin):
name_pattern = ExpandableNameField(label='Name')
class Meta:
model = Interface
fields = ['name_pattern', 'form_factor', 'mgmt_only', 'description']
class InterfaceBulkCreateForm(InterfaceCreateForm, BootstrapMixin):
pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput)
#
# Interface connections
#
class InterfaceConnectionForm(forms.ModelForm, BootstrapMixin):
interface_a = forms.ChoiceField(choices=[], widget=SelectWithDisabled, label='Interface')
rack_b = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False,
widget=forms.Select(attrs={'filter-for': 'device_b'}))
device_b = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False,
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack_b}}',
attrs={'filter-for': 'interface_b'}))
livesearch = forms.CharField(required=False, label='Device', widget=Livesearch(
query_key='q', query_url='dcim-api:device_list', field_to_update='device_b')
)
interface_b = forms.ModelChoiceField(queryset=Interface.objects.all(), label='Interface',
widget=APISelect(api_url='/api/dcim/devices/{{device_b}}/interfaces/?type=physical',
disabled_indicator='is_connected'))
class Meta:
model = InterfaceConnection
fields = ['interface_a', 'rack_b', 'device_b', 'interface_b', 'livesearch', 'connection_status']
def __init__(self, device_a, *args, **kwargs):
super(InterfaceConnectionForm, self).__init__(*args, **kwargs)
self.fields['rack_b'].queryset = Rack.objects.filter(site=device_a.rack.site)
# Initialize interface A choices
device_a_interfaces = Interface.objects.filter(device=device_a).exclude(form_factor=IFACE_FF_VIRTUAL) \
.select_related('circuit', 'connected_as_a', 'connected_as_b')
self.fields['interface_a'].choices = [
(iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in device_a_interfaces
]
# Initialize device_b choices if rack_b is set
if self.is_bound and self.data.get('rack_b'):
self.fields['device_b'].queryset = Device.objects.filter(rack__pk=self.data['rack_b'])
elif self.initial.get('rack_b'):
self.fields['device_b'].queryset = Device.objects.filter(rack=self.initial['rack_b'])
else:
self.fields['device_b'].choices = []
# Initialize interface_b choices if device_b is set
if self.is_bound:
device_b_interfaces = Interface.objects.filter(device=self.data['device_b']) \
.exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit', 'connected_as_a', 'connected_as_b')
elif self.initial.get('device_b'):
device_b_interfaces = Interface.objects.filter(device=self.initial['device_b']) \
.exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit', 'connected_as_a', 'connected_as_b')
else:
device_b_interfaces = []
self.fields['interface_b'].choices = [
(iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in device_b_interfaces
]
class InterfaceConnectionCSVForm(forms.Form):
device_a = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Device A not found.'})
interface_a = forms.CharField()
device_b = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Device B not found.'})
interface_b = forms.CharField()
status = forms.ChoiceField(choices=[('planned', 'Planned'), ('connected', 'Connected')])
def clean(self):
# Validate interface A
if self.cleaned_data.get('device_a'):
try:
interface_a = Interface.objects.get(device=self.cleaned_data['device_a'],
name=self.cleaned_data['interface_a'])
except Interface.DoesNotExist:
raise forms.ValidationError("Invalid interface ({} {})"
.format(self.cleaned_data['device_a'], self.cleaned_data['interface_a']))
try:
InterfaceConnection.objects.get(Q(interface_a=interface_a) | Q(interface_b=interface_a))
raise forms.ValidationError("{} {} is already connected"
.format(self.cleaned_data['device_a'], self.cleaned_data['interface_a']))
except InterfaceConnection.DoesNotExist:
pass
# Validate interface B
if self.cleaned_data.get('device_b'):
try:
interface_b = Interface.objects.get(device=self.cleaned_data['device_b'],
name=self.cleaned_data['interface_b'])
except Interface.DoesNotExist:
raise forms.ValidationError("Invalid interface ({} {})"
.format(self.cleaned_data['device_b'], self.cleaned_data['interface_b']))
try:
InterfaceConnection.objects.get(Q(interface_a=interface_b) | Q(interface_b=interface_b))
raise forms.ValidationError("{} {} is already connected"
.format(self.cleaned_data['device_b'], self.cleaned_data['interface_b']))
except InterfaceConnection.DoesNotExist:
pass
class InterfaceConnectionImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=InterfaceConnectionCSVForm)
def clean(self):
records = self.cleaned_data.get('csv')
if not records:
return
connection_list = []
for i, record in enumerate(records, start=1):
form = self.fields['csv'].csv_form(data=record)
if form.is_valid():
interface_a = Interface.objects.get(device=form.cleaned_data['device_a'],
name=form.cleaned_data['interface_a'])
interface_b = Interface.objects.get(device=form.cleaned_data['device_b'],
name=form.cleaned_data['interface_b'])
connection = InterfaceConnection(interface_a=interface_a, interface_b=interface_b)
if form.cleaned_data['status'] == 'planned':
connection.connection_status = CONNECTION_STATUS_PLANNED
else:
connection.connection_status = CONNECTION_STATUS_CONNECTED
connection_list.append(connection)
else:
for field, errors in form.errors.items():
for e in errors:
self.add_error('csv', "Record {} {}: {}".format(i, field, e))
self.cleaned_data['csv'] = connection_list
class InterfaceConnectionDeletionForm(forms.Form, BootstrapMixin):
confirm = forms.BooleanField(required=True)
# Used for HTTP redirect upon successful deletion
device = forms.ModelChoiceField(queryset=Device.objects.all(), widget=forms.HiddenInput(), required=False)
#
# Connections
#
class ConsoleConnectionFilterForm(forms.Form, BootstrapMixin):
site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug')
class PowerConnectionFilterForm(forms.Form, BootstrapMixin):
site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug')
class InterfaceConnectionFilterForm(forms.Form, BootstrapMixin):
site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug')
#
# IP addresses
#
class IPAddressForm(forms.ModelForm, BootstrapMixin):
set_as_primary = forms.BooleanField(label='Set as primary IP for device', required=False)
class Meta:
model = IPAddress
fields = ['address', 'vrf', 'interface', 'set_as_primary']
help_texts = {
'address': 'IPv4 or IPv6 address (with mask)'
}
def __init__(self, device, *args, **kwargs):
super(IPAddressForm, self).__init__(*args, **kwargs)
self.fields['vrf'].empty_label = 'Global'
self.fields['interface'].queryset = device.interfaces.all()
self.fields['interface'].required = True
# If this device does not have any IP addresses assigned, default to setting the first IP as its primary
if not IPAddress.objects.filter(interface__device=device).count():
self.fields['set_as_primary'].initial = True

View File

@@ -0,0 +1,291 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.1 on 2016-02-27 02:35
from __future__ import unicode_literals
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import utilities.fields
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='ConsolePort',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=30)),
('connection_status', models.NullBooleanField(choices=[[False, b'Planned'], [True, b'Connected']], default=True)),
],
options={
'ordering': ['device', 'name'],
},
),
migrations.CreateModel(
name='ConsolePortTemplate',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=30)),
],
options={
'ordering': ['device_type', 'name'],
},
),
migrations.CreateModel(
name='ConsoleServerPort',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=30)),
],
),
migrations.CreateModel(
name='ConsoleServerPortTemplate',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=30)),
],
options={
'ordering': ['device_type', 'name'],
},
),
migrations.CreateModel(
name='Device',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', utilities.fields.NullableCharField(blank=True, max_length=50, null=True, unique=True)),
('serial', models.CharField(blank=True, max_length=50, verbose_name=b'Serial number')),
('position', models.PositiveSmallIntegerField(blank=True, help_text=b'Number of the lowest U position occupied by the device', null=True, validators=[django.core.validators.MinValueValidator(1)], verbose_name=b'Position (U)')),
('face', models.PositiveSmallIntegerField(blank=True, choices=[[0, b'Front'], [1, b'Rear']], null=True, verbose_name=b'Rack face')),
('status', models.BooleanField(choices=[[True, b'Active'], [False, b'Offline']], default=True, verbose_name=b'Status')),
('ro_snmp', models.CharField(blank=True, max_length=50, verbose_name=b'SNMP (RO)')),
('comments', models.TextField(blank=True)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='DeviceRole',
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', models.CharField(choices=[[b'teal', b'Teal'], [b'green', b'Green'], [b'blue', b'Blue'], [b'purple', b'Purple'], [b'yellow', b'Yellow'], [b'orange', b'Orange'], [b'red', b'Red'], [b'light_gray', b'Light Gray'], [b'medium_gray', b'Medium Gray'], [b'dark_gray', b'Dark Gray']], max_length=30)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='DeviceType',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('model', models.CharField(max_length=50)),
('slug', models.SlugField()),
('u_height', models.PositiveSmallIntegerField(default=1, verbose_name=b'Height (U)')),
('is_full_depth', models.BooleanField(default=True, help_text=b'Device consumes both front and rear rack faces', verbose_name=b'Is full depth')),
('is_console_server', models.BooleanField(default=False, help_text=b'Include this type of device in lists of console servers', verbose_name=b'Is a console server')),
('is_pdu', models.BooleanField(default=False, help_text=b'Include this type of device in lists of PDUs', verbose_name=b'Is a PDU')),
('is_network_device', models.BooleanField(default=True, help_text=b'This is a network device (e.g. switch, router, etc.)', verbose_name=b'Is a network device')),
],
options={
'ordering': ['manufacturer', 'model'],
},
),
migrations.CreateModel(
name='Interface',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=30)),
('form_factor', models.PositiveSmallIntegerField(choices=[[0, b'Virtual'], [800, b'10/100M (Copper)'], [1000, b'1GE (Copper)'], [1100, b'1GE (SFP)'], [1200, b'10GE (SFP+)'], [1300, b'10GE (XFP)'], [1400, b'40GE (QSFP+)']], default=1200)),
('mgmt_only', models.BooleanField(default=False, verbose_name=b'OOB Management')),
('description', models.CharField(blank=True, max_length=100)),
('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interfaces', to='dcim.Device')),
],
options={
'ordering': ['device', 'name'],
},
),
migrations.CreateModel(
name='InterfaceConnection',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('connection_status', models.BooleanField(choices=[[False, b'Planned'], [True, b'Connected']], default=True, verbose_name=b'Status')),
('interface_a', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='connected_as_a', to='dcim.Interface')),
('interface_b', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='connected_as_b', to='dcim.Interface')),
],
),
migrations.CreateModel(
name='InterfaceTemplate',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=30)),
('form_factor', models.PositiveSmallIntegerField(choices=[[0, b'Virtual'], [800, b'10/100M (Copper)'], [1000, b'1GE (Copper)'], [1100, b'1GE (SFP)'], [1200, b'10GE (SFP+)'], [1300, b'10GE (XFP)'], [1400, b'40GE (QSFP+)']], default=1200)),
('mgmt_only', models.BooleanField(default=False, verbose_name=b'Management only')),
('device_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interface_templates', to='dcim.DeviceType')),
],
options={
'ordering': ['device_type', 'name'],
},
),
migrations.CreateModel(
name='Manufacturer',
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='Module',
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')),
('part_id', models.CharField(blank=True, max_length=50, verbose_name=b'Part ID')),
('serial', models.CharField(blank=True, max_length=50, verbose_name=b'Serial number')),
('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='modules', to='dcim.Device')),
],
options={
'ordering': ['device', 'name'],
},
),
migrations.CreateModel(
name='Platform',
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)),
('rpc_client', models.CharField(blank=True, choices=[[b'juniper-junos', b'Juniper Junos (NETCONF)'], [b'cisco-ios', b'Cisco IOS (SSH)'], [b'opengear', b'Opengear (SSH)']], max_length=30, verbose_name=b'RPC client')),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='PowerOutlet',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=30)),
('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='power_outlets', to='dcim.Device')),
],
),
migrations.CreateModel(
name='PowerOutletTemplate',
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='power_outlet_templates', to='dcim.DeviceType')),
],
options={
'ordering': ['device_type', 'name'],
},
),
migrations.CreateModel(
name='PowerPort',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=30)),
('connection_status', models.NullBooleanField(choices=[[False, b'Planned'], [True, b'Connected']], default=True)),
('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='power_ports', to='dcim.Device')),
('power_outlet', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='connected_port', to='dcim.PowerOutlet')),
],
options={
'ordering': ['device', 'name'],
},
),
migrations.CreateModel(
name='PowerPortTemplate',
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='power_port_templates', to='dcim.DeviceType')),
],
options={
'ordering': ['device_type', 'name'],
},
),
migrations.CreateModel(
name='Rack',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50)),
('facility_id', utilities.fields.NullableCharField(blank=True, max_length=30, null=True, verbose_name=b'Facility ID')),
('u_height', models.PositiveSmallIntegerField(default=42, verbose_name=b'Height (U)')),
('comments', models.TextField(blank=True)),
],
options={
'ordering': ['site', 'name'],
},
),
migrations.CreateModel(
name='RackGroup',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50)),
('slug', models.SlugField()),
],
options={
'ordering': ['site', 'name'],
},
),
migrations.CreateModel(
name='Site',
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)),
('facility', models.CharField(blank=True, max_length=50)),
('asn', models.PositiveIntegerField(blank=True, null=True, verbose_name=b'ASN')),
('physical_address', models.CharField(blank=True, max_length=200)),
('shipping_address', models.CharField(blank=True, max_length=200)),
('comments', models.TextField(blank=True)),
],
options={
'ordering': ['name'],
},
),
migrations.AddField(
model_name='rackgroup',
name='site',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rack_groups', to='dcim.Site'),
),
migrations.AddField(
model_name='rack',
name='group',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='racks', to='dcim.RackGroup'),
),
migrations.AddField(
model_name='rack',
name='site',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='racks', to='dcim.Site'),
),
migrations.AddField(
model_name='devicetype',
name='manufacturer',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='device_types', to='dcim.Manufacturer'),
),
migrations.AddField(
model_name='device',
name='device_role',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='dcim.DeviceRole'),
),
migrations.AddField(
model_name='device',
name='device_type',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='instances', to='dcim.DeviceType'),
),
migrations.AddField(
model_name='device',
name='platform',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='devices', to='dcim.Platform'),
),
]

View File

@@ -0,0 +1,114 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.1 on 2016-02-27 02:35
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('dcim', '0001_initial'),
('ipam', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='device',
name='primary_ip',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='primary_for', to='ipam.IPAddress', verbose_name=b'Primary IP'),
),
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', 'name')]),
),
migrations.AlterUniqueTogether(
name='interfacetemplate',
unique_together=set([('device_type', 'name')]),
),
migrations.AlterUniqueTogether(
name='interface',
unique_together=set([('device', 'name')]),
),
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')]),
),
]

View File

686
netbox/dcim/models.py Normal file
View File

@@ -0,0 +1,686 @@
from collections import OrderedDict
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
from django.core.urlresolvers import reverse
from django.core.validators import MinValueValidator
from django.db import models
from django.db.models import Q, ObjectDoesNotExist
from extras.rpc import RPC_CLIENTS
from secrets.models import Secret
from utilities.fields import NullableCharField
RACK_FACE_FRONT = 0
RACK_FACE_REAR = 1
RACK_FACE_CHOICES = [
[RACK_FACE_FRONT, 'Front'],
[RACK_FACE_REAR, 'Rear'],
]
COLOR_TEAL = 'teal'
COLOR_GREEN = 'green'
COLOR_BLUE = 'blue'
COLOR_PURPLE = 'purple'
COLOR_YELLOW = 'yellow'
COLOR_ORANGE = 'orange'
COLOR_RED = 'red'
COLOR_GRAY1 = 'light_gray'
COLOR_GRAY2 = 'medium_gray'
COLOR_GRAY3 = 'dark_gray'
DEVICE_ROLE_COLOR_CHOICES = [
[COLOR_TEAL, 'Teal'],
[COLOR_GREEN, 'Green'],
[COLOR_BLUE, 'Blue'],
[COLOR_PURPLE, 'Purple'],
[COLOR_YELLOW, 'Yellow'],
[COLOR_ORANGE, 'Orange'],
[COLOR_RED, 'Red'],
[COLOR_GRAY1, 'Light Gray'],
[COLOR_GRAY2, 'Medium Gray'],
[COLOR_GRAY3, 'Dark Gray'],
]
IFACE_FF_VIRTUAL = 0
IFACE_FF_100M_COPPER = 800
IFACE_FF_1GE_COPPER = 1000
IFACE_FF_SFP = 1100
IFACE_FF_SFP_PLUS = 1200
IFACE_FF_XFP = 1300
IFACE_FF_QSFP_PLUS = 1400
IFACE_FF_CHOICES = [
[IFACE_FF_VIRTUAL, 'Virtual'],
[IFACE_FF_100M_COPPER, '10/100M (Copper)'],
[IFACE_FF_1GE_COPPER, '1GE (Copper)'],
[IFACE_FF_SFP, '1GE (SFP)'],
[IFACE_FF_SFP_PLUS, '10GE (SFP+)'],
[IFACE_FF_XFP, '10GE (XFP)'],
[IFACE_FF_QSFP_PLUS, '40GE (QSFP+)'],
]
STATUS_ACTIVE = True
STATUS_OFFLINE = False
STATUS_CHOICES = [
[STATUS_ACTIVE, 'Active'],
[STATUS_OFFLINE, 'Offline'],
]
CONNECTION_STATUS_PLANNED = False
CONNECTION_STATUS_CONNECTED = True
CONNECTION_STATUS_CHOICES = [
[CONNECTION_STATUS_PLANNED, 'Planned'],
[CONNECTION_STATUS_CONNECTED, 'Connected'],
]
# For mapping platform -> NC client
RPC_CLIENT_JUNIPER_JUNOS = 'juniper-junos'
RPC_CLIENT_CISCO_IOS = 'cisco-ios'
RPC_CLIENT_OPENGEAR = 'opengear'
RPC_CLIENT_CHOICES = [
[RPC_CLIENT_JUNIPER_JUNOS, 'Juniper Junos (NETCONF)'],
[RPC_CLIENT_CISCO_IOS, 'Cisco IOS (SSH)'],
[RPC_CLIENT_OPENGEAR, 'Opengear (SSH)'],
]
class Site(models.Model):
"""
A physical site
"""
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
facility = models.CharField(max_length=50, blank=True)
asn = models.PositiveIntegerField(blank=True, null=True, verbose_name='ASN')
physical_address = models.CharField(max_length=200, blank=True)
shipping_address = models.CharField(max_length=200, blank=True)
comments = models.TextField(blank=True)
class Meta:
ordering = ['name']
def __unicode__(self):
return self.name
def get_absolute_url(self):
return reverse('dcim:site', args=[self.slug])
@property
def count_prefixes(self):
return self.prefixes.count()
@property
def count_vlans(self):
return self.vlans.count()
@property
def count_racks(self):
return Rack.objects.filter(site=self).count()
@property
def count_devices(self):
return Device.objects.filter(rack__site=self).count()
@property
def count_circuits(self):
return self.circuits.count()
class RackGroup(models.Model):
"""
An arbitrary grouping of Racks; e.g. a building or room.
"""
name = models.CharField(max_length=50)
slug = models.SlugField()
site = models.ForeignKey('Site', related_name='rack_groups')
class Meta:
ordering = ['site', 'name']
unique_together = [
['site', 'name'],
['site', 'slug'],
]
def __unicode__(self):
return self.name
class Rack(models.Model):
"""
An equipment rack within a site (e.g. a 48U rack)
"""
name = models.CharField(max_length=50)
facility_id = NullableCharField(max_length=30, blank=True, null=True, verbose_name='Facility ID')
site = models.ForeignKey('Site', related_name='racks', on_delete=models.PROTECT)
group = models.ForeignKey('RackGroup', related_name='racks', blank=True, null=True, on_delete=models.SET_NULL)
u_height = models.PositiveSmallIntegerField(default=42, verbose_name='Height (U)')
comments = models.TextField(blank=True)
class Meta:
ordering = ['site', 'name']
unique_together = [
['site', 'name'],
['site', 'facility_id'],
]
def __unicode__(self):
if self.facility_id:
return "{} ({})".format(self.name, self.facility_id)
return self.name
def get_absolute_url(self):
return reverse('dcim:rack', args=[self.pk])
@property
def units(self):
return reversed(range(1, self.u_height + 1))
def get_rack_units(self, face=RACK_FACE_FRONT, remove_redundant=False):
"""
Return a list of rack units as dictionaries. Example: {'device': None, 'face': 0, 'id': 48, 'name': 'U48'}
Each key 'device' is either a Device or None. By default, multi-U devices are repeated for each U they occupy.
:param face: Rack face (front or rear)
:param remove_redundant: If True, rack units occupied by a device already listed will be omitted
"""
elevation = OrderedDict()
for u in reversed(range(1, self.u_height + 1)):
elevation[u] = {'id': u, 'name': 'U{}'.format(u), 'face': face, 'device': None}
# Add devices to rack units list
if self.pk:
for device in Device.objects.select_related('device_type__manufacturer', 'device_role')\
.filter(rack=self, position__gt=0).filter(Q(face=face) | Q(device_type__is_full_depth=True)):
if remove_redundant:
elevation[device.position]['device'] = device
for u in range(device.position + 1, device.position + device.device_type.u_height):
elevation.pop(u, None)
else:
for u in range(device.position, device.position + device.device_type.u_height):
elevation[u]['device'] = device
return [u for u in elevation.values()]
def get_front_elevation(self):
return self.get_rack_units(face=RACK_FACE_FRONT, remove_redundant=True)
def get_rear_elevation(self):
return self.get_rack_units(face=RACK_FACE_REAR, remove_redundant=True)
def get_available_units(self, u_height=1, rack_face=None, exclude=list()):
"""
Return a list of units within the rack available to accommodate a device of a given U height (default 1).
Optionally exclude one or more devices when calculating empty units (needed when moving a device from one
position to another within a rack).
:param u_height: Minimum number of contiguous free units required
:param rack_face: The face of the rack (front or rear) required; 'None' if device is full depth
:param exclude: List of devices IDs to exclude (useful when moving a device within a rack)
"""
# Gather all devices which consume U space within the rack
devices = self.devices.select_related().filter(position__gte=1).exclude(pk__in=exclude)
# Initialize the rack unit skeleton
units = range(1, self.u_height + 1)
# Remove units consumed by installed devices
for d in devices:
if rack_face is None or d.face == rack_face or d.device_type.is_full_depth:
for u in range(d.position, d.position + d.device_type.u_height):
try:
units.remove(u)
except ValueError:
# Found overlapping devices in the rack!
pass
# Remove units without enough space above them to accommodate a device of the specified height
available_units = []
for u in units:
if set(range(u, u + u_height)).issubset(units):
available_units.append(u)
return list(reversed(available_units))
def get_0u_devices(self):
return self.devices.filter(position=0)
#
# Device Types
#
class Manufacturer(models.Model):
"""
A hardware manufacturer
"""
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
class Meta:
ordering = ['name']
def __unicode__(self):
return self.name
class DeviceType(models.Model):
"""
A unique hardware type; manufacturer and model number (e.g. Juniper EX4300-48T)
"""
manufacturer = models.ForeignKey('Manufacturer', related_name='device_types', on_delete=models.PROTECT)
model = models.CharField(max_length=50)
slug = models.SlugField()
u_height = models.PositiveSmallIntegerField(verbose_name='Height (U)', default=1)
is_full_depth = models.BooleanField(default=True, verbose_name="Is full depth",
help_text="Device consumes both front and rear rack faces")
is_console_server = models.BooleanField(default=False, verbose_name='Is a console server',
help_text="Include this type of device in lists of console servers")
is_pdu = models.BooleanField(default=False, verbose_name='Is a PDU',
help_text="Include this type of device in lists of PDUs")
is_network_device = models.BooleanField(default=True, verbose_name='Is a network device',
help_text="This is a network device (e.g. switch, router, etc.)")
class Meta:
ordering = ['manufacturer', 'model']
unique_together = [
['manufacturer', 'model'],
['manufacturer', 'slug'],
]
def __unicode__(self):
return "{0} {1}".format(self.manufacturer, self.model)
class ConsolePortTemplate(models.Model):
"""
A template for a ConsolePort to be created for a new device
"""
device_type = models.ForeignKey('DeviceType', related_name='console_port_templates', on_delete=models.CASCADE)
name = models.CharField(max_length=30)
class Meta:
ordering = ['device_type', 'name']
unique_together = ['device_type', 'name']
def __unicode__(self):
return self.name
class ConsoleServerPortTemplate(models.Model):
"""
A template for a ConsoleServerPort to be created for a new device
"""
device_type = models.ForeignKey('DeviceType', related_name='cs_port_templates', on_delete=models.CASCADE)
name = models.CharField(max_length=30)
class Meta:
ordering = ['device_type', 'name']
unique_together = ['device_type', 'name']
def __unicode__(self):
return self.name
class PowerPortTemplate(models.Model):
"""
A template for a PowerPort to be created for a new device
"""
device_type = models.ForeignKey('DeviceType', related_name='power_port_templates', on_delete=models.CASCADE)
name = models.CharField(max_length=30)
class Meta:
ordering = ['device_type', 'name']
unique_together = ['device_type', 'name']
def __unicode__(self):
return self.name
class PowerOutletTemplate(models.Model):
"""
A template for a PowerOutlet to be created for a new device
"""
device_type = models.ForeignKey('DeviceType', related_name='power_outlet_templates', on_delete=models.CASCADE)
name = models.CharField(max_length=30)
class Meta:
ordering = ['device_type', 'name']
unique_together = ['device_type', 'name']
def __unicode__(self):
return self.name
class InterfaceTemplate(models.Model):
"""
A template for a physical data interface on a new device
"""
device_type = models.ForeignKey('DeviceType', related_name='interface_templates', on_delete=models.CASCADE)
name = models.CharField(max_length=30)
form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_SFP_PLUS)
mgmt_only = models.BooleanField(default=False, verbose_name='Management only')
class Meta:
ordering = ['device_type', 'name']
unique_together = ['device_type', 'name']
def __unicode__(self):
return self.name
#
# Devices
#
class DeviceRole(models.Model):
"""
The functional role of a device (e.g. router, switch, console server, etc.)
"""
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
color = models.CharField(max_length=30, choices=DEVICE_ROLE_COLOR_CHOICES)
class Meta:
ordering = ['name']
def __unicode__(self):
return self.name
class Platform(models.Model):
"""
A class of software running on a hardware device (e.g. Juniper Junos or Cisco IOS)
"""
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
rpc_client = models.CharField(max_length=30, choices=RPC_CLIENT_CHOICES, blank=True, verbose_name='RPC client')
class Meta:
ordering = ['name']
def __unicode__(self):
return self.name
class Device(models.Model):
"""
A physical piece of equipment mounted within a rack
"""
device_type = models.ForeignKey('DeviceType', related_name='instances', on_delete=models.PROTECT)
device_role = models.ForeignKey('DeviceRole', related_name='devices', on_delete=models.PROTECT)
platform = models.ForeignKey('Platform', related_name='devices', blank=True, null=True, on_delete=models.SET_NULL)
name = NullableCharField(max_length=50, blank=True, null=True, unique=True)
serial = models.CharField(max_length=50, blank=True, verbose_name='Serial number')
rack = models.ForeignKey('Rack', related_name='devices', on_delete=models.PROTECT)
position = models.PositiveSmallIntegerField(blank=True, null=True, validators=[MinValueValidator(1)], verbose_name='Position (U)', help_text='Number of the lowest U position occupied by the device')
face = models.PositiveSmallIntegerField(blank=True, null=True, choices=RACK_FACE_CHOICES, verbose_name='Rack face')
status = models.BooleanField(choices=STATUS_CHOICES, default=STATUS_ACTIVE, verbose_name='Status')
primary_ip = models.OneToOneField('ipam.IPAddress', related_name='primary_for', on_delete=models.SET_NULL, blank=True, null=True, verbose_name='Primary IP')
ro_snmp = models.CharField(max_length=50, blank=True, verbose_name='SNMP (RO)')
comments = models.TextField(blank=True)
secrets = GenericRelation(Secret)
class Meta:
ordering = ['name']
unique_together = ['rack', 'position', 'face']
def __unicode__(self):
return self.display_name
def get_absolute_url(self):
return reverse('dcim:device', args=[self.pk])
@property
def display_name(self):
if self.name:
return self.name
elif self.position:
return "{} ({} U{})".format(self.device_type, self.rack, self.position)
else:
return "{} ({})".format(self.device_type, self.rack)
def clean(self):
# Validate position/face combination
if self.position and self.face is None:
raise ValidationError("Must specify rack face with rack position.")
# Validate rack space
rack_face = self.face if not self.device_type.is_full_depth else None
exclude_list = [self.pk] if self.pk else []
try:
available_units = self.rack.get_available_units(u_height=self.device_type.u_height, rack_face=rack_face,
exclude=exclude_list)
if self.position and self.position not in available_units:
raise ValidationError("U{} is already occupied or does not have sufficient space to accommodate a(n) "
"{} ({}U).".format(self.position, self.device_type, self.device_type.u_height))
except Rack.DoesNotExist:
pass
def save(self, *args, **kwargs):
is_new = not bool(self.pk)
super(Device, self).save(*args, **kwargs)
# If this is a new Device, instantiate all of the related components per the DeviceType definition
if is_new:
ConsolePort.objects.bulk_create(
[ConsolePort(device=self, name=template.name) for template in self.device_type.console_port_templates.all()]
)
ConsoleServerPort.objects.bulk_create(
[ConsoleServerPort(device=self, name=template.name) for template in self.device_type.cs_port_templates.all()]
)
PowerPort.objects.bulk_create(
[PowerPort(device=self, name=template.name) for template in self.device_type.power_port_templates.all()]
)
PowerOutlet.objects.bulk_create(
[PowerOutlet(device=self, name=template.name) for template in self.device_type.power_outlet_templates.all()]
)
Interface.objects.bulk_create(
[Interface(device=self, name=template.name, form_factor=template.form_factor, mgmt_only=template.mgmt_only) for template in self.device_type.interface_templates.all()]
)
def get_rpc_client(self):
"""
Return the appropriate RPC (e.g. NETCONF, ssh, etc.) client for this device's platform, if one is defined.
"""
if not self.platform:
return None
return RPC_CLIENTS.get(self.platform.rpc_client)
class ConsolePort(models.Model):
"""
A physical console port on a device
"""
device = models.ForeignKey('Device', related_name='console_ports', on_delete=models.CASCADE)
name = models.CharField(max_length=30)
cs_port = models.OneToOneField('ConsoleServerPort', related_name='connected_console', on_delete=models.SET_NULL, verbose_name='Console server port', blank=True, null=True)
connection_status = models.NullBooleanField(choices=CONNECTION_STATUS_CHOICES, default=CONNECTION_STATUS_CONNECTED)
class Meta:
ordering = ['device', 'name']
unique_together = ['device', 'name']
def __unicode__(self):
return self.name
class ConsoleServerPortManager(models.Manager):
def get_queryset(self):
"""
Include the trailing numeric portion of each port name to allow for proper ordering.
For example:
Port 1, Port 2, Port 3 ... Port 9, Port 10, Port 11 ...
Instead of:
Port 1, Port 10, Port 11 ... Port 19, Port 2, Port 20 ...
"""
return super(ConsoleServerPortManager, self).get_queryset().extra(select={
'name_as_integer': "CAST(substring(dcim_consoleserverport.name FROM '[0-9]+$') AS INTEGER)",
}).order_by('device', 'name_as_integer')
class ConsoleServerPort(models.Model):
"""
A physical port on a console server
"""
device = models.ForeignKey('Device', related_name='cs_ports', on_delete=models.CASCADE)
name = models.CharField(max_length=30)
objects = ConsoleServerPortManager()
class Meta:
unique_together = ['device', 'name']
def __unicode__(self):
return self.name
class PowerPort(models.Model):
"""
A physical power supply (intake) port on a device
"""
device = models.ForeignKey('Device', related_name='power_ports', on_delete=models.CASCADE)
name = models.CharField(max_length=30)
power_outlet = models.OneToOneField('PowerOutlet', related_name='connected_port', on_delete=models.SET_NULL, blank=True, null=True)
connection_status = models.NullBooleanField(choices=CONNECTION_STATUS_CHOICES, default=CONNECTION_STATUS_CONNECTED)
class Meta:
ordering = ['device', 'name']
unique_together = ['device', 'name']
def __unicode__(self):
return self.name
class PowerOutletManager(models.Manager):
def get_queryset(self):
return super(PowerOutletManager, self).get_queryset().extra(select={
'name_padded': "CONCAT(SUBSTRING(dcim_poweroutlet.name FROM '^[^0-9]+'), LPAD(SUBSTRING(dcim_poweroutlet.name FROM '[0-9\/]+$'), 8, '0'))",
}).order_by('device', 'name_padded')
class PowerOutlet(models.Model):
"""
A physical power outlet (output) port on a device
"""
device = models.ForeignKey('Device', related_name='power_outlets', on_delete=models.CASCADE)
name = models.CharField(max_length=30)
objects = PowerOutletManager()
class Meta:
unique_together = ['device', 'name']
def __unicode__(self):
return self.name
class InterfaceManager(models.Manager):
def get_queryset(self):
"""
Cast up to three interface slot/position IDs as independent integers and order appropriately. This ensures that
interfaces are ordered numerically without regard to type. For example:
xe-0/0/0, xe-0/0/1, xe-0/0/2 ... et-0/0/47, et-0/0/48, et-0/0/49 ...
instead of:
et-0/0/48, et-0/0/49, et-0/0/50 ... et-0/0/53, xe-0/0/0, xe-0/0/1 ...
"""
return super(InterfaceManager, self).get_queryset().extra(select={
'_id1': "CAST(SUBSTRING(dcim_interface.name FROM '([0-9]+)\/([0-9]+)\/([0-9]+)$') AS integer)",
'_id2': "CAST(SUBSTRING(dcim_interface.name FROM '([0-9]+)\/([0-9]+)$') AS integer)",
'_id3': "CAST(SUBSTRING(dcim_interface.name FROM '([0-9]+)$') AS integer)",
}).order_by('device', '_id1', '_id2', '_id3')
def virtual(self):
return self.get_queryset().filter(form_factor=IFACE_FF_VIRTUAL)
def physical(self):
return self.get_queryset().exclude(form_factor=IFACE_FF_VIRTUAL)
class Interface(models.Model):
"""
A physical data interface on a device
"""
device = models.ForeignKey('Device', related_name='interfaces', on_delete=models.CASCADE)
name = models.CharField(max_length=30)
form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_SFP_PLUS)
mgmt_only = models.BooleanField(default=False, verbose_name='OOB Management')
description = models.CharField(max_length=100, blank=True)
objects = InterfaceManager()
class Meta:
ordering = ['device', 'name']
unique_together = ['device', 'name']
def __unicode__(self):
return self.name
@property
def is_physical(self):
return self.form_factor != IFACE_FF_VIRTUAL
@property
def is_connected(self):
try:
return bool(self.circuit)
except ObjectDoesNotExist:
pass
return bool(self.connection)
@property
def connection(self):
try:
return self.connected_as_a
except ObjectDoesNotExist:
pass
try:
return self.connected_as_b
except ObjectDoesNotExist:
pass
return None
def get_connected_interface(self):
try:
connection = InterfaceConnection.objects.select_related().get(Q(interface_a=self) | Q(interface_b=self))
if connection.interface_a == self:
return connection.interface_b
else:
return connection.interface_a
except InterfaceConnection.DoesNotExist:
return None
except InterfaceConnection.MultipleObjectsReturned as e:
raise e("Multiple connections found for {0} interface {1}!".format(self.device, self))
class InterfaceConnection(models.Model):
"""
A symmetrical, one-to-one connection between two device interfaces
"""
interface_a = models.OneToOneField('Interface', related_name='connected_as_a', on_delete=models.CASCADE)
interface_b = models.OneToOneField('Interface', related_name='connected_as_b', on_delete=models.CASCADE)
connection_status = models.BooleanField(choices=CONNECTION_STATUS_CHOICES, default=CONNECTION_STATUS_CONNECTED, verbose_name='Status')
class Module(models.Model):
"""
A hardware module belonging to a device. Used for inventory purposes only.
"""
device = models.ForeignKey('Device', related_name='modules', on_delete=models.CASCADE)
name = models.CharField(max_length=50, verbose_name='Name')
part_id = models.CharField(max_length=50, verbose_name='Part ID', blank=True)
serial = models.CharField(max_length=50, verbose_name='Serial number', blank=True)
class Meta:
ordering = ['device', 'name']
unique_together = ['device', 'name']
def __unicode__(self):
return self.name

165
netbox/dcim/tables.py Normal file
View File

@@ -0,0 +1,165 @@
import django_tables2 as tables
from django_tables2.utils import Accessor
from .models import Site, Rack, Device, ConsolePort, PowerPort
PREFIXES_PER_VLAN = """
{% for p in record.prefix_set.all %}
<a href="{% url 'ipam:prefix' pk=p.pk %}">{{ p }}</a>
{% if not forloop.last %}<br />{% endif %}
{% endfor %}
"""
STATUS_LABEL = """
<span class="label label-{{ record.status.get_bootstrap_class_display|lower }}">
{{ record.status.name }}
</span>
"""
DEVICE_LINK = """
<a href="{% url 'dcim:device' pk=record.pk %}">{{ record.name|default:'<span class="label label-info">Unnamed device</span>' }}</a>
"""
#
# Sites
#
class SiteTable(tables.Table):
name = tables.LinkColumn('dcim:site', args=[Accessor('slug')], verbose_name='Name')
facility = tables.Column(verbose_name='Facility')
asn = tables.Column(verbose_name='ASN')
rack_count = tables.Column(accessor=Accessor('count_racks'), orderable=False, verbose_name='Racks')
device_count = tables.Column(accessor=Accessor('count_devices'), orderable=False, verbose_name='Devices')
prefix_count = tables.Column(accessor=Accessor('count_prefixes'), orderable=False, verbose_name='Prefixes')
vlan_count = tables.Column(accessor=Accessor('count_vlans'), orderable=False, verbose_name='VLANs')
circuit_count = tables.Column(accessor=Accessor('count_circuits'), orderable=False, verbose_name='Circuits')
class Meta:
model = Site
fields = ('name', 'facility', 'asn', 'rack_count', 'device_count', 'prefix_count', 'vlan_count', 'circuit_count')
empty_text = "No sites have been defined."
attrs = {
'class': 'table table-hover',
}
#
# Racks
#
class RackTable(tables.Table):
name = tables.LinkColumn('dcim:rack', args=[Accessor('pk')], verbose_name='Name')
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
group = tables.Column(verbose_name='Group')
facility_id = tables.Column(verbose_name='Facility ID')
u_height = tables.Column(verbose_name='Height (U)')
devices = tables.Column(accessor=Accessor('device_count'), orderable=False, verbose_name='Devices')
class Meta:
model = Rack
fields = ('name', 'site', 'group', 'facility_id', 'u_height')
empty_text = "No racks were found."
attrs = {
'class': 'table table-hover',
}
class RackBulkEditTable(RackTable):
pk = tables.CheckBoxColumn()
class Meta(RackTable.Meta):
model = None # django_tables2 bugfix
fields = ('pk', 'name', 'site', 'group', 'facility_id', 'u_height')
#
# Devices
#
class DeviceTable(tables.Table):
name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
site = tables.Column(accessor=Accessor('rack.site'), verbose_name='Site')
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
device_role = tables.Column(verbose_name='Role')
device_type = tables.Column(verbose_name='Type')
primary_ip = tables.TemplateColumn(orderable=False, verbose_name='IP Address', template_code="{{ record.primary_ip.address.ip }}")
class Meta:
model = Device
fields = ('name', 'site', 'rack', 'device_role', 'device_type', 'primary_ip')
empty_text = "No devices were found."
attrs = {
'class': 'table table-hover',
}
class DeviceBulkEditTable(DeviceTable):
pk = tables.CheckBoxColumn()
class Meta(DeviceTable.Meta):
model = None # django_tables2 bugfix
fields = ('pk', 'name', 'site', 'rack', 'device_role', 'device_type', 'primary_ip')
class DeviceImportTable(tables.Table):
name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
site = tables.Column(accessor=Accessor('rack.site'), verbose_name='Site')
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
position = tables.Column(verbose_name='Position')
device_role = tables.Column(verbose_name='Role')
device_type = tables.Column(verbose_name='Type')
class Meta:
model = Device
fields = ('name', 'site', 'rack', 'position', 'device_role', 'device_type')
attrs = {
'class': 'table table-hover',
}
#
# Device connections
#
class ConsoleConnectionTable(tables.Table):
console_server = tables.LinkColumn('dcim:device', accessor=Accessor('cs_port.device'), args=[Accessor('cs_port.device.pk')], verbose_name='Console server')
cs_port = tables.Column(verbose_name='Port')
device = tables.LinkColumn('dcim:device', args=[Accessor('device.pk')], verbose_name='Device')
name = tables.Column(verbose_name='Console port')
class Meta:
model = ConsolePort
fields = ('console_server', 'cs_port', 'device', 'name')
attrs = {
'class': 'table table-hover',
}
class PowerConnectionTable(tables.Table):
pdu = tables.LinkColumn('dcim:device', accessor=Accessor('power_outlet.device'), args=[Accessor('power_outlet.device.pk')], verbose_name='PDU')
power_outlet = tables.Column(verbose_name='Outlet')
device = tables.LinkColumn('dcim:device', args=[Accessor('device.pk')], verbose_name='Device')
name = tables.Column(verbose_name='Console port')
class Meta:
model = PowerPort
fields = ('pdu', 'power_outlet', 'device', 'name')
attrs = {
'class': 'table table-hover',
}
class InterfaceConnectionTable(tables.Table):
device_a = tables.LinkColumn('dcim:device', accessor=Accessor('interface_a.device'), args=[Accessor('interface_a.device.pk')], verbose_name='Device A')
interface_a = tables.Column(verbose_name='Interface A')
device_b = tables.LinkColumn('dcim:device', accessor=Accessor('interface_b.device'), args=[Accessor('interface_b.device.pk')], verbose_name='Device B')
interface_b = tables.Column(verbose_name='Interface B')
class Meta:
model = PowerPort
fields = ('device_a', 'interface_a', 'device_b', 'interface_b')
attrs = {
'class': 'table table-hover',
}

View File

View File

@@ -0,0 +1,71 @@
from django.test import TestCase
from dcim.forms import *
from dcim.models import *
def get_id(model, slug):
return model.objects.get(slug=slug).id
class DeviceTestCase(TestCase):
fixtures = ['dcim', 'ipam']
def test_racked_device(self):
test = DeviceForm(data={
'device_role': get_id(DeviceRole, 'leaf-switch'),
'name': 'test',
'site': get_id(Site, 'test1'),
'face': RACK_FACE_FRONT,
'platform': get_id(Platform, 'juniper-junos'),
'device_type': get_id(DeviceType, 'qfx5100-48s'),
'position': 41,
'rack': '1',
'manufacturer': get_id(Manufacturer, 'juniper'),
})
self.assertTrue(test.is_valid(), test.fields['position'].choices)
self.assertTrue(test.save())
def test_racked_device_occupied(self):
test = DeviceForm(data={
'device_role': get_id(DeviceRole, 'leaf-switch'),
'name': 'test',
'site': get_id(Site, 'test1'),
'face': RACK_FACE_FRONT,
'platform': get_id(Platform, 'juniper-junos'),
'device_type': get_id(DeviceType, 'qfx5100-48s'),
'position': 1,
'rack': '1',
'manufacturer': get_id(Manufacturer, 'juniper'),
})
self.assertFalse(test.is_valid())
def test_non_racked_device(self):
test = DeviceForm(data={
'device_role': get_id(DeviceRole, 'pdu'),
'name': 'test',
'site': get_id(Site, 'test1'),
'face': None,
'platform': None,
'device_type': get_id(DeviceType, 'cwg-24vym415c9'),
'position': None,
'rack': '1',
'manufacturer': get_id(Manufacturer, 'servertech'),
})
self.assertTrue(test.is_valid())
self.assertTrue(test.save())
def test_non_racked_device_with_face(self):
test = DeviceForm(data={
'device_role': get_id(DeviceRole, 'pdu'),
'name': 'test',
'site': get_id(Site, 'test1'),
'face': RACK_FACE_REAR,
'platform': None,
'device_type': get_id(DeviceType, 'cwg-24vym415c9'),
'position': None,
'rack': '1',
'manufacturer': get_id(Manufacturer, 'servertech'),
})
self.assertTrue(test.is_valid())
self.assertTrue(test.save())

View File

@@ -0,0 +1,96 @@
from django.test import TestCase
from dcim.models import *
class RackTestCase(TestCase):
def setUp(self):
site = Site.objects.create(
name='TestSite1',
slug='my-test-site'
)
self.rack = Rack.objects.create(
name='TestRack1',
facility_id='A101',
site=site,
u_height=42
)
self.manufacturer = Manufacturer.objects.create(
name='Acme',
slug='acme'
)
self.device_type = {
'ff2048': DeviceType.objects.create(
manufacturer=self.manufacturer,
model='FrameForwarder 2048',
slug='ff2048'
),
'cc5000': DeviceType.objects.create(
manufacturer=self.manufacturer,
model='CurrentCatapult 5000',
slug='cc5000',
u_height=0
),
}
self.role = {
'Server': DeviceRole.objects.create(
name='Server',
slug='server',
),
'Switch': DeviceRole.objects.create(
name='Switch',
slug='switch',
),
'Console Server': DeviceRole.objects.create(
name='Console Server',
slug='console-server',
),
'PDU': DeviceRole.objects.create(
name='PDU',
slug='pdu',
),
}
def test_mount_single_device(self):
rack1 = Rack.objects.get(name='TestRack1')
device1 = Device(
name='TestSwitch1',
device_type=DeviceType.objects.get(manufacturer__slug='acme', slug='ff2048'),
device_role=DeviceRole.objects.get(slug='switch'),
rack=rack1,
position=10,
face=RACK_FACE_REAR,
)
device1.save()
# Validate rack height
self.assertEqual(list(rack1.units), list(reversed(range(1, 43))))
# Validate inventory (front face)
rack1_inventory_front = rack1.get_front_elevation()
self.assertEqual(rack1_inventory_front[-10]['device'], device1)
del(rack1_inventory_front[-10])
for u in rack1_inventory_front:
self.assertIsNone(u['device'])
# Validate inventory (rear face)
rack1_inventory_rear = rack1.get_rear_elevation()
self.assertEqual(rack1_inventory_rear[-10]['device'], device1)
del(rack1_inventory_rear[-10])
for u in rack1_inventory_rear:
self.assertIsNone(u['device'])
def test_mount_zero_ru(self):
pdu = Device.objects.create(
name='TestPDU',
device_role=self.role.get('PDU'),
device_type=self.device_type.get('cc5000'),
rack=self.rack,
position=None,
face=None,
)
self.assertTrue(pdu)

86
netbox/dcim/urls.py Normal file
View File

@@ -0,0 +1,86 @@
from django.conf.urls import url
from secrets.views import secret_add
from . import views
urlpatterns = [
# Sites
url(r'^sites/$', views.site_list, name='site_list'),
url(r'^sites/add/$', views.site_add, name='site_add'),
url(r'^sites/import/$', views.SiteBulkImportView.as_view(), name='site_import'),
url(r'^sites/(?P<slug>[\w-]+)/$', views.site, name='site'),
url(r'^sites/(?P<slug>[\w-]+)/edit/$', views.site_edit, name='site_edit'),
url(r'^sites/(?P<slug>[\w-]+)/delete/$', views.site_delete, name='site_delete'),
# Racks
url(r'^racks/$', views.rack_list, name='rack_list'),
url(r'^racks/add/$', views.rack_add, name='rack_add'),
url(r'^racks/import/$', views.RackBulkImportView.as_view(), name='rack_import'),
url(r'^racks/edit/$', views.RackBulkEditView.as_view(), name='rack_bulk_edit'),
url(r'^racks/delete/$', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'),
url(r'^racks/(?P<pk>\d+)/$', views.rack, name='rack'),
url(r'^racks/(?P<pk>\d+)/edit/$', views.rack_edit, name='rack_edit'),
url(r'^racks/(?P<pk>\d+)/delete/$', views.rack_delete, name='rack_delete'),
# Devices
url(r'^devices/$', views.device_list, name='device_list'),
url(r'^devices/add/$', views.device_add, name='device_add'),
url(r'^devices/import/$', views.DeviceBulkImportView.as_view(), name='device_import'),
url(r'^devices/edit/$', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'),
url(r'^devices/delete/$', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'),
url(r'^devices/(?P<pk>\d+)/$', views.device, name='device'),
url(r'^devices/(?P<pk>\d+)/edit/$', views.device_edit, name='device_edit'),
url(r'^devices/(?P<pk>\d+)/delete/$', views.device_delete, name='device_delete'),
url(r'^devices/(?P<pk>\d+)/inventory/$', views.device_inventory, name='device_inventory'),
url(r'^devices/(?P<pk>\d+)/lldp-neighbors/$', views.device_lldp_neighbors, name='device_lldp_neighbors'),
url(r'^devices/(?P<pk>\d+)/ip-addresses/assign/$', views.ipaddress_assign, name='ipaddress_assign'),
url(r'^devices/(?P<parent_pk>\d+)/add-secret/$', secret_add, {'parent_model': 'dcim.Device'},
name='device_addsecret'),
# Console ports
url(r'^devices/(?P<pk>\d+)/console-ports/add/$', views.consoleport_add, name='consoleport_add'),
url(r'^console-ports/(?P<pk>\d+)/connect/$', views.consoleport_connect, name='consoleport_connect'),
url(r'^console-ports/(?P<pk>\d+)/disconnect/$', views.consoleport_disconnect, name='consoleport_disconnect'),
url(r'^console-ports/(?P<pk>\d+)/edit/$', views.consoleport_edit, name='consoleport_edit'),
url(r'^console-ports/(?P<pk>\d+)/delete/$', views.consoleport_delete, name='consoleport_delete'),
# Console server ports
url(r'^devices/(?P<pk>\d+)/console-server-ports/add/$', views.consoleserverport_add, name='consoleserverport_add'),
url(r'^console-server-ports/(?P<pk>\d+)/connect/$', views.consoleserverport_connect, name='consoleserverport_connect'),
url(r'^console-server-ports/(?P<pk>\d+)/disconnect/$', views.consoleserverport_disconnect, name='consoleserverport_disconnect'),
url(r'^console-server-ports/(?P<pk>\d+)/edit/$', views.consoleserverport_edit, name='consoleserverport_edit'),
url(r'^console-server-ports/(?P<pk>\d+)/delete/$', views.consoleserverport_delete, name='consoleserverport_delete'),
# Power ports
url(r'^devices/(?P<pk>\d+)/power-ports/add/$', views.powerport_add, name='powerport_add'),
url(r'^power-ports/(?P<pk>\d+)/connect/$', views.powerport_connect, name='powerport_connect'),
url(r'^power-ports/(?P<pk>\d+)/disconnect/$', views.powerport_disconnect, name='powerport_disconnect'),
url(r'^power-ports/(?P<pk>\d+)/edit/$', views.powerport_edit, name='powerport_edit'),
url(r'^power-ports/(?P<pk>\d+)/delete/$', views.powerport_delete, name='powerport_delete'),
# Power outlets
url(r'^devices/(?P<pk>\d+)/power-outlets/add/$', views.poweroutlet_add, name='poweroutlet_add'),
url(r'^power-outlets/(?P<pk>\d+)/connect/$', views.poweroutlet_connect, name='poweroutlet_connect'),
url(r'^power-outlets/(?P<pk>\d+)/disconnect/$', views.poweroutlet_disconnect, name='poweroutlet_disconnect'),
url(r'^power-outlets/(?P<pk>\d+)/edit/$', views.poweroutlet_edit, name='poweroutlet_edit'),
url(r'^power-outlets/(?P<pk>\d+)/delete/$', views.poweroutlet_delete, name='poweroutlet_delete'),
# Console/power/interface connections
url(r'^console-connections/$', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'),
url(r'^console-connections/import/$', views.ConsoleConnectionsBulkImportView.as_view(), name='console_connections_import'),
url(r'^power-connections/$', views.PowerConnectionsListView.as_view(), name='power_connections_list'),
url(r'^power-connections/import/$', views.PowerConnectionsBulkImportView.as_view(), name='power_connections_import'),
url(r'^interface-connections/$', views.InterfaceConnectionsListView.as_view(), name='interface_connections_list'),
url(r'^interface-connections/import/$', views.InterfaceConnectionsBulkImportView.as_view(), name='interface_connections_import'),
# Interfaces
url(r'^devices/interfaces/add/$', views.InterfaceBulkAddView.as_view(), name='interface_bulk_add'),
url(r'^devices/(?P<pk>\d+)/interfaces/add/$', views.interface_add, name='interface_add'),
url(r'^devices/(?P<pk>\d+)/interface-connections/add/$', views.interfaceconnection_add, name='interfaceconnection_add'),
url(r'^interface-connections/(?P<pk>\d+)/delete/$', views.interfaceconnection_delete, name='interfaceconnection_delete'),
url(r'^interfaces/(?P<pk>\d+)/edit/$', views.interface_edit, name='interface_edit'),
url(r'^interfaces/(?P<pk>\d+)/delete/$', views.interface_delete, name='interface_delete'),
]

1444
netbox/dcim/views.py Normal file

File diff suppressed because it is too large Load Diff

View File

13
netbox/extras/admin.py Normal file
View File

@@ -0,0 +1,13 @@
from django.contrib import admin
from .models import Graph, ExportTemplate
@admin.register(Graph)
class GraphAdmin(admin.ModelAdmin):
list_display = ['name', 'type', 'weight', 'source']
@admin.register(ExportTemplate)
class ExportTemplateAdmin(admin.ModelAdmin):
list_display = ['content_type', 'name', 'mime_type', 'file_extension']

View File

View File

@@ -0,0 +1,31 @@
from rest_framework import renderers
# IP address family designations
AF = {
4: 'A',
6: 'AAAA',
}
class BINDZoneRenderer(renderers.BaseRenderer):
"""
Generate a BIND zone file from a list of DNS records.
Required fields: `name`, `primary_ip`
"""
media_type = 'text/plain'
format = 'bind-zone'
def render(self, data, media_type=None, renderer_context=None):
records = []
for record in data:
if record.get('name') and record.get('primary_ip'):
try:
records.append("{} IN {} {}".format(
record['name'],
AF[record['primary_ip']['family']],
record['primary_ip']['address'].split('/')[0],
))
except KeyError:
pass
return '\n'.join(records)

View File

@@ -0,0 +1,14 @@
from rest_framework import serializers
from extras.models import Graph
class GraphSerializer(serializers.ModelSerializer):
embed_url = serializers.SerializerMethodField()
class Meta:
model = Graph
fields = ['name', 'embed_url', 'link']
def get_embed_url(self, obj):
return obj.embed_url(self.context['graphed_object'])

View File

@@ -0,0 +1,33 @@
from rest_framework import generics
from django.http import Http404
from django.shortcuts import get_object_or_404
from circuits.models import Provider
from dcim.models import Site, Interface
from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_PROVIDER, GRAPH_TYPE_SITE
from .serializers import GraphSerializer
class GraphListView(generics.ListAPIView):
"""
Returns a list of relevant graphs
"""
serializer_class = GraphSerializer
def get_serializer_context(self):
cls = {
GRAPH_TYPE_INTERFACE: Interface,
GRAPH_TYPE_PROVIDER: Provider,
GRAPH_TYPE_SITE: Site,
}
context = super(GraphListView, self).get_serializer_context()
context.update({'graphed_object': get_object_or_404(cls[self.kwargs.get('type')], pk=self.kwargs['pk'])})
return context
def get_queryset(self):
graph_type = self.kwargs.get('type', None)
if not graph_type:
raise Http404()
queryset = Graph.objects.filter(type=graph_type)
return queryset

View File

@@ -0,0 +1,12 @@
- model: extras.graph
pk: 1
fields: {type: 300, weight: 1000, name: Site Test Graph, source: 'http://localhost/na.png',
link: ''}
- model: extras.graph
pk: 2
fields: {type: 200, weight: 1000, name: Provider Test Graph, source: 'http://localhost/provider_graph.png',
link: ''}
- model: extras.graph
pk: 3
fields: {type: 100, weight: 1000, name: Interface Test Graph, source: 'http://localhost/interface_graph.png',
link: ''}

View File

View File

@@ -0,0 +1,117 @@
from Exscript.protocols.Exception import LoginFailure
from getpass import getpass
from ncclient.transport.errors import AuthenticationError
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from dcim.models import Device, Module, Site
class Command(BaseCommand):
help = "Update inventory information for specified devices"
username = settings.NETBOX_USERNAME
password = settings.NETBOX_PASSWORD
def add_arguments(self, parser):
parser.add_argument('-u', '--username', dest='username', help="Specify the username to use")
parser.add_argument('-p', '--password', action='store_true', default=False, help="Prompt for password to use")
parser.add_argument('-s', '--site', dest='site', action='append', help="Filter devices by site (include argument once per site)")
parser.add_argument('-n', '--name', dest='name', help="Filter devices by name (regular expression)")
parser.add_argument('--full', action='store_true', default=False, help="For inventory update for all devices")
parser.add_argument('--fake', action='store_true', default=False, help="Do not actually update database")
def handle(self, *args, **options):
# Credentials
if options['username']:
self.username = options['username']
if options['password']:
self.password = getpass("Password: ")
device_list = Device.objects.filter()
# --site: Include only devices belonging to specified site(s)
if options['site']:
sites = Site.objects.filter(slug__in=options['site'])
if sites:
site_names = [s.name for s in sites]
self.stdout.write("Running inventory for these sites: {}".format(', '.join(site_names)))
else:
raise CommandError("One or more sites specified but none found.")
device_list = device_list.filter(rack__site__in=sites)
# --name: Filter devices by name matching a regex
if options['name']:
device_list = device_list.filter(name__iregex=options['name'])
# --full: Gather inventory data for *all* devices
if options['full']:
self.stdout.write("WARNING: Running inventory for all devices! Prior data will be overwritten. (--full)")
# --fake: Gathering data but not updating the database
if options['fake']:
self.stdout.write("WARNING: Inventory data will not be saved! (--fake)")
device_count = device_list.count()
self.stdout.write("** Found {} devices...".format(device_count))
for i, device in enumerate(device_list, start=1):
self.stdout.write("[{}/{}] {}: ".format(i, device_count, device.name), ending='')
# Skip inactive devices
if not device.status:
self.stdout.write("Skipped (inactive)")
continue
# Skip devices without primary_ip set
if not device.primary_ip:
self.stdout.write("Skipped (no primary IP set)")
continue
# Skip devices which have already been inventoried if not doing a full update
if device.serial and not options['full']:
self.stdout.write("Skipped (Serial: {})".format(device.serial))
continue
RPC = device.get_rpc_client()
if not RPC:
self.stdout.write("Skipped (no RPC client available for platform {})".format(device.platform))
continue
# Connect to device and retrieve inventory info
try:
with RPC(device, self.username, self.password) as rpc_client:
inventory = rpc_client.get_inventory()
except KeyboardInterrupt:
raise
except (AuthenticationError, LoginFailure):
self.stdout.write("Authentication error!")
continue
except Exception as e:
self.stdout.write("Error for {} ({}): {}".format(device, device.primary_ip.address.ip, e))
continue
self.stdout.write("")
self.stdout.write("\tSerial: {}".format(inventory['chassis']['serial']))
self.stdout.write("\tDescription: {}".format(inventory['chassis']['description']))
for module in inventory['modules']:
self.stdout.write("\tModule: {} / {} ({})".format(module['name'], module['part_id'], module['serial']))
if not options['fake']:
with transaction.atomic():
if inventory['chassis']['serial']:
device.serial = inventory['chassis']['serial']
device.save()
Module.objects.filter(device=device).delete()
modules = []
for module in inventory['modules']:
modules.append(Module(device=device,
name=module['name'],
part_id=module['part_id'],
serial=module['serial']))
Module.objects.bulk_create(modules)
self.stdout.write("Finished!")

View File

@@ -0,0 +1,50 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.1 on 2016-02-27 02:35
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('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=200)),
('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')),
],
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, b'Interface'), (200, b'Provider'), (300, b'Site')])),
('weight', models.PositiveSmallIntegerField(default=1000)),
('name', models.CharField(max_length=100, verbose_name=b'Name')),
('source', models.CharField(max_length=500, verbose_name=b'Source URL')),
('link', models.URLField(blank=True, verbose_name=b'Link URL')),
],
options={
'ordering': ['type', 'weight', 'name'],
},
),
migrations.AlterUniqueTogether(
name='exporttemplate',
unique_together=set([('content_type', 'name')]),
),
]

View File

70
netbox/extras/models.py Normal file
View File

@@ -0,0 +1,70 @@
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.http import HttpResponse
from django.template import Template, Context
GRAPH_TYPE_INTERFACE = 100
GRAPH_TYPE_PROVIDER = 200
GRAPH_TYPE_SITE = 300
GRAPH_TYPE_CHOICES = (
(GRAPH_TYPE_INTERFACE, 'Interface'),
(GRAPH_TYPE_PROVIDER, 'Provider'),
(GRAPH_TYPE_SITE, 'Site'),
)
EXPORTTEMPLATE_MODELS = [
'site', 'rack', 'device', 'consoleport', 'powerport', 'interfaceconnection',
'aggregate', 'prefix', 'ipaddress', 'vlan',
'provider', 'circuit'
]
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)
class Meta:
ordering = ['type', 'weight', 'name']
def __unicode__(self):
return self.name
def embed_url(self, obj):
template = Template(self.source)
return template.render(Context({'obj': obj}))
class ExportTemplate(models.Model):
content_type = models.ForeignKey(ContentType, limit_choices_to={'model__in': EXPORTTEMPLATE_MODELS})
name = models.CharField(max_length=200)
template_code = models.TextField()
mime_type = models.CharField(max_length=15, blank=True)
file_extension = models.CharField(max_length=15, blank=True)
class Meta:
ordering = ['content_type', 'name']
unique_together = [
['content_type', 'name']
]
def __unicode__(self):
return "{}: {}".format(self.content_type, self.name)
def to_response(self, context_dict, filename):
"""
Render the template to an HTTP response, delivered as a named file attachment
"""
template = Template(self.template_code)
mime_type = 'text/plain' if not self.mime_type else self.mime_type
response = HttpResponse(
template.render(Context(context_dict)),
content_type=mime_type
)
if self.file_extension:
filename += '.{}'.format(self.file_extension)
response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
return response

247
netbox/extras/rpc.py Normal file
View File

@@ -0,0 +1,247 @@
from Exscript import Account
from Exscript.protocols import SSH2
from ncclient import manager
import paramiko
import re
import xmltodict
CONNECT_TIMEOUT = 5 # seconds
class RPCClient(object):
def __init__(self, device, username='', password=''):
self.username = username
self.password = password
try:
self.host = str(device.primary_ip.address.ip)
except AttributeError:
raise Exception("Specified device ({}) does not have a primary IP defined.".format(device))
def get_lldp_neighbors(self):
"""
Returns a list of dictionaries, each representing an LLDP neighbor adjacency.
{
'local-interface': <str>,
'name': <str>,
'remote-interface': <str>,
'chassis-id': <str>,
}
"""
raise NotImplementedError("Feature not implemented for this platform.")
def get_inventory(self):
"""
Returns a dictionary representing the device chassis and installed modules.
{
'chassis': {
'serial': <str>,
'description': <str>,
}
'modules': [
{
'name': <str>,
'part_id': <str>,
'serial': <str>,
},
...
]
}
"""
raise NotImplementedError("Feature not implemented for this platform.")
class JunosNC(RPCClient):
"""
NETCONF client for Juniper Junos devices
"""
def __enter__(self):
# Initiate a connection to the device
self.manager = manager.connect(host=self.host, username=self.username, password=self.password,
hostkey_verify=False, timeout=CONNECT_TIMEOUT)
return self
def __exit__(self, exc_type, exc_val, exc_tb):
# Close the connection to the device
self.manager.close_session()
def get_lldp_neighbors(self):
rpc_reply = self.manager.dispatch('get-lldp-neighbors-information')
lldp_neighbors_raw = xmltodict.parse(rpc_reply.xml)['rpc-reply']['lldp-neighbors-information']['lldp-neighbor-information']
result = []
for neighbor_raw in lldp_neighbors_raw:
neighbor = dict()
neighbor['local-interface'] = neighbor_raw.get('lldp-local-port-id')
neighbor['name'] = neighbor_raw.get('lldp-remote-system-name')
neighbor['name'] = neighbor['name'].split('.')[0] # Split hostname from domain if one is present
try:
neighbor['remote-interface'] = neighbor_raw['lldp-remote-port-description']
except KeyError:
# Older versions of Junos report on interface ID instead of description
neighbor['remote-interface'] = neighbor_raw.get('lldp-remote-port-id')
neighbor['chassis-id'] = neighbor_raw.get('lldp-remote-chassis-id')
result.append(neighbor)
return result
def get_inventory(self):
rpc_reply = self.manager.dispatch('get-chassis-inventory')
inventory_raw = xmltodict.parse(rpc_reply.xml)['rpc-reply']['chassis-inventory']['chassis']
result = dict()
# Gather chassis data
result['chassis'] = {
'serial': inventory_raw['serial-number'],
'description': inventory_raw['description'],
}
# Gather modules
result['modules'] = []
for module in inventory_raw['chassis-module']:
try:
# Skip built-in modules
if module['name'] and module['serial-number'] != inventory_raw['serial-number']:
result['modules'].append({
'name': module['name'],
'part_id': module['model-number'] or '',
'serial': module['serial-number'] or '',
})
except KeyError:
pass
return result
class IOSSSH(RPCClient):
"""
SSH client for Cisco IOS devices
"""
def __enter__(self):
# Initiate a connection to the device
self.ssh = SSH2(connect_timeout=CONNECT_TIMEOUT)
self.ssh.connect(self.host)
self.ssh.login(Account(self.username, self.password))
# Disable terminal paging
self.ssh.execute("terminal length 0")
return self
def __exit__(self, exc_type, exc_val, exc_tb):
# Close the connection to the device
self.ssh.send("exit\r")
self.ssh.close()
def get_inventory(self):
result = dict()
# Gather chassis data
try:
self.ssh.execute("show version")
show_version = self.ssh.response
serial = re.search("Processor board ID ([^\s]+)", show_version).groups()[0]
description = re.search("\r\n\r\ncisco ([^\s]+)", show_version).groups()[0]
except:
raise RuntimeError("Failed to glean chassis info from device.")
result['chassis'] = {
'serial': serial,
'description': description,
}
# Gather modules
result['modules'] = []
try:
self.ssh.execute("show inventory")
show_inventory = self.ssh.response
# Split modules on double line
modules_raw = show_inventory.strip().split('\r\n\r\n')
for module_raw in modules_raw:
try:
m_name = re.search('NAME: "([^"]+)"', module_raw).group(1)
m_pid = re.search('PID: ([^\s]+)', module_raw).group(1)
m_serial = re.search('SN: ([^\s]+)', module_raw).group(1)
# Omit built-in modules and those with no PID
if m_serial != result['chassis']['serial'] and m_pid.lower() != 'unspecified':
result['modules'].append({
'name': m_name,
'part_id': m_pid,
'serial': m_serial,
})
except AttributeError:
continue
except:
raise RuntimeError("Failed to glean module info from device.")
return result
class OpengearSSH(RPCClient):
"""
SSH client for Opengear devices
"""
def __enter__(self):
# Initiate a connection to the device
self.ssh = paramiko.SSHClient()
self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
self.ssh.connect(self.host, username=self.username, password=self.password, timeout=CONNECT_TIMEOUT)
except paramiko.AuthenticationException:
# Try default Opengear credentials if the configured creds don't work
self.ssh.connect(self.host, username='root', password='default')
return self
def __exit__(self, exc_type, exc_val, exc_tb):
# Close the connection to the device
self.ssh.close()
def get_inventory(self):
try:
stdin, stdout, stderr = self.ssh.exec_command("showserial")
serial = stdout.readlines()[0].strip()
except:
raise RuntimeError("Failed to glean chassis serial from device.")
# Older models don't provide serial info
if serial == "No serial number information available":
serial = ''
try:
stdin, stdout, stderr = self.ssh.exec_command("config -g config.system.model")
description = stdout.readlines()[0].split(' ', 1)[1].strip()
except:
raise RuntimeError("Failed to glean chassis description from device.")
return {
'chassis': {
'serial': serial,
'description': description,
},
'modules': [],
}
# For mapping platform -> NC client
RPC_CLIENTS = {
'juniper-junos': JunosNC,
'cisco-ios': IOSSSH,
'opengear': OpengearSSH,
}

3
netbox/extras/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

3
netbox/extras/views.py Normal file
View File

@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

2
netbox/ipam/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
default_app_config = 'ipam.apps.IPAMConfig'

74
netbox/ipam/admin.py Normal file
View File

@@ -0,0 +1,74 @@
from django.contrib import admin
from .models import *
@admin.register(VRF)
class VRFAdmin(admin.ModelAdmin):
list_display = ['name', 'rd']
@admin.register(Status)
class StatusAdmin(admin.ModelAdmin):
prepopulated_fields = {
'slug': ['name'],
}
list_display = ['name', 'slug', 'weight', 'bootstrap_class']
@admin.register(Role)
class RoleAdmin(admin.ModelAdmin):
prepopulated_fields = {
'slug': ['name'],
}
list_display = ['name', 'slug', 'weight']
@admin.register(RIR)
class RIRAdmin(admin.ModelAdmin):
prepopulated_fields = {
'slug': ['name'],
}
list_display = ['name', 'slug']
@admin.register(Aggregate)
class AggregateAdmin(admin.ModelAdmin):
list_display = ['prefix', 'rir', 'date_added']
list_filter = ['family', 'rir']
search_fields = ['prefix']
@admin.register(Prefix)
class PrefixAdmin(admin.ModelAdmin):
list_display = ['prefix', 'vrf', 'site', 'status', 'role', 'vlan']
list_filter = ['family', 'site', 'status', 'role']
search_fields = ['prefix']
def get_queryset(self, request):
qs = super(PrefixAdmin, self).get_queryset(request)
return qs.select_related('vrf', 'site', 'status', 'role', 'vlan')
@admin.register(IPAddress)
class IPAddressAdmin(admin.ModelAdmin):
list_display = ['address', 'vrf', 'nat_inside']
list_filter = ['family']
fields = ['address', 'vrf', 'device', 'interface', 'nat_inside']
readonly_fields = ['interface', 'device', 'nat_inside']
search_fields = ['address']
def get_queryset(self, request):
qs = super(IPAddressAdmin, self).get_queryset(request)
return qs.select_related('vrf', 'nat_inside')
@admin.register(VLAN)
class VLANAdmin(admin.ModelAdmin):
list_display = ['site', 'vid', 'name', 'status', 'role']
list_filter = ['site', 'status', 'role']
search_fields = ['vid', 'name']
def get_queryset(self, request):
qs = super(VLANAdmin, self).get_queryset(request)
return qs.select_related('site', 'status', 'role')

View File

View File

@@ -0,0 +1,158 @@
from rest_framework import serializers
from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer
from ipam.models import VRF, Status, Role, RIR, Aggregate, Prefix, IPAddress, VLAN
#
# VRFs
#
class VRFSerializer(serializers.ModelSerializer):
class Meta:
model = VRF
fields = ['id', 'name', 'rd', 'description']
class VRFNestedSerializer(VRFSerializer):
class Meta(VRFSerializer.Meta):
fields = ['id', 'name', 'rd']
#
# Statuses
#
class StatusSerializer(serializers.ModelSerializer):
class Meta:
model = Status
fields = ['id', 'name', 'slug', 'weight', 'bootstrap_class']
class StatusNestedSerializer(StatusSerializer):
class Meta(StatusSerializer.Meta):
fields = ['id', 'name', 'slug']
#
# Roles
#
class RoleSerializer(serializers.ModelSerializer):
class Meta:
model = Role
fields = ['id', 'name', 'slug', 'weight']
class RoleNestedSerializer(RoleSerializer):
class Meta(RoleSerializer.Meta):
fields = ['id', 'name', 'slug']
#
# RIRs
#
class RIRSerializer(serializers.ModelSerializer):
class Meta:
model = RIR
fields = ['id', 'name', 'slug']
class RIRNestedSerializer(RIRSerializer):
class Meta(RIRSerializer.Meta):
pass
#
# Aggregates
#
class AggregateSerializer(serializers.ModelSerializer):
rir = RIRNestedSerializer()
class Meta:
model = Aggregate
fields = ['id', 'family', 'prefix', 'rir', 'date_added', 'description']
class AggregateNestedSerializer(AggregateSerializer):
class Meta(AggregateSerializer.Meta):
fields = ['id', 'family', 'prefix']
#
# VLANs
#
class VLANSerializer(serializers.ModelSerializer):
display_name = serializers.SerializerMethodField()
site = SiteNestedSerializer()
status = StatusNestedSerializer()
role = RoleNestedSerializer()
class Meta:
model = VLAN
fields = ['id', 'site', 'vid', 'name', 'status', 'role', 'display_name']
def get_display_name(self, obj):
return "{} ({})".format(obj.vid, obj.name)
class VLANNestedSerializer(VLANSerializer):
class Meta(VLANSerializer.Meta):
fields = ['id', 'vid', 'name', 'display_name']
#
# Prefixes
#
class PrefixSerializer(serializers.ModelSerializer):
site = SiteNestedSerializer()
vrf = VRFNestedSerializer()
vlan = VLANNestedSerializer()
status = StatusNestedSerializer()
role = RoleNestedSerializer()
class Meta:
model = Prefix
fields = ['id', 'family', 'prefix', 'site', 'vrf', 'vlan', 'status', 'role', 'description']
class PrefixNestedSerializer(PrefixSerializer):
class Meta(PrefixSerializer.Meta):
fields = ['id', 'family', 'prefix']
#
# IP addresses
#
class IPAddressSerializer(serializers.ModelSerializer):
vrf = VRFNestedSerializer()
interface = InterfaceNestedSerializer()
class Meta:
model = IPAddress
fields = ['id', 'family', 'address', 'vrf', 'interface', 'description', 'nat_inside', 'nat_outside']
class IPAddressNestedSerializer(IPAddressSerializer):
class Meta(IPAddressSerializer.Meta):
fields = ['id', 'family', 'address']
IPAddressSerializer._declared_fields['nat_inside'] = IPAddressNestedSerializer()
IPAddressSerializer._declared_fields['nat_outside'] = IPAddressNestedSerializer()

40
netbox/ipam/api/urls.py Normal file
View File

@@ -0,0 +1,40 @@
from django.conf.urls import url
from .views import *
urlpatterns = [
# VRFs
url(r'^vrfs/$', VRFListView.as_view(), name='vrf_list'),
url(r'^vrfs/(?P<pk>\d+)/$', VRFDetailView.as_view(), name='vrf_detail'),
# Statuses
url(r'^statuses/$', StatusListView.as_view(), name='status_list'),
url(r'^statuses/(?P<pk>\d+)/$', StatusDetailView.as_view(), name='status_detail'),
# Roles
url(r'^roles/$', RoleListView.as_view(), name='role_list'),
url(r'^roles/(?P<pk>\d+)/$', RoleDetailView.as_view(), name='role_detail'),
# RIRs
url(r'^rirs/$', RIRListView.as_view(), name='rir_list'),
url(r'^rirs/(?P<pk>\d+)/$', RIRDetailView.as_view(), name='rir_detail'),
# Aggregates
url(r'^aggregates/$', AggregateListView.as_view(), name='aggregate_list'),
url(r'^aggregates/(?P<pk>\d+)/$', AggregateDetailView.as_view(), name='aggregate_detail'),
# Prefixes
url(r'^prefixes/$', PrefixListView.as_view(), name='prefix_list'),
url(r'^prefixes/(?P<pk>\d+)/$', PrefixDetailView.as_view(), name='prefix_detail'),
# IP addresses
url(r'^ip-addresses/$', IPAddressListView.as_view(), name='ipaddress_list'),
url(r'^ip-addresses/(?P<pk>\d+)/$', IPAddressDetailView.as_view(), name='ipaddress_detail'),
# VLANs
url(r'^vlans/$', VLANListView.as_view(), name='vlan_list'),
url(r'^vlans/(?P<pk>\d+)/$', VLANDetailView.as_view(), name='vlan_detail'),
]

140
netbox/ipam/api/views.py Normal file
View File

@@ -0,0 +1,140 @@
from rest_framework import generics
from ipam.models import VRF, Status, Role, RIR, Aggregate, Prefix, IPAddress, VLAN
from ipam.filters import AggregateFilter, PrefixFilter, IPAddressFilter, VLANFilter
from .serializers import VRFSerializer, StatusSerializer, RoleSerializer, RIRSerializer, AggregateSerializer, \
PrefixSerializer, IPAddressSerializer, VLANSerializer
class VRFListView(generics.ListAPIView):
"""
List all VRFs
"""
queryset = VRF.objects.all()
serializer_class = VRFSerializer
class VRFDetailView(generics.RetrieveAPIView):
"""
Retrieve a single VRF
"""
queryset = VRF.objects.all()
serializer_class = VRFSerializer
class StatusListView(generics.ListAPIView):
"""
List all statuses
"""
queryset = Status.objects.all()
serializer_class = StatusSerializer
class StatusDetailView(generics.RetrieveAPIView):
"""
Retrieve a single status
"""
queryset = Status.objects.all()
serializer_class = StatusSerializer
class RoleListView(generics.ListAPIView):
"""
List all roles
"""
queryset = Role.objects.all()
serializer_class = RoleSerializer
class RoleDetailView(generics.RetrieveAPIView):
"""
Retrieve a single role
"""
queryset = Role.objects.all()
serializer_class = RoleSerializer
class RIRListView(generics.ListAPIView):
"""
List all RIRs
"""
queryset = RIR.objects.all()
serializer_class = RIRSerializer
class RIRDetailView(generics.RetrieveAPIView):
"""
Retrieve a single RIR
"""
queryset = RIR.objects.all()
serializer_class = RIRSerializer
class AggregateListView(generics.ListAPIView):
"""
List aggregates (filterable)
"""
queryset = Aggregate.objects.select_related('rir')
serializer_class = AggregateSerializer
filter_class = AggregateFilter
class AggregateDetailView(generics.RetrieveAPIView):
"""
Retrieve a single aggregate
"""
queryset = Aggregate.objects.select_related('rir')
serializer_class = AggregateSerializer
class PrefixListView(generics.ListAPIView):
"""
List prefixes (filterable)
"""
queryset = Prefix.objects.select_related('site', 'vrf', 'vlan', 'status', 'role')
serializer_class = PrefixSerializer
filter_class = PrefixFilter
class PrefixDetailView(generics.RetrieveAPIView):
"""
Retrieve a single prefix
"""
queryset = Prefix.objects.select_related('site', 'vrf', 'vlan', 'status', 'role')
serializer_class = PrefixSerializer
class IPAddressListView(generics.ListAPIView):
"""
List IP addresses (filterable)
"""
queryset = IPAddress.objects.select_related('vrf', 'interface__device', 'nat_inside')\
.prefetch_related('nat_outside')
serializer_class = IPAddressSerializer
filter_class = IPAddressFilter
class IPAddressDetailView(generics.RetrieveAPIView):
"""
Retrieve a single IP address
"""
queryset = IPAddress.objects.select_related('vrf', 'interface__device', 'nat_inside')\
.prefetch_related('nat_outside')
serializer_class = IPAddressSerializer
class VLANListView(generics.ListAPIView):
"""
List VLANs (filterable)
"""
queryset = VLAN.objects.select_related('site', 'status', 'role')
serializer_class = VLANSerializer
filter_class = VLANFilter
class VLANDetailView(generics.RetrieveAPIView):
"""
Retrieve a single VLAN
"""
queryset = VLAN.objects.select_related('site', 'status', 'role')
serializer_class = VLANSerializer

6
netbox/ipam/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class IPAMConfig(AppConfig):
name = "ipam"
verbose_name = "IPAM"

82
netbox/ipam/fields.py Normal file
View File

@@ -0,0 +1,82 @@
from netaddr import IPNetwork
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.six import with_metaclass
from .formfields import IPFormField
from .lookups import EndsWith, IEndsWith, StartsWith, IStartsWith, Regex, IRegex, NetContained, NetContainedOrEqual, \
NetContains, NetContainsOrEquals, NetHost
class _BaseIPField(models.Field):
def python_type(self):
return IPNetwork
def to_python(self, value):
if not value:
return value
try:
return IPNetwork(value)
except ValueError as e:
raise ValidationError(e)
def get_prep_value(self, value):
if not value:
return None
return str(self.to_python(value))
def form_class(self):
return IPFormField
def formfield(self, **kwargs):
defaults = {'form_class': self.form_class()}
defaults.update(kwargs)
return super(_BaseIPField, self).formfield(**defaults)
class IPNetworkField(with_metaclass(models.SubfieldBase, _BaseIPField)):
"""
IP prefix (network and mask)
"""
description = "PostgreSQL CIDR field"
def db_type(self, connection):
return 'cidr'
IPNetworkField.register_lookup(EndsWith)
IPNetworkField.register_lookup(IEndsWith)
IPNetworkField.register_lookup(StartsWith)
IPNetworkField.register_lookup(IStartsWith)
IPNetworkField.register_lookup(Regex)
IPNetworkField.register_lookup(IRegex)
IPNetworkField.register_lookup(NetContained)
IPNetworkField.register_lookup(NetContainedOrEqual)
IPNetworkField.register_lookup(NetContains)
IPNetworkField.register_lookup(NetContainsOrEquals)
IPNetworkField.register_lookup(NetHost)
class IPAddressField(with_metaclass(models.SubfieldBase, _BaseIPField)):
"""
IP address (host address and mask)
"""
description = "PostgreSQL INET field"
def db_type(self, connection):
return 'inet'
IPAddressField.register_lookup(EndsWith)
IPAddressField.register_lookup(IEndsWith)
IPAddressField.register_lookup(StartsWith)
IPAddressField.register_lookup(IStartsWith)
IPAddressField.register_lookup(Regex)
IPAddressField.register_lookup(IRegex)
IPAddressField.register_lookup(NetContained)
IPAddressField.register_lookup(NetContainedOrEqual)
IPAddressField.register_lookup(NetContains)
IPAddressField.register_lookup(NetContainsOrEquals)
IPAddressField.register_lookup(NetHost)

216
netbox/ipam/filters.py Normal file
View File

@@ -0,0 +1,216 @@
import django_filters
from netaddr import IPNetwork
from netaddr.core import AddrFormatError
from dcim.models import Site, Device, Interface
from ipam.models import RIR, Aggregate, VRF, Prefix, IPAddress, VLAN, Status, Role
class VRFFilter(django_filters.FilterSet):
name = django_filters.CharFilter(
name='name',
lookup_type='icontains',
label='Name',
)
class Meta:
model = VRF
fields = ['name', 'rd']
class AggregateFilter(django_filters.FilterSet):
rir_id = django_filters.ModelMultipleChoiceFilter(
name='rir',
queryset=RIR.objects.all(),
label='RIR (ID)',
)
rir = django_filters.ModelMultipleChoiceFilter(
name='rir',
queryset=RIR.objects.all(),
to_field_name='slug',
label='RIR (slug)',
)
class Meta:
model = Aggregate
fields = ['family', 'rir_id', 'rir', 'date_added']
class PrefixFilter(django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
label='Search',
)
parent = django_filters.MethodFilter(
action='search_by_parent',
label='Parent prefix',
)
vrf_id = django_filters.MethodFilter(
action='vrf',
label='VRF (ID)',
)
site_id = django_filters.ModelMultipleChoiceFilter(
name='site',
queryset=Site.objects.all(),
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
name='site',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site (slug)',
)
vlan_id = django_filters.ModelMultipleChoiceFilter(
name='vlan',
queryset=VLAN.objects.all(),
label='VLAN (ID)',
)
vlan_vid = django_filters.NumberFilter(
name='vlan__vid',
label='VLAN number (1-4095)',
)
status_id = django_filters.ModelMultipleChoiceFilter(
name='status',
queryset=Status.objects.all(),
label='Status (ID)',
)
status = django_filters.ModelMultipleChoiceFilter(
name='status',
queryset=Status.objects.all(),
to_field_name='slug',
label='Status (slug)',
)
role_id = django_filters.ModelMultipleChoiceFilter(
name='role',
queryset=Role.objects.all(),
label='Role (ID)',
)
role = django_filters.ModelMultipleChoiceFilter(
name='role',
queryset=Role.objects.all(),
to_field_name='slug',
label='Role (slug)',
)
class Meta:
model = Prefix
fields = ['family', 'site_id', 'site', 'vrf_id', 'vrf', 'vlan_id', 'vlan_vid', 'status_id', 'status', 'role_id',
'role']
def search(self, queryset, value):
value = value.strip()
try:
query = str(IPNetwork(value).cidr)
return queryset.filter(prefix__net_contains_or_equals=query)
except AddrFormatError:
return queryset.none()
def search_by_parent(self, queryset, value):
value = value.strip()
if not value:
return queryset
try:
query = str(IPNetwork(value).cidr)
return queryset.filter(prefix__net_contained_or_equal=query)
except AddrFormatError:
return queryset.none()
def vrf(self, queryset, value):
if str(value) == '':
return queryset
try:
vrf_id = int(value)
except ValueError:
return queryset.none()
if vrf_id == 0:
return queryset.filter(vrf__isnull=True)
return queryset.filter(vrf__pk=value)
class IPAddressFilter(django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
label='Search',
)
vrf_id = django_filters.ModelMultipleChoiceFilter(
name='vrf',
queryset=VRF.objects.all(),
label='VRF (ID)',
)
device_id = django_filters.ModelMultipleChoiceFilter(
name='interface__device',
queryset=Device.objects.all(),
label='Device (ID)',
)
device = django_filters.ModelMultipleChoiceFilter(
name='interface__device',
queryset=Device.objects.all(),
to_field_name='name',
label='Device (name)',
)
interface_id = django_filters.ModelMultipleChoiceFilter(
name='interface',
queryset=Interface.objects.all(),
label='Interface (ID)',
)
class Meta:
model = IPAddress
fields = ['q', 'family', 'vrf_id', 'vrf', 'device_id', 'device', 'interface_id']
def search(self, queryset, value):
value = value.strip()
try:
query = str(IPNetwork(value))
return queryset.filter(address__net_host=query)
except AddrFormatError:
return queryset.none()
class VLANFilter(django_filters.FilterSet):
site_id = django_filters.ModelMultipleChoiceFilter(
name='site',
queryset=Site.objects.all(),
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
name='site',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site (slug)',
)
name = django_filters.CharFilter(
name='name',
lookup_type='icontains',
label='Name',
)
vid = django_filters.NumberFilter(
name='vid',
label='VLAN number (1-4095)',
)
status_id = django_filters.ModelMultipleChoiceFilter(
name='status',
queryset=Status.objects.all(),
label='Status (ID)',
)
status = django_filters.ModelMultipleChoiceFilter(
name='status',
queryset=Status.objects.all(),
to_field_name='slug',
label='Status (slug)',
)
role_id = django_filters.ModelMultipleChoiceFilter(
name='role',
queryset=Role.objects.all(),
label='Role (ID)',
)
role = django_filters.ModelMultipleChoiceFilter(
name='role',
queryset=Role.objects.all(),
to_field_name='slug',
label='Role (slug)',
)
class Meta:
model = VLAN
fields = ['site_id', 'site', 'vid', 'name', 'status_id', 'status', 'role_id', 'role']

View File

@@ -0,0 +1,98 @@
- model: ipam.status
pk: 1
fields: {name: Active, slug: active, weight: 1000, bootstrap_class: 1}
- model: ipam.status
pk: 2
fields: {name: Inactive, slug: inactive, weight: 500, bootstrap_class: 3}
- model: ipam.role
pk: 1
fields: {name: Lab Network, slug: lab-network, weight: 1000}
- model: ipam.rir
pk: 1
fields: {name: RFC1918, slug: rfc1918}
- model: ipam.aggregate
pk: 1
fields: {family: 4, prefix: 10.0.0.0/8, rir: 1, date_added: 2016-01-01, description: ''}
- model: ipam.prefix
pk: 1
fields: {family: 4, prefix: 10.1.1.0/24, site: 1, vrf: null, vlan: null, status: 1,
role: 1, description: ''}
- model: ipam.prefix
pk: 2
fields: {family: 4, prefix: 10.0.255.0/24, site: 1, vrf: null, vlan: null, status: 1,
role: 1, description: ''}
- model: ipam.ipaddress
pk: 1
fields: {family: 4, address: 10.0.255.1/32, vrf: null, interface: 3, nat_inside: null,
description: ''}
- model: ipam.ipaddress
pk: 2
fields: {family: 4, address: 169.254.254.1/31, vrf: null, interface: 4, nat_inside: null,
description: ''}
- model: ipam.ipaddress
pk: 3
fields: {family: 4, address: 10.0.255.2/32, vrf: null, interface: 185, nat_inside: null,
description: ''}
- model: ipam.ipaddress
pk: 4
fields: {family: 4, address: 169.254.1.1/31, vrf: null, interface: 213, nat_inside: null,
description: ''}
- model: ipam.ipaddress
pk: 5
fields: {family: 4, address: 10.0.254.1/24, vrf: null, interface: 12, nat_inside: null,
description: ''}
- model: ipam.ipaddress
pk: 8
fields: {family: 4, address: 10.15.21.1/31, vrf: null, interface: 218, nat_inside: null,
description: ''}
- model: ipam.ipaddress
pk: 9
fields: {family: 4, address: 10.15.21.2/31, vrf: null, interface: 9, nat_inside: null,
description: ''}
- model: ipam.ipaddress
pk: 10
fields: {family: 4, address: 10.15.22.1/31, vrf: null, interface: 8, nat_inside: null,
description: ''}
- model: ipam.ipaddress
pk: 11
fields: {family: 4, address: 10.15.20.1/31, vrf: null, interface: 7, nat_inside: null,
description: ''}
- model: ipam.ipaddress
pk: 12
fields: {family: 4, address: 10.16.20.1/31, vrf: null, interface: 216, nat_inside: null,
description: ''}
- model: ipam.ipaddress
pk: 13
fields: {family: 4, address: 10.15.22.2/31, vrf: null, interface: 206, nat_inside: null,
description: ''}
- model: ipam.ipaddress
pk: 14
fields: {family: 4, address: 10.16.22.1/31, vrf: null, interface: 217, nat_inside: null,
description: ''}
- model: ipam.ipaddress
pk: 15
fields: {family: 4, address: 10.16.22.2/31, vrf: null, interface: 205, nat_inside: null,
description: ''}
- model: ipam.ipaddress
pk: 16
fields: {family: 4, address: 10.16.20.2/31, vrf: null, interface: 211, nat_inside: null,
description: ''}
- model: ipam.ipaddress
pk: 17
fields: {family: 4, address: 10.15.22.2/31, vrf: null, interface: 212, nat_inside: null,
description: ''}
- model: ipam.ipaddress
pk: 19
fields: {family: 4, address: 10.0.254.2/32, vrf: null, interface: 188, nat_inside: null,
description: ''}
- model: ipam.ipaddress
pk: 20
fields: {family: 4, address: 169.254.1.1/31, vrf: null, interface: 200, nat_inside: null,
description: ''}
- model: ipam.ipaddress
pk: 21
fields: {family: 4, address: 169.254.1.2/31, vrf: null, interface: 194, nat_inside: null,
description: ''}
- model: ipam.vlan
pk: 1
fields: {site: 1, vid: 999, name: TEST, status: 1, role: 1}

30
netbox/ipam/formfields.py Normal file
View File

@@ -0,0 +1,30 @@
from netaddr import IPNetwork, AddrFormatError
from django import forms
from django.core.exceptions import ValidationError
#
# Form fields
#
class IPFormField(forms.Field):
default_error_messages = {
'invalid': "Enter a valid IPv4 or IPv6 address (with CIDR mask).",
}
def to_python(self, value):
if not value:
return None
if isinstance(value, IPNetwork):
return value
# Ensure that a subnet mask has been specified. This prevents IPs from defaulting to a /32 or /128.
if len(value.split('/')) != 2:
raise ValidationError('CIDR mask (e.g. /24) is required.')
try:
return IPNetwork(value)
except AddrFormatError:
raise ValidationError("Please specify a valid IPv4 or IPv6 address.")

408
netbox/ipam/forms.py Normal file
View File

@@ -0,0 +1,408 @@
from netaddr import IPNetwork
from django import forms
from django.db.models import Count
from dcim.models import Site, Device, Interface
from utilities.forms import BootstrapMixin, ConfirmationForm, APISelect, Livesearch, CSVDataField, BulkImportForm
from .models import VRF, RIR, Aggregate, Prefix, IPAddress, VLAN, Status, Role
#
# VRFs
#
class VRFForm(forms.ModelForm, BootstrapMixin):
class Meta:
model = VRF
fields = ['name', 'rd', 'description']
help_texts = {
'rd': "Route distinguisher in any format",
}
class VRFFromCSVForm(forms.ModelForm):
class Meta:
model = VRF
fields = ['name', 'rd', 'description']
class VRFImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=VRFFromCSVForm)
class VRFBulkEditForm(forms.Form, BootstrapMixin):
pk = forms.ModelMultipleChoiceField(queryset=VRF.objects.all(), widget=forms.MultipleHiddenInput)
description = forms.CharField(max_length=100, required=False)
class VRFBulkDeleteForm(ConfirmationForm):
pk = forms.ModelMultipleChoiceField(queryset=VRF.objects.all(), widget=forms.MultipleHiddenInput)
#
# Aggregates
#
class AggregateForm(forms.ModelForm, BootstrapMixin):
class Meta:
model = Aggregate
fields = ['prefix', 'rir', 'date_added', 'description']
help_texts = {
'prefix': "IPv4 or IPv6 network",
'rir': "Regional Internet Registry responsible for this prefix",
'date_added': "Format: YYYY-MM-DD",
}
class AggregateFromCSVForm(forms.ModelForm):
rir = forms.ModelChoiceField(queryset=RIR.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'RIR not found.'})
class Meta:
model = Aggregate
fields = ['prefix', 'rir', 'date_added', 'description']
class AggregateImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=AggregateFromCSVForm)
class AggregateBulkEditForm(forms.Form, BootstrapMixin):
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)
description = forms.CharField(max_length=50, required=False)
class AggregateBulkDeleteForm(ConfirmationForm):
pk = forms.ModelMultipleChoiceField(queryset=Aggregate.objects.all(), widget=forms.MultipleHiddenInput)
def aggregate_rir_choices():
rir_choices = RIR.objects.annotate(aggregate_count=Count('aggregates'))
return [(r.slug, '{} ({})'.format(r.name, r.aggregate_count)) for r in rir_choices]
class AggregateFilterForm(forms.Form, BootstrapMixin):
rir = forms.MultipleChoiceField(required=False, choices=aggregate_rir_choices, label='RIR',
widget=forms.SelectMultiple(attrs={'size': 8}))
#
# Prefixes
#
class PrefixForm(forms.ModelForm, BootstrapMixin):
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site',
widget=forms.Select(attrs={'filter-for': 'vlan'}))
vlan = forms.ModelChoiceField(queryset=VLAN.objects.all(), required=False, label='VLAN',
widget=APISelect(api_url='/api/ipam/vlans/?site_id={{site}}'))
class Meta:
model = Prefix
fields = ['prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'description']
help_texts = {
'prefix': "IPv4 or IPv6 network",
'vrf': "VRF (if applicable)",
'site': "The site to which this prefix is assigned (if applicable)",
'vlan': "The VLAN to which this prefix is assigned (if applicable)",
'status': "Operational status of this prefix",
'role': "The primary function of this prefix",
}
def __init__(self, *args, **kwargs):
super(PrefixForm, self).__init__(*args, **kwargs)
self.fields['vrf'].empty_label = 'Global'
# Initialize field without choices to avoid pulling all VLANs from the database
if self.is_bound and self.data.get('site'):
self.fields['vlan'].queryset = VLAN.objects.filter(site__pk=self.data['site'])
elif self.initial.get('site'):
self.fields['vlan'].queryset = VLAN.objects.filter(site=self.initial['site'])
else:
self.fields['vlan'].choices = []
def clean_prefix(self):
data = self.cleaned_data['prefix']
try:
prefix = IPNetwork(data)
except:
raise
if prefix.version == 4 and prefix.prefixlen == 32:
raise forms.ValidationError("Cannot create host addresses (/32) as prefixes. These should be IPv4 "
"addresses instead.")
elif prefix.version == 6 and prefix.prefixlen == 128:
raise forms.ValidationError("Cannot create host addresses (/128) as prefixes. These should be IPv6 "
"addresses instead.")
return data
class PrefixFromCSVForm(forms.ModelForm):
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, to_field_name='rd',
error_messages={'invalid_choice': 'VRF not found.'})
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'Site not found.'})
status = forms.ModelChoiceField(queryset=Status.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Invalid status.'})
role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'Invalid role.'})
class Meta:
model = Prefix
fields = ['prefix', 'vrf', 'site', 'status', 'role', 'description']
class PrefixImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=PrefixFromCSVForm)
class PrefixBulkEditForm(forms.Form, BootstrapMixin):
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',
help_text="Select the VRF to assign, or check below to remove VRF assignment")
vrf_global = forms.BooleanField(required=False, label='Set VRF to global')
status = forms.ModelChoiceField(queryset=Status.objects.all(), required=False)
role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False)
description = forms.CharField(max_length=50, required=False)
class PrefixBulkDeleteForm(ConfirmationForm):
pk = forms.ModelMultipleChoiceField(queryset=Prefix.objects.all(), widget=forms.MultipleHiddenInput)
def prefix_vrf_choices():
vrf_choices = [('', 'All'), (0, 'Global')]
vrf_choices += [(v.pk, v.name) for v in VRF.objects.all()]
return vrf_choices
def prefix_status_choices():
status_choices = Status.objects.annotate(prefix_count=Count('prefixes'))
return [(s.slug, '{} ({})'.format(s.name, s.prefix_count)) for s in status_choices]
def prefix_site_choices():
site_choices = Site.objects.annotate(prefix_count=Count('prefixes'))
return [(s.slug, '{} ({})'.format(s.name, s.prefix_count)) for s in site_choices]
def prefix_role_choices():
role_choices = Role.objects.annotate(prefix_count=Count('prefixes'))
return [(r.slug, '{} ({})'.format(r.name, r.prefix_count)) for r in role_choices]
class PrefixFilterForm(forms.Form, BootstrapMixin):
parent = forms.CharField(required=False, label='Search Within')
vrf = forms.ChoiceField(required=False, choices=prefix_vrf_choices, label='VRF')
status = forms.MultipleChoiceField(required=False, choices=prefix_status_choices)
site = forms.MultipleChoiceField(required=False, choices=prefix_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
role = forms.MultipleChoiceField(required=False, choices=prefix_role_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
expand = forms.BooleanField(required=False, label='Expand prefix hierarchy')
#
# IP addresses
#
class IPAddressForm(forms.ModelForm, BootstrapMixin):
nat_site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site',
widget=forms.Select(attrs={'filter-for': 'nat_device'}))
nat_device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, label='Device',
widget=APISelect(api_url='/api/dcim/devices/?site_id={{nat_site}}',
attrs={'filter-for': 'nat_inside'}))
livesearch = forms.CharField(required=False, label='IP Address', widget=Livesearch(
query_key='q', query_url='ipam-api:ipaddress_list', field_to_update='nat_inside', obj_label='address')
)
nat_inside = forms.ModelChoiceField(queryset=IPAddress.objects.all(), required=False, label='NAT (Inside)',
widget=APISelect(api_url='/api/ipam/ip-addresses/?device_id={{nat_device}}',
display_field='address'))
class Meta:
model = IPAddress
fields = ['address', 'vrf', 'nat_device', 'nat_inside', 'description']
help_texts = {
'address': "IPv4 or IPv6 address and mask",
'vrf': "VRF (if applicable)",
}
def __init__(self, *args, **kwargs):
super(IPAddressForm, self).__init__(*args, **kwargs)
self.fields['vrf'].empty_label = 'Global'
if self.instance.nat_inside:
nat_inside = self.instance.nat_inside
# If the IP is assigned to an interface, populate site/device fields accordingly
if self.instance.nat_inside.interface:
self.initial['nat_site'] = self.instance.nat_inside.interface.device.rack.site.pk
self.initial['nat_device'] = self.instance.nat_inside.interface.device.pk
self.fields['nat_device'].queryset = Device.objects.filter(rack__site=nat_inside.interface.device.rack.site)
self.fields['nat_inside'].queryset = IPAddress.objects.filter(interface__device=nat_inside.interface.device)
else:
self.fields['nat_inside'].queryset = IPAddress.objects.filter(pk=nat_inside.pk)
else:
# Initialize nat_device choices if nat_site is set
if self.is_bound and self.data.get('nat_site'):
self.fields['nat_device'].queryset = Device.objects.filter(rack__site__pk=self.data['nat_site'])
elif self.initial.get('nat_site'):
self.fields['nat_device'].queryset = Device.objects.filter(rack__site=self.initial['nat_site'])
else:
self.fields['nat_device'].choices = []
# Initialize nat_inside choices if nat_device is set
if self.is_bound and self.data.get('nat_device'):
self.fields['nat_inside'].queryset = IPAddress.objects.filter(interface__device__pk=self.data['nat_device'])
elif self.initial.get('nat_device'):
self.fields['nat_inside'].queryset = IPAddress.objects.filter(interface__device__pk=self.initial['nat_device'])
else:
self.fields['nat_inside'].choices = []
class IPAddressFromCSVForm(forms.ModelForm):
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, to_field_name='rd',
error_messages={'invalid_choice': 'Site not found.'})
device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'Device not found.'})
interface_name = forms.CharField(required=False)
is_primary = forms.BooleanField(required=False)
class Meta:
model = IPAddress
fields = ['address', 'vrf', 'device', 'interface_name', 'is_primary', 'description']
def clean(self):
device = self.cleaned_data.get('device')
interface_name = self.cleaned_data.get('interface_name')
is_primary = self.cleaned_data.get('is_primary')
# Validate interface
if device and interface_name:
try:
Interface.objects.get(device=device, name=interface_name)
except Interface.DoesNotExist:
self.add_error('interface_name', "Invalid interface ({}) for {}".format(interface_name, device))
elif device and not interface_name:
self.add_error('interface_name', "Device set ({}) but interface missing".format(device))
elif interface_name and not device:
self.add_error('device', "Interface set ({}) but device missing or invalid".format(interface_name))
# Validate is_primary
if is_primary and not device:
self.add_error('is_primary', "No device specified; cannot set as primary IP")
def save(self, commit=True):
# Set interface
if self.cleaned_data['device'] and self.cleaned_data['interface_name']:
self.instance.interface = Interface.objects.get(device=self.cleaned_data['device'],
name=self.cleaned_data['interface_name'])
# Set as primary for device
if self.cleaned_data['is_primary']:
self.instance.primary_for = self.cleaned_data['device']
return super(IPAddressFromCSVForm, self).save(commit=commit)
class IPAddressImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=IPAddressFromCSVForm)
class IPAddressBulkEditForm(forms.Form, BootstrapMixin):
pk = forms.ModelMultipleChoiceField(queryset=IPAddress.objects.all(), widget=forms.MultipleHiddenInput)
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF',
help_text="Select the VRF to assign, or check below to remove VRF assignment")
vrf_global = forms.BooleanField(required=False, label='Set VRF to global')
description = forms.CharField(max_length=50, required=False)
class IPAddressBulkDeleteForm(ConfirmationForm):
pk = forms.ModelMultipleChoiceField(queryset=IPAddress.objects.all(), widget=forms.MultipleHiddenInput)
def ipaddress_family_choices():
return [('', 'All'), (4, 'IPv4'), (6, 'IPv6')]
class IPAddressFilterForm(forms.Form, BootstrapMixin):
family = forms.ChoiceField(required=False, choices=ipaddress_family_choices, label='Address Family')
#
# VLANs
#
class VLANForm(forms.ModelForm, BootstrapMixin):
class Meta:
model = VLAN
fields = ['site', 'vid', 'name', 'status', 'role']
help_texts = {
'site': "The site at which this VLAN exists",
'vid': "Configured VLAN ID",
'name': "Configured VLAN name",
'status': "Operational status of this VLAN",
'role': "The primary function of this VLAN",
}
class VLANFromCSVForm(forms.ModelForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Device not found.'})
status = forms.ModelChoiceField(queryset=Status.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Invalid status.'})
role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'Invalid role.'})
class Meta:
model = VLAN
fields = ['site', 'vid', 'name', 'status', 'role']
class VLANImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=VLANFromCSVForm)
class VLANBulkEditForm(forms.Form, BootstrapMixin):
pk = forms.ModelMultipleChoiceField(queryset=VLAN.objects.all(), widget=forms.MultipleHiddenInput)
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False)
status = forms.ModelChoiceField(queryset=Status.objects.all(), required=False)
role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False)
class VLANBulkDeleteForm(ConfirmationForm):
pk = forms.ModelMultipleChoiceField(queryset=VLAN.objects.all(), widget=forms.MultipleHiddenInput)
def vlan_site_choices():
site_choices = Site.objects.annotate(vlan_count=Count('vlans'))
return [(s.slug, '{} ({})'.format(s.name, s.vlan_count)) for s in site_choices]
def vlan_status_choices():
status_choices = Status.objects.annotate(vlan_count=Count('vlans'))
return [(s.slug, '{} ({})'.format(s.name, s.vlan_count)) for s in status_choices]
def vlan_role_choices():
role_choices = Role.objects.annotate(vlan_count=Count('vlans'))
return [(r.slug, '{} ({})'.format(r.name, r.vlan_count)) for r in role_choices]
class VLANFilterForm(forms.Form, BootstrapMixin):
site = forms.MultipleChoiceField(required=False, choices=vlan_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
status = forms.MultipleChoiceField(required=False, choices=vlan_status_choices)
role = forms.MultipleChoiceField(required=False, choices=vlan_role_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))

89
netbox/ipam/lookups.py Normal file
View File

@@ -0,0 +1,89 @@
from django.db.models import Lookup
from django.db.models.lookups import BuiltinLookup
class NetFieldDecoratorMixin(object):
def process_lhs(self, qn, connection, lhs=None):
lhs = lhs or self.lhs
lhs_string, lhs_params = qn.compile(lhs)
lhs_string = 'TEXT(%s)' % lhs_string
return lhs_string, lhs_params
class EndsWith(NetFieldDecoratorMixin, BuiltinLookup):
lookup_name = 'endswith'
class IEndsWith(NetFieldDecoratorMixin, BuiltinLookup):
lookup_name = 'iendswith'
class StartsWith(NetFieldDecoratorMixin, BuiltinLookup):
lookup_name = 'startswith'
class IStartsWith(NetFieldDecoratorMixin, BuiltinLookup):
lookup_name = 'istartswith'
class Regex(NetFieldDecoratorMixin, BuiltinLookup):
lookup_name = 'regex'
class IRegex(NetFieldDecoratorMixin, BuiltinLookup):
lookup_name = 'iregex'
class NetContainsOrEquals(Lookup):
lookup_name = 'net_contains_or_equals'
def as_sql(self, qn, connection):
lhs, lhs_params = self.process_lhs(qn, connection)
rhs, rhs_params = self.process_rhs(qn, connection)
params = lhs_params + rhs_params
return '%s >>= %s' % (lhs, rhs), params
class NetContains(Lookup):
lookup_name = 'net_contains'
def as_sql(self, qn, connection):
lhs, lhs_params = self.process_lhs(qn, connection)
rhs, rhs_params = self.process_rhs(qn, connection)
params = lhs_params + rhs_params
return '%s >> %s' % (lhs, rhs), params
class NetContained(Lookup):
lookup_name = 'net_contained'
def as_sql(self, qn, connection):
lhs, lhs_params = self.process_lhs(qn, connection)
rhs, rhs_params = self.process_rhs(qn, connection)
params = lhs_params + rhs_params
return '%s << %s' % (lhs, rhs), params
class NetContainedOrEqual(Lookup):
lookup_name = 'net_contained_or_equal'
def as_sql(self, qn, connection):
lhs, lhs_params = self.process_lhs(qn, connection)
rhs, rhs_params = self.process_rhs(qn, connection)
params = lhs_params + rhs_params
return '%s <<= %s' % (lhs, rhs), params
class NetHost(Lookup):
lookup_name = 'net_host'
def as_sql(self, qn, connection):
lhs, lhs_params = self.process_lhs(qn, connection)
rhs, rhs_params = self.process_rhs(qn, connection)
# Query parameters are automatically converted to IPNetwork objects, which are then turned to strings. We need
# to omit the mask portion of the object's string representation to match PostgreSQL's HOST() function.
if rhs_params:
rhs_params[0] = rhs_params[0].split('/')[0]
params = lhs_params + rhs_params
return 'HOST(%s) = %s' % (lhs, rhs), params

View File

@@ -0,0 +1,166 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.1 on 2016-02-27 02:35
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):
initial = True
dependencies = [
('dcim', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Aggregate',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('family', models.PositiveSmallIntegerField(choices=[(4, b'IPv4'), (6, b'IPv6')])),
('prefix', ipam.fields.IPNetworkField()),
('date_added', models.DateField(blank=True, null=True)),
('description', models.CharField(blank=True, max_length=100)),
],
options={
'ordering': ['family', 'prefix'],
},
),
migrations.CreateModel(
name='IPAddress',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('family', models.PositiveSmallIntegerField(choices=[(4, b'IPv4'), (6, b'IPv6')], editable=False)),
('address', ipam.fields.IPAddressField()),
('description', models.CharField(blank=True, max_length=100)),
('interface', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='ip_addresses', to='dcim.Interface')),
('nat_inside', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='nat_outside', to='ipam.IPAddress', verbose_name=b'NAT IP (inside)')),
],
options={
'ordering': ['family', 'address'],
'verbose_name': 'IP address',
'verbose_name_plural': 'IP addresses',
},
),
migrations.CreateModel(
name='Prefix',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('family', models.PositiveSmallIntegerField(choices=[(4, b'IPv4'), (6, b'IPv6')], editable=False)),
('prefix', ipam.fields.IPNetworkField()),
('description', models.CharField(blank=True, max_length=100)),
],
options={
'ordering': ['family', 'prefix'],
'verbose_name_plural': 'prefixes',
},
),
migrations.CreateModel(
name='RIR',
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'],
'verbose_name': 'RIR',
'verbose_name_plural': 'RIRs',
},
),
migrations.CreateModel(
name='Role',
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)),
('weight', models.PositiveSmallIntegerField(default=1000)),
],
options={
'ordering': ['weight', 'name'],
},
),
migrations.CreateModel(
name='Status',
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)),
('weight', models.PositiveSmallIntegerField(default=1000)),
('bootstrap_class', models.PositiveSmallIntegerField(choices=[(0, b'Default'), (1, b'Primary'), (2, b'Success'), (3, b'Info'), (4, b'Warning'), (5, b'Danger')], default=0)),
],
options={
'ordering': ['weight', 'name'],
'verbose_name_plural': 'statuses',
},
),
migrations.CreateModel(
name='VLAN',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('vid', models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(4094)], verbose_name=b'ID')),
('name', models.CharField(max_length=30)),
('role', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='vlans', to='ipam.Role')),
('site', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='vlans', to='dcim.Site')),
('status', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='vlans', to='ipam.Status')),
],
options={
'ordering': ['site', 'vid'],
'verbose_name': 'VLAN',
'verbose_name_plural': 'VLANs',
},
),
migrations.CreateModel(
name='VRF',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50)),
('rd', models.CharField(max_length=21, unique=True, verbose_name=b'Route distinguisher')),
('description', models.CharField(blank=True, max_length=100)),
],
options={
'ordering': ['name'],
'verbose_name': 'VRF',
'verbose_name_plural': 'VRFs',
},
),
migrations.AddField(
model_name='prefix',
name='role',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='prefixes', to='ipam.Role'),
),
migrations.AddField(
model_name='prefix',
name='site',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='prefixes', to='dcim.Site'),
),
migrations.AddField(
model_name='prefix',
name='status',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='prefixes', to='ipam.Status'),
),
migrations.AddField(
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=b'VLAN'),
),
migrations.AddField(
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=b'VRF'),
),
migrations.AddField(
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=b'VRF'),
),
migrations.AddField(
model_name='aggregate',
name='rir',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='aggregates', to='ipam.RIR', verbose_name=b'RIR'),
),
]

View File

275
netbox/ipam/models.py Normal file
View File

@@ -0,0 +1,275 @@
from netaddr import cidr_merge
from django.core.exceptions import ValidationError
from django.core.urlresolvers import reverse
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from dcim.models import Interface
from .fields import IPNetworkField, IPAddressField
AF_CHOICES = (
(4, 'IPv4'),
(6, 'IPv6'),
)
BOOTSTRAP_CLASS_CHOICES = (
(0, 'Default'),
(1, 'Primary'),
(2, 'Success'),
(3, 'Info'),
(4, 'Warning'),
(5, 'Danger'),
)
class VRF(models.Model):
"""
A discrete layer three forwarding domain (e.g. a routing table)
"""
name = models.CharField(max_length=50)
rd = models.CharField(max_length=21, unique=True, verbose_name='Route distinguisher')
description = models.CharField(max_length=100, blank=True)
class Meta:
ordering = ['name']
verbose_name = 'VRF'
verbose_name_plural = 'VRFs'
def __unicode__(self):
return self.name
def get_absolute_url(self):
return reverse('ipam:vrf', args=[self.pk])
class Status(models.Model):
"""
The status of a prefix or VLAN (e.g. allocated, reserved, etc.)
"""
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
weight = models.PositiveSmallIntegerField(default=1000)
bootstrap_class = models.PositiveSmallIntegerField(choices=BOOTSTRAP_CLASS_CHOICES, default=0)
class Meta:
ordering = ['weight', 'name']
verbose_name_plural = 'statuses'
def __unicode__(self):
return self.name
class Role(models.Model):
"""
The role of an address resource (e.g. customer, infrastructure, mgmt, etc.)
"""
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
weight = models.PositiveSmallIntegerField(default=1000)
class Meta:
ordering = ['weight', 'name']
def __unicode__(self):
return self.name
class RIR(models.Model):
"""
A regional Internet registry (e.g. ARIN) or governing standard (e.g. RFC 1918)
"""
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
class Meta:
ordering = ['name']
verbose_name = 'RIR'
verbose_name_plural = 'RIRs'
def __unicode__(self):
return self.name
class Aggregate(models.Model):
"""
A top-level IPv4 or IPv6 prefix
"""
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)
class Meta:
ordering = ['family', 'prefix']
def __unicode__(self):
return str(self.prefix)
def get_absolute_url(self):
return reverse('ipam:aggregate', args=[self.pk])
def clean(self):
if self.prefix:
# Clear host bits from prefix
self.prefix = self.prefix.cidr
# Ensure that the aggregate being added is not covered by an existing aggregate
covering_aggregates = Aggregate.objects.filter(prefix__net_contains_or_equals=str(self.prefix))
if self.pk:
covering_aggregates = covering_aggregates.exclude(pk=self.pk)
if covering_aggregates:
raise ValidationError("{} is already covered by an existing aggregate ({})"
.format(self.prefix, covering_aggregates[0]))
def save(self, *args, **kwargs):
if self.prefix:
# Infer address family from IPNetwork object
self.family = self.prefix.version
super(Aggregate, self).save(*args, **kwargs)
def get_utilization(self):
"""
Determine the utilization rate of the aggregate prefix and return it as a percentage.
"""
child_prefixes = Prefix.objects.filter(prefix__net_contained_or_equal=str(self.prefix))
# Remove overlapping prefixes from list of children
networks = cidr_merge([c.prefix for c in child_prefixes])
children_size = float(0)
for p in networks:
children_size += p.size
return int(children_size / self.prefix.size * 100)
class PrefixQuerySet(models.QuerySet):
def annotate_depth(self, limit=None):
"""
Iterate through a QuerySet of Prefixes and annotate the hierarchical level of each. While it would be preferable
to do this using .extra() on the QuerySet to count the unique parents of each prefix, that approach introduces
performance issues at scale.
Because we're adding a non-field attribute to the model, annotation must be made *after* any QuerySet
modifications.
"""
queryset = self
stack = []
for p in queryset:
try:
prev_p = stack[-1]
except IndexError:
prev_p = None
if prev_p is not None:
while (p.prefix not in prev_p.prefix) or p.prefix == prev_p.prefix:
stack.pop()
try:
prev_p = stack[-1]
except IndexError:
prev_p = None
break
if prev_p is not None:
prev_p.has_children = True
stack.append(p)
p.depth = len(stack) - 1
if limit is None:
return queryset
return filter(lambda p: p.depth <= limit, queryset)
class Prefix(models.Model):
"""
An IPv4 or IPv6 prefix, including mask length
"""
family = models.PositiveSmallIntegerField(choices=AF_CHOICES, editable=False)
prefix = IPNetworkField()
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')
vlan = models.ForeignKey('VLAN', related_name='prefixes', on_delete=models.PROTECT, blank=True, null=True, verbose_name='VLAN')
status = models.ForeignKey('Status', related_name='prefixes', on_delete=models.PROTECT)
role = models.ForeignKey('Role', related_name='prefixes', on_delete=models.SET_NULL, blank=True, null=True)
description = models.CharField(max_length=100, blank=True)
objects = PrefixQuerySet.as_manager()
class Meta:
ordering = ['family', 'prefix']
verbose_name_plural = 'prefixes'
def __unicode__(self):
return str(self.prefix)
def get_absolute_url(self):
return reverse('ipam:prefix', args=[self.pk])
def save(self, *args, **kwargs):
if self.prefix:
# Clear host bits from prefix
self.prefix = self.prefix.cidr
# Infer address family from IPNetwork object
self.family = self.prefix.version
super(Prefix, self).save(*args, **kwargs)
class IPAddress(models.Model):
"""
An IPv4 or IPv6 address
"""
family = models.PositiveSmallIntegerField(choices=AF_CHOICES, editable=False)
address = IPAddressField()
vrf = models.ForeignKey('VRF', related_name='ip_addresses', on_delete=models.PROTECT, blank=True, null=True, verbose_name='VRF')
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 IP (inside)')
description = models.CharField(max_length=100, blank=True)
class Meta:
ordering = ['family', 'address']
verbose_name = 'IP address'
verbose_name_plural = 'IP addresses'
def __unicode__(self):
return str(self.address)
def get_absolute_url(self):
return reverse('ipam:ipaddress', args=[self.pk])
def save(self, *args, **kwargs):
if self.address:
# Infer address family from IPAddress object
self.family = self.address.version
super(IPAddress, self).save(*args, **kwargs)
@property
def device(self):
if self.interface:
return self.interface.device
return None
class VLAN(models.Model):
"""
A VLAN within a site
"""
site = models.ForeignKey('dcim.Site', related_name='vlans', on_delete=models.PROTECT)
vid = models.PositiveSmallIntegerField(verbose_name='ID', validators=[
MinValueValidator(1),
MaxValueValidator(4094)
])
name = models.CharField(max_length=30)
status = models.ForeignKey('Status', related_name='vlans', on_delete=models.PROTECT)
role = models.ForeignKey('Role', related_name='vlans', on_delete=models.SET_NULL, blank=True, null=True)
class Meta:
ordering = ['site', 'vid']
verbose_name = 'VLAN'
verbose_name_plural = 'VLANs'
def __unicode__(self):
return "{0} ({1})".format(self.vid, self.name)
def get_absolute_url(self):
return reverse('ipam:vlan', args=[self.pk])

210
netbox/ipam/tables.py Normal file
View File

@@ -0,0 +1,210 @@
import django_tables2 as tables
from django_tables2.utils import Accessor
from .models import Aggregate, Prefix, IPAddress, VLAN, VRF
UTILIZATION_GRAPH = """
{% with record.get_utilization as percentage %}
<div class="progress text-center">
{% if percentage < 15 %}<span style="font-size: 12px;">{{ percentage }}%</span>{% endif %}
<div class="progress-bar progress-bar-{% if percentage >= 90 %}danger{% elif percentage >= 75 %}warning{% else %}success{% endif %}" role="progressbar" aria-valuenow="{{ percentage }}" aria-valuemin="0" aria-valuemax="100" style="width: {{ percentage }}%">
{% if percentage >= 15 %}{{ percentage }}%{% endif %}
</div>
</div>
{% endwith %}
"""
PREFIX_LINK = """
{% if record.has_children %}
<span style="padding-left: {{ record.depth }}0px "><i class="fa fa-caret-right"></i></a>
{% else %}
<span style="padding-left: {{ record.depth }}9px">
{% endif %}
<a href="{% if record.pk %}{% url 'ipam:prefix' pk=record.pk %}{% else %}{% url 'ipam:prefix_add' %}?prefix={{ record }}{% endif %}">{{ record.prefix }}</a>
</span>
"""
PREFIX_LINK_BRIEF = """
<span style="padding-left: {{ record.depth }}0px">
<a href="{% if record.pk %}{% url 'ipam:prefix' pk=record.pk %}{% else %}{% url 'ipam:prefix_add' %}?prefix={{ record }}{% endif %}">{{ record.prefix }}</a>
</span>
"""
STATUS_LABEL = """
{% if record.pk %}
<span class="label label-{{ record.status.get_bootstrap_class_display|lower }}">{{ record.status.name }}</span>
{% else %}
<span class="label label-success">Available</span>
{% endif %}
"""
#
# VRFs
#
class VRFTable(tables.Table):
name = tables.LinkColumn('ipam:vrf', args=[Accessor('pk')], verbose_name='Name')
rd = tables.Column(verbose_name='RD')
description = tables.Column(sortable=False, verbose_name='Description')
class Meta:
model = VRF
fields = ('name', 'rd', 'description')
empty_text = "No VRFs found."
attrs = {
'class': 'table table-hover',
}
class VRFBulkEditTable(VRFTable):
pk = tables.CheckBoxColumn()
class Meta(VRFTable.Meta):
model = None # django_tables2 bugfix
fields = ('pk', 'name', 'rd', 'description')
#
# Aggregates
#
class AggregateTable(tables.Table):
prefix = tables.LinkColumn('ipam:aggregate', args=[Accessor('pk')], verbose_name='Aggregate')
rir = tables.Column(verbose_name='RIR')
child_count = tables.Column(verbose_name='Prefixes')
utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
date_added = tables.DateColumn(format="Y-m-d", verbose_name='Added')
description = tables.Column(sortable=False, verbose_name='Description')
class Meta:
model = Aggregate
fields = ('prefix', 'rir', 'child_count', 'utilization', 'date_added', 'description')
empty_text = "No aggregates found."
attrs = {
'class': 'table table-hover',
}
class AggregateBulkEditTable(AggregateTable):
pk = tables.CheckBoxColumn()
class Meta(AggregateTable.Meta):
model = None # django_tables2 bugfix
fields = ('pk', 'prefix', 'rir', 'child_count', 'utilization', 'date_added', 'description')
#
# Prefixes
#
class PrefixTable(tables.Table):
status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
prefix = tables.TemplateColumn(PREFIX_LINK, verbose_name='Prefix')
vrf = tables.Column(orderable=False, default='Global', verbose_name='VRF')
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
role = tables.Column(verbose_name='Role')
description = tables.Column(sortable=False, verbose_name='Description')
class Meta:
model = Prefix
fields = ('prefix', 'status', 'vrf', 'site', 'role', 'description')
empty_text = "No prefixes found."
attrs = {
'class': 'table table-hover',
}
class PrefixBriefTable(tables.Table):
prefix = tables.TemplateColumn(PREFIX_LINK_BRIEF, verbose_name='Prefix')
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
role = tables.Column(verbose_name='Role')
class Meta:
model = Prefix
fields = ('prefix', 'status', 'site', 'role')
empty_text = "No prefixes found."
attrs = {
'class': 'table table-hover',
}
class PrefixBulkEditTable(PrefixTable):
pk = tables.CheckBoxColumn(default='')
class Meta(PrefixTable.Meta):
model = None # django_tables2 bugfix
fields = ('pk', 'prefix', 'status', 'vrf', 'site', 'role', 'description')
#
# IPAddresses
#
class IPAddressTable(tables.Table):
address = tables.LinkColumn('ipam:ipaddress', args=[Accessor('pk')], verbose_name='IP Address')
vrf = tables.Column(orderable=False, default='Global', verbose_name='VRF')
device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False, verbose_name='Device')
interface = tables.Column(orderable=False, verbose_name='Interface')
description = tables.Column(sortable=False, verbose_name='Description')
class Meta:
model = IPAddress
fields = ('address', 'vrf', 'device', 'interface', 'description')
empty_text = "No IP addresses found."
attrs = {
'class': 'table table-hover',
}
class IPAddressBriefTable(tables.Table):
address = tables.LinkColumn('ipam:ipaddress', args=[Accessor('pk')], verbose_name='IP Address')
device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False, verbose_name='Device')
interface = tables.Column(orderable=False, verbose_name='Interface')
nat_inside = tables.LinkColumn('ipam:ipaddress', args=[Accessor('nat_inside.pk')], orderable=False, verbose_name='NAT (Inside)')
class Meta:
model = IPAddress
fields = ('address', 'device', 'interface', 'nat_inside')
empty_text = "No IP addresses found."
attrs = {
'class': 'table table-hover',
}
class IPAddressBulkEditTable(IPAddressTable):
pk = tables.CheckBoxColumn()
class Meta(IPAddressTable.Meta):
model = None # django_tables2 bugfix
fields = ('pk', 'address', 'vrf', 'device', 'interface', 'description')
#
# VLANs
#
class VLANTable(tables.Table):
vid = tables.LinkColumn('ipam:vlan', args=[Accessor('pk')], verbose_name='ID')
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
name = tables.Column(verbose_name='Name')
status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
role = tables.Column(verbose_name='Role')
class Meta:
model = VLAN
fields = ('vid', 'site', 'name', 'status', 'role')
empty_text = "No VLANs found."
attrs = {
'class': 'table table-hover',
}
class VLANBulkEditTable(VLANTable):
pk = tables.CheckBoxColumn()
class Meta(VLANTable.Meta):
model = None # django_tables2 bugfix
fields = ('pk', 'vid', 'site', 'name', 'status', 'role')

3
netbox/ipam/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

51
netbox/ipam/urls.py Normal file
View File

@@ -0,0 +1,51 @@
from django.conf.urls import url
from . import views
urlpatterns = [
url(r'^vrfs/$', views.vrf_list, name='vrf_list'),
url(r'^vrfs/add/$', views.vrf_add, name='vrf_add'),
url(r'^vrfs/import/$', views.VRFBulkImportView.as_view(), name='vrf_import'),
url(r'^vrfs/edit/$', views.VRFBulkEditView.as_view(), name='vrf_bulk_edit'),
url(r'^vrfs/delete/$', views.VRFBulkDeleteView.as_view(), name='vrf_bulk_delete'),
url(r'^vrfs/(?P<pk>\d+)/$', views.vrf, name='vrf'),
url(r'^vrfs/(?P<pk>\d+)/edit/$', views.vrf_edit, name='vrf_edit'),
url(r'^vrfs/(?P<pk>\d+)/delete/$', views.vrf_delete, name='vrf_delete'),
url(r'^aggregates/$', views.aggregate_list, name='aggregate_list'),
url(r'^aggregates/add/$', views.aggregate_add, name='aggregate_add'),
url(r'^aggregates/import/$', views.AggregateBulkImportView.as_view(), name='aggregate_import'),
url(r'^aggregates/edit/$', views.AggregateBulkEditView.as_view(), name='aggregate_bulk_edit'),
url(r'^aggregates/delete/$', views.AggregateBulkDeleteView.as_view(), name='aggregate_bulk_delete'),
url(r'^aggregates/(?P<pk>\d+)/$', views.aggregate, name='aggregate'),
url(r'^aggregates/(?P<pk>\d+)/edit/$', views.aggregate_edit, name='aggregate_edit'),
url(r'^aggregates/(?P<pk>\d+)/delete/$', views.aggregate_delete, name='aggregate_delete'),
url(r'^prefixes/$', views.prefix_list, name='prefix_list'),
url(r'^prefixes/add/$', views.prefix_add, name='prefix_add'),
url(r'^prefixes/import/$', views.PrefixBulkImportView.as_view(), name='prefix_import'),
url(r'^prefixes/edit/$', views.PrefixBulkEditView.as_view(), name='prefix_bulk_edit'),
url(r'^prefixes/delete/$', views.PrefixBulkDeleteView.as_view(), name='prefix_bulk_delete'),
url(r'^prefixes/(?P<pk>\d+)/$', views.prefix, name='prefix'),
url(r'^prefixes/(?P<pk>\d+)/edit/$', views.prefix_edit, name='prefix_edit'),
url(r'^prefixes/(?P<pk>\d+)/delete/$', views.prefix_delete, name='prefix_delete'),
url(r'^prefixes/(?P<pk>\d+)/ip-addresses/$', views.prefix_ipaddresses, name='prefix_ipaddresses'),
url(r'^ip-addresses/$', views.ipaddress_list, name='ipaddress_list'),
url(r'^ip-addresses/add/$', views.ipaddress_add, name='ipaddress_add'),
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+)/$', views.ipaddress, name='ipaddress'),
url(r'^ip-addresses/(?P<pk>\d+)/edit/$', views.ipaddress_edit, name='ipaddress_edit'),
url(r'^ip-addresses/(?P<pk>\d+)/delete/$', views.ipaddress_delete, name='ipaddress_delete'),
url(r'^vlans/$', views.vlan_list, name='vlan_list'),
url(r'^vlans/add/$', views.vlan_add, name='vlan_add'),
url(r'^vlans/import/$', views.VLANBulkImportView.as_view(), name='vlan_import'),
url(r'^vlans/edit/$', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'),
url(r'^vlans/delete/$', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'),
url(r'^vlans/(?P<pk>\d+)/$', views.vlan, name='vlan'),
url(r'^vlans/(?P<pk>\d+)/edit/$', views.vlan_edit, name='vlan_edit'),
url(r'^vlans/(?P<pk>\d+)/delete/$', views.vlan_delete, name='vlan_delete'),
]

899
netbox/ipam/views.py Normal file
View File

@@ -0,0 +1,899 @@
from netaddr import IPNetwork, IPSet
from netaddr.core import AddrFormatError
from django_tables2 import RequestConfig
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import permission_required
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.urlresolvers import reverse
from django.db.models import ProtectedError
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect, render
from django.utils.http import urlencode
from dcim.models import Device
from extras.models import ExportTemplate
from utilities.error_handlers import handle_protectederror
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator
from utilities.views import BulkImportView, BulkEditView, BulkDeleteView
from .filters import AggregateFilter, PrefixFilter, IPAddressFilter, VLANFilter, VRFFilter
from .forms import AggregateForm, AggregateImportForm, AggregateBulkEditForm, AggregateBulkDeleteForm, \
AggregateFilterForm, PrefixForm, PrefixImportForm, PrefixBulkEditForm, PrefixBulkDeleteForm, PrefixFilterForm, \
IPAddressForm, IPAddressImportForm, IPAddressBulkEditForm, IPAddressBulkDeleteForm, IPAddressFilterForm, VLANForm, \
VLANImportForm, VLANBulkEditForm, VLANBulkDeleteForm, VRFForm, VRFImportForm, VRFBulkEditForm, VRFBulkDeleteForm, \
VLANFilterForm
from .models import VRF, Aggregate, Prefix, VLAN
from .tables import AggregateTable, AggregateBulkEditTable, PrefixTable, PrefixBriefTable, PrefixBulkEditTable, \
IPAddress, IPAddressBriefTable, IPAddressTable, IPAddressBulkEditTable, VLANTable, VLANBulkEditTable, VRFTable, \
VRFBulkEditTable
def add_available_prefixes(parent, prefix_list):
"""
Create fake Prefix objects for all unallocated space within a prefix.
"""
# Find all unallocated space
available_prefixes = IPSet(parent) ^ IPSet([p.prefix for p in prefix_list])
available_prefixes = [Prefix(prefix=p) for p in available_prefixes.iter_cidrs()]
# Concatenate and sort complete list of children
prefix_list = list(prefix_list) + available_prefixes
prefix_list.sort(key=lambda p: p.prefix)
return prefix_list
#
# VRFs
#
def vrf_list(request):
queryset = VRF.objects.all()
queryset = VRFFilter(request.GET, queryset).qs
# annotate_depth(queryset)
# Export
if 'export' in request.GET:
et = get_object_or_404(ExportTemplate, content_type__model='vrf', name=request.GET.get('export'))
response = et.to_response(context_dict={'queryset': queryset}, filename='netbox_vrfs')
return response
if request.user.has_perm('ipam.change_vrf') or request.user.has_perm('ipam.delete_vrf'):
vrf_table = VRFBulkEditTable(queryset)
else:
vrf_table = VRFTable(queryset)
RequestConfig(request, paginate={'per_page': settings.PAGINATE_COUNT, 'klass': EnhancedPaginator}).configure(vrf_table)
export_templates = ExportTemplate.objects.filter(content_type__model='vrf')
return render(request, 'ipam/vrf_list.html', {
'vrf_table': vrf_table,
'export_templates': export_templates,
})
def vrf(request, pk):
vrf = get_object_or_404(VRF.objects.all(), pk=pk)
prefixes = Prefix.objects.filter(vrf=vrf)
return render(request, 'ipam/vrf.html', {
'vrf': vrf,
'prefixes': prefixes,
})
@permission_required('ipam.add_vrf')
def vrf_add(request):
if request.method == 'POST':
form = VRFForm(request.POST)
if form.is_valid():
vrf = form.save()
messages.success(request, "Added new VRF: {0}".format(vrf))
if '_addanother' in request.POST:
return redirect('ipam:vrf_add')
else:
return redirect('ipam:vrf', pk=vrf.pk)
else:
form = VRFForm()
return render(request, 'ipam/vrf_edit.html', {
'form': form,
'cancel_url': reverse('ipam:vrf_list'),
})
@permission_required('ipam.change_vrf')
def vrf_edit(request, pk):
vrf = get_object_or_404(VRF, pk=pk)
if request.method == 'POST':
form = VRFForm(request.POST, instance=vrf)
if form.is_valid():
vrf = form.save()
messages.success(request, "Modified VRF {0}".format(vrf))
return redirect('ipam:vrf', pk=vrf.pk)
else:
form = VRFForm(instance=vrf)
return render(request, 'ipam/vrf_edit.html', {
'vrf': vrf,
'form': form,
'cancel_url': reverse('ipam:vrf', kwargs={'pk': vrf.pk}),
})
@permission_required('ipam.delete_vrf')
def vrf_delete(request, pk):
vrf = get_object_or_404(VRF, pk=pk)
if request.method == 'POST':
form = ConfirmationForm(request.POST)
if form.is_valid():
try:
vrf.delete()
messages.success(request, "VRF {0} has been deleted".format(vrf))
return redirect('ipam:vrf_list')
except ProtectedError, e:
handle_protectederror(vrf, request, e)
return redirect('ipam:vrf', pk=vrf.pk)
else:
form = ConfirmationForm()
return render(request, 'ipam/vrf_delete.html', {
'vrf': vrf,
'form': form,
'cancel_url': reverse('ipam:vrf', kwargs={'pk': vrf.pk})
})
class VRFBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'ipam.add_vrf'
form = VRFImportForm
table = VRFTable
template_name = 'ipam/vrf_import.html'
obj_list_url = 'ipam:vrf_list'
class VRFBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'ipam.change_vrf'
cls = VRF
form = VRFBulkEditForm
template_name = 'ipam/vrf_bulk_edit.html'
redirect_url = 'ipam:vrf_list'
def update_objects(self, pk_list, form):
fields_to_update = {}
for field in ['description']:
if form.cleaned_data[field]:
fields_to_update[field] = form.cleaned_data[field]
updated_count = self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
messages.success(self.request, "Updated {} VRFs".format(updated_count))
class VRFBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_vrf'
cls = VRF
form = VRFBulkDeleteForm
template_name = 'ipam/vrf_bulk_delete.html'
redirect_url = 'ipam:vrf_list'
#
# Aggregates
#
def aggregate_list(request):
queryset = Aggregate.objects.select_related('rir').extra(
select = {
'child_count': 'SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix',
}
)
queryset = AggregateFilter(request.GET, queryset).qs
# Export
if 'export' in request.GET:
et = get_object_or_404(ExportTemplate, content_type__model='aggregate', name=request.GET.get('export'))
response = et.to_response(context_dict={'queryset': queryset}, filename='netbox_aggregates')
return response
if request.user.has_perm('ipam.change_aggregate') or request.user.has_perm('ipam.delete_aggregate'):
aggregate_table = AggregateBulkEditTable(queryset)
else:
aggregate_table = AggregateTable(queryset)
RequestConfig(request, paginate={'per_page': settings.PAGINATE_COUNT, 'klass': EnhancedPaginator})\
.configure(aggregate_table)
export_templates = ExportTemplate.objects.filter(content_type__model='aggregate')
return render(request, 'ipam/aggregate_list.html', {
'aggregate_table': aggregate_table,
'export_templates': export_templates,
'filter_form': AggregateFilterForm(request.GET, label_suffix=''),
})
def aggregate(request, pk):
aggregate = get_object_or_404(Aggregate, pk=pk)
# Find all child prefixes contained by this aggregate
child_prefixes = Prefix.objects.filter(prefix__net_contained_or_equal=str(aggregate.prefix))\
.select_related('site', 'status', 'role').annotate_depth(limit=0)
child_prefixes = add_available_prefixes(aggregate.prefix, child_prefixes)
if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
prefix_table = PrefixBulkEditTable(child_prefixes)
else:
prefix_table = PrefixTable(child_prefixes)
RequestConfig(request, paginate={'per_page': settings.PAGINATE_COUNT, 'klass': EnhancedPaginator})\
.configure(prefix_table)
return render(request, 'ipam/aggregate.html', {
'aggregate': aggregate,
'prefix_table': prefix_table,
})
@permission_required('ipam.add_aggregate')
def aggregate_add(request):
if request.method == 'POST':
form = AggregateForm(request.POST)
if form.is_valid():
aggregate = form.save()
messages.success(request, "Added new aggregate: {0}".format(aggregate.prefix))
if '_addanother' in request.POST:
return redirect('ipam:aggregate_add')
else:
return redirect('ipam:aggregate', pk=aggregate.pk)
else:
form = AggregateForm()
return render(request, 'ipam/aggregate_edit.html', {
'form': form,
'cancel_url': reverse('ipam:aggregate_list'),
})
@permission_required('ipam.change_aggregate')
def aggregate_edit(request, pk):
aggregate = get_object_or_404(Aggregate, pk=pk)
if request.method == 'POST':
form = AggregateForm(request.POST, instance=aggregate)
if form.is_valid():
aggregate = form.save()
messages.success(request, "Modified aggregate {0}".format(aggregate.prefix))
return redirect('ipam:aggregate', pk=aggregate.pk)
else:
form = AggregateForm(instance=aggregate)
return render(request, 'ipam/aggregate_edit.html', {
'aggregate': aggregate,
'form': form,
'cancel_url': reverse('ipam:aggregate', kwargs={'pk': aggregate.pk}),
})
@permission_required('ipam.delete_aggregate')
def aggregate_delete(request, pk):
aggregate = get_object_or_404(Aggregate, pk=pk)
if request.method == 'POST':
form = ConfirmationForm(request.POST)
if form.is_valid():
try:
aggregate.delete()
messages.success(request, "Aggregate {0} has been deleted".format(aggregate))
return redirect('ipam:aggregate_list')
except ProtectedError, e:
handle_protectederror(aggregate, request, e)
return redirect('ipam:aggregate', pk=aggregate.pk)
else:
form = ConfirmationForm()
return render(request, 'ipam/aggregate_delete.html', {
'aggregate': aggregate,
'form': form,
'cancel_url': reverse('ipam:aggregate', kwargs={'pk': aggregate.pk})
})
class AggregateBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'ipam.add_aggregate'
form = AggregateImportForm
table = AggregateTable
template_name = 'ipam/aggregate_import.html'
obj_list_url = 'ipam:aggregate_list'
class AggregateBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'ipam.change_aggregate'
cls = Aggregate
form = AggregateBulkEditForm
template_name = 'ipam/aggregate_bulk_edit.html'
redirect_url = 'ipam:aggregate_list'
def update_objects(self, pk_list, form):
fields_to_update = {}
for field in ['rir', 'date_added', 'description']:
if form.cleaned_data[field]:
fields_to_update[field] = form.cleaned_data[field]
updated_count = self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
messages.success(self.request, "Updated {} aggregates".format(updated_count))
class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_aggregate'
cls = Aggregate
form = AggregateBulkDeleteForm
template_name = 'ipam/aggregate_bulk_delete.html'
redirect_url = 'ipam:aggregate_list'
#
# Prefixes
#
def prefix_list(request):
queryset = Prefix.objects.select_related('site', 'status', 'role')
queryset = PrefixFilter(request.GET, queryset).qs
# Export
if 'export' in request.GET:
et = get_object_or_404(ExportTemplate, content_type__model='prefix', name=request.GET.get('export'))
response = et.to_response(context_dict={'queryset': queryset}, filename='netbox_prefixes')
return response
# Show only top-level prefixes by default
limit = None if request.GET.get('expand') else 0
prefixes = queryset.annotate_depth(limit=limit)
if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
prefix_table = PrefixBulkEditTable(prefixes)
else:
prefix_table = PrefixTable(prefixes)
RequestConfig(request, paginate={'per_page': settings.PAGINATE_COUNT, 'klass': EnhancedPaginator}).configure(prefix_table)
export_templates = ExportTemplate.objects.filter(content_type__model='prefix')
return render(request, 'ipam/prefix_list.html', {
'prefix_table': prefix_table,
'export_templates': export_templates,
'filter_form': PrefixFilterForm(request.GET, label_suffix=''),
})
def prefix(request, pk):
prefix = get_object_or_404(Prefix.objects.select_related('site', 'vlan', 'status', 'role'), pk=pk)
try:
aggregate = Aggregate.objects.get(prefix__net_contains_or_equals=str(prefix.prefix))
except Aggregate.DoesNotExist:
aggregate = None
# Count child IP addresses
ipaddress_count = IPAddress.objects.filter(address__net_contained_or_equal=str(prefix.prefix)).count()
# Parent prefixes table
parent_prefixes = Prefix.objects.filter(vrf=prefix.vrf, prefix__net_contains=str(prefix.prefix))\
.select_related('site', 'status', 'role').annotate_depth()
parent_prefix_table = PrefixBriefTable(parent_prefixes)
# Duplicate prefixes table
duplicate_prefixes = Prefix.objects.filter(vrf=prefix.vrf, prefix=str(prefix.prefix)).exclude(pk=prefix.pk)\
.select_related('site', 'status', 'role')
duplicate_prefix_table = PrefixBriefTable(duplicate_prefixes)
# Child prefixes table
child_prefixes = Prefix.objects.filter(vrf=prefix.vrf, prefix__net_contained=str(prefix.prefix))\
.select_related('site', 'status', 'role').annotate_depth(limit=0)
if child_prefixes:
child_prefixes = add_available_prefixes(prefix.prefix, child_prefixes)
if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
child_prefix_table = PrefixBulkEditTable(child_prefixes)
else:
child_prefix_table = PrefixTable(child_prefixes)
RequestConfig(request, paginate={'per_page': settings.PAGINATE_COUNT, 'klass': EnhancedPaginator})\
.configure(child_prefix_table)
return render(request, 'ipam/prefix.html', {
'prefix': prefix,
'aggregate': aggregate,
'ipaddress_count': ipaddress_count,
'parent_prefix_table': parent_prefix_table,
'child_prefix_table': child_prefix_table,
'duplicate_prefix_table': duplicate_prefix_table,
})
@permission_required('ipam.add_prefix')
def prefix_add(request):
if request.method == 'POST':
form = PrefixForm(request.POST)
if form.is_valid():
prefix = form.save()
messages.success(request, "Added new prefix: {0}".format(prefix.prefix))
if '_addanother' in request.POST:
return redirect('ipam:prefix_add')
else:
return redirect('ipam:prefix', pk=prefix.pk)
else:
form = PrefixForm(initial={
'site': request.GET.get('site'),
'prefix': request.GET.get('prefix'),
})
return render(request, 'ipam/prefix_edit.html', {
'form': form,
'cancel_url': reverse('ipam:prefix_list'),
})
@permission_required('ipam.change_prefix')
def prefix_edit(request, pk):
prefix = get_object_or_404(Prefix, pk=pk)
if request.method == 'POST':
form = PrefixForm(request.POST, instance=prefix)
if form.is_valid():
prefix = form.save()
messages.success(request, "Modified prefix {0}".format(prefix.prefix))
return redirect('ipam:prefix', pk=prefix.pk)
else:
form = PrefixForm(instance=prefix)
return render(request, 'ipam/prefix_edit.html', {
'prefix': prefix,
'form': form,
'cancel_url': reverse('ipam:prefix', kwargs={'pk': prefix.pk}),
})
@permission_required('ipam.delete_prefix')
def prefix_delete(request, pk):
prefix = get_object_or_404(Prefix, pk=pk)
if request.method == 'POST':
form = ConfirmationForm(request.POST)
if form.is_valid():
try:
prefix.delete()
messages.success(request, "Prefix {0} has been deleted".format(prefix))
return redirect('ipam:prefix_list')
except ProtectedError, e:
handle_protectederror(prefix, request, e)
return redirect('ipam:prefix', pk=prefix.pk)
else:
form = ConfirmationForm()
return render(request, 'ipam/prefix_delete.html', {
'prefix': prefix,
'form': form,
'cancel_url': reverse('ipam:prefix', kwargs={'pk': prefix.pk})
})
class PrefixBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'ipam.add_prefix'
form = PrefixImportForm
table = PrefixTable
template_name = 'ipam/prefix_import.html'
obj_list_url = 'ipam:prefix_list'
class PrefixBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'ipam.change_prefix'
cls = Prefix
form = PrefixBulkEditForm
template_name = 'ipam/prefix_bulk_edit.html'
redirect_url = 'ipam:prefix_list'
def update_objects(self, pk_list, form):
fields_to_update = {}
if form.cleaned_data['vrf']:
fields_to_update['vrf'] = form.cleaned_data['vrf']
elif form.cleaned_data['vrf_global']:
fields_to_update['vrf'] = None
for field in ['site', 'status', 'role', 'description']:
if form.cleaned_data[field]:
fields_to_update[field] = form.cleaned_data[field]
updated_count = self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
messages.success(self.request, "Updated {} prefixes".format(updated_count))
class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_prefix'
cls = Prefix
form = PrefixBulkDeleteForm
template_name = 'ipam/prefix_bulk_delete.html'
redirect_url = 'ipam:prefix_list'
def prefix_ipaddresses(request, pk):
prefix = get_object_or_404(Prefix.objects.all(), pk=pk)
# Find all IPAddresses belonging to this Prefix
ipaddresses = IPAddress.objects.filter(address__net_contained_or_equal=str(prefix.prefix))\
.select_related('vrf', 'interface__device', 'primary_for')
if request.user.has_perm('ipam.change_ipaddress') or request.user.has_perm('ipam.delete_ipaddress'):
ip_table = IPAddressBulkEditTable(ipaddresses)
else:
ip_table = IPAddressTable(ipaddresses)
RequestConfig(request, paginate={'per_page': settings.PAGINATE_COUNT, 'klass': EnhancedPaginator})\
.configure(ip_table)
return render(request, 'ipam/prefix_ipaddresses.html', {
'prefix': prefix,
'ip_table': ip_table,
})
#
# IP addresses
#
def ipaddress_list(request):
queryset = IPAddress.objects.select_related('vrf', 'interface__device', 'primary_for')
queryset = IPAddressFilter(request.GET, queryset).qs
# Export
if 'export' in request.GET:
et = get_object_or_404(ExportTemplate, content_type__model='ipaddress', name=request.GET.get('export'))
response = et.to_response(context_dict={'queryset': queryset}, filename='netbox_ips')
return response
if request.user.has_perm('ipam.change_ipaddress') or request.user.has_perm('ipam.delete_ipaddress'):
ip_table = IPAddressBulkEditTable(queryset)
else:
ip_table = IPAddressTable(queryset)
RequestConfig(request, paginate={'per_page': settings.PAGINATE_COUNT, 'klass': EnhancedPaginator}).configure(ip_table)
export_templates = ExportTemplate.objects.filter(content_type__model='ipaddress')
# If searching and no IPAddresses were found, include a list of parent prefixes matching the query
prefix_table = None
if request.GET.get('q') and not queryset:
try:
ip = str(IPNetwork(request.GET.get('q')))
prefix_table = PrefixTable(Prefix.objects.filter(prefix__net_contains_or_equals=ip))
RequestConfig(request).configure(prefix_table)
except AddrFormatError:
pass
return render(request, 'ipam/ipaddress_list.html', {
'ip_table': ip_table,
'prefix_table': prefix_table,
'export_templates': export_templates,
'filter_form': IPAddressFilterForm(request.GET, label_suffix=''),
})
def ipaddress(request, pk):
ipaddress = get_object_or_404(IPAddress.objects.select_related('interface__device'), pk=pk)
parent_prefixes = Prefix.objects.filter(vrf=ipaddress.vrf, prefix__net_contains=str(ipaddress.address.ip))
related_ips = IPAddress.objects.select_related('interface__device').exclude(pk=ipaddress.pk).filter(vrf=ipaddress.vrf, address__net_contained_or_equal=str(ipaddress.address))
related_ips_table = IPAddressBriefTable(related_ips)
RequestConfig(request, paginate={'per_page': settings.PAGINATE_COUNT, 'klass': EnhancedPaginator}).configure(related_ips_table)
return render(request, 'ipam/ipaddress.html', {
'ipaddress': ipaddress,
'parent_prefixes': parent_prefixes,
'related_ips_table': related_ips_table,
})
@permission_required('ipam.add_ipaddress')
def ipaddress_add(request):
if request.method == 'POST':
form = IPAddressForm(request.POST)
if form.is_valid():
ipaddress = form.save()
messages.success(request, "Created new IP Address: {0}".format(ipaddress))
if '_addanother' in request.POST:
return redirect('ipam:ipaddress_add')
else:
return redirect('ipam:ipaddress', pk=ipaddress.pk)
else:
form = IPAddressForm(initial={
'ipaddress': request.GET.get('ipaddress', None),
})
return render(request, 'ipam/ipaddress_edit.html', {
'form': form,
'cancel_url': reverse('ipam:ipaddress_list'),
})
@permission_required('ipam.change_ipaddress')
def ipaddress_edit(request, pk):
ipaddress = get_object_or_404(IPAddress, pk=pk)
if request.method == 'POST':
form = IPAddressForm(request.POST, instance=ipaddress)
if form.is_valid():
ipaddress = form.save()
messages.success(request, "Modified IP address {0}".format(ipaddress))
return redirect('ipam:ipaddress', pk=ipaddress.pk)
else:
form = IPAddressForm(instance=ipaddress)
return render(request, 'ipam/ipaddress_edit.html', {
'ipaddress': ipaddress,
'form': form,
'cancel_url': reverse('ipam:ipaddress', kwargs={'pk': ipaddress.pk}),
})
@permission_required('ipam.delete_ipaddress')
def ipaddress_delete(request, pk):
ipaddress = get_object_or_404(IPAddress, pk=pk)
if request.method == 'POST':
form = ConfirmationForm(request.POST)
if form.is_valid():
try:
ipaddress.delete()
messages.success(request, "IP address {0} has been deleted".format(ipaddress))
if ipaddress.interface:
return redirect('dcim:device', pk=ipaddress.interface.device.pk)
else:
return redirect('ipam:ipaddress_list')
except ProtectedError, e:
handle_protectederror(ipaddress, request, e)
return redirect('ipam:ipaddress', pk=ipaddress.pk)
else:
form = ConfirmationForm()
# Upon cancellation, redirect to the assigned device if one exists
if ipaddress.interface:
cancel_url = reverse('dcim:device', kwargs={'pk': ipaddress.interface.device.pk})
else:
cancel_url = reverse('ipam:ipaddress_list')
return render(request, 'ipam/ipaddress_delete.html', {
'ipaddress': ipaddress,
'form': form,
'cancel_url': cancel_url,
})
class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'ipam.add_ipaddress'
form = IPAddressImportForm
table = IPAddressTable
template_name = 'ipam/ipaddress_import.html'
obj_list_url = 'ipam:ipaddress_list'
def save_obj(self, obj):
obj.save()
# Update primary IP for device if needed
try:
device = obj.primary_for
device.primary_ip = obj
device.save()
except Device.DoesNotExist:
pass
class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'ipam.change_ipaddress'
cls = IPAddress
form = IPAddressBulkEditForm
template_name = 'ipam/ipaddress_bulk_edit.html'
redirect_url = 'ipam:ipaddress_list'
def update_objects(self, pk_list, form):
fields_to_update = {}
if form.cleaned_data['vrf']:
fields_to_update['vrf'] = form.cleaned_data['vrf']
elif form.cleaned_data['vrf_global']:
fields_to_update['vrf'] = None
for field in ['description']:
if form.cleaned_data[field]:
fields_to_update[field] = form.cleaned_data[field]
updated_count = self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
messages.success(self.request, "Updated {} IP addresses".format(updated_count))
class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_ipaddress'
cls = IPAddress
form = IPAddressBulkDeleteForm
template_name = 'ipam/ipaddress_bulk_delete.html'
redirect_url = 'ipam:ipaddress_list'
#
# VLANs
#
def vlan_list(request):
queryset = VLAN.objects.select_related('site', 'status', 'role')
queryset = VLANFilter(request.GET, queryset).qs
# Export
if 'export' in request.GET:
et = get_object_or_404(ExportTemplate, content_type__model='vlan', name=request.GET.get('export'))
response = et.to_response(context_dict={'queryset': queryset}, filename='netbox_vlans')
return response
if request.user.has_perm('ipam.change_vlan') or request.user.has_perm('ipam.delete_vlan'):
vlan_table = VLANBulkEditTable(queryset)
else:
vlan_table = VLANTable(queryset)
RequestConfig(request, paginate={'per_page': settings.PAGINATE_COUNT, 'klass': EnhancedPaginator}).configure(vlan_table)
export_templates = ExportTemplate.objects.filter(content_type__model='vlan')
return render(request, 'ipam/vlan_list.html', {
'vlan_table': vlan_table,
'export_templates': export_templates,
'filter_form': VLANFilterForm(request.GET, label_suffix=''),
})
def vlan(request, pk):
vlan = get_object_or_404(VLAN.objects.select_related('site', 'status', 'role'), pk=pk)
prefixes = Prefix.objects.filter(vlan=vlan)
return render(request, 'ipam/vlan.html', {
'vlan': vlan,
'prefixes': prefixes,
})
@permission_required('ipam.add_vlan')
def vlan_add(request):
if request.method == 'POST':
form = VLANForm(request.POST)
if form.is_valid():
vlan = form.save()
messages.success(request, "Added new VLAN: {0}".format(vlan))
if '_addanother' in request.POST:
base_url = reverse('ipam:vlan_add')
params = urlencode({
'site': vlan.site.pk,
})
return HttpResponseRedirect('{}?{}'.format(base_url, params))
else:
return redirect('ipam:vlan', pk=vlan.pk)
else:
form = VLANForm()
return render(request, 'ipam/vlan_edit.html', {
'form': form,
'cancel_url': reverse('ipam:vlan_list'),
})
@permission_required('ipam.change_vlan')
def vlan_edit(request, pk):
vlan = get_object_or_404(VLAN, pk=pk)
if request.method == 'POST':
form = VLANForm(request.POST, instance=vlan)
if form.is_valid():
vlan = form.save()
messages.success(request, "Modified VLAN {0}".format(vlan))
return redirect('ipam:vlan', pk=vlan.pk)
else:
form = VLANForm(instance=vlan)
return render(request, 'ipam/vlan_edit.html', {
'vlan': vlan,
'form': form,
'cancel_url': reverse('ipam:vlan', kwargs={'pk': vlan.pk}),
})
@permission_required('ipam.delete_vlan')
def vlan_delete(request, pk):
vlan = get_object_or_404(VLAN, pk=pk)
if request.method == 'POST':
form = ConfirmationForm(request.POST)
if form.is_valid():
try:
vlan.delete()
messages.success(request, "VLAN {0} has been deleted".format(vlan))
return redirect('ipam:vlan_list')
except ProtectedError, e:
handle_protectederror(vlan, request, e)
return redirect('ipam:vlan', pk=vlan.pk)
else:
form = ConfirmationForm()
return render(request, 'ipam/vlan_delete.html', {
'vlan': vlan,
'form': form,
'cancel_url': reverse('ipam:vlan', kwargs={'pk': vlan.pk})
})
class VLANBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'ipam.add_vlan'
form = VLANImportForm
table = VLANTable
template_name = 'ipam/vlan_import.html'
obj_list_url = 'ipam:vlan_list'
class VLANBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'ipam.change_vlan'
cls = VLAN
form = VLANBulkEditForm
template_name = 'ipam/vlan_bulk_edit.html'
redirect_url = 'ipam:vlan_list'
def update_objects(self, pk_list, form):
fields_to_update = {}
for field in ['site', 'status', 'role']:
if form.cleaned_data[field]:
fields_to_update[field] = form.cleaned_data[field]
updated_count = self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
messages.success(self.request, "Updated {} VLANs".format(updated_count))
class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_vlan'
cls = VLAN
form = VLANBulkDeleteForm
template_name = 'ipam/vlan_bulk_delete.html'
redirect_url = 'ipam:vlan_list'

10
netbox/manage.py Executable file
View File

@@ -0,0 +1,10 @@
#!/usr/bin/env python
import os
import sys
if __name__ == "__main__":
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings")
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)

View File

View File

@@ -0,0 +1,36 @@
# This key is used for secure generation of random numbers and strings. It must never be exposed outside of this file.
# For optimal security, SECRET_KEY should be at least 50 characters in length and contain a mix of letters, numbers, and
# symbols. NetBox will not run without this defined. For more information, see
# https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-SECRET_KEY
SECRET_KEY = ''
# If enabled, NetBox will run with debugging turned on. This should only be used for development or troubleshooting.
# NEVER ENABLE DEBUGGING ON A PRODUCTION SYSTEM.
DEBUG = False
# Set this to your server's FQDN. This is required when DEBUG = False.
# E.g. ALLOWED_HOSTS = ['netbox.yourdomain.com']
ALLOWED_HOSTS = []
# Setting this to true will display a "maintenance mode" banner at the top of every page.
MAINTENANCE_MODE = False
# PostgreSQL database configuration.
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'netbox', # Database name
'USER': 'netbox', # PostgreSQL username
'PASSWORD': '', # PostgreSQL password
'HOST': 'localhost', # Database server
'PORT': '', # Database port (leave blank for default)
}
}
# If true, user authentication will be required for all site access. If false, unauthenticated users will be able to
# access NetBox but not make any changes.
LOGIN_REQUIRED = False
# Credentials that NetBox will use to access live devices. (Optional)
NETBOX_USERNAME = ''
NETBOX_PASSWORD = ''

140
netbox/netbox/settings.py Normal file
View File

@@ -0,0 +1,140 @@
"""
Django settings for netbox project.
Generated by 'django-admin startproject' using Django 1.8.2.
For more information on this file, see
https://docs.djangoproject.com/en/1.8/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/1.8/ref/settings/
"""
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
import os
import socket
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Application definition
INSTALLED_APPS = (
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'corsheaders',
'debug_toolbar',
'django_extensions',
'django_tables2',
'rest_framework',
'rest_framework_swagger',
'circuits',
'dcim',
'ipam',
'extras',
'secrets',
'users',
'utilities',
)
MIDDLEWARE_CLASSES = (
'django.contrib.sessions.middleware.SessionMiddleware',
'corsheaders.middleware.CorsMiddleware',
'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.LoginRequiredMiddleware',
)
ROOT_URLCONF = 'netbox.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR + '/templates/'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'utilities.context_processors.settings',
'django.core.context_processors.request',
],
},
},
]
WSGI_APPLICATION = 'netbox.wsgi.application'
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
# Internationalization
# https://docs.djangoproject.com/en/1.8/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.8/howto/static-files/
STATIC_URL = '/static/'
STATICFILES_DIRS = (
os.path.join(BASE_DIR, "project-static"),
)
# Messages
from django.contrib.messages import constants as messages
MESSAGE_TAGS = {
messages.ERROR: 'danger',
}
# Pagination
PAGINATE_COUNT = 50
# Authentication
LOGIN_URL = '/login/'
LOGIN_REDIRECT_URL = '/'
LOGOUT_URL = '/logout/'
# Default time formats
DATE_FORMAT = 'N j, Y'
SHORT_DATE_FORMAT = 'Y-m-d'
TIME_FORMAT = 'g:i:s a'
SHORT_TIME_FORMAT = 'H:i:s'
DATETIME_FORMAT = 'N j, Y \a\t g:i a'
SHORT_DATETIME_FORMAT = 'Y-m-d H:i'
# Secrets
SECRETS_MIN_PUBKEY_SIZE = 2048
# Django REST framework
REST_FRAMEWORK = {
'DEFAULT_FILTER_BACKENDS': ('rest_framework.filters.DjangoFilterBackend',)
}
try:
HOSTNAME = socket.gethostname()
except:
HOSTNAME = 'localhost'
# Import local configuration
try:
from configuration import *
except ImportError:
pass
# django-cors-headers (API Cross-Origin Resource Sharing)
if DEBUG:
CORS_ORIGIN_ALLOW_ALL = True
CORS_ALLOW_METHODS = (
'GET',
'OPTIONS',
)

32
netbox/netbox/urls.py Normal file
View File

@@ -0,0 +1,32 @@
from django.conf.urls import include, url
from django.contrib import admin
from django.views.defaults import page_not_found
from views import home, trigger_500
from users.views import login, logout
urlpatterns = [
url(r'^$', home, name='home'),
url(r'^circuits/', include('circuits.urls', namespace='circuits')),
url(r'^dcim/', include('dcim.urls', namespace='dcim')),
url(r'^ipam/', include('ipam.urls', namespace='ipam')),
url(r'^secrets/', include('secrets.urls', namespace='secrets')),
url(r'^profile/', include('users.urls', namespace='users')),
url(r'^login/$', login, name='login'),
url(r'^logout/$', logout, name='logout'),
url(r'^api/circuits/', include('circuits.api.urls', namespace='circuits-api')),
url(r'^api/dcim/', include('dcim.api.urls', namespace='dcim-api')),
url(r'^api/ipam/', include('ipam.api.urls', namespace='ipam-api')),
url(r'^api/secrets/', include('secrets.api.urls', namespace='secrets-api')),
url(r'^api/docs/', include('rest_framework_swagger.urls')),
url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')),
# Error testing
url(r'^404/$', page_not_found),
url(r'^500/$', trigger_500),
url(r'^admin/', include(admin.site.urls)),
]

45
netbox/netbox/views.py Normal file
View File

@@ -0,0 +1,45 @@
from django.shortcuts import render
from circuits.models import Provider, Circuit
from dcim.models import Site, Rack, Device, ConsolePort, PowerPort, InterfaceConnection
from ipam.models import Aggregate, Prefix, IPAddress, VLAN
from secrets.models import Secret
def home(request):
stats = {
# DCIM
'site_count': Site.objects.count(),
'rack_count': Rack.objects.count(),
'device_count': Device.objects.count(),
'interface_connections_count': InterfaceConnection.objects.count(),
'console_connections_count': ConsolePort.objects.filter(cs_port__isnull=False).count(),
'power_connections_count': PowerPort.objects.filter(power_outlet__isnull=False).count(),
# IPAM
'aggregate_count': Aggregate.objects.count(),
'prefix_count': Prefix.objects.count(),
'ipaddress_count': IPAddress.objects.count(),
'vlan_count': VLAN.objects.count(),
# Circuits
'provider_count': Provider.objects.count(),
'circuit_count': Circuit.objects.count(),
# Secrets
'secret_count': Secret.objects.count(),
}
return render(request, 'home.html', {
'stats': stats,
})
def trigger_500(request):
"""Hot-wired method of triggering a server error to test reporting."""
raise Exception("Congratulations, you've triggered an exception! Go tell all your friends what an exceptional "
"person you are.")

16
netbox/netbox/wsgi.py Normal file
View File

@@ -0,0 +1,16 @@
"""
WSGI config for do_ipam project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/1.8/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings")
application = get_wsgi_application()

View File

@@ -0,0 +1,224 @@
/* Layout */
* {
margin: 0;
}
html, body {
height: 100%;
}
body {
padding-top: 70px;
}
.container {
width: 1340px;
}
.wrapper {
min-height: 100%;
height: auto !important;
margin: 0 auto -61px; /* the bottom margin is the negative value of the footer's height */
padding-bottom: 30px;
}
.footer, .push {
height: 60px; /* .push must be the same height as .footer */
}
.footer {
background-color: #f5f5f5;
border-top: 1px solid #d0d0d0;
}
footer p {
margin: 20px 0;
}
/* Forms */
label {
font-weight: normal;
}
label.required {
font-weight: bold;
}
/* Tables */
th.pk, td.pk {
width: 30px;
}
/* Paginator */
nav ul.pagination {
margin-top: 0;
}
/* Racks */
div.rack_header {
margin-left: 36px;
text-align: center;
width: 200px;
}
ul.rack_legend {
float: left;
list-style-type: none;
margin-right: 6px;
padding: 0;
width: 30px;
}
ul.rack_legend li {
color: #c0c0c0;
display: block;
font-size: 10px;
height: 20px;
overflow: hidden;
padding: 5px 0;
text-align: right;
}
div.rack_frame {
float: left;
position: relative;
}
ul.rack {
border: 2px solid #404040;
float: left;
list-style-type: none;
padding: 0;
position: absolute;
width: 200px;
}
ul.rack li {
display: block;
font-size: 13px;
height: 20px;
overflow: hidden;
text-align: center;
}
ul.rack_empty li {
background-color: #f7f7f7;
border-bottom: 1px solid #dddddd;
height: 20px;
}
ul.rack li.empty:last-child {
border-bottom: 0;
}
ul.rack_far_face {
z-index: 100;
}
ul.rack_near_face {
z-index: 200;
}
ul.rack li.h2u { height: 40px; }
ul.rack li.h2u a, ul.rack li.h2u span { padding: 10px 0; }
ul.rack li.h3u { height: 60px; }
ul.rack li.h3u a, ul.rack li.h3u span { padding: 20px 0; }
ul.rack li.h4u { height: 80px; }
ul.rack li.h4u a, ul.rack li.h4u span { padding: 30px 0; }
ul.rack li.h5u { height: 100px; }
ul.rack li.h5u a, ul.rack li.h5u span { padding: 40px 0; }
ul.rack li.h6u { height: 120px; }
ul.rack li.h6u a, ul.rack li.h6u span { padding: 50px 0; }
ul.rack li.h7u { height: 140px; }
ul.rack li.h7u a, ul.rack li.h7u span { padding: 60px 0; }
ul.rack li.h8u { height: 160px; }
ul.rack li.h8u a, ul.rack li.h8u span { padding: 70px 0; }
ul.rack li.h9u { height: 180px; }
ul.rack li.h9u a, ul.rack li.h9u span { padding: 80px 0; }
ul.rack li.h10u { height: 200px; }
ul.rack li.h10u a, ul.rack li.h10u span { padding: 90px 0; }
ul.rack li.h11u { height: 220px; }
ul.rack li.h11u a, ul.rack li.h11u span { padding: 100px 0; }
ul.rack li.h12u { height: 240px; }
ul.rack li.h12u a, ul.rack li.h12u span { padding: 110px 0; }
ul.rack li.h13u { height: 260px; }
ul.rack li.h13u a, ul.rack li.h13u span { padding: 120px 0; }
ul.rack li.h14u { height: 280px; }
ul.rack li.h14u a, ul.rack li.h14u span { padding: 130px 0; }
ul.rack li.h15u { height: 300px; }
ul.rack li.h15u a, ul.rack li.h15u span { padding: 140px 0; }
ul.rack li.h16u { height: 320px; }
ul.rack li.h16u a, ul.rack li.h16u span { padding: 150px 0; }
ul.rack li.occupied a {
color: #ffffff;
display: block;
font-weight: bold;
}
ul.rack li.occupied a:hover {
text-decoration: none;
}
ul.rack li.occupied span {
display: block;
}
ul.rack_near_face li {
border-bottom: 1px solid #e0e0e0;
}
ul.rack_near_face li.occupied {
color: #474747;
}
ul.rack_far_face li.occupied {
background: repeating-linear-gradient(
45deg,
#f7f7f7,
#f7f7f7 7px,
#f0f0f0 7px,
#f0f0f0 14px
);
color: #303030;
}
ul.rack_far_face li.blocked {
background: repeating-linear-gradient(
45deg,
#f7f7f7,
#f7f7f7 7px,
#ffc7c7 7px,
#ffc7c7 14px
);
color: #303030;
}
ul.rack_near_face li.empty a {
color: #0000ff;
display: none;
text-decoration: none;
}
ul.rack_near_face li.empty:hover {
background-color: #ffffff;
}
ul.rack_near_face li.empty:hover a {
display: block;
}
/* Rack elevation colors (from http://flatuicolors.com) */
.teal { background-color: #1abc9c; border-bottom: 1px solid #16a085; }
.teal:hover { background-color: #16a085; }
.green { background-color: #2ecc71; border-bottom: 1px solid #27ae60; }
.green:hover { background-color: #27ae60; }
.blue { background-color: #3498db; border-bottom: 1px solid #2980b9; }
.blue:hover { background-color: #2980b9; }
.purple { background-color: #9b59b6; border-bottom: 1px solid #8e44ad; }
.purple:hover { background-color: #8e44ad; }
.yellow { background-color: #f1c40f; border-bottom: 1px solid #f39c12; }
.yellow:hover { background-color: #f39c12; }
.orange { background-color: #e67e22; border-bottom: 1px solid #d35400; }
.orange:hover { background-color: #d35400; }
.red { background-color: #e74c3c; border-bottom: 1px solid #c0392b; }
.red:hover { background-color: #c0392b; }
.light_gray { background-color: #ecf0f1; border-bottom: 1px solid #bdc3c7; }
.light_gray:hover { background-color: #bdc3c7; }
.medium_gray { background-color: #95a5a6; border-bottom: 1px solid #7f8c8d; }
.medium_gray:hover { background-color: #7f8c8d; }
.dark_gray { background-color: #34495e; border-bottom: 1px solid #2c3e50; }
.dark_gray:hover { background-color: #2c3e50; }
/* Misc */
.panel table>thead>tr>th {
border-bottom: 0;
}
ul.nav-tabs, ul.nav-pills {
margin-bottom: 20px;
}
.panel .list-group {
max-height: 400px;
overflow: auto;
}
/* Fix progress bar margin inside table cells */
td .progress {
margin-bottom: 0;
}
textarea {
font-family: Consolas, Lucida Console, monospace;
}

View File

@@ -0,0 +1,116 @@
$(document).ready(function() {
// "Select all" checkbox in a table header
$('th input:checkbox').click(function (event) {
$(this).parents('table').find('td input:checkbox').prop('checked', $(this).prop('checked'));
});
// Helper select fields
$('select.helper-parent').change(function () {
// Resolve child field by ID specified in parent
var child_field = $('#id_' + $(this).attr('child'));
// Wipe out any existing options within the child field
child_field.empty();
child_field.append($("<option></option>").attr("value", "").text(""));
// If the parent has a value set, fetch a list of child options via the API and populate the child field with them
if ($(this).val()) {
// Construct the API request URL
var api_url = $(this).attr('child-source');
var parent_accessor = $(this).attr('parent-accessor');
if (parent_accessor) {
api_url += '?' + parent_accessor + '=' + $(this).val();
} else {
api_url += '?' + $(this).attr('name') + '_id=' + $(this).val();
}
var api_url_extra = $(this).attr('child-filter');
if (api_url_extra) {
api_url += '&' + api_url_extra;
}
var disabled_indicator = $(this).attr('disabled-indicator');
var disabled_exempt = child_field.attr('exempt');
var child_display = $(this).attr('child-display');
if (!child_display) {
child_display = 'name';
}
$.ajax({
url: api_url,
dataType: 'json',
success: function (response, status) {
console.log(response);
$.each(response, function (index, choice) {
var option = $("<option></option>").attr("value", choice.id).text(choice[child_display]);
if (disabled_indicator && choice[disabled_indicator] && choice.id != disabled_exempt) {
option.attr("disabled", "disabled")
}
child_field.append(option);
});
}
});
}
// Trigger change event in case the child field is the parent of another field
child_field.change();
});
// API select widget
$('select[filter-for]').change(function () {
// Resolve child field by ID specified in parent
var child_name = $(this).attr('filter-for');
var child_field = $('#id_' + child_name);
// Wipe out any existing options within the child field
child_field.empty();
child_field.append($("<option></option>").attr("value", "").text(""));
if ($(this).val()) {
var api_url = child_field.attr('api-url');
var disabled_indicator = child_field.attr('disabled-indicator');
var initial_value = child_field.attr('initial');
var display_field = child_field.attr('display-field') || 'name';
// Gather the values of all other filter fields for this child
$("select[filter-for='" + child_name + "']").each(function() {
var filter_field = $(this);
if (filter_field.val()) {
api_url = api_url.replace('{{' + filter_field.attr('name') + '}}', filter_field.val());
} else {
// Not all filters have been selected yet
return false;
}
});
// If all URL variables have been replaced, make the API call
if (api_url.search('{{') < 0) {
$.ajax({
url: api_url,
dataType: 'json',
success: function (response, status) {
$.each(response, function (index, choice) {
var option = $("<option></option>").attr("value", choice.id).text(choice[display_field]);
if (disabled_indicator && choice[disabled_indicator] && choice.id != initial_value) {
option.attr("disabled", "disabled")
}
child_field.append(option);
});
}
});
}
}
// Trigger change event in case the child field is the parent of another field
child_field.change();
});
});

View File

@@ -0,0 +1,40 @@
$(document).ready(function() {
var search_field = $('#id_livesearch');
var search_key = search_field.attr('data-key');
var label = search_field.attr('data-label');
if (!label) {
label = 'name';
}
search_field.autocomplete({
source: function(request, response) {
$.ajax({
type: 'GET',
url: search_field.attr('data-source'),
data: search_key + '=' + request.term,
success: function(data) {
var choices = [];
$.each(data, function (index, choice) {
choices.push({
value: choice.id,
label: choice[label]
});
});
response(choices);
}
});
},
select: function(event, ui) {
event.preventDefault();
search_field.val(ui.item.label);
var real_field = $('#id_' + search_field.attr('data-field'));
real_field.empty();
real_field.append($("<option></option>").attr('value', ui.item.value).text(ui.item.label));
real_field.change();
// If the field has a parent helper, reset the parent to no selection
$('select[filter-for="' + real_field.attr('name') + '"]').val('');
},
minLength: 4,
delay: 500
});
});

View File

@@ -0,0 +1,96 @@
$(document).ready(function() {
// Unlocking a secret
$('button.unlock-secret').click(function (event) {
var secret_id = $(this).attr('secret-id');
// Retrieve from storage or prompt for private key
var private_key = sessionStorage.getItem('private_key');
if (!private_key) {
$('#privkey_modal').modal('show');
} else {
unlock_secret(secret_id, private_key);
$(this).hide();
$(this).siblings('button.lock-secret').show();
}
});
// Locking a secret
$('button.lock-secret').click(function (event) {
var secret_id = $(this).attr('secret-id');
$('#secret_' + secret_id).html('********');
$(this).hide();
$(this).siblings('button.unlock-secret').show();
});
// Adding/editing a secret
$('form.requires-private-key').submit(function(event) {
var private_key = sessionStorage.getItem('private_key');
if (private_key) {
$('#id_private_key').val(private_key);
} else {
$('#privkey_modal').modal('show');
return false;
}
});
// Prompt the user to enter a private RSA key for decryption
$('#submit_privkey').click(function() {
var private_key = $('#user_privkey').val();
sessionStorage.setItem('private_key', private_key);
});
// Generate a new public/private key pair via the API
$('#generate_keypair').click(function() {
$('#new_keypair_modal').modal('show');
$.ajax({
url: '/api/secrets/generate-keys/',
type: 'GET',
dataType: 'json',
success: function (response, status) {
var public_key = response.public_key;
var private_key = response.private_key;
$('#new_pubkey').val(public_key);
$('#new_privkey').val(private_key);
},
error: function (xhr, ajaxOptions, thrownError) {
alert("There was an error generating a new key pair.");
}
});
});
// Enter a newly generated public key
$('#use_new_pubkey').click(function() {
var new_pubkey = $('#new_pubkey');
if (new_pubkey.val()) {
$('#id_public_key').val(new_pubkey.val());
}
});
// Retrieve a secret via the API
function unlock_secret(secret_id, private_key) {
var csrf_token = $('input[name=csrfmiddlewaretoken]').val();
$.ajax({
url: '/api/secrets/secrets/' + secret_id + '/decrypt/',
type: 'POST',
data: {
private_key: private_key
},
dataType: 'json',
beforeSend: function(xhr, settings) {
xhr.setRequestHeader("X-CSRFToken", csrf_token);
},
success: function (response, status) {
var secret_plaintext = response.plaintext;
$('#secret_' + secret_id).html(secret_plaintext);
return true;
},
error: function (xhr, ajaxOptions, thrownError) {
if (xhr.status == 403) {
alert("Decryption failed: " + xhr.statusText);
}
}
});
}
});

View File

71
netbox/secrets/admin.py Normal file
View File

@@ -0,0 +1,71 @@
from django.contrib import admin, messages
from django.shortcuts import redirect, render
from .forms import ActivateUserKeyForm
from .models import UserKey, SecretRole, Secret
@admin.register(UserKey)
class UserKeyAdmin(admin.ModelAdmin):
actions = ['activate_selected']
list_display = ['user', 'is_filled', 'is_active', 'created']
fields = ['user', 'public_key', 'is_active', 'last_modified']
readonly_fields = ['is_active', 'last_modified']
def get_readonly_fields(self, request, obj=None):
# Don't allow a user to modify an existing public key directly.
if obj and obj.public_key:
return ['public_key'] + self.readonly_fields
return self.readonly_fields
def get_actions(self, request):
# Bulk deletion is disabled at the manager level, so remove the action from the admin site for this model.
actions = super(UserKeyAdmin, self).get_actions(request)
if 'delete_selected' in actions:
del actions['delete_selected']
if not request.user.has_perm('secrets.activate_userkey'):
del actions['activate_selected']
return actions
def activate_selected(modeladmin, request, queryset):
"""
Enable bulk activation of UserKeys
"""
try:
my_userkey = UserKey.objects.get(user=request.user)
except UserKey.DoesNotExist:
messages.error(request, "You do not have an active User Key.")
return redirect('/admin/secrets/userkey/')
if 'activate' in request.POST:
form = ActivateUserKeyForm(request.POST)
if form.is_valid():
try:
master_key = my_userkey.get_master_key(form.cleaned_data['secret_key'])
for uk in form.cleaned_data['_selected_action']:
uk.activate(master_key)
return redirect('/admin/secrets/userkey/')
except ValueError:
messages.error(request, "Invalid private key provided. Unable to retrieve master key.")
else:
form = ActivateUserKeyForm(initial={'_selected_action': request.POST.getlist(admin.ACTION_CHECKBOX_NAME)})
return render(request, 'activate_keys.html', {
'form': form,
})
activate_selected.short_description = "Activate selected user keys"
@admin.register(SecretRole)
class SecretRoleAdmin(admin.ModelAdmin):
list_display = ['name', 'slug']
prepopulated_fields = {
'slug': ['name'],
}
@admin.register(Secret)
class SecretAdmin(admin.ModelAdmin):
list_display = ['parent', 'role', 'name', 'created', 'last_modified']
fields = ['parent', 'role', 'name', 'hash', 'created', 'last_modified']
readonly_fields = ['parent', 'hash', 'created', 'last_modified']

View File

View File

@@ -0,0 +1,39 @@
from rest_framework import serializers
from secrets.models import Secret, SecretRole
#
# SecretRoles
#
class SecretRoleSerializer(serializers.ModelSerializer):
class Meta:
model = SecretRole
fields = ['id', 'name', 'slug']
class SecretRoleNestedSerializer(SecretRoleSerializer):
class Meta(SecretRoleSerializer.Meta):
pass
#
# Secrets
#
# TODO: Serialize parent info
class SecretSerializer(serializers.ModelSerializer):
role = SecretRoleNestedSerializer()
class Meta:
model = Secret
fields = ['id', 'role', 'name', 'hash', 'created', 'last_modified']
class SecretNestedSerializer(SecretSerializer):
class Meta(SecretSerializer.Meta):
fields = ['id', 'name']

View File

@@ -0,0 +1,20 @@
from django.conf.urls import url
from .views import *
urlpatterns = [
# Secrets
url(r'^secrets/$', SecretListView.as_view(), name='secret_list'),
url(r'^secrets/(?P<pk>\d+)/$', SecretDetailView.as_view(), name='secret_detail'),
url(r'^secrets/(?P<pk>\d+)/decrypt/$', SecretDecryptView.as_view(), name='secret_decrypt'),
# Secret roles
url(r'^secret-roles/$', SecretRoleListView.as_view(), name='secretrole_list'),
url(r'^secret-roles/(?P<pk>\d+)/$', SecretRoleDetailView.as_view(), name='secretrole_detail'),
# Miscellaneous
url(r'^generate-keys/$', RSAKeyGeneratorView.as_view(), name='generate_keys'),
]

104
netbox/secrets/api/views.py Normal file
View File

@@ -0,0 +1,104 @@
from Crypto.PublicKey import RSA
from django.http import HttpResponseForbidden
from django.shortcuts import get_object_or_404
from rest_framework import generics
from rest_framework.exceptions import ValidationError
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from secrets.models import Secret, SecretRole, UserKey
from .serializers import SecretRoleSerializer, SecretSerializer
class SecretRoleListView(generics.ListAPIView):
"""
List all secret roles
"""
queryset = SecretRole.objects.all()
serializer_class = SecretRoleSerializer
class SecretRoleDetailView(generics.RetrieveAPIView):
"""
Retrieve a single secret role
"""
queryset = SecretRole.objects.all()
serializer_class = SecretRoleSerializer
class SecretListView(generics.ListAPIView):
"""
List secrets (filterable)
"""
queryset = Secret.objects.select_related('role')
serializer_class = SecretSerializer
#filter_class = SecretFilter
permission_classes = [IsAuthenticated]
class SecretDetailView(generics.RetrieveAPIView):
"""
Retrieve a single Secret
"""
queryset = Secret.objects.select_related('role')
serializer_class = SecretSerializer
permission_classes = [IsAuthenticated]
class SecretDecryptView(APIView):
"""
Retrieve the plaintext from a stored Secret. The request must include a valid private key.
"""
permission_classes = [IsAuthenticated]
def post(self, request, pk):
secret = get_object_or_404(Secret, pk=pk)
private_key = request.POST.get('private_key')
if not private_key:
raise ValidationError("Private key is missing from request.")
# Retrieve the Secret's plaintext with the user's private key
try:
uk = UserKey.objects.get(user=request.user)
except UserKey.DoesNotExist:
return HttpResponseForbidden(reason="No UserKey found.")
if not uk.is_active():
return HttpResponseForbidden(reason="UserKey is inactive.")
# Attempt to decrypt the Secret.
master_key = uk.get_master_key(private_key)
if master_key is None:
return HttpResponseForbidden(reason="Invalid secret key.")
secret.decrypt(master_key)
return Response({
'plaintext': secret.plaintext,
})
class RSAKeyGeneratorView(APIView):
"""
Generate a new RSA key pair for a user. Authenticated because it's a ripe avenue for DoS.
"""
permission_classes = [IsAuthenticated]
def get(self, request):
# Determine what size key to generate
key_size = request.GET.get('key_size', 2048)
if key_size not in range(2048, 4097, 256):
key_size = 2048
# Export RSA private and public keys in PEM format
key = RSA.generate(key_size)
private_key = key.exportKey('PEM')
public_key = key.publickey().exportKey('PEM')
return Response({
'private_key': private_key,
'public_key': public_key,
})

7
netbox/secrets/apps.py Normal file
View File

@@ -0,0 +1,7 @@
from __future__ import unicode_literals
from django.apps import AppConfig
class SecretsConfig(AppConfig):
name = 'secrets'

View File

@@ -0,0 +1,24 @@
from django.contrib import messages
from django.shortcuts import redirect
from .models import UserKey
def userkey_required():
"""
Decorator for views which require that the user has an active UserKey (typically for encryption/decryption of
Secrets).
"""
def _decorator(view):
def wrapped_view(request, *args, **kwargs):
try:
uk = UserKey.objects.get(user=request.user)
except UserKey.DoesNotExist:
messages.warning(request, "This operation requires an active user key, but you don't have one.")
return redirect('users:userkey')
if not uk.is_active():
messages.warning(request, "This operation is not available. Your user key has not been activated.")
return redirect('users:userkey')
return view(request, *args, **kwargs)
return wrapped_view
return _decorator

21
netbox/secrets/filters.py Normal file
View File

@@ -0,0 +1,21 @@
import django_filters
from .models import Secret, SecretRole
class SecretFilter(django_filters.FilterSet):
role_id = django_filters.ModelMultipleChoiceFilter(
name='role',
queryset=SecretRole.objects.all(),
label='Role (ID)',
)
role = django_filters.ModelMultipleChoiceFilter(
name='role',
queryset=SecretRole.objects.all(),
to_field_name='slug',
label='Role (slug)',
)
class Meta:
model = Secret
fields = ['name', 'role_id', 'role']

146
netbox/secrets/forms.py Normal file
View File

@@ -0,0 +1,146 @@
from Crypto.Cipher import PKCS1_OAEP
from Crypto.PublicKey import RSA
from django import forms
from django.apps import apps
from django.db.models import Count
from utilities.forms import BootstrapMixin, ConfirmationForm, CSVDataField
from .models import Secret, SecretRole, UserKey
def validate_rsa_key(key, is_secret=True):
"""
Validate the format and type of an RSA key.
"""
try:
key = RSA.importKey(key)
except ValueError:
raise forms.ValidationError("Invalid RSA key. Please ensure that your key is in PEM (base64) format.")
except Exception as e:
raise forms.ValidationError("Invalid key detected: {}".format(e))
if is_secret and not key.has_private():
raise forms.ValidationError("This looks like a public key. Please provide your private RSA key.")
elif not is_secret and key.has_private():
raise forms.ValidationError("This looks like a private key. Please provide your public RSA key.")
try:
PKCS1_OAEP.new(key)
except:
raise forms.ValidationError("Error validating RSA key. Please ensure that your key supports PKCS#1 OAEP.")
#
# Secrets
#
class SecretForm(forms.ModelForm, BootstrapMixin):
private_key = forms.CharField(widget=forms.HiddenInput())
plaintext = forms.CharField(max_length=65535, required=False, label='Plaintext')
plaintext2 = forms.CharField(max_length=65535, required=False, label='Plaintext (verify)')
class Meta:
model = Secret
fields = ['role', 'name', 'plaintext', 'plaintext2']
def clean(self):
validate_rsa_key(self.cleaned_data['private_key'])
def clean_plaintext2(self):
plaintext = self.cleaned_data['plaintext']
plaintext2 = self.cleaned_data['plaintext2']
if plaintext != plaintext2:
raise forms.ValidationError("The two given plaintext values do not match. Please check your input.")
class SecretFromCSVForm(forms.ModelForm):
parent_name = forms.CharField()
role = forms.ModelChoiceField(queryset=SecretRole.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Invalid secret role.'})
plaintext = forms.CharField()
class Meta:
model = Secret
fields = ['parent_name', 'role', 'name', 'plaintext']
class SecretImportForm(forms.Form, BootstrapMixin):
private_key = forms.CharField(widget=forms.HiddenInput())
parent_type = forms.ChoiceField(label='Parent Type', choices=(
('dcim.Device', 'Device'),
))
csv = CSVDataField(csv_form=SecretFromCSVForm)
def clean(self):
parent_type = self.cleaned_data.get('parent_type')
records = self.cleaned_data.get('csv')
if not records or not parent_type:
return
secrets = []
parent_cls = apps.get_model(parent_type)
for i, record in enumerate(records, start=1):
secret_form = SecretFromCSVForm(data=record)
if secret_form.is_valid():
s = secret_form.save(commit=False)
# Set parent
try:
s.parent = parent_cls.objects.get(name=secret_form.cleaned_data['parent_name'])
except parent_cls.DoesNotExist:
self.add_error('csv', "Invalid parent object ({})".format(secret_form.cleaned_data['parent_name']))
# Set plaintext
s.plaintext = str(secret_form.cleaned_data['plaintext'])
secrets.append(s)
else:
for field, errors in secret_form.errors.items():
for e in errors:
self.add_error('csv', "Record {} {}: {}".format(i, field, e))
self.cleaned_data['csv'] = secrets
class SecretBulkEditForm(forms.Form, BootstrapMixin):
pk = forms.ModelMultipleChoiceField(queryset=Secret.objects.all(), widget=forms.MultipleHiddenInput)
role = forms.ModelChoiceField(queryset=SecretRole.objects.all())
name = forms.CharField(max_length=100, required=False)
class SecretBulkDeleteForm(ConfirmationForm):
pk = forms.ModelMultipleChoiceField(queryset=Secret.objects.all(), widget=forms.MultipleHiddenInput)
def secret_role_choices():
role_choices = SecretRole.objects.annotate(secret_count=Count('secrets'))
return [(r.slug, '{} ({})'.format(r.name, r.secret_count)) for r in role_choices]
class SecretFilterForm(forms.Form, BootstrapMixin):
role = forms.MultipleChoiceField(required=False, choices=secret_role_choices)
#
# UserKeys
#
class UserKeyForm(forms.ModelForm, BootstrapMixin):
class Meta:
model = UserKey
fields = ['public_key']
help_texts = {
'public_key': "Enter your public RSA key. Keep the private one with you; you'll need it for decryption.",
}
def clean_public_key(self):
key = self.cleaned_data['public_key']
# Validate the RSA key format.
validate_rsa_key(key, is_secret=False)
return key
class ActivateUserKeyForm(forms.Form):
_selected_action = forms.ModelMultipleChoiceField(queryset=UserKey.objects.all(), label='User Keys')
secret_key = forms.CharField(label='Your private key', widget=forms.Textarea(attrs={'class': 'vLargeTextField'}))

View File

@@ -0,0 +1,68 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.1 on 2016-02-27 02:35
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):
initial = True
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Secret',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('object_id', models.PositiveIntegerField()),
('name', models.CharField(blank=True, max_length=100)),
('ciphertext', models.BinaryField(max_length=65568)),
('hash', models.CharField(editable=False, max_length=128)),
('created', models.DateTimeField(auto_now_add=True, verbose_name=b'Created')),
('last_modified', models.DateTimeField(auto_now=True, verbose_name=b'Last modified')),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')),
],
options={
'ordering': ['role', 'name'],
'permissions': (('view_secret', 'Can view secrets'),),
},
),
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)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='UserKey',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('public_key', models.TextField(verbose_name=b'RSA public key')),
('master_key_cipher', models.BinaryField(blank=True, max_length=512, null=True)),
('created', models.DateTimeField(auto_now_add=True, verbose_name=b'Time created')),
('last_modified', models.DateTimeField(auto_now=True, verbose_name=b'Last modified')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='user_key', to=settings.AUTH_USER_MODEL, verbose_name=b'User')),
],
options={
'ordering': ['user__username'],
'permissions': (('activate_userkey', 'Can activate user keys for decryption'),),
},
),
migrations.AddField(
model_name='secret',
name='role',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='secrets', to='secrets.SecretRole'),
),
]

View File

282
netbox/secrets/models.py Normal file
View File

@@ -0,0 +1,282 @@
import os
from Crypto.Cipher import AES, PKCS1_OAEP
from Crypto.PublicKey import RSA
from django.conf import settings
from django.contrib.auth.hashers import make_password, check_password
from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.core.urlresolvers import reverse
from django.db import models
from django.utils.encoding import force_bytes
def generate_master_key():
"""
Generate a new 256-bit (32 bytes) AES key to be used for symmetric encryption of secrets.
"""
return os.urandom(32)
def encrypt_master_key(master_key, public_key):
"""
Encrypt a secret key with the provided public RSA key.
"""
key = RSA.importKey(public_key)
cipher = PKCS1_OAEP.new(key)
return cipher.encrypt(master_key)
def decrypt_master_key(master_key_cipher, private_key):
"""
Decrypt a secret key with the provided private RSA key.
"""
key = RSA.importKey(private_key)
cipher = PKCS1_OAEP.new(key)
return cipher.decrypt(master_key_cipher)
class UserKeyQuerySet(models.QuerySet):
def active(self):
return self.filter(master_key_cipher__isnull=False)
def delete(self):
# Disable bulk deletion to avoid accidentally wiping out all copies of the master key.
raise Exception("Bulk deletion has been disabled.")
class UserKey(models.Model):
"""
A user's personal public RSA key.
"""
user = models.OneToOneField(User, related_name='user_key', verbose_name='User')
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.DateTimeField(auto_now_add=True, verbose_name='Time created')
last_modified = models.DateTimeField(auto_now=True, verbose_name='Last modified')
objects = UserKeyQuerySet.as_manager()
class Meta:
ordering = ['user__username']
permissions = (
('activate_userkey', "Can activate user keys for decryption"),
)
def __init__(self, *args, **kwargs):
super(UserKey, self).__init__(*args, **kwargs)
# Store the initial public_key and master_key_cipher to check for changes on save().
self.__initial_public_key = self.public_key
self.__initial_master_key_cipher = self.master_key_cipher
def __unicode__(self):
return self.user.username
def clean(self, *args, **kwargs):
# Validate the public key format and length.
if self.public_key:
try:
pubkey = RSA.importKey(self.public_key)
except ValueError:
raise ValidationError("Invalid RSA key format.")
except:
raise ValidationError("Something went wrong while trying to save your key. Please ensure that you're "
"uploading a valid RSA public key in PEM format (no SSH/PGP).")
# key.size() returns 1 less than the key modulus
pubkey_length = pubkey.size() + 1
if pubkey_length < settings.SECRETS_MIN_PUBKEY_SIZE:
raise ValidationError("Insufficient key length. Keys must be at least {} bits long."
.format(settings.SECRETS_MIN_PUBKEY_SIZE))
# We can't use keys bigger than our master_key_cipher field can hold
if pubkey_length > 4096:
raise ValidationError("Public key size ({}) is too large. Maximum key size is 4096 bits."
.format(pubkey_length))
super(UserKey, self).clean()
def save(self, *args, **kwargs):
# Check whether public_key has been modified. If so, nullify the initial master_key_cipher.
if self.__initial_master_key_cipher and self.public_key != self.__initial_public_key:
self.master_key_cipher = None
# If no other active UserKeys exist, generate a new master key and use it to activate this UserKey.
if self.is_filled() and not self.is_active() and not UserKey.objects.active().count():
master_key = generate_master_key()
self.master_key_cipher = encrypt_master_key(master_key, self.public_key)
super(UserKey, self).save(*args, **kwargs)
def delete(self, *args, **kwargs):
# If Secrets exist and this is the last active UserKey, prevent its deletion. Deleting the last UserKey will
# result in the master key being destroyed and rendering all Secrets inaccessible.
if Secret.objects.count() and [uk.pk for uk in UserKey.objects.active()] == [self.pk]:
raise Exception("Cannot delete the last active UserKey when Secrets exist! This would render all secrets "
"inaccessible.")
super(UserKey, self).delete(*args, **kwargs)
def is_filled(self):
"""
Returns True if the UserKey has been filled with a public RSA key.
"""
return bool(self.public_key)
is_filled.boolean = True
def is_active(self):
"""
Returns True if the UserKey has been populated with an encrypted copy of the master key.
"""
return self.master_key_cipher is not None
is_active.boolean = True
def get_master_key(self, private_key):
"""
Given the User's private key, return the encrypted master key.
"""
if not self.is_active:
raise ValueError("Unable to retrieve master key: UserKey is inactive.")
try:
return decrypt_master_key(force_bytes(self.master_key_cipher), private_key)
except ValueError:
return None
def activate(self, master_key):
"""
Activate the UserKey by saving an encrypted copy of the master key to the database.
"""
if not self.public_key:
raise Exception("Cannot activate UserKey: Its public key must be filled first.")
self.master_key_cipher = encrypt_master_key(master_key, self.public_key)
self.save()
class SecretRole(models.Model):
"""
A functional classification of secret type. For example: login credentials, SNMP communities, etc.
"""
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
class Meta:
ordering = ['name']
def __unicode__(self):
return self.name
class Secret(models.Model):
"""
A secret string of up to 255 bytes in length, stored as both an AES256-encrypted ciphertext and an irreversible
salted SHA256 hash (for plaintext validation).
"""
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
parent = GenericForeignKey('content_type', 'object_id')
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)
created = models.DateTimeField(auto_now_add=True, editable=False, verbose_name='Created')
last_modified = models.DateTimeField(auto_now=True, verbose_name='Last modified')
plaintext = None
class Meta:
ordering = ['role', 'name']
permissions = (
('view_secret', "Can view secrets"),
)
def __init__(self, *args, **kwargs):
self.plaintext = kwargs.pop('plaintext', None)
super(Secret, self).__init__(*args, **kwargs)
def __unicode__(self):
if self.role and self.parent:
return "{} for {}".format(self.role, self.parent)
return "Secret"
def get_absolute_url(self):
return reverse('secrets:secret', args=[self.pk])
def _pad(self, s):
"""
Prepend the length of the plaintext (2B) and pad with garbage to a multiple of 16B (minimum of 64B).
+--+--------+-------------------------------------------+
|LL|MySecret|xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx|
+--+--------+-------------------------------------------+
"""
if len(s) > 65535:
raise ValueError("Maximum plaintext size is 65535 bytes.")
# Minimum ciphertext size is 64 bytes to conceal the length of short secrets.
if len(s) <= 62:
pad_length = 62 - len(s)
elif (len(s) + 2) % 16:
pad_length = 16 - ((len(s) + 2) % 16)
else:
pad_length = 0
return chr(len(s) >> 8) + chr(len(s) % 256) + s + os.urandom(pad_length)
def _unpad(self, s):
"""
Consume the first two bytes of s as a plaintext length indicator and return only that many bytes as the
plaintext.
"""
plaintext_length = (ord(s[0]) << 8) + ord(s[1])
return s[2:plaintext_length + 2]
def encrypt(self, secret_key):
"""
Generate a random initialization vector (IV) for AES. Pad the plaintext to the AES block size (16 bytes) and
encrypt. Prepend the IV for use in decryption. Finally, record the SHA256 hash of the plaintext for validation
upon decryption.
"""
if self.plaintext is None:
raise Exception("Must unlock or set plaintext before locking.")
# Pad and encrypt plaintext
iv = os.urandom(16)
aes = AES.new(secret_key, AES.MODE_CFB, iv)
self.ciphertext = iv + aes.encrypt(self._pad(self.plaintext))
# Generate SHA256 using Django's built-in password hashing mechanism
self.hash = make_password(self.plaintext, hasher='pbkdf2_sha256')
self.plaintext = None
def decrypt(self, secret_key):
"""
Consume the first 16 bytes of self.ciphertext as the AES initialization vector (IV). The remainder is decrypted
using the IV and the provided secret key. Padding is then removed to reveal the plaintext. Finally, validate the
decrypted plaintext value against the stored hash.
"""
if self.plaintext is not None:
return
if not self.ciphertext:
raise Exception("Must define ciphertext before unlocking.")
# Decrypt ciphertext and remove padding
iv = self.ciphertext[0:16]
aes = AES.new(secret_key, AES.MODE_CFB, iv)
plaintext = self._unpad(aes.decrypt(self.ciphertext[16:]))
# Verify decrypted plaintext against hash
if not self.validate(plaintext):
raise ValueError("Invalid key or ciphertext!")
self.plaintext = plaintext
def validate(self, plaintext):
"""
Validate that a given plaintext matches the stored hash.
"""
if not self.hash:
raise Exception("Hash has not been generated for this secret.")
return check_password(plaintext, self.hash)

31
netbox/secrets/tables.py Normal file
View File

@@ -0,0 +1,31 @@
import django_tables2 as tables
from django_tables2.utils import Accessor
from .models import Secret
#
# Secrets
#
class SecretTable(tables.Table):
parent = tables.LinkColumn('secrets:secret', args=[Accessor('pk')], verbose_name='Parent')
role = tables.Column(verbose_name='Role')
name = tables.Column(verbose_name='Name')
last_modified = tables.DateTimeColumn(verbose_name='Last modified')
class Meta:
model = Secret
fields = ('parent', 'role', 'name', 'last_modified')
empty_text = "No secrets found."
attrs = {
'class': 'table table-hover',
}
class SecretBulkEditTable(SecretTable):
pk = tables.CheckBoxColumn()
class Meta(SecretTable.Meta):
model = None # django_tables2 bugfix
fields = ('pk', 'parent', 'role', 'name')

View File

@@ -0,0 +1,12 @@
{% extends "admin/base_site.html" %}
{% block content %}
<form action="." method="post">
{% csrf_token %}
{{ form.as_p }}
<input type="hidden" name="action" value="activate_selected" />
<input type="submit" name="activate" value="Activate User Key(s)" />
</form>
{% endblock %}

View File

@@ -0,0 +1 @@
from test_models import *

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