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

Merge branch 'main' into feature

# Conflicts:
#	contrib/openapi.json
#	netbox/core/forms/filtersets.py
#	netbox/core/tests/test_openapi_schema.py
#	netbox/dcim/forms/mixins.py
#	netbox/extras/events.py
#	netbox/ipam/forms/bulk_edit.py
#	netbox/ipam/forms/model_forms.py
#	netbox/ipam/models/services.py
#	netbox/ipam/tests/test_forms.py
#	netbox/ipam/tests/test_models.py
#	netbox/ipam/tests/test_views.py
#	netbox/netbox/jobs.py
#	netbox/project-static/dist/netbox.js
#	netbox/project-static/dist/netbox.js.map
#	netbox/release.yaml
#	requirements.txt
Jeremy Stretch 1 день назад
Родитель
Сommit
56693d62ae
88 измененных файлов с 5767 добавлено и 3298 удалено
  1. 37 0
      .github/workflows/enforce-milestone.yml
  2. 506 50
      contrib/openapi.json
  3. 6 2
      docs/models/dcim/frontport.md
  4. 24 0
      docs/release-notes/version-4.6.md
  5. 2 1
      netbox/circuits/models/circuits.py
  6. 22 0
      netbox/circuits/tests/test_models.py
  7. 9 3
      netbox/core/api/schema.py
  8. 2 2
      netbox/core/forms/filtersets.py
  9. 151 7
      netbox/core/tests/test_openapi_schema.py
  10. 15 8
      netbox/dcim/forms/mixins.py
  11. 2 2
      netbox/dcim/forms/model_forms.py
  12. 14 12
      netbox/dcim/forms/object_create.py
  13. 23 3
      netbox/dcim/models/cables.py
  14. 1 1
      netbox/dcim/tests/query_counts.json
  15. 2 0
      netbox/dcim/tests/test_api.py
  16. 274 1
      netbox/dcim/tests/test_cablepaths2.py
  17. 125 0
      netbox/dcim/tests/test_forms.py
  18. 27 0
      netbox/dcim/tests/test_models.py
  19. 37 2
      netbox/dcim/tests/test_signals.py
  20. 42 1
      netbox/dcim/tests/test_views.py
  21. 18 11
      netbox/extras/api/views.py
  22. 17 2
      netbox/extras/event_rules.py
  23. 25 0
      netbox/extras/jobs.py
  24. 25 19
      netbox/extras/management/commands/runscript.py
  25. 51 0
      netbox/extras/scripts.py
  26. 49 1
      netbox/extras/tests/test_api.py
  27. 58 1
      netbox/extras/tests/test_event_rules.py
  28. 25 0
      netbox/extras/tests/test_management_commands.py
  29. 273 1
      netbox/extras/tests/test_scripts.py
  30. 57 0
      netbox/extras/tests/test_views.py
  31. 20 13
      netbox/extras/views.py
  32. 1 0
      netbox/ipam/api/serializers_/asns.py
  33. 9 9
      netbox/ipam/filtersets.py
  34. 1 1
      netbox/ipam/forms/model_forms.py
  35. 11 7
      netbox/ipam/models/vlans.py
  36. 2 2
      netbox/ipam/tests/query_counts.json
  37. 14 0
      netbox/ipam/tests/test_api.py
  38. 92 8
      netbox/ipam/tests/test_filtersets.py
  39. 97 2
      netbox/ipam/tests/test_forms.py
  40. 4 1
      netbox/ipam/tests/test_views.py
  41. 42 21
      netbox/netbox/jobs.py
  42. 1 0
      netbox/netbox/settings.py
  43. 0 0
      netbox/project-static/dist/netbox.js
  44. 0 0
      netbox/project-static/dist/netbox.js.map
  45. 3 3
      netbox/project-static/package.json
  46. 24 10
      netbox/project-static/src/sidenav.ts
  47. 140 99
      netbox/project-static/yarn.lock
  48. BIN
      netbox/translations/cs/LC_MESSAGES/django.mo
  49. 179 175
      netbox/translations/cs/LC_MESSAGES/django.po
  50. BIN
      netbox/translations/da/LC_MESSAGES/django.mo
  51. 179 175
      netbox/translations/da/LC_MESSAGES/django.po
  52. BIN
      netbox/translations/de/LC_MESSAGES/django.mo
  53. 179 175
      netbox/translations/de/LC_MESSAGES/django.po
  54. 179 175
      netbox/translations/en/LC_MESSAGES/django.po
  55. BIN
      netbox/translations/es/LC_MESSAGES/django.mo
  56. 179 175
      netbox/translations/es/LC_MESSAGES/django.po
  57. BIN
      netbox/translations/fr/LC_MESSAGES/django.mo
  58. 179 175
      netbox/translations/fr/LC_MESSAGES/django.po
  59. BIN
      netbox/translations/it/LC_MESSAGES/django.mo
  60. 179 175
      netbox/translations/it/LC_MESSAGES/django.po
  61. BIN
      netbox/translations/ja/LC_MESSAGES/django.mo
  62. 179 175
      netbox/translations/ja/LC_MESSAGES/django.po
  63. BIN
      netbox/translations/ko/LC_MESSAGES/django.mo
  64. 179 175
      netbox/translations/ko/LC_MESSAGES/django.po
  65. BIN
      netbox/translations/lv/LC_MESSAGES/django.mo
  66. 179 175
      netbox/translations/lv/LC_MESSAGES/django.po
  67. BIN
      netbox/translations/nl/LC_MESSAGES/django.mo
  68. 179 175
      netbox/translations/nl/LC_MESSAGES/django.po
  69. BIN
      netbox/translations/pl/LC_MESSAGES/django.mo
  70. 179 175
      netbox/translations/pl/LC_MESSAGES/django.po
  71. BIN
      netbox/translations/pt/LC_MESSAGES/django.mo
  72. 179 175
      netbox/translations/pt/LC_MESSAGES/django.po
  73. BIN
      netbox/translations/ru/LC_MESSAGES/django.mo
  74. 179 175
      netbox/translations/ru/LC_MESSAGES/django.po
  75. BIN
      netbox/translations/tr/LC_MESSAGES/django.mo
  76. 179 175
      netbox/translations/tr/LC_MESSAGES/django.po
  77. BIN
      netbox/translations/uk/LC_MESSAGES/django.mo
  78. 179 175
      netbox/translations/uk/LC_MESSAGES/django.po
  79. BIN
      netbox/translations/zh/LC_MESSAGES/django.mo
  80. 179 175
      netbox/translations/zh/LC_MESSAGES/django.po
  81. 2 0
      netbox/users/api/serializers_/nested.py
  82. 1 1
      netbox/users/tests/query_counts.json
  83. 25 8
      netbox/utilities/api.py
  84. 25 1
      netbox/utilities/templatetags/builtins/tags.py
  85. 9 1
      netbox/utilities/testing/mixins.py
  86. 209 1
      netbox/utilities/tests/test_api.py
  87. 68 0
      netbox/utilities/tests/test_templatetags.py
  88. 5 5
      requirements.txt

+ 37 - 0
.github/workflows/enforce-milestone.yml

@@ -0,0 +1,37 @@
+name: Enforce milestone on close
+
+on:
+  issues:
+    types:
+      - closed
+
+permissions:
+  issues: write
+
+jobs:
+  check-milestone:
+    name: Check Milestone
+    if: github.repository == 'netbox-community/netbox' && github.event.issue.state_reason == 'completed'
+    runs-on: ubuntu-slim
+
+    steps:
+      - name: Reopen issues completed without a milestone
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          GH_REPO: ${{ github.repository }}
+          ISSUE: ${{ github.event.issue.number }}
+        run: |
+          # Grace period, in case the milestone is assigned immediately after closure
+          sleep 90
+
+          # Re-check the issue: bail out if it has been reopened or a milestone has since been set
+          DATA=$(gh issue view "$ISSUE" --json state,milestone)
+          STATE=$(jq -r '.state' <<< "$DATA")
+          MILESTONE=$(jq -r '.milestone.title // ""' <<< "$DATA")
+          if [ "$STATE" != "CLOSED" ] || [ -n "$MILESTONE" ]; then
+            echo "Nothing to do (state=$STATE, milestone=${MILESTONE:-none})"
+            exit 0
+          fi
+
+          gh issue reopen "$ISSUE" --comment \
+            "This issue was closed as completed without a milestone assigned, and has been reopened automatically. Please assign the milestone for the upcoming release, then close the issue again."

+ 506 - 50
contrib/openapi.json

@@ -203919,15 +203919,27 @@
                         "in": "query",
                         "name": "cluster",
                         "schema": {
-                            "type": "integer"
-                        }
+                            "type": "array",
+                            "items": {
+                                "type": "integer",
+                                "format": "int32"
+                            }
+                        },
+                        "explode": true,
+                        "style": "form"
                     },
                     {
                         "in": "query",
                         "name": "cluster_group",
                         "schema": {
-                            "type": "integer"
-                        }
+                            "type": "array",
+                            "items": {
+                                "type": "integer",
+                                "format": "int32"
+                            }
+                        },
+                        "explode": true,
+                        "style": "form"
                     },
                     {
                         "in": "query",
@@ -204383,8 +204395,14 @@
                         "in": "query",
                         "name": "location",
                         "schema": {
-                            "type": "integer"
-                        }
+                            "type": "array",
+                            "items": {
+                                "type": "integer",
+                                "format": "int32"
+                            }
+                        },
+                        "explode": true,
+                        "style": "form"
                     },
                     {
                         "in": "query",
@@ -204689,22 +204707,40 @@
                         "in": "query",
                         "name": "rack",
                         "schema": {
-                            "type": "integer"
-                        }
+                            "type": "array",
+                            "items": {
+                                "type": "integer",
+                                "format": "int32"
+                            }
+                        },
+                        "explode": true,
+                        "style": "form"
                     },
                     {
                         "in": "query",
                         "name": "rack_group",
                         "schema": {
-                            "type": "number"
-                        }
+                            "type": "array",
+                            "items": {
+                                "type": "integer",
+                                "format": "int32"
+                            }
+                        },
+                        "explode": true,
+                        "style": "form"
                     },
                     {
                         "in": "query",
                         "name": "region",
                         "schema": {
-                            "type": "integer"
-                        }
+                            "type": "array",
+                            "items": {
+                                "type": "integer",
+                                "format": "int32"
+                            }
+                        },
+                        "explode": true,
+                        "style": "form"
                     },
                     {
                         "in": "query",
@@ -204819,15 +204855,27 @@
                         "in": "query",
                         "name": "site",
                         "schema": {
-                            "type": "integer"
-                        }
+                            "type": "array",
+                            "items": {
+                                "type": "integer",
+                                "format": "int32"
+                            }
+                        },
+                        "explode": true,
+                        "style": "form"
                     },
                     {
                         "in": "query",
                         "name": "site_group",
                         "schema": {
-                            "type": "integer"
-                        }
+                            "type": "array",
+                            "items": {
+                                "type": "integer",
+                                "format": "int32"
+                            }
+                        },
+                        "explode": true,
+                        "style": "form"
                     },
                     {
                         "in": "query",
@@ -270547,6 +270595,42 @@
                     "user"
                 ]
             },
+            "BriefASN": {
+                "type": "object",
+                "description": "Base serializer class for models inheriting from PrimaryModel.",
+                "properties": {
+                    "id": {
+                        "type": "integer",
+                        "readOnly": true
+                    },
+                    "url": {
+                        "type": "string",
+                        "format": "uri",
+                        "readOnly": true
+                    },
+                    "display": {
+                        "type": "string",
+                        "readOnly": true
+                    },
+                    "asn": {
+                        "type": "integer",
+                        "maximum": 4294967295,
+                        "minimum": 1,
+                        "format": "int64",
+                        "description": "16- or 32-bit autonomous system number"
+                    },
+                    "description": {
+                        "type": "string",
+                        "maxLength": 200
+                    }
+                },
+                "required": [
+                    "asn",
+                    "display",
+                    "id",
+                    "url"
+                ]
+            },
             "BriefCable": {
                 "type": "object",
                 "description": "Base serializer class for models inheriting from PrimaryModel.",
@@ -271220,6 +271304,57 @@
                     "url"
                 ]
             },
+            "BriefContactGroup": {
+                "type": "object",
+                "description": "Base serializer class for models inheriting from NestedGroupModel.",
+                "properties": {
+                    "id": {
+                        "type": "integer",
+                        "readOnly": true
+                    },
+                    "url": {
+                        "type": "string",
+                        "format": "uri",
+                        "readOnly": true
+                    },
+                    "display": {
+                        "type": "string",
+                        "readOnly": true
+                    },
+                    "name": {
+                        "type": "string",
+                        "maxLength": 100
+                    },
+                    "slug": {
+                        "type": "string",
+                        "maxLength": 100,
+                        "pattern": "^[-a-zA-Z0-9_]+$"
+                    },
+                    "description": {
+                        "type": "string",
+                        "maxLength": 200
+                    },
+                    "contact_count": {
+                        "type": "integer",
+                        "readOnly": true,
+                        "default": 0
+                    },
+                    "_depth": {
+                        "type": "integer",
+                        "readOnly": true,
+                        "title": " depth"
+                    }
+                },
+                "required": [
+                    "_depth",
+                    "contact_count",
+                    "display",
+                    "id",
+                    "name",
+                    "slug",
+                    "url"
+                ]
+            },
             "BriefContactRequest": {
                 "type": "object",
                 "description": "Base serializer class for models inheriting from PrimaryModel.",
@@ -271905,6 +272040,39 @@
                     "protocol"
                 ]
             },
+            "BriefGroup": {
+                "type": "object",
+                "description": "Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during\nvalidation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)",
+                "properties": {
+                    "id": {
+                        "type": "integer",
+                        "readOnly": true
+                    },
+                    "url": {
+                        "type": "string",
+                        "format": "uri",
+                        "readOnly": true
+                    },
+                    "display": {
+                        "type": "string",
+                        "readOnly": true
+                    },
+                    "name": {
+                        "type": "string",
+                        "maxLength": 150
+                    },
+                    "description": {
+                        "type": "string",
+                        "maxLength": 200
+                    }
+                },
+                "required": [
+                    "display",
+                    "id",
+                    "name",
+                    "url"
+                ]
+            },
             "BriefIKEPolicy": {
                 "type": "object",
                 "description": "Base serializer class for models inheriting from PrimaryModel.",
@@ -271956,6 +272124,39 @@
                     "name"
                 ]
             },
+            "BriefIKEProposal": {
+                "type": "object",
+                "description": "Base serializer class for models inheriting from PrimaryModel.",
+                "properties": {
+                    "id": {
+                        "type": "integer",
+                        "readOnly": true
+                    },
+                    "url": {
+                        "type": "string",
+                        "format": "uri",
+                        "readOnly": true
+                    },
+                    "display": {
+                        "type": "string",
+                        "readOnly": true
+                    },
+                    "name": {
+                        "type": "string",
+                        "maxLength": 100
+                    },
+                    "description": {
+                        "type": "string",
+                        "maxLength": 200
+                    }
+                },
+                "required": [
+                    "display",
+                    "id",
+                    "name",
+                    "url"
+                ]
+            },
             "BriefIPAddress": {
                 "type": "object",
                 "description": "Base serializer class for models inheriting from PrimaryModel.",
@@ -272182,6 +272383,39 @@
                     "name"
                 ]
             },
+            "BriefIPSecProposal": {
+                "type": "object",
+                "description": "Base serializer class for models inheriting from PrimaryModel.",
+                "properties": {
+                    "id": {
+                        "type": "integer",
+                        "readOnly": true
+                    },
+                    "url": {
+                        "type": "string",
+                        "format": "uri",
+                        "readOnly": true
+                    },
+                    "display": {
+                        "type": "string",
+                        "readOnly": true
+                    },
+                    "name": {
+                        "type": "string",
+                        "maxLength": 100
+                    },
+                    "description": {
+                        "type": "string",
+                        "maxLength": 200
+                    }
+                },
+                "required": [
+                    "display",
+                    "id",
+                    "name",
+                    "url"
+                ]
+            },
             "BriefInterface": {
                 "type": "object",
                 "description": "Mixin for Interface and VMInterface serializers that adds a `mac_address` shortcut field for\ncreating/updating the primary MACAddress in a single request. The validated write is centralized\non the interface model (BaseInterface.set_primary_mac_address); this mixin is a thin adapter that\nowns the permission check, the shortcut-vs-primary_mac_address conflict guard, and translating the\nmodel's validation errors into API errors.",
@@ -272804,6 +273038,66 @@
                     "url"
                 ]
             },
+            "BriefModuleBayType": {
+                "type": "object",
+                "description": "Base serializer class for models inheriting from PrimaryModel.",
+                "properties": {
+                    "id": {
+                        "type": "integer",
+                        "readOnly": true
+                    },
+                    "url": {
+                        "type": "string",
+                        "format": "uri",
+                        "readOnly": true
+                    },
+                    "display": {
+                        "type": "string",
+                        "readOnly": true
+                    },
+                    "name": {
+                        "type": "string",
+                        "maxLength": 100
+                    },
+                    "slug": {
+                        "type": "string",
+                        "maxLength": 100,
+                        "pattern": "^[-a-zA-Z0-9_]+$"
+                    },
+                    "manufacturer": {
+                        "allOf": [
+                            {
+                                "$ref": "#/components/schemas/BriefManufacturer"
+                            }
+                        ],
+                        "nullable": true
+                    },
+                    "color": {
+                        "oneOf": [
+                            {
+                                "type": "string",
+                                "pattern": "^[0-9a-f]{6}$",
+                                "maxLength": 6
+                            },
+                            {
+                                "type": "string",
+                                "maxLength": 0
+                            }
+                        ]
+                    },
+                    "description": {
+                        "type": "string",
+                        "maxLength": 200
+                    }
+                },
+                "required": [
+                    "display",
+                    "id",
+                    "name",
+                    "slug",
+                    "url"
+                ]
+            },
             "BriefModuleRequest": {
                 "type": "object",
                 "description": "Base serializer class for models inheriting from PrimaryModel.",
@@ -272973,6 +273267,58 @@
                     "model"
                 ]
             },
+            "BriefObjectPermission": {
+                "type": "object",
+                "description": "Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during\nvalidation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)",
+                "properties": {
+                    "id": {
+                        "type": "integer",
+                        "readOnly": true
+                    },
+                    "url": {
+                        "type": "string",
+                        "format": "uri",
+                        "readOnly": true
+                    },
+                    "display": {
+                        "type": "string",
+                        "readOnly": true
+                    },
+                    "name": {
+                        "type": "string",
+                        "maxLength": 100
+                    },
+                    "description": {
+                        "type": "string",
+                        "maxLength": 200
+                    },
+                    "enabled": {
+                        "type": "boolean"
+                    },
+                    "object_types": {
+                        "type": "array",
+                        "items": {
+                            "type": "string"
+                        }
+                    },
+                    "actions": {
+                        "type": "array",
+                        "items": {
+                            "type": "string",
+                            "maxLength": 30
+                        },
+                        "description": "The list of actions granted by this permission"
+                    }
+                },
+                "required": [
+                    "actions",
+                    "display",
+                    "id",
+                    "name",
+                    "object_types",
+                    "url"
+                ]
+            },
             "BriefOwner": {
                 "type": "object",
                 "description": "Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during\nvalidation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)",
@@ -273990,6 +274336,40 @@
                     "slug"
                 ]
             },
+            "BriefRouteTarget": {
+                "type": "object",
+                "description": "Base serializer class for models inheriting from PrimaryModel.",
+                "properties": {
+                    "id": {
+                        "type": "integer",
+                        "readOnly": true
+                    },
+                    "url": {
+                        "type": "string",
+                        "format": "uri",
+                        "readOnly": true
+                    },
+                    "display": {
+                        "type": "string",
+                        "readOnly": true
+                    },
+                    "name": {
+                        "type": "string",
+                        "description": "Route target value (formatted in accordance with RFC 4360)",
+                        "maxLength": 21
+                    },
+                    "description": {
+                        "type": "string",
+                        "maxLength": 200
+                    }
+                },
+                "required": [
+                    "display",
+                    "id",
+                    "name",
+                    "url"
+                ]
+            },
             "BriefSite": {
                 "type": "object",
                 "description": "Base serializer class for models inheriting from PrimaryModel.",
@@ -274931,6 +275311,49 @@
                     "slug"
                 ]
             },
+            "BriefVirtualDeviceContext": {
+                "type": "object",
+                "description": "Base serializer class for models inheriting from PrimaryModel.",
+                "properties": {
+                    "id": {
+                        "type": "integer",
+                        "readOnly": true
+                    },
+                    "url": {
+                        "type": "string",
+                        "format": "uri",
+                        "readOnly": true
+                    },
+                    "display": {
+                        "type": "string",
+                        "readOnly": true
+                    },
+                    "name": {
+                        "type": "string",
+                        "maxLength": 64
+                    },
+                    "device": {
+                        "$ref": "#/components/schemas/BriefDevice"
+                    },
+                    "identifier": {
+                        "type": "integer",
+                        "maximum": 32767,
+                        "minimum": 0,
+                        "nullable": true
+                    },
+                    "description": {
+                        "type": "string",
+                        "maxLength": 200
+                    }
+                },
+                "required": [
+                    "device",
+                    "display",
+                    "id",
+                    "name",
+                    "url"
+                ]
+            },
             "BriefVirtualMachine": {
                 "type": "object",
                 "description": "Base serializer class for models inheriting from PrimaryModel.",
@@ -275046,6 +275469,39 @@
                     "slug"
                 ]
             },
+            "BriefWirelessLAN": {
+                "type": "object",
+                "description": "Base serializer class for models inheriting from PrimaryModel.",
+                "properties": {
+                    "id": {
+                        "type": "integer",
+                        "readOnly": true
+                    },
+                    "url": {
+                        "type": "string",
+                        "format": "uri",
+                        "readOnly": true
+                    },
+                    "display": {
+                        "type": "string",
+                        "readOnly": true
+                    },
+                    "ssid": {
+                        "type": "string",
+                        "maxLength": 32
+                    },
+                    "description": {
+                        "type": "string",
+                        "maxLength": 200
+                    }
+                },
+                "required": [
+                    "display",
+                    "id",
+                    "ssid",
+                    "url"
+                ]
+            },
             "BriefWirelessLANGroup": {
                 "type": "object",
                 "description": "Base serializer class for models inheriting from NestedGroupModel.",
@@ -292096,73 +292552,73 @@
                     "regions": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/Region"
+                            "$ref": "#/components/schemas/BriefRegion"
                         }
                     },
                     "site_groups": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/SiteGroup"
+                            "$ref": "#/components/schemas/BriefSiteGroup"
                         }
                     },
                     "sites": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/Site"
+                            "$ref": "#/components/schemas/BriefSite"
                         }
                     },
                     "locations": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/Location"
+                            "$ref": "#/components/schemas/BriefLocation"
                         }
                     },
                     "device_types": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/DeviceType"
+                            "$ref": "#/components/schemas/BriefDeviceType"
                         }
                     },
                     "roles": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/DeviceRole"
+                            "$ref": "#/components/schemas/BriefDeviceRole"
                         }
                     },
                     "platforms": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/Platform"
+                            "$ref": "#/components/schemas/BriefPlatform"
                         }
                     },
                     "cluster_types": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/ClusterType"
+                            "$ref": "#/components/schemas/BriefClusterType"
                         }
                     },
                     "cluster_groups": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/ClusterGroup"
+                            "$ref": "#/components/schemas/BriefClusterGroup"
                         }
                     },
                     "clusters": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/Cluster"
+                            "$ref": "#/components/schemas/BriefCluster"
                         }
                     },
                     "tenant_groups": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/TenantGroup"
+                            "$ref": "#/components/schemas/BriefTenantGroup"
                         }
                     },
                     "tenants": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/Tenant"
+                            "$ref": "#/components/schemas/BriefTenant"
                         }
                     },
                     "owner": {
@@ -293863,7 +294319,7 @@
                     "groups": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/ContactGroup"
+                            "$ref": "#/components/schemas/BriefContactGroup"
                         }
                     },
                     "name": {
@@ -301161,7 +301617,7 @@
                     "permissions": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/ObjectPermission"
+                            "$ref": "#/components/schemas/BriefObjectPermission"
                         }
                     },
                     "user_count": {
@@ -301277,7 +301733,7 @@
                     "proposals": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/IKEProposal"
+                            "$ref": "#/components/schemas/BriefIKEProposal"
                         }
                     },
                     "preshared_key": {
@@ -302433,7 +302889,7 @@
                     "proposals": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/IPSecProposal"
+                            "$ref": "#/components/schemas/BriefIPSecProposal"
                         }
                     },
                     "pfs_group": {
@@ -303218,7 +303674,7 @@
                     "vdcs": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/VirtualDeviceContext"
+                            "$ref": "#/components/schemas/BriefVirtualDeviceContext"
                         }
                     },
                     "module": {
@@ -304388,7 +304844,7 @@
                     "tagged_vlans": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/VLAN"
+                            "$ref": "#/components/schemas/BriefVLAN"
                         }
                     },
                     "qinq_svlan": {
@@ -304455,7 +304911,7 @@
                     "wireless_lans": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/WirelessLAN"
+                            "$ref": "#/components/schemas/BriefWirelessLAN"
                         }
                     },
                     "vrf": {
@@ -307459,13 +307915,13 @@
                     "import_targets": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/RouteTarget"
+                            "$ref": "#/components/schemas/BriefRouteTarget"
                         }
                     },
                     "export_targets": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/RouteTarget"
+                            "$ref": "#/components/schemas/BriefRouteTarget"
                         }
                     },
                     "description": {
@@ -308513,7 +308969,7 @@
                     "module_bay_types": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/ModuleBayType"
+                            "$ref": "#/components/schemas/BriefModuleBayType"
                         }
                     },
                     "installed_module": {
@@ -308741,7 +309197,7 @@
                     "module_bay_types": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/ModuleBayType"
+                            "$ref": "#/components/schemas/BriefModuleBayType"
                         }
                     },
                     "created": {
@@ -309278,7 +309734,7 @@
                     "module_bay_types": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/ModuleBayType"
+                            "$ref": "#/components/schemas/BriefModuleBayType"
                         }
                     },
                     "owner": {
@@ -310990,13 +311446,13 @@
                     "groups": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/Group"
+                            "$ref": "#/components/schemas/BriefGroup"
                         }
                     },
                     "users": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/User"
+                            "$ref": "#/components/schemas/BriefUser"
                         }
                     }
                 },
@@ -311453,13 +311909,13 @@
                     "user_groups": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/Group"
+                            "$ref": "#/components/schemas/BriefGroup"
                         }
                     },
                     "users": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/User"
+                            "$ref": "#/components/schemas/BriefUser"
                         }
                     }
                 },
@@ -346926,7 +347382,7 @@
                     "asns": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/ASN"
+                            "$ref": "#/components/schemas/BriefASN"
                         }
                     },
                     "tags": {
@@ -351193,7 +351649,7 @@
                     "ipaddresses": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/IPAddress"
+                            "$ref": "#/components/schemas/BriefIPAddress"
                         }
                     },
                     "description": {
@@ -351668,7 +352124,7 @@
                     "asns": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/ASN"
+                            "$ref": "#/components/schemas/BriefASN"
                         }
                     },
                     "tags": {
@@ -353816,13 +354272,13 @@
                     "groups": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/Group"
+                            "$ref": "#/components/schemas/BriefGroup"
                         }
                     },
                     "permissions": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/ObjectPermission"
+                            "$ref": "#/components/schemas/BriefObjectPermission"
                         }
                     }
                 },
@@ -354728,7 +355184,7 @@
                     "tagged_vlans": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/VLAN"
+                            "$ref": "#/components/schemas/BriefVLAN"
                         }
                     },
                     "qinq_svlan": {

+ 6 - 2
docs/models/dcim/frontport.md

@@ -1,6 +1,6 @@
 # Front Ports
 
-Front ports are pass-through ports which represent physical cable connections that comprise part of a longer path. For example, the ports on the front face of a UTP patch panel would be modeled in NetBox as front ports. Each port is assigned a physical type, and must be mapped to a specific [rear port](./rearport.md) on the same device. A single rear port may be mapped to multiple front ports, using numeric positions to annotate the specific alignment of each.
+Front ports are pass-through ports which represent physical cable connections that comprise part of a longer path. For example, the ports on the front face of a UTP patch panel would be modeled in NetBox as front ports. Each port is assigned a physical type, and must be mapped to one or more [rear port](./rearport.md) positions on the same device. A single rear port may be mapped to multiple front ports, using numeric positions to annotate the specific alignment of each.
 
 !!! tip
     Like most device components, front ports are instantiated automatically from [front port templates](./frontporttemplate.md) assigned to the selected device type when a device is created.
@@ -27,12 +27,16 @@ An alternative physical label identifying the port.
 
 The port's termination type.
 
+### Positions
+
+The number of [rear port](./rearport.md) positions to which this front port maps. For a front port which passes through to a single rear port position, set this to `1`.
+
 ### Rear Ports
 
 The rear port and position to which this front port maps.
 
 !!! tip
-    When creating multiple front ports using a patterned name (e.g. `Port [1-12]`), you may select the equivalent number of rear port-position mappings from the list.
+    When creating multiple front ports using a patterned name (e.g. `Port [1-12]`), select one rear port-position mapping for every position of every front port being created. For example, 12 front ports with two positions each requires 24 mappings, which are assigned to the generated ports in order.
 
 ### Color
 

+ 24 - 0
docs/release-notes/version-4.6.md

@@ -1,5 +1,29 @@
 # NetBox v4.6
 
+## v4.6.10 (2026-09-01)
+
+### Performance Improvements
+
+* [#22988](https://github.com/netbox-community/netbox/issues/22988) - Prefetch the related fields of nested serializers referenced by `SerializedPKRelatedField`
+* [#23060](https://github.com/netbox-community/netbox/issues/23060) - Include reverse many-to-many relationships when determining REST API prefetches
+
+### Bug Fixes
+
+* [#22671](https://github.com/netbox-community/netbox/issues/22671) - Support the selection of multiple values when filtering VLAN groups by scope (e.g. by site group)
+* [#22872](https://github.com/netbox-community/netbox/issues/22872) - Gracefully handle invalid values assigned to a custom script's `Meta` attributes
+* [#22989](https://github.com/netbox-community/netbox/issues/22989) - Reference brief components in the OpenAPI response schemas for nested `SerializedPKRelatedField` fields
+* [#23035](https://github.com/netbox-community/netbox/issues/23035) - Expand the active navigation menu section when resizing from a mobile to a desktop viewport
+* [#23038](https://github.com/netbox-community/netbox/issues/23038) - Omit the cache-busting query parameter from signed static file URLs (e.g. when using S3 storage)
+* [#23040](https://github.com/netbox-community/netbox/issues/23040) - Fix the validation error raised when removing the scope assigned to a VLAN group
+* [#23043](https://github.com/netbox-community/netbox/issues/23043) - Correct the validation of front port positions when creating front ports in bulk
+* [#23052](https://github.com/netbox-community/netbox/issues/23052) - Fix the duplicated `owner` field on the power outlet and service forms
+* [#23066](https://github.com/netbox-community/netbox/issues/23066) - Include the configured Redis username in the default Django cache settings
+* [#23072](https://github.com/netbox-community/netbox/issues/23072) - Retain cable paths when applying a cable profile via bulk edit
+* [#23078](https://github.com/netbox-community/netbox/issues/23078) - Normalize `update_fields` to avoid consuming a generator in `VLANGroup.save()` and `CircuitTermination.save()`
+* [#23090](https://github.com/netbox-community/netbox/issues/23090) - Fix the filtering of background jobs by user in the UI
+
+---
+
 ## v4.6.9 (2026-08-25)
 
 ### Enhancements

+ 2 - 1
netbox/circuits/models/circuits.py

@@ -17,6 +17,7 @@ from netbox.models.features import (
     TagsMixin,
 )
 from netbox.models.mixins import DistanceMixin
+from utilities.data import normalize_update_fields
 from utilities.string import title
 
 from .base import BaseCircuitType
@@ -382,7 +383,7 @@ class CircuitTermination(
 
     def save(self, *args, **kwargs):
         is_new = self._state.adding
-        update_fields = kwargs.get('update_fields')
+        update_fields = normalize_update_fields(kwargs)
 
         # Only consider circuit/term_side changes if those fields
         # are actually being persisted

+ 22 - 0
netbox/circuits/tests/test_models.py

@@ -76,6 +76,28 @@ class CircuitTerminationTestCase(TestCase):
         # New circuit's cache should be populated
         self.assertEqual(self.circuits[1].termination_a, termination)
 
+    def test_circuit_termination_circuit_change_with_generator_update_fields(self):
+        """
+        A one-shot iterable passed as update_fields must still reach the database, so the
+        circuit change is persisted and both caches are updated.
+        """
+        termination = CircuitTermination.objects.create(
+            circuit=self.circuits[0],
+            term_side='A',
+            termination=self.sites[0],
+        )
+
+        termination.circuit = self.circuits[1]
+        termination.save(update_fields=(field for field in ('circuit',)))
+
+        termination.refresh_from_db()
+        self.circuits[0].refresh_from_db()
+        self.circuits[1].refresh_from_db()
+
+        self.assertEqual(termination.circuit, self.circuits[1])
+        self.assertIsNone(self.circuits[0].termination_a)
+        self.assertEqual(self.circuits[1].termination_a, termination)
+
     def test_circuit_termination_term_side_change_clears_old_cache(self):
         """
         When a CircuitTermination's term_side is changed, the old side's cache should be cleared

+ 9 - 3
netbox/core/api/schema.py

@@ -320,8 +320,10 @@ class NetBoxAutoSchema(AutoSchema):
     def _get_serializer_name(self, serializer, direction, bypass_extensions=False) -> str:
         name = super()._get_serializer_name(serializer, direction, bypass_extensions)
 
-        # If this serializer is nested, prepend its name with "Brief"
-        if getattr(serializer, 'nested', False):
+        # If this serializer is nested, prepend its name with "Brief". Serializers which declare an explicit
+        # Meta.ref_name are exempt: those are brief by design and have no complete form in the schema, so the
+        # prefix would only rename an existing component to no purpose. See #22989.
+        if getattr(serializer, 'nested', False) and not getattr(getattr(serializer, 'Meta', None), 'ref_name', None):
             name = f'Brief{name}'
 
         return name
@@ -536,7 +538,11 @@ class FixSerializedPKRelatedField(OpenApiSerializerFieldExtension):
 
     def map_serializer_field(self, auto_schema, direction):
         if direction == "response":
-            component = auto_schema.resolve_serializer(self.target.serializer, direction)
+            # Resolve an instance of the serializer carrying the field's nested setting, so that the brief
+            # component is referenced wherever the field renders a brief representation. (The field's
+            # to_representation() passes nested in the same manner.) See #22989.
+            serializer = self.target.serializer(nested=self.target.nested)
+            component = auto_schema.resolve_serializer(serializer, direction)
             return component.ref if component else None
         return build_basic_type(OpenApiTypes.INT)
 

+ 2 - 2
netbox/core/forms/filtersets.py

@@ -75,7 +75,7 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
     model = Job
     fieldsets = (
         FieldSet('q', 'filter_id'),
-        FieldSet('object_type_id', 'status', 'queue_name', 'user', name=_('Attributes')),
+        FieldSet('object_type_id', 'status', 'queue_name', 'user_id', name=_('Attributes')),
         FieldSet(
             'created__before', 'created__after', 'scheduled__before', 'scheduled__after', 'started__before',
             'started__after', 'completed__before', 'completed__after', name=_('Scheduling')
@@ -136,7 +136,7 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
         required=False,
         widget=DateTimePicker()
     )
-    user = DynamicModelMultipleChoiceField(
+    user_id = DynamicModelMultipleChoiceField(
         queryset=User.objects.all(),
         required=False,
         label=_('User')

+ 151 - 7
netbox/core/tests/test_openapi_schema.py

@@ -5,21 +5,30 @@ Refs: #20638
 """
 import json
 
-from django.test import TestCase
+from django.test import SimpleTestCase, TestCase
 
-from core.api.schema import NetBoxAutoSchema
+from core.api.schema import FixSerializedPKRelatedField, NetBoxAutoSchema
+from dcim.api.serializers import SiteSerializer
+from dcim.models import Site
 from ipam.api.serializers import ServiceSerializer
+from netbox.api.fields import SerializedPKRelatedField
 from netbox.api.serializers import BulkOperationErrorSerializer
 
 
 class OpenAPISchemaTestCase(TestCase):
     """Tests for OpenAPI schema generation."""
 
-    def setUp(self):
-        """Fetch schema via API endpoint."""
-        response = self.client.get('/api/schema/', {'format': 'json'})
-        self.assertEqual(response.status_code, 200)
-        self.schema = json.loads(response.content)
+    @classmethod
+    def setUpClass(cls):
+        """
+        Fetch the schema via the API endpoint. Schema generation is expensive and its output is
+        immutable across these tests, so do this once for the class rather than per test method.
+        """
+        super().setUpClass()
+
+        response = cls.client_class().get('/api/schema/', {'format': 'json'})
+        assert response.status_code == 200, f'Failed to generate OpenAPI schema (HTTP {response.status_code})'
+        cls.schema = json.loads(response.content)
 
     def test_post_operation_documents_single_or_array(self):
         """
@@ -241,6 +250,87 @@ class OpenAPISchemaTestCase(TestCase):
                 for field in ('port_mappings', 'protocol', 'ports'):
                     self.assertIn(field, properties, f"{ref} should document the '{field}' field")
 
+    def test_nested_related_fields_reference_brief_components(self):
+        """
+        A SerializedPKRelatedField declared with nested=True must reference the brief component in
+        response schemas, as that is what the API returns.
+
+        Refs: #22989
+        """
+        components = self.schema['components']['schemas']
+
+        for component, field, ref in (
+            ('Site', 'asns', 'BriefASN'),
+            ('ConfigContext', 'sites', 'BriefSite'),
+            ('Interface', 'tagged_vlans', 'BriefVLAN'),
+        ):
+            with self.subTest(component=component, field=field):
+                self.assertEqual(
+                    components[component]['properties'][field]['items']['$ref'],
+                    f'#/components/schemas/{ref}'
+                )
+
+        # The brief component must advertise only the serializer's brief fields
+        self.assertEqual(
+            set(components['BriefASN']['properties']),
+            {'id', 'url', 'display', 'asn', 'description'}
+        )
+
+    def test_ref_name_exempts_serializer_from_brief_prefix(self):
+        """
+        A serializer which declares an explicit Meta.ref_name keeps that name when nested, rather than
+        acquiring a Brief prefix. These serializers are brief by design and have no complete form in the
+        schema, so prefixing them would rename an existing component to no purpose.
+
+        Refs: #22989
+        """
+        components = self.schema['components']['schemas']
+
+        for component, field, ref in (
+            ('ASN', 'sites', 'ASNSite'),
+            ('ObjectPermission', 'groups', 'NestedGroup'),
+            ('ObjectPermission', 'users', 'NestedUser'),
+        ):
+            with self.subTest(component=component, field=field):
+                self.assertEqual(
+                    components[component]['properties'][field]['items']['$ref'],
+                    f'#/components/schemas/{ref}'
+                )
+                self.assertNotIn(f'Brief{ref}', components)
+
+    def test_non_nested_related_fields_reference_full_components(self):
+        """
+        A SerializedPKRelatedField declared without nested=True must continue to reference the
+        complete component.
+
+        Refs: #22989
+        """
+        components = self.schema['components']['schemas']
+
+        for field in ('import_targets', 'export_targets'):
+            with self.subTest(field=field):
+                self.assertEqual(
+                    components['VRF']['properties'][field]['items']['$ref'],
+                    '#/components/schemas/RouteTarget'
+                )
+
+    def test_nested_related_fields_accept_pks_on_write(self):
+        """
+        Request schemas for a SerializedPKRelatedField must continue to accept an array of integer
+        primary keys.
+
+        Refs: #22989
+        """
+        components = self.schema['components']['schemas']
+
+        for component, field in (
+            ('SiteRequest', 'asns'),
+            ('ConfigContextRequest', 'sites'),
+            ('ASNRequest', 'sites'),
+        ):
+            with self.subTest(component=component, field=field):
+                self.assertEqual(components[component]['properties'][field]['items']['type'], 'integer')
+
 
 class WritableFieldRebuildTestCase(TestCase):
     """
@@ -284,3 +374,57 @@ class WritableFieldRebuildTestCase(TestCase):
     def test_serializer_without_model(self):
         """A serializer with no Meta.model has nothing to rebuild from."""
         self.assertFalse(NetBoxAutoSchema._rebuilds_as_writable(BulkOperationErrorSerializer(), 'id'))
+
+
+class SerializedPKRelatedFieldSchemaTestCase(SimpleTestCase):
+    """Tests for the schema extension which maps SerializedPKRelatedField."""
+
+    class DummyComponent:
+        ref = {'$ref': '#/components/schemas/Dummy'}
+
+    class DummyAutoSchema:
+        """Records the serializer resolved by the extension, in place of generating a component."""
+
+        def __init__(self):
+            self.resolved = []
+
+        def resolve_serializer(self, serializer, direction):
+            self.resolved.append(serializer)
+            return SerializedPKRelatedFieldSchemaTestCase.DummyComponent
+
+    def test_nested_flag_is_passed_to_serializer(self):
+        """
+        The field's serializer must be instantiated with the field's nested setting, so that the
+        component matching the rendered representation is referenced.
+
+        Refs: #22989
+        """
+        for nested in (True, False):
+            with self.subTest(nested=nested):
+                field = SerializedPKRelatedField(
+                    serializer=SiteSerializer,
+                    queryset=Site.objects.all(),
+                    nested=nested
+                )
+                auto_schema = self.DummyAutoSchema()
+
+                schema = FixSerializedPKRelatedField(field).map_serializer_field(auto_schema, 'response')
+
+                serializer = auto_schema.resolved[0]
+                self.assertIsInstance(serializer, SiteSerializer)
+                self.assertEqual(serializer.nested, nested)
+                self.assertEqual(schema, self.DummyComponent.ref)
+
+    def test_request_schema_is_an_integer(self):
+        """
+        Request schemas must document an integer primary key, regardless of the nested setting.
+
+        Refs: #22989
+        """
+        field = SerializedPKRelatedField(serializer=SiteSerializer, queryset=Site.objects.all(), nested=True)
+        auto_schema = self.DummyAutoSchema()
+
+        schema = FixSerializedPKRelatedField(field).map_serializer_field(auto_schema, 'request')
+
+        self.assertEqual(schema['type'], 'integer')
+        self.assertEqual(auto_schema.resolved, [])

+ 15 - 8
netbox/dcim/forms/mixins.py

@@ -109,19 +109,26 @@ class FrontPortFormMixin(forms.Form):
     def clean(self):
         super().clean()
 
-        # Check that the total number of FrontPorts and positions matches the selected number of RearPort:position
-        # mappings. Note that `name` will be a list under FrontPortCreateForm, in which cases we multiply the number of
-        # FrontPorts being creation by the number of positions.
-        positions = self.cleaned_data['positions']
-        frontport_count = len(self.cleaned_data['name']) if type(self.cleaned_data['name']) is list else 1
-        rearport_count = len(self.cleaned_data['rear_ports'])
-        if frontport_count * positions != rearport_count:
+        # All three are required fields, so bail out if any of them failed its own validation
+        positions = self.cleaned_data.get('positions')
+        name = self.cleaned_data.get('name')
+        rear_ports = self.cleaned_data.get('rear_ports')
+        if not (positions and name and rear_ports):
+            return
+
+        # `name` is a list under FrontPortCreateForm, and each generated FrontPort consumes `positions` mappings
+        frontport_count = len(name) if isinstance(name, list) else 1
+        frontport_position_count = frontport_count * positions
+        rearport_count = len(rear_ports)
+
+        # {frontport_count} receives the position total. Its name is unchanged to keep existing translations valid.
+        if frontport_position_count != rearport_count:
             raise forms.ValidationError({
                 'rear_ports': _(
                     "The total number of front port positions ({frontport_count}) must match the selected number of "
                     "rear port positions ({rearport_count})."
                 ).format(
-                    frontport_count=frontport_count,
+                    frontport_count=frontport_position_count,
                     rearport_count=rearport_count
                 )
             })

+ 2 - 2
netbox/dcim/forms/model_forms.py

@@ -1953,7 +1953,7 @@ class PowerOutletForm(ModularDeviceComponentForm):
     fieldsets = (
         FieldSet(
             'device', 'module', 'name', 'label', 'type', 'status', 'color', 'power_port', 'feed_leg', 'mark_connected',
-            'description', 'owner', 'tags',
+            'description', 'tags',
         ),
     )
 
@@ -1961,7 +1961,7 @@ class PowerOutletForm(ModularDeviceComponentForm):
         model = PowerOutlet
         fields = [
             'device', 'module', 'name', 'label', 'type', 'status', 'color', 'power_port', 'feed_leg', 'mark_connected',
-            'description', 'tags',
+            'description', 'owner', 'tags',
         ]
 
 

+ 14 - 12
netbox/dcim/forms/object_create.py

@@ -71,18 +71,20 @@ class ComponentCreateForm(forms.Form):
             return
         pattern_count = len(patterns)
         for field_name in self.replication_fields:
-            value_count = len(self.cleaned_data[field_name])
-            if self.cleaned_data[field_name]:
-                if value_count == 1:
-                    # If the field resolves to a single value (because no pattern was used), multiply it by the number
-                    # of expected values. This allows us to reuse the same label when creating multiple components.
-                    self.cleaned_data[field_name] = self.cleaned_data[field_name] * pattern_count
-                elif value_count != pattern_count:
-                    raise forms.ValidationError({
-                        field_name: _(
-                            "The provided pattern specifies {value_count} values, but {pattern_count} are expected."
-                        ).format(value_count=value_count, pattern_count=pattern_count)
-                    }, code='label_pattern_mismatch')
+            # A field is absent from cleaned_data if it failed its own validation, e.g. an inverted numeric range
+            if not (values := self.cleaned_data.get(field_name)):
+                continue
+            value_count = len(values)
+            if value_count == 1:
+                # If the field resolves to a single value (because no pattern was used), multiply it by the number
+                # of expected values. This allows us to reuse the same label when creating multiple components.
+                self.cleaned_data[field_name] = values * pattern_count
+            elif value_count != pattern_count:
+                raise forms.ValidationError({
+                    field_name: _(
+                        "The provided pattern specifies {value_count} values, but {pattern_count} are expected."
+                    ).format(value_count=value_count, pattern_count=pattern_count)
+                }, code='label_pattern_mismatch')
 
 
 #

+ 23 - 3
netbox/dcim/models/cables.py

@@ -23,6 +23,7 @@ from dcim.utils import decompile_path_node, object_to_path_node
 from netbox.choices import ColorChoices
 from netbox.models import ChangeLoggedModel, PrimaryModel
 from utilities.conversion import to_meters
+from utilities.data import normalize_update_fields
 from utilities.exceptions import AbortRequest
 from utilities.fields import ColorField, GenericArrayForeignKey
 from utilities.querysets import RestrictedQuerySet, chunked_update
@@ -321,6 +322,11 @@ class Cable(PrimaryModel):
 
     def save(self, *args, force_insert=False, force_update=False, using=None, update_fields=None):
         _created = self.pk is None
+        save_kwargs = {
+            'using': using,
+            'update_fields': update_fields,
+        }
+        update_fields = normalize_update_fields(save_kwargs)
 
         # Store the given length (if any) in meters for use in database ordering
         if self.length is not None and self.length_unit:
@@ -332,24 +338,35 @@ class Cable(PrimaryModel):
         if self.length is None:
             self.length_unit = None
 
+        # A field counts as changed only when this save actually writes it
+        status_written = update_fields is None or 'status' in update_fields
+        profile_written = update_fields is None or 'profile' in update_fields
+
         # If this is a new Cable, save it before attempting to create its CableTerminations
         if self._state.adding:
-            super().save(*args, force_insert=True, using=using, update_fields=update_fields)
+            super().save(*args, force_insert=True, **save_kwargs)
             # Update the private PK used in __str__()
             self._pk = self.pk
 
-        if self._orig_profile != self.profile:
+        if profile_written and self._orig_profile != self.profile:
             self.update_terminations(force=True)
         elif self._terminations_modified:
             self.update_terminations()
 
-        super().save(*args, force_update=True, using=using, update_fields=update_fields)
+        super().save(*args, force_update=True, **save_kwargs)
 
         try:
             trace_paths.send(Cable, instance=self, created=_created)
         except UnsupportedCablePath as e:
             raise AbortRequest(e)
 
+        # Reset change tracking for the next save of this instance
+        if status_written:
+            self._orig_status = self.status
+        if profile_written:
+            self._orig_profile = self.profile
+        self._terminations_modified = False
+
     def delete(self, *args, **kwargs):
         # Track this Cable as being deleted so the post_delete signal handler
         # for cascaded CableTerminations can skip redundant path retracing;
@@ -501,6 +518,9 @@ class Cable(PrimaryModel):
         if force_b and not hasattr(self, '_b_terminations'):
             self._b_terminations = list(b_terminations.keys())
 
+            # Recreating terminations invalidates existing paths, even when the endpoints are unchanged
+            self._terminations_modified = True
+
         # Delete any stale CableTerminations
         for termination, ct in a_terminations.items():
             if force_a or (termination.pk and termination not in self.a_terminations):

+ 1 - 1
netbox/dcim/tests/query_counts.json

@@ -33,7 +33,7 @@
   "frontport:api_list_objects": 14,
   "frontport:list_objects_with_permission": 22,
   "frontporttemplate:api_list_objects": 12,
-  "interface:api_list_objects": 22,
+  "interface:api_list_objects": 23,
   "interface:list_objects_with_permission": 18,
   "interfaceconnection:list_objects_with_permission": 41,
   "interfacetemplate:api_list_objects": 11,

+ 2 - 0
netbox/dcim/tests/test_api.py

@@ -3517,6 +3517,8 @@ class InterfaceTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTest
             VirtualDeviceContext(name='VDC 2', identifier=2, device=device)
         )
         VirtualDeviceContext.objects.bulk_create(vdcs)
+        for interface in interfaces:
+            interface.vdcs.set(vdcs)
 
         vlans = (
             VLAN(name='VLAN 1', vid=1),

+ 274 - 1
netbox/dcim/tests/test_cablepaths2.py

@@ -15,7 +15,7 @@ class CablePathTestCase(BaseCablePathTestCase):
     Tests are numbered as follows:
         1XX: Test direct connections using each profile
         2XX: Topology tests replicated from the legacy test case and adapted to use profiles
-        3XX: Dynamic port mapping and termination changes
+        3XX: Dynamic port mapping, profile and termination changes
     """
 
     def test_101_cable_profile_single_1c1p(self):
@@ -2512,3 +2512,276 @@ class CablePathTestCase(BaseCablePathTestCase):
             is_complete=True,
             is_active=True
         )
+
+    def test_307_change_cable_profile_rebuilds_paths(self):
+        """
+        [IF1] --C1-- [IF2]
+
+        Applying a profile to an existing cable rebuilds its paths.
+        """
+        interfaces = [
+            Interface.objects.create(device=self.device, name='Interface 1'),
+            Interface.objects.create(device=self.device, name='Interface 2'),
+        ]
+
+        # Create cable 1 without a profile
+        cable1 = Cable(
+            a_terminations=[interfaces[0]],
+            b_terminations=[interfaces[1]],
+        )
+        cable1.clean()
+        cable1.save()
+
+        self.assertPathExists(
+            (interfaces[0], cable1, interfaces[1]),
+            is_complete=True,
+            is_active=True
+        )
+        self.assertPathExists(
+            (interfaces[1], cable1, interfaces[0]),
+            is_complete=True,
+            is_active=True
+        )
+
+        # Reload so _terminations_modified starts False, as it does in a bulk edit
+        cable1 = Cable.objects.get(pk=cable1.pk)
+        cable1.profile = CableProfileChoices.SINGLE_1C1P
+        cable1.full_clean()
+        cable1.save()
+
+        path1 = self.assertPathExists(
+            (interfaces[0], cable1, interfaces[1]),
+            is_complete=True,
+            is_active=True
+        )
+        path2 = self.assertPathExists(
+            (interfaces[1], cable1, interfaces[0]),
+            is_complete=True,
+            is_active=True
+        )
+        self.assertEqual(CablePath.objects.count(), 2)
+        interfaces[0].refresh_from_db()
+        interfaces[1].refresh_from_db()
+        self.assertPathIsSet(interfaces[0], path1)
+        self.assertPathIsSet(interfaces[1], path2)
+
+    def test_308_change_cable_profile_regroups_trunk_paths(self):
+        """
+        [IF1] --C1-- [IF3]
+        [IF2]        [IF4]
+
+        Applying a trunk profile to an existing cable regroups its paths by connector, and
+        clearing it again collapses them.
+        """
+        interfaces = [
+            Interface.objects.create(device=self.device, name='Interface 1'),
+            Interface.objects.create(device=self.device, name='Interface 2'),
+            Interface.objects.create(device=self.device, name='Interface 3'),
+            Interface.objects.create(device=self.device, name='Interface 4'),
+        ]
+
+        # Create cable 1 without a profile
+        cable1 = Cable(
+            a_terminations=[interfaces[0], interfaces[1]],
+            b_terminations=[interfaces[2], interfaces[3]],
+        )
+        cable1.clean()
+        cable1.save()
+
+        # Without a profile both terminations on each end share a single path
+        self.assertPathExists(
+            ((interfaces[0], interfaces[1]), cable1, (interfaces[2], interfaces[3])),
+            is_complete=True,
+            is_active=True
+        )
+        self.assertPathExists(
+            ((interfaces[2], interfaces[3]), cable1, (interfaces[0], interfaces[1])),
+            is_complete=True,
+            is_active=True
+        )
+        self.assertEqual(CablePath.objects.count(), 2)
+
+        # Reload so _terminations_modified starts False, as it does in a bulk edit
+        cable1 = Cable.objects.get(pk=cable1.pk)
+        cable1.profile = CableProfileChoices.TRUNK_2C1P
+        cable1.full_clean()
+        cable1.save()
+
+        path1 = self.assertPathExists(
+            (interfaces[0], cable1, interfaces[2]),
+            is_complete=True,
+            is_active=True
+        )
+        path2 = self.assertPathExists(
+            (interfaces[1], cable1, interfaces[3]),
+            is_complete=True,
+            is_active=True
+        )
+        path3 = self.assertPathExists(
+            (interfaces[2], cable1, interfaces[0]),
+            is_complete=True,
+            is_active=True
+        )
+        path4 = self.assertPathExists(
+            (interfaces[3], cable1, interfaces[1]),
+            is_complete=True,
+            is_active=True
+        )
+        self.assertEqual(CablePath.objects.count(), 4)
+
+        for interface in interfaces:
+            interface.refresh_from_db()
+        self.assertPathIsSet(interfaces[0], path1)
+        self.assertPathIsSet(interfaces[1], path2)
+        self.assertPathIsSet(interfaces[2], path3)
+        self.assertPathIsSet(interfaces[3], path4)
+        self.assertEqual(interfaces[0].cable_connector, 1)
+        self.assertEqual(interfaces[1].cable_connector, 2)
+        self.assertEqual(interfaces[2].cable_connector, 1)
+        self.assertEqual(interfaces[3].cable_connector, 2)
+        for interface in interfaces:
+            self.assertEqual(interface.cable_positions, [1])
+
+        # Clearing the profile is a bulk-edit action in its own right
+        cable1 = Cable.objects.get(pk=cable1.pk)
+        cable1.profile = ''
+        cable1.full_clean()
+        cable1.save()
+
+        path5 = self.assertPathExists(
+            ((interfaces[0], interfaces[1]), cable1, (interfaces[2], interfaces[3])),
+            is_complete=True,
+            is_active=True
+        )
+        path6 = self.assertPathExists(
+            ((interfaces[2], interfaces[3]), cable1, (interfaces[0], interfaces[1])),
+            is_complete=True,
+            is_active=True
+        )
+        self.assertEqual(CablePath.objects.count(), 2)
+
+        for interface in interfaces:
+            interface.refresh_from_db()
+            self.assertIsNone(interface.cable_connector)
+        self.assertPathIsSet(interfaces[0], path5)
+        self.assertPathIsSet(interfaces[1], path5)
+        self.assertPathIsSet(interfaces[2], path6)
+        self.assertPathIsSet(interfaces[3], path6)
+
+    def test_309_change_midspan_cable_profile_rebuilds_paths(self):
+        """
+        [IF1] --C1-- [FP1][RP1] --C3-- [RP2][FP2] --C2-- [IF2]
+
+        Applying a profile to a cable which terminates on pass-through ports rebuilds the
+        paths traversing it. The rear ports are not path origins, so a missing rebuild
+        truncates those paths rather than deleting them.
+        """
+        interfaces = [
+            Interface.objects.create(device=self.device, name='Interface 1'),
+            Interface.objects.create(device=self.device, name='Interface 2'),
+        ]
+        rear_ports = [
+            RearPort.objects.create(device=self.device, name='Rear Port 1'),
+            RearPort.objects.create(device=self.device, name='Rear Port 2'),
+        ]
+        front_ports = [
+            FrontPort.objects.create(device=self.device, name='Front Port 1'),
+            FrontPort.objects.create(device=self.device, name='Front Port 2'),
+        ]
+        for front_port, rear_port in zip(front_ports, rear_ports):
+            PortMapping.objects.create(
+                device=self.device,
+                front_port=front_port,
+                front_port_position=1,
+                rear_port=rear_port,
+                rear_port_position=1
+            )
+
+        cable1 = Cable(a_terminations=[interfaces[0]], b_terminations=[front_ports[0]])
+        cable1.clean()
+        cable1.save()
+        cable2 = Cable(a_terminations=[front_ports[1]], b_terminations=[interfaces[1]])
+        cable2.clean()
+        cable2.save()
+
+        # Create the mid-span cable without a profile
+        cable3 = Cable(a_terminations=[rear_ports[0]], b_terminations=[rear_ports[1]])
+        cable3.clean()
+        cable3.save()
+
+        nodes_a_to_b = (
+            interfaces[0], cable1, front_ports[0], rear_ports[0], cable3, rear_ports[1], front_ports[1], cable2,
+            interfaces[1],
+        )
+        nodes_b_to_a = (
+            interfaces[1], cable2, front_ports[1], rear_ports[1], cable3, rear_ports[0], front_ports[0], cable1,
+            interfaces[0],
+        )
+        self.assertPathExists(nodes_a_to_b, is_complete=True, is_active=True)
+        self.assertPathExists(nodes_b_to_a, is_complete=True, is_active=True)
+        self.assertEqual(CablePath.objects.count(), 2)
+
+        # Reload so _terminations_modified starts False, as it does in a bulk edit
+        cable3 = Cable.objects.get(pk=cable3.pk)
+        cable3.profile = CableProfileChoices.SINGLE_1C1P
+        cable3.full_clean()
+        cable3.save()
+
+        path1 = self.assertPathExists(nodes_a_to_b, is_complete=True, is_active=True)
+        path2 = self.assertPathExists(nodes_b_to_a, is_complete=True, is_active=True)
+        self.assertEqual(CablePath.objects.count(), 2)
+
+        for interface in interfaces:
+            interface.refresh_from_db()
+        self.assertPathIsSet(interfaces[0], path1)
+        self.assertPathIsSet(interfaces[1], path2)
+
+    def test_310_repeat_save_does_not_recreate_paths(self):
+        """
+        [IF1] --C1-- [IF2]
+
+        Saving an unchanged cable again leaves its terminations and paths untouched.
+        """
+        interfaces = [
+            Interface.objects.create(device=self.device, name='Interface 1'),
+            Interface.objects.create(device=self.device, name='Interface 2'),
+        ]
+
+        cable1 = Cable(
+            a_terminations=[interfaces[0]],
+            b_terminations=[interfaces[1]],
+        )
+        cable1.clean()
+        cable1.save()
+
+        path_pks = set(CablePath.objects.values_list('pk', flat=True))
+        termination_pks = set(CableTermination.objects.filter(cable=cable1).values_list('pk', flat=True))
+        self.assertEqual(len(path_pks), 2)
+        self.assertEqual(len(termination_pks), 2)
+
+        # Saving the same instance again must not duplicate its paths
+        cable1.save()
+        self.assertEqual(set(CablePath.objects.values_list('pk', flat=True)), path_pks)
+        self.assertEqual(
+            set(CableTermination.objects.filter(cable=cable1).values_list('pk', flat=True)),
+            termination_pks
+        )
+
+        # Reload so _terminations_modified starts False, as it does in a bulk edit
+        cable1 = Cable.objects.get(pk=cable1.pk)
+        cable1.profile = CableProfileChoices.SINGLE_1C1P
+        cable1.full_clean()
+        cable1.save()
+
+        path_pks = set(CablePath.objects.values_list('pk', flat=True))
+        termination_pks = set(CableTermination.objects.filter(cable=cable1).values_list('pk', flat=True))
+        self.assertEqual(len(path_pks), 2)
+        self.assertEqual(len(termination_pks), 2)
+
+        # The profile change is applied once, so a second save must not recreate anything
+        cable1.save()
+        self.assertEqual(set(CablePath.objects.values_list('pk', flat=True)), path_pks)
+        self.assertEqual(
+            set(CableTermination.objects.filter(cable=cable1).values_list('pk', flat=True)),
+            termination_pks
+        )

+ 125 - 0
netbox/dcim/tests/test_forms.py

@@ -637,6 +637,13 @@ class FrontPortTestCase(TestCase):
             RearPort(name='RearPort4', device=cls.device, type=PortTypeChoices.TYPE_8P8C),
         )
         RearPort.objects.bulk_create(cls.rear_ports)
+        cls.rear_port_templates = (
+            RearPortTemplate(name='RearPort1', device_type=cls.device.device_type, type=PortTypeChoices.TYPE_8P8C),
+            RearPortTemplate(name='RearPort2', device_type=cls.device.device_type, type=PortTypeChoices.TYPE_8P8C),
+            RearPortTemplate(name='RearPort3', device_type=cls.device.device_type, type=PortTypeChoices.TYPE_8P8C),
+            RearPortTemplate(name='RearPort4', device_type=cls.device.device_type, type=PortTypeChoices.TYPE_8P8C),
+        )
+        RearPortTemplate.objects.bulk_create(cls.rear_port_templates)
 
     def test_front_port_label_count_valid(self):
         """
@@ -671,6 +678,124 @@ class FrontPortTestCase(TestCase):
         self.assertFalse(form.is_valid())
         self.assertIn('label', form.errors)
 
+    def test_front_port_position_count_valid(self):
+        """
+        Test that generating front ports with multiple positions each passes form validation.
+        """
+        front_port_data = {
+            'device': self.device.pk,
+            'name': 'FrontPort[1-2]',
+            'type': PortTypeChoices.TYPE_8P8C,
+            'positions': 2,
+            'rear_ports': [f'{rear_port.pk}:1' for rear_port in self.rear_ports],
+        }
+        form = FrontPortCreateForm(front_port_data)
+
+        self.assertTrue(form.is_valid(), form.errors)
+
+    def test_front_port_position_count_mismatch(self):
+        """
+        Check that the mismatch error reports the total number of front port positions, not the port count.
+        """
+        bad_front_port_data = {
+            'device': self.device.pk,
+            'name': 'FrontPort[1-2]',
+            'type': PortTypeChoices.TYPE_8P8C,
+            'positions': 2,
+            'rear_ports': [f'{rear_port.pk}:1' for rear_port in self.rear_ports[:2]],
+        }
+        form = FrontPortCreateForm(bad_front_port_data)
+
+        self.assertFalse(form.is_valid())
+        self.assertIn(
+            'The total number of front port positions (4) must match the selected number of rear port '
+            'positions (2).',
+            form.errors['rear_ports']
+        )
+
+    def test_front_port_template_position_count_mismatch(self):
+        """
+        Check that the front port template form reports the same corrected position total.
+        """
+        bad_front_port_template_data = {
+            'device_type': self.device.device_type.pk,
+            'name': 'FrontPort[1-2]',
+            'type': PortTypeChoices.TYPE_8P8C,
+            'positions': 2,
+            'rear_ports': [f'{rear_port_template.pk}:1' for rear_port_template in self.rear_port_templates[:2]],
+        }
+        form = FrontPortTemplateCreateForm(bad_front_port_template_data)
+
+        self.assertFalse(form.is_valid())
+        self.assertIn(
+            'The total number of front port positions (4) must match the selected number of rear port '
+            'positions (2).',
+            form.errors['rear_ports']
+        )
+
+    def test_front_port_missing_rear_ports(self):
+        """
+        Check that omitting the rear port selection reports a field error rather than raising an exception.
+        """
+        bad_front_port_data = {
+            'device': self.device.pk,
+            'name': 'FrontPort[1-2]',
+            'type': PortTypeChoices.TYPE_8P8C,
+            'positions': 1,
+        }
+        form = FrontPortCreateForm(bad_front_port_data)
+
+        self.assertFalse(form.is_valid())
+        self.assertIn('rear_ports', form.errors)
+
+    def test_front_port_invalid_positions(self):
+        """
+        Check that a non-numeric position count reports a field error rather than raising an exception.
+        """
+        bad_front_port_data = {
+            'device': self.device.pk,
+            'name': 'FrontPort[1-2]',
+            'type': PortTypeChoices.TYPE_8P8C,
+            'positions': 'two',
+            'rear_ports': [f'{rear_port.pk}:1' for rear_port in self.rear_ports[:2]],
+        }
+        form = FrontPortCreateForm(bad_front_port_data)
+
+        self.assertFalse(form.is_valid())
+        self.assertIn('positions', form.errors)
+
+    def test_front_port_template_missing_rear_ports(self):
+        """
+        Check that the front port template form also reports a field error rather than raising an exception.
+        """
+        bad_front_port_template_data = {
+            'device_type': self.device.device_type.pk,
+            'name': 'FrontPort[1-2]',
+            'type': PortTypeChoices.TYPE_8P8C,
+            'positions': 1,
+        }
+        form = FrontPortTemplateCreateForm(bad_front_port_template_data)
+
+        self.assertFalse(form.is_valid())
+        self.assertIn('rear_ports', form.errors)
+
+    def test_front_port_invalid_label_range(self):
+        """
+        Check that an inverted label range reports a field error rather than raising an exception.
+        """
+        bad_front_port_data = {
+            'device': self.device.pk,
+            'name': 'FrontPort[1-2]',
+            'label': 'Port[2-1]',
+            'type': PortTypeChoices.TYPE_8P8C,
+            'positions': 1,
+            'rear_ports': [f'{rear_port.pk}:1' for rear_port in self.rear_ports[:2]],
+        }
+        form = FrontPortCreateForm(bad_front_port_data)
+
+        self.assertFalse(form.is_valid())
+        self.assertIn('label', form.errors)
+
 
 class InterfaceTestCase(TestCase):
 

+ 27 - 0
netbox/dcim/tests/test_models.py

@@ -2413,6 +2413,33 @@ class CableTestCase(TestCase):
         with self.assertRaises(ValidationError):
             cable.clean()
 
+    def test_partial_save_does_not_apply_an_unwritten_profile(self):
+        """
+        A save excluding profile must leave the terminations alone but keep the change pending.
+        """
+        cable = Cable.objects.first()
+        interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
+        termination_pks = set(CableTermination.objects.filter(cable=cable).values_list('pk', flat=True))
+
+        cable.profile = CableProfileChoices.SINGLE_1C1P
+        cable.save(update_fields=['label'])
+
+        interface1.refresh_from_db()
+        # Requery rather than refresh, so the pending profile stays on the instance under test
+        self.assertEqual(Cable.objects.get(pk=cable.pk).profile, '')
+        self.assertIsNone(interface1.cable_connector)
+        self.assertEqual(
+            set(CableTermination.objects.filter(cable=cable).values_list('pk', flat=True)),
+            termination_pks
+        )
+
+        # _orig_profile was not advanced, so the pending change still applies here
+        cable.save()
+
+        interface1.refresh_from_db()
+        self.assertEqual(Cable.objects.get(pk=cable.pk).profile, CableProfileChoices.SINGLE_1C1P)
+        self.assertEqual(interface1.cable_connector, 1)
+
     def test_cable_profile_change_preserves_terminations(self):
         """
         When a Cable's profile is changed via save() without explicitly setting terminations (as happens during

+ 37 - 2
netbox/dcim/tests/test_signals.py

@@ -680,8 +680,7 @@ class CableSignalTestCase(TestCase):
         cable.save()
         self.assertTrue(all(cp.is_active for cp in CablePath.objects.all()))
 
-        # Reload the cable so _orig_status reflects the persisted value and
-        # _terminations_modified resets to False.
+        # Reload to exercise status tracking on a freshly loaded instance, as a request does
         cable = Cable.objects.get(pk=cable.pk)
         cable.status = LinkStatusChoices.STATUS_PLANNED
         cable.save()
@@ -705,6 +704,42 @@ class CableSignalTestCase(TestCase):
 
         self.assertTrue(all(cp.is_active for cp in CablePath.objects.all()))
 
+    def test_toggling_cable_status_on_one_instance_reactivates_paths(self):
+        interface_a = Interface.objects.create(device=self.device, name='Interface A')
+        interface_b = Interface.objects.create(device=self.device, name='Interface B')
+        cable = Cable(a_terminations=[interface_a], b_terminations=[interface_b])
+        cable.save()
+
+        # Reuse the same instance for both changes, as a script would
+        cable = Cable.objects.get(pk=cable.pk)
+        cable.status = LinkStatusChoices.STATUS_PLANNED
+        cable.save()
+        self.assertFalse(any(cp.is_active for cp in CablePath.objects.all()))
+
+        cable.status = LinkStatusChoices.STATUS_CONNECTED
+        cable.save()
+        self.assertTrue(all(cp.is_active for cp in CablePath.objects.all()))
+
+    def test_partial_save_does_not_consume_an_unwritten_status_change(self):
+        interface_a = Interface.objects.create(device=self.device, name='Interface A')
+        interface_b = Interface.objects.create(device=self.device, name='Interface B')
+        cable = Cable(
+            a_terminations=[interface_a],
+            b_terminations=[interface_b],
+            status=LinkStatusChoices.STATUS_PLANNED,
+        )
+        cable.save()
+        self.assertFalse(any(cp.is_active for cp in CablePath.objects.all()))
+
+        # A save that excludes status must not advance the status snapshot
+        cable.status = LinkStatusChoices.STATUS_CONNECTED
+        cable.save(update_fields=['label'])
+        self.assertFalse(any(cp.is_active for cp in CablePath.objects.all()))
+
+        # _orig_status was not advanced, so the change must still be detected
+        cable.save()
+        self.assertTrue(all(cp.is_active for cp in CablePath.objects.all()))
+
     def test_deleting_cable_retraces_paths(self):
         interface_a = Interface.objects.create(device=self.device, name='Interface A')
         interface_b = Interface.objects.create(device=self.device, name='Interface B')

+ 42 - 1
netbox/dcim/tests/test_views.py

@@ -31,7 +31,7 @@ from netbox.choices import (
     WeightUnitChoices,
 )
 from tenancy.models import Tenant
-from users.models import ObjectPermission, User
+from users.models import ObjectPermission, Owner, User
 from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data
 from wireless.models import WirelessLAN
 
@@ -3832,6 +3832,8 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
         )
         PowerOutlet.objects.bulk_create(power_outlets)
 
+        owner = Owner.objects.create(name='Owner 1')
+
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
         cls.form_data = {
@@ -3842,6 +3844,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
             'power_port': powerports[1].pk,
             'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
             'description': 'A power outlet',
+            'owner': owner.pk,
             'tags': [t.pk for t in tags],
         }
 
@@ -3853,6 +3856,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
             'power_port': powerports[1].pk,
             'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
             'description': 'A power outlet',
+            'owner': owner.pk,
             'tags': [t.pk for t in tags],
         }
 
@@ -4370,6 +4374,43 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
         self.assertEqual(response.status_code, 200)
         self.assertFalse(FrontPort.objects.filter(name='Front Port 10').exists())
 
+    def test_create_multiple_objects_with_multiple_positions(self):
+        """
+        Check that bulk creation gives each generated front port its own slice of the selected mappings.
+        """
+        device = Device.objects.get(name='Device 1')
+        rear_ports = (
+            RearPort(device=device, name='Rear Port 7', positions=2),
+            RearPort(device=device, name='Rear Port 8', positions=2),
+        )
+        RearPort.objects.bulk_create(rear_ports)
+        self.add_permissions('dcim.add_frontport')
+
+        response = self.client.post(self._get_url('add'), post_data({
+            'device': device.pk,
+            'name': 'Multi Port [1-2]',
+            'type': PortTypeChoices.TYPE_8P8C,
+            'positions': 2,
+            'rear_ports': [
+                f'{rear_ports[0].pk}:1',
+                f'{rear_ports[0].pk}:2',
+                f'{rear_ports[1].pk}:1',
+                f'{rear_ports[1].pk}:2',
+            ],
+        }))
+
+        self.assertHttpStatus(response, 302)
+        for front_port_name, rear_port in (('Multi Port 1', rear_ports[0]), ('Multi Port 2', rear_ports[1])):
+            front_port = FrontPort.objects.get(device=device, name=front_port_name)
+            self.assertEqual(front_port.positions, 2)
+            self.assertEqual(
+                [
+                    (m.front_port_position, m.rear_port_id, m.rear_port_position)
+                    for m in front_port.mappings.order_by('front_port_position')
+                ],
+                [(1, rear_port.pk, 1), (2, rear_port.pk, 2)]
+            )
+
     def test_trace(self):
         self.add_permissions(
             'dcim.view_frontport',

+ 18 - 11
netbox/extras/api/views.py

@@ -1,3 +1,4 @@
+from django.core.exceptions import ValidationError as DjangoValidationError
 from django.http import Http404
 from django.shortcuts import get_object_or_404
 from django.utils.translation import gettext_lazy as _
@@ -404,17 +405,23 @@ class ScriptViewSet(ListModelMixin, RetrieveModelMixin, BaseViewSet):
             raise RQWorkerNotRunningException()
 
         if input_serializer.is_valid():
-            ScriptJob.enqueue(
-                instance=script,
-                user=request.user,
-                data=input_serializer.data['data'],
-                request=copy_safe_request(request),
-                commit=input_serializer.data['commit'],
-                job_timeout=script.python_class.job_timeout,
-                schedule_at=input_serializer.validated_data.get('schedule_at'),
-                interval=input_serializer.validated_data.get('interval'),
-                notifications=input_serializer.validated_data.get('notifications'),
-            )
+            try:
+                ScriptJob.enqueue(
+                    instance=script,
+                    user=request.user,
+                    data=input_serializer.data['data'],
+                    request=copy_safe_request(request),
+                    commit=input_serializer.data['commit'],
+                    job_timeout=script.python_class.job_timeout,
+                    schedule_at=input_serializer.validated_data.get('schedule_at'),
+                    interval=input_serializer.validated_data.get('interval'),
+                    notifications=input_serializer.validated_data.get('notifications'),
+                )
+            except DjangoValidationError as e:
+                # The script's execution configuration is invalid (see #22872). Surface it as a 400 rather than
+                # allowing the exception to bubble up as an HTTP 500. These are script-level config errors, not
+                # request-field errors, so report them under the non-field "detail" key.
+                raise ValidationError({'detail': e.messages}) from e
             serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
 
             return Response(serializer.data)

+ 17 - 2
netbox/extras/event_rules.py

@@ -1,3 +1,6 @@
+import logging
+
+from django.core.exceptions import ValidationError
 from django.utils import timezone
 from django.utils.translation import gettext_lazy as _
 from django_rq import get_queue
@@ -17,6 +20,8 @@ __all__ = (
     'WebhookAction',
 )
 
+logger = logging.getLogger('netbox.events_processor')
+
 
 class WebhookAction(EventRuleAction):
     slug = EventRuleActionChoices.WEBHOOK
@@ -79,8 +84,18 @@ class ScriptAction(EventRuleAction):
         if 'request' in event_context:
             params['request'] = copy_safe_request(event_context['request'], include_files=False)
 
-        # Enqueue the job
-        ScriptJob.enqueue(**params)
+        # Enqueue the job. If the script's Meta configuration is invalid (see #22872), log the error and skip this
+        # action rather than allowing the exception to abort the event pipeline (and, since events are processed
+        # in-request, the originating object change). Note this is intentionally asymmetric with the webhook
+        # action, which lets enqueue failures propagate: script Meta is validated eagerly at enqueue and a
+        # misconfigured script must not take down an unrelated object change.
+        try:
+            ScriptJob.enqueue(**params)
+        except ValidationError as e:
+            logger.error(
+                "Skipping script action for event rule %s: invalid script configuration: %s",
+                event_rule, '; '.join(e.messages)
+            )
 
     def resolve_import_object(self, value):
         from extras.scripts import get_module_and_script

+ 25 - 0
netbox/extras/jobs.py

@@ -13,6 +13,7 @@ from extras.choices import CustomFieldStatusChoices
 from extras.constants import CUSTOMFIELD_JOB_TIMEOUT
 from extras.models import CustomField
 from extras.models import Script as ScriptModel
+from extras.scripts import _UNSET
 from netbox.context_managers import event_tracking
 from netbox.jobs import JobRunner
 from netbox.registry import registry
@@ -270,6 +271,30 @@ class ScriptJob(JobRunner):
     class Meta:
         name = 'Run Script'
 
+    @classmethod
+    def enqueue(cls, *args, **kwargs):
+        """
+        Validate the script's execution parameters before enqueueing. This is the single choke point through which
+        every script execution passes (interactive runs, the REST API, the runscript command, event-rule actions, and
+        recurring reschedules), so validating here surfaces a misconfigured script as an actionable error rather than
+        an unhandled exception at enqueue time (see #22872).
+
+        The values actually being enqueued are validated, not just the script's Meta defaults, so an explicit
+        job_timeout or notifications supplied by the caller is checked too.
+        """
+        # The instance may be passed positionally (JobRunner.enqueue() forwards it to Job.enqueue()'s first argument)
+        # or by keyword. Resolve it for validation without consuming it, so the original arguments are forwarded to
+        # super() unchanged and the inherited calling contract is preserved.
+        instance = args[0] if args else kwargs.get('instance')
+        script_class = getattr(instance, 'python_class', None)
+        if script_class is not None:
+            script_class.validate_meta(
+                job_timeout=kwargs.get('job_timeout', _UNSET),
+                notifications=kwargs.get('notifications', _UNSET),
+            )
+
+        return super().enqueue(*args, **kwargs)
+
     def run_script(self, script, request, data, commit):
         """
         Core script execution task. We capture this within a method to allow for conditionally wrapping it with the

+ 25 - 19
netbox/extras/management/commands/runscript.py

@@ -3,6 +3,7 @@ import logging
 import sys
 import uuid
 
+from django.core.exceptions import ValidationError
 from django.core.management.base import BaseCommand, CommandError
 
 from extras.jobs import ScriptJob
@@ -88,24 +89,29 @@ class Command(BaseCommand):
         notifications = form.cleaned_data.pop('_notifications')
 
         # Execute the script.
-        job = ScriptJob.enqueue(
-            instance=script_obj,
-            user=user,
-            immediate=True,
-            data=form.cleaned_data,
-            notifications=notifications,
-            request=NetBoxFakeRequest({
-                'META': {},
-                'COOKIES': {},
-                'POST': data,
-                'GET': {},
-                'FILES': {},
-                'user': user,
-                'method': 'POST',
-                'path': '',
-                'id': uuid.uuid4()
-            }),
-            commit=commit,
-        )
+        try:
+            job = ScriptJob.enqueue(
+                instance=script_obj,
+                user=user,
+                immediate=True,
+                data=form.cleaned_data,
+                notifications=notifications,
+                request=NetBoxFakeRequest({
+                    'META': {},
+                    'COOKIES': {},
+                    'POST': data,
+                    'GET': {},
+                    'FILES': {},
+                    'user': user,
+                    'method': 'POST',
+                    'path': '',
+                    'id': uuid.uuid4()
+                }),
+                commit=commit,
+            )
+        except ValidationError as e:
+            # The script's Meta configuration is invalid (see #22872). Report it as a clean command error rather than
+            # an unhandled traceback.
+            raise CommandError('; '.join(e.messages))
 
         logger.info(f"Script completed in {job.duration}")

+ 51 - 0
netbox/extras/scripts.py

@@ -4,11 +4,14 @@ import os
 import re
 
 from django import forms
+from django.core.exceptions import ValidationError
 from django.core.files.storage import storages
 from django.core.validators import RegexValidator
 from django.utils import timezone
 from django.utils.functional import classproperty
 from django.utils.translation import gettext as _
+from rq.exceptions import TimeoutFormatError
+from rq.utils import parse_timeout
 
 from core.choices import JobNotificationChoices
 from extras.choices import LogLevelChoices
@@ -43,6 +46,9 @@ __all__ = (
     'get_module_and_script',
 )
 
+# Sentinel distinguishing "argument not supplied" from an explicit None in validate_meta().
+_UNSET = object()
+
 
 #
 # Script variables
@@ -403,6 +409,51 @@ class BaseScript:
     def notifications_default(self):
         return getattr(self.Meta, 'notifications_default', JobNotificationChoices.NOTIFICATION_ALWAYS)
 
+    @classmethod
+    def validate_meta(cls, job_timeout=_UNSET, notifications=_UNSET):
+        """
+        Validate the execution parameters used to run this script. Raises a ValidationError if any value is invalid,
+        so that a misconfigured script surfaces an actionable error rather than an unhandled exception when the job is
+        enqueued (see #22872).
+
+        The values actually enqueued are validated, not the raw Meta values: a caller may supply an explicit
+        `job_timeout` or `notifications` (e.g. via the REST API), in which case that value is checked. When a caller
+        omits a value, the corresponding Meta default is validated instead. Unset values fall back to valid defaults
+        and are not rejected.
+        """
+        errors = {}
+
+        job_timeout = cls.job_timeout if job_timeout is _UNSET else job_timeout
+        if job_timeout is not None:
+            # parse_timeout() is what RQ applies to the timeout downstream. It raises TimeoutFormatError for
+            # malformed duration strings, but a job_timeout of an unexpected type (e.g. a list) instead raises
+            # TypeError/ValueError/AssertionError from its internal int()/assert. Catch them all so any invalid value
+            # surfaces as an actionable error rather than an unhandled 500.
+            try:
+                parsed_timeout = parse_timeout(job_timeout)
+            except (TimeoutFormatError, TypeError, ValueError, AssertionError):
+                parsed_timeout = None
+                errors['job_timeout'] = _(
+                    "Invalid job_timeout value '{value}': must be an integer (seconds) or a duration string such as "
+                    "'1h' or '30m'."
+                ).format(value=job_timeout)
+            if parsed_timeout is not None and parsed_timeout <= 0:
+                errors['job_timeout'] = _(
+                    "Invalid job_timeout value '{value}': must be a positive duration."
+                ).format(value=job_timeout)
+
+        # A caller may pass notifications=None to mean "use the script's default"; treat that as unset.
+        if notifications is _UNSET or notifications is None:
+            notifications = cls.notifications_default
+        if notifications not in JobNotificationChoices.values():
+            valid = ', '.join(JobNotificationChoices.values())
+            errors['notifications_default'] = _(
+                "Invalid notifications value '{value}': must be one of {valid}."
+            ).format(value=notifications, valid=valid)
+
+        if errors:
+            raise ValidationError(errors)
+
     @property
     def filename(self):
         return inspect.getfile(self.__class__)

+ 49 - 1
netbox/extras/tests/test_api.py

@@ -3,7 +3,7 @@ import hashlib
 import io
 import json
 from contextlib import contextmanager
-from unittest.mock import MagicMock, patch
+from unittest.mock import MagicMock, PropertyMock, patch
 
 from django.contrib.contenttypes.models import ContentType
 from django.core.files.uploadedfile import SimpleUploadedFile
@@ -1757,6 +1757,54 @@ class ScriptTestCase(APITestCase):
 
         self.assertEqual(Job.objects.count(), len(lookups))
 
+    def test_run_script_invalid_job_timeout(self):
+        """
+        A script whose Meta.job_timeout is invalid must be rejected with a 400, not raise an unhandled exception
+        (#22872).
+        """
+        self.add_permissions('extras.run_script')
+
+        class BadTimeoutScript(PythonClass):
+            class Meta:
+                name = 'Bad Timeout'
+                job_timeout = 'not-a-timeout'
+
+            def run(self, data, commit=True):
+                pass
+
+        payload = {'data': {}, 'commit': True}
+        with patch.object(Script, 'python_class', new_callable=PropertyMock) as mock_python_class:
+            mock_python_class.return_value = BadTimeoutScript
+            with disable_warnings('django.request'):
+                response = self.client.post(self.url, payload, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertFalse(Job.objects.exists())
+
+    def test_run_script_invalid_notifications_default(self):
+        """
+        A script whose Meta.notifications_default is invalid must be rejected with a 400, not raise an unhandled
+        exception (#22872).
+        """
+        self.add_permissions('extras.run_script')
+
+        class BadNotificationsScript(PythonClass):
+            class Meta:
+                name = 'Bad Notifications'
+                notifications_default = 'on_error'
+
+            def run(self, data, commit=True):
+                pass
+
+        payload = {'data': {}, 'commit': True}
+        with patch.object(Script, 'python_class', new_callable=PropertyMock) as mock_python_class:
+            mock_python_class.return_value = BadNotificationsScript
+            with disable_warnings('django.request'):
+                response = self.client.post(self.url, payload, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertFalse(Job.objects.exists())
+
     def test_modify_script_methods_disabled(self):
         """
         Individual scripts are created, modified, and deleted through their module, so PUT/PATCH/DELETE on

+ 58 - 1
netbox/extras/tests/test_event_rules.py

@@ -3,7 +3,7 @@ import logging
 import uuid
 from io import BytesIO
 from unittest import skipIf
-from unittest.mock import Mock, patch
+from unittest.mock import Mock, PropertyMock, patch
 
 import django_rq
 import requests
@@ -1264,6 +1264,63 @@ class EventRuleTestCase(RQQueueTestMixin, APITestCase):
         self.assertEqual(script_job.status, "completed")
         self.assertEqual(script_job.data.get('output', ''), "finished successfully")
 
+    @tag('regression')  # Issue #22872
+    def test_eventrule_script_action_invalid_meta_does_not_abort_change(self):
+        """
+        A Script event-rule action whose Meta configuration is invalid must be logged and skipped without aborting
+        the triggering object change or raising an HTTP 500 (#22872). Because event rules are processed in-request,
+        an unhandled ValidationError here would fail the originating request.
+        """
+        class BadMetaScript(ScriptBase):
+            class Meta:
+                name = "Bad Meta Script"
+                job_timeout = 'not-a-timeout'
+
+            def run(self, data, commit=True):
+                return "never reached"
+
+        with patch.object(ScriptModule, 'sync_classes'):
+            module = ScriptModule.objects.create(
+                file_root=ManagedFileRootPathChoices.SCRIPTS,
+                file_path='bad_meta_script.py',
+            )
+        script = Script.objects.create(module=module, name='Bad Meta Script', is_executable=True)
+        script_type = ObjectType.objects.get_for_model(Script)
+
+        # Trigger on Manufacturer rather than Site: the class-level event rules all target Site, so a Site-based rule
+        # here would collide with them and perturb other tests' queue expectations.
+        manufacturer_type = ObjectType.objects.get_for_model(Manufacturer)
+        event_rule = EventRule.objects.create(
+            name='Bad Meta Script Rule',
+            event_types=[OBJECT_UPDATED],
+            action_type=EventRuleActionChoices.SCRIPT,
+            action_object_type=script_type,
+            action_object_id=script.pk,
+        )
+        event_rule.object_types.set([manufacturer_type])
+
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        self.add_permissions('dcim.change_manufacturer')
+        url = reverse('dcim-api:manufacturer-detail', kwargs={'pk': manufacturer.pk})
+
+        # python_class is a property returning the script class; patch it to return our bad-Meta class so validate_meta
+        # (a classmethod on it) is exercised the way production reads it.
+        with patch.object(Script, 'python_class', new_callable=PropertyMock) as mock:
+            mock.return_value = BadMetaScript
+            with self.captureOnCommitCallbacks(execute=True):
+                with self.assertLogs('netbox.events_processor', 'ERROR') as captured:
+                    response = self.client.patch(url, {'description': 'updated'}, format='json', **self.header)
+
+        # The triggering object change succeeds despite the misconfigured script
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        manufacturer.refresh_from_db()
+        self.assertEqual(manufacturer.description, 'updated')
+
+        # No script job was enqueued (nothing queued, no Job record), and the misconfiguration was logged
+        self.assertEqual(self.queue.count, 0)
+        self.assertEqual(Job.objects.filter(name=BadMetaScript.Meta.name).count(), 0)
+        self.assertTrue(any('Bad Meta Script Rule' in line for line in captured.output))
+
     @tag('regression')  # Issue #22852
     def test_eventrule_script_action_honors_script_defaults(self):
         """A script run from an event rule uses the notification policy and job timeout from its Meta class."""

+ 25 - 0
netbox/extras/tests/test_management_commands.py

@@ -414,6 +414,31 @@ class RunScriptTestCase(TestCase):
 
         self.assertEqual(enqueue.call_args.kwargs['user'], self.user)
 
+    def test_invalid_meta_raises_command_error(self):
+        """
+        A script with an invalid Meta value must fail with a clean CommandError rather than an unhandled
+        exception (#22872).
+        """
+        class BadMetaScript(Script):
+            class Meta:
+                job_timeout = 'not-a-timeout'
+
+            def run(self, data, commit):
+                return None
+
+        script_obj = SimpleNamespace(python_class=BadMetaScript)
+
+        # Note: ScriptJob.enqueue is intentionally NOT mocked here, so validate_meta() runs and raises.
+        with (
+            patch(
+                'extras.management.commands.runscript.get_module_and_script',
+                return_value=(None, script_obj),
+            ),
+            patch('extras.management.commands.runscript.logging.getLogger'),
+        ):
+            with self.assertRaises(CommandError):
+                call_command('runscript', 'test.Script', user='admin', stdout=StringIO())
+
 
 class RebuildConfigContextCacheCommandTest(TestCase):
 

+ 273 - 1
netbox/extras/tests/test_scripts.py

@@ -1,15 +1,22 @@
 import io
 import sys
+import uuid
 from datetime import UTC, date, datetime
 from decimal import Decimal
-from unittest.mock import patch
+from unittest.mock import PropertyMock, patch
 
+from django.contrib.auth import get_user_model
+from django.core.exceptions import ValidationError
 from django.core.files.uploadedfile import SimpleUploadedFile
 from django.test import TestCase
 from netaddr import IPAddress, IPNetwork
 
+from core.choices import JobNotificationChoices, JobStatusChoices, ManagedFileRootPathChoices
+from core.models import Job
 from dcim.models import DeviceRole
 from extras.constants import SCRIPT_MODULE_NAME_PREFIX
+from extras.jobs import ScriptJob
+from extras.models import Script as ScriptModel
 from extras.models import ScriptModule
 from extras.scripts import *
 
@@ -469,3 +476,268 @@ class ScriptModuleLoadingTestCase(TestCase):
         with self.assertLogs(logger_name, 'INFO') as captured:
             script.log_success('Start')
         self.assertIn('Start', captured.output[0])
+
+
+class ScriptMetaValidationTestCase(TestCase):
+    """
+    Tests for BaseScript.validate_meta() (#22872): invalid execution-related Meta values must raise an actionable
+    ValidationError, while unset/valid values must not.
+    """
+
+    def test_valid_meta_passes(self):
+        class TestScript(Script):
+            class Meta:
+                job_timeout = 600
+                notifications_default = JobNotificationChoices.NOTIFICATION_ON_FAILURE
+
+            def run(self, data, commit):
+                pass
+
+        TestScript.validate_meta()  # should not raise
+
+    def test_job_timeout_duration_string_passes(self):
+        class TestScript(Script):
+            class Meta:
+                job_timeout = '1h'
+
+            def run(self, data, commit):
+                pass
+
+        TestScript.validate_meta()  # should not raise
+
+    def test_unset_meta_passes(self):
+        class TestScript(Script):
+            def run(self, data, commit):
+                pass
+
+        # job_timeout defaults to None and notifications_default to ALWAYS; neither should be rejected
+        TestScript.validate_meta()
+
+    def test_all_notification_choices_pass(self):
+        for choice in JobNotificationChoices.values():
+            class TestScript(Script):
+                class Meta:
+                    notifications_default = choice
+
+                def run(self, data, commit):
+                    pass
+
+            TestScript.validate_meta()  # should not raise
+
+    def test_invalid_job_timeout_raises(self):
+        class TestScript(Script):
+            class Meta:
+                job_timeout = 'not-a-timeout'
+
+            def run(self, data, commit):
+                pass
+
+        with self.assertRaises(ValidationError) as cm:
+            TestScript.validate_meta()
+        self.assertIn('job_timeout', cm.exception.message_dict)
+
+    def test_invalid_notifications_default_raises(self):
+        class TestScript(Script):
+            class Meta:
+                notifications_default = 'on_error'
+
+            def run(self, data, commit):
+                pass
+
+        with self.assertRaises(ValidationError) as cm:
+            TestScript.validate_meta()
+        self.assertIn('notifications_default', cm.exception.message_dict)
+
+    def test_non_string_job_timeout_raises(self):
+        # A job_timeout of an unexpected type must surface as a ValidationError, not an unhandled TypeError.
+        class TestScript(Script):
+            class Meta:
+                job_timeout = [60]
+
+            def run(self, data, commit):
+                pass
+
+        with self.assertRaises(ValidationError) as cm:
+            TestScript.validate_meta()
+        self.assertIn('job_timeout', cm.exception.message_dict)
+
+    def test_non_positive_job_timeout_raises(self):
+        # parse_timeout() accepts 0 and negatives, but a non-positive timeout is nonsensical and must be rejected.
+        for value in (0, -30):
+            class TestScript(Script):
+                class Meta:
+                    job_timeout = value
+
+                def run(self, data, commit):
+                    pass
+
+            with self.assertRaises(ValidationError) as cm:
+                TestScript.validate_meta()
+            self.assertIn('job_timeout', cm.exception.message_dict)
+
+
+class ScriptJobEnqueueValidationTestCase(TestCase):
+    """
+    Tests that ScriptJob.enqueue() validates Meta before creating a Job (#22872). This is the choke point exercised by
+    event-rule actions and recurring reschedules, which have no request layer to catch the error.
+    """
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.user = get_user_model().objects.create_user('scriptrunner')
+
+    def _make_script(self, python_class):
+        with patch.object(ScriptModule, 'sync_classes'):
+            module = ScriptModule.objects.create(
+                file_root=ManagedFileRootPathChoices.SCRIPTS,
+                file_path=f'meta_validation_{id(python_class)}.py',
+            )
+        script = ScriptModel.objects.create(module=module, name=python_class.Meta.name, is_executable=True)
+        # Return the raw python_class regardless of on-disk module state
+        patcher = patch.object(ScriptModel, 'python_class', property(lambda self, pc=python_class: pc))
+        patcher.start()
+        self.addCleanup(patcher.stop)
+        return script
+
+    def test_enqueue_rejects_invalid_job_timeout(self):
+        class BadTimeout(Script):
+            class Meta:
+                name = 'Bad Timeout'
+                job_timeout = 'not-a-timeout'
+
+            def run(self, data, commit):
+                pass
+
+        script = self._make_script(BadTimeout)
+        with self.captureOnCommitCallbacks(execute=True):
+            with self.assertRaises(ValidationError):
+                ScriptJob.enqueue(
+                    instance=script, user=self.user, job_timeout=BadTimeout.job_timeout,
+                    notifications=BadTimeout.notifications_default, data={}, commit=True,
+                )
+        self.assertEqual(Job.objects.count(), 0)
+
+    def test_enqueue_rejects_invalid_notifications_default(self):
+        class BadNotifications(Script):
+            class Meta:
+                name = 'Bad Notifications'
+                notifications_default = 'on_error'
+
+            def run(self, data, commit):
+                pass
+
+        script = self._make_script(BadNotifications)
+        with self.captureOnCommitCallbacks(execute=True):
+            with self.assertRaises(ValidationError):
+                ScriptJob.enqueue(
+                    instance=script, user=self.user, job_timeout=BadNotifications.job_timeout,
+                    notifications=BadNotifications.notifications_default, data={}, commit=True,
+                )
+        self.assertEqual(Job.objects.count(), 0)
+
+    def test_enqueue_accepts_valid_meta(self):
+        class GoodScript(Script):
+            class Meta:
+                name = 'Good Script'
+                job_timeout = '1h'
+                notifications_default = JobNotificationChoices.NOTIFICATION_ALWAYS
+
+            def run(self, data, commit):
+                pass
+
+        script = self._make_script(GoodScript)
+        # Do not execute the on_commit callback: the Job row is created by Job.enqueue() before the RQ push is
+        # registered, so asserting the row exists needs no real enqueue. Executing it would leave a job in the shared
+        # Redis queue that races other tests under the parallel runner (see #22872).
+        with self.captureOnCommitCallbacks():
+            job = ScriptJob.enqueue(
+                instance=script, user=self.user, job_timeout=GoodScript.job_timeout,
+                notifications=GoodScript.notifications_default, data={}, commit=True,
+            )
+        self.assertIsNotNone(job)
+        self.assertEqual(Job.objects.count(), 1)
+
+    def test_enqueue_positional_instance_is_validated_and_forwarded(self):
+        """
+        The instance may be passed positionally (JobRunner.enqueue() forwards it to Job.enqueue()'s first argument).
+        The override must validate it without breaking that inherited calling contract (#22872).
+        """
+        class BadTimeout(Script):
+            class Meta:
+                name = 'Bad Timeout Positional'
+                job_timeout = 'not-a-timeout'
+
+            def run(self, data, commit):
+                pass
+
+        script = self._make_script(BadTimeout)
+        # Passed positionally, not instance=... — must still be validated and rejected.
+        with self.captureOnCommitCallbacks(execute=True):
+            with self.assertRaises(ValidationError):
+                ScriptJob.enqueue(script, user=self.user, data={}, commit=True)
+        self.assertEqual(Job.objects.count(), 0)
+
+    def test_enqueue_positional_instance_valid_meta_creates_job(self):
+        """A valid script passed positionally must enqueue cleanly, i.e. the override forwards args unchanged."""
+        class GoodScript(Script):
+            class Meta:
+                name = 'Good Positional'
+
+            def run(self, data, commit):
+                pass
+
+        script = self._make_script(GoodScript)
+        # Do not execute the on_commit callback (see the note in test_enqueue_accepts_valid_meta): asserting the Job
+        # row exists needs no real RQ push, and executing it would leak a job into the shared queue (see #22872).
+        with self.captureOnCommitCallbacks():
+            job = ScriptJob.enqueue(script, user=self.user, data={}, commit=True)
+        self.assertIsNotNone(job)
+        self.assertEqual(Job.objects.count(), 1)
+
+    def test_reschedule_with_invalid_meta_preserves_completed_run(self):
+        """
+        If a recurring script's Meta.job_timeout becomes invalid between runs, the occurrence that just ran to
+        completion must keep its COMPLETED status and not be re-terminated as ERRORED, no successor may be scheduled,
+        and the reschedule failure must be recorded on the job (#22872).
+        """
+        class RecurringScript(Script):
+            class Meta:
+                name = 'Recurring'
+                # No custom job_timeout at schedule time: valid.
+
+            def run(self, data, commit):
+                pass
+
+        script = self._make_script(RecurringScript)
+
+        # Create a completed, recurring job as if a scheduled occurrence had just finished successfully.
+        job = Job.objects.create(
+            object=script,
+            name='Recurring',
+            status=JobStatusChoices.STATUS_COMPLETED,
+            user=self.user,
+            interval=60,
+            job_id=uuid.uuid4(),
+        )
+
+        # The script's Meta is edited to an invalid job_timeout before the reschedule fires.
+        class RecurringScriptBadTimeout(RecurringScript):
+            class Meta(RecurringScript.Meta):
+                job_timeout = 'not-a-timeout'
+
+        with patch.object(ScriptModel, 'python_class', new_callable=PropertyMock) as mock_pc:
+            mock_pc.return_value = RecurringScriptBadTimeout
+            with self.captureOnCommitCallbacks(execute=True):
+                # handle() runs the script (which succeeds) and then reschedules in its finally block; the reschedule
+                # enqueue is what fails validation here.
+                ScriptJob.handle(job, data={}, commit=False)
+
+        job.refresh_from_db()
+        # The completed run's status is preserved (not flipped to ERRORED)
+        self.assertEqual(job.status, JobStatusChoices.STATUS_COMPLETED)
+        # No successor was scheduled
+        self.assertEqual(
+            Job.objects.filter(name='Recurring').exclude(pk=job.pk).count(), 0
+        )
+        # The reschedule failure was recorded on the job
+        self.assertTrue(any('not rescheduled' in entry.get('message', '') for entry in job.log_entries))

+ 57 - 0
netbox/extras/tests/test_views.py

@@ -1349,6 +1349,63 @@ class ScriptValidationErrorTestCase(TestCase):
         self.assertEqual(len(messages), 0)
 
 
+class ScriptMetaValidationViewTestCase(TestCase):
+    """
+    A script whose Meta declares an invalid job_timeout or notifications_default must surface an actionable error on
+    the run view rather than returning an HTTP 500 (#22872).
+    """
+    user_permissions = ['extras.view_script', 'extras.run_script']
+
+    class BadTimeoutScript(PythonClass):
+        class Meta:
+            name = 'Bad Timeout'
+            job_timeout = 'not-a-timeout'
+
+        def run(self, data, commit):
+            return "Complete"
+
+    class BadNotificationsScript(PythonClass):
+        class Meta:
+            name = 'Bad Notifications'
+            notifications_default = 'on_error'
+
+        def run(self, data, commit):
+            return "Complete"
+
+    @classmethod
+    def setUpTestData(cls):
+        with patch.object(ScriptModule, 'sync_classes'):
+            module = ScriptModule.objects.create(
+                file_root=ManagedFileRootPathChoices.SCRIPTS,
+                file_path='bad_meta.py',
+            )
+        cls.script = Script.objects.create(module=module, name='Bad meta', is_executable=True)
+
+    def _run_and_assert(self, python_class):
+        url = reverse('extras:script', kwargs={'pk': self.script.pk})
+        with patch.object(Script, 'python_class', new_callable=PropertyMock) as mock_python_class:
+            mock_python_class.return_value = python_class
+            with patch('extras.views.any_workers_for_queue', return_value=True):
+                with self.captureOnCommitCallbacks(execute=True):
+                    # Quick-run style: omit _notifications
+                    response = self.client.post(url, {'_commit': 'true'})
+
+        # Re-render with an error message, not a 500, and no Job enqueued
+        self.assertEqual(response.status_code, 200)
+        messages = list(response.context['messages'])
+        self.assertEqual(len(messages), 1)
+        self.assertIn('Unable to run script', str(messages[0]))
+        self.assertEqual(Job.objects.count(), 0)
+
+    @tag('regression')
+    def test_invalid_job_timeout_shows_error(self):
+        self._run_and_assert(self.BadTimeoutScript)
+
+    @tag('regression')
+    def test_invalid_notifications_default_shows_error(self):
+        self._run_and_assert(self.BadNotificationsScript)
+
+
 class ScriptDefaultValuesTestCase(TestCase):
     user_permissions = ['extras.view_script', 'extras.run_script']
 

+ 20 - 13
netbox/extras/views.py

@@ -3,6 +3,7 @@ from datetime import datetime
 from django.contrib import messages
 from django.contrib.auth.mixins import LoginRequiredMixin
 from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import ValidationError
 from django.core.paginator import EmptyPage
 from django.db.models import Count, Q
 from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
@@ -1751,19 +1752,25 @@ class ScriptView(BaseScriptView):
             messages.error(request, _("Unable to run script: RQ worker process not running."))
         elif form.is_valid():
             ScriptJob = import_string("extras.jobs.ScriptJob")
-            job = ScriptJob.enqueue(
-                instance=script,
-                user=request.user,
-                schedule_at=form.cleaned_data.pop('_schedule_at'),
-                interval=form.cleaned_data.pop('_interval'),
-                notifications=form.cleaned_data.pop('_notifications'),
-                data=form.cleaned_data,
-                request=copy_safe_request(request),
-                job_timeout=script.python_class.job_timeout,
-                commit=form.cleaned_data.pop('_commit'),
-            )
-
-            return redirect('extras:script_result', job_pk=job.pk)
+            try:
+                job = ScriptJob.enqueue(
+                    instance=script,
+                    user=request.user,
+                    schedule_at=form.cleaned_data.pop('_schedule_at'),
+                    interval=form.cleaned_data.pop('_interval'),
+                    notifications=form.cleaned_data.pop('_notifications'),
+                    data=form.cleaned_data,
+                    request=copy_safe_request(request),
+                    job_timeout=script.python_class.job_timeout,
+                    commit=form.cleaned_data.pop('_commit'),
+                )
+            except ValidationError as e:
+                # The script's Meta configuration is invalid (see #22872). Surface it as a form error rather than
+                # allowing the exception to bubble up as an HTTP 500.
+                for msg in e.messages:
+                    messages.error(request, _("Unable to run script: {error}").format(error=msg))
+            else:
+                return redirect('extras:script_result', job_pk=job.pk)
         else:
             fieldset_fields = {field for _, fields in script_class.get_fieldsets() for field in fields}
             hidden_errors = {

+ 1 - 0
netbox/ipam/api/serializers_/asns.py

@@ -54,6 +54,7 @@ class ASNSiteSerializer(PrimaryModelSerializer):
         model = Site
         fields = ('id', 'url', 'display', 'name', 'description', 'slug')
         brief_fields = ('id', 'url', 'display', 'name', 'description', 'slug')
+        ref_name = 'ASNSite'
 
 
 class ASNSerializer(PrimaryModelSerializer):

+ 9 - 9
netbox/ipam/filtersets.py

@@ -946,28 +946,28 @@ class FHRPGroupAssignmentFilterSet(ChangeLoggedModelFilterSet):
 @register_filterset
 class VLANGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
     scope_type = MultiValueContentTypeFilter()
-    region = django_filters.NumberFilter(
+    region = MultiValueNumberFilter(
         method='filter_scope'
     )
-    site_group = django_filters.NumberFilter(
+    site_group = MultiValueNumberFilter(
         method='filter_scope'
     )
-    site = django_filters.NumberFilter(
+    site = MultiValueNumberFilter(
         method='filter_scope'
     )
-    location = django_filters.NumberFilter(
+    location = MultiValueNumberFilter(
         method='filter_scope'
     )
-    rack_group = django_filters.NumberFilter(
+    rack_group = MultiValueNumberFilter(
         method='filter_scope'
     )
-    rack = django_filters.NumberFilter(
+    rack = MultiValueNumberFilter(
         method='filter_scope'
     )
-    cluster_group = django_filters.NumberFilter(
+    cluster_group = MultiValueNumberFilter(
         method='filter_scope'
     )
-    cluster = django_filters.NumberFilter(
+    cluster = MultiValueNumberFilter(
         method='filter_scope'
     )
     contains_vid = django_filters.NumberFilter(
@@ -992,7 +992,7 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
         model_name = name.replace('_', '')
         return queryset.filter(
             scope_type=ContentType.objects.get(model=model_name),
-            scope_id=value
+            scope_id__in=value
         )
 
 

+ 1 - 1
netbox/ipam/forms/model_forms.py

@@ -895,7 +895,7 @@ class ServiceCreateForm(ServiceForm):
 
     class Meta(ServiceForm.Meta):
         fields = [
-            'service_template', 'name', 'port_mappings', 'ipaddresses', 'description',
+            'service_template', 'name', 'port_mappings', 'ipaddresses', 'description', 'owner',
             'comments', 'tags',
         ]
 

+ 11 - 7
netbox/ipam/models/vlans.py

@@ -16,6 +16,7 @@ from utilities.data import (
     check_ranges_overlap,
     get_inclusive_integer_range_bounds,
     normalize_integer_range,
+    normalize_update_fields,
     ranges_to_string,
     ranges_to_string_list,
 )
@@ -100,13 +101,16 @@ class VLANGroup(OrganizationalModel):
         verbose_name_plural = _('VLAN groups')
 
     def clean(self):
-        super().clean()
-
-        # Validate scope assignment
+        # Validate the scope pair first, since BaseModel.clean() keys its errors to scope_id, which forms omit
         if self.scope_type and not self.scope_id:
-            raise ValidationError(_("Cannot set scope_type without scope_id."))
+            scope_type = self.scope_type.model_class()
+            raise ValidationError(
+                _("Please select a {scope_type}.").format(scope_type=scope_type._meta.model_name)
+            )
         if self.scope_id and not self.scope_type:
-            raise ValidationError(_("Cannot set scope_id without scope_type."))
+            raise ValidationError({'scope_type': _("Please select a scope type.")})
+
+        super().clean()
 
         # Validate VID ranges
         for vid_range in self.vid_ranges:
@@ -145,10 +149,10 @@ class VLANGroup(OrganizationalModel):
             self.total_vlan_ids += vid_range.upper - vid_range.lower
         self.vid_ranges = vid_ranges
 
-        update_fields = kwargs.get('update_fields')
+        update_fields = normalize_update_fields(kwargs)
         if update_fields is not None and 'vid_ranges' in update_fields:
             # total_vlan_ids is a denormalized cache of vid_ranges; persist them together.
-            kwargs['update_fields'] = list(set(update_fields) | {'total_vlan_ids'})
+            kwargs['update_fields'] = update_fields | {'total_vlan_ids'}
 
         super().save(*args, **kwargs)
 

+ 2 - 2
netbox/ipam/tests/query_counts.json

@@ -1,7 +1,7 @@
 {
   "aggregate:api_list_objects": 13,
   "aggregate:list_objects_with_permission": 21,
-  "asn:api_list_objects": 17,
+  "asn:api_list_objects": 16,
   "asn:list_objects_with_permission": 28,
   "asnrange:api_list_objects": 14,
   "asnrange:list_objects_with_permission": 19,
@@ -32,6 +32,6 @@
   "vlantranslationpolicy:list_objects_with_permission": 17,
   "vlantranslationrule:api_list_objects": 12,
   "vlantranslationrule:list_objects_with_permission": 18,
-  "vrf:api_list_objects": 14,
+  "vrf:api_list_objects": 20,
   "vrf:list_objects_with_permission": 17
 }

+ 14 - 0
netbox/ipam/tests/test_api.py

@@ -224,6 +224,15 @@ class VRFTestCase(APIViewTestCases.APIViewTestCase):
     @classmethod
     def setUpTestData(cls):
 
+        tenant = Tenant.objects.create(name='Tenant 1', slug='tenant-1')
+
+        route_targets = (
+            RouteTarget(name='65000:1001', tenant=tenant),
+            RouteTarget(name='65000:1002', tenant=tenant),
+            RouteTarget(name='65000:1003', tenant=tenant),
+        )
+        RouteTarget.objects.bulk_create(route_targets)
+
         vrfs = (
             VRF(name='VRF 1', rd='65000:1'),
             VRF(name='VRF 2', rd='65000:2'),
@@ -231,6 +240,11 @@ class VRFTestCase(APIViewTestCases.APIViewTestCase):
         )
         VRF.objects.bulk_create(vrfs)
 
+        # Assigned so the query count baseline covers the non-nested route target expansion.
+        for vrf in vrfs:
+            vrf.import_targets.set(route_targets)
+            vrf.export_targets.set(route_targets)
+
 
 class RouteTargetTestCase(APIViewTestCases.APIViewTestCase):
     model = RouteTarget

+ 92 - 8
netbox/ipam/tests/test_filtersets.py

@@ -5,7 +5,19 @@ from netaddr import IPNetwork
 
 from circuits.models import Provider
 from dcim.choices import InterfaceModeChoices, InterfaceTypeChoices
-from dcim.models import Device, DeviceRole, DeviceType, Interface, Location, Manufacturer, Rack, Region, Site, SiteGroup
+from dcim.models import (
+    Device,
+    DeviceRole,
+    DeviceType,
+    Interface,
+    Location,
+    Manufacturer,
+    Rack,
+    RackGroup,
+    Region,
+    Site,
+    SiteGroup,
+)
 from ipam.choices import *
 from ipam.filtersets import *
 from ipam.models import *
@@ -1764,32 +1776,104 @@ class VLANGroupTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
     def test_region(self):
-        params = {'region': Region.objects.first().pk}
+        regions = (
+            Region.objects.get(slug='region-1'),
+            Region.objects.create(name='Region 2', slug='region-2'),
+        )
+        VLANGroup.objects.create(name='VLAN Group 9', slug='vlan-group-9', scope=regions[1])
+
+        params = {'region': [regions[0].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'region': [regions[0].pk, regions[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
     def test_site_group(self):
-        params = {'site_group': SiteGroup.objects.first().pk}
+        site_groups = (
+            SiteGroup.objects.get(slug='site-group-1'),
+            SiteGroup.objects.create(name='Site Group 2', slug='site-group-2'),
+        )
+        VLANGroup.objects.create(name='VLAN Group 9', slug='vlan-group-9', scope=site_groups[1])
+
+        params = {'site_group': [site_groups[0].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'site_group': [site_groups[0].pk, site_groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
     def test_site(self):
-        params = {'site': Site.objects.first().pk}
+        sites = (
+            Site.objects.get(slug='site-1'),
+            Site.objects.create(name='Site 2', slug='site-2'),
+        )
+        VLANGroup.objects.create(name='VLAN Group 9', slug='vlan-group-9', scope=sites[1])
+
+        params = {'site': [sites[0].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'site': [sites[0].pk, sites[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
     def test_location(self):
-        params = {'location': Location.objects.first().pk}
+        site = Site.objects.get(slug='site-1')
+        locations = (
+            Location.objects.get(slug='location-1'),
+            Location.objects.create(name='Location 2', slug='location-2', site=site),
+        )
+        VLANGroup.objects.create(name='VLAN Group 9', slug='vlan-group-9', scope=locations[1])
+
+        params = {'location': [locations[0].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'location': [locations[0].pk, locations[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_rack_group(self):
+        rack_groups = (
+            RackGroup.objects.create(name='Rack Group 1', slug='rack-group-1'),
+            RackGroup.objects.create(name='Rack Group 2', slug='rack-group-2'),
+        )
+        VLANGroup.objects.create(name='VLAN Group 9', slug='vlan-group-9', scope=rack_groups[0])
+        VLANGroup.objects.create(name='VLAN Group 10', slug='vlan-group-10', scope=rack_groups[1])
+
+        params = {'rack_group': [rack_groups[0].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'rack_group': [rack_groups[0].pk, rack_groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
     def test_rack(self):
-        params = {'rack': Rack.objects.first().pk}
+        site = Site.objects.get(slug='site-1')
+        racks = (
+            Rack.objects.get(name='Rack 1'),
+            Rack.objects.create(name='Rack 2', site=site),
+        )
+        VLANGroup.objects.create(name='VLAN Group 9', slug='vlan-group-9', scope=racks[1])
+
+        params = {'rack': [racks[0].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'rack': [racks[0].pk, racks[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
     def test_cluster_group(self):
-        params = {'cluster_group': ClusterGroup.objects.first().pk}
+        cluster_groups = (
+            ClusterGroup.objects.get(slug='cluster-group-1'),
+            ClusterGroup.objects.create(name='Cluster Group 2', slug='cluster-group-2'),
+        )
+        VLANGroup.objects.create(name='VLAN Group 9', slug='vlan-group-9', scope=cluster_groups[1])
+
+        params = {'cluster_group': [cluster_groups[0].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'cluster_group': [cluster_groups[0].pk, cluster_groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
     def test_cluster(self):
-        params = {'cluster': Cluster.objects.first().pk}
+        cluster_type = ClusterType.objects.get(slug='cluster-type-1')
+        clusters = (
+            Cluster.objects.get(name='Cluster 1'),
+            Cluster.objects.create(name='Cluster 2', type=cluster_type),
+        )
+        VLANGroup.objects.create(name='VLAN Group 9', slug='vlan-group-9', scope=clusters[1])
+
+        params = {'cluster': [clusters[0].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'cluster': [clusters[0].pk, clusters[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
     def test_tenant(self):
         tenants = Tenant.objects.all()[:2]

+ 97 - 2
netbox/ipam/tests/test_forms.py

@@ -5,13 +5,15 @@ from django.test import TestCase
 
 from dcim.constants import InterfaceTypeChoices
 from dcim.models import Device, DeviceRole, DeviceType, Interface, Location, Manufacturer, Region, Site, SiteGroup
-from ipam.constants import SERVICE_PORT_MAX
+from ipam.choices import PrefixStatusChoices
+from ipam.constants import SERVICE_PORT_MAX, VLANGROUP_SCOPE_TYPES
 from ipam.filtersets import ServiceFilterSet, ServiceTemplateFilterSet
-from ipam.forms import PrefixForm, VLANIDBulkCreateForm
+from ipam.forms import PrefixForm, VLANGroupBulkEditForm, VLANGroupForm, VLANIDBulkCreateForm
 from ipam.forms.bulk_import import IPAddressImportForm, ServiceTemplateImportForm
 from ipam.forms.fields import PortMappingField
 from ipam.forms.filtersets import ServiceFilterForm, ServiceTemplateFilterForm
 from ipam.forms.widgets import PortMappingWidget
+from ipam.models import Prefix, VLANGroup
 
 
 class PrefixFormTestCase(TestCase):
@@ -60,6 +62,26 @@ class PrefixFormTestCase(TestCase):
             })
             assert 'data-dynamic-params' not in form.fields['vlan'].widget.attrs
 
+    def test_scope_type_change_without_scope(self):
+        """Changing the scope type without selecting a scope is reported on the scope field."""
+        prefix = Prefix.objects.create(
+            prefix='10.0.0.0/24',
+            scope_type=ContentType.objects.get_for_model(Site),
+            scope_id=self.site.pk,
+        )
+        form = PrefixForm(
+            data={
+                'prefix': '10.0.0.0/24',
+                'status': PrefixStatusChoices.STATUS_ACTIVE,
+                'scope_content_type': ContentType.objects.get_for_model(Location).pk,
+                'scope_object_id': '',
+            },
+            instance=prefix,
+        )
+
+        self.assertFalse(form.is_valid())
+        self.assertIn('scope', form.errors)
+
 
 class IPAddressImportFormTestCase(TestCase):
     """Tests for IPAddressImportForm bulk import behavior."""
@@ -452,3 +474,76 @@ class ServiceFilterFormTestCase(TestCase):
                 form = form_class(data={'port_mappings': 'tcp/80'})
                 self.assertTrue(form.is_valid(), form.errors)
                 self.assertEqual(form.cleaned_data['port_mappings'], 'tcp/80')
+
+
+class VLANGroupFormTestCase(TestCase):
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.site = Site.objects.create(name='Site 1', slug='site-1')
+        cls.site_type = ContentType.objects.get_for_model(Site)
+        cls.location_type = ContentType.objects.get_for_model(Location)
+        cls.vlan_group = VLANGroup.objects.create(
+            name='VLAN Group 1',
+            slug='vlan-group-1',
+            scope=cls.site,
+        )
+
+    def test_scope_can_be_cleared(self):
+        """Clearing scope type and scope on an existing group nulls the assignment."""
+        form = VLANGroupForm(
+            data=self.get_form_data(scope_content_type='', scope_object_id=''),
+            instance=VLANGroup.objects.get(pk=self.vlan_group.pk),
+        )
+        self.assertTrue(form.is_valid(), form.errors)
+
+        vlan_group = form.save()
+        vlan_group.refresh_from_db()
+        self.assertIsNone(vlan_group.scope_type_id)
+        self.assertIsNone(vlan_group.scope_id)
+
+    def test_scope_required_with_scope_type(self):
+        """A scope type without a scope is reported on the scope field."""
+        forms = {
+            'existing group': VLANGroupForm(
+                data=self.get_form_data(scope_object_id=''),
+                instance=VLANGroup.objects.get(pk=self.vlan_group.pk),
+            ),
+            'new group': VLANGroupForm(
+                data=self.get_form_data(name='VLAN Group 2', slug='vlan-group-2', scope_object_id=''),
+            ),
+            'retyped group': VLANGroupForm(
+                data=self.get_form_data(scope_content_type=self.location_type.pk, scope_object_id=''),
+                instance=VLANGroup.objects.get(pk=self.vlan_group.pk),
+            ),
+        }
+        for case, form in forms.items():
+            with self.subTest(case=case):
+                self.assertFalse(form.is_valid())
+                self.assertIn('scope', form.errors)
+
+    def test_scope_initial_retained_for_new_group(self):
+        """A prepopulated scope survives instantiation of an unsaved group."""
+        form = VLANGroupForm(initial={'scope': self.site})
+
+        self.assertEqual(form.initial['scope'], self.site)
+
+    def test_scope_type_choices(self):
+        """Both VLAN group forms offer every VLAN group scope type."""
+        for form_class in (VLANGroupForm, VLANGroupBulkEditForm):
+            with self.subTest(form=form_class.__name__):
+                form = form_class()
+                models = set(
+                    form.fields['scope'].content_type_queryset.values_list('model', flat=True)
+                )
+                self.assertEqual(models, set(VLANGROUP_SCOPE_TYPES))
+
+    def get_form_data(self, **overrides):
+        return {
+            'name': self.vlan_group.name,
+            'slug': self.vlan_group.slug,
+            'vid_ranges': '1-4094',
+            'scope_content_type': self.site_type.pk,
+            'scope_object_id': self.site.pk,
+            **overrides,
+        }

+ 4 - 1
netbox/ipam/tests/test_views.py

@@ -20,7 +20,7 @@ from ipam.utils import AvailableIPSpace
 from ipam.views import AggregatePrefixesView, PrefixPrefixesView
 from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
 from tenancy.models import Tenant
-from users.models import Group, ObjectPermission
+from users.models import Group, ObjectPermission, Owner
 from utilities.testing import ViewTestCases, create_tags, post_data
 
 
@@ -2817,6 +2817,8 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         )
         IPAddress.objects.bulk_create(ip_addresses)
 
+        owner = Owner.objects.create(name='Owner 1')
+
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
         cls.form_data = {
@@ -2826,6 +2828,7 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'port_mappings': '[{"protocol": "tcp", "ports": "104,105"}, {"protocol": "udp", "ports": "104"}]',
             'ipaddresses': [],
             'description': 'A new service',
+            'owner': owner.pk,
             'tags': [t.pk for t in tags],
         }
 

+ 42 - 21
netbox/netbox/jobs.py

@@ -8,13 +8,14 @@ from io import BytesIO
 from pathlib import Path
 
 from django.contrib.auth import get_user_model
-from django.core.exceptions import ImproperlyConfigured, PermissionDenied
+from django.core.exceptions import ImproperlyConfigured, PermissionDenied, ValidationError
 from django.core.handlers.wsgi import WSGIRequest
 from django.db.models import ProtectedError, RestrictedError
 from django.http import Http404
 from django.utils import timezone
 from django.utils.functional import classproperty
 from django.utils.module_loading import import_string
+from django.utils.translation import gettext_lazy as _
 from django_pg_utils import advisory_lock
 from rest_framework.exceptions import APIException
 from rq.timeouts import JobTimeoutException
@@ -157,26 +158,46 @@ class JobRunner(ABC):
                     **kwargs,
                 )
 
-                if cls in registry['system_jobs']:
-                    # System jobs are also scheduled by `enqueue_once()` at worker startup,
-                    # which races with this finally block and can produce duplicate schedules
-                    # (see #22232). Acquire the same advisory lock used by `enqueue_once()`
-                    # and skip rescheduling if a successor is already enqueued.
-                    #
-                    # This branch is limited to system jobs because generic recurring jobs
-                    # (e.g. scheduled scripts) may have multiple legitimate schedules sharing
-                    # the same runner/object/interval but differing in their runtime kwargs.
-                    with advisory_lock(ADVISORY_LOCK_KEYS['job-schedules']):
-                        successor_exists = Job.objects.filter(
-                            name=cls.name,
-                            object_id__isnull=True,
-                            status__in=JobStatusChoices.ENQUEUED_STATE_CHOICES,
-                            interval=job.interval,
-                        ).exclude(pk=job.pk).exists()
-                        if not successor_exists:
-                            cls.enqueue(**enqueue_kwargs)
-                else:
-                    cls.enqueue(**enqueue_kwargs)
+                # Reschedule the next occurrence. If the object's configuration has become invalid since this run was
+                # scheduled (e.g. a script's Meta.job_timeout was edited to an invalid value, see #22872), the enqueue
+                # will raise a ValidationError. Record it on this job and decline to reschedule rather than allowing an
+                # unhandled exception to escape the worker's finally block.
+                try:
+                    if cls in registry['system_jobs']:
+                        # System jobs are also scheduled by `enqueue_once()` at worker startup,
+                        # which races with this finally block and can produce duplicate schedules
+                        # (see #22232). Acquire the same advisory lock used by `enqueue_once()`
+                        # and skip rescheduling if a successor is already enqueued.
+                        #
+                        # This branch is limited to system jobs because generic recurring jobs
+                        # (e.g. scheduled scripts) may have multiple legitimate schedules sharing
+                        # the same runner/object/interval but differing in their runtime kwargs.
+                        with advisory_lock(ADVISORY_LOCK_KEYS['job-schedules']):
+                            successor_exists = Job.objects.filter(
+                                name=cls.name,
+                                object_id__isnull=True,
+                                status__in=JobStatusChoices.ENQUEUED_STATE_CHOICES,
+                                interval=job.interval,
+                            ).exclude(pk=job.pk).exists()
+                            if not successor_exists:
+                                cls.enqueue(**enqueue_kwargs)
+                    else:
+                        cls.enqueue(**enqueue_kwargs)
+                except ValidationError as e:
+                    # The successor could not be scheduled because the object's configuration is now invalid. Record
+                    # this against the (already-terminated) job without overwriting the outcome of the run that just
+                    # completed — re-running terminate() here would clobber a successful run's status and fire a
+                    # duplicate notification (see #22872).
+                    error = _("Recurring job not rescheduled due to invalid configuration: {error}").format(
+                        error='; '.join(e.messages)
+                    )
+                    logger.error(f"Job {job}: {error}")
+                    job.log(logging.makeLogRecord({
+                        'levelno': logging.ERROR,
+                        'levelname': 'ERROR',
+                        'msg': error,
+                    }))
+                    job.save()
 
     @classmethod
     def get_jobs(cls, instance=None):

+ 1 - 0
netbox/netbox/settings.py

@@ -425,6 +425,7 @@ CACHES = {
         'LOCATION': CACHING_REDIS_URL,
         'OPTIONS': {
             'CLIENT_CLASS': 'django_redis.client.DefaultClient',
+            'USERNAME': CACHING_REDIS_USERNAME,
             'PASSWORD': CACHING_REDIS_PASSWORD,
         }
     }

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js.map


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

@@ -30,9 +30,9 @@
     "flatpickr": "4.6.13",
     "gridstack": "12.6.0",
     "htmx.org": "2.0.10",
-    "markdown-it": "^14.3.0",
-    "picomatch": "4.0.5",
-    "query-string": "9.5.0",
+    "markdown-it": "^15.0.1",
+    "picomatch": "4.0.7",
+    "query-string": "9.5.1",
     "sass": "1.103.1",
     "tom-select": "2.6.2",
     "typeface-inter": "3.18.1",

+ 24 - 10
netbox/project-static/src/sidenav.ts

@@ -75,22 +75,34 @@ class SideNav {
       toggler.addEventListener('click', event => this.onMobileToggle(event));
     }
 
-    if (window.matchMedia(SIDENAV_DESKTOP_MEDIA).matches) {
-      if (this.state.get('pinned')) {
-        this.pin();
-      } else {
-        this.unpin();
-      }
-    } else {
-      this.bodyRemove('hide');
-      this.bodyAdd('hidden');
-    }
+    const desktopMedia = window.matchMedia(SIDENAV_DESKTOP_MEDIA);
+    this.setResponsiveState(desktopMedia.matches);
+    desktopMedia.addEventListener('change', event => {
+      this.setResponsiveState(event.matches);
+      this.initLinks();
+    });
+
     window.addEventListener('resize', () => this.onResize());
 
     this.base.addEventListener('mouseenter', () => this.onEnter());
     this.base.addEventListener('mouseleave', () => this.onLeave());
   }
 
+  /**
+   * Apply the appropriate sidenav state for the current responsive layout.
+   */
+  private setResponsiveState(isDesktop: boolean): void {
+    this.bodyRemove('hide');
+
+    if (isDesktop && this.state.get('pinned')) {
+      this.bodyRemove('hidden');
+      this.bodyAdd('show', 'pinned');
+    } else {
+      this.bodyRemove('show', 'pinned');
+      this.bodyAdd('hidden');
+    }
+  }
+
   /**
    * If the sidenav is shown, expand active nav links. Otherwise, collapse them.
    */
@@ -164,12 +176,14 @@ class SideNav {
         switch (action) {
           case 'expand':
             groupLink.setAttribute('aria-expanded', 'true');
+            groupLink.classList.add('show');
             groupItem.classList.add('active');
             dropdownMenu.classList.add('show');
             link.classList.add('active');
             break;
           case 'collapse':
             groupLink.setAttribute('aria-expanded', 'false');
+            groupLink.classList.remove('show');
             groupItem.classList.remove('active');
             dropdownMenu.classList.remove('show');
             link.classList.remove('active');

+ 140 - 99
netbox/project-static/yarn.lock

@@ -154,13 +154,20 @@
   resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz#b21affb804cc167c133d95f45b3a1dc1323b9a87"
   integrity sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==
 
-"@eslint-community/eslint-utils@^4.8.0", "@eslint-community/eslint-utils@^4.9.1":
+"@eslint-community/eslint-utils@^4.8.0":
   version "4.9.1"
   resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz#4e90af67bc51ddee6cdef5284edf572ec376b595"
   integrity sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==
   dependencies:
     eslint-visitor-keys "^3.4.3"
 
+"@eslint-community/eslint-utils@^4.9.1":
+  version "4.10.1"
+  resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz#8911bd72b2c3640a543609e0400b8c4d2e7e7cb6"
+  integrity sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==
+  dependencies:
+    eslint-visitor-keys "^3.4.3"
+
 "@eslint-community/regexpp@^4.12.2":
   version "4.12.2"
   resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b"
@@ -212,9 +219,9 @@
     strip-json-comments "^3.1.1"
 
 "@eslint/js@^9.39.2":
-  version "9.39.4"
-  resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.39.4.tgz#a3f83bfc6fd9bf33a853dfacd0b49b398eb596c1"
-  integrity sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==
+  version "9.39.5"
+  resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.39.5.tgz#6f2fbcff75500d229d535e0a949ae13472c84787"
+  integrity sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==
 
 "@eslint/object-schema@^3.0.5":
   version "3.0.5"
@@ -918,99 +925,99 @@
     "@types/estree" "*"
 
 "@typescript-eslint/eslint-plugin@^8.68.0":
-  version "8.68.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.68.0.tgz#a8fbdb1cf49aafaf16071b646daad890151bd149"
-  integrity sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q==
+  version "8.69.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.69.0.tgz#bf74cc392ebcaaf096bc8b4c4d7bbeb0677687b8"
+  integrity sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA==
   dependencies:
     "@eslint-community/regexpp" "^4.12.2"
-    "@typescript-eslint/scope-manager" "8.68.0"
-    "@typescript-eslint/type-utils" "8.68.0"
-    "@typescript-eslint/utils" "8.68.0"
-    "@typescript-eslint/visitor-keys" "8.68.0"
+    "@typescript-eslint/scope-manager" "8.69.0"
+    "@typescript-eslint/type-utils" "8.69.0"
+    "@typescript-eslint/utils" "8.69.0"
+    "@typescript-eslint/visitor-keys" "8.69.0"
     ignore "^7.0.5"
     natural-compare "^1.4.0"
     ts-api-utils "^2.5.0"
 
 "@typescript-eslint/parser@^8.68.0":
-  version "8.68.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.68.0.tgz#61de31481354c50457bc9621a7ed746779f09ee7"
-  integrity sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==
-  dependencies:
-    "@typescript-eslint/scope-manager" "8.68.0"
-    "@typescript-eslint/types" "8.68.0"
-    "@typescript-eslint/typescript-estree" "8.68.0"
-    "@typescript-eslint/visitor-keys" "8.68.0"
+  version "8.69.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.69.0.tgz#de3ead2b35e5c71580eda40820adb4fd14834ca1"
+  integrity sha512-l4b0DhWioGg6Gt2ebGlvfkFMOjRsauxtsnDRwUSRX1qHq3HdTfQHV8wW9zEXeciai6HfeaKOedQn2Zoofx3WBw==
+  dependencies:
+    "@typescript-eslint/scope-manager" "8.69.0"
+    "@typescript-eslint/types" "8.69.0"
+    "@typescript-eslint/typescript-estree" "8.69.0"
+    "@typescript-eslint/visitor-keys" "8.69.0"
     debug "^4.4.3"
 
-"@typescript-eslint/project-service@8.68.0":
-  version "8.68.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.68.0.tgz#ea4b2869f59165c420cd7a4bbebc38039794e8cc"
-  integrity sha512-5GQtWZCXFcFYux955pvoS02WLc49pXNlvIxocKjS0clvwo3in1RdlzVKyiqQH9vE5AKWFLTaUgeQkOrTS+0Qxw==
+"@typescript-eslint/project-service@8.69.0":
+  version "8.69.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.69.0.tgz#cf728554436a50e644a5214a89fe02cb1ffa9af8"
+  integrity sha512-yi4obFrHMmnsesWehHbkg9zMA7Jt8cXT+mKM08G999pH1yT6nqgsHx7MYm0uY1wAj8CqiBXYRJ7WAT0QdQHQXg==
   dependencies:
-    "@typescript-eslint/tsconfig-utils" "^8.68.0"
-    "@typescript-eslint/types" "^8.68.0"
+    "@typescript-eslint/tsconfig-utils" "^8.69.0"
+    "@typescript-eslint/types" "^8.69.0"
     debug "^4.4.3"
 
-"@typescript-eslint/scope-manager@8.68.0":
-  version "8.68.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.68.0.tgz#e5a13a1159497faeab4e48279bf07576045b1499"
-  integrity sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA==
+"@typescript-eslint/scope-manager@8.69.0":
+  version "8.69.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.69.0.tgz#13f3d1e25108e95a9ceb5a198806d1fa558f8c7a"
+  integrity sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ==
   dependencies:
-    "@typescript-eslint/types" "8.68.0"
-    "@typescript-eslint/visitor-keys" "8.68.0"
+    "@typescript-eslint/types" "8.69.0"
+    "@typescript-eslint/visitor-keys" "8.69.0"
 
-"@typescript-eslint/tsconfig-utils@8.68.0", "@typescript-eslint/tsconfig-utils@^8.68.0":
-  version "8.68.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.68.0.tgz#594d7a3c5952055b3c431fc563ca7fd1defcce18"
-  integrity sha512-F7zrGQfiJHojPwi8vhxZQC1tWtJzvL74cK/nqri2lk8YUXvYaYwl263xOJ69jDWPUk1hmcdoayFwk9lX09npVw==
+"@typescript-eslint/tsconfig-utils@8.69.0", "@typescript-eslint/tsconfig-utils@^8.69.0":
+  version "8.69.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.69.0.tgz#d3b0ccc781ab252a90a0b3989b9d1eb85ab59469"
+  integrity sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ==
 
-"@typescript-eslint/type-utils@8.68.0":
-  version "8.68.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.68.0.tgz#8f3e838dbd740909db27053857468cd037b00220"
-  integrity sha512-X77zqoY1EjeWGs/0JNxeaMfp5C5lIz4Tw8y66F1Ne8Faq6g424sBNYM6xBAqElfGZPLpWS+CZAp0DXyKDzWiHg==
+"@typescript-eslint/type-utils@8.69.0":
+  version "8.69.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.69.0.tgz#7ce68d2ebcbedd8421806c27a7f360755017159f"
+  integrity sha512-ZfoJAVg3JZndQEpEl9petVlxau3lRuElc4HRMuAlLCf8to04/iHz692RUSNmXKDjEuJmIL+KZ2/BsOcBc16dsA==
   dependencies:
-    "@typescript-eslint/types" "8.68.0"
-    "@typescript-eslint/typescript-estree" "8.68.0"
-    "@typescript-eslint/utils" "8.68.0"
+    "@typescript-eslint/types" "8.69.0"
+    "@typescript-eslint/typescript-estree" "8.69.0"
+    "@typescript-eslint/utils" "8.69.0"
     debug "^4.4.3"
     ts-api-utils "^2.5.0"
 
-"@typescript-eslint/types@8.68.0", "@typescript-eslint/types@^8.68.0":
-  version "8.68.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.68.0.tgz#3f9d4e62fbe5728f09403cdc7b4d58af842ac1af"
-  integrity sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg==
+"@typescript-eslint/types@8.69.0", "@typescript-eslint/types@^8.69.0":
+  version "8.69.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.69.0.tgz#5d9ad3f707c2e4f70a2db540031104df3e63bcf5"
+  integrity sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA==
 
-"@typescript-eslint/typescript-estree@8.68.0":
-  version "8.68.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.68.0.tgz#bf4165029825138ac27231a3ff02923ecd977f38"
-  integrity sha512-OKKsD0tYmoNiU5PW2zehO1yO56jYOm1ShYlxon/Z0SJNidAkdVg86eg9ruRuoXf8xfnuWZGbwDsStkoXbZtIIA==
+"@typescript-eslint/typescript-estree@8.69.0":
+  version "8.69.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.69.0.tgz#efa915913ffe2049bbfd26092b95d1bc7c9c454f"
+  integrity sha512-AdFkgqck3Vudb/kWnxlyafU/4aBhHrbQ9locP2N4psXTy5mOBg0SHJumnLvx7r6g1gV4DKvUFwV2nJZBoqOD8w==
   dependencies:
-    "@typescript-eslint/project-service" "8.68.0"
-    "@typescript-eslint/tsconfig-utils" "8.68.0"
-    "@typescript-eslint/types" "8.68.0"
-    "@typescript-eslint/visitor-keys" "8.68.0"
+    "@typescript-eslint/project-service" "8.69.0"
+    "@typescript-eslint/tsconfig-utils" "8.69.0"
+    "@typescript-eslint/types" "8.69.0"
+    "@typescript-eslint/visitor-keys" "8.69.0"
     debug "^4.4.3"
     minimatch "^10.2.2"
     semver "^7.7.3"
     tinyglobby "^0.2.15"
     ts-api-utils "^2.5.0"
 
-"@typescript-eslint/utils@8.68.0":
-  version "8.68.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.68.0.tgz#00547f2c8de8aca2a3c21752a9711f73206fd36d"
-  integrity sha512-PB5gJMMOg0Q5P1tsgWtEAqQacJXq0qEqRHDX/YJ4FaTMLfZPpHB3gjl2EJuiZyPABxmj4ZQYiY9m1bdAJ5y7tQ==
+"@typescript-eslint/utils@8.69.0":
+  version "8.69.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.69.0.tgz#67ad9c00edf12fe2fbc0bf0a71b00822a8d02e97"
+  integrity sha512-tUbx60BBqQa31kXF5MCsOOLL5E/WzUuxIn7YpAvq+eaUlqvk8/NXnXMBNAdLCr0icjkzem7iUA5QqWHe/hJ1aw==
   dependencies:
     "@eslint-community/eslint-utils" "^4.9.1"
-    "@typescript-eslint/scope-manager" "8.68.0"
-    "@typescript-eslint/types" "8.68.0"
-    "@typescript-eslint/typescript-estree" "8.68.0"
+    "@typescript-eslint/scope-manager" "8.69.0"
+    "@typescript-eslint/types" "8.69.0"
+    "@typescript-eslint/typescript-estree" "8.69.0"
 
-"@typescript-eslint/visitor-keys@8.68.0":
-  version "8.68.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.68.0.tgz#78db3c9bb258a0309d9e2b1b617127c3a8fb1f54"
-  integrity sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A==
+"@typescript-eslint/visitor-keys@8.69.0":
+  version "8.69.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.69.0.tgz#f659785dbb79733c40499f71a65439e2033966b5"
+  integrity sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==
   dependencies:
-    "@typescript-eslint/types" "8.68.0"
+    "@typescript-eslint/types" "8.69.0"
     eslint-visitor-keys "^5.0.0"
 
 "@unrs/resolver-binding-android-arm-eabi@1.11.1":
@@ -1140,6 +1147,11 @@ argparse@^2.0.1:
   resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
   integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
 
+argparse@^3.0.0:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/argparse/-/argparse-3.0.1.tgz#328ad1798f5ab5558a13fb33d1cb3bb072b5c0f8"
+  integrity sha512-nM4mHF/KM1v59ZNKX7zfusQz5wUAxR511YG8Vo6TyiV4aqhu++rbJW4v04xsWhpSsHFj66flT8P7znVpyO20xQ==
+
 aria-hidden@^1.2.3, aria-hidden@^1.2.4:
   version "1.2.6"
   resolved "https://registry.yarnpkg.com/aria-hidden/-/aria-hidden-1.2.6.tgz#73051c9b088114c795b1ea414e9c0fff874ffc1a"
@@ -1277,10 +1289,10 @@ brace-expansion@^1.1.7:
     balanced-match "^1.0.0"
     concat-map "0.0.1"
 
-brace-expansion@^5.0.5:
-  version "5.0.6"
-  resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.6.tgz#ec68fe0a641a29d8711579caf641d05bae1f2285"
-  integrity sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==
+brace-expansion@^5.0.8:
+  version "5.0.9"
+  resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.9.tgz#7c72438809b5fa5babf54199a1f1c281a6984fcf"
+  integrity sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==
   dependencies:
     balanced-match "^4.0.2"
 
@@ -1537,11 +1549,16 @@ dunder-proto@^1.0.0, dunder-proto@^1.0.1:
     es-errors "^1.3.0"
     gopd "^1.2.0"
 
-entities@^4.4.0, entities@^4.5.0:
+entities@^4.4.0:
   version "4.5.0"
   resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48"
   integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==
 
+entities@^8.0.0:
+  version "8.0.0"
+  resolved "https://registry.yarnpkg.com/entities/-/entities-8.0.0.tgz#c1df5fe3602429747fa233d0dd26f142f0ce4743"
+  integrity sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==
+
 es-abstract@^1.22.1, es-abstract@^1.22.3, es-abstract@^1.23.0, es-abstract@^1.23.2:
   version "1.23.3"
   resolved "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.3.tgz"
@@ -2169,9 +2186,9 @@ globals@^14.0.0:
   integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==
 
 globals@^17.11.0:
-  version "17.11.0"
-  resolved "https://registry.yarnpkg.com/globals/-/globals-17.11.0.tgz#d643485bb30220d7751e511cf4f68c73d3870d87"
-  integrity sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==
+  version "17.12.0"
+  resolved "https://registry.yarnpkg.com/globals/-/globals-17.12.0.tgz#910a368b8f093d3feb3f77a56dcefa05647d698e"
+  integrity sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==
 
 globalthis@^1.0.3, globalthis@^1.0.4:
   version "1.0.4"
@@ -2293,9 +2310,9 @@ ignore@^5.2.0:
   integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==
 
 ignore@^7.0.5:
-  version "7.0.5"
-  resolved "https://registry.yarnpkg.com/ignore/-/ignore-7.0.5.tgz#4cb5f6cd7d4c7ab0365738c7aea888baa6d7efd9"
-  integrity sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==
+  version "7.0.8"
+  resolved "https://registry.yarnpkg.com/ignore/-/ignore-7.0.8.tgz#84d8466899958458ee30b4190c839ee1446cc88d"
+  integrity sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==
 
 immutable@^5.1.5:
   version "5.1.5"
@@ -2697,13 +2714,20 @@ levn@^0.4.1:
     prelude-ls "^1.2.1"
     type-check "~0.4.0"
 
-linkify-it@^5.0.1, linkify-it@^5.0.2:
+linkify-it@^5.0.1:
   version "5.0.2"
   resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-5.0.2.tgz#d3be0a693af3da9df3883f1e346a0e97461a8c19"
   integrity sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==
   dependencies:
     uc.micro "^2.0.0"
 
+linkify-it@^6.0.0:
+  version "6.1.0"
+  resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-6.1.0.tgz#cd750d70fe9295eb7169e632ee67a4ff43e7f62d"
+  integrity sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==
+  dependencies:
+    uc.micro "^3.0.0"
+
 locate-path@^6.0.0:
   version "6.0.0"
   resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286"
@@ -2718,7 +2742,7 @@ loose-envify@^1.1.0:
   dependencies:
     js-tokens "^3.0.0 || ^4.0.0"
 
-markdown-it@^14.1.0, markdown-it@^14.2.0, markdown-it@^14.3.0:
+markdown-it@^14.1.0, markdown-it@^14.2.0:
   version "14.2.0"
   resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-14.2.0.tgz#06d48d9035e77d5b1c85adb315482fc8240289ef"
   integrity sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==
@@ -2730,12 +2754,24 @@ markdown-it@^14.1.0, markdown-it@^14.2.0, markdown-it@^14.3.0:
     punycode.js "^2.3.1"
     uc.micro "^2.1.0"
 
+markdown-it@^15.0.1:
+  version "15.0.1"
+  resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-15.0.1.tgz#33b87eafff0feb08cbef5edfd9bc20b3d920f5d8"
+  integrity sha512-9/7gE95FNPkfUWrjJIoHZza2iLmuJlPD0UNMxPi7bxUrbCR525YZY0r+zyfes0dZI5ZZ/uNIXUJca0pJvtw41g==
+  dependencies:
+    argparse "^3.0.0"
+    entities "^8.0.0"
+    linkify-it "^6.0.0"
+    mdurl "^2.1.0"
+    punycode.js "^2.3.1"
+    uc.micro "^3.0.0"
+
 math-intrinsics@^1.1.0:
   version "1.1.0"
   resolved "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz"
   integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==
 
-mdurl@^2.0.0:
+mdurl@^2.0.0, mdurl@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-2.1.0.tgz#d711d3f7bce7f22c487c91be78545f356fa96573"
   integrity sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==
@@ -2754,11 +2790,11 @@ micromatch@^4.0.5:
     picomatch "^2.3.1"
 
 minimatch@^10.2.2:
-  version "10.2.5"
-  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.5.tgz#bd48687a0be38ed2961399105600f832095861d1"
-  integrity sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==
+  version "10.2.6"
+  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.6.tgz#fd956bbe0b77241e9f15ac5dccb1c638060968ef"
+  integrity sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==
   dependencies:
-    brace-expansion "^5.0.5"
+    brace-expansion "^5.0.8"
 
 minimatch@^10.2.4, minimatch@^10.2.5, minimatch@^3.1.2, minimatch@^3.1.3, minimatch@^3.1.5:
   version "3.1.5"
@@ -2937,15 +2973,15 @@ picomatch@2.3.2, picomatch@^2.3.1:
   resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.2.tgz#5a942915e26b372dc0f0e6753149a16e6b1c5601"
   integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==
 
-picomatch@4.0.4, picomatch@^4.0.3, picomatch@^4.0.4:
+picomatch@4.0.4, picomatch@^4.0.3:
   version "4.0.4"
   resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589"
   integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==
 
-picomatch@4.0.5:
-  version "4.0.5"
-  resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.5.tgz#51ea57a17d86f605f81039595fbc40ed06a55fab"
-  integrity sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==
+picomatch@4.0.7, picomatch@^4.0.4:
+  version "4.0.7"
+  resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.7.tgz#6313360034ccb36b3dc61ecbdff78121f90fe21f"
+  integrity sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==
 
 possible-typed-array-names@^1.0.0:
   version "1.0.0"
@@ -2979,10 +3015,10 @@ punycode@^2.1.0:
   resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
   integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
 
-query-string@9.5.0:
-  version "9.5.0"
-  resolved "https://registry.yarnpkg.com/query-string/-/query-string-9.5.0.tgz#e6f4003dcb321580dd043109b6fbe2a3890afff6"
-  integrity sha512-YlJmwNyi0RGYjlxYcuDncMsxFU7YyutbuI7gTm8ySxIGBlwx5yiBCOD5ig9ZNoHkawk/1Dey0N5mEfcUybMVAA==
+query-string@9.5.1:
+  version "9.5.1"
+  resolved "https://registry.yarnpkg.com/query-string/-/query-string-9.5.1.tgz#aecdc091d3dc7ce293eed83957e220d523a3d0f7"
+  integrity sha512-/zO3RwuRCMTIcEgq6YMv4OrtEE1XzBG7w5N6zc6ydYnkWYOWsnLI/5894hYEzfESfMOT4cHCsRTIdxsSl1KjGg==
   dependencies:
     decode-uri-component "^0.5.0"
     filter-obj "^5.1.0"
@@ -3228,9 +3264,9 @@ semver@^7.7.1:
   integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==
 
 semver@^7.7.3:
-  version "7.8.0"
-  resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.0.tgz#ed0661039fcbcda2ce71f01fa6adbefaa77040df"
-  integrity sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==
+  version "7.8.5"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.5.tgz#39b646037dd50c14fb451e7e4cac58ed8b863f69"
+  integrity sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==
 
 set-function-length@^1.2.1, set-function-length@^1.2.2:
   version "1.2.2"
@@ -3448,9 +3484,9 @@ tinyglobby@^0.2.14:
     picomatch "^4.0.3"
 
 tinyglobby@^0.2.15:
-  version "0.2.16"
-  resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.16.tgz#1c3b7eb953fce42b226bc5a1ee06428281aff3d6"
-  integrity sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==
+  version "0.2.17"
+  resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.17.tgz#562a9a6c9eb2b3b123d39719f9af5bb44fcd7631"
+  integrity sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==
   dependencies:
     fdir "^6.5.0"
     picomatch "^4.0.4"
@@ -3611,6 +3647,11 @@ uc.micro@^2.0.0, uc.micro@^2.1.0:
   resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-2.1.0.tgz#f8d3f7d0ec4c3dea35a7e3c8efa4cb8b45c9e7ee"
   integrity sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==
 
+uc.micro@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-3.0.0.tgz#64d6cb0bfe2a558ce0ec7c8c0dc88a02d898f506"
+  integrity sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==
+
 unbox-primitive@^1.0.2:
   version "1.0.2"
   resolved "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz"

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


Разница между файлами не показана из-за своего большого размера
+ 179 - 175
netbox/translations/cs/LC_MESSAGES/django.po


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


Разница между файлами не показана из-за своего большого размера
+ 179 - 175
netbox/translations/da/LC_MESSAGES/django.po


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


Разница между файлами не показана из-за своего большого размера
+ 179 - 175
netbox/translations/de/LC_MESSAGES/django.po


Разница между файлами не показана из-за своего большого размера
+ 179 - 175
netbox/translations/en/LC_MESSAGES/django.po


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


Разница между файлами не показана из-за своего большого размера
+ 179 - 175
netbox/translations/es/LC_MESSAGES/django.po


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


Разница между файлами не показана из-за своего большого размера
+ 179 - 175
netbox/translations/fr/LC_MESSAGES/django.po


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


Разница между файлами не показана из-за своего большого размера
+ 179 - 175
netbox/translations/it/LC_MESSAGES/django.po


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


Разница между файлами не показана из-за своего большого размера
+ 179 - 175
netbox/translations/ja/LC_MESSAGES/django.po


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


Разница между файлами не показана из-за своего большого размера
+ 179 - 175
netbox/translations/ko/LC_MESSAGES/django.po


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


Разница между файлами не показана из-за своего большого размера
+ 179 - 175
netbox/translations/lv/LC_MESSAGES/django.po


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


Разница между файлами не показана из-за своего большого размера
+ 179 - 175
netbox/translations/nl/LC_MESSAGES/django.po


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


Разница между файлами не показана из-за своего большого размера
+ 179 - 175
netbox/translations/pl/LC_MESSAGES/django.po


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


Разница между файлами не показана из-за своего большого размера
+ 179 - 175
netbox/translations/pt/LC_MESSAGES/django.po


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


Разница между файлами не показана из-за своего большого размера
+ 179 - 175
netbox/translations/ru/LC_MESSAGES/django.po


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


Разница между файлами не показана из-за своего большого размера
+ 179 - 175
netbox/translations/tr/LC_MESSAGES/django.po


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


Разница между файлами не показана из-за своего большого размера
+ 179 - 175
netbox/translations/uk/LC_MESSAGES/django.po


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


Разница между файлами не показана из-за своего большого размера
+ 179 - 175
netbox/translations/zh/LC_MESSAGES/django.po


+ 2 - 0
netbox/users/api/serializers_/nested.py

@@ -15,6 +15,7 @@ class NestedGroupSerializer(WritableNestedSerializer):
     class Meta:
         model = models.Group
         fields = ['id', 'url', 'display_url', 'display', 'name']
+        ref_name = 'NestedGroup'
 
 
 class NestedUserSerializer(WritableNestedSerializer):
@@ -22,6 +23,7 @@ class NestedUserSerializer(WritableNestedSerializer):
     class Meta:
         model = models.User
         fields = ['id', 'url', 'display_url', 'display', 'username']
+        ref_name = 'NestedUser'
 
     @extend_schema_field(OpenApiTypes.STR)
     def get_display(self, obj):

+ 1 - 1
netbox/users/tests/query_counts.json

@@ -1,7 +1,7 @@
 {
   "group:api_list_objects": 10,
   "group:list_objects_with_permission": 16,
-  "objectpermission:api_list_objects": 14,
+  "objectpermission:api_list_objects": 12,
   "objectpermission:list_objects_with_permission": 17,
   "owner:api_list_objects": 11,
   "owner:list_objects_with_permission": 18,

+ 25 - 8
netbox/utilities/api.py

@@ -8,17 +8,18 @@ from django.core.exceptions import (
     ObjectDoesNotExist,
     ValidationError,
 )
-from django.db.models.fields.related import ManyToOneRel, RelatedField
+from django.db.models.fields.related import ManyToManyRel, ManyToOneRel, RelatedField
 from django.urls import reverse
 from django.utils.module_loading import import_string
 from django.utils.translation import gettext_lazy as _
 from rest_framework.permissions import BasePermission
+from rest_framework.relations import ManyRelatedField
 from rest_framework.serializers import ListSerializer, Serializer
 from rest_framework.views import get_view_name as drf_get_view_name
 
 from extras.constants import HTTP_CONTENT_TYPE_JSON
 from netbox.api.exceptions import GraphQLTypeNotFound, SerializerNotFound
-from netbox.api.fields import RelatedObjectCountField
+from netbox.api.fields import RelatedObjectCountField, SerializedPKRelatedField
 from netbox.registry import registry
 
 from .query import count_related, dict_to_filter_params
@@ -158,6 +159,13 @@ def _get_nested_serializer(serializer_field):
     if isinstance(serializer_field, ListSerializer):
         serializer_field = serializer_field.child
 
+    # DRF wraps a many-valued related field, keeping the original field on child_relation
+    if isinstance(serializer_field, ManyRelatedField):
+        serializer_field = serializer_field.child_relation
+
+    if isinstance(serializer_field, SerializedPKRelatedField):
+        return serializer_field.serializer(nested=serializer_field.nested)
+
     if isinstance(serializer_field, Serializer) and hasattr(serializer_field, 'nested'):
         return serializer_field
 
@@ -175,7 +183,7 @@ def _get_serializer_fields(serializer: Serializer):
     return [field_name for field_name in fields if field_name not in omit]
 
 
-def get_prefetches_for_serializer(serializer_class, fields=None, omit=None):
+def get_prefetches_for_serializer(serializer_class, fields=None, omit=None, _serializer_states=None):
     """
     Compile and return a list of fields which should be prefetched on the queryset for a serializer.
     """
@@ -187,11 +195,18 @@ def get_prefetches_for_serializer(serializer_class, fields=None, omit=None):
     # If fields are not specified, default to all
     fields_to_include = fields or serializer_class.Meta.fields
     fields_to_omit = omit or []
+    effective_fields = tuple(name for name in fields_to_include if name not in fields_to_omit)
+
+    # Break reference cycles on the current path. The field set is in the key because re-entry at a
+    # narrower depth is finite, and the states are copied per frame to keep sibling fields independent.
+    serializer_states = set(_serializer_states or ())
+    serializer_state = (serializer_class, effective_fields)
+    if serializer_state in serializer_states:
+        return []
+    serializer_states.add(serializer_state)
 
     prefetch_fields = []
-    for field_name in fields_to_include:
-        if field_name in fields_to_omit:
-            continue
+    for field_name in effective_fields:
         serializer_field = serializer_class._declared_fields.get(field_name)
 
         # Determine the name of the model field referenced by the serializer field
@@ -202,7 +217,7 @@ def get_prefetches_for_serializer(serializer_class, fields=None, omit=None):
         # If the serializer field does not map to a discrete model field, skip it.
         try:
             field = model._meta.get_field(model_field_name)
-            if isinstance(field, (RelatedField, ManyToOneRel, GenericForeignKey)):
+            if isinstance(field, (RelatedField, ManyToOneRel, ManyToManyRel, GenericForeignKey)):
                 prefetch_fields.append(field.name)
         except FieldDoesNotExist:
             continue
@@ -212,7 +227,9 @@ def get_prefetches_for_serializer(serializer_class, fields=None, omit=None):
         # constraints set on that serializer field instance.
         if nested_serializer := _get_nested_serializer(serializer_field):
             subfields = _get_serializer_fields(nested_serializer)
-            for subfield in get_prefetches_for_serializer(type(nested_serializer), fields=subfields):
+            for subfield in get_prefetches_for_serializer(
+                type(nested_serializer), fields=subfields, _serializer_states=serializer_states
+            ):
                 prefetch_fields.append(f'{field.name}__{subfield}')
 
     return prefetch_fields

+ 25 - 1
netbox/utilities/templatetags/builtins/tags.py

@@ -22,6 +22,14 @@ __all__ = (
 
 register = template.Library()
 
+# Query parameters which indicate that a URL has been cryptographically signed by the storage
+# backend. Parameters must not be appended to such URLs, as doing so invalidates the signature.
+SIGNED_URL_PARAMS = (
+    'signature',         # AWS signature v2; Google Cloud Storage v2
+    'x-amz-signature',   # AWS signature v4 (also MinIO, Ceph, Garage, Cloudflare R2, et al.)
+    'x-goog-signature',  # Google Cloud Storage v4
+)
+
 
 @register.inclusion_tag('builtins/tag.html')
 def tag(value, viewname=None):
@@ -168,6 +176,11 @@ def static_with_params(path, **params):
     parameter conflicts. A warning will be logged if any of the provided parameters
     conflict with existing parameters in the URL.
 
+    URLs which have been cryptographically signed by the storage backend (e.g. S3 presigned
+    URLs) are returned unmodified, as appending parameters to them would invalidate their
+    signature. Such URLs embed an expiration and are regenerated on each request, so they
+    require no cache-busting parameters.
+
     Args:
         path: The static file path (e.g., 'setmode.js')
         **params: Query parameters to append (e.g., v='4.3.1')
@@ -179,6 +192,8 @@ def static_with_params(path, **params):
         If any provided parameters conflict with existing URL parameters, a warning
         will be logged and the new parameter value will override the existing one.
     """
+    logger = logging.getLogger('netbox.utilities.templatetags.tags')
+
     # Get the base static URL
     static_url = static(path)
 
@@ -186,8 +201,17 @@ def static_with_params(path, **params):
     parsed = urlparse(static_url)
     existing_params = parse_qs(parsed.query)
 
+    # If the storage backend has signed the URL, return it as-is. Signature schemes such as AWS
+    # signature v4 cover the entire query string, so appending a parameter here would invalidate
+    # the signature and the request would be rejected by the storage backend.
+    if signature_params := [p for p in existing_params if p.lower() in SIGNED_URL_PARAMS]:
+        logger.debug(
+            "Static URL '%s' is signed (%s); omitting parameters %s",
+            static_url, ', '.join(signature_params), tuple(params)
+        )
+        return static_url
+
     # Check for duplicate parameters and log warnings
-    logger = logging.getLogger('netbox.utilities.templatetags.tags')
     for key, value in params.items():
         if key in existing_params:
             logger.warning(

+ 9 - 1
netbox/utilities/testing/mixins.py

@@ -1,3 +1,4 @@
+from django.test.testcases import SerializeMixin
 from django_rq import get_queue
 from django_rq.workers import get_worker
 from rq import SimpleWorker
@@ -7,10 +8,17 @@ __all__ = (
 )
 
 
-class RQQueueTestMixin:
+class RQQueueTestMixin(SerializeMixin):
     """
     Clear RQ queues before and after each test.
+
+    Test classes using this mixin share a single RQ (Redis) instance. Under the parallel
+    test runner that Redis is not isolated per worker (unlike the database), so concurrent
+    classes that enqueue and assert exact queue counts race each other. SerializeMixin
+    holds an exclusive lock on `lockfile`, so no two classes using this mixin run at the
+    same time, which removes that cross-worker contention.
     """
+    lockfile = __file__
     rq_queue_names = ('default', 'high', 'low')
 
     @classmethod

+ 209 - 1
netbox/utilities/tests/test_api.py

@@ -11,11 +11,12 @@ from extras.choices import CustomFieldTypeChoices
 from extras.models import CustomField
 from ipam.api.serializers import VLANSerializer
 from ipam.models import VLAN
+from netbox.api.fields import SerializedPKRelatedField
 from netbox.api.serializers import BaseModelSerializer
 from netbox.config import get_config
 from netbox.plugins import register_serializer_resolver
 from netbox.registry import registry
-from users.models import ObjectPermission
+from users.models import Group, ObjectPermission
 from utilities.api import (
     get_prefetches_for_serializer,
     get_serializer_for_model,
@@ -581,6 +582,213 @@ class GetPrefetchesForSerializerTestCase(TestCase):
             ['region', 'region__parent'],
         )
 
+    def test_serialized_pk_related_field(self):
+        class RegionSerializer(BaseModelSerializer):
+            class Meta:
+                model = Region
+                fields = ('id', 'name', 'parent', 'sites')
+                brief_fields = ('id', 'parent')
+
+        class SiteSerializer(BaseModelSerializer):
+            region = SerializedPKRelatedField(
+                queryset=Region.objects.all(),
+                serializer=RegionSerializer,
+                nested=True,
+            )
+
+            class Meta:
+                model = Site
+                fields = ('id', 'region')
+
+        self.assertListEqual(
+            get_prefetches_for_serializer(SiteSerializer),
+            ['region', 'region__parent'],
+        )
+
+    def test_many_serialized_pk_related_field(self):
+        class SiteSerializer(BaseModelSerializer):
+            class Meta:
+                model = Site
+                fields = ('id', 'name', 'region', 'group')
+                brief_fields = ('id', 'region')
+
+        class RegionSerializer(BaseModelSerializer):
+            sites = SerializedPKRelatedField(
+                queryset=Site.objects.all(),
+                serializer=SiteSerializer,
+                nested=True,
+                many=True,
+            )
+
+            class Meta:
+                model = Region
+                fields = ('id', 'sites')
+
+        self.assertListEqual(
+            get_prefetches_for_serializer(RegionSerializer),
+            ['sites', 'sites__region'],
+        )
+
+        self.assertListEqual(
+            get_prefetches_for_serializer(RegionSerializer, fields=('id',)),
+            [],
+        )
+
+        self.assertListEqual(
+            get_prefetches_for_serializer(RegionSerializer, omit=('sites',)),
+            [],
+        )
+
+    def test_many_serialized_pk_related_field_not_nested(self):
+        class SiteSerializer(BaseModelSerializer):
+            class Meta:
+                model = Site
+                fields = ('id', 'name', 'region', 'group')
+                brief_fields = ('id', 'region')
+
+        class RegionSerializer(BaseModelSerializer):
+            sites = SerializedPKRelatedField(
+                queryset=Site.objects.all(),
+                serializer=SiteSerializer,
+                nested=False,
+                many=True,
+            )
+
+            class Meta:
+                model = Region
+                fields = ('id', 'sites')
+
+        self.assertListEqual(
+            get_prefetches_for_serializer(RegionSerializer),
+            ['sites', 'sites__region', 'sites__group'],
+        )
+
+    def test_self_referential_serialized_pk_related_field(self):
+        class RegionSerializer(BaseModelSerializer):
+            class Meta:
+                model = Region
+                fields = ('id', 'parent', 'children')
+
+        # The field can only name its own serializer once the class exists.
+        RegionSerializer._declared_fields['children'] = SerializedPKRelatedField(
+            queryset=Region.objects.all(),
+            serializer=RegionSerializer,
+            many=True,
+        )
+
+        self.assertListEqual(
+            get_prefetches_for_serializer(RegionSerializer),
+            ['parent', 'children'],
+        )
+
+    def test_self_referential_serialized_pk_related_field_with_brief_fields(self):
+        class RegionSerializer(BaseModelSerializer):
+            class Meta:
+                model = Region
+                fields = ('id', 'sites', 'children')
+                brief_fields = ('id', 'sites')
+
+        RegionSerializer._declared_fields['children'] = SerializedPKRelatedField(
+            queryset=Region.objects.all(),
+            serializer=RegionSerializer,
+            nested=True,
+            many=True,
+        )
+
+        # Re-entering the serializer at brief depth is not a cycle, so brief_fields must expand.
+        self.assertListEqual(
+            get_prefetches_for_serializer(RegionSerializer),
+            ['sites', 'children', 'children__sites'],
+        )
+
+    def test_mutually_referential_serialized_pk_related_fields(self):
+        class RegionSerializer(BaseModelSerializer):
+            class Meta:
+                model = Region
+                fields = ('id', 'sites')
+
+        class SiteSerializer(BaseModelSerializer):
+            region = SerializedPKRelatedField(
+                queryset=Region.objects.all(),
+                serializer=RegionSerializer,
+            )
+
+            class Meta:
+                model = Site
+                fields = ('id', 'region')
+
+        RegionSerializer._declared_fields['sites'] = SerializedPKRelatedField(
+            queryset=Site.objects.all(),
+            serializer=SiteSerializer,
+            many=True,
+        )
+
+        self.assertListEqual(
+            get_prefetches_for_serializer(RegionSerializer),
+            ['sites', 'sites__region'],
+        )
+
+    def test_serializer_class_reused_on_sibling_fields(self):
+        class TargetRegionSerializer(BaseModelSerializer):
+            class Meta:
+                model = Region
+                fields = ('id', 'sites')
+
+        class RegionSerializer(BaseModelSerializer):
+            parent = SerializedPKRelatedField(
+                queryset=Region.objects.all(),
+                serializer=TargetRegionSerializer,
+            )
+            children = SerializedPKRelatedField(
+                queryset=Region.objects.all(),
+                serializer=TargetRegionSerializer,
+                many=True,
+            )
+
+            class Meta:
+                model = Region
+                fields = ('id', 'parent', 'children')
+
+        self.assertListEqual(
+            get_prefetches_for_serializer(RegionSerializer),
+            ['parent', 'parent__sites', 'children', 'children__sites'],
+        )
+
+    def test_reverse_many_to_many_relation_is_prefetched(self):
+        class ObjectPermissionSerializer(BaseModelSerializer):
+            class Meta:
+                model = ObjectPermission
+                fields = ('groups',)
+
+        self.assertListEqual(
+            get_prefetches_for_serializer(ObjectPermissionSerializer),
+            ['groups'],
+        )
+
+    def test_reverse_many_to_many_serialized_related_field_is_prefetched(self):
+        class GroupSerializer(BaseModelSerializer):
+            class Meta:
+                model = Group
+                fields = ('id', 'name')
+
+        class ObjectPermissionSerializer(BaseModelSerializer):
+            groups = SerializedPKRelatedField(
+                queryset=Group.objects.all(),
+                serializer=GroupSerializer,
+                nested=True,
+                required=False,
+                many=True
+            )
+
+            class Meta:
+                model = ObjectPermission
+                fields = ('id', 'groups')
+
+        self.assertListEqual(
+            get_prefetches_for_serializer(ObjectPermissionSerializer),
+            ['groups'],
+        )
+
 
 class _ResolvedSerializerA(Serializer):
     pass

+ 68 - 0
netbox/utilities/tests/test_templatetags.py

@@ -132,6 +132,74 @@ class StaticWithParamsTestCase(TestCase):
                 self.assertIn('v=new_version', result)
                 self.assertNotIn('v=old_version', result)
 
+    @override_settings(STATIC_URL='https://s3.example.com/netbox/static/')
+    def test_static_with_params_sigv4_presigned_url(self):
+        """Test that parameters are not appended to an AWS signature v4 presigned URL."""
+        signed_url = (
+            'https://s3.example.com/netbox/static/test.js'
+            '?X-Amz-Algorithm=AWS4-HMAC-SHA256'
+            '&X-Amz-Credential=ABC123%2F20260827%2Fus-east-1%2Fs3%2Faws4_request'
+            '&X-Amz-Date=20260827T141543Z'
+            '&X-Amz-Expires=3600'
+            '&X-Amz-SignedHeaders=host'
+            '&X-Amz-Signature=7a5af16a67d2bc7dc15b77fab733cafdc1344414d884fcf2e0b997b0b78dabca'
+        )
+        with patch('utilities.templatetags.builtins.tags.static') as mock_static:
+            mock_static.return_value = signed_url
+
+            result = static_with_params('test.js', v='1.0.0')
+
+            # The signed URL must be returned verbatim: appending a parameter would be included in
+            # the signature calculation performed by the storage backend, invalidating the signature.
+            self.assertEqual(result, signed_url)
+            self.assertNotIn('v=1.0.0', result)
+
+    @override_settings(STATIC_URL='https://s3.example.com/netbox/static/')
+    def test_static_with_params_sigv2_presigned_url(self):
+        """Test that parameters are not appended to an AWS signature v2 presigned URL."""
+        signed_url = (
+            'https://s3.example.com/netbox/static/test.js'
+            '?AWSAccessKeyId=ABC123&Signature=hR9%2F5pRTOWo%3D&Expires=1748635659'
+        )
+        with patch('utilities.templatetags.builtins.tags.static') as mock_static:
+            mock_static.return_value = signed_url
+
+            result = static_with_params('test.js', v='1.0.0')
+
+            self.assertEqual(result, signed_url)
+            self.assertNotIn('v=1.0.0', result)
+
+    @override_settings(STATIC_URL='https://storage.example.com/netbox/static/')
+    def test_static_with_params_gcs_presigned_url(self):
+        """Test that parameters are not appended to a Google Cloud Storage v4 signed URL."""
+        signed_url = (
+            'https://storage.example.com/netbox/static/test.js'
+            '?X-Goog-Algorithm=GOOG4-RSA-SHA256'
+            '&X-Goog-Credential=netbox%40example.iam.gserviceaccount.com%2F20260827%2Fauto%2Fstorage'
+            '%2Fgoog4_request'
+            '&X-Goog-Date=20260827T141543Z'
+            '&X-Goog-Expires=3600'
+            '&X-Goog-SignedHeaders=host'
+            '&X-Goog-Signature=4bd3a1f0'
+        )
+        with patch('utilities.templatetags.builtins.tags.static') as mock_static:
+            mock_static.return_value = signed_url
+
+            result = static_with_params('test.js', v='1.0.0')
+
+            self.assertEqual(result, signed_url)
+            self.assertNotIn('v=1.0.0', result)
+
+    @override_settings(STATIC_URL='https://s3.example.com/netbox/static/')
+    def test_static_with_params_unsigned_s3_url(self):
+        """Test that parameters are appended to an unsigned (public bucket) S3 URL."""
+        with patch('utilities.templatetags.builtins.tags.static') as mock_static:
+            mock_static.return_value = 'https://s3.example.com/netbox/static/test.js'
+
+            result = static_with_params('test.js', v='1.0.0')
+
+            self.assertEqual(result, 'https://s3.example.com/netbox/static/test.js?v=1.0.0')
+
 
 class BadgeTestCase(TestCase):
     """

+ 5 - 5
requirements.txt

@@ -26,20 +26,20 @@ Markdown==3.10.3
 mkdocs==1.6.1
 mkdocs-material==9.7.7
 mkdocstrings==1.0.6
-mkdocstrings-python==2.0.7
+mkdocstrings-python==2.0.8
 netaddr==1.3.0
 nh3==0.3.7
 Pillow==12.3.0
-psycopg[c,pool]==3.3.4
+psycopg[c,pool]==3.3.5
 PyYAML==6.0.3
 redis==7.4.1
 requests==2.34.2
-rq==2.11.0
+rq==2.12.0
 social-auth-app-django==6.0.1
 social-auth-core==5.1.0
 sorl-thumbnail==13.1.0
-strawberry-graphql==0.324.0
-strawberry-graphql-django==0.87.0
+strawberry-graphql==0.327.0
+strawberry-graphql-django==0.88.0
 svgwrite==1.4.3
 tablib==3.10.0
 tzdata==2026.3

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