From 60187004215e3745285eb38ed8946e5f9316109b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 19 Oct 2016 16:27:46 -0400 Subject: [PATCH 01/26] Post-release version bump --- netbox/netbox/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 68ecac7924..94df915bb9 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -12,7 +12,7 @@ "the documentation.") -VERSION = '1.6.3' +VERSION = '1.6.4-dev' # Import local configuration for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']: From 998608111ffe7a10eb547b59d1eea6bbfbb3ed1d Mon Sep 17 00:00:00 2001 From: Christophe CHAUVET Date: Thu, 20 Oct 2016 09:55:03 +0200 Subject: [PATCH 02/26] Fix path to find configuration.py on migration --- docs/installation/upgrading.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index 087d9f1984..303915dc76 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -18,7 +18,7 @@ Download and extract the latest version: Copy the 'configuration.py' you created when first installing to the new version: ``` -# cp /opt/netbox-X.Y.Z/configuration.py /opt/netbox/configuration.py +# cp /opt/netbox-X.Y.Z/netbox/netbox/configuration.py /opt/netbox/netbox/netbox/configuration.py ``` If you followed the original installation guide to set up gunicorn, be sure to copy its configuration as well: From 13243785f17e6b39b27673f953867ff14f54992c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 21 Oct 2016 12:34:02 -0400 Subject: [PATCH 03/26] Closes #87: Added status field to IP addresses --- netbox/ipam/api/serializers.py | 4 +-- netbox/ipam/filters.py | 2 +- netbox/ipam/forms.py | 25 +++++++++++++------ .../migrations/0009_ipaddress_add_status.py | 20 +++++++++++++++ netbox/ipam/models.py | 13 ++++++++++ netbox/ipam/tables.py | 3 ++- netbox/templates/ipam/ipaddress.html | 6 +++++ .../templates/ipam/ipaddress_bulk_edit.html | 2 ++ netbox/templates/ipam/ipaddress_edit.html | 1 + netbox/templates/ipam/ipaddress_import.html | 7 +++++- 10 files changed, 70 insertions(+), 13 deletions(-) create mode 100644 netbox/ipam/migrations/0009_ipaddress_add_status.py diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 653d9eba51..f7cf20636f 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -159,8 +159,8 @@ class IPAddressSerializer(CustomFieldSerializer, serializers.ModelSerializer): class Meta: model = IPAddress - fields = ['id', 'family', 'address', 'vrf', 'tenant', 'interface', 'description', 'nat_inside', 'nat_outside', - 'custom_fields'] + fields = ['id', 'family', 'address', 'vrf', 'tenant', 'status', 'interface', 'description', 'nat_inside', + 'nat_outside', 'custom_fields'] class IPAddressNestedSerializer(IPAddressSerializer): diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index a998bb2a04..e7e150b343 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -232,7 +232,7 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): class Meta: model = IPAddress - fields = ['q', 'family', 'device_id', 'device', 'interface_id'] + fields = ['q', 'family', 'status', 'device_id', 'device', 'interface_id'] def search(self, queryset, value): qs_filter = Q(description__icontains=value) diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 1a08aa7fc5..7a4b369e55 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -5,16 +5,15 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from tenancy.models import Tenant from utilities.forms import ( - APISelect, BootstrapMixin, CSVDataField, BulkImportForm, FilterChoiceField, Livesearch, SlugField, + APISelect, BootstrapMixin, CSVDataField, BulkImportForm, FilterChoiceField, Livesearch, SlugField, add_blank_choice, ) from .models import ( - Aggregate, IPAddress, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, VLAN, VLANGroup, VLAN_STATUS_CHOICES, VRF, + Aggregate, IPAddress, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, VLAN, VLANGroup, + VLAN_STATUS_CHOICES, VRF, ) -FORM_PREFIX_STATUS_CHOICES = (('', '---------'),) + PREFIX_STATUS_CHOICES -FORM_VLAN_STATUS_CHOICES = (('', '---------'),) + VLAN_STATUS_CHOICES IP_FAMILY_CHOICES = [ ('', 'All'), (4, 'IPv4'), @@ -248,7 +247,7 @@ class PrefixBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False) vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF') tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) - status = forms.ChoiceField(choices=FORM_PREFIX_STATUS_CHOICES, required=False) + status = forms.ChoiceField(choices=add_blank_choice(PREFIX_STATUS_CHOICES), required=False) role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False) description = forms.CharField(max_length=100, required=False) @@ -301,7 +300,7 @@ class IPAddressForm(BootstrapMixin, CustomFieldForm): class Meta: model = IPAddress - fields = ['address', 'vrf', 'tenant', 'nat_device', 'nat_inside', 'description'] + fields = ['address', 'vrf', 'tenant', 'status', 'nat_device', 'nat_inside', 'description'] help_texts = { 'address': "IPv4 or IPv6 address and mask", 'vrf': "VRF (if applicable)", @@ -352,6 +351,7 @@ class IPAddressFromCSVForm(forms.ModelForm): error_messages={'invalid_choice': 'VRF not found.'}) tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False, error_messages={'invalid_choice': 'Tenant not found.'}) + status_name = forms.ChoiceField(choices=[(s[1], s[0]) for s in IPADDRESS_STATUS_CHOICES]) device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, to_field_name='name', error_messages={'invalid_choice': 'Device not found.'}) interface_name = forms.CharField(required=False) @@ -359,7 +359,7 @@ class IPAddressFromCSVForm(forms.ModelForm): class Meta: model = IPAddress - fields = ['address', 'vrf', 'tenant', 'device', 'interface_name', 'is_primary', 'description'] + fields = ['address', 'vrf', 'tenant', 'status_name', 'device', 'interface_name', 'is_primary', 'description'] def clean(self): @@ -406,12 +406,20 @@ class IPAddressBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=IPAddress.objects.all(), widget=forms.MultipleHiddenInput) vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF') tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) + status = forms.ChoiceField(choices=add_blank_choice(IPADDRESS_STATUS_CHOICES), required=False) description = forms.CharField(max_length=100, required=False) class Meta: nullable_fields = ['vrf', 'tenant', 'description'] +def ipaddress_status_choices(): + status_counts = {} + for status in IPAddress.objects.values('status').annotate(count=Count('status')).order_by('status'): + status_counts[status['status']] = status['count'] + return [(s[0], u'{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in IPADDRESS_STATUS_CHOICES] + + class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm): model = IPAddress parent = forms.CharField(required=False, label='Search Within', widget=forms.TextInput(attrs={ @@ -422,6 +430,7 @@ class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm): label='VRF', null_option=(0, 'Global')) tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('ip_addresses')), to_field_name='slug', null_option=(0, 'None')) + status = forms.MultipleChoiceField(choices=ipaddress_status_choices, required=False) # @@ -510,7 +519,7 @@ class VLANBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False) group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False) tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) - status = forms.ChoiceField(choices=FORM_VLAN_STATUS_CHOICES, required=False) + status = forms.ChoiceField(choices=add_blank_choice(VLAN_STATUS_CHOICES), required=False) role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False) description = forms.CharField(max_length=100, required=False) diff --git a/netbox/ipam/migrations/0009_ipaddress_add_status.py b/netbox/ipam/migrations/0009_ipaddress_add_status.py new file mode 100644 index 0000000000..ad876c3b6b --- /dev/null +++ b/netbox/ipam/migrations/0009_ipaddress_add_status.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10 on 2016-10-21 15:44 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0008_prefix_change_order'), + ] + + operations = [ + migrations.AddField( + model_name='ipaddress', + name='status', + field=models.PositiveSmallIntegerField(choices=[(1, b'Active'), (2, b'Reserved'), (5, b'DHCP')], default=1, verbose_name=b'Status'), + ), + ] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 794198b925..88d83470e2 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -29,6 +29,12 @@ (3, 'Deprecated') ) +IPADDRESS_STATUS_CHOICES = ( + (1, 'Active'), + (2, 'Reserved'), + (5, 'DHCP') +) + VLAN_STATUS_CHOICES = ( (1, 'Active'), (2, 'Reserved'), @@ -40,6 +46,8 @@ 1: 'primary', 2: 'info', 3: 'danger', + 4: 'warning', + 5: 'success', } @@ -333,6 +341,7 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel): vrf = models.ForeignKey('VRF', related_name='ip_addresses', on_delete=models.PROTECT, blank=True, null=True, verbose_name='VRF') tenant = models.ForeignKey(Tenant, related_name='ip_addresses', blank=True, null=True, on_delete=models.PROTECT) + status = models.PositiveSmallIntegerField('Status', choices=IPADDRESS_STATUS_CHOICES, default=1) interface = models.ForeignKey(Interface, related_name='ip_addresses', on_delete=models.CASCADE, blank=True, null=True) nat_inside = models.OneToOneField('self', related_name='nat_outside', on_delete=models.SET_NULL, blank=True, @@ -387,6 +396,7 @@ def to_csv(self): str(self.address), self.vrf.rd if self.vrf else '', self.tenant.name if self.tenant else '', + self.get_status_display(), self.device.identifier if self.device else '', self.interface.name if self.interface else '', 'True' if is_primary else '', @@ -399,6 +409,9 @@ def device(self): return self.interface.device return None + def get_status_class(self): + return STATUS_CHOICE_CLASSES[self.status] + class VLANGroup(models.Model): """ diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index c669362c51..0a787ad3b7 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -193,6 +193,7 @@ class Meta(BaseTable.Meta): class IPAddressTable(BaseTable): pk = ToggleColumn() address = tables.TemplateColumn(IPADDRESS_LINK, verbose_name='IP Address') + status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status') vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF') tenant = tables.TemplateColumn(TENANT_LINK, verbose_name='Tenant') device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False, @@ -202,7 +203,7 @@ class IPAddressTable(BaseTable): class Meta(BaseTable.Meta): model = IPAddress - fields = ('pk', 'address', 'vrf', 'tenant', 'device', 'interface', 'description') + fields = ('pk', 'address', 'status', 'vrf', 'tenant', 'device', 'interface', 'description') row_attrs = { 'class': lambda record: 'success' if not isinstance(record, IPAddress) else '', } diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index 7524b00fe2..b85b98c931 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -76,6 +76,12 @@

{{ ipaddress }}

{% endif %} + + Status + + {{ ipaddress.get_status_display }} + + Description diff --git a/netbox/templates/ipam/ipaddress_bulk_edit.html b/netbox/templates/ipam/ipaddress_bulk_edit.html index 818fc20e66..7dc0f6d1af 100644 --- a/netbox/templates/ipam/ipaddress_bulk_edit.html +++ b/netbox/templates/ipam/ipaddress_bulk_edit.html @@ -8,6 +8,7 @@ IP Address VRF Tenant + Status Assigned Description @@ -16,6 +17,7 @@ {{ ipaddress }} {{ ipaddress.vrf|default:"Global" }} {{ ipaddress.tenant }} + {{ ipaddress.get_status_display }} {% if ipaddress.interface %}{% endif %} {{ ipaddress.description }} diff --git a/netbox/templates/ipam/ipaddress_edit.html b/netbox/templates/ipam/ipaddress_edit.html index eb36ff977f..e5e7f0b5b5 100644 --- a/netbox/templates/ipam/ipaddress_edit.html +++ b/netbox/templates/ipam/ipaddress_edit.html @@ -9,6 +9,7 @@ {% render_field form.address %} {% render_field form.vrf %} {% render_field form.tenant %} + {% render_field form.status %} {% if obj %}
diff --git a/netbox/templates/ipam/ipaddress_import.html b/netbox/templates/ipam/ipaddress_import.html index b819df4948..ad62b44df3 100644 --- a/netbox/templates/ipam/ipaddress_import.html +++ b/netbox/templates/ipam/ipaddress_import.html @@ -43,6 +43,11 @@

CSV Format

Name of tenant (optional) ABC01 + + Status + Current status + Active + Device Device name (optional) @@ -66,7 +71,7 @@

CSV Format

Example

-
192.0.2.42/24,65000:123,ABC01,switch12,ge-0/0/31,True,Management IP
+
192.0.2.42/24,65000:123,ABC01,Active,switch12,ge-0/0/31,True,Management IP
{% endblock %} From fc2ac8a02b4b9bd0622101879bd1ebb3beea9053 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 21 Oct 2016 15:39:13 -0400 Subject: [PATCH 04/26] Attributed all model ValidationErrors to specific fields (where appropriate) --- netbox/dcim/models.py | 109 +++++++++++++++++++++++++-------------- netbox/ipam/forms.py | 10 ---- netbox/ipam/models.py | 38 +++++++++----- netbox/secrets/forms.py | 10 ++-- netbox/secrets/models.py | 26 +++++++--- 5 files changed, 118 insertions(+), 75 deletions(-) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 2cfbbcc702..a7ef02396f 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -401,8 +401,11 @@ def clean(self): if top_device: min_height = top_device.position + top_device.device_type.u_height - 1 if self.u_height < min_height: - raise ValidationError("Rack must be at least {}U tall with currently installed devices." - .format(min_height)) + raise ValidationError({ + 'u_height': "Rack must be at least {}U tall to house currently installed devices.".format( + min_height + ) + }) def to_csv(self): return ','.join([ @@ -596,27 +599,39 @@ def clean(self): u_available = d.rack.get_available_units(u_height=self.u_height, rack_face=face_required, exclude=[d.pk]) if d.position not in u_available: - raise ValidationError("Device {} in rack {} does not have sufficient space to accommodate a height " - "of {}U".format(d, d.rack, self.u_height)) + raise ValidationError({ + 'u_height': "Device {} in rack {} does not have sufficient space to accommodate a height of " + "{}U".format(d, d.rack, self.u_height) + }) if not self.is_console_server and self.cs_port_templates.count(): - raise ValidationError("Must delete all console server port templates associated with this device before " - "declassifying it as a console server.") + raise ValidationError({ + 'is_console_server': "Must delete all console server port templates associated with this device before " + "declassifying it as a console server." + }) if not self.is_pdu and self.power_outlet_templates.count(): - raise ValidationError("Must delete all power outlet templates associated with this device before " - "declassifying it as a PDU.") + raise ValidationError({ + 'is_pdu': "Must delete all power outlet templates associated with this device before declassifying it " + "as a PDU." + }) if not self.is_network_device and self.interface_templates.filter(mgmt_only=False).count(): - raise ValidationError("Must delete all non-management-only interface templates associated with this device " - "before declassifying it as a network device.") + raise ValidationError({ + 'is_network_device': "Must delete all non-management-only interface templates associated with this " + "device before declassifying it as a network device." + }) if self.subdevice_role != SUBDEVICE_ROLE_PARENT and self.device_bay_templates.count(): - raise ValidationError("Must delete all device bay templates associated with this device before " - "declassifying it as a parent device.") + raise ValidationError({ + 'subdevice_role': "Must delete all device bay templates associated with this device before " + "declassifying it as a parent device." + }) if self.u_height and self.subdevice_role == SUBDEVICE_ROLE_CHILD: - raise ValidationError("Child device types must be 0U.") + raise ValidationError({ + 'u_height': "Child device types must be 0U." + }) @property def is_parent_device(self): @@ -824,29 +839,39 @@ def get_absolute_url(self): def clean(self): - # Validate device type assignment - if not hasattr(self, 'device_type'): - raise ValidationError("Must specify device type.") - - # Child devices cannot be assigned to a rack face/unit - if self.device_type.is_child_device and (self.face is not None or self.position): - raise ValidationError("Child device types cannot be assigned a rack face or position.") - # Validate position/face combination if self.position and self.face is None: - raise ValidationError("Must specify rack face with rack position.") - - # Validate rack space - rack_face = self.face if not self.device_type.is_full_depth else None - exclude_list = [self.pk] if self.pk else [] - try: - available_units = self.rack.get_available_units(u_height=self.device_type.u_height, rack_face=rack_face, - exclude=exclude_list) - if self.position and self.position not in available_units: - raise ValidationError("U{} is already occupied or does not have sufficient space to accommodate a(n) " - "{} ({}U).".format(self.position, self.device_type, self.device_type.u_height)) - except Rack.DoesNotExist: - pass + raise ValidationError({ + 'face': "Must specify rack face when defining rack position." + }) + + if self.device_type: + + # Child devices cannot be assigned to a rack face/unit + if self.device_type.is_child_device and self.face is not None: + raise ValidationError({ + 'face': "Child device types cannot be assigned to a rack face. This is an attribute of the parent " + "device." + }) + if self.device_type.is_child_device and self.position: + raise ValidationError({ + 'position': "Child device types cannot be assigned to a rack position. This is an attribute of the " + "parent device." + }) + + # Validate rack space + rack_face = self.face if not self.device_type.is_full_depth else None + exclude_list = [self.pk] if self.pk else [] + try: + available_units = self.rack.get_available_units(u_height=self.device_type.u_height, rack_face=rack_face, + exclude=exclude_list) + if self.position and self.position not in available_units: + raise ValidationError({ + 'position': "U{} is already occupied or does not have sufficient space to accommodate a(n) {} " + "({}U).".format(self.position, self.device_type, self.device_type.u_height) + }) + except Rack.DoesNotExist: + pass def save(self, *args, **kwargs): @@ -1094,9 +1119,10 @@ def __unicode__(self): def clean(self): if self.form_factor == IFACE_FF_VIRTUAL and self.is_connected: - raise ValidationError({'form_factor': "Virtual interfaces cannot be connected to another interface or " - "circuit. Disconnect the interface or choose a physical form " - "factor."}) + raise ValidationError({ + 'form_factor': "Virtual interfaces cannot be connected to another interface or circuit. Disconnect the " + "interface or choose a physical form factor." + }) @property def is_physical(self): @@ -1147,7 +1173,9 @@ class InterfaceConnection(models.Model): def clean(self): if self.interface_a == self.interface_b: - raise ValidationError("Cannot connect an interface to itself") + raise ValidationError({ + 'interface_b': "Cannot connect an interface to itself." + }) # Used for connections export def to_csv(self): @@ -1180,8 +1208,9 @@ def clean(self): # Validate that the parent Device can have DeviceBays if not self.device.device_type.is_parent_device: - raise ValidationError("This type of device ({}) does not support device bays." - .format(self.device.device_type)) + raise ValidationError("This type of device ({}) does not support device bays.".format( + self.device.device_type + )) # Cannot install a device into itself, obviously if self.device == self.installed_device: diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 7a4b369e55..cc67db47c7 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -172,16 +172,6 @@ def __init__(self, *args, **kwargs): else: self.fields['vlan'].choices = [] - def clean_prefix(self): - prefix = self.cleaned_data['prefix'] - if prefix.version == 4 and prefix.prefixlen == 32: - raise forms.ValidationError("Cannot create host addresses (/32) as prefixes. These should be IPv4 " - "addresses instead.") - elif prefix.version == 6 and prefix.prefixlen == 128: - raise forms.ValidationError("Cannot create host addresses (/128) as prefixes. These should be IPv6 " - "addresses instead.") - return prefix - class PrefixFromCSVForm(forms.ModelForm): vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, to_field_name='rd', diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 88d83470e2..e8f4238b84 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -139,16 +139,22 @@ def clean(self): if self.pk: covering_aggregates = covering_aggregates.exclude(pk=self.pk) if covering_aggregates: - raise ValidationError("{} is already covered by an existing aggregate ({})" - .format(self.prefix, covering_aggregates[0])) + raise ValidationError({ + 'prefix': "Aggregates cannot overlap. {} is already covered by an existing aggregate ({}).".format( + self.prefix, covering_aggregates[0] + ) + }) # Ensure that the aggregate being added does not cover an existing aggregate covered_aggregates = Aggregate.objects.filter(prefix__net_contained=str(self.prefix)) if self.pk: covered_aggregates = covered_aggregates.exclude(pk=self.pk) if covered_aggregates: - raise ValidationError("{} overlaps with an existing aggregate ({})" - .format(self.prefix, covered_aggregates[0])) + raise ValidationError({ + 'prefix': "Aggregates cannot overlap. {} covers an existing aggregate ({}).".format( + self.prefix, covered_aggregates[0] + ) + }) def save(self, *args, **kwargs): if self.prefix: @@ -268,14 +274,17 @@ def get_absolute_url(self): return reverse('ipam:prefix', args=[self.pk]) def clean(self): + # Disallow host masks if self.prefix: if self.prefix.version == 4 and self.prefix.prefixlen == 32: - raise ValidationError("Cannot create host addresses (/32) as prefixes. These should be IPv4 addresses " - "instead.") + raise ValidationError({ + 'prefix': "Cannot create host addresses (/32) as prefixes. Create an IPv4 address instead." + }) elif self.prefix.version == 6 and self.prefix.prefixlen == 128: - raise ValidationError("Cannot create host addresses (/128) as prefixes. These should be IPv6 addresses " - "instead.") + raise ValidationError({ + 'prefix': "Cannot create host addresses (/128) as prefixes. Create an IPv6 address instead." + }) def save(self, *args, **kwargs): if self.prefix: @@ -369,13 +378,16 @@ def clean(self): duplicate_ips = IPAddress.objects.filter(vrf=self.vrf, address__net_host=str(self.address.ip))\ .exclude(pk=self.pk) if duplicate_ips: - raise ValidationError("Duplicate IP address found in VRF {}: {}".format(self.vrf, - duplicate_ips.first())) + raise ValidationError({ + 'address': "Duplicate IP address found in VRF {}: {}".format(self.vrf, duplicate_ips.first()) + }) elif not self.vrf and settings.ENFORCE_GLOBAL_UNIQUE: duplicate_ips = IPAddress.objects.filter(vrf=None, address__net_host=str(self.address.ip))\ .exclude(pk=self.pk) if duplicate_ips: - raise ValidationError("Duplicate IP address found in global table: {}".format(duplicate_ips.first())) + raise ValidationError({ + 'address': "Duplicate IP address found in global table: {}".format(duplicate_ips.first()) + }) def save(self, *args, **kwargs): if self.address: @@ -478,7 +490,9 @@ def clean(self): # Validate VLAN group if self.group and self.group.site != self.site: - raise ValidationError("VLAN group must belong to the assigned site ({}).".format(self.site)) + raise ValidationError({ + 'group': "VLAN group must belong to the assigned site ({}).".format(self.site) + }) def to_csv(self): return ','.join([ diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index a163640b83..7eb9e816af 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -57,14 +57,14 @@ class Meta: fields = ['role', 'name', 'plaintext', 'plaintext2'] def clean(self): + if self.cleaned_data['plaintext']: validate_rsa_key(self.cleaned_data['private_key']) - def clean_plaintext2(self): - plaintext = self.cleaned_data['plaintext'] - plaintext2 = self.cleaned_data['plaintext2'] - if plaintext != plaintext2: - raise forms.ValidationError("The two given plaintext values do not match. Please check your input.") + if self.cleaned_data['plaintext'] != self.cleaned_data['plaintext2']: + raise forms.ValidationError({ + 'plaintext2': "The two given plaintext values do not match. Please check your input." + }) class SecretFromCSVForm(forms.ModelForm): diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index 80abfcbdf6..930d6e0329 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -81,24 +81,34 @@ def __unicode__(self): def clean(self, *args, **kwargs): - # Validate the public key format and length. if self.public_key: + + # Validate the public key format try: pubkey = RSA.importKey(self.public_key) except ValueError: - raise ValidationError("Invalid RSA key format.") + raise ValidationError({ + 'public_key': "Invalid RSA key format." + }) except: raise ValidationError("Something went wrong while trying to save your key. Please ensure that you're " "uploading a valid RSA public key in PEM format (no SSH/PGP).") - # key.size() returns 1 less than the key modulus - pubkey_length = pubkey.size() + 1 + + # Validate the public key length + pubkey_length = pubkey.size() + 1 # key.size() returns 1 less than the key modulus if pubkey_length < settings.SECRETS_MIN_PUBKEY_SIZE: - raise ValidationError("Insufficient key length. Keys must be at least {} bits long." - .format(settings.SECRETS_MIN_PUBKEY_SIZE)) + raise ValidationError({ + 'public_key': "Insufficient key length. Keys must be at least {} bits long.".format( + settings.SECRETS_MIN_PUBKEY_SIZE + ) + }) # We can't use keys bigger than our master_key_cipher field can hold if pubkey_length > 4096: - raise ValidationError("Public key size ({}) is too large. Maximum key size is 4096 bits." - .format(pubkey_length)) + raise ValidationError({ + 'public_key': "Public key size ({}) is too large. Maximum key size is 4096 bits.".format( + pubkey_length + ) + }) super(UserKey, self).clean() From f44a322df5a0e315599371f63bb3717dbdafd1f7 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 24 Oct 2016 13:53:58 -0400 Subject: [PATCH 05/26] Closes #630: Added a custom 404 page --- netbox/netbox/urls.py | 4 +--- netbox/netbox/views.py | 18 +++++++++++------- netbox/templates/404.html | 19 +++++++++++++++++++ 3 files changed, 31 insertions(+), 10 deletions(-) create mode 100644 netbox/templates/404.html diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 41b71546e0..b579671bf0 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -1,9 +1,8 @@ from django.conf import settings from django.conf.urls import include, url from django.contrib import admin -from django.views.defaults import page_not_found -from views import home, trigger_500, handle_500 +from views import home, handle_500, trigger_500 from users.views import login, logout @@ -36,7 +35,6 @@ url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')), # Error testing - url(r'^404/$', page_not_found), url(r'^500/$', trigger_500), # Admin diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index 2da97a2cf2..7aa1442957 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -47,16 +47,20 @@ def home(request): }) -def trigger_500(request): - """Hot-wired method of triggering a server error to test reporting.""" - raise Exception("Congratulations, you've triggered an exception! Go tell all your friends what an exceptional " - "person you are.") - - def handle_500(request): - """Custom server error handler""" + """ + Custom server error handler + """ type_, error, traceback = sys.exc_info() return render(request, '500.html', { 'exception': str(type_), 'error': error, }, status=500) + + +def trigger_500(request): + """ + Hot-wired method of triggering a server error to test reporting + """ + raise Exception("Congratulations, you've triggered an exception! Go tell all your friends what an exceptional " + "person you are.") diff --git a/netbox/templates/404.html b/netbox/templates/404.html new file mode 100644 index 0000000000..92d1f3589b --- /dev/null +++ b/netbox/templates/404.html @@ -0,0 +1,19 @@ +{% extends '_base.html' %} + +{% block content %} +
+
+
+
+ Page Not Found +
+
+ The requested page does not exist. +
+ +
+
+
+{% endblock %} From e22eafc4a76b5e8de4651a109115b4134158603d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 24 Oct 2016 15:07:11 -0400 Subject: [PATCH 06/26] Closes #211: Allow device assignment and removal from IP address view --- netbox/dcim/views.py | 5 +- netbox/ipam/forms.py | 25 ++++++- netbox/ipam/urls.py | 2 + netbox/ipam/views.py | 73 ++++++++++++++++++- netbox/templates/ipam/ipaddress.html | 6 ++ netbox/templates/ipam/ipaddress_assign.html | 56 ++++++++++++++ netbox/templates/ipam/ipaddress_edit.html | 14 +++- netbox/templates/ipam/ipaddress_unassign.html | 8 ++ 8 files changed, 182 insertions(+), 7 deletions(-) create mode 100644 netbox/templates/ipam/ipaddress_assign.html create mode 100644 netbox/templates/ipam/ipaddress_unassign.html diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index b68a1dba05..501859f558 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1715,7 +1715,7 @@ class InterfaceConnectionsListView(ObjectListView): # IP addresses # -@permission_required('ipam.add_ipaddress') +@permission_required(['dcim.change_device', 'ipam.add_ipaddress']) def ipaddress_assign(request, pk): device = get_object_or_404(Device, pk=pk) @@ -1727,8 +1727,7 @@ def ipaddress_assign(request, pk): ipaddress = form.save(commit=False) ipaddress.interface = form.cleaned_data['interface'] ipaddress.save() - messages.success(request, "Added new IP address {0} to interface {1}".format(ipaddress, - ipaddress.interface)) + messages.success(request, "Added new IP address {} to interface {}".format(ipaddress, ipaddress.interface)) if form.cleaned_data['set_as_primary']: if ipaddress.family == 4: diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index cc67db47c7..039475d036 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -1,7 +1,7 @@ from django import forms from django.db.models import Count -from dcim.models import Site, Device, Interface +from dcim.models import Site, Rack, Device, Interface from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from tenancy.models import Tenant from utilities.forms import ( @@ -336,6 +336,29 @@ def __init__(self, *args, **kwargs): self.fields['nat_inside'].choices = [] +class IPAddressAssignForm(BootstrapMixin, forms.Form): + site = forms.ModelChoiceField(queryset=Site.objects.all(), label='Site', required=False, + widget=forms.Select(attrs={'filter-for': 'rack'})) + rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False, + widget=APISelect(api_url='/api/dcim/racks/?site_id={{site}}', display_field='display_name', attrs={'filter-for': 'device'})) + 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'})) + livesearch = forms.CharField(required=False, label='Device', widget=Livesearch( + query_key='q', query_url='dcim-api:device_list', field_to_update='device') + ) + interface = forms.ModelChoiceField(queryset=Interface.objects.all(), label='Interface', + widget=APISelect(api_url='/api/dcim/devices/{{device}}/interfaces/')) + set_as_primary = forms.BooleanField(label='Set as primary IP for device', required=False) + + def __init__(self, *args, **kwargs): + + super(IPAddressAssignForm, self).__init__(*args, **kwargs) + + self.fields['rack'].choices = [] + self.fields['device'].choices = [] + self.fields['interface'].choices = [] + + class IPAddressFromCSVForm(forms.ModelForm): vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, to_field_name='rd', error_messages={'invalid_choice': 'VRF not found.'}) diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index 22c4cd512c..dc5fcc9646 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -56,6 +56,8 @@ url(r'^ip-addresses/delete/$', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'), url(r'^ip-addresses/(?P\d+)/$', views.ipaddress, name='ipaddress'), url(r'^ip-addresses/(?P\d+)/edit/$', views.IPAddressEditView.as_view(), name='ipaddress_edit'), + url(r'^ip-addresses/(?P\d+)/assign/$', views.ipaddress_assign, name='ipaddress_assign'), + url(r'^ip-addresses/(?P\d+)/remove/$', views.ipaddress_remove, name='ipaddress_remove'), url(r'^ip-addresses/(?P\d+)/delete/$', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'), # VLAN groups diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index c7c5a46c6e..646dbde8a4 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -1,11 +1,15 @@ import netaddr from django_tables2 import RequestConfig +from django.contrib.auth.decorators import permission_required from django.contrib.auth.mixins import PermissionRequiredMixin +from django.contrib import messages +from django.core.urlresolvers import reverse from django.db.models import Count, Q -from django.shortcuts import get_object_or_404, render +from django.shortcuts import get_object_or_404, redirect, render from dcim.models import Device +from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator from utilities.views import ( BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, @@ -446,6 +450,73 @@ def ipaddress(request, pk): }) +@permission_required(['dcim.change_device', 'ipam.change_ipaddress']) +def ipaddress_assign(request, pk): + + ipaddress = get_object_or_404(IPAddress, pk=pk) + + if request.method == 'POST': + form = forms.IPAddressAssignForm(request.POST) + if form.is_valid(): + + interface = form.cleaned_data['interface'] + ipaddress.interface = interface + ipaddress.save() + messages.success(request, "Assigned IP address {} to interface {}".format(ipaddress, ipaddress.interface)) + + if form.cleaned_data['set_as_primary']: + device = interface.device + if ipaddress.family == 4: + device.primary_ip4 = ipaddress + elif ipaddress.family == 6: + device.primary_ip6 = ipaddress + device.save() + + return redirect('ipam:ipaddress', pk=ipaddress.pk) + + else: + form = forms.IPAddressAssignForm() + + return render(request, 'ipam/ipaddress_assign.html', { + 'ipaddress': ipaddress, + 'form': form, + 'cancel_url': reverse('ipam:ipaddress', kwargs={'pk': ipaddress.pk}), + }) + + +@permission_required(['dcim.change_device', 'ipam.change_ipaddress']) +def ipaddress_remove(request, pk): + + ipaddress = get_object_or_404(IPAddress, pk=pk) + + if request.method == 'POST': + form = ConfirmationForm(request.POST) + if form.is_valid(): + + device = ipaddress.interface.device + ipaddress.interface = None + ipaddress.save() + messages.success(request, "Removed IP address {} from {}".format(ipaddress, device)) + + if device.primary_ip4 == ipaddress.pk: + device.primary_ip4 = None + device.save() + elif device.primary_ip6 == ipaddress.pk: + device.primary_ip6 = None + device.save() + + return redirect('ipam:ipaddress', pk=ipaddress.pk) + + else: + form = ConfirmationForm() + + return render(request, 'ipam/ipaddress_unassign.html', { + 'ipaddress': ipaddress, + 'form': form, + 'cancel_url': reverse('ipam:ipaddress', kwargs={'pk': ipaddress.pk}), + }) + + class IPAddressEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.change_ipaddress' model = IPAddress diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index b85b98c931..2392e462be 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -97,8 +97,14 @@

{{ ipaddress }}

{% if ipaddress.interface %} {{ ipaddress.interface.device }} ({{ ipaddress.interface }}) + {% if perms.dcim.change_device and perms.ipam.change_ipaddress %} + Remove + {% endif %} {% else %} None + {% if perms.dcim.change_device and perms.ipam.change_ipaddress %} + Assign + {% endif %} {% endif %} diff --git a/netbox/templates/ipam/ipaddress_assign.html b/netbox/templates/ipam/ipaddress_assign.html new file mode 100644 index 0000000000..4143dc3eea --- /dev/null +++ b/netbox/templates/ipam/ipaddress_assign.html @@ -0,0 +1,56 @@ +{% extends '_base.html' %} +{% load static from staticfiles %} +{% load form_helpers %} + +{% block title %}Assign IP Address{% endblock %} + +{% block content %} +
+ {% csrf_token %} +
+
+ {% if form.non_field_errors %} +
+
Errors
+
+ {{ form.non_field_errors }} +
+
+ {% endif %} +
+
+ Assign IP Address {{ ipaddress }} ({% if ipaddress.vrf %}VRF {{ ipaddress.vrf }}{% else %}Global Table{% endif %}) +
+
+ +
+ +
+ {% render_field form.site %} + {% render_field form.rack %} + {% render_field form.device %} +
+
+ {% render_field form.interface %} + {% render_field form.set_as_primary %} +
+
+
+
+ + Cancel +
+
+
+
+
+{% endblock %} + +{% block javascript %} + +{% endblock %} diff --git a/netbox/templates/ipam/ipaddress_edit.html b/netbox/templates/ipam/ipaddress_edit.html index e5e7f0b5b5..65d0678b77 100644 --- a/netbox/templates/ipam/ipaddress_edit.html +++ b/netbox/templates/ipam/ipaddress_edit.html @@ -17,8 +17,12 @@

{% if obj.interface %} {{ obj.interface.device }} + Remove {% else %} - None + None + {% if obj.pk %} + Assign + {% endif %} {% endif %}

@@ -26,7 +30,13 @@
-

{{ obj.interface }}

+

+ {% if obj.interface %} + {{ obj.interface }} + {% else %} + None + {% endif %} +

{% endif %} diff --git a/netbox/templates/ipam/ipaddress_unassign.html b/netbox/templates/ipam/ipaddress_unassign.html new file mode 100644 index 0000000000..5cd83abb95 --- /dev/null +++ b/netbox/templates/ipam/ipaddress_unassign.html @@ -0,0 +1,8 @@ +{% extends 'utilities/confirmation_form.html' %} +{% load form_helpers %} + +{% block title %}Remove {{ ipaddress }} from {{ ipaddress.interface }}?{% endblock %} + +{% block message %} +

Are you sure you want to remove this IP address from {{ ipaddress.interface.device }} {{ ipaddress.interface }}?

+{% endblock %} From 198674f3687f70e946e721493bc303421a0d6baf Mon Sep 17 00:00:00 2001 From: Jonathan Senecal Date: Mon, 24 Oct 2016 17:22:01 -0400 Subject: [PATCH 07/26] Fixed "Power Port" column name --- netbox/dcim/tables.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 9906a398e0..c451f623aa 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -357,7 +357,7 @@ class PowerConnectionTable(BaseTable): args=[Accessor('power_outlet.device.pk')], verbose_name='PDU') power_outlet = tables.Column(verbose_name='Outlet') device = tables.LinkColumn('dcim:device', args=[Accessor('device.pk')], verbose_name='Device') - name = tables.Column(verbose_name='Console port') + name = tables.Column(verbose_name='Power Port') class Meta(BaseTable.Meta): model = PowerPort From 5cd9c111695c4eaaf23b247b0d54fd31f16b1eae Mon Sep 17 00:00:00 2001 From: Florian Klink Date: Fri, 28 Oct 2016 12:24:55 +0200 Subject: [PATCH 08/26] gitignore static folder, concretize configuration.py location this adds the netbox/static folder to the gitignore file, and further specifies the path from where we'd like to ignore net netbox configuration.py. --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 954607b60f..4fc377333e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ *.pyc -configuration.py +/netbox/netbox/configuration.py +/netbox/static .idea /*.sh !upgrade.sh From 2db50dd4a7920e341577e7f42f011ae381dce231 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 28 Oct 2016 11:30:40 -0400 Subject: [PATCH 09/26] Closes #191: Support for racks numbered top-to-bottom --- netbox/dcim/api/serializers.py | 4 ++-- netbox/dcim/forms.py | 8 +++++--- .../dcim/migrations/0020_rack_desc_units.py | 20 +++++++++++++++++++ netbox/dcim/models.py | 11 +++++++--- netbox/dcim/tests/test_apis.py | 3 +++ netbox/templates/dcim/device_import.html | 2 +- .../dcim/{ => inc}/_rack_elevation.html | 0 netbox/templates/dcim/rack.html | 6 +++--- netbox/templates/dcim/rack_edit.html | 1 + netbox/templates/dcim/rack_import.html | 7 ++++++- 10 files changed, 49 insertions(+), 13 deletions(-) create mode 100644 netbox/dcim/migrations/0020_rack_desc_units.py rename netbox/templates/dcim/{ => inc}/_rack_elevation.html (100%) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 1f3ced50a0..ef7a4be605 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -79,7 +79,7 @@ class RackSerializer(CustomFieldSerializer, serializers.ModelSerializer): class Meta: model = Rack fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width', - 'u_height', 'comments', 'custom_fields'] + 'u_height', 'desc_units', 'comments', 'custom_fields'] class RackNestedSerializer(RackSerializer): @@ -94,7 +94,7 @@ class RackDetailSerializer(RackSerializer): class Meta(RackSerializer.Meta): fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width', - 'u_height', 'comments', 'custom_fields', 'front_units', 'rear_units'] + 'u_height', 'desc_units', 'comments', 'custom_fields', 'front_units', 'rear_units'] def get_front_units(self, obj): units = obj.get_rack_units(face=RACK_FACE_FRONT) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 3c6ac7e43a..7e0867396f 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -142,7 +142,8 @@ class RackForm(BootstrapMixin, CustomFieldForm): class Meta: model = Rack - fields = ['site', 'group', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height', 'comments'] + fields = ['site', 'group', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units', + 'comments'] help_texts = { 'site': "The site at which the rack exists", 'name': "Organizational rack name", @@ -178,7 +179,8 @@ class RackFromCSVForm(forms.ModelForm): class Meta: model = Rack - fields = ['site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height'] + fields = ['site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height', + 'desc_units'] def clean(self): @@ -368,7 +370,7 @@ class DeviceForm(BootstrapMixin, CustomFieldForm): attrs={'filter-for': 'position'} )) position = forms.TypedChoiceField(required=False, empty_value=None, - help_text="For multi-U devices, this is the lowest occupied rack unit.", + help_text="The lowest-numbered unit occupied by the device", widget=APISelect(api_url='/api/dcim/racks/{{rack}}/rack-units/?face={{face}}', disabled_indicator='device')) manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), diff --git a/netbox/dcim/migrations/0020_rack_desc_units.py b/netbox/dcim/migrations/0020_rack_desc_units.py new file mode 100644 index 0000000000..d5a74706d3 --- /dev/null +++ b/netbox/dcim/migrations/0020_rack_desc_units.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10 on 2016-10-28 15:01 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0019_new_iface_form_factors'), + ] + + operations = [ + migrations.AddField( + model_name='rack', + name='desc_units', + field=models.BooleanField(default=False, help_text=b'Units are numbered top-to-bottom', verbose_name=b'Descending units'), + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index a7ef02396f..b40770c354 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -375,6 +375,8 @@ class Rack(CreatedUpdatedModel, CustomFieldModel): help_text='Rail-to-rail width') u_height = models.PositiveSmallIntegerField(default=42, verbose_name='Height (U)', validators=[MinValueValidator(1), MaxValueValidator(100)]) + desc_units = models.BooleanField(default=False, verbose_name='Descending units', + help_text='Units are numbered top-to-bottom') comments = models.TextField(blank=True) custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') @@ -422,7 +424,10 @@ def to_csv(self): @property def units(self): - return reversed(range(1, self.u_height + 1)) + if self.desc_units: + return range(1, self.u_height + 1) + else: + return reversed(range(1, self.u_height + 1)) @property def display_name(self): @@ -441,7 +446,7 @@ def get_rack_units(self, face=RACK_FACE_FRONT, exclude=None, remove_redundant=Fa """ elevation = OrderedDict() - for u in reversed(range(1, self.u_height + 1)): + for u in self.units: elevation[u] = {'id': u, 'name': 'U{}'.format(u), 'face': face, 'device': None} # Add devices to rack units list @@ -815,7 +820,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel): rack = models.ForeignKey('Rack', related_name='devices', on_delete=models.PROTECT) position = models.PositiveSmallIntegerField(blank=True, null=True, validators=[MinValueValidator(1)], verbose_name='Position (U)', - help_text='Number of the lowest U position occupied by the device') + help_text='The lowest-numbered unit occupied by the device') face = models.PositiveSmallIntegerField(blank=True, null=True, choices=RACK_FACE_CHOICES, verbose_name='Rack face') status = models.BooleanField(choices=STATUS_CHOICES, default=STATUS_ACTIVE, verbose_name='Status') primary_ip4 = models.OneToOneField('ipam.IPAddress', related_name='primary_ip4_for', on_delete=models.SET_NULL, diff --git a/netbox/dcim/tests/test_apis.py b/netbox/dcim/tests/test_apis.py index 5f52776c34..352c368992 100644 --- a/netbox/dcim/tests/test_apis.py +++ b/netbox/dcim/tests/test_apis.py @@ -49,6 +49,7 @@ class SiteTest(APITestCase): 'type', 'width', 'u_height', + 'desc_units', 'comments', 'custom_fields', ] @@ -129,6 +130,7 @@ class RackTest(APITestCase): 'type', 'width', 'u_height', + 'desc_units', 'comments', 'custom_fields', ] @@ -145,6 +147,7 @@ class RackTest(APITestCase): 'type', 'width', 'u_height', + 'desc_units', 'comments', 'custom_fields', 'front_units', diff --git a/netbox/templates/dcim/device_import.html b/netbox/templates/dcim/device_import.html index 5220e6c2d6..a603ab4efc 100644 --- a/netbox/templates/dcim/device_import.html +++ b/netbox/templates/dcim/device_import.html @@ -78,7 +78,7 @@

CSV Format

Position (U) - Lowest rack unit occupied by the device (optional) + Lowest-numbered rack unit occupied by the device (optional) 21 diff --git a/netbox/templates/dcim/_rack_elevation.html b/netbox/templates/dcim/inc/_rack_elevation.html similarity index 100% rename from netbox/templates/dcim/_rack_elevation.html rename to netbox/templates/dcim/inc/_rack_elevation.html diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 4c2aef15df..af457a21de 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -122,7 +122,7 @@

Rack {{ rack.name }}

Height - {{ rack.u_height }}U + {{ rack.u_height }}U ({% if rack.desc_units %}descending{% else %}ascending{% endif %}) Devices @@ -189,13 +189,13 @@

Rack {{ rack.name }}

Front

- {% include 'dcim/_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 %}

Rear

- {% include 'dcim/_rack_elevation.html' with primary_face=rear_elevation secondary_face=front_elevation face_id=1 %} + {% include 'dcim/inc/_rack_elevation.html' with primary_face=rear_elevation secondary_face=front_elevation face_id=1 %}
diff --git a/netbox/templates/dcim/rack_edit.html b/netbox/templates/dcim/rack_edit.html index c2066afcd9..dd4f610c34 100644 --- a/netbox/templates/dcim/rack_edit.html +++ b/netbox/templates/dcim/rack_edit.html @@ -14,6 +14,7 @@ {% render_field form.type %} {% render_field form.width %} {% render_field form.u_height %} + {% render_field form.desc_units %} {% if form.custom_fields %} diff --git a/netbox/templates/dcim/rack_import.html b/netbox/templates/dcim/rack_import.html index c5775cffdb..807bff8eb5 100644 --- a/netbox/templates/dcim/rack_import.html +++ b/netbox/templates/dcim/rack_import.html @@ -73,10 +73,15 @@

CSV Format

Height in rack units 42 + + Descending units + Units are numbered top-to-bottom + False +

Example

-
DC-4,Cage 1400,R101,J12.100,Pied Piper,Compute,4-post cabinet,19,42
+
DC-4,Cage 1400,R101,J12.100,Pied Piper,Compute,4-post cabinet,19,42,False
{% endblock %} From 28b4f6b8fd46072fced5c8439dc77504f0510033 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 28 Oct 2016 15:12:53 -0400 Subject: [PATCH 10/26] #181: Added ExpandableIPAddressField --- netbox/utilities/forms.py | 60 ++++++++++++++++++++++++++++++++++----- 1 file changed, 53 insertions(+), 7 deletions(-) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 0a43377322..74e0749db1 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -11,25 +11,51 @@ from django.utils.safestring import mark_safe -EXPANSION_PATTERN = '\[(\d+-\d+)\]' +NUMERIC_EXPANSION_PATTERN = '\[(\d+-\d+)\]' +IP4_EXPANSION_PATTERN = '\[([0-9]{1,3}-[0-9]{1,3})\]' +IP6_EXPANSION_PATTERN = '\[([0-9a-f]{1,4}-[0-9a-f]{1,4})\]' -def expand_pattern(string): +def expand_numeric_pattern(string): """ Expand a numeric pattern into a list of strings. Examples: 'ge-0/0/[0-3]' => ['ge-0/0/0', 'ge-0/0/1', 'ge-0/0/2', 'ge-0/0/3'] 'xe-0/[0-3]/[0-7]' => ['xe-0/0/0', 'xe-0/0/1', 'xe-0/0/2', ... 'xe-0/3/5', 'xe-0/3/6', 'xe-0/3/7'] """ - lead, pattern, remnant = re.split(EXPANSION_PATTERN, string, maxsplit=1) + lead, pattern, remnant = re.split(NUMERIC_EXPANSION_PATTERN, string, maxsplit=1) x, y = pattern.split('-') for i in range(int(x), int(y) + 1): - if re.search(EXPANSION_PATTERN, remnant): - for string in expand_pattern(remnant): + if re.search(NUMERIC_EXPANSION_PATTERN, remnant): + for string in expand_numeric_pattern(remnant): yield "{}{}{}".format(lead, i, string) else: yield "{}{}{}".format(lead, i, remnant) +def expand_ipaddress_pattern(string, family): + """ + Expand an IP address pattern into a list of strings. Examples: + '192.0.2.[1-254]/24' => ['192.0.2.1/24', '192.0.2.2/24', '192.0.2.3/24' ... '192.0.2.254/24'] + '2001:db8:0:[0-ff]::/64' => ['2001:db8:0:0::/64', '2001:db8:0:1::/64', ... '2001:db8:0:ff::/64'] + """ + if family not in [4, 6]: + raise Exception("Invalid IP address family: {}".format(family)) + if family == 4: + regex = IP4_EXPANSION_PATTERN + base = 10 + else: + regex = IP6_EXPANSION_PATTERN + base = 16 + lead, pattern, remnant = re.split(regex, string, maxsplit=1) + x, y = pattern.split('-') + for i in range(int(x, base), int(y, base) + 1): + if re.search(regex, remnant): + for string in expand_ipaddress_pattern(remnant, family): + yield ''.join([lead, format(i, 'x' if family == 6 else 'd'), string]) + else: + yield ''.join([lead, format(i, 'x' if family == 6 else 'd'), remnant]) + + def add_blank_choice(choices): """ Add a blank choice to the beginning of a choices list. @@ -178,8 +204,28 @@ def __init__(self, *args, **kwargs): 'Example: ge-0/0/[0-47]' def to_python(self, value): - if re.search(EXPANSION_PATTERN, value): - return list(expand_pattern(value)) + if re.search(NUMERIC_EXPANSION_PATTERN, value): + return list(expand_numeric_pattern(value)) + return [value] + + +class ExpandableIPAddressField(forms.CharField): + """ + A field which allows for expansion of IP address ranges + Example: '192.0.2.[1-254]/24' => ['192.0.2.1/24', '192.0.2.2/24', '192.0.2.3/24' ... '192.0.2.254/24'] + """ + def __init__(self, *args, **kwargs): + super(ExpandableIPAddressField, self).__init__(*args, **kwargs) + if not self.help_text: + self.help_text = 'Specify a numeric range to create multiple IPs.
'\ + 'Example: 192.0.2.[1-254]/24' + + def to_python(self, value): + # Hackish address family detection but it's all we have to work with + if '.' in value and re.search(IP4_EXPANSION_PATTERN, value): + return list(expand_ipaddress_pattern(value, 4)) + elif ':' in value and re.search(IP6_EXPANSION_PATTERN, value): + return list(expand_ipaddress_pattern(value, 6)) return [value] From fd38daf0c58945f6366c81f843ea414330156123 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 28 Oct 2016 16:14:23 -0400 Subject: [PATCH 11/26] Standardized device component edit views to use ObjectEditView() --- netbox/dcim/models.py | 21 ++ netbox/dcim/urls.py | 14 +- netbox/dcim/views.py | 191 ++++-------------- netbox/templates/dcim/consoleport_edit.html | 51 ----- .../dcim/consoleserverport_edit.html | 51 ----- ...le_edit.html => device_component_add.html} | 14 +- netbox/templates/dcim/devicebay_edit.html | 51 ----- netbox/templates/dcim/interface_edit.html | 51 ----- netbox/templates/dcim/poweroutlet_edit.html | 51 ----- netbox/templates/dcim/powerport_edit.html | 51 ----- 10 files changed, 76 insertions(+), 470 deletions(-) delete mode 100644 netbox/templates/dcim/consoleport_edit.html delete mode 100644 netbox/templates/dcim/consoleserverport_edit.html rename netbox/templates/dcim/{module_edit.html => device_component_add.html} (60%) delete mode 100644 netbox/templates/dcim/devicebay_edit.html delete mode 100644 netbox/templates/dcim/interface_edit.html delete mode 100644 netbox/templates/dcim/poweroutlet_edit.html delete mode 100644 netbox/templates/dcim/powerport_edit.html diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index b40770c354..f487759a7b 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -991,6 +991,9 @@ class Meta: def __unicode__(self): return self.name + def get_absolute_url(self): + return self.device.get_absolute_url() + # Used for connections export def to_csv(self): return ','.join([ @@ -1032,6 +1035,9 @@ class Meta: def __unicode__(self): return self.name + def get_absolute_url(self): + return self.device.get_absolute_url() + class PowerPort(models.Model): """ @@ -1050,6 +1056,9 @@ class Meta: def __unicode__(self): return self.name + def get_absolute_url(self): + return self.device.get_absolute_url() + # Used for connections export def to_csv(self): return ','.join([ @@ -1085,6 +1094,9 @@ class Meta: def __unicode__(self): return self.name + def get_absolute_url(self): + return self.device.get_absolute_url() + class InterfaceManager(models.Manager): @@ -1121,6 +1133,9 @@ class Meta: def __unicode__(self): return self.name + def get_absolute_url(self): + return self.device.get_absolute_url() + def clean(self): if self.form_factor == IFACE_FF_VIRTUAL and self.is_connected: @@ -1209,6 +1224,9 @@ class Meta: def __unicode__(self): return u'{} - {}'.format(self.device.name, self.name) + def get_absolute_url(self): + return self.device.get_absolute_url() + def clean(self): # Validate that the parent Device can have DeviceBays @@ -1242,3 +1260,6 @@ class Meta: def __unicode__(self): return self.name + + def get_absolute_url(self): + return reverse('dcim:device_inventory', args=[self.device.pk]) diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index b5b960f57e..0b3714159b 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -110,7 +110,7 @@ url(r'^devices/(?P\d+)/console-ports/delete/$', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'), url(r'^console-ports/(?P\d+)/connect/$', views.consoleport_connect, name='consoleport_connect'), url(r'^console-ports/(?P\d+)/disconnect/$', views.consoleport_disconnect, name='consoleport_disconnect'), - url(r'^console-ports/(?P\d+)/edit/$', views.consoleport_edit, name='consoleport_edit'), + url(r'^console-ports/(?P\d+)/edit/$', views.ConsolePortEditView.as_view(), name='consoleport_edit'), url(r'^console-ports/(?P\d+)/delete/$', views.consoleport_delete, name='consoleport_delete'), # Console server ports @@ -118,7 +118,7 @@ url(r'^devices/(?P\d+)/console-server-ports/delete/$', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'), url(r'^console-server-ports/(?P\d+)/connect/$', views.consoleserverport_connect, name='consoleserverport_connect'), url(r'^console-server-ports/(?P\d+)/disconnect/$', views.consoleserverport_disconnect, name='consoleserverport_disconnect'), - url(r'^console-server-ports/(?P\d+)/edit/$', views.consoleserverport_edit, name='consoleserverport_edit'), + url(r'^console-server-ports/(?P\d+)/edit/$', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'), url(r'^console-server-ports/(?P\d+)/delete/$', views.consoleserverport_delete, name='consoleserverport_delete'), # Power ports @@ -126,7 +126,7 @@ url(r'^devices/(?P\d+)/power-ports/delete/$', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'), url(r'^power-ports/(?P\d+)/connect/$', views.powerport_connect, name='powerport_connect'), url(r'^power-ports/(?P\d+)/disconnect/$', views.powerport_disconnect, name='powerport_disconnect'), - url(r'^power-ports/(?P\d+)/edit/$', views.powerport_edit, name='powerport_edit'), + url(r'^power-ports/(?P\d+)/edit/$', views.PowerPortEditView.as_view(), name='powerport_edit'), url(r'^power-ports/(?P\d+)/delete/$', views.powerport_delete, name='powerport_delete'), # Power outlets @@ -134,13 +134,13 @@ url(r'^devices/(?P\d+)/power-outlets/delete/$', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'), url(r'^power-outlets/(?P\d+)/connect/$', views.poweroutlet_connect, name='poweroutlet_connect'), url(r'^power-outlets/(?P\d+)/disconnect/$', views.poweroutlet_disconnect, name='poweroutlet_disconnect'), - url(r'^power-outlets/(?P\d+)/edit/$', views.poweroutlet_edit, name='poweroutlet_edit'), + url(r'^power-outlets/(?P\d+)/edit/$', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'), url(r'^power-outlets/(?P\d+)/delete/$', views.poweroutlet_delete, name='poweroutlet_delete'), # Device bays url(r'^devices/(?P\d+)/bays/add/$', views.devicebay_add, name='devicebay_add'), url(r'^devices/(?P\d+)/bays/delete/$', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'), - url(r'^device-bays/(?P\d+)/edit/$', views.devicebay_edit, name='devicebay_edit'), + url(r'^device-bays/(?P\d+)/edit/$', views.DeviceBayEditView.as_view(), name='devicebay_edit'), url(r'^device-bays/(?P\d+)/delete/$', views.devicebay_delete, name='devicebay_delete'), url(r'^device-bays/(?P\d+)/populate/$', views.devicebay_populate, name='devicebay_populate'), url(r'^device-bays/(?P\d+)/depopulate/$', views.devicebay_depopulate, name='devicebay_depopulate'), @@ -160,12 +160,12 @@ url(r'^devices/(?P\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'), url(r'^devices/(?P\d+)/interface-connections/add/$', views.interfaceconnection_add, name='interfaceconnection_add'), url(r'^interface-connections/(?P\d+)/delete/$', views.interfaceconnection_delete, name='interfaceconnection_delete'), - url(r'^interfaces/(?P\d+)/edit/$', views.interface_edit, name='interface_edit'), + url(r'^interfaces/(?P\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'), url(r'^interfaces/(?P\d+)/delete/$', views.interface_delete, name='interface_delete'), # Modules url(r'^devices/(?P\d+)/modules/add/$', views.module_add, name='module_add'), - url(r'^modules/(?P\d+)/edit/$', views.module_edit, name='module_edit'), + url(r'^modules/(?P\d+)/edit/$', views.ModuleEditView.as_view(), name='module_edit'), url(r'^modules/(?P\d+)/delete/$', views.module_delete, name='module_delete'), ] diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 501859f558..1577edb841 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -722,8 +722,9 @@ def consoleport_add(request, pk): else: form = forms.ConsolePortCreateForm() - return render(request, 'dcim/consoleport_edit.html', { + return render(request, 'dcim/device_component_add.html', { 'device': device, + 'component_type': 'Console Port', 'form': form, 'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}), }) @@ -788,26 +789,10 @@ def consoleport_disconnect(request, pk): }) -@permission_required('dcim.change_consoleport') -def consoleport_edit(request, pk): - - consoleport = get_object_or_404(ConsolePort, pk=pk) - - if request.method == 'POST': - form = forms.ConsolePortForm(request.POST, instance=consoleport) - if form.is_valid(): - consoleport = form.save() - messages.success(request, "Modified {0} {1}".format(consoleport.device.name, consoleport.name)) - return redirect('dcim:device', pk=consoleport.device.pk) - - else: - form = forms.ConsolePortForm(instance=consoleport) - - return render(request, 'dcim/consoleport_edit.html', { - 'consoleport': consoleport, - 'form': form, - 'cancel_url': reverse('dcim:device', kwargs={'pk': consoleport.device.pk}), - }) +class ConsolePortEditView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.change_consoleport' + model = ConsolePort + form_class = forms.ConsolePortForm @permission_required('dcim.delete_consoleport') @@ -882,8 +867,9 @@ def consoleserverport_add(request, pk): else: form = forms.ConsoleServerPortCreateForm() - return render(request, 'dcim/consoleserverport_edit.html', { + return render(request, 'dcim/device_component_add.html', { 'device': device, + 'component_type': 'Console Server Port', 'form': form, 'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}), }) @@ -949,26 +935,10 @@ def consoleserverport_disconnect(request, pk): }) -@permission_required('dcim.change_consoleserverport') -def consoleserverport_edit(request, pk): - - consoleserverport = get_object_or_404(ConsoleServerPort, pk=pk) - - if request.method == 'POST': - form = forms.ConsoleServerPortForm(request.POST, instance=consoleserverport) - if form.is_valid(): - consoleserverport = form.save() - messages.success(request, "Modified {0} {1}".format(consoleserverport.device.name, consoleserverport.name)) - return redirect('dcim:device', pk=consoleserverport.device.pk) - - else: - form = forms.ConsoleServerPortForm(instance=consoleserverport) - - return render(request, 'dcim/consoleserverport_edit.html', { - 'consoleserverport': consoleserverport, - 'form': form, - 'cancel_url': reverse('dcim:device', kwargs={'pk': consoleserverport.device.pk}), - }) +class ConsoleServerPortEditView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.change_consoleserverport' + model = ConsoleServerPort + form_class = forms.ConsoleServerPortForm @permission_required('dcim.delete_consoleserverport') @@ -1035,8 +1005,9 @@ def powerport_add(request, pk): else: form = forms.PowerPortCreateForm() - return render(request, 'dcim/powerport_edit.html', { + return render(request, 'dcim/device_component_add.html', { 'device': device, + 'component_type': 'Power Port', 'form': form, 'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}), }) @@ -1101,26 +1072,10 @@ def powerport_disconnect(request, pk): }) -@permission_required('dcim.change_powerport') -def powerport_edit(request, pk): - - powerport = get_object_or_404(PowerPort, pk=pk) - - if request.method == 'POST': - form = forms.PowerPortForm(request.POST, instance=powerport) - if form.is_valid(): - powerport = form.save() - messages.success(request, "Modified {0} power port {1}".format(powerport.device.name, powerport.name)) - return redirect('dcim:device', pk=powerport.device.pk) - - else: - form = forms.PowerPortForm(instance=powerport) - - return render(request, 'dcim/powerport_edit.html', { - 'powerport': powerport, - 'form': form, - 'cancel_url': reverse('dcim:device', kwargs={'pk': powerport.device.pk}), - }) +class PowerPortEditView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.change_powerport' + model = PowerPort + form_class = forms.PowerPortForm @permission_required('dcim.delete_powerport') @@ -1193,8 +1148,9 @@ def poweroutlet_add(request, pk): else: form = forms.PowerOutletCreateForm() - return render(request, 'dcim/poweroutlet_edit.html', { + return render(request, 'dcim/device_component_add.html', { 'device': device, + 'component_type': 'Power Outlet', 'form': form, 'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}), }) @@ -1259,26 +1215,10 @@ def poweroutlet_disconnect(request, pk): }) -@permission_required('dcim.change_poweroutlet') -def poweroutlet_edit(request, pk): - - poweroutlet = get_object_or_404(PowerOutlet, pk=pk) - - if request.method == 'POST': - form = forms.PowerOutletForm(request.POST, instance=poweroutlet) - if form.is_valid(): - poweroutlet = form.save() - messages.success(request, "Modified {0} power outlet {1}".format(poweroutlet.device.name, poweroutlet.name)) - return redirect('dcim:device', pk=poweroutlet.device.pk) - - else: - form = forms.PowerOutletForm(instance=poweroutlet) - - return render(request, 'dcim/poweroutlet_edit.html', { - 'poweroutlet': poweroutlet, - 'form': form, - 'cancel_url': reverse('dcim:device', kwargs={'pk': poweroutlet.device.pk}), - }) +class PowerOutletEditView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.change_poweroutlet' + model = PowerOutlet + form_class = forms.PowerOutletForm @permission_required('dcim.delete_poweroutlet') @@ -1347,35 +1287,20 @@ def interface_add(request, pk): return redirect('dcim:device', pk=device.pk) else: - form = forms.InterfaceCreateForm() + form = forms.InterfaceCreateForm(initial={'mgmt_only': request.GET.get('mgmt_only')}) - return render(request, 'dcim/interface_edit.html', { + return render(request, 'dcim/device_component_add.html', { 'device': device, + 'component_type': 'Interface', 'form': form, 'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}), }) -@permission_required('dcim.change_interface') -def interface_edit(request, pk): - - interface = get_object_or_404(Interface, pk=pk) - - if request.method == 'POST': - form = forms.InterfaceForm(request.POST, instance=interface) - if form.is_valid(): - interface = form.save() - messages.success(request, "Modified {0} interface {1}".format(interface.device.name, interface.name)) - return redirect('dcim:device', pk=interface.device.pk) - - else: - form = forms.InterfaceForm(instance=interface) - - return render(request, 'dcim/interface_edit.html', { - 'interface': interface, - 'form': form, - 'cancel_url': reverse('dcim:device', kwargs={'pk': interface.device.pk}), - }) +class InterfaceEditView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.change_interface' + model = Interface + form_class = forms.InterfaceForm @permission_required('dcim.delete_interface') @@ -1483,33 +1408,18 @@ def devicebay_add(request, pk): else: form = forms.DeviceBayCreateForm() - return render(request, 'dcim/devicebay_edit.html', { + return render(request, 'dcim/device_component_add.html', { 'device': device, + 'component_type': 'Device Bay', 'form': form, 'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}), }) -@permission_required('dcim.change_devicebay') -def devicebay_edit(request, pk): - - devicebay = get_object_or_404(DeviceBay, pk=pk) - - if request.method == 'POST': - form = forms.DeviceBayForm(request.POST, instance=devicebay) - if form.is_valid(): - devicebay = form.save() - messages.success(request, "Modified {} bay {}".format(devicebay.device.name, devicebay.name)) - return redirect('dcim:device', pk=devicebay.device.pk) - - else: - form = forms.DeviceBayForm(instance=devicebay) - - return render(request, 'dcim/devicebay_edit.html', { - 'devicebay': devicebay, - 'form': form, - 'cancel_url': reverse('dcim:device', kwargs={'pk': devicebay.device.pk}), - }) +class DeviceBayEditView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.change_devicebay' + model = DeviceBay + form_class = forms.DeviceBayForm @permission_required('dcim.delete_devicebay') @@ -1775,33 +1685,18 @@ def module_add(request, pk): else: form = forms.ModuleForm() - return render(request, 'dcim/module_edit.html', { + return render(request, 'dcim/device_component_add.html', { 'device': device, + 'component_type': 'Module', 'form': form, 'cancel_url': reverse('dcim:device_inventory', kwargs={'pk': device.pk}), }) -@permission_required('dcim.change_module') -def module_edit(request, pk): - - module = get_object_or_404(Module, pk=pk) - - if request.method == 'POST': - form = forms.ModuleForm(request.POST, instance=module) - if form.is_valid(): - module = form.save() - messages.success(request, "Modified {} module {}".format(module.device.name, module.name)) - return redirect('dcim:device_inventory', pk=module.device.pk) - - else: - form = forms.ModuleForm(instance=module) - - return render(request, 'dcim/module_edit.html', { - 'module': module, - 'form': form, - 'cancel_url': reverse('dcim:device_inventory', kwargs={'pk': module.device.pk}), - }) +class ModuleEditView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.change_module' + model = Module + form_class = forms.ModuleForm @permission_required('dcim.delete_module') diff --git a/netbox/templates/dcim/consoleport_edit.html b/netbox/templates/dcim/consoleport_edit.html deleted file mode 100644 index ebec708b0b..0000000000 --- a/netbox/templates/dcim/consoleport_edit.html +++ /dev/null @@ -1,51 +0,0 @@ -{% extends '_base.html' %} -{% load form_helpers %} - -{% block title %}{% if consoleport.pk %}Editing {{ consoleport.device }} {{ consoleport }}{% else %}Add a Console Port ({{ device }}){% endif %}{% endblock %} - -{% block content %} -
- {% csrf_token %} -
-
- {% if form.non_field_errors %} -
-
Errors
-
- {{ form.non_field_errors }} -
-
- {% endif %} -
-
- {% if consoleport.pk %} - Editing {{ consoleport }} - {% else %} - Add a Console Port - {% endif %} -
-
-
- -
-

{% if consoleport %}{{ consoleport.device }}{% else %}{{ device }}{% endif %}

-
-
- {% render_form form %} -
-
-
-
- {% if consoleport.pk %} - - {% else %} - - - {% endif %} - Cancel -
-
-
-
-
-{% endblock %} diff --git a/netbox/templates/dcim/consoleserverport_edit.html b/netbox/templates/dcim/consoleserverport_edit.html deleted file mode 100644 index 21871c4774..0000000000 --- a/netbox/templates/dcim/consoleserverport_edit.html +++ /dev/null @@ -1,51 +0,0 @@ -{% extends '_base.html' %} -{% load form_helpers %} - -{% block title %}{% if consoleserverport.pk %}Editing {{ consoleserverport.device }} {{ consoleserverport }}{% else %}Add a Console Server Port ({{ device }}){% endif %}{% endblock %} - -{% block content %} -
- {% csrf_token %} -
-
- {% if form.non_field_errors %} -
-
Errors
-
- {{ form.non_field_errors }} -
-
- {% endif %} -
-
- {% if consoleserverport.pk %} - Editing {{ consoleserverport }} - {% else %} - Add a Console Server Port - {% endif %} -
-
-
- -
-

{% if consoleserverport %}{{ consoleserverport.device }}{% else %}{{ device }}{% endif %}

-
-
- {% render_form form %} -
-
-
-
- {% if consoleserverport.pk %} - - {% else %} - - - {% endif %} - Cancel -
-
-
-
-
-{% endblock %} diff --git a/netbox/templates/dcim/module_edit.html b/netbox/templates/dcim/device_component_add.html similarity index 60% rename from netbox/templates/dcim/module_edit.html rename to netbox/templates/dcim/device_component_add.html index b1a1d43349..f678877a1e 100644 --- a/netbox/templates/dcim/module_edit.html +++ b/netbox/templates/dcim/device_component_add.html @@ -1,7 +1,7 @@ {% extends '_base.html' %} {% load form_helpers %} -{% block title %}{% if module %}Editing {{ module.device }} {{ module }}{% else %}Add a Module to {{ device }}{% endif %}{% endblock %} +{% block title %}Create {{ component_type }} ({{ device }}){% endblock %} {% block content %}
@@ -18,13 +18,13 @@ {% endif %}
- {% if module %}Editing {{ module.device }} {{ module }}{% else %}Add a Module to {{ device }}{% endif %} + {{ component_type }}
-

{% if module %}{{ module.device }}{% else %}{{ device }}{% endif %}

+

{{ device }}

{% render_form form %} @@ -32,12 +32,8 @@
- {% if module.pk %} - - {% else %} - - - {% endif %} + + Cancel
diff --git a/netbox/templates/dcim/devicebay_edit.html b/netbox/templates/dcim/devicebay_edit.html deleted file mode 100644 index 507cf0eaf7..0000000000 --- a/netbox/templates/dcim/devicebay_edit.html +++ /dev/null @@ -1,51 +0,0 @@ -{% extends '_base.html' %} -{% load form_helpers %} - -{% block title %}{% if devicebay.pk %}Editing {{ devicebay.device }} {{ devicebay }}{% else %}Add a Device Bay ({{ device }}){% endif %}{% endblock %} - -{% block content %} - - {% csrf_token %} -
-
- {% if form.non_field_errors %} -
-
Errors
-
- {{ form.non_field_errors }} -
-
- {% endif %} -
-
- {% if poweroutlet.pk %} - Editing {{ devicebay }} - {% else %} - Add a Device Bay - {% endif %} -
-
-
- -
-

{% if devicebay %}{{ devicebay.device }}{% else %}{{ device }}{% endif %}

-
-
- {% render_form form %} -
-
-
-
- {% if devicebay.pk %} - - {% else %} - - - {% endif %} - Cancel -
-
-
-
- -{% endblock %} diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html deleted file mode 100644 index 098199184a..0000000000 --- a/netbox/templates/dcim/interface_edit.html +++ /dev/null @@ -1,51 +0,0 @@ -{% extends '_base.html' %} -{% load form_helpers %} - -{% block title %}{% if interface.pk %}Editing {{ interface.device }} {{ interface }}{% else %}Add an Interface ({{ device }}){% endif %}{% endblock %} - -{% block content %} -
- {% csrf_token %} -
-
- {% if form.non_field_errors %} -
-
Errors
-
- {{ form.non_field_errors }} -
-
- {% endif %} -
-
- {% if interface.pk %} - Editing {{ interface }} - {% else %} - Add an Interface - {% endif %} -
-
-
- -
-

{% if interface %}{{ interface.device }}{% else %}{{ device }}{% endif %}

-
-
- {% render_form form %} -
-
-
-
- {% if interface.pk %} - - {% else %} - - - {% endif %} - Cancel -
-
-
-
-
-{% endblock %} diff --git a/netbox/templates/dcim/poweroutlet_edit.html b/netbox/templates/dcim/poweroutlet_edit.html deleted file mode 100644 index 9e83a9b533..0000000000 --- a/netbox/templates/dcim/poweroutlet_edit.html +++ /dev/null @@ -1,51 +0,0 @@ -{% extends '_base.html' %} -{% load form_helpers %} - -{% block title %}{% if poweroutlet.pk %}Editing {{ poweroutlet.device }} {{ poweroutlet }}{% else %}Add a Power Outlet ({{ device }}){% endif %}{% endblock %} - -{% block content %} -
- {% csrf_token %} -
-
- {% if form.non_field_errors %} -
-
Errors
-
- {{ form.non_field_errors }} -
-
- {% endif %} -
-
- {% if poweroutlet.pk %} - Editing {{ poweroutlet }} - {% else %} - Add a Power Outlet - {% endif %} -
-
-
- -
-

{% if poweroutlet %}{{ poweroutlet.device }}{% else %}{{ device }}{% endif %}

-
-
- {% render_form form %} -
-
-
-
- {% if poweroutlet.pk %} - - {% else %} - - - {% endif %} - Cancel -
-
-
-
-
-{% endblock %} diff --git a/netbox/templates/dcim/powerport_edit.html b/netbox/templates/dcim/powerport_edit.html deleted file mode 100644 index 4eeb940b4f..0000000000 --- a/netbox/templates/dcim/powerport_edit.html +++ /dev/null @@ -1,51 +0,0 @@ -{% extends '_base.html' %} -{% load form_helpers %} - -{% block title %}{% if powerport.pk %}Editing {{ powerport.device }} {{ powerport }}{% else %}Add a Power Port ({{ device }}){% endif %}{% endblock %} - -{% block content %} -
- {% csrf_token %} -
-
- {% if form.non_field_errors %} -
-
Errors
-
- {{ form.non_field_errors }} -
-
- {% endif %} -
-
- {% if powerport.pk %} - Editing {{ powerport }} - {% else %} - Add a Power Port - {% endif %} -
-
-
- -
-

{% if powerport %}{{ powerport.device }}{% else %}{{ device }}{% endif %}

-
-
- {% render_form form %} -
-
-
-
- {% if powerport.pk %} - - {% else %} - - - {% endif %} - Cancel -
-
-
-
-
-{% endblock %} From df9a6a0c538b542784101a6aecbb9df1ad3807af Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 28 Oct 2016 17:00:41 -0400 Subject: [PATCH 12/26] Standardized device component deletion views to use ObjectDeleteView() --- netbox/dcim/models.py | 14 ++-- netbox/dcim/urls.py | 14 ++-- netbox/dcim/views.py | 164 +++++--------------------------------- netbox/utilities/views.py | 39 ++++++--- 4 files changed, 65 insertions(+), 166 deletions(-) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index f487759a7b..52eeb7f3a0 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -991,7 +991,7 @@ class Meta: def __unicode__(self): return self.name - def get_absolute_url(self): + def get_parent_url(self): return self.device.get_absolute_url() # Used for connections export @@ -1035,7 +1035,7 @@ class Meta: def __unicode__(self): return self.name - def get_absolute_url(self): + def get_parent_url(self): return self.device.get_absolute_url() @@ -1056,7 +1056,7 @@ class Meta: def __unicode__(self): return self.name - def get_absolute_url(self): + def get_parent_url(self): return self.device.get_absolute_url() # Used for connections export @@ -1094,7 +1094,7 @@ class Meta: def __unicode__(self): return self.name - def get_absolute_url(self): + def get_parent_url(self): return self.device.get_absolute_url() @@ -1133,7 +1133,7 @@ class Meta: def __unicode__(self): return self.name - def get_absolute_url(self): + def get_parent_url(self): return self.device.get_absolute_url() def clean(self): @@ -1224,7 +1224,7 @@ class Meta: def __unicode__(self): return u'{} - {}'.format(self.device.name, self.name) - def get_absolute_url(self): + def get_parent_url(self): return self.device.get_absolute_url() def clean(self): @@ -1261,5 +1261,5 @@ class Meta: def __unicode__(self): return self.name - def get_absolute_url(self): + def get_parent_url(self): return reverse('dcim:device_inventory', args=[self.device.pk]) diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 0b3714159b..1806891267 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -111,7 +111,7 @@ url(r'^console-ports/(?P\d+)/connect/$', views.consoleport_connect, name='consoleport_connect'), url(r'^console-ports/(?P\d+)/disconnect/$', views.consoleport_disconnect, name='consoleport_disconnect'), url(r'^console-ports/(?P\d+)/edit/$', views.ConsolePortEditView.as_view(), name='consoleport_edit'), - url(r'^console-ports/(?P\d+)/delete/$', views.consoleport_delete, name='consoleport_delete'), + url(r'^console-ports/(?P\d+)/delete/$', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'), # Console server ports url(r'^devices/(?P\d+)/console-server-ports/add/$', views.consoleserverport_add, name='consoleserverport_add'), @@ -119,7 +119,7 @@ url(r'^console-server-ports/(?P\d+)/connect/$', views.consoleserverport_connect, name='consoleserverport_connect'), url(r'^console-server-ports/(?P\d+)/disconnect/$', views.consoleserverport_disconnect, name='consoleserverport_disconnect'), url(r'^console-server-ports/(?P\d+)/edit/$', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'), - url(r'^console-server-ports/(?P\d+)/delete/$', views.consoleserverport_delete, name='consoleserverport_delete'), + url(r'^console-server-ports/(?P\d+)/delete/$', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'), # Power ports url(r'^devices/(?P\d+)/power-ports/add/$', views.powerport_add, name='powerport_add'), @@ -127,7 +127,7 @@ url(r'^power-ports/(?P\d+)/connect/$', views.powerport_connect, name='powerport_connect'), url(r'^power-ports/(?P\d+)/disconnect/$', views.powerport_disconnect, name='powerport_disconnect'), url(r'^power-ports/(?P\d+)/edit/$', views.PowerPortEditView.as_view(), name='powerport_edit'), - url(r'^power-ports/(?P\d+)/delete/$', views.powerport_delete, name='powerport_delete'), + url(r'^power-ports/(?P\d+)/delete/$', views.PowerPortDeleteView.as_view(), name='powerport_delete'), # Power outlets url(r'^devices/(?P\d+)/power-outlets/add/$', views.poweroutlet_add, name='poweroutlet_add'), @@ -135,13 +135,13 @@ url(r'^power-outlets/(?P\d+)/connect/$', views.poweroutlet_connect, name='poweroutlet_connect'), url(r'^power-outlets/(?P\d+)/disconnect/$', views.poweroutlet_disconnect, name='poweroutlet_disconnect'), url(r'^power-outlets/(?P\d+)/edit/$', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'), - url(r'^power-outlets/(?P\d+)/delete/$', views.poweroutlet_delete, name='poweroutlet_delete'), + url(r'^power-outlets/(?P\d+)/delete/$', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'), # Device bays url(r'^devices/(?P\d+)/bays/add/$', views.devicebay_add, name='devicebay_add'), url(r'^devices/(?P\d+)/bays/delete/$', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'), url(r'^device-bays/(?P\d+)/edit/$', views.DeviceBayEditView.as_view(), name='devicebay_edit'), - url(r'^device-bays/(?P\d+)/delete/$', views.devicebay_delete, name='devicebay_delete'), + url(r'^device-bays/(?P\d+)/delete/$', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'), url(r'^device-bays/(?P\d+)/populate/$', views.devicebay_populate, name='devicebay_populate'), url(r'^device-bays/(?P\d+)/depopulate/$', views.devicebay_depopulate, name='devicebay_depopulate'), @@ -161,11 +161,11 @@ url(r'^devices/(?P\d+)/interface-connections/add/$', views.interfaceconnection_add, name='interfaceconnection_add'), url(r'^interface-connections/(?P\d+)/delete/$', views.interfaceconnection_delete, name='interfaceconnection_delete'), url(r'^interfaces/(?P\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'), - url(r'^interfaces/(?P\d+)/delete/$', views.interface_delete, name='interface_delete'), + url(r'^interfaces/(?P\d+)/delete/$', views.InterfaceDeleteView.as_view(), name='interface_delete'), # Modules url(r'^devices/(?P\d+)/modules/add/$', views.module_add, name='module_add'), url(r'^modules/(?P\d+)/edit/$', views.ModuleEditView.as_view(), name='module_edit'), - url(r'^modules/(?P\d+)/delete/$', views.module_delete, name='module_delete'), + url(r'^modules/(?P\d+)/delete/$', views.ModuleDeleteView.as_view(), name='module_delete'), ] diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 1577edb841..354855d440 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -795,27 +795,9 @@ class ConsolePortEditView(PermissionRequiredMixin, ObjectEditView): form_class = forms.ConsolePortForm -@permission_required('dcim.delete_consoleport') -def consoleport_delete(request, pk): - - consoleport = get_object_or_404(ConsolePort, pk=pk) - - if request.method == 'POST': - form = ConfirmationForm(request.POST) - if form.is_valid(): - consoleport.delete() - messages.success(request, "Console port {0} has been deleted from {1}".format(consoleport, - consoleport.device)) - return redirect('dcim:device', pk=consoleport.device.pk) - - else: - form = ConfirmationForm() - - return render(request, 'dcim/consoleport_delete.html', { - 'consoleport': consoleport, - 'form': form, - 'cancel_url': reverse('dcim:device', kwargs={'pk': consoleport.device.pk}), - }) +class ConsolePortDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_consoleport' + model = ConsolePort class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): @@ -941,27 +923,9 @@ class ConsoleServerPortEditView(PermissionRequiredMixin, ObjectEditView): form_class = forms.ConsoleServerPortForm -@permission_required('dcim.delete_consoleserverport') -def consoleserverport_delete(request, pk): - - consoleserverport = get_object_or_404(ConsoleServerPort, pk=pk) - - if request.method == 'POST': - form = ConfirmationForm(request.POST) - if form.is_valid(): - consoleserverport.delete() - messages.success(request, "Console server port {0} has been deleted from {1}" - .format(consoleserverport, consoleserverport.device)) - return redirect('dcim:device', pk=consoleserverport.device.pk) - - else: - form = ConfirmationForm() - - return render(request, 'dcim/consoleserverport_delete.html', { - 'consoleserverport': consoleserverport, - 'form': form, - 'cancel_url': reverse('dcim:device', kwargs={'pk': consoleserverport.device.pk}), - }) +class ConsoleServerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_consoleserverport' + model = ConsoleServerPort class ConsoleServerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): @@ -1078,26 +1042,9 @@ class PowerPortEditView(PermissionRequiredMixin, ObjectEditView): form_class = forms.PowerPortForm -@permission_required('dcim.delete_powerport') -def powerport_delete(request, pk): - - powerport = get_object_or_404(PowerPort, pk=pk) - - if request.method == 'POST': - form = ConfirmationForm(request.POST) - if form.is_valid(): - powerport.delete() - messages.success(request, "Power port {0} has been deleted from {1}".format(powerport, powerport.device)) - return redirect('dcim:device', pk=powerport.device.pk) - - else: - form = ConfirmationForm() - - return render(request, 'dcim/powerport_delete.html', { - 'powerport': powerport, - 'form': form, - 'cancel_url': reverse('dcim:device', kwargs={'pk': powerport.device.pk}), - }) +class PowerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_powerport' + model = PowerPort class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): @@ -1221,27 +1168,9 @@ class PowerOutletEditView(PermissionRequiredMixin, ObjectEditView): form_class = forms.PowerOutletForm -@permission_required('dcim.delete_poweroutlet') -def poweroutlet_delete(request, pk): - - poweroutlet = get_object_or_404(PowerOutlet, pk=pk) - - if request.method == 'POST': - form = ConfirmationForm(request.POST) - if form.is_valid(): - poweroutlet.delete() - messages.success(request, "Power outlet {0} has been deleted from {1}".format(poweroutlet, - poweroutlet.device)) - return redirect('dcim:device', pk=poweroutlet.device.pk) - - else: - form = ConfirmationForm() - - return render(request, 'dcim/poweroutlet_delete.html', { - 'poweroutlet': poweroutlet, - 'form': form, - 'cancel_url': reverse('dcim:device', kwargs={'pk': poweroutlet.device.pk}), - }) +class PowerOutletDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_poweroutlet' + model = PowerOutlet class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): @@ -1303,26 +1232,9 @@ class InterfaceEditView(PermissionRequiredMixin, ObjectEditView): form_class = forms.InterfaceForm -@permission_required('dcim.delete_interface') -def interface_delete(request, pk): - - interface = get_object_or_404(Interface, pk=pk) - - if request.method == 'POST': - form = ConfirmationForm(request.POST) - if form.is_valid(): - interface.delete() - messages.success(request, "Interface {0} has been deleted from {1}".format(interface, interface.device)) - return redirect('dcim:device', pk=interface.device.pk) - - else: - form = ConfirmationForm() - - return render(request, 'dcim/interface_delete.html', { - 'interface': interface, - 'form': form, - 'cancel_url': reverse('dcim:device', kwargs={'pk': interface.device.pk}), - }) +class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_interface' + model = Interface class InterfaceBulkAddView(PermissionRequiredMixin, BulkEditView): @@ -1422,26 +1334,9 @@ class DeviceBayEditView(PermissionRequiredMixin, ObjectEditView): form_class = forms.DeviceBayForm -@permission_required('dcim.delete_devicebay') -def devicebay_delete(request, pk): - - devicebay = get_object_or_404(DeviceBay, pk=pk) - - if request.method == 'POST': - form = ConfirmationForm(request.POST) - if form.is_valid(): - devicebay.delete() - messages.success(request, "Device bay {} has been deleted from {}".format(devicebay, devicebay.device)) - return redirect('dcim:device', pk=devicebay.device.pk) - - else: - form = ConfirmationForm() - - return render(request, 'dcim/devicebay_delete.html', { - 'devicebay': devicebay, - 'form': form, - 'cancel_url': reverse('dcim:device', kwargs={'pk': devicebay.device.pk}), - }) +class DeviceBayDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_devicebay' + model = DeviceBay @permission_required('dcim.change_devicebay') @@ -1699,23 +1594,6 @@ class ModuleEditView(PermissionRequiredMixin, ObjectEditView): form_class = forms.ModuleForm -@permission_required('dcim.delete_module') -def module_delete(request, pk): - - module = get_object_or_404(Module, pk=pk) - - if request.method == 'POST': - form = ConfirmationForm(request.POST) - if form.is_valid(): - module.delete() - messages.success(request, "Module {} has been deleted from {}".format(module, module.device)) - return redirect('dcim:device_inventory', pk=module.device.pk) - - else: - form = ConfirmationForm() - - return render(request, 'dcim/module_delete.html', { - 'module': module, - 'form': form, - 'cancel_url': reverse('dcim:device_inventory', kwargs={'pk': module.device.pk}), - }) +class ModuleDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_module' + model = Module diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 7b47e76e16..8606a9bb61 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -129,6 +129,13 @@ def get_object(self, kwargs): else: return get_object_or_404(self.model, pk=kwargs['pk']) + def get_cancel_url(self, obj): + if hasattr(obj, 'get_absolute_url'): + return obj.get_absolute_url() + if hasattr(obj, 'get_parent_url'): + return obj.get_parent_url() + return reverse(self.cancel_url) + def get(self, request, *args, **kwargs): if kwargs: @@ -142,7 +149,7 @@ def get(self, request, *args, **kwargs): 'obj': obj, 'obj_type': self.model._meta.verbose_name, 'form': form, - 'cancel_url': obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else reverse(self.cancel_url), + 'cancel_url': self.get_cancel_url(obj), }) def post(self, request, *args, **kwargs): @@ -174,14 +181,16 @@ def post(self, request, *args, **kwargs): return redirect(request.path) elif self.success_url: return redirect(self.success_url) - else: + elif hasattr(obj, 'get_absolute_url'): return redirect(obj.get_absolute_url()) + elif hasattr(obj, 'get_parent_url'): + return redirect(obj.get_parent_url()) return render(request, self.template_name, { 'obj': obj, 'obj_type': self.model._meta.verbose_name, 'form': form, - 'cancel_url': obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else reverse(self.cancel_url), + 'cancel_url': self.get_cancel_url(obj), }) @@ -197,6 +206,13 @@ def get_object(self, kwargs): else: return get_object_or_404(self.model, pk=kwargs['pk']) + def get_cancel_url(self, obj): + if hasattr(obj, 'get_absolute_url'): + return obj.get_absolute_url() + if hasattr(obj, 'get_parent_url'): + return obj.get_parent_url() + return reverse('home') + def get(self, request, *args, **kwargs): obj = self.get_object(kwargs) @@ -206,7 +222,7 @@ def get(self, request, *args, **kwargs): 'obj': obj, 'form': form, 'obj_type': self.model._meta.verbose_name, - 'cancel_url': obj.get_absolute_url(), + 'cancel_url': self.get_cancel_url(obj), }) def post(self, request, *args, **kwargs): @@ -216,19 +232,24 @@ def post(self, request, *args, **kwargs): if form.is_valid(): try: obj.delete() - msg = u'Deleted {} {}'.format(self.model._meta.verbose_name, obj) - messages.success(request, msg) - UserAction.objects.log_delete(request.user, obj, msg) - return redirect(self.redirect_url) except ProtectedError, e: handle_protectederror(obj, request, e) return redirect(obj.get_absolute_url()) + msg = u'Deleted {} {}'.format(self.model._meta.verbose_name, obj) + messages.success(request, msg) + UserAction.objects.log_delete(request.user, obj, msg) + if self.redirect_url: + return redirect(self.redirect_url) + elif hasattr(obj, 'get_parent_url'): + return redirect(obj.get_parent_url()) + else: + return redirect('home') return render(request, self.template_name, { 'obj': obj, 'form': form, 'obj_type': self.model._meta.verbose_name, - 'cancel_url': obj.get_absolute_url(), + 'cancel_url': self.get_cancel_url(obj), }) From d97dd266b72aa6b934200bd4655f9b17929bf7bc Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 31 Oct 2016 11:16:30 -0400 Subject: [PATCH 13/26] Cleaned up message strings --- netbox/dcim/views.py | 54 ++++++++++++++++++------------------ netbox/ipam/views.py | 4 +-- netbox/secrets/admin.py | 4 +-- netbox/secrets/decorators.py | 4 +-- netbox/secrets/views.py | 6 ++-- netbox/users/views.py | 8 +++--- netbox/utilities/views.py | 6 ++-- 7 files changed, 43 insertions(+), 43 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 354855d440..1c58905eff 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -394,7 +394,7 @@ def post(self, request, pk): if not form.errors: self.model.objects.bulk_create(component_templates) - messages.success(request, "Added {} component(s) to {}".format(len(component_templates), devicetype)) + messages.success(request, u"Added {} component(s) to {}.".format(len(component_templates), devicetype)) if '_addanother' in request.POST: return redirect(request.path) else: @@ -713,7 +713,7 @@ def consoleport_add(request, pk): if not form.errors: ConsolePort.objects.bulk_create(console_ports) - messages.success(request, "Added {} console port(s) to {}".format(len(console_ports), device)) + messages.success(request, u"Added {} console port(s) to {}.".format(len(console_ports), device)) if '_addanother' in request.POST: return redirect('dcim:consoleport_add', pk=device.pk) else: @@ -739,7 +739,7 @@ def consoleport_connect(request, pk): form = forms.ConsolePortConnectionForm(request.POST, instance=consoleport) if form.is_valid(): consoleport = form.save() - messages.success(request, "Connected {0} {1} to {2} {3}".format( + messages.success(request, u"Connected {} {} to {} {}.".format( consoleport.device, consoleport.name, consoleport.cs_port.device, @@ -766,7 +766,7 @@ def consoleport_disconnect(request, pk): consoleport = get_object_or_404(ConsolePort, pk=pk) if not consoleport.cs_port: - messages.warning(request, "Cannot disconnect console port {0}: It is not connected to anything" + messages.warning(request, u"Cannot disconnect console port {}: It is not connected to anything." .format(consoleport)) return redirect('dcim:device', pk=consoleport.device.pk) @@ -776,7 +776,7 @@ def consoleport_disconnect(request, pk): consoleport.cs_port = None consoleport.connection_status = None consoleport.save() - messages.success(request, "Console port {0} has been disconnected".format(consoleport)) + messages.success(request, u"Console port {} has been disconnected.".format(consoleport)) return redirect('dcim:device', pk=consoleport.device.pk) else: @@ -840,7 +840,7 @@ def consoleserverport_add(request, pk): if not form.errors: ConsoleServerPort.objects.bulk_create(cs_ports) - messages.success(request, "Added {} console server port(s) to {}".format(len(cs_ports), device)) + messages.success(request, u"Added {} console server port(s) to {}.".format(len(cs_ports), device)) if '_addanother' in request.POST: return redirect('dcim:consoleserverport_add', pk=device.pk) else: @@ -869,7 +869,7 @@ def consoleserverport_connect(request, pk): consoleport.cs_port = consoleserverport consoleport.connection_status = form.cleaned_data['connection_status'] consoleport.save() - messages.success(request, "Connected {0} {1} to {2} {3}".format( + messages.success(request, u"Connected {} {} to {} {}.".format( consoleport.device, consoleport.name, consoleserverport.device, @@ -893,7 +893,7 @@ def consoleserverport_disconnect(request, pk): consoleserverport = get_object_or_404(ConsoleServerPort, pk=pk) if not hasattr(consoleserverport, 'connected_console'): - messages.warning(request, "Cannot disconnect console server port {0}: Nothing is connected to it" + messages.warning(request, u"Cannot disconnect console server port {}: Nothing is connected to it." .format(consoleserverport)) return redirect('dcim:device', pk=consoleserverport.device.pk) @@ -904,7 +904,7 @@ def consoleserverport_disconnect(request, pk): consoleport.cs_port = None consoleport.connection_status = None consoleport.save() - messages.success(request, "Console server port {0} has been disconnected".format(consoleserverport)) + messages.success(request, u"Console server port {} has been disconnected.".format(consoleserverport)) return redirect('dcim:device', pk=consoleserverport.device.pk) else: @@ -960,7 +960,7 @@ def powerport_add(request, pk): if not form.errors: PowerPort.objects.bulk_create(power_ports) - messages.success(request, "Added {} power port(s) to {}".format(len(power_ports), device)) + messages.success(request, u"Added {} power port(s) to {}.".format(len(power_ports), device)) if '_addanother' in request.POST: return redirect('dcim:powerport_add', pk=device.pk) else: @@ -986,7 +986,7 @@ def powerport_connect(request, pk): form = forms.PowerPortConnectionForm(request.POST, instance=powerport) if form.is_valid(): powerport = form.save() - messages.success(request, "Connected {0} {1} to {2} {3}".format( + messages.success(request, u"Connected {} {} to {} {}.".format( powerport.device, powerport.name, powerport.power_outlet.device, @@ -1013,7 +1013,7 @@ def powerport_disconnect(request, pk): powerport = get_object_or_404(PowerPort, pk=pk) if not powerport.power_outlet: - messages.warning(request, "Cannot disconnect power port {0}: It is not connected to an outlet" + messages.warning(request, u"Cannot disconnect power port {}: It is not connected to an outlet." .format(powerport)) return redirect('dcim:device', pk=powerport.device.pk) @@ -1023,7 +1023,7 @@ def powerport_disconnect(request, pk): powerport.power_outlet = None powerport.connection_status = None powerport.save() - messages.success(request, "Power port {0} has been disconnected".format(powerport)) + messages.success(request, u"Power port {} has been disconnected.".format(powerport)) return redirect('dcim:device', pk=powerport.device.pk) else: @@ -1086,7 +1086,7 @@ def poweroutlet_add(request, pk): if not form.errors: PowerOutlet.objects.bulk_create(power_outlets) - messages.success(request, "Added {} power outlet(s) to {}".format(len(power_outlets), device)) + messages.success(request, u"Added {} power outlet(s) to {}.".format(len(power_outlets), device)) if '_addanother' in request.POST: return redirect('dcim:poweroutlet_add', pk=device.pk) else: @@ -1115,7 +1115,7 @@ def poweroutlet_connect(request, pk): powerport.power_outlet = poweroutlet powerport.connection_status = form.cleaned_data['connection_status'] powerport.save() - messages.success(request, "Connected {0} {1} to {2} {3}".format( + messages.success(request, u"Connected {} {} to {} {}.".format( powerport.device, powerport.name, poweroutlet.device, @@ -1139,7 +1139,7 @@ def poweroutlet_disconnect(request, pk): poweroutlet = get_object_or_404(PowerOutlet, pk=pk) if not hasattr(poweroutlet, 'connected_port'): - messages.warning(request, "Cannot disconnect power outlet {0}: Nothing is connected to it".format(poweroutlet)) + messages.warning(request, u"Cannot disconnect power outlet {}: Nothing is connected to it.".format(poweroutlet)) return redirect('dcim:device', pk=poweroutlet.device.pk) if request.method == 'POST': @@ -1149,7 +1149,7 @@ def poweroutlet_disconnect(request, pk): powerport.power_outlet = None powerport.connection_status = None powerport.save() - messages.success(request, "Power outlet {0} has been disconnected".format(poweroutlet)) + messages.success(request, u"Power outlet {} has been disconnected.".format(poweroutlet)) return redirect('dcim:device', pk=poweroutlet.device.pk) else: @@ -1209,7 +1209,7 @@ def interface_add(request, pk): if not form.errors: Interface.objects.bulk_create(interfaces) - messages.success(request, "Added {} interface(s) to {}".format(len(interfaces), device)) + messages.success(request, u"Added {} interface(s) to {}.".format(len(interfaces), device)) if '_addanother' in request.POST: return redirect('dcim:interface_add', pk=device.pk) else: @@ -1266,8 +1266,8 @@ def update_objects(self, pk_list, form, fields): if not form.errors: Interface.objects.bulk_create(interfaces) - messages.success(self.request, "Added {} interfaces to {} devices".format(len(interfaces), - len(selected_devices))) + messages.success(self.request, u"Added {} interfaces to {} devices.".format(len(interfaces), + len(selected_devices))) class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView): @@ -1311,7 +1311,7 @@ def devicebay_add(request, pk): if not form.errors: DeviceBay.objects.bulk_create(device_bays) - messages.success(request, "Added {} device bay(s) to {}".format(len(device_bays), device)) + messages.success(request, u"Added {} device bay(s) to {}.".format(len(device_bays), device)) if '_addanother' in request.POST: return redirect('dcim:devicebay_add', pk=device.pk) else: @@ -1352,7 +1352,7 @@ def devicebay_populate(request, pk): device_bay.save() if not form.errors: - messages.success(request, "Added {} to {}".format(device_bay.installed_device, device_bay)) + messages.success(request, u"Added {} to {}.".format(device_bay.installed_device, device_bay)) return redirect('dcim:device', pk=device_bay.device.pk) else: @@ -1376,7 +1376,7 @@ def devicebay_depopulate(request, pk): removed_device = device_bay.installed_device device_bay.installed_device = None device_bay.save() - messages.success(request, "{} has been removed from {}".format(removed_device, device_bay)) + messages.success(request, u"{} has been removed from {}.".format(removed_device, device_bay)) return redirect('dcim:device', pk=device_bay.device.pk) else: @@ -1408,7 +1408,7 @@ def interfaceconnection_add(request, pk): form = forms.InterfaceConnectionForm(device, request.POST) if form.is_valid(): interfaceconnection = form.save() - messages.success(request, "Connected {0} {1} to {2} {3}".format( + messages.success(request, u"Connected {} {} to {} {}.".format( interfaceconnection.interface_a.device, interfaceconnection.interface_a, interfaceconnection.interface_b.device, @@ -1448,7 +1448,7 @@ def interfaceconnection_delete(request, pk): form = forms.InterfaceConnectionDeletionForm(request.POST) if form.is_valid(): interfaceconnection.delete() - messages.success(request, "Deleted the connection between {0} {1} and {2} {3}".format( + messages.success(request, u"Deleted the connection between {} {} and {} {}.".format( interfaceconnection.interface_a.device, interfaceconnection.interface_a, interfaceconnection.interface_b.device, @@ -1532,7 +1532,7 @@ def ipaddress_assign(request, pk): ipaddress = form.save(commit=False) ipaddress.interface = form.cleaned_data['interface'] ipaddress.save() - messages.success(request, "Added new IP address {} to interface {}".format(ipaddress, ipaddress.interface)) + messages.success(request, u"Added new IP address {} to interface {}.".format(ipaddress, ipaddress.interface)) if form.cleaned_data['set_as_primary']: if ipaddress.family == 4: @@ -1571,7 +1571,7 @@ def module_add(request, pk): module = form.save(commit=False) module.device = device module.save() - messages.success(request, "Added module {} to {}".format(module.name, module.device.name)) + messages.success(request, u"Added module {} to {}".format(module.name, module.device.name)) if '_addanother' in request.POST: return redirect('dcim:module_add', pk=module.device.pk) else: diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 646dbde8a4..3262bbeb55 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -462,7 +462,7 @@ def ipaddress_assign(request, pk): interface = form.cleaned_data['interface'] ipaddress.interface = interface ipaddress.save() - messages.success(request, "Assigned IP address {} to interface {}".format(ipaddress, ipaddress.interface)) + messages.success(request, u"Assigned IP address {} to interface {}.".format(ipaddress, ipaddress.interface)) if form.cleaned_data['set_as_primary']: device = interface.device @@ -496,7 +496,7 @@ def ipaddress_remove(request, pk): device = ipaddress.interface.device ipaddress.interface = None ipaddress.save() - messages.success(request, "Removed IP address {} from {}".format(ipaddress, device)) + messages.success(request, u"Removed IP address {} from {}.".format(ipaddress, device)) if device.primary_ip4 == ipaddress.pk: device.primary_ip4 = None diff --git a/netbox/secrets/admin.py b/netbox/secrets/admin.py index 4fb5f7c483..ac0cf1b8a1 100644 --- a/netbox/secrets/admin.py +++ b/netbox/secrets/admin.py @@ -34,7 +34,7 @@ def activate_selected(modeladmin, request, queryset): try: my_userkey = UserKey.objects.get(user=request.user) except UserKey.DoesNotExist: - messages.error(request, "You do not have an active User Key.") + messages.error(request, u"You do not have an active User Key.") return redirect('/admin/secrets/userkey/') if 'activate' in request.POST: @@ -46,7 +46,7 @@ def activate_selected(modeladmin, request, queryset): uk.activate(master_key) return redirect('/admin/secrets/userkey/') except ValueError: - messages.error(request, "Invalid private key provided. Unable to retrieve master key.") + messages.error(request, u"Invalid private key provided. Unable to retrieve master key.") else: form = ActivateUserKeyForm(initial={'_selected_action': request.POST.getlist(admin.ACTION_CHECKBOX_NAME)}) diff --git a/netbox/secrets/decorators.py b/netbox/secrets/decorators.py index ebbdae916e..41af204da1 100644 --- a/netbox/secrets/decorators.py +++ b/netbox/secrets/decorators.py @@ -14,10 +14,10 @@ def wrapped_view(request, *args, **kwargs): try: uk = UserKey.objects.get(user=request.user) except UserKey.DoesNotExist: - messages.warning(request, "This operation requires an active user key, but you don't have one.") + messages.warning(request, u"This operation requires an active user key, but you don't have one.") return redirect('users:userkey') if not uk.is_active(): - messages.warning(request, "This operation is not available. Your user key has not been activated.") + messages.warning(request, u"This operation is not available. Your user key has not been activated.") return redirect('users:userkey') return view(request, *args, **kwargs) return wrapped_view diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index 14ac4fa785..a99af80b6f 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -90,7 +90,7 @@ def secret_add(request, pk): secret.encrypt(master_key) secret.save() - messages.success(request, "Added new secret: {0}".format(secret)) + messages.success(request, u"Added new secret: {}.".format(secret)) if '_addanother' in request.POST: return redirect('dcim:device_addsecret', pk=device.pk) else: @@ -135,7 +135,7 @@ def secret_edit(request, pk): else: secret = form.save() - messages.success(request, "Modified secret {0}".format(secret)) + messages.success(request, u"Modified secret {}.".format(secret)) return redirect('secrets:secret', pk=secret.pk) else: @@ -180,7 +180,7 @@ def secret_import(request): new_secrets.append(secret) table = tables.SecretTable(new_secrets) - messages.success(request, "Imported {} new secrets".format(len(new_secrets))) + messages.success(request, u"Imported {} new secrets.".format(len(new_secrets))) return render(request, 'import_success.html', { 'table': table, diff --git a/netbox/users/views.py b/netbox/users/views.py index 7a9b502665..3ec385f9cb 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -29,7 +29,7 @@ def login(request): # Authenticate user auth_login(request, form.get_user()) - messages.info(request, "Logged in as {0}.".format(request.user)) + messages.info(request, u"Logged in as {}.".format(request.user)) return HttpResponseRedirect(redirect_to) @@ -44,7 +44,7 @@ def login(request): def logout(request): auth_logout(request) - messages.info(request, "You have logged out.") + messages.info(request, u"You have logged out.") return HttpResponseRedirect(reverse('home')) @@ -67,7 +67,7 @@ def change_password(request): if form.is_valid(): form.save() update_session_auth_hash(request, form.user) - messages.success(request, "Your password has been changed successfully.") + messages.success(request, u"Your password has been changed successfully.") return redirect('users:profile') else: @@ -105,7 +105,7 @@ def userkey_edit(request): uk = form.save(commit=False) uk.user = request.user uk.save() - messages.success(request, "Your user key has been saved.") + messages.success(request, u"Your user key has been saved.") return redirect('users:userkey') else: diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 8606a9bb61..76d15c3316 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -67,7 +67,7 @@ def get(self, request, *args, **kwargs): filename='netbox_{}'.format(model._meta.verbose_name_plural)) return response except TemplateSyntaxError: - messages.error(request, "There was an error rendering the selected export template ({})." + messages.error(request, u"There was an error rendering the selected export template ({})." .format(et.name)) # Fall back to built-in CSV export elif 'export' in request.GET and hasattr(model, 'to_csv'): @@ -368,7 +368,7 @@ def post(self, request, **kwargs): selected_objects = self.cls.objects.filter(pk__in=pk_list) if not selected_objects: - messages.warning(request, "No {} were selected.".format(self.cls._meta.verbose_name_plural)) + messages.warning(request, u"No {} were selected.".format(self.cls._meta.verbose_name_plural)) return redirect(redirect_url) return render(request, self.template_name, { @@ -481,7 +481,7 @@ def post(self, request, **kwargs): selected_objects = self.cls.objects.filter(pk__in=pk_list) if not selected_objects: - messages.warning(request, "No {} were selected for deletion.".format(self.cls._meta.verbose_name_plural)) + messages.warning(request, u"No {} were selected for deletion.".format(self.cls._meta.verbose_name_plural)) return redirect(redirect_url) return render(request, self.template_name, { From dc186a57cd0d76a4862de94ea0eaea85022a9d45 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 31 Oct 2016 11:36:05 -0400 Subject: [PATCH 14/26] Closes #661: Display relevant IP addressing when viewing a circuit --- netbox/templates/circuits/circuit.html | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index 46f37c44fa..5b9f8381f6 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -131,6 +131,21 @@

{{ circuit.provider }} - {{ circuit.cid }}

{% endif %} + + IP Addressing + + {% if circuit.interface %} + {% for ip in circuit.interface.ip_addresses.all %} + {% if not forloop.first %}
{% endif %} + {{ ip }} ({{ ip.vrf|default:"Global" }}) + {% empty %} + None + {% endfor %} + {% else %} + N/A + {% endif %} + + Cross-Connect From c525939b13a24220584166db790b04973c19b034 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 31 Oct 2016 12:22:05 -0400 Subject: [PATCH 15/26] Closes #654: Added Cisco FlexStack and FlexStack Plus form factors --- .../dcim/migrations/0021_add_ff_flexstack.py | 31 +++++++++++++++++++ netbox/dcim/models.py | 4 +++ 2 files changed, 35 insertions(+) create mode 100644 netbox/dcim/migrations/0021_add_ff_flexstack.py diff --git a/netbox/dcim/migrations/0021_add_ff_flexstack.py b/netbox/dcim/migrations/0021_add_ff_flexstack.py new file mode 100644 index 0000000000..17fdbce369 --- /dev/null +++ b/netbox/dcim/migrations/0021_add_ff_flexstack.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10 on 2016-10-31 16:20 +from __future__ import unicode_literals + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0020_rack_desc_units'), + ] + + operations = [ + migrations.AlterField( + model_name='device', + name='position', + field=models.PositiveSmallIntegerField(blank=True, help_text=b'The lowest-numbered unit occupied by the device', null=True, validators=[django.core.validators.MinValueValidator(1)], verbose_name=b'Position (U)'), + ), + migrations.AlterField( + model_name='interface', + name='form_factor', + field=models.PositiveSmallIntegerField(choices=[[b'Virtual interfaces', [[0, b'Virtual']]], [b'Ethernet (fixed)', [[800, b'100BASE-TX (10/100ME)'], [1000, b'1000BASE-T (1GE)'], [1150, b'10GBASE-T (10GE)']]], [b'Ethernet (modular)', [[1050, b'GBIC (1GE)'], [1100, b'SFP (1GE)'], [1200, b'SFP+ (10GE)'], [1300, b'XFP (10GE)'], [1310, b'XENPAK (10GE)'], [1320, b'X2 (10GE)'], [1350, b'SFP28 (25GE)'], [1400, b'QSFP+ (40GE)'], [1500, b'CFP (100GE)'], [1600, b'QSFP28 (100GE)']]], [b'FibreChannel', [[3010, b'SFP (1GFC)'], [3020, b'SFP (2GFC)'], [3040, b'SFP (4GFC)'], [3080, b'SFP+ (8GFC)'], [3160, b'SFP+ (16GFC)']]], [b'Serial', [[4000, b'T1 (1.544 Mbps)'], [4010, b'E1 (2.048 Mbps)'], [4040, b'T3 (45 Mbps)'], [4050, b'E3 (34 Mbps)']]], [b'Stacking', [[5000, b'Cisco StackWise'], [5050, b'Cisco StackWise Plus'], [5000, b'Cisco FlexStack'], [5050, b'Cisco FlexStack Plus']]], [b'Other', [[32767, b'Other']]]], default=1200), + ), + migrations.AlterField( + model_name='interfacetemplate', + name='form_factor', + field=models.PositiveSmallIntegerField(choices=[[b'Virtual interfaces', [[0, b'Virtual']]], [b'Ethernet (fixed)', [[800, b'100BASE-TX (10/100ME)'], [1000, b'1000BASE-T (1GE)'], [1150, b'10GBASE-T (10GE)']]], [b'Ethernet (modular)', [[1050, b'GBIC (1GE)'], [1100, b'SFP (1GE)'], [1200, b'SFP+ (10GE)'], [1300, b'XFP (10GE)'], [1310, b'XENPAK (10GE)'], [1320, b'X2 (10GE)'], [1350, b'SFP28 (25GE)'], [1400, b'QSFP+ (40GE)'], [1500, b'CFP (100GE)'], [1600, b'QSFP28 (100GE)']]], [b'FibreChannel', [[3010, b'SFP (1GFC)'], [3020, b'SFP (2GFC)'], [3040, b'SFP (4GFC)'], [3080, b'SFP+ (8GFC)'], [3160, b'SFP+ (16GFC)']]], [b'Serial', [[4000, b'T1 (1.544 Mbps)'], [4010, b'E1 (2.048 Mbps)'], [4040, b'T3 (45 Mbps)'], [4050, b'E3 (34 Mbps)']]], [b'Stacking', [[5000, b'Cisco StackWise'], [5050, b'Cisco StackWise Plus'], [5000, b'Cisco FlexStack'], [5050, b'Cisco FlexStack Plus']]], [b'Other', [[32767, b'Other']]]], default=1200), + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 52eeb7f3a0..449202c6eb 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -107,6 +107,8 @@ # Stacking IFACE_FF_STACKWISE = 5000 IFACE_FF_STACKWISE_PLUS = 5050 +IFACE_FF_FLEXSTACK = 5100 +IFACE_FF_FLEXSTACK_PLUS = 5150 # Other IFACE_FF_OTHER = 32767 @@ -164,6 +166,8 @@ [ [IFACE_FF_STACKWISE, 'Cisco StackWise'], [IFACE_FF_STACKWISE_PLUS, 'Cisco StackWise Plus'], + [IFACE_FF_STACKWISE, 'Cisco FlexStack'], + [IFACE_FF_STACKWISE_PLUS, 'Cisco FlexStack Plus'], ] ], [ From a37d2ff4f83facd1d63917a33935e5dae07f058c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 31 Oct 2016 12:30:50 -0400 Subject: [PATCH 16/26] Closes #652: Use password input controls when editing secrets --- netbox/secrets/forms.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index 7eb9e816af..3f3d397a37 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -49,8 +49,9 @@ class Meta: class SecretForm(forms.ModelForm, BootstrapMixin): private_key = forms.CharField(required=False, widget=forms.HiddenInput()) plaintext = forms.CharField(max_length=65535, required=False, label='Plaintext', - widget=forms.TextInput(attrs={'class': 'requires-private-key'})) - plaintext2 = forms.CharField(max_length=65535, required=False, label='Plaintext (verify)') + widget=forms.PasswordInput(attrs={'class': 'requires-private-key'})) + plaintext2 = forms.CharField(max_length=65535, required=False, label='Plaintext (verify)', + widget=forms.PasswordInput()) class Meta: model = Secret From 4af3072b53447dc82ce998ff2c5e9e59e576d68a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 31 Oct 2016 14:45:34 -0400 Subject: [PATCH 17/26] Fix typo in c525939b13a24220584166db790b04973c19b034 --- netbox/dcim/migrations/0021_add_ff_flexstack.py | 6 +++--- netbox/dcim/models.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/netbox/dcim/migrations/0021_add_ff_flexstack.py b/netbox/dcim/migrations/0021_add_ff_flexstack.py index 17fdbce369..9e85ac9093 100644 --- a/netbox/dcim/migrations/0021_add_ff_flexstack.py +++ b/netbox/dcim/migrations/0021_add_ff_flexstack.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.10 on 2016-10-31 16:20 +# Generated by Django 1.10 on 2016-10-31 18:47 from __future__ import unicode_literals import django.core.validators @@ -21,11 +21,11 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='interface', name='form_factor', - field=models.PositiveSmallIntegerField(choices=[[b'Virtual interfaces', [[0, b'Virtual']]], [b'Ethernet (fixed)', [[800, b'100BASE-TX (10/100ME)'], [1000, b'1000BASE-T (1GE)'], [1150, b'10GBASE-T (10GE)']]], [b'Ethernet (modular)', [[1050, b'GBIC (1GE)'], [1100, b'SFP (1GE)'], [1200, b'SFP+ (10GE)'], [1300, b'XFP (10GE)'], [1310, b'XENPAK (10GE)'], [1320, b'X2 (10GE)'], [1350, b'SFP28 (25GE)'], [1400, b'QSFP+ (40GE)'], [1500, b'CFP (100GE)'], [1600, b'QSFP28 (100GE)']]], [b'FibreChannel', [[3010, b'SFP (1GFC)'], [3020, b'SFP (2GFC)'], [3040, b'SFP (4GFC)'], [3080, b'SFP+ (8GFC)'], [3160, b'SFP+ (16GFC)']]], [b'Serial', [[4000, b'T1 (1.544 Mbps)'], [4010, b'E1 (2.048 Mbps)'], [4040, b'T3 (45 Mbps)'], [4050, b'E3 (34 Mbps)']]], [b'Stacking', [[5000, b'Cisco StackWise'], [5050, b'Cisco StackWise Plus'], [5000, b'Cisco FlexStack'], [5050, b'Cisco FlexStack Plus']]], [b'Other', [[32767, b'Other']]]], default=1200), + field=models.PositiveSmallIntegerField(choices=[[b'Virtual interfaces', [[0, b'Virtual']]], [b'Ethernet (fixed)', [[800, b'100BASE-TX (10/100ME)'], [1000, b'1000BASE-T (1GE)'], [1150, b'10GBASE-T (10GE)']]], [b'Ethernet (modular)', [[1050, b'GBIC (1GE)'], [1100, b'SFP (1GE)'], [1200, b'SFP+ (10GE)'], [1300, b'XFP (10GE)'], [1310, b'XENPAK (10GE)'], [1320, b'X2 (10GE)'], [1350, b'SFP28 (25GE)'], [1400, b'QSFP+ (40GE)'], [1500, b'CFP (100GE)'], [1600, b'QSFP28 (100GE)']]], [b'FibreChannel', [[3010, b'SFP (1GFC)'], [3020, b'SFP (2GFC)'], [3040, b'SFP (4GFC)'], [3080, b'SFP+ (8GFC)'], [3160, b'SFP+ (16GFC)']]], [b'Serial', [[4000, b'T1 (1.544 Mbps)'], [4010, b'E1 (2.048 Mbps)'], [4040, b'T3 (45 Mbps)'], [4050, b'E3 (34 Mbps)']]], [b'Stacking', [[5000, b'Cisco StackWise'], [5050, b'Cisco StackWise Plus'], [5100, b'Cisco FlexStack'], [5150, b'Cisco FlexStack Plus']]], [b'Other', [[32767, b'Other']]]], default=1200), ), migrations.AlterField( model_name='interfacetemplate', name='form_factor', - field=models.PositiveSmallIntegerField(choices=[[b'Virtual interfaces', [[0, b'Virtual']]], [b'Ethernet (fixed)', [[800, b'100BASE-TX (10/100ME)'], [1000, b'1000BASE-T (1GE)'], [1150, b'10GBASE-T (10GE)']]], [b'Ethernet (modular)', [[1050, b'GBIC (1GE)'], [1100, b'SFP (1GE)'], [1200, b'SFP+ (10GE)'], [1300, b'XFP (10GE)'], [1310, b'XENPAK (10GE)'], [1320, b'X2 (10GE)'], [1350, b'SFP28 (25GE)'], [1400, b'QSFP+ (40GE)'], [1500, b'CFP (100GE)'], [1600, b'QSFP28 (100GE)']]], [b'FibreChannel', [[3010, b'SFP (1GFC)'], [3020, b'SFP (2GFC)'], [3040, b'SFP (4GFC)'], [3080, b'SFP+ (8GFC)'], [3160, b'SFP+ (16GFC)']]], [b'Serial', [[4000, b'T1 (1.544 Mbps)'], [4010, b'E1 (2.048 Mbps)'], [4040, b'T3 (45 Mbps)'], [4050, b'E3 (34 Mbps)']]], [b'Stacking', [[5000, b'Cisco StackWise'], [5050, b'Cisco StackWise Plus'], [5000, b'Cisco FlexStack'], [5050, b'Cisco FlexStack Plus']]], [b'Other', [[32767, b'Other']]]], default=1200), + field=models.PositiveSmallIntegerField(choices=[[b'Virtual interfaces', [[0, b'Virtual']]], [b'Ethernet (fixed)', [[800, b'100BASE-TX (10/100ME)'], [1000, b'1000BASE-T (1GE)'], [1150, b'10GBASE-T (10GE)']]], [b'Ethernet (modular)', [[1050, b'GBIC (1GE)'], [1100, b'SFP (1GE)'], [1200, b'SFP+ (10GE)'], [1300, b'XFP (10GE)'], [1310, b'XENPAK (10GE)'], [1320, b'X2 (10GE)'], [1350, b'SFP28 (25GE)'], [1400, b'QSFP+ (40GE)'], [1500, b'CFP (100GE)'], [1600, b'QSFP28 (100GE)']]], [b'FibreChannel', [[3010, b'SFP (1GFC)'], [3020, b'SFP (2GFC)'], [3040, b'SFP (4GFC)'], [3080, b'SFP+ (8GFC)'], [3160, b'SFP+ (16GFC)']]], [b'Serial', [[4000, b'T1 (1.544 Mbps)'], [4010, b'E1 (2.048 Mbps)'], [4040, b'T3 (45 Mbps)'], [4050, b'E3 (34 Mbps)']]], [b'Stacking', [[5000, b'Cisco StackWise'], [5050, b'Cisco StackWise Plus'], [5100, b'Cisco FlexStack'], [5150, b'Cisco FlexStack Plus']]], [b'Other', [[32767, b'Other']]]], default=1200), ), ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 449202c6eb..99640bfbb1 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -166,8 +166,8 @@ [ [IFACE_FF_STACKWISE, 'Cisco StackWise'], [IFACE_FF_STACKWISE_PLUS, 'Cisco StackWise Plus'], - [IFACE_FF_STACKWISE, 'Cisco FlexStack'], - [IFACE_FF_STACKWISE_PLUS, 'Cisco FlexStack Plus'], + [IFACE_FF_FLEXSTACK, 'Cisco FlexStack'], + [IFACE_FF_FLEXSTACK_PLUS, 'Cisco FlexStack Plus'], ] ], [ From 2d58cfaa05806d8cbb0a691b4c2c158fa4a757f6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 31 Oct 2016 15:29:32 -0400 Subject: [PATCH 18/26] Add is_full_depth and instance count columns to DeviceType table --- netbox/dcim/tables.py | 4 +++- netbox/dcim/views.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index c451f623aa..3576729b43 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -196,10 +196,12 @@ class DeviceTypeTable(BaseTable): manufacturer = tables.Column(verbose_name='Manufacturer') model = tables.LinkColumn('dcim:devicetype', args=[Accessor('pk')], verbose_name='Device Type') part_number = tables.Column(verbose_name='Part Number') + is_full_depth = tables.BooleanColumn(verbose_name='Full Depth') + instance_count = tables.Column(verbose_name='Instances') class Meta(BaseTable.Meta): model = DeviceType - fields = ('pk', 'model', 'manufacturer', 'part_number', 'u_height') + fields = ('pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count') # diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 1c58905eff..c08f9147b2 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -275,7 +275,7 @@ class ManufacturerBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # class DeviceTypeListView(ObjectListView): - queryset = DeviceType.objects.select_related('manufacturer') + queryset = DeviceType.objects.select_related('manufacturer').annotate(instance_count=Count('instances')) filter = filters.DeviceTypeFilter filter_form = forms.DeviceTypeFilterForm table = tables.DeviceTypeTable From 41af9c890007693106c7f6637a3f61b9fe24925e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 31 Oct 2016 16:44:00 -0400 Subject: [PATCH 19/26] Fixes #660: Correct calculation of utilized space for rack list display --- netbox/dcim/models.py | 6 ++---- netbox/dcim/tables.py | 9 ++++----- netbox/dcim/views.py | 6 ++---- 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 99640bfbb1..b20f229408 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -488,7 +488,7 @@ def get_available_units(self, u_height=1, rack_face=None, exclude=list()): """ # Gather all devices which consume U space within the rack - devices = self.devices.select_related().filter(position__gte=1).exclude(pk__in=exclude) + devices = self.devices.select_related('device_type').filter(position__gte=1).exclude(pk__in=exclude) # Initialize the rack unit skeleton units = range(1, self.u_height + 1) @@ -518,9 +518,7 @@ def get_utilization(self): """ Determine the utilization rate of the rack and return it as a percentage. """ - if self.u_consumed is None: - self.u_consumed = 0 - u_available = self.u_height - self.u_consumed + u_available = len(self.get_available_units()) return int(float(self.u_height - u_available) / self.u_height * 100) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 3576729b43..6c138b4466 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -72,7 +72,7 @@ UTILIZATION_GRAPH = """ {% load helpers %} -{% utilization_graph record.get_utilization %} +{% utilization_graph value %} """ @@ -148,13 +148,12 @@ class RackTable(BaseTable): role = tables.TemplateColumn(RACK_ROLE, verbose_name='Role') u_height = tables.TemplateColumn("{{ record.u_height }}U", verbose_name='Height') devices = tables.Column(accessor=Accessor('device_count'), verbose_name='Devices') - u_consumed = tables.TemplateColumn("{{ record.u_consumed|default:'0' }}U", verbose_name='Used') - utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization') + get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization') class Meta(BaseTable.Meta): model = Rack - fields = ('pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height', 'devices', 'u_consumed', - 'utilization') + fields = ('pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height', 'devices', + 'get_utilization') class RackImportTable(BaseTable): diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index c08f9147b2..7f4bbeadeb 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -7,8 +7,7 @@ from django.contrib.auth.mixins import PermissionRequiredMixin from django.core.exceptions import ValidationError from django.core.urlresolvers import reverse -from django.db.models import Count, Sum -from django.db.models.functions import Coalesce +from django.db.models import Count from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404, redirect, render from django.utils.http import urlencode @@ -181,8 +180,7 @@ class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class RackListView(ObjectListView): queryset = Rack.objects.select_related('site', 'group', 'tenant', 'role').prefetch_related('devices__device_type')\ - .annotate(device_count=Count('devices', distinct=True), - u_consumed=Coalesce(Sum('devices__device_type__u_height'), 0)) + .annotate(device_count=Count('devices', distinct=True)) filter = filters.RackFilter filter_form = forms.RackFilterForm table = tables.RackTable From 084b86cab1034ea40081750880941fcb1b237bda Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 31 Oct 2016 16:47:00 -0400 Subject: [PATCH 20/26] Tweaked Aggregate get_utilization for table display --- netbox/ipam/tables.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 0a787ad3b7..6859472a65 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -14,7 +14,7 @@ UTILIZATION_GRAPH = """ {% load helpers %} -{% utilization_graph record.get_utilization %} +{% utilization_graph value %} """ ROLE_ACTIONS = """ @@ -125,13 +125,13 @@ class AggregateTable(BaseTable): prefix = tables.LinkColumn('ipam:aggregate', args=[Accessor('pk')], verbose_name='Aggregate') rir = tables.Column(verbose_name='RIR') child_count = tables.Column(verbose_name='Prefixes') - 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') description = tables.Column(orderable=False, verbose_name='Description') class Meta(BaseTable.Meta): model = Aggregate - fields = ('pk', 'prefix', 'rir', 'child_count', 'utilization', 'date_added', 'description') + fields = ('pk', 'prefix', 'rir', 'child_count', 'get_utilization', 'date_added', 'description') # From f2137683f96a49a5eeb99858a5ad901c70789862 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 1 Nov 2016 13:59:24 -0400 Subject: [PATCH 21/26] Closes #647: Extend form used when assigning an IP to a device --- netbox/dcim/forms.py | 7 ++--- netbox/dcim/views.py | 4 ++- netbox/ipam/forms.py | 10 ++----- .../migrations/0010_ipaddress_help_texts.py | 27 +++++++++++++++++ netbox/ipam/models.py | 5 ++-- netbox/templates/dcim/inc/_ipaddress.html | 3 ++ netbox/templates/dcim/ipaddress_assign.html | 30 +++++++++++++++++-- 7 files changed, 68 insertions(+), 18 deletions(-) create mode 100644 netbox/ipam/migrations/0010_ipaddress_help_texts.py diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 7e0867396f..4f1d7228db 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1228,15 +1228,12 @@ class InterfaceConnectionFilterForm(forms.Form, BootstrapMixin): # IP addresses # -class IPAddressForm(forms.ModelForm, BootstrapMixin): +class IPAddressForm(BootstrapMixin, CustomFieldForm): set_as_primary = forms.BooleanField(label='Set as primary IP for device', required=False) class Meta: model = IPAddress - fields = ['address', 'vrf', 'interface', 'set_as_primary'] - help_texts = { - 'address': 'IPv4 or IPv6 address (with mask)' - } + fields = ['address', 'vrf', 'tenant', 'status', 'interface', 'description'] def __init__(self, device, *args, **kwargs): diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 7f4bbeadeb..2d603d8e91 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -572,7 +572,8 @@ def device(request, pk): secrets = device.secrets.all() # Find all IP addresses assigned to this device - ip_addresses = IPAddress.objects.filter(interface__device=device).select_related('interface').order_by('address') + ip_addresses = IPAddress.objects.filter(interface__device=device).select_related('interface', 'vrf')\ + .order_by('address') # Find any related devices for convenient linking in the UI related_devices = [] @@ -1530,6 +1531,7 @@ def ipaddress_assign(request, pk): ipaddress = form.save(commit=False) ipaddress.interface = form.cleaned_data['interface'] ipaddress.save() + form.save_custom_fields() messages.success(request, u"Added new IP address {} to interface {}.".format(ipaddress, ipaddress.interface)) if form.cleaned_data['set_as_primary']: diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 039475d036..de6d599157 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -284,16 +284,12 @@ class IPAddressForm(BootstrapMixin, CustomFieldForm): livesearch = forms.CharField(required=False, label='IP Address', widget=Livesearch( query_key='q', query_url='ipam-api:ipaddress_list', field_to_update='nat_inside', obj_label='address') ) - nat_inside = forms.ModelChoiceField(queryset=IPAddress.objects.all(), required=False, label='NAT (Inside)', - widget=APISelect(api_url='/api/ipam/ip-addresses/?device_id={{nat_device}}', - display_field='address')) class Meta: model = IPAddress - fields = ['address', 'vrf', 'tenant', 'status', 'nat_device', 'nat_inside', 'description'] - help_texts = { - 'address': "IPv4 or IPv6 address and mask", - 'vrf': "VRF (if applicable)", + fields = ['address', 'vrf', 'tenant', 'status', 'nat_inside', 'description'] + widgets ={ + 'nat_inside': APISelect(api_url='/api/ipam/ip-addresses/?device_id={{nat_device}}', display_field='address') } def __init__(self, *args, **kwargs): diff --git a/netbox/ipam/migrations/0010_ipaddress_help_texts.py b/netbox/ipam/migrations/0010_ipaddress_help_texts.py new file mode 100644 index 0000000000..a1e05171df --- /dev/null +++ b/netbox/ipam/migrations/0010_ipaddress_help_texts.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10 on 2016-11-01 17:46 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +import ipam.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0009_ipaddress_add_status'), + ] + + operations = [ + migrations.AlterField( + model_name='ipaddress', + name='address', + field=ipam.fields.IPAddressField(help_text=b'IPv4 or IPv6 address (with mask)'), + ), + migrations.AlterField( + model_name='ipaddress', + name='nat_inside', + field=models.OneToOneField(blank=True, help_text=b'The IP for which this address is the "outside" IP', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='nat_outside', to='ipam.IPAddress', verbose_name=b'NAT (Inside)'), + ), + ] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index e8f4238b84..163712d1e7 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -346,7 +346,7 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel): which has a NAT outside IP, that Interface's Device can use either the inside or outside IP as its primary IP. """ family = models.PositiveSmallIntegerField(choices=AF_CHOICES, editable=False) - address = IPAddressField() + address = IPAddressField(help_text="IPv4 or IPv6 address (with mask)") vrf = models.ForeignKey('VRF', related_name='ip_addresses', on_delete=models.PROTECT, blank=True, null=True, verbose_name='VRF') tenant = models.ForeignKey(Tenant, related_name='ip_addresses', blank=True, null=True, on_delete=models.PROTECT) @@ -354,7 +354,8 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel): interface = models.ForeignKey(Interface, related_name='ip_addresses', on_delete=models.CASCADE, blank=True, null=True) nat_inside = models.OneToOneField('self', related_name='nat_outside', on_delete=models.SET_NULL, blank=True, - null=True, verbose_name='NAT IP (inside)') + null=True, verbose_name='NAT (Inside)', + help_text="The IP for which this address is the \"outside\" IP") description = models.CharField(max_length=100, blank=True) custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') diff --git a/netbox/templates/dcim/inc/_ipaddress.html b/netbox/templates/dcim/inc/_ipaddress.html index 3f805b611e..7bdc8bc1e3 100644 --- a/netbox/templates/dcim/inc/_ipaddress.html +++ b/netbox/templates/dcim/inc/_ipaddress.html @@ -2,6 +2,9 @@ {{ ip }} + + {{ ip.vrf|default:"Global" }} + {{ ip.interface }} {% if device.primary_ip4 == ip or device.primary_ip6 == ip %} diff --git a/netbox/templates/dcim/ipaddress_assign.html b/netbox/templates/dcim/ipaddress_assign.html index 212a374580..538023d088 100644 --- a/netbox/templates/dcim/ipaddress_assign.html +++ b/netbox/templates/dcim/ipaddress_assign.html @@ -1,7 +1,7 @@ {% extends '_base.html' %} {% load form_helpers %} -{% block title %}Add an IP Address{% endblock %} +{% block title %}Assign an IP Address{% endblock %} {% block content %}
@@ -18,10 +18,34 @@ {% endif %}
- Add an IP Address + IP Address
- {% render_form form %} + {% render_field form.address %} + {% render_field form.vrf %} + {% render_field form.tenant %} + {% render_field form.status %} + {% render_field form.description %} +
+
+
+
+ Interface Assignment +
+
+
+ +
+

{{ device }}

+
+
+ {% render_field form.interface %} +
+
+
+
Custom Fields
+
+ {% render_custom_fields form %}
From ad1c3d49105f777bb463cdf7e94ed6c1a2c9f845 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 1 Nov 2016 15:44:10 -0400 Subject: [PATCH 22/26] Fixed typo in error message --- netbox/ipam/forms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index de6d599157..0ac4ac551b 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -497,7 +497,7 @@ def __init__(self, *args, **kwargs): class VLANFromCSVForm(forms.ModelForm): site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name', - error_messages={'invalid_choice': 'Device not found.'}) + error_messages={'invalid_choice': 'Site not found.'}) group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False, to_field_name='name', error_messages={'invalid_choice': 'VLAN group not found.'}) tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False, From bbac6e2ba667011b0dd1957eb4ffa9961fbd081d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 2 Nov 2016 16:43:24 -0400 Subject: [PATCH 23/26] Fixes #664: Re-implemented view for bulk creation of interfaces across multiple devices --- netbox/dcim/forms.py | 18 ++- netbox/dcim/urls.py | 2 +- netbox/dcim/views.py | 109 ++++++++++++------ .../dcim/device_bulk_add_component.html | 60 ++++++++++ netbox/templates/dcim/inc/device_table.html | 2 +- .../templates/dcim/interface_add_multi.html | 23 ---- 6 files changed, 151 insertions(+), 63 deletions(-) create mode 100644 netbox/templates/dcim/device_bulk_add_component.html delete mode 100644 netbox/templates/dcim/interface_add_multi.html diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 4f1d7228db..6e95803f44 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -584,6 +584,18 @@ class Meta: nullable_fields = ['tenant', 'platform'] +class DeviceBulkAddComponentForm(forms.Form, BootstrapMixin): + pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput) + name_pattern = ExpandableNameField(label='Name') + + +class DeviceBulkAddInterfaceForm(forms.ModelForm, DeviceBulkAddComponentForm): + + class Meta: + model = Interface + fields = ['name_pattern', 'form_factor', 'mgmt_only', 'description'] + + class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Device site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('racks__devices')), to_field_name='slug') @@ -1014,10 +1026,6 @@ class Meta: fields = ['name_pattern', 'form_factor', 'mac_address', 'mgmt_only', 'description'] -class InterfaceBulkCreateForm(InterfaceCreateForm, BootstrapMixin): - pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput) - - class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput) form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False) @@ -1250,7 +1258,7 @@ def __init__(self, device, *args, **kwargs): # -# Interfaces +# Modules # class ModuleForm(forms.ModelForm, BootstrapMixin): diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 1806891267..3ec0181163 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -154,7 +154,7 @@ url(r'^interface-connections/import/$', views.InterfaceConnectionsBulkImportView.as_view(), name='interface_connections_import'), # Interfaces - url(r'^devices/interfaces/add/$', views.InterfaceBulkAddView.as_view(), name='interface_add_multi'), + url(r'^devices/interfaces/add/$', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'), url(r'^devices/(?P\d+)/interfaces/add/$', views.interface_add, name='interface_add'), url(r'^devices/(?P\d+)/interfaces/edit/$', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'), url(r'^devices/(?P\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 2d603d8e91..d316704464 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1,3 +1,4 @@ +from copy import deepcopy import re from natsort import natsorted from operator import attrgetter @@ -7,6 +8,7 @@ from django.contrib.auth.mixins import PermissionRequiredMixin from django.core.exceptions import ValidationError from django.core.urlresolvers import reverse +from django.db import transaction from django.db.models import Count from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404, redirect, render @@ -686,6 +688,80 @@ def device_lldp_neighbors(request, pk): }) +class DeviceBulkAddComponentView(View): + """ + Add one or more components (e.g. interfaces) to a selected set of Devices. + """ + form = None + component_cls = None + component_form = None + + def get(self): + return redirect('dcim:device_list') + + def post(self, request): + + # Are we editing *all* objects in the queryset or just a selected subset? + if request.POST.get('_all'): + pk_list = [int(pk) for pk in request.POST.get('pk_all').split(',') if pk] + else: + pk_list = [int(pk) for pk in request.POST.getlist('pk')] + + if '_create' in request.POST: + form = self.form(request.POST) + if form.is_valid(): + + new_components = [] + data = deepcopy(form.cleaned_data) + for device in data['pk']: + + names = data['name_pattern'] + for name in names: + component_data = { + 'device': device.pk, + 'name': name, + } + component_data.update(data) + component_form = self.component_form(component_data) + if component_form.is_valid(): + new_components.append(component_form.save(commit=False)) + else: + form.add_error('name_pattern', "Duplicate {} name for {}: {}".format( + self.component_cls._meta.verbose_name, device, name + )) + + if not form.errors: + self.component_cls.objects.bulk_create(new_components) + messages.success(request, u"Added {} {} to {} devices.".format( + len(new_components), self.component_cls._meta.verbose_name_plural, len(form.cleaned_data['pk']) + )) + return redirect('dcim:device_list') + + else: + form = self.form(initial={'pk': pk_list}) + + selected_devices = Device.objects.filter(pk__in=pk_list) + if not selected_devices: + messages.warning(request, u"No devices were selected.") + return redirect('dcim:device_list') + + return render(request, 'dcim/device_bulk_add_component.html', { + 'form': form, + 'component_name': self.component_cls._meta.verbose_name_plural, + 'selected_devices': selected_devices, + 'cancel_url': reverse('dcim:device_list'), + }) + + +class DeviceBulkAddInterfaceView(DeviceBulkAddComponentView): + """ + Add one or more components (e.g. interfaces) to a selected set of Devices. + """ + form = forms.DeviceBulkAddInterfaceForm + component_cls = Interface + component_form = forms.InterfaceForm + + # # Console ports # @@ -1236,39 +1312,6 @@ class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView): model = Interface -class InterfaceBulkAddView(PermissionRequiredMixin, BulkEditView): - permission_required = 'dcim.add_interface' - cls = Device - form = forms.InterfaceBulkCreateForm - template_name = 'dcim/interface_add_multi.html' - default_redirect_url = 'dcim:device_list' - - def update_objects(self, pk_list, form, fields): - - selected_devices = Device.objects.filter(pk__in=pk_list) - interfaces = [] - - for device in selected_devices: - for name in form.cleaned_data['name_pattern']: - iface_form = forms.InterfaceForm({ - 'device': device.pk, - 'name': name, - 'mac_address': form.cleaned_data['mac_address'], - 'form_factor': form.cleaned_data['form_factor'], - 'mgmt_only': form.cleaned_data['mgmt_only'], - 'description': form.cleaned_data['description'], - }) - if iface_form.is_valid(): - interfaces.append(iface_form.save(commit=False)) - else: - form.add_error(None, "Duplicate interface {} found for device {}".format(name, device)) - - if not form.errors: - Interface.objects.bulk_create(interfaces) - messages.success(self.request, u"Added {} interfaces to {} devices.".format(len(interfaces), - len(selected_devices))) - - class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_interface' cls = Interface diff --git a/netbox/templates/dcim/device_bulk_add_component.html b/netbox/templates/dcim/device_bulk_add_component.html new file mode 100644 index 0000000000..60d42484ce --- /dev/null +++ b/netbox/templates/dcim/device_bulk_add_component.html @@ -0,0 +1,60 @@ +{% extends '_base.html' %} +{% load form_helpers %} + +{% block content %} +

Add {{ component_name|title }}

+ + {% csrf_token %} + {% if request.POST.redirect_url %} + + {% endif %} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %} +
+
+
+
Selected Devices
+ + + + + + + {% for device in selected_devices %} + + + + + + {% endfor %} +
DeviceTypeRole
{{ device }}{{ device.device_type }}{{ device.device_role }}
+
+
+
+ {% if form.non_field_errors %} +
+
Errors
+
+ {{ form.non_field_errors }} +
+
+ {% endif %} +
+
{{ component_name|title }} to Add
+
+ {% for field in form.visible_fields %} + {% render_field field %} + {% endfor %} +
+
+
+
+ + Cancel +
+
+
+
+ +{% endblock %} diff --git a/netbox/templates/dcim/inc/device_table.html b/netbox/templates/dcim/inc/device_table.html index 480bbc9336..08344706e1 100644 --- a/netbox/templates/dcim/inc/device_table.html +++ b/netbox/templates/dcim/inc/device_table.html @@ -2,7 +2,7 @@ {% block extra_actions %} {% if perms.dcim.add_interface %} - {% endif %} diff --git a/netbox/templates/dcim/interface_add_multi.html b/netbox/templates/dcim/interface_add_multi.html deleted file mode 100644 index 3d56dc1655..0000000000 --- a/netbox/templates/dcim/interface_add_multi.html +++ /dev/null @@ -1,23 +0,0 @@ -{% extends 'utilities/bulk_edit_form.html' %} -{% load form_helpers %} - -{% block title %}Add Interfaces{% endblock %} - -{% block selected_objects_title %}Selected Devices{% endblock %} - -{% block form_title %}Interface(s) to Add{% endblock %} - -{% block selected_objects_table %} - - Device - Type - Role - - {% for device in selected_objects %} - - {{ device }} - {{ device.device_type }} - {{ device.device_role }} - - {% endfor %} -{% endblock %} From 96eaea7db9ea380304d332e7155db20c94324f27 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 3 Nov 2016 14:15:57 -0400 Subject: [PATCH 24/26] Miscellaneous cleanup --- netbox/extras/forms.py | 1 - netbox/ipam/forms.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 6780cfba79..d7a37dacdf 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -142,7 +142,6 @@ def __init__(self, *args, **kwargs): self.fields[name] = field # Annotate this as a custom field self.custom_fields.append(name) - print(self.nullable_fields) class CustomFieldFilterForm(forms.Form): diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 0ac4ac551b..958a99a3fc 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -288,7 +288,7 @@ class IPAddressForm(BootstrapMixin, CustomFieldForm): class Meta: model = IPAddress fields = ['address', 'vrf', 'tenant', 'status', 'nat_inside', 'description'] - widgets ={ + widgets = { 'nat_inside': APISelect(api_url='/api/ipam/ip-addresses/?device_id={{nat_device}}', display_field='address') } From ea92e92c5ab7ee5882b4f5e3ff07db06cd98ebb3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 3 Nov 2016 14:49:02 -0400 Subject: [PATCH 25/26] Fixes #632: Use semicolons instead of commas to separate regexes in topology maps --- docs/data-model/extras.md | 2 +- netbox/extras/api/views.py | 4 +-- ...4_topologymap_change_comma_to_semicolon.py | 29 +++++++++++++++++++ netbox/extras/models.py | 9 +++--- 4 files changed, 37 insertions(+), 7 deletions(-) create mode 100644 netbox/extras/migrations/0004_topologymap_change_comma_to_semicolon.py diff --git a/docs/data-model/extras.md b/docs/data-model/extras.md index 9d69af40a1..dca6d7f03c 100644 --- a/docs/data-model/extras.md +++ b/docs/data-model/extras.md @@ -98,4 +98,4 @@ dist-switch\d access-switch\d+,oob-switch\d+ ``` -Note that you can combine multiple regexes onto one line using commas. (Commas can only be used for separating regexes; they will not be processed as part of a regex.) The order in which regexes are listed on a line is significant: devices matching the first regex will be rendered first, and subsequent groups will be rendered to the right of those. +Note that you can combine multiple regexes onto one line using semicolons. The order in which regexes are listed on a line is significant: devices matching the first regex will be rendered first, and subsequent groups will be rendered to the right of those. diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index b5928dae15..19d7fab5fc 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -80,7 +80,7 @@ def get(self, request, slug): # Add each device to the graph devices = [] - for query in device_set.split(','): + for query in device_set.split(';'): # Split regexes on semicolons devices += Device.objects.filter(name__regex=query) for d in devices: subgraph.node(d.name) @@ -94,7 +94,7 @@ def get(self, request, slug): # Compile list of all devices device_superset = Q() for device_set in tmap.device_sets: - for query in device_set.split(','): + for query in device_set.split(';'): # Split regexes on semicolons device_superset = device_superset | Q(name__regex=query) # Add all connections to the graph diff --git a/netbox/extras/migrations/0004_topologymap_change_comma_to_semicolon.py b/netbox/extras/migrations/0004_topologymap_change_comma_to_semicolon.py new file mode 100644 index 0000000000..bf2711c43e --- /dev/null +++ b/netbox/extras/migrations/0004_topologymap_change_comma_to_semicolon.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10 on 2016-11-03 18:33 +from __future__ import unicode_literals + +from django.db import migrations, models + +from extras.models import TopologyMap + + +def commas_to_semicolons(apps, schema_editor): + for tm in TopologyMap.objects.filter(device_patterns__contains=','): + tm.device_patterns = tm.device_patterns.replace(',', ';') + tm.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0003_exporttemplate_add_description'), + ] + + operations = [ + migrations.AlterField( + model_name='topologymap', + name='device_patterns', + field=models.TextField(help_text=b'Identify devices to include in the diagram using regular expressions, one per line. Each line will result in a new tier of the drawing. Separate multiple regexes within a line using semicolons. Devices will be rendered in the order they are defined.'), + ), + migrations.RunPython(commas_to_semicolons), + ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 40ce4a1f51..609e878e98 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -268,10 +268,11 @@ class TopologyMap(models.Model): name = models.CharField(max_length=50, unique=True) slug = models.SlugField(unique=True) site = models.ForeignKey('dcim.Site', related_name='topology_maps', blank=True, null=True) - device_patterns = models.TextField(help_text="Identify devices to include in the diagram using regular expressions," - "one per line. Each line will result in a new tier of the drawing. " - "Separate multiple regexes on a line using commas. Devices will be " - "rendered in the order they are defined.") + device_patterns = models.TextField( + help_text="Identify devices to include in the diagram using regular expressions, one per line. Each line will " + "result in a new tier of the drawing. Separate multiple regexes within a line using semicolons. " + "Devices will be rendered in the order they are defined." + ) description = models.CharField(max_length=100, blank=True) class Meta: From 6c1fb1bd027871a0ddc97bcdbed4460457383a0e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 3 Nov 2016 15:06:08 -0400 Subject: [PATCH 26/26] Release v1.7.0 --- netbox/netbox/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 94df915bb9..27195c6d32 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -12,7 +12,7 @@ "the documentation.") -VERSION = '1.6.4-dev' +VERSION = '1.7.0' # Import local configuration for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']: