diff --git a/.gitignore b/.gitignore index 954607b60..4fc377333 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ *.pyc -configuration.py +/netbox/netbox/configuration.py +/netbox/static .idea /*.sh !upgrade.sh diff --git a/docs/data-model/extras.md b/docs/data-model/extras.md index 9d69af40a..dca6d7f03 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/docs/installation/upgrading.md b/docs/installation/upgrading.md index 087d9f198..303915dc7 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: diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 1f3ced50a..ef7a4be60 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 3c6ac7e43..6e95803f4 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(), @@ -582,6 +584,18 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): 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') @@ -1012,10 +1026,6 @@ class InterfaceCreateForm(forms.ModelForm, BootstrapMixin): 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) @@ -1226,15 +1236,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): @@ -1251,7 +1258,7 @@ class IPAddressForm(forms.ModelForm, BootstrapMixin): # -# Interfaces +# Modules # class ModuleForm(forms.ModelForm, BootstrapMixin): 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 000000000..d5a74706d --- /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/migrations/0021_add_ff_flexstack.py b/netbox/dcim/migrations/0021_add_ff_flexstack.py new file mode 100644 index 000000000..9e85ac909 --- /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 18:47 +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'], [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'], [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 2cfbbcc70..b20f22940 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -107,6 +107,8 @@ IFACE_FF_E3 = 4050 # 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_CHOICES = [ [ [IFACE_FF_STACKWISE, 'Cisco StackWise'], [IFACE_FF_STACKWISE_PLUS, 'Cisco StackWise Plus'], + [IFACE_FF_FLEXSTACK, 'Cisco FlexStack'], + [IFACE_FF_FLEXSTACK_PLUS, 'Cisco FlexStack Plus'], ] ], [ @@ -375,6 +379,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') @@ -401,8 +407,11 @@ class Rack(CreatedUpdatedModel, CustomFieldModel): 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([ @@ -419,7 +428,10 @@ class Rack(CreatedUpdatedModel, CustomFieldModel): @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): @@ -438,7 +450,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel): """ 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 @@ -476,7 +488,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel): """ # 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) @@ -506,9 +518,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel): """ 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) @@ -596,27 +606,39 @@ class DeviceType(models.Model): 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): @@ -800,7 +822,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, @@ -824,29 +846,39 @@ class Device(CreatedUpdatedModel, CustomFieldModel): 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.") + raise ValidationError({ + 'face': "Must specify rack face when defining 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 + 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): @@ -961,6 +993,9 @@ class ConsolePort(models.Model): def __unicode__(self): return self.name + def get_parent_url(self): + return self.device.get_absolute_url() + # Used for connections export def to_csv(self): return ','.join([ @@ -1002,6 +1037,9 @@ class ConsoleServerPort(models.Model): def __unicode__(self): return self.name + def get_parent_url(self): + return self.device.get_absolute_url() + class PowerPort(models.Model): """ @@ -1020,6 +1058,9 @@ class PowerPort(models.Model): def __unicode__(self): return self.name + def get_parent_url(self): + return self.device.get_absolute_url() + # Used for connections export def to_csv(self): return ','.join([ @@ -1055,6 +1096,9 @@ class PowerOutlet(models.Model): def __unicode__(self): return self.name + def get_parent_url(self): + return self.device.get_absolute_url() + class InterfaceManager(models.Manager): @@ -1091,12 +1135,16 @@ class Interface(models.Model): def __unicode__(self): return self.name + def get_parent_url(self): + return self.device.get_absolute_url() + def clean(self): if self.form_factor == IFACE_FF_VIRTUAL and self.is_connected: - 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 +1195,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): @@ -1176,12 +1226,16 @@ class DeviceBay(models.Model): def __unicode__(self): return u'{} - {}'.format(self.device.name, self.name) + def get_parent_url(self): + return self.device.get_absolute_url() + def clean(self): # Validate that the parent Device can have DeviceBays 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: @@ -1208,3 +1262,6 @@ class Module(models.Model): def __unicode__(self): return self.name + + def get_parent_url(self): + return reverse('dcim:device_inventory', args=[self.device.pk]) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 9906a398e..6c138b446 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -72,7 +72,7 @@ STATUS_ICON = """ 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): @@ -196,10 +195,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') # @@ -357,7 +358,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 diff --git a/netbox/dcim/tests/test_apis.py b/netbox/dcim/tests/test_apis.py index 5f52776c3..352c36899 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/dcim/urls.py b/netbox/dcim/urls.py index b5b960f57..3ec018116 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -110,38 +110,38 @@ urlpatterns = [ 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+)/delete/$', views.consoleport_delete, name='consoleport_delete'), + url(r'^console-ports/(?P\d+)/edit/$', views.ConsolePortEditView.as_view(), name='consoleport_edit'), + 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'), 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+)/delete/$', views.consoleserverport_delete, name='consoleserverport_delete'), + url(r'^console-server-ports/(?P\d+)/edit/$', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'), + 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'), 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+)/delete/$', views.powerport_delete, name='powerport_delete'), + url(r'^power-ports/(?P\d+)/edit/$', views.PowerPortEditView.as_view(), name='powerport_edit'), + 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'), 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+)/delete/$', views.poweroutlet_delete, name='poweroutlet_delete'), + url(r'^power-outlets/(?P\d+)/edit/$', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'), + 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.devicebay_edit, name='devicebay_edit'), - url(r'^device-bays/(?P\d+)/delete/$', views.devicebay_delete, name='devicebay_delete'), + url(r'^device-bays/(?P\d+)/edit/$', views.DeviceBayEditView.as_view(), name='devicebay_edit'), + 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'), @@ -154,18 +154,18 @@ urlpatterns = [ 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'), 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+)/delete/$', views.interface_delete, name='interface_delete'), + url(r'^interfaces/(?P\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'), + 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.module_edit, name='module_edit'), - url(r'^modules/(?P\d+)/delete/$', views.module_delete, name='module_delete'), + url(r'^modules/(?P\d+)/edit/$', views.ModuleEditView.as_view(), name='module_edit'), + 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 b68a1dba0..d31670446 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,8 +8,8 @@ from django.contrib.auth.decorators import permission_required from django.contrib.auth.mixins import PermissionRequiredMixin from django.core.exceptions import ValidationError from django.core.urlresolvers import reverse -from django.db.models import Count, Sum -from django.db.models.functions import Coalesce +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 from django.utils.http import urlencode @@ -181,8 +182,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 @@ -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 @@ -394,7 +394,7 @@ class ComponentTemplateCreateView(View): 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: @@ -574,7 +574,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 = [] @@ -687,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 # @@ -713,7 +788,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: @@ -722,8 +797,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}), }) @@ -738,7 +814,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, @@ -765,7 +841,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) @@ -775,7 +851,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: @@ -788,49 +864,15 @@ 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') -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): @@ -873,7 +915,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: @@ -882,8 +924,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}), }) @@ -901,7 +944,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, @@ -925,7 +968,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) @@ -936,7 +979,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: @@ -949,49 +992,15 @@ 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') -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): @@ -1026,7 +1035,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: @@ -1035,8 +1044,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}), }) @@ -1051,7 +1061,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, @@ -1078,7 +1088,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) @@ -1088,7 +1098,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: @@ -1101,48 +1111,15 @@ 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') -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): @@ -1184,7 +1161,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: @@ -1193,8 +1170,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}), }) @@ -1212,7 +1190,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, @@ -1236,7 +1214,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': @@ -1246,7 +1224,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: @@ -1259,49 +1237,15 @@ 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') -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): @@ -1340,97 +1284,32 @@ 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: 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') -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 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, "Added {} interfaces to {} devices".format(len(interfaces), - len(selected_devices))) +class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_interface' + model = Interface class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView): @@ -1474,7 +1353,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: @@ -1483,55 +1362,23 @@ 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') -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') @@ -1547,7 +1394,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: @@ -1571,7 +1418,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: @@ -1603,7 +1450,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, @@ -1643,7 +1490,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, @@ -1715,7 +1562,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 +1574,8 @@ 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)) + 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']: if ipaddress.family == 4: @@ -1767,7 +1614,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: @@ -1776,52 +1623,20 @@ 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') -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/extras/api/views.py b/netbox/extras/api/views.py index b5928dae1..19d7fab5f 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -80,7 +80,7 @@ class TopologyMapView(APIView): # 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 @@ class TopologyMapView(APIView): # 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/forms.py b/netbox/extras/forms.py index 6780cfba7..d7a37dacd 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -142,7 +142,6 @@ class CustomFieldBulkEditForm(BulkEditForm): 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/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 000000000..bf2711c43 --- /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 40ce4a1f5..609e878e9 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: diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 653d9eba5..f7cf20636 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 a998bb2a0..e7e150b34 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 1a08aa7fc..958a99a3f 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -1,20 +1,19 @@ 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 ( - 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'), @@ -173,16 +172,6 @@ class PrefixForm(BootstrapMixin, CustomFieldForm): 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', @@ -248,7 +237,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) @@ -295,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', '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): @@ -347,11 +332,35 @@ class IPAddressForm(BootstrapMixin, CustomFieldForm): 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.'}) 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 +368,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 +415,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 +439,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) # @@ -479,7 +497,7 @@ class VLANForm(BootstrapMixin, CustomFieldForm): 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, @@ -510,7 +528,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 000000000..ad876c3b6 --- /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/migrations/0010_ipaddress_help_texts.py b/netbox/ipam/migrations/0010_ipaddress_help_texts.py new file mode 100644 index 000000000..a1e05171d --- /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 794198b92..163712d1e 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -29,6 +29,12 @@ PREFIX_STATUS_CHOICES = ( (3, 'Deprecated') ) +IPADDRESS_STATUS_CHOICES = ( + (1, 'Active'), + (2, 'Reserved'), + (5, 'DHCP') +) + VLAN_STATUS_CHOICES = ( (1, 'Active'), (2, 'Reserved'), @@ -40,6 +46,8 @@ STATUS_CHOICE_CLASSES = { 1: 'primary', 2: 'info', 3: 'danger', + 4: 'warning', + 5: 'success', } @@ -131,16 +139,22 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel): 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: @@ -260,14 +274,17 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel): 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: @@ -329,14 +346,16 @@ 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) + 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, - 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') @@ -360,13 +379,16 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel): 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: @@ -387,6 +409,7 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel): 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 +422,9 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel): return self.interface.device return None + def get_status_class(self): + return STATUS_CHOICE_CLASSES[self.status] + class VLANGroup(models.Model): """ @@ -465,7 +491,9 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel): # 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/ipam/tables.py b/netbox/ipam/tables.py index c669362c5..6859472a6 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -14,7 +14,7 @@ RIR_ACTIONS = """ 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') # @@ -193,6 +193,7 @@ class PrefixBriefTable(BaseTable): 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/ipam/urls.py b/netbox/ipam/urls.py index 22c4cd512..dc5fcc964 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -56,6 +56,8 @@ urlpatterns = [ 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 c7c5a46c6..3262bbeb5 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, u"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, u"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/netbox/settings.py b/netbox/netbox/settings.py index 68ecac792..27195c6d3 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -12,7 +12,7 @@ except ImportError: "the documentation.") -VERSION = '1.6.3' +VERSION = '1.7.0' # Import local configuration for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']: diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 41b71546e..b579671bf 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 @@ _patterns = [ 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 2da97a2cf..7aa144295 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/secrets/admin.py b/netbox/secrets/admin.py index 4fb5f7c48..ac0cf1b8a 100644 --- a/netbox/secrets/admin.py +++ b/netbox/secrets/admin.py @@ -34,7 +34,7 @@ class UserKeyAdmin(admin.ModelAdmin): 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 @@ class UserKeyAdmin(admin.ModelAdmin): 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 ebbdae916..41af204da 100644 --- a/netbox/secrets/decorators.py +++ b/netbox/secrets/decorators.py @@ -14,10 +14,10 @@ def userkey_required(): 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/forms.py b/netbox/secrets/forms.py index a163640b8..3f3d397a3 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -49,22 +49,23 @@ class SecretRoleForm(forms.ModelForm, BootstrapMixin): 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 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 80abfcbdf..930d6e032 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -81,24 +81,34 @@ class UserKey(CreatedUpdatedModel): 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() diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index 14ac4fa78..a99af80b6 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/templates/404.html b/netbox/templates/404.html new file mode 100644 index 000000000..92d1f3589 --- /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 %} diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index 46f37c44f..5b9f8381f 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -131,6 +131,21 @@ {% 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 diff --git a/netbox/templates/dcim/consoleport_edit.html b/netbox/templates/dcim/consoleport_edit.html deleted file mode 100644 index ebec708b0..000000000 --- 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 21871c477..000000000 --- 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/device_bulk_add_component.html b/netbox/templates/dcim/device_bulk_add_component.html new file mode 100644 index 000000000..60d42484c --- /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/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 b1a1d4334..f678877a1 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/device_import.html b/netbox/templates/dcim/device_import.html index 5220e6c2d..a603ab4ef 100644 --- a/netbox/templates/dcim/device_import.html +++ b/netbox/templates/dcim/device_import.html @@ -78,7 +78,7 @@ 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/devicebay_edit.html b/netbox/templates/dcim/devicebay_edit.html deleted file mode 100644 index 507cf0eaf..000000000 --- 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/inc/_ipaddress.html b/netbox/templates/dcim/inc/_ipaddress.html index 3f805b611..7bdc8bc1e 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/_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/inc/device_table.html b/netbox/templates/dcim/inc/device_table.html index 480bbc933..08344706e 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 3d56dc165..000000000 --- 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 %} diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html deleted file mode 100644 index 098199184..000000000 --- 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/ipaddress_assign.html b/netbox/templates/dcim/ipaddress_assign.html index 212a37458..538023d08 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 %}
diff --git a/netbox/templates/dcim/poweroutlet_edit.html b/netbox/templates/dcim/poweroutlet_edit.html deleted file mode 100644 index 9e83a9b53..000000000 --- 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 4eeb940b4..000000000 --- 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 %} diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 4c2aef15d..af457a21d 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -122,7 +122,7 @@ Height - {{ rack.u_height }}U + {{ rack.u_height }}U ({% if rack.desc_units %}descending{% else %}ascending{% endif %}) Devices @@ -189,13 +189,13 @@

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 c2066afcd..dd4f610c3 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 c5775cffd..807bff8eb 100644 --- a/netbox/templates/dcim/rack_import.html +++ b/netbox/templates/dcim/rack_import.html @@ -73,10 +73,15 @@ 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 %} diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index 7524b00fe..2392e462b 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -76,6 +76,12 @@ {% endif %} + + Status + + {{ ipaddress.get_status_display }} + + Description @@ -91,8 +97,14 @@ {% 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 000000000..4143dc3ee --- /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_bulk_edit.html b/netbox/templates/ipam/ipaddress_bulk_edit.html index 818fc20e6..7dc0f6d1a 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 eb36ff977..65d0678b7 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 %}
@@ -16,8 +17,12 @@

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

@@ -25,7 +30,13 @@
-

{{ obj.interface }}

+

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

{% endif %} diff --git a/netbox/templates/ipam/ipaddress_import.html b/netbox/templates/ipam/ipaddress_import.html index b819df494..ad62b44df 100644 --- a/netbox/templates/ipam/ipaddress_import.html +++ b/netbox/templates/ipam/ipaddress_import.html @@ -43,6 +43,11 @@ Name of tenant (optional) ABC01 + + Status + Current status + Active + Device Device name (optional) @@ -66,7 +71,7 @@

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 %} diff --git a/netbox/templates/ipam/ipaddress_unassign.html b/netbox/templates/ipam/ipaddress_unassign.html new file mode 100644 index 000000000..5cd83abb9 --- /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 %} diff --git a/netbox/users/views.py b/netbox/users/views.py index 7a9b50266..3ec385f9c 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/forms.py b/netbox/utilities/forms.py index 0a4337732..74e0749db 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -11,25 +11,51 @@ from django.utils.html import format_html 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 @@ class ExpandableNameField(forms.CharField): '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] diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 7b47e76e1..76d15c331 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -67,7 +67,7 @@ class ObjectListView(View): 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'): @@ -129,6 +129,13 @@ class ObjectEditView(View): 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 @@ class ObjectEditView(View): '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 @@ class ObjectEditView(View): 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 @@ class ObjectDeleteView(View): 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 @@ class ObjectDeleteView(View): '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 @@ class ObjectDeleteView(View): 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), }) @@ -347,7 +368,7 @@ class BulkEditView(View): 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, { @@ -460,7 +481,7 @@ class BulkDeleteView(View): 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, {