mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-23 16:06:43 -06:00
Merge branch 'netbox-community:develop' into fix-creating-config-template-using-rest-api
This commit is contained in:
commit
5d9e8f3957
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.6.1
|
||||
placeholder: v3.6.2
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.6.1
|
||||
placeholder: v3.6.2
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
@ -1,6 +1,6 @@
|
||||
<div align="center">
|
||||
<img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
|
||||
<p>The premiere source of truth powering network automation</p>
|
||||
<p>The premier source of truth powering network automation</p>
|
||||
<img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master" alt="CI status" />
|
||||
<p></p>
|
||||
</div>
|
||||
|
@ -342,8 +342,10 @@
|
||||
"100gbase-x-qsfpdd",
|
||||
"200gbase-x-qsfp56",
|
||||
"200gbase-x-qsfpdd",
|
||||
"400gbase-x-qsfp112",
|
||||
"400gbase-x-qsfpdd",
|
||||
"400gbase-x-osfp",
|
||||
"400gbase-x-osfp-rhs",
|
||||
"400gbase-x-cdfp",
|
||||
"400gbase-x-cfp8",
|
||||
"800gbase-x-qsfpdd",
|
||||
|
@ -20,7 +20,7 @@ DEFAULT_DASHBOARD = [
|
||||
{
|
||||
'widget': 'extras.ObjectCountsWidget',
|
||||
'width': 4,
|
||||
'height': 2,
|
||||
'height': 3,
|
||||
'title': 'Organization',
|
||||
'config': {
|
||||
'models': [
|
||||
@ -32,6 +32,8 @@ DEFAULT_DASHBOARD = [
|
||||
},
|
||||
{
|
||||
'widget': 'extras.ObjectCountsWidget',
|
||||
'width': 4,
|
||||
'height': 3,
|
||||
'title': 'IPAM',
|
||||
'color': 'blue',
|
||||
'config': {
|
||||
|
@ -1,6 +1,6 @@
|
||||
{style="height: 100px; margin-bottom: 3em"}
|
||||
|
||||
# The Premiere Network Source of Truth
|
||||
# The Premier Network Source of Truth
|
||||
|
||||
NetBox is the leading solution for modeling and documenting modern networks. By combining the traditional disciplines of IP address management (IPAM) and datacenter infrastructure management (DCIM) with powerful APIs and extensions, NetBox provides the ideal "source of truth" to power network automation. Read on to discover why thousands of organizations worldwide put NetBox at the heart of their infrastructure.
|
||||
|
||||
|
@ -1,5 +1,8 @@
|
||||
# Installation
|
||||
|
||||
!!! info "NetBox Cloud"
|
||||
The instructions below are for installing NetBox as a standalone, self-hosted application. For a Cloud-delivered solution, check out [NetBox Cloud](https://netboxlabs.com/netbox-cloud/) by NetBox Labs.
|
||||
|
||||
The installation instructions provided here have been tested to work on Ubuntu 22.04 and CentOS 8.3. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
|
||||
|
||||
<iframe width="560" height="315" src="https://www.youtube.com/embed/_y5JRiW_PLM" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
|
||||
|
@ -1,12 +1,36 @@
|
||||
# NetBox v3.6
|
||||
|
||||
## v3.6.2 (FUTURE)
|
||||
## v3.6.3 (FUTURE)
|
||||
|
||||
---
|
||||
|
||||
## v3.6.2 (2023-09-20)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#13245](https://github.com/netbox-community/netbox/issues/13245) - Add interface types for QSFP112 and OSFP-RHS
|
||||
* [#13563](https://github.com/netbox-community/netbox/issues/13563) - Add support for other delimiting characters when using CSV import
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#11209](https://github.com/netbox-community/netbox/issues/11209) - Hide available IP/VLAN listing when sorting under a parent prefix or VLAN range
|
||||
* [#11617](https://github.com/netbox-community/netbox/issues/11617) - Raise validation error on the presence of an unknown CSV header during bulk import
|
||||
* [#12219](https://github.com/netbox-community/netbox/issues/12219) - Fix dashboard widget heading contrast under dark mode
|
||||
* [#12685](https://github.com/netbox-community/netbox/issues/12685) - Render Markdown in custom field help text on object edit forms
|
||||
* [#13653](https://github.com/netbox-community/netbox/issues/13653) - Tweak color of error text to improve legibility
|
||||
* [#13701](https://github.com/netbox-community/netbox/issues/13701) - Correct display of power feed legs under device view
|
||||
* [#13706](https://github.com/netbox-community/netbox/issues/13706) - Restore extra filters dropdown on device interfaces list
|
||||
* [#13721](https://github.com/netbox-community/netbox/issues/13721) - Filter VLAN choices by selected site (if any) when creating a prefix
|
||||
* [#13727](https://github.com/netbox-community/netbox/issues/13727) - Fix exception when viewing rendered config for VM without a role assigned
|
||||
* [#13745](https://github.com/netbox-community/netbox/issues/13745) - Optimize counter field migrations for large databases
|
||||
* [#13756](https://github.com/netbox-community/netbox/issues/13756) - Fix exception when sorting module bay list by installed module status
|
||||
* [#13757](https://github.com/netbox-community/netbox/issues/13757) - Fix RecursionError exception when assigning config context to a device type
|
||||
* [#13767](https://github.com/netbox-community/netbox/issues/13767) - Fix support for comments when creating a new service via web UI
|
||||
* [#13782](https://github.com/netbox-community/netbox/issues/13782) - Fix tag exclusion support for contact assignments
|
||||
* [#13791](https://github.com/netbox-community/netbox/issues/13791) - Preserve whitespace in values when performing bulk rename of objects via web UI
|
||||
* [#13809](https://github.com/netbox-community/netbox/issues/13809) - Avoid TypeError exception when editing active configuration with statically defined `CUSTOM_VALIDATORS`
|
||||
* [#13813](https://github.com/netbox-community/netbox/issues/13813) - Fix member count for newly created virtual chassis
|
||||
* [#13818](https://github.com/netbox-community/netbox/issues/13818) - Restore missing tags field on L2VPN termination edit form
|
||||
|
||||
---
|
||||
|
||||
@ -33,7 +57,7 @@
|
||||
* [#13657](https://github.com/netbox-community/netbox/issues/13657) - Fix decoding of data file content
|
||||
* [#13674](https://github.com/netbox-community/netbox/issues/13674) - Fix retrieving individual report via REST API
|
||||
* [#13682](https://github.com/netbox-community/netbox/issues/13682) - Fix error message returned when validation of custom field default value fails
|
||||
* [#13684](https://github.com/netbox-community/netbox/issues/13684) - Enable modying the configuration when maintenance mode is enabled
|
||||
* [#13684](https://github.com/netbox-community/netbox/issues/13684) - Enable modifying the configuration when maintenance mode is enabled
|
||||
|
||||
---
|
||||
|
||||
|
@ -837,8 +837,10 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
TYPE_200GE_QSFP56 = '200gbase-x-qsfp56'
|
||||
TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd'
|
||||
TYPE_400GE_CFP2 = '400gbase-x-cfp2'
|
||||
TYPE_400GE_QSFP112 = '400gbase-x-qsfp112'
|
||||
TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd'
|
||||
TYPE_400GE_OSFP = '400gbase-x-osfp'
|
||||
TYPE_400GE_OSFP_RHS = '400gbase-x-osfp-rhs'
|
||||
TYPE_400GE_CDFP = '400gbase-x-cdfp'
|
||||
TYPE_400GE_CFP8 = '400gbase-x-cfp8'
|
||||
TYPE_800GE_QSFP_DD = '800gbase-x-qsfpdd'
|
||||
@ -989,8 +991,10 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
(TYPE_100GE_QSFP_DD, 'QSFP-DD (100GE)'),
|
||||
(TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
|
||||
(TYPE_200GE_QSFP_DD, 'QSFP-DD (200GE)'),
|
||||
(TYPE_400GE_QSFP112, 'QSFP112 (400GE)'),
|
||||
(TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'),
|
||||
(TYPE_400GE_OSFP, 'OSFP (400GE)'),
|
||||
(TYPE_400GE_OSFP_RHS, 'OSFP-RHS (400GE)'),
|
||||
(TYPE_400GE_CDFP, 'CDFP (400GE)'),
|
||||
(TYPE_400GE_CFP8, 'CPF8 (400GE)'),
|
||||
(TYPE_800GE_QSFP_DD, 'QSFP-DD (800GE)'),
|
||||
|
@ -2,47 +2,22 @@ from django.db import migrations
|
||||
from django.db.models import Count
|
||||
|
||||
import utilities.fields
|
||||
from utilities.counters import update_counts
|
||||
|
||||
|
||||
def recalculate_device_counts(apps, schema_editor):
|
||||
Device = apps.get_model("dcim", "Device")
|
||||
devices = Device.objects.annotate(
|
||||
_console_port_count=Count('consoleports', distinct=True),
|
||||
_console_server_port_count=Count('consoleserverports', distinct=True),
|
||||
_power_port_count=Count('powerports', distinct=True),
|
||||
_power_outlet_count=Count('poweroutlets', distinct=True),
|
||||
_interface_count=Count('interfaces', distinct=True),
|
||||
_front_port_count=Count('frontports', distinct=True),
|
||||
_rear_port_count=Count('rearports', distinct=True),
|
||||
_device_bay_count=Count('devicebays', distinct=True),
|
||||
_module_bay_count=Count('modulebays', distinct=True),
|
||||
_inventory_item_count=Count('inventoryitems', distinct=True),
|
||||
)
|
||||
|
||||
for device in devices:
|
||||
device.console_port_count = device._console_port_count
|
||||
device.console_server_port_count = device._console_server_port_count
|
||||
device.power_port_count = device._power_port_count
|
||||
device.power_outlet_count = device._power_outlet_count
|
||||
device.interface_count = device._interface_count
|
||||
device.front_port_count = device._front_port_count
|
||||
device.rear_port_count = device._rear_port_count
|
||||
device.device_bay_count = device._device_bay_count
|
||||
device.module_bay_count = device._module_bay_count
|
||||
device.inventory_item_count = device._inventory_item_count
|
||||
|
||||
Device.objects.bulk_update(devices, [
|
||||
'console_port_count',
|
||||
'console_server_port_count',
|
||||
'power_port_count',
|
||||
'power_outlet_count',
|
||||
'interface_count',
|
||||
'front_port_count',
|
||||
'rear_port_count',
|
||||
'device_bay_count',
|
||||
'module_bay_count',
|
||||
'inventory_item_count',
|
||||
], batch_size=100)
|
||||
update_counts(Device, 'console_port_count', 'consoleports')
|
||||
update_counts(Device, 'console_server_port_count', 'consoleserverports')
|
||||
update_counts(Device, 'power_port_count', 'powerports')
|
||||
update_counts(Device, 'power_outlet_count', 'poweroutlets')
|
||||
update_counts(Device, 'interface_count', 'interfaces')
|
||||
update_counts(Device, 'front_port_count', 'frontports')
|
||||
update_counts(Device, 'rear_port_count', 'rearports')
|
||||
update_counts(Device, 'device_bay_count', 'devicebays')
|
||||
update_counts(Device, 'module_bay_count', 'modulebays')
|
||||
update_counts(Device, 'inventory_item_count', 'inventoryitems')
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
@ -2,47 +2,22 @@ from django.db import migrations
|
||||
from django.db.models import Count
|
||||
|
||||
import utilities.fields
|
||||
from utilities.counters import update_counts
|
||||
|
||||
|
||||
def recalculate_devicetype_template_counts(apps, schema_editor):
|
||||
DeviceType = apps.get_model("dcim", "DeviceType")
|
||||
device_types = list(DeviceType.objects.all().annotate(
|
||||
_console_port_template_count=Count('consoleporttemplates', distinct=True),
|
||||
_console_server_port_template_count=Count('consoleserverporttemplates', distinct=True),
|
||||
_power_port_template_count=Count('powerporttemplates', distinct=True),
|
||||
_power_outlet_template_count=Count('poweroutlettemplates', distinct=True),
|
||||
_interface_template_count=Count('interfacetemplates', distinct=True),
|
||||
_front_port_template_count=Count('frontporttemplates', distinct=True),
|
||||
_rear_port_template_count=Count('rearporttemplates', distinct=True),
|
||||
_device_bay_template_count=Count('devicebaytemplates', distinct=True),
|
||||
_module_bay_template_count=Count('modulebaytemplates', distinct=True),
|
||||
_inventory_item_template_count=Count('inventoryitemtemplates', distinct=True),
|
||||
))
|
||||
|
||||
for devicetype in device_types:
|
||||
devicetype.console_port_template_count = devicetype._console_port_template_count
|
||||
devicetype.console_server_port_template_count = devicetype._console_server_port_template_count
|
||||
devicetype.power_port_template_count = devicetype._power_port_template_count
|
||||
devicetype.power_outlet_template_count = devicetype._power_outlet_template_count
|
||||
devicetype.interface_template_count = devicetype._interface_template_count
|
||||
devicetype.front_port_template_count = devicetype._front_port_template_count
|
||||
devicetype.rear_port_template_count = devicetype._rear_port_template_count
|
||||
devicetype.device_bay_template_count = devicetype._device_bay_template_count
|
||||
devicetype.module_bay_template_count = devicetype._module_bay_template_count
|
||||
devicetype.inventory_item_template_count = devicetype._inventory_item_template_count
|
||||
|
||||
DeviceType.objects.bulk_update(device_types, [
|
||||
'console_port_template_count',
|
||||
'console_server_port_template_count',
|
||||
'power_port_template_count',
|
||||
'power_outlet_template_count',
|
||||
'interface_template_count',
|
||||
'front_port_template_count',
|
||||
'rear_port_template_count',
|
||||
'device_bay_template_count',
|
||||
'module_bay_template_count',
|
||||
'inventory_item_template_count',
|
||||
])
|
||||
update_counts(DeviceType, 'console_port_template_count', 'consoleporttemplates')
|
||||
update_counts(DeviceType, 'console_server_port_template_count', 'consoleserverporttemplates')
|
||||
update_counts(DeviceType, 'power_port_template_count', 'powerporttemplates')
|
||||
update_counts(DeviceType, 'power_outlet_template_count', 'poweroutlettemplates')
|
||||
update_counts(DeviceType, 'interface_template_count', 'interfacetemplates')
|
||||
update_counts(DeviceType, 'front_port_template_count', 'frontporttemplates')
|
||||
update_counts(DeviceType, 'rear_port_template_count', 'rearporttemplates')
|
||||
update_counts(DeviceType, 'device_bay_template_count', 'devicebaytemplates')
|
||||
update_counts(DeviceType, 'module_bay_template_count', 'modulebaytemplates')
|
||||
update_counts(DeviceType, 'inventory_item_template_count', 'inventoryitemtemplates')
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
@ -2,17 +2,13 @@ from django.db import migrations
|
||||
from django.db.models import Count
|
||||
|
||||
import utilities.fields
|
||||
from utilities.counters import update_counts
|
||||
|
||||
|
||||
def populate_virtualchassis_members(apps, schema_editor):
|
||||
VirtualChassis = apps.get_model('dcim', 'VirtualChassis')
|
||||
|
||||
vcs = VirtualChassis.objects.annotate(_member_count=Count('members', distinct=True))
|
||||
|
||||
for vc in vcs:
|
||||
vc.member_count = vc._member_count
|
||||
|
||||
VirtualChassis.objects.bulk_update(vcs, ['member_count'], batch_size=100)
|
||||
update_counts(VirtualChassis, 'member_count', 'members')
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
@ -98,10 +98,10 @@ class Cable(PrimaryModel):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# A copy of the PK to be used by __str__ in case the object is deleted
|
||||
self._pk = self.pk
|
||||
self._pk = self.__dict__.get('id')
|
||||
|
||||
# Cache the original status so we can check later if it's been changed
|
||||
self._orig_status = self.status
|
||||
self._orig_status = self.__dict__.get('status')
|
||||
|
||||
self._terminations_modified = False
|
||||
|
||||
|
@ -89,7 +89,7 @@ class ComponentTemplateModel(ChangeLoggedModel, TrackingModelMixin):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Cache the original DeviceType ID for reference under clean()
|
||||
self._original_device_type = self.device_type_id
|
||||
self._original_device_type = self.__dict__.get('device_type_id')
|
||||
|
||||
def to_objectchange(self, action):
|
||||
objectchange = super().to_objectchange(action)
|
||||
|
@ -86,7 +86,7 @@ class ComponentModel(NetBoxModel):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Cache the original Device ID for reference under clean()
|
||||
self._original_device = self.device_id
|
||||
self._original_device = self.__dict__.get('device_id')
|
||||
|
||||
def __str__(self):
|
||||
if self.label:
|
||||
|
@ -205,11 +205,11 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Save a copy of u_height for validation in clean()
|
||||
self._original_u_height = self.u_height
|
||||
self._original_u_height = self.__dict__.get('u_height')
|
||||
|
||||
# Save references to the original front/rear images
|
||||
self._original_front_image = self.front_image
|
||||
self._original_rear_image = self.rear_image
|
||||
self._original_front_image = self.__dict__.get('front_image')
|
||||
self._original_rear_image = self.__dict__.get('rear_image')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:devicetype', args=[self.pk])
|
||||
|
@ -69,7 +69,7 @@ class RenderConfigMixin(models.Model):
|
||||
"""
|
||||
if self.config_template:
|
||||
return self.config_template
|
||||
if self.role.config_template:
|
||||
if self.role and self.role.config_template:
|
||||
return self.role.config_template
|
||||
if self.platform and self.platform.config_template:
|
||||
return self.platform.config_template
|
||||
|
@ -871,8 +871,9 @@ class ModuleBayTable(DeviceComponentTable):
|
||||
url_name='dcim:modulebay_list'
|
||||
)
|
||||
module_status = columns.TemplateColumn(
|
||||
verbose_name=_('Module Status'),
|
||||
template_code=MODULEBAY_STATUS
|
||||
accessor=tables.A('installed_module__status'),
|
||||
template_code=MODULEBAY_STATUS,
|
||||
verbose_name=_('Module Status')
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
|
@ -9,6 +9,7 @@ from utilities.forms.fields import DynamicModelMultipleChoiceField
|
||||
__all__ = (
|
||||
'CustomFieldsMixin',
|
||||
'SavedFiltersMixin',
|
||||
'TagsMixin',
|
||||
)
|
||||
|
||||
|
||||
@ -72,3 +73,19 @@ class SavedFiltersMixin(forms.Form):
|
||||
'usable': True,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TagsMixin(forms.Form):
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
queryset=Tag.objects.all(),
|
||||
required=False,
|
||||
label=_('Tags'),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Limit tags to those applicable to the object type
|
||||
content_type = ContentType.objects.get_for_model(self._meta.model)
|
||||
if content_type and hasattr(self.fields['tags'].widget, 'add_query_param'):
|
||||
self.fields['tags'].widget.add_query_param('for_object_type_id', content_type.pk)
|
||||
|
@ -76,7 +76,8 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
|
||||
'type': _(
|
||||
"The type of data stored in this field. For object/multi-object fields, select the related object "
|
||||
"type below."
|
||||
)
|
||||
),
|
||||
'description': _("This will be displayed as help text for the form field. Markdown is supported.")
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@ -517,22 +518,34 @@ class ConfigRevisionForm(BootstrapMixin, forms.ModelForm, metaclass=ConfigFormMe
|
||||
config = get_config()
|
||||
for param in PARAMS:
|
||||
value = getattr(config, param.name)
|
||||
is_static = hasattr(settings, param.name)
|
||||
if value:
|
||||
help_text = self.fields[param.name].help_text
|
||||
if help_text:
|
||||
help_text += '<br />' # Line break
|
||||
help_text += _('Current value: <strong>{value}</strong>').format(value=value)
|
||||
if is_static:
|
||||
help_text += _(' (defined statically)')
|
||||
elif value == param.default:
|
||||
help_text += _(' (default)')
|
||||
self.fields[param.name].help_text = help_text
|
||||
|
||||
# Set the field's initial value, if it can be serialized. (This may not be the case e.g. for
|
||||
# CUSTOM_VALIDATORS, which may reference Python objects.)
|
||||
try:
|
||||
json.dumps(value)
|
||||
if type(value) in (tuple, list):
|
||||
value = ', '.join(value)
|
||||
self.fields[param.name].initial = value
|
||||
if is_static:
|
||||
self.fields[param.name].initial = ', '.join(value)
|
||||
else:
|
||||
self.fields[param.name].initial = value
|
||||
except TypeError:
|
||||
pass
|
||||
|
||||
# Check whether this parameter is statically configured (e.g. in configuration.py)
|
||||
if hasattr(settings, param.name):
|
||||
self.fields[param.name].disabled = True
|
||||
self.fields[param.name].help_text = _(
|
||||
'This parameter has been defined statically and cannot be modified.'
|
||||
)
|
||||
continue
|
||||
|
||||
# Set the field's help text
|
||||
help_text = self.fields[param.name].help_text
|
||||
if help_text:
|
||||
help_text += '<br />' # Line break
|
||||
help_text += _('Current value: <strong>{value}</strong>').format(value=value or '—')
|
||||
if value == param.default:
|
||||
help_text += _(' (default)')
|
||||
self.fields[param.name].help_text = help_text
|
||||
|
||||
def save(self, commit=True):
|
||||
instance = super().save(commit=False)
|
||||
|
@ -28,6 +28,7 @@ from utilities.forms.fields import (
|
||||
from utilities.forms.utils import add_blank_choice
|
||||
from utilities.forms.widgets import APISelect, APISelectMultiple, DatePicker, DateTimePicker
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.templatetags.builtins.filters import render_markdown
|
||||
from utilities.validators import validate_regex
|
||||
|
||||
__all__ = (
|
||||
@ -219,7 +220,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Cache instance's original name so we can check later whether it has changed
|
||||
self._name = self.name
|
||||
self._name = self.__dict__.get('name')
|
||||
|
||||
@property
|
||||
def search_type(self):
|
||||
@ -498,7 +499,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
field.model = self
|
||||
field.label = str(self)
|
||||
if self.description:
|
||||
field.help_text = escape(self.description)
|
||||
field.help_text = render_markdown(self.description)
|
||||
|
||||
# Annotate read-only fields
|
||||
if enforce_visibility and self.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY:
|
||||
|
@ -6,7 +6,7 @@ from django.contrib.auth import get_user_model
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.urls import reverse
|
||||
|
||||
from dcim.models import Site
|
||||
from dcim.models import DeviceType, Manufacturer, Site
|
||||
from extras.choices import *
|
||||
from extras.models import *
|
||||
from utilities.testing import ViewTestCases, TestCase
|
||||
@ -434,7 +434,8 @@ class ConfigContextTestCase(
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
|
||||
|
||||
# Create three ConfigContexts
|
||||
for i in range(1, 4):
|
||||
@ -443,7 +444,7 @@ class ConfigContextTestCase(
|
||||
data={'foo': i}
|
||||
)
|
||||
configcontext.save()
|
||||
configcontext.sites.add(site)
|
||||
configcontext.device_types.add(devicetype)
|
||||
|
||||
cls.form_data = {
|
||||
'name': 'Config Context X',
|
||||
@ -451,11 +452,12 @@ class ConfigContextTestCase(
|
||||
'description': 'A new config context',
|
||||
'is_active': True,
|
||||
'regions': [],
|
||||
'sites': [site.pk],
|
||||
'sites': [],
|
||||
'roles': [],
|
||||
'platforms': [],
|
||||
'tenant_groups': [],
|
||||
'tenants': [],
|
||||
'device_types': [devicetype.id,],
|
||||
'tags': [],
|
||||
'data': '{"foo": 123}',
|
||||
}
|
||||
|
@ -731,7 +731,7 @@ class ServiceCreateForm(ServiceForm):
|
||||
class Meta(ServiceForm.Meta):
|
||||
fields = [
|
||||
'device', 'virtual_machine', 'service_template', 'name', 'protocol', 'ports', 'ipaddresses', 'description',
|
||||
'tags',
|
||||
'comments', 'tags',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
@ -290,8 +290,8 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Cache the original prefix and VRF so we can check if they have changed on post_save
|
||||
self._prefix = self.prefix
|
||||
self._vrf_id = self.vrf_id
|
||||
self._prefix = self.__dict__.get('prefix')
|
||||
self._vrf_id = self.__dict__.get('vrf_id')
|
||||
|
||||
def __str__(self):
|
||||
return str(self.prefix)
|
||||
|
@ -4,10 +4,11 @@ from django.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices, CustomFieldVisibilityChoices
|
||||
from extras.forms.mixins import CustomFieldsMixin, SavedFiltersMixin
|
||||
from extras.forms.mixins import CustomFieldsMixin, SavedFiltersMixin, TagsMixin
|
||||
from extras.models import CustomField, Tag
|
||||
from utilities.forms import BootstrapMixin, CSVModelForm, CheckLastUpdatedMixin
|
||||
from utilities.forms import CSVModelForm
|
||||
from utilities.forms.fields import CSVModelMultipleChoiceField, DynamicModelMultipleChoiceField
|
||||
from utilities.forms.mixins import BootstrapMixin, CheckLastUpdatedMixin
|
||||
|
||||
__all__ = (
|
||||
'NetBoxModelForm',
|
||||
@ -17,7 +18,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class NetBoxModelForm(BootstrapMixin, CheckLastUpdatedMixin, CustomFieldsMixin, forms.ModelForm):
|
||||
class NetBoxModelForm(BootstrapMixin, CheckLastUpdatedMixin, CustomFieldsMixin, TagsMixin, forms.ModelForm):
|
||||
"""
|
||||
Base form for creating & editing NetBox models. Extends Django's ModelForm to add support for custom fields.
|
||||
|
||||
@ -26,18 +27,6 @@ class NetBoxModelForm(BootstrapMixin, CheckLastUpdatedMixin, CustomFieldsMixin,
|
||||
the rendered form (optional). If not defined, the all fields will be rendered as a single section.
|
||||
"""
|
||||
fieldsets = ()
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
queryset=Tag.objects.all(),
|
||||
required=False,
|
||||
label=_('Tags'),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Limit tags to those applicable to the object type
|
||||
if (ct := self._get_content_type()) and hasattr(self.fields['tags'].widget, 'add_query_param'):
|
||||
self.fields['tags'].widget.add_query_param('for_object_type_id', ct.pk)
|
||||
|
||||
def _get_content_type(self):
|
||||
return ContentType.objects.get_for_model(self._meta.model)
|
||||
|
@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '3.6.2-dev'
|
||||
VERSION = '3.6.3-dev'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
|
@ -17,6 +17,36 @@ class CSVImportTestCase(ModelViewTestCase):
|
||||
def _get_csv_data(self, csv_data):
|
||||
return '\n'.join(csv_data)
|
||||
|
||||
def test_invalid_headers(self):
|
||||
"""
|
||||
Test that import form validation fails when an unknown CSV header is present.
|
||||
"""
|
||||
self.add_permissions('dcim.add_region')
|
||||
|
||||
csv_data = [
|
||||
'name,slug,INVALIDHEADER',
|
||||
'Region 1,region-1,abc',
|
||||
'Region 2,region-2,def',
|
||||
'Region 3,region-3,ghi',
|
||||
]
|
||||
data = {
|
||||
'format': ImportFormatChoices.CSV,
|
||||
'data': self._get_csv_data(csv_data),
|
||||
'csv_delimiter': CSVDelimiterChoices.AUTO,
|
||||
}
|
||||
|
||||
# Form validation should fail with invalid header present
|
||||
self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200)
|
||||
self.assertEqual(Region.objects.count(), 0)
|
||||
|
||||
# Correct the CSV header name
|
||||
csv_data[0] = 'name,slug,description'
|
||||
data['data'] = self._get_csv_data(csv_data)
|
||||
|
||||
# Validation should succeed
|
||||
self.assertHttpStatus(self.client.post(self._get_url('import'), data), 302)
|
||||
self.assertEqual(Region.objects.count(), 3)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_valid_tags(self):
|
||||
csv_data = (
|
||||
|
BIN
netbox/project-static/dist/netbox-dark.css
vendored
BIN
netbox/project-static/dist/netbox-dark.css
vendored
Binary file not shown.
@ -282,7 +282,7 @@ $btn-close-white-filter: invert(1) grayscale(100%) brightness(200%);
|
||||
$btn-close-bg: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='#{$btn-close-color}'><path d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/></svg>");
|
||||
|
||||
// Code
|
||||
$code-color: $gray-200;
|
||||
$code-color: $gray-600;
|
||||
$kbd-color: $white;
|
||||
$kbd-bg: $gray-300;
|
||||
$pre-color: null;
|
||||
|
@ -32,7 +32,7 @@
|
||||
<td>{{ object.group_name|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"></th>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.description|markdown|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
@ -42,7 +42,7 @@
|
||||
<div class="alert alert-warning d-flex align-items-center" role="alert">
|
||||
<i class="mdi mdi-alert"></i>
|
||||
{% blocktrans trimmed with file_path=module.full_path %}
|
||||
Script file at <code>{{ file_path }}</code> could not be loaded.
|
||||
Script file at <code class="mx-1">{{ file_path }}</code> could not be loaded.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
{% else %}
|
||||
|
@ -41,6 +41,7 @@
|
||||
<div class="tab-pane {% if form.initial.vminterface %}active{% endif %}" id="vminterface" role="tabpanel" aria-labeled-by="vminterface_tab">
|
||||
{% render_field form.vminterface %}
|
||||
</div>
|
||||
{% render_field form.tags %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,10 +1,11 @@
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from extras.forms.mixins import TagsMixin
|
||||
from extras.models import Tag
|
||||
from netbox.forms import NetBoxModelForm
|
||||
from tenancy.models import *
|
||||
from utilities.forms import BootstrapMixin
|
||||
from utilities.forms.mixins import BootstrapMixin
|
||||
from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField
|
||||
|
||||
__all__ = (
|
||||
@ -121,7 +122,7 @@ class ContactForm(NetBoxModelForm):
|
||||
}
|
||||
|
||||
|
||||
class ContactAssignmentForm(BootstrapMixin, forms.ModelForm):
|
||||
class ContactAssignmentForm(BootstrapMixin, TagsMixin, forms.ModelForm):
|
||||
group = DynamicModelChoiceField(
|
||||
label=_('Group'),
|
||||
queryset=ContactGroup.objects.all(),
|
||||
@ -141,11 +142,6 @@ class ContactAssignmentForm(BootstrapMixin, forms.ModelForm):
|
||||
label=_('Role'),
|
||||
queryset=ContactRole.objects.all()
|
||||
)
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
queryset=Tag.objects.all(),
|
||||
required=False,
|
||||
label=_('Tags')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ContactAssignment
|
||||
|
@ -1,5 +1,5 @@
|
||||
from django.apps import apps
|
||||
from django.db.models import F
|
||||
from django.db.models import F, Count, OuterRef, Subquery
|
||||
from django.db.models.signals import post_delete, post_save
|
||||
|
||||
from netbox.registry import registry
|
||||
@ -23,6 +23,24 @@ def update_counter(model, pk, counter_name, value):
|
||||
)
|
||||
|
||||
|
||||
def update_counts(model, field_name, related_query):
|
||||
"""
|
||||
Perform a bulk update for the given model and counter field. For example,
|
||||
|
||||
update_counts(Device, '_interface_count', 'interfaces')
|
||||
|
||||
will effectively set
|
||||
|
||||
Device.objects.update(_interface_count=Count('interfaces'))
|
||||
"""
|
||||
subquery = Subquery(
|
||||
model.objects.filter(pk=OuterRef('pk')).annotate(_count=Count(related_query)).values('_count')
|
||||
)
|
||||
return model.objects.update(**{
|
||||
field_name: subquery
|
||||
})
|
||||
|
||||
|
||||
#
|
||||
# Signal handlers
|
||||
#
|
||||
@ -34,12 +52,13 @@ def post_save_receiver(sender, instance, created, **kwargs):
|
||||
for field_name, counter_name in get_counters_for_model(sender):
|
||||
parent_model = sender._meta.get_field(field_name).related_model
|
||||
new_pk = getattr(instance, field_name, None)
|
||||
old_pk = instance.tracker.get(field_name) if field_name in instance.tracker else None
|
||||
has_old_field = field_name in instance.tracker
|
||||
old_pk = instance.tracker.get(field_name) if has_old_field else None
|
||||
|
||||
# Update the counters on the old and/or new parents as needed
|
||||
if old_pk is not None:
|
||||
update_counter(parent_model, old_pk, counter_name, -1)
|
||||
if new_pk is not None and (old_pk or created):
|
||||
if new_pk is not None and (has_old_field or created):
|
||||
update_counter(parent_model, new_pk, counter_name, 1)
|
||||
|
||||
|
||||
|
@ -129,6 +129,7 @@ class BulkImportForm(BootstrapMixin, SyncedDataMixin, forms.Form):
|
||||
headers, records = parse_csv(reader)
|
||||
|
||||
# Set CSV headers for reference by the model form
|
||||
headers.pop('id', None)
|
||||
self._csv_headers = headers
|
||||
|
||||
return records
|
||||
|
@ -40,8 +40,11 @@ class BulkRenameForm(BootstrapMixin, forms.Form):
|
||||
"""
|
||||
An extendable form to be used for renaming objects in bulk.
|
||||
"""
|
||||
find = forms.CharField()
|
||||
find = forms.CharField(
|
||||
strip=False
|
||||
)
|
||||
replace = forms.CharField(
|
||||
strip=False,
|
||||
required=False
|
||||
)
|
||||
use_regex = forms.BooleanField(
|
||||
@ -67,22 +70,24 @@ class CSVModelForm(forms.ModelForm):
|
||||
"""
|
||||
ModelForm used for the import of objects in CSV format.
|
||||
"""
|
||||
def __init__(self, *args, headers=None, fields=None, **kwargs):
|
||||
headers = headers or {}
|
||||
fields = fields or []
|
||||
def __init__(self, *args, headers=None, **kwargs):
|
||||
self.headers = headers or {}
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Modify the model form to accommodate any customized to_field_name properties
|
||||
for field, to_field in headers.items():
|
||||
for field, to_field in self.headers.items():
|
||||
if to_field is not None:
|
||||
self.fields[field].to_field_name = to_field
|
||||
|
||||
# Omit any fields not specified (e.g. because the form is being used to
|
||||
# updated rather than create objects)
|
||||
if fields:
|
||||
for field in list(self.fields.keys()):
|
||||
if field not in fields:
|
||||
del self.fields[field]
|
||||
def clean(self):
|
||||
# Flag any invalid CSV headers
|
||||
for header in self.headers:
|
||||
if header not in self.fields:
|
||||
raise forms.ValidationError(
|
||||
_("Unrecognized header: {name}").format(name=header)
|
||||
)
|
||||
|
||||
return super().clean()
|
||||
|
||||
|
||||
class FilterForm(BootstrapMixin, forms.Form):
|
||||
|
@ -4,6 +4,7 @@ from django.core.management.base import BaseCommand
|
||||
from django.db.models import Count, OuterRef, Subquery
|
||||
|
||||
from netbox.registry import registry
|
||||
from utilities.counters import update_counts
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@ -26,27 +27,9 @@ class Command(BaseCommand):
|
||||
|
||||
return models
|
||||
|
||||
def update_counts(self, model, field_name, related_query):
|
||||
"""
|
||||
Perform a bulk update for the given model and counter field. For example,
|
||||
|
||||
update_counts(Device, '_interface_count', 'interfaces')
|
||||
|
||||
will effectively set
|
||||
|
||||
Device.objects.update(_interface_count=Count('interfaces'))
|
||||
"""
|
||||
self.stdout.write(f'Updating {model.__name__} {field_name}...')
|
||||
subquery = Subquery(
|
||||
model.objects.filter(pk=OuterRef('pk')).annotate(_count=Count(related_query)).values('_count')
|
||||
)
|
||||
return model.objects.update(**{
|
||||
field_name: subquery
|
||||
})
|
||||
|
||||
def handle(self, *model_names, **options):
|
||||
for model, mappings in self.collect_models().items():
|
||||
for field_name, related_query in mappings.items():
|
||||
self.update_counts(model, field_name, related_query)
|
||||
update_counts(model, field_name, related_query)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('Finished.'))
|
||||
|
@ -36,10 +36,18 @@ class CountersTest(TestCase):
|
||||
self.assertEqual(device1.interface_count, 3)
|
||||
self.assertEqual(device2.interface_count, 3)
|
||||
|
||||
# test saving an existing object - counter should not change
|
||||
interface1.save()
|
||||
device1.refresh_from_db()
|
||||
self.assertEqual(device1.interface_count, 3)
|
||||
|
||||
# test save where tracked object FK back pointer is None
|
||||
vc = VirtualChassis.objects.create(name='Virtual Chassis 1')
|
||||
device1.virtual_chassis = vc
|
||||
device1.save()
|
||||
vc.refresh_from_db()
|
||||
self.assertEqual(vc.member_count, 1)
|
||||
|
||||
def test_interface_count_deletion(self):
|
||||
"""
|
||||
When a tracked object (Interface) is deleted the tracking counter should be updated.
|
||||
|
@ -3,6 +3,7 @@ from django.test import TestCase
|
||||
|
||||
from utilities.choices import ImportFormatChoices
|
||||
from utilities.forms.bulk_import import BulkImportForm
|
||||
from utilities.forms.forms import BulkRenameForm
|
||||
from utilities.forms.utils import expand_alphanumeric_pattern, expand_ipaddress_pattern
|
||||
|
||||
|
||||
@ -364,3 +365,16 @@ class ImportFormTest(TestCase):
|
||||
{'a': '1', 'b': '2', 'c': '3'},
|
||||
{'a': '4', 'b': '5', 'c': '6'},
|
||||
])
|
||||
|
||||
|
||||
class BulkRenameFormTest(TestCase):
|
||||
def test_no_strip_whitespace(self):
|
||||
# Tests to make sure Bulk Rename Form isn't stripping whitespaces
|
||||
# See: https://github.com/netbox-community/netbox/issues/13791
|
||||
form = BulkRenameForm(data={
|
||||
"find": " hello ",
|
||||
"replace": " world "
|
||||
})
|
||||
self.assertTrue(form.is_valid())
|
||||
self.assertEqual(form.cleaned_data["find"], " hello ")
|
||||
self.assertEqual(form.cleaned_data["replace"], " world ")
|
||||
|
@ -2,17 +2,13 @@ from django.db import migrations
|
||||
from django.db.models import Count
|
||||
|
||||
import utilities.fields
|
||||
from utilities.counters import update_counts
|
||||
|
||||
|
||||
def populate_virtualmachine_counts(apps, schema_editor):
|
||||
VirtualMachine = apps.get_model('virtualization', 'VirtualMachine')
|
||||
|
||||
vms = VirtualMachine.objects.annotate(_interface_count=Count('interfaces', distinct=True))
|
||||
|
||||
for vm in vms:
|
||||
vm.interface_count = vm._interface_count
|
||||
|
||||
VirtualMachine.objects.bulk_update(vms, ['interface_count'], batch_size=100)
|
||||
update_counts(VirtualMachine, 'interface_count', 'interfaces')
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
@ -2,7 +2,7 @@ bleach==6.0.0
|
||||
Django==4.2.5
|
||||
django-cors-headers==4.2.0
|
||||
django-debug-toolbar==4.2.0
|
||||
django-filter==23.2
|
||||
django-filter==23.3
|
||||
django-graphiql-debug-toolbar==0.2.0
|
||||
django-mptt==0.14
|
||||
django-pglocks==1.0.4
|
||||
@ -12,7 +12,7 @@ django-rich==1.7.0
|
||||
django-rq==2.8.1
|
||||
django-tables2==2.6.0
|
||||
django-taggit==4.0.0
|
||||
django-timezone-field==6.0
|
||||
django-timezone-field==6.0.1
|
||||
djangorestframework==3.14.0
|
||||
drf-spectacular==0.26.4
|
||||
drf-spectacular-sidecar==2023.9.1
|
||||
@ -21,13 +21,13 @@ graphene-django==3.0.0
|
||||
gunicorn==21.2.0
|
||||
Jinja2==3.1.2
|
||||
Markdown==3.3.7
|
||||
mkdocs-material==9.2.7
|
||||
mkdocs-material==9.3.2
|
||||
mkdocstrings[python-legacy]==0.23.0
|
||||
netaddr==0.8.0
|
||||
Pillow==10.0.0
|
||||
netaddr==0.9.0
|
||||
Pillow==10.0.1
|
||||
psycopg[binary,pool]==3.1.10
|
||||
PyYAML==6.0.1
|
||||
sentry-sdk==1.30.0
|
||||
sentry-sdk==1.31.0
|
||||
social-auth-app-django==5.3.0
|
||||
social-auth-core[openidconnect]==4.4.2
|
||||
svgwrite==1.4.3
|
||||
|
Loading…
Reference in New Issue
Block a user