1
0
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:
Jeremy Stretch
2019-10-10 09:15:57 -04:00
10 changed files with 136 additions and 9 deletions

View File

@ -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:

View File

@ -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
---

View File

@ -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,

View File

@ -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)

View File

@ -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

View File

@ -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):

View File

@ -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

View File

@ -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:

View File

@ -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']) {

View File

@ -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)