2
0
Эх сурвалжийг харах

Merge branch 'main' into feature

Jeremy Stretch 3 сар өмнө
parent
commit
a4365be0a3
76 өөрчлөгдсөн 3825 нэмэгдсэн , 2910 устгасан
  1. 1 1
      .github/ISSUE_TEMPLATE/01-feature_request.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/02-bug_report.yaml
  3. 0 8
      .pre-commit-config.yaml
  4. 88 21
      contrib/openapi.json
  5. 1 0
      docs/configuration/index.md
  6. 1 1
      docs/configuration/security.md
  7. 28 0
      docs/release-notes/version-4.4.md
  8. 0 2
      netbox/circuits/filtersets.py
  9. 24 2
      netbox/core/api/serializers_/jobs.py
  10. 5 1
      netbox/core/filtersets.py
  11. 2 2
      netbox/core/forms/filtersets.py
  12. 0 4
      netbox/core/models/files.py
  13. 1 1
      netbox/core/object_actions.py
  14. 2 7
      netbox/core/signals.py
  15. 67 4
      netbox/core/tests/test_changelog.py
  16. 18 0
      netbox/dcim/choices.py
  17. 48 5
      netbox/dcim/forms/bulk_import.py
  18. 1 0
      netbox/dcim/forms/object_create.py
  19. 7 1
      netbox/dcim/models/devices.py
  20. 1 1
      netbox/dcim/object_actions.py
  21. 1 13
      netbox/dcim/signals.py
  22. 89 0
      netbox/dcim/tests/test_models.py
  23. 138 4
      netbox/dcim/tests/test_views.py
  24. 11 18
      netbox/dcim/views.py
  25. 4 0
      netbox/extras/forms/bulk_import.py
  26. 1 1
      netbox/extras/forms/model_forms.py
  27. 1 2
      netbox/extras/models/mixins.py
  28. 1 1
      netbox/extras/models/scripts.py
  29. 1 1
      netbox/netbox/config/__init__.py
  30. 30 0
      netbox/netbox/forms/bulk_import.py
  31. 12 10
      netbox/netbox/models/deletion.py
  32. 1 1
      netbox/netbox/object_actions.py
  33. 303 0
      netbox/netbox/tests/test_forms.py
  34. 28 7
      netbox/netbox/views/generic/bulk_views.py
  35. 1 1
      netbox/project-static/package.json
  36. 4 4
      netbox/project-static/yarn.lock
  37. 2 2
      netbox/release.yaml
  38. 1 1
      netbox/templates/core/rq_task.html
  39. BIN
      netbox/translations/cs/LC_MESSAGES/django.mo
  40. 183 183
      netbox/translations/cs/LC_MESSAGES/django.po
  41. BIN
      netbox/translations/da/LC_MESSAGES/django.mo
  42. 183 183
      netbox/translations/da/LC_MESSAGES/django.po
  43. BIN
      netbox/translations/de/LC_MESSAGES/django.mo
  44. 183 183
      netbox/translations/de/LC_MESSAGES/django.po
  45. 185 185
      netbox/translations/en/LC_MESSAGES/django.po
  46. BIN
      netbox/translations/es/LC_MESSAGES/django.mo
  47. 183 183
      netbox/translations/es/LC_MESSAGES/django.po
  48. BIN
      netbox/translations/fr/LC_MESSAGES/django.mo
  49. 183 183
      netbox/translations/fr/LC_MESSAGES/django.po
  50. BIN
      netbox/translations/it/LC_MESSAGES/django.mo
  51. 183 183
      netbox/translations/it/LC_MESSAGES/django.po
  52. BIN
      netbox/translations/ja/LC_MESSAGES/django.mo
  53. 183 183
      netbox/translations/ja/LC_MESSAGES/django.po
  54. BIN
      netbox/translations/nl/LC_MESSAGES/django.mo
  55. 183 183
      netbox/translations/nl/LC_MESSAGES/django.po
  56. BIN
      netbox/translations/pl/LC_MESSAGES/django.mo
  57. 183 183
      netbox/translations/pl/LC_MESSAGES/django.po
  58. BIN
      netbox/translations/pt/LC_MESSAGES/django.mo
  59. 183 183
      netbox/translations/pt/LC_MESSAGES/django.po
  60. BIN
      netbox/translations/ru/LC_MESSAGES/django.mo
  61. 183 183
      netbox/translations/ru/LC_MESSAGES/django.po
  62. BIN
      netbox/translations/tr/LC_MESSAGES/django.mo
  63. 183 183
      netbox/translations/tr/LC_MESSAGES/django.po
  64. BIN
      netbox/translations/uk/LC_MESSAGES/django.mo
  65. 184 184
      netbox/translations/uk/LC_MESSAGES/django.po
  66. BIN
      netbox/translations/zh/LC_MESSAGES/django.mo
  67. 184 183
      netbox/translations/zh/LC_MESSAGES/django.po
  68. 3 0
      netbox/users/forms/model_forms.py
  69. 20 10
      netbox/utilities/counters.py
  70. 21 1
      netbox/utilities/forms/fields/csv.py
  71. 33 0
      netbox/utilities/tests/test_forms.py
  72. 52 10
      netbox/virtualization/forms/bulk_import.py
  73. 1 1
      netbox/virtualization/object_actions.py
  74. 13 4
      netbox/virtualization/tests/test_views.py
  75. 1 1
      pyproject.toml
  76. 7 7
      requirements.txt

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

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

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

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

+ 0 - 8
.pre-commit-config.yaml

@@ -21,14 +21,6 @@ repos:
       language: system
       pass_filenames: false
       types: [python]
-    - id: openapi-check
-      name: "Validate OpenAPI schema"
-      description: "Check for any unexpected changes to the OpenAPI schema"
-      files: api/.*\.py$
-      entry: scripts/verify-openapi.sh
-      language: system
-      pass_filenames: false
-      types: [python]
     - id: mkdocs-build
       name: "Build documentation"
       description: "Build the documentation with mkdocs"

+ 88 - 21
contrib/openapi.json

@@ -2,7 +2,7 @@
     "openapi": "3.0.3",
     "info": {
         "title": "NetBox REST API",
-        "version": "4.4.5",
+        "version": "4.4.6",
         "license": {
             "name": "Apache v2 License"
         }
@@ -20174,6 +20174,32 @@
                             "type": "string"
                         }
                     },
+                    {
+                        "in": "query",
+                        "name": "object_type_id",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer",
+                                "nullable": true
+                            }
+                        },
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "object_type_id__n",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer",
+                                "nullable": true
+                            }
+                        },
+                        "explode": true,
+                        "style": "form"
+                    },
                     {
                         "name": "offset",
                         "required": false,
@@ -23845,7 +23871,7 @@
                             "type": "array",
                             "items": {
                                 "type": "string",
-                                "x-spec-enum-id": "c731f2793fceac04",
+                                "x-spec-enum-id": "8d6d8ba53d82f066",
                                 "nullable": true
                             }
                         },
@@ -23866,7 +23892,7 @@
                             "type": "array",
                             "items": {
                                 "type": "string",
-                                "x-spec-enum-id": "c731f2793fceac04",
+                                "x-spec-enum-id": "8d6d8ba53d82f066",
                                 "nullable": true
                             }
                         },
@@ -23880,7 +23906,7 @@
                             "type": "array",
                             "items": {
                                 "type": "string",
-                                "x-spec-enum-id": "c731f2793fceac04",
+                                "x-spec-enum-id": "8d6d8ba53d82f066",
                                 "nullable": true
                             }
                         },
@@ -23894,7 +23920,7 @@
                             "type": "array",
                             "items": {
                                 "type": "string",
-                                "x-spec-enum-id": "c731f2793fceac04",
+                                "x-spec-enum-id": "8d6d8ba53d82f066",
                                 "nullable": true
                             }
                         },
@@ -23908,7 +23934,7 @@
                             "type": "array",
                             "items": {
                                 "type": "string",
-                                "x-spec-enum-id": "c731f2793fceac04",
+                                "x-spec-enum-id": "8d6d8ba53d82f066",
                                 "nullable": true
                             }
                         },
@@ -23922,7 +23948,7 @@
                             "type": "array",
                             "items": {
                                 "type": "string",
-                                "x-spec-enum-id": "c731f2793fceac04",
+                                "x-spec-enum-id": "8d6d8ba53d82f066",
                                 "nullable": true
                             }
                         },
@@ -23936,7 +23962,7 @@
                             "type": "array",
                             "items": {
                                 "type": "string",
-                                "x-spec-enum-id": "c731f2793fceac04",
+                                "x-spec-enum-id": "8d6d8ba53d82f066",
                                 "nullable": true
                             }
                         },
@@ -23950,7 +23976,7 @@
                             "type": "array",
                             "items": {
                                 "type": "string",
-                                "x-spec-enum-id": "c731f2793fceac04",
+                                "x-spec-enum-id": "8d6d8ba53d82f066",
                                 "nullable": true
                             }
                         },
@@ -23964,7 +23990,7 @@
                             "type": "array",
                             "items": {
                                 "type": "string",
-                                "x-spec-enum-id": "c731f2793fceac04",
+                                "x-spec-enum-id": "8d6d8ba53d82f066",
                                 "nullable": true
                             }
                         },
@@ -23978,7 +24004,7 @@
                             "type": "array",
                             "items": {
                                 "type": "string",
-                                "x-spec-enum-id": "c731f2793fceac04",
+                                "x-spec-enum-id": "8d6d8ba53d82f066",
                                 "nullable": true
                             }
                         },
@@ -23992,7 +24018,7 @@
                             "type": "array",
                             "items": {
                                 "type": "string",
-                                "x-spec-enum-id": "c731f2793fceac04",
+                                "x-spec-enum-id": "8d6d8ba53d82f066",
                                 "nullable": true
                             }
                         },
@@ -24006,7 +24032,7 @@
                             "type": "array",
                             "items": {
                                 "type": "string",
-                                "x-spec-enum-id": "c731f2793fceac04",
+                                "x-spec-enum-id": "8d6d8ba53d82f066",
                                 "nullable": true
                             }
                         },
@@ -211354,6 +211380,15 @@
                             "dac-active",
                             "dac-passive",
                             "coaxial",
+                            "rg-6",
+                            "rg-8",
+                            "rg-11",
+                            "rg-59",
+                            "rg-62",
+                            "rg-213",
+                            "lmr-100",
+                            "lmr-200",
+                            "lmr-400",
                             "mmf",
                             "mmf-om1",
                             "mmf-om2",
@@ -211370,8 +211405,8 @@
                             null
                         ],
                         "type": "string",
-                        "description": "* `cat3` - CAT3\n* `cat5` - CAT5\n* `cat5e` - CAT5e\n* `cat6` - CAT6\n* `cat6a` - CAT6a\n* `cat7` - CAT7\n* `cat7a` - CAT7a\n* `cat8` - CAT8\n* `mrj21-trunk` - MRJ21 Trunk\n* `dac-active` - Direct Attach Copper (Active)\n* `dac-passive` - Direct Attach Copper (Passive)\n* `coaxial` - Coaxial\n* `mmf` - Multimode Fiber\n* `mmf-om1` - Multimode Fiber (OM1)\n* `mmf-om2` - Multimode Fiber (OM2)\n* `mmf-om3` - Multimode Fiber (OM3)\n* `mmf-om4` - Multimode Fiber (OM4)\n* `mmf-om5` - Multimode Fiber (OM5)\n* `smf` - Single-mode Fiber\n* `smf-os1` - Single-mode Fiber (OS1)\n* `smf-os2` - Single-mode Fiber (OS2)\n* `aoc` - Active Optical Cabling (AOC)\n* `power` - Power\n* `usb` - USB",
-                        "x-spec-enum-id": "c731f2793fceac04",
+                        "description": "* `cat3` - CAT3\n* `cat5` - CAT5\n* `cat5e` - CAT5e\n* `cat6` - CAT6\n* `cat6a` - CAT6a\n* `cat7` - CAT7\n* `cat7a` - CAT7a\n* `cat8` - CAT8\n* `mrj21-trunk` - MRJ21 Trunk\n* `dac-active` - Direct Attach Copper (Active)\n* `dac-passive` - Direct Attach Copper (Passive)\n* `coaxial` - Coaxial\n* `rg-6` - RG-6\n* `rg-8` - RG-8\n* `rg-11` - RG-11\n* `rg-59` - RG-59\n* `rg-62` - RG-62\n* `rg-213` - RG-213\n* `lmr-100` - LMR-100\n* `lmr-200` - LMR-200\n* `lmr-400` - LMR-400\n* `mmf` - Multimode Fiber\n* `mmf-om1` - Multimode Fiber (OM1)\n* `mmf-om2` - Multimode Fiber (OM2)\n* `mmf-om3` - Multimode Fiber (OM3)\n* `mmf-om4` - Multimode Fiber (OM4)\n* `mmf-om5` - Multimode Fiber (OM5)\n* `smf` - Single-mode Fiber\n* `smf-os1` - Single-mode Fiber (OS1)\n* `smf-os2` - Single-mode Fiber (OS2)\n* `aoc` - Active Optical Cabling (AOC)\n* `power` - Power\n* `usb` - USB",
+                        "x-spec-enum-id": "8d6d8ba53d82f066",
                         "nullable": true
                     },
                     "a_terminations": {
@@ -211532,6 +211567,15 @@
                             "dac-active",
                             "dac-passive",
                             "coaxial",
+                            "rg-6",
+                            "rg-8",
+                            "rg-11",
+                            "rg-59",
+                            "rg-62",
+                            "rg-213",
+                            "lmr-100",
+                            "lmr-200",
+                            "lmr-400",
                             "mmf",
                             "mmf-om1",
                             "mmf-om2",
@@ -211548,8 +211592,8 @@
                             null
                         ],
                         "type": "string",
-                        "description": "* `cat3` - CAT3\n* `cat5` - CAT5\n* `cat5e` - CAT5e\n* `cat6` - CAT6\n* `cat6a` - CAT6a\n* `cat7` - CAT7\n* `cat7a` - CAT7a\n* `cat8` - CAT8\n* `mrj21-trunk` - MRJ21 Trunk\n* `dac-active` - Direct Attach Copper (Active)\n* `dac-passive` - Direct Attach Copper (Passive)\n* `coaxial` - Coaxial\n* `mmf` - Multimode Fiber\n* `mmf-om1` - Multimode Fiber (OM1)\n* `mmf-om2` - Multimode Fiber (OM2)\n* `mmf-om3` - Multimode Fiber (OM3)\n* `mmf-om4` - Multimode Fiber (OM4)\n* `mmf-om5` - Multimode Fiber (OM5)\n* `smf` - Single-mode Fiber\n* `smf-os1` - Single-mode Fiber (OS1)\n* `smf-os2` - Single-mode Fiber (OS2)\n* `aoc` - Active Optical Cabling (AOC)\n* `power` - Power\n* `usb` - USB",
-                        "x-spec-enum-id": "c731f2793fceac04",
+                        "description": "* `cat3` - CAT3\n* `cat5` - CAT5\n* `cat5e` - CAT5e\n* `cat6` - CAT6\n* `cat6a` - CAT6a\n* `cat7` - CAT7\n* `cat7a` - CAT7a\n* `cat8` - CAT8\n* `mrj21-trunk` - MRJ21 Trunk\n* `dac-active` - Direct Attach Copper (Active)\n* `dac-passive` - Direct Attach Copper (Passive)\n* `coaxial` - Coaxial\n* `rg-6` - RG-6\n* `rg-8` - RG-8\n* `rg-11` - RG-11\n* `rg-59` - RG-59\n* `rg-62` - RG-62\n* `rg-213` - RG-213\n* `lmr-100` - LMR-100\n* `lmr-200` - LMR-200\n* `lmr-400` - LMR-400\n* `mmf` - Multimode Fiber\n* `mmf-om1` - Multimode Fiber (OM1)\n* `mmf-om2` - Multimode Fiber (OM2)\n* `mmf-om3` - Multimode Fiber (OM3)\n* `mmf-om4` - Multimode Fiber (OM4)\n* `mmf-om5` - Multimode Fiber (OM5)\n* `smf` - Single-mode Fiber\n* `smf-os1` - Single-mode Fiber (OS1)\n* `smf-os2` - Single-mode Fiber (OS2)\n* `aoc` - Active Optical Cabling (AOC)\n* `power` - Power\n* `usb` - USB",
+                        "x-spec-enum-id": "8d6d8ba53d82f066",
                         "nullable": true
                     },
                     "a_terminations": {
@@ -226194,6 +226238,10 @@
                         "format": "int64",
                         "nullable": true
                     },
+                    "object": {
+                        "nullable": true,
+                        "readOnly": true
+                    },
                     "name": {
                         "type": "string",
                         "maxLength": 200
@@ -226287,6 +226335,7 @@
                     "id",
                     "job_id",
                     "name",
+                    "object",
                     "object_type",
                     "status",
                     "url",
@@ -237541,6 +237590,15 @@
                             "dac-active",
                             "dac-passive",
                             "coaxial",
+                            "rg-6",
+                            "rg-8",
+                            "rg-11",
+                            "rg-59",
+                            "rg-62",
+                            "rg-213",
+                            "lmr-100",
+                            "lmr-200",
+                            "lmr-400",
                             "mmf",
                             "mmf-om1",
                             "mmf-om2",
@@ -237557,8 +237615,8 @@
                             null
                         ],
                         "type": "string",
-                        "description": "* `cat3` - CAT3\n* `cat5` - CAT5\n* `cat5e` - CAT5e\n* `cat6` - CAT6\n* `cat6a` - CAT6a\n* `cat7` - CAT7\n* `cat7a` - CAT7a\n* `cat8` - CAT8\n* `mrj21-trunk` - MRJ21 Trunk\n* `dac-active` - Direct Attach Copper (Active)\n* `dac-passive` - Direct Attach Copper (Passive)\n* `coaxial` - Coaxial\n* `mmf` - Multimode Fiber\n* `mmf-om1` - Multimode Fiber (OM1)\n* `mmf-om2` - Multimode Fiber (OM2)\n* `mmf-om3` - Multimode Fiber (OM3)\n* `mmf-om4` - Multimode Fiber (OM4)\n* `mmf-om5` - Multimode Fiber (OM5)\n* `smf` - Single-mode Fiber\n* `smf-os1` - Single-mode Fiber (OS1)\n* `smf-os2` - Single-mode Fiber (OS2)\n* `aoc` - Active Optical Cabling (AOC)\n* `power` - Power\n* `usb` - USB",
-                        "x-spec-enum-id": "c731f2793fceac04",
+                        "description": "* `cat3` - CAT3\n* `cat5` - CAT5\n* `cat5e` - CAT5e\n* `cat6` - CAT6\n* `cat6a` - CAT6a\n* `cat7` - CAT7\n* `cat7a` - CAT7a\n* `cat8` - CAT8\n* `mrj21-trunk` - MRJ21 Trunk\n* `dac-active` - Direct Attach Copper (Active)\n* `dac-passive` - Direct Attach Copper (Passive)\n* `coaxial` - Coaxial\n* `rg-6` - RG-6\n* `rg-8` - RG-8\n* `rg-11` - RG-11\n* `rg-59` - RG-59\n* `rg-62` - RG-62\n* `rg-213` - RG-213\n* `lmr-100` - LMR-100\n* `lmr-200` - LMR-200\n* `lmr-400` - LMR-400\n* `mmf` - Multimode Fiber\n* `mmf-om1` - Multimode Fiber (OM1)\n* `mmf-om2` - Multimode Fiber (OM2)\n* `mmf-om3` - Multimode Fiber (OM3)\n* `mmf-om4` - Multimode Fiber (OM4)\n* `mmf-om5` - Multimode Fiber (OM5)\n* `smf` - Single-mode Fiber\n* `smf-os1` - Single-mode Fiber (OS1)\n* `smf-os2` - Single-mode Fiber (OS2)\n* `aoc` - Active Optical Cabling (AOC)\n* `power` - Power\n* `usb` - USB",
+                        "x-spec-enum-id": "8d6d8ba53d82f066",
                         "nullable": true
                     },
                     "a_terminations": {
@@ -259391,6 +259449,15 @@
                             "dac-active",
                             "dac-passive",
                             "coaxial",
+                            "rg-6",
+                            "rg-8",
+                            "rg-11",
+                            "rg-59",
+                            "rg-62",
+                            "rg-213",
+                            "lmr-100",
+                            "lmr-200",
+                            "lmr-400",
                             "mmf",
                             "mmf-om1",
                             "mmf-om2",
@@ -259407,8 +259474,8 @@
                             null
                         ],
                         "type": "string",
-                        "description": "* `cat3` - CAT3\n* `cat5` - CAT5\n* `cat5e` - CAT5e\n* `cat6` - CAT6\n* `cat6a` - CAT6a\n* `cat7` - CAT7\n* `cat7a` - CAT7a\n* `cat8` - CAT8\n* `mrj21-trunk` - MRJ21 Trunk\n* `dac-active` - Direct Attach Copper (Active)\n* `dac-passive` - Direct Attach Copper (Passive)\n* `coaxial` - Coaxial\n* `mmf` - Multimode Fiber\n* `mmf-om1` - Multimode Fiber (OM1)\n* `mmf-om2` - Multimode Fiber (OM2)\n* `mmf-om3` - Multimode Fiber (OM3)\n* `mmf-om4` - Multimode Fiber (OM4)\n* `mmf-om5` - Multimode Fiber (OM5)\n* `smf` - Single-mode Fiber\n* `smf-os1` - Single-mode Fiber (OS1)\n* `smf-os2` - Single-mode Fiber (OS2)\n* `aoc` - Active Optical Cabling (AOC)\n* `power` - Power\n* `usb` - USB",
-                        "x-spec-enum-id": "c731f2793fceac04",
+                        "description": "* `cat3` - CAT3\n* `cat5` - CAT5\n* `cat5e` - CAT5e\n* `cat6` - CAT6\n* `cat6a` - CAT6a\n* `cat7` - CAT7\n* `cat7a` - CAT7a\n* `cat8` - CAT8\n* `mrj21-trunk` - MRJ21 Trunk\n* `dac-active` - Direct Attach Copper (Active)\n* `dac-passive` - Direct Attach Copper (Passive)\n* `coaxial` - Coaxial\n* `rg-6` - RG-6\n* `rg-8` - RG-8\n* `rg-11` - RG-11\n* `rg-59` - RG-59\n* `rg-62` - RG-62\n* `rg-213` - RG-213\n* `lmr-100` - LMR-100\n* `lmr-200` - LMR-200\n* `lmr-400` - LMR-400\n* `mmf` - Multimode Fiber\n* `mmf-om1` - Multimode Fiber (OM1)\n* `mmf-om2` - Multimode Fiber (OM2)\n* `mmf-om3` - Multimode Fiber (OM3)\n* `mmf-om4` - Multimode Fiber (OM4)\n* `mmf-om5` - Multimode Fiber (OM5)\n* `smf` - Single-mode Fiber\n* `smf-os1` - Single-mode Fiber (OS1)\n* `smf-os2` - Single-mode Fiber (OS2)\n* `aoc` - Active Optical Cabling (AOC)\n* `power` - Power\n* `usb` - USB",
+                        "x-spec-enum-id": "8d6d8ba53d82f066",
                         "nullable": true
                     },
                     "a_terminations": {

+ 1 - 0
docs/configuration/index.md

@@ -35,6 +35,7 @@ Some configuration parameters are primarily controlled via NetBox's admin interf
 * [`POWERFEED_DEFAULT_MAX_UTILIZATION`](./default-values.md#powerfeed_default_max_utilization)
 * [`POWERFEED_DEFAULT_VOLTAGE`](./default-values.md#powerfeed_default_voltage)
 * [`PREFER_IPV4`](./miscellaneous.md#prefer_ipv4)
+* [`PROTECTION_RULES`](./data-validation.md#protection_rules)
 * [`RACK_ELEVATION_DEFAULT_UNIT_HEIGHT`](./default-values.md#rack_elevation_default_unit_height)
 * [`RACK_ELEVATION_DEFAULT_UNIT_WIDTH`](./default-values.md#rack_elevation_default_unit_width)
 

+ 1 - 1
docs/configuration/security.md

@@ -81,7 +81,7 @@ If `True`, the cookie employed for cross-site request forgery (CSRF) protection
 
 Default: `[]`
 
-Defines a list of trusted origins for unsafe (e.g. `POST`) requests. This is a pass-through to Django's [`CSRF_TRUSTED_ORIGINS`](https://docs.djangoproject.com/en/stable/ref/settings/#csrf-trusted-origins) setting. Note that each host listed must specify a scheme (e.g. `http://` or `https://).
+Defines a list of trusted origins for unsafe (e.g. `POST`) requests. This is a pass-through to Django's [`CSRF_TRUSTED_ORIGINS`](https://docs.djangoproject.com/en/stable/ref/settings/#csrf-trusted-origins) setting. Note that each host listed must specify a scheme (e.g. `http://` or `https://`).
 
 ```python
 CSRF_TRUSTED_ORIGINS = (

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

@@ -1,5 +1,33 @@
 # NetBox v4.4
 
+## v4.4.6 (2025-11-11)
+
+### Enhancements
+
+* [#14171](https://github.com/netbox-community/netbox/issues/14171) - Support VLAN assignment for device & VM interfaces being bulk imported
+* [#20297](https://github.com/netbox-community/netbox/issues/20297) - Introduce additional coaxial cable types
+
+### Bug Fixes
+
+* [#20378](https://github.com/netbox-community/netbox/issues/20378) - Prevent exception when attempting to delete a data source utilized by a custom script
+* [#20645](https://github.com/netbox-community/netbox/issues/20645) - CSVChoiceField should defer to model field's default value when CSV field is empty
+* [#20647](https://github.com/netbox-community/netbox/issues/20647) - Improve handling of empty strings during bulk imports
+* [#20653](https://github.com/netbox-community/netbox/issues/20653) - Fix filtering of jobs by object type ID
+* [#20660](https://github.com/netbox-community/netbox/issues/20660) - Optimize loading of custom script modules from remote storage
+* [#20670](https://github.com/netbox-community/netbox/issues/20670) - Improve validation of related objects during bulk import
+* [#20688](https://github.com/netbox-community/netbox/issues/20688) - Suppress non-harmful "No active configuration revision found" warning message
+* [#20697](https://github.com/netbox-community/netbox/issues/20697) - Prevent duplication of signals which increment/decrement related object counts
+* [#20699](https://github.com/netbox-community/netbox/issues/20699) - Ensure proper ordering of changelog entries resulting from cascading deletions
+* [#20713](https://github.com/netbox-community/netbox/issues/20713) - Ensure a pre-change snapshot is recorded on virtual chassis members being added/removed
+* [#20721](https://github.com/netbox-community/netbox/issues/20721) - Fix breadcrumb navigation links in UI for background tasks
+* [#20738](https://github.com/netbox-community/netbox/issues/20738) - Deleting a virtual chassis should nullify the `vc_position` of all former members
+* [#20750](https://github.com/netbox-community/netbox/issues/20750) - Fix cloning of permissions when only one action is enabled
+* [#20755](https://github.com/netbox-community/netbox/issues/20755) - Prevent duplicate results under certain conditions when filtering providers
+* [#20771](https://github.com/netbox-community/netbox/issues/20771) - Comments are required when creating a new journal entry
+* [#20774](https://github.com/netbox-community/netbox/issues/20774) - Bulk action button labels should be translated
+
+---
+
 ## v4.4.5 (2025-10-28)
 
 ### Enhancements

+ 0 - 2
netbox/circuits/filtersets.py

@@ -89,8 +89,6 @@ class ProviderFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
         return queryset.filter(
             Q(name__icontains=value) |
             Q(description__icontains=value) |
-            Q(accounts__account__icontains=value) |
-            Q(accounts__name__icontains=value) |
             Q(comments__icontains=value)
         )
 

+ 24 - 2
netbox/core/api/serializers_/jobs.py

@@ -1,8 +1,13 @@
+from drf_spectacular.utils import extend_schema_field
+from rest_framework import serializers
+
 from core.choices import *
 from core.models import Job
+from netbox.api.exceptions import SerializerNotFound
 from netbox.api.fields import ChoiceField, ContentTypeField
 from netbox.api.serializers import BaseModelSerializer
 from users.api.serializers_.users import UserSerializer
+from utilities.api import get_serializer_for_model
 
 __all__ = (
     'JobSerializer',
@@ -18,11 +23,28 @@ class JobSerializer(BaseModelSerializer):
     object_type = ContentTypeField(
         read_only=True
     )
+    object = serializers.SerializerMethodField(
+        read_only=True
+    )
 
     class Meta:
         model = Job
         fields = [
-            'id', 'url', 'display_url', 'display', 'object_type', 'object_id', 'name', 'status', 'created', 'scheduled',
-            'interval', 'started', 'completed', 'user', 'data', 'error', 'job_id', 'log_entries',
+            'id', 'url', 'display_url', 'display', 'object_type', 'object_id', 'object', 'name', 'status', 'created',
+            'scheduled', 'interval', 'started', 'completed', 'user', 'data', 'error', 'job_id', 'log_entries',
         ]
         brief_fields = ('url', 'created', 'completed', 'user', 'status')
+
+    @extend_schema_field(serializers.JSONField(allow_null=True))
+    def get_object(self, obj):
+        """
+        Serialize a nested representation of the object.
+        """
+        if obj.object is None:
+            return None
+        try:
+            serializer = get_serializer_for_model(obj.object)
+        except SerializerNotFound:
+            return obj.object_repr
+        context = {'request': self.context['request']}
+        return serializer(obj.object, nested=True, context=context).data

+ 5 - 1
netbox/core/filtersets.py

@@ -80,6 +80,10 @@ class JobFilterSet(BaseFilterSet):
         method='search',
         label=_('Search'),
     )
+    object_type_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=ObjectType.objects.with_feature('jobs'),
+        field_name='object_type_id',
+    )
     object_type = ContentTypeFilter()
     created = django_filters.DateTimeFilter()
     created__before = django_filters.DateTimeFilter(
@@ -124,7 +128,7 @@ class JobFilterSet(BaseFilterSet):
 
     class Meta:
         model = Job
-        fields = ('id', 'object_type', 'object_id', 'name', 'interval', 'status', 'user', 'job_id')
+        fields = ('id', 'object_type', 'object_type_id', 'object_id', 'name', 'interval', 'status', 'user', 'job_id')
 
     def search(self, queryset, name, value):
         if not value.strip():

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

@@ -71,13 +71,13 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
     model = Job
     fieldsets = (
         FieldSet('q', 'filter_id'),
-        FieldSet('object_type', 'status', name=_('Attributes')),
+        FieldSet('object_type_id', 'status', name=_('Attributes')),
         FieldSet(
             'created__before', 'created__after', 'scheduled__before', 'scheduled__after', 'started__before',
             'started__after', 'completed__before', 'completed__after', 'user', name=_('Creation')
         ),
     )
-    object_type = ContentTypeChoiceField(
+    object_type_id = ContentTypeChoiceField(
         label=_('Object Type'),
         queryset=ObjectType.objects.with_feature('jobs'),
         required=False,

+ 0 - 4
netbox/core/models/files.py

@@ -6,7 +6,6 @@ from django.conf import settings
 from django.core.exceptions import ValidationError
 from django.db import models
 from django.core.files.storage import storages
-from django.urls import reverse
 from django.utils.translation import gettext as _
 
 from ..choices import ManagedFileRootPathChoices
@@ -64,9 +63,6 @@ class ManagedFile(SyncedDataMixin, models.Model):
     def __str__(self):
         return self.name
 
-    def get_absolute_url(self):
-        return reverse('core:managedfile', args=[self.pk])
-
     @property
     def name(self):
         return self.file_path

+ 1 - 1
netbox/core/object_actions.py

@@ -1,4 +1,4 @@
-from django.utils.translation import gettext as _
+from django.utils.translation import gettext_lazy as _
 
 from netbox.object_actions import ObjectAction
 

+ 2 - 7
netbox/core/signals.py

@@ -3,6 +3,7 @@ from threading import local
 
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ObjectDoesNotExist, ValidationError
+from django.db.models import CASCADE
 from django.db.models.fields.reverse_related import ManyToManyRel, ManyToOneRel
 from django.db.models.signals import m2m_changed, post_migrate, post_save, pre_delete
 from django.dispatch import receiver, Signal
@@ -220,14 +221,8 @@ def handle_deleted_object(sender, instance, **kwargs):
             obj.snapshot()  # Ensure the change record includes the "before" state
             if type(relation) is ManyToManyRel:
                 getattr(obj, related_field_name).remove(instance)
-            elif type(relation) is ManyToOneRel and relation.field.null is True:
+            elif type(relation) is ManyToOneRel and relation.null and relation.on_delete is not CASCADE:
                 setattr(obj, related_field_name, None)
-                # make sure the object hasn't been deleted - in case of
-                # deletion chaining of related objects
-                try:
-                    obj.refresh_from_db()
-                except DoesNotExist:
-                    continue
                 obj.save()
 
     # Enqueue the object for event processing

+ 67 - 4
netbox/core/tests/test_changelog.py

@@ -5,14 +5,16 @@ from rest_framework import status
 
 from core.choices import ObjectChangeActionChoices
 from core.models import ObjectChange, ObjectType
-from dcim.choices import SiteStatusChoices
-from dcim.models import Site, CableTermination, Device, DeviceType, DeviceRole, Interface, Cable
+from dcim.choices import InterfaceTypeChoices, ModuleStatusChoices, SiteStatusChoices
+from dcim.models import (
+    Cable, CableTermination, Device, DeviceRole, DeviceType, Manufacturer, Module, ModuleBay, ModuleType, Interface,
+    Site,
+)
 from extras.choices import *
 from extras.models import CustomField, CustomFieldChoiceSet, Tag
 from utilities.testing import APITestCase
-from utilities.testing.utils import create_tags, post_data
+from utilities.testing.utils import create_tags, create_test_device, post_data
 from utilities.testing.views import ModelViewTestCase
-from dcim.models import Manufacturer
 
 
 class ChangeLogViewTest(ModelViewTestCase):
@@ -622,3 +624,64 @@ class ChangeLogAPITest(APITestCase):
         self.assertEqual(objectchange.prechange_data['name'], 'Site 1')
         self.assertEqual(objectchange.prechange_data['slug'], 'site-1')
         self.assertEqual(objectchange.postchange_data, None)
+
+    def test_deletion_ordering(self):
+        """
+        Check that the cascading deletion of dependent objects is recorded in the correct order.
+        """
+        device = create_test_device('device1')
+        module_bay = ModuleBay.objects.create(device=device, name='Module Bay 1')
+        module_type = ModuleType.objects.create(manufacturer=Manufacturer.objects.first(), model='Module Type 1')
+        self.add_permissions('dcim.add_module', 'dcim.add_interface', 'dcim.delete_module')
+        self.assertEqual(ObjectChange.objects.count(), 0)  # Sanity check
+
+        # Create a new Module
+        data = {
+            'device': device.pk,
+            'module_bay': module_bay.pk,
+            'module_type': module_type.pk,
+            'status': ModuleStatusChoices.STATUS_ACTIVE,
+        }
+        url = reverse('dcim-api:module-list')
+        response = self.client.post(url, data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        module = device.modules.first()
+
+        # Create an Interface on the Module
+        data = {
+            'device': device.pk,
+            'module': module.pk,
+            'name': 'Interface 1',
+            'type': InterfaceTypeChoices.TYPE_1GE_FIXED,
+        }
+        url = reverse('dcim-api:interface-list')
+        response = self.client.post(url, data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        interface = device.interfaces.first()
+
+        # Delete the Module
+        url = reverse('dcim-api:module-detail', kwargs={'pk': module.pk})
+        response = self.client.delete(url, **self.header)
+        self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
+        self.assertEqual(Module.objects.count(), 0)
+        self.assertEqual(Interface.objects.count(), 0)
+
+        # Verify the creation of the expected ObjectChange records. We should see four total records, in this order:
+        #  1. Module created
+        #  2. Interface created
+        #  3. Interface deleted
+        #  4. Module deleted
+        changes = ObjectChange.objects.order_by('time')
+        self.assertEqual(len(changes), 4)
+        self.assertEqual(changes[0].changed_object_type, ContentType.objects.get_for_model(Module))
+        self.assertEqual(changes[0].changed_object_id, module.pk)
+        self.assertEqual(changes[0].action, ObjectChangeActionChoices.ACTION_CREATE)
+        self.assertEqual(changes[1].changed_object_type, ContentType.objects.get_for_model(Interface))
+        self.assertEqual(changes[1].changed_object_id, interface.pk)
+        self.assertEqual(changes[1].action, ObjectChangeActionChoices.ACTION_CREATE)
+        self.assertEqual(changes[2].changed_object_type, ContentType.objects.get_for_model(Interface))
+        self.assertEqual(changes[2].changed_object_id, interface.pk)
+        self.assertEqual(changes[2].action, ObjectChangeActionChoices.ACTION_DELETE)
+        self.assertEqual(changes[3].changed_object_type, ContentType.objects.get_for_model(Module))
+        self.assertEqual(changes[3].changed_object_id, module.pk)
+        self.assertEqual(changes[3].action, ObjectChangeActionChoices.ACTION_DELETE)

+ 18 - 0
netbox/dcim/choices.py

@@ -1736,6 +1736,15 @@ class CableTypeChoices(ChoiceSet):
 
     # Copper - Coaxial
     TYPE_COAXIAL = 'coaxial'
+    TYPE_RG_6 = 'rg-6'
+    TYPE_RG_8 = 'rg-8'
+    TYPE_RG_11 = 'rg-11'
+    TYPE_RG_59 = 'rg-59'
+    TYPE_RG_62 = 'rg-62'
+    TYPE_RG_213 = 'rg-213'
+    TYPE_LMR_100 = 'lmr-100'
+    TYPE_LMR_200 = 'lmr-200'
+    TYPE_LMR_400 = 'lmr-400'
 
     # Fiber Optic - Multimode
     TYPE_MMF = 'mmf'
@@ -1785,6 +1794,15 @@ class CableTypeChoices(ChoiceSet):
             _('Copper - Coaxial'),
             (
                 (TYPE_COAXIAL, 'Coaxial'),
+                (TYPE_RG_6, 'RG-6'),
+                (TYPE_RG_8, 'RG-8'),
+                (TYPE_RG_11, 'RG-11'),
+                (TYPE_RG_59, 'RG-59'),
+                (TYPE_RG_62, 'RG-62'),
+                (TYPE_RG_213, 'RG-213'),
+                (TYPE_LMR_100, 'LMR-100'),
+                (TYPE_LMR_200, 'LMR-200'),
+                (TYPE_LMR_400, 'LMR-400'),
             ),
         ),
         (

+ 48 - 5
netbox/dcim/forms/bulk_import.py

@@ -9,7 +9,8 @@ from dcim.choices import *
 from dcim.constants import *
 from dcim.models import *
 from extras.models import ConfigTemplate
-from ipam.models import VRF, IPAddress
+from ipam.choices import VLANQinQRoleChoices
+from ipam.models import VLAN, VRF, IPAddress, VLANGroup
 from netbox.choices import *
 from netbox.forms import (
     NestedGroupModelImportForm, NetBoxModelImportForm, OrganizationalModelImportForm, OwnerCSVMixin,
@@ -20,7 +21,7 @@ from utilities.forms.fields import (
     CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, CSVTypedChoiceField,
     SlugField,
 )
-from virtualization.models import Cluster, VMInterface, VirtualMachine
+from virtualization.models import Cluster, VirtualMachine, VMInterface
 from wireless.choices import WirelessRoleChoices
 from .common import ModuleCommonForm
 
@@ -941,7 +942,7 @@ class InterfaceImportForm(OwnerCSVMixin, NetBoxModelImportForm):
         required=False,
         to_field_name='name',
         help_text=mark_safe(
-            _('VDC names separated by commas, encased with double quotes. Example:') + ' <code>vdc1,vdc2,vdc3</code>'
+            _('VDC names separated by commas, encased with double quotes. Example:') + ' <code>"vdc1,vdc2,vdc3"</code>'
         )
     )
     type = CSVChoiceField(
@@ -970,7 +971,41 @@ class InterfaceImportForm(OwnerCSVMixin, NetBoxModelImportForm):
         label=_('Mode'),
         choices=InterfaceModeChoices,
         required=False,
-        help_text=_('IEEE 802.1Q operational mode (for L2 interfaces)')
+        help_text=_('IEEE 802.1Q operational mode (for L2 interfaces)'),
+    )
+    vlan_group = CSVModelChoiceField(
+        label=_('VLAN group'),
+        queryset=VLANGroup.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text=_('Filter VLANs available for assignment by group'),
+    )
+    untagged_vlan = CSVModelChoiceField(
+        label=_('Untagged VLAN'),
+        queryset=VLAN.objects.all(),
+        required=False,
+        to_field_name='vid',
+        help_text=_('Assigned untagged VLAN ID (filtered by VLAN group)'),
+    )
+    tagged_vlans = CSVModelMultipleChoiceField(
+        label=_('Tagged VLANs'),
+        queryset=VLAN.objects.all(),
+        required=False,
+        to_field_name='vid',
+        help_text=mark_safe(
+            _(
+                'Assigned tagged VLAN IDs separated by commas, encased with double quotes '
+                '(filtered by VLAN group). Example:'
+            )
+            + ' <code>"100,200,300"</code>'
+        ),
+    )
+    qinq_svlan = CSVModelChoiceField(
+        label=_('Q-in-Q Service VLAN'),
+        queryset=VLAN.objects.filter(qinq_role=VLANQinQRoleChoices.ROLE_SERVICE),
+        required=False,
+        to_field_name='vid',
+        help_text=_('Assigned Q-in-Q Service VLAN ID (filtered by VLAN group)'),
     )
     vrf = CSVModelChoiceField(
         label=_('VRF'),
@@ -991,7 +1026,8 @@ class InterfaceImportForm(OwnerCSVMixin, NetBoxModelImportForm):
         fields = (
             'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled',
             'mark_connected', 'wwn', 'vdcs', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'mode',
-            'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'owner', 'tags'
+            'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vrf', 'rf_role', 'rf_channel',
+            'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'owner', 'tags'
         )
 
     def __init__(self, data=None, *args, **kwargs):
@@ -1008,6 +1044,13 @@ class InterfaceImportForm(OwnerCSVMixin, NetBoxModelImportForm):
                 self.fields['lag'].queryset = self.fields['lag'].queryset.filter(**params)
                 self.fields['vdcs'].queryset = self.fields['vdcs'].queryset.filter(**params)
 
+            # Limit choices for VLANs to the assigned VLAN group
+            if vlan_group := data.get('vlan_group'):
+                params = {f"group__{self.fields['vlan_group'].to_field_name}": vlan_group}
+                self.fields['untagged_vlan'].queryset = self.fields['untagged_vlan'].queryset.filter(**params)
+                self.fields['tagged_vlans'].queryset = self.fields['tagged_vlans'].queryset.filter(**params)
+                self.fields['qinq_svlan'].queryset = self.fields['qinq_svlan'].queryset.filter(**params)
+
     def clean_enabled(self):
         # Make sure enabled is True when it's not included in the uploaded data
         if 'enabled' not in self.data:

+ 1 - 0
netbox/dcim/forms/object_create.py

@@ -453,6 +453,7 @@ class VirtualChassisCreateForm(NetBoxModelForm):
         if instance.pk and self.cleaned_data['members']:
             initial_position = self.cleaned_data.get('initial_position', 1)
             for i, member in enumerate(self.cleaned_data['members'], start=initial_position):
+                member.snapshot()
                 member.virtual_chassis = instance
                 member.vc_position = i
                 member.save()

+ 7 - 1
netbox/dcim/models/devices.py

@@ -1154,7 +1154,6 @@ class VirtualChassis(PrimaryModel):
             })
 
     def delete(self, *args, **kwargs):
-
         # Check for LAG interfaces split across member chassis
         interfaces = Interface.objects.filter(
             device__in=self.members.all(),
@@ -1168,6 +1167,13 @@ class VirtualChassis(PrimaryModel):
                 "interfaces."
             ).format(self=self, interfaces=InterfaceSpeedChoices))
 
+        # Clear vc_position and vc_priority on member devices BEFORE calling super().delete()
+        # This must be done here because on_delete=SET_NULL executes before pre_delete signal
+        for device in self.members.all():
+            device.vc_position = None
+            device.vc_priority = None
+            device.save()
+
         return super().delete(*args, **kwargs)
 
 

+ 1 - 1
netbox/dcim/object_actions.py

@@ -1,4 +1,4 @@
-from django.utils.translation import gettext as _
+from django.utils.translation import gettext_lazy as _
 
 from netbox.object_actions import ObjectAction
 

+ 1 - 13
netbox/dcim/signals.py

@@ -1,6 +1,6 @@
 import logging
 
-from django.db.models.signals import post_save, post_delete, pre_delete
+from django.db.models.signals import post_save, post_delete
 from django.dispatch import receiver
 
 from dcim.choices import CableEndChoices, LinkStatusChoices
@@ -85,18 +85,6 @@ def assign_virtualchassis_master(instance, created, **kwargs):
         master.save()
 
 
-@receiver(pre_delete, sender=VirtualChassis)
-def clear_virtualchassis_members(instance, **kwargs):
-    """
-    When a VirtualChassis is deleted, nullify the vc_position and vc_priority fields of its prior members.
-    """
-    devices = Device.objects.filter(virtual_chassis=instance.pk)
-    for device in devices:
-        device.vc_position = None
-        device.vc_priority = None
-        device.save()
-
-
 #
 # Cables
 #

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

@@ -1031,3 +1031,92 @@ class VirtualDeviceContextTestCase(TestCase):
         vdc2 = VirtualDeviceContext(device=device, name="VDC 2", identifier=1, status='active')
         with self.assertRaises(ValidationError):
             vdc2.full_clean()
+
+
+class VirtualChassisTestCase(TestCase):
+
+    @classmethod
+    def setUpTestData(cls):
+        site = Site.objects.create(name='Test Site 1', slug='test-site-1')
+        manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
+        devicetype = DeviceType.objects.create(
+            manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
+        )
+        role = DeviceRole.objects.create(
+            name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
+        )
+        Device.objects.create(
+            device_type=devicetype, role=role, name='TestDevice1', site=site
+        )
+        Device.objects.create(
+            device_type=devicetype, role=role, name='TestDevice2', site=site
+        )
+
+    def test_virtualchassis_deletion_clears_vc_position(self):
+        """
+        Test that when a VirtualChassis is deleted, member devices have their
+        vc_position and vc_priority fields set to None.
+        """
+        devices = Device.objects.all()
+        device1 = devices[0]
+        device2 = devices[1]
+
+        # Create a VirtualChassis with two member devices
+        vc = VirtualChassis.objects.create(name='Test VC', master=device1)
+
+        device1.virtual_chassis = vc
+        device1.vc_position = 1
+        device1.vc_priority = 10
+        device1.save()
+
+        device2.virtual_chassis = vc
+        device2.vc_position = 2
+        device2.vc_priority = 20
+        device2.save()
+
+        # Verify devices are members of the VC with positions set
+        device1.refresh_from_db()
+        device2.refresh_from_db()
+        self.assertEqual(device1.virtual_chassis, vc)
+        self.assertEqual(device1.vc_position, 1)
+        self.assertEqual(device1.vc_priority, 10)
+        self.assertEqual(device2.virtual_chassis, vc)
+        self.assertEqual(device2.vc_position, 2)
+        self.assertEqual(device2.vc_priority, 20)
+
+        # Delete the VirtualChassis
+        vc.delete()
+
+        # Verify devices have vc_position and vc_priority set to None
+        device1.refresh_from_db()
+        device2.refresh_from_db()
+        self.assertIsNone(device1.virtual_chassis)
+        self.assertIsNone(device1.vc_position)
+        self.assertIsNone(device1.vc_priority)
+        self.assertIsNone(device2.virtual_chassis)
+        self.assertIsNone(device2.vc_position)
+        self.assertIsNone(device2.vc_priority)
+
+    def test_virtualchassis_duplicate_vc_position(self):
+        """
+        Test that two devices cannot be assigned to the same vc_position
+        within the same VirtualChassis.
+        """
+        devices = Device.objects.all()
+        device1 = devices[0]
+        device2 = devices[1]
+
+        # Create a VirtualChassis
+        vc = VirtualChassis.objects.create(name='Test VC')
+
+        # Assign first device to vc_position 1
+        device1.virtual_chassis = vc
+        device1.vc_position = 1
+        device1.full_clean()
+        device1.save()
+
+        # Try to assign second device to the same vc_position
+        device2.virtual_chassis = vc
+        device2.vc_position = 1
+        with self.assertRaises(ValidationError):
+            device2.full_clean()

+ 138 - 4
netbox/dcim/tests/test_views.py

@@ -986,6 +986,131 @@ inventory-items:
         ii1 = InventoryItemTemplate.objects.first()
         self.assertEqual(ii1.name, 'Inventory Item 1')
 
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+    def test_import_error_numbering(self):
+        # Add all required permissions to the test user
+        self.add_permissions(
+            'dcim.view_devicetype',
+            'dcim.add_devicetype',
+            'dcim.add_consoleporttemplate',
+            'dcim.add_consoleserverporttemplate',
+            'dcim.add_powerporttemplate',
+            'dcim.add_poweroutlettemplate',
+            'dcim.add_interfacetemplate',
+            'dcim.add_frontporttemplate',
+            'dcim.add_rearporttemplate',
+            'dcim.add_modulebaytemplate',
+            'dcim.add_devicebaytemplate',
+            'dcim.add_inventoryitemtemplate',
+        )
+
+        import_data = '''
+---
+manufacturer: Manufacturer 1
+model: TEST-2001
+slug: test-2001
+u_height: 1
+module-bays:
+  - name: Module Bay 1-1
+  - name: Module Bay 1-2
+---
+- manufacturer: Manufacturer 1
+  model: TEST-2002
+  slug: test-2002
+  u_height: 1
+  module-bays:
+    - name: Module Bay 2-1
+    - name: Module Bay 2-2
+    - not_name: Module Bay 2-3
+- manufacturer: Manufacturer 1
+  model: TEST-2003
+  slug: test-2003
+  u_height: 1
+  module-bays:
+    - name: Module Bay 3-1
+'''
+        form_data = {
+            'data': import_data,
+            'format': 'yaml'
+        }
+
+        response = self.client.post(reverse('dcim:devicetype_bulk_import'), data=form_data, follow=True)
+        self.assertHttpStatus(response, 200)
+        self.assertContains(response, "Record 2 module-bays[3].name: This field is required.")
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+    def test_import_nolist(self):
+        # Add all required permissions to the test user
+        self.add_permissions(
+            'dcim.view_devicetype',
+            'dcim.add_devicetype',
+            'dcim.add_consoleporttemplate',
+            'dcim.add_consoleserverporttemplate',
+            'dcim.add_powerporttemplate',
+            'dcim.add_poweroutlettemplate',
+            'dcim.add_interfacetemplate',
+            'dcim.add_frontporttemplate',
+            'dcim.add_rearporttemplate',
+            'dcim.add_modulebaytemplate',
+            'dcim.add_devicebaytemplate',
+            'dcim.add_inventoryitemtemplate',
+        )
+
+        for value in ('', 'null', '3', '"My console port"', '{name: "My other console port"}'):
+            with self.subTest(value=value):
+                import_data = f'''
+manufacturer: Manufacturer 1
+model: TEST-3000
+slug: test-3000
+u_height: 1
+console-ports: {value}
+'''
+                form_data = {
+                    'data': import_data,
+                    'format': 'yaml'
+                }
+
+                response = self.client.post(reverse('dcim:devicetype_bulk_import'), data=form_data, follow=True)
+                self.assertHttpStatus(response, 200)
+                self.assertContains(response, "Record 1 console-ports: Must be a list.")
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+    def test_import_nodict(self):
+        # Add all required permissions to the test user
+        self.add_permissions(
+            'dcim.view_devicetype',
+            'dcim.add_devicetype',
+            'dcim.add_consoleporttemplate',
+            'dcim.add_consoleserverporttemplate',
+            'dcim.add_powerporttemplate',
+            'dcim.add_poweroutlettemplate',
+            'dcim.add_interfacetemplate',
+            'dcim.add_frontporttemplate',
+            'dcim.add_rearporttemplate',
+            'dcim.add_modulebaytemplate',
+            'dcim.add_devicebaytemplate',
+            'dcim.add_inventoryitemtemplate',
+        )
+
+        for value in ('', 'null', '3', '"My console port"', '["My other console port"]'):
+            with self.subTest(value=value):
+                import_data = f'''
+manufacturer: Manufacturer 1
+model: TEST-4000
+slug: test-4000
+u_height: 1
+console-ports:
+  - {value}
+'''
+                form_data = {
+                    'data': import_data,
+                    'format': 'yaml'
+                }
+
+                response = self.client.post(reverse('dcim:devicetype_bulk_import'), data=form_data, follow=True)
+                self.assertHttpStatus(response, 200)
+                self.assertContains(response, "Record 1 console-ports[1]: Must be a dictionary.")
+
     def test_export_objects(self):
         url = reverse('dcim:devicetype_list')
         self.add_permissions('dcim.view_devicetype')
@@ -2834,10 +2959,19 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
         }
 
         cls.csv_data = (
-            "device,name,type,vrf.pk,poe_mode,poe_type",
-            f"Device 1,Interface 4,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af",
-            f"Device 1,Interface 5,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af",
-            f"Device 1,Interface 6,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af",
+            "device,name,type,vrf.pk,poe_mode,poe_type,mode,untagged_vlan,tagged_vlans",
+            (
+                f"Device 1,Interface 4,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af,"
+                f"tagged,{vlans[0].vid},'{','.join([str(v.vid) for v in vlans[1:4]])}'"
+            ),
+            (
+                f"Device 1,Interface 5,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af,"
+                f"tagged,{vlans[0].vid},'{','.join([str(v.vid) for v in vlans[1:4]])}'"
+            ),
+            (
+                f"Device 1,Interface 6,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af,"
+                f"tagged,{vlans[0].vid},'{','.join([str(v.vid) for v in vlans[1:4]])}'"
+            ),
         )
 
         cls.csv_update_data = (

+ 11 - 18
netbox/dcim/views.py

@@ -4044,6 +4044,7 @@ class VirtualChassisEditView(ObjectPermissionRequiredMixin, GetReturnURLMixin, V
     def post(self, request, pk):
 
         virtual_chassis = get_object_or_404(self.queryset, pk=pk)
+        virtual_chassis.snapshot()
         VCMemberFormSet = modelformset_factory(
             model=Device,
             form=forms.DeviceVCMembershipForm,
@@ -4096,9 +4097,7 @@ class VirtualChassisAddMemberView(ObjectPermissionRequiredMixin, GetReturnURLMix
         return 'dcim.change_virtualchassis'
 
     def get(self, request, pk):
-
         virtual_chassis = get_object_or_404(self.queryset, pk=pk)
-
         initial_data = {k: request.GET[k] for k in request.GET}
         member_select_form = forms.VCMemberSelectForm(initial=initial_data)
         membership_form = forms.DeviceVCMembershipForm(initial=initial_data)
@@ -4111,20 +4110,20 @@ class VirtualChassisAddMemberView(ObjectPermissionRequiredMixin, GetReturnURLMix
         })
 
     def post(self, request, pk):
-
         virtual_chassis = get_object_or_404(self.queryset, pk=pk)
-
         member_select_form = forms.VCMemberSelectForm(request.POST)
 
         if member_select_form.is_valid():
-
             device = member_select_form.cleaned_data['device']
+            device.snapshot()
             device.virtual_chassis = virtual_chassis
-            data = {k: request.POST[k] for k in ['vc_position', 'vc_priority']}
+            data = {
+                'vc_position': request.POST['vc_position'],
+                'vc_priority': request.POST['vc_priority'],
+            }
             membership_form = forms.DeviceVCMembershipForm(data=data, validate_vc_position=True, instance=device)
 
             if membership_form.is_valid():
-
                 membership_form.save()
                 messages.success(request, mark_safe(
                     _('Added member <a href="{url}">{device}</a>').format(
@@ -4134,11 +4133,9 @@ class VirtualChassisAddMemberView(ObjectPermissionRequiredMixin, GetReturnURLMix
 
                 if '_addanother' in request.POST and safe_for_redirect(request.get_full_path()):
                     return redirect(request.get_full_path())
-
                 return redirect(self.get_return_url(request, device))
 
         else:
-
             membership_form = forms.DeviceVCMembershipForm(data=request.POST)
 
         return render(request, 'dcim/virtualchassis_add_member.html', {
@@ -4156,7 +4153,6 @@ class VirtualChassisRemoveMemberView(ObjectPermissionRequiredMixin, GetReturnURL
         return 'dcim.change_device'
 
     def get(self, request, pk):
-
         device = get_object_or_404(self.queryset, pk=pk, virtual_chassis__isnull=False)
         form = ConfirmationForm(initial=request.GET)
 
@@ -4167,7 +4163,6 @@ class VirtualChassisRemoveMemberView(ObjectPermissionRequiredMixin, GetReturnURL
         })
 
     def post(self, request, pk):
-
         device = get_object_or_404(self.queryset, pk=pk, virtual_chassis__isnull=False)
         form = ConfirmationForm(request.POST)
 
@@ -4181,13 +4176,11 @@ class VirtualChassisRemoveMemberView(ObjectPermissionRequiredMixin, GetReturnURL
             return redirect(device.get_absolute_url())
 
         if form.is_valid():
-
-            devices = Device.objects.filter(pk=device.pk)
-            for device in devices:
-                device.virtual_chassis = None
-                device.vc_position = None
-                device.vc_priority = None
-                device.save()
+            device.snapshot()
+            device.virtual_chassis = None
+            device.vc_position = None
+            device.vc_priority = None
+            device.save()
 
             msg = _('Removed {device} from virtual chassis {chassis}').format(
                 device=device,

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

@@ -272,6 +272,10 @@ class JournalEntryImportForm(NetBoxModelImportForm):
         choices=JournalEntryKindChoices,
         help_text=_('The classification of entry')
     )
+    comments = forms.CharField(
+        label=_('Comments'),
+        required=True
+    )
 
     class Meta:
         model = JournalEntry

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

@@ -794,7 +794,7 @@ class JournalEntryForm(NetBoxModelForm):
         label=_('Kind'),
         choices=JournalEntryKindChoices
     )
-    comments = CommentField()
+    comments = CommentField(required=True)
 
     class Meta:
         model = JournalEntry

+ 1 - 2
netbox/extras/models/mixins.py

@@ -30,8 +30,7 @@ class CustomStoragesLoader(importlib.abc.Loader):
         return None  # Use default module creation
 
     def exec_module(self, module):
-        storage = storages.create_storage(storages.backends["scripts"])
-        with storage.open(self.filename, 'rb') as f:
+        with storages["scripts"].open(self.filename, 'rb') as f:
             code = f.read()
         exec(code, module.__dict__)
 

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

@@ -126,7 +126,7 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
         ordered.extend(script_objects.values())
         return ordered
 
-    @property
+    @cached_property
     def module_scripts(self):
 
         def _get_name(cls):

+ 1 - 1
netbox/netbox/config/__init__.py

@@ -82,7 +82,7 @@ class Config:
             revision = ConfigRevision.objects.get(active=True)
             logger.debug(f"Loaded active configuration revision #{revision.pk}")
         except (ConfigRevision.DoesNotExist, ConfigRevision.MultipleObjectsReturned):
-            logger.warning("No active configuration revision found - falling back to most recent")
+            logger.debug("No active configuration revision found - falling back to most recent")
             revision = ConfigRevision.objects.order_by('-created').first()
             if revision is None:
                 logger.debug("No previous configuration found in database; proceeding with default values")

+ 30 - 0
netbox/netbox/forms/bulk_import.py

@@ -1,4 +1,5 @@
 from django import forms
+from django.db import models
 from django.utils.translation import gettext_lazy as _
 
 from extras.choices import *
@@ -38,6 +39,35 @@ class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm):
     def _get_form_field(self, customfield):
         return customfield.to_form_field(for_csv_import=True)
 
+    def clean(self):
+        """
+        Cleans data in a form, ensuring proper handling of model fields with `null=True`.
+        Overrides the `clean` method from the parent form to process and sanitize cleaned
+        data for defined fields in the associated model.
+        """
+        super().clean()
+        cleaned = self.cleaned_data
+
+        model = getattr(self._meta, "model", None)
+        if not model:
+            return cleaned
+
+        for f in model._meta.get_fields():
+            # Only forward, DB-backed fields (skip M2M & reverse relations)
+            if not isinstance(f, models.Field) or not f.concrete or f.many_to_many:
+                continue
+
+            if getattr(f, "null", False):
+                name = f.name
+                if name not in cleaned:
+                    continue
+                val = cleaned[name]
+                # Only coerce empty strings; leave other types alone
+                if isinstance(val, str) and val.strip() == "":
+                    cleaned[name] = None
+
+        return cleaned
+
 
 class OwnerCSVMixin(forms.Form):
     owner = CSVModelChoiceField(

+ 12 - 10
netbox/netbox/models/deletion.py

@@ -2,14 +2,14 @@ import logging
 
 from django.contrib.contenttypes.fields import GenericRelation
 from django.db import router
-from django.db.models.deletion import Collector
+from django.db.models.deletion import CASCADE, Collector
 
 logger = logging.getLogger("netbox.models.deletion")
 
 
 class CustomCollector(Collector):
     """
-    Custom collector that handles GenericRelations correctly.
+    Override Django's stock Collector to handle GenericRelations and ensure proper ordering of cascading deletions.
     """
 
     def collect(
@@ -23,11 +23,15 @@ class CustomCollector(Collector):
         keep_parents=False,
         fail_on_restricted=True,
     ):
-        """
-        Override collect to first collect standard dependencies,
-        then add GenericRelations to the dependency graph.
-        """
-        # Call parent collect first to get all standard dependencies
+        # By default, Django will force the deletion of dependent objects before the parent only if the ForeignKey field
+        # is not nullable. We want to ensure proper ordering regardless, so if the ForeignKey has `on_delete=CASCADE`
+        # applied, we set `nullable` to False when calling `collect()`.
+        if objs and source and source_attr:
+            model = objs[0].__class__
+            field = model._meta.get_field(source_attr)
+            if field.remote_field.on_delete == CASCADE:
+                nullable = False
+
         super().collect(
             objs,
             source=source,
@@ -39,10 +43,8 @@ class CustomCollector(Collector):
             fail_on_restricted=fail_on_restricted,
         )
 
-        # Track which GenericRelations we've already processed to prevent infinite recursion
+        # Add GenericRelations to the dependency graph
         processed_relations = set()
-
-        # Now add GenericRelations to the dependency graph
         for _, instances in list(self.data.items()):
             for instance in instances:
                 # Get all GenericRelations for this model

+ 1 - 1
netbox/netbox/object_actions.py

@@ -1,6 +1,6 @@
 from django.template import loader
 from django.urls.exceptions import NoReverseMatch
-from django.utils.translation import gettext as _
+from django.utils.translation import gettext_lazy as _
 
 from core.models import ObjectType
 from extras.models import ExportTemplate

+ 303 - 0
netbox/netbox/tests/test_forms.py

@@ -0,0 +1,303 @@
+from django.test import TestCase
+
+from dcim.choices import InterfaceTypeChoices
+from dcim.forms import InterfaceImportForm
+from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Site
+
+
+class NetBoxModelImportFormCleanTest(TestCase):
+    """
+    Test the clean() method of NetBoxModelImportForm to ensure it properly converts
+    empty strings to None for nullable fields during CSV import.
+    Uses InterfaceImportForm as the concrete implementation to test.
+    """
+
+    @classmethod
+    def setUpTestData(cls):
+        # Create minimal test fixtures for Interface
+        cls.site = Site.objects.create(name='Test Site', slug='test-site')
+        cls.manufacturer = Manufacturer.objects.create(name='Test Manufacturer', slug='test-manufacturer')
+        cls.device_type = DeviceType.objects.create(
+            manufacturer=cls.manufacturer, model='Test Device Type', slug='test-device-type'
+        )
+        cls.device_role = DeviceRole.objects.create(name='Test Role', slug='test-role', color='ff0000')
+        cls.device = Device.objects.create(
+            name='Test Device', device_type=cls.device_type, role=cls.device_role, site=cls.site
+        )
+        # Create parent interfaces for ForeignKey testing
+        cls.parent_interface = Interface.objects.create(
+            device=cls.device, name='Parent Interface', type=InterfaceTypeChoices.TYPE_1GE_GBIC
+        )
+        cls.lag_interface = Interface.objects.create(
+            device=cls.device, name='LAG Interface', type=InterfaceTypeChoices.TYPE_LAG
+        )
+
+    def test_empty_string_to_none_nullable_charfield(self):
+        """Empty strings should convert to None for nullable CharField"""
+        form = InterfaceImportForm(
+            data={
+                'device': self.device,
+                'name': 'Interface 1',
+                'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
+                'duplex': '',  # nullable CharField
+            }
+        )
+        self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
+        self.assertIsNone(form.cleaned_data['duplex'])
+
+    def test_empty_string_to_none_nullable_integerfield(self):
+        """Empty strings should convert to None for nullable PositiveIntegerField"""
+        form = InterfaceImportForm(
+            data={
+                'device': self.device,
+                'name': 'Interface 2',
+                'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
+                'speed': '',  # nullable PositiveIntegerField
+            }
+        )
+        self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
+        self.assertIsNone(form.cleaned_data['speed'])
+
+    def test_empty_string_to_none_nullable_smallintegerfield(self):
+        """Empty strings should convert to None for nullable SmallIntegerField"""
+        form = InterfaceImportForm(
+            data={
+                'device': self.device,
+                'name': 'Interface 3',
+                'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
+                'tx_power': '',  # nullable SmallIntegerField
+            }
+        )
+        self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
+        self.assertIsNone(form.cleaned_data['tx_power'])
+
+    def test_empty_string_to_none_nullable_decimalfield(self):
+        """Empty strings should convert to None for nullable DecimalField"""
+        form = InterfaceImportForm(
+            data={
+                'device': self.device,
+                'name': 'Interface 4',
+                'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
+                'rf_channel_frequency': '',  # nullable DecimalField
+                'rf_channel_width': '',  # nullable DecimalField
+            }
+        )
+        self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
+        self.assertIsNone(form.cleaned_data['rf_channel_frequency'])
+        self.assertIsNone(form.cleaned_data['rf_channel_width'])
+
+    def test_empty_string_to_none_nullable_foreignkey(self):
+        """Empty strings should convert to None for nullable ForeignKey"""
+        form = InterfaceImportForm(
+            data={
+                'device': self.device,
+                'name': 'Interface 5',
+                'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
+                'lag': '',  # nullable ForeignKey
+                'parent': '',  # nullable ForeignKey
+                'bridge': '',  # nullable ForeignKey
+                'vrf': '',  # nullable ForeignKey
+            }
+        )
+        self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
+        self.assertIsNone(form.cleaned_data['lag'])
+        self.assertIsNone(form.cleaned_data['parent'])
+        self.assertIsNone(form.cleaned_data['bridge'])
+        self.assertIsNone(form.cleaned_data['vrf'])
+
+    def test_empty_string_preserved_non_nullable_charfield(self):
+        """Empty strings should be preserved for non-nullable CharField (blank=True only)"""
+        form = InterfaceImportForm(
+            data={
+                'device': self.device,
+                'name': 'Interface 6',
+                'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
+                'label': '',  # CharField with blank=True (not null=True)
+                'description': '',  # CharField with blank=True (not null=True)
+            }
+        )
+        self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
+        # label and description are NOT nullable in the model, so empty string remains
+        self.assertEqual(form.cleaned_data['label'], '')
+        self.assertEqual(form.cleaned_data['description'], '')
+
+    def test_empty_string_not_converted_for_required_fields(self):
+        """Empty strings should NOT be converted for required fields"""
+        form = InterfaceImportForm(
+            data={
+                'device': self.device,
+                'name': '',  # required field, empty string should remain and cause error
+                'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
+            }
+        )
+        # Form should be invalid because name is required
+        self.assertFalse(form.is_valid())
+        if form.errors:
+            self.assertIn('name', form.errors)
+
+    def test_non_string_none_value_preserved(self):
+        """None values should be preserved (not modified)"""
+        form = InterfaceImportForm(
+            data={
+                'device': self.device,
+                'name': 'Interface 7',
+                'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
+                'speed': None,  # Already None
+                'tx_power': None,  # Already None
+            }
+        )
+        self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
+        self.assertIsNone(form.cleaned_data['speed'])
+        self.assertIsNone(form.cleaned_data['tx_power'])
+
+    def test_non_string_numeric_values_preserved(self):
+        """Numeric values (including 0) should not be modified"""
+        form = InterfaceImportForm(
+            data={
+                'device': self.device,
+                'name': 'Interface 8',
+                'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
+                'speed': 0,  # nullable PositiveIntegerField with value 0
+                'tx_power': 0,  # nullable SmallIntegerField with value 0
+            }
+        )
+        self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
+        self.assertEqual(form.cleaned_data['speed'], 0)
+        self.assertEqual(form.cleaned_data['tx_power'], 0)
+
+    def test_manytomany_fields_skipped(self):
+        """ManyToMany fields should be skipped and not cause errors"""
+        # Interface has 'vdcs' and 'wireless_lans' as M2M fields
+        form = InterfaceImportForm(
+            data={
+                'device': self.device,
+                'name': 'Interface 9',
+                'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
+                # vdcs and wireless_lans fields are M2M, handled by parent class
+            }
+        )
+        self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
+
+    def test_fields_not_in_cleaned_data_skipped(self):
+        """Fields not present in cleaned_data should be skipped gracefully"""
+        # Create minimal form data - some nullable fields won't be in cleaned_data
+        form = InterfaceImportForm(
+            data={
+                'device': self.device,
+                'name': 'Interface 10',
+                'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
+                # lag, parent, bridge, vrf, speed, etc. not provided
+            }
+        )
+        # Should not raise KeyError when checking fields not in form data
+        self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
+
+    def test_valid_string_values_preserved(self):
+        """Non-empty string values should be properly converted to their target types"""
+        form = InterfaceImportForm(
+            data={
+                'device': self.device,
+                'name': 'Interface 11',
+                'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
+                'speed': '1000000',  # Valid speed value (string will be converted to int)
+                'mtu': '1500',  # Valid mtu value (string will be converted to int)
+                'description': 'Test description',
+            }
+        )
+        self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
+        # speed and mtu are converted to int
+        self.assertEqual(form.cleaned_data['speed'], 1000000)
+        self.assertEqual(form.cleaned_data['mtu'], 1500)
+        self.assertEqual(form.cleaned_data['description'], 'Test description')
+
+    def test_multiple_nullable_fields_with_empty_strings(self):
+        """Multiple nullable fields with empty strings should all convert to None"""
+        form = InterfaceImportForm(
+            data={
+                'device': self.device,
+                'name': 'Interface 12',
+                'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
+                'speed': '',  # nullable
+                'duplex': '',  # nullable
+                'tx_power': '',  # nullable
+                'vrf': '',  # nullable ForeignKey
+                'poe_mode': '',  # nullable
+                'poe_type': '',  # nullable
+            }
+        )
+        self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
+        # All nullable fields should convert to None
+        self.assertIsNone(form.cleaned_data['speed'])
+        self.assertIsNone(form.cleaned_data['duplex'])
+        self.assertIsNone(form.cleaned_data['tx_power'])
+        self.assertIsNone(form.cleaned_data['vrf'])
+        self.assertIsNone(form.cleaned_data['poe_mode'])
+        self.assertIsNone(form.cleaned_data['poe_type'])
+
+    def test_mixed_nullable_and_non_nullable_empty_strings(self):
+        """Combination of nullable and non-nullable fields with empty strings"""
+        form = InterfaceImportForm(
+            data={
+                'device': self.device,
+                'name': 'Interface 13',
+                'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
+                'speed': '',  # nullable, should become None
+                'label': '',  # NOT nullable (blank=True only), should remain empty string
+                'duplex': '',  # nullable, should become None
+                'description': '',  # NOT nullable (blank=True only), should remain empty string
+            }
+        )
+        self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
+        # Nullable fields convert to None
+        self.assertIsNone(form.cleaned_data['speed'])
+        self.assertIsNone(form.cleaned_data['duplex'])
+        # Non-nullable fields remain empty strings
+        self.assertEqual(form.cleaned_data['label'], '')
+        self.assertEqual(form.cleaned_data['description'], '')
+
+    def test_wireless_fields_nullable(self):
+        """Wireless-specific nullable fields should convert empty strings to None"""
+        form = InterfaceImportForm(
+            data={
+                'device': self.device,
+                'name': 'Interface 14',
+                'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
+                'rf_role': '',  # nullable CharField
+                'rf_channel': '',  # nullable CharField
+                'rf_channel_frequency': '',  # nullable DecimalField
+                'rf_channel_width': '',  # nullable DecimalField
+            }
+        )
+        self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
+        self.assertIsNone(form.cleaned_data['rf_role'])
+        self.assertIsNone(form.cleaned_data['rf_channel'])
+        self.assertIsNone(form.cleaned_data['rf_channel_frequency'])
+        self.assertIsNone(form.cleaned_data['rf_channel_width'])
+
+    def test_poe_fields_nullable(self):
+        """PoE-specific nullable fields should convert empty strings to None"""
+        form = InterfaceImportForm(
+            data={
+                'device': self.device,
+                'name': 'Interface 15',
+                'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
+                'poe_mode': '',  # nullable CharField
+                'poe_type': '',  # nullable CharField
+            }
+        )
+        self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
+        self.assertIsNone(form.cleaned_data['poe_mode'])
+        self.assertIsNone(form.cleaned_data['poe_type'])
+
+    def test_wwn_field_nullable(self):
+        """WWN field (special field type) should convert empty string to None"""
+        form = InterfaceImportForm(
+            data={
+                'device': self.device,
+                'name': 'Interface 16',
+                'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
+                'wwn': '',  # nullable WWNField
+            }
+        )
+        self.assertTrue(form.is_valid(), f'Form errors: {form.errors}')
+        self.assertIsNone(form.cleaned_data['wwn'])

+ 28 - 7
netbox/netbox/views/generic/bulk_views.py

@@ -323,7 +323,7 @@ class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView):
 
 class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
     """
-    Import objects in bulk (CSV format).
+    Import objects in bulk (CSV/JSON/YAML format).
 
     Attributes:
         model_form: The form used to create each imported object
@@ -368,7 +368,7 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
                 error_messages.append(f"Record {index} {prefix}{field_name}: {err}")
         return error_messages
 
-    def _save_object(self, model_form, request):
+    def _save_object(self, model_form, request, parent_idx):
         _action = 'Updated' if model_form.instance.pk else 'Created'
 
         # Save the primary object
@@ -381,8 +381,25 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
         # Iterate through the related object forms (if any), validating and saving each instance.
         for field_name, related_object_form in self.related_object_forms.items():
 
+            related_objects = model_form.data.get(field_name, list())
+            if not isinstance(related_objects, list):
+                raise ValidationError(
+                    self._compile_form_errors(
+                        {field_name: [_("Must be a list.")]},
+                        index=parent_idx
+                    )
+                )
+
             related_obj_pks = []
-            for i, rel_obj_data in enumerate(model_form.data.get(field_name, list())):
+            for i, rel_obj_data in enumerate(related_objects, start=1):
+                if not isinstance(rel_obj_data, dict):
+                    raise ValidationError(
+                        self._compile_form_errors(
+                            {f'{field_name}[{i}]': [_("Must be a dictionary.")]},
+                            index=parent_idx,
+                        )
+                    )
+
                 rel_obj_data = self.prep_related_object_data(obj, rel_obj_data)
                 f = related_object_form(rel_obj_data)
 
@@ -396,7 +413,7 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
                 else:
                     # Replicate errors on the related object form to the import form for display and abort
                     raise ValidationError(
-                        self._compile_form_errors(f.errors, index=i, prefix=f'{field_name}[{i}]')
+                        self._compile_form_errors(f.errors, index=parent_idx, prefix=f'{field_name}[{i}]')
                     )
 
             # Enforce object-level permissions on related objects
@@ -439,8 +456,12 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
                 try:
                     instance = prefetched_objects[object_id]
                 except KeyError:
-                    form.add_error('data', _("Row {i}: Object with ID {id} does not exist").format(i=i, id=object_id))
-                    raise ValidationError('')
+                    raise ValidationError(
+                        self._compile_form_errors(
+                            {'id': [_("Object with ID {id} does not exist").format(id=object_id)]},
+                            index=i
+                        )
+                    )
 
                 # Take a snapshot for change logging
                 if instance.pk and hasattr(instance, 'snapshot'):
@@ -481,7 +502,7 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
             restrict_form_fields(model_form, request.user)
 
             if model_form.is_valid():
-                obj = self._save_object(model_form, request)
+                obj = self._save_object(model_form, request, i)
                 saved_objects.append(obj)
             else:
                 # Raise model form errors

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

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

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

@@ -3190,10 +3190,10 @@ safe-regex-test@^1.1.0:
     es-errors "^1.3.0"
     is-regex "^1.2.1"
 
-sass@1.93.2:
-  version "1.93.2"
-  resolved "https://registry.yarnpkg.com/sass/-/sass-1.93.2.tgz#e97d225d60f59a3b3dbb6d2ae3c1b955fd1f2cd1"
-  integrity sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==
+sass@1.94.0:
+  version "1.94.0"
+  resolved "https://registry.yarnpkg.com/sass/-/sass-1.94.0.tgz#a04198d8940358ca6ad537d2074051edbbe7c1a7"
+  integrity sha512-Dqh7SiYcaFtdv5Wvku6QgS5IGPm281L+ZtVD1U2FJa7Q0EFRlq8Z3sjYtz6gYObsYThUOz9ArwFqPZx+1azILQ==
   dependencies:
     chokidar "^4.0.0"
     immutable "^5.0.2"

+ 2 - 2
netbox/release.yaml

@@ -1,3 +1,3 @@
-version: "4.4.5"
+version: "4.4.6"
 edition: "Community"
-published: "2025-10-28"
+published: "2025-11-11"

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

@@ -6,7 +6,7 @@
 
 {% block breadcrumbs %}
   <li class="breadcrumb-item"><a href="{% url 'core:background_queue_list' %}">{% trans 'Background Tasks' %}</a></li>
-  <li class="breadcrumb-item"><a href="{% url 'core:background_task_list' queue_index=queue_index status=job.get_status %}">{{ queue.name }}</a></li>
+  <li class="breadcrumb-item"><a href="{% url 'core:background_task_list' queue_index=queue_index status=job.get_status.value %}">{{ queue.name }}</a></li>
 {% endblock breadcrumbs %}
 
 {% block title %}{% trans "Job" %} {{ job.id }}{% endblock %}

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


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 183 - 183
netbox/translations/cs/LC_MESSAGES/django.po


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


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 183 - 183
netbox/translations/da/LC_MESSAGES/django.po


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


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 183 - 183
netbox/translations/de/LC_MESSAGES/django.po


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 185 - 185
netbox/translations/en/LC_MESSAGES/django.po


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


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 183 - 183
netbox/translations/es/LC_MESSAGES/django.po


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


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 183 - 183
netbox/translations/fr/LC_MESSAGES/django.po


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


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 183 - 183
netbox/translations/it/LC_MESSAGES/django.po


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


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 183 - 183
netbox/translations/ja/LC_MESSAGES/django.po


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


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 183 - 183
netbox/translations/nl/LC_MESSAGES/django.po


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


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 183 - 183
netbox/translations/pl/LC_MESSAGES/django.po


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


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 183 - 183
netbox/translations/pt/LC_MESSAGES/django.po


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


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 183 - 183
netbox/translations/ru/LC_MESSAGES/django.po


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


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 183 - 183
netbox/translations/tr/LC_MESSAGES/django.po


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


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 184 - 184
netbox/translations/uk/LC_MESSAGES/django.po


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


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 184 - 183
netbox/translations/zh/LC_MESSAGES/django.po


+ 3 - 0
netbox/users/forms/model_forms.py

@@ -384,6 +384,9 @@ class ObjectPermissionForm(forms.ModelForm):
         elif self.initial:
             # Handle cloned objects - actions come from initial data (URL parameters)
             if 'actions' in self.initial:
+                # Normalize actions to a list of strings
+                if isinstance(self.initial['actions'], str):
+                    self.initial['actions'] = [self.initial['actions']]
                 if cloned_actions := self.initial['actions']:
                     for action in ['view', 'add', 'change', 'delete']:
                         if action in cloned_actions:

+ 20 - 10
netbox/utilities/counters.py

@@ -77,7 +77,7 @@ def post_delete_receiver(sender, instance, origin, **kwargs):
         parent_pk = getattr(instance, field_name, None)
 
         # Decrement the parent's counter by one
-        if parent_pk is not None and not hasattr(instance, "_previously_removed"):
+        if parent_pk is not None and not hasattr(instance, '_previously_removed'):
             update_counter(parent_model, parent_pk, counter_name, -1)
 
 
@@ -87,38 +87,48 @@ def post_delete_receiver(sender, instance, origin, **kwargs):
 
 def connect_counters(*models):
     """
-    Register counter fields and connect post_save & post_delete signal handlers for the affected models.
+    Register counter fields and connect signal handlers for their child models.
+    Ensures exactly one receiver per child (sender), even when multiple counters
+    reference the same sender (e.g., Device).
     """
-    for model in models:
+    connected = set()  # child models we've already connected
 
+    for model in models:
         # Find all CounterCacheFields on the model
-        counter_fields = [
-            field for field in model._meta.get_fields() if type(field) is CounterCacheField
-        ]
+        counter_fields = [field for field in model._meta.get_fields() if isinstance(field, CounterCacheField)]
 
         for field in counter_fields:
             to_model = apps.get_model(field.to_model_name)
 
             # Register the counter in the registry
             change_tracking_fields = registry['counter_fields'][to_model]
-            change_tracking_fields[f"{field.to_field_name}_id"] = field.name
+            change_tracking_fields[f'{field.to_field_name}_id'] = field.name
+
+            # Connect signals once per child model
+            if to_model in connected:
+                continue
+
+            # Ensure dispatch_uid is unique per model (sender), not per field
+            uid_base = f'countercache.{to_model._meta.label_lower}'
 
             # Connect the post_save and post_delete handlers
             post_save.connect(
                 post_save_receiver,
                 sender=to_model,
                 weak=False,
-                dispatch_uid=f'{model._meta.label}.{field.name}'
+                dispatch_uid=f'{uid_base}.post_save',
             )
             pre_delete.connect(
                 pre_delete_receiver,
                 sender=to_model,
                 weak=False,
-                dispatch_uid=f'{model._meta.label}.{field.name}'
+                dispatch_uid=f'{uid_base}.pre_delete',
             )
             post_delete.connect(
                 post_delete_receiver,
                 sender=to_model,
                 weak=False,
-                dispatch_uid=f'{model._meta.label}.{field.name}'
+                dispatch_uid=f'{uid_base}.post_delete',
             )
+
+            connected.add(to_model)

+ 21 - 1
netbox/utilities/forms/fields/csv.py

@@ -18,6 +18,20 @@ __all__ = (
 )
 
 
+class CSVSelectWidget(forms.Select):
+    """
+    Custom Select widget for CSV imports that treats blank values as omitted.
+    This allows model defaults to be applied when a CSV field is present but empty.
+    """
+    def value_omitted_from_data(self, data, files, name):
+        # Check if value is omitted using parent behavior
+        if super().value_omitted_from_data(data, files, name):
+            return True
+        # Treat blank/empty strings as omitted to allow model defaults
+        value = data.get(name)
+        return value == '' or value is None
+
+
 class CSVChoicesMixin:
     STATIC_CHOICES = True
 
@@ -29,8 +43,9 @@ class CSVChoicesMixin:
 class CSVChoiceField(CSVChoicesMixin, forms.ChoiceField):
     """
     A CSV field which accepts a single selection value.
+    Treats blank CSV values as omitted to allow model defaults.
     """
-    pass
+    widget = CSVSelectWidget
 
 
 class CSVMultipleChoiceField(CSVChoicesMixin, forms.MultipleChoiceField):
@@ -46,7 +61,12 @@ class CSVMultipleChoiceField(CSVChoicesMixin, forms.MultipleChoiceField):
 
 
 class CSVTypedChoiceField(forms.TypedChoiceField):
+    """
+    A CSV field for typed choice values.
+    Treats blank CSV values as omitted to allow model defaults.
+    """
     STATIC_CHOICES = True
+    widget = CSVSelectWidget
 
 
 class CSVModelChoiceField(forms.ModelChoiceField):

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

@@ -4,6 +4,7 @@ from django.test import TestCase
 from dcim.models import Site
 from netbox.choices import ImportFormatChoices
 from utilities.forms.bulk_import import BulkImportForm
+from utilities.forms.fields.csv import CSVSelectWidget
 from utilities.forms.forms import BulkRenameForm
 from utilities.forms.utils import get_field_value, expand_alphanumeric_pattern, expand_ipaddress_pattern
 
@@ -448,3 +449,35 @@ class GetFieldValueTest(TestCase):
             get_field_value(form, 'site'),
             None
         )
+
+
+class CSVSelectWidgetTest(TestCase):
+    """
+    Validate that CSVSelectWidget treats blank values as omitted.
+    This allows model defaults to be applied when CSV fields are present but empty.
+    Related to issue #20645.
+    """
+
+    def test_blank_value_treated_as_omitted(self):
+        """Test that blank string values are treated as omitted"""
+        widget = CSVSelectWidget()
+        data = {'test_field': ''}
+        self.assertTrue(widget.value_omitted_from_data(data, {}, 'test_field'))
+
+    def test_none_value_treated_as_omitted(self):
+        """Test that None values are treated as omitted"""
+        widget = CSVSelectWidget()
+        data = {'test_field': None}
+        self.assertTrue(widget.value_omitted_from_data(data, {}, 'test_field'))
+
+    def test_missing_field_treated_as_omitted(self):
+        """Test that missing fields are treated as omitted"""
+        widget = CSVSelectWidget()
+        data = {}
+        self.assertTrue(widget.value_omitted_from_data(data, {}, 'test_field'))
+
+    def test_valid_value_not_omitted(self):
+        """Test that valid values are not treated as omitted"""
+        widget = CSVSelectWidget()
+        data = {'test_field': 'valid_value'}
+        self.assertFalse(widget.value_omitted_from_data(data, {}, 'test_field'))

+ 52 - 10
netbox/virtualization/forms/bulk_import.py

@@ -1,15 +1,16 @@
+from django.utils.safestring import mark_safe
 from django.utils.translation import gettext_lazy as _
 
 from dcim.choices import InterfaceModeChoices
 from dcim.forms.mixins import ScopedImportForm
 from dcim.models import Device, DeviceRole, Platform, Site
 from extras.models import ConfigTemplate
-from ipam.models import VRF
-from netbox.forms import (
-    NetBoxModelImportForm, OrganizationalModelImportForm, OwnerCSVMixin, PrimaryModelImportForm,
-)
+from ipam.choices import VLANQinQRoleChoices
+from ipam.models import VLAN, VRF, VLANGroup
+from netbox.forms import NetBoxModelImportForm
+from netbox.forms import OrganizationalModelImportForm, OwnerCSVMixin, PrimaryModelImportForm
 from tenancy.models import Tenant
-from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField
+from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, CSVModelMultipleChoiceField
 from virtualization.choices import *
 from virtualization.models import *
 
@@ -159,20 +160,54 @@ class VMInterfaceImportForm(OwnerCSVMixin, NetBoxModelImportForm):
         queryset=VMInterface.objects.all(),
         required=False,
         to_field_name='name',
-        help_text=_('Parent interface')
+        help_text=_('Parent interface'),
     )
     bridge = CSVModelChoiceField(
         label=_('Bridge'),
         queryset=VMInterface.objects.all(),
         required=False,
         to_field_name='name',
-        help_text=_('Bridged interface')
+        help_text=_('Bridged interface'),
     )
     mode = CSVChoiceField(
         label=_('Mode'),
         choices=InterfaceModeChoices,
         required=False,
-        help_text=_('IEEE 802.1Q operational mode (for L2 interfaces)')
+        help_text=_('IEEE 802.1Q operational mode (for L2 interfaces)'),
+    )
+    vlan_group = CSVModelChoiceField(
+        label=_('VLAN group'),
+        queryset=VLANGroup.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text=_('Filter VLANs available for assignment by group'),
+    )
+    untagged_vlan = CSVModelChoiceField(
+        label=_('Untagged VLAN'),
+        queryset=VLAN.objects.all(),
+        required=False,
+        to_field_name='vid',
+        help_text=_('Assigned untagged VLAN ID (filtered by VLAN group)'),
+    )
+    tagged_vlans = CSVModelMultipleChoiceField(
+        label=_('Tagged VLANs'),
+        queryset=VLAN.objects.all(),
+        required=False,
+        to_field_name='vid',
+        help_text=mark_safe(
+            _(
+                'Assigned tagged VLAN IDs separated by commas, encased with double quotes '
+                '(filtered by VLAN group). Example:'
+            )
+            + ' <code>"100,200,300"</code>'
+        ),
+    )
+    qinq_svlan = CSVModelChoiceField(
+        label=_('Q-in-Q Service VLAN'),
+        queryset=VLAN.objects.filter(qinq_role=VLANQinQRoleChoices.ROLE_SERVICE),
+        required=False,
+        to_field_name='vid',
+        help_text=_('Assigned Q-in-Q Service VLAN ID (filtered by VLAN group)'),
     )
     vrf = CSVModelChoiceField(
         label=_('VRF'),
@@ -185,8 +220,8 @@ class VMInterfaceImportForm(OwnerCSVMixin, NetBoxModelImportForm):
     class Meta:
         model = VMInterface
         fields = (
-            'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mtu', 'description', 'mode',
-            'vrf', 'owner', 'tags'
+            'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mtu', 'description', 'mode', 'vlan_group',
+            'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vrf', 'owner', 'tags',
         )
 
     def __init__(self, data=None, *args, **kwargs):
@@ -201,6 +236,13 @@ class VMInterfaceImportForm(OwnerCSVMixin, NetBoxModelImportForm):
                 self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
                 self.fields['bridge'].queryset = self.fields['bridge'].queryset.filter(**params)
 
+            # Limit choices for VLANs to the assigned VLAN group
+            if vlan_group := data.get('vlan_group'):
+                params = {f"group__{self.fields['vlan_group'].to_field_name}": vlan_group}
+                self.fields['untagged_vlan'].queryset = self.fields['untagged_vlan'].queryset.filter(**params)
+                self.fields['tagged_vlans'].queryset = self.fields['tagged_vlans'].queryset.filter(**params)
+                self.fields['qinq_svlan'].queryset = self.fields['qinq_svlan'].queryset.filter(**params)
+
     def clean_enabled(self):
         # Make sure enabled is True when it's not included in the uploaded data
         if 'enabled' not in self.data:

+ 1 - 1
netbox/virtualization/object_actions.py

@@ -1,4 +1,4 @@
-from django.utils.translation import gettext as _
+from django.utils.translation import gettext_lazy as _
 
 from netbox.object_actions import ObjectAction
 

+ 13 - 4
netbox/virtualization/tests/test_views.py

@@ -395,10 +395,19 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
         }
 
         cls.csv_data = (
-            "virtual_machine,name,vrf.pk",
-            f"Virtual Machine 2,Interface 4,{vrfs[0].pk}",
-            f"Virtual Machine 2,Interface 5,{vrfs[0].pk}",
-            f"Virtual Machine 2,Interface 6,{vrfs[0].pk}",
+            "virtual_machine,name,vrf.pk,mode,untagged_vlan,tagged_vlans",
+            (
+                f"Virtual Machine 2,Interface 4,{vrfs[0].pk},"
+                f"tagged,{vlans[0].vid},'{','.join([str(v.vid) for v in vlans[1:4]])}'"
+            ),
+            (
+                f"Virtual Machine 2,Interface 5,{vrfs[0].pk},"
+                f"tagged,{vlans[0].vid},'{','.join([str(v.vid) for v in vlans[1:4]])}'"
+            ),
+            (
+                f"Virtual Machine 2,Interface 6,{vrfs[0].pk},"
+                f"tagged,{vlans[0].vid},'{','.join([str(v.vid) for v in vlans[1:4]])}'"
+            ),
         )
 
         cls.csv_update_data = (

+ 1 - 1
pyproject.toml

@@ -3,7 +3,7 @@
 
 [project]
 name = "netbox"
-version = "4.4.5"
+version = "4.4.6"
 requires-python = ">=3.10"
 description = "The premier source of truth powering network automation."
 readme = "README.md"

+ 7 - 7
requirements.txt

@@ -1,7 +1,7 @@
 colorama==0.4.6
-Django==5.2.7
+Django==5.2.8
 django-cors-headers==4.9.0
-django-debug-toolbar==6.0.0
+django-debug-toolbar==6.1.0
 django-filter==25.2
 django-graphiql-debug-toolbar==0.2.0
 django-htmx==1.26.0
@@ -16,18 +16,18 @@ django-tables2==2.7.5
 django-taggit==6.1.0
 django-timezone-field==7.1
 djangorestframework==3.16.1
-drf-spectacular==0.28.0
+drf-spectacular==0.29.0
 drf-spectacular-sidecar==2025.10.1
 feedparser==6.0.12
 gunicorn==23.0.0
 Jinja2==3.1.6
 jsonschema==4.25.1
-Markdown==3.9
+Markdown==3.10
 mkdocs-material==9.6.22
 mkdocstrings==0.30.1
-mkdocstrings-python==1.18.2
+mkdocstrings-python==1.19.0
 netaddr==1.3.0
-nh3==0.3.1
+nh3==0.3.2
 Pillow==12.0.0
 psycopg[c,pool]==3.2.12
 PyYAML==6.0.3
@@ -36,7 +36,7 @@ rq==2.6.0
 social-auth-app-django==5.6.0
 social-auth-core==4.8.1
 sorl-thumbnail==12.11.0
-strawberry-graphql==0.284.1
+strawberry-graphql==0.285.0
 strawberry-graphql-django==0.67.0
 svgwrite==1.4.3
 tablib==3.9.0

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно