Merge pull request #814 from digitalocean/develop

Release v1.8.2
This commit is contained in:
Jeremy Stretch 2017-01-18 16:23:28 -05:00 committed by GitHub
commit b6bbcb0609
53 changed files with 433 additions and 275 deletions

View File

@ -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 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/). 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. 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).
Reddit registration is free and does not require providing an email address (although it is encouraged).
## Reporting Bugs ## 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 comment describing how it's affecting your installation. This will allow us to prioritize bugs based on how many users
are affected. 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 **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. distracting and slow the pace at which NetBox is developed.

View File

@ -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). 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**! 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 # 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))

View File

@ -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. 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 # 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. 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.
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
### Circuit Types ### Circuit Types
Circuits can be classified by type. For example: Circuits are classified by type. For example:
* Internet transit * Internet transit
* Out-of-band connectivity * Out-of-band connectivity
* Peering * Peering
* Private backhaul * Private backhaul
Each circuit must be assigned exactly one circuit type. 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.

View File

@ -62,7 +62,8 @@ class CircuitSerializer(CustomFieldSerializer, serializers.ModelSerializer):
class Meta: class Meta:
model = Circuit 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): class CircuitNestedSerializer(CircuitSerializer):

View File

@ -98,5 +98,6 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
Q(cid__icontains=value) | Q(cid__icontains=value) |
Q(terminations__xconnect_id__icontains=value) | Q(terminations__xconnect_id__icontains=value) |
Q(terminations__pp_info__icontains=value) | Q(terminations__pp_info__icontains=value) |
Q(description__icontains=value) |
Q(comments__icontains=value) Q(comments__icontains=value)
) ).distinct()

View File

@ -86,7 +86,7 @@ class CircuitForm(BootstrapMixin, CustomFieldForm):
class Meta: class Meta:
model = Circuit 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 = { help_texts = {
'cid': "Unique circuit ID", 'cid': "Unique circuit ID",
'install_date': "Format: YYYY-MM-DD", 'install_date': "Format: YYYY-MM-DD",
@ -104,7 +104,7 @@ class CircuitFromCSVForm(forms.ModelForm):
class Meta: class Meta:
model = Circuit 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): class CircuitImportForm(BootstrapMixin, BulkImportForm):
@ -117,10 +117,11 @@ class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
provider = forms.ModelChoiceField(queryset=Provider.objects.all(), required=False) provider = forms.ModelChoiceField(queryset=Provider.objects.all(), required=False)
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
commit_rate = forms.IntegerField(required=False, label='Commit rate (Kbps)') commit_rate = forms.IntegerField(required=False, label='Commit rate (Kbps)')
description = forms.CharField(max_length=100, required=False)
comments = CommentField(widget=SmallTextarea) comments = CommentField(widget=SmallTextarea)
class Meta: class Meta:
nullable_fields = ['tenant', 'commit_rate', 'comments'] nullable_fields = ['tenant', 'commit_rate', 'description', 'comments']
class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm): class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):

View File

@ -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),
),
]

View File

@ -97,6 +97,7 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
tenant = models.ForeignKey(Tenant, related_name='circuits', blank=True, null=True, on_delete=models.PROTECT) 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') 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)') 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) comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') 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.tenant.name if self.tenant else None,
self.install_date.isoformat() if self.install_date else None, self.install_date.isoformat() if self.install_date else None,
self.commit_rate, self.commit_rate,
self.description,
]) ])
def _get_termination(self, side): def _get_termination(self, side):
@ -157,9 +159,6 @@ class CircuitTermination(models.Model):
def __unicode__(self): def __unicode__(self):
return u'{} (Side {})'.format(self.circuit, self.get_term_side_display()) 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): def get_peer_termination(self):
peer_side = 'Z' if self.term_side == 'A' else 'A' peer_side = 'Z' if self.term_side == 'A' else 'A'
try: try:

View File

@ -60,9 +60,8 @@ class CircuitTable(BaseTable):
args=[Accessor('termination_a.site.slug')]) args=[Accessor('termination_a.site.slug')])
z_side = tables.LinkColumn('dcim:site', accessor=Accessor('termination_z.site'), orderable=False, z_side = tables.LinkColumn('dcim:site', accessor=Accessor('termination_z.site'), orderable=False,
args=[Accessor('termination_z.site.slug')]) args=[Accessor('termination_z.site.slug')])
commit_rate = tables.Column(accessor=Accessor('commit_rate_human'), order_by=Accessor('commit_rate'), description = tables.Column(verbose_name='Description')
verbose_name='Commit Rate')
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Circuit 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')

View File

@ -1,6 +1,7 @@
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import permission_required from django.contrib.auth.decorators import permission_required
from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.urlresolvers import reverse
from django.db import transaction from django.db import transaction
from django.db.models import Count from django.db.models import Count
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
@ -52,7 +53,7 @@ class ProviderEditView(PermissionRequiredMixin, ObjectEditView):
class ProviderDeleteView(PermissionRequiredMixin, ObjectDeleteView): class ProviderDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'circuits.delete_provider' permission_required = 'circuits.delete_provider'
model = Provider model = Provider
redirect_url = 'circuits:provider_list' default_return_url = 'circuits:provider_list'
class ProviderBulkImportView(PermissionRequiredMixin, BulkImportView): class ProviderBulkImportView(PermissionRequiredMixin, BulkImportView):
@ -92,8 +93,9 @@ class CircuitTypeEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'circuits.change_circuittype' permission_required = 'circuits.change_circuittype'
model = CircuitType model = CircuitType
form_class = forms.CircuitTypeForm 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): class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@ -140,7 +142,7 @@ class CircuitEditView(PermissionRequiredMixin, ObjectEditView):
class CircuitDeleteView(PermissionRequiredMixin, ObjectDeleteView): class CircuitDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'circuits.delete_circuit' permission_required = 'circuits.delete_circuit'
model = Circuit model = Circuit
redirect_url = 'circuits:circuit_list' default_return_url = 'circuits:circuit_list'
class CircuitBulkImportView(PermissionRequiredMixin, BulkImportView): class CircuitBulkImportView(PermissionRequiredMixin, BulkImportView):
@ -223,10 +225,12 @@ class CircuitTerminationEditView(PermissionRequiredMixin, ObjectEditView):
def alter_obj(self, obj, args, kwargs): def alter_obj(self, obj, args, kwargs):
if 'circuit' in kwargs: if 'circuit' in kwargs:
circuit = get_object_or_404(Circuit, pk=kwargs['circuit']) obj.circuit = get_object_or_404(Circuit, pk=kwargs['circuit'])
obj.circuit = circuit
return obj return obj
def get_return_url(self, obj):
return obj.circuit.get_absolute_url()
class CircuitTerminationDeleteView(PermissionRequiredMixin, ObjectDeleteView): class CircuitTerminationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'circuits.delete_circuittermination' permission_required = 'circuits.delete_circuittermination'

View File

@ -138,7 +138,8 @@ class DeviceTypeSerializer(CustomFieldSerializer, serializers.ModelSerializer):
class Meta: class Meta:
model = DeviceType model = DeviceType
fields = ['id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 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): def get_subdevice_role(self, obj):
return { return {
@ -198,9 +199,9 @@ class DeviceTypeDetailSerializer(DeviceTypeSerializer):
class Meta(DeviceTypeSerializer.Meta): class Meta(DeviceTypeSerializer.Meta):
fields = ['id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 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',
'console_port_templates', 'cs_port_templates', 'power_port_templates', 'power_outlet_templates', 'comments', 'custom_fields', 'console_port_templates', 'cs_port_templates', 'power_port_templates',
'interface_templates'] 'power_outlet_templates', 'interface_templates']
# #

View File

@ -17,9 +17,9 @@ from formfields import MACAddressFormField
from .models import ( from .models import (
DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_PLANNED, CONNECTION_STATUS_CONNECTED, DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_PLANNED, CONNECTION_STATUS_CONNECTED,
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType,
Interface, IFACE_FF_CHOICES, IFACE_FF_VIRTUAL, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Interface, IFACE_FF_CHOICES, IFACE_FF_VIRTUAL, IFACE_ORDERING_CHOICES, InterfaceConnection, InterfaceTemplate,
Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES,
Rack, RackGroup, RackRole, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD RACK_WIDTH_CHOICES, Rack, RackGroup, RackRole, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD
) )
@ -263,13 +263,17 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldForm):
class Meta: class Meta:
model = DeviceType model = DeviceType
fields = ['manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 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): class DeviceTypeBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=DeviceType.objects.all(), widget=forms.MultipleHiddenInput) pk = forms.ModelMultipleChoiceField(queryset=DeviceType.objects.all(), widget=forms.MultipleHiddenInput)
manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), required=False) manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), required=False)
u_height = forms.IntegerField(min_value=1, 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: class Meta:
nullable_fields = [] nullable_fields = []

View File

@ -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),
),
]

View File

@ -56,6 +56,13 @@ SUBDEVICE_ROLE_CHOICES = (
(SUBDEVICE_ROLE_CHILD, 'Child'), (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 # Virtual
IFACE_FF_VIRTUAL = 0 IFACE_FF_VIRTUAL = 0
# Ethernet # 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 # Sites
# #
@ -551,6 +516,8 @@ class DeviceType(models.Model, CustomFieldModel):
u_height = models.PositiveSmallIntegerField(verbose_name='Height (U)', default=1) u_height = models.PositiveSmallIntegerField(verbose_name='Height (U)', default=1)
is_full_depth = models.BooleanField(default=True, verbose_name="Is full depth", is_full_depth = models.BooleanField(default=True, verbose_name="Is full depth",
help_text="Device consumes both front and rear rack faces") 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', is_console_server = models.BooleanField(default=False, verbose_name='Is a console server',
help_text="This type of device has console server ports") help_text="This type of device has console server ports")
is_pdu = models.BooleanField(default=False, verbose_name='Is a PDU', is_pdu = models.BooleanField(default=False, verbose_name='Is a PDU',
@ -701,11 +668,42 @@ class PowerOutletTemplate(models.Model):
return self.name return self.name
class InterfaceTemplateManager(models.Manager): class InterfaceManager(models.Manager):
def get_queryset(self): def order_naturally(self, method=IFACE_ORDERING_POSITION):
qs = super(InterfaceTemplateManager, self).get_queryset() """
return order_interfaces(qs, 'dcim_interfacetemplate.name', ('device_type',)) 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): 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) form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_10GE_SFP_PLUS)
mgmt_only = models.BooleanField(default=False, verbose_name='Management only') mgmt_only = models.BooleanField(default=False, verbose_name='Management only')
objects = InterfaceTemplateManager() objects = InterfaceManager()
class Meta: class Meta:
ordering = ['device_type', 'name'] ordering = ['device_type', 'name']
@ -987,9 +985,6 @@ class ConsolePort(models.Model):
def __unicode__(self): def __unicode__(self):
return self.name return self.name
def get_parent_url(self):
return self.device.get_absolute_url()
# Used for connections export # Used for connections export
def to_csv(self): def to_csv(self):
return csv_format([ return csv_format([
@ -1031,9 +1026,6 @@ class ConsoleServerPort(models.Model):
def __unicode__(self): def __unicode__(self):
return self.name return self.name
def get_parent_url(self):
return self.device.get_absolute_url()
class PowerPort(models.Model): class PowerPort(models.Model):
""" """
@ -1052,9 +1044,6 @@ class PowerPort(models.Model):
def __unicode__(self): def __unicode__(self):
return self.name return self.name
def get_parent_url(self):
return self.device.get_absolute_url()
# Used for connections export # Used for connections export
def csv_format(self): def csv_format(self):
return ','.join([ return ','.join([
@ -1090,22 +1079,6 @@ class PowerOutlet(models.Model):
def __unicode__(self): def __unicode__(self):
return self.name 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): class Interface(models.Model):
""" """
@ -1129,9 +1102,6 @@ class Interface(models.Model):
def __unicode__(self): def __unicode__(self):
return self.name return self.name
def get_parent_url(self):
return self.device.get_absolute_url()
def clean(self): def clean(self):
if self.form_factor == IFACE_FF_VIRTUAL and self.is_connected: if self.form_factor == IFACE_FF_VIRTUAL and self.is_connected:
@ -1222,9 +1192,6 @@ class DeviceBay(models.Model):
def __unicode__(self): def __unicode__(self):
return u'{} - {}'.format(self.device.name, self.name) return u'{} - {}'.format(self.device.name, self.name)
def get_parent_url(self):
return self.device.get_absolute_url()
def clean(self): def clean(self):
# Validate that the parent Device can have DeviceBays # Validate that the parent Device can have DeviceBays
@ -1258,6 +1225,3 @@ class Module(models.Model):
def __unicode__(self): def __unicode__(self):
return self.name return self.name
def get_parent_url(self):
return reverse('dcim:device_inventory', args=[self.device.pk])

View File

@ -311,7 +311,8 @@ class DeviceTable(BaseTable):
status = tables.TemplateColumn(template_code=STATUS_ICON, verbose_name='') status = tables.TemplateColumn(template_code=STATUS_ICON, verbose_name='')
name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name') name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant') 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') rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
device_role = tables.TemplateColumn(DEVICE_ROLE, verbose_name='Role') device_role = tables.TemplateColumn(DEVICE_ROLE, verbose_name='Role')
device_type = tables.LinkColumn('dcim:devicetype', args=[Accessor('device_type.pk')], verbose_name='Type', device_type = tables.LinkColumn('dcim:devicetype', args=[Accessor('device_type.pk')], verbose_name='Type',
@ -327,7 +328,8 @@ class DeviceTable(BaseTable):
class DeviceImportTable(BaseTable): class DeviceImportTable(BaseTable):
name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name') name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant') 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') rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
position = tables.Column(verbose_name='Position') position = tables.Column(verbose_name='Position')
device_role = tables.Column(verbose_name='Role') device_role = tables.Column(verbose_name='Role')

View File

@ -232,6 +232,7 @@ class DeviceTypeTest(APITestCase):
'part_number', 'part_number',
'u_height', 'u_height',
'is_full_depth', 'is_full_depth',
'interface_ordering',
'is_console_server', 'is_console_server',
'is_pdu', 'is_pdu',
'is_network_device', 'is_network_device',

View File

@ -163,7 +163,7 @@ class SiteEditView(PermissionRequiredMixin, ObjectEditView):
class SiteDeleteView(PermissionRequiredMixin, ObjectDeleteView): class SiteDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_site' permission_required = 'dcim.delete_site'
model = Site model = Site
redirect_url = 'dcim:site_list' default_return_url = 'dcim:site_list'
class SiteBulkImportView(PermissionRequiredMixin, BulkImportView): class SiteBulkImportView(PermissionRequiredMixin, BulkImportView):
@ -199,8 +199,9 @@ class RackGroupEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_rackgroup' permission_required = 'dcim.change_rackgroup'
model = RackGroup model = RackGroup
form_class = forms.RackGroupForm 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): class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@ -224,8 +225,9 @@ class RackRoleEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_rackrole' permission_required = 'dcim.change_rackrole'
model = RackRole model = RackRole
form_class = forms.RackRoleForm 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): class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@ -278,7 +280,7 @@ class RackEditView(PermissionRequiredMixin, ObjectEditView):
class RackDeleteView(PermissionRequiredMixin, ObjectDeleteView): class RackDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_rack' permission_required = 'dcim.delete_rack'
model = Rack model = Rack
redirect_url = 'dcim:rack_list' default_return_url = 'dcim:rack_list'
class RackBulkImportView(PermissionRequiredMixin, BulkImportView): class RackBulkImportView(PermissionRequiredMixin, BulkImportView):
@ -318,8 +320,9 @@ class ManufacturerEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_manufacturer' permission_required = 'dcim.change_manufacturer'
model = Manufacturer model = Manufacturer
form_class = forms.ManufacturerForm 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): class ManufacturerBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@ -358,10 +361,14 @@ def devicetype(request, pk):
poweroutlet_table = tables.PowerOutletTemplateTable( poweroutlet_table = tables.PowerOutletTemplateTable(
natsorted(PowerOutletTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')) natsorted(PowerOutletTemplate.objects.filter(device_type=devicetype), key=attrgetter('name'))
) )
mgmt_interface_table = tables.InterfaceTemplateTable(InterfaceTemplate.objects.filter(device_type=devicetype, mgmt_interface_table = tables.InterfaceTemplateTable(
mgmt_only=True)) list(InterfaceTemplate.objects.order_naturally(devicetype.interface_ordering).filter(device_type=devicetype,
interface_table = tables.InterfaceTemplateTable(InterfaceTemplate.objects.filter(device_type=devicetype, mgmt_only=True))
mgmt_only=False)) )
interface_table = tables.InterfaceTemplateTable(
list(InterfaceTemplate.objects.order_naturally(devicetype.interface_ordering).filter(device_type=devicetype,
mgmt_only=False))
)
devicebay_table = tables.DeviceBayTemplateTable( devicebay_table = tables.DeviceBayTemplateTable(
natsorted(DeviceBayTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')) natsorted(DeviceBayTemplate.objects.filter(device_type=devicetype), key=attrgetter('name'))
) )
@ -397,7 +404,7 @@ class DeviceTypeEditView(PermissionRequiredMixin, ObjectEditView):
class DeviceTypeDeleteView(PermissionRequiredMixin, ObjectDeleteView): class DeviceTypeDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_devicetype' permission_required = 'dcim.delete_devicetype'
model = DeviceType model = DeviceType
redirect_url = 'dcim:devicetype_list' default_return_url = 'dcim:devicetype_list'
class DeviceTypeBulkEditView(PermissionRequiredMixin, BulkEditView): class DeviceTypeBulkEditView(PermissionRequiredMixin, BulkEditView):
@ -533,8 +540,9 @@ class DeviceRoleEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_devicerole' permission_required = 'dcim.change_devicerole'
model = DeviceRole model = DeviceRole
form_class = forms.DeviceRoleForm 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): class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@ -558,8 +566,9 @@ class PlatformEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_platform' permission_required = 'dcim.change_platform'
model = Platform model = Platform
form_class = forms.PlatformForm 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): class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@ -597,16 +606,14 @@ def device(request, pk):
power_outlets = natsorted( power_outlets = natsorted(
PowerOutlet.objects.filter(device=device).select_related('connected_port'), key=attrgetter('name') PowerOutlet.objects.filter(device=device).select_related('connected_port'), key=attrgetter('name')
) )
interfaces = Interface.objects.filter(device=device, mgmt_only=False).select_related( interfaces = Interface.objects.order_naturally(device.device_type.interface_ordering)\
'connected_as_a__interface_b__device', .filter(device=device, mgmt_only=False)\
'connected_as_b__interface_a__device', .select_related('connected_as_a__interface_b__device', 'connected_as_b__interface_a__device',
'circuit_termination__circuit', 'circuit_termination__circuit')
) mgmt_interfaces = Interface.objects.order_naturally(device.device_type.interface_ordering)\
mgmt_interfaces = Interface.objects.filter(device=device, mgmt_only=True).select_related( .filter(device=device, mgmt_only=True)\
'connected_as_a__interface_b__device', .select_related('connected_as_a__interface_b__device', 'connected_as_b__interface_a__device',
'connected_as_b__interface_a__device', 'circuit_termination__circuit')
'circuit_termination__circuit',
)
device_bays = natsorted( device_bays = natsorted(
DeviceBay.objects.filter(device=device).select_related('installed_device__device_type__manufacturer'), DeviceBay.objects.filter(device=device).select_related('installed_device__device_type__manufacturer'),
key=attrgetter('name') key=attrgetter('name')
@ -665,7 +672,7 @@ class DeviceEditView(PermissionRequiredMixin, ObjectEditView):
class DeviceDeleteView(PermissionRequiredMixin, ObjectDeleteView): class DeviceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_device' permission_required = 'dcim.delete_device'
model = Device model = Device
redirect_url = 'dcim:device_list' default_return_url = 'dcim:device_list'
class DeviceBulkImportView(PermissionRequiredMixin, BulkImportView): class DeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
@ -1500,10 +1507,12 @@ class ModuleEditView(PermissionRequiredMixin, ObjectEditView):
def alter_obj(self, obj, args, kwargs): def alter_obj(self, obj, args, kwargs):
if 'device' in kwargs: if 'device' in kwargs:
device = get_object_or_404(Device, pk=kwargs['device']) obj.device = get_object_or_404(Device, pk=kwargs['device'])
obj.device = device
return obj return obj
def get_return_url(self, obj):
return obj.device.get_absolute_url()
class ModuleDeleteView(PermissionRequiredMixin, ObjectDeleteView): class ModuleDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_module' permission_required = 'dcim.delete_module'

View File

@ -215,6 +215,8 @@ class PrefixFromCSVForm(forms.ModelForm):
elif vlan_vid and site: elif vlan_vid and site:
try: try:
self.instance.vlan = VLAN.objects.get(site=site, vid=vlan_vid) 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: except VLAN.MultipleObjectsReturned:
self.add_error('vlan_vid', "Multiple VLANs found ({} - VID {})".format(site, vlan_vid)) self.add_error('vlan_vid', "Multiple VLANs found ({} - VID {})".format(site, vlan_vid))
elif vlan_vid: elif vlan_vid:
@ -334,7 +336,7 @@ class IPAddressForm(BootstrapMixin, CustomFieldForm):
class IPAddressBulkAddForm(BootstrapMixin, forms.Form): class IPAddressBulkAddForm(BootstrapMixin, forms.Form):
address = ExpandableIPAddressField() 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) tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
status = forms.ChoiceField(choices=IPADDRESS_STATUS_CHOICES) status = forms.ChoiceField(choices=IPADDRESS_STATUS_CHOICES)
description = forms.CharField(max_length=100, required=False) 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, site = forms.ModelChoiceField(queryset=Site.objects.all(), label='Site', required=False,
widget=forms.Select(attrs={'filter-for': 'rack'})) widget=forms.Select(attrs={'filter-for': 'rack'}))
rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False, 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, 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( livesearch = forms.CharField(required=False, label='Device', widget=Livesearch(
query_key='q', query_url='dcim-api:device_list', field_to_update='device') query_key='q', query_url='dcim-api:device_list', field_to_update='device')
) )

View File

@ -298,10 +298,14 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
def get_absolute_url(self): def get_absolute_url(self):
return reverse('ipam:prefix', args=[self.pk]) 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): def clean(self):
# Disallow host masks
if self.prefix: if self.prefix:
# Disallow host masks
if self.prefix.version == 4 and self.prefix.prefixlen == 32: if self.prefix.version == 4 and self.prefix.prefixlen == 32:
raise ValidationError({ raise ValidationError({
'prefix': "Cannot create host addresses (/32) as prefixes. Create an IPv4 address instead." '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." '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): def save(self, *args, **kwargs):
if self.prefix: if self.prefix:
# Clear host bits from prefix # Clear host bits from prefix
@ -400,23 +415,23 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
def get_absolute_url(self): def get_absolute_url(self):
return reverse('ipam:ipaddress', args=[self.pk]) 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): def clean(self):
# Enforce unique IP space if applicable if self.address:
if self.vrf and self.vrf.enforce_unique:
duplicate_ips = IPAddress.objects.filter(vrf=self.vrf, address__net_host=str(self.address.ip))\ # Enforce unique IP space (if applicable)
.exclude(pk=self.pk) if (self.vrf is None and settings.ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique):
if duplicate_ips: duplicate_ips = self.get_duplicates()
raise ValidationError({ if duplicate_ips:
'address': "Duplicate IP address found in VRF {}: {}".format(self.vrf, duplicate_ips.first()) raise ValidationError({
}) 'address': "Duplicate IP address found in {}: {}".format(
elif not self.vrf and settings.ENFORCE_GLOBAL_UNIQUE: "VRF {}".format(self.vrf) if self.vrf else "global table",
duplicate_ips = IPAddress.objects.filter(vrf=None, address__net_host=str(self.address.ip))\ duplicate_ips.first(),
.exclude(pk=self.pk) )
if duplicate_ips: })
raise ValidationError({
'address': "Duplicate IP address found in global table: {}".format(duplicate_ips.first())
})
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.address: if self.address:
@ -563,6 +578,3 @@ class Service(CreatedUpdatedModel):
def __unicode__(self): def __unicode__(self):
return u'{} ({}/{})'.format(self.name, self.port, self.get_protocol_display()) return u'{} ({}/{})'.format(self.name, self.port, self.get_protocol_display())
def get_parent_url(self):
return self.device.get_absolute_url()

View File

@ -136,7 +136,7 @@ class VRFTable(BaseTable):
name = tables.LinkColumn('ipam:vrf', args=[Accessor('pk')], verbose_name='Name') name = tables.LinkColumn('ipam:vrf', args=[Accessor('pk')], verbose_name='Name')
rd = tables.Column(verbose_name='RD') rd = tables.Column(verbose_name='RD')
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant') 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): class Meta(BaseTable.Meta):
model = VRF model = VRF
@ -182,7 +182,7 @@ class AggregateTable(BaseTable):
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') get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
date_added = tables.DateColumn(format="Y-m-d", verbose_name='Added') 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): class Meta(BaseTable.Meta):
model = Aggregate model = Aggregate
@ -219,7 +219,7 @@ class PrefixTable(BaseTable):
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site') site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
vlan = tables.LinkColumn('ipam:vlan', args=[Accessor('vlan.pk')], verbose_name='VLAN') vlan = tables.LinkColumn('ipam:vlan', args=[Accessor('vlan.pk')], verbose_name='VLAN')
role = tables.TemplateColumn(PREFIX_ROLE_LINK, verbose_name='Role') 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): class Meta(BaseTable.Meta):
model = Prefix model = Prefix
@ -255,7 +255,7 @@ class IPAddressTable(BaseTable):
device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False, device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False,
verbose_name='Device') verbose_name='Device')
interface = tables.Column(orderable=False, verbose_name='Interface') 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): class Meta(BaseTable.Meta):
model = IPAddress model = IPAddress
@ -310,7 +310,8 @@ class VLANTable(BaseTable):
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant') tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status') status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
role = tables.TemplateColumn(VLAN_ROLE_LINK, verbose_name='Role') role = tables.TemplateColumn(VLAN_ROLE_LINK, verbose_name='Role')
description = tables.Column(verbose_name='Description')
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = VLAN model = VLAN
fields = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role') fields = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role', 'description')

View File

View File

@ -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)

View File

@ -102,8 +102,10 @@ class VRFListView(ObjectListView):
def vrf(request, pk): def vrf(request, pk):
vrf = get_object_or_404(VRF.objects.all(), pk=pk) vrf = get_object_or_404(VRF.objects.all(), pk=pk)
prefixes = Prefix.objects.filter(vrf=vrf) prefix_table = tables.PrefixBriefTable(
prefix_table = tables.PrefixBriefTable(prefixes) list(Prefix.objects.filter(vrf=vrf).select_related('site', 'role'))
)
prefix_table.exclude = ('vrf',)
return render(request, 'ipam/vrf.html', { return render(request, 'ipam/vrf.html', {
'vrf': vrf, 'vrf': vrf,
@ -122,7 +124,7 @@ class VRFEditView(PermissionRequiredMixin, ObjectEditView):
class VRFDeleteView(PermissionRequiredMixin, ObjectDeleteView): class VRFDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_vrf' permission_required = 'ipam.delete_vrf'
model = VRF model = VRF
redirect_url = 'ipam:vrf_list' default_return_url = 'ipam:vrf_list'
class VRFBulkImportView(PermissionRequiredMixin, BulkImportView): class VRFBulkImportView(PermissionRequiredMixin, BulkImportView):
@ -240,8 +242,9 @@ class RIREditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_rir' permission_required = 'ipam.change_rir'
model = RIR model = RIR
form_class = forms.RIRForm 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): class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@ -311,7 +314,7 @@ class AggregateEditView(PermissionRequiredMixin, ObjectEditView):
class AggregateDeleteView(PermissionRequiredMixin, ObjectDeleteView): class AggregateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_aggregate' permission_required = 'ipam.delete_aggregate'
model = Aggregate model = Aggregate
redirect_url = 'ipam:aggregate_list' default_return_url = 'ipam:aggregate_list'
class AggregateBulkImportView(PermissionRequiredMixin, BulkImportView): class AggregateBulkImportView(PermissionRequiredMixin, BulkImportView):
@ -351,8 +354,9 @@ class RoleEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_role' permission_required = 'ipam.change_role'
model = Role model = Role
form_class = forms.RoleForm 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): class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@ -401,7 +405,7 @@ def prefix(request, pk):
# Duplicate prefixes table # Duplicate prefixes table
duplicate_prefixes = Prefix.objects.filter(vrf=prefix.vrf, prefix=str(prefix.prefix)).exclude(pk=prefix.pk)\ duplicate_prefixes = Prefix.objects.filter(vrf=prefix.vrf, prefix=str(prefix.prefix)).exclude(pk=prefix.pk)\
.select_related('site', 'role') .select_related('site', 'role')
duplicate_prefix_table = tables.PrefixBriefTable(duplicate_prefixes) duplicate_prefix_table = tables.PrefixBriefTable(list(duplicate_prefixes))
# Child prefixes table # Child prefixes table
if prefix.vrf: if prefix.vrf:
@ -441,7 +445,8 @@ class PrefixEditView(PermissionRequiredMixin, ObjectEditView):
class PrefixDeleteView(PermissionRequiredMixin, ObjectDeleteView): class PrefixDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_prefix' permission_required = 'ipam.delete_prefix'
model = Prefix model = Prefix
redirect_url = 'ipam:prefix_list' default_return_url = 'ipam:prefix_list'
template_name = 'ipam/prefix_delete.html'
class PrefixBulkImportView(PermissionRequiredMixin, BulkImportView): 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) ipaddress = get_object_or_404(IPAddress.objects.select_related('interface__device'), pk=pk)
# Parent prefixes table # Parent prefixes table
parent_prefixes = Prefix.objects.filter(vrf=ipaddress.vrf, prefix__net_contains=str(ipaddress.address.ip)) parent_prefixes = Prefix.objects.filter(vrf=ipaddress.vrf, prefix__net_contains=str(ipaddress.address.ip))\
parent_prefixes_table = tables.PrefixBriefTable(parent_prefixes) .select_related('site', 'role')
parent_prefixes_table = tables.PrefixBriefTable(list(parent_prefixes))
parent_prefixes_table.exclude = ('vrf',)
# Duplicate IPs table # Duplicate IPs table
duplicate_ips = IPAddress.objects.filter(vrf=ipaddress.vrf, address=str(ipaddress.address))\ duplicate_ips = IPAddress.objects.filter(vrf=ipaddress.vrf, address=str(ipaddress.address))\
.exclude(pk=ipaddress.pk).select_related('interface__device', 'nat_inside') .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 IP table
related_ips = IPAddress.objects.select_related('interface__device').exclude(address=str(ipaddress.address))\ 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)) .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', { return render(request, 'ipam/ipaddress.html', {
'ipaddress': ipaddress, 'ipaddress': ipaddress,
@ -604,7 +611,7 @@ class IPAddressEditView(PermissionRequiredMixin, ObjectEditView):
class IPAddressDeleteView(PermissionRequiredMixin, ObjectDeleteView): class IPAddressDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_ipaddress' permission_required = 'ipam.delete_ipaddress'
model = IPAddress model = IPAddress
redirect_url = 'ipam:ipaddress_list' default_return_url = 'ipam:ipaddress_list'
class IPAddressBulkAddView(PermissionRequiredMixin, BulkAddView): class IPAddressBulkAddView(PermissionRequiredMixin, BulkAddView):
@ -669,8 +676,9 @@ class VLANGroupEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_vlangroup' permission_required = 'ipam.change_vlangroup'
model = VLANGroup model = VLANGroup
form_class = forms.VLANGroupForm 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): class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@ -695,8 +703,8 @@ class VLANListView(ObjectListView):
def vlan(request, pk): def vlan(request, pk):
vlan = get_object_or_404(VLAN.objects.select_related('site', 'role'), pk=pk) vlan = get_object_or_404(VLAN.objects.select_related('site', 'role'), pk=pk)
prefixes = Prefix.objects.filter(vlan=vlan) prefixes = Prefix.objects.filter(vlan=vlan).select_related('vrf', 'site', 'role')
prefix_table = tables.PrefixBriefTable(prefixes) prefix_table = tables.PrefixBriefTable(list(prefixes))
return render(request, 'ipam/vlan.html', { return render(request, 'ipam/vlan.html', {
'vlan': vlan, 'vlan': vlan,
@ -715,7 +723,7 @@ class VLANEditView(PermissionRequiredMixin, ObjectEditView):
class VLANDeleteView(PermissionRequiredMixin, ObjectDeleteView): class VLANDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_vlan' permission_required = 'ipam.delete_vlan'
model = VLAN model = VLAN
redirect_url = 'ipam:vlan_list' default_return_url = 'ipam:vlan_list'
class VLANBulkImportView(PermissionRequiredMixin, BulkImportView): class VLANBulkImportView(PermissionRequiredMixin, BulkImportView):
@ -755,6 +763,9 @@ class ServiceEditView(PermissionRequiredMixin, ObjectEditView):
obj.device = get_object_or_404(Device, pk=kwargs['device']) obj.device = get_object_or_404(Device, pk=kwargs['device'])
return obj return obj
def get_return_url(self, obj):
return obj.device.get_absolute_url()
class ServiceDeleteView(PermissionRequiredMixin, ObjectDeleteView): class ServiceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_service' permission_required = 'ipam.delete_service'

View File

@ -12,7 +12,7 @@ except ImportError:
"the documentation.") "the documentation.")
VERSION = '1.8.1' VERSION = '1.8.2'
# Import local configuration # Import local configuration
for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']: for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:

View File

@ -30,8 +30,9 @@ class SecretRoleEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'secrets.change_secretrole' permission_required = 'secrets.change_secretrole'
model = SecretRole model = SecretRole
form_class = forms.SecretRoleForm 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): class SecretRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@ -151,7 +152,7 @@ def secret_edit(request, pk):
class SecretDeleteView(PermissionRequiredMixin, ObjectDeleteView): class SecretDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'secrets.delete_secret' permission_required = 'secrets.delete_secret'
model = Secret model = Secret
redirect_url = 'secrets:secret_list' default_return_url = 'secrets:secret_list'
@permission_required('secrets.add_secret') @permission_required('secrets.add_secret')

View File

@ -92,6 +92,16 @@
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
<tr>
<td>Description</td>
<td>
{% if circuit.description %}
{{ circuit.description }}
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
</table> </table>
</div> </div>
{% with circuit.get_custom_fields as custom_fields %} {% with circuit.get_custom_fields as custom_fields %}

View File

@ -11,6 +11,7 @@
{% render_field form.tenant %} {% render_field form.tenant %}
{% render_field form.install_date %} {% render_field form.install_date %}
{% render_field form.commit_rate %} {% render_field form.commit_rate %}
{% render_field form.description %}
</div> </div>
</div> </div>
{% if form.custom_fields %} {% if form.custom_fields %}

View File

@ -58,10 +58,15 @@
<td>Commited rate in Kbps (optional)</td> <td>Commited rate in Kbps (optional)</td>
<td>2000</td> <td>2000</td>
</tr> </tr>
<tr>
<td>Description</td>
<td>Short description (optional)</td>
<td>Primary for voice</td>
</tr>
</tbody> </tbody>
</table> </table>
<h4>Example</h4> <h4>Example</h4>
<pre>IC-603122,TeliaSonera,Transit,Strickland Propane,2016-02-23,2000</pre> <pre>IC-603122,TeliaSonera,Transit,Strickland Propane,2016-02-23,2000,Primary for voice</pre>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -15,7 +15,7 @@
</a> </a>
{% endif %} {% endif %}
{% if termination and perms.circuits.delete_circuittermination %} {% if termination and perms.circuits.delete_circuittermination %}
<a href="{% url 'circuits:circuittermination_delete' pk=termination.pk %}" class="btn btn-xs btn-danger"> <a href="{% url 'circuits:circuittermination_delete' pk=termination.pk %}?return_url={{ circuit.get_absolute_url }}" class="btn btn-xs btn-danger">
<span class="fa fa-trash" aria-hidden="true"></span> Delete <span class="fa fa-trash" aria-hidden="true"></span> Delete
</a> </a>
{% endif %} {% endif %}

View File

@ -6,7 +6,7 @@
{% block title %}{{ device }}{% endblock %} {% block title %}{{ device }}{% endblock %}
{% block content %} {% block content %}
{% include 'dcim/inc/_device_header.html' with active_tab='info' %} {% include 'dcim/inc/device_header.html' with active_tab='info' %}
<div class="row"> <div class="row">
<div class="col-md-5 col-lg-6"> <div class="col-md-5 col-lg-6">
<div class="panel panel-default"> <div class="panel panel-default">
@ -183,7 +183,7 @@
{% if ip_addresses %} {% if ip_addresses %}
<table class="table table-hover panel-body"> <table class="table table-hover panel-body">
{% for ip in ip_addresses %} {% for ip in ip_addresses %}
{% include 'dcim/inc/_ipaddress.html' %} {% include 'dcim/inc/ipaddress.html' %}
{% endfor %} {% endfor %}
</table> </table>
{% elif interfaces or mgmt_interfaces %} {% elif interfaces or mgmt_interfaces %}
@ -212,7 +212,7 @@
{% if services %} {% if services %}
<table class="table table-hover panel-body"> <table class="table table-hover panel-body">
{% for service in services %} {% for service in services %}
{% include 'dcim/inc/_service.html' %} {% include 'dcim/inc/service.html' %}
{% endfor %} {% endfor %}
</table> </table>
{% else %} {% else %}
@ -234,7 +234,7 @@
</div> </div>
<table class="table table-hover panel-body"> <table class="table table-hover panel-body">
{% for iface in mgmt_interfaces %} {% for iface in mgmt_interfaces %}
{% include 'dcim/inc/_interface.html' with icon='wrench' %} {% include 'dcim/inc/interface.html' with icon='wrench' %}
{% empty %} {% empty %}
<tr> <tr>
<td colspan="5" class="alert-warning"> <td colspan="5" class="alert-warning">
@ -246,7 +246,7 @@
</tr> </tr>
{% endfor %} {% endfor %}
{% for cp in console_ports %} {% for cp in console_ports %}
{% include 'dcim/inc/_consoleport.html' %} {% include 'dcim/inc/consoleport.html' %}
{% empty %} {% empty %}
<tr> <tr>
<td colspan="5" class="alert-warning"> <td colspan="5" class="alert-warning">
@ -258,7 +258,7 @@
</tr> </tr>
{% endfor %} {% endfor %}
{% for pp in power_ports %} {% for pp in power_ports %}
{% include 'dcim/inc/_powerport.html' %} {% include 'dcim/inc/powerport.html' %}
{% empty %} {% empty %}
<tr> <tr>
<td colspan="5" class="alert-warning"> <td colspan="5" class="alert-warning">
@ -349,7 +349,7 @@
</div> </div>
<table class="table table-hover panel-body"> <table class="table table-hover panel-body">
{% for devicebay in device_bays %} {% for devicebay in device_bays %}
{% include 'dcim/inc/_devicebay.html' with selectable=True %} {% include 'dcim/inc/devicebay.html' with selectable=True %}
{% empty %} {% empty %}
<tr> <tr>
<td colspan="4">No device bays defined</td> <td colspan="4">No device bays defined</td>
@ -401,7 +401,7 @@
</div> </div>
<table class="table table-hover panel-body"> <table class="table table-hover panel-body">
{% for iface in interfaces %} {% for iface in interfaces %}
{% include 'dcim/inc/_interface.html' with selectable=True %} {% include 'dcim/inc/interface.html' with selectable=True %}
{% empty %} {% empty %}
<tr> <tr>
<td colspan="4">No interfaces defined</td> <td colspan="4">No interfaces defined</td>
@ -458,7 +458,7 @@
</div> </div>
<table class="table table-hover panel-body"> <table class="table table-hover panel-body">
{% for csp in cs_ports %} {% for csp in cs_ports %}
{% include 'dcim/inc/_consoleserverport.html' with selectable=True %} {% include 'dcim/inc/consoleserverport.html' with selectable=True %}
{% empty %} {% empty %}
<tr> <tr>
<td colspan="4">No console server ports defined</td> <td colspan="4">No console server ports defined</td>
@ -510,7 +510,7 @@
</div> </div>
<table class="table table-hover panel-body"> <table class="table table-hover panel-body">
{% for po in power_outlets %} {% for po in power_outlets %}
{% include 'dcim/inc/_poweroutlet.html' with selectable=True %} {% include 'dcim/inc/poweroutlet.html' with selectable=True %}
{% empty %} {% empty %}
<tr> <tr>
<td colspan="4">No power outlets defined</td> <td colspan="4">No power outlets defined</td>

View File

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

View File

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

View File

@ -3,7 +3,7 @@
{% block title %}{{ device }} - Inventory{% endblock %} {% block title %}{{ device }} - Inventory{% endblock %}
{% block content %} {% block content %}
{% include 'dcim/inc/_device_header.html' with active_tab='inventory' %} {% include 'dcim/inc/device_header.html' with active_tab='inventory' %}
<div class="row"> <div class="row">
<div class="col-md-4"> <div class="col-md-4">
<div class="panel panel-default"> <div class="panel panel-default">
@ -67,7 +67,7 @@
<a href="{% url 'dcim:module_edit' pk=m.pk %}" class="btn btn-xs btn-warning"><span class="glyphicon glyphicon-pencil" aria-hidden="true"></span></a> <a href="{% url 'dcim:module_edit' pk=m.pk %}" class="btn btn-xs btn-warning"><span class="glyphicon glyphicon-pencil" aria-hidden="true"></span></a>
{% endif %} {% endif %}
{% if perms.dcim.delete_module %} {% if perms.dcim.delete_module %}
<a href="{% url 'dcim:module_delete' pk=m.pk %}" class="btn btn-xs btn-danger"><span class="glyphicon glyphicon-trash" aria-hidden="true"></span></a> <a href="{% url 'dcim:module_delete' pk=m.pk %}?return_url={% url 'dcim:device_inventory' pk=device.pk %}" class="btn btn-xs btn-danger"><span class="glyphicon glyphicon-trash" aria-hidden="true"></span></a>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@ -80,10 +80,10 @@
<td>{{ m2.serial }}</td> <td>{{ m2.serial }}</td>
<td class="text-right"> <td class="text-right">
{% if perms.dcim.change_module %} {% if perms.dcim.change_module %}
<a href="{% url 'dcim:module_edit' pk=m.pk %}" class="btn btn-xs btn-warning"><span class="glyphicon glyphicon-pencil" aria-hidden="true"></span></a> <a href="{% url 'dcim:module_edit' pk=m2.pk %}" class="btn btn-xs btn-warning"><span class="glyphicon glyphicon-pencil" aria-hidden="true"></span></a>
{% endif %} {% endif %}
{% if perms.dcim.delete_module %} {% if perms.dcim.delete_module %}
<a href="{% url 'dcim:module_delete' pk=m.pk %}" class="btn btn-xs btn-danger"><span class="glyphicon glyphicon-trash" aria-hidden="true"></span></a> <a href="{% url 'dcim:module_delete' pk=m2.pk %}?return_url={% url 'dcim:device_inventory' pk=device.pk %}" class="btn btn-xs btn-danger"><span class="glyphicon glyphicon-trash" aria-hidden="true"></span></a>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@ -96,10 +96,10 @@
<td>{{ m3.serial }}</td> <td>{{ m3.serial }}</td>
<td class="text-right"> <td class="text-right">
{% if perms.dcim.change_module %} {% if perms.dcim.change_module %}
<a href="{% url 'dcim:module_edit' pk=m.pk %}" class="btn btn-xs btn-warning"><span class="glyphicon glyphicon-pencil" aria-hidden="true"></span></a> <a href="{% url 'dcim:module_edit' pk=m3.pk %}" class="btn btn-xs btn-warning"><span class="glyphicon glyphicon-pencil" aria-hidden="true"></span></a>
{% endif %} {% endif %}
{% if perms.dcim.delete_module %} {% if perms.dcim.delete_module %}
<a href="{% url 'dcim:module_delete' pk=m.pk %}" class="btn btn-xs btn-danger"><span class="glyphicon glyphicon-trash" aria-hidden="true"></span></a> <a href="{% url 'dcim:module_delete' pk=m3.pk %}?return_url={% url 'dcim:device_inventory' pk=device.pk %}" class="btn btn-xs btn-danger"><span class="glyphicon glyphicon-trash" aria-hidden="true"></span></a>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@ -112,10 +112,10 @@
<td>{{ m4.serial }}</td> <td>{{ m4.serial }}</td>
<td class="text-right"> <td class="text-right">
{% if perms.dcim.change_module %} {% if perms.dcim.change_module %}
<a href="{% url 'dcim:module_edit' pk=m.pk %}" class="btn btn-xs btn-warning"><span class="glyphicon glyphicon-pencil" aria-hidden="true"></span></a> <a href="{% url 'dcim:module_edit' pk=m4.pk %}" class="btn btn-xs btn-warning"><span class="glyphicon glyphicon-pencil" aria-hidden="true"></span></a>
{% endif %} {% endif %}
{% if perms.dcim.delete_module %} {% if perms.dcim.delete_module %}
<a href="{% url 'dcim:module_delete' pk=m.pk %}" class="btn btn-xs btn-danger"><span class="glyphicon glyphicon-trash" aria-hidden="true"></span></a> <a href="{% url 'dcim:module_delete' pk=m4.pk %}?return_url={% url 'dcim:device_inventory' pk=device.pk %}" class="btn btn-xs btn-danger"><span class="glyphicon glyphicon-trash" aria-hidden="true"></span></a>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>

View File

@ -3,7 +3,7 @@
{% block title %}{{ device }} - LLDP Neighbors{% endblock %} {% block title %}{{ device }} - LLDP Neighbors{% endblock %}
{% block content %} {% block content %}
{% include 'dcim/inc/_device_header.html' with active_tab='lldp-neighbors' %} {% include 'dcim/inc/device_header.html' with active_tab='lldp-neighbors' %}
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<strong>LLDP Neighbors</strong> <strong>LLDP Neighbors</strong>

View File

@ -72,6 +72,10 @@
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
<tr>
<td>Interface Ordering</td>
<td>{{ devicetype.get_interface_ordering_display }}</td>
</tr>
<tr> <tr>
<td>Instances</td> <td>Instances</td>
<td><a href="{% url 'dcim:device_list' %}?device_type_id={{ devicetype.pk }}">{{ devicetype.instances.count }}</a></td> <td><a href="{% url 'dcim:device_list' %}?device_type_id={{ devicetype.pk }}">{{ devicetype.instances.count }}</a></td>

View File

@ -11,6 +11,12 @@
{% render_field form.part_number %} {% render_field form.part_number %}
{% render_field form.u_height %} {% render_field form.u_height %}
{% render_field form.is_full_depth %} {% render_field form.is_full_depth %}
{% render_field form.interface_ordering %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Function</strong></div>
<div class="panel-body">
{% render_field form.is_console_server %} {% render_field form.is_console_server %}
{% render_field form.is_pdu %} {% render_field form.is_pdu %}
{% render_field form.is_network_device %} {% render_field form.is_network_device %}

View File

@ -1,5 +1,5 @@
<tr{% if cp.cs_port and not cp.connection_status %} class="info"{% endif %}> <tr{% if cp.cs_port and not cp.connection_status %} class="info"{% endif %}>
{% if selectable and perms.dcim.delete_consoleport %} {% if selectable and perms.dcim.change_consoleport or perms.dcim.delete_consoleport %}
<td class="pk"> <td class="pk">
<input name="pk" type="checkbox" value="{{ cp.pk }}" /> <input name="pk" type="checkbox" value="{{ cp.pk }}" />
</td> </td>
@ -50,7 +50,7 @@
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i> <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</button> </button>
{% else %} {% else %}
<a href="{% url 'dcim:consoleport_delete' pk=cp.pk %}" class="btn btn-danger btn-xs"> <a href="{% url 'dcim:consoleport_delete' pk=cp.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete port"></i> <i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete port"></i>
</a> </a>
{% endif %} {% endif %}

View File

@ -1,5 +1,5 @@
<tr{% if csp.connected_console and not csp.connected_console.connection_status %} class="info"{% endif %}> <tr{% if csp.connected_console and not csp.connected_console.connection_status %} class="info"{% endif %}>
{% if selectable and perms.dcim.delete_consoleserverport %} {% if selectable and perms.dcim.change_consoleserverport or perms.dcim.delete_consoleserverport %}
<td class="pk"> <td class="pk">
<input name="pk" type="checkbox" value="{{ csp.pk }}" /> <input name="pk" type="checkbox" value="{{ csp.pk }}" />
</td> </td>
@ -49,7 +49,7 @@
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i> <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</button> </button>
{% else %} {% else %}
<a href="{% url 'dcim:consoleserverport_delete' pk=csp.pk %}" class="btn btn-danger btn-xs"> <a href="{% url 'dcim:consoleserverport_delete' pk=csp.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete port"></i> <i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete port"></i>
</a> </a>
{% endif %} {% endif %}

View File

@ -1,5 +1,5 @@
<tr> <tr>
{% if selectable and perms.dcim.delete_devicebay %} {% if selectable and perms.dcim.change_devicebay or perms.dcim.delete_devicebay %}
<td class="pk"> <td class="pk">
<input name="pk" type="checkbox" value="{{ devicebay.pk }}" /> <input name="pk" type="checkbox" value="{{ devicebay.pk }}" />
</td> </td>
@ -40,7 +40,7 @@
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i> <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</button> </button>
{% else %} {% else %}
<a href="{% url 'dcim:devicebay_delete' pk=devicebay.pk %}" class="btn btn-danger btn-xs"> <a href="{% url 'dcim:devicebay_delete' pk=devicebay.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete device bay"></i> <i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete device bay"></i>
</a> </a>
{% endif %} {% endif %}

View File

@ -1,5 +1,5 @@
<tr{% if iface.connection and not iface.connection.connection_status %} class="info"{% endif %}> <tr{% if iface.connection and not iface.connection.connection_status %} class="info"{% endif %}>
{% if selectable and 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 }}" />
</td> </td>
@ -85,7 +85,7 @@
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i> <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</button> </button>
{% else %} {% else %}
<a href="{% url 'dcim:interface_delete' pk=iface.pk %}" class="btn btn-danger btn-xs" title="Delete interface"> <a href="{% url 'dcim:interface_delete' pk=iface.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs" title="Delete interface">
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i> <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</a> </a>
{% endif %} {% endif %}

View File

@ -13,7 +13,7 @@
</td> </td>
<td class="text-right"> <td class="text-right">
{% if perms.ipam.delete_ipaddress %} {% if perms.ipam.delete_ipaddress %}
<a href="{% url 'ipam:ipaddress_delete' pk=ip.pk %}" class="btn btn-danger btn-xs"> <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> <i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete IP address"></i>
</a> </a>
{% endif %} {% endif %}

View File

@ -1,5 +1,5 @@
<tr{% if po.connected_port and not po.connected_port.connection_status %} class="info"{% endif %}> <tr{% if po.connected_port and not po.connected_port.connection_status %} class="info"{% endif %}>
{% if selectable and perms.dcim.delete_poweroutlet %} {% if selectable and perms.dcim.change_poweroutlet or perms.dcim.delete_poweroutlet %}
<td class="pk"> <td class="pk">
<input name="pk" type="checkbox" value="{{ po.pk }}" /> <input name="pk" type="checkbox" value="{{ po.pk }}" />
</td> </td>
@ -49,7 +49,7 @@
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i> <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</button> </button>
{% else %} {% else %}
<a href="{% url 'dcim:poweroutlet_delete' pk=po.pk %}" class="btn btn-danger btn-xs"> <a href="{% url 'dcim:poweroutlet_delete' pk=po.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete outlet"></i> <i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete outlet"></i>
</a> </a>
{% endif %} {% endif %}

View File

@ -1,5 +1,5 @@
<tr{% if pp.power_outlet and not pp.connection_status %} class="info"{% endif %}> <tr{% if pp.power_outlet and not pp.connection_status %} class="info"{% endif %}>
{% if selectable and perms.dcim.delete_powerport %} {% if selectable and perms.dcim.change_powerport or perms.dcim.delete_powerport %}
<td class="pk"> <td class="pk">
<input name="pk" type="checkbox" value="{{ pp.pk }}" /> <input name="pk" type="checkbox" value="{{ pp.pk }}" />
</td> </td>
@ -50,7 +50,7 @@
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i> <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</button> </button>
{% else %} {% else %}
<a href="{% url 'dcim:powerport_delete' pk=pp.pk %}" class="btn btn-danger btn-xs"> <a href="{% url 'dcim:powerport_delete' pk=pp.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete port"></i> <i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete port"></i>
</a> </a>
{% endif %} {% endif %}

View File

@ -18,7 +18,7 @@
</a> </a>
{% endif %} {% endif %}
{% if perms.ipam.delete_service %} {% if perms.ipam.delete_service %}
<a href="{% url 'ipam:service_delete' pk=service.pk %}" class="btn btn-danger btn-xs"> <a href="{% url 'ipam:service_delete' pk=service.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete service"></i> <i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete service"></i>
</a> </a>
{% endif %} {% endif %}

View File

@ -195,13 +195,13 @@
<div class="rack_header"> <div class="rack_header">
<h4>Front</h4> <h4>Front</h4>
</div> </div>
{% 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 %}
</div> </div>
<div class="col-md-6 col-sm-6 col-xs-12"> <div class="col-md-6 col-sm-6 col-xs-12">
<div class="rack_header"> <div class="rack_header">
<h4>Rear</h4> <h4>Rear</h4>
</div> </div>
{% 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 %}
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,5 @@
{% extends 'utilities/obj_delete.html' %}
{% block message_extra %}
<p>Note: This will <strong>not</strong> delete any child prefixes or IP addresses.</p>
{% endblock %}

View File

@ -30,7 +30,7 @@ class TenantSerializer(CustomFieldSerializer, serializers.ModelSerializer):
class Meta: class Meta:
model = Tenant model = Tenant
fields = ['id', 'name', 'slug', 'group', 'comments', 'custom_fields'] fields = ['id', 'name', 'slug', 'group', 'description', 'comments', 'custom_fields']
class TenantNestedSerializer(TenantSerializer): class TenantNestedSerializer(TenantSerializer):

View File

@ -1,4 +1,5 @@
from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.urlresolvers import reverse
from django.db.models import Count, Q from django.db.models import Count, Q
from django.shortcuts import get_object_or_404, render from django.shortcuts import get_object_or_404, render
@ -28,8 +29,9 @@ class TenantGroupEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'tenancy.change_tenantgroup' permission_required = 'tenancy.change_tenantgroup'
model = TenantGroup model = TenantGroup
form_class = forms.TenantGroupForm 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): class TenantGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@ -89,7 +91,7 @@ class TenantEditView(PermissionRequiredMixin, ObjectEditView):
class TenantDeleteView(PermissionRequiredMixin, ObjectDeleteView): class TenantDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'tenancy.delete_tenant' permission_required = 'tenancy.delete_tenant'
model = Tenant model = Tenant
redirect_url = 'tenancy:tenant_list' default_return_url = 'tenancy:tenant_list'
class TenantBulkImportView(PermissionRequiredMixin, BulkImportView): class TenantBulkImportView(PermissionRequiredMixin, BulkImportView):

View File

@ -386,7 +386,12 @@ class BootstrapMixin(forms.BaseForm):
class ConfirmationForm(BootstrapMixin, forms.Form): 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) confirm = forms.BooleanField(required=True)
return_url = forms.CharField(required=False, widget=forms.HiddenInput())
class BulkEditForm(forms.Form): class BulkEditForm(forms.Form):

View File

@ -8,7 +8,7 @@ from django.core.urlresolvers import reverse
from django.db import transaction, IntegrityError from django.db import transaction, IntegrityError
from django.db.models import ProtectedError from django.db.models import ProtectedError
from django.forms import CharField, ModelMultipleChoiceField, MultipleHiddenInput, TypedChoiceField 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.shortcuts import get_object_or_404, redirect, render
from django.template import TemplateSyntaxError from django.template import TemplateSyntaxError
from django.utils.http import is_safe_url 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 fields_initial: A set of fields that will be prepopulated in the form from the request parameters
template_name: The name of the template template_name: The name of the template
obj_list_url: The name of the URL used to display a list of this object type 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 model = None
form_class = None form_class = None
fields_initial = [] fields_initial = []
template_name = 'utilities/obj_edit.html' template_name = 'utilities/obj_edit.html'
obj_list_url = None obj_list_url = None
use_obj_view = True
def get_object(self, kwargs): def get_object(self, kwargs):
# Look up object by slug or PK. Return None if neither was provided. # 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. # given some parameter from the request URI.
return obj 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). # 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() return obj.get_absolute_url()
if obj and self.use_obj_view and hasattr(obj, 'get_parent_url'): if self.obj_list_url is not None:
return obj.get_parent_url() return reverse(self.obj_list_url)
return reverse(self.obj_list_url) return reverse('home')
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
@ -169,7 +166,7 @@ class ObjectEditView(View):
'obj': obj, 'obj': obj,
'obj_type': self.model._meta.verbose_name, 'obj_type': self.model._meta.verbose_name,
'form': form, 'form': form,
'cancel_url': self.get_redirect_url(obj), 'cancel_url': self.get_return_url(obj),
}) })
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
@ -200,13 +197,13 @@ class ObjectEditView(View):
if '_addanother' in request.POST: if '_addanother' in request.POST:
return redirect(request.path) return redirect(request.path)
return redirect(self.get_redirect_url(obj)) return redirect(self.get_return_url(obj))
return render(request, self.template_name, { return render(request, self.template_name, {
'obj': obj, 'obj': obj,
'obj_type': self.model._meta.verbose_name, 'obj_type': self.model._meta.verbose_name,
'form': form, '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 model: The model of the object being edited
template_name: The name of the template 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 model = None
template_name = 'utilities/obj_delete.html' template_name = 'utilities/obj_delete.html'
redirect_url = None default_return_url = 'home'
def get_object(self, kwargs): def get_object(self, kwargs):
# Look up object by slug if one has been provided. Otherwise, use PK. # 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): def get_cancel_url(self, obj):
if hasattr(obj, 'get_absolute_url'): if hasattr(obj, 'get_absolute_url'):
return obj.get_absolute_url() return obj.get_absolute_url()
if hasattr(obj, 'get_parent_url'):
return obj.get_parent_url()
return reverse('home') return reverse('home')
def get(self, request, **kwargs): def get(self, request, **kwargs):
obj = self.get_object(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, { return render(request, self.template_name, {
'obj': obj, 'obj': obj,
'form': form, 'form': form,
'obj_type': self.model._meta.verbose_name, '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): def post(self, request, **kwargs):
@ -253,26 +251,28 @@ class ObjectDeleteView(View):
obj = self.get_object(kwargs) obj = self.get_object(kwargs)
form = ConfirmationForm(request.POST) form = ConfirmationForm(request.POST)
if form.is_valid(): if form.is_valid():
try: try:
obj.delete() obj.delete()
except ProtectedError as e: except ProtectedError as e:
handle_protectederror(obj, request, e) handle_protectederror(obj, request, e)
return redirect(obj.get_absolute_url()) return redirect(obj.get_absolute_url())
msg = u'Deleted {} {}'.format(self.model._meta.verbose_name, obj) msg = u'Deleted {} {}'.format(self.model._meta.verbose_name, obj)
messages.success(request, msg) messages.success(request, msg)
UserAction.objects.log_delete(request.user, obj, msg) UserAction.objects.log_delete(request.user, obj, msg)
if self.redirect_url:
return redirect(self.redirect_url) return_url = form.cleaned_data['return_url']
elif hasattr(obj, 'get_parent_url'): if return_url and is_safe_url(url=return_url, host=request.get_host()):
return redirect(obj.get_parent_url()) return redirect(return_url)
else: else:
return redirect('home') return redirect(self.default_return_url)
return render(request, self.template_name, { return render(request, self.template_name, {
'obj': obj, 'obj': obj,
'form': form, 'form': form,
'obj_type': self.model._meta.verbose_name, '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: if not form.errors:
messages.success(request, u"Added {} {}.".format(len(new_objs), self.model._meta.verbose_name_plural)) 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 redirect(self.redirect_url)
return render(request, self.template_name, { return render(request, self.template_name, {