Просмотр исходного кода

Merge branch 'develop' into feature

Jeremy Stretch 1 год назад
Родитель
Сommit
39ca3ce571
64 измененных файлов с 27381 добавлено и 22383 удалено
  1. 1 1
      .github/ISSUE_TEMPLATE/01-feature_request.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/02-bug_report.yaml
  3. 9 0
      .github/workflows/update-translation-strings.yml
  4. 4 0
      docs/development/application-registry.md
  5. 27 1
      docs/release-notes/version-4.1.md
  6. 6 0
      netbox/core/apps.py
  7. 6 1
      netbox/dcim/forms/bulk_edit.py
  8. 25 3
      netbox/dcim/forms/bulk_import.py
  9. 5 0
      netbox/dcim/models/devices.py
  10. 2 0
      netbox/dcim/svg/racks.py
  11. 5 5
      netbox/dcim/views.py
  12. 4 0
      netbox/extras/views.py
  13. 4 2
      netbox/ipam/filtersets.py
  14. 27 3
      netbox/ipam/forms/bulk_import.py
  15. 41 13
      netbox/ipam/forms/model_forms.py
  16. 3 1
      netbox/netbox/api/pagination.py
  17. 6 0
      netbox/netbox/api/serializers/base.py
  18. 2 0
      netbox/netbox/context_managers.py
  19. 7 3
      netbox/netbox/middleware.py
  20. 1 0
      netbox/netbox/registry.py
  21. 10 0
      netbox/netbox/utils.py
  22. 5 2
      netbox/netbox/views/generic/bulk_views.py
  23. 0 0
      netbox/project-static/dist/netbox-external.css
  24. 0 0
      netbox/project-static/dist/netbox.js
  25. 0 0
      netbox/project-static/dist/netbox.js.map
  26. 3 3
      netbox/project-static/package.json
  27. 8 8
      netbox/project-static/yarn.lock
  28. 1 1
      netbox/templates/base/layout.html
  29. 21 12
      netbox/templates/dcim/device/render_config.html
  30. 21 12
      netbox/templates/virtualization/virtualmachine/render_config.html
  31. BIN
      netbox/translations/cs/LC_MESSAGES/django.mo
  32. 1869 1541
      netbox/translations/cs/LC_MESSAGES/django.po
  33. BIN
      netbox/translations/da/LC_MESSAGES/django.mo
  34. 1385 1119
      netbox/translations/da/LC_MESSAGES/django.po
  35. BIN
      netbox/translations/de/LC_MESSAGES/django.mo
  36. 1386 1120
      netbox/translations/de/LC_MESSAGES/django.po
  37. 3585 3049
      netbox/translations/en/LC_MESSAGES/django.po
  38. BIN
      netbox/translations/es/LC_MESSAGES/django.mo
  39. 1385 1119
      netbox/translations/es/LC_MESSAGES/django.po
  40. BIN
      netbox/translations/fr/LC_MESSAGES/django.mo
  41. 1385 1119
      netbox/translations/fr/LC_MESSAGES/django.po
  42. BIN
      netbox/translations/it/LC_MESSAGES/django.mo
  43. 1385 1119
      netbox/translations/it/LC_MESSAGES/django.po
  44. BIN
      netbox/translations/ja/LC_MESSAGES/django.mo
  45. 2486 2071
      netbox/translations/ja/LC_MESSAGES/django.po
  46. BIN
      netbox/translations/nl/LC_MESSAGES/django.mo
  47. 1385 1119
      netbox/translations/nl/LC_MESSAGES/django.po
  48. BIN
      netbox/translations/pl/LC_MESSAGES/django.mo
  49. 1869 1541
      netbox/translations/pl/LC_MESSAGES/django.po
  50. BIN
      netbox/translations/pt/LC_MESSAGES/django.mo
  51. 1385 1119
      netbox/translations/pt/LC_MESSAGES/django.po
  52. BIN
      netbox/translations/ru/LC_MESSAGES/django.mo
  53. 1385 1119
      netbox/translations/ru/LC_MESSAGES/django.po
  54. BIN
      netbox/translations/tr/LC_MESSAGES/django.mo
  55. 1385 1119
      netbox/translations/tr/LC_MESSAGES/django.po
  56. BIN
      netbox/translations/uk/LC_MESSAGES/django.mo
  57. 1871 1543
      netbox/translations/uk/LC_MESSAGES/django.po
  58. BIN
      netbox/translations/zh/LC_MESSAGES/django.mo
  59. 2940 2479
      netbox/translations/zh/LC_MESSAGES/django.po
  60. 7 3
      netbox/utilities/jinja2.py
  61. 16 3
      netbox/utilities/tests/test_api.py
  62. 5 5
      netbox/virtualization/views.py
  63. 9 1
      netbox/vpn/choices.py
  64. 3 3
      requirements.txt

+ 1 - 1
.github/ISSUE_TEMPLATE/01-feature_request.yaml

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

+ 1 - 1
.github/ISSUE_TEMPLATE/02-bug_report.yaml

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

+ 9 - 0
.github/workflows/update-translation-strings.yml

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

+ 4 - 0
docs/development/application-registry.md

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

+ 27 - 1
docs/release-notes/version-4.1.md

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

+ 6 - 0
netbox/core/apps.py

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

+ 6 - 1
netbox/dcim/forms/bulk_edit.py

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

+ 25 - 3
netbox/dcim/forms/bulk_import.py

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

+ 5 - 0
netbox/dcim/models/devices.py

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

+ 2 - 0
netbox/dcim/svg/racks.py

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

+ 5 - 5
netbox/dcim/views.py

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

+ 4 - 0
netbox/extras/views.py

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

+ 4 - 2
netbox/ipam/filtersets.py

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

+ 27 - 3
netbox/ipam/forms/bulk_import.py

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

+ 41 - 13
netbox/ipam/forms/model_forms.py

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

+ 3 - 1
netbox/netbox/api/pagination.py

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

+ 6 - 0
netbox/netbox/api/serializers/base.py

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

+ 2 - 0
netbox/netbox/context_managers.py

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

+ 7 - 3
netbox/netbox/middleware.py

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

+ 1 - 0
netbox/netbox/registry.py

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

+ 10 - 0
netbox/netbox/utils.py

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

+ 5 - 2
netbox/netbox/views/generic/bulk_views.py

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

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox-external.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js.map


+ 3 - 3
netbox/project-static/package.json

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

+ 8 - 8
netbox/project-static/yarn.lock

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

+ 1 - 1
netbox/templates/base/layout.html

@@ -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" %}">

+ 21 - 12
netbox/templates/dcim/device/render_config.html

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

+ 21 - 12
netbox/templates/virtualization/virtualmachine/render_config.html

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

BIN
netbox/translations/cs/LC_MESSAGES/django.mo


Разница между файлами не показана из-за своего большого размера
+ 1869 - 1541
netbox/translations/cs/LC_MESSAGES/django.po


BIN
netbox/translations/da/LC_MESSAGES/django.mo


Разница между файлами не показана из-за своего большого размера
+ 1385 - 1119
netbox/translations/da/LC_MESSAGES/django.po


BIN
netbox/translations/de/LC_MESSAGES/django.mo


Разница между файлами не показана из-за своего большого размера
+ 1386 - 1120
netbox/translations/de/LC_MESSAGES/django.po


Разница между файлами не показана из-за своего большого размера
+ 3585 - 3049
netbox/translations/en/LC_MESSAGES/django.po


BIN
netbox/translations/es/LC_MESSAGES/django.mo


Разница между файлами не показана из-за своего большого размера
+ 1385 - 1119
netbox/translations/es/LC_MESSAGES/django.po


BIN
netbox/translations/fr/LC_MESSAGES/django.mo


Разница между файлами не показана из-за своего большого размера
+ 1385 - 1119
netbox/translations/fr/LC_MESSAGES/django.po


BIN
netbox/translations/it/LC_MESSAGES/django.mo


Разница между файлами не показана из-за своего большого размера
+ 1385 - 1119
netbox/translations/it/LC_MESSAGES/django.po


BIN
netbox/translations/ja/LC_MESSAGES/django.mo


Разница между файлами не показана из-за своего большого размера
+ 2486 - 2071
netbox/translations/ja/LC_MESSAGES/django.po


BIN
netbox/translations/nl/LC_MESSAGES/django.mo


Разница между файлами не показана из-за своего большого размера
+ 1385 - 1119
netbox/translations/nl/LC_MESSAGES/django.po


BIN
netbox/translations/pl/LC_MESSAGES/django.mo


Разница между файлами не показана из-за своего большого размера
+ 1869 - 1541
netbox/translations/pl/LC_MESSAGES/django.po


BIN
netbox/translations/pt/LC_MESSAGES/django.mo


Разница между файлами не показана из-за своего большого размера
+ 1385 - 1119
netbox/translations/pt/LC_MESSAGES/django.po


BIN
netbox/translations/ru/LC_MESSAGES/django.mo


Разница между файлами не показана из-за своего большого размера
+ 1385 - 1119
netbox/translations/ru/LC_MESSAGES/django.po


BIN
netbox/translations/tr/LC_MESSAGES/django.mo


Разница между файлами не показана из-за своего большого размера
+ 1385 - 1119
netbox/translations/tr/LC_MESSAGES/django.po


BIN
netbox/translations/uk/LC_MESSAGES/django.mo


Разница между файлами не показана из-за своего большого размера
+ 1871 - 1543
netbox/translations/uk/LC_MESSAGES/django.po


BIN
netbox/translations/zh/LC_MESSAGES/django.mo


Разница между файлами не показана из-за своего большого размера
+ 2940 - 2479
netbox/translations/zh/LC_MESSAGES/django.po


+ 7 - 3
netbox/utilities/jinja2.py

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

+ 16 - 3
netbox/utilities/tests/test_api.py

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

+ 5 - 5
netbox/virtualization/views.py

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

+ 9 - 1
netbox/vpn/choices.py

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

+ 3 - 3
requirements.txt

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

Некоторые файлы не были показаны из-за большого количества измененных файлов