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

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

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

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

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

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

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

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

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

+ 6 - 0
netbox/core/apps.py

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

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

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

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

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

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

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

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

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

+ 5 - 5
netbox/dcim/views.py

@@ -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))
-                rendered_config = traceback.format_exc()
+                error_message = _("An error occurred while rendering the template: {error}").format(error=e)
 
 
         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,
         }
         }
 
 
 
 

+ 4 - 0
netbox/extras/views.py

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

+ 4 - 2
netbox/ipam/filtersets.py

@@ -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(qs_filter)
+        return queryset.filter(
+            Q(name__icontains=value) |
+            Q(description__icontains=value)
+        )
 
 
 
 
 class ASNFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
 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'),
         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
 
 
 
 

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

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

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

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

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

+ 2 - 0
netbox/netbox/context_managers.py

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

+ 7 - 3
netbox/netbox/middleware.py

@@ -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.
-        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)
             response = self.get_response(request)
 
 
         # Check if language cookie should be renewed
         # Check if language cookie should be renewed

+ 1 - 0
netbox/netbox/registry.py

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

+ 10 - 0
netbox/netbox/utils.py

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

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

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

Разница между файлами не показана из-за своего большого размера
+ 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",
   "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"

+ 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"
   resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.9.0.tgz#1c310e63f16a49ce1fbb230bd0a000e99f6f115f"
   integrity sha512-GGTKBX4SD7Wdb8mqeDLni2oaRGYQWjWHGKPQ24ZMnUtKfcsVoiv4uX8+LJr1K6U5VW2Lu1BwJnj7uiori0YtRw==
   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:
 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:
-  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:
   dependencies:
     chokidar "^4.0.0"
     chokidar "^4.0.0"
     immutable "^5.0.2"
     immutable "^5.0.2"

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

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

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

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

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

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

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

+ 5 - 5
netbox/virtualization/views.py

@@ -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))
-                rendered_config = traceback.format_exc()
+                error_message = _("An error occurred while rendering the template: {error}").format(error=e)
 
 
         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,
         }
         }
 
 
 
 

+ 9 - 1
netbox/vpn/choices.py

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

+ 3 - 3
requirements.txt

@@ -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-django==0.50.0
+strawberry-graphql==0.253.1
+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

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