Przeglądaj źródła

Merge branch 'develop' into feature

Jeremy Stretch 1 rok temu
rodzic
commit
02ae91589d
61 zmienionych plików z 8249 dodań i 7366 usunięć
  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:
     attributes:
       label: NetBox Version
       label: NetBox Version
       description: What version of NetBox are you currently running?
       description: What version of NetBox are you currently running?
-      placeholder: v4.0.6
+      placeholder: v4.0.7
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

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

@@ -14,7 +14,7 @@ body:
     attributes:
     attributes:
       label: NetBox version
       label: NetBox version
       description: What version of NetBox are you currently running?
       description: What version of NetBox are you currently running?
-      placeholder: v4.0.6
+      placeholder: v4.0.7
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - 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
 ## SENTRY_TAGS
 
 
 An optional dictionary of tag names and values to apply to Sentry error reports.For example:
 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)
 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.
 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
 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.
 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.
 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.
 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;
 netbox=> DROP TABLE pluginname_bar;
 DROP TABLE
 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
 # 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)
             # Set the user's preferred language (if any)
             if language := request.user.config.get('locale.language'):
             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
             return response
 
 
@@ -208,7 +208,7 @@ class UserConfigView(LoginRequiredMixin, View):
 
 
             # Set/clear language cookie
             # Set/clear language cookie
             if language := form.cleaned_data['locale.language']:
             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:
             else:
                 response.delete_cookie(settings.LANGUAGE_COOKIE_NAME)
                 response.delete_cookie(settings.LANGUAGE_COOKIE_NAME)
 
 

+ 4 - 0
netbox/circuits/choices.py

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

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

@@ -66,9 +66,6 @@ class CircuitTypeImportForm(NetBoxModelImportForm):
     class Meta:
     class Meta:
         model = CircuitType
         model = CircuitType
         fields = ('name', 'slug', 'color', 'description', 'tags')
         fields = ('name', 'slug', 'color', 'description', 'tags')
-        help_texts = {
-            'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
-        }
 
 
 
 
 class CircuitImportForm(NetBoxModelImportForm):
 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'))
             config = ConfigRevision.objects.get(pk=cache.get('config_version'))
         except ConfigRevision.DoesNotExist:
         except ConfigRevision.DoesNotExist:
             # Fall back to using the active config data if no record is found
             # 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
         # Raw data export
         if 'export' in request.GET:
         if 'export' in request.GET:

+ 12 - 1
netbox/dcim/filtersets.py

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

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

@@ -174,9 +174,6 @@ class RackRoleImportForm(NetBoxModelImportForm):
     class Meta:
     class Meta:
         model = RackRole
         model = RackRole
         fields = ('name', 'slug', 'color', 'description', 'tags')
         fields = ('name', 'slug', 'color', 'description', 'tags')
-        help_texts = {
-            'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
-        }
 
 
 
 
 class RackImportForm(NetBoxModelImportForm):
 class RackImportForm(NetBoxModelImportForm):
@@ -384,9 +381,6 @@ class DeviceRoleImportForm(NetBoxModelImportForm):
     class Meta:
     class Meta:
         model = DeviceRole
         model = DeviceRole
         fields = ('name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags')
         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):
 class PlatformImportForm(NetBoxModelImportForm):
@@ -1052,7 +1046,7 @@ class InventoryItemImportForm(NetBoxModelImportForm):
     class Meta:
     class Meta:
         model = InventoryItem
         model = InventoryItem
         fields = (
         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',
             'description', 'tags', 'component_type', 'component_name',
         )
         )
 
 
@@ -1104,9 +1098,6 @@ class InventoryItemRoleImportForm(NetBoxModelImportForm):
     class Meta:
     class Meta:
         model = InventoryItemRole
         model = InventoryItemRole
         fields = ('name', 'slug', 'color', 'description')
         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',
             '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',
             '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):
     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.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField
 from utilities.forms.rendering import FieldSet
 from utilities.forms.rendering import FieldSet
 from utilities.forms.widgets import NumberWithOptions
 from utilities.forms.widgets import NumberWithOptions
+from virtualization.models import Cluster, ClusterGroup
 from vpn.models import L2VPN
 from vpn.models import L2VPN
 from wireless.choices import *
 from wireless.choices import *
 
 
@@ -655,6 +656,7 @@ class DeviceFilterForm(
             'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports',
             'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports',
             name=_('Components')
             name=_('Components')
         ),
         ),
+        FieldSet('cluster_group_id', 'cluster_id', name=_('Cluster')),
         FieldSet(
         FieldSet(
             'has_primary_ip', 'has_oob_ip', 'virtual_chassis_member', 'config_template_id', 'local_context_data',
             'has_primary_ip', 'has_oob_ip', 'virtual_chassis_member', 'config_template_id', 'local_context_data',
             'has_virtual_device_context',
             'has_virtual_device_context',
@@ -821,6 +823,16 @@ class DeviceFilterForm(
             choices=BOOLEAN_WITH_BLANK_CHOICES
             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)
     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 netbox.choices import ColorChoices
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
 from utilities.testing import ChangeLoggedFilterSetTests, create_test_device
 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
 from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
 
 
 User = get_user_model()
 User = get_user_model()
@@ -1959,10 +1959,16 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
         Rack.objects.bulk_create(racks)
         Rack.objects.bulk_create(racks)
 
 
         cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
         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 = (
         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)
         Cluster.objects.bulk_create(clusters)
 
 
@@ -2213,6 +2219,13 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'cluster_id': [clusters[0].pk, clusters[1].pk]}
         params = {'cluster_id': [clusters[0].pk, clusters[1].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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):
     def test_model(self):
         params = {'model': ['model-1', 'model-2']}
         params = {'model': ['model-1', 'model-2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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
     GetRelatedModelsMixin, GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view
 )
 )
 from virtualization.filtersets import VirtualMachineFilterSet
 from virtualization.filtersets import VirtualMachineFilterSet
+from virtualization.forms import VirtualMachineFilterForm
 from virtualization.models import VirtualMachine
 from virtualization.models import VirtualMachine
 from virtualization.tables import VirtualMachineTable
 from virtualization.tables import VirtualMachineTable
 from . import filtersets, forms, tables
 from . import filtersets, forms, tables
@@ -679,6 +680,7 @@ class RackRackReservationsView(generic.ObjectChildrenView):
     child_model = RackReservation
     child_model = RackReservation
     table = tables.RackReservationTable
     table = tables.RackReservationTable
     filterset = filtersets.RackReservationFilterSet
     filterset = filtersets.RackReservationFilterSet
+    filterset_form = forms.RackReservationFilterForm
     template_name = 'dcim/rack/reservations.html'
     template_name = 'dcim/rack/reservations.html'
     tab = ViewTab(
     tab = ViewTab(
         label=_('Reservations'),
         label=_('Reservations'),
@@ -697,6 +699,7 @@ class RackNonRackedView(generic.ObjectChildrenView):
     child_model = Device
     child_model = Device
     table = tables.DeviceTable
     table = tables.DeviceTable
     filterset = filtersets.DeviceFilterSet
     filterset = filtersets.DeviceFilterSet
+    filterset_form = forms.DeviceFilterForm
     template_name = 'dcim/rack/non_racked_devices.html'
     template_name = 'dcim/rack/non_racked_devices.html'
     tab = ViewTab(
     tab = ViewTab(
         label=_('Non-Racked Devices'),
         label=_('Non-Racked Devices'),
@@ -1835,6 +1838,7 @@ class DeviceConsolePortsView(DeviceComponentsView):
     child_model = ConsolePort
     child_model = ConsolePort
     table = tables.DeviceConsolePortTable
     table = tables.DeviceConsolePortTable
     filterset = filtersets.ConsolePortFilterSet
     filterset = filtersets.ConsolePortFilterSet
+    filterset_form = forms.ConsolePortFilterForm
     template_name = 'dcim/device/consoleports.html',
     template_name = 'dcim/device/consoleports.html',
     tab = ViewTab(
     tab = ViewTab(
         label=_('Console Ports'),
         label=_('Console Ports'),
@@ -1850,6 +1854,7 @@ class DeviceConsoleServerPortsView(DeviceComponentsView):
     child_model = ConsoleServerPort
     child_model = ConsoleServerPort
     table = tables.DeviceConsoleServerPortTable
     table = tables.DeviceConsoleServerPortTable
     filterset = filtersets.ConsoleServerPortFilterSet
     filterset = filtersets.ConsoleServerPortFilterSet
+    filterset_form = forms.ConsoleServerPortFilterForm
     template_name = 'dcim/device/consoleserverports.html'
     template_name = 'dcim/device/consoleserverports.html'
     tab = ViewTab(
     tab = ViewTab(
         label=_('Console Server Ports'),
         label=_('Console Server Ports'),
@@ -1865,6 +1870,7 @@ class DevicePowerPortsView(DeviceComponentsView):
     child_model = PowerPort
     child_model = PowerPort
     table = tables.DevicePowerPortTable
     table = tables.DevicePowerPortTable
     filterset = filtersets.PowerPortFilterSet
     filterset = filtersets.PowerPortFilterSet
+    filterset_form = forms.PowerPortFilterForm
     template_name = 'dcim/device/powerports.html'
     template_name = 'dcim/device/powerports.html'
     tab = ViewTab(
     tab = ViewTab(
         label=_('Power Ports'),
         label=_('Power Ports'),
@@ -1880,6 +1886,7 @@ class DevicePowerOutletsView(DeviceComponentsView):
     child_model = PowerOutlet
     child_model = PowerOutlet
     table = tables.DevicePowerOutletTable
     table = tables.DevicePowerOutletTable
     filterset = filtersets.PowerOutletFilterSet
     filterset = filtersets.PowerOutletFilterSet
+    filterset_form = forms.PowerOutletFilterForm
     template_name = 'dcim/device/poweroutlets.html'
     template_name = 'dcim/device/poweroutlets.html'
     tab = ViewTab(
     tab = ViewTab(
         label=_('Power Outlets'),
         label=_('Power Outlets'),
@@ -1895,6 +1902,7 @@ class DeviceInterfacesView(DeviceComponentsView):
     child_model = Interface
     child_model = Interface
     table = tables.DeviceInterfaceTable
     table = tables.DeviceInterfaceTable
     filterset = filtersets.InterfaceFilterSet
     filterset = filtersets.InterfaceFilterSet
+    filterset_form = forms.InterfaceFilterForm
     template_name = 'dcim/device/interfaces.html'
     template_name = 'dcim/device/interfaces.html'
     tab = ViewTab(
     tab = ViewTab(
         label=_('Interfaces'),
         label=_('Interfaces'),
@@ -1916,6 +1924,7 @@ class DeviceFrontPortsView(DeviceComponentsView):
     child_model = FrontPort
     child_model = FrontPort
     table = tables.DeviceFrontPortTable
     table = tables.DeviceFrontPortTable
     filterset = filtersets.FrontPortFilterSet
     filterset = filtersets.FrontPortFilterSet
+    filterset_form = forms.FrontPortFilterForm
     template_name = 'dcim/device/frontports.html'
     template_name = 'dcim/device/frontports.html'
     tab = ViewTab(
     tab = ViewTab(
         label=_('Front Ports'),
         label=_('Front Ports'),
@@ -1931,6 +1940,7 @@ class DeviceRearPortsView(DeviceComponentsView):
     child_model = RearPort
     child_model = RearPort
     table = tables.DeviceRearPortTable
     table = tables.DeviceRearPortTable
     filterset = filtersets.RearPortFilterSet
     filterset = filtersets.RearPortFilterSet
+    filterset_form = forms.RearPortFilterForm
     template_name = 'dcim/device/rearports.html'
     template_name = 'dcim/device/rearports.html'
     tab = ViewTab(
     tab = ViewTab(
         label=_('Rear Ports'),
         label=_('Rear Ports'),
@@ -1946,6 +1956,7 @@ class DeviceModuleBaysView(DeviceComponentsView):
     child_model = ModuleBay
     child_model = ModuleBay
     table = tables.DeviceModuleBayTable
     table = tables.DeviceModuleBayTable
     filterset = filtersets.ModuleBayFilterSet
     filterset = filtersets.ModuleBayFilterSet
+    filterset_form = forms.ModuleBayFilterForm
     template_name = 'dcim/device/modulebays.html'
     template_name = 'dcim/device/modulebays.html'
     actions = {
     actions = {
         **DEFAULT_ACTION_PERMISSIONS,
         **DEFAULT_ACTION_PERMISSIONS,
@@ -1965,6 +1976,7 @@ class DeviceDeviceBaysView(DeviceComponentsView):
     child_model = DeviceBay
     child_model = DeviceBay
     table = tables.DeviceDeviceBayTable
     table = tables.DeviceDeviceBayTable
     filterset = filtersets.DeviceBayFilterSet
     filterset = filtersets.DeviceBayFilterSet
+    filterset_form = forms.DeviceBayFilterForm
     template_name = 'dcim/device/devicebays.html'
     template_name = 'dcim/device/devicebays.html'
     actions = {
     actions = {
         **DEFAULT_ACTION_PERMISSIONS,
         **DEFAULT_ACTION_PERMISSIONS,
@@ -1984,6 +1996,7 @@ class DeviceInventoryView(DeviceComponentsView):
     child_model = InventoryItem
     child_model = InventoryItem
     table = tables.DeviceInventoryItemTable
     table = tables.DeviceInventoryItemTable
     filterset = filtersets.InventoryItemFilterSet
     filterset = filtersets.InventoryItemFilterSet
+    filterset_form = forms.InventoryItemFilterForm
     template_name = 'dcim/device/inventory.html'
     template_name = 'dcim/device/inventory.html'
     actions = {
     actions = {
         **DEFAULT_ACTION_PERMISSIONS,
         **DEFAULT_ACTION_PERMISSIONS,
@@ -2062,6 +2075,7 @@ class DeviceVirtualMachinesView(generic.ObjectChildrenView):
     child_model = VirtualMachine
     child_model = VirtualMachine
     table = VirtualMachineTable
     table = VirtualMachineTable
     filterset = VirtualMachineFilterSet
     filterset = VirtualMachineFilterSet
+    filterset_form = VirtualMachineFilterForm
     tab = ViewTab(
     tab = ViewTab(
         label=_('Virtual Machines'),
         label=_('Virtual Machines'),
         badge=lambda obj: VirtualMachine.objects.filter(cluster=obj.cluster, device=obj).count(),
         badge=lambda obj: VirtualMachine.objects.filter(cluster=obj.cluster, device=obj).count(),
@@ -2944,6 +2958,7 @@ class InventoryItemChildrenView(generic.ObjectChildrenView):
     child_model = InventoryItem
     child_model = InventoryItem
     table = tables.InventoryItemTable
     table = tables.InventoryItemTable
     filterset = filtersets.InventoryItemFilterSet
     filterset = filtersets.InventoryItemFilterSet
+    filterset_form = forms.InventoryItemFilterForm
     tab = ViewTab(
     tab = ViewTab(
         label=_('Children'),
         label=_('Children'),
         badge=lambda obj: obj.child_items.count(),
         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:
         if request.user.is_anonymous:
             bookmarks = list()
             bookmarks = list()
         else:
         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'):
             if object_types := self.config.get('object_types'):
                 models = get_models_from_content_types(object_types)
                 models = get_models_from_content_types(object_types)
                 content_types = ObjectType.objects.get_for_models(*models).values()
                 content_types = ObjectType.objects.get_for_models(*models).values()
                 bookmarks = bookmarks.filter(object_type__in=content_types)
                 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'):
             if max_items := self.config.get('max_items'):
                 bookmarks = bookmarks[: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:
     if key in queue:
         queue[key]['data'] = serialize_for_event(instance)
         queue[key]['data'] = serialize_for_event(instance)
         queue[key]['snapshots']['postchange'] = get_snapshots(instance, action)['postchange']
         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:
     else:
         queue[key] = {
         queue[key] = {
             'content_type': ContentType.objects.get_for_model(instance),
             '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:
     class Meta:
         model = Tag
         model = Tag
         fields = ('name', 'slug', 'color', 'description')
         fields = ('name', 'slug', 'color', 'description')
-        help_texts = {
-            'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
-        }
 
 
 
 
 class JournalEntryImportForm(NetBoxModelImportForm):
 class JournalEntryImportForm(NetBoxModelImportForm):

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

@@ -66,11 +66,16 @@ class Command(BaseCommand):
             raise CommandError(_("No indexers found!"))
             raise CommandError(_("No indexers found!"))
         self.stdout.write(f'Reindexing {len(indexers)} models.')
         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 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.write('Clearing cached values... ', ending='')
             self.stdout.flush()
             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.')
             self.stdout.write(f'{deleted_count} entries deleted.')
 
 
         # Index models
         # Index models

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

@@ -501,7 +501,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
 
 
         # JSON
         # JSON
         elif self.type == CustomFieldTypeChoices.TYPE_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
         # Object
         elif self.type == CustomFieldTypeChoices.TYPE_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.id = uuid.uuid4()
         request.user = self.user
         request.user = self.user
 
 
-        self.assertEqual(self.queue.count, 0, msg="Unexpected jobs found in queue")
-
+        # Test create & update
         with event_tracking(request):
         with event_tracking(request):
             site = Site(name='Site 1', slug='site-1')
             site = Site(name='Site 1', slug='site-1')
             site.save()
             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()
             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")
         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 circuits.models import Provider
 from dcim.filtersets import InterfaceFilterSet
 from dcim.filtersets import InterfaceFilterSet
+from dcim.forms import InterfaceFilterForm
 from dcim.models import Interface, Site
 from dcim.models import Interface, Site
 from netbox.views import generic
 from netbox.views import generic
 from tenancy.views import ObjectContactsView
 from tenancy.views import ObjectContactsView
@@ -14,6 +15,7 @@ from utilities.query import count_related
 from utilities.tables import get_table_ordering
 from utilities.tables import get_table_ordering
 from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view
 from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view
 from virtualization.filtersets import VMInterfaceFilterSet
 from virtualization.filtersets import VMInterfaceFilterSet
+from virtualization.forms import VMInterfaceFilterForm
 from virtualization.models import VMInterface
 from virtualization.models import VMInterface
 from . import filtersets, forms, tables
 from . import filtersets, forms, tables
 from .choices import PrefixStatusChoices
 from .choices import PrefixStatusChoices
@@ -206,6 +208,7 @@ class ASNRangeASNsView(generic.ObjectChildrenView):
     child_model = ASN
     child_model = ASN
     table = tables.ASNTable
     table = tables.ASNTable
     filterset = filtersets.ASNFilterSet
     filterset = filtersets.ASNFilterSet
+    filterset_form = forms.ASNFilterForm
     tab = ViewTab(
     tab = ViewTab(
         label=_('ASNs'),
         label=_('ASNs'),
         badge=lambda x: x.get_child_asns().count(),
         badge=lambda x: x.get_child_asns().count(),
@@ -337,6 +340,7 @@ class AggregatePrefixesView(generic.ObjectChildrenView):
     child_model = Prefix
     child_model = Prefix
     table = tables.PrefixTable
     table = tables.PrefixTable
     filterset = filtersets.PrefixFilterSet
     filterset = filtersets.PrefixFilterSet
+    filterset_form = forms.PrefixFilterForm
     template_name = 'ipam/aggregate/prefixes.html'
     template_name = 'ipam/aggregate/prefixes.html'
     tab = ViewTab(
     tab = ViewTab(
         label=_('Prefixes'),
         label=_('Prefixes'),
@@ -523,6 +527,7 @@ class PrefixPrefixesView(generic.ObjectChildrenView):
     child_model = Prefix
     child_model = Prefix
     table = tables.PrefixTable
     table = tables.PrefixTable
     filterset = filtersets.PrefixFilterSet
     filterset = filtersets.PrefixFilterSet
+    filterset_form = forms.PrefixFilterForm
     template_name = 'ipam/prefix/prefixes.html'
     template_name = 'ipam/prefix/prefixes.html'
     tab = ViewTab(
     tab = ViewTab(
         label=_('Child Prefixes'),
         label=_('Child Prefixes'),
@@ -558,6 +563,7 @@ class PrefixIPRangesView(generic.ObjectChildrenView):
     child_model = IPRange
     child_model = IPRange
     table = tables.IPRangeTable
     table = tables.IPRangeTable
     filterset = filtersets.IPRangeFilterSet
     filterset = filtersets.IPRangeFilterSet
+    filterset_form = forms.IPRangeFilterForm
     template_name = 'ipam/prefix/ip_ranges.html'
     template_name = 'ipam/prefix/ip_ranges.html'
     tab = ViewTab(
     tab = ViewTab(
         label=_('Child Ranges'),
         label=_('Child Ranges'),
@@ -584,6 +590,7 @@ class PrefixIPAddressesView(generic.ObjectChildrenView):
     child_model = IPAddress
     child_model = IPAddress
     table = tables.IPAddressTable
     table = tables.IPAddressTable
     filterset = filtersets.IPAddressFilterSet
     filterset = filtersets.IPAddressFilterSet
+    filterset_form = forms.IPAddressFilterForm
     template_name = 'ipam/prefix/ip_addresses.html'
     template_name = 'ipam/prefix/ip_addresses.html'
     tab = ViewTab(
     tab = ViewTab(
         label=_('IP Addresses'),
         label=_('IP Addresses'),
@@ -683,6 +690,7 @@ class IPRangeIPAddressesView(generic.ObjectChildrenView):
     child_model = IPAddress
     child_model = IPAddress
     table = tables.IPAddressTable
     table = tables.IPAddressTable
     filterset = filtersets.IPAddressFilterSet
     filterset = filtersets.IPAddressFilterSet
+    filterset_form = forms.IPRangeFilterForm
     template_name = 'ipam/iprange/ip_addresses.html'
     template_name = 'ipam/iprange/ip_addresses.html'
     tab = ViewTab(
     tab = ViewTab(
         label=_('IP Addresses'),
         label=_('IP Addresses'),
@@ -885,6 +893,7 @@ class IPAddressRelatedIPsView(generic.ObjectChildrenView):
     child_model = IPAddress
     child_model = IPAddress
     table = tables.IPAddressTable
     table = tables.IPAddressTable
     filterset = filtersets.IPAddressFilterSet
     filterset = filtersets.IPAddressFilterSet
+    filterset_form = forms.IPAddressFilterForm
     tab = ViewTab(
     tab = ViewTab(
         label=_('Related IPs'),
         label=_('Related IPs'),
         badge=lambda x: x.get_related_ips().count(),
         badge=lambda x: x.get_related_ips().count(),
@@ -957,6 +966,7 @@ class VLANGroupVLANsView(generic.ObjectChildrenView):
     child_model = VLAN
     child_model = VLAN
     table = tables.VLANTable
     table = tables.VLANTable
     filterset = filtersets.VLANFilterSet
     filterset = filtersets.VLANFilterSet
+    filterset_form = forms.VLANFilterForm
     tab = ViewTab(
     tab = ViewTab(
         label=_('VLANs'),
         label=_('VLANs'),
         badge=lambda x: x.get_child_vlans().count(),
         badge=lambda x: x.get_child_vlans().count(),
@@ -1112,6 +1122,7 @@ class VLANInterfacesView(generic.ObjectChildrenView):
     child_model = Interface
     child_model = Interface
     table = tables.VLANDevicesTable
     table = tables.VLANDevicesTable
     filterset = InterfaceFilterSet
     filterset = InterfaceFilterSet
+    filterset_form = InterfaceFilterForm
     tab = ViewTab(
     tab = ViewTab(
         label=_('Device Interfaces'),
         label=_('Device Interfaces'),
         badge=lambda x: x.get_interfaces().count(),
         badge=lambda x: x.get_interfaces().count(),
@@ -1129,6 +1140,7 @@ class VLANVMInterfacesView(generic.ObjectChildrenView):
     child_model = VMInterface
     child_model = VMInterface
     table = tables.VLANVirtualMachinesTable
     table = tables.VLANVirtualMachinesTable
     filterset = VMInterfaceFilterSet
     filterset = VMInterfaceFilterSet
+    filterset_form = VMInterfaceFilterForm
     tab = ViewTab(
     tab = ViewTab(
         label=_('VM Interfaces'),
         label=_('VM Interfaces'),
         badge=lambda x: x.get_vminterfaces().count(),
         badge=lambda x: x.get_vminterfaces().count(),

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

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

+ 5 - 0
netbox/netbox/middleware.py

@@ -36,6 +36,11 @@ class CoreMiddleware:
         with event_tracking(request):
         with event_tracking(request):
             response = self.get_response(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.
         # Attach the unique request ID as an HTTP header.
         response['X-Request-ID'] = request.id
         response['X-Request-ID'] = request.id
 
 

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

@@ -462,16 +462,13 @@ MENUS = [
     PROVISIONING_MENU,
     PROVISIONING_MENU,
     CUSTOMIZATION_MENU,
     CUSTOMIZATION_MENU,
     OPERATIONS_MENU,
     OPERATIONS_MENU,
-    ADMIN_MENU,
 ]
 ]
 
 
-#
-# Add plugin menus
-#
-
+# Add top-level plugin menus
 for menu in registry['plugins']['menus']:
 for menu in registry['plugins']['menus']:
     MENUS.append(menu)
     MENUS.append(menu)
 
 
+# Add the default "plugins" menu
 if registry['plugins']['menu_items']:
 if registry['plugins']['menu_items']:
 
 
     # Build the default plugins menu
     # Build the default plugins menu
@@ -485,3 +482,6 @@ if registry['plugins']['menu_items']:
         groups=groups
         groups=groups
     )
     )
     MENUS.append(plugins_menu)
     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.functions import window
 from django.db.models.signals import post_delete, post_save
 from django.db.models.signals import post_delete, post_save
 from django.utils.module_loading import import_string
 from django.utils.module_loading import import_string
+from django.utils.translation import gettext_lazy as _
 import netaddr
 import netaddr
 from netaddr.core import AddrFormatError
 from netaddr.core import AddrFormatError
 
 
@@ -39,7 +40,7 @@ class SearchBackend:
             # Organize choices by category
             # Organize choices by category
             categories = defaultdict(dict)
             categories = defaultdict(dict)
             for label, idx in registry['search'].items():
             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
             # Compile a nested tuple of choices for form rendering
             results = (
             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_DSN = getattr(configuration, 'SENTRY_DSN', None)
 SENTRY_ENABLED = getattr(configuration, 'SENTRY_ENABLED', False)
 SENTRY_ENABLED = getattr(configuration, 'SENTRY_ENABLED', False)
 SENTRY_SAMPLE_RATE = getattr(configuration, 'SENTRY_SAMPLE_RATE', 1.0)
 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_TAGS = getattr(configuration, 'SENTRY_TAGS', {})
 SENTRY_TRACES_SAMPLE_RATE = getattr(configuration, 'SENTRY_TRACES_SAMPLE_RATE', 0)
 SENTRY_TRACES_SAMPLE_RATE = getattr(configuration, 'SENTRY_TRACES_SAMPLE_RATE', 0)
 SESSION_COOKIE_NAME = getattr(configuration, 'SESSION_COOKIE_NAME', 'sessionid')
 SESSION_COOKIE_NAME = getattr(configuration, 'SESSION_COOKIE_NAME', 'sessionid')
@@ -227,6 +228,23 @@ if STORAGE_BACKEND is not None:
             return globals().get(name, default)
             return globals().get(name, default)
         storages.utils.setting = _setting
         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:
 if STORAGE_CONFIG and STORAGE_BACKEND is None:
     warnings.warn(
     warnings.warn(
         "STORAGE_CONFIG has been set in configuration.py but STORAGE_BACKEND is not defined. STORAGE_CONFIG will be "
         "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,
         release=RELEASE.full_version,
         sample_rate=SENTRY_SAMPLE_RATE,
         sample_rate=SENTRY_SAMPLE_RATE,
         traces_sample_rate=SENTRY_TRACES_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,
         http_proxy=HTTP_PROXIES.get('http') if HTTP_PROXIES else None,
         https_proxy=HTTP_PROXIES.get('https') 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,
             'model': model,
             'table': table,
             'table': table,
             'actions': actions,
             '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),
             'prerequisite_model': get_prerequisite_model(self.queryset),
             **self.get_extra_context(request),
             **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
         child_model: The model class which represents the child objects
         table: The django-tables2 Table class used to render the child objects list
         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: 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
         actions: A mapping of supported actions to their required permissions. When adding custom actions, bulk
             action names must be prefixed with `bulk_`. (See ActionsMixin.)
             action names must be prefixed with `bulk_`. (See ActionsMixin.)
     """
     """
     child_model = None
     child_model = None
     table = None
     table = None
     filterset = None
     filterset = None
+    filterset_form = None
     template_name = 'generic/object_children.html'
     template_name = 'generic/object_children.html'
 
 
     def get_children(self, request, parent):
     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',
             'base_template': f'{instance._meta.app_label}/{instance._meta.model_name}.html',
             'table': table,
             'table': table,
             'table_config': f'{table.name}_config',
             'table_config': f'{table.name}_config',
+            'filter_form': self.filterset_form(request.GET) if self.filterset_form else None,
             'actions': actions,
             'actions': actions,
             'tab': self.tab,
             'tab': self.tab,
             'return_url': request.get_full_path(),
             'return_url': request.get_full_path(),

Plik diff jest za duży
+ 0 - 0
netbox/project-static/dist/netbox.js


Plik diff jest za duży
+ 0 - 0
netbox/project-static/dist/netbox.js.map


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

@@ -27,7 +27,7 @@
     "bootstrap": "5.3.3",
     "bootstrap": "5.3.3",
     "clipboard": "2.0.11",
     "clipboard": "2.0.11",
     "flatpickr": "4.6.13",
     "flatpickr": "4.6.13",
-    "gridstack": "10.2.1",
+    "gridstack": "10.3.0",
     "htmx.org": "1.9.12",
     "htmx.org": "1.9.12",
     "query-string": "9.0.0",
     "query-string": "9.0.0",
     "sass": "1.77.6",
     "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) {
   load(value: string) {
     const self = this;
     const self = this;
-    const url = self.getRequestUrl(value);
 
 
     // Automatically clear any cached options. (Only options included
     // Automatically clear any cached options. (Only options included
     // in the API response should be present.)
     // in the API response should be present.)
     self.clearOptions();
     self.clearOptions();
 
 
-    addClasses(self.wrapper, self.settings.loadingClass);
-    self.loading++;
-
     // Populate the null option (if any) if not searching
     // Populate the null option (if any) if not searching
     if (self.nullOption && !value) {
     if (self.nullOption && !value) {
       self.addOption(self.nullOption);
       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
     // Make the API request
     fetch(url)
     fetch(url)
       .then(response => response.json())
       .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'))) {
       for (const result of this.api_url.matchAll(new RegExp(`({{${key}}})`, 'g'))) {
         if (value) {
         if (value) {
           url = replaceAll(url, result[1], value.toString());
           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"
   resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.8.1.tgz#1930a965bef1170603702acdb68aedd3f3cf6f07"
   integrity sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==
   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:
 has-bigints@^1.0.1, has-bigints@^1.0.2:
   version "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="col col-md-12">
       <div class="card">
       <div class="card">
         <h5 class="card-header">{% trans "Current Configuration" %}</h5>
         <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>
 
 
     </div>
     </div>

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

@@ -125,28 +125,30 @@
                       </div>
                       </div>
                     </h5>
                     </h5>
                     <table class="table table-hover attr-table">
                     <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>
                         </tr>
+                      </thead>
+                      <tbody>
                         {% for vc_member in vc_members %}
                         {% 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 %}
                         {% endfor %}
+                      </tbody>
                     </table>
                     </table>
                 </div>
                 </div>
             {% endif %}
             {% endif %}
@@ -221,6 +223,11 @@
                         <td>
                         <td>
                           {% if object.oob_ip %}
                           {% if object.oob_ip %}
                             <a href="{{ object.oob_ip.get_absolute_url }}" id="oob_ip">{{ object.oob_ip.address.ip }}</a>
                             <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" %}
                             {% copy_content "oob_ip" %}
                           {% else %}
                           {% else %}
                             {{ ''|placeholder }}
                             {{ ''|placeholder }}

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

@@ -48,7 +48,7 @@ Context:
     <li class="nav-item" role="presentation">
     <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">
       <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" %}
         {% 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>
       </a>
     </li>
     </li>
     {% if filter_form %}
     {% if filter_form %}

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

@@ -13,14 +13,16 @@
     </div>
     </div>
   </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>
       </div>
-      {{ filter_form.filter_id }}
     </div>
     </div>
-  </div>
+  {% endif %}
 
 
   <div class="col-auto ms-auto d-print-none">
   <div class="col-auto ms-auto d-print-none">
     {% if request.user.is_authenticated and table_modal %}
     {% if request.user.is_authenticated and table_modal %}

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

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

+ 2 - 1
netbox/tenancy/views.py

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

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


Plik diff jest za duży
+ 218 - 214
netbox/translations/de/LC_MESSAGES/django.po


Plik diff jest za duży
+ 232 - 228
netbox/translations/en/LC_MESSAGES/django.po


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


Plik diff jest za duży
+ 234 - 230
netbox/translations/fr/LC_MESSAGES/django.po


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


Plik diff jest za duży
+ 232 - 229
netbox/translations/pt/LC_MESSAGES/django.po


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


Plik diff jest za duży
+ 216 - 213
netbox/translations/ru/LC_MESSAGES/django.po


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


Plik diff jest za duży
+ 229 - 225
netbox/translations/tr/LC_MESSAGES/django.po


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


Plik diff jest za duży
+ 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.contrib.contenttypes.fields import GenericForeignKey
 from django.db import models
 from django.db import models
+from django.utils.safestring import mark_safe
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
 from utilities.ordering import naturalize
 from utilities.ordering import naturalize
@@ -26,6 +27,7 @@ class ColorField(models.CharField):
 
 
     def formfield(self, **kwargs):
     def formfield(self, **kwargs):
         kwargs['widget'] = ColorSelect
         kwargs['widget'] = ColorSelect
+        kwargs['help_text'] = mark_safe(_('RGB color in hexadecimal. Example: ') + '<code>00ff00</code>')
         return super().formfield(**kwargs)
         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():
     for key, value in attrs.items():
         if type(value) in (list, tuple):
         if type(value) in (list, tuple):
             params.extend([(key, v) for v in value])
             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))
             params.append((key, value))
         else:
         else:
             params.append((key, ''))
             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.')
                 '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({
             raise ValidationError({
                 'cluster': _(
                 'cluster': _(
                     'The selected cluster ({cluster}) is not assigned to this site ({site}).'
                     '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 jinja2.exceptions import TemplateError
 
 
 from dcim.filtersets import DeviceFilterSet
 from dcim.filtersets import DeviceFilterSet
+from dcim.forms import DeviceFilterForm
 from dcim.models import Device
 from dcim.models import Device
 from dcim.tables import DeviceTable
 from dcim.tables import DeviceTable
 from extras.views import ObjectConfigContextView
 from extras.views import ObjectConfigContextView
@@ -173,6 +174,7 @@ class ClusterVirtualMachinesView(generic.ObjectChildrenView):
     child_model = VirtualMachine
     child_model = VirtualMachine
     table = tables.VirtualMachineTable
     table = tables.VirtualMachineTable
     filterset = filtersets.VirtualMachineFilterSet
     filterset = filtersets.VirtualMachineFilterSet
+    filterset_form = forms.VirtualMachineFilterForm
     tab = ViewTab(
     tab = ViewTab(
         label=_('Virtual Machines'),
         label=_('Virtual Machines'),
         badge=lambda obj: obj.virtual_machines.count(),
         badge=lambda obj: obj.virtual_machines.count(),
@@ -190,6 +192,7 @@ class ClusterDevicesView(generic.ObjectChildrenView):
     child_model = Device
     child_model = Device
     table = DeviceTable
     table = DeviceTable
     filterset = DeviceFilterSet
     filterset = DeviceFilterSet
+    filterset_form = DeviceFilterForm
     template_name = 'virtualization/cluster/devices.html'
     template_name = 'virtualization/cluster/devices.html'
     actions = {
     actions = {
         'add': {'add'},
         'add': {'add'},
@@ -350,6 +353,7 @@ class VirtualMachineInterfacesView(generic.ObjectChildrenView):
     child_model = VMInterface
     child_model = VMInterface
     table = tables.VirtualMachineVMInterfaceTable
     table = tables.VirtualMachineVMInterfaceTable
     filterset = filtersets.VMInterfaceFilterSet
     filterset = filtersets.VMInterfaceFilterSet
+    filterset_form = forms.VMInterfaceFilterForm
     template_name = 'virtualization/virtualmachine/interfaces.html'
     template_name = 'virtualization/virtualmachine/interfaces.html'
     actions = {
     actions = {
         **DEFAULT_ACTION_PERMISSIONS,
         **DEFAULT_ACTION_PERMISSIONS,
@@ -375,6 +379,7 @@ class VirtualMachineVirtualDisksView(generic.ObjectChildrenView):
     child_model = VirtualDisk
     child_model = VirtualDisk
     table = tables.VirtualMachineVirtualDiskTable
     table = tables.VirtualMachineVirtualDiskTable
     filterset = filtersets.VirtualDiskFilterSet
     filterset = filtersets.VirtualDiskFilterSet
+    filterset_form = forms.VirtualDiskFilterForm
     template_name = 'virtualization/virtualmachine/virtual_disks.html'
     template_name = 'virtualization/virtualmachine/virtual_disks.html'
     tab = ViewTab(
     tab = ViewTab(
         label=_('Virtual Disks'),
         label=_('Virtual Disks'),

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

@@ -22,7 +22,8 @@ class IKEProposalSerializer(NetBoxModelSerializer):
         choices=EncryptionAlgorithmChoices
         choices=EncryptionAlgorithmChoices
     )
     )
     authentication_algorithm = ChoiceField(
     authentication_algorithm = ChoiceField(
-        choices=AuthenticationAlgorithmChoices
+        choices=AuthenticationAlgorithmChoices,
+        required=False
     )
     )
     group = ChoiceField(
     group = ChoiceField(
         choices=DHGroupChoices
         choices=DHGroupChoices
@@ -43,7 +44,8 @@ class IKEPolicySerializer(NetBoxModelSerializer):
         choices=IKEVersionChoices
         choices=IKEVersionChoices
     )
     )
     mode = ChoiceField(
     mode = ChoiceField(
-        choices=IKEModeChoices
+        choices=IKEModeChoices,
+        required=False
     )
     )
     proposals = SerializedPKRelatedField(
     proposals = SerializedPKRelatedField(
         queryset=IKEProposal.objects.all(),
         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-cors-headers==4.4.0
 django-debug-toolbar==4.3.0
 django-debug-toolbar==4.3.0
 django-filter==24.2
 django-filter==24.2
@@ -12,26 +12,26 @@ django-rich==1.9.0
 django-rq==2.10.2
 django-rq==2.10.2
 django-taggit==5.0.1
 django-taggit==5.0.1
 django-tables2==2.7.0
 django-tables2==2.7.0
-django-timezone-field==6.1.0
+django-timezone-field==7.0
 djangorestframework==3.15.2
 djangorestframework==3.15.2
 drf-spectacular==0.27.2
 drf-spectacular==0.27.2
-drf-spectacular-sidecar==2024.6.1
+drf-spectacular-sidecar==2024.7.1
 feedparser==6.0.11
 feedparser==6.0.11
 gunicorn==22.0.0
 gunicorn==22.0.0
 Jinja2==3.1.4
 Jinja2==3.1.4
 Markdown==3.6
 Markdown==3.6
-mkdocs-material==9.5.27
+mkdocs-material==9.5.28
 mkdocstrings[python-legacy]==0.25.1
 mkdocstrings[python-legacy]==0.25.1
 netaddr==1.3.0
 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
 PyYAML==6.0.1
 requests==2.32.3
 requests==2.32.3
 social-auth-app-django==5.4.1
 social-auth-app-django==5.4.1
 social-auth-core==4.5.4
 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
 svgwrite==1.4.3
 tablib==3.6.1
 tablib==3.6.1
 tzdata==2024.1
 tzdata==2024.1

+ 2 - 2
upgrade.sh

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

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików