Jelajahi Sumber

Merge branch 'develop' into develop-2.9

Jeremy Stretch 5 tahun lalu
induk
melakukan
e917535380

+ 15 - 0
docs/api/examples.md

@@ -145,3 +145,18 @@ $ curl -v -X DELETE -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f
 ```
 
 The response to a successful `DELETE` request will have code 204 (No Content); the body of the response will be empty.
+
+
+## Bulk Object Creation
+
+The REST API supports the creation of multiple objects of the same type using a single `POST` request. For example, to create multiple devices:
+
+```
+curl -X POST -H "Authorization: Token <TOKEN>" -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/dcim/devices/ --data '[
+{"name": "device1", "device_type": 24, "device_role": 17, "site": 6},
+{"name": "device2", "device_type": 24, "device_role": 17, "site": 6},
+{"name": "device3", "device_type": 24, "device_role": 17, "site": 6},
+]'
+```
+
+Bulk creation is all-or-none: If any of the creations fails, the entire operation is rolled back. A successful response returns an HTTP code 201 and the body of the response will be a list/array of the objects created.

+ 8 - 0
docs/configuration/optional-settings.md

@@ -13,6 +13,14 @@ ADMINS = [
 
 ---
 
+## ALLOWED_URL_SCHEMES
+
+Default: `('file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp')`
+
+A list of permitted URL schemes referenced when rendering links within NetBox. Note that only the schemes specified in this list will be accepted: If adding your own, be sure to replicate the entire default list as well (excluding those schemes which are not desirable).
+
+---
+
 ## BANNER_TOP
 
 ## BANNER_BOTTOM

+ 12 - 1
docs/release-notes/version-2.8.md

@@ -1,16 +1,27 @@
 # NetBox v2.8
 
-## v2.8.6 (FUTURE)
+## v2.8.6 (2020-06-15)
 
 ### Enhancements
 
 * [#4698](https://github.com/netbox-community/netbox/issues/4698) - Improve display of template code for object in admin UI
+* [#4717](https://github.com/netbox-community/netbox/issues/4717) - Introduce `ALLOWED_URL_SCHEMES` configuration parameter to mitigate dangerous hyperlinks
+* [#4744](https://github.com/netbox-community/netbox/issues/4744) - Hide "IP addresses" tab when viewing a container prefix
+* [#4755](https://github.com/netbox-community/netbox/issues/4755) - Enable creation of rack reservations directly from navigation menu
+* [#4761](https://github.com/netbox-community/netbox/issues/4761) - Enable tag assignment during bulk creation of IP addresses
 
 ### Bug Fixes
 
+* [#4674](https://github.com/netbox-community/netbox/issues/4674) - Fix API definition for available prefix and IP address endpoints
 * [#4702](https://github.com/netbox-community/netbox/issues/4702) - Catch IntegrityError exception when adding a non-unique secret
 * [#4707](https://github.com/netbox-community/netbox/issues/4707) - Fix `prefix_count` population on VLAN API serializer
+* [#4710](https://github.com/netbox-community/netbox/issues/4710) - Fix merging of form fields among custom scripts
 * [#4725](https://github.com/netbox-community/netbox/issues/4725) - Fix "brief" rendering of various REST API endpoints
+* [#4736](https://github.com/netbox-community/netbox/issues/4736) - Add cable trace endpoints for pass-through ports
+* [#4737](https://github.com/netbox-community/netbox/issues/4737) - Fix display of role labels in virtual machines table
+* [#4743](https://github.com/netbox-community/netbox/issues/4743) - Allow users to create "next available" IPs without needing permission to create prefixes
+* [#4756](https://github.com/netbox-community/netbox/issues/4756) - Filter parent group by site when creating rack groups
+* [#4760](https://github.com/netbox-community/netbox/issues/4760) - Enable power port template assignment when bulk editing power outlet templates
 
 ---
 

+ 2 - 2
netbox/dcim/api/views.py

@@ -502,13 +502,13 @@ class InterfaceViewSet(CableTraceMixin, ModelViewSet):
         return Response(serializer.data)
 
 
-class FrontPortViewSet(ModelViewSet):
+class FrontPortViewSet(CableTraceMixin, ModelViewSet):
     queryset = FrontPort.objects.prefetch_related('device__device_type__manufacturer', 'rear_port', 'cable', 'tags')
     serializer_class = serializers.FrontPortSerializer
     filterset_class = filters.FrontPortFilterSet
 
 
-class RearPortViewSet(ModelViewSet):
+class RearPortViewSet(CableTraceMixin, ModelViewSet):
     queryset = RearPort.objects.prefetch_related('device__device_type__manufacturer', 'cable', 'tags')
     serializer_class = serializers.RearPortSerializer
     filterset_class = filters.RearPortFilterSet

+ 54 - 34
netbox/dcim/forms.py

@@ -21,11 +21,11 @@ from ipam.models import IPAddress, VLAN
 from tenancy.forms import TenancyFilterForm, TenancyForm
 from tenancy.models import Tenant, TenantGroup
 from utilities.forms import (
-    APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm,
-    BOOLEAN_WITH_BLANK_CHOICES, BulkEditNullBooleanSelect, ColorSelect, CommentField, ConfirmationForm, CSVChoiceField,
-    CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField,
-    form_from_model, JSONField, LabeledComponentForm, SelectWithPK, SmallTextarea, SlugField, StaticSelect2,
-    StaticSelect2Multiple, TagFilterField,
+    APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
+    ColorSelect, CommentField, ConfirmationForm, CSVChoiceField, CSVModelChoiceField, CSVModelForm,
+    DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField,
+    LabeledComponentForm, NumericArrayField, SelectWithPK, SmallTextarea, SlugField, StaticSelect2,
+    StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
 )
 from virtualization.models import Cluster, ClusterGroup, VirtualMachine
 from .choices import *
@@ -363,7 +363,12 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
 
 class RackGroupForm(BootstrapMixin, forms.ModelForm):
     site = DynamicModelChoiceField(
-        queryset=Site.objects.all()
+        queryset=Site.objects.all(),
+        widget=APISelect(
+            filter_for={
+                'parent': 'site_id',
+            }
+        )
     )
     parent = DynamicModelChoiceField(
         queryset=RackGroup.objects.all(),
@@ -729,21 +734,32 @@ class RackElevationFilterForm(RackFilterForm):
 #
 
 class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm):
-    rack = forms.ModelChoiceField(
-        queryset=Rack.objects.all(),
+    site = DynamicModelChoiceField(
+        queryset=Site.objects.all(),
         required=False,
-        widget=forms.HiddenInput()
+        widget=APISelect(
+            filter_for={
+                'rack_group': 'site_id',
+                'rack': 'site_id',
+            }
+        )
     )
-    # TODO: Change this to an API-backed form field. We can't do this currently because we want to retain
-    # the multi-line <select> widget for easy selection of multiple rack units.
-    units = SimpleArrayField(
-        base_field=forms.IntegerField(),
-        widget=ArrayFieldSelectMultiple(
-            attrs={
-                'size': 10,
+    rack_group = DynamicModelChoiceField(
+        queryset=RackGroup.objects.all(),
+        required=False,
+        widget=APISelect(
+            filter_for={
+                'rack': 'group_id'
             }
         )
     )
+    rack = DynamicModelChoiceField(
+        queryset=Rack.objects.all()
+    )
+    units = NumericArrayField(
+        base_field=forms.IntegerField(),
+        help_text="Comma-separated list of numeric unit IDs. A range may be specified using a hyphen."
+    )
     user = forms.ModelChoiceField(
         queryset=User.objects.order_by(
             'username'
@@ -760,23 +776,6 @@ class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm):
             'rack', 'units', 'user', 'tenant_group', 'tenant', 'description', 'tags',
         ]
 
-    def __init__(self, *args, **kwargs):
-
-        super().__init__(*args, **kwargs)
-
-        # Populate rack unit choices
-        if hasattr(self.instance, 'rack'):
-            self.fields['units'].widget.choices = self._get_unit_choices()
-
-    def _get_unit_choices(self):
-        rack = self.instance.rack
-        reserved_units = []
-        for resv in rack.reservations.exclude(pk=self.instance.pk):
-            for u in resv.units:
-                reserved_units.append(u)
-        unit_choices = [(u, {'label': str(u), 'disabled': u in reserved_units}) for u in rack.units]
-        return unit_choices
-
 
 class RackReservationCSVForm(CSVModelForm):
     site = CSVModelChoiceField(
@@ -1220,11 +1219,21 @@ class PowerOutletTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
         queryset=PowerOutletTemplate.objects.all(),
         widget=forms.MultipleHiddenInput()
     )
+    device_type = forms.ModelChoiceField(
+        queryset=DeviceType.objects.all(),
+        required=False,
+        disabled=True,
+        widget=forms.HiddenInput()
+    )
     type = forms.ChoiceField(
         choices=add_blank_choice(PowerOutletTypeChoices),
         required=False,
         widget=StaticSelect2()
     )
+    power_port = forms.ModelChoiceField(
+        queryset=PowerPortTemplate.objects.all(),
+        required=False
+    )
     feed_leg = forms.ChoiceField(
         choices=add_blank_choice(PowerOutletFeedLegChoices),
         required=False,
@@ -1232,7 +1241,18 @@ class PowerOutletTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
     )
 
     class Meta:
-        nullable_fields = ('type', 'feed_leg')
+        nullable_fields = ('type', 'power_port', 'feed_leg')
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Limit power_port queryset to PowerPortTemplates which belong to the parent DeviceType
+        if 'device_type' in self.initial:
+            device_type = DeviceType.objects.filter(pk=self.initial['device_type']).first()
+            self.fields['power_port'].queryset = PowerPortTemplate.objects.filter(device_type=device_type)
+        else:
+            self.fields['power_port'].choices = ()
+            self.fields['power_port'].widget.attrs['disabled'] = True
 
 
 class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):

+ 3 - 20
netbox/dcim/tables.py

@@ -2,7 +2,7 @@ import django_tables2 as tables
 from django_tables2.utils import Accessor
 
 from tenancy.tables import COL_TENANT
-from utilities.tables import BaseTable, BooleanColumn, ColorColumn, TagColumn, ToggleColumn
+from utilities.tables import BaseTable, BooleanColumn, ColorColumn, ColoredLabelColumn, TagColumn, ToggleColumn
 from .models import (
     Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
@@ -72,15 +72,6 @@ RACKROLE_ACTIONS = """
 {% endif %}
 """
 
-RACK_ROLE = """
-{% if record.role %}
-    {% load helpers %}
-    <label class="label" style="color: {{ record.role.color|fgcolor }}; background-color: #{{ record.role.color }}">{{ value }}</label>
-{% else %}
-    &mdash;
-{% endif %}
-"""
-
 RACK_DEVICE_COUNT = """
 <a href="{% url 'dcim:device_list' %}?rack_id={{ record.pk }}">{{ value }}</a>
 """
@@ -137,11 +128,6 @@ PLATFORM_ACTIONS = """
 {% endif %}
 """
 
-DEVICE_ROLE = """
-{% load helpers %}
-<label class="label" style="color: {{ record.device_role.color|fgcolor }}; background-color: #{{ record.device_role.color }}">{{ value }}</label>
-"""
-
 STATUS_LABEL = """
 <span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
 """
@@ -325,9 +311,7 @@ class RackTable(BaseTable):
     status = tables.TemplateColumn(
         template_code=STATUS_LABEL
     )
-    role = tables.TemplateColumn(
-        template_code=RACK_ROLE
-    )
+    role = ColoredLabelColumn()
     u_height = tables.TemplateColumn(
         template_code="{{ record.u_height }}U",
         verbose_name='Height'
@@ -810,8 +794,7 @@ class DeviceTable(BaseTable):
         viewname='dcim:rack',
         args=[Accessor('rack.pk')]
     )
-    device_role = tables.TemplateColumn(
-        template_code=DEVICE_ROLE,
+    device_role = ColoredLabelColumn(
         verbose_name='Role'
     )
     device_type = tables.LinkColumn(

+ 194 - 2
netbox/dcim/tests/test_api.py

@@ -8,9 +8,9 @@ from dcim.choices import *
 from dcim.constants import *
 from dcim.models import (
     Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
-    DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, Interface, InterfaceTemplate, Manufacturer,
+    DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, Manufacturer,
     InventoryItem, Platform, PowerFeed, PowerPort, PowerPortTemplate, PowerOutlet, PowerOutletTemplate, PowerPanel,
-    Rack, RackGroup, RackReservation, RackRole, RearPort, Region, Site, VirtualChassis,
+    Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis,
 )
 from ipam.models import VLAN
 from extras.models import Graph
@@ -566,6 +566,111 @@ class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase):
         ]
 
 
+class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase):
+    model = FrontPortTemplate
+    brief_fields = ['id', 'name', 'url']
+
+    @classmethod
+    def setUpTestData(cls):
+        manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
+        devicetype = DeviceType.objects.create(
+            manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
+        )
+
+        rear_port_templates = (
+            RearPortTemplate(device_type=devicetype, name='Rear Port Template 1', type=PortTypeChoices.TYPE_8P8C),
+            RearPortTemplate(device_type=devicetype, name='Rear Port Template 2', type=PortTypeChoices.TYPE_8P8C),
+            RearPortTemplate(device_type=devicetype, name='Rear Port Template 3', type=PortTypeChoices.TYPE_8P8C),
+            RearPortTemplate(device_type=devicetype, name='Rear Port Template 4', type=PortTypeChoices.TYPE_8P8C),
+            RearPortTemplate(device_type=devicetype, name='Rear Port Template 5', type=PortTypeChoices.TYPE_8P8C),
+            RearPortTemplate(device_type=devicetype, name='Rear Port Template 6', type=PortTypeChoices.TYPE_8P8C),
+        )
+        RearPortTemplate.objects.bulk_create(rear_port_templates)
+
+        front_port_templates = (
+            FrontPortTemplate(
+                device_type=devicetype,
+                name='Front Port Template 1',
+                type=PortTypeChoices.TYPE_8P8C,
+                rear_port=rear_port_templates[0]
+            ),
+            FrontPortTemplate(
+                device_type=devicetype,
+                name='Front Port Template 2',
+                type=PortTypeChoices.TYPE_8P8C,
+                rear_port=rear_port_templates[1]
+            ),
+            FrontPortTemplate(
+                device_type=devicetype,
+                name='Front Port Template 3',
+                type=PortTypeChoices.TYPE_8P8C,
+                rear_port=rear_port_templates[2]
+            ),
+        )
+        FrontPortTemplate.objects.bulk_create(front_port_templates)
+
+        cls.create_data = [
+            {
+                'device_type': devicetype.pk,
+                'name': 'Front Port Template 4',
+                'type': PortTypeChoices.TYPE_8P8C,
+                'rear_port': rear_port_templates[3].pk,
+                'position': 1,
+            },
+            {
+                'device_type': devicetype.pk,
+                'name': 'Front Port Template 5',
+                'type': PortTypeChoices.TYPE_8P8C,
+                'rear_port': rear_port_templates[4].pk,
+                'position': 1,
+            },
+            {
+                'device_type': devicetype.pk,
+                'name': 'Front Port Template 6',
+                'type': PortTypeChoices.TYPE_8P8C,
+                'rear_port': rear_port_templates[5].pk,
+                'position': 1,
+            },
+        ]
+
+
+class RearPortTemplateTest(APIViewTestCases.APIViewTestCase):
+    model = RearPortTemplate
+    brief_fields = ['id', 'name', 'url']
+
+    @classmethod
+    def setUpTestData(cls):
+        manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
+        devicetype = DeviceType.objects.create(
+            manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
+        )
+
+        rear_port_templates = (
+            RearPortTemplate(device_type=devicetype, name='Rear Port Template 1', type=PortTypeChoices.TYPE_8P8C),
+            RearPortTemplate(device_type=devicetype, name='Rear Port Template 2', type=PortTypeChoices.TYPE_8P8C),
+            RearPortTemplate(device_type=devicetype, name='Rear Port Template 3', type=PortTypeChoices.TYPE_8P8C),
+        )
+        RearPortTemplate.objects.bulk_create(rear_port_templates)
+
+        cls.create_data = [
+            {
+                'device_type': devicetype.pk,
+                'name': 'Rear Port Template 4',
+                'type': PortTypeChoices.TYPE_8P8C,
+            },
+            {
+                'device_type': devicetype.pk,
+                'name': 'Rear Port Template 5',
+                'type': PortTypeChoices.TYPE_8P8C,
+            },
+            {
+                'device_type': devicetype.pk,
+                'name': 'Rear Port Template 6',
+                'type': PortTypeChoices.TYPE_8P8C,
+            },
+        ]
+
+
 class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase):
     model = DeviceBayTemplate
     brief_fields = ['id', 'name', 'url']
@@ -1146,6 +1251,35 @@ class InterfaceTest(APIViewTestCases.APIViewTestCase):
         self.assertEqual(len(response.data), 3)
         self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?interface=Interface 1&foo=1')
 
+    def test_trace_interface(self):
+        """
+        Test tracing an Interface cable.
+        """
+        interface_a = Interface.objects.first()
+        peer_device = Device.objects.create(
+            site=Site.objects.first(),
+            device_type=DeviceType.objects.first(),
+            device_role=DeviceRole.objects.first(),
+            name='Peer Device'
+        )
+        interface_b = Interface.objects.create(
+            device=peer_device,
+            name='Interface X'
+        )
+        cable = Cable(termination_a=interface_a, termination_b=interface_b, label='Cable 1')
+        cable.save()
+
+        self.add_permissions('dcim.view_interface')
+        url = reverse('dcim-api:interface-trace', kwargs={'pk': interface_a.pk})
+        response = self.client.get(url, **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(len(response.data), 1)
+        segment1 = response.data[0]
+        self.assertEqual(segment1[0]['name'], interface_a.name)
+        self.assertEqual(segment1[1]['label'], cable.label)
+        self.assertEqual(segment1[2]['name'], interface_b.name)
+
 
 class FrontPortTest(APIViewTestCases.APIViewTestCase):
     model = FrontPort
@@ -1200,6 +1334,35 @@ class FrontPortTest(APIViewTestCases.APIViewTestCase):
             },
         ]
 
+    def test_trace_frontport(self):
+        """
+        Test tracing a FrontPort cable.
+        """
+        frontport = FrontPort.objects.first()
+        peer_device = Device.objects.create(
+            site=Site.objects.first(),
+            device_type=DeviceType.objects.first(),
+            device_role=DeviceRole.objects.first(),
+            name='Peer Device'
+        )
+        interface = Interface.objects.create(
+            device=peer_device,
+            name='Interface X'
+        )
+        cable = Cable(termination_a=frontport, termination_b=interface, label='Cable 1')
+        cable.save()
+
+        self.add_permissions('dcim.view_frontport')
+        url = reverse('dcim-api:frontport-trace', kwargs={'pk': frontport.pk})
+        response = self.client.get(url, **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(len(response.data), 1)
+        segment1 = response.data[0]
+        self.assertEqual(segment1[0]['name'], frontport.name)
+        self.assertEqual(segment1[1]['label'], cable.label)
+        self.assertEqual(segment1[2]['name'], interface.name)
+
 
 class RearPortTest(APIViewTestCases.APIViewTestCase):
     model = RearPort
@@ -1238,6 +1401,35 @@ class RearPortTest(APIViewTestCases.APIViewTestCase):
             },
         ]
 
+    def test_trace_rearport(self):
+        """
+        Test tracing a RearPort cable.
+        """
+        rearport = RearPort.objects.first()
+        peer_device = Device.objects.create(
+            site=Site.objects.first(),
+            device_type=DeviceType.objects.first(),
+            device_role=DeviceRole.objects.first(),
+            name='Peer Device'
+        )
+        interface = Interface.objects.create(
+            device=peer_device,
+            name='Interface X'
+        )
+        cable = Cable(termination_a=rearport, termination_b=interface, label='Cable 1')
+        cable.save()
+
+        self.add_permissions('dcim.view_rearport')
+        url = reverse('dcim-api:rearport-trace', kwargs={'pk': rearport.pk})
+        response = self.client.get(url, **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(len(response.data), 1)
+        segment1 = response.data[0]
+        self.assertEqual(segment1[0]['name'], rearport.name)
+        self.assertEqual(segment1[1]['label'], cable.label)
+        self.assertEqual(segment1[2]['name'], interface.name)
+
 
 class DeviceBayTest(APIViewTestCases.APIViewTestCase):
     model = DeviceBay

+ 1 - 1
netbox/dcim/tests/test_views.py

@@ -198,7 +198,7 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 
         cls.form_data = {
             'rack': rack.pk,
-            'units': [10, 11, 12],
+            'units': "10,11,12",
             'user': user3.pk,
             'tenant': None,
             'description': 'Rack reservation',

+ 1 - 10
netbox/extras/forms.py

@@ -430,18 +430,9 @@ class ScriptForm(BootstrapMixin, forms.Form):
         help_text="Commit changes to the database (uncheck for a dry-run)"
     )
 
-    def __init__(self, vars, *args, commit_default=True, **kwargs):
-
-        # Dynamically populate fields for variables
-        for name, var in vars.items():
-            self.base_fields[name] = var.as_field()
-
+    def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
 
-        # Toggle default commit behavior based on Meta option
-        if not commit_default:
-            self.fields['_commit'].initial = False
-
         # Move _commit to the end of the form
         commit = self.fields.pop('_commit')
         self.fields['_commit'] = commit

+ 10 - 9
netbox/extras/scripts.py

@@ -276,13 +276,6 @@ class BaseScript:
     @classmethod
     def _get_vars(cls):
         vars = OrderedDict()
-
-        # Infer order from Meta.field_order (Python 3.5 and lower)
-        field_order = getattr(cls.Meta, 'field_order', [])
-        for name in field_order:
-            vars[name] = getattr(cls, name)
-
-        # Default to order of declaration on class
         for name, attr in cls.__dict__.items():
             if name not in vars and issubclass(attr.__class__, ScriptVariable):
                 vars[name] = attr
@@ -296,8 +289,16 @@ class BaseScript:
         """
         Return a Django form suitable for populating the context data required to run this Script.
         """
-        vars = self._get_vars()
-        form = ScriptForm(vars, data, files, initial=initial, commit_default=getattr(self.Meta, 'commit_default', True))
+        # Create a dynamic ScriptForm subclass from script variables
+        fields = {
+            name: var.as_field() for name, var in self._get_vars().items()
+        }
+        FormClass = type('ScriptForm', (ScriptForm,), fields)
+
+        form = FormClass(data, files, initial=initial)
+
+        # Set initial "commit" checkbox state based on the script's Meta parameter
+        form.fields['_commit'].initial = getattr(self.Meta, 'commit_default', True)
 
         return form
 

+ 0 - 1
netbox/extras/views.py

@@ -431,7 +431,6 @@ class ScriptView(ObjectPermissionRequiredMixin, View):
             raise Http404
 
     def get(self, request, module, name):
-
         script = self._get_script(module, name)
         form = script.as_form(initial=request.GET)
 

+ 5 - 21
netbox/ipam/api/views.py

@@ -5,7 +5,6 @@ from django_pglocks import advisory_lock
 from drf_yasg.utils import swagger_auto_schema
 from rest_framework import status
 from rest_framework.decorators import action
-from rest_framework.exceptions import PermissionDenied
 from rest_framework.response import Response
 
 from extras.api.views import CustomFieldModelViewSet
@@ -74,12 +73,8 @@ class PrefixViewSet(CustomFieldModelViewSet):
     serializer_class = serializers.PrefixSerializer
     filterset_class = filters.PrefixFilterSet
 
-    @swagger_auto_schema(
-        methods=['get', 'post'],
-        responses={
-            200: serializers.AvailablePrefixSerializer(many=True),
-        }
-    )
+    @swagger_auto_schema(method='get', responses={200: serializers.AvailablePrefixSerializer(many=True)})
+    @swagger_auto_schema(method='post', responses={201: serializers.AvailablePrefixSerializer(many=True)})
     @action(detail=True, url_path='available-prefixes', methods=['get', 'post'])
     @advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes'])
     def available_prefixes(self, request, pk=None):
@@ -94,10 +89,6 @@ class PrefixViewSet(CustomFieldModelViewSet):
 
         if request.method == 'POST':
 
-            # Permissions check
-            if not request.user.has_perm('ipam.add_prefix'):
-                raise PermissionDenied()
-
             # Validate Requested Prefixes' length
             serializer = serializers.PrefixLengthSerializer(
                 data=request.data if isinstance(request.data, list) else [request.data],
@@ -158,12 +149,9 @@ class PrefixViewSet(CustomFieldModelViewSet):
 
             return Response(serializer.data)
 
-    @swagger_auto_schema(
-        methods=['get', 'post'],
-        responses={
-            200: serializers.AvailableIPSerializer(many=True),
-        }
-    )
+    @swagger_auto_schema(method='get', responses={200: serializers.AvailableIPSerializer(many=True)})
+    @swagger_auto_schema(method='post', responses={201: serializers.AvailableIPSerializer(many=True)},
+                         request_body=serializers.AvailableIPSerializer(many=False))
     @action(detail=True, url_path='available-ips', methods=['get', 'post'], queryset=IPAddress.objects.all())
     @advisory_lock(ADVISORY_LOCK_KEYS['available-ips'])
     def available_ips(self, request, pk=None):
@@ -180,10 +168,6 @@ class PrefixViewSet(CustomFieldModelViewSet):
         # Create the next available IP within the prefix
         if request.method == 'POST':
 
-            # Permissions check
-            if not request.user.has_perm('ipam.add_ipaddress'):
-                raise PermissionDenied()
-
             # Normalize to a list of objects
             requested_ips = request.data if isinstance(request.data, list) else [request.data]
 

+ 4 - 1
netbox/ipam/forms.py

@@ -681,11 +681,14 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
         required=False,
         label='VRF'
     )
+    tags = TagField(
+        required=False
+    )
 
     class Meta:
         model = IPAddress
         fields = [
-            'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'tenant_group', 'tenant',
+            'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'tenant_group', 'tenant', 'tags',
         ]
         widgets = {
             'status': StaticSelect2(),

+ 5 - 0
netbox/netbox/configuration.example.py

@@ -68,6 +68,11 @@ ADMINS = [
     # ['John Doe', 'jdoe@example.com'],
 ]
 
+# URL schemes that are allowed within links in NetBox
+ALLOWED_URL_SCHEMES = (
+    'file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp',
+)
+
 # Optionally display a persistent banner at the top and/or bottom of every page. HTML is allowed. To display the same
 # content in both banners, define BANNER_TOP and set BANNER_BOTTOM = BANNER_TOP.
 BANNER_TOP = ''

+ 3 - 0
netbox/netbox/settings.py

@@ -58,6 +58,9 @@ SECRET_KEY = getattr(configuration, 'SECRET_KEY')
 
 # Set optional parameters
 ADMINS = getattr(configuration, 'ADMINS', [])
+ALLOWED_URL_SCHEMES = getattr(configuration, 'ALLOWED_URL_SCHEMES', (
+    'file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp',
+))
 BANNER_BOTTOM = getattr(configuration, 'BANNER_BOTTOM', '')
 BANNER_LOGIN = getattr(configuration, 'BANNER_LOGIN', '')
 BANNER_TOP = getattr(configuration, 'BANNER_TOP', '')

+ 2 - 2
netbox/templates/dcim/inc/devicetype_component_table.html

@@ -9,12 +9,12 @@
             <div class="panel-footer noprint">
                 {% if table.rows %}
                     {% if edit_url %}
-                        <button type="submit" name="_edit" formaction="{% url edit_url %}?return_url={{ devicetype.get_absolute_url }}" class="btn btn-xs btn-warning">
+                        <button type="submit" name="_edit" formaction="{% url edit_url %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}" class="btn btn-xs btn-warning">
                             <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit Selected
                         </button>
                     {% endif %}
                     {% if delete_url %}
-                        <button type="submit" name="_delete" formaction="{% url delete_url %}?return_url={{ devicetype.get_absolute_url }}" class="btn btn-xs btn-danger">
+                        <button type="submit" name="_delete" formaction="{% url delete_url %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}" class="btn btn-xs btn-danger">
                             <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected
                         </button>
                     {% endif %}

+ 11 - 9
netbox/templates/dcim/rackreservation_edit.html

@@ -3,20 +3,22 @@
 
 {% block form %}
     <div class="panel panel-default">
-        <div class="panel-heading"><strong>{{ obj_type|capfirst }}</strong></div>
+        <div class="panel-heading"><strong>Rack Reservation</strong></div>
         <div class="panel-body">
-            <div class="form-group">
-                <label class="col-md-3 control-label">Rack</label>
-                <div class="col-md-9">
-                    <p class="form-control-static">{{ obj.rack }}</p>
-                </div>
-            </div>
+            {% render_field form.site %}
+            {% render_field form.rack_group %}
+            {% render_field form.rack %}
             {% render_field form.units %}
             {% render_field form.user %}
-            {% render_field form.tenant_group %}
-            {% render_field form.tenant %}
             {% render_field form.description %}
             {% render_field form.tags %}
         </div>
     </div>
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Tenant Assignment</strong></div>
+        <div class="panel-body">
+            {% render_field form.tenant_group %}
+            {% render_field form.tenant %}
+        </div>
+    </div>
 {% endblock %}

+ 1 - 0
netbox/templates/inc/nav_menu.html

@@ -70,6 +70,7 @@
                         <li{% if not perms.dcim.view_rackreservation %} class="disabled"{% endif %}>
                             {% if perms.dcim.add_rackreservation %}
                                 <div class="buttons pull-right">
+                                    <a href="{% url 'dcim:rackreservation_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
                                     <a href="{% url 'dcim:rackreservation_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
                                 </div>
                             {% endif %}

+ 6 - 0
netbox/templates/ipam/ipaddress_bulk_add.html

@@ -26,6 +26,12 @@
             {% render_field model_form.tenant %}
         </div>
     </div>
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Tags</strong></div>
+        <div class="panel-body">
+            {% render_field model_form.tags %}
+        </div>
+    </div>
     {% if model_form.custom_fields %}
         <div class="panel panel-default">
             <div class="panel-heading"><strong>Custom Fields</strong></div>

+ 1 - 1
netbox/templates/ipam/prefix.html

@@ -64,7 +64,7 @@
         <li role="presentation"{% if active_tab == 'prefixes' %} class="active"{% endif %}>
             <a href="{% url 'ipam:prefix_prefixes' pk=prefix.pk %}">Child Prefixes <span class="badge">{{ prefix.get_child_prefixes.count }}</span></a>
         </li>
-        {% if perms.ipam.view_ipaddress %}
+        {% if perms.ipam.view_ipaddress and prefix.status != 'container' %}
             <li role="presentation"{% if active_tab == 'ip-addresses' %} class="active"{% endif %}>
                 <a href="{% url 'ipam:prefix_ipaddresses' pk=prefix.pk %}">IP Addresses <span class="badge">{{ prefix.get_child_ips.count }}</span></a>
             </li>

+ 7 - 20
netbox/utilities/forms.py

@@ -7,6 +7,7 @@ import django_filters
 import yaml
 from django import forms
 from django.conf import settings
+from django.contrib.postgres.forms import SimpleArrayField
 from django.contrib.postgres.forms.jsonb import JSONField as _JSONField, InvalidJSONInput
 from django.core.exceptions import MultipleObjectsReturned
 from django.db.models import Count
@@ -243,24 +244,11 @@ class ContentTypeSelect(StaticSelect2):
     option_template_name = 'widgets/select_contenttype.html'
 
 
-class ArrayFieldSelectMultiple(SelectWithDisabled, forms.SelectMultiple):
-    """
-    MultiSelect widget for a SimpleArrayField. Choices must be populated on the widget.
-    """
-    def __init__(self, *args, **kwargs):
-        self.delimiter = kwargs.pop('delimiter', ',')
-        super().__init__(*args, **kwargs)
-
-    def optgroups(self, name, value, attrs=None):
-        # Split the delimited string of values into a list
-        if value:
-            value = value[0].split(self.delimiter)
-        return super().optgroups(name, value, attrs)
+class NumericArrayField(SimpleArrayField):
 
-    def value_from_datadict(self, data, files, name):
-        # Condense the list of selected choices into a delimited string
-        data = super().value_from_datadict(data, files, name)
-        return self.delimiter.join(data)
+    def to_python(self, value):
+        value = ','.join([str(n) for n in parse_numeric_range(value)])
+        return super().to_python(value)
 
 
 class APISelect(SelectWithDisabled):
@@ -661,9 +649,8 @@ class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultip
 
 class LaxURLField(forms.URLField):
     """
-    Modifies Django's built-in URLField in two ways:
-      1) Allow any valid scheme per RFC 3986 section 3.1
-      2) Remove the requirement for fully-qualified domain names (e.g. http://myserver/ is valid)
+    Modifies Django's built-in URLField to remove the requirement for fully-qualified domain names
+    (e.g. http://myserver/ is valid)
     """
     default_validators = [EnhancedURLValidator()]
 

+ 17 - 0
netbox/utilities/tables.py

@@ -84,6 +84,10 @@ class BaseTable(tables.Table):
         return [name for name in self.sequence if self.columns[name].visible]
 
 
+#
+# Table columns
+#
+
 class ToggleColumn(tables.CheckBoxColumn):
     """
     Extend CheckBoxColumn to add a "toggle all" checkbox in the column header.
@@ -129,6 +133,19 @@ class ColorColumn(tables.Column):
         )
 
 
+class ColoredLabelColumn(tables.TemplateColumn):
+    """
+    Render a colored label (e.g. for DeviceRoles).
+    """
+    template_code = """
+    {% load helpers %}
+    {% if value %}<label class="label" style="color: {{ value.color|fgcolor }}; background-color: #{{ value.color }}">{{ value }}</label>{% else %}&mdash;{% endif %}
+    """
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(template_code=self.template_code, *args, **kwargs)
+
+
 class TagColumn(tables.TemplateColumn):
     """
     Display a list of tags assigned to the object.

+ 5 - 1
netbox/utilities/templatetags/helpers.py

@@ -10,7 +10,6 @@ from django.utils.html import strip_tags
 from django.utils.safestring import mark_safe
 from markdown import markdown
 
-from utilities.choices import unpack_grouped_choices
 from utilities.utils import foreground_color
 
 register = template.Library()
@@ -39,6 +38,11 @@ def render_markdown(value):
     # Strip HTML tags
     value = strip_tags(value)
 
+    # Sanitize Markdown links
+    schemes = '|'.join(settings.ALLOWED_URL_SCHEMES)
+    pattern = fr'\[(.+)\]\((?!({schemes})).*:(.+)\)'
+    value = re.sub(pattern, '[\\1](\\3)', value, flags=re.IGNORECASE)
+
     # Render Markdown
     html = markdown(value, extensions=['fenced_code', 'tables'])
 

+ 7 - 1
netbox/utilities/testing/views.py

@@ -1,5 +1,6 @@
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.models import ContentType
+from django.contrib.postgres.fields import ArrayField
 from django.core.exceptions import ObjectDoesNotExist
 from django.db.models import ForeignKey, ManyToManyField
 from django.forms.models import model_to_dict
@@ -64,7 +65,6 @@ class TestCase(_TestCase):
         """
         Compare a model instance to a dictionary, checking that its attribute values match those specified
         in the dictionary.
-
         :instance: Python object instance
         :data: Dictionary of test data used to define the instance
         :api: Set to True is the data is a JSON representation of the instance
@@ -95,6 +95,12 @@ class TestCase(_TestCase):
                 elif type(value) is IPNetwork:
                     model_dict[key] = str(value)
 
+            else:
+
+                # Convert ArrayFields to CSV strings
+                if type(instance._meta.get_field(key)) is ArrayField:
+                    model_dict[key] = ','.join([str(v) for v in value])
+
         # Omit any dictionary keys which are not instance attributes
         relevant_data = {
             k: v for k, v in data.items() if hasattr(instance, k)

+ 5 - 12
netbox/utilities/validators.py

@@ -1,31 +1,24 @@
 import re
 
+from django.conf import settings
 from django.core.validators import _lazy_re_compile, BaseValidator, URLValidator
 
 
 class EnhancedURLValidator(URLValidator):
     """
-    Extends Django's built-in URLValidator to permit the use of hostnames with no domain extension.
+    Extends Django's built-in URLValidator to permit the use of hostnames with no domain extension and enforce allowed
+    schemes specified in the configuration.
     """
-    class AnyURLScheme(object):
-        """
-        A fake URL list which "contains" all scheme names abiding by the syntax defined in RFC 3986 section 3.1
-        """
-        def __contains__(self, item):
-            if not item or not re.match(r'^[a-z][0-9a-z+\-.]*$', item.lower()):
-                return False
-            return True
-
     fqdn_re = URLValidator.hostname_re + URLValidator.domain_re + URLValidator.tld_re
     host_res = [URLValidator.ipv4_re, URLValidator.ipv6_re, fqdn_re, URLValidator.hostname_re]
     regex = _lazy_re_compile(
-        r'^(?:[a-z0-9\.\-\+]*)://'          # Scheme (previously enforced by AnyURLScheme or schemes kwarg)
+        r'^(?:[a-z0-9\.\-\+]*)://'          # Scheme (enforced separately)
         r'(?:\S+(?::\S*)?@)?'               # HTTP basic authentication
         r'(?:' + '|'.join(host_res) + ')'   # IPv4, IPv6, FQDN, or hostname
         r'(?::\d{2,5})?'                    # Port number
         r'(?:[/?#][^\s]*)?'                 # Path
         r'\Z', re.IGNORECASE)
-    schemes = AnyURLScheme()
+    schemes = settings.ALLOWED_URL_SCHEMES
 
 
 class ExclusionValidator(BaseValidator):

+ 2 - 0
netbox/utilities/views.py

@@ -944,6 +944,8 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
             # TODO: Find a better way to accomplish this
             if 'device' in request.GET:
                 initial_data['device'] = request.GET.get('device')
+            elif 'device_type' in request.GET:
+                initial_data['device_type'] = request.GET.get('device_type')
 
             form = self.form(model, initial=initial_data)
 

+ 2 - 8
netbox/virtualization/tables.py

@@ -3,7 +3,7 @@ from django_tables2.utils import Accessor
 
 from dcim.models import Interface
 from tenancy.tables import COL_TENANT
-from utilities.tables import BaseTable, TagColumn, ToggleColumn
+from utilities.tables import BaseTable, ColoredLabelColumn, TagColumn, ToggleColumn
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 
 CLUSTERTYPE_ACTIONS = """
@@ -28,10 +28,6 @@ VIRTUALMACHINE_STATUS = """
 <span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
 """
 
-VIRTUALMACHINE_ROLE = """
-{% if record.role %}<label class="label" style="background-color: #{{ record.role.color }}">{{ value }}</label>{% else %}&mdash;{% endif %}
-"""
-
 VIRTUALMACHINE_PRIMARY_IP = """
 {{ record.primary_ip6.address.ip|default:"" }}
 {% if record.primary_ip6 and record.primary_ip4 %}<br />{% endif %}
@@ -132,9 +128,7 @@ class VirtualMachineTable(BaseTable):
         viewname='virtualization:cluster',
         args=[Accessor('cluster.pk')]
     )
-    role = tables.TemplateColumn(
-        template_code=VIRTUALMACHINE_ROLE
-    )
+    role = ColoredLabelColumn()
     tenant = tables.TemplateColumn(
         template_code=COL_TENANT
     )