Ver código fonte

Merge pull request #18221 from netbox-community/develop

Release v4.1.8
Jeremy Stretch 1 ano atrás
pai
commit
b89601d93d
70 arquivos alterados com 27598 adições e 23149 exclusões
  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. 9 0
      docs/release-notes/index.md
  6. 27 1
      docs/release-notes/version-4.1.md
  7. 6 0
      netbox/core/apps.py
  8. 6 1
      netbox/dcim/forms/bulk_edit.py
  9. 25 3
      netbox/dcim/forms/bulk_import.py
  10. 5 0
      netbox/dcim/models/devices.py
  11. 2 0
      netbox/dcim/svg/racks.py
  12. 5 5
      netbox/dcim/views.py
  13. 4 0
      netbox/extras/views.py
  14. 4 2
      netbox/ipam/filtersets.py
  15. 27 3
      netbox/ipam/forms/bulk_import.py
  16. 41 8
      netbox/ipam/forms/model_forms.py
  17. 3 1
      netbox/netbox/api/pagination.py
  18. 6 0
      netbox/netbox/api/serializers/base.py
  19. 2 0
      netbox/netbox/context_managers.py
  20. 7 3
      netbox/netbox/middleware.py
  21. 1 0
      netbox/netbox/registry.py
  22. 10 0
      netbox/netbox/utils.py
  23. 5 2
      netbox/netbox/views/generic/bulk_views.py
  24. 142 710
      netbox/project-static/dist/graphiql/graphiql.min.js
  25. 0 0
      netbox/project-static/dist/netbox-external.css
  26. 0 0
      netbox/project-static/dist/netbox.js
  27. 0 0
      netbox/project-static/dist/netbox.js.map
  28. 2 2
      netbox/project-static/netbox-graphiql/package.json
  29. 4 4
      netbox/project-static/package.json
  30. 19 20
      netbox/project-static/src/select/classes/dynamicTomSelect.ts
  31. 2 2
      netbox/project-static/tsconfig.json
  32. 44 38
      netbox/project-static/yarn.lock
  33. 2 2
      netbox/release.yaml
  34. 1 1
      netbox/templates/base/layout.html
  35. 21 12
      netbox/templates/dcim/device/render_config.html
  36. 21 12
      netbox/templates/virtualization/virtualmachine/render_config.html
  37. BIN
      netbox/translations/cs/LC_MESSAGES/django.mo
  38. 1869 1541
      netbox/translations/cs/LC_MESSAGES/django.po
  39. BIN
      netbox/translations/da/LC_MESSAGES/django.mo
  40. 1385 1119
      netbox/translations/da/LC_MESSAGES/django.po
  41. BIN
      netbox/translations/de/LC_MESSAGES/django.mo
  42. 1386 1120
      netbox/translations/de/LC_MESSAGES/django.po
  43. 3585 3049
      netbox/translations/en/LC_MESSAGES/django.po
  44. BIN
      netbox/translations/es/LC_MESSAGES/django.mo
  45. 1385 1119
      netbox/translations/es/LC_MESSAGES/django.po
  46. BIN
      netbox/translations/fr/LC_MESSAGES/django.mo
  47. 1385 1119
      netbox/translations/fr/LC_MESSAGES/django.po
  48. BIN
      netbox/translations/it/LC_MESSAGES/django.mo
  49. 1385 1119
      netbox/translations/it/LC_MESSAGES/django.po
  50. BIN
      netbox/translations/ja/LC_MESSAGES/django.mo
  51. 2486 2071
      netbox/translations/ja/LC_MESSAGES/django.po
  52. BIN
      netbox/translations/nl/LC_MESSAGES/django.mo
  53. 1385 1119
      netbox/translations/nl/LC_MESSAGES/django.po
  54. BIN
      netbox/translations/pl/LC_MESSAGES/django.mo
  55. 1869 1541
      netbox/translations/pl/LC_MESSAGES/django.po
  56. BIN
      netbox/translations/pt/LC_MESSAGES/django.mo
  57. 1385 1119
      netbox/translations/pt/LC_MESSAGES/django.po
  58. BIN
      netbox/translations/ru/LC_MESSAGES/django.mo
  59. 1385 1119
      netbox/translations/ru/LC_MESSAGES/django.po
  60. BIN
      netbox/translations/tr/LC_MESSAGES/django.mo
  61. 1385 1119
      netbox/translations/tr/LC_MESSAGES/django.po
  62. BIN
      netbox/translations/uk/LC_MESSAGES/django.mo
  63. 1871 1543
      netbox/translations/uk/LC_MESSAGES/django.po
  64. BIN
      netbox/translations/zh/LC_MESSAGES/django.mo
  65. 2940 2479
      netbox/translations/zh/LC_MESSAGES/django.po
  66. 7 3
      netbox/utilities/jinja2.py
  67. 16 3
      netbox/utilities/tests/test_api.py
  68. 5 5
      netbox/virtualization/views.py
  69. 9 1
      netbox/vpn/choices.py
  70. 7 7
      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.

+ 9 - 0
docs/release-notes/index.md

@@ -10,6 +10,15 @@ Minor releases are published in April, August, and December of each calendar yea
 
 This page contains a history of all major and minor releases since NetBox v2.0. For more detail on a specific patch release, please see the release notes page for that specific minor release.
 
+#### [Version 4.1](./version-4.1.md) (September 2024)
+
+* Circuit Groups ([#7025](https://github.com/netbox-community/netbox/issues/7025))
+* VLAN Group ID Ranges ([#9627](https://github.com/netbox-community/netbox/issues/9627))
+* Nested Device Modules ([#10500](https://github.com/netbox-community/netbox/issues/10500))
+* Rack Types ([#12826](https://github.com/netbox-community/netbox/issues/12826))
+* Plugins Catalog Integration ([#14731](https://github.com/netbox-community/netbox/issues/14731))
+* User Notifications ([#15621](https://github.com/netbox-community/netbox/issues/15621))
+
 #### [Version 4.0](./version-4.0.md) (April 2024)
 
 * Complete UI Refresh ([#12128](https://github.com/netbox-community/netbox/issues/12128))

+ 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

@@ -359,6 +359,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,
@@ -438,7 +443,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

@@ -256,6 +256,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,
@@ -265,8 +272,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,
@@ -289,9 +301,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):
@@ -303,6 +315,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
@@ -2106,7 +2104,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
@@ -2124,17 +2123,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

@@ -1141,12 +1141,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,
@@ -1162,6 +1164,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,
             })
 
@@ -1186,6 +1189,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

@@ -211,8 +211,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

@@ -326,12 +326,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):
@@ -345,7 +350,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']}
@@ -358,16 +363,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):
 
@@ -386,6 +404,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 - 8
netbox/ipam/forms/model_forms.py

@@ -309,6 +309,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 = (
@@ -320,7 +324,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)')),
     )
@@ -328,8 +332,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):
@@ -348,7 +352,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 (
@@ -357,6 +361,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,
@@ -385,10 +392,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
@@ -400,6 +412,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)
 
@@ -421,6 +443,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(),
     'tables': collections.defaultdict(dict),
     'views': 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

@@ -744,7 +744,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()
@@ -758,7 +757,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
@@ -793,6 +792,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)

Diferenças do arquivo suprimidas por serem muito extensas
+ 142 - 710
netbox/project-static/dist/graphiql/graphiql.min.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
netbox/project-static/dist/netbox-external.css


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
netbox/project-static/dist/netbox.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
netbox/project-static/dist/netbox.js.map


+ 2 - 2
netbox/project-static/netbox-graphiql/package.json

@@ -6,8 +6,8 @@
   "license": "Apache-2.0",
   "private": true,
   "dependencies": {
-    "@graphiql/plugin-explorer": "3.2.2",
-    "graphiql": "3.7.1",
+    "@graphiql/plugin-explorer": "3.2.3",
+    "graphiql": "3.7.2",
     "graphql": "16.9.0",
     "js-cookie": "3.0.5",
     "react": "18.3.1",

+ 4 - 4
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,11 +27,11 @@
     "bootstrap": "5.3.3",
     "clipboard": "2.0.11",
     "flatpickr": "4.6.13",
-    "gridstack": "10.3.1",
+    "gridstack": "11.1.2",
     "htmx.org": "1.9.12",
     "query-string": "9.1.1",
-    "sass": "1.80.5",
-    "tom-select": "2.3.1",
+    "sass": "1.82.0",
+    "tom-select": "2.4.1",
     "typeface-inter": "3.18.1",
     "typeface-roboto-mono": "1.1.13"
   },

+ 19 - 20
netbox/project-static/src/select/classes/dynamicTomSelect.ts

@@ -1,18 +1,17 @@
-import { RecursivePartial, TomInput, TomOption, TomSettings } from 'tom-select/dist/types/types';
-import { addClasses } from 'tom-select/src/vanilla'
+import { RecursivePartial, TomOption, TomSettings } from 'tom-select/dist/types/types';
+import { TomInput } from 'tom-select/dist/cjs/types/core';
+import { addClasses } from 'tom-select/src/vanilla.ts';
 import queryString from 'query-string';
 import TomSelect from 'tom-select';
 import type { Stringifiable } from 'query-string';
 import { DynamicParamsMap } from './dynamicParamsMap';
 
 // Transitional
-import { QueryFilter, PathFilter } from '../types'
+import { QueryFilter, PathFilter } from '../types';
 import { getElement, replaceAll } from '../../util';
 
-
 // Extends TomSelect to provide enhanced fetching of options via the REST API
 export class DynamicTomSelect extends TomSelect {
-
   public readonly nullOption: Nullable<TomOption> = null;
 
   // Transitional code from APISelect
@@ -25,7 +24,7 @@ export class DynamicTomSelect extends TomSelect {
    * Overrides
    */
 
-  constructor( input_arg: string|TomInput, user_settings: RecursivePartial<TomSettings> ) {
+  constructor(input_arg: string | TomInput, user_settings: RecursivePartial<TomSettings>) {
     super(input_arg, user_settings);
 
     // Glean the REST API endpoint URL from the <select> element
@@ -34,7 +33,8 @@ export class DynamicTomSelect extends TomSelect {
     // Override any field names set as widget attributes
     this.valueField = this.input.getAttribute('ts-value-field') || this.settings.valueField;
     this.labelField = this.input.getAttribute('ts-label-field') || this.settings.labelField;
-    this.disabledField = this.input.getAttribute('ts-disabled-field') || this.settings.disabledField;
+    this.disabledField =
+      this.input.getAttribute('ts-disabled-field') || this.settings.disabledField;
     this.descriptionField = this.input.getAttribute('ts-description-field') || 'description';
     this.depthField = this.input.getAttribute('ts-depth-field') || '_depth';
     this.parentField = this.input.getAttribute('ts-parent-field') || null;
@@ -43,9 +43,9 @@ export class DynamicTomSelect extends TomSelect {
     // Set the null option (if any)
     const nullOption = this.input.getAttribute('data-null-option');
     if (nullOption) {
-      let valueField = this.settings.valueField;
-      let labelField = this.settings.labelField;
-      this.nullOption = {}
+      const valueField = this.settings.valueField;
+      const labelField = this.settings.labelField;
+      this.nullOption = {};
       this.nullOption[valueField] = 'null';
       this.nullOption[labelField] = nullOption;
     }
@@ -98,8 +98,8 @@ export class DynamicTomSelect extends TomSelect {
       .then(response => response.json())
       .then(apiData => {
         const results: Dict[] = apiData.results;
-        let options: Dict[] = []
-        for (let result of results) {
+        const options: Dict[] = [];
+        for (const result of results) {
           const option = self.getOptionFromData(result);
           options.push(option);
         }
@@ -108,10 +108,10 @@ export class DynamicTomSelect extends TomSelect {
       // Pass the options to the callback function
       .then(options => {
         self.loadCallback(options, []);
-      }).catch(()=>{
+      })
+      .catch(() => {
         self.loadCallback([], []);
       });
-
   }
 
   /**
@@ -155,14 +155,14 @@ export class DynamicTomSelect extends TomSelect {
 
   // Compile TomOption data from an API result
   getOptionFromData(data: Dict) {
-    let option: Dict = {
+    const option: Dict = {
       id: data[this.valueField],
       display: data[this.labelField],
       depth: data[this.depthField] || null,
       description: data[this.descriptionField] || null,
     };
     if (data[this.parentField]) {
-      let parent: Dict = data[this.parentField] as Dict;
+      const parent: Dict = data[this.parentField] as Dict;
       option['parent'] = parent[this.labelField];
     }
     if (data[this.countField]) {
@@ -171,7 +171,7 @@ export class DynamicTomSelect extends TomSelect {
     if (data[this.disabledField]) {
       option['disabled'] = data[this.disabledField];
     }
-    return option
+    return option;
   }
 
   /**
@@ -218,7 +218,6 @@ export class DynamicTomSelect extends TomSelect {
     }
   }
 
-
   // Parse the `data-url` attribute to add any variables to `pathValues` as keys with empty
   // values. As those keys' corresponding form fields' values change, `pathValues` will be
   // updated to reflect the new value.
@@ -297,7 +296,8 @@ export class DynamicTomSelect extends TomSelect {
       // value. For example, if the dependency is the `rack` field, and the `rack` field's value
       // is `1`, this element's URL would change from `/dcim/racks/{{rack}}/` to `/dcim/racks/1/`.
       const hasReplacement =
-        this.api_url.includes(`{{`) && Boolean(this.api_url.match(new RegExp(`({{(${id})}})`, 'g')));
+        this.api_url.includes(`{{`) &&
+        Boolean(this.api_url.match(new RegExp(`({{(${id})}})`, 'g')));
 
       if (hasReplacement) {
         if (element.value) {
@@ -349,5 +349,4 @@ export class DynamicTomSelect extends TomSelect {
     // Load new data.
     this.load(this.lastValue);
   }
-
 }

+ 2 - 2
netbox/project-static/tsconfig.json

@@ -1,10 +1,10 @@
 {
   "compilerOptions": {
     "forceConsistentCasingInFileNames": true,
+    // Needed for tom-select/src/vanilla.ts
+    "allowImportingTsExtensions": true,
     "allowSyntheticDefaultImports": true,
     "moduleResolution": "node",
-    // tom-select v2.3.1 raises several TS6133 errors with noUnusedParameters
-    "noUnusedParameters": false,
     "esModuleInterop": true,
     "isolatedModules": true,
     "noUnusedLocals": true,

+ 44 - 38
netbox/project-static/yarn.lock

@@ -200,17 +200,17 @@
   resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.2.tgz#d8bae93ac8b815b2bd7a98078cf91e2724ef11e5"
   integrity sha512-J4yDIIthosAsRZ5CPYP/jQvUAQtlZTTD/4suA08/FEnlxqW3sKS9iAhgsa9VYLZ6vDHn/ixJgIqRQPotoBjxIw==
 
-"@graphiql/plugin-explorer@3.2.2":
-  version "3.2.2"
-  resolved "https://registry.yarnpkg.com/@graphiql/plugin-explorer/-/plugin-explorer-3.2.2.tgz#973d6015b6db15041902e95c3e4b746473313eb6"
-  integrity sha512-zeBZJUAX9h+3nXw3GLHZoxi6wwYqDBU2L/xeSXSTagJhcLNW1Hwb/t/wb296hQ1x/9nyGySsTA0DQiiWV3rCBQ==
+"@graphiql/plugin-explorer@3.2.3":
+  version "3.2.3"
+  resolved "https://registry.yarnpkg.com/@graphiql/plugin-explorer/-/plugin-explorer-3.2.3.tgz#03854d7e62d6e24c6552ae6706e3945b9324fa23"
+  integrity sha512-yh5WXRqDPuKjVyNxUwXYjx8tImvVOx+2FGanLyjoAJP2LKQu6eDtButyJ8sExk1qW4+HCSrXxJNSPs4W7cYT3g==
   dependencies:
     graphiql-explorer "^0.9.0"
 
-"@graphiql/react@^0.26.2":
-  version "0.26.2"
-  resolved "https://registry.yarnpkg.com/@graphiql/react/-/react-0.26.2.tgz#3a1a01a569b624de8141c53eed24a7db9a523668"
-  integrity sha512-aO4GWf/kJmqrjO+PORT/NPxwGvPGlg+mwye1v8xAlf8Q9j7P0hVtVBawYaSLUCCfJ/QnH7JAP+0VRamyooZZCw==
+"@graphiql/react@^0.27.0":
+  version "0.27.0"
+  resolved "https://registry.yarnpkg.com/@graphiql/react/-/react-0.27.0.tgz#4475a0f4ddf25d8ebc1bfc538fb21f5f1d435916"
+  integrity sha512-K9ZKWd+ewodbS/1kewedmITeeKLUQswMOXwIv8XFLPt3Ondodji0vr1XXXsttlyl+V2QG/9tYVV2RJ9Ch5LdrA==
   dependencies:
     "@graphiql/toolkit" "^0.11.0"
     "@headlessui/react" "^1.7.15"
@@ -353,17 +353,17 @@
   resolved "https://registry.yarnpkg.com/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz#3dc35ba0f1e66b403c00b39344f870298ebb1c8e"
   integrity sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==
 
-"@orchidjs/sifter@^1.0.3":
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/@orchidjs/sifter/-/sifter-1.0.3.tgz#43f42519472282eb632d0a1589184f044d64129b"
-  integrity sha512-zCZbwKegHytfsPm8Amcfh7v/4vHqTAaOu6xFswBYcn8nznBOuseu6COB2ON7ez0tFV0mKL0nRNnCiZZA+lU9/g==
+"@orchidjs/sifter@^1.1.0":
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/@orchidjs/sifter/-/sifter-1.1.0.tgz#b36154ad0cda4898305d1ac44f318b41048a0438"
+  integrity sha512-mYwHCfr736cIWWdhhSZvDbf90AKt2xyrJspKFC3qyIJG1LtrJeJunYEqCGG4Aq2ijENbc4WkOjszcvNaIAS/pQ==
   dependencies:
-    "@orchidjs/unicode-variants" "^1.0.4"
+    "@orchidjs/unicode-variants" "^1.1.2"
 
-"@orchidjs/unicode-variants@^1.0.4":
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/@orchidjs/unicode-variants/-/unicode-variants-1.0.4.tgz#6d2f812e3b19545bba2d81caffff1204de9a6a58"
-  integrity sha512-NvVBRnZNE+dugiXERFsET1JlKZfM5lJDEpSMilKW4bToYJ7pxf0Zne78xyXB2ny2c2aHfJ6WLnz1AaTNHAmQeQ==
+"@orchidjs/unicode-variants@^1.1.2":
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/@orchidjs/unicode-variants/-/unicode-variants-1.1.2.tgz#1fd71791a67fdd1591ebe0dcaadd3964537a824e"
+  integrity sha512-5DobW1CHgnBROOEpFlEXytED5OosEWESFvg/VYmH0143oXcijYTprRYJTs+55HzGM4IqxiLFSuqEzu9mPNwVsA==
 
 "@parcel/watcher-android-arm64@2.4.1":
   version "2.4.1"
@@ -1883,12 +1883,12 @@ graphiql-explorer@^0.9.0:
   resolved "https://registry.yarnpkg.com/graphiql-explorer/-/graphiql-explorer-0.9.0.tgz#25f6b990bfc3e04e88c0cf419e28d12abe2c4fbe"
   integrity sha512-fZC/wsuatqiQDO2otchxriFO0LaWIo/ovF/CQJ1yOudmY0P7pzDiP+l9CEHUiWbizk3e99x6DQG4XG1VxA+d6A==
 
-graphiql@3.7.1:
-  version "3.7.1"
-  resolved "https://registry.yarnpkg.com/graphiql/-/graphiql-3.7.1.tgz#9fb727e15db443b22823389d13dc5d98c3ce0ff9"
-  integrity sha512-kmummedOrFYs0BI5evrVY0AerOYlaMt/Sc/e+Sta1x8X6vEMYWNeUUz/kKF2NQT5BcsR3FnNdFt1Gk2QMgueGQ==
+graphiql@3.7.2:
+  version "3.7.2"
+  resolved "https://registry.yarnpkg.com/graphiql/-/graphiql-3.7.2.tgz#6a754256f4f2e6268a64e585b0fe35bf38f1b87d"
+  integrity sha512-DL+KrX+aQdyzl+KwcqjlmdYdjyKegm7FcZJKkIQ1e56xn6Eoe8lw5F4t65gFex/45fHzv8e8CpaIcljxfJhO7A==
   dependencies:
-    "@graphiql/react" "^0.26.2"
+    "@graphiql/react" "^0.27.0"
 
 graphql-language-service@5.3.0, graphql-language-service@^5.3.0:
   version "5.3.0"
@@ -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@10.3.1:
-  version "10.3.1"
-  resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-10.3.1.tgz#4ed704279c40094fc1b9e3318f20b573f2fe9f40"
-  integrity sha512-Ra82k/88gdeiu3ZP40COS4bI4sGhNQlZAaAQ6szfPfr68zVpsXxiyLKr5zYcTpKX4jjcwyNsNNdcV1tDJc71fA==
+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"
@@ -1970,6 +1970,11 @@ immutable@^4.0.0:
   resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.7.tgz#c70145fc90d89fb02021e65c84eb0226e4e5a381"
   integrity sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==
 
+immutable@^5.0.2:
+  version "5.0.3"
+  resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.0.3.tgz#aa037e2313ea7b5d400cd9298fa14e404c933db1"
+  integrity sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==
+
 import-fresh@^3.2.1:
   version "3.3.0"
   resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b"
@@ -2656,15 +2661,16 @@ safe-regex-test@^1.0.3:
     es-errors "^1.3.0"
     is-regex "^1.1.4"
 
-sass@1.80.5:
-  version "1.80.5"
-  resolved "https://registry.yarnpkg.com/sass/-/sass-1.80.5.tgz#0ba965223d44df22497f2966b498cf5c453fae8f"
-  integrity sha512-TQd2aoQl/+zsxRMEDSxVdpPIqeq9UFc6pr7PzkugiTx3VYCFPUaa3P4RrBQsqok4PO200Vkz0vXQBNlg7W907g==
+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:
-    "@parcel/watcher" "^2.4.1"
     chokidar "^4.0.0"
-    immutable "^4.0.0"
+    immutable "^5.0.2"
     source-map-js ">=0.6.2 <2.0.0"
+  optionalDependencies:
+    "@parcel/watcher" "^2.4.1"
 
 sass@^1.71.1:
   version "1.77.8"
@@ -2864,13 +2870,13 @@ toggle-selection@^1.0.6:
   resolved "https://registry.yarnpkg.com/toggle-selection/-/toggle-selection-1.0.6.tgz#6e45b1263f2017fa0acc7d89d78b15b8bf77da32"
   integrity sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==
 
-tom-select@2.3.1:
-  version "2.3.1"
-  resolved "https://registry.yarnpkg.com/tom-select/-/tom-select-2.3.1.tgz#df338d9082874cd0bceb3bee87ed0184447c47f1"
-  integrity sha512-QS4vnOcB6StNGqX4sGboGXL2fkhBF2gIBB+8Hwv30FZXYPn0CyYO8kkdATRvwfCTThxiR4WcXwKJZ3cOmtI9eg==
+tom-select@2.4.1:
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/tom-select/-/tom-select-2.4.1.tgz#6a0b6df8af3df7b09b22dd965eb75ce4d1c547bc"
+  integrity sha512-adI8H8+wk8RRzHYLQ3bXSk2Q+FAq/kzAATrcWlJ2fbIrEzb0VkwaXzKHTAlBwSJrhqbPJvhV/0eypFkED/nAug==
   dependencies:
-    "@orchidjs/sifter" "^1.0.3"
-    "@orchidjs/unicode-variants" "^1.0.4"
+    "@orchidjs/sifter" "^1.1.0"
+    "@orchidjs/unicode-variants" "^1.1.2"
 
 ts-api-utils@^1.3.0:
   version "1.3.0"

+ 2 - 2
netbox/release.yaml

@@ -1,3 +1,3 @@
-version: "4.1.7"
+version: "4.1.8"
 edition: "Community"
-published: "2024-11-21"
+published: "2024-12-12"

+ 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


Diferenças do arquivo suprimidas por serem muito extensas
+ 1869 - 1541
netbox/translations/cs/LC_MESSAGES/django.po


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


Diferenças do arquivo suprimidas por serem muito extensas
+ 1385 - 1119
netbox/translations/da/LC_MESSAGES/django.po


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


Diferenças do arquivo suprimidas por serem muito extensas
+ 1386 - 1120
netbox/translations/de/LC_MESSAGES/django.po


Diferenças do arquivo suprimidas por serem muito extensas
+ 3585 - 3049
netbox/translations/en/LC_MESSAGES/django.po


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


Diferenças do arquivo suprimidas por serem muito extensas
+ 1385 - 1119
netbox/translations/es/LC_MESSAGES/django.po


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


Diferenças do arquivo suprimidas por serem muito extensas
+ 1385 - 1119
netbox/translations/fr/LC_MESSAGES/django.po


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


Diferenças do arquivo suprimidas por serem muito extensas
+ 1385 - 1119
netbox/translations/it/LC_MESSAGES/django.po


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


Diferenças do arquivo suprimidas por serem muito extensas
+ 2486 - 2071
netbox/translations/ja/LC_MESSAGES/django.po


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


Diferenças do arquivo suprimidas por serem muito extensas
+ 1385 - 1119
netbox/translations/nl/LC_MESSAGES/django.po


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


Diferenças do arquivo suprimidas por serem muito extensas
+ 1869 - 1541
netbox/translations/pl/LC_MESSAGES/django.po


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


Diferenças do arquivo suprimidas por serem muito extensas
+ 1385 - 1119
netbox/translations/pt/LC_MESSAGES/django.po


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


Diferenças do arquivo suprimidas por serem muito extensas
+ 1385 - 1119
netbox/translations/ru/LC_MESSAGES/django.po


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


Diferenças do arquivo suprimidas por serem muito extensas
+ 1385 - 1119
netbox/translations/tr/LC_MESSAGES/django.po


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


Diferenças do arquivo suprimidas por serem muito extensas
+ 1871 - 1543
netbox/translations/uk/LC_MESSAGES/django.po


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


Diferenças do arquivo suprimidas por serem muito extensas
+ 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
@@ -425,7 +423,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
@@ -443,17 +442,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')),
     ]
 
 

+ 7 - 7
requirements.txt

@@ -1,4 +1,4 @@
-Django==5.0.9
+Django==5.0.10
 django-cors-headers==4.6.0
 django-debug-toolbar==4.4.6
 django-filter==24.3
@@ -14,16 +14,16 @@ django-taggit==6.1.0
 django-tables2==2.7.0
 django-timezone-field==7.0
 djangorestframework==3.15.2
-drf-spectacular==0.27.2
-drf-spectacular-sidecar==2024.11.1
+drf-spectacular==0.28.0
+drf-spectacular-sidecar==2024.12.1
 feedparser==6.0.11
 gunicorn==23.0.0
 Jinja2==3.1.4
 Markdown==3.7
-mkdocs-material==9.5.45
+mkdocs-material==9.5.48
 mkdocstrings[python-legacy]==0.27.0
 netaddr==1.3.0
-nh3==0.2.18
+nh3==0.2.19
 Pillow==11.0.0
 psycopg[c,pool]==3.2.3
 PyYAML==6.0.2
@@ -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.251.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

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff