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:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v4.1.7
placeholder: v4.1.8
validations:
required: true
- type: dropdown

View File

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

View File

@ -18,8 +18,17 @@ jobs:
NETBOX_CONFIGURATION: netbox.configuration_testing
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
uses: actions/checkout@v4
with:
token: ${{ steps.app-token.outputs.token }}
- name: Set up Python
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.
### `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`
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
## 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

View File

@ -1,4 +1,6 @@
from django.apps import AppConfig
from django.conf import settings
from django.core.cache import cache
from django.db import models
from django.db.migrations.operations import AlterModelOptions
@ -22,3 +24,7 @@ class CoreConfig(AppConfig):
# Register 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(),
required=False
)
rack_type = DynamicModelChoiceField(
label=_('Rack type'),
queryset=RackType.objects.all(),
required=False,
)
serial = forms.CharField(
max_length=50,
required=False,
@ -441,7 +446,7 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
model = Rack
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(
'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',
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(
label=_('Type'),
choices=RackFormFactorChoices,
@ -267,8 +274,13 @@ class RackImportForm(NetBoxModelImportForm):
width = forms.ChoiceField(
label=_('Width'),
choices=RackWidthChoices,
required=False,
help_text=_('Rail-to-rail width (in inches)')
)
u_height = forms.IntegerField(
required=False,
label=_('Height (U)')
)
outer_unit = CSVChoiceField(
label=_('Outer unit'),
choices=RackDimensionUnitChoices,
@ -291,9 +303,9 @@ class RackImportForm(NetBoxModelImportForm):
class Meta:
model = Rack
fields = (
'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'form_factor', 'serial', 'asset_tag',
'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow',
'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags',
'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'rack_type', 'form_factor', 'serial',
'asset_tag', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit',
'mounting_depth', 'airflow', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags',
)
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')}
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):
site = CSVModelChoiceField(

View File

@ -1277,6 +1277,11 @@ class Module(PrimaryModel, ConfigContextModel):
if not disable_replication:
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:
component_model.objects.bulk_create(create_instances)
# Emit the post_save signal for each newly created object

View File

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

View File

@ -1,5 +1,3 @@
import traceback
from django.contrib import messages
from django.contrib.contenttypes.models import ContentType
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
# downloadable file.
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"
response['Content-Disposition'] = f'attachment; filename="{filename}"'
return response
@ -2256,17 +2255,18 @@ class DeviceRenderConfigView(generic.ObjectView):
# Render the config template
rendered_config = None
error_message = None
if config_template := instance.get_config_template():
try:
rendered_config = config_template.render(context=context_data)
except TemplateError as e:
messages.error(request, _("An error occurred while rendering the template: {error}").format(error=e))
rendered_config = traceback.format_exc()
error_message = _("An error occurred while rendering the template: {error}").format(error=e)
return {
'config_template': config_template,
'context_data': context_data,
'rendered_config': rendered_config,
'error_message': error_message,
}

View File

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

View File

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

View File

@ -325,12 +325,17 @@ class IPAddressImportForm(NetBoxModelImportForm):
help_text=_('Make this the primary IP for the assigned device'),
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:
model = IPAddress
fields = [
'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):
@ -344,7 +349,7 @@ class IPAddressImportForm(NetBoxModelImportForm):
**{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'):
self.fields['interface'].queryset = VMInterface.objects.filter(
**{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')
interface = self.cleaned_data.get('interface')
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:
raise forms.ValidationError({
"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:
raise forms.ValidationError({
"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):
@ -385,6 +403,12 @@ class IPAddressImportForm(NetBoxModelImportForm):
parent.primary_ip6 = ipaddress
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

View File

@ -311,6 +311,10 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
required=False,
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()
fieldsets = (
@ -322,7 +326,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
FieldSet('vminterface', name=_('Virtual Machine')),
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)')),
)
@ -330,8 +334,8 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
class Meta:
model = IPAddress
fields = [
'address', 'vrf', 'status', 'role', 'dns_name', 'primary_for_parent', 'nat_inside', 'tenant_group',
'tenant', 'description', 'comments', 'tags',
'address', 'vrf', 'status', 'role', 'dns_name', 'primary_for_parent', 'oob_for_parent', 'nat_inside',
'tenant_group', 'tenant', 'description', 'comments', 'tags',
]
def __init__(self, *args, **kwargs):
@ -350,7 +354,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
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:
parent = getattr(self.instance.assigned_object, 'parent_object', None)
if parent and (
@ -359,6 +363,9 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
):
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:
self.fields['interface'].widget.add_query_params({
'device_id': instance.assigned_object.device.pk,
@ -387,15 +394,15 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
})
elif selected_objects:
assigned_object = self.cleaned_data[selected_objects[0]]
if (
self.instance.pk and
self.instance.assigned_object and
self.cleaned_data['primary_for_parent'] and
assigned_object != self.instance.assigned_object
):
raise ValidationError(
_("Cannot reassign IP address while it is designated as the primary IP for the parent object")
)
if self.instance.pk and self.instance.assigned_object and assigned_object != self.instance.assigned_object:
if self.cleaned_data['primary_for_parent']:
raise ValidationError(
_("Cannot reassign primary IP address for the parent device/VM")
)
if self.cleaned_data['oob_for_parent']:
raise ValidationError(
_("Cannot reassign out-of-Band IP address for the parent device")
)
self.instance.assigned_object = assigned_object
else:
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.")
)
# 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):
ipaddress = super().save(*args, **kwargs)
@ -428,6 +445,17 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
parent.primary_ip6 = None
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

View File

@ -38,12 +38,14 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
def get_limit(self, request):
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:
limit = int(request.query_params[self.limit_query_param])
if limit < 0:
raise ValueError()
# Enforce maximum page size, if defined
MAX_PAGE_SIZE = get_config().MAX_PAGE_SIZE
if MAX_PAGE_SIZE:
return MAX_PAGE_SIZE if limit == 0 else min(limit, MAX_PAGE_SIZE)
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
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):
# Skip validation if we're being used to represent a nested object

View File

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

View File

@ -1,3 +1,5 @@
from contextlib import ExitStack
import logging
import uuid
@ -10,7 +12,7 @@ from django.db.utils import InternalError
from django.http import Http404, HttpResponseRedirect
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 utilities.api import is_api_request
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.
request.id = uuid.uuid4()
# Enable the event_tracking context manager and process the request.
with event_tracking(request):
# Apply all registered request processors
with ExitStack() as stack:
for request_processor in registry['request_processors']:
stack.enter_context(request_processor(request))
response = self.get_response(request)
# Check if language cookie should be renewed

View File

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

View File

@ -3,6 +3,7 @@ from netbox.registry import registry
__all__ = (
'get_data_backend_choices',
'register_data_backend',
'register_request_processor',
)
@ -24,3 +25,12 @@ def register_data_backend():
return cls
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 = []
for obj in selected_objects:
# Take a snapshot of change-logged models
if hasattr(obj, 'snapshot'):
obj.snapshot()
@ -752,7 +751,7 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
except re.error:
obj.new_name = obj.name
else:
obj.new_name = obj.name.replace(find, replace)
obj.new_name = (obj.name or '').replace(find, replace)
renamed_pks.append(obj.pk)
return renamed_pks
@ -787,6 +786,10 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
)
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:
logger.debug(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",
"version": "4.0.0",
"version": "4.1.0",
"main": "dist/netbox.js",
"license": "Apache-2.0",
"private": true,
@ -27,10 +27,10 @@
"bootstrap": "5.3.3",
"clipboard": "2.0.11",
"flatpickr": "4.6.13",
"gridstack": "11.1.1",
"gridstack": "11.1.2",
"htmx.org": "1.9.12",
"query-string": "9.1.1",
"sass": "1.81.0",
"sass": "1.82.0",
"tom-select": "2.4.1",
"typeface-inter": "3.18.1",
"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"
integrity sha512-GGTKBX4SD7Wdb8mqeDLni2oaRGYQWjWHGKPQ24ZMnUtKfcsVoiv4uX8+LJr1K6U5VW2Lu1BwJnj7uiori0YtRw==
gridstack@11.1.1:
version "11.1.1"
resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-11.1.1.tgz#50f6c7a46f703a5c92a9819a607b22a6e8bd9703"
integrity sha512-St50Ra3FlxxERrMcnRAmxQKE8paXOIwQ88zpafUkzdOYg9Sn/3/Vf4EqCWv8P/hkNIlfW/8VYsk8fk+3DQPVxQ==
gridstack@11.1.2:
version "11.1.2"
resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-11.1.2.tgz#e72091e2883f7b37cbd150c218d38eebc9fc4f18"
integrity sha512-6wJ5RffnFchF63/Yhs6tcZcWxRG1EgCnxgejbQsAjQ6Qj8QqKjew73jPq5c1yCAiyEAsXxI2tOJ8lZABOAZxoQ==
has-bigints@^1.0.1, has-bigints@^1.0.2:
version "1.0.2"
@ -2661,10 +2661,10 @@ safe-regex-test@^1.0.3:
es-errors "^1.3.0"
is-regex "^1.1.4"
sass@1.81.0:
version "1.81.0"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.81.0.tgz#a9010c0599867909dfdbad057e4a6fbdd5eec941"
integrity sha512-Q4fOxRfhmv3sqCLoGfvrC9pRV8btc0UtqL9mN6Yrv6Qi9ScL55CVH1vlPP863ISLEEMNLLuu9P+enCeGHlnzhA==
sass@1.82.0:
version "1.82.0"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.82.0.tgz#30da277af3d0fa6042e9ceabd0d984ed6d07df70"
integrity sha512-j4GMCTa8elGyN9A7x7bEglx0VgSpNUG4W4wNedQ33wSMdnkqQCT8HTwOaVSV4e6yQovcu/3Oc4coJP/l0xhL2Q==
dependencies:
chokidar "^4.0.0"
immutable "^5.0.2"

View File

@ -19,7 +19,7 @@ Blocks:
<div class="page">
{# 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 %}
<img class="motif" src="{% static 'motif.svg' %}" alt="{% trans "NetBox Motif" %}">

View File

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

View File

@ -5,7 +5,7 @@
{% block title %}{{ object }} - {% trans "Config" %}{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="row">
<div class="col-5">
<div class="card">
<h2 class="card-header">{% trans "Config Template" %}</h2>
@ -48,19 +48,28 @@
</div>
<div class="row">
<div class="col">
<div class="card">
<h2 class="card-header d-flex justify-content-between">
{% trans "Rendered Config" %}
<a href="?export=True" class="btn btn-primary lh-1" role="button">
<i class="mdi mdi-download" aria-hidden="true"></i> {% trans "Download" %}
</a>
</h2>
{% if config_template %}
<pre class="card-body">{{ rendered_config }}</pre>
{% if config_template %}
{% if rendered_config %}
<div class="card">
<h2 class="card-header d-flex justify-content-between">
{% trans "Rendered Config" %}
<a href="?export=True" class="btn btn-primary lh-1" role="button">
<i class="mdi mdi-download" aria-hidden="true"></i> {% trans "Download" %}
</a>
</h2>
<pre class="card-body">{{ rendered_config }}</pre>
</div>
{% 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 %}
</div>
{% else %}
<div class="alert alert-info">
{% trans "No configuration template has been assigned for this virtual machine." %}
</div>
{% endif %}
</div>
</div>
{% 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)
# 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({
df.path: df.data_as_string for df in
DataFile.objects.filter(source=self.data_source, path__in=referenced_templates)
df.path: df.data_as_string for df in related_files
})
return template_source, template, lambda: True

View File

@ -144,6 +144,19 @@ class APIPaginationTestCase(APITestCase):
self.assertIsNone(response.data['previous'])
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):
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.assertEqual(len(response.data['results']), 10)
@override_settings(MAX_PAGE_SIZE=20)
@override_settings(MAX_PAGE_SIZE=80)
def test_max_page_size(self):
response = self.client.get(f'{self.url}?limit=0', format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
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.assertEqual(len(response.data['results']), 20)
self.assertEqual(len(response.data['results']), 80)
@override_settings(MAX_PAGE_SIZE=0)
def test_max_page_size_disabled(self):

View File

@ -1,5 +1,3 @@
import traceback
from django.contrib import messages
from django.db import transaction
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
# downloadable file.
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"
response['Content-Disposition'] = f'attachment; filename="{filename}"'
return response
@ -462,17 +461,18 @@ class VirtualMachineRenderConfigView(generic.ObjectView):
# Render the config template
rendered_config = None
error_message = None
if config_template := instance.get_config_template():
try:
rendered_config = config_template.render(context=context_data)
except TemplateError as e:
messages.error(request, _("An error occurred while rendering the template: {error}").format(error=e))
rendered_config = traceback.format_exc()
error_message = _("An error occurred while rendering the template: {error}").format(error=e)
return {
'config_template': config_template,
'context_data': context_data,
'rendered_config': rendered_config,
'error_message': error_message,
}

View File

@ -23,15 +23,23 @@ class TunnelStatusChoices(ChoiceSet):
class TunnelEncapsulationChoices(ChoiceSet):
ENCAP_GRE = 'gre'
ENCAP_IP_IP = 'ip-ip'
ENCAP_IPSEC_TRANSPORT = 'ipsec-transport'
ENCAP_IPSEC_TUNNEL = 'ipsec-tunnel'
ENCAP_IP_IP = 'ip-ip'
ENCAP_L2TP = 'l2tp'
ENCAP_OPENVPN = 'openvpn'
ENCAP_PPTP = 'pptp'
ENCAP_WIREGUARD = 'wireguard'
CHOICES = [
(ENCAP_IPSEC_TRANSPORT, _('IPsec - Transport')),
(ENCAP_IPSEC_TUNNEL, _('IPsec - Tunnel')),
(ENCAP_IP_IP, _('IP-in-IP')),
(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
Jinja2==3.1.4
Markdown==3.7
mkdocs-material==9.5.47
mkdocs-material==9.5.48
mkdocstrings[python-legacy]==0.27.0
netaddr==1.3.0
nh3==0.2.19
@ -31,8 +31,8 @@ requests==2.32.3
rq==2.0
social-auth-app-django==5.4.2
social-auth-core==4.5.4
strawberry-graphql==0.253.0
strawberry-graphql-django==0.50.0
strawberry-graphql==0.253.1
strawberry-graphql-django==0.51.0
svgwrite==1.4.3
tablib==3.7.0
tzdata==2024.2