Преглед изворни кода

Merge branch 'develop' into feature

Jeremy Stretch пре 1 година
родитељ
комит
02ae91589d
61 измењених фајлова са 8249 додато и 7366 уклоњено
  1. 1 1
      .github/ISSUE_TEMPLATE/bug_report.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/feature_request.yaml
  3. 11 0
      docs/configuration/error-reporting.md
  4. 2 2
      docs/configuration/system.md
  5. 2 0
      docs/development/release-checklist.md
  6. 16 0
      docs/plugins/removal.md
  7. 37 1
      docs/release-notes/version-4.0.md
  8. 2 2
      netbox/account/views.py
  9. 4 0
      netbox/circuits/choices.py
  10. 0 3
      netbox/circuits/forms/bulk_import.py
  11. 1 1
      netbox/core/views.py
  12. 12 1
      netbox/dcim/filtersets.py
  13. 1 13
      netbox/dcim/forms/bulk_import.py
  14. 12 0
      netbox/dcim/forms/filtersets.py
  15. 17 4
      netbox/dcim/tests/test_filtersets.py
  16. 15 0
      netbox/dcim/views.py
  17. 7 7
      netbox/extras/dashboard/widgets.py
  18. 3 0
      netbox/extras/events.py
  19. 0 3
      netbox/extras/forms/bulk_import.py
  20. 7 2
      netbox/extras/management/commands/reindex.py
  21. 1 1
      netbox/extras/models/customfields.py
  22. 26 3
      netbox/extras/tests/test_event_rules.py
  23. 12 0
      netbox/ipam/views.py
  24. 3 2
      netbox/netbox/forms/__init__.py
  25. 5 0
      netbox/netbox/middleware.py
  26. 5 5
      netbox/netbox/navigation/menu.py
  27. 2 1
      netbox/netbox/search/backends.py
  28. 19 1
      netbox/netbox/settings.py
  29. 1 1
      netbox/netbox/views/generic/bulk_views.py
  30. 3 0
      netbox/netbox/views/generic/object_views.py
  31. 0 0
      netbox/project-static/dist/netbox.js
  32. 0 0
      netbox/project-static/dist/netbox.js.map
  33. 1 1
      netbox/project-static/package.json
  34. 12 4
      netbox/project-static/src/select/classes/dynamicTomSelect.ts
  35. 4 4
      netbox/project-static/yarn.lock
  36. 1 1
      netbox/templates/core/system.html
  37. 26 19
      netbox/templates/dcim/device.html
  38. 1 1
      netbox/templates/generic/object_list.html
  39. 8 6
      netbox/templates/inc/table_controls_htmx.html
  40. 4 4
      netbox/templates/ipam/vlan_edit.html
  41. 2 1
      netbox/tenancy/views.py
  42. BIN
      netbox/translations/de/LC_MESSAGES/django.mo
  43. 218 214
      netbox/translations/de/LC_MESSAGES/django.po
  44. 232 228
      netbox/translations/en/LC_MESSAGES/django.po
  45. BIN
      netbox/translations/fr/LC_MESSAGES/django.mo
  46. 234 230
      netbox/translations/fr/LC_MESSAGES/django.po
  47. BIN
      netbox/translations/pt/LC_MESSAGES/django.mo
  48. 232 229
      netbox/translations/pt/LC_MESSAGES/django.po
  49. BIN
      netbox/translations/ru/LC_MESSAGES/django.mo
  50. 216 213
      netbox/translations/ru/LC_MESSAGES/django.po
  51. BIN
      netbox/translations/tr/LC_MESSAGES/django.mo
  52. 229 225
      netbox/translations/tr/LC_MESSAGES/django.po
  53. BIN
      netbox/translations/zh/LC_MESSAGES/django.mo
  54. 6576 5915
      netbox/translations/zh/LC_MESSAGES/django.po
  55. 2 0
      netbox/utilities/fields.py
  56. 1 1
      netbox/utilities/querydict.py
  57. 2 2
      netbox/virtualization/models/virtualmachines.py
  58. 5 0
      netbox/virtualization/views.py
  59. 4 2
      netbox/vpn/api/serializers_/crypto.py
  60. 9 9
      requirements.txt
  61. 2 2
      upgrade.sh

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

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

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

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

+ 11 - 0
docs/configuration/error-reporting.md

@@ -31,6 +31,17 @@ The sampling rate for errors. Must be a value between 0 (disabled) and 1.0 (repo
 
 ---
 
+## SENTRY_SEND_DEFAULT_PII
+
+Default: False
+
+Maps to the Sentry SDK's [`send_default_pii`](https://docs.sentry.io/platforms/python/configuration/options/#send-default-pii) parameter. If enabled, certain personally identifiable information (PII) is added.
+
+!!! warning "Sensitive data"
+    If you enable this option, be aware that sensitive data such as cookies and authentication tokens will be logged.
+
+---
+
 ## SENTRY_TAGS
 
 An optional dictionary of tag names and values to apply to Sentry error reports.For example:

+ 2 - 2
docs/configuration/system.md

@@ -177,7 +177,7 @@ The dotted path to the desired search backend class. `CachedValueSearchBackend`
 
 Default: None (local storage)
 
-The backend storage engine for handling uploaded files (e.g. image attachments). NetBox supports integration with the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) package, which provides backends for several popular file storage services. If not configured, local filesystem storage will be used.
+The backend storage engine for handling uploaded files (e.g. image attachments). NetBox supports integration with the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) and [`django-storage-swift`](https://github.com/dennisv/django-storage-swift) packages, which provide backends for several popular file storage services. If not configured, local filesystem storage will be used.
 
 The configuration parameters for the specified storage backend are defined under the `STORAGE_CONFIG` setting.
 
@@ -187,7 +187,7 @@ The configuration parameters for the specified storage backend are defined under
 
 Default: Empty
 
-A dictionary of configuration parameters for the storage backend configured as `STORAGE_BACKEND`. The specific parameters to be used here are specific to each backend; see the [`django-storages` documentation](https://django-storages.readthedocs.io/en/stable/) for more detail.
+A dictionary of configuration parameters for the storage backend configured as `STORAGE_BACKEND`. The specific parameters to be used here are specific to each backend; see the documentation for your selected backend ([`django-storages`](https://django-storages.readthedocs.io/en/stable/) or [`django-storage-swift`](https://github.com/dennisv/django-storage-swift)) for more detail.
 
 If `STORAGE_BACKEND` is not defined, this setting will be ignored.
 

+ 2 - 0
docs/development/release-checklist.md

@@ -135,4 +135,6 @@ First, run the `build-site` action, by navigating to Actions > build-site > Run
 
 Once the documentation files have been compiled, they must be published by running the `deploy-kinsta` action. Select the desired deployment environment (staging or production) and specify `latest` as the deploy tag.
 
+Clear the CDN cache from the [Kinsta](https://my.kinsta.com/) portal. Navigate to _Sites_ / _NetBox Labs_ / _Live_, select _CDN_ in the left-nav, click the _Clear CDN cache_ button, and confirm the clear operation.
+
 Finally, verify that the documentation at <https://netboxlabs.com/docs/netbox/en/stable/> has been updated.

+ 16 - 0
docs/plugins/removal.md

@@ -70,3 +70,19 @@ DROP TABLE
 netbox=> DROP TABLE pluginname_bar;
 DROP TABLE
 ```
+
+### Remove the Django Migration Records
+
+After removing the tables created by a plugin, the migrations that created the tables need to be removed from Django's migration history as well. This is necessary to make it possible to reinstall the plugin at a later time. If the migration history were left in place, Django would skip all migrations that were executed in the course of a previous installation, which would cause the plugin to fail after reinstallation.
+
+```no-highlight
+netbox=> SELECT * FROM django_migrations WHERE app='pluginname';
+ id  |    app     |          name          |            applied
+-----+------------+------------------------+-------------------------------
+ 492 | pluginname | 0001_initial           | 2023-12-21 11:59:59.325995+00
+ 493 | pluginname | 0002_add_foo           | 2023-12-21 11:59:59.330026+00
+netbox=> DELETE FROM django_migrations WHERE app='pluginname';
+```
+
+!!! warning
+    Exercise extreme caution when altering Django system tables. Users are strongly encouraged to perform a backup of their database immediately before taking these actions.

+ 37 - 1
docs/release-notes/version-4.0.md

@@ -1,6 +1,42 @@
 # NetBox v4.0
 
-## v4.0.7 (FUTURE)
+## v4.0.8 (FUTURE)
+
+---
+
+## v4.0.7 (2024-07-09)
+
+### Enhancements
+
+* [#14554](https://github.com/netbox-community/netbox/issues/14554) - Add support for [django-storage-swift](https://github.com/dennisv/django-storage-swift) storage backend
+* [#16424](https://github.com/netbox-community/netbox/issues/16424) - Enable filtering of devices by cluster and cluster group
+* [#16716](https://github.com/netbox-community/netbox/issues/16716) - Display NAT address (if any) for OOB IP address under device view
+* [#16725](https://github.com/netbox-community/netbox/issues/16725) - Always position the admin section last in the navigation menu
+* [#16791](https://github.com/netbox-community/netbox/issues/16791) - Add 200 & 400 Gbps selections for circuit termination port speed
+* [#16802](https://github.com/netbox-community/netbox/issues/16802) - Introduce `SENTRY_SEND_DEFAULT_PII` configuration parameter and disable PII export by default
+* [#16817](https://github.com/netbox-community/netbox/issues/16817) - Add 200 & 400 Gbps selections for circuit commit rate
+
+### Bug Fixes
+
+* [#16523](https://github.com/netbox-community/netbox/issues/16523) - Restore highlighting of current device in virtual chassis members panel
+* [#16654](https://github.com/netbox-community/netbox/issues/16654) - Fix parent item assignment for inventory item bulk import
+* [#16657](https://github.com/netbox-community/netbox/issues/16657) - Fix translation of object types in global search
+* [#16679](https://github.com/netbox-community/netbox/issues/16679) - Avoid overwriting custom JSON fields during bulk edit
+* [#16689](https://github.com/netbox-community/netbox/issues/16689) - System configuration view should reflect static parameters when no config revisions exist
+* [#16714](https://github.com/netbox-community/netbox/issues/16714) - Fix cloning of device types with 0U height
+* [#16721](https://github.com/netbox-community/netbox/issues/16721) - Fix errant API request after deselecting a rack in device edit form
+* [#16723](https://github.com/netbox-community/netbox/issues/16723) - Fix escaping of path to virtual environment in `upgrade.sh`
+* [#16735](https://github.com/netbox-community/netbox/issues/16735) - Object list "results" tab should show a count of zero when empty
+* [#16747](https://github.com/netbox-community/netbox/issues/16747) - Avoid clearing entire search cache when manually reindexing specific apps/models
+* [#16758](https://github.com/netbox-community/netbox/issues/16758) - Ensure manually selected lagnuage persists across browser sessions
+* [#16779](https://github.com/netbox-community/netbox/issues/16779) - Fix saved filter selection for child object lists
+* [#16780](https://github.com/netbox-community/netbox/issues/16780) - IKE proposal created via REST API should not require authentication_algorithm
+* [#16796](https://github.com/netbox-community/netbox/issues/16796) - Allow assignment of VM with no site to a cluster with a site
+* [#16806](https://github.com/netbox-community/netbox/issues/16806) - Fix redirect URL when creating contact assignments with "add another" button
+* [#16807](https://github.com/netbox-community/netbox/issues/16807) - Fix layout of VLAN edit form when custom fields are present
+* [#16808](https://github.com/netbox-community/netbox/issues/16808) - Fix event rule triggering in scenario where objects are updated immediately prior to deletion
+* [#16813](https://github.com/netbox-community/netbox/issues/16813) - Fix AttributeError exception when filtering bookmarks in dashboard widget by object type
+* [#16843](https://github.com/netbox-community/netbox/issues/16843) - Permit creation of IKE policies via REST API without specifying an IKE mode
 
 ---
 

+ 2 - 2
netbox/account/views.py

@@ -113,7 +113,7 @@ class LoginView(View):
 
             # Set the user's preferred language (if any)
             if language := request.user.config.get('locale.language'):
-                response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language)
+                response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language, max_age=request.session.get_expiry_age())
 
             return response
 
@@ -208,7 +208,7 @@ class UserConfigView(LoginRequiredMixin, View):
 
             # Set/clear language cookie
             if language := form.cleaned_data['locale.language']:
-                response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language)
+                response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language, max_age=request.session.get_expiry_age())
             else:
                 response.delete_cookie(settings.LANGUAGE_COOKIE_NAME)
 

+ 4 - 0
netbox/circuits/choices.py

@@ -38,6 +38,8 @@ class CircuitCommitRateChoices(ChoiceSet):
         (25000000, '25 Gbps'),
         (40000000, '40 Gbps'),
         (100000000, '100 Gbps'),
+        (200000000, '200 Gbps'),
+        (400000000, '400 Gbps'),
         (1544, 'T1 (1.544 Mbps)'),
         (2048, 'E1 (2.048 Mbps)'),
     ]
@@ -69,6 +71,8 @@ class CircuitTerminationPortSpeedChoices(ChoiceSet):
         (25000000, '25 Gbps'),
         (40000000, '40 Gbps'),
         (100000000, '100 Gbps'),
+        (200000000, '200 Gbps'),
+        (400000000, '400 Gbps'),
         (1544, 'T1 (1.544 Mbps)'),
         (2048, 'E1 (2.048 Mbps)'),
     ]

+ 0 - 3
netbox/circuits/forms/bulk_import.py

@@ -66,9 +66,6 @@ class CircuitTypeImportForm(NetBoxModelImportForm):
     class Meta:
         model = CircuitType
         fields = ('name', 'slug', 'color', 'description', 'tags')
-        help_texts = {
-            'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
-        }
 
 
 class CircuitImportForm(NetBoxModelImportForm):

+ 1 - 1
netbox/core/views.py

@@ -625,7 +625,7 @@ class SystemView(UserPassesTestMixin, View):
             config = ConfigRevision.objects.get(pk=cache.get('config_version'))
         except ConfigRevision.DoesNotExist:
             # Fall back to using the active config data if no record is found
-            config = ConfigRevision(data=get_config().defaults)
+            config = get_config()
 
         # Raw data export
         if 'export' in request.GET:

+ 12 - 1
netbox/dcim/filtersets.py

@@ -20,7 +20,7 @@ from utilities.filters import (
     ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter,
     NumericArrayFilter, TreeNodeMultipleChoiceFilter,
 )
-from virtualization.models import Cluster
+from virtualization.models import Cluster, ClusterGroup
 from vpn.models import L2VPN
 from wireless.choices import WirelessRoleChoices, WirelessChannelChoices
 from wireless.models import WirelessLAN, WirelessLink
@@ -1012,6 +1012,17 @@ class DeviceFilterSet(
         queryset=Cluster.objects.all(),
         label=_('VM cluster (ID)'),
     )
+    cluster_group = django_filters.ModelMultipleChoiceFilter(
+        field_name='cluster__group__slug',
+        queryset=ClusterGroup.objects.all(),
+        to_field_name='slug',
+        label=_('Cluster group (slug)'),
+    )
+    cluster_group_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='cluster__group',
+        queryset=ClusterGroup.objects.all(),
+        label=_('Cluster group (ID)'),
+    )
     model = django_filters.ModelMultipleChoiceFilter(
         field_name='device_type__slug',
         queryset=DeviceType.objects.all(),

+ 1 - 13
netbox/dcim/forms/bulk_import.py

@@ -174,9 +174,6 @@ class RackRoleImportForm(NetBoxModelImportForm):
     class Meta:
         model = RackRole
         fields = ('name', 'slug', 'color', 'description', 'tags')
-        help_texts = {
-            'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
-        }
 
 
 class RackImportForm(NetBoxModelImportForm):
@@ -384,9 +381,6 @@ class DeviceRoleImportForm(NetBoxModelImportForm):
     class Meta:
         model = DeviceRole
         fields = ('name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags')
-        help_texts = {
-            'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
-        }
 
 
 class PlatformImportForm(NetBoxModelImportForm):
@@ -1052,7 +1046,7 @@ class InventoryItemImportForm(NetBoxModelImportForm):
     class Meta:
         model = InventoryItem
         fields = (
-            'device', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered',
+            'device', 'name', 'label', 'role', 'manufacturer', 'parent', 'part_id', 'serial', 'asset_tag', 'discovered',
             'description', 'tags', 'component_type', 'component_name',
         )
 
@@ -1104,9 +1098,6 @@ class InventoryItemRoleImportForm(NetBoxModelImportForm):
     class Meta:
         model = InventoryItemRole
         fields = ('name', 'slug', 'color', 'description')
-        help_texts = {
-            'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
-        }
 
 
 #
@@ -1183,9 +1174,6 @@ class CableImportForm(NetBoxModelImportForm):
             'side_a_device', 'side_a_type', 'side_a_name', 'side_b_device', 'side_b_type', 'side_b_name', 'type',
             'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'comments', 'tags',
         ]
-        help_texts = {
-            'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
-        }
 
     def _clean_side(self, side):
         """

+ 12 - 0
netbox/dcim/forms/filtersets.py

@@ -14,6 +14,7 @@ from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_ch
 from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField
 from utilities.forms.rendering import FieldSet
 from utilities.forms.widgets import NumberWithOptions
+from virtualization.models import Cluster, ClusterGroup
 from vpn.models import L2VPN
 from wireless.choices import *
 
@@ -655,6 +656,7 @@ class DeviceFilterForm(
             'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports',
             name=_('Components')
         ),
+        FieldSet('cluster_group_id', 'cluster_id', name=_('Cluster')),
         FieldSet(
             'has_primary_ip', 'has_oob_ip', 'virtual_chassis_member', 'config_template_id', 'local_context_data',
             'has_virtual_device_context',
@@ -821,6 +823,16 @@ class DeviceFilterForm(
             choices=BOOLEAN_WITH_BLANK_CHOICES
         )
     )
+    cluster_id = DynamicModelMultipleChoiceField(
+        queryset=Cluster.objects.all(),
+        required=False,
+        label=_('Cluster')
+    )
+    cluster_group_id = DynamicModelMultipleChoiceField(
+        queryset=ClusterGroup.objects.all(),
+        required=False,
+        label=_('Cluster group')
+    )
     tag = TagFilterField(model)
 
 

+ 17 - 4
netbox/dcim/tests/test_filtersets.py

@@ -9,7 +9,7 @@ from ipam.models import ASN, IPAddress, RIR, VRF
 from netbox.choices import ColorChoices
 from tenancy.models import Tenant, TenantGroup
 from utilities.testing import ChangeLoggedFilterSetTests, create_test_device
-from virtualization.models import Cluster, ClusterType
+from virtualization.models import Cluster, ClusterType, ClusterGroup
 from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
 
 User = get_user_model()
@@ -1959,10 +1959,16 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
         Rack.objects.bulk_create(racks)
 
         cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
+        cluster_groups = (
+            ClusterGroup(name='Cluster Group 1', slug='cluster-group-1'),
+            ClusterGroup(name='Cluster Group 2', slug='cluster-group-2'),
+            ClusterGroup(name='Cluster Group 3', slug='cluster-group-3'),
+        )
+        ClusterGroup.objects.bulk_create(cluster_groups)
         clusters = (
-            Cluster(name='Cluster 1', type=cluster_type),
-            Cluster(name='Cluster 2', type=cluster_type),
-            Cluster(name='Cluster 3', type=cluster_type),
+            Cluster(name='Cluster 1', type=cluster_type, group=cluster_groups[0]),
+            Cluster(name='Cluster 2', type=cluster_type, group=cluster_groups[1]),
+            Cluster(name='Cluster 3', type=cluster_type, group=cluster_groups[2]),
         )
         Cluster.objects.bulk_create(clusters)
 
@@ -2213,6 +2219,13 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'cluster_id': [clusters[0].pk, clusters[1].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_cluster_group(self):
+        cluster_groups = ClusterGroup.objects.all()[:2]
+        params = {'cluster_group_id': [cluster_groups[0].pk, cluster_groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'cluster_group': [cluster_groups[0].slug, cluster_groups[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_model(self):
         params = {'model': ['model-1', 'model-2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

+ 15 - 0
netbox/dcim/views.py

@@ -31,6 +31,7 @@ from utilities.views import (
     GetRelatedModelsMixin, GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view
 )
 from virtualization.filtersets import VirtualMachineFilterSet
+from virtualization.forms import VirtualMachineFilterForm
 from virtualization.models import VirtualMachine
 from virtualization.tables import VirtualMachineTable
 from . import filtersets, forms, tables
@@ -679,6 +680,7 @@ class RackRackReservationsView(generic.ObjectChildrenView):
     child_model = RackReservation
     table = tables.RackReservationTable
     filterset = filtersets.RackReservationFilterSet
+    filterset_form = forms.RackReservationFilterForm
     template_name = 'dcim/rack/reservations.html'
     tab = ViewTab(
         label=_('Reservations'),
@@ -697,6 +699,7 @@ class RackNonRackedView(generic.ObjectChildrenView):
     child_model = Device
     table = tables.DeviceTable
     filterset = filtersets.DeviceFilterSet
+    filterset_form = forms.DeviceFilterForm
     template_name = 'dcim/rack/non_racked_devices.html'
     tab = ViewTab(
         label=_('Non-Racked Devices'),
@@ -1835,6 +1838,7 @@ class DeviceConsolePortsView(DeviceComponentsView):
     child_model = ConsolePort
     table = tables.DeviceConsolePortTable
     filterset = filtersets.ConsolePortFilterSet
+    filterset_form = forms.ConsolePortFilterForm
     template_name = 'dcim/device/consoleports.html',
     tab = ViewTab(
         label=_('Console Ports'),
@@ -1850,6 +1854,7 @@ class DeviceConsoleServerPortsView(DeviceComponentsView):
     child_model = ConsoleServerPort
     table = tables.DeviceConsoleServerPortTable
     filterset = filtersets.ConsoleServerPortFilterSet
+    filterset_form = forms.ConsoleServerPortFilterForm
     template_name = 'dcim/device/consoleserverports.html'
     tab = ViewTab(
         label=_('Console Server Ports'),
@@ -1865,6 +1870,7 @@ class DevicePowerPortsView(DeviceComponentsView):
     child_model = PowerPort
     table = tables.DevicePowerPortTable
     filterset = filtersets.PowerPortFilterSet
+    filterset_form = forms.PowerPortFilterForm
     template_name = 'dcim/device/powerports.html'
     tab = ViewTab(
         label=_('Power Ports'),
@@ -1880,6 +1886,7 @@ class DevicePowerOutletsView(DeviceComponentsView):
     child_model = PowerOutlet
     table = tables.DevicePowerOutletTable
     filterset = filtersets.PowerOutletFilterSet
+    filterset_form = forms.PowerOutletFilterForm
     template_name = 'dcim/device/poweroutlets.html'
     tab = ViewTab(
         label=_('Power Outlets'),
@@ -1895,6 +1902,7 @@ class DeviceInterfacesView(DeviceComponentsView):
     child_model = Interface
     table = tables.DeviceInterfaceTable
     filterset = filtersets.InterfaceFilterSet
+    filterset_form = forms.InterfaceFilterForm
     template_name = 'dcim/device/interfaces.html'
     tab = ViewTab(
         label=_('Interfaces'),
@@ -1916,6 +1924,7 @@ class DeviceFrontPortsView(DeviceComponentsView):
     child_model = FrontPort
     table = tables.DeviceFrontPortTable
     filterset = filtersets.FrontPortFilterSet
+    filterset_form = forms.FrontPortFilterForm
     template_name = 'dcim/device/frontports.html'
     tab = ViewTab(
         label=_('Front Ports'),
@@ -1931,6 +1940,7 @@ class DeviceRearPortsView(DeviceComponentsView):
     child_model = RearPort
     table = tables.DeviceRearPortTable
     filterset = filtersets.RearPortFilterSet
+    filterset_form = forms.RearPortFilterForm
     template_name = 'dcim/device/rearports.html'
     tab = ViewTab(
         label=_('Rear Ports'),
@@ -1946,6 +1956,7 @@ class DeviceModuleBaysView(DeviceComponentsView):
     child_model = ModuleBay
     table = tables.DeviceModuleBayTable
     filterset = filtersets.ModuleBayFilterSet
+    filterset_form = forms.ModuleBayFilterForm
     template_name = 'dcim/device/modulebays.html'
     actions = {
         **DEFAULT_ACTION_PERMISSIONS,
@@ -1965,6 +1976,7 @@ class DeviceDeviceBaysView(DeviceComponentsView):
     child_model = DeviceBay
     table = tables.DeviceDeviceBayTable
     filterset = filtersets.DeviceBayFilterSet
+    filterset_form = forms.DeviceBayFilterForm
     template_name = 'dcim/device/devicebays.html'
     actions = {
         **DEFAULT_ACTION_PERMISSIONS,
@@ -1984,6 +1996,7 @@ class DeviceInventoryView(DeviceComponentsView):
     child_model = InventoryItem
     table = tables.DeviceInventoryItemTable
     filterset = filtersets.InventoryItemFilterSet
+    filterset_form = forms.InventoryItemFilterForm
     template_name = 'dcim/device/inventory.html'
     actions = {
         **DEFAULT_ACTION_PERMISSIONS,
@@ -2062,6 +2075,7 @@ class DeviceVirtualMachinesView(generic.ObjectChildrenView):
     child_model = VirtualMachine
     table = VirtualMachineTable
     filterset = VirtualMachineFilterSet
+    filterset_form = VirtualMachineFilterForm
     tab = ViewTab(
         label=_('Virtual Machines'),
         badge=lambda obj: VirtualMachine.objects.filter(cluster=obj.cluster, device=obj).count(),
@@ -2944,6 +2958,7 @@ class InventoryItemChildrenView(generic.ObjectChildrenView):
     child_model = InventoryItem
     table = tables.InventoryItemTable
     filterset = filtersets.InventoryItemFilterSet
+    filterset_form = forms.InventoryItemFilterForm
     tab = ViewTab(
         label=_('Children'),
         badge=lambda obj: obj.child_items.count(),

+ 7 - 7
netbox/extras/dashboard/widgets.py

@@ -381,17 +381,17 @@ class BookmarksWidget(DashboardWidget):
         if request.user.is_anonymous:
             bookmarks = list()
         else:
-            user_bookmarks = Bookmark.objects.filter(user=request.user)
-            if self.config['order_by'] == BookmarkOrderingChoices.ORDERING_ALPHABETICAL_AZ:
-                bookmarks = sorted(user_bookmarks, key=lambda bookmark: bookmark.__str__().lower())
-            elif self.config['order_by'] == BookmarkOrderingChoices.ORDERING_ALPHABETICAL_ZA:
-                bookmarks = sorted(user_bookmarks, key=lambda bookmark: bookmark.__str__().lower(), reverse=True)
-            else:
-                bookmarks = user_bookmarks.order_by(self.config['order_by'])
+            bookmarks = Bookmark.objects.filter(user=request.user)
             if object_types := self.config.get('object_types'):
                 models = get_models_from_content_types(object_types)
                 content_types = ObjectType.objects.get_for_models(*models).values()
                 bookmarks = bookmarks.filter(object_type__in=content_types)
+            if self.config['order_by'] == BookmarkOrderingChoices.ORDERING_ALPHABETICAL_AZ:
+                bookmarks = sorted(bookmarks, key=lambda bookmark: bookmark.__str__().lower())
+            elif self.config['order_by'] == BookmarkOrderingChoices.ORDERING_ALPHABETICAL_ZA:
+                bookmarks = sorted(bookmarks, key=lambda bookmark: bookmark.__str__().lower(), reverse=True)
+            else:
+                bookmarks = bookmarks.order_by(self.config['order_by'])
             if max_items := self.config.get('max_items'):
                 bookmarks = bookmarks[:max_items]
 

+ 3 - 0
netbox/extras/events.py

@@ -66,6 +66,9 @@ def enqueue_object(queue, instance, user, request_id, action):
     if key in queue:
         queue[key]['data'] = serialize_for_event(instance)
         queue[key]['snapshots']['postchange'] = get_snapshots(instance, action)['postchange']
+        # If the object is being deleted, update any prior "update" event to "delete"
+        if action == ObjectChangeActionChoices.ACTION_DELETE:
+            queue[key]['event'] = action
     else:
         queue[key] = {
             'content_type': ContentType.objects.get_for_model(instance),

+ 0 - 3
netbox/extras/forms/bulk_import.py

@@ -229,9 +229,6 @@ class TagImportForm(CSVModelForm):
     class Meta:
         model = Tag
         fields = ('name', 'slug', 'color', 'description')
-        help_texts = {
-            'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
-        }
 
 
 class JournalEntryImportForm(NetBoxModelImportForm):

+ 7 - 2
netbox/extras/management/commands/reindex.py

@@ -66,11 +66,16 @@ class Command(BaseCommand):
             raise CommandError(_("No indexers found!"))
         self.stdout.write(f'Reindexing {len(indexers)} models.')
 
-        # Clear all cached values for the specified models (if not being lazy)
+        # Clear cached values for the specified models (if not being lazy)
         if not kwargs['lazy']:
+            if model_labels:
+                content_types = [ContentType.objects.get_for_model(model) for model in indexers.keys()]
+            else:
+                content_types = None
+
             self.stdout.write('Clearing cached values... ', ending='')
             self.stdout.flush()
-            deleted_count = search_backend.clear()
+            deleted_count = search_backend.clear(object_types=content_types)
             self.stdout.write(f'{deleted_count} entries deleted.')
 
         # Index models

+ 1 - 1
netbox/extras/models/customfields.py

@@ -501,7 +501,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
 
         # JSON
         elif self.type == CustomFieldTypeChoices.TYPE_JSON:
-            field = JSONField(required=required, initial=json.dumps(initial) if initial else '')
+            field = JSONField(required=required, initial=json.dumps(initial) if initial else None)
 
         # Object
         elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:

+ 26 - 3
netbox/extras/tests/test_event_rules.py

@@ -391,13 +391,36 @@ class EventRuleTest(APITestCase):
         request.id = uuid.uuid4()
         request.user = self.user
 
-        self.assertEqual(self.queue.count, 0, msg="Unexpected jobs found in queue")
-
+        # Test create & update
         with event_tracking(request):
             site = Site(name='Site 1', slug='site-1')
             site.save()
+            site.description = 'foo'
+            site.save()
+        self.assertEqual(self.queue.count, 1, msg="Duplicate jobs found in queue")
+        job = self.queue.get_jobs()[0]
+        self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_CREATE)
+        self.queue.empty()
 
-            # Save the site a second time
+        # Test multiple updates
+        site = Site.objects.create(name='Site 2', slug='site-2')
+        with event_tracking(request):
+            site.description = 'foo'
+            site.save()
+            site.description = 'bar'
             site.save()
+        self.assertEqual(self.queue.count, 1, msg="Duplicate jobs found in queue")
+        job = self.queue.get_jobs()[0]
+        self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_UPDATE)
+        self.queue.empty()
 
+        # Test update & delete
+        site = Site.objects.create(name='Site 3', slug='site-3')
+        with event_tracking(request):
+            site.description = 'foo'
+            site.save()
+            site.delete()
         self.assertEqual(self.queue.count, 1, msg="Duplicate jobs found in queue")
+        job = self.queue.get_jobs()[0]
+        self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_DELETE)
+        self.queue.empty()

+ 12 - 0
netbox/ipam/views.py

@@ -7,6 +7,7 @@ from django.utils.translation import gettext as _
 
 from circuits.models import Provider
 from dcim.filtersets import InterfaceFilterSet
+from dcim.forms import InterfaceFilterForm
 from dcim.models import Interface, Site
 from netbox.views import generic
 from tenancy.views import ObjectContactsView
@@ -14,6 +15,7 @@ from utilities.query import count_related
 from utilities.tables import get_table_ordering
 from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view
 from virtualization.filtersets import VMInterfaceFilterSet
+from virtualization.forms import VMInterfaceFilterForm
 from virtualization.models import VMInterface
 from . import filtersets, forms, tables
 from .choices import PrefixStatusChoices
@@ -206,6 +208,7 @@ class ASNRangeASNsView(generic.ObjectChildrenView):
     child_model = ASN
     table = tables.ASNTable
     filterset = filtersets.ASNFilterSet
+    filterset_form = forms.ASNFilterForm
     tab = ViewTab(
         label=_('ASNs'),
         badge=lambda x: x.get_child_asns().count(),
@@ -337,6 +340,7 @@ class AggregatePrefixesView(generic.ObjectChildrenView):
     child_model = Prefix
     table = tables.PrefixTable
     filterset = filtersets.PrefixFilterSet
+    filterset_form = forms.PrefixFilterForm
     template_name = 'ipam/aggregate/prefixes.html'
     tab = ViewTab(
         label=_('Prefixes'),
@@ -523,6 +527,7 @@ class PrefixPrefixesView(generic.ObjectChildrenView):
     child_model = Prefix
     table = tables.PrefixTable
     filterset = filtersets.PrefixFilterSet
+    filterset_form = forms.PrefixFilterForm
     template_name = 'ipam/prefix/prefixes.html'
     tab = ViewTab(
         label=_('Child Prefixes'),
@@ -558,6 +563,7 @@ class PrefixIPRangesView(generic.ObjectChildrenView):
     child_model = IPRange
     table = tables.IPRangeTable
     filterset = filtersets.IPRangeFilterSet
+    filterset_form = forms.IPRangeFilterForm
     template_name = 'ipam/prefix/ip_ranges.html'
     tab = ViewTab(
         label=_('Child Ranges'),
@@ -584,6 +590,7 @@ class PrefixIPAddressesView(generic.ObjectChildrenView):
     child_model = IPAddress
     table = tables.IPAddressTable
     filterset = filtersets.IPAddressFilterSet
+    filterset_form = forms.IPAddressFilterForm
     template_name = 'ipam/prefix/ip_addresses.html'
     tab = ViewTab(
         label=_('IP Addresses'),
@@ -683,6 +690,7 @@ class IPRangeIPAddressesView(generic.ObjectChildrenView):
     child_model = IPAddress
     table = tables.IPAddressTable
     filterset = filtersets.IPAddressFilterSet
+    filterset_form = forms.IPRangeFilterForm
     template_name = 'ipam/iprange/ip_addresses.html'
     tab = ViewTab(
         label=_('IP Addresses'),
@@ -885,6 +893,7 @@ class IPAddressRelatedIPsView(generic.ObjectChildrenView):
     child_model = IPAddress
     table = tables.IPAddressTable
     filterset = filtersets.IPAddressFilterSet
+    filterset_form = forms.IPAddressFilterForm
     tab = ViewTab(
         label=_('Related IPs'),
         badge=lambda x: x.get_related_ips().count(),
@@ -957,6 +966,7 @@ class VLANGroupVLANsView(generic.ObjectChildrenView):
     child_model = VLAN
     table = tables.VLANTable
     filterset = filtersets.VLANFilterSet
+    filterset_form = forms.VLANFilterForm
     tab = ViewTab(
         label=_('VLANs'),
         badge=lambda x: x.get_child_vlans().count(),
@@ -1112,6 +1122,7 @@ class VLANInterfacesView(generic.ObjectChildrenView):
     child_model = Interface
     table = tables.VLANDevicesTable
     filterset = InterfaceFilterSet
+    filterset_form = InterfaceFilterForm
     tab = ViewTab(
         label=_('Device Interfaces'),
         badge=lambda x: x.get_interfaces().count(),
@@ -1129,6 +1140,7 @@ class VLANVMInterfacesView(generic.ObjectChildrenView):
     child_model = VMInterface
     table = tables.VLANVirtualMachinesTable
     filterset = VMInterfaceFilterSet
+    filterset_form = VMInterfaceFilterForm
     tab = ViewTab(
         label=_('VM Interfaces'),
         badge=lambda x: x.get_vminterfaces().count(),

+ 3 - 2
netbox/netbox/forms/__init__.py

@@ -1,7 +1,7 @@
 import re
 
 from django import forms
-from django.utils.translation import gettext as _
+from django.utils.translation import gettext_lazy as _
 
 from netbox.search import LookupTypes
 from netbox.search.backends import search_backend
@@ -36,7 +36,8 @@ class SearchForm(forms.Form):
     lookup = forms.ChoiceField(
         choices=LOOKUP_CHOICES,
         initial=LookupTypes.PARTIAL,
-        required=False
+        required=False,
+        label=_('Lookup')
     )
 
     def __init__(self, *args, **kwargs):

+ 5 - 0
netbox/netbox/middleware.py

@@ -36,6 +36,11 @@ class CoreMiddleware:
         with event_tracking(request):
             response = self.get_response(request)
 
+        # Check if language cookie should be renewed
+        if request.user.is_authenticated and settings.SESSION_SAVE_EVERY_REQUEST:
+            if language := request.user.config.get('locale.language'):
+                response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language, max_age=request.session.get_expiry_age())
+
         # Attach the unique request ID as an HTTP header.
         response['X-Request-ID'] = request.id
 

+ 5 - 5
netbox/netbox/navigation/menu.py

@@ -462,16 +462,13 @@ MENUS = [
     PROVISIONING_MENU,
     CUSTOMIZATION_MENU,
     OPERATIONS_MENU,
-    ADMIN_MENU,
 ]
 
-#
-# Add plugin menus
-#
-
+# Add top-level plugin menus
 for menu in registry['plugins']['menus']:
     MENUS.append(menu)
 
+# Add the default "plugins" menu
 if registry['plugins']['menu_items']:
 
     # Build the default plugins menu
@@ -485,3 +482,6 @@ if registry['plugins']['menu_items']:
         groups=groups
     )
     MENUS.append(plugins_menu)
+
+# Add the admin menu last
+MENUS.append(ADMIN_MENU)

+ 2 - 1
netbox/netbox/search/backends.py

@@ -8,6 +8,7 @@ from django.db.models.fields.related import ForeignKey
 from django.db.models.functions import window
 from django.db.models.signals import post_delete, post_save
 from django.utils.module_loading import import_string
+from django.utils.translation import gettext_lazy as _
 import netaddr
 from netaddr.core import AddrFormatError
 
@@ -39,7 +40,7 @@ class SearchBackend:
             # Organize choices by category
             categories = defaultdict(dict)
             for label, idx in registry['search'].items():
-                categories[idx.get_category()][label] = title(idx.model._meta.verbose_name)
+                categories[idx.get_category()][label] = _(title(idx.model._meta.verbose_name))
 
             # Compile a nested tuple of choices for form rendering
             results = (

+ 19 - 1
netbox/netbox/settings.py

@@ -149,6 +149,7 @@ SECURE_SSL_REDIRECT = getattr(configuration, 'SECURE_SSL_REDIRECT', False)
 SENTRY_DSN = getattr(configuration, 'SENTRY_DSN', None)
 SENTRY_ENABLED = getattr(configuration, 'SENTRY_ENABLED', False)
 SENTRY_SAMPLE_RATE = getattr(configuration, 'SENTRY_SAMPLE_RATE', 1.0)
+SENTRY_SEND_DEFAULT_PII = getattr(configuration, 'SENTRY_SEND_DEFAULT_PII', False)
 SENTRY_TAGS = getattr(configuration, 'SENTRY_TAGS', {})
 SENTRY_TRACES_SAMPLE_RATE = getattr(configuration, 'SENTRY_TRACES_SAMPLE_RATE', 0)
 SESSION_COOKIE_NAME = getattr(configuration, 'SESSION_COOKIE_NAME', 'sessionid')
@@ -227,6 +228,23 @@ if STORAGE_BACKEND is not None:
             return globals().get(name, default)
         storages.utils.setting = _setting
 
+    # django-storage-swift
+    elif STORAGE_BACKEND == 'swift.storage.SwiftStorage':
+        try:
+            import swift.utils  # type: ignore
+        except ModuleNotFoundError as e:
+            if getattr(e, 'name') == 'swift':
+                raise ImproperlyConfigured(
+                    f"STORAGE_BACKEND is set to {STORAGE_BACKEND} but django-storage-swift is not present. "
+                    "It can be installed by running 'pip install django-storage-swift'."
+                )
+            raise e
+
+        # Load all SWIFT_* settings from the user configuration
+        for param, value in STORAGE_CONFIG.items():
+            if param.startswith('SWIFT_'):
+                globals()[param] = value
+
 if STORAGE_CONFIG and STORAGE_BACKEND is None:
     warnings.warn(
         "STORAGE_CONFIG has been set in configuration.py but STORAGE_BACKEND is not defined. STORAGE_CONFIG will be "
@@ -529,7 +547,7 @@ if SENTRY_ENABLED:
         release=RELEASE.full_version,
         sample_rate=SENTRY_SAMPLE_RATE,
         traces_sample_rate=SENTRY_TRACES_SAMPLE_RATE,
-        send_default_pii=True,
+        send_default_pii=SENTRY_SEND_DEFAULT_PII,
         http_proxy=HTTP_PROXIES.get('http') if HTTP_PROXIES else None,
         https_proxy=HTTP_PROXIES.get('https') if HTTP_PROXIES else None
     )

+ 1 - 1
netbox/netbox/views/generic/bulk_views.py

@@ -176,7 +176,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
             'model': model,
             'table': table,
             'actions': actions,
-            'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None,
+            'filter_form': self.filterset_form(request.GET) if self.filterset_form else None,
             'prerequisite_model': get_prerequisite_model(self.queryset),
             **self.get_extra_context(request),
         }

+ 3 - 0
netbox/netbox/views/generic/object_views.py

@@ -87,12 +87,14 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
         child_model: The model class which represents the child objects
         table: The django-tables2 Table class used to render the child objects list
         filterset: A django-filter FilterSet that is applied to the queryset
+        filterset_form: The form class used to render filter options
         actions: A mapping of supported actions to their required permissions. When adding custom actions, bulk
             action names must be prefixed with `bulk_`. (See ActionsMixin.)
     """
     child_model = None
     table = None
     filterset = None
+    filterset_form = None
     template_name = 'generic/object_children.html'
 
     def get_children(self, request, parent):
@@ -152,6 +154,7 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
             'base_template': f'{instance._meta.app_label}/{instance._meta.model_name}.html',
             'table': table,
             'table_config': f'{table.name}_config',
+            'filter_form': self.filterset_form(request.GET) if self.filterset_form else None,
             'actions': actions,
             'tab': self.tab,
             'return_url': request.get_full_path(),

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
netbox/project-static/dist/netbox.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
netbox/project-static/dist/netbox.js.map


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

@@ -27,7 +27,7 @@
     "bootstrap": "5.3.3",
     "clipboard": "2.0.11",
     "flatpickr": "4.6.13",
-    "gridstack": "10.2.1",
+    "gridstack": "10.3.0",
     "htmx.org": "1.9.12",
     "query-string": "9.0.0",
     "sass": "1.77.6",

+ 12 - 4
netbox/project-static/src/select/classes/dynamicTomSelect.ts

@@ -74,20 +74,25 @@ export class DynamicTomSelect extends TomSelect {
 
   load(value: string) {
     const self = this;
-    const url = self.getRequestUrl(value);
 
     // Automatically clear any cached options. (Only options included
     // in the API response should be present.)
     self.clearOptions();
 
-    addClasses(self.wrapper, self.settings.loadingClass);
-    self.loading++;
-
     // Populate the null option (if any) if not searching
     if (self.nullOption && !value) {
       self.addOption(self.nullOption);
     }
 
+    // Get the API request URL. If none is provided, abort as no request can be made.
+    const url = self.getRequestUrl(value);
+    if (!url) {
+      return;
+    }
+
+    addClasses(self.wrapper, self.settings.loadingClass);
+    self.loading++;
+
     // Make the API request
     fetch(url)
       .then(response => response.json())
@@ -129,6 +134,9 @@ export class DynamicTomSelect extends TomSelect {
       for (const result of this.api_url.matchAll(new RegExp(`({{${key}}})`, 'g'))) {
         if (value) {
           url = replaceAll(url, result[1], value.toString());
+        } else {
+          // No value is available to replace the token; abort.
+          return '';
         }
       }
     }

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

@@ -1754,10 +1754,10 @@ graphql@16.8.1:
   resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.8.1.tgz#1930a965bef1170603702acdb68aedd3f3cf6f07"
   integrity sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==
 
-gridstack@10.2.1:
-  version "10.2.1"
-  resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-10.2.1.tgz#3ce6119ae86cfb0a533c5f0d15b03777a55384ca"
-  integrity sha512-UAPKnIvd9sIqPDFMtKMqj0G5GDj8MUFPcelRJq7FzQFSxSYBblKts/Gd52iEJg0EvTFP51t6ZuMWGx0pSSFBdw==
+gridstack@10.3.0:
+  version "10.3.0"
+  resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-10.3.0.tgz#8fa065f896d0a880c5c54c24d189f3197184488a"
+  integrity sha512-eGKsmU2TppV4coyDu9IIdIkm4qjgLLdjlEOFwQyQMuSwfOpzSfLdPc8du0HuebGr7CvAIrJxN4lBOmGrWSBg9g==
 
 has-bigints@^1.0.1, has-bigints@^1.0.2:
   version "1.0.2"

+ 1 - 1
netbox/templates/core/system.html

@@ -93,7 +93,7 @@
     <div class="col col-md-12">
       <div class="card">
         <h5 class="card-header">{% trans "Current Configuration" %}</h5>
-        {% include 'core/inc/config_data.html' with config=config.data %}
+        {% include 'core/inc/config_data.html' %}
       </div>
 
     </div>

+ 26 - 19
netbox/templates/dcim/device.html

@@ -125,28 +125,30 @@
                       </div>
                     </h5>
                     <table class="table table-hover attr-table">
-                        <tr>
-                            <th>{% trans "Device" %}</th>
-                            <th>{% trans "Position" %}</th>
-                            <th>{% trans "Master" %}</th>
-                            <th>{% trans "Priority" %}</th>
+                      <thead>
+                        <tr class="border-bottom">
+                          <th>{% trans "Device" %}</th>
+                          <th>{% trans "Position" %}</th>
+                          <th>{% trans "Master" %}</th>
+                          <th>{% trans "Priority" %}</th>
                         </tr>
+                      </thead>
+                      <tbody>
                         {% for vc_member in vc_members %}
-                            <tr{% if vc_member == object %} class="info"{% endif %}>
-                                <td>
-                                    {{ vc_member|linkify }}
-                                </td>
-                                <td>
-                                  {% badge vc_member.vc_position show_empty=True %}
-                                </td>
-                                <td>
-                                  {% if object.virtual_chassis.master == vc_member %}<i class="mdi mdi-check-bold"></i>{% endif %}
-                                </td>
-                                <td>
-                                  {{ vc_member.vc_priority|placeholder }}
-                                </td>
-                            </tr>
+                          <tr{% if vc_member == object %} class="table-primary"{% endif %}>
+                            <td>{{ vc_member|linkify }}</td>
+                            <td>{% badge vc_member.vc_position show_empty=True %}</td>
+                            <td>
+                              {% if object.virtual_chassis.master == vc_member %}
+                                {% checkmark True %}
+                              {% else %}
+                                {{ ''|placeholder }}
+                              {% endif %}
+                            </td>
+                            <td>{{ vc_member.vc_priority|placeholder }}</td>
+                          </tr>
                         {% endfor %}
+                      </tbody>
                     </table>
                 </div>
             {% endif %}
@@ -221,6 +223,11 @@
                         <td>
                           {% if object.oob_ip %}
                             <a href="{{ object.oob_ip.get_absolute_url }}" id="oob_ip">{{ object.oob_ip.address.ip }}</a>
+                            {% if object.oob_ip.nat_inside %}
+                              ({% trans "NAT for" %} <a href="{{ object.oob_ip.nat_inside.get_absolute_url }}">{{ object.oob_ip.nat_inside.address.ip }}</a>)
+                            {% elif object.oob_ip.nat_outside.exists %}
+                              ({% trans "NAT" %}: {% for nat in object.oob_ip.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
+                            {% endif %}
                             {% copy_content "oob_ip" %}
                           {% else %}
                             {{ ''|placeholder }}

+ 1 - 1
netbox/templates/generic/object_list.html

@@ -48,7 +48,7 @@ Context:
     <li class="nav-item" role="presentation">
       <a class="nav-link active" id="object-list-tab" data-bs-toggle="tab" data-bs-target="#object-list" type="button" role="tab" aria-controls="edit-form" aria-selected="true">
         {% trans "Results" %}
-        <span class="badge text-bg-secondary total-object-count">{% if table.page.paginator.count %}{{ table.page.paginator.count }}{% else %}{{ total_count }}{% endif %}</span>
+        <span class="badge text-bg-secondary total-object-count">{% if table.page.paginator.count %}{{ table.page.paginator.count }}{% else %}{{ total_count|default:"0" }}{% endif %}</span>
       </a>
     </li>
     {% if filter_form %}

+ 8 - 6
netbox/templates/inc/table_controls_htmx.html

@@ -13,14 +13,16 @@
     </div>
   </div>
 
-  <div class="col-auto d-print-none">
-    <div class="input-group">
-      <div class="input-group-text">
-        <i class="mdi mdi-filter" title="{% trans "Saved filter" %}"></i>
+  {% if filter_form %}
+    <div class="col-auto d-print-none">
+      <div class="input-group">
+        <div class="input-group-text">
+          <i class="mdi mdi-filter" title="{% trans "Saved filter" %}"></i>
+        </div>
+        {{ filter_form.filter_id }}
       </div>
-      {{ filter_form.filter_id }}
     </div>
-  </div>
+  {% endif %}
 
   <div class="col-auto ms-auto d-print-none">
     {% if request.user.is_authenticated and table_modal %}

+ 4 - 4
netbox/templates/ipam/vlan_edit.html

@@ -53,10 +53,6 @@
     {% endwith %}
   </div>
 
-  <div class="field-group my-5">
-    {% render_field form.comments %}
-  </div>
-
   {% if form.custom_fields %}
     <div class="field-group my-5">
       <div class="row">
@@ -65,4 +61,8 @@
       {% render_custom_fields form %}
     </div>
   {% endif %}
+
+  <div class="field-group my-5">
+    {% render_field form.comments %}
+  </div>
 {% endblock %}

+ 2 - 1
netbox/tenancy/views.py

@@ -13,6 +13,7 @@ class ObjectContactsView(generic.ObjectChildrenView):
     child_model = ContactAssignment
     table = tables.ContactAssignmentTable
     filterset = filtersets.ContactAssignmentFilterSet
+    filterset_form = forms.ContactAssignmentFilterForm
     template_name = 'tenancy/object_contacts.html'
     tab = ViewTab(
         label=_('Contacts'),
@@ -364,7 +365,7 @@ class ContactAssignmentEditView(generic.ObjectEditView):
 
     def get_extra_addanother_params(self, request):
         return {
-            'content_type': request.GET.get('content_type'),
+            'object_type': request.GET.get('object_type'),
             'object_id': request.GET.get('object_id'),
         }
 

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


Разлика између датотеке није приказан због своје велике величине
+ 218 - 214
netbox/translations/de/LC_MESSAGES/django.po


Разлика између датотеке није приказан због своје велике величине
+ 232 - 228
netbox/translations/en/LC_MESSAGES/django.po


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


Разлика између датотеке није приказан због своје велике величине
+ 234 - 230
netbox/translations/fr/LC_MESSAGES/django.po


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


Разлика између датотеке није приказан због своје велике величине
+ 232 - 229
netbox/translations/pt/LC_MESSAGES/django.po


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


Разлика између датотеке није приказан због своје велике величине
+ 216 - 213
netbox/translations/ru/LC_MESSAGES/django.po


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


Разлика између датотеке није приказан због своје велике величине
+ 229 - 225
netbox/translations/tr/LC_MESSAGES/django.po


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


Разлика између датотеке није приказан због своје велике величине
+ 6576 - 5915
netbox/translations/zh/LC_MESSAGES/django.po


+ 2 - 0
netbox/utilities/fields.py

@@ -2,6 +2,7 @@ from collections import defaultdict
 
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.db import models
+from django.utils.safestring import mark_safe
 from django.utils.translation import gettext_lazy as _
 
 from utilities.ordering import naturalize
@@ -26,6 +27,7 @@ class ColorField(models.CharField):
 
     def formfield(self, **kwargs):
         kwargs['widget'] = ColorSelect
+        kwargs['help_text'] = mark_safe(_('RGB color in hexadecimal. Example: ') + '<code>00ff00</code>')
         return super().formfield(**kwargs)
 
 

+ 1 - 1
netbox/utilities/querydict.py

@@ -55,7 +55,7 @@ def prepare_cloned_fields(instance):
     for key, value in attrs.items():
         if type(value) in (list, tuple):
             params.extend([(key, v) for v in value])
-        elif value not in (False, None):
+        elif value is not False and value is not None:
             params.append((key, value))
         else:
             params.append((key, ''))

+ 2 - 2
netbox/virtualization/models/virtualmachines.py

@@ -184,8 +184,8 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co
                 'cluster': _('A virtual machine must be assigned to a site and/or cluster.')
             })
 
-        # Validate site for cluster & device
-        if self.cluster and self.cluster.site is not None and self.cluster.site != self.site:
+        # Validate site for cluster & VM
+        if self.cluster and self.site and self.cluster.site and self.cluster.site != self.site:
             raise ValidationError({
                 'cluster': _(
                     'The selected cluster ({cluster}) is not assigned to this site ({site}).'

+ 5 - 0
netbox/virtualization/views.py

@@ -10,6 +10,7 @@ from django.utils.translation import gettext as _
 from jinja2.exceptions import TemplateError
 
 from dcim.filtersets import DeviceFilterSet
+from dcim.forms import DeviceFilterForm
 from dcim.models import Device
 from dcim.tables import DeviceTable
 from extras.views import ObjectConfigContextView
@@ -173,6 +174,7 @@ class ClusterVirtualMachinesView(generic.ObjectChildrenView):
     child_model = VirtualMachine
     table = tables.VirtualMachineTable
     filterset = filtersets.VirtualMachineFilterSet
+    filterset_form = forms.VirtualMachineFilterForm
     tab = ViewTab(
         label=_('Virtual Machines'),
         badge=lambda obj: obj.virtual_machines.count(),
@@ -190,6 +192,7 @@ class ClusterDevicesView(generic.ObjectChildrenView):
     child_model = Device
     table = DeviceTable
     filterset = DeviceFilterSet
+    filterset_form = DeviceFilterForm
     template_name = 'virtualization/cluster/devices.html'
     actions = {
         'add': {'add'},
@@ -350,6 +353,7 @@ class VirtualMachineInterfacesView(generic.ObjectChildrenView):
     child_model = VMInterface
     table = tables.VirtualMachineVMInterfaceTable
     filterset = filtersets.VMInterfaceFilterSet
+    filterset_form = forms.VMInterfaceFilterForm
     template_name = 'virtualization/virtualmachine/interfaces.html'
     actions = {
         **DEFAULT_ACTION_PERMISSIONS,
@@ -375,6 +379,7 @@ class VirtualMachineVirtualDisksView(generic.ObjectChildrenView):
     child_model = VirtualDisk
     table = tables.VirtualMachineVirtualDiskTable
     filterset = filtersets.VirtualDiskFilterSet
+    filterset_form = forms.VirtualDiskFilterForm
     template_name = 'virtualization/virtualmachine/virtual_disks.html'
     tab = ViewTab(
         label=_('Virtual Disks'),

+ 4 - 2
netbox/vpn/api/serializers_/crypto.py

@@ -22,7 +22,8 @@ class IKEProposalSerializer(NetBoxModelSerializer):
         choices=EncryptionAlgorithmChoices
     )
     authentication_algorithm = ChoiceField(
-        choices=AuthenticationAlgorithmChoices
+        choices=AuthenticationAlgorithmChoices,
+        required=False
     )
     group = ChoiceField(
         choices=DHGroupChoices
@@ -43,7 +44,8 @@ class IKEPolicySerializer(NetBoxModelSerializer):
         choices=IKEVersionChoices
     )
     mode = ChoiceField(
-        choices=IKEModeChoices
+        choices=IKEModeChoices,
+        required=False
     )
     proposals = SerializedPKRelatedField(
         queryset=IKEProposal.objects.all(),

+ 9 - 9
requirements.txt

@@ -1,4 +1,4 @@
-Django==5.0.6
+Django==5.0.7
 django-cors-headers==4.4.0
 django-debug-toolbar==4.3.0
 django-filter==24.2
@@ -12,26 +12,26 @@ django-rich==1.9.0
 django-rq==2.10.2
 django-taggit==5.0.1
 django-tables2==2.7.0
-django-timezone-field==6.1.0
+django-timezone-field==7.0
 djangorestframework==3.15.2
 drf-spectacular==0.27.2
-drf-spectacular-sidecar==2024.6.1
+drf-spectacular-sidecar==2024.7.1
 feedparser==6.0.11
 gunicorn==22.0.0
 Jinja2==3.1.4
 Markdown==3.6
-mkdocs-material==9.5.27
+mkdocs-material==9.5.28
 mkdocstrings[python-legacy]==0.25.1
 netaddr==1.3.0
-nh3==0.2.17
-Pillow==10.3.0
-psycopg[c,pool]==3.1.19
+nh3==0.2.18
+Pillow==10.4.0
+psycopg[c,pool]==3.2.1
 PyYAML==6.0.1
 requests==2.32.3
 social-auth-app-django==5.4.1
 social-auth-core==4.5.4
-strawberry-graphql==0.235.0
-strawberry-graphql-django==0.44.2
+strawberry-graphql==0.235.2
+strawberry-graphql-django==0.46.1
 svgwrite==1.4.3
 tablib==3.6.1
 tzdata==2024.1

+ 2 - 2
upgrade.sh

@@ -33,7 +33,7 @@ echo "Using ${PYTHON_VERSION}"
 
 # Remove the existing virtual environment (if any)
 if [ -d "$VIRTUALENV" ]; then
-  COMMAND="rm -rf ${VIRTUALENV}"
+  COMMAND="rm -rf \"${VIRTUALENV}\""
   echo "Removing old virtual environment..."
   eval $COMMAND
 else
@@ -41,7 +41,7 @@ else
 fi
 
 # Create a new virtual environment
-COMMAND="${PYTHON} -m venv ${VIRTUALENV}"
+COMMAND="${PYTHON} -m venv \"${VIRTUALENV}\""
 echo "Creating a new virtual environment at ${VIRTUALENV}..."
 eval $COMMAND || {
   echo "--------------------------------------------------------------------"

Неке датотеке нису приказане због велике количине промена