Merge branch 'develop' into feature

This commit is contained in:
Jeremy Stretch 2024-12-12 12:13:45 -05:00
commit 39ca3ce571
64 changed files with 89441 additions and 76839 deletions

View File

@ -14,7 +14,7 @@ body:
attributes: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v4.1.7 placeholder: v4.1.8
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -39,7 +39,7 @@ body:
attributes: attributes:
label: NetBox Version label: NetBox Version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v4.1.7 placeholder: v4.1.8
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -18,8 +18,17 @@ jobs:
NETBOX_CONFIGURATION: netbox.configuration_testing NETBOX_CONFIGURATION: netbox.configuration_testing
steps: steps:
- name: Create app token
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: 1076524
private-key: ${{ secrets.HOUSEKEEPING_SECRET_KEY }}
- name: Check out repo - name: Check out repo
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
token: ${{ steps.app-token.outputs.token }}
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v5

View File

@ -49,6 +49,10 @@ This key lists all models which have been registered in NetBox which are not des
This store maintains all registered items for plugins, such as navigation menus, template extensions, etc. This store maintains all registered items for plugins, such as navigation menus, template extensions, etc.
### `request_processors`
A list of context managers to invoke when processing a request e.g. in middleware or when executing a background job. Request processors can be registered with the `@register_request_processor` decorator.
### `search` ### `search`
A dictionary mapping each model (identified by its app and label) to its search index class, if one has been registered for it. A dictionary mapping each model (identified by its app and label) to its search index class, if one has been registered for it.

View File

@ -1,6 +1,32 @@
# NetBox v4.1 # NetBox v4.1
## v4.1.7 (FUTURE) ## v4.1.8 (2024-12-12)
### Enhancements
* [#17071](https://github.com/netbox-community/netbox/issues/17071) - Enable OOB IP address designation during bulk import
* [#17465](https://github.com/netbox-community/netbox/issues/17465) - Enable designation of rack type during bulk import & bulk edit
* [#17889](https://github.com/netbox-community/netbox/issues/17889) - Enable designating an IP address as out-of-band for a device upon creation
* [#17960](https://github.com/netbox-community/netbox/issues/17960) - Add L2TP, PPTP, Wireguard, and OpenVPN tunnel types
* [#18021](https://github.com/netbox-community/netbox/issues/18021) - Automatically clear cache on restart when `DEBUG` is enabled
* [#18061](https://github.com/netbox-community/netbox/issues/18061) - Omit stack trace from rendered device/VM configuration when an exception is raised
* [#18065](https://github.com/netbox-community/netbox/issues/18065) - Include status in device details when hovering on rack elevation
* [#18211](https://github.com/netbox-community/netbox/issues/18211) - Enable the dynamic registration of context managers for request processing
### Bug Fixes
* [#14044](https://github.com/netbox-community/netbox/issues/14044) - Fix unhandled AttributeError exception when bulk renaming objects
* [#17490](https://github.com/netbox-community/netbox/issues/17490) - Fix dynamic inclusion support for config templates
* [#17810](https://github.com/netbox-community/netbox/issues/17810) - Fix validation of racked device fields when modifying via REST API
* [#17820](https://github.com/netbox-community/netbox/issues/17820) - Ensure default custom field values are populated when creating new modules
* [#18044](https://github.com/netbox-community/netbox/issues/18044) - Show plugin-generated alerts within UI views for custom scripts
* [#18150](https://github.com/netbox-community/netbox/issues/18150) - Fix REST API pagination for low `MAX_PAGE_SIZE` values
* [#18183](https://github.com/netbox-community/netbox/issues/18183) - Omit UI navigation bar when printing
* [#18213](https://github.com/netbox-community/netbox/issues/18213) - Fix searching for ASN ranges by name
---
## v4.1.7 (2024-11-21)
### Enhancements ### Enhancements

View File

@ -1,4 +1,6 @@
from django.apps import AppConfig from django.apps import AppConfig
from django.conf import settings
from django.core.cache import cache
from django.db import models from django.db import models
from django.db.migrations.operations import AlterModelOptions from django.db.migrations.operations import AlterModelOptions
@ -22,3 +24,7 @@ class CoreConfig(AppConfig):
# Register models # Register models
register_models(*self.get_models()) register_models(*self.get_models())
# Clear Redis cache on startup in development mode
if settings.DEBUG:
cache.clear()

View File

@ -362,6 +362,11 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
queryset=RackRole.objects.all(), queryset=RackRole.objects.all(),
required=False required=False
) )
rack_type = DynamicModelChoiceField(
label=_('Rack type'),
queryset=RackType.objects.all(),
required=False,
)
serial = forms.CharField( serial = forms.CharField(
max_length=50, max_length=50,
required=False, required=False,
@ -441,7 +446,7 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
model = Rack model = Rack
fieldsets = ( fieldsets = (
FieldSet('status', 'role', 'tenant', 'serial', 'asset_tag', 'description', name=_('Rack')), FieldSet('status', 'role', 'tenant', 'serial', 'asset_tag', 'rack_type', 'description', name=_('Rack')),
FieldSet('region', 'site_group', 'site', 'location', name=_('Location')), FieldSet('region', 'site_group', 'site', 'location', name=_('Location')),
FieldSet( FieldSet(
'form_factor', 'width', 'u_height', 'desc_units', 'airflow', 'outer_width', 'outer_depth', 'outer_unit', 'form_factor', 'width', 'u_height', 'desc_units', 'airflow', 'outer_width', 'outer_depth', 'outer_unit',

View File

@ -258,6 +258,13 @@ class RackImportForm(NetBoxModelImportForm):
to_field_name='name', to_field_name='name',
help_text=_('Name of assigned role') help_text=_('Name of assigned role')
) )
rack_type = CSVModelChoiceField(
label=_('Rack type'),
queryset=RackType.objects.all(),
to_field_name='model',
required=False,
help_text=_('Rack type model')
)
form_factor = CSVChoiceField( form_factor = CSVChoiceField(
label=_('Type'), label=_('Type'),
choices=RackFormFactorChoices, choices=RackFormFactorChoices,
@ -267,8 +274,13 @@ class RackImportForm(NetBoxModelImportForm):
width = forms.ChoiceField( width = forms.ChoiceField(
label=_('Width'), label=_('Width'),
choices=RackWidthChoices, choices=RackWidthChoices,
required=False,
help_text=_('Rail-to-rail width (in inches)') help_text=_('Rail-to-rail width (in inches)')
) )
u_height = forms.IntegerField(
required=False,
label=_('Height (U)')
)
outer_unit = CSVChoiceField( outer_unit = CSVChoiceField(
label=_('Outer unit'), label=_('Outer unit'),
choices=RackDimensionUnitChoices, choices=RackDimensionUnitChoices,
@ -291,9 +303,9 @@ class RackImportForm(NetBoxModelImportForm):
class Meta: class Meta:
model = Rack model = Rack
fields = ( fields = (
'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'form_factor', 'serial', 'asset_tag', 'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'rack_type', 'form_factor', 'serial',
'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'asset_tag', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit',
'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags',
) )
def __init__(self, data=None, *args, **kwargs): def __init__(self, data=None, *args, **kwargs):
@ -305,6 +317,16 @@ class RackImportForm(NetBoxModelImportForm):
params = {f"site__{self.fields['site'].to_field_name}": data.get('site')} params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
self.fields['location'].queryset = self.fields['location'].queryset.filter(**params) self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
def clean(self):
super().clean()
# width & u_height must be set if not specifying a rack type on import
if not self.instance.pk:
if not self.cleaned_data.get('rack_type') and not self.cleaned_data.get('width'):
raise forms.ValidationError(_("Width must be set if not specifying a rack type."))
if not self.cleaned_data.get('rack_type') and not self.cleaned_data.get('u_height'):
raise forms.ValidationError(_("U height must be set if not specifying a rack type."))
class RackReservationImportForm(NetBoxModelImportForm): class RackReservationImportForm(NetBoxModelImportForm):
site = CSVModelChoiceField( site = CSVModelChoiceField(

View File

@ -1277,6 +1277,11 @@ class Module(PrimaryModel, ConfigContextModel):
if not disable_replication: if not disable_replication:
create_instances.append(template_instance) create_instances.append(template_instance)
# Set default values for any applicable custom fields
if cf_defaults := CustomField.objects.get_defaults_for_model(component_model):
for component in create_instances:
component.custom_field_data = cf_defaults
if component_model is not ModuleBay: if component_model is not ModuleBay:
component_model.objects.bulk_create(create_instances) component_model.objects.bulk_create(create_instances)
# Emit the post_save signal for each newly created object # Emit the post_save signal for each newly created object

View File

@ -48,6 +48,7 @@ def get_device_description(device):
Name: <name> Name: <name>
Role: <role> Role: <role>
Status: <status>
Device Type: <manufacturer> <model> (<u_height>) Device Type: <manufacturer> <model> (<u_height>)
Asset tag: <asset_tag> (if defined) Asset tag: <asset_tag> (if defined)
Serial: <serial> (if defined) Serial: <serial> (if defined)
@ -55,6 +56,7 @@ def get_device_description(device):
""" """
description = f'Name: {device.name}' description = f'Name: {device.name}'
description += f'\nRole: {device.role}' description += f'\nRole: {device.role}'
description += f'\nStatus: {device.get_status_display()}'
u_height = f'{floatformat(device.device_type.u_height)}U' u_height = f'{floatformat(device.device_type.u_height)}U'
description += f'\nDevice Type: {device.device_type.manufacturer.name} {device.device_type.model} ({u_height})' description += f'\nDevice Type: {device.device_type.manufacturer.name} {device.device_type.model} ({u_height})'
if device.asset_tag: if device.asset_tag:

View File

@ -1,5 +1,3 @@
import traceback
from django.contrib import messages from django.contrib import messages
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.paginator import EmptyPage, PageNotAnInteger from django.core.paginator import EmptyPage, PageNotAnInteger
@ -2238,7 +2236,8 @@ class DeviceRenderConfigView(generic.ObjectView):
# If a direct export has been requested, return the rendered template content as a # If a direct export has been requested, return the rendered template content as a
# downloadable file. # downloadable file.
if request.GET.get('export'): if request.GET.get('export'):
response = HttpResponse(context['rendered_config'], content_type='text') content = context['rendered_config'] or context['error_message']
response = HttpResponse(content, content_type='text')
filename = f"{instance.name or 'config'}.txt" filename = f"{instance.name or 'config'}.txt"
response['Content-Disposition'] = f'attachment; filename="{filename}"' response['Content-Disposition'] = f'attachment; filename="{filename}"'
return response return response
@ -2256,17 +2255,18 @@ class DeviceRenderConfigView(generic.ObjectView):
# Render the config template # Render the config template
rendered_config = None rendered_config = None
error_message = None
if config_template := instance.get_config_template(): if config_template := instance.get_config_template():
try: try:
rendered_config = config_template.render(context=context_data) rendered_config = config_template.render(context=context_data)
except TemplateError as e: except TemplateError as e:
messages.error(request, _("An error occurred while rendering the template: {error}").format(error=e)) error_message = _("An error occurred while rendering the template: {error}").format(error=e)
rendered_config = traceback.format_exc()
return { return {
'config_template': config_template, 'config_template': config_template,
'context_data': context_data, 'context_data': context_data,
'rendered_config': rendered_config, 'rendered_config': rendered_config,
'error_message': error_message,
} }

View File

@ -1210,12 +1210,14 @@ class ScriptView(BaseScriptView):
script_class = self._get_script_class(script) script_class = self._get_script_class(script)
if not script_class: if not script_class:
return render(request, 'extras/script.html', { return render(request, 'extras/script.html', {
'object': script,
'script': script, 'script': script,
}) })
form = script_class.as_form(initial=normalize_querydict(request.GET)) form = script_class.as_form(initial=normalize_querydict(request.GET))
return render(request, 'extras/script.html', { return render(request, 'extras/script.html', {
'object': script,
'script': script, 'script': script,
'script_class': script_class, 'script_class': script_class,
'form': form, 'form': form,
@ -1231,6 +1233,7 @@ class ScriptView(BaseScriptView):
script_class = self._get_script_class(script) script_class = self._get_script_class(script)
if not script_class: if not script_class:
return render(request, 'extras/script.html', { return render(request, 'extras/script.html', {
'object': script,
'script': script, 'script': script,
}) })
@ -1255,6 +1258,7 @@ class ScriptView(BaseScriptView):
return redirect('extras:script_result', job_pk=job.pk) return redirect('extras:script_result', job_pk=job.pk)
return render(request, 'extras/script.html', { return render(request, 'extras/script.html', {
'object': script,
'script': script, 'script': script,
'script_class': script.python_class(), 'script_class': script.python_class(),
'form': form, 'form': form,

View File

@ -214,8 +214,10 @@ class ASNRangeFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():
return queryset return queryset
qs_filter = Q(description__icontains=value) return queryset.filter(
return queryset.filter(qs_filter) Q(name__icontains=value) |
Q(description__icontains=value)
)
class ASNFilterSet(OrganizationalModelFilterSet, TenancyFilterSet): class ASNFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):

View File

@ -325,12 +325,17 @@ class IPAddressImportForm(NetBoxModelImportForm):
help_text=_('Make this the primary IP for the assigned device'), help_text=_('Make this the primary IP for the assigned device'),
required=False required=False
) )
is_oob = forms.BooleanField(
label=_('Is out-of-band'),
help_text=_('Designate this as the out-of-band IP address for the assigned device'),
required=False
)
class Meta: class Meta:
model = IPAddress model = IPAddress
fields = [ fields = [
'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'is_primary', 'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'is_primary',
'dns_name', 'description', 'comments', 'tags', 'is_oob', 'dns_name', 'description', 'comments', 'tags',
] ]
def __init__(self, data=None, *args, **kwargs): def __init__(self, data=None, *args, **kwargs):
@ -344,7 +349,7 @@ class IPAddressImportForm(NetBoxModelImportForm):
**{f"device__{self.fields['device'].to_field_name}": data['device']} **{f"device__{self.fields['device'].to_field_name}": data['device']}
) )
# Limit interface queryset by assigned device # Limit interface queryset by assigned VM
elif data.get('virtual_machine'): elif data.get('virtual_machine'):
self.fields['interface'].queryset = VMInterface.objects.filter( self.fields['interface'].queryset = VMInterface.objects.filter(
**{f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data['virtual_machine']} **{f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data['virtual_machine']}
@ -357,16 +362,29 @@ class IPAddressImportForm(NetBoxModelImportForm):
virtual_machine = self.cleaned_data.get('virtual_machine') virtual_machine = self.cleaned_data.get('virtual_machine')
interface = self.cleaned_data.get('interface') interface = self.cleaned_data.get('interface')
is_primary = self.cleaned_data.get('is_primary') is_primary = self.cleaned_data.get('is_primary')
is_oob = self.cleaned_data.get('is_oob')
# Validate is_primary # Validate is_primary and is_oob
if is_primary and not device and not virtual_machine: if is_primary and not device and not virtual_machine:
raise forms.ValidationError({ raise forms.ValidationError({
"is_primary": _("No device or virtual machine specified; cannot set as primary IP") "is_primary": _("No device or virtual machine specified; cannot set as primary IP")
}) })
if is_oob and not device:
raise forms.ValidationError({
"is_oob": _("No device specified; cannot set as out-of-band IP")
})
if is_oob and virtual_machine:
raise forms.ValidationError({
"is_oob": _("Cannot set out-of-band IP for virtual machines")
})
if is_primary and not interface: if is_primary and not interface:
raise forms.ValidationError({ raise forms.ValidationError({
"is_primary": _("No interface specified; cannot set as primary IP") "is_primary": _("No interface specified; cannot set as primary IP")
}) })
if is_oob and not interface:
raise forms.ValidationError({
"is_oob": _("No interface specified; cannot set as out-of-band IP")
})
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
@ -385,6 +403,12 @@ class IPAddressImportForm(NetBoxModelImportForm):
parent.primary_ip6 = ipaddress parent.primary_ip6 = ipaddress
parent.save() parent.save()
# Set as OOB for device
if self.cleaned_data.get('is_oob'):
parent = self.cleaned_data.get('device')
parent.oob_ip = ipaddress
parent.save()
return ipaddress return ipaddress

View File

@ -311,6 +311,10 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
required=False, required=False,
label=_('Make this the primary IP for the device/VM') label=_('Make this the primary IP for the device/VM')
) )
oob_for_parent = forms.BooleanField(
required=False,
label=_('Make this the out-of-band IP for the device')
)
comments = CommentField() comments = CommentField()
fieldsets = ( fieldsets = (
@ -322,7 +326,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
FieldSet('vminterface', name=_('Virtual Machine')), FieldSet('vminterface', name=_('Virtual Machine')),
FieldSet('fhrpgroup', name=_('FHRP Group')), FieldSet('fhrpgroup', name=_('FHRP Group')),
), ),
'primary_for_parent', name=_('Assignment') 'primary_for_parent', 'oob_for_parent', name=_('Assignment')
), ),
FieldSet('nat_inside', name=_('NAT IP (Inside)')), FieldSet('nat_inside', name=_('NAT IP (Inside)')),
) )
@ -330,8 +334,8 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
class Meta: class Meta:
model = IPAddress model = IPAddress
fields = [ fields = [
'address', 'vrf', 'status', 'role', 'dns_name', 'primary_for_parent', 'nat_inside', 'tenant_group', 'address', 'vrf', 'status', 'role', 'dns_name', 'primary_for_parent', 'oob_for_parent', 'nat_inside',
'tenant', 'description', 'comments', 'tags', 'tenant_group', 'tenant', 'description', 'comments', 'tags',
] ]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -350,7 +354,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Initialize primary_for_parent if IP address is already assigned # Initialize parent object & fields if IP address is already assigned
if self.instance.pk and self.instance.assigned_object: if self.instance.pk and self.instance.assigned_object:
parent = getattr(self.instance.assigned_object, 'parent_object', None) parent = getattr(self.instance.assigned_object, 'parent_object', None)
if parent and ( if parent and (
@ -359,6 +363,9 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
): ):
self.initial['primary_for_parent'] = True self.initial['primary_for_parent'] = True
if parent and (parent.oob_ip_id == self.instance.pk):
self.initial['oob_for_parent'] = True
if type(instance.assigned_object) is Interface: if type(instance.assigned_object) is Interface:
self.fields['interface'].widget.add_query_params({ self.fields['interface'].widget.add_query_params({
'device_id': instance.assigned_object.device.pk, 'device_id': instance.assigned_object.device.pk,
@ -387,15 +394,15 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
}) })
elif selected_objects: elif selected_objects:
assigned_object = self.cleaned_data[selected_objects[0]] assigned_object = self.cleaned_data[selected_objects[0]]
if ( if self.instance.pk and self.instance.assigned_object and assigned_object != self.instance.assigned_object:
self.instance.pk and if self.cleaned_data['primary_for_parent']:
self.instance.assigned_object and raise ValidationError(
self.cleaned_data['primary_for_parent'] and _("Cannot reassign primary IP address for the parent device/VM")
assigned_object != self.instance.assigned_object )
): if self.cleaned_data['oob_for_parent']:
raise ValidationError( raise ValidationError(
_("Cannot reassign IP address while it is designated as the primary IP for the parent object") _("Cannot reassign out-of-Band IP address for the parent device")
) )
self.instance.assigned_object = assigned_object self.instance.assigned_object = assigned_object
else: else:
self.instance.assigned_object = None self.instance.assigned_object = None
@ -407,6 +414,16 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
'primary_for_parent', _("Only IP addresses assigned to an interface can be designated as primary IPs.") 'primary_for_parent', _("Only IP addresses assigned to an interface can be designated as primary IPs.")
) )
# OOB IP assignment is only available if device interface has been assigned.
interface = self.cleaned_data.get('interface')
if self.cleaned_data.get('oob_for_parent') and not interface:
self.add_error(
'oob_for_parent', _(
"Only IP addresses assigned to a device interface can be designated as the out-of-band IP for a "
"device."
)
)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
ipaddress = super().save(*args, **kwargs) ipaddress = super().save(*args, **kwargs)
@ -428,6 +445,17 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
parent.primary_ip6 = None parent.primary_ip6 = None
parent.save() parent.save()
# Assign/clear this IPAddress as the OOB for the associated Device
if type(interface) is Interface:
parent = interface.parent_object
parent.snapshot()
if self.cleaned_data['oob_for_parent']:
parent.oob_ip = ipaddress
parent.save()
elif parent.oob_ip == ipaddress:
parent.oob_ip = None
parent.save()
return ipaddress return ipaddress

View File

@ -38,12 +38,14 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
def get_limit(self, request): def get_limit(self, request):
if self.limit_query_param: if self.limit_query_param:
MAX_PAGE_SIZE = get_config().MAX_PAGE_SIZE
if MAX_PAGE_SIZE:
MAX_PAGE_SIZE = max(MAX_PAGE_SIZE, self.default_limit)
try: try:
limit = int(request.query_params[self.limit_query_param]) limit = int(request.query_params[self.limit_query_param])
if limit < 0: if limit < 0:
raise ValueError() raise ValueError()
# Enforce maximum page size, if defined # Enforce maximum page size, if defined
MAX_PAGE_SIZE = get_config().MAX_PAGE_SIZE
if MAX_PAGE_SIZE: if MAX_PAGE_SIZE:
return MAX_PAGE_SIZE if limit == 0 else min(limit, MAX_PAGE_SIZE) return MAX_PAGE_SIZE if limit == 0 else min(limit, MAX_PAGE_SIZE)
return limit return limit

View File

@ -76,6 +76,12 @@ class ValidatedModelSerializer(BaseModelSerializer):
Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during
validation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144) validation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)
""" """
# Bypass DRF's built-in validation of unique constraints due to DRF bug #9410. Rely instead
# on our own custom model validation (below).
def get_unique_together_constraints(self, model):
return []
def validate(self, data): def validate(self, data):
# Skip validation if we're being used to represent a nested object # Skip validation if we're being used to represent a nested object

View File

@ -1,9 +1,11 @@
from contextlib import contextmanager from contextlib import contextmanager
from netbox.context import current_request, events_queue from netbox.context import current_request, events_queue
from netbox.utils import register_request_processor
from extras.events import flush_events from extras.events import flush_events
@register_request_processor
@contextmanager @contextmanager
def event_tracking(request): def event_tracking(request):
""" """

View File

@ -1,3 +1,5 @@
from contextlib import ExitStack
import logging import logging
import uuid import uuid
@ -10,7 +12,7 @@ from django.db.utils import InternalError
from django.http import Http404, HttpResponseRedirect from django.http import Http404, HttpResponseRedirect
from netbox.config import clear_config, get_config from netbox.config import clear_config, get_config
from netbox.context_managers import event_tracking from netbox.registry import registry
from netbox.views import handler_500 from netbox.views import handler_500
from utilities.api import is_api_request from utilities.api import is_api_request
from utilities.error_handlers import handle_rest_api_exception from utilities.error_handlers import handle_rest_api_exception
@ -32,8 +34,10 @@ class CoreMiddleware:
# Assign a random unique ID to the request. This will be used for change logging. # Assign a random unique ID to the request. This will be used for change logging.
request.id = uuid.uuid4() request.id = uuid.uuid4()
# Enable the event_tracking context manager and process the request. # Apply all registered request processors
with event_tracking(request): with ExitStack() as stack:
for request_processor in registry['request_processors']:
stack.enter_context(request_processor(request))
response = self.get_response(request) response = self.get_response(request)
# Check if language cookie should be renewed # Check if language cookie should be renewed

View File

@ -29,6 +29,7 @@ registry = Registry({
'model_features': dict(), 'model_features': dict(),
'models': collections.defaultdict(set), 'models': collections.defaultdict(set),
'plugins': dict(), 'plugins': dict(),
'request_processors': list(),
'search': dict(), 'search': dict(),
'system_jobs': dict(), 'system_jobs': dict(),
'tables': collections.defaultdict(dict), 'tables': collections.defaultdict(dict),

View File

@ -3,6 +3,7 @@ from netbox.registry import registry
__all__ = ( __all__ = (
'get_data_backend_choices', 'get_data_backend_choices',
'register_data_backend', 'register_data_backend',
'register_request_processor',
) )
@ -24,3 +25,12 @@ def register_data_backend():
return cls return cls
return _wrapper return _wrapper
def register_request_processor(func):
"""
Decorator for registering a request processor.
"""
registry['request_processors'].append(func)
return func

View File

@ -738,7 +738,6 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
renamed_pks = [] renamed_pks = []
for obj in selected_objects: for obj in selected_objects:
# Take a snapshot of change-logged models # Take a snapshot of change-logged models
if hasattr(obj, 'snapshot'): if hasattr(obj, 'snapshot'):
obj.snapshot() obj.snapshot()
@ -752,7 +751,7 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
except re.error: except re.error:
obj.new_name = obj.name obj.new_name = obj.name
else: else:
obj.new_name = obj.name.replace(find, replace) obj.new_name = (obj.name or '').replace(find, replace)
renamed_pks.append(obj.pk) renamed_pks.append(obj.pk)
return renamed_pks return renamed_pks
@ -787,6 +786,10 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
) )
return redirect(self.get_return_url(request)) return redirect(self.get_return_url(request))
except IntegrityError as e:
messages.error(self.request, ", ".join(e.args))
clear_events.send(sender=self)
except (AbortRequest, PermissionsViolation) as e: except (AbortRequest, PermissionsViolation) as e:
logger.debug(e.message) logger.debug(e.message)
form.add_error(None, e.message) form.add_error(None, e.message)

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,6 +1,6 @@
{ {
"name": "netbox", "name": "netbox",
"version": "4.0.0", "version": "4.1.0",
"main": "dist/netbox.js", "main": "dist/netbox.js",
"license": "Apache-2.0", "license": "Apache-2.0",
"private": true, "private": true,
@ -27,10 +27,10 @@
"bootstrap": "5.3.3", "bootstrap": "5.3.3",
"clipboard": "2.0.11", "clipboard": "2.0.11",
"flatpickr": "4.6.13", "flatpickr": "4.6.13",
"gridstack": "11.1.1", "gridstack": "11.1.2",
"htmx.org": "1.9.12", "htmx.org": "1.9.12",
"query-string": "9.1.1", "query-string": "9.1.1",
"sass": "1.81.0", "sass": "1.82.0",
"tom-select": "2.4.1", "tom-select": "2.4.1",
"typeface-inter": "3.18.1", "typeface-inter": "3.18.1",
"typeface-roboto-mono": "1.1.13" "typeface-roboto-mono": "1.1.13"

View File

@ -1904,10 +1904,10 @@ graphql@16.9.0:
resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.9.0.tgz#1c310e63f16a49ce1fbb230bd0a000e99f6f115f" resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.9.0.tgz#1c310e63f16a49ce1fbb230bd0a000e99f6f115f"
integrity sha512-GGTKBX4SD7Wdb8mqeDLni2oaRGYQWjWHGKPQ24ZMnUtKfcsVoiv4uX8+LJr1K6U5VW2Lu1BwJnj7uiori0YtRw== integrity sha512-GGTKBX4SD7Wdb8mqeDLni2oaRGYQWjWHGKPQ24ZMnUtKfcsVoiv4uX8+LJr1K6U5VW2Lu1BwJnj7uiori0YtRw==
gridstack@11.1.1: gridstack@11.1.2:
version "11.1.1" version "11.1.2"
resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-11.1.1.tgz#50f6c7a46f703a5c92a9819a607b22a6e8bd9703" resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-11.1.2.tgz#e72091e2883f7b37cbd150c218d38eebc9fc4f18"
integrity sha512-St50Ra3FlxxERrMcnRAmxQKE8paXOIwQ88zpafUkzdOYg9Sn/3/Vf4EqCWv8P/hkNIlfW/8VYsk8fk+3DQPVxQ== integrity sha512-6wJ5RffnFchF63/Yhs6tcZcWxRG1EgCnxgejbQsAjQ6Qj8QqKjew73jPq5c1yCAiyEAsXxI2tOJ8lZABOAZxoQ==
has-bigints@^1.0.1, has-bigints@^1.0.2: has-bigints@^1.0.1, has-bigints@^1.0.2:
version "1.0.2" version "1.0.2"
@ -2661,10 +2661,10 @@ safe-regex-test@^1.0.3:
es-errors "^1.3.0" es-errors "^1.3.0"
is-regex "^1.1.4" is-regex "^1.1.4"
sass@1.81.0: sass@1.82.0:
version "1.81.0" version "1.82.0"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.81.0.tgz#a9010c0599867909dfdbad057e4a6fbdd5eec941" resolved "https://registry.yarnpkg.com/sass/-/sass-1.82.0.tgz#30da277af3d0fa6042e9ceabd0d984ed6d07df70"
integrity sha512-Q4fOxRfhmv3sqCLoGfvrC9pRV8btc0UtqL9mN6Yrv6Qi9ScL55CVH1vlPP863ISLEEMNLLuu9P+enCeGHlnzhA== integrity sha512-j4GMCTa8elGyN9A7x7bEglx0VgSpNUG4W4wNedQ33wSMdnkqQCT8HTwOaVSV4e6yQovcu/3Oc4coJP/l0xhL2Q==
dependencies: dependencies:
chokidar "^4.0.0" chokidar "^4.0.0"
immutable "^5.0.2" immutable "^5.0.2"

View File

@ -19,7 +19,7 @@ Blocks:
<div class="page"> <div class="page">
{# Sidebar #} {# Sidebar #}
<aside class="navbar navbar-vertical navbar-expand-lg"> <aside class="navbar navbar-vertical navbar-expand-lg d-print-none">
{% if 'commercial' in settings.RELEASE.features %} {% if 'commercial' in settings.RELEASE.features %}
<img class="motif" src="{% static 'motif.svg' %}" alt="{% trans "NetBox Motif" %}"> <img class="motif" src="{% static 'motif.svg' %}" alt="{% trans "NetBox Motif" %}">

View File

@ -5,7 +5,7 @@
{% block title %}{{ object }} - {% trans "Config" %}{% endblock %} {% block title %}{{ object }} - {% trans "Config" %}{% endblock %}
{% block content %} {% block content %}
<div class="row mb-3"> <div class="row">
<div class="col-5"> <div class="col-5">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Config Template" %}</h2> <h2 class="card-header">{% trans "Config Template" %}</h2>
@ -48,19 +48,28 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<div class="card"> {% if config_template %}
<h2 class="card-header d-flex justify-content-between"> {% if rendered_config %}
{% trans "Rendered Config" %} <div class="card">
<a href="?export=True" class="btn btn-primary lh-1" role="button"> <h2 class="card-header d-flex justify-content-between">
<i class="mdi mdi-download" aria-hidden="true"></i> {% trans "Download" %} {% trans "Rendered Config" %}
</a> <a href="?export=True" class="btn btn-primary lh-1" role="button">
</h2> <i class="mdi mdi-download" aria-hidden="true"></i> {% trans "Download" %}
{% if config_template %} </a>
<pre class="card-body">{{ rendered_config }}</pre> </h2>
<pre class="card-body">{{ rendered_config }}</pre>
</div>
{% else %} {% else %}
<div class="card-body text-muted">{% trans "No configuration template found" %}</div> <div class="alert alert-warning">
<h4 class="alert-title mb-1">{% trans "Error rendering template" %}</h4>
{% trans error_message %}
</div>
{% endif %} {% endif %}
</div> {% else %}
<div class="alert alert-info">
{% trans "No configuration template has been assigned for this device." %}
</div>
{% endif %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -5,7 +5,7 @@
{% block title %}{{ object }} - {% trans "Config" %}{% endblock %} {% block title %}{{ object }} - {% trans "Config" %}{% endblock %}
{% block content %} {% block content %}
<div class="row mb-3"> <div class="row">
<div class="col-5"> <div class="col-5">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Config Template" %}</h2> <h2 class="card-header">{% trans "Config Template" %}</h2>
@ -48,19 +48,28 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<div class="card"> {% if config_template %}
<h2 class="card-header d-flex justify-content-between"> {% if rendered_config %}
{% trans "Rendered Config" %} <div class="card">
<a href="?export=True" class="btn btn-primary lh-1" role="button"> <h2 class="card-header d-flex justify-content-between">
<i class="mdi mdi-download" aria-hidden="true"></i> {% trans "Download" %} {% trans "Rendered Config" %}
</a> <a href="?export=True" class="btn btn-primary lh-1" role="button">
</h2> <i class="mdi mdi-download" aria-hidden="true"></i> {% trans "Download" %}
{% if config_template %} </a>
<pre class="card-body">{{ rendered_config }}</pre> </h2>
<pre class="card-body">{{ rendered_config }}</pre>
</div>
{% else %} {% else %}
<div class="card-body text-muted">{% trans "No configuration template found" %}</div> <div class="alert alert-warning">
<h4 class="alert-title mb-1">{% trans "Error rendering template" %}</h4>
{% trans error_message %}
</div>
{% endif %} {% endif %}
</div> {% else %}
<div class="alert alert-info">
{% trans "No configuration template has been assigned for this virtual machine." %}
</div>
{% endif %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -28,10 +28,14 @@ class DataFileLoader(BaseLoader):
raise TemplateNotFound(template) raise TemplateNotFound(template)
# Find and pre-fetch referenced templates # Find and pre-fetch referenced templates
if referenced_templates := find_referenced_templates(environment.parse(template_source)): if referenced_templates := tuple(find_referenced_templates(environment.parse(template_source))):
related_files = DataFile.objects.filter(source=self.data_source)
# None indicates the use of dynamic resolution. If dependent files are statically
# defined, we can filter by path for optimization.
if None not in referenced_templates:
related_files = related_files.filter(path__in=referenced_templates)
self.cache_templates({ self.cache_templates({
df.path: df.data_as_string for df in df.path: df.data_as_string for df in related_files
DataFile.objects.filter(source=self.data_source, path__in=referenced_templates)
}) })
return template_source, template, lambda: True return template_source, template, lambda: True

View File

@ -144,6 +144,19 @@ class APIPaginationTestCase(APITestCase):
self.assertIsNone(response.data['previous']) self.assertIsNone(response.data['previous'])
self.assertEqual(len(response.data['results']), page_size) self.assertEqual(len(response.data['results']), page_size)
@override_settings(MAX_PAGE_SIZE=30)
def test_default_page_size_with_small_max_page_size(self):
response = self.client.get(self.url, format='json', **self.header)
page_size = get_config().MAX_PAGE_SIZE
paginate_count = get_config().PAGINATE_COUNT
self.assertLess(page_size, 100, "Default page size not sufficient for data set")
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['count'], 100)
self.assertTrue(response.data['next'].endswith(f'?limit={paginate_count}&offset={paginate_count}'))
self.assertIsNone(response.data['previous'])
self.assertEqual(len(response.data['results']), paginate_count)
def test_custom_page_size(self): def test_custom_page_size(self):
response = self.client.get(f'{self.url}?limit=10', format='json', **self.header) response = self.client.get(f'{self.url}?limit=10', format='json', **self.header)
@ -153,15 +166,15 @@ class APIPaginationTestCase(APITestCase):
self.assertIsNone(response.data['previous']) self.assertIsNone(response.data['previous'])
self.assertEqual(len(response.data['results']), 10) self.assertEqual(len(response.data['results']), 10)
@override_settings(MAX_PAGE_SIZE=20) @override_settings(MAX_PAGE_SIZE=80)
def test_max_page_size(self): def test_max_page_size(self):
response = self.client.get(f'{self.url}?limit=0', format='json', **self.header) response = self.client.get(f'{self.url}?limit=0', format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK) self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['count'], 100) self.assertEqual(response.data['count'], 100)
self.assertTrue(response.data['next'].endswith('?limit=20&offset=20')) self.assertTrue(response.data['next'].endswith('?limit=80&offset=80'))
self.assertIsNone(response.data['previous']) self.assertIsNone(response.data['previous'])
self.assertEqual(len(response.data['results']), 20) self.assertEqual(len(response.data['results']), 80)
@override_settings(MAX_PAGE_SIZE=0) @override_settings(MAX_PAGE_SIZE=0)
def test_max_page_size_disabled(self): def test_max_page_size_disabled(self):

View File

@ -1,5 +1,3 @@
import traceback
from django.contrib import messages from django.contrib import messages
from django.db import transaction from django.db import transaction
from django.db.models import Prefetch, Sum from django.db.models import Prefetch, Sum
@ -444,7 +442,8 @@ class VirtualMachineRenderConfigView(generic.ObjectView):
# If a direct export has been requested, return the rendered template content as a # If a direct export has been requested, return the rendered template content as a
# downloadable file. # downloadable file.
if request.GET.get('export'): if request.GET.get('export'):
response = HttpResponse(context['rendered_config'], content_type='text') content = context['rendered_config'] or context['error_message']
response = HttpResponse(content, content_type='text')
filename = f"{instance.name or 'config'}.txt" filename = f"{instance.name or 'config'}.txt"
response['Content-Disposition'] = f'attachment; filename="{filename}"' response['Content-Disposition'] = f'attachment; filename="{filename}"'
return response return response
@ -462,17 +461,18 @@ class VirtualMachineRenderConfigView(generic.ObjectView):
# Render the config template # Render the config template
rendered_config = None rendered_config = None
error_message = None
if config_template := instance.get_config_template(): if config_template := instance.get_config_template():
try: try:
rendered_config = config_template.render(context=context_data) rendered_config = config_template.render(context=context_data)
except TemplateError as e: except TemplateError as e:
messages.error(request, _("An error occurred while rendering the template: {error}").format(error=e)) error_message = _("An error occurred while rendering the template: {error}").format(error=e)
rendered_config = traceback.format_exc()
return { return {
'config_template': config_template, 'config_template': config_template,
'context_data': context_data, 'context_data': context_data,
'rendered_config': rendered_config, 'rendered_config': rendered_config,
'error_message': error_message,
} }

View File

@ -23,15 +23,23 @@ class TunnelStatusChoices(ChoiceSet):
class TunnelEncapsulationChoices(ChoiceSet): class TunnelEncapsulationChoices(ChoiceSet):
ENCAP_GRE = 'gre' ENCAP_GRE = 'gre'
ENCAP_IP_IP = 'ip-ip'
ENCAP_IPSEC_TRANSPORT = 'ipsec-transport' ENCAP_IPSEC_TRANSPORT = 'ipsec-transport'
ENCAP_IPSEC_TUNNEL = 'ipsec-tunnel' ENCAP_IPSEC_TUNNEL = 'ipsec-tunnel'
ENCAP_IP_IP = 'ip-ip'
ENCAP_L2TP = 'l2tp'
ENCAP_OPENVPN = 'openvpn'
ENCAP_PPTP = 'pptp'
ENCAP_WIREGUARD = 'wireguard'
CHOICES = [ CHOICES = [
(ENCAP_IPSEC_TRANSPORT, _('IPsec - Transport')), (ENCAP_IPSEC_TRANSPORT, _('IPsec - Transport')),
(ENCAP_IPSEC_TUNNEL, _('IPsec - Tunnel')), (ENCAP_IPSEC_TUNNEL, _('IPsec - Tunnel')),
(ENCAP_IP_IP, _('IP-in-IP')), (ENCAP_IP_IP, _('IP-in-IP')),
(ENCAP_GRE, _('GRE')), (ENCAP_GRE, _('GRE')),
(ENCAP_WIREGUARD, _('WireGuard')),
(ENCAP_OPENVPN, _('OpenVPN')),
(ENCAP_L2TP, _('L2TP')),
(ENCAP_PPTP, _('PPTP')),
] ]

View File

@ -20,7 +20,7 @@ feedparser==6.0.11
gunicorn==23.0.0 gunicorn==23.0.0
Jinja2==3.1.4 Jinja2==3.1.4
Markdown==3.7 Markdown==3.7
mkdocs-material==9.5.47 mkdocs-material==9.5.48
mkdocstrings[python-legacy]==0.27.0 mkdocstrings[python-legacy]==0.27.0
netaddr==1.3.0 netaddr==1.3.0
nh3==0.2.19 nh3==0.2.19
@ -31,8 +31,8 @@ requests==2.32.3
rq==2.0 rq==2.0
social-auth-app-django==5.4.2 social-auth-app-django==5.4.2
social-auth-core==4.5.4 social-auth-core==4.5.4
strawberry-graphql==0.253.0 strawberry-graphql==0.253.1
strawberry-graphql-django==0.50.0 strawberry-graphql-django==0.51.0
svgwrite==1.4.3 svgwrite==1.4.3
tablib==3.7.0 tablib==3.7.0
tzdata==2024.2 tzdata==2024.2