mirror of
https://github.com/netbox-community/netbox.git
synced 2024-05-10 07:54:54 +00:00
Merge branch 'develop' into reorganized-changelog
This commit is contained in:
@ -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:
|
||||
|
@ -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
|
||||
|
||||
---
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
|
||||
|
@ -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']) {
|
||||
|
@ -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)
|
||||
|
||||
|
||||
|
Reference in New Issue
Block a user