mirror of
https://github.com/netbox-community/netbox.git
synced 2026-02-05 16:39:32 +01:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2c3fea5b9 | ||
|
|
8ee083f7c1 | ||
|
|
9a9e3c1479 | ||
|
|
48b8602c3f | ||
|
|
e1fc78bc44 | ||
|
|
65fb10059a | ||
|
|
2e8211399d | ||
|
|
6fe40ef223 | ||
|
|
3f94295d7e | ||
|
|
5c59677c57 | ||
|
|
0bd2aa9289 | ||
|
|
19d7caf1da | ||
|
|
b8d7dd170e | ||
|
|
c643e3a74f | ||
|
|
2d690ca38a | ||
|
|
c65b9fcb0b | ||
|
|
4f6f032ca2 | ||
|
|
50d20650b4 | ||
|
|
783341017f | ||
|
|
c9dc6d04ef | ||
|
|
82ad479037 | ||
|
|
0d46a65a36 | ||
|
|
7a50cd2320 | ||
|
|
4f347d3428 | ||
|
|
d6c2fe2385 | ||
|
|
cb4643d810 | ||
|
|
d201dad535 | ||
|
|
32d8cf451a | ||
|
|
46da9866e3 | ||
|
|
534e6ac19e | ||
|
|
518af1b95c | ||
|
|
4f95ce4984 | ||
|
|
da10b34738 | ||
|
|
a9ab0a012f | ||
|
|
45a8ee7325 | ||
|
|
23451fe974 | ||
|
|
5def0e91d7 | ||
|
|
f301af5ecd | ||
|
|
dd62caf2f0 | ||
|
|
4a00971d44 |
@@ -12,8 +12,9 @@ possible that the bug has already been fixed.
|
||||
reported. If you think you may be experiencing a reported issue, please add a quick comment to it with a "+1" and a
|
||||
quick description of how it's affecting your installation.
|
||||
|
||||
* If you're unsure whether the behavior you're seeing is expected, you can join #netbox on irc.freenode.net and ask
|
||||
before going through the trouble of submitting an issue report.
|
||||
* If you're having trouble installing NetBox, please join #netbox on irc.freenode.net and ask for help before creating
|
||||
an issue on GitHub. Many installation problems are simple fixes. The issues list should be reserved for bug reports and
|
||||
feature requests.
|
||||
|
||||
* When submitting an issue, please be as descriptive as possible. Be sure to describe:
|
||||
|
||||
@@ -40,12 +41,15 @@ feature creep. For example, the following features would be firmly out of scope
|
||||
* Acting as a DNS server
|
||||
* Acting as an authentication server
|
||||
|
||||
* Feature requests must be very narrowly defined. The more effort you put into writing a feature request, the better its
|
||||
chances are of being implemented. Overly broad feature requests will be closed.
|
||||
|
||||
* If you're not sure whether the feature you want is a good fit for NetBox, please ask in #netbox on irc.freenode.net.
|
||||
Even if it's not quite right for NetBox, we may be able to point you to a tool better suited for the job.
|
||||
|
||||
* When submitting a feature request, be sure to include the following:
|
||||
|
||||
* A brief description of the functionality
|
||||
* A detailed description of the functionality
|
||||
* A use case for the feature; who would use it and what value it would add to NetBox
|
||||
* A rough description of any changes necessary to the database schema (if applicable)
|
||||
* Any third-party libraries or other resources which would be involved
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -197,17 +197,17 @@ class CircuitBulkDeleteForm(ConfirmationForm):
|
||||
|
||||
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]
|
||||
return [(t.slug, u'{} ({})'.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]
|
||||
return [(p.slug, u'{} ({})'.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]
|
||||
return [(s.slug, u'{} ({})'.format(s.name, s.circuit_count)) for s in site_choices]
|
||||
|
||||
|
||||
class CircuitFilterForm(forms.Form, BootstrapMixin):
|
||||
|
||||
@@ -80,7 +80,7 @@ class Circuit(CreatedUpdatedModel):
|
||||
unique_together = ['provider', 'cid']
|
||||
|
||||
def __unicode__(self):
|
||||
return "{0} {1}".format(self.provider, self.cid)
|
||||
return u'{} {}'.format(self.provider, self.cid)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('circuits:circuit', args=[self.pk])
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -61,7 +61,8 @@ urlpatterns = [
|
||||
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'),
|
||||
url(r'^interface-connections/$', InterfaceConnectionListView.as_view(), name='interfaceconnection_list'),
|
||||
url(r'^interface-connections/(?P<pk>\d+)/$', InterfaceConnectionView.as_view(), name='interfaceconnection_detail'),
|
||||
|
||||
# Miscellaneous
|
||||
url(r'^related-connections/$', RelatedConnectionsView.as_view(), name='related_connections'),
|
||||
|
||||
@@ -326,6 +326,14 @@ class InterfaceConnectionView(generics.RetrieveUpdateDestroyAPIView):
|
||||
queryset = InterfaceConnection.objects.all()
|
||||
|
||||
|
||||
class InterfaceConnectionListView(generics.ListAPIView):
|
||||
"""
|
||||
Retrieve a list of all interface connections
|
||||
"""
|
||||
serializer_class = serializers.InterfaceConnectionSerializer
|
||||
queryset = InterfaceConnection.objects.all()
|
||||
|
||||
|
||||
#
|
||||
# Device bays
|
||||
#
|
||||
|
||||
@@ -91,7 +91,7 @@ class RackGroupBulkDeleteForm(ConfirmationForm):
|
||||
|
||||
def rackgroup_site_choices():
|
||||
site_choices = Site.objects.annotate(rack_count=Count('rack_groups'))
|
||||
return [(s.slug, '{} ({})'.format(s.name, s.rack_count)) for s in site_choices]
|
||||
return [(s.slug, u'{} ({})'.format(s.name, s.rack_count)) for s in site_choices]
|
||||
|
||||
|
||||
class RackGroupFilterForm(forms.Form, BootstrapMixin):
|
||||
@@ -175,12 +175,12 @@ class RackBulkDeleteForm(ConfirmationForm):
|
||||
|
||||
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]
|
||||
return [(s.slug, u'{} ({})'.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.pk, '{} ({})'.format(g, g.rack_count)) for g in group_choices]
|
||||
return [(g.pk, u'{} ({})'.format(g, g.rack_count)) for g in group_choices]
|
||||
|
||||
|
||||
class RackFilterForm(forms.Form, BootstrapMixin):
|
||||
@@ -231,7 +231,7 @@ class DeviceTypeBulkDeleteForm(ConfirmationForm):
|
||||
|
||||
def devicetype_manufacturer_choices():
|
||||
manufacturer_choices = Manufacturer.objects.annotate(devicetype_count=Count('device_types'))
|
||||
return [(m.slug, '{} ({})'.format(m.name, m.devicetype_count)) for m in manufacturer_choices]
|
||||
return [(m.slug, u'{} ({})'.format(m.name, m.devicetype_count)) for m in manufacturer_choices]
|
||||
|
||||
|
||||
class DeviceTypeFilterForm(forms.Form, BootstrapMixin):
|
||||
@@ -373,10 +373,10 @@ class DeviceForm(forms.ModelForm, BootstrapMixin):
|
||||
for family in [4, 6]:
|
||||
ip_choices = []
|
||||
interface_ips = IPAddress.objects.filter(family=family, interface__device=self.instance)
|
||||
ip_choices += [(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips]
|
||||
ip_choices += [(ip.id, u'{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips]
|
||||
nat_ips = IPAddress.objects.filter(family=family, nat_inside__interface__device=self.instance)\
|
||||
.select_related('nat_inside__interface')
|
||||
ip_choices += [(ip.id, '{} ({} NAT)'.format(ip.address, ip.nat_inside.interface)) for ip in nat_ips]
|
||||
ip_choices += [(ip.id, u'{} ({} NAT)'.format(ip.address, ip.nat_inside.interface)) for ip in nat_ips]
|
||||
self.fields['primary_ip{}'.format(family)].choices = [(None, '---------')] + ip_choices
|
||||
|
||||
else:
|
||||
@@ -396,8 +396,8 @@ class DeviceForm(forms.ModelForm, BootstrapMixin):
|
||||
self.fields['rack'].choices = []
|
||||
|
||||
# Rack position
|
||||
pk = self.instance.pk if self.instance.pk else None
|
||||
try:
|
||||
pk = self.instance.pk if self.instance.pk else None
|
||||
if self.is_bound and self.data.get('rack') and str(self.data.get('face')):
|
||||
position_choices = Rack.objects.get(pk=self.data['rack'])\
|
||||
.get_rack_units(face=self.data.get('face'), exclude=pk)
|
||||
@@ -425,8 +425,13 @@ class DeviceForm(forms.ModelForm, BootstrapMixin):
|
||||
else:
|
||||
self.fields['device_type'].choices = []
|
||||
|
||||
# Disable rack assignment if this is a child device installed in a parent device
|
||||
if pk and self.instance.device_type.is_child_device and hasattr(self.instance, 'parent_bay'):
|
||||
self.fields['site'].disabled = True
|
||||
self.fields['rack'].disabled = True
|
||||
|
||||
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 +439,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 +456,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 +484,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')
|
||||
@@ -499,27 +548,27 @@ class DeviceBulkDeleteForm(ConfirmationForm):
|
||||
|
||||
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]
|
||||
return [(s.slug, u'{} ({})'.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]
|
||||
return [(g.pk, u'{} ({})'.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]
|
||||
return [(r.slug, u'{} ({})'.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.pk, '{} ({})'.format(t, t.device_count)) for t in type_choices]
|
||||
return [(t.pk, u'{} ({})'.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]
|
||||
return [(p.slug, u'{} ({})'.format(p.name, p.device_count)) for p in platform_choices]
|
||||
|
||||
|
||||
class DeviceFilterForm(forms.Form, BootstrapMixin):
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -1,7 +1,7 @@
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.exceptions import MultipleObjectsReturned, ValidationError
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models
|
||||
@@ -9,10 +9,12 @@ from django.db.models import Count, Q, ObjectDoesNotExist
|
||||
|
||||
from extras.rpc import RPC_CLIENTS
|
||||
from utilities.fields import NullableCharField
|
||||
from utilities.managers import NaturalOrderByManager
|
||||
from utilities.models import CreatedUpdatedModel
|
||||
|
||||
from .fields import ASNField, MACAddressField
|
||||
|
||||
|
||||
RACK_FACE_FRONT = 0
|
||||
RACK_FACE_REAR = 1
|
||||
RACK_FACE_CHOICES = [
|
||||
@@ -137,6 +139,12 @@ def order_interfaces(queryset, sql_col, primary_ordering=tuple()):
|
||||
}).order_by(*ordering)
|
||||
|
||||
|
||||
class SiteManager(NaturalOrderByManager):
|
||||
|
||||
def get_queryset(self):
|
||||
return self.natural_order_by('name')
|
||||
|
||||
|
||||
class Site(CreatedUpdatedModel):
|
||||
"""
|
||||
A Site represents a geographic location within a network; typically a building or campus. The optional facility
|
||||
@@ -150,6 +158,8 @@ class Site(CreatedUpdatedModel):
|
||||
shipping_address = models.CharField(max_length=200, blank=True)
|
||||
comments = models.TextField(blank=True)
|
||||
|
||||
objects = SiteManager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
||||
@@ -206,12 +216,18 @@ class RackGroup(models.Model):
|
||||
]
|
||||
|
||||
def __unicode__(self):
|
||||
return '{} - {}'.format(self.site.name, self.name)
|
||||
return u'{} - {}'.format(self.site.name, self.name)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk)
|
||||
|
||||
|
||||
class RackManager(NaturalOrderByManager):
|
||||
|
||||
def get_queryset(self):
|
||||
return self.natural_order_by('site__name', 'name')
|
||||
|
||||
|
||||
class Rack(CreatedUpdatedModel):
|
||||
"""
|
||||
Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
|
||||
@@ -224,6 +240,8 @@ class Rack(CreatedUpdatedModel):
|
||||
u_height = models.PositiveSmallIntegerField(default=42, verbose_name='Height (U)')
|
||||
comments = models.TextField(blank=True)
|
||||
|
||||
objects = RackManager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['site', 'name']
|
||||
unique_together = [
|
||||
@@ -342,6 +360,15 @@ class Rack(CreatedUpdatedModel):
|
||||
def get_0u_devices(self):
|
||||
return self.devices.filter(position=0)
|
||||
|
||||
def get_utilization(self):
|
||||
"""
|
||||
Determine the utilization rate of the rack and return it as a percentage.
|
||||
"""
|
||||
if self.u_consumed is None:
|
||||
self.u_consumed = 0
|
||||
u_available = self.u_height - self.u_consumed
|
||||
return int(float(self.u_height - u_available) / self.u_height * 100)
|
||||
|
||||
|
||||
#
|
||||
# Device Types
|
||||
@@ -404,7 +431,7 @@ class DeviceType(models.Model):
|
||||
]
|
||||
|
||||
def __unicode__(self):
|
||||
return "{} {}".format(self.manufacturer, self.model)
|
||||
return u'{} {}'.format(self.manufacturer, self.model)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:devicetype', args=[self.pk])
|
||||
@@ -583,6 +610,12 @@ class Platform(models.Model):
|
||||
return "{}?platform={}".format(reverse('dcim:device_list'), self.slug)
|
||||
|
||||
|
||||
class DeviceManager(NaturalOrderByManager):
|
||||
|
||||
def get_queryset(self):
|
||||
return self.natural_order_by('name')
|
||||
|
||||
|
||||
class Device(CreatedUpdatedModel):
|
||||
"""
|
||||
A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType,
|
||||
@@ -612,6 +645,8 @@ class Device(CreatedUpdatedModel):
|
||||
blank=True, null=True, verbose_name='Primary IPv6')
|
||||
comments = models.TextField(blank=True)
|
||||
|
||||
objects = DeviceManager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
unique_together = ['rack', 'position', 'face']
|
||||
@@ -624,6 +659,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 +672,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 +716,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 '',
|
||||
@@ -918,8 +957,8 @@ class Interface(models.Model):
|
||||
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))
|
||||
except InterfaceConnection.MultipleObjectsReturned:
|
||||
raise MultipleObjectsReturned("Multiple connections found for {} interface {}!".format(self.device, self))
|
||||
|
||||
|
||||
class InterfaceConnection(models.Model):
|
||||
@@ -953,14 +992,15 @@ 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']
|
||||
unique_together = ['device', 'name']
|
||||
|
||||
def __unicode__(self):
|
||||
return '{} - {}'.format(self.device.name, self.name)
|
||||
return u'{} - {}'.format(self.device.name, self.name)
|
||||
|
||||
def clean(self):
|
||||
|
||||
|
||||
@@ -48,6 +48,11 @@ STATUS_ICON = """
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
UTILIZATION_GRAPH = """
|
||||
{% load helpers %}
|
||||
{% utilization_graph record.get_utilization %}
|
||||
"""
|
||||
|
||||
|
||||
#
|
||||
# Sites
|
||||
@@ -97,6 +102,8 @@ class RackTable(BaseTable):
|
||||
group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
|
||||
facility_id = tables.Column(verbose_name='Facility ID')
|
||||
u_height = tables.Column(verbose_name='Height (U)')
|
||||
u_consumed = tables.Column(accessor=Accessor('u_consumed'), verbose_name='Used (U)')
|
||||
utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
|
||||
devices = tables.Column(accessor=Accessor('device_count'), verbose_name='Devices')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -7,7 +7,7 @@ from django.contrib.auth.decorators import permission_required
|
||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.db.models import Count, ProtectedError
|
||||
from django.db.models import Count, ProtectedError, Sum
|
||||
from django.forms import ModelMultipleChoiceField, MultipleHiddenInput
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
@@ -144,7 +144,7 @@ class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
#
|
||||
|
||||
class RackListView(ObjectListView):
|
||||
queryset = Rack.objects.select_related('site').annotate(device_count=Count('devices', distinct=True))
|
||||
queryset = Rack.objects.select_related('site').prefetch_related('devices__device_type').annotate(device_count=Count('devices', distinct=True), u_consumed=Sum('devices__device_type__u_height'))
|
||||
filter = filters.RackFilter
|
||||
filter_form = forms.RackFilterForm
|
||||
table = tables.RackTable
|
||||
@@ -609,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
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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):
|
||||
@@ -112,7 +112,7 @@ class AggregateBulkDeleteForm(ConfirmationForm):
|
||||
|
||||
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]
|
||||
return [(r.slug, u'{} ({})'.format(r.name, r.aggregate_count)) for r in rir_choices]
|
||||
|
||||
|
||||
class AggregateFilterForm(forms.Form, 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)
|
||||
@@ -236,19 +266,19 @@ def prefix_vrf_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]
|
||||
return [(s.slug, u'{} ({})'.format(s.name, s.prefix_count)) for s in site_choices]
|
||||
|
||||
|
||||
def prefix_status_choices():
|
||||
status_counts = {}
|
||||
for status in Prefix.objects.values('status').annotate(count=Count('status')).order_by('status'):
|
||||
status_counts[status['status']] = status['count']
|
||||
return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in PREFIX_STATUS_CHOICES]
|
||||
return [(s[0], u'{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in PREFIX_STATUS_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]
|
||||
return [(r.slug, u'{} ({})'.format(r.name, r.prefix_count)) for r in role_choices]
|
||||
|
||||
|
||||
class PrefixFilterForm(forms.Form, BootstrapMixin):
|
||||
@@ -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, u'{} ({})'.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)
|
||||
@@ -452,6 +529,7 @@ class VLANImportForm(BulkImportForm, BootstrapMixin):
|
||||
class VLANBulkEditForm(forms.Form, BootstrapMixin):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=VLAN.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False)
|
||||
group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False)
|
||||
status = forms.ChoiceField(choices=FORM_VLAN_STATUS_CHOICES, required=False)
|
||||
role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False)
|
||||
|
||||
@@ -462,24 +540,31 @@ class VLANBulkDeleteForm(ConfirmationForm):
|
||||
|
||||
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]
|
||||
return [(s.slug, u'{} ({})'.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, u'{} ({})'.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'):
|
||||
status_counts[status['status']] = status['count']
|
||||
return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in VLAN_STATUS_CHOICES]
|
||||
return [(s[0], u'{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in VLAN_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]
|
||||
return [(r.slug, u'{} ({})'.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}))
|
||||
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}))
|
||||
|
||||
20
netbox/ipam/migrations/0002_vrf_add_enforce_unique.py
Normal file
20
netbox/ipam/migrations/0002_vrf_add_enforce_unique.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
38
netbox/ipam/migrations/0003_ipam_add_vlangroups.py
Normal file
38
netbox/ipam/migrations/0003_ipam_add_vlangroups.py
Normal 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')]),
|
||||
),
|
||||
]
|
||||
27
netbox/ipam/migrations/0004_ipam_vlangroup_uniqueness.py
Normal file
27
netbox/ipam/migrations/0004_ipam_vlangroup_uniqueness.py
Normal 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')]),
|
||||
),
|
||||
]
|
||||
@@ -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 u'{} - {}'.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,
|
||||
@@ -377,7 +442,7 @@ class VLAN(CreatedUpdatedModel):
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
return u"{} ({})".format(self.vid, self.name)
|
||||
return u'{} ({})'.format(self.vid, self.name)
|
||||
|
||||
def get_status_class(self):
|
||||
return STATUS_CHOICE_CLASSES[self.status]
|
||||
|
||||
@@ -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 = """
|
||||
@@ -11,15 +11,8 @@ RIR_EDIT_LINK = """
|
||||
"""
|
||||
|
||||
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 %}
|
||||
{% load helpers %}
|
||||
{% utilization_graph record.get_utilization %}
|
||||
"""
|
||||
|
||||
ROLE_EDIT_LINK = """
|
||||
@@ -50,6 +43,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 +176,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 +201,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')
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
#
|
||||
@@ -538,7 +565,7 @@ class VLANBulkEditView(PermissionRequiredMixin, BulkEditView):
|
||||
def update_objects(self, pk_list, form):
|
||||
|
||||
fields_to_update = {}
|
||||
for field in ['site', 'status', 'role']:
|
||||
for field in ['site', 'group', 'status', 'role']:
|
||||
if form.cleaned_data[field]:
|
||||
fields_to_update[field] = form.cleaned_data[field]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,7 +12,7 @@ except ImportError:
|
||||
"the documentation.")
|
||||
|
||||
|
||||
VERSION = '1.2.3-dev'
|
||||
VERSION = '1.3.1'
|
||||
|
||||
# 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -225,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;
|
||||
|
||||
@@ -28,6 +28,7 @@ class SecretRoleListView(generics.ListAPIView):
|
||||
"""
|
||||
queryset = SecretRole.objects.all()
|
||||
serializer_class = serializers.SecretRoleSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
|
||||
class SecretRoleDetailView(generics.RetrieveAPIView):
|
||||
@@ -36,6 +37,7 @@ class SecretRoleDetailView(generics.RetrieveAPIView):
|
||||
"""
|
||||
queryset = SecretRole.objects.all()
|
||||
serializer_class = serializers.SecretRoleSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
|
||||
class SecretListView(generics.GenericAPIView):
|
||||
@@ -47,6 +49,7 @@ class SecretListView(generics.GenericAPIView):
|
||||
serializer_class = serializers.SecretSerializer
|
||||
filter_class = SecretFilter
|
||||
renderer_classes = [FormlessBrowsableAPIRenderer, JSONRenderer, FreeRADIUSClientsRenderer]
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request, private_key=None):
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
@@ -91,6 +94,7 @@ class SecretDetailView(generics.GenericAPIView):
|
||||
.prefetch_related('role__users', 'role__groups')
|
||||
serializer_class = serializers.SecretSerializer
|
||||
renderer_classes = [FormlessBrowsableAPIRenderer, JSONRenderer, FreeRADIUSClientsRenderer]
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request, pk, private_key=None):
|
||||
secret = get_object_or_404(Secret, pk=pk)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import django_filters
|
||||
|
||||
from .models import Secret, SecretRole
|
||||
from dcim.models import Device
|
||||
|
||||
|
||||
class SecretFilter(django_filters.FilterSet):
|
||||
@@ -15,7 +16,13 @@ class SecretFilter(django_filters.FilterSet):
|
||||
to_field_name='slug',
|
||||
label='Role (slug)',
|
||||
)
|
||||
device = django_filters.ModelMultipleChoiceFilter(
|
||||
name='device',
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name',
|
||||
label='Device (Name)',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Secret
|
||||
fields = ['name', 'role_id', 'role']
|
||||
fields = ['name', 'role_id', 'role', 'device']
|
||||
|
||||
@@ -103,7 +103,7 @@ class SecretBulkDeleteForm(ConfirmationForm):
|
||||
|
||||
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]
|
||||
return [(r.slug, u'{} ({})'.format(r.name, r.secret_count)) for r in role_choices]
|
||||
|
||||
|
||||
class SecretFilterForm(forms.Form, BootstrapMixin):
|
||||
|
||||
@@ -219,8 +219,8 @@ class Secret(CreatedUpdatedModel):
|
||||
|
||||
def __unicode__(self):
|
||||
if self.role and self.device:
|
||||
return "{} for {}".format(self.role, self.device)
|
||||
return "Secret"
|
||||
return u'{} for {}'.format(self.role, self.device)
|
||||
return u'Secret'
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('secrets:secret', args=[self.pk])
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -22,8 +22,32 @@
|
||||
<div class="panel-body">
|
||||
{% render_field form.site %}
|
||||
{% render_field form.rack %}
|
||||
{% render_field form.face %}
|
||||
{% render_field form.position %}
|
||||
{% if obj.device_type.is_child_device and obj.parent_bay %}
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label">Parent device</label>
|
||||
<div class="col-md-9">
|
||||
<p class="form-control-static">
|
||||
<a href="{% url 'dcim:device' pk=obj.parent_bay.device.pk %}">{{ obj.parent_bay.device }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label">Parent bay</label>
|
||||
<div class="col-md-9">
|
||||
<p class="form-control-static">
|
||||
{{ obj.parent_bay.name }}
|
||||
{% if perms.dcim.change_devicebay %}
|
||||
<a href="{% url 'dcim:devicebay_depopulate' pk=obj.parent_bay.pk %}" class="btn btn-danger btn-xs">
|
||||
<i class="glyphicon glyphicon-remove" aria-hidden="true" title="Remove device"></i> Remove
|
||||
</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% elif not obj.device_type.is_child_device %}
|
||||
{% render_field form.face %}
|
||||
{% render_field form.position %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
|
||||
@@ -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">
|
||||
|
||||
75
netbox/templates/dcim/device_import_child.html
Normal file
75
netbox/templates/dcim/device_import_child.html
Normal 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 %}
|
||||
5
netbox/templates/dcim/inc/_device_import_header.html
Normal file
5
netbox/templates/dcim/inc/_device_import_header.html
Normal 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>
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
24
netbox/templates/ipam/vlangroup_list.html
Normal file
24
netbox/templates/ipam/vlangroup_list.html
Normal 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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<div class="progress text-center">
|
||||
{% if utilization < 30 %}<span style="font-size: 12px;">{{ utilization }}%</span>{% endif %}
|
||||
<div class="progress-bar progress-bar-{% if utilization >= danger_threshold %}danger{% elif utilization >= warning_threshold %}warning{% else %}success{% endif %}"
|
||||
role="progressbar" aria-valuenow="{{ utilization }}" aria-valuemin="0" aria-valuemax="100" style="width: {{ utilization }}%">
|
||||
{% if utilization >= 30 %}{{ utilization }}%{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -60,7 +60,7 @@ class SelectWithDisabled(forms.Select):
|
||||
option_label = option_label['label']
|
||||
disabled_html = ' disabled="disabled"' if option_disabled else ''
|
||||
|
||||
return format_html('<option value="{}"{}{}>{}</option>',
|
||||
return format_html(u'<option value="{}"{}{}>{}</option>',
|
||||
option_value,
|
||||
selected_html,
|
||||
disabled_html,
|
||||
|
||||
30
netbox/utilities/managers.py
Normal file
30
netbox/utilities/managers.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from django.db.models import Manager
|
||||
|
||||
|
||||
class NaturalOrderByManager(Manager):
|
||||
|
||||
def natural_order_by(self, *fields):
|
||||
"""
|
||||
Attempt to order records naturally by segmenting a field into three parts:
|
||||
|
||||
1. Leading integer (if any)
|
||||
2. Middle portion
|
||||
3. Trailing integer (if any)
|
||||
|
||||
:param fields: The fields on which to order the queryset. The last field in the list will be ordered naturally.
|
||||
"""
|
||||
db_table = self.model._meta.db_table
|
||||
primary_field = fields[-1]
|
||||
|
||||
id1 = '_{}_{}1'.format(db_table, primary_field)
|
||||
id2 = '_{}_{}2'.format(db_table, primary_field)
|
||||
id3 = '_{}_{}3'.format(db_table, primary_field)
|
||||
|
||||
queryset = super(NaturalOrderByManager, self).get_queryset().extra(select={
|
||||
id1: "CAST(SUBSTRING({}.{} FROM '^(\d+)') AS integer)".format(db_table, primary_field),
|
||||
id2: "SUBSTRING({}.{} FROM '^\d*(.*?)\d*$')".format(db_table, primary_field),
|
||||
id3: "CAST(SUBSTRING({}.{} FROM '(\d+)$') AS integer)".format(db_table, primary_field),
|
||||
})
|
||||
ordering = fields[0:-1] + (id1, id2, id3)
|
||||
|
||||
return queryset.order_by(*ordering)
|
||||
@@ -95,3 +95,15 @@ def querystring_toggle(request, multi=True, page_key='page', **kwargs):
|
||||
return '?' + querystring
|
||||
else:
|
||||
return ''
|
||||
|
||||
|
||||
@register.inclusion_tag('utilities/templatetags/utilization_graph.html')
|
||||
def utilization_graph(utilization, warning_threshold=75, danger_threshold=90):
|
||||
"""
|
||||
Display a horizontal bar graph indicating a percentage of utilization.
|
||||
"""
|
||||
return {
|
||||
'utilization': utilization,
|
||||
'warning_threshold': warning_threshold,
|
||||
'danger_threshold': danger_threshold,
|
||||
}
|
||||
|
||||
@@ -134,12 +134,12 @@ class ObjectEditView(View):
|
||||
obj_created = not obj.pk
|
||||
obj.save()
|
||||
|
||||
msg = 'Created ' if obj_created else 'Modified '
|
||||
msg = u'Created ' if obj_created else u'Modified '
|
||||
msg += self.model._meta.verbose_name
|
||||
if hasattr(obj, 'get_absolute_url'):
|
||||
msg = '{} <a href="{}">{}</a>'.format(msg, obj.get_absolute_url(), obj)
|
||||
msg = u'{} <a href="{}">{}</a>'.format(msg, obj.get_absolute_url(), obj)
|
||||
else:
|
||||
msg = '{} {}'.format(msg, obj)
|
||||
msg = u'{} {}'.format(msg, obj)
|
||||
messages.success(request, msg)
|
||||
if obj_created:
|
||||
UserAction.objects.log_create(request.user, obj, msg)
|
||||
@@ -192,7 +192,7 @@ class ObjectDeleteView(View):
|
||||
if form.is_valid():
|
||||
try:
|
||||
obj.delete()
|
||||
msg = 'Deleted {} {}'.format(self.model._meta.verbose_name, obj)
|
||||
msg = u'Deleted {} {}'.format(self.model._meta.verbose_name, obj)
|
||||
messages.success(request, msg)
|
||||
UserAction.objects.log_delete(request.user, obj, msg)
|
||||
return redirect(self.redirect_url)
|
||||
@@ -234,7 +234,7 @@ class BulkImportView(View):
|
||||
|
||||
obj_table = self.table(new_objs)
|
||||
if new_objs:
|
||||
msg = 'Imported {} {}'.format(len(new_objs), new_objs[0]._meta.verbose_name_plural)
|
||||
msg = u'Imported {} {}'.format(len(new_objs), new_objs[0]._meta.verbose_name_plural)
|
||||
messages.success(request, msg)
|
||||
UserAction.objects.log_import(request.user, ContentType.objects.get_for_model(new_objs[0]), msg)
|
||||
|
||||
@@ -281,7 +281,7 @@ class BulkEditView(View):
|
||||
if form.is_valid():
|
||||
updated_count = self.update_objects(pk_list, form)
|
||||
if updated_count:
|
||||
msg = 'Updated {} {}'.format(updated_count, self.cls._meta.verbose_name_plural)
|
||||
msg = u'Updated {} {}'.format(updated_count, self.cls._meta.verbose_name_plural)
|
||||
messages.success(self.request, msg)
|
||||
UserAction.objects.log_bulk_edit(request.user, ContentType.objects.get_for_model(self.cls), msg)
|
||||
return redirect(redirect_url)
|
||||
@@ -345,7 +345,7 @@ class BulkDeleteView(View):
|
||||
handle_protectederror(list(queryset), request, e)
|
||||
return redirect(redirect_url)
|
||||
|
||||
msg = 'Deleted {} {}'.format(deleted_count, self.cls._meta.verbose_name_plural)
|
||||
msg = u'Deleted {} {}'.format(deleted_count, self.cls._meta.verbose_name_plural)
|
||||
messages.success(request, msg)
|
||||
UserAction.objects.log_bulk_delete(request.user, ContentType.objects.get_for_model(self.cls), msg)
|
||||
return redirect(redirect_url)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
cryptography==1.4
|
||||
Django==1.9.7
|
||||
Django==1.9.8
|
||||
django-debug-toolbar==1.4
|
||||
django-filter==0.13.0
|
||||
django-rest-swagger==0.3.7
|
||||
|
||||
Reference in New Issue
Block a user