Compare commits

...

50 Commits

Author SHA1 Message Date
Jeremy Stretch
5ba5e8def9 Merge pull request #324 from digitalocean/develop
Release v1.3.0
2016-07-18 13:49:08 -04:00
Jeremy Stretch
4f347d3428 Version bump: v1.3.0 2016-07-18 13:43:39 -04:00
Jeremy Stretch
d6c2fe2385 Fixes #317: Rack elevation display fix for device types greater than 42U in height 2016-07-18 13:03:40 -04:00
Jeremy Stretch
cb4643d810 Added support for group assignment during VLAN import 2016-07-18 11:59:55 -04:00
Jeremy Stretch
d201dad535 Fixes #322: Corrected 'vlan_group' to 'group' 2016-07-18 11:35:50 -04:00
Jeremy Stretch
32d8cf451a Fixes #320: Disallow prefixes with host masks 2016-07-18 10:06:43 -04:00
Jeremy Stretch
46da9866e3 Added group to VLAN view 2016-07-15 16:32:00 -04:00
Jeremy Stretch
534e6ac19e Fixes #308: Update rack assignment for all child devices when moving a parent device 2016-07-15 16:05:21 -04:00
Jeremy Stretch
518af1b95c Corrected RackGroupNestedSerializer() definition 2016-07-15 15:34:28 -04:00
Jeremy Stretch
4f95ce4984 Fixes #311: Correct IPAddress family evaluation on import 2016-07-15 15:14:49 -04:00
Jeremy Stretch
da10b34738 Closes #42: Allow VLAN assignment during prefix import 2016-07-15 14:25:30 -04:00
Jeremy Stretch
a9ab0a012f Merge pull request #309 from digitalocean/vlan-groups
Closes #111: Implement VLAN groups
2016-07-15 13:36:32 -04:00
Jeremy Stretch
45a8ee7325 Closes #111: Implement VLAN groups 2016-07-15 13:26:54 -04:00
Jeremy Stretch
23451fe974 Added a custom 500 handler to include exception details 2016-07-15 11:04:03 -04:00
Jeremy Stretch
5def0e91d7 Fixes #307: Validate device type assignment during import validation 2016-07-15 09:45:45 -04:00
Jeremy Stretch
f301af5ecd Fixes #301: Prevent deletion of DeviceBay when installed device is deleted 2016-07-14 17:41:16 -04:00
Jeremy Stretch
dd62caf2f0 Fixes #227: Introduces support for bulk import of child devices 2016-07-14 17:35:52 -04:00
Jeremy Stretch
4a00971d44 Fixes #43: Introduce toggle to enforce unique IP space per VRF 2016-07-14 16:13:02 -04:00
Jeremy Stretch
bf44e512ff Post-release version bump 2016-07-14 15:22:14 -04:00
Jeremy Stretch
4e64e1ea95 Merge pull request #299 from digitalocean/develop
Release v1.2.2
2016-07-14 15:21:40 -04:00
Jeremy Stretch
026403ed38 Release v1.2.2 2016-07-14 15:21:22 -04:00
Jeremy Stretch
f6bd1f0c48 Make the HA warning re: SECRET_KEY a note 2016-07-14 14:03:57 -04:00
Jeremy Stretch
66489438b9 Merge pull request #298 from rekkoner/develop
Updated SECRET_KEY instructions for HA installs. Issue 295
2016-07-14 14:01:06 -04:00
Jeremy Stretch
e5a6a4f05e Fixes #174: Added search and site filter to provider list 2016-07-14 13:53:30 -04:00
brandon whitehead
9e4aa9c056 Updated SECRET_KEY instructions for HA installs. Issue 295 2016-07-14 12:33:21 -05:00
Jeremy Stretch
4ce40891f0 Prettified device type view 2016-07-14 12:39:55 -04:00
Jeremy Stretch
46b1ac23af Allow for setting mgmt_only=True in "Add management interfaces" link 2016-07-14 11:39:53 -04:00
Jeremy Stretch
a5f6e64849 Fixes #290: Added mgmt interfaces table to device type view 2016-07-14 11:30:15 -04:00
Jeremy Stretch
b9db1ac7f7 Merge pull request #283 from ercpe/html-overflow
Use overflow-y: scoll on html element
2016-07-13 16:03:53 -04:00
Jeremy Stretch
124c2acad7 Merge pull request #287 from bellwood/ui-add-glyphicons-to-panel-headers
Add 'filter' glyphicon to filter panel header
2016-07-13 16:02:15 -04:00
bellwood
2691590aa1 Add 'search' glyphicon to filter panel header 2016-07-13 15:36:26 -04:00
bellwood
51cc0d5083 Add 'search' glyphicon to filter panel header 2016-07-13 15:36:07 -04:00
bellwood
9c32943d73 Add 'search' glyphicon to filter panel header 2016-07-13 15:35:41 -04:00
bellwood
4483ba55dd Add 'search' glyphicon to filter panel header 2016-07-13 15:34:23 -04:00
bellwood
f20e0edb35 Add 'search' glyphicon to filter panel header 2016-07-13 15:33:52 -04:00
bellwood
aed2180142 Add 'search' glyphicon to filter panel header 2016-07-13 15:32:39 -04:00
Jeremy Stretch
4913d25d18 Fixes #268: Added support for full 32-bit ASN space 2016-07-13 15:30:15 -04:00
bellwood
9e181c20c7 Add 'filter' glyphicon to filter panel header 2016-07-13 15:26:24 -04:00
Jeremy Stretch
404d934736 Removed redundant template context processor 2016-07-13 14:08:46 -04:00
Jeremy Stretch
024c7da15b Fixes #115: Fix deprecated django.core.context_processors reference 2016-07-13 14:05:21 -04:00
Jeremy Stretch
d3a5b82d93 Fixes #282: De-select "all" checkbox if one or more objects are deselected 2016-07-13 13:50:50 -04:00
Jeremy Stretch
1e3a03c463 Merge branch 'develop' of github.com:digitalocean/netbox into develop 2016-07-13 13:08:17 -04:00
Jeremy Stretch
bafbc052e2 Fixes #270: Add rack group filter for devices 2016-07-13 13:07:55 -04:00
Jeremy Stretch
9421ec040c Fixes #271: Add rack group filter for devices 2016-07-13 13:07:02 -04:00
Jeremy Stretch
07fc2e5502 Merge pull request #273 from bellwood/devices-filter-add-rackgroup
allow filtering by rack group
2016-07-13 12:55:11 -04:00
Jeremy Stretch
9098001bcb Post-release version bump 2016-07-13 12:11:10 -04:00
Johann Schmitz
35a2671525 Use overflow-y: scoll on html element to avoid jumping around when the previous/next page adds a vertical scrollbar. 2016-07-13 15:39:59 +02:00
bellwood
69affb7a6e fixed "rack group" filter label for/dcim/racks/ 2016-07-12 15:16:32 -04:00
bellwood
6a6cf14a38 Update forms.py
added label
2016-07-12 15:12:36 -04:00
bellwood
da50cd0f03 allow filtering by rack group
adds the ability to filter devices by rack group
2016-07-12 14:42:47 -04:00
56 changed files with 977 additions and 92 deletions

View File

@@ -47,9 +47,17 @@ In order to send email, NetBox needs an email server configured. The following i
---
# ENFORCE_GLOBAL_UNIQUE
Default: False
Enforcement of unique IP space can be toggled on a per-VRF basis. To enforce unique IP space within the global table (all prefixes and IP addresses not assigned to a VRF), set `ENFORCE_GLOBAL_UNIQUE` to True.
---
## LOGIN_REQUIRED
Default: False,
Default: False
Setting this to True will permit only authenticated users to access any part of NetBox. By default, anonymous users are permitted to access most data in NetBox (excluding secrets) but not make any changes.

View File

@@ -112,6 +112,9 @@ Generate a random secret key of at least 50 alphanumeric characters. This key mu
You may use the script located at `netbox/generate_secret_key.py` to generate a suitable key.
!!! note
In the case of a highly available installation with multiple web servers, `SECRET_KEY` must be identical among all servers in order to maintain a persistent user session state.
# Run Database Migrations
Before NetBox can run, we need to install the database schema. This is done by running `./manage.py migrate` from the `netbox` directory (`/opt/netbox/netbox/` in our example):

View File

@@ -1,9 +1,40 @@
import django_filters
from django.db.models import Q
from dcim.models import Site
from .models import Provider, Circuit, CircuitType
class ProviderFilter(django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
label='Search',
)
site_id = django_filters.ModelMultipleChoiceFilter(
name='circuits__site',
queryset=Site.objects.all(),
label='Site',
)
site = django_filters.ModelMultipleChoiceFilter(
name='circuits__site',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site (slug)',
)
class Meta:
model = Provider
fields = ['q', 'name', 'account', 'asn']
def search(self, queryset, value):
value = value.strip()
return queryset.filter(
Q(name__icontains=value) |
Q(account__icontains=value)
)
class CircuitFilter(django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',

View File

@@ -59,6 +59,16 @@ class ProviderBulkDeleteForm(ConfirmationForm):
pk = forms.ModelMultipleChoiceField(queryset=Provider.objects.all(), widget=forms.MultipleHiddenInput)
def provider_site_choices():
site_choices = Site.objects.all()
return [(s.slug, s.name) for s in site_choices]
class ProviderFilterForm(forms.Form, BootstrapMixin):
site = forms.MultipleChoiceField(required=False, choices=provider_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
#
# Circuit types
#

View File

@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.7 on 2016-07-13 19:24
from __future__ import unicode_literals
import dcim.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('circuits', '0002_auto_20160622_1821'),
]
operations = [
migrations.AlterField(
model_name='provider',
name='asn',
field=dcim.fields.ASNField(blank=True, null=True, verbose_name=b'ASN'),
),
]

View File

@@ -1,6 +1,7 @@
from django.core.urlresolvers import reverse
from django.db import models
from dcim.fields import ASNField
from dcim.models import Site, Interface
from utilities.models import CreatedUpdatedModel
@@ -12,7 +13,7 @@ class Provider(CreatedUpdatedModel):
"""
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
asn = models.PositiveIntegerField(blank=True, null=True, verbose_name='ASN')
asn = ASNField(blank=True, null=True, verbose_name='ASN')
account = models.CharField(max_length=30, blank=True, verbose_name='Account number')
portal_url = models.URLField(blank=True, verbose_name='Portal')
noc_contact = models.TextField(blank=True, verbose_name='NOC contact')

View File

@@ -16,6 +16,8 @@ from .models import Circuit, CircuitType, Provider
class ProviderListView(ObjectListView):
queryset = Provider.objects.annotate(count_circuits=Count('circuits'))
filter = filters.ProviderFilter
filter_form = forms.ProviderFilterForm
table = tables.ProviderTable
edit_permissions = ['circuits.change_provider', 'circuits.delete_provider']
template_name = 'circuits/provider_list.html'

View File

@@ -38,7 +38,7 @@ class RackGroupSerializer(serializers.ModelSerializer):
fields = ['id', 'name', 'slug', 'site']
class RackGroupNestedSerializer(SiteSerializer):
class RackGroupNestedSerializer(RackGroupSerializer):
class Meta(SiteSerializer.Meta):
fields = ['id', 'name', 'slug']

View File

@@ -1,11 +1,20 @@
from netaddr import EUI, mac_unix_expanded
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator, MaxValueValidator
from django.db import models
from .formfields import MACAddressFormField
class ASNField(models.BigIntegerField):
description = "32-bit ASN field"
default_validators = [
MinValueValidator(1),
MaxValueValidator(4294967295),
]
class mac_unix_expanded_uppercase(mac_unix_expanded):
word_fmt = '%.2X'

View File

@@ -122,6 +122,11 @@ class DeviceFilter(django_filters.FilterSet):
to_field_name='slug',
label='Site name (slug)',
)
rack_group_id = django_filters.ModelMultipleChoiceFilter(
name='rack__group',
queryset=RackGroup.objects.all(),
label='Rack group (ID)',
)
rack_id = django_filters.ModelMultipleChoiceFilter(
name='rack',
queryset=Rack.objects.all(),

View File

@@ -186,7 +186,7 @@ def rack_group_choices():
class RackFilterForm(forms.Form, BootstrapMixin):
site = forms.MultipleChoiceField(required=False, choices=rack_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
group_id = forms.MultipleChoiceField(required=False, choices=rack_group_choices,
group_id = forms.MultipleChoiceField(required=False, choices=rack_group_choices, label='Rack Group',
widget=forms.SelectMultiple(attrs={'size': 8}))
@@ -426,7 +426,7 @@ class DeviceForm(forms.ModelForm, BootstrapMixin):
self.fields['device_type'].choices = []
class DeviceFromCSVForm(forms.ModelForm):
class BaseDeviceFromCSVForm(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',
@@ -434,23 +434,15 @@ class DeviceFromCSVForm(forms.ModelForm):
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.CharField(required=False)
class Meta:
fields = []
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:
@@ -459,6 +451,25 @@ class DeviceFromCSVForm(forms.ModelForm):
except DeviceType.DoesNotExist:
self.add_error('model_name', "Invalid device type ({} {})".format(manufacturer, model_name))
class DeviceFromCSVForm(BaseDeviceFromCSVForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name', error_messages={
'invalid_choice': 'Invalid site name.',
})
rack_name = forms.CharField()
face = forms.CharField(required=False)
class Meta(BaseDeviceFromCSVForm.Meta):
fields = ['name', 'device_role', 'manufacturer', 'model_name', 'platform', 'serial', 'site', 'rack_name',
'position', 'face']
def clean(self):
super(DeviceFromCSVForm, self).clean()
site = self.cleaned_data.get('site')
rack_name = self.cleaned_data.get('rack_name')
# Validate rack
if site and rack_name:
try:
@@ -468,21 +479,54 @@ class DeviceFromCSVForm(forms.ModelForm):
def clean_face(self):
face = self.cleaned_data['face']
if face:
if not face:
return None
try:
return {
'front': 0,
'rear': 1,
}[face.lower()]
except KeyError:
raise forms.ValidationError('Invalid rack face ({}); must be "front" or "rear".'.format(face))
class ChildDeviceFromCSVForm(BaseDeviceFromCSVForm):
parent = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name', required=False,
error_messages={'invalid_choice': 'Parent device not found.'})
device_bay_name = forms.CharField(required=False)
class Meta(BaseDeviceFromCSVForm.Meta):
fields = ['name', 'device_role', 'manufacturer', 'model_name', 'platform', 'serial', 'parent',
'device_bay_name']
def clean(self):
super(ChildDeviceFromCSVForm, self).clean()
parent = self.cleaned_data.get('parent')
device_bay_name = self.cleaned_data.get('device_bay_name')
# Validate device bay
if parent and device_bay_name:
try:
return {
'front': 0,
'rear': 1,
}[face.lower()]
except KeyError:
raise forms.ValidationError('Invalid rack face ({}); must be "front" or "rear".'.format(face))
return face
device_bay = DeviceBay.objects.get(device=parent, name=device_bay_name)
if device_bay.installed_device:
self.add_error('device_bay_name',
"Device bay ({} {}) is already occupied".format(parent, device_bay_name))
else:
self.instance.parent_bay = device_bay
except DeviceBay.DoesNotExist:
self.add_error('device_bay_name', "Parent device/bay ({} {}) not found".format(parent, device_bay_name))
class DeviceImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=DeviceFromCSVForm)
class ChildDeviceImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=ChildDeviceFromCSVForm)
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')
@@ -502,6 +546,11 @@ def device_site_choices():
return [(s.slug, '{} ({})'.format(s.name, s.device_count)) for s in site_choices]
def device_rack_group_choices():
group_choices = RackGroup.objects.select_related('site').annotate(device_count=Count('racks__devices'))
return [(g.pk, '{} ({})'.format(g, g.device_count)) for g in group_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]
@@ -520,6 +569,8 @@ def device_platform_choices():
class DeviceFilterForm(forms.Form, BootstrapMixin):
site = forms.MultipleChoiceField(required=False, choices=device_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
rack_group_id = forms.MultipleChoiceField(required=False, choices=device_rack_group_choices, label='Rack Group',
widget=forms.SelectMultiple(attrs={'size': 8}))
role = forms.MultipleChoiceField(required=False, choices=device_role_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
device_type_id = forms.MultipleChoiceField(required=False, choices=device_type_choices, label='Type',

View File

@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.7 on 2016-07-13 19:24
from __future__ import unicode_literals
import dcim.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('dcim', '0008_device_remove_primary_ip'),
]
operations = [
migrations.AlterField(
model_name='site',
name='asn',
field=dcim.fields.ASNField(blank=True, null=True, verbose_name=b'ASN'),
),
]

View File

@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.7 on 2016-07-14 21:38
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('dcim', '0009_site_32bit_asn_support'),
]
operations = [
migrations.AlterField(
model_name='devicebay',
name='installed_device',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='parent_bay', to='dcim.Device'),
),
]

View File

@@ -11,7 +11,7 @@ from extras.rpc import RPC_CLIENTS
from utilities.fields import NullableCharField
from utilities.models import CreatedUpdatedModel
from .fields import MACAddressField
from .fields import ASNField, MACAddressField
RACK_FACE_FRONT = 0
RACK_FACE_REAR = 1
@@ -145,7 +145,7 @@ class Site(CreatedUpdatedModel):
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')
asn = ASNField(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)
@@ -624,6 +624,10 @@ class Device(CreatedUpdatedModel):
def clean(self):
# Validate device type assignment
if not hasattr(self, 'device_type'):
raise ValidationError("Must specify device type.")
# Child devices cannot be assigned to a rack face/unit
if self.device_type.is_child_device and (self.face is not None or self.position):
raise ValidationError("Child device types cannot be assigned a rack face or position.")
@@ -633,10 +637,7 @@ class Device(CreatedUpdatedModel):
raise ValidationError("Must specify rack face with rack position.")
# Validate rack space
try:
rack_face = self.face if not self.device_type.is_full_depth else None
except DeviceType.DoesNotExist:
raise ValidationError("Must specify device type.")
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,
@@ -680,6 +681,9 @@ class Device(CreatedUpdatedModel):
self.device_type.device_bay_templates.all()]
)
# Update Rack assignment for any child Devices
Device.objects.filter(parent_bay__device=self).update(rack=self.rack)
def to_csv(self):
return ','.join([
self.name or '',
@@ -953,7 +957,8 @@ class DeviceBay(models.Model):
"""
device = models.ForeignKey('Device', related_name='device_bays', on_delete=models.CASCADE)
name = models.CharField(max_length=50, verbose_name='Name')
installed_device = models.OneToOneField('Device', related_name='parent_bay', blank=True, null=True)
installed_device = models.OneToOneField('Device', related_name='parent_bay', on_delete=models.SET_NULL, blank=True,
null=True)
class Meta:
ordering = ['device', 'name']

View File

@@ -92,6 +92,7 @@ urlpatterns = [
url(r'^devices/$', views.DeviceListView.as_view(), name='device_list'),
url(r'^devices/add/$', views.DeviceEditView.as_view(), name='device_add'),
url(r'^devices/import/$', views.DeviceBulkImportView.as_view(), name='device_import'),
url(r'^devices/import/child-devices/$', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'),
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'),

View File

@@ -273,7 +273,10 @@ def devicetype(request, pk):
poweroutlet_table = tables.PowerOutletTemplateTable(
natsorted(PowerOutletTemplate.objects.filter(device_type=devicetype), key=attrgetter('name'))
)
interface_table = tables.InterfaceTemplateTable(InterfaceTemplate.objects.filter(device_type=devicetype))
mgmt_interface_table = tables.InterfaceTemplateTable(InterfaceTemplate.objects.filter(device_type=devicetype,
mgmt_only=True))
interface_table = tables.InterfaceTemplateTable(InterfaceTemplate.objects.filter(device_type=devicetype,
mgmt_only=False))
devicebay_table = tables.DeviceBayTemplateTable(
natsorted(DeviceBayTemplate.objects.filter(device_type=devicetype), key=attrgetter('name'))
)
@@ -282,6 +285,7 @@ def devicetype(request, pk):
consoleserverport_table.base_columns['pk'].visible = True
powerport_table.base_columns['pk'].visible = True
poweroutlet_table.base_columns['pk'].visible = True
mgmt_interface_table.base_columns['pk'].visible = True
interface_table.base_columns['pk'].visible = True
devicebay_table.base_columns['pk'].visible = True
@@ -291,6 +295,7 @@ def devicetype(request, pk):
'consoleserverport_table': consoleserverport_table,
'powerport_table': powerport_table,
'poweroutlet_table': poweroutlet_table,
'mgmt_interface_table': mgmt_interface_table,
'interface_table': interface_table,
'devicebay_table': devicebay_table,
})
@@ -348,7 +353,7 @@ class ComponentTemplateCreateView(View):
return render(request, 'dcim/component_template_add.html', {
'devicetype': devicetype,
'component_type': self.model._meta.verbose_name,
'form': self.form(),
'form': self.form(initial=request.GET),
'cancel_url': reverse('dcim:devicetype', kwargs={'pk': devicetype.pk}),
})
@@ -604,6 +609,23 @@ class DeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
obj_list_url = 'dcim:device_list'
class ChildDeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_device'
form = forms.ChildDeviceImportForm
table = tables.DeviceImportTable
template_name = 'dcim/device_import_child.html'
obj_list_url = 'dcim:device_list'
def save_obj(self, obj):
# Inherent rack from parent device
obj.rack = obj.parent_bay.device.rack
obj.save()
# Save the reverse relation
device_bay = obj.parent_bay
device_bay.installed_device = obj
device_bay.save()
class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_device'
cls = Device

View File

@@ -1,7 +1,7 @@
from django.contrib import admin
from .models import (
Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VRF,
Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF,
)
@@ -57,6 +57,14 @@ class IPAddressAdmin(admin.ModelAdmin):
return qs.select_related('vrf', 'nat_inside')
@admin.register(VLANGroup)
class VLANGroupAdmin(admin.ModelAdmin):
list_display = ['name', 'site', 'slug']
prepopulated_fields = {
'slug': ['name'],
}
@admin.register(VLAN)
class VLANAdmin(admin.ModelAdmin):
list_display = ['site', 'vid', 'name', 'status', 'role']

View File

@@ -1,7 +1,7 @@
from rest_framework import serializers
from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer
from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN
from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN, VLANGroup
#
@@ -12,7 +12,7 @@ class VRFSerializer(serializers.ModelSerializer):
class Meta:
model = VRF
fields = ['id', 'name', 'rd', 'description']
fields = ['id', 'name', 'rd', 'enforce_unique', 'description']
class VRFNestedSerializer(VRFSerializer):
@@ -73,17 +73,36 @@ class AggregateNestedSerializer(AggregateSerializer):
fields = ['id', 'family', 'prefix']
#
# VLAN groups
#
class VLANGroupSerializer(serializers.ModelSerializer):
site = SiteNestedSerializer()
class Meta:
model = VLANGroup
fields = ['id', 'name', 'slug', 'site']
class VLANGroupNestedSerializer(VLANGroupSerializer):
class Meta(VLANGroupSerializer.Meta):
fields = ['id', 'name', 'slug']
#
# VLANs
#
class VLANSerializer(serializers.ModelSerializer):
site = SiteNestedSerializer()
group = VLANGroupNestedSerializer()
role = RoleNestedSerializer()
class Meta:
model = VLAN
fields = ['id', 'site', 'vid', 'name', 'status', 'role', 'display_name']
fields = ['id', 'site', 'group', 'vid', 'name', 'status', 'role', 'display_name']
class VLANNestedSerializer(VLANSerializer):

View File

@@ -29,6 +29,10 @@ urlpatterns = [
url(r'^ip-addresses/$', IPAddressListView.as_view(), name='ipaddress_list'),
url(r'^ip-addresses/(?P<pk>\d+)/$', IPAddressDetailView.as_view(), name='ipaddress_detail'),
# VLAN groups
url(r'^vlan-groups/$', VLANGroupListView.as_view(), name='vlangroup_list'),
url(r'^vlan-groups/(?P<pk>\d+)/$', VLANGroupDetailView.as_view(), name='vlangroup_detail'),
# VLANs
url(r'^vlans/$', VLANListView.as_view(), name='vlan_list'),
url(r'^vlans/(?P<pk>\d+)/$', VLANDetailView.as_view(), name='vlan_detail'),

View File

@@ -1,18 +1,22 @@
from rest_framework import generics
from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN
from ipam.filters import AggregateFilter, PrefixFilter, IPAddressFilter, VLANFilter, VRFFilter
from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN, VLANGroup
from ipam import filters
from . import serializers
#
# VRFs
#
class VRFListView(generics.ListAPIView):
"""
List all VRFs
"""
queryset = VRF.objects.all()
serializer_class = serializers.VRFSerializer
filter_class = VRFFilter
filter_class = filters.VRFFilter
class VRFDetailView(generics.RetrieveAPIView):
@@ -23,6 +27,10 @@ class VRFDetailView(generics.RetrieveAPIView):
serializer_class = serializers.VRFSerializer
#
# Roles
#
class RoleListView(generics.ListAPIView):
"""
List all roles
@@ -39,6 +47,10 @@ class RoleDetailView(generics.RetrieveAPIView):
serializer_class = serializers.RoleSerializer
#
# RIRs
#
class RIRListView(generics.ListAPIView):
"""
List all RIRs
@@ -55,13 +67,17 @@ class RIRDetailView(generics.RetrieveAPIView):
serializer_class = serializers.RIRSerializer
#
# Aggregates
#
class AggregateListView(generics.ListAPIView):
"""
List aggregates (filterable)
"""
queryset = Aggregate.objects.select_related('rir')
serializer_class = serializers.AggregateSerializer
filter_class = AggregateFilter
filter_class = filters.AggregateFilter
class AggregateDetailView(generics.RetrieveAPIView):
@@ -72,13 +88,17 @@ class AggregateDetailView(generics.RetrieveAPIView):
serializer_class = serializers.AggregateSerializer
#
# Prefixes
#
class PrefixListView(generics.ListAPIView):
"""
List prefixes (filterable)
"""
queryset = Prefix.objects.select_related('site', 'vrf', 'vlan', 'role')
serializer_class = serializers.PrefixSerializer
filter_class = PrefixFilter
filter_class = filters.PrefixFilter
class PrefixDetailView(generics.RetrieveAPIView):
@@ -89,6 +109,10 @@ class PrefixDetailView(generics.RetrieveAPIView):
serializer_class = serializers.PrefixSerializer
#
# IP addresses
#
class IPAddressListView(generics.ListAPIView):
"""
List IP addresses (filterable)
@@ -96,7 +120,7 @@ class IPAddressListView(generics.ListAPIView):
queryset = IPAddress.objects.select_related('vrf', 'interface__device', 'nat_inside')\
.prefetch_related('nat_outside')
serializer_class = serializers.IPAddressSerializer
filter_class = IPAddressFilter
filter_class = filters.IPAddressFilter
class IPAddressDetailView(generics.RetrieveAPIView):
@@ -108,13 +132,38 @@ class IPAddressDetailView(generics.RetrieveAPIView):
serializer_class = serializers.IPAddressSerializer
#
# VLAN groups
#
class VLANGroupListView(generics.ListAPIView):
"""
List all VLAN groups
"""
queryset = VLANGroup.objects.all()
serializer_class = serializers.VLANGroupSerializer
filter_class = filters.VLANGroupFilter
class VLANGroupDetailView(generics.RetrieveAPIView):
"""
Retrieve a single VLAN group
"""
queryset = VLANGroup.objects.all()
serializer_class = serializers.VLANGroupSerializer
#
# VLANs
#
class VLANListView(generics.ListAPIView):
"""
List VLANs (filterable)
"""
queryset = VLAN.objects.select_related('site', 'role')
serializer_class = serializers.VLANSerializer
filter_class = VLANFilter
filter_class = filters.VLANFilter
class VLANDetailView(generics.RetrieveAPIView):

View File

@@ -4,7 +4,7 @@ from netaddr.core import AddrFormatError
from dcim.models import Site, Device, Interface
from .models import RIR, Aggregate, VRF, Prefix, IPAddress, VLAN, Role
from .models import RIR, Aggregate, VRF, Prefix, IPAddress, VLAN, VLANGroup, Role
class VRFFilter(django_filters.FilterSet):
@@ -176,6 +176,24 @@ class IPAddressFilter(django_filters.FilterSet):
return queryset.filter(vrf__pk=value)
class VLANGroupFilter(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 = VLANGroup
fields = ['site_id', 'site']
class VLANFilter(django_filters.FilterSet):
site_id = django_filters.ModelMultipleChoiceFilter(
name='site',
@@ -188,6 +206,17 @@ class VLANFilter(django_filters.FilterSet):
to_field_name='slug',
label='Site (slug)',
)
group_id = django_filters.ModelMultipleChoiceFilter(
name='group',
queryset=VLANGroup.objects.all(),
label='Group (ID)',
)
group = django_filters.ModelMultipleChoiceFilter(
name='group',
queryset=VLANGroup.objects.all(),
to_field_name='slug',
label='Group',
)
name = django_filters.CharFilter(
name='name',
lookup_type='icontains',

View File

@@ -9,7 +9,7 @@ from utilities.forms import (
)
from .models import (
Aggregate, IPAddress, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, VLAN, VLAN_STATUS_CHOICES, VRF,
Aggregate, IPAddress, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, VLAN, VLANGroup, VLAN_STATUS_CHOICES, VRF,
)
@@ -25,7 +25,7 @@ class VRFForm(forms.ModelForm, BootstrapMixin):
class Meta:
model = VRF
fields = ['name', 'rd', 'description']
fields = ['name', 'rd', 'enforce_unique', 'description']
labels = {
'rd': "RD",
}
@@ -38,7 +38,7 @@ class VRFFromCSVForm(forms.ModelForm):
class Meta:
model = VRF
fields = ['name', 'rd', 'description']
fields = ['name', 'rd', 'enforce_unique', 'description']
class VRFImportForm(BulkImportForm, BootstrapMixin):
@@ -192,13 +192,43 @@ class PrefixFromCSVForm(forms.ModelForm):
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.'})
vlan_group_name = forms.CharField(required=False)
vlan_vid = forms.IntegerField(required=False)
status_name = forms.ChoiceField(choices=[(s[1], s[0]) for s in PREFIX_STATUS_CHOICES])
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_name', 'role', 'description']
fields = ['prefix', 'vrf', 'site', 'vlan_group_name', 'vlan_vid', 'status_name', 'role', 'description']
def clean(self):
super(PrefixFromCSVForm, self).clean()
site = self.cleaned_data.get('site')
vlan_group_name = self.cleaned_data.get('vlan_group_name')
vlan_vid = self.cleaned_data.get('vlan_vid')
# Validate VLAN
vlan_group = None
if vlan_group_name:
try:
vlan_group = VLANGroup.objects.get(site=site, name=vlan_group_name)
except VLANGroup.DoesNotExist:
self.add_error('vlan_group_name', "Invalid VLAN group ({} - {}).".format(site, vlan_group_name))
if vlan_vid and vlan_group:
try:
self.instance.vlan = VLAN.objects.get(group=vlan_group, vid=vlan_vid)
except VLAN.DoesNotExist:
self.add_error('vlan_vid', "Invalid VLAN ID ({} - {}).".format(vlan_group, vlan_vid))
elif vlan_vid and site:
try:
self.instance.vlan = VLAN.objects.get(site=site, vid=vlan_vid)
except VLAN.MultipleObjectsReturned:
self.add_error('vlan_vid', "Multiple VLANs found ({} - VID {})".format(site, vlan_vid))
elif vlan_vid:
self.add_error('vlan_vid', "Must specify site and/or VLAN group when assigning a VLAN.")
def save(self, *args, **kwargs):
m = super(PrefixFromCSVForm, self).save(commit=False)
@@ -368,9 +398,9 @@ class IPAddressFromCSVForm(forms.ModelForm):
name=self.cleaned_data['interface_name'])
# Set as primary for device
if self.cleaned_data['is_primary']:
if self.instance.family == 4:
if self.instance.address.version == 4:
self.instance.primary_ip4_for = self.cleaned_data['device']
elif self.instance.family == 6:
elif self.instance.address.version == 6:
self.instance.primary_ip6_for = self.cleaned_data['device']
return super(IPAddressFromCSVForm, self).save(commit=commit)
@@ -407,34 +437,81 @@ class IPAddressFilterForm(forms.Form, BootstrapMixin):
vrf = forms.ChoiceField(required=False, choices=ipaddress_vrf_choices, label='VRF')
#
# VLAN groups
#
class VLANGroupForm(forms.ModelForm, BootstrapMixin):
slug = SlugField()
class Meta:
model = VLANGroup
fields = ['site', 'name', 'slug']
class VLANGroupBulkDeleteForm(ConfirmationForm):
pk = forms.ModelMultipleChoiceField(queryset=VLANGroup.objects.all(), widget=forms.MultipleHiddenInput)
def vlangroup_site_choices():
site_choices = Site.objects.annotate(vlangroup_count=Count('vlan_groups'))
return [(s.slug, '{} ({})'.format(s.name, s.vlangroup_count)) for s in site_choices]
class VLANGroupFilterForm(forms.Form, BootstrapMixin):
site = forms.MultipleChoiceField(required=False, choices=vlangroup_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
#
# VLANs
#
class VLANForm(forms.ModelForm, BootstrapMixin):
group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False, label='Group', widget=APISelect(
api_url='/api/ipam/vlan-groups/?site_id={{site}}',
))
class Meta:
model = VLAN
fields = ['site', 'vid', 'name', 'status', 'role']
fields = ['site', 'group', 'vid', 'name', 'status', 'role']
help_texts = {
'site': "The site at which this VLAN exists",
'group': "VLAN group (optional)",
'vid': "Configured VLAN ID",
'name': "Configured VLAN name",
'status': "Operational status of this VLAN",
'role': "The primary function of this VLAN",
}
widgets = {
'site': forms.Select(attrs={'filter-for': 'group'}),
}
def __init__(self, *args, **kwargs):
super(VLANForm, self).__init__(*args, **kwargs)
# Limit VLAN group choices
if self.is_bound and self.data.get('site'):
self.fields['group'].queryset = VLANGroup.objects.filter(site__pk=self.data['site'])
elif self.initial.get('site'):
self.fields['group'].queryset = VLANGroup.objects.filter(site=self.initial['site'])
else:
self.fields['group'].choices = []
class VLANFromCSVForm(forms.ModelForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Device not found.'})
group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'VLAN group not found.'})
status_name = forms.ChoiceField(choices=[(s[1], s[0]) for s in VLAN_STATUS_CHOICES])
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_name', 'role']
fields = ['site', 'group', 'vid', 'name', 'status_name', 'role']
def save(self, *args, **kwargs):
m = super(VLANFromCSVForm, self).save(commit=False)
@@ -465,6 +542,11 @@ def vlan_site_choices():
return [(s.slug, '{} ({})'.format(s.name, s.vlan_count)) for s in site_choices]
def vlan_group_choices():
group_choices = VLANGroup.objects.select_related('site').annotate(vlan_count=Count('vlans'))
return [(g.pk, '{} ({})'.format(g, g.vlan_count)) for g in group_choices]
def vlan_status_choices():
status_counts = {}
for status in VLAN.objects.values('status').annotate(count=Count('status')).order_by('status'):
@@ -480,6 +562,8 @@ def vlan_role_choices():
class VLANFilterForm(forms.Form, BootstrapMixin):
site = forms.MultipleChoiceField(required=False, choices=vlan_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
group_id = forms.MultipleChoiceField(required=False, choices=vlan_group_choices, label='VLAN Group',
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}))

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.7 on 2016-07-14 19:34
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ipam', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='vrf',
name='enforce_unique',
field=models.BooleanField(default=True, help_text=b'Prevent duplicate prefixes/IP addresses within this VRF', verbose_name=b'Enforce unique space'),
),
]

View File

@@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.7 on 2016-07-15 16:22
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('dcim', '0010_devicebay_installed_device_set_null'),
('ipam', '0002_vrf_add_enforce_unique'),
]
operations = [
migrations.CreateModel(
name='VLANGroup',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50)),
('slug', models.SlugField()),
('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='vlan_groups', to='dcim.Site')),
],
options={
'ordering': ['site', 'name'],
},
),
migrations.AddField(
model_name='vlan',
name='group',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vlans', to='ipam.VLANGroup'),
),
migrations.AlterUniqueTogether(
name='vlangroup',
unique_together=set([('site', 'name'), ('site', 'slug')]),
),
]

View File

@@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.7 on 2016-07-15 17:14
from __future__ import unicode_literals
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('ipam', '0003_ipam_add_vlangroups'),
]
operations = [
migrations.AlterModelOptions(
name='vlan',
options={'ordering': ['site', 'group', 'vid'], 'verbose_name': 'VLAN', 'verbose_name_plural': 'VLANs'},
),
migrations.AlterModelOptions(
name='vlangroup',
options={'ordering': ['site', 'name'], 'verbose_name': 'VLAN group', 'verbose_name_plural': 'VLAN groups'},
),
migrations.AlterUniqueTogether(
name='vlan',
unique_together=set([('group', 'name'), ('group', 'vid')]),
),
]

View File

@@ -1,5 +1,6 @@
from netaddr import IPNetwork, cidr_merge
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.urlresolvers import reverse
from django.core.validators import MaxValueValidator, MinValueValidator
@@ -45,6 +46,8 @@ class VRF(CreatedUpdatedModel):
"""
name = models.CharField(max_length=50)
rd = models.CharField(max_length=21, unique=True, verbose_name='Route distinguisher')
enforce_unique = models.BooleanField(default=True, verbose_name='Enforce unique space',
help_text="Prevent duplicate prefixes/IP addresses within this VRF")
description = models.CharField(max_length=100, blank=True)
class Meta:
@@ -244,6 +247,15 @@ class Prefix(CreatedUpdatedModel):
def get_absolute_url(self):
return reverse('ipam:prefix', args=[self.pk])
def clean(self):
# Disallow host masks
if self.prefix.version == 4 and self.prefix.prefixlen == 32:
raise ValidationError("Cannot create host addresses (/32) as prefixes. These should be IPv4 addresses "
"instead.")
elif self.prefix.version == 6 and self.prefix.prefixlen == 128:
raise ValidationError("Cannot create host addresses (/128) as prefixes. These should be IPv6 addresses "
"instead.")
def save(self, *args, **kwargs):
if self.prefix:
# Clear host bits from prefix
@@ -309,6 +321,21 @@ class IPAddress(CreatedUpdatedModel):
def get_absolute_url(self):
return reverse('ipam:ipaddress', args=[self.pk])
def clean(self):
# Enforce unique IP space if applicable
if self.vrf and self.vrf.enforce_unique:
duplicate_ips = IPAddress.objects.filter(vrf=self.vrf, address__net_host=str(self.address.ip))\
.exclude(pk=self.pk)
if duplicate_ips:
raise ValidationError("Duplicate IP address found in VRF {}: {}".format(self.vrf,
duplicate_ips.first()))
elif not self.vrf and settings.ENFORCE_GLOBAL_UNIQUE:
duplicate_ips = IPAddress.objects.filter(vrf=None, address__net_host=str(self.address.ip))\
.exclude(pk=self.pk)
if duplicate_ips:
raise ValidationError("Duplicate IP address found in global table: {}".format(duplicate_ips.first()))
def save(self, *args, **kwargs):
if self.address:
# Infer address family from IPAddress object
@@ -340,13 +367,41 @@ class IPAddress(CreatedUpdatedModel):
return None
class VLANGroup(models.Model):
"""
A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique.
"""
name = models.CharField(max_length=50)
slug = models.SlugField()
site = models.ForeignKey('dcim.Site', related_name='vlan_groups')
class Meta:
ordering = ['site', 'name']
unique_together = [
['site', 'name'],
['site', 'slug'],
]
verbose_name = 'VLAN group'
verbose_name_plural = 'VLAN groups'
def __unicode__(self):
return '{} - {}'.format(self.site.name, self.name)
def get_absolute_url(self):
return "{}?group_id={}".format(reverse('ipam:vlan_list'), self.pk)
class VLAN(CreatedUpdatedModel):
"""
A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned
to a Site, however VLAN IDs need not be unique within a Site. Like Prefixes, each VLAN is assigned an operational
status and optionally a user-defined Role. A VLAN can have zero or more Prefixes assigned to it.
to a Site, however VLAN IDs need not be unique within a Site. A VLAN may optionally be assigned to a VLANGroup,
within which all VLAN IDs and names but be unique.
Like Prefixes, each VLAN is assigned an operational status and optionally a user-defined Role. A VLAN can have zero
or more Prefixes assigned to it.
"""
site = models.ForeignKey('dcim.Site', related_name='vlans', on_delete=models.PROTECT)
group = models.ForeignKey('VLANGroup', related_name='vlans', blank=True, null=True, on_delete=models.PROTECT)
vid = models.PositiveSmallIntegerField(verbose_name='ID', validators=[
MinValueValidator(1),
MaxValueValidator(4094)
@@ -356,7 +411,11 @@ class VLAN(CreatedUpdatedModel):
role = models.ForeignKey('Role', related_name='vlans', on_delete=models.SET_NULL, blank=True, null=True)
class Meta:
ordering = ['site', 'vid']
ordering = ['site', 'group', 'vid']
unique_together = [
['group', 'vid'],
['group', 'name'],
]
verbose_name = 'VLAN'
verbose_name_plural = 'VLANs'
@@ -366,6 +425,12 @@ class VLAN(CreatedUpdatedModel):
def get_absolute_url(self):
return reverse('ipam:vlan', args=[self.pk])
def clean(self):
# Validate VLAN group
if self.group and self.group.site != self.site:
raise ValidationError("VLAN group must belong to the assigned site ({}).".format(self.site))
def to_csv(self):
return ','.join([
self.site.name,

View File

@@ -3,7 +3,7 @@ from django_tables2.utils import Accessor
from utilities.tables import BaseTable, ToggleColumn
from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VRF
from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF
RIR_EDIT_LINK = """
@@ -50,6 +50,12 @@ STATUS_LABEL = """
{% endif %}
"""
VLANGROUP_EDIT_LINK = """
{% if perms.ipam.change_vlangroup %}
<a href="{% url 'ipam:vlangroup_edit' pk=record.pk %}">Edit</a>
{% endif %}
"""
#
# VRFs
@@ -177,6 +183,23 @@ class IPAddressBriefTable(BaseTable):
fields = ('address', 'device', 'interface', 'nat_inside')
#
# VLAN groups
#
class VLANGroupTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn(verbose_name='Name')
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
vlan_count = tables.Column(verbose_name='VLANs')
slug = tables.Column(verbose_name='Slug')
edit = tables.TemplateColumn(template_code=VLANGROUP_EDIT_LINK, verbose_name='')
class Meta(BaseTable.Meta):
model = VLANGroup
fields = ('pk', 'name', 'site', 'vlan_count', 'slug', 'edit')
#
# VLANs
#
@@ -185,10 +208,11 @@ class VLANTable(BaseTable):
pk = ToggleColumn()
vid = tables.LinkColumn('ipam:vlan', args=[Accessor('pk')], verbose_name='ID')
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
name = tables.Column(verbose_name='Name')
status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
role = tables.Column(verbose_name='Role')
class Meta(BaseTable.Meta):
model = VLAN
fields = ('pk', 'vid', 'site', 'name', 'status', 'role')
fields = ('pk', 'vid', 'site', 'group', 'name', 'status', 'role')

View File

@@ -58,6 +58,12 @@ urlpatterns = [
url(r'^ip-addresses/(?P<pk>\d+)/edit/$', views.IPAddressEditView.as_view(), name='ipaddress_edit'),
url(r'^ip-addresses/(?P<pk>\d+)/delete/$', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'),
# VLAN groups
url(r'^vlan-groups/$', views.VLANGroupListView.as_view(), name='vlangroup_list'),
url(r'^vlan-groups/add/$', views.VLANGroupEditView.as_view(), name='vlangroup_add'),
url(r'^vlan-groups/delete/$', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'),
url(r'^vlan-groups/(?P<pk>\d+)/edit/$', views.VLANGroupEditView.as_view(), name='vlangroup_edit'),
# VLANs
url(r'^vlans/$', views.VLANListView.as_view(), name='vlan_list'),
url(r'^vlans/add/$', views.VLANEditView.as_view(), name='vlan_add'),

View File

@@ -12,7 +12,7 @@ from utilities.views import (
)
from . import filters, forms, tables
from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VRF
from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF
def add_available_prefixes(parent, prefix_list):
@@ -483,6 +483,33 @@ class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
default_redirect_url = 'ipam:ipaddress_list'
#
# VLAN groups
#
class VLANGroupListView(ObjectListView):
queryset = VLANGroup.objects.annotate(vlan_count=Count('vlans'))
filter = filters.VLANGroupFilter
filter_form = forms.VLANGroupFilterForm
table = tables.VLANGroupTable
edit_permissions = ['ipam.change_vlangroup', 'ipam.delete_vlangroup']
template_name = 'ipam/vlangroup_list.html'
class VLANGroupEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_vlangroup'
model = VLANGroup
form_class = forms.VLANGroupForm
cancel_url = 'ipam:vlangroup_list'
class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_vlangroup'
cls = VLANGroup
form = forms.VLANGroupBulkDeleteForm
default_redirect_url = 'ipam:vlangroup_list'
#
# VLANs
#

View File

@@ -82,3 +82,7 @@ BANNER_BOTTOM = ''
# When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to True to
# prefer IPv4 instead.
PREFER_IPV4 = False
# Enforcement of unique IP space can be toggled on a per-VRF basis. To enforce unique IP space within the global table
# (all prefixes and IP addresses not assigned to a VRF), set ENFORCE_GLOBAL_UNIQUE to True.
ENFORCE_GLOBAL_UNIQUE = False

View File

@@ -12,7 +12,7 @@ except ImportError:
"the documentation.")
VERSION = '1.2.1'
VERSION = '1.3.0'
# Import local configuration
for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
@@ -41,6 +41,7 @@ SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H
BANNER_TOP = getattr(configuration, 'BANNER_TOP', False)
BANNER_BOTTOM = getattr(configuration, 'BANNER_BOTTOM', False)
PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False)
ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False)
CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS
# Attempt to import LDAP configuration if it has been defined
@@ -138,7 +139,6 @@ TEMPLATES = [
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'utilities.context_processors.settings',
'django.core.context_processors.request',
],
},
},

View File

@@ -2,10 +2,12 @@ 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 views import home, trigger_500, handle_500
from users.views import login, logout
handler500 = handle_500
urlpatterns = [
# Default page

View File

@@ -1,9 +1,6 @@
from markdown import markdown
import sys
from django.conf import settings
from django.http import Http404
from django.shortcuts import render
from django.utils.safestring import mark_safe
from circuits.models import Provider, Circuit
from dcim.models import Site, Rack, Device, ConsolePort, PowerPort, InterfaceConnection
@@ -47,6 +44,14 @@ def home(request):
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.")
def handle_500(request):
"""Custom server error handler"""
type_, error, traceback = sys.exc_info()
return render(request, '500.html', {
'exception': str(type_),
'error': error,
}, status=500)

View File

@@ -2,6 +2,9 @@
* {
margin: 0;
}
html {
overflow-y: scroll;
}
html, body {
height: 100%;
}
@@ -222,6 +225,22 @@ ul.rack li.h41u { height: 820px; }
ul.rack li.h41u a, ul.rack li.h41u span { padding: 400px 0; }
ul.rack li.h42u { height: 840px; }
ul.rack li.h42u a, ul.rack li.h42u span { padding: 410px 0; }
ul.rack li.h43u { height: 860px; }
ul.rack li.h43u a, ul.rack li.h43u span { padding: 420px 0; }
ul.rack li.h44u { height: 880px; }
ul.rack li.h44u a, ul.rack li.h44u span { padding: 430px 0; }
ul.rack li.h45u { height: 900px; }
ul.rack li.h45u a, ul.rack li.h45u span { padding: 440px 0; }
ul.rack li.h46u { height: 920px; }
ul.rack li.h46u a, ul.rack li.h46u span { padding: 450px 0; }
ul.rack li.h47u { height: 940px; }
ul.rack li.h47u a, ul.rack li.h47u span { padding: 460px 0; }
ul.rack li.h48u { height: 960px; }
ul.rack li.h48u a, ul.rack li.h48u span { padding: 470px 0; }
ul.rack li.h49u { height: 980px; }
ul.rack li.h49u a, ul.rack li.h49u span { padding: 480px 0; }
ul.rack li.h50u { height: 1000px; }
ul.rack li.h50u a, ul.rack li.h50u span { padding: 490px 0; }
ul.rack li.occupied a {
color: #ffffff;
display: block;

View File

@@ -1,9 +1,15 @@
$(document).ready(function() {
// "Select all" checkbox in a table header
$('th input:checkbox').click(function (event) {
$('th input:checkbox[name=_all]').click(function (event) {
$(this).parents('table').find('td input:checkbox').prop('checked', $(this).prop('checked'));
});
// Uncheck the "select all" checkbox if an item is unchecked
$('input:checkbox[name=pk]').click(function (event) {
if (!$(this).attr('checked')) {
$(this).parents('table').find('input:checkbox[name=_all]').prop('checked', false);
}
});
// Slugify
function slugify(s, num_chars) {

View File

@@ -12,13 +12,19 @@
<div class="col-md-4 col-md-offset-4">
<div class="panel panel-danger" style="margin-top: 200px">
<div class="panel-heading">
<strong>Server Error</strong>
<strong>
<i class="glyphicon glyphicon-warning-sign"></i>
Server Error
</strong>
</div>
<div class="panel-body">
<p>There was a problem with your request. This error has been logged and administrative staff have
been notified. Please return to the home page and try again.</p>
<p>If you are responsible for this installation, please consider
<a href="https://github.com/digitalocean/netbox/issues">filing a bug report</a>.</p>
<a href="https://github.com/digitalocean/netbox/issues">filing a bug report</a>. Additional
information is provided below:</p>
<pre><strong>{{ exception }}</strong><br />
{{ error }}</pre>
<div class="text-right">
<a href="/" class="btn btn-primary">Home Page</a>
</div>

View File

@@ -110,7 +110,7 @@
{% endif %}
</ul>
</li>
<li class="dropdown{% if request.path|startswith:'/ipam/' and not request.path|startswith:'/ipam/vlans/' %} active{% endif %}">
<li class="dropdown{% if request.path|startswith:'/ipam/' and not request.path|startswith:'/ipam/vlan' %} active{% endif %}">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">IP Space <span class="caret"></span></a>
<ul class="dropdown-menu">
<li><a href="{% url 'ipam:ipaddress_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> IP Addresses</a></li>
@@ -156,17 +156,20 @@
{% endif %}
</ul>
</li>
<li class="dropdown{% if request.path|startswith:'/ipam/vlans/' %} active{% endif %}">
{% if perms.ipam.add_vlan %}
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">VLANs <span class="caret"></span></a>
<ul class="dropdown-menu">
<li><a href="{% url 'ipam:vlan_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> VLANs</a></li>
<li class="dropdown{% if request.path|startswith:'/ipam/vlan' %} active{% endif %}">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">VLANs <span class="caret"></span></a>
<ul class="dropdown-menu">
<li><a href="{% url 'ipam:vlan_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> VLANs</a></li>
{% if perms.ipam.add_vlan %}
<li><a href="{% url 'ipam:vlan_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add a VLAN</a></li>
<li><a href="{% url 'ipam:vlan_import' %}"><i class="glyphicon glyphicon-import" aria-hidden="true"></i> Import VLANs</a></li>
</ul>
{% else %}
<a href="{% url 'ipam:vlan_list' %}">VLANs</a>
{% endif %}
{% endif %}
<li class="divider"></li>
<li><a href="{% url 'ipam:vlangroup_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> VLAN Groups</a></li>
{% if perms.ipam.add_vlangroup %}
<li><a href="{% url 'ipam:vlangroup_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add a VLAN Group</a></li>
{% endif %}
</ul>
</li>
<li class="dropdown{% if request.path|startswith:'/circuits/' %} active{% endif %}">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Circuits <span class="caret"></span></a>

View File

@@ -14,8 +14,28 @@
</div>
<h1>Providers</h1>
<div class="row">
<div class="col-md-12">
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_edit_url='circuits:provider_bulk_edit' bulk_delete_url='circuits:provider_bulk_delete' %}
</div>
<div class="col-md-3">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Search</strong>
</div>
<div class="panel-body">
<form action="{% url 'circuits:provider_list' %}" method="get">
<div class="input-group">
<input type="text" name="q" class="form-control" placeholder="Name" {% if request.GET.q %}value="{{ request.GET.q }}" {% endif %}/>
<span class="input-group-btn">
<button type="submit" class="btn btn-primary">
<span class="glyphicon glyphicon-search" aria-hidden="true"></span>
</button>
</span>
</div>
</form>
</div>
</div>
{% include 'inc/filter_panel.html' %}
</div>
</div>
{% endblock %}

View File

@@ -5,7 +5,7 @@
{% block title %}Device Import{% endblock %}
{% block content %}
<h1>Device Import</h1>
{% include 'dcim/inc/_device_import_header.html' %}
<div class="row">
<div class="col-md-12">
<form action="." method="post" class="form">

View File

@@ -0,0 +1,75 @@
{% extends '_base.html' %}
{% load render_table from django_tables2 %}
{% load form_helpers %}
{% block title %}Device Import{% endblock %}
{% block content %}
{% include 'dcim/inc/_device_import_header.html' with active_tab='child_import' %}
<div class="row">
<div class="col-md-12">
<form action="." method="post" class="form">
{% csrf_token %}
{% render_form form %}
<div class="form-group">
<button type="submit" class="btn btn-primary">Submit</button>
<a href="{% url obj_list_url %}" class="btn btn-default">Cancel</a>
</div>
</form>
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>Name</td>
<td>Device name (optional)</td>
<td>Blade12</td>
</tr>
<tr>
<td>Device role</td>
<td>Functional role of device</td>
<td>Blade Server</td>
</tr>
<tr>
<td>Device manufacturer</td>
<td>Hardware manufacturer</td>
<td>Dell</td>
</tr>
<tr>
<td>Device model</td>
<td>Hardware model</td>
<td>BS2000T</td>
</tr>
<tr>
<td>Platform</td>
<td>Software running on device (optional)</td>
<td>Linux</td>
</tr>
<tr>
<td>Serial</td>
<td>Serial number (optional)</td>
<td>CAB00577291</td>
</tr>
<tr>
<td>Parent device</td>
<td>Parent device</td>
<td>Server101</td>
</tr>
<tr>
<td>Device bay</td>
<td>Device bay name</td>
<td>Slot 4</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>Blade12,Blade Server,Dell,BS2000T,Linux,CAB00577291,Server101,Slot4</pre>
</div>
</div>
{% endblock %}

View File

@@ -25,6 +25,7 @@
<div class="col-md-3">
<div class="panel panel-default">
<div class="panel-heading">
<span class="glyphicon glyphicon-search" aria-hidden="true"></span>
<strong>Search</strong>
</div>
<div class="panel-body">

View File

@@ -42,7 +42,7 @@
<table class="table table-hover panel-body">
<tr>
<td>Manufacturer</td>
<td>{{ devicetype.manufacturer }}</td>
<td><a href="{% url 'dcim:devicetype_list' %}?manufacturer={{ devicetype.manufacturer.slug }}">{{ devicetype.manufacturer }}</a></td>
</tr>
<tr>
<td>Model Name</td>
@@ -54,7 +54,13 @@
</tr>
<tr>
<td>Full Depth</td>
<td>{{ devicetype.is_full_depth|yesno|capfirst }}</td>
<td>
{% if devicetype.is_full_depth %}
<i class="glyphicon glyphicon-ok text-success" title="Yes"></i>
{% else %}
<i class="glyphicon glyphicon-remove text-danger" title="No"></i>
{% endif %}
</td>
</tr>
</table>
</div>
@@ -64,21 +70,70 @@
</div>
<table class="table table-hover panel-body">
<tr>
<td>Is a Console Server</td>
<td>{{ devicetype.is_console_server|yesno|capfirst }}</td>
<td class="text-right">
{% if devicetype.is_console_server %}
<i class="glyphicon glyphicon-ok text-success" title="Yes"></i>
{% else %}
<i class="glyphicon glyphicon-remove text-danger" title="No"></i>
{% endif %}
</td>
<td>
<strong>Console Server</strong><br />
<small class="text-muted">This device {% if devicetype.is_console_server %}has{% else %}does not have{% endif %} console server ports</small>
</td>
</tr>
<tr>
<td>Is a PDU</td>
<td>{{ devicetype.is_pdu|yesno|capfirst }}</td>
<td class="text-right">
{% if devicetype.is_pdu %}
<i class="glyphicon glyphicon-ok text-success" title="Yes"></i>
{% else %}
<i class="glyphicon glyphicon-remove text-danger" title="No"></i>
{% endif %}
</td>
<td>
<strong>PDU</strong><br />
<small class="text-muted">This device {% if devicetype.is_pdu %}has{% else %}does not have{% endif %} power outlets</small>
</td>
</tr>
<tr>
<td>Is a Network Device</td>
<td>{{ devicetype.is_network_device|yesno|capfirst }}</td>
<td class="text-right">
{% if devicetype.is_network_device %}
<i class="glyphicon glyphicon-ok text-success" title="Yes"></i>
{% else %}
<i class="glyphicon glyphicon-remove text-danger" title="No"></i>
{% endif %}
</td>
<td>
<strong>Network Device</strong><br />
<small class="text-muted">This device {% if devicetype.is_network_device %}has{% else %}does not have{% endif %} non-management network interfaces</small>
</td>
</tr>
<tr>
<td class="text-right">
{% if devicetype.subdevice_role == True %}
<label class="label label-primary">Parent</label>
{% elif devicetype.subdevice_role == False %}
<label class="label label-info">Child</label>
{% else %}
<label class="label label-default">None</label>
{% endif %}
</td>
<td>
<strong>Parent/Child</strong><br />
{% if devicetype.subdevice_role == True %}
<small class="text-muted">This device has device bays for mounting child devices</small>
{% elif devicetype.subdevice_role == False %}
<small class="text-muted">This device can only be mounted in a parent device</small>
{% else %}
<small class="text-muted">This device does not have device bays</small>
{% endif %}
</td>
</tr>
</table>
</div>
{% include 'dcim/inc/devicetype_component_table.html' with table=consoleport_table title='Console Ports' add_url='dcim:devicetype_add_consoleport' delete_url='dcim:devicetype_delete_consoleport' %}
{% include 'dcim/inc/devicetype_component_table.html' with table=powerport_table title='Power Ports' add_url='dcim:devicetype_add_powerport' delete_url='dcim:devicetype_delete_powerport' %}
{% include 'dcim/inc/devicetype_component_table.html' with table=mgmt_interface_table title='Management Interfaces' add_url='dcim:devicetype_add_interface' add_url_extra='?mgmt_only=1' delete_url='dcim:devicetype_delete_interface' %}
</div>
<div class="col-md-6">
{% if devicetype.is_parent_device %}

View File

@@ -0,0 +1,5 @@
<h1>Device Import</h1>
<ul class="nav nav-tabs" style="margin-bottom: 20px">
<li role="presentation"{% if not active_tab %} class="active"{% endif %}><a href="{% url 'dcim:device_import' %}">Racked Devices</a></li>
<li role="presentation"{% if active_tab == 'child_import' %} class="active"{% endif %}><a href="{% url 'dcim:device_import_child' %}">Child Devices</a></li>
</ul>

View File

@@ -4,7 +4,10 @@
{% csrf_token %}
<div class="panel panel-default">
<div class="panel-heading">
<a href="{% url add_url pk=devicetype.pk %}" class="btn btn-primary btn-xs pull-right"><span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add {{ title }}</a>
<a href="{% url add_url pk=devicetype.pk %}{{ add_url_extra }}" class="btn btn-primary btn-xs pull-right">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Add {{ title }}
</a>
<strong>{{ title }}</strong>
</div>
{% render_table table 'table.html' %}

View File

@@ -25,6 +25,7 @@
<div class="col-md-3">
<div class="panel panel-default">
<div class="panel-heading">
<span class="glyphicon glyphicon-search" aria-hidden="true"></span>
<strong>Search</strong>
</div>
<div class="panel-body">

View File

@@ -21,6 +21,7 @@
<div class="col-md-3">
<div class="panel panel-default">
<div class="panel-heading">
<span class="glyphicon glyphicon-search" aria-hidden="true"></span>
<strong>Search</strong>
</div>
<div class="panel-body">

View File

@@ -2,6 +2,7 @@
<div class="panel panel-default">
<div class="panel-heading">
<span class="glyphicon glyphicon-filter" aria-hidden="true"></span>
<strong>Filter</strong>
</div>
<div class="panel-body">

View File

@@ -26,6 +26,7 @@
<div class="col-md-3">
<div class="panel panel-default">
<div class="panel-heading">
<span class="glyphicon glyphicon-search" aria-hidden="true"></span>
<strong>Search</strong>
</div>
<div class="panel-body">

View File

@@ -43,6 +43,16 @@
<td>Name of assigned site (optional)</td>
<td>HQ</td>
</tr>
<tr>
<td>VLAN Group</td>
<td>Name of group for VLAN selection (optional)</td>
<td>Customers</td>
</tr>
<tr>
<td>VLAN ID</td>
<td>Numeric VLAN ID (optional)</td>
<td>801</td>
</tr>
<tr>
<td>Status</td>
<td>Current status</td>
@@ -61,7 +71,7 @@
</tbody>
</table>
<h4>Example</h4>
<pre>192.168.42.0/24,65000:123,HQ,Active,Customer,7th floor WiFi</pre>
<pre>192.168.42.0/24,65000:123,HQ,Customers,801,Active,Customer,7th floor WiFi</pre>
</div>
</div>
{% endblock %}

View File

@@ -26,6 +26,7 @@
<div class="col-md-3">
<div class="panel panel-default">
<div class="panel-heading">
<span class="glyphicon glyphicon-search" aria-hidden="true"></span>
<strong>Search</strong>
</div>
<div class="panel-body">

View File

@@ -51,6 +51,16 @@
<td>Site</td>
<td><a href="{% url 'dcim:site' slug=vlan.site.slug %}">{{ vlan.site }}</a></td>
</tr>
<tr>
<td>Group</td>
<td>
{% if vlan.group %}
<a href="{{ vlan.group.get_absolute_url }}">{{ vlan.group.name }}</a>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr>
<tr>
<td>VLAN ID</td>
<td>{{ vlan.vid }}</td>

View File

@@ -33,6 +33,11 @@
<td>Name of assigned site</td>
<td>LAS2</td>
</tr>
<tr>
<td>Group</td>
<td>Name of VLAN group (optional)</td>
<td>Backend Network</td>
</tr>
<tr>
<td>ID</td>
<td>Configured VLAN ID</td>
@@ -56,7 +61,7 @@
</tbody>
</table>
<h4>Example</h4>
<pre>LAS2,1400,Cameras,Active,Security</pre>
<pre>LAS2,Backend Network,1400,Cameras,Active,Security</pre>
</div>
</div>
{% endblock %}

View File

@@ -26,6 +26,7 @@
<div class="col-md-3">
<div class="panel panel-default">
<div class="panel-heading">
<span class="glyphicon glyphicon-search" aria-hidden="true"></span>
<strong>Search by ID</strong>
</div>
<div class="panel-body">

View File

@@ -0,0 +1,24 @@
{% extends '_base.html' %}
{% load helpers %}
{% block title %}VLAN Groups{% endblock %}
{% block content %}
<div class="pull-right">
{% if perms.ipam.add_vlangroup %}
<a href="{% url 'ipam:vlangroup_add' %}" class="btn btn-primary">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Add a VLAN group
</a>
{% endif %}
</div>
<h1>VLAN Groups</h1>
<div class="row">
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_delete_url='ipam:vlangroup_bulk_delete' %}
</div>
<div class="col-md-3">
{% include 'inc/filter_panel.html' %}
</div>
</div>
{% endblock %}

View File

@@ -30,6 +30,16 @@
<td>Route Distinguisher</td>
<td>{{ vrf.rd }}</td>
</tr>
<tr>
<td>Enforce Uniqueness</td>
<td>
{% if vrf.enforce_unique %}
<i class="glyphicon glyphicon-ok text-success" title="Yes"></i>
{% else %}
<i class="glyphicon glyphicon-remove text-danger" title="No"></i>
{% endif %}
</td>
</tr>
<tr>
<td>Description</td>
<td>

View File

@@ -38,6 +38,11 @@
<td>Route distinguisher</td>
<td>65000:123456</td>
</tr>
<tr>
<td>Enforce uniqueness</td>
<td>Prevent duplicate prefixes/IP addresses</td>
<td>True</td>
</tr>
<tr>
<td>Description</td>
<td>Short description (optional)</td>
@@ -46,7 +51,7 @@
</tbody>
</table>
<h4>Example</h4>
<pre>Customer_ABC,65000:123456,Native VRF for customer ABC</pre>
<pre>Customer_ABC,65000:123456,True,Native VRF for customer ABC</pre>
</div>
</div>
{% endblock %}