diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 85119102b..0ae5beb57 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -8,10 +8,9 @@ If you encounter any issues installing or using NetBox, try one of the following
Join the #netbox channel on [Freenode IRC](https://freenode.net/). You can connect to Freenode at irc.freenode.net using
an IRC client, or you can use their [webchat client](https://webchat.freenode.net/).
-### Reddit
+### Mailing List
-We have established [/r/netbox](https://www.reddit.com/r/netbox) on Reddit for NetBox issues and general discussion.
-Reddit registration is free and does not require providing an email address (although it is encouraged).
+We have established a Google Groups Mailing List for issues and general discussion. You can find us [here]( https://groups.google.com/forum/#!forum/netbox-discuss).
## Reporting Bugs
@@ -24,7 +23,7 @@ click "add a reaction" in the top right corner of the issue and add a thumbs up
comment describing how it's affecting your installation. This will allow us to prioritize bugs based on how many users
are affected.
-* If you haven't found an existing issue that describes your suspected bug, please inquire about it on IRC or Reddit.
+* If you haven't found an existing issue that describes your suspected bug, please inquire about it on IRC or Google Groups.
**Do not** file an issue until you have received confirmation that it is in fact a bug. Invalid issues are very
distracting and slow the pace at which NetBox is developed.
diff --git a/README.md b/README.md
index e4ba912a3..66c35250b 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@ NetBox is an IP address management (IPAM) and data center infrastructure managem
NetBox runs as a web application atop the [Django](https://www.djangoproject.com/) Python framework with a [PostgreSQL](http://www.postgresql.org/) database. For a complete list of requirements, see `requirements.txt`. The code is available [on GitHub](https://github.com/digitalocean/netbox).
-The complete documentation for Netbox can be found at [Read the Docs](http://netbox.readthedocs.io/en/latest/).
+The complete documentation for NetBox can be found at [Read the Docs](http://netbox.readthedocs.io/en/stable/).
Questions? Comments? Please subscribe to [the netbox-discuss mailing list](https://groups.google.com/forum/#!forum/netbox-discuss), or join us on IRC in **#netbox** on **irc.freenode.net**!
@@ -25,6 +25,9 @@ Questions? Comments? Please subscribe to [the netbox-discuss mailing list](https
# Installation
-Please see [the documentation](http://netbox.readthedocs.io/en/latest/) for instructions on installing NetBox.
+Please see [the documentation](http://netbox.readthedocs.io/en/stable/) for instructions on installing NetBox. To upgrade NetBox, please download the [latest release](https://github.com/digitalocean/netbox/releases) and run `upgrade.sh`.
-To upgrade NetBox, please download the [latest release](https://github.com/digitalocean/netbox/releases) and run `upgrade.sh`.
+## Alternative Installations
+
+* [Docker container](http://netbox.readthedocs.io/en/stable/installation/docker/)
+* [Heroku deployment](https://heroku.com/deploy?template=https://github.com/BILDQUADRAT/netbox/tree/heroku) (via [@mraerino](https://github.com/BILDQUADRAT/netbox/tree/heroku))
diff --git a/docs/data-model/circuits.md b/docs/data-model/circuits.md
index 563e5df7c..226c62814 100644
--- a/docs/data-model/circuits.md
+++ b/docs/data-model/circuits.md
@@ -4,29 +4,30 @@ The circuits component of NetBox deals with the management of long-haul Internet
A provider is any entity which provides some form of connectivity. This obviously includes carriers which offer Internet and private transit service. However, it might also include Internet exchange (IX) points and even organizations with whom you peer directly.
-Each provider may be assigned an autonomous system number (ASN) for reference. Each provider can also be assigned account and contact information, as well as miscellaneous comments.
+Each provider may be assigned an autonomous system number (ASN), an account number, and contact information.
---
# Circuits
-A circuit represents a single physical data link connecting two endpoints. Each circuit belongs to a provider and must be assigned circuit ID which is unique to that provider. Each circuit must also be assigned to a site, and may optionally be connected to a specific interface on a specific device within that site.
-
-NetBox also tracks miscellaneous circuit attributes (most of which are optional), including:
-
-* Date of installation
-* Port speed
-* Commit rate
-* Cross-connect ID
-* Patch panel information
+A circuit represents a single physical data link connecting two endpoints. Each circuit belongs to a provider and must be assigned a circuit ID which is unique to that provider.
### Circuit Types
-Circuits can be classified by type. For example:
+Circuits are classified by type. For example:
* Internet transit
* Out-of-band connectivity
* Peering
* Private backhaul
-Each circuit must be assigned exactly one circuit type.
\ No newline at end of file
+Circuit types are fully customizable.
+
+### Circuit Terminations
+
+A circuit may have one or two terminations, annotated as the "A" and "Z" sides of the circuit. A single-termination circuit can be used when you don't know (or care) about the far end of a circuit (for example, an Internet access circuit which connects to a transit provider). A dual-termination circuit is useful for tracking circuits which connect two sites.
+
+Each circuit termination can be tied to a site, or to a specific device and interface within that site. Each termination can be assigned a separate downstream and upstream speed independent from one another. Fields are also available to track cross-connect and patch panel details.
+
+!!! note
+ A circuit represents a physical link, and cannot have more than two endpoints. When modeling a multi-point topology, each leg of the topology must be defined as a discrete circuit.
diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py
index 1b894afe0..947aa9860 100644
--- a/netbox/circuits/api/serializers.py
+++ b/netbox/circuits/api/serializers.py
@@ -62,7 +62,8 @@ class CircuitSerializer(CustomFieldSerializer, serializers.ModelSerializer):
class Meta:
model = Circuit
- fields = ['id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'comments', 'terminations', 'custom_fields']
+ fields = ['id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
+ 'terminations', 'custom_fields']
class CircuitNestedSerializer(CircuitSerializer):
diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py
index e2ec5f55b..fa57a74dc 100644
--- a/netbox/circuits/filters.py
+++ b/netbox/circuits/filters.py
@@ -98,5 +98,6 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
Q(cid__icontains=value) |
Q(terminations__xconnect_id__icontains=value) |
Q(terminations__pp_info__icontains=value) |
+ Q(description__icontains=value) |
Q(comments__icontains=value)
- )
+ ).distinct()
diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py
index dc59bc0c7..a1777bb16 100644
--- a/netbox/circuits/forms.py
+++ b/netbox/circuits/forms.py
@@ -86,7 +86,7 @@ class CircuitForm(BootstrapMixin, CustomFieldForm):
class Meta:
model = Circuit
- fields = ['cid', 'type', 'provider', 'tenant', 'install_date', 'commit_rate', 'comments']
+ fields = ['cid', 'type', 'provider', 'tenant', 'install_date', 'commit_rate', 'description', 'comments']
help_texts = {
'cid': "Unique circuit ID",
'install_date': "Format: YYYY-MM-DD",
@@ -104,7 +104,7 @@ class CircuitFromCSVForm(forms.ModelForm):
class Meta:
model = Circuit
- fields = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate']
+ fields = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description']
class CircuitImportForm(BootstrapMixin, BulkImportForm):
@@ -117,10 +117,11 @@ class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
provider = forms.ModelChoiceField(queryset=Provider.objects.all(), required=False)
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
commit_rate = forms.IntegerField(required=False, label='Commit rate (Kbps)')
+ description = forms.CharField(max_length=100, required=False)
comments = CommentField(widget=SmallTextarea)
class Meta:
- nullable_fields = ['tenant', 'commit_rate', 'comments']
+ nullable_fields = ['tenant', 'commit_rate', 'description', 'comments']
class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
diff --git a/netbox/circuits/migrations/0007_circuit_add_description.py b/netbox/circuits/migrations/0007_circuit_add_description.py
new file mode 100644
index 000000000..023e5890a
--- /dev/null
+++ b/netbox/circuits/migrations/0007_circuit_add_description.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.10.4 on 2017-01-17 20:08
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('circuits', '0006_terminations'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='circuit',
+ name='description',
+ field=models.CharField(blank=True, max_length=100),
+ ),
+ ]
diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py
index d43778657..7f6cc4f21 100644
--- a/netbox/circuits/models.py
+++ b/netbox/circuits/models.py
@@ -97,6 +97,7 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
tenant = models.ForeignKey(Tenant, related_name='circuits', blank=True, null=True, on_delete=models.PROTECT)
install_date = models.DateField(blank=True, null=True, verbose_name='Date installed')
commit_rate = models.PositiveIntegerField(blank=True, null=True, verbose_name='Commit rate (Kbps)')
+ description = models.CharField(max_length=100, blank=True)
comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
@@ -118,6 +119,7 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
self.tenant.name if self.tenant else None,
self.install_date.isoformat() if self.install_date else None,
self.commit_rate,
+ self.description,
])
def _get_termination(self, side):
@@ -157,9 +159,6 @@ class CircuitTermination(models.Model):
def __unicode__(self):
return u'{} (Side {})'.format(self.circuit, self.get_term_side_display())
- def get_parent_url(self):
- return self.circuit.get_absolute_url()
-
def get_peer_termination(self):
peer_side = 'Z' if self.term_side == 'A' else 'A'
try:
diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py
index 34236d843..ab877a8ce 100644
--- a/netbox/circuits/tables.py
+++ b/netbox/circuits/tables.py
@@ -60,9 +60,8 @@ class CircuitTable(BaseTable):
args=[Accessor('termination_a.site.slug')])
z_side = tables.LinkColumn('dcim:site', accessor=Accessor('termination_z.site'), orderable=False,
args=[Accessor('termination_z.site.slug')])
- commit_rate = tables.Column(accessor=Accessor('commit_rate_human'), order_by=Accessor('commit_rate'),
- verbose_name='Commit Rate')
+ description = tables.Column(verbose_name='Description')
class Meta(BaseTable.Meta):
model = Circuit
- fields = ('pk', 'cid', 'type', 'provider', 'tenant', 'a_side', 'z_side', 'commit_rate')
+ fields = ('pk', 'cid', 'type', 'provider', 'tenant', 'a_side', 'z_side', 'description')
diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py
index 9feb19ef6..3eb269327 100644
--- a/netbox/circuits/views.py
+++ b/netbox/circuits/views.py
@@ -1,6 +1,7 @@
from django.contrib import messages
from django.contrib.auth.decorators import permission_required
from django.contrib.auth.mixins import PermissionRequiredMixin
+from django.core.urlresolvers import reverse
from django.db import transaction
from django.db.models import Count
from django.shortcuts import get_object_or_404, redirect, render
@@ -52,7 +53,7 @@ class ProviderEditView(PermissionRequiredMixin, ObjectEditView):
class ProviderDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'circuits.delete_provider'
model = Provider
- redirect_url = 'circuits:provider_list'
+ default_return_url = 'circuits:provider_list'
class ProviderBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -92,8 +93,9 @@ class CircuitTypeEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'circuits.change_circuittype'
model = CircuitType
form_class = forms.CircuitTypeForm
- obj_list_url = 'circuits:circuittype_list'
- use_obj_view = False
+
+ def get_return_url(self, obj):
+ return reverse('circuits:circuittype_list')
class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -140,7 +142,7 @@ class CircuitEditView(PermissionRequiredMixin, ObjectEditView):
class CircuitDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'circuits.delete_circuit'
model = Circuit
- redirect_url = 'circuits:circuit_list'
+ default_return_url = 'circuits:circuit_list'
class CircuitBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -223,10 +225,12 @@ class CircuitTerminationEditView(PermissionRequiredMixin, ObjectEditView):
def alter_obj(self, obj, args, kwargs):
if 'circuit' in kwargs:
- circuit = get_object_or_404(Circuit, pk=kwargs['circuit'])
- obj.circuit = circuit
+ obj.circuit = get_object_or_404(Circuit, pk=kwargs['circuit'])
return obj
+ def get_return_url(self, obj):
+ return obj.circuit.get_absolute_url()
+
class CircuitTerminationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'circuits.delete_circuittermination'
diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py
index 3b1ab720c..698e7d2d6 100644
--- a/netbox/dcim/api/serializers.py
+++ b/netbox/dcim/api/serializers.py
@@ -138,7 +138,8 @@ class DeviceTypeSerializer(CustomFieldSerializer, serializers.ModelSerializer):
class Meta:
model = DeviceType
fields = ['id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
- 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments', 'custom_fields']
+ 'interface_ordering', 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role',
+ 'comments', 'custom_fields']
def get_subdevice_role(self, obj):
return {
@@ -198,9 +199,9 @@ class DeviceTypeDetailSerializer(DeviceTypeSerializer):
class Meta(DeviceTypeSerializer.Meta):
fields = ['id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
- 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments', 'custom_fields',
- 'console_port_templates', 'cs_port_templates', 'power_port_templates', 'power_outlet_templates',
- 'interface_templates']
+ 'interface_ordering', 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role',
+ 'comments', 'custom_fields', 'console_port_templates', 'cs_port_templates', 'power_port_templates',
+ 'power_outlet_templates', 'interface_templates']
#
diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py
index d82f28090..cb35ff881 100644
--- a/netbox/dcim/forms.py
+++ b/netbox/dcim/forms.py
@@ -17,9 +17,9 @@ from formfields import MACAddressFormField
from .models import (
DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_PLANNED, CONNECTION_STATUS_CONNECTED,
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType,
- Interface, IFACE_FF_CHOICES, IFACE_FF_VIRTUAL, InterfaceConnection, InterfaceTemplate, Manufacturer, Module,
- Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES,
- Rack, RackGroup, RackRole, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD
+ Interface, IFACE_FF_CHOICES, IFACE_FF_VIRTUAL, IFACE_ORDERING_CHOICES, InterfaceConnection, InterfaceTemplate,
+ Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES,
+ RACK_WIDTH_CHOICES, Rack, RackGroup, RackRole, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD
)
@@ -263,13 +263,17 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldForm):
class Meta:
model = DeviceType
fields = ['manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server',
- 'is_pdu', 'is_network_device', 'subdevice_role', 'comments']
+ 'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering', 'comments']
+ labels = {
+ 'interface_ordering': 'Order interfaces by',
+ }
class DeviceTypeBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=DeviceType.objects.all(), widget=forms.MultipleHiddenInput)
manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), required=False)
u_height = forms.IntegerField(min_value=1, required=False)
+ interface_ordering = forms.ChoiceField(choices=add_blank_choice(IFACE_ORDERING_CHOICES), required=False)
class Meta:
nullable_fields = []
diff --git a/netbox/dcim/migrations/0025_devicetype_add_interface_ordering.py b/netbox/dcim/migrations/0025_devicetype_add_interface_ordering.py
new file mode 100644
index 000000000..d1263cb89
--- /dev/null
+++ b/netbox/dcim/migrations/0025_devicetype_add_interface_ordering.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.10.4 on 2017-01-06 16:56
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dcim', '0024_site_add_contact_fields'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='devicetype',
+ name='interface_ordering',
+ field=models.PositiveSmallIntegerField(choices=[[1, b'Slot/position'], [2, b'Name (alphabetically)']], default=1),
+ ),
+ ]
diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py
index c95bca426..6274f3a53 100644
--- a/netbox/dcim/models.py
+++ b/netbox/dcim/models.py
@@ -56,6 +56,13 @@ SUBDEVICE_ROLE_CHOICES = (
(SUBDEVICE_ROLE_CHILD, 'Child'),
)
+IFACE_ORDERING_POSITION = 1
+IFACE_ORDERING_NAME = 2
+IFACE_ORDERING_CHOICES = [
+ [IFACE_ORDERING_POSITION, 'Slot/position'],
+ [IFACE_ORDERING_NAME, 'Name (alphabetically)']
+]
+
# Virtual
IFACE_FF_VIRTUAL = 0
# Ethernet
@@ -182,48 +189,6 @@ RPC_CLIENT_CHOICES = [
]
-def order_interfaces(queryset, sql_col, primary_ordering=tuple()):
- """
- Attempt to match interface names by their slot/position identifiers and order according. Matching is done using the
- following pattern:
-
- {a}/{b}/{c}:{d}
-
- Interfaces are ordered first by field a, then b, then c, and finally d. Leading text (which typically indicates the
- interface's type) is ignored. If any fields are not contained by an interface name, those fields are treated as
- None. 'None' is ordered after all other values. For example:
-
- et-0/0/0
- et-0/0/1
- et-0/1/0
- xe-0/1/1:0
- xe-0/1/1:1
- xe-0/1/1:2
- xe-0/1/1:3
- et-0/1/2
- ...
- et-0/1/9
- et-0/1/10
- et-0/1/11
- et-1/0/0
- et-1/0/1
- ...
- vlan1
- vlan10
-
- :param queryset: The base queryset to be ordered
- :param sql_col: Table and name of the SQL column which contains the interface name (ex: ''dcim_interface.name')
- :param primary_ordering: A tuple of fields which take ordering precedence before the interface name (optional)
- """
- ordering = primary_ordering + ('_id1', '_id2', '_id3', '_id4')
- return queryset.extra(select={
- '_id1': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+\/[0-9]+(:[0-9]+)?$') AS integer)".format(sql_col),
- '_id2': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+(:[0-9]+)?$') AS integer)".format(sql_col),
- '_id3': "CAST(SUBSTRING({} FROM '([0-9]+)(:[0-9]+)?$') AS integer)".format(sql_col),
- '_id4': "CAST(SUBSTRING({} FROM ':([0-9]+)$') AS integer)".format(sql_col),
- }).order_by(*ordering)
-
-
#
# Sites
#
@@ -551,6 +516,8 @@ class DeviceType(models.Model, CustomFieldModel):
u_height = models.PositiveSmallIntegerField(verbose_name='Height (U)', default=1)
is_full_depth = models.BooleanField(default=True, verbose_name="Is full depth",
help_text="Device consumes both front and rear rack faces")
+ interface_ordering = models.PositiveSmallIntegerField(choices=IFACE_ORDERING_CHOICES,
+ default=IFACE_ORDERING_POSITION)
is_console_server = models.BooleanField(default=False, verbose_name='Is a console server',
help_text="This type of device has console server ports")
is_pdu = models.BooleanField(default=False, verbose_name='Is a PDU',
@@ -701,11 +668,42 @@ class PowerOutletTemplate(models.Model):
return self.name
-class InterfaceTemplateManager(models.Manager):
+class InterfaceManager(models.Manager):
- def get_queryset(self):
- qs = super(InterfaceTemplateManager, self).get_queryset()
- return order_interfaces(qs, 'dcim_interfacetemplate.name', ('device_type',))
+ def order_naturally(self, method=IFACE_ORDERING_POSITION):
+ """
+ Naturally order interfaces by their name and numeric position. The sort method must be one of the defined
+ IFACE_ORDERING_CHOICES (typically indicated by a parent Device's DeviceType).
+
+ To order interfaces naturally, the `name` field is split into five distinct components: leading text (name),
+ slot, subslot, position, and channel:
+
+ {name}{slot}/{subslot}/{position}:{channel}
+
+ Components absent from the interface name are ignored. For example, an interface named GigabitEthernet0/1 would
+ be parsed as follows:
+
+ name = 'GigabitEthernet'
+ slot = None
+ subslot = 0
+ position = 1
+ channel = None
+
+ The chosen sorting method will determine which fields are ordered first in the query.
+ """
+ queryset = self.get_queryset()
+ sql_col = '{}.name'.format(queryset.model._meta.db_table)
+ ordering = {
+ IFACE_ORDERING_POSITION: ('_slot', '_subslot', '_position', '_channel', '_name'),
+ IFACE_ORDERING_NAME: ('_name', '_slot', '_subslot', '_position', '_channel'),
+ }[method]
+ return queryset.extra(select={
+ '_name': "SUBSTRING({} FROM '^([^0-9]+)')".format(sql_col),
+ '_slot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+\/[0-9]+(:[0-9]+)?$') AS integer)".format(sql_col),
+ '_subslot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+(:[0-9]+)?$') AS integer)".format(sql_col),
+ '_position': "CAST(SUBSTRING({} FROM '([0-9]+)(:[0-9]+)?$') AS integer)".format(sql_col),
+ '_channel': "CAST(SUBSTRING({} FROM ':([0-9]+)$') AS integer)".format(sql_col),
+ }).order_by(*ordering)
class InterfaceTemplate(models.Model):
@@ -717,7 +715,7 @@ class InterfaceTemplate(models.Model):
form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_10GE_SFP_PLUS)
mgmt_only = models.BooleanField(default=False, verbose_name='Management only')
- objects = InterfaceTemplateManager()
+ objects = InterfaceManager()
class Meta:
ordering = ['device_type', 'name']
@@ -987,9 +985,6 @@ class ConsolePort(models.Model):
def __unicode__(self):
return self.name
- def get_parent_url(self):
- return self.device.get_absolute_url()
-
# Used for connections export
def to_csv(self):
return csv_format([
@@ -1031,9 +1026,6 @@ class ConsoleServerPort(models.Model):
def __unicode__(self):
return self.name
- def get_parent_url(self):
- return self.device.get_absolute_url()
-
class PowerPort(models.Model):
"""
@@ -1052,9 +1044,6 @@ class PowerPort(models.Model):
def __unicode__(self):
return self.name
- def get_parent_url(self):
- return self.device.get_absolute_url()
-
# Used for connections export
def csv_format(self):
return ','.join([
@@ -1090,22 +1079,6 @@ class PowerOutlet(models.Model):
def __unicode__(self):
return self.name
- def get_parent_url(self):
- return self.device.get_absolute_url()
-
-
-class InterfaceManager(models.Manager):
-
- def get_queryset(self):
- qs = super(InterfaceManager, self).get_queryset()
- return order_interfaces(qs, 'dcim_interface.name', ('device',))
-
- def virtual(self):
- return self.get_queryset().filter(form_factor=IFACE_FF_VIRTUAL)
-
- def physical(self):
- return self.get_queryset().exclude(form_factor=IFACE_FF_VIRTUAL)
-
class Interface(models.Model):
"""
@@ -1129,9 +1102,6 @@ class Interface(models.Model):
def __unicode__(self):
return self.name
- def get_parent_url(self):
- return self.device.get_absolute_url()
-
def clean(self):
if self.form_factor == IFACE_FF_VIRTUAL and self.is_connected:
@@ -1222,9 +1192,6 @@ class DeviceBay(models.Model):
def __unicode__(self):
return u'{} - {}'.format(self.device.name, self.name)
- def get_parent_url(self):
- return self.device.get_absolute_url()
-
def clean(self):
# Validate that the parent Device can have DeviceBays
@@ -1258,6 +1225,3 @@ class Module(models.Model):
def __unicode__(self):
return self.name
-
- def get_parent_url(self):
- return reverse('dcim:device_inventory', args=[self.device.pk])
diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py
index 94d359ac0..442e2f8fb 100644
--- a/netbox/dcim/tables.py
+++ b/netbox/dcim/tables.py
@@ -311,7 +311,8 @@ class DeviceTable(BaseTable):
status = tables.TemplateColumn(template_code=STATUS_ICON, verbose_name='')
name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
- site = tables.Column(accessor=Accessor('rack.site'), verbose_name='Site')
+ site = tables.LinkColumn('dcim:site', accessor=Accessor('rack.site'), args=[Accessor('rack.site.slug')],
+ verbose_name='Site')
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
device_role = tables.TemplateColumn(DEVICE_ROLE, verbose_name='Role')
device_type = tables.LinkColumn('dcim:devicetype', args=[Accessor('device_type.pk')], verbose_name='Type',
@@ -327,7 +328,8 @@ class DeviceTable(BaseTable):
class DeviceImportTable(BaseTable):
name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
- site = tables.Column(accessor=Accessor('rack.site'), verbose_name='Site')
+ site = tables.LinkColumn('dcim:site', accessor=Accessor('rack.site'), args=[Accessor('rack.site.slug')],
+ verbose_name='Site')
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
position = tables.Column(verbose_name='Position')
device_role = tables.Column(verbose_name='Role')
diff --git a/netbox/dcim/tests/test_apis.py b/netbox/dcim/tests/test_apis.py
index 1305d7e37..3cef01701 100644
--- a/netbox/dcim/tests/test_apis.py
+++ b/netbox/dcim/tests/test_apis.py
@@ -232,6 +232,7 @@ class DeviceTypeTest(APITestCase):
'part_number',
'u_height',
'is_full_depth',
+ 'interface_ordering',
'is_console_server',
'is_pdu',
'is_network_device',
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index 58c38bfb0..6b01c7bb8 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -163,7 +163,7 @@ class SiteEditView(PermissionRequiredMixin, ObjectEditView):
class SiteDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_site'
model = Site
- redirect_url = 'dcim:site_list'
+ default_return_url = 'dcim:site_list'
class SiteBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -199,8 +199,9 @@ class RackGroupEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_rackgroup'
model = RackGroup
form_class = forms.RackGroupForm
- obj_list_url = 'dcim:rackgroup_list'
- use_obj_view = False
+
+ def get_return_url(self, obj):
+ return reverse('dcim:rackgroup_list')
class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -224,8 +225,9 @@ class RackRoleEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_rackrole'
model = RackRole
form_class = forms.RackRoleForm
- obj_list_url = 'dcim:rackrole_list'
- use_obj_view = False
+
+ def get_return_url(self, obj):
+ return reverse('dcim:rackrole_list')
class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -278,7 +280,7 @@ class RackEditView(PermissionRequiredMixin, ObjectEditView):
class RackDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_rack'
model = Rack
- redirect_url = 'dcim:rack_list'
+ default_return_url = 'dcim:rack_list'
class RackBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -318,8 +320,9 @@ class ManufacturerEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_manufacturer'
model = Manufacturer
form_class = forms.ManufacturerForm
- obj_list_url = 'dcim:manufacturer_list'
- use_obj_view = False
+
+ def get_return_url(self, obj):
+ return reverse('dcim:manufacturer_list')
class ManufacturerBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -358,10 +361,14 @@ def devicetype(request, pk):
poweroutlet_table = tables.PowerOutletTemplateTable(
natsorted(PowerOutletTemplate.objects.filter(device_type=devicetype), key=attrgetter('name'))
)
- mgmt_interface_table = tables.InterfaceTemplateTable(InterfaceTemplate.objects.filter(device_type=devicetype,
- mgmt_only=True))
- interface_table = tables.InterfaceTemplateTable(InterfaceTemplate.objects.filter(device_type=devicetype,
- mgmt_only=False))
+ mgmt_interface_table = tables.InterfaceTemplateTable(
+ list(InterfaceTemplate.objects.order_naturally(devicetype.interface_ordering).filter(device_type=devicetype,
+ mgmt_only=True))
+ )
+ interface_table = tables.InterfaceTemplateTable(
+ list(InterfaceTemplate.objects.order_naturally(devicetype.interface_ordering).filter(device_type=devicetype,
+ mgmt_only=False))
+ )
devicebay_table = tables.DeviceBayTemplateTable(
natsorted(DeviceBayTemplate.objects.filter(device_type=devicetype), key=attrgetter('name'))
)
@@ -397,7 +404,7 @@ class DeviceTypeEditView(PermissionRequiredMixin, ObjectEditView):
class DeviceTypeDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_devicetype'
model = DeviceType
- redirect_url = 'dcim:devicetype_list'
+ default_return_url = 'dcim:devicetype_list'
class DeviceTypeBulkEditView(PermissionRequiredMixin, BulkEditView):
@@ -533,8 +540,9 @@ class DeviceRoleEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_devicerole'
model = DeviceRole
form_class = forms.DeviceRoleForm
- obj_list_url = 'dcim:devicerole_list'
- use_obj_view = False
+
+ def get_return_url(self, obj):
+ return reverse('dcim:devicerole_list')
class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -558,8 +566,9 @@ class PlatformEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_platform'
model = Platform
form_class = forms.PlatformForm
- obj_list_url = 'dcim:platform_list'
- use_obj_view = False
+
+ def get_return_url(self, obj):
+ return reverse('dcim:platform_list')
class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -597,16 +606,14 @@ def device(request, pk):
power_outlets = natsorted(
PowerOutlet.objects.filter(device=device).select_related('connected_port'), key=attrgetter('name')
)
- interfaces = Interface.objects.filter(device=device, mgmt_only=False).select_related(
- 'connected_as_a__interface_b__device',
- 'connected_as_b__interface_a__device',
- 'circuit_termination__circuit',
- )
- mgmt_interfaces = Interface.objects.filter(device=device, mgmt_only=True).select_related(
- 'connected_as_a__interface_b__device',
- 'connected_as_b__interface_a__device',
- 'circuit_termination__circuit',
- )
+ interfaces = Interface.objects.order_naturally(device.device_type.interface_ordering)\
+ .filter(device=device, mgmt_only=False)\
+ .select_related('connected_as_a__interface_b__device', 'connected_as_b__interface_a__device',
+ 'circuit_termination__circuit')
+ mgmt_interfaces = Interface.objects.order_naturally(device.device_type.interface_ordering)\
+ .filter(device=device, mgmt_only=True)\
+ .select_related('connected_as_a__interface_b__device', 'connected_as_b__interface_a__device',
+ 'circuit_termination__circuit')
device_bays = natsorted(
DeviceBay.objects.filter(device=device).select_related('installed_device__device_type__manufacturer'),
key=attrgetter('name')
@@ -665,7 +672,7 @@ class DeviceEditView(PermissionRequiredMixin, ObjectEditView):
class DeviceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_device'
model = Device
- redirect_url = 'dcim:device_list'
+ default_return_url = 'dcim:device_list'
class DeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -1500,10 +1507,12 @@ class ModuleEditView(PermissionRequiredMixin, ObjectEditView):
def alter_obj(self, obj, args, kwargs):
if 'device' in kwargs:
- device = get_object_or_404(Device, pk=kwargs['device'])
- obj.device = device
+ obj.device = get_object_or_404(Device, pk=kwargs['device'])
return obj
+ def get_return_url(self, obj):
+ return obj.device.get_absolute_url()
+
class ModuleDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_module'
diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py
index 7cb04cc60..2f6f0af84 100644
--- a/netbox/ipam/forms.py
+++ b/netbox/ipam/forms.py
@@ -215,6 +215,8 @@ class PrefixFromCSVForm(forms.ModelForm):
elif vlan_vid and site:
try:
self.instance.vlan = VLAN.objects.get(site=site, vid=vlan_vid)
+ except VLAN.DoesNotExist:
+ self.add_error('vlan_vid', "Invalid VLAN ID ({}) for site {}.".format(vlan_vid, site))
except VLAN.MultipleObjectsReturned:
self.add_error('vlan_vid', "Multiple VLANs found ({} - VID {})".format(site, vlan_vid))
elif vlan_vid:
@@ -334,7 +336,7 @@ class IPAddressForm(BootstrapMixin, CustomFieldForm):
class IPAddressBulkAddForm(BootstrapMixin, forms.Form):
address = ExpandableIPAddressField()
- vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF')
+ vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF', empty_label='Global')
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
status = forms.ChoiceField(choices=IPADDRESS_STATUS_CHOICES)
description = forms.CharField(max_length=100, required=False)
@@ -344,9 +346,11 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form):
site = forms.ModelChoiceField(queryset=Site.objects.all(), label='Site', required=False,
widget=forms.Select(attrs={'filter-for': 'rack'}))
rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False,
- widget=APISelect(api_url='/api/dcim/racks/?site_id={{site}}', display_field='display_name', attrs={'filter-for': 'device'}))
+ widget=APISelect(api_url='/api/dcim/racks/?site_id={{site}}',
+ display_field='display_name', attrs={'filter-for': 'device'}))
device = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False,
- widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}', display_field='display_name', attrs={'filter-for': 'interface'}))
+ widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}',
+ display_field='display_name', attrs={'filter-for': 'interface'}))
livesearch = forms.CharField(required=False, label='Device', widget=Livesearch(
query_key='q', query_url='dcim-api:device_list', field_to_update='device')
)
diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py
index fa42d68f1..c8afc6402 100644
--- a/netbox/ipam/models.py
+++ b/netbox/ipam/models.py
@@ -298,10 +298,14 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
def get_absolute_url(self):
return reverse('ipam:prefix', args=[self.pk])
+ def get_duplicates(self):
+ return Prefix.objects.filter(vrf=self.vrf, prefix=str(self.prefix)).exclude(pk=self.pk)
+
def clean(self):
- # Disallow host masks
if self.prefix:
+
+ # Disallow host masks
if self.prefix.version == 4 and self.prefix.prefixlen == 32:
raise ValidationError({
'prefix': "Cannot create host addresses (/32) as prefixes. Create an IPv4 address instead."
@@ -311,6 +315,17 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
'prefix': "Cannot create host addresses (/128) as prefixes. Create an IPv6 address instead."
})
+ # Enforce unique IP space (if applicable)
+ if (self.vrf is None and settings.ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique):
+ duplicate_prefixes = self.get_duplicates()
+ if duplicate_prefixes:
+ raise ValidationError({
+ 'prefix': "Duplicate prefix found in {}: {}".format(
+ "VRF {}".format(self.vrf) if self.vrf else "global table",
+ duplicate_prefixes.first(),
+ )
+ })
+
def save(self, *args, **kwargs):
if self.prefix:
# Clear host bits from prefix
@@ -400,23 +415,23 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
def get_absolute_url(self):
return reverse('ipam:ipaddress', args=[self.pk])
+ def get_duplicates(self):
+ return IPAddress.objects.filter(vrf=self.vrf, address__net_host=str(self.address.ip)).exclude(pk=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({
- 'address': "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({
- 'address': "Duplicate IP address found in global table: {}".format(duplicate_ips.first())
- })
+ if self.address:
+
+ # Enforce unique IP space (if applicable)
+ if (self.vrf is None and settings.ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique):
+ duplicate_ips = self.get_duplicates()
+ if duplicate_ips:
+ raise ValidationError({
+ 'address': "Duplicate IP address found in {}: {}".format(
+ "VRF {}".format(self.vrf) if self.vrf else "global table",
+ duplicate_ips.first(),
+ )
+ })
def save(self, *args, **kwargs):
if self.address:
@@ -563,6 +578,3 @@ class Service(CreatedUpdatedModel):
def __unicode__(self):
return u'{} ({}/{})'.format(self.name, self.port, self.get_protocol_display())
-
- def get_parent_url(self):
- return self.device.get_absolute_url()
diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py
index 2c04b97c3..f4ceffd60 100644
--- a/netbox/ipam/tables.py
+++ b/netbox/ipam/tables.py
@@ -136,7 +136,7 @@ class VRFTable(BaseTable):
name = tables.LinkColumn('ipam:vrf', args=[Accessor('pk')], verbose_name='Name')
rd = tables.Column(verbose_name='RD')
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
- description = tables.Column(orderable=False, verbose_name='Description')
+ description = tables.Column(verbose_name='Description')
class Meta(BaseTable.Meta):
model = VRF
@@ -182,7 +182,7 @@ class AggregateTable(BaseTable):
child_count = tables.Column(verbose_name='Prefixes')
get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
date_added = tables.DateColumn(format="Y-m-d", verbose_name='Added')
- description = tables.Column(orderable=False, verbose_name='Description')
+ description = tables.Column(verbose_name='Description')
class Meta(BaseTable.Meta):
model = Aggregate
@@ -219,7 +219,7 @@ class PrefixTable(BaseTable):
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
vlan = tables.LinkColumn('ipam:vlan', args=[Accessor('vlan.pk')], verbose_name='VLAN')
role = tables.TemplateColumn(PREFIX_ROLE_LINK, verbose_name='Role')
- description = tables.Column(orderable=False, verbose_name='Description')
+ description = tables.Column(verbose_name='Description')
class Meta(BaseTable.Meta):
model = Prefix
@@ -255,7 +255,7 @@ class IPAddressTable(BaseTable):
device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False,
verbose_name='Device')
interface = tables.Column(orderable=False, verbose_name='Interface')
- description = tables.Column(orderable=False, verbose_name='Description')
+ description = tables.Column(verbose_name='Description')
class Meta(BaseTable.Meta):
model = IPAddress
@@ -310,7 +310,8 @@ class VLANTable(BaseTable):
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
role = tables.TemplateColumn(VLAN_ROLE_LINK, verbose_name='Role')
+ description = tables.Column(verbose_name='Description')
class Meta(BaseTable.Meta):
model = VLAN
- fields = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role')
+ fields = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role', 'description')
diff --git a/netbox/ipam/tests/__init__.py b/netbox/ipam/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/netbox/ipam/tests/test_models.py b/netbox/ipam/tests/test_models.py
new file mode 100644
index 000000000..3385c643f
--- /dev/null
+++ b/netbox/ipam/tests/test_models.py
@@ -0,0 +1,60 @@
+import netaddr
+
+from django.test import TestCase, override_settings
+
+from ipam.models import IPAddress, Prefix, VRF
+from django.core.exceptions import ValidationError
+
+
+class TestPrefix(TestCase):
+
+ @override_settings(ENFORCE_GLOBAL_UNIQUE=False)
+ def test_duplicate_global(self):
+ Prefix.objects.create(prefix=netaddr.IPNetwork('192.0.2.0/24'))
+ duplicate_prefix = Prefix(prefix=netaddr.IPNetwork('192.0.2.0/24'))
+ self.assertIsNone(duplicate_prefix.clean())
+
+ @override_settings(ENFORCE_GLOBAL_UNIQUE=True)
+ def test_duplicate_global_unique(self):
+ Prefix.objects.create(prefix=netaddr.IPNetwork('192.0.2.0/24'))
+ duplicate_prefix = Prefix(prefix=netaddr.IPNetwork('192.0.2.0/24'))
+ self.assertRaises(ValidationError, duplicate_prefix.clean)
+
+ def test_duplicate_vrf(self):
+ vrf = VRF.objects.create(name='Test', rd='1:1', enforce_unique=False)
+ Prefix.objects.create(vrf=vrf, prefix=netaddr.IPNetwork('192.0.2.0/24'))
+ duplicate_prefix = Prefix(vrf=vrf, prefix=netaddr.IPNetwork('192.0.2.0/24'))
+ self.assertIsNone(duplicate_prefix.clean())
+
+ def test_duplicate_vrf_unique(self):
+ vrf = VRF.objects.create(name='Test', rd='1:1', enforce_unique=True)
+ Prefix.objects.create(vrf=vrf, prefix=netaddr.IPNetwork('192.0.2.0/24'))
+ duplicate_prefix = Prefix(vrf=vrf, prefix=netaddr.IPNetwork('192.0.2.0/24'))
+ self.assertRaises(ValidationError, duplicate_prefix.clean)
+
+
+class TestIPAddress(TestCase):
+
+ @override_settings(ENFORCE_GLOBAL_UNIQUE=False)
+ def test_duplicate_global(self):
+ IPAddress.objects.create(address=netaddr.IPNetwork('192.0.2.1/24'))
+ duplicate_ip = IPAddress(address=netaddr.IPNetwork('192.0.2.1/24'))
+ self.assertIsNone(duplicate_ip.clean())
+
+ @override_settings(ENFORCE_GLOBAL_UNIQUE=True)
+ def test_duplicate_global_unique(self):
+ IPAddress.objects.create(address=netaddr.IPNetwork('192.0.2.1/24'))
+ duplicate_ip = IPAddress(address=netaddr.IPNetwork('192.0.2.1/24'))
+ self.assertRaises(ValidationError, duplicate_ip.clean)
+
+ def test_duplicate_vrf(self):
+ vrf = VRF.objects.create(name='Test', rd='1:1', enforce_unique=False)
+ IPAddress.objects.create(vrf=vrf, address=netaddr.IPNetwork('192.0.2.1/24'))
+ duplicate_ip = IPAddress(vrf=vrf, address=netaddr.IPNetwork('192.0.2.1/24'))
+ self.assertIsNone(duplicate_ip.clean())
+
+ def test_duplicate_vrf_unique(self):
+ vrf = VRF.objects.create(name='Test', rd='1:1', enforce_unique=True)
+ IPAddress.objects.create(vrf=vrf, address=netaddr.IPNetwork('192.0.2.1/24'))
+ duplicate_ip = IPAddress(vrf=vrf, address=netaddr.IPNetwork('192.0.2.1/24'))
+ self.assertRaises(ValidationError, duplicate_ip.clean)
diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py
index c7fc4ea31..4182532ab 100644
--- a/netbox/ipam/views.py
+++ b/netbox/ipam/views.py
@@ -102,8 +102,10 @@ class VRFListView(ObjectListView):
def vrf(request, pk):
vrf = get_object_or_404(VRF.objects.all(), pk=pk)
- prefixes = Prefix.objects.filter(vrf=vrf)
- prefix_table = tables.PrefixBriefTable(prefixes)
+ prefix_table = tables.PrefixBriefTable(
+ list(Prefix.objects.filter(vrf=vrf).select_related('site', 'role'))
+ )
+ prefix_table.exclude = ('vrf',)
return render(request, 'ipam/vrf.html', {
'vrf': vrf,
@@ -122,7 +124,7 @@ class VRFEditView(PermissionRequiredMixin, ObjectEditView):
class VRFDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_vrf'
model = VRF
- redirect_url = 'ipam:vrf_list'
+ default_return_url = 'ipam:vrf_list'
class VRFBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -240,8 +242,9 @@ class RIREditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_rir'
model = RIR
form_class = forms.RIRForm
- obj_list_url = 'ipam:rir_list'
- use_obj_view = False
+
+ def get_return_url(self, obj):
+ return reverse('ipam:rir_list')
class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -311,7 +314,7 @@ class AggregateEditView(PermissionRequiredMixin, ObjectEditView):
class AggregateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_aggregate'
model = Aggregate
- redirect_url = 'ipam:aggregate_list'
+ default_return_url = 'ipam:aggregate_list'
class AggregateBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -351,8 +354,9 @@ class RoleEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_role'
model = Role
form_class = forms.RoleForm
- obj_list_url = 'ipam:role_list'
- use_obj_view = False
+
+ def get_return_url(self, obj):
+ return reverse('ipam:role_list')
class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -401,7 +405,7 @@ def prefix(request, pk):
# Duplicate prefixes table
duplicate_prefixes = Prefix.objects.filter(vrf=prefix.vrf, prefix=str(prefix.prefix)).exclude(pk=prefix.pk)\
.select_related('site', 'role')
- duplicate_prefix_table = tables.PrefixBriefTable(duplicate_prefixes)
+ duplicate_prefix_table = tables.PrefixBriefTable(list(duplicate_prefixes))
# Child prefixes table
if prefix.vrf:
@@ -441,7 +445,8 @@ class PrefixEditView(PermissionRequiredMixin, ObjectEditView):
class PrefixDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_prefix'
model = Prefix
- redirect_url = 'ipam:prefix_list'
+ default_return_url = 'ipam:prefix_list'
+ template_name = 'ipam/prefix_delete.html'
class PrefixBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -504,18 +509,20 @@ def ipaddress(request, pk):
ipaddress = get_object_or_404(IPAddress.objects.select_related('interface__device'), pk=pk)
# Parent prefixes table
- parent_prefixes = Prefix.objects.filter(vrf=ipaddress.vrf, prefix__net_contains=str(ipaddress.address.ip))
- parent_prefixes_table = tables.PrefixBriefTable(parent_prefixes)
+ parent_prefixes = Prefix.objects.filter(vrf=ipaddress.vrf, prefix__net_contains=str(ipaddress.address.ip))\
+ .select_related('site', 'role')
+ parent_prefixes_table = tables.PrefixBriefTable(list(parent_prefixes))
+ parent_prefixes_table.exclude = ('vrf',)
# Duplicate IPs table
duplicate_ips = IPAddress.objects.filter(vrf=ipaddress.vrf, address=str(ipaddress.address))\
.exclude(pk=ipaddress.pk).select_related('interface__device', 'nat_inside')
- duplicate_ips_table = tables.IPAddressBriefTable(duplicate_ips)
+ duplicate_ips_table = tables.IPAddressBriefTable(list(duplicate_ips))
# Related IP table
related_ips = IPAddress.objects.select_related('interface__device').exclude(address=str(ipaddress.address))\
.filter(vrf=ipaddress.vrf, address__net_contained_or_equal=str(ipaddress.address))
- related_ips_table = tables.IPAddressBriefTable(related_ips)
+ related_ips_table = tables.IPAddressBriefTable(list(related_ips))
return render(request, 'ipam/ipaddress.html', {
'ipaddress': ipaddress,
@@ -604,7 +611,7 @@ class IPAddressEditView(PermissionRequiredMixin, ObjectEditView):
class IPAddressDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_ipaddress'
model = IPAddress
- redirect_url = 'ipam:ipaddress_list'
+ default_return_url = 'ipam:ipaddress_list'
class IPAddressBulkAddView(PermissionRequiredMixin, BulkAddView):
@@ -669,8 +676,9 @@ class VLANGroupEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_vlangroup'
model = VLANGroup
form_class = forms.VLANGroupForm
- obj_list_url = 'ipam:vlangroup_list'
- use_obj_view = False
+
+ def get_return_url(self, obj):
+ return reverse('ipam:vlangroup_list')
class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -695,8 +703,8 @@ class VLANListView(ObjectListView):
def vlan(request, pk):
vlan = get_object_or_404(VLAN.objects.select_related('site', 'role'), pk=pk)
- prefixes = Prefix.objects.filter(vlan=vlan)
- prefix_table = tables.PrefixBriefTable(prefixes)
+ prefixes = Prefix.objects.filter(vlan=vlan).select_related('vrf', 'site', 'role')
+ prefix_table = tables.PrefixBriefTable(list(prefixes))
return render(request, 'ipam/vlan.html', {
'vlan': vlan,
@@ -715,7 +723,7 @@ class VLANEditView(PermissionRequiredMixin, ObjectEditView):
class VLANDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_vlan'
model = VLAN
- redirect_url = 'ipam:vlan_list'
+ default_return_url = 'ipam:vlan_list'
class VLANBulkImportView(PermissionRequiredMixin, BulkImportView):
@@ -755,6 +763,9 @@ class ServiceEditView(PermissionRequiredMixin, ObjectEditView):
obj.device = get_object_or_404(Device, pk=kwargs['device'])
return obj
+ def get_return_url(self, obj):
+ return obj.device.get_absolute_url()
+
class ServiceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_service'
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index 9ea64e6b7..ad21789ec 100644
--- a/netbox/netbox/settings.py
+++ b/netbox/netbox/settings.py
@@ -12,7 +12,7 @@ except ImportError:
"the documentation.")
-VERSION = '1.8.1'
+VERSION = '1.8.2'
# Import local configuration
for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py
index 7880adfb2..7fd3b4380 100644
--- a/netbox/secrets/views.py
+++ b/netbox/secrets/views.py
@@ -30,8 +30,9 @@ class SecretRoleEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'secrets.change_secretrole'
model = SecretRole
form_class = forms.SecretRoleForm
- obj_list_url = 'secrets:secretrole_list'
- use_obj_view = False
+
+ def get_return_url(self, obj):
+ return reverse('secrets:secretrole_list')
class SecretRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -151,7 +152,7 @@ def secret_edit(request, pk):
class SecretDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'secrets.delete_secret'
model = Secret
- redirect_url = 'secrets:secret_list'
+ default_return_url = 'secrets:secret_list'
@permission_required('secrets.add_secret')
diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html
index 5591c64b9..ab54b45a5 100644
--- a/netbox/templates/circuits/circuit.html
+++ b/netbox/templates/circuits/circuit.html
@@ -92,6 +92,16 @@
{% endif %}
+
+ Description
+
+ {% if circuit.description %}
+ {{ circuit.description }}
+ {% else %}
+ N/A
+ {% endif %}
+
+
{% with circuit.get_custom_fields as custom_fields %}
diff --git a/netbox/templates/circuits/circuit_edit.html b/netbox/templates/circuits/circuit_edit.html
index 67d18d1ae..6b5e4497d 100644
--- a/netbox/templates/circuits/circuit_edit.html
+++ b/netbox/templates/circuits/circuit_edit.html
@@ -11,6 +11,7 @@
{% render_field form.tenant %}
{% render_field form.install_date %}
{% render_field form.commit_rate %}
+ {% render_field form.description %}
{% if form.custom_fields %}
diff --git a/netbox/templates/circuits/circuit_import.html b/netbox/templates/circuits/circuit_import.html
index fec364b60..fec7ff65e 100644
--- a/netbox/templates/circuits/circuit_import.html
+++ b/netbox/templates/circuits/circuit_import.html
@@ -58,10 +58,15 @@
Commited rate in Kbps (optional)
2000
+
+ Description
+ Short description (optional)
+ Primary for voice
+
Example
- IC-603122,TeliaSonera,Transit,Strickland Propane,2016-02-23,2000
+ IC-603122,TeliaSonera,Transit,Strickland Propane,2016-02-23,2000,Primary for voice
{% endblock %}
diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html
index 7c641975c..ba0f8b5fe 100644
--- a/netbox/templates/circuits/inc/circuit_termination.html
+++ b/netbox/templates/circuits/inc/circuit_termination.html
@@ -15,7 +15,7 @@
{% endif %}
{% if termination and perms.circuits.delete_circuittermination %}
-
+
Delete
{% endif %}
diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html
index 5bfb70cd3..53273da47 100644
--- a/netbox/templates/dcim/device.html
+++ b/netbox/templates/dcim/device.html
@@ -6,7 +6,7 @@
{% block title %}{{ device }}{% endblock %}
{% block content %}
-{% include 'dcim/inc/_device_header.html' with active_tab='info' %}
+{% include 'dcim/inc/device_header.html' with active_tab='info' %}
@@ -183,7 +183,7 @@
{% if ip_addresses %}
{% for ip in ip_addresses %}
- {% include 'dcim/inc/_ipaddress.html' %}
+ {% include 'dcim/inc/ipaddress.html' %}
{% endfor %}
{% elif interfaces or mgmt_interfaces %}
@@ -212,7 +212,7 @@
{% if services %}
{% for service in services %}
- {% include 'dcim/inc/_service.html' %}
+ {% include 'dcim/inc/service.html' %}
{% endfor %}
{% else %}
@@ -234,7 +234,7 @@
{% for iface in mgmt_interfaces %}
- {% include 'dcim/inc/_interface.html' with icon='wrench' %}
+ {% include 'dcim/inc/interface.html' with icon='wrench' %}
{% empty %}
@@ -246,7 +246,7 @@
{% endfor %}
{% for cp in console_ports %}
- {% include 'dcim/inc/_consoleport.html' %}
+ {% include 'dcim/inc/consoleport.html' %}
{% empty %}
@@ -258,7 +258,7 @@
{% endfor %}
{% for pp in power_ports %}
- {% include 'dcim/inc/_powerport.html' %}
+ {% include 'dcim/inc/powerport.html' %}
{% empty %}
@@ -349,7 +349,7 @@
{% for devicebay in device_bays %}
- {% include 'dcim/inc/_devicebay.html' with selectable=True %}
+ {% include 'dcim/inc/devicebay.html' with selectable=True %}
{% empty %}
No device bays defined
@@ -401,7 +401,7 @@
{% for iface in interfaces %}
- {% include 'dcim/inc/_interface.html' with selectable=True %}
+ {% include 'dcim/inc/interface.html' with selectable=True %}
{% empty %}
No interfaces defined
@@ -458,7 +458,7 @@
{% for csp in cs_ports %}
- {% include 'dcim/inc/_consoleserverport.html' with selectable=True %}
+ {% include 'dcim/inc/consoleserverport.html' with selectable=True %}
{% empty %}
No console server ports defined
@@ -510,7 +510,7 @@
{% for po in power_outlets %}
- {% include 'dcim/inc/_poweroutlet.html' with selectable=True %}
+ {% include 'dcim/inc/poweroutlet.html' with selectable=True %}
{% empty %}
No power outlets defined
diff --git a/netbox/templates/dcim/device_import.html b/netbox/templates/dcim/device_import.html
index a603ab4ef..c075abe34 100644
--- a/netbox/templates/dcim/device_import.html
+++ b/netbox/templates/dcim/device_import.html
@@ -5,7 +5,7 @@
{% block title %}Device Import{% endblock %}
{% block content %}
-{% include 'dcim/inc/_device_import_header.html' %}
+{% include 'dcim/inc/device_import_header.html' %}
@@ -80,10 +80,10 @@
{{ m2.serial }}
{% if perms.dcim.change_module %}
-
+
{% endif %}
{% if perms.dcim.delete_module %}
-
+
{% endif %}
@@ -96,10 +96,10 @@
{{ m3.serial }}
{% if perms.dcim.change_module %}
-
+
{% endif %}
{% if perms.dcim.delete_module %}
-
+
{% endif %}
@@ -112,10 +112,10 @@
{{ m4.serial }}
{% if perms.dcim.change_module %}
-
+
{% endif %}
{% if perms.dcim.delete_module %}
-
+
{% endif %}
diff --git a/netbox/templates/dcim/device_lldp_neighbors.html b/netbox/templates/dcim/device_lldp_neighbors.html
index 415456a29..6a5446c3b 100644
--- a/netbox/templates/dcim/device_lldp_neighbors.html
+++ b/netbox/templates/dcim/device_lldp_neighbors.html
@@ -3,7 +3,7 @@
{% block title %}{{ device }} - LLDP Neighbors{% endblock %}
{% block content %}
-{% include 'dcim/inc/_device_header.html' with active_tab='lldp-neighbors' %}
+{% include 'dcim/inc/device_header.html' with active_tab='lldp-neighbors' %}
LLDP Neighbors
diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html
index 9bc16d146..a9a9fa130 100644
--- a/netbox/templates/dcim/devicetype.html
+++ b/netbox/templates/dcim/devicetype.html
@@ -72,6 +72,10 @@
{% endif %}
+
+ Interface Ordering
+ {{ devicetype.get_interface_ordering_display }}
+
Instances
{{ devicetype.instances.count }}
diff --git a/netbox/templates/dcim/devicetype_edit.html b/netbox/templates/dcim/devicetype_edit.html
index 929da06b8..d2a107607 100644
--- a/netbox/templates/dcim/devicetype_edit.html
+++ b/netbox/templates/dcim/devicetype_edit.html
@@ -11,6 +11,12 @@
{% render_field form.part_number %}
{% render_field form.u_height %}
{% render_field form.is_full_depth %}
+ {% render_field form.interface_ordering %}
+
+
+
+
Function
+
{% render_field form.is_console_server %}
{% render_field form.is_pdu %}
{% render_field form.is_network_device %}
diff --git a/netbox/templates/dcim/inc/_consoleport.html b/netbox/templates/dcim/inc/consoleport.html
similarity index 93%
rename from netbox/templates/dcim/inc/_consoleport.html
rename to netbox/templates/dcim/inc/consoleport.html
index 43b353bb1..ebe96a660 100644
--- a/netbox/templates/dcim/inc/_consoleport.html
+++ b/netbox/templates/dcim/inc/consoleport.html
@@ -1,5 +1,5 @@
- {% if selectable and perms.dcim.delete_consoleport %}
+ {% if selectable and perms.dcim.change_consoleport or perms.dcim.delete_consoleport %}
@@ -50,7 +50,7 @@
{% else %}
-
+
{% endif %}
diff --git a/netbox/templates/dcim/inc/_consoleserverport.html b/netbox/templates/dcim/inc/consoleserverport.html
similarity index 93%
rename from netbox/templates/dcim/inc/_consoleserverport.html
rename to netbox/templates/dcim/inc/consoleserverport.html
index b9c5e8e59..848bb8f9d 100644
--- a/netbox/templates/dcim/inc/_consoleserverport.html
+++ b/netbox/templates/dcim/inc/consoleserverport.html
@@ -1,5 +1,5 @@
- {% if selectable and perms.dcim.delete_consoleserverport %}
+ {% if selectable and perms.dcim.change_consoleserverport or perms.dcim.delete_consoleserverport %}
@@ -49,7 +49,7 @@
{% else %}
-
+
{% endif %}
diff --git a/netbox/templates/dcim/inc/_device_header.html b/netbox/templates/dcim/inc/device_header.html
similarity index 100%
rename from netbox/templates/dcim/inc/_device_header.html
rename to netbox/templates/dcim/inc/device_header.html
diff --git a/netbox/templates/dcim/inc/_device_import_header.html b/netbox/templates/dcim/inc/device_import_header.html
similarity index 100%
rename from netbox/templates/dcim/inc/_device_import_header.html
rename to netbox/templates/dcim/inc/device_import_header.html
diff --git a/netbox/templates/dcim/inc/_devicebay.html b/netbox/templates/dcim/inc/devicebay.html
similarity index 92%
rename from netbox/templates/dcim/inc/_devicebay.html
rename to netbox/templates/dcim/inc/devicebay.html
index 8996cd225..bc0934283 100644
--- a/netbox/templates/dcim/inc/_devicebay.html
+++ b/netbox/templates/dcim/inc/devicebay.html
@@ -1,5 +1,5 @@
- {% if selectable and perms.dcim.delete_devicebay %}
+ {% if selectable and perms.dcim.change_devicebay or perms.dcim.delete_devicebay %}
@@ -40,7 +40,7 @@
{% else %}
-
+
{% endif %}
diff --git a/netbox/templates/dcim/inc/_interface.html b/netbox/templates/dcim/inc/interface.html
similarity index 96%
rename from netbox/templates/dcim/inc/_interface.html
rename to netbox/templates/dcim/inc/interface.html
index dc7ac4ecf..d249b8b6e 100644
--- a/netbox/templates/dcim/inc/_interface.html
+++ b/netbox/templates/dcim/inc/interface.html
@@ -1,5 +1,5 @@
- {% if selectable and perms.dcim.delete_interface %}
+ {% if selectable and perms.dcim.change_interface or perms.dcim.delete_interface %}
@@ -85,7 +85,7 @@
{% else %}
-
+
{% endif %}
diff --git a/netbox/templates/dcim/inc/_ipaddress.html b/netbox/templates/dcim/inc/ipaddress.html
similarity index 80%
rename from netbox/templates/dcim/inc/_ipaddress.html
rename to netbox/templates/dcim/inc/ipaddress.html
index 7bdc8bc1e..72920986e 100644
--- a/netbox/templates/dcim/inc/_ipaddress.html
+++ b/netbox/templates/dcim/inc/ipaddress.html
@@ -13,7 +13,7 @@
{% if perms.ipam.delete_ipaddress %}
-
+
{% endif %}
diff --git a/netbox/templates/dcim/inc/_poweroutlet.html b/netbox/templates/dcim/inc/poweroutlet.html
similarity index 93%
rename from netbox/templates/dcim/inc/_poweroutlet.html
rename to netbox/templates/dcim/inc/poweroutlet.html
index 18619a37d..929c9c903 100644
--- a/netbox/templates/dcim/inc/_poweroutlet.html
+++ b/netbox/templates/dcim/inc/poweroutlet.html
@@ -1,5 +1,5 @@
- {% if selectable and perms.dcim.delete_poweroutlet %}
+ {% if selectable and perms.dcim.change_poweroutlet or perms.dcim.delete_poweroutlet %}
@@ -49,7 +49,7 @@
{% else %}
-
+
{% endif %}
diff --git a/netbox/templates/dcim/inc/_powerport.html b/netbox/templates/dcim/inc/powerport.html
similarity index 94%
rename from netbox/templates/dcim/inc/_powerport.html
rename to netbox/templates/dcim/inc/powerport.html
index 7d519599d..c06d38b5a 100644
--- a/netbox/templates/dcim/inc/_powerport.html
+++ b/netbox/templates/dcim/inc/powerport.html
@@ -1,5 +1,5 @@
- {% if selectable and perms.dcim.delete_powerport %}
+ {% if selectable and perms.dcim.change_powerport or perms.dcim.delete_powerport %}
@@ -50,7 +50,7 @@
{% else %}
-
+
{% endif %}
diff --git a/netbox/templates/dcim/inc/_rack_elevation.html b/netbox/templates/dcim/inc/rack_elevation.html
similarity index 100%
rename from netbox/templates/dcim/inc/_rack_elevation.html
rename to netbox/templates/dcim/inc/rack_elevation.html
diff --git a/netbox/templates/dcim/inc/_service.html b/netbox/templates/dcim/inc/service.html
similarity index 92%
rename from netbox/templates/dcim/inc/_service.html
rename to netbox/templates/dcim/inc/service.html
index 28cd64094..1e42a1811 100644
--- a/netbox/templates/dcim/inc/_service.html
+++ b/netbox/templates/dcim/inc/service.html
@@ -18,7 +18,7 @@
{% endif %}
{% if perms.ipam.delete_service %}
-
+
{% endif %}
diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html
index 2d0e065fd..d73a0b560 100644
--- a/netbox/templates/dcim/rack.html
+++ b/netbox/templates/dcim/rack.html
@@ -195,13 +195,13 @@
- {% include 'dcim/inc/_rack_elevation.html' with primary_face=front_elevation secondary_face=rear_elevation face_id=0 %}
+ {% include 'dcim/inc/rack_elevation.html' with primary_face=front_elevation secondary_face=rear_elevation face_id=0 %}
- {% include 'dcim/inc/_rack_elevation.html' with primary_face=rear_elevation secondary_face=front_elevation face_id=1 %}
+ {% include 'dcim/inc/rack_elevation.html' with primary_face=rear_elevation secondary_face=front_elevation face_id=1 %}
diff --git a/netbox/templates/ipam/prefix_delete.html b/netbox/templates/ipam/prefix_delete.html
new file mode 100644
index 000000000..5ea39dc4c
--- /dev/null
+++ b/netbox/templates/ipam/prefix_delete.html
@@ -0,0 +1,5 @@
+{% extends 'utilities/obj_delete.html' %}
+
+{% block message_extra %}
+ Note: This will not delete any child prefixes or IP addresses.
+{% endblock %}
diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py
index bde6f3345..6d22561ef 100644
--- a/netbox/tenancy/api/serializers.py
+++ b/netbox/tenancy/api/serializers.py
@@ -30,7 +30,7 @@ class TenantSerializer(CustomFieldSerializer, serializers.ModelSerializer):
class Meta:
model = Tenant
- fields = ['id', 'name', 'slug', 'group', 'comments', 'custom_fields']
+ fields = ['id', 'name', 'slug', 'group', 'description', 'comments', 'custom_fields']
class TenantNestedSerializer(TenantSerializer):
diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py
index 821f6d7bf..e8c772fc3 100644
--- a/netbox/tenancy/views.py
+++ b/netbox/tenancy/views.py
@@ -1,4 +1,5 @@
from django.contrib.auth.mixins import PermissionRequiredMixin
+from django.core.urlresolvers import reverse
from django.db.models import Count, Q
from django.shortcuts import get_object_or_404, render
@@ -28,8 +29,9 @@ class TenantGroupEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'tenancy.change_tenantgroup'
model = TenantGroup
form_class = forms.TenantGroupForm
- obj_list_url = 'tenancy:tenantgroup_list'
- use_obj_view = False
+
+ def get_return_url(self, obj):
+ return reverse('tenancy:tenantgroup_list')
class TenantGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -89,7 +91,7 @@ class TenantEditView(PermissionRequiredMixin, ObjectEditView):
class TenantDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'tenancy.delete_tenant'
model = Tenant
- redirect_url = 'tenancy:tenant_list'
+ default_return_url = 'tenancy:tenant_list'
class TenantBulkImportView(PermissionRequiredMixin, BulkImportView):
diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py
index 9351c6044..f957f8e88 100644
--- a/netbox/utilities/forms.py
+++ b/netbox/utilities/forms.py
@@ -386,7 +386,12 @@ class BootstrapMixin(forms.BaseForm):
class ConfirmationForm(BootstrapMixin, forms.Form):
+ """
+ A generic confirmation form. The form is not valid unless the confirm field is checked. An optional return_url can
+ be specified to direct the user to a specific URL after the action has been taken.
+ """
confirm = forms.BooleanField(required=True)
+ return_url = forms.CharField(required=False, widget=forms.HiddenInput())
class BulkEditForm(forms.Form):
diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py
index 3422b2d9d..528f11c6c 100644
--- a/netbox/utilities/views.py
+++ b/netbox/utilities/views.py
@@ -8,7 +8,7 @@ from django.core.urlresolvers import reverse
from django.db import transaction, IntegrityError
from django.db.models import ProtectedError
from django.forms import CharField, ModelMultipleChoiceField, MultipleHiddenInput, TypedChoiceField
-from django.http import HttpResponse, HttpResponseRedirect
+from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.template import TemplateSyntaxError
from django.utils.http import is_safe_url
@@ -127,15 +127,12 @@ class ObjectEditView(View):
fields_initial: A set of fields that will be prepopulated in the form from the request parameters
template_name: The name of the template
obj_list_url: The name of the URL used to display a list of this object type
- use_obj_view: If True, the user will be directed to a view of the object after it has been edited. Otherwise, the
- user will be directed to the object's list view (defined as `obj_list_url`).
"""
model = None
form_class = None
fields_initial = []
template_name = 'utilities/obj_edit.html'
obj_list_url = None
- use_obj_view = True
def get_object(self, kwargs):
# Look up object by slug or PK. Return None if neither was provided.
@@ -150,13 +147,13 @@ class ObjectEditView(View):
# given some parameter from the request URI.
return obj
- def get_redirect_url(self, obj):
+ def get_return_url(self, obj):
# Determine where to redirect the user after updating an object (or aborting an update).
- if obj.pk and self.use_obj_view and hasattr(obj, 'get_absolute_url'):
+ if obj.pk and hasattr(obj, 'get_absolute_url'):
return obj.get_absolute_url()
- if obj and self.use_obj_view and hasattr(obj, 'get_parent_url'):
- return obj.get_parent_url()
- return reverse(self.obj_list_url)
+ if self.obj_list_url is not None:
+ return reverse(self.obj_list_url)
+ return reverse('home')
def get(self, request, *args, **kwargs):
@@ -169,7 +166,7 @@ class ObjectEditView(View):
'obj': obj,
'obj_type': self.model._meta.verbose_name,
'form': form,
- 'cancel_url': self.get_redirect_url(obj),
+ 'cancel_url': self.get_return_url(obj),
})
def post(self, request, *args, **kwargs):
@@ -200,13 +197,13 @@ class ObjectEditView(View):
if '_addanother' in request.POST:
return redirect(request.path)
- return redirect(self.get_redirect_url(obj))
+ return redirect(self.get_return_url(obj))
return render(request, self.template_name, {
'obj': obj,
'obj_type': self.model._meta.verbose_name,
'form': form,
- 'cancel_url': self.get_redirect_url(obj),
+ 'cancel_url': self.get_return_url(obj),
})
@@ -216,11 +213,11 @@ class ObjectDeleteView(View):
model: The model of the object being edited
template_name: The name of the template
- redirect_url: Name of the URL to which the user is redirected after deleting the object
+ default_return_url: Name of the URL to which the user is redirected after deleting the object
"""
model = None
template_name = 'utilities/obj_delete.html'
- redirect_url = None
+ default_return_url = 'home'
def get_object(self, kwargs):
# Look up object by slug if one has been provided. Otherwise, use PK.
@@ -232,20 +229,21 @@ class ObjectDeleteView(View):
def get_cancel_url(self, obj):
if hasattr(obj, 'get_absolute_url'):
return obj.get_absolute_url()
- if hasattr(obj, 'get_parent_url'):
- return obj.get_parent_url()
return reverse('home')
def get(self, request, **kwargs):
obj = self.get_object(kwargs)
- form = ConfirmationForm()
+ initial_data = {
+ 'return_url': request.GET.get('return_url'),
+ }
+ form = ConfirmationForm(initial=initial_data)
return render(request, self.template_name, {
'obj': obj,
'form': form,
'obj_type': self.model._meta.verbose_name,
- 'cancel_url': self.get_cancel_url(obj),
+ 'cancel_url': request.GET.get('return_url') or self.get_cancel_url(obj),
})
def post(self, request, **kwargs):
@@ -253,26 +251,28 @@ class ObjectDeleteView(View):
obj = self.get_object(kwargs)
form = ConfirmationForm(request.POST)
if form.is_valid():
+
try:
obj.delete()
except ProtectedError as e:
handle_protectederror(obj, request, e)
return redirect(obj.get_absolute_url())
+
msg = u'Deleted {} {}'.format(self.model._meta.verbose_name, obj)
messages.success(request, msg)
UserAction.objects.log_delete(request.user, obj, msg)
- if self.redirect_url:
- return redirect(self.redirect_url)
- elif hasattr(obj, 'get_parent_url'):
- return redirect(obj.get_parent_url())
+
+ return_url = form.cleaned_data['return_url']
+ if return_url and is_safe_url(url=return_url, host=request.get_host()):
+ return redirect(return_url)
else:
- return redirect('home')
+ return redirect(self.default_return_url)
return render(request, self.template_name, {
'obj': obj,
'form': form,
'obj_type': self.model._meta.verbose_name,
- 'cancel_url': self.get_cancel_url(obj),
+ 'cancel_url': request.GET.get('return_url') or self.get_cancel_url(obj),
})
@@ -326,6 +326,8 @@ class BulkAddView(View):
if not form.errors:
messages.success(request, u"Added {} {}.".format(len(new_objs), self.model._meta.verbose_name_plural))
+ if '_addanother' in request.POST:
+ return redirect(request.path)
return redirect(self.redirect_url)
return render(request, self.template_name, {