Przeglądaj źródła

Merge branch 'main' into feature

Jeremy Stretch 2 miesięcy temu
rodzic
commit
97d0a16fd4
58 zmienionych plików z 3863 dodań i 2948 usunięć
  1. 1 1
      .github/ISSUE_TEMPLATE/01-feature_request.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/02-bug_report.yaml
  3. 521 1
      contrib/openapi.json
  4. 1 1
      docs/development/models.md
  5. 17 0
      docs/release-notes/version-4.4.md
  6. 11 0
      netbox/dcim/filtersets.py
  7. 17 1
      netbox/dcim/forms/bulk_import.py
  8. 22 11
      netbox/dcim/forms/filtersets.py
  9. 10 8
      netbox/dcim/migrations/0216_latitude_longitude_validators.py
  10. 2 2
      netbox/dcim/models/device_component_templates.py
  11. 8 2
      netbox/dcim/models/devices.py
  12. 0 2
      netbox/dcim/models/modules.py
  13. 10 2
      netbox/dcim/models/sites.py
  14. 98 0
      netbox/dcim/tests/test_filtersets.py
  15. 48 2
      netbox/dcim/tests/test_models.py
  16. 25 11
      netbox/extras/jobs.py
  17. 4 0
      netbox/netbox/views/generic/object_views.py
  18. 0 0
      netbox/project-static/dist/netbox.css
  19. 0 0
      netbox/project-static/dist/netbox.js
  20. 0 0
      netbox/project-static/dist/netbox.js.map
  21. 1 1
      netbox/project-static/package.json
  22. 52 11
      netbox/project-static/src/buttons/moveOptions.ts
  23. 14 0
      netbox/project-static/styles/transitional/_forms.scss
  24. 4 4
      netbox/project-static/yarn.lock
  25. 2 2
      netbox/release.yaml
  26. BIN
      netbox/translations/cs/LC_MESSAGES/django.mo
  27. 190 190
      netbox/translations/cs/LC_MESSAGES/django.po
  28. BIN
      netbox/translations/da/LC_MESSAGES/django.mo
  29. 190 190
      netbox/translations/da/LC_MESSAGES/django.po
  30. BIN
      netbox/translations/de/LC_MESSAGES/django.mo
  31. 189 189
      netbox/translations/de/LC_MESSAGES/django.po
  32. 192 192
      netbox/translations/en/LC_MESSAGES/django.po
  33. BIN
      netbox/translations/es/LC_MESSAGES/django.mo
  34. 190 190
      netbox/translations/es/LC_MESSAGES/django.po
  35. BIN
      netbox/translations/fr/LC_MESSAGES/django.mo
  36. 190 190
      netbox/translations/fr/LC_MESSAGES/django.po
  37. BIN
      netbox/translations/it/LC_MESSAGES/django.mo
  38. 190 190
      netbox/translations/it/LC_MESSAGES/django.po
  39. BIN
      netbox/translations/ja/LC_MESSAGES/django.mo
  40. 192 192
      netbox/translations/ja/LC_MESSAGES/django.po
  41. BIN
      netbox/translations/nl/LC_MESSAGES/django.mo
  42. 190 190
      netbox/translations/nl/LC_MESSAGES/django.po
  43. BIN
      netbox/translations/pl/LC_MESSAGES/django.mo
  44. 190 190
      netbox/translations/pl/LC_MESSAGES/django.po
  45. BIN
      netbox/translations/pt/LC_MESSAGES/django.mo
  46. 190 190
      netbox/translations/pt/LC_MESSAGES/django.po
  47. BIN
      netbox/translations/ru/LC_MESSAGES/django.mo
  48. 192 192
      netbox/translations/ru/LC_MESSAGES/django.po
  49. BIN
      netbox/translations/tr/LC_MESSAGES/django.mo
  50. 190 190
      netbox/translations/tr/LC_MESSAGES/django.po
  51. BIN
      netbox/translations/uk/LC_MESSAGES/django.mo
  52. 192 192
      netbox/translations/uk/LC_MESSAGES/django.po
  53. BIN
      netbox/translations/zh/LC_MESSAGES/django.mo
  54. 192 192
      netbox/translations/zh/LC_MESSAGES/django.po
  55. 21 4
      netbox/users/forms/model_forms.py
  56. 36 13
      netbox/utilities/forms/widgets/select.py
  57. 69 0
      netbox/utilities/tests/test_forms.py
  58. 9 9
      requirements.txt

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

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

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

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

+ 521 - 1
contrib/openapi.json

@@ -2,7 +2,7 @@
     "openapi": "3.0.3",
     "info": {
         "title": "NetBox REST API",
-        "version": "4.4.7",
+        "version": "4.4.8",
         "license": {
             "name": "Apache v2 License"
         }
@@ -27997,6 +27997,58 @@
                         "explode": true,
                         "style": "form"
                     },
+                    {
+                        "in": "query",
+                        "name": "tenant",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        },
+                        "description": "Tenant (slug)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant__n",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        },
+                        "description": "Tenant (slug)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant_id",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer"
+                            }
+                        },
+                        "description": "Tenant (ID)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant_id__n",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer"
+                            }
+                        },
+                        "description": "Tenant (ID)",
+                        "explode": true,
+                        "style": "form"
+                    },
                     {
                         "in": "query",
                         "name": "type",
@@ -31608,6 +31660,58 @@
                         "explode": true,
                         "style": "form"
                     },
+                    {
+                        "in": "query",
+                        "name": "tenant",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        },
+                        "description": "Tenant (slug)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant__n",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        },
+                        "description": "Tenant (slug)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant_id",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer"
+                            }
+                        },
+                        "description": "Tenant (ID)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant_id__n",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer"
+                            }
+                        },
+                        "description": "Tenant (ID)",
+                        "explode": true,
+                        "style": "form"
+                    },
                     {
                         "in": "query",
                         "name": "type",
@@ -35022,6 +35126,58 @@
                         "explode": true,
                         "style": "form"
                     },
+                    {
+                        "in": "query",
+                        "name": "tenant",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        },
+                        "description": "Tenant (slug)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant__n",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        },
+                        "description": "Tenant (slug)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant_id",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer"
+                            }
+                        },
+                        "description": "Tenant (ID)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant_id__n",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer"
+                            }
+                        },
+                        "description": "Tenant (ID)",
+                        "explode": true,
+                        "style": "form"
+                    },
                     {
                         "in": "query",
                         "name": "updated_by_request",
@@ -47623,6 +47779,58 @@
                         "explode": true,
                         "style": "form"
                     },
+                    {
+                        "in": "query",
+                        "name": "tenant",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        },
+                        "description": "Tenant (slug)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant__n",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        },
+                        "description": "Tenant (slug)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant_id",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer"
+                            }
+                        },
+                        "description": "Tenant (ID)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant_id__n",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer"
+                            }
+                        },
+                        "description": "Tenant (ID)",
+                        "explode": true,
+                        "style": "form"
+                    },
                     {
                         "in": "query",
                         "name": "type",
@@ -53692,6 +53900,58 @@
                         "explode": true,
                         "style": "form"
                     },
+                    {
+                        "in": "query",
+                        "name": "tenant",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        },
+                        "description": "Tenant (slug)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant__n",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        },
+                        "description": "Tenant (slug)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant_id",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer"
+                            }
+                        },
+                        "description": "Tenant (ID)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant_id__n",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer"
+                            }
+                        },
+                        "description": "Tenant (ID)",
+                        "explode": true,
+                        "style": "form"
+                    },
                     {
                         "in": "query",
                         "name": "tx_power",
@@ -60311,6 +60571,58 @@
                         "explode": true,
                         "style": "form"
                     },
+                    {
+                        "in": "query",
+                        "name": "tenant",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        },
+                        "description": "Tenant (slug)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant__n",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        },
+                        "description": "Tenant (slug)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant_id",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer"
+                            }
+                        },
+                        "description": "Tenant (ID)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant_id__n",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer"
+                            }
+                        },
+                        "description": "Tenant (ID)",
+                        "explode": true,
+                        "style": "form"
+                    },
                     {
                         "in": "query",
                         "name": "updated_by_request",
@@ -68666,6 +68978,58 @@
                         "explode": true,
                         "style": "form"
                     },
+                    {
+                        "in": "query",
+                        "name": "tenant",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        },
+                        "description": "Tenant (slug)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant__n",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        },
+                        "description": "Tenant (slug)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant_id",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer"
+                            }
+                        },
+                        "description": "Tenant (ID)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant_id__n",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer"
+                            }
+                        },
+                        "description": "Tenant (ID)",
+                        "explode": true,
+                        "style": "form"
+                    },
                     {
                         "in": "query",
                         "name": "updated_by_request",
@@ -81283,6 +81647,58 @@
                         "explode": true,
                         "style": "form"
                     },
+                    {
+                        "in": "query",
+                        "name": "tenant",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        },
+                        "description": "Tenant (slug)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant__n",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        },
+                        "description": "Tenant (slug)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant_id",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer"
+                            }
+                        },
+                        "description": "Tenant (ID)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant_id__n",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer"
+                            }
+                        },
+                        "description": "Tenant (ID)",
+                        "explode": true,
+                        "style": "form"
+                    },
                     {
                         "in": "query",
                         "name": "type",
@@ -86612,6 +87028,58 @@
                         "explode": true,
                         "style": "form"
                     },
+                    {
+                        "in": "query",
+                        "name": "tenant",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        },
+                        "description": "Tenant (slug)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant__n",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        },
+                        "description": "Tenant (slug)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant_id",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer"
+                            }
+                        },
+                        "description": "Tenant (ID)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant_id__n",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer"
+                            }
+                        },
+                        "description": "Tenant (ID)",
+                        "explode": true,
+                        "style": "form"
+                    },
                     {
                         "in": "query",
                         "name": "type",
@@ -99835,6 +100303,58 @@
                         "explode": true,
                         "style": "form"
                     },
+                    {
+                        "in": "query",
+                        "name": "tenant",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        },
+                        "description": "Tenant (slug)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant__n",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        },
+                        "description": "Tenant (slug)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant_id",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer"
+                            }
+                        },
+                        "description": "Tenant (ID)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant_id__n",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer"
+                            }
+                        },
+                        "description": "Tenant (ID)",
+                        "explode": true,
+                        "style": "form"
+                    },
                     {
                         "in": "query",
                         "name": "type",

+ 1 - 1
docs/development/models.md

@@ -12,7 +12,7 @@ Depending on its classification, each NetBox model may support various features
 
 | Feature                                                    | Feature Mixin           | Registry Key        | Description                                                                             |
 |------------------------------------------------------------|-------------------------|---------------------|-----------------------------------------------------------------------------------------|
-| [Bookmarks](../features/customization.md#bookmarks)        | `BookmarksMixin`        | `bookmarks`         | These models can be bookmarked natively in the user interface                           |
+| [Bookmarks](../features/user-preferences.md#bookmarks)     | `BookmarksMixin`        | `bookmarks`         | These models can be bookmarked natively in the user interface                           |
 | [Change logging](../features/change-logging.md)            | `ChangeLoggingMixin`    | `change_logging`    | Changes to these objects are automatically recorded in the change log                   |
 | Cloning                                                    | `CloningMixin`          | `cloning`           | Provides the `clone()` method to prepare a copy                                         |
 | [Contacts](../features/contacts.md)                        | `ContactsMixin`         | `contacts`          | Contacts can be associated with these models                                            |

+ 17 - 0
docs/release-notes/version-4.4.md

@@ -1,5 +1,22 @@
 # NetBox v4.4
 
+## v4.4.8 (2025-12-09)
+
+### Enhancements
+
+* [#20068](https://github.com/netbox-community/netbox/issues/20068) - Support the assignment of module type profile attributes via bulk import
+* [#20914](https://github.com/netbox-community/netbox/issues/20914) - Enable filtering device components by tenant assigned to device
+
+### Bug Fixes
+
+* [#19918](https://github.com/netbox-community/netbox/issues/19918) - Fix support for `{module}` resolution of components of child modules
+* [#20759](https://github.com/netbox-community/netbox/issues/20759) - Improve legibility of object types in permissions form
+* [#20860](https://github.com/netbox-community/netbox/issues/20860) - Ensure user-provided changelog message is recorded when creating device components via the UI
+* [#20878](https://github.com/netbox-community/netbox/issues/20878) - Use the active database connection when executing custom scripts
+* [#20888](https://github.com/netbox-community/netbox/issues/20888) - Resolve warnings about non-decimal values for min/max latitude & longitude fields
+
+---
+
 ## v4.4.7 (2025-11-25)
 
 ### Enhancements

+ 11 - 0
netbox/dcim/filtersets.py

@@ -1664,6 +1664,17 @@ class DeviceComponentFilterSet(OwnerFilterMixin, NetBoxModelFilterSet):
         choices=DeviceStatusChoices,
         field_name='device__status',
     )
+    tenant_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='device__tenant',
+        queryset=Tenant.objects.all(),
+        label=_('Tenant (ID)'),
+    )
+    tenant = django_filters.ModelMultipleChoiceFilter(
+        field_name='device__tenant__slug',
+        queryset=Tenant.objects.all(),
+        to_field_name='slug',
+        label=_('Tenant (slug)'),
+    )
 
     def search(self, queryset, name, value):
         if not value.strip():

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

@@ -476,14 +476,30 @@ class ModuleTypeImportForm(PrimaryModelImportForm):
         required=False,
         help_text=_('Unit for module weight')
     )
+    attribute_data = forms.JSONField(
+        label=_('Attributes'),
+        required=False,
+        help_text=_('Attribute values for the assigned profile, passed as a dictionary')
+    )
 
     class Meta:
         model = ModuleType
         fields = [
             'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit', 'profile',
-            'owner', 'comments', 'tags'
+            'attribute_data', 'owner', 'comments', 'tags',
         ]
 
+    def clean(self):
+        super().clean()
+
+        # Attribute data may be included only if a profile is specified
+        if self.cleaned_data.get('attribute_data') and not self.cleaned_data.get('profile'):
+            raise forms.ValidationError(_("Profile must be specified if attribute data is provided."))
+
+        # Default attribute_data to an empty dictionary if a profile is specified (to enforce schema validation)
+        if self.cleaned_data.get('profile') and not self.cleaned_data.get('attribute_data'):
+            self.cleaned_data['attribute_data'] = {}
+
 
 class DeviceRoleImportForm(NestedGroupModelImportForm):
     parent = CSVModelChoiceField(

+ 22 - 11
netbox/dcim/forms/filtersets.py

@@ -13,6 +13,7 @@ from netbox.forms import (
     PrimaryModelFilterSetForm,
 )
 from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
+from tenancy.models import Tenant
 from users.models import Owner, User
 from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
 from utilities.forms.fields import ColorField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField
@@ -123,6 +124,11 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
         required=False,
         label=_('Device role')
     )
+    tenant_id = DynamicModelMultipleChoiceField(
+        queryset=Tenant.objects.all(),
+        required=False,
+        label=_('Tenant')
+    )
     device_id = DynamicModelMultipleChoiceField(
         queryset=Device.objects.all(),
         required=False,
@@ -131,7 +137,8 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
             'location_id': '$location_id',
             'virtual_chassis_id': '$virtual_chassis_id',
             'device_type_id': '$device_type_id',
-            'role_id': '$role_id'
+            'role_id': '$role_id',
+            'tenant_id': '$tenant_id'
         },
         label=_('Device')
     )
@@ -1360,7 +1367,8 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
         FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet(
-            'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', name=_('Device')
+            'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
+            name=_('Device')
         ),
         FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
     )
@@ -1384,7 +1392,7 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF
         FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet(
-            'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
+            'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
             name=_('Device')
         ),
         FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
@@ -1409,7 +1417,8 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
         FieldSet('name', 'label', 'type', name=_('Attributes')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet(
-            'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', name=_('Device')
+            'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
+            name=_('Device')
         ),
         FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
     )
@@ -1428,7 +1437,7 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
         FieldSet('name', 'label', 'type', 'color', 'status', name=_('Attributes')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet(
-            'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
+            'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
             name=_('Device')
         ),
         FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
@@ -1461,7 +1470,8 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
         FieldSet('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power', name=_('Wireless')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet(
-            'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', 'vdc_id',
+            'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
+            'vdc_id',
             name=_('Device')
         ),
         FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
@@ -1582,7 +1592,8 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
         FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet(
-            'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', name=_('Device')
+            'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
+            name=_('Device')
         ),
         FieldSet('cabled', 'occupied', name=_('Cable')),
     )
@@ -1606,7 +1617,7 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
         FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet(
-            'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
+            'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
             name=_('Device')
         ),
         FieldSet('cabled', 'occupied', name=_('Cable')),
@@ -1630,7 +1641,7 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
         FieldSet('name', 'label', 'position', name=_('Attributes')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet(
-            'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
+            'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
             name=_('Device')
         ),
     )
@@ -1648,7 +1659,7 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
         FieldSet('name', 'label', name=_('Attributes')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet(
-            'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
+            'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
             name=_('Device')
         ),
     )
@@ -1665,7 +1676,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
         ),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet(
-            'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
+            'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
             name=_('Device')
         ),
     )

+ 10 - 8
netbox/dcim/migrations/0216_latitude_longitude_validators.py

@@ -1,3 +1,5 @@
+import decimal
+
 import django.core.validators
 from django.db import migrations, models
 
@@ -17,8 +19,8 @@ class Migration(migrations.Migration):
                 max_digits=8,
                 null=True,
                 validators=[
-                    django.core.validators.MinValueValidator(-90.0),
-                    django.core.validators.MaxValueValidator(90.0),
+                    django.core.validators.MinValueValidator(decimal.Decimal('-90.0')),
+                    django.core.validators.MaxValueValidator(decimal.Decimal('90.0'))
                 ],
             ),
         ),
@@ -31,8 +33,8 @@ class Migration(migrations.Migration):
                 max_digits=9,
                 null=True,
                 validators=[
-                    django.core.validators.MinValueValidator(-180.0),
-                    django.core.validators.MaxValueValidator(180.0),
+                    django.core.validators.MinValueValidator(decimal.Decimal('-180.0')),
+                    django.core.validators.MaxValueValidator(decimal.Decimal('180.0'))
                 ],
             ),
         ),
@@ -45,8 +47,8 @@ class Migration(migrations.Migration):
                 max_digits=8,
                 null=True,
                 validators=[
-                    django.core.validators.MinValueValidator(-90.0),
-                    django.core.validators.MaxValueValidator(90.0),
+                    django.core.validators.MinValueValidator(decimal.Decimal('-90.0')),
+                    django.core.validators.MaxValueValidator(decimal.Decimal('90.0'))
                 ],
             ),
         ),
@@ -59,8 +61,8 @@ class Migration(migrations.Migration):
                 max_digits=9,
                 null=True,
                 validators=[
-                    django.core.validators.MinValueValidator(-180.0),
-                    django.core.validators.MaxValueValidator(180.0),
+                    django.core.validators.MinValueValidator(decimal.Decimal('-180.0')),
+                    django.core.validators.MaxValueValidator(decimal.Decimal('180.0'))
                 ],
             ),
         ),

+ 2 - 2
netbox/dcim/models/device_component_templates.py

@@ -687,8 +687,8 @@ class ModuleBayTemplate(ModularComponentTemplateModel):
 
     def instantiate(self, **kwargs):
         return self.component_model(
-            name=self.name,
-            label=self.label,
+            name=self.resolve_name(kwargs.get('module')),
+            label=self.resolve_label(kwargs.get('module')),
             position=self.position,
             **kwargs
         )

+ 8 - 2
netbox/dcim/models/devices.py

@@ -650,7 +650,10 @@ class Device(
         decimal_places=6,
         blank=True,
         null=True,
-        validators=[MinValueValidator(-90.0), MaxValueValidator(90.0)],
+        validators=[
+            MinValueValidator(decimal.Decimal('-90.0')),
+            MaxValueValidator(decimal.Decimal('90.0'))
+        ],
         help_text=_("GPS coordinate in decimal format (xx.yyyyyy)")
     )
     longitude = models.DecimalField(
@@ -659,7 +662,10 @@ class Device(
         decimal_places=6,
         blank=True,
         null=True,
-        validators=[MinValueValidator(-180.0), MaxValueValidator(180.0)],
+        validators=[
+            MinValueValidator(decimal.Decimal('-180.0')),
+            MaxValueValidator(decimal.Decimal('180.0'))
+        ],
         help_text=_("GPS coordinate in decimal format (xx.yyyyyy)")
     )
     services = GenericRelation(

+ 0 - 2
netbox/dcim/models/modules.py

@@ -7,7 +7,6 @@ from django.utils.translation import gettext_lazy as _
 from jsonschema.exceptions import ValidationError as JSONValidationError
 
 from dcim.choices import *
-from dcim.constants import MODULE_TOKEN
 from dcim.utils import update_interface_bridges
 from extras.models import ConfigContextModel, CustomField
 from netbox.models import PrimaryModel
@@ -337,7 +336,6 @@ class Module(TrackingModelMixin, PrimaryModel, ConfigContextModel):
             else:
                 # ModuleBays must be saved individually for MPTT
                 for instance in create_instances:
-                    instance.name = instance.name.replace(MODULE_TOKEN, str(self.module_bay.position))
                     instance.save()
 
             update_fields = ['module']

+ 10 - 2
netbox/dcim/models/sites.py

@@ -1,3 +1,5 @@
+import decimal
+
 from django.contrib.contenttypes.fields import GenericRelation
 from django.core.exceptions import ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
@@ -211,7 +213,10 @@ class Site(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
         decimal_places=6,
         blank=True,
         null=True,
-        validators=[MinValueValidator(-90.0), MaxValueValidator(90.0)],
+        validators=[
+            MinValueValidator(decimal.Decimal('-90.0')),
+            MaxValueValidator(decimal.Decimal('90.0'))
+        ],
         help_text=_('GPS coordinate in decimal format (xx.yyyyyy)')
     )
     longitude = models.DecimalField(
@@ -220,7 +225,10 @@ class Site(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
         decimal_places=6,
         blank=True,
         null=True,
-        validators=[MinValueValidator(-180.0), MaxValueValidator(180.0)],
+        validators=[
+            MinValueValidator(decimal.Decimal('-180.0')),
+            MaxValueValidator(decimal.Decimal('180.0'))
+        ],
         help_text=_('GPS coordinate in decimal format (xx.yyyyyy)')
     )
 

+ 98 - 0
netbox/dcim/tests/test_filtersets.py

@@ -43,6 +43,13 @@ class DeviceComponentFilterSetTests:
         params = {'device_status': ['active', 'planned']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_tenant(self):
+        tenants = Tenant.objects.all()[:2]
+        params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'tenant': [tenants[0].slug, tenants[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
 
 class DeviceComponentTemplateFilterSetTests:
 
@@ -3384,9 +3391,17 @@ class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
         )
         Rack.objects.bulk_create(racks)
 
+        tenants = (
+            Tenant(name='Tenant 1', slug='tenant-1'),
+            Tenant(name='Tenant 2', slug='tenant-2'),
+            Tenant(name='Tenant 3', slug='tenant-3'),
+        )
+        Tenant.objects.bulk_create(tenants)
+
         devices = (
             Device(
                 name='Device 1',
+                tenant=tenants[0],
                 device_type=device_types[0],
                 role=roles[0],
                 site=sites[0],
@@ -3396,6 +3411,7 @@ class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
             ),
             Device(
                 name='Device 2',
+                tenant=tenants[1],
                 device_type=device_types[1],
                 role=roles[1],
                 site=sites[1],
@@ -3405,6 +3421,7 @@ class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
             ),
             Device(
                 name='Device 3',
+                tenant=tenants[2],
                 device_type=device_types[2],
                 role=roles[2],
                 site=sites[2],
@@ -3624,9 +3641,17 @@ class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeL
         )
         Rack.objects.bulk_create(racks)
 
+        tenants = (
+            Tenant(name='Tenant 1', slug='tenant-1'),
+            Tenant(name='Tenant 2', slug='tenant-2'),
+            Tenant(name='Tenant 3', slug='tenant-3'),
+        )
+        Tenant.objects.bulk_create(tenants)
+
         devices = (
             Device(
                 name='Device 1',
+                tenant=tenants[0],
                 device_type=device_types[0],
                 role=roles[0],
                 site=sites[0],
@@ -3636,6 +3661,7 @@ class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeL
             ),
             Device(
                 name='Device 2',
+                tenant=tenants[1],
                 device_type=device_types[1],
                 role=roles[1],
                 site=sites[1],
@@ -3645,6 +3671,7 @@ class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeL
             ),
             Device(
                 name='Device 3',
+                tenant=tenants[2],
                 device_type=device_types[2],
                 role=roles[2],
                 site=sites[2],
@@ -3864,9 +3891,17 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
         )
         Rack.objects.bulk_create(racks)
 
+        tenants = (
+            Tenant(name='Tenant 1', slug='tenant-1'),
+            Tenant(name='Tenant 2', slug='tenant-2'),
+            Tenant(name='Tenant 3', slug='tenant-3'),
+        )
+        Tenant.objects.bulk_create(tenants)
+
         devices = (
             Device(
                 name='Device 1',
+                tenant=tenants[0],
                 device_type=device_types[0],
                 role=roles[0],
                 site=sites[0],
@@ -3876,6 +3911,7 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
             ),
             Device(
                 name='Device 2',
+                tenant=tenants[1],
                 device_type=device_types[1],
                 role=roles[1],
                 site=sites[1],
@@ -3885,6 +3921,7 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
             ),
             Device(
                 name='Device 3',
+                tenant=tenants[2],
                 device_type=device_types[2],
                 role=roles[2],
                 site=sites[2],
@@ -4118,9 +4155,17 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
         )
         Rack.objects.bulk_create(racks)
 
+        tenants = (
+            Tenant(name='Tenant 1', slug='tenant-1'),
+            Tenant(name='Tenant 2', slug='tenant-2'),
+            Tenant(name='Tenant 3', slug='tenant-3'),
+        )
+        Tenant.objects.bulk_create(tenants)
+
         devices = (
             Device(
                 name='Device 1',
+                tenant=tenants[0],
                 device_type=device_types[0],
                 role=roles[0],
                 site=sites[0],
@@ -4130,6 +4175,7 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
             ),
             Device(
                 name='Device 2',
+                tenant=tenants[1],
                 device_type=device_types[1],
                 role=roles[1],
                 site=sites[1],
@@ -4139,6 +4185,7 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
             ),
             Device(
                 name='Device 3',
+                tenant=tenants[2],
                 device_type=device_types[2],
                 role=roles[2],
                 site=sites[2],
@@ -4397,9 +4444,17 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
         virtual_chassis = VirtualChassis(name='Virtual Chassis')
         virtual_chassis.save()
 
+        tenants = (
+            Tenant(name='Tenant 1', slug='tenant-1'),
+            Tenant(name='Tenant 2', slug='tenant-2'),
+            Tenant(name='Tenant 3', slug='tenant-3'),
+        )
+        Tenant.objects.bulk_create(tenants)
+
         devices = (
             Device(
                 name='Device 1A',
+                tenant=tenants[0],
                 device_type=device_types[0],
                 role=roles[0],
                 site=sites[0],
@@ -4412,6 +4467,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
             ),
             Device(
                 name='Device 1B',
+                tenant=tenants[1],
                 device_type=device_types[2],
                 role=roles[2],
                 site=sites[2],
@@ -4424,6 +4480,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
             ),
             Device(
                 name='Device 2',
+                tenant=tenants[2],
                 device_type=device_types[1],
                 role=roles[1],
                 site=sites[1],
@@ -4433,6 +4490,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
             ),
             Device(
                 name='Device 3',
+                tenant=tenants[2],
                 device_type=device_types[2],
                 role=roles[2],
                 site=sites[2],
@@ -5018,9 +5076,17 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
         )
         Rack.objects.bulk_create(racks)
 
+        tenants = (
+            Tenant(name='Tenant 1', slug='tenant-1'),
+            Tenant(name='Tenant 2', slug='tenant-2'),
+            Tenant(name='Tenant 3', slug='tenant-3'),
+        )
+        Tenant.objects.bulk_create(tenants)
+
         devices = (
             Device(
                 name='Device 1',
+                tenant=tenants[0],
                 device_type=device_types[0],
                 role=roles[0],
                 site=sites[0],
@@ -5030,6 +5096,7 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
             ),
             Device(
                 name='Device 2',
+                tenant=tenants[1],
                 device_type=device_types[1],
                 role=roles[1],
                 site=sites[1],
@@ -5039,6 +5106,7 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
             ),
             Device(
                 name='Device 3',
+                tenant=tenants[2],
                 device_type=device_types[2],
                 role=roles[2],
                 site=sites[2],
@@ -5309,9 +5377,17 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
         )
         Rack.objects.bulk_create(racks)
 
+        tenants = (
+            Tenant(name='Tenant 1', slug='tenant-1'),
+            Tenant(name='Tenant 2', slug='tenant-2'),
+            Tenant(name='Tenant 3', slug='tenant-3'),
+        )
+        Tenant.objects.bulk_create(tenants)
+
         devices = (
             Device(
                 name='Device 1',
+                tenant=tenants[0],
                 device_type=device_types[0],
                 role=roles[0],
                 site=sites[0],
@@ -5321,6 +5397,7 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
             ),
             Device(
                 name='Device 2',
+                tenant=tenants[1],
                 device_type=device_types[1],
                 role=roles[1],
                 site=sites[1],
@@ -5330,6 +5407,7 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
             ),
             Device(
                 name='Device 3',
+                tenant=tenants[2],
                 device_type=device_types[2],
                 role=roles[2],
                 site=sites[2],
@@ -5586,9 +5664,17 @@ class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
         )
         Rack.objects.bulk_create(racks)
 
+        tenants = (
+            Tenant(name='Tenant 1', slug='tenant-1'),
+            Tenant(name='Tenant 2', slug='tenant-2'),
+            Tenant(name='Tenant 3', slug='tenant-3'),
+        )
+        Tenant.objects.bulk_create(tenants)
+
         devices = (
             Device(
                 name='Device 1',
+                tenant=tenants[0],
                 device_type=device_types[0],
                 role=roles[0],
                 site=sites[0],
@@ -5598,6 +5684,7 @@ class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
             ),
             Device(
                 name='Device 2',
+                tenant=tenants[1],
                 device_type=device_types[1],
                 role=roles[1],
                 site=sites[1],
@@ -5607,6 +5694,7 @@ class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
             ),
             Device(
                 name='Device 3',
+                tenant=tenants[2],
                 device_type=device_types[2],
                 role=roles[2],
                 site=sites[2],
@@ -5759,9 +5847,17 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
         )
         Rack.objects.bulk_create(racks)
 
+        tenants = (
+            Tenant(name='Tenant 1', slug='tenant-1'),
+            Tenant(name='Tenant 2', slug='tenant-2'),
+            Tenant(name='Tenant 3', slug='tenant-3'),
+        )
+        Tenant.objects.bulk_create(tenants)
+
         devices = (
             Device(
                 name='Device 1',
+                tenant=tenants[0],
                 device_type=device_types[0],
                 role=roles[0],
                 site=sites[0],
@@ -5771,6 +5867,7 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
             ),
             Device(
                 name='Device 2',
+                tenant=tenants[1],
                 device_type=device_types[1],
                 role=roles[1],
                 site=sites[1],
@@ -5780,6 +5877,7 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
             ),
             Device(
                 name='Device 3',
+                tenant=tenants[2],
                 device_type=device_types[2],
                 role=roles[2],
                 site=sites[2],

+ 48 - 2
netbox/dcim/tests/test_models.py

@@ -792,8 +792,54 @@ class ModuleBayTestCase(TestCase):
         )
         device.consoleports.first()
 
-    def test_nested_module_token(self):
-        pass
+    @tag('regression')  # #19918
+    def test_nested_module_bay_label_resolution(self):
+        """Test that nested module bay labels properly resolve {module} placeholders"""
+        manufacturer = Manufacturer.objects.first()
+        site = Site.objects.first()
+        device_role = DeviceRole.objects.first()
+
+        # Create device type with module bay template (position='A')
+        device_type = DeviceType.objects.create(
+            manufacturer=manufacturer,
+            model='Device with Bays',
+            slug='device-with-bays'
+        )
+        ModuleBayTemplate.objects.create(
+            device_type=device_type,
+            name='Bay A',
+            position='A'
+        )
+
+        # Create module type with nested bay template using {module} placeholder
+        module_type = ModuleType.objects.create(
+            manufacturer=manufacturer,
+            model='Module with Nested Bays'
+        )
+        ModuleBayTemplate.objects.create(
+            module_type=module_type,
+            name='SFP {module}-21',
+            label='{module}-21',
+            position='21'
+        )
+
+        # Create device and install module
+        device = Device.objects.create(
+            name='Test Device',
+            device_type=device_type,
+            role=device_role,
+            site=site
+        )
+        module_bay = device.modulebays.get(name='Bay A')
+        module = Module.objects.create(
+            device=device,
+            module_bay=module_bay,
+            module_type=module_type
+        )
+
+        # Verify nested bay label resolves {module} to parent position
+        nested_bay = module.modulebays.get(name='SFP A-21')
+        self.assertEqual(nested_bay.label, 'A-21')
 
 
 class CableTestCase(TestCase):

+ 25 - 11
netbox/extras/jobs.py

@@ -2,11 +2,14 @@ import logging
 import traceback
 from contextlib import ExitStack
 
-from django.db import transaction
+from django.db import router, transaction
+from django.db import DEFAULT_DB_ALIAS
 from django.utils.translation import gettext as _
 
 from core.signals import clear_events
+from dcim.models import Device
 from extras.models import Script as ScriptModel
+from netbox.context_managers import event_tracking
 from netbox.jobs import JobRunner
 from netbox.registry import registry
 from utilities.exceptions import AbortScript, AbortTransaction
@@ -42,10 +45,21 @@ class ScriptJob(JobRunner):
                 # A script can modify multiple models so need to do an atomic lock on
                 # both the default database (for non ChangeLogged models) and potentially
                 # any other database (for ChangeLogged models)
-                with transaction.atomic():
-                    script.output = script.run(data, commit)
-                    if not commit:
-                        raise AbortTransaction()
+                changeloged_db = router.db_for_write(Device)
+                with transaction.atomic(using=DEFAULT_DB_ALIAS):
+                    # If branch database is different from default, wrap in a second atomic transaction
+                    # Note: Don't add any extra code between the two atomic transactions,
+                    # otherwise the changes might get committed to the default database
+                    # if there are any raised exceptions.
+                    if changeloged_db != DEFAULT_DB_ALIAS:
+                        with transaction.atomic(using=changeloged_db):
+                            script.output = script.run(data, commit)
+                            if not commit:
+                                raise AbortTransaction()
+                    else:
+                        script.output = script.run(data, commit)
+                        if not commit:
+                            raise AbortTransaction()
             except AbortTransaction:
                 script.log_info(message=_("Database changes have been reverted automatically."))
                 if script.failed:
@@ -108,14 +122,14 @@ class ScriptJob(JobRunner):
         script.request = request
         self.logger.debug(f"Request ID: {request.id if request else None}")
 
-        # Execute the script. If commit is True, wrap it with the event_tracking context manager to ensure we process
-        # change logging, event rules, etc.
         if commit:
             self.logger.info("Executing script (commit enabled)")
-            with ExitStack() as stack:
-                for request_processor in registry['request_processors']:
-                    stack.enter_context(request_processor(request))
-                self.run_script(script, request, data, commit)
         else:
             self.logger.warning("Executing script (commit disabled)")
+
+        with ExitStack() as stack:
+            for request_processor in registry['request_processors']:
+                if not commit and request_processor is event_tracking:
+                    continue
+                stack.enter_context(request_processor(request))
             self.run_script(script, request, data, commit)

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

@@ -562,6 +562,7 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
         form.instance._replicated_base = hasattr(self.form, "replication_fields")
 
         if form.is_valid():
+            changelog_message = form.cleaned_data.pop('changelog_message', '')
             new_components = []
             data = deepcopy(request.POST)
             pattern_count = len(form.cleaned_data[self.form.replication_fields[0]])
@@ -588,6 +589,9 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
                         # Create the new components
                         new_objs = []
                         for component_form in new_components:
+                            # Record changelog message (if any)
+                            if changelog_message:
+                                component_form.instance._changelog_message = changelog_message
                             obj = component_form.save()
                             new_objs.append(obj)
 

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


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

@@ -31,7 +31,7 @@
     "gridstack": "12.3.3",
     "htmx.org": "2.0.8",
     "query-string": "9.3.1",
-    "sass": "1.94.2",
+    "sass": "1.95.0",
     "tom-select": "2.4.3",
     "typeface-inter": "3.18.1",
     "typeface-roboto-mono": "1.1.13"

+ 52 - 11
netbox/project-static/src/buttons/moveOptions.ts

@@ -1,7 +1,7 @@
 import { getElements } from '../util';
 
 /**
- * Move selected options from one select element to another.
+ * Move selected options from one select element to another, preserving optgroup structure.
  *
  * @param source Select Element
  * @param target Select Element
@@ -9,14 +9,42 @@ import { getElements } from '../util';
 function moveOption(source: HTMLSelectElement, target: HTMLSelectElement): void {
   for (const option of Array.from(source.options)) {
     if (option.selected) {
-      target.appendChild(option.cloneNode(true));
+      // Check if option is inside an optgroup
+      const parentOptgroup = option.parentElement as HTMLElement;
+
+      if (parentOptgroup.tagName === 'OPTGROUP') {
+        // Find or create matching optgroup in target
+        const groupLabel = parentOptgroup.getAttribute('label');
+        let targetOptgroup = Array.from(target.children).find(
+          child => child.tagName === 'OPTGROUP' && child.getAttribute('label') === groupLabel,
+        ) as HTMLOptGroupElement;
+
+        if (!targetOptgroup) {
+          // Create new optgroup in target
+          targetOptgroup = document.createElement('optgroup');
+          targetOptgroup.setAttribute('label', groupLabel!);
+          target.appendChild(targetOptgroup);
+        }
+
+        // Move option to target optgroup
+        targetOptgroup.appendChild(option.cloneNode(true));
+      } else {
+        // Option is not in an optgroup, append directly
+        target.appendChild(option.cloneNode(true));
+      }
+
       option.remove();
+
+      // Clean up empty optgroups in source
+      if (parentOptgroup.tagName === 'OPTGROUP' && parentOptgroup.children.length === 0) {
+        parentOptgroup.remove();
+      }
     }
   }
 }
 
 /**
- * Move selected options of a select element up in order.
+ * Move selected options of a select element up in order, respecting optgroup boundaries.
  *
  * Adapted from:
  * @see https://www.tomred.net/css-html-js/reorder-option-elements-of-an-html-select.html
@@ -27,14 +55,21 @@ function moveOptionUp(element: HTMLSelectElement): void {
   for (let i = 1; i < options.length; i++) {
     const option = options[i];
     if (option.selected) {
-      element.removeChild(option);
-      element.insertBefore(option, element.options[i - 1]);
+      const parent = option.parentElement as HTMLElement;
+      const previousOption = element.options[i - 1];
+      const previousParent = previousOption.parentElement as HTMLElement;
+
+      // Only move if previous option is in the same parent (optgroup or select)
+      if (parent === previousParent) {
+        parent.removeChild(option);
+        parent.insertBefore(option, previousOption);
+      }
     }
   }
 }
 
 /**
- * Move selected options of a select element down in order.
+ * Move selected options of a select element down in order, respecting optgroup boundaries.
  *
  * Adapted from:
  * @see https://www.tomred.net/css-html-js/reorder-option-elements-of-an-html-select.html
@@ -43,12 +78,18 @@ function moveOptionUp(element: HTMLSelectElement): void {
 function moveOptionDown(element: HTMLSelectElement): void {
   const options = Array.from(element.options);
   for (let i = options.length - 2; i >= 0; i--) {
-    let option = options[i];
+    const option = options[i];
     if (option.selected) {
-      let next = element.options[i + 1];
-      option = element.removeChild(option);
-      next = element.replaceChild(option, next);
-      element.insertBefore(next, option);
+      const parent = option.parentElement as HTMLElement;
+      const nextOption = element.options[i + 1];
+      const nextParent = nextOption.parentElement as HTMLElement;
+
+      // Only move if next option is in the same parent (optgroup or select)
+      if (parent === nextParent) {
+        const optionClone = parent.removeChild(option);
+        const nextClone = parent.replaceChild(optionClone, nextOption);
+        parent.insertBefore(nextClone, optionClone);
+      }
     }
   }
 }

+ 14 - 0
netbox/project-static/styles/transitional/_forms.scss

@@ -33,6 +33,20 @@ form.object-edit {
   }
 }
 
+// Make optgroup labels sticky when scrolling through select elements
+select[multiple] {
+  optgroup {
+    position: sticky;
+    top: 0;
+    background-color: var(--bs-body-bg);
+    font-style: normal;
+    font-weight: bold;
+  }
+  option {
+    padding-left: 0.5rem;
+  }
+}
+
 // Filter modifier dropdown sizing
 .modifier-select {
   min-width: 10rem;

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

@@ -3251,10 +3251,10 @@ safe-regex-test@^1.1.0:
     es-errors "^1.3.0"
     is-regex "^1.2.1"
 
-sass@1.94.2:
-  version "1.94.2"
-  resolved "https://registry.npmjs.org/sass/-/sass-1.94.2.tgz"
-  integrity sha512-N+7WK20/wOr7CzA2snJcUSSNTCzeCGUTFY3OgeQP3mZ1aj9NMQ0mSTXwlrnd89j33zzQJGqIN52GIOmYrfq46A==
+sass@1.95.0:
+  version "1.95.0"
+  resolved "https://registry.yarnpkg.com/sass/-/sass-1.95.0.tgz#3a3a4d4d954313ab50eaf16f6e2548a2f6ec0811"
+  integrity sha512-9QMjhLq+UkOg/4bb8Lt8A+hJZvY3t+9xeZMKSBtBEgxrXA3ed5Ts4NDreUkYgJP1BTmrscQE/xYhf7iShow6lw==
   dependencies:
     chokidar "^4.0.0"
     immutable "^5.0.2"

+ 2 - 2
netbox/release.yaml

@@ -1,3 +1,3 @@
-version: "4.4.7"
+version: "4.4.8"
 edition: "Community"
-published: "2025-11-25"
+published: "2025-12-09"

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


Plik diff jest za duży
+ 190 - 190
netbox/translations/cs/LC_MESSAGES/django.po


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


Plik diff jest za duży
+ 190 - 190
netbox/translations/da/LC_MESSAGES/django.po


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


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


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


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


Plik diff jest za duży
+ 190 - 190
netbox/translations/es/LC_MESSAGES/django.po


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


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


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


Plik diff jest za duży
+ 190 - 190
netbox/translations/it/LC_MESSAGES/django.po


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


Plik diff jest za duży
+ 192 - 192
netbox/translations/ja/LC_MESSAGES/django.po


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


Plik diff jest za duży
+ 190 - 190
netbox/translations/nl/LC_MESSAGES/django.po


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


Plik diff jest za duży
+ 190 - 190
netbox/translations/pl/LC_MESSAGES/django.po


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


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


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


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


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


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


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


Plik diff jest za duży
+ 192 - 192
netbox/translations/uk/LC_MESSAGES/django.po


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


Plik diff jest za duży
+ 192 - 192
netbox/translations/zh/LC_MESSAGES/django.po


+ 21 - 4
netbox/users/forms/model_forms.py

@@ -1,6 +1,8 @@
 import json
+from collections import defaultdict
 
 from django import forms
+from django.apps import apps
 from django.contrib.auth import password_validation
 from django.contrib.postgres.forms import SimpleArrayField
 from django.core.exceptions import FieldError
@@ -22,6 +24,7 @@ from utilities.forms.fields import (
 from utilities.forms.rendering import FieldSet
 from utilities.forms.widgets import DateTimePicker, SplitMultiSelectWidget
 from utilities.permissions import qs_filter_from_constraints
+from utilities.string import title
 
 __all__ = (
     'GroupForm',
@@ -295,10 +298,24 @@ class GroupForm(forms.ModelForm):
 
 
 def get_object_types_choices():
-    return [
-        (ot.pk, str(ot))
-        for ot in ObjectType.objects.filter(OBJECTPERMISSION_OBJECT_TYPES).order_by('app_label', 'model')
-    ]
+    """
+    Generate choices for object types grouped by app label using optgroups.
+    Returns nested structure: [(app_label, [(id, model_name), ...]), ...]
+    """
+    app_label_map = {
+        app_config.label: app_config.verbose_name
+        for app_config in apps.get_app_configs()
+    }
+    choices_by_app = defaultdict(list)
+
+    for ot in ObjectType.objects.filter(OBJECTPERMISSION_OBJECT_TYPES).order_by('app_label', 'model'):
+        app_label = app_label_map.get(ot.app_label, ot.app_label)
+
+        model_class = ot.model_class()
+        model_name = model_class._meta.verbose_name if model_class else ot.model
+        choices_by_app[app_label].append((ot.pk, title(model_name)))
+
+    return list(choices_by_app.items())
 
 
 class ObjectPermissionForm(forms.ModelForm):

+ 36 - 13
netbox/utilities/forms/widgets/select.py

@@ -66,18 +66,46 @@ class SelectWithPK(forms.Select):
     option_template_name = 'widgets/select_option_with_pk.html'
 
 
-class AvailableOptions(forms.SelectMultiple):
+class SelectMultipleBase(forms.SelectMultiple):
     """
-    Renders a <select multiple=true> including only choices that have been selected. (For unbound fields, this list
-    will be empty.) Employed by SplitMultiSelectWidget.
+    Base class for select widgets that filter choices based on selected values.
+    Subclasses should set `include_selected` to control filtering behavior.
     """
+    include_selected = False
+
     def optgroups(self, name, value, attrs=None):
-        self.choices = [
-            choice for choice in self.choices if str(choice[0]) not in value
-        ]
+        filtered_choices = []
+        include_selected = self.include_selected
+
+        for choice in self.choices:
+            if isinstance(choice[1], (list, tuple)):  # optgroup
+                group_label, group_choices = choice
+                filtered_group = [
+                    c for c in group_choices if (str(c[0]) in value) == include_selected
+                ]
+
+                if filtered_group:  # Only include optgroup if it has choices left
+                    filtered_choices.append((group_label, filtered_group))
+            else:  # option, e.g. flat choice
+                if (str(choice[0]) in value) == include_selected:
+                    filtered_choices.append(choice)
+
+        self.choices = filtered_choices
         value = []  # Clear selected choices
         return super().optgroups(name, value, attrs)
 
+    def create_option(self, name, value, label, selected, index, subindex=None, attrs=None):
+        option = super().create_option(name, value, label, selected, index, subindex, attrs)
+        option['attrs']['title'] = label  # Add title attribute to show full text on hover
+        return option
+
+
+class AvailableOptions(SelectMultipleBase):
+    """
+    Renders a <select multiple=true> including only choices that have been selected. (For unbound fields, this list
+    will be empty.) Employed by SplitMultiSelectWidget.
+    """
+
     def get_context(self, name, value, attrs):
         context = super().get_context(name, value, attrs)
 
@@ -87,17 +115,12 @@ class AvailableOptions(forms.SelectMultiple):
         return context
 
 
-class SelectedOptions(forms.SelectMultiple):
+class SelectedOptions(SelectMultipleBase):
     """
     Renders a <select multiple=true> including only choices that have _not_ been selected. (For unbound fields, this
     will include _all_ choices.) Employed by SplitMultiSelectWidget.
     """
-    def optgroups(self, name, value, attrs=None):
-        self.choices = [
-            choice for choice in self.choices if str(choice[0]) in value
-        ]
-        value = []  # Clear selected choices
-        return super().optgroups(name, value, attrs)
+    include_selected = True
 
 
 class SplitMultiSelectWidget(forms.MultiWidget):

+ 69 - 0
netbox/utilities/tests/test_forms.py

@@ -7,6 +7,7 @@ from utilities.forms.bulk_import import BulkImportForm
 from utilities.forms.fields.csv import CSVSelectWidget
 from utilities.forms.forms import BulkRenameForm
 from utilities.forms.utils import get_field_value, expand_alphanumeric_pattern, expand_ipaddress_pattern
+from utilities.forms.widgets.select import AvailableOptions, SelectedOptions
 
 
 class ExpandIPAddress(TestCase):
@@ -481,3 +482,71 @@ class CSVSelectWidgetTest(TestCase):
         widget = CSVSelectWidget()
         data = {'test_field': 'valid_value'}
         self.assertFalse(widget.value_omitted_from_data(data, {}, 'test_field'))
+
+
+class SelectMultipleWidgetTest(TestCase):
+    """
+    Validate filtering behavior of AvailableOptions and SelectedOptions widgets.
+    """
+
+    def test_available_options_flat_choices(self):
+        """AvailableOptions should exclude selected values from flat choices"""
+        widget = AvailableOptions(choices=[
+            (1, 'Option 1'),
+            (2, 'Option 2'),
+            (3, 'Option 3'),
+        ])
+        widget.optgroups('test', ['2'], None)
+
+        self.assertEqual(len(widget.choices), 2)
+        self.assertEqual(widget.choices[0], (1, 'Option 1'))
+        self.assertEqual(widget.choices[1], (3, 'Option 3'))
+
+    def test_available_options_optgroups(self):
+        """AvailableOptions should exclude selected values from optgroups"""
+        widget = AvailableOptions(choices=[
+            ('Group A', [(1, 'Option 1'), (2, 'Option 2')]),
+            ('Group B', [(3, 'Option 3'), (4, 'Option 4')]),
+        ])
+
+        # Select options 2 and 3
+        widget.optgroups('test', ['2', '3'], None)
+
+        # Should have 2 groups with filtered choices
+        self.assertEqual(len(widget.choices), 2)
+        self.assertEqual(widget.choices[0][0], 'Group A')
+        self.assertEqual(widget.choices[0][1], [(1, 'Option 1')])
+        self.assertEqual(widget.choices[1][0], 'Group B')
+        self.assertEqual(widget.choices[1][1], [(4, 'Option 4')])
+
+    def test_selected_options_flat_choices(self):
+        """SelectedOptions should include only selected values from flat choices"""
+        widget = SelectedOptions(choices=[
+            (1, 'Option 1'),
+            (2, 'Option 2'),
+            (3, 'Option 3'),
+        ])
+
+        # Select option 2
+        widget.optgroups('test', ['2'], None)
+
+        # Should only have option 2
+        self.assertEqual(len(widget.choices), 1)
+        self.assertEqual(widget.choices[0], (2, 'Option 2'))
+
+    def test_selected_options_optgroups(self):
+        """SelectedOptions should include only selected values from optgroups"""
+        widget = SelectedOptions(choices=[
+            ('Group A', [(1, 'Option 1'), (2, 'Option 2')]),
+            ('Group B', [(3, 'Option 3'), (4, 'Option 4')]),
+        ])
+
+        # Select options 2 and 3
+        widget.optgroups('test', ['2', '3'], None)
+
+        # Should have 2 groups with only selected choices
+        self.assertEqual(len(widget.choices), 2)
+        self.assertEqual(widget.choices[0][0], 'Group A')
+        self.assertEqual(widget.choices[0][1], [(2, 'Option 2')])
+        self.assertEqual(widget.choices[1][0], 'Group B')
+        self.assertEqual(widget.choices[1][1], [(3, 'Option 3')])

+ 9 - 9
requirements.txt

@@ -1,10 +1,10 @@
 colorama==0.4.6
-Django==5.2.8
+Django==5.2.9
 django-cors-headers==4.9.0
 django-debug-toolbar==6.1.0
 django-filter==25.2
 django-graphiql-debug-toolbar==0.2.0
-django-htmx==1.26.0
+django-htmx==1.27.0
 django-mptt==0.17.0
 django-pglocks==1.0.4
 django-prometheus==2.4.1
@@ -14,30 +14,30 @@ django-rq==3.2.1
 django-storages==1.14.6
 django-tables2==2.8.0
 django-taggit==6.1.0
-django-timezone-field==7.1
+django-timezone-field==7.2.1
 djangorestframework==3.16.1
 drf-spectacular==0.29.0
-drf-spectacular-sidecar==2025.10.1
+drf-spectacular-sidecar==2025.12.1
 feedparser==6.0.12
 gunicorn==23.0.0
 Jinja2==3.1.6
 jsonschema==4.25.1
 Markdown==3.10
 mkdocs-material==9.7.0
-mkdocstrings==0.30.1
-mkdocstrings-python==1.19.0
+mkdocstrings==1.0.0
+mkdocstrings-python==2.0.1
 netaddr==1.3.0
 nh3==0.3.2
 Pillow==12.0.0
-psycopg[c,pool]==3.2.13
+psycopg[c,pool]==3.3.2
 PyYAML==6.0.3
 requests==2.32.5
 rq==2.6.1
 social-auth-app-django==5.6.0
 social-auth-core==4.8.1
 sorl-thumbnail==12.11.0
-strawberry-graphql==0.287.0
-strawberry-graphql-django==0.67.2
+strawberry-graphql==0.287.2
+strawberry-graphql-django==0.70.1
 svgwrite==1.4.3
 tablib==3.9.0
 tzdata==2025.2

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