1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

Merge pull request #1537 from digitalocean/develop

Release v2.1.5
This commit is contained in:
Jeremy Stretch
2017-09-25 14:52:43 -04:00
committed by GitHub
24 changed files with 382 additions and 137 deletions

View File

@ -4,7 +4,8 @@ Supported HTTP methods:
* `GET`: Retrieve an object or list of objects * `GET`: Retrieve an object or list of objects
* `POST`: Create a new object * `POST`: Create a new object
* `PUT`: Update an existing object * `PUT`: Update an existing object, all mandatory fields must be specified
* `PATCH`: Updates an existing object, only specifiying the field to be changed
* `DELETE`: Delete an existing object * `DELETE`: Delete an existing object
To authenticate a request, attach your token in an `Authorization` header: To authenticate a request, attach your token in an `Authorization` header:
@ -104,12 +105,19 @@ $ curl -X POST -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0
### Modify an existing site ### Modify an existing site
Make an authenticated `PUT` request to the site detail endpoint. As with a create (POST) request, all mandatory fields must be included. Make an authenticated `PUT` request to the site detail endpoint. As with a create (`POST`) request, all mandatory fields must be included.
``` ```
$ curl -X PUT -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0" -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/dcim/sites/16/ --data '{"name": "Renamed Site", "slug": "renamed-site"}' $ curl -X PUT -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0" -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/dcim/sites/16/ --data '{"name": "Renamed Site", "slug": "renamed-site"}'
``` ```
### Modify an object by changing a field
Make an authenticated `PATCH` request to the device endpoint. With `PATCH`, unlike `POST` and `PUT`, we only specify the field that is being changed. In this example, we add a serial number to a device.
```
$ curl -X PATCH -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0" -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/dcim/devices/2549/ --data '{"serial": "FTX1123A090"}'
```
### Delete an existing site ### Delete an existing site
Send an authenticated `DELETE` request to the site detail endpoint. Send an authenticated `DELETE` request to the site detail endpoint.

View File

@ -6,7 +6,7 @@ REST stands for [representational state transfer](https://en.wikipedia.org/wiki/
* `GET`: Retrieve an object or list of objects * `GET`: Retrieve an object or list of objects
* `POST`: Create an object * `POST`: Create an object
* `PUT` / `PATCH`: Modify an existing object * `PUT` / `PATCH`: Modify an existing object. `PUT` requires all mandatory fields to be specified, while `PATCH` only expects the field that is being modified to be specified.
* `DELETE`: Delete an existing object * `DELETE`: Delete an existing object
The NetBox API represents all objects in [JavaScript Object Notation (JSON)](http://www.json.org/). This makes it very easy to interact with NetBox data on the command line with common tools. For example, we can request an IP address from NetBox and output the JSON using `curl` and `jq`. (Piping the output through `jq` isn't strictly required but makes it much easier to read.) The NetBox API represents all objects in [JavaScript Object Notation (JSON)](http://www.json.org/). This makes it very easy to interact with NetBox data on the command line with common tools. For example, we can request an IP address from NetBox and output the JSON using `curl` and `jq`. (Piping the output through `jq` isn't strictly required but makes it much easier to read.)

View File

@ -17,7 +17,7 @@ ADMINS = [
## BANNER_BOTTOM ## BANNER_BOTTOM
Setting these variables will display content in a banner at the top and/or bottom of the page, respectively. To replicate the content of the top banner in the bottom banner, set: Setting these variables will display content in a banner at the top and/or bottom of the page, respectively. HTML is allowed. To replicate the content of the top banner in the bottom banner, set:
``` ```
BANNER_TOP = 'Your banner text' BANNER_TOP = 'Your banner text'
@ -26,6 +26,12 @@ BANNER_BOTTOM = BANNER_TOP
--- ---
## BANNER_LOGIN
The value of this variable will be displayed on the login page above the login form. HTML is allowed.
---
## BASE_PATH ## BASE_PATH
Default: None Default: None

View File

@ -78,6 +78,8 @@ AUTH_LDAP_USER_ATTR_MAP = {
``` ```
# User Groups for Permissions # User Groups for Permissions
!!! Info
When using Microsoft Active Directory, Support for nested Groups can be activated by using `GroupOfNamesType()` instead of `NestedGroupOfNamesType()` for AUTH_LDAP_GROUP_TYPE.
```python ```python
from django_auth_ldap.config import LDAPSearch, GroupOfNamesType from django_auth_ldap.config import LDAPSearch, GroupOfNamesType

View File

@ -20,7 +20,7 @@ Python 3:
```no-highlight ```no-highlight
# yum install -y epel-release # yum install -y epel-release
# yum install -y gcc python34 python34-devel python34-setuptools libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel # yum install -y gcc python34 python34-devel python34-setuptools libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel redhat-rpm-config
# easy_install-3.4 pip # easy_install-3.4 pip
# ln -s -f python3.4 /usr/bin/python # ln -s -f python3.4 /usr/bin/python
``` ```
@ -29,7 +29,7 @@ Python 2:
```no-highlight ```no-highlight
# yum install -y epel-release # yum install -y epel-release
# yum install -y gcc python2 python-devel python-pip libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel # yum install -y gcc python2 python-devel python-pip libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel redhat-rpm-config
``` ```
You may opt to install NetBox either from a numbered release or by cloning the master branch of its repository on GitHub. You may opt to install NetBox either from a numbered release or by cloning the master branch of its repository on GitHub.

View File

@ -12,7 +12,7 @@ from ipam.models import IPAddress
from tenancy.forms import TenancyForm from tenancy.forms import TenancyForm
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import ( from utilities.forms import (
APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, APISelect, ArrayFieldSelectMultiple, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ConfirmationForm, CSVChoiceField, ExpandableNameField, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ConfirmationForm, CSVChoiceField, ExpandableNameField,
FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField,
FilterTreeNodeMultipleChoiceField, FilterTreeNodeMultipleChoiceField,
@ -28,12 +28,6 @@ from .models import (
) )
FORM_STATUS_CHOICES = [
['', '---------'],
]
FORM_STATUS_CHOICES += STATUS_CHOICES
DEVICE_BY_PK_RE = '{\d+\}' DEVICE_BY_PK_RE = '{\d+\}'
@ -642,13 +636,28 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
# Compile list of choices for primary IPv4 and IPv6 addresses # Compile list of choices for primary IPv4 and IPv6 addresses
for family in [4, 6]: for family in [4, 6]:
ip_choices = [] ip_choices = [(None, '---------')]
interface_ips = IPAddress.objects.filter(family=family, interface__device=self.instance) # Collect interface IPs
ip_choices += [(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips] interface_ips = IPAddress.objects.select_related('interface').filter(
nat_ips = IPAddress.objects.filter(family=family, nat_inside__interface__device=self.instance)\ family=family, 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] if interface_ips:
self.fields['primary_ip{}'.format(family)].choices = [(None, '---------')] + ip_choices ip_choices.append(
('Interface IPs', [
(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips
])
)
# Collect NAT IPs
nat_ips = IPAddress.objects.select_related('nat_inside').filter(
family=family, nat_inside__interface__device=self.instance
)
if nat_ips:
ip_choices.append(
('NAT IPs', [
(ip.id, '{} ({})'.format(ip.address, ip.nat_inside.address)) for ip in nat_ips
])
)
self.fields['primary_ip{}'.format(family)].choices = ip_choices
# If editing an existing device, exclude it from the list of occupied rack units. This ensures that a device # If editing an existing device, exclude it from the list of occupied rack units. This ensures that a device
# can be flipped from one face to another. # can be flipped from one face to another.
@ -848,7 +857,7 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), required=False, label='Role') device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), required=False, label='Role')
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
platform = forms.ModelChoiceField(queryset=Platform.objects.all(), required=False) platform = forms.ModelChoiceField(queryset=Platform.objects.all(), required=False)
status = forms.ChoiceField(choices=FORM_STATUS_CHOICES, required=False, initial='', label='Status') status = forms.ChoiceField(choices=add_blank_choice(STATUS_CHOICES), required=False, initial='')
serial = forms.CharField(max_length=50, required=False, label='Serial Number') serial = forms.CharField(max_length=50, required=False, label='Serial Number')
class Meta: class Meta:

View File

@ -13,6 +13,7 @@ from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from django.db.models import Count, Q, ObjectDoesNotExist from django.db.models import Count, Q, ObjectDoesNotExist
from django.db.models.expressions import RawSQL
from django.urls import reverse from django.urls import reverse
from django.utils.encoding import python_2_unicode_compatible from django.utils.encoding import python_2_unicode_compatible
@ -642,15 +643,16 @@ class InterfaceQuerySet(models.QuerySet):
To order interfaces naturally, the `name` field is split into six distinct components: leading text (type), To order interfaces naturally, the `name` field is split into six distinct components: leading text (type),
slot, subslot, position, channel, and virtual circuit: slot, subslot, position, channel, and virtual circuit:
{type}{slot}/{subslot}/{position}:{channel}.{vc} {type}{slot}/{subslot}/{position}/{subposition}:{channel}.{vc}
Components absent from the interface name are ignored. For example, an interface named GigabitEthernet0/1 would Components absent from the interface name are ignored. For example, an interface named GigabitEthernet1/2/3
be parsed as follows: would be parsed as follows:
name = 'GigabitEthernet' name = 'GigabitEthernet'
slot = None slot = 1
subslot = 0 subslot = 2
position = 1 position = 3
subposition = 0
channel = None channel = None
vc = 0 vc = 0
@ -659,17 +661,35 @@ class InterfaceQuerySet(models.QuerySet):
""" """
sql_col = '{}.name'.format(self.model._meta.db_table) sql_col = '{}.name'.format(self.model._meta.db_table)
ordering = { ordering = {
IFACE_ORDERING_POSITION: ('_slot', '_subslot', '_position', '_channel', '_vc', '_type', 'name'), IFACE_ORDERING_POSITION: (
IFACE_ORDERING_NAME: ('_type', '_slot', '_subslot', '_position', '_channel', '_vc', 'name'), '_slot', '_subslot', '_position', '_subposition', '_channel', '_vc', '_type', '_id', 'name',
),
IFACE_ORDERING_NAME: (
'_type', '_slot', '_subslot', '_position', '_subposition', '_channel', '_vc', '_id', 'name',
),
}[method] }[method]
return self.extra(select={
'_type': "SUBSTRING({} FROM '^([^0-9]+)')".format(sql_col), TYPE_RE = r"SUBSTRING({} FROM '^([^0-9]+)')"
'_slot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+\/[0-9]+(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col), ID_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)([0-9]+)$') AS integer)"
'_subslot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col), SLOT_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)([0-9]+)\/') AS integer)"
'_position': "CAST(SUBSTRING({} FROM '([0-9]+)(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col), SUBSLOT_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)(?:[0-9]+\/)([0-9]+)') AS integer)"
'_channel': "COALESCE(CAST(SUBSTRING({} FROM ':([0-9]+)(\.[0-9]+)?$') AS integer), 0)".format(sql_col), POSITION_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)(?:[0-9]+\/){{2}}([0-9]+)') AS integer)"
'_vc': "COALESCE(CAST(SUBSTRING({} FROM '\.([0-9]+)$') AS integer), 0)".format(sql_col), SUBPOSITION_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)(?:[0-9]+\/){{3}}([0-9]+)') AS integer)"
}).order_by(*ordering) CHANNEL_RE = r"COALESCE(CAST(SUBSTRING({} FROM ':([0-9]+)(\.[0-9]+)?$') AS integer), 0)"
VC_RE = r"COALESCE(CAST(SUBSTRING({} FROM '\.([0-9]+)$') AS integer), 0)"
fields = {
'_type': RawSQL(TYPE_RE.format(sql_col), []),
'_id': RawSQL(ID_RE.format(sql_col), []),
'_slot': RawSQL(SLOT_RE.format(sql_col), []),
'_subslot': RawSQL(SUBSLOT_RE.format(sql_col), []),
'_position': RawSQL(POSITION_RE.format(sql_col), []),
'_subposition': RawSQL(SUBPOSITION_RE.format(sql_col), []),
'_channel': RawSQL(CHANNEL_RE.format(sql_col), []),
'_vc': RawSQL(VC_RE.format(sql_col), []),
}
return self.annotate(**fields).order_by(*ordering)
def connectable(self): def connectable(self):
""" """
@ -891,13 +911,25 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
pass pass
# Validate primary IPv4 address # Validate primary IPv4 address
if self.primary_ip4 and (self.primary_ip4.interface is None or self.primary_ip4.interface.device != self): if self.primary_ip4 and (
self.primary_ip4.interface is None or
self.primary_ip4.interface.device != self
) and (
self.primary_ip4.nat_inside.interface is None or
self.primary_ip4.nat_inside.interface.device != self
):
raise ValidationError({ raise ValidationError({
'primary_ip4': "The specified IP address ({}) is not assigned to this device.".format(self.primary_ip4), 'primary_ip4': "The specified IP address ({}) is not assigned to this device.".format(self.primary_ip4),
}) })
# Validate primary IPv6 address # Validate primary IPv6 address
if self.primary_ip6 and (self.primary_ip6.interface is None or self.primary_ip6.interface.device != self): if self.primary_ip6 and (
self.primary_ip6.interface is None or
self.primary_ip6.interface.device != self
) and (
self.primary_ip6.nat_inside.interface is None or
self.primary_ip6.nat_inside.interface.device != self
):
raise ValidationError({ raise ValidationError({
'primary_ip6': "The specified IP address ({}) is not assigned to this device.".format(self.primary_ip6), 'primary_ip6': "The specified IP address ({}) is not assigned to this device.".format(self.primary_ip6),
}) })

View File

@ -98,3 +98,112 @@ class RackTestCase(TestCase):
face=None, face=None,
) )
self.assertTrue(pdu) self.assertTrue(pdu)
class InterfaceTestCase(TestCase):
def setUp(self):
self.site = Site.objects.create(
name='TestSite1',
slug='my-test-site'
)
self.rack = Rack.objects.create(
name='TestRack1',
facility_id='A101',
site=self.site,
u_height=42
)
self.manufacturer = Manufacturer.objects.create(
name='Acme',
slug='acme'
)
self.device_type = DeviceType.objects.create(
manufacturer=self.manufacturer,
model='FrameForwarder 2048',
slug='ff2048'
)
self.role = DeviceRole.objects.create(
name='Switch',
slug='switch',
)
def test_interface_order_natural(self):
device1 = Device.objects.create(
name='TestSwitch1',
device_type=self.device_type,
device_role=self.role,
site=self.site,
rack=self.rack,
position=10,
face=RACK_FACE_REAR,
)
interface1 = Interface.objects.create(
device=device1,
name='Ethernet1/3/1'
)
interface2 = Interface.objects.create(
device=device1,
name='Ethernet1/5/1'
)
interface3 = Interface.objects.create(
device=device1,
name='Ethernet1/4'
)
interface4 = Interface.objects.create(
device=device1,
name='Ethernet1/3/2/4'
)
interface5 = Interface.objects.create(
device=device1,
name='Ethernet1/3/2/1'
)
interface6 = Interface.objects.create(
device=device1,
name='Loopback1'
)
self.assertEqual(
list(Interface.objects.all().order_naturally()),
[interface1, interface5, interface4, interface3, interface2, interface6]
)
def test_interface_order_natural_subinterfaces(self):
device1 = Device.objects.create(
name='TestSwitch1',
device_type=self.device_type,
device_role=self.role,
site=self.site,
rack=self.rack,
position=10,
face=RACK_FACE_REAR,
)
interface1 = Interface.objects.create(
device=device1,
name='GigabitEthernet0/0/3'
)
interface2 = Interface.objects.create(
device=device1,
name='GigabitEthernet0/0/2.2'
)
interface3 = Interface.objects.create(
device=device1,
name='GigabitEthernet0/0/0.120'
)
interface4 = Interface.objects.create(
device=device1,
name='GigabitEthernet0/0/0'
)
interface5 = Interface.objects.create(
device=device1,
name='GigabitEthernet0/0/1.117'
)
interface6 = Interface.objects.create(
device=device1,
name='GigabitEthernet0'
)
self.assertEqual(
list(Interface.objects.all().order_naturally()),
[interface4, interface3, interface5, interface2, interface1, interface6]
)

View File

@ -29,34 +29,47 @@ class CustomFieldsSerializer(serializers.BaseSerializer):
for field_name, value in data.items(): for field_name, value in data.items():
cf = custom_fields[field_name] try:
cf = custom_fields[field_name]
except KeyError:
raise ValidationError(
"Invalid custom field for {} objects: {}".format(content_type, field_name)
)
# Validate custom field name # Data validation
if field_name not in custom_fields: if value not in [None, '']:
raise ValidationError("Invalid custom field for {} objects: {}".format(content_type, field_name))
# Validate boolean # Validate boolean
if cf.type == CF_TYPE_BOOLEAN and value not in [True, False, 1, 0]: if cf.type == CF_TYPE_BOOLEAN and value not in [True, False, 1, 0]:
raise ValidationError("Invalid value for boolean field {}: {}".format(field_name, value)) raise ValidationError(
"Invalid value for boolean field {}: {}".format(field_name, value)
)
# Validate date # Validate date
if cf.type == CF_TYPE_DATE: if cf.type == CF_TYPE_DATE:
try: try:
datetime.strptime(value, '%Y-%m-%d') datetime.strptime(value, '%Y-%m-%d')
except ValueError: except ValueError:
raise ValidationError("Invalid date for field {}: {}. (Required format is YYYY-MM-DD.)".format( raise ValidationError(
field_name, value "Invalid date for field {}: {}. (Required format is YYYY-MM-DD.)".format(field_name, value)
)) )
# Validate selected choice # Validate selected choice
if cf.type == CF_TYPE_SELECT: if cf.type == CF_TYPE_SELECT:
try: try:
value = int(value) value = int(value)
except ValueError: except ValueError:
raise ValidationError("{}: Choice selections must be passed as integers.".format(field_name)) raise ValidationError(
valid_choices = [c.pk for c in cf.choices.all()] "{}: Choice selections must be passed as integers.".format(field_name)
if value not in valid_choices: )
raise ValidationError("Invalid choice for field {}: {}".format(field_name, value)) valid_choices = [c.pk for c in cf.choices.all()]
if value not in valid_choices:
raise ValidationError(
"Invalid choice for field {}: {}".format(field_name, value)
)
elif cf.required:
raise ValidationError("Required field {} cannot be empty.".format(field_name))
# Check for missing required fields # Check for missing required fields
missing_fields = [] missing_fields = []

View File

@ -98,7 +98,7 @@ class PrefixViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
# Create the new IP address # Create the new IP address
data = request.data.copy() data = request.data.copy()
data['address'] = '{}/{}'.format(ipaddress, prefix.prefix.prefixlen) data['address'] = '{}/{}'.format(ipaddress, prefix.prefix.prefixlen)
data['vrf'] = prefix.vrf data['vrf'] = prefix.vrf.pk if prefix.vrf else None
serializer = serializers.WritableIPAddressSerializer(data=data) serializer = serializers.WritableIPAddressSerializer(data=data)
if serializer.is_valid(): if serializer.is_valid():
serializer.save() serializer.save()

View File

@ -25,11 +25,8 @@ IP_FAMILY_CHOICES = [
(6, 'IPv6'), (6, 'IPv6'),
] ]
PREFIX_MASK_LENGTH_CHOICES = [ PREFIX_MASK_LENGTH_CHOICES = add_blank_choice([(i, i) for i in range(1, 128)])
('', '---------'), IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([(i, i) for i in range(1, 129)])
] + [(i, i) for i in range(1, 128)]
IPADDRESS_MASK_LENGTH_CHOICES = PREFIX_MASK_LENGTH_CHOICES + [(128, 128)]
# #

View File

@ -512,6 +512,16 @@ class VLANGroup(models.Model):
def get_absolute_url(self): def get_absolute_url(self):
return "{}?group_id={}".format(reverse('ipam:vlan_list'), self.pk) return "{}?group_id={}".format(reverse('ipam:vlan_list'), self.pk)
def get_next_available_vid(self):
"""
Return the first available VLAN ID (1-4094) in the group.
"""
vids = [vlan['vid'] for vlan in self.vlans.order_by('vid').values('vid')]
for i in range(1, 4095):
if i not in vids:
return i
return None
@python_2_unicode_compatible @python_2_unicode_compatible
class VLAN(CreatedUpdatedModel, CustomFieldModel): class VLAN(CreatedUpdatedModel, CustomFieldModel):

View File

@ -34,7 +34,7 @@ RIR_ACTIONS = """
UTILIZATION_GRAPH = """ UTILIZATION_GRAPH = """
{% load helpers %} {% load helpers %}
{% if record.pk %}{% utilization_graph value %}{% else %}—{% endif %} {% if record.pk %}{% utilization_graph record.get_utilization %}{% else %}—{% endif %}
""" """
ROLE_ACTIONS = """ ROLE_ACTIONS = """
@ -120,6 +120,13 @@ VLAN_ROLE_LINK = """
""" """
VLANGROUP_ACTIONS = """ VLANGROUP_ACTIONS = """
{% with next_vid=record.get_next_available_vid %}
{% if next_vid and perms.ipam.add_vlan %}
<a href="{% url 'ipam:vlan_add' %}?site={{ record.site_id }}&group={{ record.pk }}&vid={{ next_vid }}" title="Add VLAN" class="btn btn-xs btn-success">
<i class="glyphicon glyphicon-plus" aria-hidden="true"></i>
</a>
{% endif %}
{% endwith %}
{% if perms.ipam.change_vlangroup %} {% if perms.ipam.change_vlangroup %}
<a href="{% url 'ipam:vlangroup_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a> <a href="{% url 'ipam:vlangroup_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %} {% endif %}
@ -203,10 +210,10 @@ class AggregateTable(BaseTable):
class AggregateDetailTable(AggregateTable): class AggregateDetailTable(AggregateTable):
child_count = tables.Column(verbose_name='Prefixes') child_count = tables.Column(verbose_name='Prefixes')
get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization') utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
class Meta(AggregateTable.Meta): class Meta(AggregateTable.Meta):
fields = ('pk', 'prefix', 'rir', 'child_count', 'get_utilization', 'date_added', 'description') fields = ('pk', 'prefix', 'rir', 'child_count', 'utilization', 'date_added', 'description')
# #
@ -249,10 +256,10 @@ class PrefixTable(BaseTable):
class PrefixDetailTable(PrefixTable): class PrefixDetailTable(PrefixTable):
get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization') utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False)
class Meta(PrefixTable.Meta): class Meta(PrefixTable.Meta):
fields = ('pk', 'prefix', 'status', 'vrf', 'get_utilization', 'tenant', 'site', 'vlan', 'role', 'description') fields = ('pk', 'prefix', 'status', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description')
# #

View File

@ -286,11 +286,12 @@ class AggregateListView(ObjectListView):
ipv4_total = 0 ipv4_total = 0
ipv6_total = 0 ipv6_total = 0
for a in self.queryset: for aggregate in self.queryset:
if a.prefix.version == 4: if aggregate.prefix.version == 6:
ipv4_total += a.prefix.size # Report equivalent /64s for IPv6 to keep things sane
elif a.prefix.version == 6: ipv6_total += int(aggregate.prefix.size / 2 ** 64)
ipv6_total += a.prefix.size / 2 ** 64 else:
ipv4_total += aggregate.prefix.size
return { return {
'ipv4_total': ipv4_total, 'ipv4_total': ipv4_total,
@ -314,7 +315,7 @@ class AggregateView(View):
) )
child_prefixes = add_available_prefixes(aggregate.prefix, child_prefixes) child_prefixes = add_available_prefixes(aggregate.prefix, child_prefixes)
prefix_table = tables.PrefixTable(child_prefixes) prefix_table = tables.PrefixDetailTable(child_prefixes)
if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'): if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
prefix_table.base_columns['pk'].visible = True prefix_table.base_columns['pk'].visible = True
@ -473,11 +474,11 @@ class PrefixView(View):
child_prefixes = Prefix.objects.filter( child_prefixes = Prefix.objects.filter(
vrf=prefix.vrf, prefix__net_contained=str(prefix.prefix) vrf=prefix.vrf, prefix__net_contained=str(prefix.prefix)
).select_related( ).select_related(
'site', 'role' 'site', 'vlan', 'role',
).annotate_depth(limit=0) ).annotate_depth(limit=0)
if child_prefixes: if child_prefixes:
child_prefixes = add_available_prefixes(prefix.prefix, child_prefixes) child_prefixes = add_available_prefixes(prefix.prefix, child_prefixes)
child_prefix_table = tables.PrefixTable(child_prefixes) child_prefix_table = tables.PrefixDetailTable(child_prefixes)
if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'): if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
child_prefix_table.base_columns['pk'].visible = True child_prefix_table.base_columns['pk'].visible = True

View File

@ -38,11 +38,14 @@ ADMINS = [
# ['John Doe', 'jdoe@example.com'], # ['John Doe', 'jdoe@example.com'],
] ]
# Optionally display a persistent banner at the top and/or bottom of every page. To display the same content in both # Optionally display a persistent banner at the top and/or bottom of every page. HTML is allowed. To display the same
# banners, define BANNER_TOP and set BANNER_BOTTOM = BANNER_TOP. # content in both banners, define BANNER_TOP and set BANNER_BOTTOM = BANNER_TOP.
BANNER_TOP = '' BANNER_TOP = ''
BANNER_BOTTOM = '' BANNER_BOTTOM = ''
# Text to include on the login page above the login form. HTML is allowed.
BANNER_LOGIN = ''
# Base URL path if accessing NetBox within a directory. For example, if installed at http://example.com/netbox/, set: # Base URL path if accessing NetBox within a directory. For example, if installed at http://example.com/netbox/, set:
# BASE_PATH = 'netbox/' # BASE_PATH = 'netbox/'
BASE_PATH = '' BASE_PATH = ''

View File

@ -13,7 +13,7 @@ except ImportError:
) )
VERSION = '2.1.4' VERSION = '2.1.5'
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@ -29,8 +29,9 @@ for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
# Import optional configuration parameters # Import optional configuration parameters
ADMINS = getattr(configuration, 'ADMINS', []) ADMINS = getattr(configuration, 'ADMINS', [])
BANNER_BOTTOM = getattr(configuration, 'BANNER_BOTTOM', False) BANNER_BOTTOM = getattr(configuration, 'BANNER_BOTTOM', '')
BANNER_TOP = getattr(configuration, 'BANNER_TOP', False) BANNER_LOGIN = getattr(configuration, 'BANNER_LOGIN', '')
BANNER_TOP = getattr(configuration, 'BANNER_TOP', '')
BASE_PATH = getattr(configuration, 'BASE_PATH', '') BASE_PATH = getattr(configuration, 'BASE_PATH', '')
if BASE_PATH: if BASE_PATH:
BASE_PATH = BASE_PATH.strip('/') + '/' # Enforce trailing slash only BASE_PATH = BASE_PATH.strip('/') + '/' # Enforce trailing slash only
@ -233,6 +234,10 @@ REST_FRAMEWORK = {
'DEFAULT_PERMISSION_CLASSES': ( 'DEFAULT_PERMISSION_CLASSES': (
'utilities.api.TokenPermissions', 'utilities.api.TokenPermissions',
), ),
'DEFAULT_RENDERER_CLASSES': (
'rest_framework.renderers.JSONRenderer',
'utilities.api.FormlessBrowsableAPIRenderer',
),
'DEFAULT_VERSION': REST_FRAMEWORK_VERSION, 'DEFAULT_VERSION': REST_FRAMEWORK_VERSION,
'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.AcceptHeaderVersioning', 'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.AcceptHeaderVersioning',
'PAGE_SIZE': PAGINATE_COUNT, 'PAGE_SIZE': PAGINATE_COUNT,

View File

@ -329,13 +329,14 @@ li.occupied + li.available {
} }
/* Devices */ /* Devices */
table.component-list tr.ipaddress td { table.component-list td.subtable {
background-color: #eeffff; padding: 0;
padding-bottom: 4px; padding-left: 16px;
padding-top: 4px;
} }
table.component-list tr.ipaddress:hover td { table.component-list td.subtable td {
background-color: #e6f7f7; border: none;
padding-bottom: 6px;
padding-top: 6px;
} }
/* AJAX loader */ /* AJAX loader */

View File

@ -43,7 +43,7 @@ $(document).ready(function() {
success: function (response, status) { success: function (response, status) {
if (response.plaintext) { if (response.plaintext) {
console.log("Secret retrieved successfully"); console.log("Secret retrieved successfully");
$('#secret_' + secret_id).html(response.plaintext); $('#secret_' + secret_id).text(response.plaintext);
$('button.unlock-secret[secret-id=' + secret_id + ']').hide(); $('button.unlock-secret[secret-id=' + secret_id + ']').hide();
$('button.lock-secret[secret-id=' + secret_id + ']').show(); $('button.lock-secret[secret-id=' + secret_id + ']').show();
} else { } else {

View File

@ -53,7 +53,7 @@ $(document).ready(function() {
success: function(json) { success: function(json) {
$.each(json['get_lldp_neighbors'], function(iface, neighbors) { $.each(json['get_lldp_neighbors'], function(iface, neighbors) {
var neighbor = neighbors[0]; var neighbor = neighbors[0];
var row = $('#' + iface.replace(/(\/)/g, "\\$1")); var row = $('#' + iface.split(".")[0].replace(/(\/)/g, "\\$1"));
var configured_device = row.children('td.configured_device').attr('data'); var configured_device = row.children('td.configured_device').attr('data');
var configured_interface = row.children('td.configured_interface').attr('data'); var configured_interface = row.children('td.configured_interface').attr('data');
// Add LLDP neighbors to table // Add LLDP neighbors to table
@ -62,7 +62,7 @@ $(document).ready(function() {
// Apply colors to rows // Apply colors to rows
if (!configured_device && neighbor['hostname']) { if (!configured_device && neighbor['hostname']) {
row.addClass('info'); row.addClass('info');
} else if (configured_device == neighbor['hostname'] && configured_interface == neighbor['port']) { } else if (configured_device == neighbor['hostname'] && configured_interface == neighbor['port'].split(".")[0]) {
row.addClass('success'); row.addClass('success');
} else { } else {
row.addClass('danger'); row.addClass('danger');

View File

@ -1,4 +1,4 @@
<tr class="interface{% if not iface.enabled %} danger{% elif iface.connection and iface.connection.connection_status %} success{% elif iface.connection and not iface.connection.connection_status %} info{% endif %}"> <tr class="interface{% if not iface.enabled %} danger{% elif iface.connection and iface.connection.connection_status or iface.circuit_termination %} success{% elif iface.connection and not iface.connection.connection_status %} info{% elif iface.is_virtual %} warning{% endif %}">
{% if selectable and perms.dcim.change_interface or perms.dcim.delete_interface %} {% if selectable and perms.dcim.change_interface or perms.dcim.delete_interface %}
<td class="pk"> <td class="pk">
<input name="pk" type="checkbox" value="{{ iface.pk }}" /> <input name="pk" type="checkbox" value="{{ iface.pk }}" />
@ -113,41 +113,55 @@
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
{% for ip in iface.ip_addresses.all %} {% with iface.ip_addresses.all as ipaddresses %}
<tr class="ipaddress"> {% if ipaddresses %}
{% if selectable and perms.dcim.change_interface or perms.dcim.delete_interface %} <tr class="ipaddress">
<td></td> {% if selectable and perms.dcim.change_interface or perms.dcim.delete_interface %}
{% endif %} <td></td>
<td colspan="3"> <td colspan="6" class="subtable">
<a href="{% url 'ipam:ipaddress' pk=ip.pk %}">{{ ip }}</a>
{% if ip.description %}
<i class="fa fa-fw fa-comment-o" title="{{ ip.description }}"></i>
{% endif %}
{% if device.primary_ip4 == ip or device.primary_ip6 == ip %}
<span class="label label-success">Primary</span>
{% endif %}
</td>
<td class="text-right">
{% if ip.vrf %}
<a href="{% url 'ipam:vrf' pk=ip.vrf.pk %}">{{ ip.vrf }}</a>
{% else %} {% else %}
<span class="text-muted">Global</span> <td colspan="7" class="subtable">
{% endif %} {% endif %}
</td> <table class="table table-hover">
<td> {% for ip in ipaddresses %}
<span class="label label-{{ ip.get_status_class }}">{{ ip.get_status_display }}</span> <tr>
</td> <td>
<td class="text-right"> <a href="{% url 'ipam:ipaddress' pk=ip.pk %}">{{ ip }}</a>
{% if perms.ipam.change_ipaddress %} {% if ip.description %}
<a href="{% url 'ipam:ipaddress_edit' pk=ip.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-info btn-xs"> <i class="fa fa-fw fa-comment-o" title="{{ ip.description }}"></i>
<i class="glyphicon glyphicon-pencil" aria-hidden="true" title="Edit IP address"></i> {% endif %}
</a> </td>
{% endif %} <td>
{% if perms.ipam.delete_ipaddress %} {% if device.primary_ip4 == ip or device.primary_ip6 == ip %}
<a href="{% url 'ipam:ipaddress_delete' pk=ip.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs"> <span class="label label-success">Primary</span>
<i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete IP address"></i> {% endif %}
</a> </td>
{% endif %} <td>
</td> {% if ip.vrf %}
</tr> <a href="{% url 'ipam:vrf' pk=ip.vrf.pk %}">{{ ip.vrf }}</a>
{% endfor %} {% else %}
<span class="text-muted">Global table</span>
{% endif %}
</td>
<td>
<span class="label label-{{ ip.get_status_class }}">{{ ip.get_status_display }}</span>
</td>
<td class="text-right">
{% if perms.ipam.change_ipaddress %}
<a href="{% url 'ipam:ipaddress_edit' pk=ip.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-info btn-xs">
<i class="glyphicon glyphicon-pencil" aria-hidden="true" title="Edit IP address"></i>
</a>
{% endif %}
{% if perms.ipam.delete_ipaddress %}
<a href="{% url 'ipam:ipaddress_delete' pk=ip.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete IP address"></i>
</a>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
</td>
</tr>
{% endif %}
{% endwith %}

View File

@ -20,11 +20,18 @@
<div class="row"> <div class="row">
<div class="col-md-9"> <div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_edit_url='ipam:aggregate_bulk_edit' bulk_delete_url='ipam:aggregate_bulk_delete' %} {% include 'utilities/obj_table.html' with bulk_edit_url='ipam:aggregate_bulk_edit' bulk_delete_url='ipam:aggregate_bulk_delete' %}
<p class="text-right">IPv4 total: <strong>{{ ipv4_total|intcomma }} /32s</strong></p>
<p class="text-right">IPv6 total: <strong>{{ ipv6_total|intcomma }} /64s</strong></p>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
{% include 'inc/search_panel.html' %} {% include 'inc/search_panel.html' %}
<div class="panel panel-default">
<div class="panel-heading">
<strong><i class="fa fa-bar-chart"></i> Statistics</strong>
</div>
<ul class="list-group">
<li class="list-group-item">Total IPv4 IPs <span class="badge">{{ ipv4_total|intcomma }}</span></li>
<li class="list-group-item">Total IPv6 /64s <span class="badge">{{ ipv6_total|intcomma }}</span></li>
</ul>
</div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -2,8 +2,13 @@
{% load form_helpers %} {% load form_helpers %}
{% block content %} {% block content %}
<div class="row" style="margin-top: 150px;"> <div class="row" style="margin-top: {% if settings.BANNER_LOGIN %}100{% else %}150{% endif %}px;">
<div class="col-sm-4 col-sm-offset-4"> <div class="col-sm-4 col-sm-offset-4">
{% if settings.BANNER_LOGIN %}
<div style="margin-bottom: 25px">
{{ settings.BANNER_LOGIN|safe }}
</div>
{% endif %}
{% if form.non_field_errors %} {% if form.non_field_errors %}
<div class="panel panel-danger"> <div class="panel panel-danger">
<div class="panel-heading"><strong>Errors</strong></div> <div class="panel-heading"><strong>Errors</strong></div>

View File

@ -8,6 +8,7 @@ from rest_framework.compat import is_authenticated
from rest_framework.exceptions import APIException from rest_framework.exceptions import APIException
from rest_framework.pagination import LimitOffsetPagination from rest_framework.pagination import LimitOffsetPagination
from rest_framework.permissions import BasePermission, DjangoModelPermissions, SAFE_METHODS from rest_framework.permissions import BasePermission, DjangoModelPermissions, SAFE_METHODS
from rest_framework.renderers import BrowsableAPIRenderer
from rest_framework.serializers import Field, ModelSerializer, ValidationError from rest_framework.serializers import Field, ModelSerializer, ValidationError
from rest_framework.views import get_view_name as drf_get_view_name from rest_framework.views import get_view_name as drf_get_view_name
@ -206,6 +207,18 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
return self.default_limit return self.default_limit
#
# Renderers
#
class FormlessBrowsableAPIRenderer(BrowsableAPIRenderer):
"""
Override the built-in BrowsableAPIRenderer to disable HTML forms.
"""
def show_form_for_method(self, *args, **kwargs):
return False
# #
# Miscellaneous # Miscellaneous
# #

View File

@ -478,7 +478,7 @@ class BulkEditView(View):
template_name = 'utilities/obj_bulk_edit.html' template_name = 'utilities/obj_bulk_edit.html'
default_return_url = 'home' default_return_url = 'home'
def get(self): def get(self, request):
return redirect(self.default_return_url) return redirect(self.default_return_url)
def post(self, request, **kwargs): def post(self, request, **kwargs):
@ -626,6 +626,9 @@ class BulkDeleteView(View):
template_name = 'utilities/obj_bulk_delete.html' template_name = 'utilities/obj_bulk_delete.html'
default_return_url = 'home' default_return_url = 'home'
def get(self, request):
return redirect(self.default_return_url)
def post(self, request, **kwargs): def post(self, request, **kwargs):
# Attempt to derive parent object if a parent class has been given # Attempt to derive parent object if a parent class has been given