Просмотр исходного кода

Merge branch 'develop' into reorganized-changelog

Jeremy Stretch 6 лет назад
Родитель
Сommit
521d6941fa

+ 8 - 0
docs/additional-features/custom-scripts.md

@@ -63,6 +63,14 @@ A list of field names indicating the order in which the form fields should appea
 field_order = ['var1', 'var2', 'var3']
 ```
 
+### `commit_default`
+
+The checkbox to commit database changes when executing a script is checked by default. Set `commit_default` to False under the script's Meta class to leave this option unchecked by default.
+
+```
+commit_default = False
+```
+
 ## Reading Data from Files
 
 The Script class provides two convenience methods for reading data from files:

+ 7 - 0
docs/release-notes/version-2.6.md

@@ -2,17 +2,24 @@
 
 ## Bug Fixes
 
+* [#3458](https://github.com/netbox-community/netbox/issues/3458) - Prevent primary IP address for a device/VM from being reassigned
+* [#3463](https://github.com/netbox-community/netbox/issues/3463) - Correct CSV headers for exported power feeds
+* [#3474](https://github.com/netbox-community/netbox/issues/3474) - Fix device status page loading when NAPALM call fails
 * [#3571](https://github.com/netbox-community/netbox/issues/3571) - Prevent erroneous redirects when editing tags
 * [#3573](https://github.com/netbox-community/netbox/issues/3573) - Ensure consistent display of changelog retention period
 * [#3574](https://github.com/netbox-community/netbox/issues/3574) - Change `device` to `parent` in interface editing VLAN filtering logic
 * [#3575](https://github.com/netbox-community/netbox/issues/3575) - Restore label for comments field when bulk editing circuits
+* [#3582](https://github.com/netbox-community/netbox/issues/3582) - Enforce view permissions on global search results
 
 ## Enhancements
 
 * [#1941](https://github.com/netbox-community/netbox/issues/1941) - Add InfiniBand interface types
 * [#3259](https://github.com/netbox-community/netbox/issues/3259) - Add `rack` and `site` filters for cables
+* [#3471](https://github.com/netbox-community/netbox/issues/3471) - Disallow raw HTML in Markdown-rendered fields
+* [#3545](https://github.com/netbox-community/netbox/issues/3545) - Add `MultiObjectVar` for custom scripts
 * [#3563](https://github.com/netbox-community/netbox/issues/3563) - Enable editing of individual DeviceType components
 * [#3580](https://github.com/netbox-community/netbox/issues/3580) - Render text and URL fields as textareas in the custom link form
+* [#3581](https://github.com/netbox-community/netbox/issues/3581) - Introduce `commit_default` custom script attribute to not commit changes by default
 
 ---
 

+ 1 - 0
netbox/dcim/models.py

@@ -3108,6 +3108,7 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
 
     def to_csv(self):
         return (
+            self.power_panel.site.name,
             self.power_panel.name,
             self.rack.name if self.rack else None,
             self.name,

+ 5 - 1
netbox/extras/forms.py

@@ -427,7 +427,7 @@ class ScriptForm(BootstrapMixin, forms.Form):
         help_text="Commit changes to the database (uncheck for a dry-run)"
     )
 
-    def __init__(self, vars, *args, **kwargs):
+    def __init__(self, vars, *args, commit_default=True, **kwargs):
 
         super().__init__(*args, **kwargs)
 
@@ -435,6 +435,10 @@ class ScriptForm(BootstrapMixin, forms.Form):
         for name, var in vars.items():
             self.fields[name] = var.as_field()
 
+        # 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
         self.fields.move_to_end('_commit', True)
 

+ 20 - 2
netbox/extras/scripts.py

@@ -11,7 +11,7 @@ from django import forms
 from django.conf import settings
 from django.core.validators import RegexValidator
 from django.db import transaction
-from mptt.forms import TreeNodeChoiceField
+from mptt.forms import TreeNodeChoiceField, TreeNodeMultipleChoiceField
 from mptt.models import MPTTModel
 
 from ipam.formfields import IPFormField
@@ -27,6 +27,7 @@ __all__ = [
     'FileVar',
     'IntegerVar',
     'IPNetworkVar',
+    'MultiObjectVar',
     'ObjectVar',
     'Script',
     'StringVar',
@@ -149,6 +150,23 @@ class ObjectVar(ScriptVariable):
             self.form_field = TreeNodeChoiceField
 
 
+class MultiObjectVar(ScriptVariable):
+    """
+    Like ObjectVar, but can represent one or more objects.
+    """
+    form_field = forms.ModelMultipleChoiceField
+
+    def __init__(self, queryset, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Queryset for field choices
+        self.field_attrs['queryset'] = queryset
+
+        # Update form field for MPTT (nested) objects
+        if issubclass(queryset.model, MPTTModel):
+            self.form_field = TreeNodeMultipleChoiceField
+
+
 class FileVar(ScriptVariable):
     """
     An uploaded file.
@@ -225,7 +243,7 @@ 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)
+        form = ScriptForm(vars, data, files, commit_default=getattr(self.Meta, 'commit_default', True))
 
         return form
 

+ 23 - 0
netbox/extras/tests/test_scripts.py

@@ -120,6 +120,29 @@ class ScriptVariablesTest(TestCase):
         self.assertTrue(form.is_valid())
         self.assertEqual(form.cleaned_data['var1'].pk, data['var1'])
 
+    def test_multiobjectvar(self):
+
+        class TestScript(Script):
+
+            var1 = MultiObjectVar(
+                queryset=DeviceRole.objects.all()
+            )
+
+        # Populate some objects
+        for i in range(1, 6):
+            DeviceRole(
+                name='Device Role {}'.format(i),
+                slug='device-role-{}'.format(i)
+            ).save()
+
+        # Validate valid data
+        data = {'var1': [role.pk for role in DeviceRole.objects.all()[:3]]}
+        form = TestScript().as_form(data, None)
+        self.assertTrue(form.is_valid())
+        self.assertEqual(form.cleaned_data['var1'][0].pk, data['var1'][0])
+        self.assertEqual(form.cleaned_data['var1'][1].pk, data['var1'][1])
+        self.assertEqual(form.cleaned_data['var1'][2].pk, data['var1'][2])
+
     def test_filevar(self):
 
         class TestScript(Script):

+ 30 - 1
netbox/ipam/models.py

@@ -9,10 +9,11 @@ from django.db.models.expressions import RawSQL
 from django.urls import reverse
 from taggit.managers import TaggableManager
 
-from dcim.models import Interface
+from dcim.models import Device, Interface
 from extras.models import CustomFieldModel, ObjectChange, TaggedItem
 from utilities.models import ChangeLoggedModel
 from utilities.utils import serialize_object
+from virtualization.models import VirtualMachine
 from .constants import *
 from .fields import IPNetworkField, IPAddressField
 from .querysets import PrefixQuerySet
@@ -636,6 +637,34 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
                         )
                     })
 
+        if self.pk:
+
+            # Check for primary IP assignment that doesn't match the assigned device/VM
+            device = Device.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
+            if device:
+                if self.interface is None:
+                    raise ValidationError({
+                        'interface': "IP address is primary for device {} but not assigned".format(device)
+                    })
+                elif (device.primary_ip4 == self or device.primary_ip6 == self) and self.interface.device != device:
+                    raise ValidationError({
+                        'interface': "IP address is primary for device {} but assigned to {} ({})".format(
+                            device, self.interface.device, self.interface
+                        )
+                    })
+            vm = VirtualMachine.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
+            if vm:
+                if self.interface is None:
+                    raise ValidationError({
+                        'interface': "IP address is primary for virtual machine {} but not assigned".format(vm)
+                    })
+                elif (vm.primary_ip4 == self or vm.primary_ip6 == self) and self.interface.virtual_machine != vm:
+                    raise ValidationError({
+                        'interface': "IP address is primary for virtual machine {} but assigned to {} ({})".format(
+                            vm, self.interface.virtual_machine, self.interface
+                        )
+                    })
+
     def save(self, *args, **kwargs):
 
         # Record address family

+ 31 - 3
netbox/netbox/views.py

@@ -40,43 +40,54 @@ SEARCH_MAX_RESULTS = 15
 SEARCH_TYPES = OrderedDict((
     # Circuits
     ('provider', {
+        'permission': 'circuits.view_provider',
         'queryset': Provider.objects.all(),
         'filter': ProviderFilter,
         'table': ProviderTable,
         'url': 'circuits:provider_list',
     }),
     ('circuit', {
-        'queryset': Circuit.objects.prefetch_related('type', 'provider', 'tenant').prefetch_related('terminations__site'),
+        'permission': 'circuits.view_circuit',
+        'queryset': Circuit.objects.prefetch_related(
+            'type', 'provider', 'tenant'
+        ).prefetch_related(
+            'terminations__site'
+        ),
         'filter': CircuitFilter,
         'table': CircuitTable,
         'url': 'circuits:circuit_list',
     }),
     # DCIM
     ('site', {
+        'permission': 'dcim.view_site',
         'queryset': Site.objects.prefetch_related('region', 'tenant'),
         'filter': SiteFilter,
         'table': SiteTable,
         'url': 'dcim:site_list',
     }),
     ('rack', {
+        'permission': 'dcim.view_rack',
         'queryset': Rack.objects.prefetch_related('site', 'group', 'tenant', 'role'),
         'filter': RackFilter,
         'table': RackTable,
         'url': 'dcim:rack_list',
     }),
     ('rackgroup', {
+        'permission': 'dcim.view_rackgroup',
         'queryset': RackGroup.objects.prefetch_related('site').annotate(rack_count=Count('racks')),
         'filter': RackGroupFilter,
         'table': RackGroupTable,
         'url': 'dcim:rackgroup_list',
     }),
     ('devicetype', {
+        'permission': 'dcim.view_devicetype',
         'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate(instance_count=Count('instances')),
         'filter': DeviceTypeFilter,
         'table': DeviceTypeTable,
         'url': 'dcim:devicetype_list',
     }),
     ('device', {
+        'permission': 'dcim.view_device',
         'queryset': Device.objects.prefetch_related(
             'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6',
         ),
@@ -85,18 +96,21 @@ SEARCH_TYPES = OrderedDict((
         'url': 'dcim:device_list',
     }),
     ('virtualchassis', {
+        'permission': 'dcim.view_virtualchassis',
         'queryset': VirtualChassis.objects.prefetch_related('master').annotate(member_count=Count('members')),
         'filter': VirtualChassisFilter,
         'table': VirtualChassisTable,
         'url': 'dcim:virtualchassis_list',
     }),
     ('cable', {
+        'permission': 'dcim.view_cable',
         'queryset': Cable.objects.all(),
         'filter': CableFilter,
         'table': CableTable,
         'url': 'dcim:cable_list',
     }),
     ('powerfeed', {
+        'permission': 'dcim.view_powerfeed',
         'queryset': PowerFeed.objects.all(),
         'filter': PowerFeedFilter,
         'table': PowerFeedTable,
@@ -104,30 +118,35 @@ SEARCH_TYPES = OrderedDict((
     }),
     # IPAM
     ('vrf', {
+        'permission': 'ipam.view_vrf',
         'queryset': VRF.objects.prefetch_related('tenant'),
         'filter': VRFFilter,
         'table': VRFTable,
         'url': 'ipam:vrf_list',
     }),
     ('aggregate', {
+        'permission': 'ipam.view_aggregate',
         'queryset': Aggregate.objects.prefetch_related('rir'),
         'filter': AggregateFilter,
         'table': AggregateTable,
         'url': 'ipam:aggregate_list',
     }),
     ('prefix', {
+        'permission': 'ipam.view_prefix',
         'queryset': Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role'),
         'filter': PrefixFilter,
         'table': PrefixTable,
         'url': 'ipam:prefix_list',
     }),
     ('ipaddress', {
+        'permission': 'ipam.view_ipaddress',
         'queryset': IPAddress.objects.prefetch_related('vrf__tenant', 'tenant'),
         'filter': IPAddressFilter,
         'table': IPAddressTable,
         'url': 'ipam:ipaddress_list',
     }),
     ('vlan', {
+        'permission': 'ipam.view_vlan',
         'queryset': VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role'),
         'filter': VLANFilter,
         'table': VLANTable,
@@ -135,6 +154,7 @@ SEARCH_TYPES = OrderedDict((
     }),
     # Secrets
     ('secret', {
+        'permission': 'secrets.view_secret',
         'queryset': Secret.objects.prefetch_related('role', 'device'),
         'filter': SecretFilter,
         'table': SecretTable,
@@ -142,6 +162,7 @@ SEARCH_TYPES = OrderedDict((
     }),
     # Tenancy
     ('tenant', {
+        'permission': 'tenancy.view_tenant',
         'queryset': Tenant.objects.prefetch_related('group'),
         'filter': TenantFilter,
         'table': TenantTable,
@@ -149,12 +170,14 @@ SEARCH_TYPES = OrderedDict((
     }),
     # Virtualization
     ('cluster', {
+        'permission': 'virtualization.view_cluster',
         'queryset': Cluster.objects.prefetch_related('type', 'group'),
         'filter': ClusterFilter,
         'table': ClusterTable,
         'url': 'virtualization:cluster_list',
     }),
     ('virtualmachine', {
+        'permission': 'virtualization.view_virtualmachine',
         'queryset': VirtualMachine.objects.prefetch_related(
             'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
         ),
@@ -244,11 +267,16 @@ class SearchView(View):
         if form.is_valid():
 
             # Searching for a single type of object
+            obj_types = []
             if form.cleaned_data['obj_type']:
-                obj_types = [form.cleaned_data['obj_type']]
+                obj_type = form.cleaned_data['obj_type']
+                if request.user.has_perm(SEARCH_TYPES[obj_type]['permission']):
+                    obj_types.append(form.cleaned_data['obj_type'])
             # Searching all object types
             else:
-                obj_types = SEARCH_TYPES.keys()
+                for obj_type in SEARCH_TYPES.keys():
+                    if request.user.has_perm(SEARCH_TYPES[obj_type]['permission']):
+                        obj_types.append(obj_type)
 
             for obj_type in obj_types:
 

+ 5 - 2
netbox/templates/dcim/device_status.html

@@ -94,8 +94,11 @@ $(document).ready(function() {
                     var row="<tr><td>" + name + "</td><td>" + obj['%usage'] + "%</td></tr>";
                     $("#cpu").after(row)
                 });
-                $('#memory').after("<tr><td>Used</td><td>" + json['get_environment']['memory']['used_ram'] + "</td></tr>");
-                $('#memory').after("<tr><td>Available</td><td>" + json['get_environment']['memory']['available_ram'] + "</td></tr>");
+                if (json['get_environment']['memory']) {
+                    var memory = $('#memory');
+                    memory.after("<tr><td>Used</td><td>" + json['get_environment']['memory']['used_ram'] + "</td></tr>");
+                    memory.after("<tr><td>Available</td><td>" + json['get_environment']['memory']['available_ram'] + "</td></tr>");
+                }
                 $.each(json['get_environment']['temperature'], function(name, obj) {
                     var style = "success";
                     if (obj['is_alert']) {

+ 6 - 0
netbox/utilities/templatetags/helpers.py

@@ -3,6 +3,7 @@ import json
 import re
 
 from django import template
+from django.utils.html import strip_tags
 from django.utils.safestring import mark_safe
 from markdown import markdown
 
@@ -58,7 +59,12 @@ def gfm(value):
     """
     Render text as GitHub-Flavored Markdown
     """
+    # Strip HTML tags
+    value = strip_tags(value)
+
+    # Render Markdown with GFM extension
     html = markdown(value, extensions=['mdx_gfm'])
+
     return mark_safe(html)