Arthur 1 день назад
Родитель
Сommit
9ac8d1574e
100 измененных файлов с 4847 добавлено и 537 удалено
  1. 3 0
      .github/VOUCHED.td
  2. 1 1
      .github/workflows/ci.yml
  3. 1 1
      .pre-commit-config.yaml
  4. 3 1
      THREAT_MODEL.md
  5. 2 1
      base_requirements.txt
  6. 46 25
      contrib/openapi.json
  7. 298 0
      docs/best-practices/modeling-servers.md
  8. 14 1
      docs/configuration/miscellaneous.md
  9. 1 0
      docs/configuration/system.md
  10. 30 0
      docs/integrations/graphql-api.md
  11. 1 1
      docs/models/dcim/module.md
  12. 3 0
      docs/release-notes/version-4.6.md
  13. 40 0
      docs/release-notes/version-4.7.md
  14. 1 0
      mkdocs.yml
  15. 27 0
      netbox/account/tests.py
  16. 1 9
      netbox/account/views.py
  17. 34 0
      netbox/circuits/migrations/0061_circuitterminations_cached_ancestors_on_delete.py
  18. 11 2
      netbox/circuits/models/circuits.py
  19. 66 1
      netbox/circuits/tests/test_models.py
  20. 0 1
      netbox/core/filtersets.py
  21. 12 5
      netbox/core/models/jobs.py
  22. 18 1
      netbox/core/signals.py
  23. 14 1
      netbox/core/tests/test_filtersets.py
  24. 38 2
      netbox/core/tests/test_jobs.py
  25. 20 0
      netbox/core/tests/test_models.py
  26. 57 0
      netbox/core/tests/test_signals.py
  27. 14 11
      netbox/dcim/api/serializers_/devices.py
  28. 38 16
      netbox/dcim/api/views.py
  29. 1 0
      netbox/dcim/filtersets.py
  30. 30 37
      netbox/dcim/forms/bulk_import.py
  31. 22 13
      netbox/dcim/forms/common.py
  32. 8 3
      netbox/dcim/forms/mixins.py
  33. 44 16
      netbox/dcim/models/cables.py
  34. 1 1
      netbox/dcim/models/device_component_templates.py
  35. 31 0
      netbox/dcim/models/device_components.py
  36. 1 0
      netbox/dcim/models/devices.py
  37. 24 11
      netbox/dcim/models/module_moves.py
  38. 20 4
      netbox/dcim/models/modules.py
  39. 12 0
      netbox/dcim/models/sites.py
  40. 9 1
      netbox/dcim/signals.py
  41. 2 2
      netbox/dcim/tests/query_counts.json
  42. 389 29
      netbox/dcim/tests/test_api.py
  43. 573 0
      netbox/dcim/tests/test_cablepaths.py
  44. 130 3
      netbox/dcim/tests/test_channelization.py
  45. 4 0
      netbox/dcim/tests/test_filtersets.py
  46. 133 0
      netbox/dcim/tests/test_forms.py
  47. 175 0
      netbox/dcim/tests/test_models.py
  48. 38 1
      netbox/dcim/tests/test_module_moves.py
  49. 73 0
      netbox/dcim/tests/test_port_mappings.py
  50. 122 3
      netbox/dcim/tests/test_views.py
  51. 25 1
      netbox/dcim/tests/utils.py
  52. 33 13
      netbox/dcim/utils.py
  53. 4 2
      netbox/extras/dashboard/utils.py
  54. 1 1
      netbox/extras/forms/bulk_edit.py
  55. 5 1
      netbox/extras/forms/bulk_import.py
  56. 1 1
      netbox/extras/forms/model_forms.py
  57. 10 0
      netbox/extras/models/customfields.py
  58. 4 0
      netbox/extras/models/models.py
  59. 37 0
      netbox/extras/tests/test_customfields.py
  60. 37 0
      netbox/extras/tests/test_forms.py
  61. 33 1
      netbox/extras/tests/test_models.py
  62. 32 0
      netbox/extras/tests/test_views.py
  63. 4 2
      netbox/extras/views.py
  64. 13 0
      netbox/ipam/filtersets.py
  65. 4 8
      netbox/ipam/forms/bulk_import.py
  66. 56 0
      netbox/ipam/tests/test_filtersets.py
  67. 49 5
      netbox/ipam/tests/test_views.py
  68. 5 1
      netbox/netbox/api/authentication.py
  69. 57 0
      netbox/netbox/api/filters.py
  70. 25 22
      netbox/netbox/api/viewsets/__init__.py
  71. 1 4
      netbox/netbox/api/viewsets/mixins.py
  72. 209 0
      netbox/netbox/graphql/extensions.py
  73. 2 0
      netbox/netbox/graphql/schema.py
  74. 9 4
      netbox/netbox/graphql/types.py
  75. 6 1
      netbox/netbox/graphql/views.py
  76. 72 62
      netbox/netbox/jobs.py
  77. 31 7
      netbox/netbox/middleware.py
  78. 23 0
      netbox/netbox/models/features.py
  79. 3 31
      netbox/netbox/search/backends.py
  80. 13 4
      netbox/netbox/settings.py
  81. 51 2
      netbox/netbox/tests/test_api.py
  82. 82 0
      netbox/netbox/tests/test_authentication.py
  83. 336 1
      netbox/netbox/tests/test_graphql.py
  84. 245 5
      netbox/netbox/tests/test_jobs.py
  85. 344 3
      netbox/netbox/tests/test_middleware.py
  86. 69 2
      netbox/netbox/tests/test_model_features.py
  87. 41 5
      netbox/netbox/tests/test_search.py
  88. 55 4
      netbox/netbox/tests/test_views.py
  89. 3 4
      netbox/netbox/views/errors.py
  90. 3 0
      netbox/netbox/views/generic/bulk_views.py
  91. 1 1
      netbox/netbox/views/generic/feature_views.py
  92. 6 6
      netbox/netbox/views/generic/object_views.py
  93. 0 0
      netbox/project-static/dist/netbox.css
  94. 0 0
      netbox/project-static/dist/netbox.js
  95. 0 0
      netbox/project-static/dist/netbox.js.map
  96. 9 9
      netbox/project-static/package.json
  97. 123 117
      netbox/project-static/yarn.lock
  98. 2 2
      netbox/release.yaml
  99. 2 1
      netbox/templates/500.html
  100. 35 0
      netbox/templates/exceptions/maintenance_mode.html

+ 3 - 0
.github/VOUCHED.td

@@ -69,6 +69,7 @@ guibi1
 haeki
 haeki
 harryajc
 harryajc
 hblandford
 hblandford
+himanchalkaushale
 ianb-mp
 ianb-mp
 ifoughal
 ifoughal
 ipb-devops
 ipb-devops
@@ -133,6 +134,8 @@ rinna11
 robduffy2010
 robduffy2010
 roberth1993
 roberth1993
 rodvand
 rodvand
+sebb0
+sensiblecoder
 sergiolpzgmz
 sergiolpzgmz
 stalker-nn
 stalker-nn
 studioussagar
 studioussagar

+ 1 - 1
.github/workflows/ci.yml

@@ -85,7 +85,7 @@ jobs:
       - name: Check Python linting & PEP8 compliance
       - name: Check Python linting & PEP8 compliance
         uses: astral-sh/ruff-action@278981a28ce3188b1e39527901f38254bf3aac89  # v4.1.0
         uses: astral-sh/ruff-action@278981a28ce3188b1e39527901f38254bf3aac89  # v4.1.0
         with:
         with:
-          version: "0.15.20"
+          version: "0.17.4"
           args: "check --output-format=github"
           args: "check --output-format=github"
           src: "netbox/"
           src: "netbox/"
 
 

+ 1 - 1
.pre-commit-config.yaml

@@ -1,6 +1,6 @@
 repos:
 repos:
 - repo: https://github.com/astral-sh/ruff-pre-commit
 - repo: https://github.com/astral-sh/ruff-pre-commit
-  rev: v0.15.20
+  rev: v0.17.4
   hooks:
   hooks:
     - id: ruff
     - id: ruff
       name: "Ruff linter"
       name: "Ruff linter"

+ 3 - 1
THREAT_MODEL.md

@@ -32,6 +32,7 @@ The central question when evaluating any NetBox security report is: **does the a
 | PostgreSQL database, Redis | **Trusted** | Assumed reachable only by the application. |
 | PostgreSQL database, Redis | **Trusted** | Assumed reachable only by the application. |
 | Infrastructure operators | **Trusted** | Shell/filesystem/DB access implies total control by design. |
 | Infrastructure operators | **Trusted** | Shell/filesystem/DB access implies total control by design. |
 | Superusers (`is_superuser`) | **Trusted** | An active superuser bypasses all object-level permission checks. This is intentional. |
 | Superusers (`is_superuser`) | **Trusted** | An active superuser bypasses all object-level permission checks. This is intentional. |
+| Users permitted to manage users (`users.add_user` or `users.change_user`) | **Trusted** | Can assign groups, object permissions, and superuser status to users, including themselves. Equivalent to a superuser by design. |
 | Users permitted to author code-bearing objects | **Trusted** | Holders of permissions to create/modify custom scripts, export templates, config templates, custom links, or webhooks (see below). |
 | Users permitted to author code-bearing objects | **Trusted** | Holders of permissions to create/modify custom scripts, export templates, config templates, custom links, or webhooks (see below). |
 | Authenticated users **without** those permissions | **Untrusted** | Subject to full object-based permission enforcement. |
 | Authenticated users **without** those permissions | **Untrusted** | Subject to full object-based permission enforcement. |
 | Unauthenticated / network-adjacent parties | **Untrusted** | Outside the supported deployment model entirely. |
 | Unauthenticated / network-adjacent parties | **Untrusted** | Outside the supported deployment model entirely. |
@@ -96,6 +97,7 @@ The following are **not** treated as NetBox vulnerabilities. Most describe a pri
 | --- | --- |-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
 | --- | --- |-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
 | A permitted user runs code via a custom script, Jinja template, custom link, or webhook | Not a vulnerability | These features exist to execute user-authored logic; the required permission is trusted, operator-tier by design.                                                                                               |
 | A permitted user runs code via a custom script, Jinja template, custom link, or webhook | Not a vulnerability | These features exist to execute user-authored logic; the required permission is trusted, operator-tier by design.                                                                                               |
 | A superuser modifies the database, reads secrets, or creates another superuser | Not a vulnerability | Superusers and infrastructure operators are trusted by design.                                                                                                                                                  |
 | A superuser modifies the database, reads secrets, or creates another superuser | Not a vulnerability | Superusers and infrastructure operators are trusted by design.                                                                                                                                                  |
+| A user permitted to add or change users grants themselves (or another user) superuser status or additional permissions | Not a vulnerability | User management permissions are administrative by design.                                                                                                                                                       |
 | A template author reads NetBox data they are otherwise permitted to view | Not a vulnerability | Template rendering with objects in scope is the purpose of the feature; the sandbox, not permission scoping, is the boundary.                                                                                   |
 | A template author reads NetBox data they are otherwise permitted to view | Not a vulnerability | Template rendering with objects in scope is the purpose of the feature; the sandbox, not permission scoping, is the boundary.                                                                                   |
 | Missing login/request rate limiting | Out of scope | A deployment-layer concern, handled by the reverse proxy rather than the application.                                                                                                                           |
 | Missing login/request rate limiting | Out of scope | A deployment-layer concern, handled by the reverse proxy rather than the application.                                                                                                                           |
 | Client IP spoofing via `X-Forwarded-For` and similar headers | Out of scope | NetBox trusts the headers the reverse proxy sets; trustworthy client IP is a proxy responsibility ([`HTTP_CLIENT_IP_HEADERS`](https://netboxlabs.com/docs/netbox/configuration/system/#http_client_ip_headers)). |
 | Client IP spoofing via `X-Forwarded-For` and similar headers | Out of scope | NetBox trusts the headers the reverse proxy sets; trustworthy client IP is a proxy responsibility ([`HTTP_CLIENT_IP_HEADERS`](https://netboxlabs.com/docs/netbox/configuration/system/#http_client_ip_headers)). |
@@ -112,7 +114,7 @@ The following are **not** treated as NetBox vulnerabilities. Most describe a pri
 | **R**epudiation | Changes are recorded via the changelog and journaling; event rules can emit notifications. |
 | **R**epudiation | Changes are recorded via the changelog and journaling; event rules can emit notifications. |
 | **I**nformation disclosure | Object-based view permissions filter every queryset. Cross-boundary disclosure (e.g. API/GraphQL filter injection) is in scope; data legitimately visible to a template author is not. |
 | **I**nformation disclosure | Object-based view permissions filter every queryset. Cross-boundary disclosure (e.g. API/GraphQL filter injection) is in scope; data legitimately visible to a template author is not. |
 | **D**enial of service | Request rate limiting and resource controls are a deployment/reverse-proxy responsibility, not the application's. |
 | **D**enial of service | Request rate limiting and resource controls are a deployment/reverse-proxy responsibility, not the application's. |
-| **E**levation of privilege | The superuser flag is all-or-nothing and trusted. Any *unintended* escalation across the permission system (constraint bypass, action bypass) is in scope. |
+| **E**levation of privilege | The superuser flag is all-or-nothing and trusted, as are user management permissions. Any *unintended* escalation across the permission system (constraint bypass, action bypass) is in scope. |
 
 
 ## Triage & Severity
 ## Triage & Severity
 
 

+ 2 - 1
base_requirements.txt

@@ -146,8 +146,9 @@ redis
 requests
 requests
 
 
 # rq
 # rq
+# Pinned below v3.0, whose new Worker.monitor_work_horse() signature breaks NetBox's tests
 # https://github.com/rq/rq/blob/master/CHANGES.md
 # https://github.com/rq/rq/blob/master/CHANGES.md
-rq
+rq<3.0
 
 
 # Django app for social-auth-core
 # Django app for social-auth-core
 # https://github.com/python-social-auth/social-app-django/blob/master/CHANGELOG.md
 # https://github.com/python-social-auth/social-app-django/blob/master/CHANGELOG.md

+ 46 - 25
contrib/openapi.json

@@ -2,7 +2,7 @@
     "openapi": "3.0.3",
     "openapi": "3.0.3",
     "info": {
     "info": {
         "title": "NetBox REST API",
         "title": "NetBox REST API",
-        "version": "4.7.4",
+        "version": "4.7.5",
         "license": {
         "license": {
             "name": "Apache v2 License"
             "name": "Apache v2 License"
         }
         }
@@ -30180,7 +30180,7 @@
                             "type": "array",
                             "type": "array",
                             "items": {
                             "items": {
                                 "type": "string",
                                 "type": "string",
-                                "x-spec-enum-id": "019cf94a890ea9e5"
+                                "x-spec-enum-id": "6a87b4ee518fe970"
                             }
                             }
                         },
                         },
                         "explode": true,
                         "explode": true,
@@ -30200,7 +30200,7 @@
                             "type": "array",
                             "type": "array",
                             "items": {
                             "items": {
                                 "type": "string",
                                 "type": "string",
-                                "x-spec-enum-id": "019cf94a890ea9e5"
+                                "x-spec-enum-id": "6a87b4ee518fe970"
                             }
                             }
                         },
                         },
                         "explode": true,
                         "explode": true,
@@ -30213,7 +30213,7 @@
                             "type": "array",
                             "type": "array",
                             "items": {
                             "items": {
                                 "type": "string",
                                 "type": "string",
-                                "x-spec-enum-id": "019cf94a890ea9e5"
+                                "x-spec-enum-id": "6a87b4ee518fe970"
                             }
                             }
                         },
                         },
                         "explode": true,
                         "explode": true,
@@ -30226,7 +30226,7 @@
                             "type": "array",
                             "type": "array",
                             "items": {
                             "items": {
                                 "type": "string",
                                 "type": "string",
-                                "x-spec-enum-id": "019cf94a890ea9e5"
+                                "x-spec-enum-id": "6a87b4ee518fe970"
                             }
                             }
                         },
                         },
                         "explode": true,
                         "explode": true,
@@ -30239,7 +30239,7 @@
                             "type": "array",
                             "type": "array",
                             "items": {
                             "items": {
                                 "type": "string",
                                 "type": "string",
-                                "x-spec-enum-id": "019cf94a890ea9e5"
+                                "x-spec-enum-id": "6a87b4ee518fe970"
                             }
                             }
                         },
                         },
                         "explode": true,
                         "explode": true,
@@ -30252,7 +30252,7 @@
                             "type": "array",
                             "type": "array",
                             "items": {
                             "items": {
                                 "type": "string",
                                 "type": "string",
-                                "x-spec-enum-id": "019cf94a890ea9e5"
+                                "x-spec-enum-id": "6a87b4ee518fe970"
                             }
                             }
                         },
                         },
                         "explode": true,
                         "explode": true,
@@ -30265,7 +30265,7 @@
                             "type": "array",
                             "type": "array",
                             "items": {
                             "items": {
                                 "type": "string",
                                 "type": "string",
-                                "x-spec-enum-id": "019cf94a890ea9e5"
+                                "x-spec-enum-id": "6a87b4ee518fe970"
                             }
                             }
                         },
                         },
                         "explode": true,
                         "explode": true,
@@ -30278,7 +30278,7 @@
                             "type": "array",
                             "type": "array",
                             "items": {
                             "items": {
                                 "type": "string",
                                 "type": "string",
-                                "x-spec-enum-id": "019cf94a890ea9e5"
+                                "x-spec-enum-id": "6a87b4ee518fe970"
                             }
                             }
                         },
                         },
                         "explode": true,
                         "explode": true,
@@ -30291,7 +30291,7 @@
                             "type": "array",
                             "type": "array",
                             "items": {
                             "items": {
                                 "type": "string",
                                 "type": "string",
-                                "x-spec-enum-id": "019cf94a890ea9e5"
+                                "x-spec-enum-id": "6a87b4ee518fe970"
                             }
                             }
                         },
                         },
                         "explode": true,
                         "explode": true,
@@ -30304,7 +30304,7 @@
                             "type": "array",
                             "type": "array",
                             "items": {
                             "items": {
                                 "type": "string",
                                 "type": "string",
-                                "x-spec-enum-id": "019cf94a890ea9e5"
+                                "x-spec-enum-id": "6a87b4ee518fe970"
                             }
                             }
                         },
                         },
                         "explode": true,
                         "explode": true,
@@ -30317,7 +30317,7 @@
                             "type": "array",
                             "type": "array",
                             "items": {
                             "items": {
                                 "type": "string",
                                 "type": "string",
-                                "x-spec-enum-id": "019cf94a890ea9e5"
+                                "x-spec-enum-id": "6a87b4ee518fe970"
                             }
                             }
                         },
                         },
                         "explode": true,
                         "explode": true,
@@ -30330,7 +30330,7 @@
                             "type": "array",
                             "type": "array",
                             "items": {
                             "items": {
                                 "type": "string",
                                 "type": "string",
-                                "x-spec-enum-id": "019cf94a890ea9e5"
+                                "x-spec-enum-id": "6a87b4ee518fe970"
                             }
                             }
                         },
                         },
                         "explode": true,
                         "explode": true,
@@ -277918,12 +277918,15 @@
                             "trunk-2c6p",
                             "trunk-2c6p",
                             "trunk-2c8p",
                             "trunk-2c8p",
                             "trunk-2c12p",
                             "trunk-2c12p",
+                            "trunk-3c8p",
+                            "trunk-3c12p",
                             "trunk-4c1p",
                             "trunk-4c1p",
                             "trunk-4c2p",
                             "trunk-4c2p",
                             "trunk-4c4p",
                             "trunk-4c4p",
                             "trunk-4c4p-shuffle",
                             "trunk-4c4p-shuffle",
                             "trunk-4c6p",
                             "trunk-4c6p",
                             "trunk-4c8p",
                             "trunk-4c8p",
+                            "trunk-4c12p",
                             "trunk-8c1p",
                             "trunk-8c1p",
                             "trunk-8c4p",
                             "trunk-8c4p",
                             "trunk-12c1p",
                             "trunk-12c1p",
@@ -277935,8 +277938,8 @@
                             "breakout-2c4p-8c1p-shuffle"
                             "breakout-2c4p-8c1p-shuffle"
                         ],
                         ],
                         "type": "string",
                         "type": "string",
-                        "description": "* `single-1c1p` - 1C1P\n* `single-1c2p` - 1C2P\n* `single-1c4p` - 1C4P\n* `single-1c6p` - 1C6P\n* `single-1c8p` - 1C8P\n* `single-1c12p` - 1C12P\n* `single-1c16p` - 1C16P\n* `trunk-2c1p` - 2C1P trunk\n* `trunk-2c2p` - 2C2P trunk\n* `trunk-2c4p` - 2C4P trunk\n* `trunk-2c4p-shuffle` - 2C4P trunk (shuffle)\n* `trunk-2c6p` - 2C6P trunk\n* `trunk-2c8p` - 2C8P trunk\n* `trunk-2c12p` - 2C12P trunk\n* `trunk-4c1p` - 4C1P trunk\n* `trunk-4c2p` - 4C2P trunk\n* `trunk-4c4p` - 4C4P trunk\n* `trunk-4c4p-shuffle` - 4C4P trunk (shuffle)\n* `trunk-4c6p` - 4C6P trunk\n* `trunk-4c8p` - 4C8P trunk\n* `trunk-8c1p` - 8C1P trunk\n* `trunk-8c4p` - 8C4P trunk\n* `trunk-12c1p` - 12C1P trunk\n* `trunk-24c1p` - 24C1P trunk\n* `breakout-1c2p-2c1p` - 1C2P:2C1P breakout\n* `breakout-1c4p-4c1p` - 1C4P:4C1P breakout\n* `breakout-1c6p-6c1p` - 1C6P:6C1P breakout\n* `breakout-1c8p-8c1p` - 1C8P:8C1P breakout\n* `breakout-2c4p-8c1p-shuffle` - 2C4P:8C1P breakout (shuffle)",
-                        "x-spec-enum-id": "019cf94a890ea9e5"
+                        "description": "* `single-1c1p` - 1C1P\n* `single-1c2p` - 1C2P\n* `single-1c4p` - 1C4P\n* `single-1c6p` - 1C6P\n* `single-1c8p` - 1C8P\n* `single-1c12p` - 1C12P\n* `single-1c16p` - 1C16P\n* `trunk-2c1p` - 2C1P trunk\n* `trunk-2c2p` - 2C2P trunk\n* `trunk-2c4p` - 2C4P trunk\n* `trunk-2c4p-shuffle` - 2C4P trunk (shuffle)\n* `trunk-2c6p` - 2C6P trunk\n* `trunk-2c8p` - 2C8P trunk\n* `trunk-2c12p` - 2C12P trunk\n* `trunk-3c8p` - 3C8P trunk\n* `trunk-3c12p` - 3C12P trunk\n* `trunk-4c1p` - 4C1P trunk\n* `trunk-4c2p` - 4C2P trunk\n* `trunk-4c4p` - 4C4P trunk\n* `trunk-4c4p-shuffle` - 4C4P trunk (shuffle)\n* `trunk-4c6p` - 4C6P trunk\n* `trunk-4c8p` - 4C8P trunk\n* `trunk-4c12p` - 4C12P trunk\n* `trunk-8c1p` - 8C1P trunk\n* `trunk-8c4p` - 8C4P trunk\n* `trunk-12c1p` - 12C1P trunk\n* `trunk-24c1p` - 24C1P trunk\n* `breakout-1c2p-2c1p` - 1C2P:2C1P breakout\n* `breakout-1c4p-4c1p` - 1C4P:4C1P breakout\n* `breakout-1c6p-6c1p` - 1C6P:6C1P breakout\n* `breakout-1c8p-8c1p` - 1C8P:8C1P breakout\n* `breakout-2c4p-8c1p-shuffle` - 2C4P:8C1P breakout (shuffle)",
+                        "x-spec-enum-id": "6a87b4ee518fe970"
                     },
                     },
                     "tenant": {
                     "tenant": {
                         "oneOf": [
                         "oneOf": [
@@ -292638,12 +292641,15 @@
                                     "trunk-2c6p",
                                     "trunk-2c6p",
                                     "trunk-2c8p",
                                     "trunk-2c8p",
                                     "trunk-2c12p",
                                     "trunk-2c12p",
+                                    "trunk-3c8p",
+                                    "trunk-3c12p",
                                     "trunk-4c1p",
                                     "trunk-4c1p",
                                     "trunk-4c2p",
                                     "trunk-4c2p",
                                     "trunk-4c4p",
                                     "trunk-4c4p",
                                     "trunk-4c4p-shuffle",
                                     "trunk-4c4p-shuffle",
                                     "trunk-4c6p",
                                     "trunk-4c6p",
                                     "trunk-4c8p",
                                     "trunk-4c8p",
+                                    "trunk-4c12p",
                                     "trunk-8c1p",
                                     "trunk-8c1p",
                                     "trunk-8c4p",
                                     "trunk-8c4p",
                                     "trunk-12c1p",
                                     "trunk-12c1p",
@@ -292655,8 +292661,8 @@
                                     "breakout-2c4p-8c1p-shuffle"
                                     "breakout-2c4p-8c1p-shuffle"
                                 ],
                                 ],
                                 "type": "string",
                                 "type": "string",
-                                "description": "* `single-1c1p` - 1C1P\n* `single-1c2p` - 1C2P\n* `single-1c4p` - 1C4P\n* `single-1c6p` - 1C6P\n* `single-1c8p` - 1C8P\n* `single-1c12p` - 1C12P\n* `single-1c16p` - 1C16P\n* `trunk-2c1p` - 2C1P trunk\n* `trunk-2c2p` - 2C2P trunk\n* `trunk-2c4p` - 2C4P trunk\n* `trunk-2c4p-shuffle` - 2C4P trunk (shuffle)\n* `trunk-2c6p` - 2C6P trunk\n* `trunk-2c8p` - 2C8P trunk\n* `trunk-2c12p` - 2C12P trunk\n* `trunk-4c1p` - 4C1P trunk\n* `trunk-4c2p` - 4C2P trunk\n* `trunk-4c4p` - 4C4P trunk\n* `trunk-4c4p-shuffle` - 4C4P trunk (shuffle)\n* `trunk-4c6p` - 4C6P trunk\n* `trunk-4c8p` - 4C8P trunk\n* `trunk-8c1p` - 8C1P trunk\n* `trunk-8c4p` - 8C4P trunk\n* `trunk-12c1p` - 12C1P trunk\n* `trunk-24c1p` - 24C1P trunk\n* `breakout-1c2p-2c1p` - 1C2P:2C1P breakout\n* `breakout-1c4p-4c1p` - 1C4P:4C1P breakout\n* `breakout-1c6p-6c1p` - 1C6P:6C1P breakout\n* `breakout-1c8p-8c1p` - 1C8P:8C1P breakout\n* `breakout-2c4p-8c1p-shuffle` - 2C4P:8C1P breakout (shuffle)",
-                                "x-spec-enum-id": "019cf94a890ea9e5"
+                                "description": "* `single-1c1p` - 1C1P\n* `single-1c2p` - 1C2P\n* `single-1c4p` - 1C4P\n* `single-1c6p` - 1C6P\n* `single-1c8p` - 1C8P\n* `single-1c12p` - 1C12P\n* `single-1c16p` - 1C16P\n* `trunk-2c1p` - 2C1P trunk\n* `trunk-2c2p` - 2C2P trunk\n* `trunk-2c4p` - 2C4P trunk\n* `trunk-2c4p-shuffle` - 2C4P trunk (shuffle)\n* `trunk-2c6p` - 2C6P trunk\n* `trunk-2c8p` - 2C8P trunk\n* `trunk-2c12p` - 2C12P trunk\n* `trunk-3c8p` - 3C8P trunk\n* `trunk-3c12p` - 3C12P trunk\n* `trunk-4c1p` - 4C1P trunk\n* `trunk-4c2p` - 4C2P trunk\n* `trunk-4c4p` - 4C4P trunk\n* `trunk-4c4p-shuffle` - 4C4P trunk (shuffle)\n* `trunk-4c6p` - 4C6P trunk\n* `trunk-4c8p` - 4C8P trunk\n* `trunk-4c12p` - 4C12P trunk\n* `trunk-8c1p` - 8C1P trunk\n* `trunk-8c4p` - 8C4P trunk\n* `trunk-12c1p` - 12C1P trunk\n* `trunk-24c1p` - 24C1P trunk\n* `breakout-1c2p-2c1p` - 1C2P:2C1P breakout\n* `breakout-1c4p-4c1p` - 1C4P:4C1P breakout\n* `breakout-1c6p-6c1p` - 1C6P:6C1P breakout\n* `breakout-1c8p-8c1p` - 1C8P:8C1P breakout\n* `breakout-2c4p-8c1p-shuffle` - 2C4P:8C1P breakout (shuffle)",
+                                "x-spec-enum-id": "6a87b4ee518fe970"
                             },
                             },
                             "label": {
                             "label": {
                                 "type": "string",
                                 "type": "string",
@@ -292675,12 +292681,15 @@
                                     "2C6P trunk",
                                     "2C6P trunk",
                                     "2C8P trunk",
                                     "2C8P trunk",
                                     "2C12P trunk",
                                     "2C12P trunk",
+                                    "3C8P trunk",
+                                    "3C12P trunk",
                                     "4C1P trunk",
                                     "4C1P trunk",
                                     "4C2P trunk",
                                     "4C2P trunk",
                                     "4C4P trunk",
                                     "4C4P trunk",
                                     "4C4P trunk (shuffle)",
                                     "4C4P trunk (shuffle)",
                                     "4C6P trunk",
                                     "4C6P trunk",
                                     "4C8P trunk",
                                     "4C8P trunk",
+                                    "4C12P trunk",
                                     "8C1P trunk",
                                     "8C1P trunk",
                                     "8C4P trunk",
                                     "8C4P trunk",
                                     "12C1P trunk",
                                     "12C1P trunk",
@@ -293029,12 +293038,15 @@
                             "trunk-2c6p",
                             "trunk-2c6p",
                             "trunk-2c8p",
                             "trunk-2c8p",
                             "trunk-2c12p",
                             "trunk-2c12p",
+                            "trunk-3c8p",
+                            "trunk-3c12p",
                             "trunk-4c1p",
                             "trunk-4c1p",
                             "trunk-4c2p",
                             "trunk-4c2p",
                             "trunk-4c4p",
                             "trunk-4c4p",
                             "trunk-4c4p-shuffle",
                             "trunk-4c4p-shuffle",
                             "trunk-4c6p",
                             "trunk-4c6p",
                             "trunk-4c8p",
                             "trunk-4c8p",
+                            "trunk-4c12p",
                             "trunk-8c1p",
                             "trunk-8c1p",
                             "trunk-8c4p",
                             "trunk-8c4p",
                             "trunk-12c1p",
                             "trunk-12c1p",
@@ -293046,8 +293058,8 @@
                             "breakout-2c4p-8c1p-shuffle"
                             "breakout-2c4p-8c1p-shuffle"
                         ],
                         ],
                         "type": "string",
                         "type": "string",
-                        "description": "* `single-1c1p` - 1C1P\n* `single-1c2p` - 1C2P\n* `single-1c4p` - 1C4P\n* `single-1c6p` - 1C6P\n* `single-1c8p` - 1C8P\n* `single-1c12p` - 1C12P\n* `single-1c16p` - 1C16P\n* `trunk-2c1p` - 2C1P trunk\n* `trunk-2c2p` - 2C2P trunk\n* `trunk-2c4p` - 2C4P trunk\n* `trunk-2c4p-shuffle` - 2C4P trunk (shuffle)\n* `trunk-2c6p` - 2C6P trunk\n* `trunk-2c8p` - 2C8P trunk\n* `trunk-2c12p` - 2C12P trunk\n* `trunk-4c1p` - 4C1P trunk\n* `trunk-4c2p` - 4C2P trunk\n* `trunk-4c4p` - 4C4P trunk\n* `trunk-4c4p-shuffle` - 4C4P trunk (shuffle)\n* `trunk-4c6p` - 4C6P trunk\n* `trunk-4c8p` - 4C8P trunk\n* `trunk-8c1p` - 8C1P trunk\n* `trunk-8c4p` - 8C4P trunk\n* `trunk-12c1p` - 12C1P trunk\n* `trunk-24c1p` - 24C1P trunk\n* `breakout-1c2p-2c1p` - 1C2P:2C1P breakout\n* `breakout-1c4p-4c1p` - 1C4P:4C1P breakout\n* `breakout-1c6p-6c1p` - 1C6P:6C1P breakout\n* `breakout-1c8p-8c1p` - 1C8P:8C1P breakout\n* `breakout-2c4p-8c1p-shuffle` - 2C4P:8C1P breakout (shuffle)",
-                        "x-spec-enum-id": "019cf94a890ea9e5"
+                        "description": "* `single-1c1p` - 1C1P\n* `single-1c2p` - 1C2P\n* `single-1c4p` - 1C4P\n* `single-1c6p` - 1C6P\n* `single-1c8p` - 1C8P\n* `single-1c12p` - 1C12P\n* `single-1c16p` - 1C16P\n* `trunk-2c1p` - 2C1P trunk\n* `trunk-2c2p` - 2C2P trunk\n* `trunk-2c4p` - 2C4P trunk\n* `trunk-2c4p-shuffle` - 2C4P trunk (shuffle)\n* `trunk-2c6p` - 2C6P trunk\n* `trunk-2c8p` - 2C8P trunk\n* `trunk-2c12p` - 2C12P trunk\n* `trunk-3c8p` - 3C8P trunk\n* `trunk-3c12p` - 3C12P trunk\n* `trunk-4c1p` - 4C1P trunk\n* `trunk-4c2p` - 4C2P trunk\n* `trunk-4c4p` - 4C4P trunk\n* `trunk-4c4p-shuffle` - 4C4P trunk (shuffle)\n* `trunk-4c6p` - 4C6P trunk\n* `trunk-4c8p` - 4C8P trunk\n* `trunk-4c12p` - 4C12P trunk\n* `trunk-8c1p` - 8C1P trunk\n* `trunk-8c4p` - 8C4P trunk\n* `trunk-12c1p` - 12C1P trunk\n* `trunk-24c1p` - 24C1P trunk\n* `breakout-1c2p-2c1p` - 1C2P:2C1P breakout\n* `breakout-1c4p-4c1p` - 1C4P:4C1P breakout\n* `breakout-1c6p-6c1p` - 1C6P:6C1P breakout\n* `breakout-1c8p-8c1p` - 1C8P:8C1P breakout\n* `breakout-2c4p-8c1p-shuffle` - 2C4P:8C1P breakout (shuffle)",
+                        "x-spec-enum-id": "6a87b4ee518fe970"
                     },
                     },
                     "tenant": {
                     "tenant": {
                         "oneOf": [
                         "oneOf": [
@@ -320094,12 +320106,15 @@
                             "trunk-2c6p",
                             "trunk-2c6p",
                             "trunk-2c8p",
                             "trunk-2c8p",
                             "trunk-2c12p",
                             "trunk-2c12p",
+                            "trunk-3c8p",
+                            "trunk-3c12p",
                             "trunk-4c1p",
                             "trunk-4c1p",
                             "trunk-4c2p",
                             "trunk-4c2p",
                             "trunk-4c4p",
                             "trunk-4c4p",
                             "trunk-4c4p-shuffle",
                             "trunk-4c4p-shuffle",
                             "trunk-4c6p",
                             "trunk-4c6p",
                             "trunk-4c8p",
                             "trunk-4c8p",
+                            "trunk-4c12p",
                             "trunk-8c1p",
                             "trunk-8c1p",
                             "trunk-8c4p",
                             "trunk-8c4p",
                             "trunk-12c1p",
                             "trunk-12c1p",
@@ -320111,8 +320126,8 @@
                             "breakout-2c4p-8c1p-shuffle"
                             "breakout-2c4p-8c1p-shuffle"
                         ],
                         ],
                         "type": "string",
                         "type": "string",
-                        "description": "* `single-1c1p` - 1C1P\n* `single-1c2p` - 1C2P\n* `single-1c4p` - 1C4P\n* `single-1c6p` - 1C6P\n* `single-1c8p` - 1C8P\n* `single-1c12p` - 1C12P\n* `single-1c16p` - 1C16P\n* `trunk-2c1p` - 2C1P trunk\n* `trunk-2c2p` - 2C2P trunk\n* `trunk-2c4p` - 2C4P trunk\n* `trunk-2c4p-shuffle` - 2C4P trunk (shuffle)\n* `trunk-2c6p` - 2C6P trunk\n* `trunk-2c8p` - 2C8P trunk\n* `trunk-2c12p` - 2C12P trunk\n* `trunk-4c1p` - 4C1P trunk\n* `trunk-4c2p` - 4C2P trunk\n* `trunk-4c4p` - 4C4P trunk\n* `trunk-4c4p-shuffle` - 4C4P trunk (shuffle)\n* `trunk-4c6p` - 4C6P trunk\n* `trunk-4c8p` - 4C8P trunk\n* `trunk-8c1p` - 8C1P trunk\n* `trunk-8c4p` - 8C4P trunk\n* `trunk-12c1p` - 12C1P trunk\n* `trunk-24c1p` - 24C1P trunk\n* `breakout-1c2p-2c1p` - 1C2P:2C1P breakout\n* `breakout-1c4p-4c1p` - 1C4P:4C1P breakout\n* `breakout-1c6p-6c1p` - 1C6P:6C1P breakout\n* `breakout-1c8p-8c1p` - 1C8P:8C1P breakout\n* `breakout-2c4p-8c1p-shuffle` - 2C4P:8C1P breakout (shuffle)",
-                        "x-spec-enum-id": "019cf94a890ea9e5"
+                        "description": "* `single-1c1p` - 1C1P\n* `single-1c2p` - 1C2P\n* `single-1c4p` - 1C4P\n* `single-1c6p` - 1C6P\n* `single-1c8p` - 1C8P\n* `single-1c12p` - 1C12P\n* `single-1c16p` - 1C16P\n* `trunk-2c1p` - 2C1P trunk\n* `trunk-2c2p` - 2C2P trunk\n* `trunk-2c4p` - 2C4P trunk\n* `trunk-2c4p-shuffle` - 2C4P trunk (shuffle)\n* `trunk-2c6p` - 2C6P trunk\n* `trunk-2c8p` - 2C8P trunk\n* `trunk-2c12p` - 2C12P trunk\n* `trunk-3c8p` - 3C8P trunk\n* `trunk-3c12p` - 3C12P trunk\n* `trunk-4c1p` - 4C1P trunk\n* `trunk-4c2p` - 4C2P trunk\n* `trunk-4c4p` - 4C4P trunk\n* `trunk-4c4p-shuffle` - 4C4P trunk (shuffle)\n* `trunk-4c6p` - 4C6P trunk\n* `trunk-4c8p` - 4C8P trunk\n* `trunk-4c12p` - 4C12P trunk\n* `trunk-8c1p` - 8C1P trunk\n* `trunk-8c4p` - 8C4P trunk\n* `trunk-12c1p` - 12C1P trunk\n* `trunk-24c1p` - 24C1P trunk\n* `breakout-1c2p-2c1p` - 1C2P:2C1P breakout\n* `breakout-1c4p-4c1p` - 1C4P:4C1P breakout\n* `breakout-1c6p-6c1p` - 1C6P:6C1P breakout\n* `breakout-1c8p-8c1p` - 1C8P:8C1P breakout\n* `breakout-2c4p-8c1p-shuffle` - 2C4P:8C1P breakout (shuffle)",
+                        "x-spec-enum-id": "6a87b4ee518fe970"
                     },
                     },
                     "tenant": {
                     "tenant": {
                         "oneOf": [
                         "oneOf": [
@@ -338087,12 +338102,15 @@
                             "trunk-2c6p",
                             "trunk-2c6p",
                             "trunk-2c8p",
                             "trunk-2c8p",
                             "trunk-2c12p",
                             "trunk-2c12p",
+                            "trunk-3c8p",
+                            "trunk-3c12p",
                             "trunk-4c1p",
                             "trunk-4c1p",
                             "trunk-4c2p",
                             "trunk-4c2p",
                             "trunk-4c4p",
                             "trunk-4c4p",
                             "trunk-4c4p-shuffle",
                             "trunk-4c4p-shuffle",
                             "trunk-4c6p",
                             "trunk-4c6p",
                             "trunk-4c8p",
                             "trunk-4c8p",
+                            "trunk-4c12p",
                             "trunk-8c1p",
                             "trunk-8c1p",
                             "trunk-8c4p",
                             "trunk-8c4p",
                             "trunk-12c1p",
                             "trunk-12c1p",
@@ -338105,8 +338123,8 @@
                             ""
                             ""
                         ],
                         ],
                         "type": "string",
                         "type": "string",
-                        "description": "* `single-1c1p` - 1C1P\n* `single-1c2p` - 1C2P\n* `single-1c4p` - 1C4P\n* `single-1c6p` - 1C6P\n* `single-1c8p` - 1C8P\n* `single-1c12p` - 1C12P\n* `single-1c16p` - 1C16P\n* `trunk-2c1p` - 2C1P trunk\n* `trunk-2c2p` - 2C2P trunk\n* `trunk-2c4p` - 2C4P trunk\n* `trunk-2c4p-shuffle` - 2C4P trunk (shuffle)\n* `trunk-2c6p` - 2C6P trunk\n* `trunk-2c8p` - 2C8P trunk\n* `trunk-2c12p` - 2C12P trunk\n* `trunk-4c1p` - 4C1P trunk\n* `trunk-4c2p` - 4C2P trunk\n* `trunk-4c4p` - 4C4P trunk\n* `trunk-4c4p-shuffle` - 4C4P trunk (shuffle)\n* `trunk-4c6p` - 4C6P trunk\n* `trunk-4c8p` - 4C8P trunk\n* `trunk-8c1p` - 8C1P trunk\n* `trunk-8c4p` - 8C4P trunk\n* `trunk-12c1p` - 12C1P trunk\n* `trunk-24c1p` - 24C1P trunk\n* `breakout-1c2p-2c1p` - 1C2P:2C1P breakout\n* `breakout-1c4p-4c1p` - 1C4P:4C1P breakout\n* `breakout-1c6p-6c1p` - 1C6P:6C1P breakout\n* `breakout-1c8p-8c1p` - 1C8P:8C1P breakout\n* `breakout-2c4p-8c1p-shuffle` - 2C4P:8C1P breakout (shuffle)",
-                        "x-spec-enum-id": "019cf94a890ea9e5"
+                        "description": "* `single-1c1p` - 1C1P\n* `single-1c2p` - 1C2P\n* `single-1c4p` - 1C4P\n* `single-1c6p` - 1C6P\n* `single-1c8p` - 1C8P\n* `single-1c12p` - 1C12P\n* `single-1c16p` - 1C16P\n* `trunk-2c1p` - 2C1P trunk\n* `trunk-2c2p` - 2C2P trunk\n* `trunk-2c4p` - 2C4P trunk\n* `trunk-2c4p-shuffle` - 2C4P trunk (shuffle)\n* `trunk-2c6p` - 2C6P trunk\n* `trunk-2c8p` - 2C8P trunk\n* `trunk-2c12p` - 2C12P trunk\n* `trunk-3c8p` - 3C8P trunk\n* `trunk-3c12p` - 3C12P trunk\n* `trunk-4c1p` - 4C1P trunk\n* `trunk-4c2p` - 4C2P trunk\n* `trunk-4c4p` - 4C4P trunk\n* `trunk-4c4p-shuffle` - 4C4P trunk (shuffle)\n* `trunk-4c6p` - 4C6P trunk\n* `trunk-4c8p` - 4C8P trunk\n* `trunk-4c12p` - 4C12P trunk\n* `trunk-8c1p` - 8C1P trunk\n* `trunk-8c4p` - 8C4P trunk\n* `trunk-12c1p` - 12C1P trunk\n* `trunk-24c1p` - 24C1P trunk\n* `breakout-1c2p-2c1p` - 1C2P:2C1P breakout\n* `breakout-1c4p-4c1p` - 1C4P:4C1P breakout\n* `breakout-1c6p-6c1p` - 1C6P:6C1P breakout\n* `breakout-1c8p-8c1p` - 1C8P:8C1P breakout\n* `breakout-2c4p-8c1p-shuffle` - 2C4P:8C1P breakout (shuffle)",
+                        "x-spec-enum-id": "6a87b4ee518fe970"
                     },
                     },
                     "tenant": {
                     "tenant": {
                         "oneOf": [
                         "oneOf": [
@@ -363078,12 +363096,15 @@
                             "trunk-2c6p",
                             "trunk-2c6p",
                             "trunk-2c8p",
                             "trunk-2c8p",
                             "trunk-2c12p",
                             "trunk-2c12p",
+                            "trunk-3c8p",
+                            "trunk-3c12p",
                             "trunk-4c1p",
                             "trunk-4c1p",
                             "trunk-4c2p",
                             "trunk-4c2p",
                             "trunk-4c4p",
                             "trunk-4c4p",
                             "trunk-4c4p-shuffle",
                             "trunk-4c4p-shuffle",
                             "trunk-4c6p",
                             "trunk-4c6p",
                             "trunk-4c8p",
                             "trunk-4c8p",
+                            "trunk-4c12p",
                             "trunk-8c1p",
                             "trunk-8c1p",
                             "trunk-8c4p",
                             "trunk-8c4p",
                             "trunk-12c1p",
                             "trunk-12c1p",
@@ -363096,8 +363117,8 @@
                             ""
                             ""
                         ],
                         ],
                         "type": "string",
                         "type": "string",
-                        "description": "* `single-1c1p` - 1C1P\n* `single-1c2p` - 1C2P\n* `single-1c4p` - 1C4P\n* `single-1c6p` - 1C6P\n* `single-1c8p` - 1C8P\n* `single-1c12p` - 1C12P\n* `single-1c16p` - 1C16P\n* `trunk-2c1p` - 2C1P trunk\n* `trunk-2c2p` - 2C2P trunk\n* `trunk-2c4p` - 2C4P trunk\n* `trunk-2c4p-shuffle` - 2C4P trunk (shuffle)\n* `trunk-2c6p` - 2C6P trunk\n* `trunk-2c8p` - 2C8P trunk\n* `trunk-2c12p` - 2C12P trunk\n* `trunk-4c1p` - 4C1P trunk\n* `trunk-4c2p` - 4C2P trunk\n* `trunk-4c4p` - 4C4P trunk\n* `trunk-4c4p-shuffle` - 4C4P trunk (shuffle)\n* `trunk-4c6p` - 4C6P trunk\n* `trunk-4c8p` - 4C8P trunk\n* `trunk-8c1p` - 8C1P trunk\n* `trunk-8c4p` - 8C4P trunk\n* `trunk-12c1p` - 12C1P trunk\n* `trunk-24c1p` - 24C1P trunk\n* `breakout-1c2p-2c1p` - 1C2P:2C1P breakout\n* `breakout-1c4p-4c1p` - 1C4P:4C1P breakout\n* `breakout-1c6p-6c1p` - 1C6P:6C1P breakout\n* `breakout-1c8p-8c1p` - 1C8P:8C1P breakout\n* `breakout-2c4p-8c1p-shuffle` - 2C4P:8C1P breakout (shuffle)",
-                        "x-spec-enum-id": "019cf94a890ea9e5"
+                        "description": "* `single-1c1p` - 1C1P\n* `single-1c2p` - 1C2P\n* `single-1c4p` - 1C4P\n* `single-1c6p` - 1C6P\n* `single-1c8p` - 1C8P\n* `single-1c12p` - 1C12P\n* `single-1c16p` - 1C16P\n* `trunk-2c1p` - 2C1P trunk\n* `trunk-2c2p` - 2C2P trunk\n* `trunk-2c4p` - 2C4P trunk\n* `trunk-2c4p-shuffle` - 2C4P trunk (shuffle)\n* `trunk-2c6p` - 2C6P trunk\n* `trunk-2c8p` - 2C8P trunk\n* `trunk-2c12p` - 2C12P trunk\n* `trunk-3c8p` - 3C8P trunk\n* `trunk-3c12p` - 3C12P trunk\n* `trunk-4c1p` - 4C1P trunk\n* `trunk-4c2p` - 4C2P trunk\n* `trunk-4c4p` - 4C4P trunk\n* `trunk-4c4p-shuffle` - 4C4P trunk (shuffle)\n* `trunk-4c6p` - 4C6P trunk\n* `trunk-4c8p` - 4C8P trunk\n* `trunk-4c12p` - 4C12P trunk\n* `trunk-8c1p` - 8C1P trunk\n* `trunk-8c4p` - 8C4P trunk\n* `trunk-12c1p` - 12C1P trunk\n* `trunk-24c1p` - 24C1P trunk\n* `breakout-1c2p-2c1p` - 1C2P:2C1P breakout\n* `breakout-1c4p-4c1p` - 1C4P:4C1P breakout\n* `breakout-1c6p-6c1p` - 1C6P:6C1P breakout\n* `breakout-1c8p-8c1p` - 1C8P:8C1P breakout\n* `breakout-2c4p-8c1p-shuffle` - 2C4P:8C1P breakout (shuffle)",
+                        "x-spec-enum-id": "6a87b4ee518fe970"
                     },
                     },
                     "tenant": {
                     "tenant": {
                         "oneOf": [
                         "oneOf": [

+ 298 - 0
docs/best-practices/modeling-servers.md

@@ -0,0 +1,298 @@
+# Modeling Servers
+
+## Use Case
+
+A physical server is a chassis that holds field-replaceable components: processors, memory modules, drives, expansion cards, and power supplies. Two servers of the same model often ship with very different configurations, and their components get replaced, upgraded, and moved between servers over the life of the hardware.
+
+These components were historically recorded as inventory items, which have been deprecated since NetBox v4.3. Existing inventory items continue to function, and NetBox does not convert them to modules automatically. Once a component has been recorded as a module, delete its inventory item so that it isn't recorded twice. This guide captures the recommended strategy for modeling servers and their components on NetBox v4.7 and later.
+
+## Modeling Strategy
+
+Each server is represented as a [device](../models/dcim/device.md), and each field-replaceable component is represented as a [module](../models/dcim/module.md) installed within a [module bay](../models/dcim/modulebay.md) on that device. A [module type profile](../models/dcim/moduletypeprofile.md) assigned to each [module type](../models/dcim/moduletype.md) records the attributes relevant to its kind of component, such as the size and class of a memory module or the capacity of a drive.
+
+A module does not need to deliver any components to the device. A memory module or a CPU is a module with no interfaces or other components. A network adapter, by contrast, delivers its interfaces to the device when installed, and a power supply delivers its power port.
+
+```mermaid
+flowchart BT
+    cpu1[CPU] --> socket1[Socket CPU1]
+    dimm1[32GB DDR5 RDIMM] --> slot1[DIMM A1]
+    disk1[3.84TB NVMe SSD] --> bay1[Drive Bay Drive0]
+    interface1[Interface Slot1-port1] & interface2[Interface Slot1-port2] --> nic1[Network Adapter]
+    nic1 --> pcie1[PCIe Slot Slot1]
+    powerport1[Power Port] --> psu1[Power Supply]
+    psu1 --> psubay1[PSU Bay PSU1]
+    socket1 & slot1 & bay1 & pcie1 & psubay1 --> device[Device]
+```
+
+### Before You Start
+
+List every slot and replaceable part in the chassis, using the vendor's technical documentation and the labels printed on the chassis. Then check the community [Device Type Library](https://github.com/netbox-community/devicetype-library): it publishes ready-made definitions for many device types and module types, including their module bays, which can be imported into NetBox rather than built by hand.
+
+### Choosing What to Model
+
+If a chassis holds complete servers, each with its own processors, memory, and operating system (such as a blade enclosure, or a multi-node chassis holding four servers in 2U), model each server as a child device installed in a [device bay](../models/dcim/devicebay.md) on the chassis, not as a module. Set the chassis device type's parent/child status to **Parent** and give it a device bay for each node. Set each node's device type to **Child**, with a height of 0U, and model it as described in this guide. Install a node from the chassis's **Device Bays** tab. Components shared by all the nodes, such as power supplies and fans, belong to the chassis.
+
+Model the components you replace, upgrade, track by serial number, or need to find later. A part that is never serviced on its own, such as a fixed chassis fan, can be left out. Hardware built into the chassis, such as onboard network ports or the management controller's port, belongs on the device type as ordinary component templates rather than as modules.
+
+A module's type can carry module bays of its own, so that other modules are installed within it rather than directly in a bay on the device. This is called nesting: a GPU installed in a bay on a GPU tray, which is in turn installed in a bay on the chassis, is a nested module. Nest modules only where an assembly that holds other components is itself replaced or tracked as a unit, such as a GPU tray. Nest only as deep as you track: if a tray simply carries a baseboard that holds the GPUs, and the tray is not tracked on its own, model the baseboard alone, installed directly in the chassis. A riser or cage that is never tracked on its own can be left out: model its slots as module bays on the device type. Conversely, when a chassis is sold with different drive backplanes, modeling the backplane as a module that holds the drive bays lets a single device type cover every configuration.
+
+When a component physically blocks a neighboring bay, such as a double-wide GPU covering the adjacent PCIe slot, disable the blocked module bay (clear its **Enabled** setting) so that nothing can be installed in it. Disabling a bay does not remove a module already installed in it.
+
+### 1. Select Module Type Profiles
+
+NetBox includes default module type profiles for the most common server components:
+
+| Profile        | Attributes                                                   |
+|----------------|--------------------------------------------------------------|
+| CPU            | Architecture, cores, speed (GHz)                             |
+| Memory         | Class (DDR3, DDR4, DDR5), size (GB), data rate (MT/s), ECC   |
+| Hard disk      | Type (HD, SSD, NVME), size (GB), speed (RPM)                 |
+| GPU            | GPU, interface (PCIe generation and width), memory (GB)      |
+| Expansion card | Connector type, bandwidth                                    |
+| Power supply   | Current type (AC, DC), voltage, wattage, hot-swappable       |
+| Fan            | RPM                                                          |
+
+These profiles are created when NetBox's database is first set up, or when it is upgraded to v4.3. Profiles added in later releases are not created on existing installations: for example, an installation that reached v4.3 before v4.3.4 has no Expansion card profile. If any of these profiles are not present, or if you need a different set of attributes, create your own profiles instead. Attributes are defined by editing a profile's [JSON schema](https://json-schema.org/) (the [module type profile documentation](../models/dcim/moduletypeprofile.md) includes an example schema), and can be added or changed at a later point. A profile does not need a schema at all: for parts such as RAID controllers or host bus adapters, a profile with no schema can serve purely to classify module types. Assigning a profile to a module type is optional, but recommended: attribute values are validated against the profile's schema, and module types can be filtered by them. If a schema defines default values (the default Memory, Hard disk and Power supply profiles do), the module type form fills them in, so review them before saving.
+
+### 2. Create Module Bay Types
+
+Create a [module bay type](../models/dcim/modulebaytype.md) for each kind of slot found in your servers, such as `LGA4677 Socket`, `DDR5 DIMM Slot`, `U.2 Drive Bay`, `PCIe x16 Slot`, and `PSU Bay`. When both a module bay and a module type declare bay types, NetBox will permit a module to be installed in the bay only if they share at least one type. This prevents, for example, a memory module from being installed in a drive bay. Bay types exist only to restrict which module types fit where, so create as few as you need: a single `PCIe Slot` type may be enough, or you might split it by slot width if cards must be checked against their slots.
+
+Assign bay types to module types and module bays before installing modules, so that compatibility is checked as each module is installed.
+
+!!! note
+    Module bay types are optional. Skip this step if you do not need NetBox to enforce compatibility.
+
+### 3. Create a Module Type for Each Component Model
+
+Next, create a module type to represent each unique component model in your servers (creating its manufacturer first, if needed). Each module type should define a manufacturer and a unique model name, assign the appropriate profile, and fill in the profile's attributes. Bulk import identifies module types by model alone, so avoid reusing a model name under two manufacturers. Whether you put the vendor's orderable part number in the model name or in the separate part number field, do it consistently. If you created bay types in the previous step, assign each module type the bay type(s) it fits. For example:
+
+| Manufacturer | Model                | Profile        | Attributes                   | Bay Type        |
+|--------------|----------------------|----------------|------------------------------|-----------------|
+| Intel        | Xeon Gold 6430       | CPU            | x86_64, 32 cores, 2.1 GHz    | LGA4677 Socket  |
+| Samsung      | M321R4GA3BB6-CQK     | Memory         | DDR5, 32 GB, 4800 MT/s, ECC  | DDR5 DIMM Slot  |
+| Micron       | 7450 PRO 3.84TB U.3  | Hard disk      | NVME, 3840 GB                | U.2 Drive Bay   |
+| NVIDIA       | MCX623106AN-CDAT     | Expansion card | PCIe x16                     | PCIe x16 Slot   |
+
+Other add-in hardware follows the same pattern: a GPU, for example, takes the GPU profile and a PCIe bay type. Profile attributes are defined on the module type, so every module of the same type shares them. Values that vary per unit, such as a serial number or asset tag, belong on the module itself.
+
+### 4. Add Components to Module Types (Where Applicable)
+
+Most server components deliver nothing to the device, so their module types need no component templates (the interfaces, power ports, and other components a module creates when installed). Add them only where the component exposes something you want to track:
+
+* **Network adapters** need an interface template for each port. Use the `{module}` token in each name so that adapters in different slots produce unique interface names: the token is replaced with the position of the module bay the adapter is installed in. For example, interface templates named `{module}-port1` and `{module}-port2` on an adapter installed in the bay with position `Slot1` become `Slot1-port1` and `Slot1-port2`.
+* **Power supplies** need a power port template, which allows each supply to be cabled to a power outlet or feed.
+* **Assemblies that hold other components**, such as a drive cage or a GPU baseboard, need module bay templates. If the same assembly can be installed in more than one bay, use the `{module}` token in these templates' names and positions as well. For example, a drive cage with module bay templates named `{module}-Bay1` through `{module}-Bay8` (added at once as `{module}-Bay[1-8]`), installed in the bay with position `Box1`, creates bays named `Box1-Bay1` through `Box1-Bay8`, so every bay name on the device stays unique. Such assemblies usually need no profile, or a profile with no schema that only classifies them.
+* **Network adapters with pluggable cages** can carry their own module bays. Transceivers installed in them follow the approach described in [Modeling Pluggable Transceivers](./modeling-pluggable-transceivers.md).
+
+### 5. Create Device Types
+
+If you haven't already, create a [device type](../models/dcim/devicetype.md) to represent each server model in your environment. Create one device type per chassis model, not one per configuration. A server's configuration is expressed through the modules installed in it.
+
+!!! note
+    Skip this step if you've already created the necessary device types.
+
+### 6. Add Module Bays to the Device Type
+
+Add a module bay template to the device type for every slot in the chassis, and assign each one or more bay types. The form for adding module bay templates accepts alphanumeric ranges for names and positions (e.g. `A[1-16]`), so a row of identical slots can be added at once. For example, a two-socket server might have:
+
+| Bays              | Count | Names and Positions          | Bay Type        |
+|-------------------|-------|------------------------------|-----------------|
+| CPU sockets       | 2     | `CPU1`, `CPU2`               | LGA4677 Socket  |
+| DIMM slots        | 32    | `A1` to `A16`, `B1` to `B16` | DDR5 DIMM Slot  |
+| Drive bays        | 24    | `Drive0` to `Drive23`        | U.2 Drive Bay   |
+| PCIe slots        | 4     | `Slot1` to `Slot4`           | PCIe x16 Slot   |
+| Power supply bays | 2     | `PSU1`, `PSU2`               | PSU Bay         |
+
+Be sure to define both the name **and position** of each module bay with a value that is unique across the entire device, including bays nested within other modules. Bulk import finds a module bay by its name within the device, and the position determines the names of the components a module creates. If a module type's templates use the `{module}` token, NetBox will not install the module in a bay that has no position. This example uses the same value for each bay's name and position. (A chassis sold with interchangeable drive backplanes would carry its drive bays on a backplane module instead, as described under [Choosing What to Model](#choosing-what-to-model).) Matching the labels printed on the chassis keeps the names in NetBox consistent with what a technician sees on site.
+
+### 7. Create a Device
+
+Create a new device using the device type added in the previous step. The module bays defined on the device type, along with their bay types, will be instantiated on the new device automatically.
+
+!!! note
+    Module bay templates are instantiated only when a device is created. If you've already created the necessary devices in NetBox, you'll need to add their module bays manually. You can add multiple module bays at once by selecting the desired devices from the device list and selecting **Add Components > Module Bays** at the bottom of the page. Give both the name and the position a range of the same length (e.g. `Drive[0-23]` for each), or every new bay will receive the same position. Bay types are not assigned by this form: select the new bays from the module bay list and use bulk edit to add them.
+
+### 8. Install the Modules
+
+Finally, record each component by installing a new module of the appropriate type in each occupied module bay, and assign each module its serial number and asset tag. Bays left without a module appear empty on the device's **Module Bays** tab, which shows the server's unused capacity.
+
+A fully populated server can contain dozens of modules. When adding many at once, you may find it helpful to use NetBox's bulk import functionality to create them from CSV, JSON, or YAML data. Each row identifies its device by name, its module bay by name within that device, and its module type by model, so these must be unambiguous. For example:
+
+```no-highlight
+device,module_bay,module_type,status,serial
+server01,A1,M321R4GA3BB6-CQK,active,S0A1B2C3
+server01,A2,M321R4GA3BB6-CQK,active,S0A1B2C4
+server01,Drive0,7450 PRO 3.84TB U.3,active,MC5D6E7F
+```
+
+Once installed, modules can be filtered by profile (e.g. all memory modules) and by serial number. Module types can be filtered by profile attribute, using the attribute's key from the profile's schema (e.g. `?profile=Memory&attr_class=DDR4`).
+
+## Moving and Removing Components
+
+An installed module can be moved to another module bay, including a bay on a different device, along with its serial number, asset tag, and change history. Moving a module also moves any modules installed within it, and deleting a module deletes them. A module must always be installed in a module bay, so a component removed from service must be either deleted or moved elsewhere. See the [module documentation](../models/dcim/module.md) for the conditions under which a module can be moved between devices.
+
+## Example: Commodity Server (Dell PowerEdge R760)
+
+The PowerEdge R760 is a 2U two-socket server, documented in Dell's [Installation and Service Manual](https://www.dell.com/support/manuals/en-us/oth-r760/per760_ism_pub/). Nearly all of its slots (CPU sockets, DIMM slots, PCIe slots, and power supply and fan bays) are defined as module bays on the device type, and each component is installed directly in one of them. The exceptions are the front drive backplane, the optional rear drive module, and the boot module: each is a module that carries module bays of its own, and the drives and the front PERC are installed in those. The component models shown in this and the following example are examples. Substitute the parts actually installed in your servers.
+
+```mermaid
+flowchart BT
+    drive[24x U.2 Drive] & perc[Front PERC] --> bp[Front Backplane]
+    reardrive[Rear Drives] --> rear[Rear Drive Module]
+    m2[2x M.2 SSD] --> boss[BOSS-N1]
+    cpu[2x CPU, 32x DIMM] & cards[PCIe, OCP, and LOM Cards] & power[2x PSU, 6x Fan] --> device[PowerEdge R760]
+    bp & rear & boss --> device
+```
+
+### Device Type
+
+| Bays                | Count | Names and Positions          | Bay Type        |
+|---------------------|-------|------------------------------|-----------------|
+| CPU sockets         | 2     | `CPU1`, `CPU2`               | LGA4677 Socket  |
+| DIMM slots          | 32    | `A1` to `A16`, `B1` to `B16` | DDR5 DIMM Slot  |
+| PCIe slots          | 8     | `Slot1` to `Slot8`           | PCIe Slot       |
+| OCP NIC slot        | 1     | `OCP`                        | OCP NIC 3.0     |
+| LOM card slot       | 1     | `LOM`                        | Dell LOM Slot   |
+| Front backplane     | 1     | `BACKPLANE`                  | Dell Backplane  |
+| Rear drive module   | 1     | `REAR-BACKPLANE`             | Dell Rear Drive Module |
+| Boot module         | 1     | `BOSS`                       | Dell BOSS Slot  |
+| Power supply bays   | 2     | `PSU1`, `PSU2`               | PSU Bay         |
+| Fans                | 6     | `FAN1` to `FAN6`             | Dell Fan Bay    |
+
+The CPU, DIMM, PCIe slot, and power supply names follow Dell's labels. The fan names are our own (Dell labels the fan connectors `FAN_2U1` to `FAN_2U6`).
+
+The dedicated iDRAC management port is part of the chassis, so it belongs on the device type as an interface template (named `iDRAC` and marked as management only) rather than in a module bay.
+
+### Risers and PCIe Slots
+
+The R760's eight PCIe slots are carried on four replaceable risers, and the width of each slot (and whether it exists at all) depends on the risers installed. This example leaves the risers out and gives every slot a single `PCIe Slot` bay type. If you need NetBox to check that a card fits its slot, create a bay type for each slot width instead. If you track risers individually, model each as a module with its slots as nested bays, as in the [Grand Teton example](#example-nested-modules-meta-grand-teton).
+
+Double-wide GPUs are supported only on risers that carry a single slot, so in those configurations slot 1 or slot 8 does not exist. Disable the module bay for the missing slot (clear its **Enabled** setting) so that nothing can be installed in it.
+
+### Drive Backplanes
+
+The R760 is offered with several front drive configurations, such as 8, 16, or 24 2.5" bays, or 12 3.5" bays. Rather than creating a device type for each configuration, model the backplane as a module whose module type carries the drive bays. A single device type then covers servers with different backplanes: the backplane installed in each server determines which drive bays it has. Each backplane's drive bays take a bay type matching its drives, such as `U.2 Drive Bay` for the NVMe backplane below, or separate types for 2.5" SAS/SATA and 3.5" backplanes. The optional rear drive module is modeled the same way, with its own drive bay names (e.g. `Rear0` to `Rear3`) so that every bay name on the device stays unique, and its bay is left empty on servers without one. The rear drive module takes the place of riser 3, so on servers that have one, disable the module bays for slots 4 and 5.
+
+Deleting a backplane module also deletes the drive modules installed in it. To record a like-for-like backplane replacement, update the existing module's serial number rather than deleting it.
+
+The front PERC storage controller plugs into the drive backplane, so the backplane's module type also carries a `PERC` module bay for it. (An adapter PERC, by contrast, installs in a PCIe slot like any other card.)
+
+### Module Types
+
+| Manufacturer | Model                              | Profile        | Bay Type       | Component Templates                    |
+|--------------|------------------------------------|----------------|----------------|----------------------------------------|
+| Dell         | 24 x 2.5" NVMe Gen5 Switched Backplane | (none)     | Dell Backplane | Module bays `Drive0` to `Drive23`, `PERC` |
+| Micron       | 7450 PRO 3.84TB U.3                | Hard disk      | U.2 Drive Bay  | None                                   |
+| Dell         | Broadcom 5720 Dual Port 1GbE LOM   | Expansion card | Dell LOM Slot  | Interfaces `{module}-1`, `{module}-2`  |
+| Dell         | BOSS-N1                            | (none)         | Dell BOSS Slot | Module bays `M.2-1`, `M.2-2`           |
+| Dell         | BOSS-N1 M.2 NVMe SSD               | Hard disk      | M.2 Slot       | None                                   |
+| Dell         | 1400W Platinum PSU                 | Power supply   | PSU Bay        | Power port `{module}`                  |
+| Dell         | HPR Gold Fan                       | Fan            | Dell Fan Bay   | None                                   |
+
+The backplane's drive bays (names and positions `Drive0` to `Drive23`) take the `U.2 Drive Bay` bay type. The BOSS-N1 boot module holds two M.2 drives, each replaceable on its own, in bays with names and positions `M.2-1` and `M.2-2` and bay type `M.2 Slot`. The LOM card installed in bay `LOM` creates interfaces named `LOM-1` and `LOM-2`, and the power supply in bay `PSU1` creates a power port named `PSU1`.
+
+### Installing the Modules
+
+The drive bays exist only once the backplane has been installed, and the M.2 bays only once the boot module has, so install those first. With bulk import, list them ahead of the modules installed in them, or import in two passes as shown here:
+
+```no-highlight
+device,module_bay,module_type,status,serial
+r760-01,BACKPLANE,"24 x 2.5"" NVMe Gen5 Switched Backplane",active,BP00001
+r760-01,BOSS,BOSS-N1,active,BOSS00001
+r760-01,LOM,Broadcom 5720 Dual Port 1GbE LOM,active,LOM00001
+r760-01,PSU1,1400W Platinum PSU,active,PSU00001
+r760-01,PSU2,1400W Platinum PSU,active,PSU00002
+```
+
+```no-highlight
+device,module_bay,module_type,status,serial
+r760-01,Drive0,7450 PRO 3.84TB U.3,active,DRV00001
+r760-01,Drive1,7450 PRO 3.84TB U.3,active,DRV00002
+r760-01,M.2-1,BOSS-N1 M.2 NVMe SSD,active,M2-00001
+```
+
+The backplane's model name contains a double quote, so it is quoted in the CSV and the quote inside it is doubled.
+
+### Replacing a Failed Drive
+
+When a drive reports a fault, search for its serial number (in the global search, or by filtering the modules list on serial) to find the server and the bay it is installed in. Set the module's status to **Failed** until the drive has been swapped. Once the replacement is in, edit the module, enter the new drive's serial number, and set its status back to **Active**. The module's change log keeps the failed drive's serial number.
+
+If you track failed parts in NetBox until they are returned to the vendor, install the replacement as a new module instead, and move the failed one to a module bay on a device that holds parts awaiting return.
+
+## Example: Nested Modules (Meta Grand Teton)
+
+Some servers group their components into trays or baseboards that are themselves replaced as a unit. These can be modeled with nested modules: each tray is a module installed in the chassis, and each component is a module installed in a tray.
+
+[Grand Teton](https://www.opencompute.org/documents/grand-teton-platform-specification-v1-0-pdf/) is an AI training platform published through the Open Compute Project (Grand Teton Platform Specification v1.0). Its chassis holds three trays: a CPU tray, a switch tray, and an accelerator tray built on the [HGX baseboard](https://www.opencompute.org/documents/open-compute-specification-hgx-baseboard-contribution-r1-v0-1-pdf), which carries eight GPUs. Any server with a removable GPU tray or baseboard can be modeled the same way.
+
+```mermaid
+flowchart BT
+    gpu[8x GPU] --> at[Accelerator Tray]
+    nic[8x OCP NIC] & e1s[16x E1.S Drive] --> sw[Switch Tray]
+    cpu[2x CPU] & dimm[32x DIMM] & boot[2x Boot Drive] & cpunic[2x OCP NIC] --> ct[CPU Tray]
+    at & sw & ct --> device[Grand Teton]
+```
+
+### Device Type
+
+The Grand Teton device type has a module bay for each tray:
+
+| Name and Position | Bay Type       |
+|-------------------|----------------|
+| `GPU-TRAY`        | GT Tray Slot   |
+| `SWITCH-TRAY`     | GT Tray Slot   |
+| `CPU-TRAY`        | GT Tray Slot   |
+
+### Tray Module Types
+
+Each tray is a module type with its own module bay templates, and each takes the `GT Tray Slot` bay type so that it fits the chassis bays. When a tray is installed in the chassis, its bays (and their bay types) are created on the device automatically. Bay names are unique across all three trays, as described in [step 6](#6-add-module-bays-to-the-device-type).
+
+| Module Type         | Bay Names and Positions  | Bay Type        |
+|---------------------|--------------------------|-----------------|
+| GT Accelerator Tray | `GPU1` to `GPU8`         | SXM5 Socket     |
+| GT Switch Tray      | `NIC1` to `NIC8`         | OCP NIC 3.0     |
+|                     | `E1S0` to `E1S15`        | E1.S Bay        |
+| GT CPU Tray         | `CPU1`, `CPU2`           | LGA4677 Socket  |
+|                     | `DIMM1` to `DIMM32`      | DDR5 DIMM Slot  |
+|                     | `BOOT0`, `BOOT1`         | E1.S Bay        |
+|                     | `CPU-NIC0`, `CPU-NIC1`   | OCP NIC 3.0     |
+
+This example models only the components a technician installs into a tray. The CPU tray's boot drives may be either E1.S or M.2 devices. E1.S is shown here.
+
+### Component Module Types
+
+| Manufacturer | Model                | Profile        | Attributes                   | Bay Type        | Component Templates     |
+|--------------|----------------------|----------------|------------------------------|-----------------|-------------------------|
+| NVIDIA       | H100 SXM5 80GB       | GPU            | 80 GB memory                 | SXM5 Socket     | None                    |
+| NVIDIA       | ConnectX-7 400G OCP  | Expansion card | OCP NIC 3.0                  | OCP NIC 3.0     | Interface `{module}-p1` |
+| Intel        | Xeon Platinum 8480+  | CPU            | x86_64, 56 cores, 2.0 GHz    | LGA4677 Socket  | None                    |
+| Samsung      | 64GB DDR5-4800 RDIMM | Memory         | DDR5, 64 GB, 4800 MT/s, ECC  | DDR5 DIMM Slot  | None                    |
+| Micron       | 7450 PRO 3.84TB E1.S | Hard disk      | NVME, 3840 GB                | E1.S Bay        | None                    |
+
+The default GPU profile's interface attribute lists only PCIe options, so it is left blank for SXM GPUs. A NIC installed in bay `NIC3` creates an interface named `NIC3-p1`, and one installed in `CPU-NIC0` creates `CPU-NIC0-p1`.
+
+### Installing the Modules
+
+A tray's bays exist only once the tray has been installed, so install the trays first. With bulk import, list the trays ahead of their components, or import them separately as shown here:
+
+```no-highlight
+device,module_bay,module_type,status,serial
+gt01,GPU-TRAY,GT Accelerator Tray,active,AT00001
+gt01,SWITCH-TRAY,GT Switch Tray,active,SW00001
+gt01,CPU-TRAY,GT CPU Tray,active,CT00001
+```
+
+```no-highlight
+device,module_bay,module_type,status,serial
+gt01,GPU1,H100 SXM5 80GB,active,GPU00001
+gt01,NIC1,ConnectX-7 400G OCP,active,NIC00001
+gt01,E1S0,7450 PRO 3.84TB E1.S,active,DRV00001
+gt01,DIMM1,64GB DDR5-4800 RDIMM,active,DIMM00001
+```
+
+### Moving a Tray
+
+When a chassis fails, its accelerator tray can be moved to a spare chassis as a unit. Edit the tray's module and select the spare device and its `GPU-TRAY` bay, which must be enabled and empty. The eight GPUs installed in the tray move with it, keeping their serial numbers and change history.
+
+A tray can be moved to another device only while none of its components carry connections or device-specific configuration. The switch tray's NIC interfaces, for example, block the move while they are cabled or have IP addresses assigned. The [module documentation](../models/dcim/module.md) lists every condition.

+ 14 - 1
docs/configuration/miscellaneous.md

@@ -178,7 +178,20 @@ The number of days to retain job results (scripts and reports). Set this to `0`
 
 
 Default: `False`
 Default: `False`
 
 
-Setting this to `True` will display a "maintenance mode" banner at the top of every page. Additionally, NetBox will no longer update a user's "last active" time upon login. This is to allow new logins when the database is in a read-only state. Recording of login times will resume when maintenance mode is disabled.
+Setting this to `True` places NetBox in a read-only state, and displays a "maintenance mode" banner (see [`BANNER_MAINTENANCE`](#banner_maintenance)) at the top of every page.
+
+While maintenance mode is enabled, any request which attempts to write to the database through the web UI or the REST API fails with an error message. The following remain available:
+
+* Logging in and out, including via single sign-on and remote authentication
+* Provisioning API tokens via the REST API
+* Modifying the configuration under Admin > Configuration History, so that maintenance mode can be disabled again from the UI (unless it has been set in `configuration.py`)
+
+Additionally, NetBox will not update a user's "last login" time, or a token's "last used" time, while maintenance mode is enabled. This allows users to log in when the database itself is read-only. Note that logging in also saves the user's session, which is stored in the database unless [`SESSION_FILE_PATH`](./security.md#session_file_path) is set.
+
+User preferences that are normally saved while browsing (such as table ordering) apply only to the current request, and viewing a notification does not mark it as read.
+
+!!! note
+    Maintenance mode applies only to web UI and API requests. It does not prevent background jobs (such as scheduled scripts, data source synchronization, and housekeeping tasks) or management commands from writing to the database.
 
 
 ---
 ---
 
 

+ 1 - 0
docs/configuration/system.md

@@ -247,6 +247,7 @@ LOGGING = {
 * `netbox.auth.*` - Authentication events
 * `netbox.auth.*` - Authentication events
 * `netbox.api.views.*` - Views which handle business logic for the REST API
 * `netbox.api.views.*` - Views which handle business logic for the REST API
 * `netbox.event_rules` - Event rules
 * `netbox.event_rules` - Event rules
+* `netbox.graphql.query` - GraphQL API requests (logged at `INFO`; see [GraphQL API logging](../integrations/graphql-api.md#logging))
 * `netbox.jobs.*` - Background jobs
 * `netbox.jobs.*` - Background jobs
 * `netbox.reports.*` - Report execution (`module.name`)
 * `netbox.reports.*` - Report execution (`module.name`)
 * `netbox.scripts.*` - Custom script execution (`module.name`)
 * `netbox.scripts.*` - Custom script execution (`module.name`)

+ 30 - 0
docs/integrations/graphql-api.md

@@ -200,6 +200,36 @@ This will return up to 20 records with an ID greater than or equal to 124.
 
 
 NetBox's GraphQL API uses the same API authentication tokens as its REST API. See the [REST API authentication](./rest-api.md#authentication) documentation for further detail.
 NetBox's GraphQL API uses the same API authentication tokens as its REST API. See the [REST API authentication](./rest-api.md#authentication) documentation for further detail.
 
 
+## Logging
+
+NetBox can log each GraphQL request to the `netbox.graphql.query` logger at the `INFO` level. This logger is not enabled by default; to enable it, configure it under the [`LOGGING`](../configuration/system.md#logging) configuration parameter:
+
+```python
+LOGGING = {
+    'version': 1,
+    'handlers': {
+        'graphql': {
+            'class': 'logging.FileHandler',
+            'filename': '/var/log/netbox/graphql.log',
+        },
+    },
+    'loggers': {
+        'netbox.graphql.query': {
+            'handlers': ['graphql'],
+            'level': 'INFO',
+        },
+    },
+}
+```
+
+Each record includes the user, the API token ID (if a token was used), the client IP address, the operation name, the query, the query variables, the time taken, the number of errors, and the size of the response in bytes. The query is logged with its literal values replaced by placeholders (e.g. `name: "foo"` becomes `name: ""`), along with a hash of the result, so that identical queries can be grouped. The query and variables are each truncated to 4096 characters. These fields are also attached to each log record as a dictionary named `graphql`, for use by structured log formatters.
+
+!!! warning
+    Query variables are logged as submitted. If clients pass sensitive values as variables, those values will be written to the log.
+
+!!! tip
+    Log records are written as part of each request. On busy installations, consider using a [`QueueHandler`](https://docs.python.org/3/library/logging.handlers.html#queuehandler) so that slow log destinations don't delay API responses.
+
 ## Disabling the GraphQL API
 ## Disabling the GraphQL API
 
 
 If not needed, the GraphQL API can be disabled by setting the [`GRAPHQL_ENABLED`](../configuration/graphql-api.md#graphql_enabled) configuration parameter to False and restarting NetBox.
 If not needed, the GraphQL API can be disabled by setting the [`GRAPHQL_ENABLED`](../configuration/graphql-api.md#graphql_enabled) configuration parameter to False and restarting NetBox.

+ 1 - 1
docs/models/dcim/module.md

@@ -10,7 +10,7 @@ Similar to devices, modules are instantiated from [module types](./moduletype.md
 
 
 An installed module can be moved to a different module bay after creation. The destination bay must be enabled and unoccupied. Moving a module relocates its entire subtree: the components installed by the module, the module bays belonging to it, and any child modules installed within those bays.
 An installed module can be moved to a different module bay after creation. The destination bay must be enabled and unoccupied. Moving a module relocates its entire subtree: the components installed by the module, the module bays belonging to it, and any child modules installed within those bays.
 
 
-Component names, labels, and module bay positions derived from the module type's templates (for example, names containing `{module}`) are re-resolved for the destination bay. A component is renamed only when its current name matches exactly one of the module type's templates as resolved for the source bay; components whose names do not match any template resolution (including manually renamed components) are preserved as-is. All resulting names are validated against the destination device before the move is applied. A move is rejected when a template-derived name, label, or position would exceed the destination field's maximum length. A move is also rejected when a component's current value matched a template for the source bay but that template cannot be resolved for the destination bay's nesting depth.
+Component names, labels, and module bay positions derived from the module type's templates (for example, names containing `{module}`) are re-resolved for the destination bay. A component is renamed only when its current name matches exactly one of the module type's templates as resolved for the source bay; components whose names do not match any template resolution (including manually renamed components) are preserved as-is. All resulting names are validated against the destination device before the move is applied. A move is rejected when a template-derived name, label, or position would exceed the destination field's maximum length. A move is also rejected when a component's current value matched a template for the source bay but that template cannot be resolved for the destination bay, because the bay's nesting depth does not match the template's placeholders or a bay position that a placeholder refers to is not defined.
 
 
 Moving a module to a different device is supported only when the moved components carry no active topology or device-scoped configuration. A cross-device move is rejected while any moved component is cabled or marked as connected, has attached inventory items, or any moved interface has IP addresses, FHRP group assignments, tunnel terminations, L2VPN terminations, virtual circuit terminations, wireless links, wireless LAN assignments, VLANs (untagged, tagged, or Q-in-Q service), a VLAN translation policy, VDC assignments, or a VRF. A parent, bridge, LAG, power outlet to power port, cooling outflow to cooling intake, or front/rear port mapping relation crossing the moved module's boundary in either direction also blocks the move. MAC addresses move together with their interfaces.
 Moving a module to a different device is supported only when the moved components carry no active topology or device-scoped configuration. A cross-device move is rejected while any moved component is cabled or marked as connected, has attached inventory items, or any moved interface has IP addresses, FHRP group assignments, tunnel terminations, L2VPN terminations, virtual circuit terminations, wireless links, wireless LAN assignments, VLANs (untagged, tagged, or Q-in-Q service), a VLAN translation policy, VDC assignments, or a VRF. A parent, bridge, LAG, power outlet to power port, cooling outflow to cooling intake, or front/rear port mapping relation crossing the moved module's boundary in either direction also blocks the move. MAC addresses move together with their interfaces.
 
 

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

@@ -63,6 +63,9 @@
 
 
 ## v4.6.8 (2026-08-11)
 ## v4.6.8 (2026-08-11)
 
 
+!!! warning "Custom Script REST API Changes"
+    Custom scripts can no longer be created, modified, or deleted directly via the `/api/extras/scripts/` endpoint: `POST` requests to `/api/extras/scripts/` and `PUT`, `PATCH`, and `DELETE` requests to `/api/extras/scripts/{id}/` now return a 405 response. Scripts are managed through their modules instead; upload a new module to `/api/extras/scripts/upload/` or replace an existing one at `/api/extras/scripts/upload/{id}/`, and NetBox will add or remove scripts to match the classes it defines. Running a script via a `POST` request to `/api/extras/scripts/{id}/` is unaffected. See [#22745](https://github.com/netbox-community/netbox/issues/22745) for details.
+
 ### Performance Improvements
 ### Performance Improvements
 
 
 * [#22787](https://github.com/netbox-community/netbox/issues/22787) - Avoid N+1 queries when resolving generic relations (e.g. assigned objects) via the GraphQL API
 * [#22787](https://github.com/netbox-community/netbox/issues/22787) - Avoid N+1 queries when resolving generic relations (e.g. assigned objects) via the GraphQL API

+ 40 - 0
docs/release-notes/version-4.7.md

@@ -1,5 +1,45 @@
 # NetBox v4.7
 # NetBox v4.7
 
 
+## v4.7.5 (2026-11-10)
+
+### Enhancements
+
+* [#23470](https://github.com/netbox-community/netbox/issues/23470) - Add 3C8P, 3C12P, and 4C12P trunk cable profiles
+
+### Performance Improvements
+
+* [#23505](https://github.com/netbox-community/netbox/issues/23505) - Avoid redundant query conditions when filtering by a non-unique name or slug, such as MAC addresses by `interface` or device components by `device`
+* [#23562](https://github.com/netbox-community/netbox/issues/23562) - Batch the loading of related objects read by export templates, rather than querying them for each object
+* [#23563](https://github.com/netbox-community/netbox/issues/23563) - Batch the loading of related objects when rendering global search results
+* [#23564](https://github.com/netbox-community/netbox/issues/23564) - Batch the loading of cable terminations and their parent objects when listing cables, cable terminations, ports, and power feeds via the REST API
+* [#23565](https://github.com/netbox-community/netbox/issues/23565) - Batch the loading of deferred fields and related objects which the GraphQL query optimizer can't anticipate, such as those read when resolving `link_peers` and `connected_endpoints`
+
+### Bug Fixes
+
+* [#23185](https://github.com/netbox-community/netbox/issues/23185) - Prevent the deletion of a region or site group from also deleting the circuit terminations attached to its sites and locations
+* [#23260](https://github.com/netbox-community/netbox/issues/23260) - Fix exception when applying migration `dcim.0245_consolidate_unique_constraints` on installations whose location, region, or site group unique constraints have auto-generated names
+* [#23407](https://github.com/netbox-community/netbox/issues/23407) - Restrict the image attachments listed under an object's Images tab to those the user is permitted to view
+* [#23446](https://github.com/netbox-community/netbox/issues/23446) - Prevent duplicate submissions of object add and edit forms (such as duplicate image attachments) by disabling the submit button after the first click
+* [#23447](https://github.com/netbox-community/netbox/issues/23447) - Fix exception when ordering REST API results by an object count field (e.g. `count_ipaddresses`) which is omitted from the response by `brief` or `fields`
+* [#23477](https://github.com/netbox-community/netbox/issues/23477) - Include the `asn_count` field in the REST API representation of ASN ranges
+* [#23478](https://github.com/netbox-community/netbox/issues/23478) - Validate `{module}` placeholders in component labels and module bay templates when installing a module, rather than raising an exception or resolving them to an empty string
+* [#23486](https://github.com/netbox-community/netbox/issues/23486) - Record pre-change data in the change log for virtual chassis members when creating, editing, or deleting a virtual chassis
+* [#23510](https://github.com/netbox-community/netbox/issues/23510) - Prevent a queued background job from running after it has been deleted or marked as failed
+* [#23511](https://github.com/netbox-community/netbox/issues/23511) - Display the correct file path on a script's Source tab, and identify the script file (rather than `<string>`) in error tracebacks
+* [#23534](https://github.com/netbox-community/netbox/issues/23534) - Fix `IntegrityError` exception when completing a job whose owner already has a notification for it
+* [#23535](https://github.com/netbox-community/netbox/issues/23535) - Prevent a duplicate recurring sync job from being created when a data source is saved as its scheduled sync finishes
+* [#23542](https://github.com/netbox-community/netbox/issues/23542) - Fix `FileNotFoundError` exception when synchronizing a local data source whose subdirectory paths repeat the root path (e.g. `/scripts/scripts_old/`)
+* [#23558](https://github.com/netbox-community/netbox/issues/23558) - Fix errors when editing an API token without permission to view users, or from the user's own API tokens view
+* [#23559](https://github.com/netbox-community/netbox/issues/23559) - Fix the prefetching of related objects shown as display attributes in global search results
+* [#23574](https://github.com/netbox-community/netbox/issues/23574) - Fix exception when passing `null` to several filters (e.g. `present_in_vrf_id`, `available_at_site`, and `interface_id`), and support filtering data sources by `sync_interval=null`
+* [#23584](https://github.com/netbox-community/netbox/issues/23584) - Enforce SSO allowlist settings (e.g. `WHITELISTED_DOMAINS` and `WHITELISTED_EMAILS`) by adding `auth_allowed` to the default `SOCIAL_AUTH_PIPELINE`
+* [#23585](https://github.com/netbox-community/netbox/issues/23585) - Enforce view permissions on parent and related objects when adding individual components or component templates, and when bulk adding prefixes, IP addresses, and VLANs
+* [#23595](https://github.com/netbox-community/netbox/issues/23595) - Implement `update_dependent_objects()` for wireless links, circuit terminations, and channel subinterfaces, to rebuild cable paths after writes which bypass `save()`
+* [#23596](https://github.com/netbox-community/netbox/issues/23596) - Record pre-change data in the change log for components adopted when installing a module
+* [#23597](https://github.com/netbox-community/netbox/issues/23597) - Record change log entries and trigger event rules for port mappings created from device type and module type templates, or moved with a module to another device
+
+---
+
 ## v4.7.4 (2026-10-27)
 ## v4.7.4 (2026-10-27)
 
 
 !!! warning "Image Thumbnails on Remote Storage"
 !!! warning "Image Thumbnails on Remote Storage"

+ 1 - 0
mkdocs.yml

@@ -131,6 +131,7 @@ nav:
         - Custom Scripts: 'customization/custom-scripts.md'
         - Custom Scripts: 'customization/custom-scripts.md'
     - Best Practices:
     - Best Practices:
         - Modeling Pluggable Transceivers: 'best-practices/modeling-pluggable-transceivers.md'
         - Modeling Pluggable Transceivers: 'best-practices/modeling-pluggable-transceivers.md'
+        - Modeling Servers: 'best-practices/modeling-servers.md'
         - Performance Handbook: 'best-practices/performance-handbook.md'
         - Performance Handbook: 'best-practices/performance-handbook.md'
     - Integrations:
     - Integrations:
         - REST API: 'integrations/rest-api.md'
         - REST API: 'integrations/rest-api.md'

+ 27 - 0
netbox/account/tests.py

@@ -0,0 +1,27 @@
+from django.test import override_settings
+from django.urls import reverse
+
+from users.models import Token
+from utilities.testing import TestCase
+
+
+@override_settings(DEFAULT_PERMISSIONS={
+    'users.change_token': ({'user': '$user'},),
+})
+class UserTokenEditTestCase(TestCase):
+
+    def test_edit_own_token(self):
+        token = Token.objects.create(user=self.user, description='Original description')
+        url = reverse('account:usertoken_edit', kwargs={'pk': token.pk})
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+        response = self.client.post(url, {
+            'description': 'Updated description',
+            'enabled': 'on',
+        })
+        self.assertEqual(response.status_code, 302)
+        token.refresh_from_db()
+        self.assertEqual(token.description, 'Updated description')
+        self.assertEqual(token.user, self.user)

+ 1 - 9
netbox/account/views.py

@@ -8,8 +8,6 @@ from django.contrib.auth import logout as auth_logout
 from django.contrib.auth import update_session_auth_hash
 from django.contrib.auth import update_session_auth_hash
 from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm
 from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm
 from django.contrib.auth.mixins import LoginRequiredMixin
 from django.contrib.auth.mixins import LoginRequiredMixin
-from django.contrib.auth.models import update_last_login
-from django.contrib.auth.signals import user_logged_in
 from django.http import HttpResponseRedirect, JsonResponse
 from django.http import HttpResponseRedirect, JsonResponse
 from django.shortcuts import get_object_or_404, redirect, render, resolve_url
 from django.shortcuts import get_object_or_404, redirect, render, resolve_url
 from django.urls import reverse, reverse_lazy
 from django.urls import reverse, reverse_lazy
@@ -117,12 +115,6 @@ class LoginView(View):
         if form.is_valid():
         if form.is_valid():
             logger.debug("Login form validation was successful")
             logger.debug("Login form validation was successful")
 
 
-            # If maintenance mode is enabled, assume the database is read-only, and disable updating the user's
-            # last_login time upon authentication.
-            if get_config().MAINTENANCE_MODE:
-                logger.warning("Maintenance mode enabled: disabling update of most recent login time")
-                user_logged_in.disconnect(update_last_login, dispatch_uid='update_last_login')
-
             # Authenticate user
             # Authenticate user
             auth_login(request, form.get_user())
             auth_login(request, form.get_user())
             logger.info(f"User {request.user} successfully authenticated")
             logger.info(f"User {request.user} successfully authenticated")
@@ -147,7 +139,7 @@ class LoginView(View):
 
 
             return response
             return response
 
 
-        username = form['username'].value()
+        username = form['username'].value() or ''
         logger.debug(f"Login form validation failed for username: {remove_linebreaks(username)}")
         logger.debug(f"Login form validation failed for username: {remove_linebreaks(username)}")
 
 
         return render(request, self.template_name, {
         return render(request, self.template_name, {

+ 34 - 0
netbox/circuits/migrations/0061_circuitterminations_cached_ancestors_on_delete.py

@@ -0,0 +1,34 @@
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('circuits', '0060_denormalization_triggers'),
+        ('dcim', '0251_fix_ltree_cascade_triggers'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='circuittermination',
+            name='_region',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name='circuit_terminations',
+                to='dcim.region',
+            ),
+        ),
+        migrations.AlterField(
+            model_name='circuittermination',
+            name='_site_group',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name='circuit_terminations',
+                to='dcim.sitegroup',
+            ),
+        ),
+    ]

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

@@ -9,6 +9,7 @@ from django.utils.translation import gettext_lazy as _
 
 
 from circuits.choices import *
 from circuits.choices import *
 from dcim.models import CabledObjectModel
 from dcim.models import CabledObjectModel
+from dcim.utils import rebuild_paths
 from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel
 from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel
 from netbox.models.features import (
 from netbox.models.features import (
     ContactsMixin,
     ContactsMixin,
@@ -345,16 +346,17 @@ class CircuitTermination(
         blank=True,
         blank=True,
         null=True
         null=True
     )
     )
+    # SET_NULL, not CASCADE: may cache an ancestor. Direct terminations cascade via direct_circuit_terminations.
     _region = models.ForeignKey(
     _region = models.ForeignKey(
         to='dcim.Region',
         to='dcim.Region',
-        on_delete=models.CASCADE,
+        on_delete=models.SET_NULL,
         related_name='circuit_terminations',
         related_name='circuit_terminations',
         blank=True,
         blank=True,
         null=True
         null=True
     )
     )
     _site_group = models.ForeignKey(
     _site_group = models.ForeignKey(
         to='dcim.SiteGroup',
         to='dcim.SiteGroup',
-        on_delete=models.CASCADE,
+        on_delete=models.SET_NULL,
         related_name='circuit_terminations',
         related_name='circuit_terminations',
         blank=True,
         blank=True,
         null=True
         null=True
@@ -499,3 +501,10 @@ class CircuitTermination(
             )
             )
         except CircuitTermination.DoesNotExist:
         except CircuitTermination.DoesNotExist:
             return None
             return None
+
+    def update_dependent_objects(self):
+        """
+        Rebuild the CablePaths which traverse this CircuitTermination or its peer.
+        """
+        rebuild_paths([t for t in (self, self.get_peer_termination()) if t])
+    update_dependent_objects.alters_data = True

+ 66 - 1
netbox/circuits/tests/test_models.py

@@ -8,11 +8,12 @@ from circuits.models import Circuit, CircuitTermination, CircuitType, Provider,
 from circuits.models.circuits import _set_circuit_terminations
 from circuits.models.circuits import _set_circuit_terminations
 from core.choices import ObjectChangeActionChoices
 from core.choices import ObjectChangeActionChoices
 from core.models import ObjectChange
 from core.models import ObjectChange
-from dcim.models import Location, Region, Site, SiteGroup
+from dcim.models import Cable, Interface, Location, Region, Site, SiteGroup
 from extras.choices import CustomFieldStatusChoices, CustomFieldTypeChoices
 from extras.choices import CustomFieldStatusChoices, CustomFieldTypeChoices
 from extras.models import CustomField
 from extras.models import CustomField
 from netbox.context_managers import event_tracking
 from netbox.context_managers import event_tracking
 from users.models import User
 from users.models import User
+from utilities.testing import create_test_device
 
 
 
 
 class CircuitTerminationTestCase(TestCase):
 class CircuitTerminationTestCase(TestCase):
@@ -198,6 +199,53 @@ class CircuitTerminationTestCase(TestCase):
         self.assertIn('Please select a Provider Network.', errors[NON_FIELD_ERRORS])
         self.assertIn('Please select a Provider Network.', errors[NON_FIELD_ERRORS])
         self.assertNotIn('termination_id', errors)
         self.assertNotIn('termination_id', errors)
 
 
+    @tag('regression')  # Ref: #23185
+    def test_deleting_site_group_keeps_terminations_of_its_sites(self):
+        """Deleting a SiteGroup deletes only the terminations attached to it directly."""
+        site_group = SiteGroup.objects.create(name='Site Group 1', slug='site-group-1')
+        site = Site.objects.create(name='Site 3', slug='site-3', group=site_group)
+        location = Location.objects.create(name='Location 1', slug='location-1', site=site)
+        direct = CircuitTermination.objects.create(circuit=self.circuits[0], term_side='A', termination=site_group)
+        kept = (
+            CircuitTermination.objects.create(circuit=self.circuits[0], term_side='Z', termination=site),
+            CircuitTermination.objects.create(circuit=self.circuits[1], term_side='A', termination=location),
+        )
+        interface = Interface.objects.create(device=create_test_device('Device 1'), name='Interface 1')
+        cable = Cable(a_terminations=[interface], b_terminations=[kept[0]])
+        cable.save()
+
+        site_group.delete()
+
+        self.assertFalse(CircuitTermination.objects.filter(pk=direct.pk).exists())
+        for termination in kept:
+            termination.refresh_from_db()
+            self.assertIsNone(termination._site_group_id)
+        self.assertEqual(kept[0].cable, cable)
+        self.assertTrue(kept[0].cable_terminations.exists())
+
+    @tag('regression')  # Ref: #23185
+    def test_deleting_region_keeps_terminations_of_its_sites(self):
+        """Deleting a Region and its child deletes only the terminations attached to them directly."""
+        parent = Region.objects.create(name='Region 1', slug='region-1')
+        region = Region.objects.create(name='Region 2', slug='region-2', parent=parent)
+        site = Site.objects.create(name='Site 3', slug='site-3', region=region)
+        location = Location.objects.create(name='Location 1', slug='location-1', site=site)
+        direct = (
+            CircuitTermination.objects.create(circuit=self.circuits[0], term_side='A', termination=parent),
+            CircuitTermination.objects.create(circuit=self.circuits[0], term_side='Z', termination=region),
+        )
+        kept = (
+            CircuitTermination.objects.create(circuit=self.circuits[1], term_side='A', termination=site),
+            CircuitTermination.objects.create(circuit=self.circuits[1], term_side='Z', termination=location),
+        )
+
+        parent.delete()
+
+        self.assertFalse(CircuitTermination.objects.filter(pk__in=[t.pk for t in direct]).exists())
+        for termination in kept:
+            termination.refresh_from_db()
+            self.assertIsNone(termination._region_id)
+
 
 
 class CircuitTerminationDenormalizationTriggerTestCase(TestCase):
 class CircuitTerminationDenormalizationTriggerTestCase(TestCase):
     """
     """
@@ -571,6 +619,23 @@ class CircuitTerminationChangeLoggingTestCase(TestCase):
         )
         )
         self.assertLess(changes[0].pk, termination_delete.pk)
         self.assertLess(changes[0].pk, termination_delete.pk)
 
 
+    @tag('regression')  # Ref: #23185
+    def test_site_group_deletion_keeps_terminations_of_its_sites(self):
+        """In a request, deleting a SiteGroup deletes and logs the termination attached to it and keeps the other."""
+        site_group = SiteGroup.objects.create(name='Site Group 1', slug='site-group-1')
+        site = Site.objects.create(name='Site 3', slug='site-3', group=site_group)
+        direct = CircuitTermination.objects.create(circuit=self.circuits[0], term_side='A', termination=site_group)
+        kept = CircuitTermination.objects.create(circuit=self.circuits[0], term_side='Z', termination=site)
+        interface = Interface.objects.create(device=create_test_device('Device 1'), name='Interface 1')
+        Cable(a_terminations=[interface], b_terminations=[kept]).save()
+
+        self._tracked(site_group.delete)
+
+        self._termination_change(direct.pk, ObjectChangeActionChoices.ACTION_DELETE)
+        kept.refresh_from_db()
+        self.assertIsNone(kept._site_group_id)
+        self.assertTrue(kept.cable_terminations.exists())
+
     def test_circuit_deletion_records_no_pointer_update(self):
     def test_circuit_deletion_records_no_pointer_update(self):
         self._tracked(lambda: CircuitTermination.objects.create(
         self._tracked(lambda: CircuitTermination.objects.create(
             circuit=self.circuits[0], term_side='A', termination=self.sites[0],
             circuit=self.circuits[0], term_side='A', termination=self.sites[0],

+ 0 - 1
netbox/core/filtersets.py

@@ -37,7 +37,6 @@ class DataSourceFilterSet(PrimaryModelFilterSet):
     sync_interval = django_filters.MultipleChoiceFilter(
     sync_interval = django_filters.MultipleChoiceFilter(
         choices=JobIntervalChoices,
         choices=JobIntervalChoices,
         distinct=False,
         distinct=False,
-        null_value=None
     )
     )
 
 
     class Meta:
     class Meta:

+ 12 - 5
netbox/core/models/jobs.py

@@ -251,9 +251,10 @@ class Job(models.Model):
         return True
         return True
     start.alters_data = True
     start.alters_data = True
 
 
-    def terminate(self, status=JobStatusChoices.STATUS_COMPLETED, error=None):
+    def terminate(self, status=JobStatusChoices.STATUS_COMPLETED, error=None, *, dispatch=True):
         """
         """
-        Mark the job as completed, optionally specifying a particular termination status.
+        Mark the job as completed, optionally specifying a particular termination status. With dispatch=False, the
+        owner's notification and job_end are left to a later _dispatch_termination() call.
         """
         """
         if status not in JobStatusChoices.TERMINAL_STATE_CHOICES:
         if status not in JobStatusChoices.TERMINAL_STATE_CHOICES:
             raise ValueError(
             raise ValueError(
@@ -271,11 +272,18 @@ class Job(models.Model):
             self.execution_time = self.completed - self.started
             self.execution_time = self.completed - self.started
         self.save()
         self.save()
 
 
-        # Notify the user (if any) of completion
+        if dispatch:
+            self._dispatch_termination()
+    terminate.alters_data = True
+
+    def _dispatch_termination(self):
+        """
+        Notify the owner (if any) of the job's outcome and send job_end.
+        """
         if self.user and self.notifications != JobNotificationChoices.NOTIFICATION_NEVER:
         if self.user and self.notifications != JobNotificationChoices.NOTIFICATION_NEVER:
             if (
             if (
                 self.notifications == JobNotificationChoices.NOTIFICATION_ALWAYS or
                 self.notifications == JobNotificationChoices.NOTIFICATION_ALWAYS or
-                status != JobStatusChoices.STATUS_COMPLETED
+                self.status != JobStatusChoices.STATUS_COMPLETED
             ):
             ):
                 # A Job may be terminated more than once, so update any earlier notification in place
                 # A Job may be terminated more than once, so update any earlier notification in place
                 Notification.objects.update_or_create(
                 Notification.objects.update_or_create(
@@ -292,7 +300,6 @@ class Job(models.Model):
 
 
         # Send signal
         # Send signal
         job_end.send(self)
         job_end.send(self)
-    terminate.alters_data = True
 
 
     def log(self, record: logging.LogRecord):
     def log(self, record: logging.LogRecord):
         """
         """

+ 18 - 1
netbox/core/signals.py

@@ -8,7 +8,7 @@ from django.core.signals import request_finished
 from django.db import transaction
 from django.db import transaction
 from django.db.models import CASCADE, RESTRICT
 from django.db.models import CASCADE, RESTRICT
 from django.db.models.fields.reverse_related import ManyToManyRel, ManyToOneRel
 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.db.models.signals import m2m_changed, post_migrate, post_save, pre_delete, pre_save
 from django.dispatch import Signal, receiver
 from django.dispatch import Signal, receiver
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import ngettext
 from django.utils.translation import ngettext
@@ -93,6 +93,19 @@ _signals_received = local()
 RUNNING_TESTS = 'test' in sys.argv
 RUNNING_TESTS = 'test' in sys.argv
 
 
 
 
+@receiver(pre_save)
+def snapshot_unsnapshotted_object(sender, instance, raw=False, using=None, **kwargs):
+    """
+    Generate a pre-change snapshot from the database for an existing object being saved without one.
+    """
+    if raw or not isinstance(instance, ChangeLoggingMixin) or current_request.get() is None:
+        return
+    if instance._state.adding or instance.pk is None or hasattr(instance, '_prechange_snapshot'):
+        return
+
+    instance.snapshot_from_db(using=using)
+
+
 @receiver((post_save, m2m_changed))
 @receiver((post_save, m2m_changed))
 def handle_changed_object(sender, instance, **kwargs):
 def handle_changed_object(sender, instance, **kwargs):
     """
     """
@@ -170,6 +183,10 @@ def handle_changed_object(sender, instance, **kwargs):
     enqueue_event(queue, instance, request, event_type, object_change_id=object_change_id)
     enqueue_event(queue, instance, request, event_type, object_change_id=object_change_id)
     events_queue.set(queue)
     events_queue.set(queue)
 
 
+    # Discard a snapshot read from the database only once the event has captured it
+    if instance.__dict__.pop('_prechange_snapshot_from_db', False):
+        instance.__dict__.pop('_prechange_snapshot', None)
+
     # Increment metric counters
     # Increment metric counters
     if event_type == OBJECT_CREATED:
     if event_type == OBJECT_CREATED:
         model_inserts.labels(instance._meta.model_name).inc()
         model_inserts.labels(instance._meta.model_name).inc()

+ 14 - 1
netbox/core/tests/test_filtersets.py

@@ -46,7 +46,6 @@ class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
                 source_url='https://example.com/git/source3',
                 source_url='https://example.com/git/source3',
                 status=DataSourceStatusChoices.COMPLETED,
                 status=DataSourceStatusChoices.COMPLETED,
                 enabled=False,
                 enabled=False,
-                sync_interval=JobIntervalChoices.INTERVAL_WEEKLY
             ),
             ),
         )
         )
         DataSource.objects.bulk_create(data_sources)
         DataSource.objects.bulk_create(data_sources)
@@ -81,6 +80,20 @@ class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         params = {'sync_interval': [JobIntervalChoices.INTERVAL_HOURLY, JobIntervalChoices.INTERVAL_DAILY]}
         params = {'sync_interval': [JobIntervalChoices.INTERVAL_HOURLY, JobIntervalChoices.INTERVAL_DAILY]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+        # The null choice value matches data sources without a sync interval
+        for params, count in (
+            ({'sync_interval': ['null']}, 1),
+            ({'sync_interval__n': ['null']}, 2),
+            ({'sync_interval': [JobIntervalChoices.INTERVAL_HOURLY, 'null']}, 2),
+        ):
+            filterset = self.filterset(params, self.queryset)
+            self.assertTrue(filterset.is_valid(), msg=params)
+            self.assertEqual(filterset.qs.count(), count, msg=params)
+
+        # Reject invalid filter values
+        for params in ({'sync_interval': ['None']}, {'sync_interval__n': ['None']}):
+            self.assertFalse(self.filterset(params, self.queryset).is_valid(), msg=params)
+
 
 
 class DataFileTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
 class DataFileTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
     queryset = DataFile.objects.all()
     queryset = DataFile.objects.all()

+ 38 - 2
netbox/core/tests/test_jobs.py

@@ -4,12 +4,13 @@ from unittest.mock import MagicMock, patch
 
 
 import requests
 import requests
 from django.conf import settings
 from django.conf import settings
-from django.test import TestCase, override_settings
+from django.test import TestCase, override_settings, tag
 from django.utils import timezone
 from django.utils import timezone
 
 
-from core.choices import DataSourceStatusChoices
+from core.choices import DataSourceStatusChoices, JobStatusChoices
 from core.jobs import SyncDataSourceJob, SystemHousekeepingJob
 from core.jobs import SyncDataSourceJob, SystemHousekeepingJob
 from core.models import DataFile, DataSource, Job
 from core.models import DataFile, DataSource, Job
+from core.signals import job_end
 
 
 
 
 def _make_runner(cls, **job_attrs):
 def _make_runner(cls, **job_attrs):
@@ -43,6 +44,28 @@ class SyncDataSourceJobTestCase(TestCase):
             source_url='/tmp/test',
             source_url='/tmp/test',
         )
         )
 
 
+    def handle_recurring_sync(self, **changes):
+        """Handle a recurring sync, saving its data source with `changes` as the job ends, and list waiting jobs."""
+        self.datasource.sync_interval = 60
+        self.datasource.save()
+        job = SyncDataSourceJob.get_jobs(self.datasource).get()
+
+        def save_datasource(sender, **kwargs):
+            datasource = DataSource.objects.get(pk=self.datasource.pk)
+            for name, value in changes.items():
+                setattr(datasource, name, value)
+            datasource.save()
+
+        job_end.connect(save_datasource, sender=job)
+        try:
+            with patch.object(SyncDataSourceJob, 'run'):
+                SyncDataSourceJob.handle(job)
+        finally:
+            job_end.disconnect(save_datasource, sender=job)
+
+        waiting = SyncDataSourceJob.get_jobs(self.datasource).filter(status__in=JobStatusChoices.ENQUEUED_STATE_CHOICES)
+        return list(waiting.values_list('status', 'interval'))
+
     def test_enqueue_sets_datasource_status_to_queued(self):
     def test_enqueue_sets_datasource_status_to_queued(self):
         job = MagicMock()
         job = MagicMock()
         job.object = self.datasource
         job.object = self.datasource
@@ -126,6 +149,19 @@ class SyncDataSourceJobTestCase(TestCase):
         with self.assertRaises(DataSource.DoesNotExist):
         with self.assertRaises(DataSource.DoesNotExist):
             runner.run()
             runner.run()
 
 
+    @tag('regression')  # Ref: #23535
+    def test_handle_keeps_one_schedule_when_saved_on_end(self):
+        """Saving the data source unchanged as its sync ends keeps one schedule."""
+        self.assertEqual(self.handle_recurring_sync(), [(JobStatusChoices.STATUS_SCHEDULED, 60)])
+
+    def test_handle_replaces_schedule_when_interval_changed_on_end(self):
+        """Changing the sync interval as the sync ends replaces the schedule."""
+        self.assertEqual(self.handle_recurring_sync(sync_interval=720), [(JobStatusChoices.STATUS_PENDING, 720)])
+
+    def test_handle_removes_schedule_when_disabled_on_end(self):
+        """Disabling the data source as its sync ends leaves no schedule."""
+        self.assertEqual(self.handle_recurring_sync(enabled=False), [])
+
 
 
 class SystemHousekeepingRunTestCase(HousekeepingRunnerMixin, TestCase):
 class SystemHousekeepingRunTestCase(HousekeepingRunnerMixin, TestCase):
     SUBMETHODS = (
     SUBMETHODS = (

+ 20 - 0
netbox/core/tests/test_models.py

@@ -609,6 +609,26 @@ class JobTestCase(TestCase):
         self.assertIsNone(notification.read)
         self.assertIsNone(notification.read)
         mock_job_end.send.assert_called_once_with(job)
         mock_job_end.send.assert_called_once_with(job)
 
 
+    @patch('core.models.jobs.job_end')
+    def test_terminate_without_dispatch(self, mock_job_end):
+        """Terminating without dispatch saves the outcome and leaves the notification and job_end for later."""
+        user = User.objects.create_user(username='notification-deferred')
+        job = self._make_job(user, JobNotificationChoices.NOTIFICATION_ON_FAILURE)
+
+        job.terminate(status=JobStatusChoices.STATUS_FAILED, dispatch=False)
+
+        job.refresh_from_db()
+        self.assertEqual(job.status, JobStatusChoices.STATUS_FAILED)
+        self.assertIsNotNone(job.completed)
+        self.assertFalse(Notification.objects.filter(user=user, object_id=job.pk).exists())
+        mock_job_end.send.assert_not_called()
+
+        job._dispatch_termination()
+
+        notification = Notification.objects.get(user=user, object_id=job.pk)
+        self.assertEqual(notification.event_type, JOB_FAILED)
+        mock_job_end.send.assert_called_once_with(job)
+
     @patch('core.models.jobs.job_end')
     @patch('core.models.jobs.job_end')
     def test_execution_time_set_on_terminate(self, mock_job_end):
     def test_execution_time_set_on_terminate(self, mock_job_end):
         """
         """

+ 57 - 0
netbox/core/tests/test_signals.py

@@ -96,6 +96,63 @@ class HandleChangedObjectSignalTestCase(TestCase):
             site.description = 'updated'
             site.description = 'updated'
             site.save()
             site.save()
 
 
+    def test_update_without_snapshot_records_prechange_from_database(self):
+        """An update saved without a snapshot records its pre-change data from the database. Ref: #23617"""
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        request = _build_request(self.user)
+
+        with (
+            patch('core.signals.RUNNING_TESTS', False),
+            self.assertLogs('netbox.models.features', level='WARNING'),
+            event_tracking(request),
+        ):
+            site.description = 'first'
+            site.save()
+            site.description = 'second'
+            site.save()
+
+        ocs = ObjectChange.objects.filter(
+            changed_object_type=ContentType.objects.get_for_model(Site),
+            changed_object_id=site.pk,
+        ).order_by('pk')
+        self.assertEqual([oc.prechange_data['description'] for oc in ocs], ['', 'first'])
+        self.assertEqual([oc.postchange_data['description'] for oc in ocs], ['first', 'second'])
+        self.assertFalse(hasattr(site, '_prechange_snapshot'))
+
+    def test_update_without_snapshot_enqueues_prechange_from_database(self):
+        """The event for an update saved without a snapshot carries its pre-change data. Ref: #23617"""
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        request = _build_request(self.user)
+
+        with (
+            patch('core.signals.RUNNING_TESTS', False),
+            self.assertLogs('netbox.models.features', level='WARNING'),
+            event_tracking(request),
+        ):
+            site.description = 'updated'
+            site.save()
+            snapshots = events_queue.get()[f'dcim.site:{site.pk}']['snapshots']
+
+        self.assertEqual(snapshots['prechange']['description'], '')
+        self.assertEqual(snapshots['postchange']['description'], 'updated')
+        self.assertFalse(hasattr(site, '_prechange_snapshot'))
+
+    def test_update_keeps_existing_snapshot(self):
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        request = _build_request(self.user)
+
+        with self.assertNoLogs('netbox.models.features', level='WARNING'), event_tracking(request):
+            site.snapshot()
+            site.description = 'first'
+            site.save()
+
+        oc = ObjectChange.objects.get(
+            changed_object_type=ContentType.objects.get_for_model(Site),
+            changed_object_id=site.pk,
+        )
+        self.assertEqual(oc.prechange_data['description'], '')
+        self.assertEqual(oc.postchange_data['description'], 'first')
+
     def test_no_request_skips_objectchange(self):
     def test_no_request_skips_objectchange(self):
         # Saving outside a request context (no event_tracking) should not record any
         # Saving outside a request context (no event_tracking) should not record any
         # ObjectChange entries.
         # ObjectChange entries.

+ 14 - 11
netbox/dcim/api/serializers_/devices.py

@@ -7,7 +7,7 @@ from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 from rest_framework import serializers
 
 
 from dcim.choices import *
 from dcim.choices import *
-from dcim.constants import MACADDRESS_ASSIGNMENT_MODELS, MODULE_TOKEN
+from dcim.constants import MACADDRESS_ASSIGNMENT_MODELS
 from dcim.models import Device, DeviceBay, MACAddress, Module, VirtualDeviceContext
 from dcim.models import Device, DeviceBay, MACAddress, Module, VirtualDeviceContext
 from dcim.utils import get_module_bay_positions, resolve_module_placeholder
 from dcim.utils import get_module_bay_positions, resolve_module_placeholder
 from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
 from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
@@ -274,16 +274,11 @@ class ModuleSerializer(PrimaryModelSerializer):
             }
             }
 
 
             for template in getattr(module_type, templates_attr).all():
             for template in getattr(module_type, templates_attr).all():
-                resolved_name = template.name
-                if MODULE_TOKEN in template.name:
-                    if not module_bay.position:
-                        raise serializers.ValidationError(
-                            _("Cannot install module with placeholder values in a module bay with no position defined.")
-                        )
-                    try:
-                        resolved_name = resolve_module_placeholder(template.name, positions)
-                    except ValueError as e:
-                        raise serializers.ValidationError(str(e))
+                try:
+                    resolved_name = resolve_module_placeholder(template.name, positions)
+                    resolve_module_placeholder(template.label, positions)
+                except ValueError as e:
+                    raise serializers.ValidationError(str(e)) from e
 
 
                 existing_item = installed_components.get(resolved_name)
                 existing_item = installed_components.get(resolved_name)
 
 
@@ -303,6 +298,14 @@ class ModuleSerializer(PrimaryModelSerializer):
                         )
                         )
                     )
                     )
 
 
+        # Module bay names are unique per module, not per device, so only their placeholders are checked
+        for template in module_type.modulebaytemplates.all():
+            try:
+                for value in (template.name, template.label, template.position):
+                    resolve_module_placeholder(value, positions)
+            except ValueError as e:
+                raise serializers.ValidationError(str(e)) from e
+
         return data
         return data
 
 
     def create(self, validated_data):
     def create(self, validated_data):

+ 38 - 16
netbox/dcim/api/views.py

@@ -1,4 +1,5 @@
 from django.contrib.contenttypes.prefetch import GenericPrefetch
 from django.contrib.contenttypes.prefetch import GenericPrefetch
+from django.db.models import FETCH_PEERS, Prefetch
 from django.http import Http404, HttpResponse
 from django.http import Http404, HttpResponse
 from django.shortcuts import get_object_or_404
 from django.shortcuts import get_object_or_404
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.types import OpenApiTypes
@@ -34,6 +35,25 @@ class DCIMRootView(APIRootView):
         return 'DCIM'
         return 'DCIM'
 
 
 
 
+class CableTerminationsPrefetch(Prefetch):
+    """
+    Prefetch cable terminations using the FETCH_PEERS fetch mode, which objects loaded by a to-many prefetch don't
+    inherit from the parent queryset.
+    """
+    def __init__(self, lookup='cable__terminations'):
+        super().__init__(lookup, queryset=self._get_queryset())
+
+    @staticmethod
+    def _get_queryset():
+        return CableTermination.objects.fetch_mode(FETCH_PEERS)
+
+    def get_current_querysets(self, level):
+        # Django adds routing hints to the queryset it's given, so build a new one rather than share this one
+        if super().get_current_querysets(level):
+            return [self._get_queryset()]
+        return None
+
+
 # Mixins
 # Mixins
 
 
 class PathEndpointMixin:
 class PathEndpointMixin:
@@ -459,32 +479,32 @@ class ModuleViewSet(NetBoxModelViewSet):
 #
 #
 
 
 class ConsolePortViewSet(PathEndpointMixin, NetBoxModelViewSet):
 class ConsolePortViewSet(PathEndpointMixin, NetBoxModelViewSet):
-    queryset = ConsolePort.objects.prefetch_related(
-        '_path', 'cable__terminations',
+    queryset = ConsolePort.objects.fetch_mode(FETCH_PEERS).prefetch_related(
+        '_path', CableTerminationsPrefetch(),
     )
     )
     serializer_class = serializers.ConsolePortSerializer
     serializer_class = serializers.ConsolePortSerializer
     filterset_class = filtersets.ConsolePortFilterSet
     filterset_class = filtersets.ConsolePortFilterSet
 
 
 
 
 class ConsoleServerPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
 class ConsoleServerPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
-    queryset = ConsoleServerPort.objects.prefetch_related(
-        '_path', 'cable__terminations',
+    queryset = ConsoleServerPort.objects.fetch_mode(FETCH_PEERS).prefetch_related(
+        '_path', CableTerminationsPrefetch(),
     )
     )
     serializer_class = serializers.ConsoleServerPortSerializer
     serializer_class = serializers.ConsoleServerPortSerializer
     filterset_class = filtersets.ConsoleServerPortFilterSet
     filterset_class = filtersets.ConsoleServerPortFilterSet
 
 
 
 
 class PowerPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
 class PowerPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
-    queryset = PowerPort.objects.prefetch_related(
-        '_path', 'cable__terminations',
+    queryset = PowerPort.objects.fetch_mode(FETCH_PEERS).prefetch_related(
+        '_path', CableTerminationsPrefetch(),
     )
     )
     serializer_class = serializers.PowerPortSerializer
     serializer_class = serializers.PowerPortSerializer
     filterset_class = filtersets.PowerPortFilterSet
     filterset_class = filtersets.PowerPortFilterSet
 
 
 
 
 class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet):
 class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet):
-    queryset = PowerOutlet.objects.prefetch_related(
-        '_path', 'cable__terminations',
+    queryset = PowerOutlet.objects.fetch_mode(FETCH_PEERS).prefetch_related(
+        '_path', CableTerminationsPrefetch(),
     )
     )
     serializer_class = serializers.PowerOutletSerializer
     serializer_class = serializers.PowerOutletSerializer
     filterset_class = filtersets.PowerOutletFilterSet
     filterset_class = filtersets.PowerOutletFilterSet
@@ -527,16 +547,16 @@ class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
 
 
 
 
 class FrontPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
 class FrontPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
-    queryset = FrontPort.objects.prefetch_related(
-        'cable__terminations',
+    queryset = FrontPort.objects.fetch_mode(FETCH_PEERS).prefetch_related(
+        CableTerminationsPrefetch(),
     )
     )
     serializer_class = serializers.FrontPortSerializer
     serializer_class = serializers.FrontPortSerializer
     filterset_class = filtersets.FrontPortFilterSet
     filterset_class = filtersets.FrontPortFilterSet
 
 
 
 
 class RearPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
 class RearPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
-    queryset = RearPort.objects.prefetch_related(
-        'cable__terminations',
+    queryset = RearPort.objects.fetch_mode(FETCH_PEERS).prefetch_related(
+        CableTerminationsPrefetch(),
     )
     )
     serializer_class = serializers.RearPortSerializer
     serializer_class = serializers.RearPortSerializer
     filterset_class = filtersets.RearPortFilterSet
     filterset_class = filtersets.RearPortFilterSet
@@ -588,14 +608,16 @@ class MACAddressViewSet(NetBoxModelViewSet):
 #
 #
 
 
 class CableViewSet(NetBoxModelViewSet):
 class CableViewSet(NetBoxModelViewSet):
-    queryset = Cable.objects.prefetch_related('terminations__termination')
+    queryset = Cable.objects.prefetch_related(
+        CableTerminationsPrefetch('terminations'),
+    )
     serializer_class = serializers.CableSerializer
     serializer_class = serializers.CableSerializer
     filterset_class = filtersets.CableFilterSet
     filterset_class = filtersets.CableFilterSet
 
 
 
 
 class CableTerminationViewSet(NetBoxReadOnlyModelViewSet):
 class CableTerminationViewSet(NetBoxReadOnlyModelViewSet):
     metadata_class = ContentTypeMetadata
     metadata_class = ContentTypeMetadata
-    queryset = CableTermination.objects.all()
+    queryset = CableTermination.objects.fetch_mode(FETCH_PEERS)
     serializer_class = serializers.CableTerminationSerializer
     serializer_class = serializers.CableTerminationSerializer
     filterset_class = filtersets.CableTerminationFilterSet
     filterset_class = filtersets.CableTerminationFilterSet
 
 
@@ -636,8 +658,8 @@ class PowerPanelViewSet(NetBoxModelViewSet):
 #
 #
 
 
 class PowerFeedViewSet(PathEndpointMixin, NetBoxModelViewSet):
 class PowerFeedViewSet(PathEndpointMixin, NetBoxModelViewSet):
-    queryset = PowerFeed.objects.prefetch_related(
-        '_path', 'cable__terminations',
+    queryset = PowerFeed.objects.fetch_mode(FETCH_PEERS).prefetch_related(
+        '_path', CableTerminationsPrefetch(),
     )
     )
     serializer_class = serializers.PowerFeedSerializer
     serializer_class = serializers.PowerFeedSerializer
     filterset_class = filtersets.PowerFeedFilterSet
     filterset_class = filtersets.PowerFeedFilterSet

+ 1 - 0
netbox/dcim/filtersets.py

@@ -1353,6 +1353,7 @@ class PlatformFilterSet(NestedGroupModelFilterSet):
     )
     )
     available_for_device_type = django_filters.ModelChoiceFilter(
     available_for_device_type = django_filters.ModelChoiceFilter(
         queryset=DeviceType.objects.all(),
         queryset=DeviceType.objects.all(),
+        null_label=None,
         method='get_for_device_type'
         method='get_for_device_type'
     )
     )
     config_template_id = django_filters.ModelMultipleChoiceFilter(
     config_template_id = django_filters.ModelMultipleChoiceFilter(

+ 30 - 37
netbox/dcim/forms/bulk_import.py

@@ -197,7 +197,7 @@ class LocationImportForm(NestedGroupModelImportForm):
 
 
         if data:
         if data:
             # Limit location queryset by assigned site
             # Limit location queryset by assigned site
-            params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
+            params = self.get_related_filter('site', 'site', data.get('site'))
             self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
             self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
 
 
 
 
@@ -369,7 +369,7 @@ class RackImportForm(PrimaryModelImportForm):
         if data:
         if data:
 
 
             # Limit location queryset by assigned site
             # Limit location queryset by assigned site
-            params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
+            params = self.get_related_filter('site', 'site', data.get('site'))
             self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
             self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
 
 
     def clean(self):
     def clean(self):
@@ -432,13 +432,13 @@ class RackReservationImportForm(PrimaryModelImportForm):
         if data:
         if data:
 
 
             # Limit location queryset by assigned site
             # Limit location queryset by assigned site
-            params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
+            params = self.get_related_filter('site', 'site', data.get('site'))
             self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
             self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
 
 
             # Limit rack queryset by assigned site and group
             # Limit rack queryset by assigned site and group
             params = {
             params = {
-                f"site__{self.fields['site'].to_field_name}": data.get('site'),
-                f"location__{self.fields['location'].to_field_name}": data.get('location'),
+                **self.get_related_filter('site', 'site', data.get('site')),
+                **self.get_related_filter('location', 'location', data.get('location')),
             }
             }
             self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
             self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
 
 
@@ -691,7 +691,7 @@ class BaseDeviceImportForm(PrimaryModelImportForm):
         if data:
         if data:
 
 
             # Limit device type queryset by manufacturer
             # Limit device type queryset by manufacturer
-            params = {f"manufacturer__{self.fields['manufacturer'].to_field_name}": data.get('manufacturer')}
+            params = self.get_related_filter('manufacturer', 'manufacturer', data.get('manufacturer'))
             self.fields['device_type'].queryset = self.fields['device_type'].queryset.filter(**params)
             self.fields['device_type'].queryset = self.fields['device_type'].queryset.filter(**params)
 
 
 
 
@@ -770,29 +770,25 @@ class DeviceImportForm(BaseDeviceImportForm):
         if data:
         if data:
 
 
             # Limit location queryset by assigned site
             # Limit location queryset by assigned site
-            params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
+            params = self.get_related_filter('site', 'site', data.get('site'))
             self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
             self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
             self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
             self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
 
 
             # Limit rack queryset by assigned site and location
             # Limit rack queryset by assigned site and location
-            params = {
-                f"site__{self.fields['site'].to_field_name}": data.get('site'),
-            }
+            params = self.get_related_filter('site', 'site', data.get('site'))
             if location := data.get('location'):
             if location := data.get('location'):
-                params.update({
-                    f"location__{self.fields['location'].to_field_name}": location,
-                })
+                params.update(self.get_related_filter('location', 'location', location))
             self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
             self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
 
 
             # Limit platform queryset by manufacturer
             # Limit platform queryset by manufacturer
-            params = {f"manufacturer__{self.fields['manufacturer'].to_field_name}": data.get('manufacturer')}
+            params = self.get_related_filter('manufacturer', 'manufacturer', data.get('manufacturer'))
             self.fields['platform'].queryset = self.fields['platform'].queryset.filter(
             self.fields['platform'].queryset = self.fields['platform'].queryset.filter(
                 Q(**params) | Q(manufacturer=None)
                 Q(**params) | Q(manufacturer=None)
             )
             )
 
 
             # Limit device bay queryset by parent device
             # Limit device bay queryset by parent device
             if parent := data.get('parent'):
             if parent := data.get('parent'):
-                params = {f"device__{self.fields['parent'].to_field_name}": parent}
+                params = self.get_related_filter('device', 'parent', parent)
                 self.fields['device_bay'].queryset = self.fields['device_bay'].queryset.filter(**params)
                 self.fields['device_bay'].queryset = self.fields['device_bay'].queryset.filter(**params)
 
 
     def clean(self):
     def clean(self):
@@ -855,7 +851,7 @@ class ModuleImportForm(ModuleCommonForm, PrimaryModelImportForm):
 
 
         if data:
         if data:
             # Limit module_bay queryset by assigned device
             # Limit module_bay queryset by assigned device
-            params = {f"device__{self.fields['device'].to_field_name}": data.get('device')}
+            params = self.get_related_filter('device', 'device', data.get('device'))
             self.fields['module_bay'].queryset = self.fields['module_bay'].queryset.filter(**params)
             self.fields['module_bay'].queryset = self.fields['module_bay'].queryset.filter(**params)
 
 
     def clean_replicate_components(self):
     def clean_replicate_components(self):
@@ -1262,9 +1258,7 @@ class InterfaceImportForm(OwnerCSVMixin, NetBoxModelImportForm):
         if data:
         if data:
             # Limit choices for parent, bridge, and LAG interfaces to the assigned device
             # Limit choices for parent, bridge, and LAG interfaces to the assigned device
             if device := data.get('device'):
             if device := data.get('device'):
-                params = {
-                    f"device__{self.fields['device'].to_field_name}": device
-                }
+                params = self.get_related_filter('device', 'device', device)
                 self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
                 self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
                 self.fields['bridge'].queryset = self.fields['bridge'].queryset.filter(**params)
                 self.fields['bridge'].queryset = self.fields['bridge'].queryset.filter(**params)
                 self.fields['lag'].queryset = self.fields['lag'].queryset.filter(**params)
                 self.fields['lag'].queryset = self.fields['lag'].queryset.filter(**params)
@@ -1272,7 +1266,7 @@ class InterfaceImportForm(OwnerCSVMixin, NetBoxModelImportForm):
 
 
             # Limit choices for VLANs to the assigned VLAN group
             # Limit choices for VLANs to the assigned VLAN group
             if vlan_group := data.get('vlan_group'):
             if vlan_group := data.get('vlan_group'):
-                params = {f"group__{self.fields['vlan_group'].to_field_name}": vlan_group}
+                params = self.get_related_filter('group', 'vlan_group', vlan_group)
                 self.fields['untagged_vlan'].queryset = self.fields['untagged_vlan'].queryset.filter(**params)
                 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['tagged_vlans'].queryset = self.fields['tagged_vlans'].queryset.filter(**params)
                 self.fields['qinq_svlan'].queryset = self.fields['qinq_svlan'].queryset.filter(**params)
                 self.fields['qinq_svlan'].queryset = self.fields['qinq_svlan'].queryset.filter(**params)
@@ -1656,13 +1650,13 @@ class MACAddressImportForm(PrimaryModelImportForm):
             # Limit interface queryset by assigned device
             # Limit interface queryset by assigned device
             if data.get('device'):
             if data.get('device'):
                 self.fields['interface'].queryset = Interface.objects.filter(
                 self.fields['interface'].queryset = Interface.objects.filter(
-                    **{f"device__{self.fields['device'].to_field_name}": data['device']}
+                    **self.get_related_filter('device', 'device', data['device'])
                 )
                 )
 
 
             # Limit interface queryset by assigned device
             # Limit interface queryset by assigned device
             elif data.get('virtual_machine'):
             elif data.get('virtual_machine'):
                 self.fields['interface'].queryset = VMInterface.objects.filter(
                 self.fields['interface'].queryset = VMInterface.objects.filter(
-                    **{f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data['virtual_machine']}
+                    **self.get_related_filter('virtual_machine', 'virtual_machine', data['virtual_machine'])
                 )
                 )
 
 
     def clean(self):
     def clean(self):
@@ -1875,7 +1869,7 @@ class CableImportForm(PrimaryModelImportForm):
         if data:
         if data:
             # Limit choices for side_a_device to the assigned side_a_site
             # Limit choices for side_a_device to the assigned side_a_site
             if side_a_site := data.get('side_a_site'):
             if side_a_site := data.get('side_a_site'):
-                side_a_parent_params = {f'site__{self.fields['side_a_site'].to_field_name}': side_a_site}
+                side_a_parent_params = self.get_related_filter('site', 'side_a_site', side_a_site)
                 self.fields['side_a_device'].queryset = self.fields['side_a_device'].queryset.filter(
                 self.fields['side_a_device'].queryset = self.fields['side_a_device'].queryset.filter(
                     **side_a_parent_params
                     **side_a_parent_params
                 )
                 )
@@ -1885,7 +1879,7 @@ class CableImportForm(PrimaryModelImportForm):
 
 
             # Limit choices for side_b_device to the assigned side_b_site
             # Limit choices for side_b_device to the assigned side_b_site
             if side_b_site := data.get('side_b_site'):
             if side_b_site := data.get('side_b_site'):
-                side_b_parent_params = {f'site__{self.fields['side_b_site'].to_field_name}': side_b_site}
+                side_b_parent_params = self.get_related_filter('site', 'side_b_site', side_b_site)
                 self.fields['side_b_device'].queryset = self.fields['side_b_device'].queryset.filter(
                 self.fields['side_b_device'].queryset = self.fields['side_b_device'].queryset.filter(
                     **side_b_parent_params
                     **side_b_parent_params
                 )
                 )
@@ -1958,12 +1952,11 @@ class CableImportForm(PrimaryModelImportForm):
             # The parent field has already raised its own validation error
             # The parent field has already raised its own validation error
             return None
             return None
         field = self.fields[field_name]
         field = self.fields[field_name]
-        to_field_name = field.to_field_name or 'pk'
 
 
         parents = []
         parents = []
         for value in self._split_side_values(self.data.get(field_name)):
         for value in self._split_side_values(self.data.get(field_name)):
             try:
             try:
-                parents.append(field.queryset.get(**{to_field_name: value}))
+                parents.append(field.filter_by_value(value).get())
             except ObjectDoesNotExist:
             except ObjectDoesNotExist:
                 self.add_error(field_name, _("Object not found: {value}").format(value=value))
                 self.add_error(field_name, _("Object not found: {value}").format(value=value))
                 return None
                 return None
@@ -2199,7 +2192,7 @@ class PowerPanelImportForm(PrimaryModelImportForm):
         if data:
         if data:
 
 
             # Limit group queryset by assigned site
             # Limit group queryset by assigned site
-            params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
+            params = self.get_related_filter('site', 'site', data.get('site'))
             self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
             self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
 
 
 
 
@@ -2270,17 +2263,17 @@ class PowerFeedImportForm(PrimaryModelImportForm):
         if data:
         if data:
 
 
             # Limit power_panel queryset by site
             # Limit power_panel queryset by site
-            params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
+            params = self.get_related_filter('site', 'site', data.get('site'))
             self.fields['power_panel'].queryset = self.fields['power_panel'].queryset.filter(**params)
             self.fields['power_panel'].queryset = self.fields['power_panel'].queryset.filter(**params)
 
 
             # Limit location queryset by site
             # Limit location queryset by site
-            params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
+            params = self.get_related_filter('site', 'site', data.get('site'))
             self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
             self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
 
 
             # Limit rack queryset by site and group
             # Limit rack queryset by site and group
             params = {
             params = {
-                f"site__{self.fields['site'].to_field_name}": data.get('site'),
-                f"location__{self.fields['location'].to_field_name}": data.get('location'),
+                **self.get_related_filter('site', 'site', data.get('site')),
+                **self.get_related_filter('location', 'location', data.get('location')),
             }
             }
             self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
             self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
 
 
@@ -2328,7 +2321,7 @@ class CoolingSourceImportForm(PrimaryModelImportForm):
         if data:
         if data:
 
 
             # Limit location queryset by assigned site
             # Limit location queryset by assigned site
-            params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
+            params = self.get_related_filter('site', 'site', data.get('site'))
             self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
             self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
 
 
 
 
@@ -2391,17 +2384,17 @@ class CoolingFeedImportForm(PrimaryModelImportForm):
         if data:
         if data:
 
 
             # Limit cooling_source queryset by site
             # Limit cooling_source queryset by site
-            params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
+            params = self.get_related_filter('site', 'site', data.get('site'))
             self.fields['cooling_source'].queryset = self.fields['cooling_source'].queryset.filter(**params)
             self.fields['cooling_source'].queryset = self.fields['cooling_source'].queryset.filter(**params)
 
 
             # Limit location queryset by site
             # Limit location queryset by site
-            params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
+            params = self.get_related_filter('site', 'site', data.get('site'))
             self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
             self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
 
 
             # Limit rack queryset by site and group
             # Limit rack queryset by site and group
             params = {
             params = {
-                f"site__{self.fields['site'].to_field_name}": data.get('site'),
-                f"location__{self.fields['location'].to_field_name}": data.get('location'),
+                **self.get_related_filter('site', 'site', data.get('site')),
+                **self.get_related_filter('location', 'location', data.get('location')),
             }
             }
             self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
             self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
 
 
@@ -2451,6 +2444,6 @@ class VirtualDeviceContextImportForm(PrimaryModelImportForm):
         if data:
         if data:
 
 
             # Limit primary_ip4/ip6 querysets by assigned device
             # Limit primary_ip4/ip6 querysets by assigned device
-            params = {f"interface__device__{self.fields['device'].to_field_name}": data.get('device')}
+            params = self.get_related_filter('interface__device', 'device', data.get('device'))
             self.fields['primary_ip4'].queryset = self.fields['primary_ip4'].queryset.filter(**params)
             self.fields['primary_ip4'].queryset = self.fields['primary_ip4'].queryset.filter(**params)
             self.fields['primary_ip6'].queryset = self.fields['primary_ip6'].queryset.filter(**params)
             self.fields['primary_ip6'].queryset = self.fields['primary_ip6'].queryset.filter(**params)

+ 22 - 13
netbox/dcim/forms/common.py

@@ -123,12 +123,18 @@ class ModuleCommonForm(forms.Form):
         if adopt_components:
         if adopt_components:
             self.instance._adopt_components = True
             self.instance._adopt_components = True
 
 
-        # Bail out if we are not installing a new module or if we are not replicating components (or if
-        # validation has already failed)
-        if self.errors or self.instance.pk or not replicate_components:
+        # Bail out if we are not installing a new module (or if validation has already failed)
+        if self.errors or self.instance.pk:
             self.instance._disable_replication = True
             self.instance._disable_replication = True
             return
             return
 
 
+        if not replicate_components:
+            self.instance._disable_replication = True
+
+        # Adoption also instantiates every template, so skip the checks only when neither operation is requested
+        if not replicate_components and not adopt_components:
+            return
+
         try:
         try:
             positions = get_module_bay_positions(module_bay)
             positions = get_module_bay_positions(module_bay)
         except ValueError as e:
         except ValueError as e:
@@ -152,16 +158,11 @@ class ModuleCommonForm(forms.Form):
 
 
             # Get the templates for the module type.
             # Get the templates for the module type.
             for template in getattr(module_type, templates).all():
             for template in getattr(module_type, templates).all():
-                resolved_name = template.name
-                if MODULE_TOKEN in template.name:
-                    if not module_bay.position:
-                        raise forms.ValidationError(
-                            _("Cannot install module with placeholder values in a module bay with no position defined.")
-                        )
-                    try:
-                        resolved_name = resolve_module_placeholder(template.name, positions)
-                    except ValueError as e:
-                        raise forms.ValidationError(str(e))
+                try:
+                    resolved_name = resolve_module_placeholder(template.name, positions)
+                    resolve_module_placeholder(template.label, positions)
+                except ValueError as e:
+                    raise forms.ValidationError(str(e)) from e
 
 
                 existing_item = installed_components.get(resolved_name)
                 existing_item = installed_components.get(resolved_name)
 
 
@@ -182,3 +183,11 @@ class ModuleCommonForm(forms.Form):
                             name=resolved_name
                             name=resolved_name
                         )
                         )
                     )
                     )
+
+        # Module bay names are unique per module, not per device, so only their placeholders are checked
+        for template in module_type.modulebaytemplates.all():
+            try:
+                for value in (template.name, template.label, template.position):
+                    resolve_module_placeholder(value, positions)
+            except ValueError as e:
+                raise forms.ValidationError(str(e)) from e

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

@@ -5,6 +5,7 @@ from django.utils.translation import gettext_lazy as _
 
 
 from dcim.constants import LOCATION_SCOPE_TYPES
 from dcim.constants import LOCATION_SCOPE_TYPES
 from dcim.utils import reconcile_port_mappings
 from dcim.utils import reconcile_port_mappings
+from netbox.context import current_request
 from utilities.forms import GenericObjectFormMixin
 from utilities.forms import GenericObjectFormMixin
 from utilities.forms.fields import (
 from utilities.forms.fields import (
     CSVContentTypeField,
     CSVContentTypeField,
@@ -157,16 +158,20 @@ class FrontPortFormMixin(forms.Form):
 
 
     def _get_rear_port_choices(self, parent_filter, front_port):
     def _get_rear_port_choices(self, parent_filter, front_port):
         """
         """
-        Return a list of choices representing each available rear port & position pair on the parent object (identified
-        by a Q filter), excluding those assigned to the specified instance.
+        Return rear port position choices for the parent, excluding positions assigned to other front ports.
         """
         """
         occupied_rear_port_positions = [
         occupied_rear_port_positions = [
             f'{mapping.rear_port_id}:{mapping.rear_port_position}'
             f'{mapping.rear_port_id}:{mapping.rear_port_position}'
             for mapping in self.port_mapping_model.objects.filter(parent_filter).exclude(front_port=front_port.pk)
             for mapping in self.port_mapping_model.objects.filter(parent_filter).exclude(front_port=front_port.pk)
         ]
         ]
 
 
+        # rear_ports is a plain choice field, so restrict_form_fields() cannot narrow it
+        rear_ports = self.rear_port_model.objects.filter(parent_filter)
+        if request := current_request.get():
+            rear_ports = rear_ports.restrict(request.user, 'view')
+
         choices = []
         choices = []
-        for rear_port in self.rear_port_model.objects.filter(parent_filter):
+        for rear_port in rear_ports:
             for i in range(1, rear_port.positions + 1):
             for i in range(1, rear_port.positions + 1):
                 pair_id = f'{rear_port.pk}:{i}'
                 pair_id = f'{rear_port.pk}:{i}'
                 if pair_id not in occupied_rear_port_positions:
                 if pair_id not in occupied_rear_port_positions:

+ 44 - 16
netbox/dcim/models/cables.py

@@ -21,6 +21,7 @@ from dcim.exceptions import UnsupportedCablePath
 from dcim.fields import PathField
 from dcim.fields import PathField
 from dcim.utils import decompile_path_node, object_to_path_node, rebuild_cable_paths
 from dcim.utils import decompile_path_node, object_to_path_node, rebuild_cable_paths
 from netbox.choices import ColorChoices
 from netbox.choices import ColorChoices
+from netbox.context import current_request
 from netbox.models import ChangeLoggedModel, PrimaryModel
 from netbox.models import ChangeLoggedModel, PrimaryModel
 from utilities.conversion import to_meters
 from utilities.conversion import to_meters
 from utilities.data import normalize_update_fields
 from utilities.data import normalize_update_fields
@@ -353,6 +354,8 @@ class Cable(PrimaryModel):
 
 
     def save(self, *args, force_insert=False, force_update=False, using=None, update_fields=None):
     def save(self, *args, force_insert=False, force_update=False, using=None, update_fields=None):
         _created = self.pk is None
         _created = self.pk is None
+        # Unlike _created, also true for a new Cable with a preset pk (e.g. one restored by deserialize_object())
+        adding = self._state.adding
         save_kwargs = {
         save_kwargs = {
             'using': using,
             'using': using,
             'update_fields': update_fields,
             'update_fields': update_fields,
@@ -391,17 +394,24 @@ class Cable(PrimaryModel):
         profile_written = update_fields is None or 'profile' in update_fields
         profile_written = update_fields is None or 'profile' in update_fields
 
 
         # If this is a new Cable, save it before attempting to create its CableTerminations
         # If this is a new Cable, save it before attempting to create its CableTerminations
-        if self._state.adding:
+        if adding:
             super().save(*args, force_insert=True, **save_kwargs)
             super().save(*args, force_insert=True, **save_kwargs)
             # Update the private PK used in __str__()
             # Update the private PK used in __str__()
             self._pk = self.pk
             self._pk = self.pk
+            # Record the pre-termination state for the update made by the save below
+            self.snapshot()
 
 
-        if profile_written and self._orig_profile != self.profile:
-            self.update_terminations(force=True)
-        elif self._terminations_modified:
-            self.update_terminations()
+        force_terminations = profile_written and self._orig_profile != self.profile
+        if force_terminations or self._terminations_modified:
+            # update_terminations() writes before the pre_save fallback runs, so snapshot the stored Cable now
+            if current_request.get() is not None and not hasattr(self, '_prechange_snapshot'):
+                self.snapshot_from_db(using=using)
+            self.update_terminations(force=force_terminations)
 
 
         super().save(*args, force_update=True, **save_kwargs)
         super().save(*args, force_update=True, **save_kwargs)
+        # Discard the pre-termination snapshot so that a later save of this instance takes a fresh one
+        if adding:
+            self.__dict__.pop('_prechange_snapshot', None)
 
 
         try:
         try:
             trace_paths.send(Cable, instance=self, created=_created, status_written=status_written)
             trace_paths.send(Cable, instance=self, created=_created, status_written=status_written)
@@ -993,6 +1003,9 @@ class CablePath(models.Model):
         is_complete = False
         is_complete = False
         is_active = True
         is_active = True
         is_split = False
         is_split = False
+        # Whether the trace fanned out across a multi-position front port without positional context
+        fanout = False
+        diverged = False
 
 
         logger.debug(f'Tracing cable path from {terminations}...')
         logger.debug(f'Tracing cable path from {terminations}...')
 
 
@@ -1012,7 +1025,11 @@ class CablePath(models.Model):
                 not isinstance(terminations[0].parent_object, Circuit) and
                 not isinstance(terminations[0].parent_object, Circuit) and
                 not all(t.parent_object == terminations[0].parent_object for t in terminations[1:])
                 not all(t.parent_object == terminations[0].parent_object for t in terminations[1:])
             ):
             ):
-                raise UnsupportedCablePath(_("All mid-span terminations must have the same parent object"))
+                if not fanout:
+                    raise UnsupportedCablePath(_("All mid-span terminations must have the same parent object"))
+                # Fanned-out branches crossing different devices are not followed, even if they reconverge later
+                diverged = True
+                break
 
 
             # Check for a split path (e.g. rear port fanning out to multiple front ports with
             # Check for a split path (e.g. rear port fanning out to multiple front ports with
             # different cables attached)
             # different cables attached)
@@ -1177,15 +1194,13 @@ class CablePath(models.Model):
                     for rt in remote_terminations:
                     for rt in remote_terminations:
                         q_filter |= Q(front_port=rt, front_port_position__in=positions)
                         q_filter |= Q(front_port=rt, front_port_position__in=positions)
                     port_mappings = PortMapping.objects.filter(q_filter)
                     port_mappings = PortMapping.objects.filter(q_filter)
-                elif remote_terminations[0].positions > 1:
-                    is_split = True
-                    logger.debug(
-                        'Encountered front port mapped to multiple rear ports but position stack is empty; aborting '
-                        'trace.'
-                    )
-                    break
                 else:
                 else:
-                    port_mappings = PortMapping.objects.filter(front_port__in=remote_terminations)
+                    # Without positional context, follow all mappings; branches must converge on one endpoint
+                    if any(t.positions > 1 for t in remote_terminations):
+                        fanout = True
+                    port_mappings = PortMapping.objects.filter(
+                        front_port__in=remote_terminations
+                    ).order_by('front_port', 'front_port_position')
                 if not port_mappings:
                 if not port_mappings:
                     break
                     break
 
 
@@ -1257,7 +1272,10 @@ class CablePath(models.Model):
 
 
             else:
             else:
                 # Check for non-symmetric path
                 # Check for non-symmetric path
-                if all(isinstance(t, type(remote_terminations[0])) for t in remote_terminations[1:]):
+                # Fanned-out branches arriving over separate links reach different endpoints
+                if fanout and len(links) > 1:
+                    diverged = True
+                elif all(isinstance(t, type(remote_terminations[0])) for t in remote_terminations[1:]):
                     is_complete = True
                     is_complete = True
                 elif len(remote_terminations) == 0:
                 elif len(remote_terminations) == 0:
                     is_complete = False
                     is_complete = False
@@ -1268,6 +1286,12 @@ class CablePath(models.Model):
                     logger.warning('Encountered an unsupported topology; aborting trace.')
                     logger.warning('Encountered an unsupported topology; aborting trace.')
                 break
                 break
 
 
+        if diverged:
+            # Keep the full trace so changes to any branch retrace this path
+            logger.debug('Front port branches diverged; treating path as split.')
+            is_complete = False
+            is_split = True
+
         return cls(
         return cls(
             path=path,
             path=path,
             is_complete=is_complete,
             is_complete=is_complete,
@@ -1372,7 +1396,8 @@ class CablePath(models.Model):
 
 
     def get_split_nodes(self):
     def get_split_nodes(self):
         """
         """
-        Return all available next segments in a split cable path.
+        Return all available next segments in a split cable path, or the diverging endpoints if the path fanned
+        out across a multi-position front port.
         """
         """
         from circuits.models import CircuitTermination
         from circuits.models import CircuitTermination
         nodes = self.path_objects[-1]
         nodes = self.path_objects[-1]
@@ -1395,6 +1420,9 @@ class CablePath(models.Model):
             return [
             return [
                 ct.get_peer_termination() for ct in nodes
                 ct.get_peer_termination() for ct in nodes
             ]
             ]
+        # Front port branches diverging to different endpoints
+        if isinstance(nodes[0], PathEndpoint):
+            return nodes
         return []
         return []
 
 
     def get_asymmetric_nodes(self):
     def get_asymmetric_nodes(self):

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

@@ -204,9 +204,9 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
             # Reached only from Module._save_new(); AbortRequest is what the view/viewset catches.
             # Reached only from Module._save_new(); AbortRequest is what the view/viewset catches.
             try:
             try:
                 positions = get_module_bay_positions(module.module_bay)
                 positions = get_module_bay_positions(module.module_bay)
+                value = resolve_module_placeholder(value, positions)
             except ValueError as e:
             except ValueError as e:
                 raise AbortRequest(str(e)) from e
                 raise AbortRequest(str(e)) from e
-            value = resolve_module_placeholder(value, positions)
         if has_vc:
         if has_vc:
             resolved_device = (module.device if module else None) or device
             resolved_device = (module.device if module else None) or device
             value = self._resolve_vc_position(value, resolved_device)
             value = self._resolve_vc_position(value, resolved_device)

+ 31 - 0
netbox/dcim/models/device_components.py

@@ -1,6 +1,7 @@
 from functools import cached_property
 from functools import cached_property
 
 
 from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
 from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
+from django.contrib.contenttypes.models import ContentType
 from django.contrib.postgres.fields import ArrayField
 from django.contrib.postgres.fields import ArrayField
 from django.contrib.postgres.indexes import GistIndex
 from django.contrib.postgres.indexes import GistIndex
 from django.core.exceptions import ObjectDoesNotExist, ValidationError
 from django.core.exceptions import ObjectDoesNotExist, ValidationError
@@ -19,6 +20,7 @@ from dcim.models.mixins import (
     InterfaceValidationMixin,
     InterfaceValidationMixin,
     MaxFlowMixin,
     MaxFlowMixin,
 )
 )
+from dcim.utils import rebuild_paths
 from netbox.choices import ColorChoices
 from netbox.choices import ColorChoices
 from netbox.models import NetBoxModel, OrganizationalModel
 from netbox.models import NetBoxModel, OrganizationalModel
 from netbox.models.features import ChangeLoggingMixin
 from netbox.models.features import ChangeLoggingMixin
@@ -1425,6 +1427,35 @@ class Interface(
         if self.channels:
         if self.channels:
             self.clear_channel_cables()
             self.clear_channel_cables()
 
 
+    def update_dependent_objects(self):
+        """
+        Reconcile a channel subinterface's mirrored cable attributes with its channelized parent, and retrace the
+        CablePaths which traverse this interface.
+        """
+        from dcim.models import CablePath, CableTermination
+
+        parent = None
+        if self.channel_id and self.parent_id:
+            parent = Interface.objects.filter(pk=self.parent_id, cable__isnull=False).select_related('cable').first()
+        if parent and parent.channels:
+            parent.cable.update_dependent_objects()
+        elif self.cable_id and not CableTermination.objects.filter(
+            termination_type=ContentType.objects.get_for_model(Interface), termination_id=self.pk
+        ).exists():
+            # A cable with no termination of this interface's own was mirrored from a former channelized parent
+            Interface.objects.filter(pk=self.pk).update(
+                cable=None, cable_end='', cable_connector=None, cable_positions=None
+            )
+            for cablepath in CablePath.objects.filter(_nodes__contains=self):
+                if self in cablepath.origins:
+                    cablepath.delete()
+        else:
+            return
+
+        # Retrace paths still traversing this interface from a former parent's cable
+        rebuild_paths([self])
+    update_dependent_objects.alters_data = True
+
     def propagate_channel_cables(self):
     def propagate_channel_cables(self):
         """
         """
         Mirror this channelized interface's cable attributes onto each of its channel subinterfaces, restricting each
         Mirror this channelized interface's cable attributes onto each of its channel subinterfaces, restricting each

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

@@ -1175,6 +1175,7 @@ class Device(
         # Update Site and Rack assignment for any child Devices
         # Update Site and Rack assignment for any child Devices
         devices = Device.objects.filter(parent_bay__device=self)
         devices = Device.objects.filter(parent_bay__device=self)
         for device in devices:
         for device in devices:
+            device.snapshot()
             device.site = self.site
             device.site = self.site
             device.rack = self.rack
             device.rack = self.rack
             device.location = self.location
             device.location = self.location

+ 24 - 11
netbox/dcim/models/module_moves.py

@@ -307,7 +307,8 @@ class ModuleMovePlan:
     def _resolve(template, value, positions, device):
     def _resolve(template, value, positions, device):
         """
         """
         Resolve {module} and {vc_position} tokens in a template value against an explicit
         Resolve {module} and {vc_position} tokens in a template value against an explicit
-        position chain and device. Raises ValueError on a token-count mismatch.
+        position chain and device. Raises ValueError on a token-count mismatch or a missing
+        bay position.
         """
         """
         if MODULE_TOKEN in value:
         if MODULE_TOKEN in value:
             value = resolve_module_placeholder(value, positions)
             value = resolve_module_placeholder(value, positions)
@@ -908,19 +909,30 @@ class ModuleMovePlan:
             self._send_post_saves(model, updated, fields)
             self._send_post_saves(model, updated, fields)
 
 
     def _apply_port_mappings(self):
     def _apply_port_mappings(self):
-        # Private model, no changelog or last_updated field; mirrors PortMapping.save()'s device derivation.
+        # Update each mapping's device after moving its ports
         if not self.cross_device:
         if not self.cross_device:
             return
             return
         moved_front_port_pks = [obj.pk for obj in self.components[FrontPort]]
         moved_front_port_pks = [obj.pk for obj in self.components[FrontPort]]
         moved_rear_port_pks = [obj.pk for obj in self.components[RearPort]]
         moved_rear_port_pks = [obj.pk for obj in self.components[RearPort]]
-        if moved_front_port_pks and moved_rear_port_pks:
-            chunked_update(
-                PortMapping.objects.filter(
-                    front_port_id__in=moved_front_port_pks,
-                    rear_port_id__in=moved_rear_port_pks,
-                ),
-                device_id=self.new_device_id,
-            )
+        if not (moved_front_port_pks and moved_rear_port_pks):
+            return
+        mappings = list(PortMapping.objects.using(router.db_for_write(PortMapping)).filter(
+            front_port_id__in=moved_front_port_pks,
+            rear_port_id__in=moved_rear_port_pks,
+        ).select_related('front_port', 'rear_port'))
+        if not mappings:
+            return
+        for mapping in mappings:
+            mapping.snapshot()
+        chunked_update(
+            PortMapping.objects.filter(pk__in=[mapping.pk for mapping in mappings]),
+            device_id=self.new_device_id,
+            last_updated=self._now,
+        )
+        for mapping in mappings:
+            mapping.device_id = self.new_device_id
+            mapping.last_updated = self._now
+        self._send_post_saves(PortMapping, mappings, ['device', 'last_updated'])
 
 
     @staticmethod
     @staticmethod
     def device_counters_by_model(device_model):
     def device_counters_by_model(device_model):
@@ -975,7 +987,8 @@ class ModuleMovePlan:
         for instance in instances:
         for instance in instances:
             # Clear tracked counter state so the incremental counter receiver no-ops;
             # Clear tracked counter state so the incremental counter receiver no-ops;
             # counters are recomputed explicitly for cross-device moves.
             # counters are recomputed explicitly for cross-device moves.
-            instance.tracker.clear()
+            if hasattr(instance, 'tracker'):
+                instance.tracker.clear()
             post_save.send(
             post_save.send(
                 sender=model,
                 sender=model,
                 instance=instance,
                 instance=instance,

+ 20 - 4
netbox/dcim/models/modules.py

@@ -6,6 +6,7 @@ from django.conf import settings
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.db import OperationalError, models, router, transaction
 from django.db import OperationalError, models, router, transaction
 from django.db.models.signals import post_save
 from django.db.models.signals import post_save
+from django.utils import timezone
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 from jsonschema.exceptions import ValidationError as JSONValidationError
 from jsonschema.exceptions import ValidationError as JSONValidationError
 
 
@@ -533,6 +534,8 @@ class Module(TrackingModelMixin, PrimaryModel):
         if disable_replication and not adopt_components:
         if disable_replication and not adopt_components:
             return
             return
 
 
+        now = timezone.now()
+
         # Iterate all component types
         # Iterate all component types
         for templates, component_attribute, component_model in [
         for templates, component_attribute, component_model in [
             ("consoleporttemplates", "consoleports", ConsolePort),
             ("consoleporttemplates", "consoleports", ConsolePort),
@@ -554,7 +557,7 @@ class Module(TrackingModelMixin, PrimaryModel):
                 component.name: component
                 component.name: component
                 for component in getattr(self.device, component_attribute).db_manager(using).filter(
                 for component in getattr(self.device, component_attribute).db_manager(using).filter(
                     module__isnull=True
                     module__isnull=True
-                )
+                ).prefetch_related('tags')
             }
             }
 
 
             # Get the template for the module type.
             # Get the template for the module type.
@@ -567,8 +570,10 @@ class Module(TrackingModelMixin, PrimaryModel):
 
 
                     # Check if there's a component with the same name already
                     # Check if there's a component with the same name already
                     if existing_item:
                     if existing_item:
-                        # Assign it to the module
+                        # Snapshot for the changelog, then assign it to the module
+                        existing_item.snapshot()
                         existing_item.module = self
                         existing_item.module = self
+                        existing_item.last_updated = now
                         update_instances.append(existing_item)
                         update_instances.append(existing_item)
                         continue
                         continue
 
 
@@ -609,7 +614,7 @@ class Module(TrackingModelMixin, PrimaryModel):
                     update_fields=None
                     update_fields=None
                 )
                 )
 
 
-            update_fields = ['module']
+            update_fields = ['module', 'last_updated']
             # ModuleBay.parent is derived from .module in ModuleBay.save(), and the
             # ModuleBay.parent is derived from .module in ModuleBay.save(), and the
             # path/sort_path trigger only fires on parent_id/name changes. A bare
             # path/sort_path trigger only fires on parent_id/name changes. A bare
             # bulk_update(['module']) bypasses both, leaving the adopted bay rooted
             # bulk_update(['module']) bypasses both, leaving the adopted bay rooted
@@ -618,11 +623,22 @@ class Module(TrackingModelMixin, PrimaryModel):
             if component_model is ModuleBay:
             if component_model is ModuleBay:
                 for instance in update_instances:
                 for instance in update_instances:
                     instance.parent = self.module_bay
                     instance.parent = self.module_bay
-                update_fields = ['module', 'parent']
+                update_fields = ['module', 'parent', 'last_updated']
 
 
             component_model.objects.using(using).bulk_update(
             component_model.objects.using(using).bulk_update(
                 update_instances, update_fields, batch_size=settings.BULK_UPDATE_CHUNK_SIZE
                 update_instances, update_fields, batch_size=settings.BULK_UPDATE_CHUNK_SIZE
             )
             )
+            # The triggers rewrote path/sort_path; refresh them so changelog snapshots aren't stale
+            if component_model is ModuleBay and update_instances:
+                refreshed = {
+                    row['pk']: row
+                    for row in ModuleBay.objects.using(using).filter(
+                        pk__in=[bay.pk for bay in update_instances]
+                    ).values('pk', 'path', 'sort_path')
+                }
+                for bay in update_instances:
+                    bay.path = refreshed[bay.pk]['path']
+                    bay.sort_path = refreshed[bay.pk]['sort_path']
             # Emit the post_save signal for each updated object
             # Emit the post_save signal for each updated object
             for component in update_instances:
             for component in update_instances:
                 post_save.send(
                 post_save.send(

+ 12 - 0
netbox/dcim/models/sites.py

@@ -55,6 +55,12 @@ class Region(ContactsMixin, NestedLtreeGroupModel):
         object_id_field='scope_id',
         object_id_field='scope_id',
         related_query_name='region'
         related_query_name='region'
     )
     )
+    # Deletion cascade only, so no related_query_name. circuit_terminations is the reverse of the cached _region.
+    direct_circuit_terminations = GenericRelation(
+        to='circuits.CircuitTermination',
+        content_type_field='termination_type',
+        object_id_field='termination_id'
+    )
 
 
     class Meta:
     class Meta:
         ordering = ('sort_path',)
         ordering = ('sort_path',)
@@ -120,6 +126,12 @@ class SiteGroup(ContactsMixin, NestedLtreeGroupModel):
         object_id_field='scope_id',
         object_id_field='scope_id',
         related_query_name='site_group'
         related_query_name='site_group'
     )
     )
+    # Deletion cascade only, so no related_query_name. circuit_terminations is the reverse of the cached _site_group.
+    direct_circuit_terminations = GenericRelation(
+        to='circuits.CircuitTermination',
+        content_type_field='termination_type',
+        object_id_field='termination_id'
+    )
 
 
     class Meta:
     class Meta:
         ordering = ('sort_path',)
         ordering = ('sort_path',)

+ 9 - 1
netbox/dcim/signals.py

@@ -308,10 +308,13 @@ def retrace_cable_paths(instance, **kwargs):
 
 
 
 
 @receiver((post_delete, post_save), sender=PortMapping)
 @receiver((post_delete, post_save), sender=PortMapping)
-def update_passthrough_port_paths(instance, **kwargs):
+def update_passthrough_port_paths(instance, update_fields=None, **kwargs):
     """
     """
     When a PortMapping is created or deleted, retrace any CablePaths which traverse its front and/or rear ports.
     When a PortMapping is created or deleted, retrace any CablePaths which traverse its front and/or rear ports.
     """
     """
+    # Moving a module to another device changes only these fields; its connections are unchanged
+    if update_fields and set(update_fields) <= {'device', 'last_updated'}:
+        return
     for cablepath in CablePath.objects.filter(
     for cablepath in CablePath.objects.filter(
         Q(_nodes__contains=instance.front_port) | Q(_nodes__contains=instance.rear_port)
         Q(_nodes__contains=instance.front_port) | Q(_nodes__contains=instance.rear_port)
     ):
     ):
@@ -498,5 +501,10 @@ def update_mac_address_interface(instance, created, raw, **kwargs):
     assign the MACAddress to the interface.
     assign the MACAddress to the interface.
     """
     """
     if created and not raw and instance.primary_mac_address:
     if created and not raw and instance.primary_mac_address:
+        # Keep any snapshot the caller took before making unsaved changes to the MAC address
+        if not hasattr(instance.primary_mac_address, '_prechange_snapshot'):
+            instance.primary_mac_address.snapshot()
         instance.primary_mac_address.assigned_object = instance
         instance.primary_mac_address.assigned_object = instance
         instance.primary_mac_address.save()
         instance.primary_mac_address.save()
+        # Discard the snapshot so that a later save of the MAC address takes a fresh one
+        instance.primary_mac_address.__dict__.pop('_prechange_snapshot', None)

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

@@ -1,9 +1,9 @@
 {
 {
-  "cable:api_list_objects": 23,
+  "cable:api_list_objects": 17,
   "cable:list_objects_with_permission": 20,
   "cable:list_objects_with_permission": 20,
   "cablebundle:api_list_objects": 12,
   "cablebundle:api_list_objects": 12,
   "cablebundle:list_objects_with_permission": 16,
   "cablebundle:list_objects_with_permission": 16,
-  "cabletermination:api_list_objects": 16,
+  "cabletermination:api_list_objects": 14,
   "consoleconnection:list_objects_with_permission": 28,
   "consoleconnection:list_objects_with_permission": 28,
   "consoleport:api_list_objects": 13,
   "consoleport:api_list_objects": 13,
   "consoleport:list_objects_with_permission": 17,
   "consoleport:list_objects_with_permission": 17,

+ 389 - 29
netbox/dcim/tests/test_api.py

@@ -9,6 +9,7 @@ from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.prefetch import GenericPrefetch
 from django.contrib.contenttypes.prefetch import GenericPrefetch
 from django.db import connection
 from django.db import connection
+from django.db.models import FETCH_PEERS, prefetch_related_objects
 from django.test import override_settings, tag
 from django.test import override_settings, tag
 from django.test.utils import CaptureQueriesContext
 from django.test.utils import CaptureQueriesContext
 from django.urls import reverse
 from django.urls import reverse
@@ -22,7 +23,7 @@ from core.choices import ObjectChangeActionChoices
 from core.models import ObjectChange, ObjectType
 from core.models import ObjectChange, ObjectType
 from dcim.api.serializers import InterfaceSerializer
 from dcim.api.serializers import InterfaceSerializer
 from dcim.api.serializers_.nested import NestedDeviceBaySerializer, NestedDeviceSerializer
 from dcim.api.serializers_.nested import NestedDeviceBaySerializer, NestedDeviceSerializer
-from dcim.api.views import DeviceViewSet, InterfaceViewSet
+from dcim.api.views import CableTerminationsPrefetch, DeviceViewSet, InterfaceViewSet
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.graphql.types import _CABLE_TERMINATION_MODELS
 from dcim.graphql.types import _CABLE_TERMINATION_MODELS
@@ -63,6 +64,24 @@ class AppTestCase(APITestCase):
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
 
 
+def assert_list_query_count_is_constant(test, url, get_params, objects, check_results):
+    """
+    Assert that listing the first of the given objects runs as many queries as listing all of them. `get_params`
+    returns the query parameters selecting the given objects, and `check_results` validates the results listed.
+    """
+    def get_query_count(objs):
+        with CaptureQueriesContext(connection) as queries:
+            response = test.client.get(url, get_params(objs), **test.header)
+        test.assertHttpStatus(response, status.HTTP_200_OK)
+        check_results(objs, response.data['results'])
+        # Token authentication refreshes last_used once a minute
+        return sum('"users_token"' not in query['sql'] for query in queries.captured_queries)
+
+    # Warm per-process caches (e.g. ContentType)
+    get_query_count(objects)
+    test.assertEqual(get_query_count(objects[:1]), get_query_count(objects))
+
+
 class Mixins:
 class Mixins:
 
 
     class ComponentTraceMixin(APITestCase):
     class ComponentTraceMixin(APITestCase):
@@ -99,6 +118,37 @@ class Mixins:
             self.assertEqual(segment1[1]['label'], cable.label)
             self.assertEqual(segment1[1]['label'], cable.label)
             self.assertEqual(segment1[2][0]['name'], peer_obj.name)
             self.assertEqual(segment1[2][0]['name'], peer_obj.name)
 
 
+    class LinkPeersMixin(APITestCase):
+        peer_termination_type = None
+
+        def test_list_objects_link_peers_query_count(self):
+            """
+            Listing cabled components loads their link peers, and the peers' devices and cables, with the same
+            number of queries however many components are listed.
+            """
+            if self.peer_termination_type is None:
+                raise NotImplementedError(_("Test case must set peer_termination_type"))
+            self.add_permissions(f'dcim.view_{self.model._meta.model_name}')
+            objects = list(self.model.objects.order_by('pk')[:3])
+            peer_device = create_test_device('Peer Device', site=Site.objects.first())
+            peer_names = {}
+            for index, obj in enumerate(objects):
+                peer = self.peer_termination_type.objects.create(device=peer_device, name=f'Peer {index}')
+                Cable(a_terminations=[obj], b_terminations=[peer]).save()
+                peer_names[obj.pk] = peer.name
+
+            def check_results(objs, results):
+                link_peers = {result['id']: [peer['name'] for peer in result['link_peers']] for result in results}
+                self.assertEqual(link_peers, {obj.pk: [peer_names[obj.pk]] for obj in objs})
+
+            assert_list_query_count_is_constant(
+                self,
+                self._get_list_url(),
+                lambda objs: {'id': [obj.pk for obj in objs], 'fields': 'id,link_peers'},
+                objects,
+                check_results,
+            )
+
     class FieldPrefetchMixin(APITestCase):
     class FieldPrefetchMixin(APITestCase):
         viewset = None
         viewset = None
         # Narrowed requests compared in addition to the default, brief and single-field ones
         # Narrowed requests compared in addition to the default, brief and single-field ones
@@ -1425,6 +1475,18 @@ class RackTestCase(APIViewTestCases.APIViewTestCase):
             },
             },
         ]
         ]
 
 
+    def test_ordering_by_count_without_field(self):
+        """Ordering by a count left out of the response still sorts by it."""
+        self.add_permissions('dcim.view_rack')
+        rack = Rack.objects.get(name='Rack 3')
+        create_test_device('Device 1', site=rack.site, rack=rack)
+        response = self.client.get(
+            f'{self._get_list_url()}?ordering=-device_count,name&fields=id,name', **self.header
+        )
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual([row['name'] for row in response.data['results']], ['Rack 3', 'Rack 1', 'Rack 2'])
+        self.assertNotIn('device_count', response.data['results'][0])
+
     def test_get_rack_elevation(self):
     def test_get_rack_elevation(self):
         """
         """
         GET a single rack elevation.
         GET a single rack elevation.
@@ -3644,6 +3706,90 @@ class ModuleTestCase(APIViewTestCases.APIViewTestCase):
         existing_iface.refresh_from_db()
         existing_iface.refresh_from_db()
         self.assertIsNotNone(existing_iface.module)
         self.assertIsNotNone(existing_iface.module)
 
 
+    def test_adopt_components_changelog(self):
+        """
+        Adopted components should be change-logged with prechange data and have last_updated bumped.
+        """
+        self.add_permissions('dcim.add_module')
+        manufacturer = Manufacturer.objects.get(name='Generic')
+        device = create_test_device('Device for Adopt Changelog Test')
+        module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Adopt Changelog Module Type')
+        InterfaceTemplate.objects.create(module_type=module_type, name='eth0', type='1000base-t')
+        ModuleBayTemplate.objects.create(module_type=module_type, name='Sub Bay')
+        module_bay = ModuleBay.objects.create(device=device, name='Adopt Changelog Bay')
+        # Adopted bays are also re-parented under the module's bay
+        expected_diffs = [
+            (Interface.objects.create(device=device, name='eth0', type='1000base-t'), {'module': None}),
+            (ModuleBay.objects.create(device=device, name='Sub Bay'), {'module': None, 'parent': None}),
+        ]
+        original_last_updated = {component.pk: component.last_updated for component, _ in expected_diffs}
+
+        url = reverse('dcim-api:module-list')
+        data = {
+            'device': device.pk,
+            'module_bay': module_bay.pk,
+            'module_type': module_type.pk,
+            'adopt_components': True,
+            'replicate_components': False,
+        }
+        response = self.client.post(url, data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        module_pk = response.data['id']
+
+        for component, expected_pre in expected_diffs:
+            component.refresh_from_db()
+            self.assertEqual(component.module_id, module_pk)
+            self.assertGreater(component.last_updated, original_last_updated[component.pk])
+            objectchange = ObjectChange.objects.get(
+                changed_object_type=ObjectType.objects.get_for_model(component),
+                changed_object_id=component.pk,
+                action=ObjectChangeActionChoices.ACTION_UPDATE,
+            )
+            self.assertIsNotNone(objectchange.prechange_data)
+            self.assertIsNone(objectchange.prechange_data['module'])
+            self.assertEqual(objectchange.postchange_data['module'], module_pk)
+            self.assertEqual(objectchange.diff()['pre'], expected_pre)
+            # Trigger-maintained ltree columns must reflect the post-adoption location
+            if isinstance(component, ModuleBay):
+                self.assertEqual(objectchange.postchange_data['path'], str(component.path))
+                self.assertEqual(objectchange.postchange_data['sort_path'], component.sort_path)
+
+    def test_install_module_interface_parent_bridge_changelog(self):
+        """
+        Interface parent/bridge assignments made during module installation must record prechange data.
+        """
+        self.add_permissions('dcim.add_module')
+        manufacturer = Manufacturer.objects.get(name='Generic')
+        device = create_test_device('Device for Interface Parent Changelog Test')
+        module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Parent Changelog Module Type')
+        eth0 = InterfaceTemplate.objects.create(module_type=module_type, name='eth0', type='1000base-t')
+        InterfaceTemplate.objects.create(module_type=module_type, name='eth0.100', type='virtual', parent=eth0)
+        InterfaceTemplate.objects.create(module_type=module_type, name='br0', type='bridge')
+        InterfaceTemplate.objects.create(
+            module_type=module_type, name='eth1', type='1000base-t', bridge=InterfaceTemplate.objects.get(name='br0')
+        )
+        module_bay = ModuleBay.objects.create(device=device, name='Parent Changelog Bay')
+
+        url = reverse('dcim-api:module-list')
+        data = {
+            'device': device.pk,
+            'module_bay': module_bay.pk,
+            'module_type': module_type.pk,
+        }
+        response = self.client.post(url, data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+
+        for name, field in (('eth0.100', 'parent'), ('eth1', 'bridge')):
+            interface = Interface.objects.get(device=device, name=name)
+            objectchange = ObjectChange.objects.get(
+                changed_object_type=ObjectType.objects.get_for_model(Interface),
+                changed_object_id=interface.pk,
+                action=ObjectChangeActionChoices.ACTION_UPDATE,
+            )
+            self.assertIsNotNone(objectchange.prechange_data)
+            self.assertIsNone(objectchange.prechange_data[field])
+            self.assertEqual(objectchange.postchange_data[field], getattr(interface, f'{field}_id'))
+
     def test_replicate_components_conflict(self):
     def test_replicate_components_conflict(self):
         """
         """
         Installing a module with replicate_components=True when a component with the same name
         Installing a module with replicate_components=True when a component with the same name
@@ -3755,6 +3901,7 @@ class ModuleTestCase(APIViewTestCases.APIViewTestCase):
         # eth1 should have been created
         # eth1 should have been created
         self.assertTrue(device.interfaces.filter(name='eth1').exists())
         self.assertTrue(device.interfaces.filter(name='eth1').exists())
 
 
+    @tag('regression')  # Issue #23478
     def test_module_token_no_position(self):
     def test_module_token_no_position(self):
         """
         """
         Installing a module whose type has a template with a MODULE_TOKEN placeholder into a
         Installing a module whose type has a template with a MODULE_TOKEN placeholder into a
@@ -3763,46 +3910,158 @@ class ModuleTestCase(APIViewTestCases.APIViewTestCase):
         self.add_permissions('dcim.add_module')
         self.add_permissions('dcim.add_module')
         manufacturer = Manufacturer.objects.get(name='Generic')
         manufacturer = Manufacturer.objects.get(name='Generic')
         device = create_test_device('Device for Token No-Position Test')
         device = create_test_device('Device for Token No-Position Test')
-        module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Token No-Position Module Type')
-        # Template name contains the MODULE_TOKEN placeholder
-        InterfaceTemplate.objects.create(
-            module_type=module_type, name=f'{MODULE_TOKEN}-eth0', type='1000base-t'
-        )
-        # Module bay has no position
-        module_bay = ModuleBay.objects.create(device=device, name='No-Position Bay')
+        url = reverse('dcim-api:module-list')
+        # One placeholder in each field that installation resolves, each on its own module type
+        templates = (
+            (InterfaceTemplate, {'name': f'{MODULE_TOKEN}-eth0', 'type': '1000base-t'}),
+            (InterfaceTemplate, {'name': 'eth0', 'label': f'Port {MODULE_TOKEN}', 'type': '1000base-t'}),
+            (ModuleBayTemplate, {'name': f'Bay {MODULE_TOKEN}'}),
+            (ModuleBayTemplate, {'name': 'Bay', 'label': f'Bay {MODULE_TOKEN}'}),
+            (ModuleBayTemplate, {'name': 'Bay', 'position': MODULE_TOKEN}),
+        )
+        for i, (template_model, attrs) in enumerate(templates, start=1):
+            with self.subTest(template=template_model.__name__, attrs=attrs):
+                module_type = ModuleType.objects.create(
+                    manufacturer=manufacturer, model=f'Token No-Position Module Type {i}'
+                )
+                template_model.objects.create(module_type=module_type, **attrs)
+                module_bay = ModuleBay.objects.create(device=device, name=f'No-Position Bay {i}')
+
+                data = {
+                    'device': device.pk,
+                    'module_bay': module_bay.pk,
+                    'module_type': module_type.pk,
+                }
+                response = self.client.post(url, data, format='json', **self.header)
+                self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+                self.assertIn('no position defined', str(response.data.get('__all__')))
+                self.assertFalse(Module.objects.filter(module_bay=module_bay).exists())
 
 
+    @tag('regression')  # Issue #23478
+    def test_module_token_depth_mismatch(self):
+        """
+        Installing a module whose template has more MODULE_TOKEN placeholders in a field than the
+        depth of the module bay tree should return a validation error.
+        """
+        self.add_permissions('dcim.add_module')
+        manufacturer = Manufacturer.objects.get(name='Generic')
+        device = create_test_device('Device for Token Depth Mismatch Test')
         url = reverse('dcim-api:module-list')
         url = reverse('dcim-api:module-list')
+        # Two placeholders in each field that installation resolves, while every bay is one level deep
+        tokens = f'{MODULE_TOKEN}/{MODULE_TOKEN}'
+        templates = (
+            (InterfaceTemplate, {'name': f'{MODULE_TOKEN}-{MODULE_TOKEN}-eth0', 'type': '1000base-t'}),
+            (InterfaceTemplate, {'name': 'eth0', 'label': tokens, 'type': '1000base-t'}),
+            (ModuleBayTemplate, {'name': tokens}),
+            (ModuleBayTemplate, {'name': 'Bay', 'label': tokens}),
+            (ModuleBayTemplate, {'name': 'Bay', 'position': tokens}),
+        )
+        for i, (template_model, attrs) in enumerate(templates, start=1):
+            with self.subTest(template=template_model.__name__, attrs=attrs):
+                module_type = ModuleType.objects.create(
+                    manufacturer=manufacturer, model=f'Token Depth Mismatch Module Type {i}'
+                )
+                template_model.objects.create(module_type=module_type, **attrs)
+                module_bay = ModuleBay.objects.create(device=device, name=f'Depth 1 Bay {i}', position='1')
+
+                data = {
+                    'device': device.pk,
+                    'module_bay': module_bay.pk,
+                    'module_type': module_type.pk,
+                }
+                response = self.client.post(url, data, format='json', **self.header)
+                self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+                # Rejected by validation, not by the AbortRequest that component creation would raise
+                self.assertIn('2 placeholders given', str(response.data.get('__all__')))
+                self.assertFalse(Module.objects.filter(module_bay=module_bay).exists())
+
+    @tag('regression')  # Issue #23478
+    def test_module_token_empty_ancestor_position(self):
+        """One placeholder per level needs every bay position, a single placeholder only the immediate one."""
+        self.add_permissions('dcim.add_module')
+        manufacturer = Manufacturer.objects.get(name='Generic')
+        device = create_test_device('Device for Token Ancestor Test')
+        parent_type = ModuleType.objects.create(manufacturer=manufacturer, model='Token Ancestor Parent Type')
+        parent_bay = ModuleBay.objects.create(device=device, name='Parent Bay')
+        parent_module = Module.objects.create(device=device, module_bay=parent_bay, module_type=parent_type)
+        child_bay = ModuleBay.objects.create(device=device, module=parent_module, name='Child Bay', position='2')
+        url = reverse('dcim-api:module-list')
+
+        level_type = ModuleType.objects.create(manufacturer=manufacturer, model='Token Ancestor Level Type')
+        InterfaceTemplate.objects.create(
+            module_type=level_type, name='eth0', label=f'{MODULE_TOKEN}/{MODULE_TOKEN}', type='1000base-t'
+        )
         data = {
         data = {
             'device': device.pk,
             'device': device.pk,
-            'module_bay': module_bay.pk,
-            'module_type': module_type.pk,
+            'module_bay': child_bay.pk,
+            'module_type': level_type.pk,
         }
         }
         response = self.client.post(url, data, format='json', **self.header)
         response = self.client.post(url, data, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
         self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertIn('no position defined', str(response.data.get('__all__')))
 
 
-    def test_module_token_depth_mismatch(self):
-        """
-        Installing a module whose template name has more MODULE_TOKEN placeholders than the
-        depth of the module bay tree should return a validation error.
-        """
+        leaf_type = ModuleType.objects.create(manufacturer=manufacturer, model='Token Ancestor Leaf Type')
+        InterfaceTemplate.objects.create(
+            module_type=leaf_type, name='eth0', label=f'Port {MODULE_TOKEN}', type='1000base-t'
+        )
+        data['module_type'] = leaf_type.pk
+        response = self.client.post(url, data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        module = Module.objects.get(pk=response.data['id'])
+        self.assertEqual(module.interfaces.get().label, 'Port 2')
+
+    def test_module_token_resolves_in_label_and_module_bay(self):
+        """Placeholders in a label and in each module bay template field resolve against a nested bay."""
         self.add_permissions('dcim.add_module')
         self.add_permissions('dcim.add_module')
         manufacturer = Manufacturer.objects.get(name='Generic')
         manufacturer = Manufacturer.objects.get(name='Generic')
-        device = create_test_device('Device for Token Depth Mismatch Test')
-        module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Token Depth Mismatch Module Type')
-        # Template name has two placeholders but the bay is at depth 1
+        device = create_test_device('Device for Token Resolution Test')
+        parent_type = ModuleType.objects.create(manufacturer=manufacturer, model='Token Resolution Parent Type')
+        parent_bay = ModuleBay.objects.create(device=device, name='Parent Bay', position='1')
+        parent_module = Module.objects.create(device=device, module_bay=parent_bay, module_type=parent_type)
+        child_bay = ModuleBay.objects.create(device=device, module=parent_module, name='Child Bay', position='2')
+        module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Token Resolution Module Type')
         InterfaceTemplate.objects.create(
         InterfaceTemplate.objects.create(
-            module_type=module_type, name=f'{MODULE_TOKEN}-{MODULE_TOKEN}-eth0', type='1000base-t'
+            module_type=module_type, name='eth0', label=f'Port {MODULE_TOKEN}/{MODULE_TOKEN}', type='1000base-t'
+        )
+        ModuleBayTemplate.objects.create(
+            module_type=module_type,
+            name=f'SFP {MODULE_TOKEN}',
+            label=f'{MODULE_TOKEN}/{MODULE_TOKEN}',
+            position=f'{MODULE_TOKEN}/1',
         )
         )
-        module_bay = ModuleBay.objects.create(device=device, name='Depth 1 Bay', position='1')
 
 
         url = reverse('dcim-api:module-list')
         url = reverse('dcim-api:module-list')
         data = {
         data = {
             'device': device.pk,
             'device': device.pk,
-            'module_bay': module_bay.pk,
+            'module_bay': child_bay.pk,
             'module_type': module_type.pk,
             'module_type': module_type.pk,
         }
         }
         response = self.client.post(url, data, format='json', **self.header)
         response = self.client.post(url, data, format='json', **self.header)
-        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        module = Module.objects.get(pk=response.data['id'])
+        self.assertEqual(module.interfaces.get().label, 'Port 1/2')
+        module_bay = module.modulebays.get()
+        self.assertEqual((module_bay.name, module_bay.label, module_bay.position), ('SFP 2', '1/2', '2/1'))
+
+    def test_module_bay_template_names_repeat_across_modules(self):
+        """Child module bays from one template may share a name across modules on a device."""
+        self.add_permissions('dcim.add_module')
+        manufacturer = Manufacturer.objects.get(name='Generic')
+        device = create_test_device('Device for Repeated Child Bay Test')
+        module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Repeated Child Bay Module Type')
+        ModuleBayTemplate.objects.create(module_type=module_type, name='SFP 1')
+
+        url = reverse('dcim-api:module-list')
+        for name in ('Repeated Bay 1', 'Repeated Bay 2'):
+            module_bay = ModuleBay.objects.create(device=device, name=name)
+            data = {
+                'device': device.pk,
+                'module_bay': module_bay.pk,
+                'module_type': module_type.pk,
+            }
+            response = self.client.post(url, data, format='json', **self.header)
+            self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(device.modulebays.filter(name='SFP 1').count(), 2)
 
 
     def test_list_objects_by_profile_id(self):
     def test_list_objects_by_profile_id(self):
         profiles = ModuleTypeProfile.objects.filter(name__startswith='Test').order_by('name')
         profiles = ModuleTypeProfile.objects.filter(name__startswith='Test').order_by('name')
@@ -3940,7 +4199,7 @@ class ModuleTestCase(APIViewTestCases.APIViewTestCase):
         self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
         self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
 
 
 
 
-class ConsolePortTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
+class ConsolePortTestCase(Mixins.ComponentTraceMixin, Mixins.LinkPeersMixin, APIViewTestCases.APIViewTestCase):
     model = ConsolePort
     model = ConsolePort
     brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
     brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -3986,7 +4245,7 @@ class ConsolePortTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTe
         ]
         ]
 
 
 
 
-class ConsoleServerPortTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
+class ConsoleServerPortTestCase(Mixins.ComponentTraceMixin, Mixins.LinkPeersMixin, APIViewTestCases.APIViewTestCase):
     model = ConsoleServerPort
     model = ConsoleServerPort
     brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
     brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -4032,7 +4291,7 @@ class ConsoleServerPortTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.API
         ]
         ]
 
 
 
 
-class PowerPortTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
+class PowerPortTestCase(Mixins.ComponentTraceMixin, Mixins.LinkPeersMixin, APIViewTestCases.APIViewTestCase):
     model = PowerPort
     model = PowerPort
     brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
     brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -4075,7 +4334,7 @@ class PowerPortTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTest
         ]
         ]
 
 
 
 
-class PowerOutletTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
+class PowerOutletTestCase(Mixins.ComponentTraceMixin, Mixins.LinkPeersMixin, APIViewTestCases.APIViewTestCase):
     model = PowerOutlet
     model = PowerOutlet
     brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
     brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -4336,6 +4595,45 @@ class InterfaceTestCase(Mixins.FieldPrefetchMixin, Mixins.ComponentTraceMixin, A
             filters.append(f'device_id={device.pk}')
             filters.append(f'device_id={device.pk}')
         return [filters[0], '&'.join(filters)]
         return [filters[0], '&'.join(filters)]
 
 
+    def test_ordering_by_count_annotation(self):
+        """Ordering by a count annotation sorts the response whether or not it renders the count."""
+        self.add_permissions('dcim.view_interface', 'extras.view_exporttemplate')
+        interfaces = {name: Interface.objects.get(name=name) for name in ('Interface 2', 'Interface 3')}
+        IPAddress.objects.bulk_create((
+            IPAddress(address='192.0.2.1/24', assigned_object=interfaces['Interface 2']),
+            IPAddress(address='192.0.2.2/24', assigned_object=interfaces['Interface 2']),
+            IPAddress(address='192.0.2.3/24', assigned_object=interfaces['Interface 3']),
+        ))
+        export_template = ExportTemplate.objects.create(
+            name='Interfaces', template_code='{% for interface in queryset %}{{ interface.name }},{% endfor %}'
+        )
+        export_template.object_types.set([ObjectType.objects.get_for_model(Interface)])
+        url = f'{self._get_list_url()}?ordering=-count_ipaddresses'
+
+        # The count's source isn't a valid ordering field
+        response = self.client.get(f'{self._get_list_url()}?ordering=-_count_ipaddresses', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(
+            [row['name'] for row in response.data['results']], ['Interface 1', 'Interface 2', 'Interface 3']
+        )
+
+        # Responses without the count annotate it for the ordering alone
+        for params in ('', '&fields=id,name,count_ipaddresses', '&brief=1', '&fields=id,name', '&export=Interfaces'):
+            with self.subTest(params=params):
+                response = self.client.get(url + params, **self.header)
+                self.assertHttpStatus(response, status.HTTP_200_OK)
+                if 'export' in params:
+                    self.assertEqual(response.content.decode(), 'Interface 2,Interface 3,Interface 1,')
+                else:
+                    self.assertEqual(
+                        [row['name'] for row in response.data['results']],
+                        ['Interface 2', 'Interface 3', 'Interface 1'],
+                    )
+                    self.assertEqual(
+                        'count_ipaddresses' in response.data['results'][0],
+                        params in ('', '&fields=id,name,count_ipaddresses'),
+                    )
+
     def test_bulk_delete_child_interfaces(self):
     def test_bulk_delete_child_interfaces(self):
         interface1 = Interface.objects.get(name='Interface 1')
         interface1 = Interface.objects.get(name='Interface 1')
         device = interface1.device
         device = interface1.device
@@ -5045,7 +5343,7 @@ class InterfaceTestCase(Mixins.FieldPrefetchMixin, Mixins.ComponentTraceMixin, A
         self.assertNotIn('"ipam_fhrpgroupassignment"', count_sql)
         self.assertNotIn('"ipam_fhrpgroupassignment"', count_sql)
 
 
 
 
-class FrontPortTestCase(APIViewTestCases.APIViewTestCase):
+class FrontPortTestCase(Mixins.LinkPeersMixin, APIViewTestCases.APIViewTestCase):
     model = FrontPort
     model = FrontPort
     brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
     brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -5167,7 +5465,7 @@ class FrontPortTestCase(APIViewTestCases.APIViewTestCase):
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertHttpStatus(response, status.HTTP_200_OK)
 
 
 
 
-class RearPortTestCase(APIViewTestCases.APIViewTestCase):
+class RearPortTestCase(Mixins.LinkPeersMixin, APIViewTestCases.APIViewTestCase):
     model = RearPort
     model = RearPort
     brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
     brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -5815,6 +6113,46 @@ class CableTestCase(APIViewTestCases.APIViewTestCase):
             },
             },
         ]
         ]
 
 
+    def test_list_objects_terminations_query_count(self):
+        """
+        Listing cables loads their terminating objects, and those objects' devices and cables, with the same number
+        of queries however many cables are listed.
+        """
+        self.add_permissions('dcim.view_cable')
+
+        def check_results(cables, results):
+            self.assertEqual(len(results), len(cables))
+            for result in results:
+                for termination in (*result['a_terminations'], *result['b_terminations']):
+                    self.assertEqual(termination['object']['cable']['id'], result['id'])
+                    self.assertIn(termination['object']['device']['name'], ('Device 1', 'Device 2'))
+
+        assert_list_query_count_is_constant(
+            self,
+            self._get_list_url(),
+            lambda cables: {'id': [cable.pk for cable in cables]},
+            list(Cable.objects.order_by('pk')[:3]),
+            check_results,
+        )
+
+    def test_terminations_prefetch_leaves_shared_queryset_unchanged(self):
+        """
+        CableTerminationsPrefetch, which is shared across requests, gives Django a new queryset to add routing hints
+        to, and the prefetched terminations use the FETCH_PEERS fetch mode.
+        """
+        cable_prefetch = CableTerminationsPrefetch('terminations')
+        cables = list(Cable.objects.all())
+        prefetch_related_objects(cables, cable_prefetch)
+        self.assertEqual(cable_prefetch.queryset._hints, {})
+        self.assertIs(cables[0].terminations.all()[0]._state.fetch_mode, FETCH_PEERS)
+
+        # The two-level lookup used by the port viewsets
+        port_prefetch = CableTerminationsPrefetch()
+        interfaces = list(Interface.objects.filter(cable__isnull=False))
+        prefetch_related_objects(interfaces, port_prefetch)
+        self.assertEqual(port_prefetch.queryset._hints, {})
+        self.assertIs(interfaces[0].cable.terminations.all()[0]._state.fetch_mode, FETCH_PEERS)
+
     def test_repeated_put_does_not_accumulate_paths(self):
     def test_repeated_put_does_not_accumulate_paths(self):
         """
         """
         Repeating an identical PUT must leave the cable with the two paths its terminations trace.
         Repeating an identical PUT must leave the cable with the two paths its terminations trace.
@@ -6189,6 +6527,27 @@ class CableTerminationTestCase(
         for cable in cables:
         for cable in cables:
             cable.save()
             cable.save()
 
 
+    def test_list_objects_query_count(self):
+        """
+        Listing cable terminations loads their terminating objects, and those objects' devices and cables, with the
+        same number of queries however many terminations are listed.
+        """
+        self.add_permissions('dcim.view_cabletermination')
+
+        def check_results(cables, results):
+            self.assertEqual(len(results), 2 * len(cables))
+            for result in results:
+                self.assertEqual(result['termination']['cable']['id'], result['cable'])
+                self.assertIn(result['termination']['device']['name'], ('Device 1', 'Device 2'))
+
+        assert_list_query_count_is_constant(
+            self,
+            self._get_list_url(),
+            lambda cables: {'cable_id': [cable.pk for cable in cables]},
+            list(Cable.objects.order_by('pk')[:3]),
+            check_results,
+        )
+
 
 
 class ConnectedDeviceTestCase(APITestCase):
 class ConnectedDeviceTestCase(APITestCase):
 
 
@@ -6405,7 +6764,7 @@ class PowerPanelTestCase(APIViewTestCases.APIViewTestCase):
         }
         }
 
 
 
 
-class PowerFeedTestCase(APIViewTestCases.APIViewTestCase):
+class PowerFeedTestCase(Mixins.LinkPeersMixin, APIViewTestCases.APIViewTestCase):
     model = PowerFeed
     model = PowerFeed
     brief_fields = ['_occupied', 'cable', 'description', 'display', 'id', 'name', 'url']
     brief_fields = ['_occupied', 'cable', 'description', 'display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -6414,6 +6773,7 @@ class PowerFeedTestCase(APIViewTestCases.APIViewTestCase):
     bulk_update_invalid_data = {
     bulk_update_invalid_data = {
         'status': 'not-a-valid-status',
         'status': 'not-a-valid-status',
     }
     }
+    peer_termination_type = PowerPort
     user_permissions = ('dcim.view_powerpanel', )
     user_permissions = ('dcim.view_powerpanel', )
 
 
     @classmethod
     @classmethod

+ 573 - 0
netbox/dcim/tests/test_cablepaths.py

@@ -9,6 +9,7 @@ from dcim.svg import CableTraceSVG
 from dcim.tests.utils import BaseCablePathTestCase
 from dcim.tests.utils import BaseCablePathTestCase
 from dcim.utils import create_cablepaths, object_to_path_node
 from dcim.utils import create_cablepaths, object_to_path_node
 from utilities.exceptions import AbortRequest
 from utilities.exceptions import AbortRequest
+from wireless.models import WirelessLink
 
 
 
 
 class LegacyCablePathTestCase(BaseCablePathTestCase):
 class LegacyCablePathTestCase(BaseCablePathTestCase):
@@ -2745,6 +2746,528 @@ class LegacyCablePathTestCase(BaseCablePathTestCase):
         cablepath.delete()
         cablepath.delete()
         self.assertIsNone(CablePath.objects.filter(pk=cablepath.pk).first())
         self.assertIsNone(CablePath.objects.filter(pk=cablepath.pk).first())
 
 
+    def test_226_multiposition_frontport_to_converging_rear_ports(self):
+        """
+        [IF1] --C1-- [FP1:1] [RP1] --C2-- [RP3] [FP2] --C4-- [IF2]
+                     [FP1:2] [RP2] --C3-- [RP4] [FP3]
+        """
+        interface1 = Interface.objects.create(device=self.device, name='Interface 1')
+        interface2 = Interface.objects.create(device=self.device, name='Interface 2')
+        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1')
+        rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2')
+        rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3')
+        rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4')
+        frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1', positions=2)
+        frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2')
+        frontport3 = FrontPort.objects.create(device=self.device, name='Front Port 3')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device, front_port=frontport1, front_port_position=1,
+                rear_port=rearport1, rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device, front_port=frontport1, front_port_position=2,
+                rear_port=rearport2, rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device, front_port=frontport2, front_port_position=1,
+                rear_port=rearport3, rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device, front_port=frontport3, front_port_position=1,
+                rear_port=rearport4, rear_port_position=1,
+            ),
+        ])
+
+        cable1 = Cable(a_terminations=[interface1], b_terminations=[frontport1])
+        cable1.save()
+        cable2 = Cable(a_terminations=[rearport1], b_terminations=[rearport3])
+        cable2.save()
+        cable3 = Cable(a_terminations=[rearport2], b_terminations=[rearport4])
+        cable3.save()
+        cable4 = Cable(a_terminations=[frontport2, frontport3], b_terminations=[interface2])
+        cable4.save()
+
+        path1 = self.assertPathExists(
+            (
+                interface1, cable1, frontport1, (rearport1, rearport2), (cable2, cable3),
+                (rearport3, rearport4), (frontport2, frontport3), cable4, interface2
+            ),
+            is_complete=True,
+            is_active=True,
+            is_split=False
+        )
+        path2 = self.assertPathExists(
+            (
+                interface2, cable4, (frontport2, frontport3), (rearport3, rearport4), (cable2, cable3),
+                (rearport1, rearport2), frontport1, cable1, interface1
+            ),
+            is_complete=True,
+            is_active=True,
+            is_split=False
+        )
+        self.assertEqual(CablePath.objects.count(), 2)
+        interface1.refresh_from_db()
+        interface2.refresh_from_db()
+        self.assertPathIsSet(interface1, path1)
+        self.assertPathIsSet(interface2, path2)
+
+    def test_227_multiposition_frontport_to_diverging_rear_ports(self):
+        """
+        [IF1] --C1-- [FP1:1] [RP1] --C2-- [IF2]
+                     [FP1:2] [RP2] --C3-- [IF3]
+        """
+        interface1 = Interface.objects.create(device=self.device, name='Interface 1')
+        interface2 = Interface.objects.create(device=self.device, name='Interface 2')
+        interface3 = Interface.objects.create(device=self.device, name='Interface 3')
+        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1')
+        rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2')
+        frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1', positions=2)
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device, front_port=frontport1, front_port_position=1,
+                rear_port=rearport1, rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device, front_port=frontport1, front_port_position=2,
+                rear_port=rearport2, rear_port_position=1,
+            ),
+        ])
+
+        cable1 = Cable(a_terminations=[interface1], b_terminations=[frontport1])
+        cable1.save()
+        cable2 = Cable(a_terminations=[rearport1], b_terminations=[interface2])
+        cable2.save()
+        cable3 = Cable(a_terminations=[rearport2], b_terminations=[interface3])
+        cable3.save()
+
+        # Branches reaching different endpoints are still treated as a split
+        path1 = self.assertPathExists(
+            (
+                interface1, cable1, frontport1, (rearport1, rearport2), (cable2, cable3),
+                (interface2, interface3)
+            ),
+            is_complete=False,
+            is_split=True
+        )
+        self.assertEqual(path1.get_split_nodes(), [interface2, interface3])
+        self.assertPathExists(
+            (interface2, cable2, rearport1, frontport1, cable1, interface1),
+            is_complete=True,
+            is_active=True
+        )
+        self.assertPathExists(
+            (interface3, cable3, rearport2, frontport1, cable1, interface1),
+            is_complete=True,
+            is_active=True
+        )
+        self.assertEqual(CablePath.objects.count(), 3)
+
+    def test_228_multiposition_frontport_to_crossed_rear_ports_on_remote_device(self):
+        """
+        Device A                                    Device B
+        [IF1] --C1-- [FP1:1] [RP1] --C2-- [RP4] [FP3] --C4-- [IF2]
+                     [FP1:2] [RP2] --C3-- [RP3] [FP2] --C4--
+        """
+        device_b = Device.objects.create(
+            site=self.site, device_type=self.device.device_type, role=self.device.role, name='Device B'
+        )
+        interface1 = Interface.objects.create(device=self.device, name='Interface 1')
+        interface2 = Interface.objects.create(device=device_b, name='Interface 2')
+        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1')
+        rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2')
+        rearport3 = RearPort.objects.create(device=device_b, name='Rear Port 3')
+        rearport4 = RearPort.objects.create(device=device_b, name='Rear Port 4')
+        frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1', positions=2)
+        frontport2 = FrontPort.objects.create(device=device_b, name='Front Port 2')
+        frontport3 = FrontPort.objects.create(device=device_b, name='Front Port 3')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device, front_port=frontport1, front_port_position=1,
+                rear_port=rearport1, rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device, front_port=frontport1, front_port_position=2,
+                rear_port=rearport2, rear_port_position=1,
+            ),
+            PortMapping(
+                device=device_b, front_port=frontport2, front_port_position=1,
+                rear_port=rearport3, rear_port_position=1,
+            ),
+            PortMapping(
+                device=device_b, front_port=frontport3, front_port_position=1,
+                rear_port=rearport4, rear_port_position=1,
+            ),
+        ])
+
+        # TX/RX crossed between the two devices
+        cable1 = Cable(a_terminations=[interface1], b_terminations=[frontport1])
+        cable1.save()
+        cable2 = Cable(a_terminations=[rearport1], b_terminations=[rearport4])
+        cable2.save()
+        cable3 = Cable(a_terminations=[rearport2], b_terminations=[rearport3])
+        cable3.save()
+        cable4 = Cable(a_terminations=[frontport2, frontport3], b_terminations=[interface2])
+        cable4.save()
+
+        path1 = self.assertPathExists(
+            (
+                interface1, cable1, frontport1, (rearport1, rearport2), (cable2, cable3),
+                (rearport4, rearport3), (frontport2, frontport3), cable4, interface2
+            ),
+            is_complete=True,
+            is_active=True,
+            is_split=False
+        )
+        path2 = self.assertPathExists(
+            (
+                interface2, cable4, (frontport2, frontport3), (rearport3, rearport4), (cable3, cable2),
+                (rearport1, rearport2), frontport1, cable1, interface1
+            ),
+            is_complete=True,
+            is_active=True,
+            is_split=False
+        )
+        self.assertEqual(CablePath.objects.count(), 2)
+        interface1.refresh_from_db()
+        interface2.refresh_from_db()
+        self.assertPathIsSet(interface1, path1)
+        self.assertPathIsSet(interface2, path2)
+        self.assertEqual(interface1.connected_endpoints, [interface2])
+        self.assertEqual(interface2.connected_endpoints, [interface1])
+
+    def test_229_multiposition_frontport_to_rear_ports_on_different_devices(self):
+        """
+        [IF1] --C1-- [FP1:1] [RP1] --C2-- [RP3] [FP2]  (Device A)
+                     [FP1:2] [RP2] --C3-- [RP4] [FP3]  (Device B)
+        """
+        device_a = Device.objects.create(
+            site=self.site, device_type=self.device.device_type, role=self.device.role, name='Device A'
+        )
+        device_b = Device.objects.create(
+            site=self.site, device_type=self.device.device_type, role=self.device.role, name='Device B'
+        )
+        interface1 = Interface.objects.create(device=self.device, name='Interface 1')
+        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1')
+        rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2')
+        rearport3 = RearPort.objects.create(device=device_a, name='Rear Port 3')
+        rearport4 = RearPort.objects.create(device=device_b, name='Rear Port 4')
+        frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1', positions=2)
+        frontport2 = FrontPort.objects.create(device=device_a, name='Front Port 2')
+        frontport3 = FrontPort.objects.create(device=device_b, name='Front Port 3')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device, front_port=frontport1, front_port_position=1,
+                rear_port=rearport1, rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device, front_port=frontport1, front_port_position=2,
+                rear_port=rearport2, rear_port_position=1,
+            ),
+            PortMapping(
+                device=device_a, front_port=frontport2, front_port_position=1,
+                rear_port=rearport3, rear_port_position=1,
+            ),
+            PortMapping(
+                device=device_b, front_port=frontport3, front_port_position=1,
+                rear_port=rearport4, rear_port_position=1,
+            ),
+        ])
+
+        cable1 = Cable(a_terminations=[interface1], b_terminations=[frontport1])
+        cable1.save()
+        cable2 = Cable(a_terminations=[rearport1], b_terminations=[rearport3], status=LinkStatusChoices.STATUS_PLANNED)
+        cable2.save()
+        cable3 = Cable(a_terminations=[rearport2], b_terminations=[rearport4])
+        cable3.save()
+
+        # Branches crossing different devices are not followed past the remote rear ports
+        path1 = self.assertPathExists(
+            (interface1, cable1, frontport1, (rearport1, rearport2), (cable2, cable3), (rearport3, rearport4)),
+            is_complete=False,
+            is_active=False,
+            is_split=True
+        )
+        self.assertEqual(CablePath.objects.count(), 1)
+        interface1.refresh_from_db()
+        self.assertPathIsSet(interface1, path1)
+
+    def test_230_multiposition_frontport_with_uncabled_rear_port(self):
+        """
+        [IF1] --C1-- [FP1:1] [RP1] --C2-- [RP3] [FP2] --C3-- [IF2]
+                     [FP1:2] [RP2]
+        """
+        interface1 = Interface.objects.create(device=self.device, name='Interface 1')
+        interface2 = Interface.objects.create(device=self.device, name='Interface 2')
+        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1')
+        rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2')
+        rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3')
+        frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1', positions=2)
+        frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device, front_port=frontport1, front_port_position=1,
+                rear_port=rearport1, rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device, front_port=frontport1, front_port_position=2,
+                rear_port=rearport2, rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device, front_port=frontport2, front_port_position=1,
+                rear_port=rearport3, rear_port_position=1,
+            ),
+        ])
+
+        cable1 = Cable(a_terminations=[interface1], b_terminations=[frontport1])
+        cable1.save()
+        cable2 = Cable(a_terminations=[rearport1], b_terminations=[rearport3])
+        cable2.save()
+        cable3 = Cable(a_terminations=[frontport2], b_terminations=[interface2])
+        cable3.save()
+
+        # The uncabled branch is flagged as split (asymmetric), as for multi-termination cables
+        path1 = self.assertPathExists(
+            (
+                interface1, cable1, frontport1, (rearport1, rearport2), cable2, rearport3, frontport2, cable3,
+                interface2
+            ),
+            is_complete=True,
+            is_active=True,
+            is_split=True
+        )
+        path2 = self.assertPathExists(
+            (interface2, cable3, frontport2, rearport3, cable2, rearport1, frontport1, cable1, interface1),
+            is_complete=True,
+            is_active=True,
+            is_split=False
+        )
+        self.assertEqual(CablePath.objects.count(), 2)
+        interface1.refresh_from_db()
+        interface2.refresh_from_db()
+        self.assertPathIsSet(interface1, path1)
+        self.assertPathIsSet(interface2, path2)
+        self.assertEqual(interface1.connected_endpoints, [interface2])
+
+    def test_231_multiposition_frontport_rewire_diverged_to_converged(self):
+        """
+        [IF1] --C1-- [FP1:1] [RP1] --C2-- [RP3] [FP2] --C4-- [IF2]
+                     [FP1:2] [RP2] --C3-- [RP4] [FP3] --C5-- [IF3]
+        Rewired so that FP2 and FP3 both connect to IF2, then back again.
+        """
+        interface1 = Interface.objects.create(device=self.device, name='Interface 1')
+        interface2 = Interface.objects.create(device=self.device, name='Interface 2')
+        interface3 = Interface.objects.create(device=self.device, name='Interface 3')
+        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1')
+        rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2')
+        rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3')
+        rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4')
+        frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1', positions=2)
+        frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2')
+        frontport3 = FrontPort.objects.create(device=self.device, name='Front Port 3')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device, front_port=frontport1, front_port_position=1,
+                rear_port=rearport1, rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device, front_port=frontport1, front_port_position=2,
+                rear_port=rearport2, rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device, front_port=frontport2, front_port_position=1,
+                rear_port=rearport3, rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device, front_port=frontport3, front_port_position=1,
+                rear_port=rearport4, rear_port_position=1,
+            ),
+        ])
+
+        def assert_path_is_current(interface):
+            interface.refresh_from_db()
+            stored = interface.path
+            traced = CablePath.from_origin([interface])
+            self.assertEqual(
+                (stored.path, stored.is_complete, stored.is_active, stored.is_split),
+                (traced.path, traced.is_complete, traced.is_active, traced.is_split),
+            )
+            return stored
+
+        cable1 = Cable(a_terminations=[interface1], b_terminations=[frontport1])
+        cable1.save()
+        cable2 = Cable(a_terminations=[rearport1], b_terminations=[rearport3])
+        cable2.save()
+        cable3 = Cable(a_terminations=[rearport2], b_terminations=[rearport4])
+        cable3.save()
+        cable4 = Cable(a_terminations=[frontport2], b_terminations=[interface2])
+        cable4.save()
+        cable5 = Cable(a_terminations=[frontport3], b_terminations=[interface3])
+        cable5.save()
+        path1 = assert_path_is_current(interface1)
+        self.assertTrue(path1.is_split)
+        self.assertFalse(path1.is_complete)
+
+        # Removing one branch's endpoint leaves the other branch complete (and asymmetric)
+        cable5.delete()
+        path1 = assert_path_is_current(interface1)
+        self.assertTrue(path1.is_complete)
+
+        # Converge both branches on a single endpoint
+        cable4.delete()
+        cable6 = Cable(a_terminations=[frontport2, frontport3], b_terminations=[interface2])
+        cable6.save()
+        path1 = assert_path_is_current(interface1)
+        self.assertTrue(path1.is_complete)
+        self.assertFalse(path1.is_split)
+        self.assertEqual(Interface.objects.get(pk=interface1.pk).connected_endpoints, [interface2])
+        self.assertEqual(Interface.objects.get(pk=interface2.pk).connected_endpoints, [interface1])
+
+        # Diverge the branches again
+        cable6.delete()
+        cable7 = Cable(a_terminations=[frontport2], b_terminations=[interface2])
+        cable7.save()
+        cable8 = Cable(a_terminations=[frontport3], b_terminations=[interface3])
+        cable8.save()
+        path1 = assert_path_is_current(interface1)
+        self.assertTrue(path1.is_split)
+        self.assertFalse(path1.is_complete)
+        self.assertEqual(Interface.objects.get(pk=interface1.pk).connected_endpoints, [])
+
+    def test_232_multiposition_frontport_reconverged_to_multiple_interfaces(self):
+        """
+        [IF1] --C1-- [FP1:1] [RP1:1] --C2-- [RP2:1] [FP2:1] --C3-- [IF2]
+                     [FP1:2] [RP1:2]        [RP2:2] [FP2:2]        [IF3]
+        """
+        interface1 = Interface.objects.create(device=self.device, name='Interface 1')
+        interface2 = Interface.objects.create(device=self.device, name='Interface 2')
+        interface3 = Interface.objects.create(device=self.device, name='Interface 3')
+        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=2)
+        rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=2)
+        frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1', positions=2)
+        frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2', positions=2)
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device, front_port=frontport, front_port_position=position,
+                rear_port=rearport, rear_port_position=position,
+            )
+            for frontport, rearport in ((frontport1, rearport1), (frontport2, rearport2))
+            for position in (1, 2)
+        ])
+
+        cable1 = Cable(a_terminations=[interface1], b_terminations=[frontport1])
+        cable1.save()
+        cable2 = Cable(a_terminations=[rearport1], b_terminations=[rearport2])
+        cable2.save()
+        cable3 = Cable(a_terminations=[frontport2], b_terminations=[interface2, interface3])
+        cable3.save()
+
+        # Branches reconverge on RP1, so the final multi-termination cable is not a split
+        self.assertPathExists(
+            (
+                interface1, cable1, frontport1, rearport1, cable2, rearport2, frontport2, cable3,
+                (interface2, interface3)
+            ),
+            is_complete=True,
+            is_active=True,
+            is_split=False
+        )
+
+    def test_233_mixed_position_frontports_to_diverging_rear_ports(self):
+        """
+        [IF1] --C1-- [FP1]   [RP1] --C2-- [IF3]
+        [IF2]        [FP2:1] [RP2] --C3-- [IF4]
+                     [FP2:2] [RP3] --C4-- [IF5]
+        """
+        interface1 = Interface.objects.create(device=self.device, name='Interface 1')
+        interface2 = Interface.objects.create(device=self.device, name='Interface 2')
+        interface3 = Interface.objects.create(device=self.device, name='Interface 3')
+        interface4 = Interface.objects.create(device=self.device, name='Interface 4')
+        interface5 = Interface.objects.create(device=self.device, name='Interface 5')
+        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1')
+        rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2')
+        rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3')
+        frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1')
+        frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2', positions=2)
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device, front_port=frontport1, front_port_position=1,
+                rear_port=rearport1, rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device, front_port=frontport2, front_port_position=1,
+                rear_port=rearport2, rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device, front_port=frontport2, front_port_position=2,
+                rear_port=rearport3, rear_port_position=1,
+            ),
+        ])
+
+        # The single-position front port is the first termination on the cable
+        cable1 = Cable(a_terminations=[interface1, interface2], b_terminations=[frontport1, frontport2])
+        cable1.save()
+        for rearport, interface in ((rearport1, interface3), (rearport2, interface4), (rearport3, interface5)):
+            Cable(a_terminations=[rearport], b_terminations=[interface]).save()
+
+        interface1.refresh_from_db()
+        self.assertFalse(interface1.path.is_complete)
+        self.assertTrue(interface1.path.is_split)
+
+    def test_234_multiposition_frontport_reconverged_then_diverging(self):
+        """
+        [IF1] --C1-- [FP1:1] [RP1:1] --C2-- [RP2:1] [FP2] --C3-- [IF2]
+                     [FP1:2] [RP1:2]        [RP2:2] [FP3] --C4-- [IF3]
+        """
+        interface1 = Interface.objects.create(device=self.device, name='Interface 1')
+        interface2 = Interface.objects.create(device=self.device, name='Interface 2')
+        interface3 = Interface.objects.create(device=self.device, name='Interface 3')
+        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=2)
+        rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=2)
+        frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1', positions=2)
+        frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2')
+        frontport3 = FrontPort.objects.create(device=self.device, name='Front Port 3')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device, front_port=frontport1, front_port_position=1,
+                rear_port=rearport1, rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device, front_port=frontport1, front_port_position=2,
+                rear_port=rearport1, rear_port_position=2,
+            ),
+            PortMapping(
+                device=self.device, front_port=frontport2, front_port_position=1,
+                rear_port=rearport2, rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device, front_port=frontport3, front_port_position=1,
+                rear_port=rearport2, rear_port_position=2,
+            ),
+        ])
+
+        cable1 = Cable(a_terminations=[interface1], b_terminations=[frontport1])
+        cable1.save()
+        cable2 = Cable(a_terminations=[rearport1], b_terminations=[rearport2])
+        cable2.save()
+        cable3 = Cable(a_terminations=[frontport2], b_terminations=[interface2])
+        cable3.save()
+        cable4 = Cable(a_terminations=[frontport3], b_terminations=[interface3])
+        cable4.save()
+
+        # Reconverging on RP1 does not hide the branches diverging again at RP2
+        path1 = self.assertPathExists(
+            (
+                interface1, cable1, frontport1, rearport1, cable2, rearport2, (frontport2, frontport3),
+                (cable3, cable4), (interface2, interface3)
+            ),
+            is_complete=False,
+            is_split=True
+        )
+        self.assertEqual(path1.get_split_nodes(), [interface2, interface3])
+        interface1.refresh_from_db()
+        self.assertEqual(interface1.connected_endpoints, [])
+
     def test_301_create_path_via_existing_cable(self):
     def test_301_create_path_via_existing_cable(self):
         """
         """
         [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2]
         [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2]
@@ -3487,6 +4010,56 @@ class LegacyCablePathTestCase(BaseCablePathTestCase):
         self.assertIsNone(interface2.cable)
         self.assertIsNone(interface2.cable)
         self.assertPathIsNotSet(interface2)
         self.assertPathIsNotSet(interface2)
 
 
+    def test_323_update_dependent_objects_extends_path_via_raw_circuit_termination(self):
+        """
+        [IF1] --C1-- [CT1] [CT2] --> [PN1], with CT2 written raw. Refs: #23595
+        """
+        interface1 = Interface.objects.create(device=self.device, name='Interface 1')
+        providernetwork = ProviderNetwork.objects.create(name='Provider Network 1', provider=self.circuit.provider)
+        circuittermination1 = CircuitTermination.objects.create(
+            circuit=self.circuit,
+            termination=self.site,
+            term_side='A'
+        )
+        cable1 = Cable(a_terminations=[interface1], b_terminations=[circuittermination1])
+        cable1.save()
+        self.assertCurrentPathExists((interface1, cable1, circuittermination1), is_complete=False)
+
+        circuittermination2 = CircuitTermination(circuit=self.circuit, termination=providernetwork, term_side='Z')
+        circuittermination2.cache_related_objects()
+        circuittermination2.save_base(raw=True)
+        self.assertCurrentPathExists((interface1, cable1, circuittermination1), is_complete=False)
+
+        self.update_dependent_objects(circuittermination2)
+
+        self.assertCurrentPathExists(
+            (interface1, cable1, circuittermination1, circuittermination2, providernetwork),
+            is_complete=True,
+            is_active=True
+        )
+        self.assertEqual(CablePath.objects.count(), 1)
+
+    def test_324_update_dependent_objects_traces_raw_wireless_link(self):
+        """
+        [IF1] --WL1-- [IF2], with WL1 written raw. Refs: #23595
+        """
+        interface1 = Interface.objects.create(device=self.device, name='Interface 1', type='ieee802.11ac')
+        interface2 = Interface.objects.create(device=self.device, name='Interface 2', type='ieee802.11ac')
+
+        wirelesslink1 = WirelessLink(interface_a=interface1, interface_b=interface2, ssid='Link 1')
+        wirelesslink1._interface_a_device = wirelesslink1._interface_b_device = self.device
+        wirelesslink1.save_base(raw=True)
+        self.assertEqual(CablePath.objects.count(), 0)
+
+        self.update_dependent_objects(wirelesslink1)
+
+        for interface in (interface1, interface2):
+            interface.refresh_from_db()
+            self.assertEqual(interface.wireless_link, wirelesslink1)
+        self.assertCurrentPathExists((interface1, wirelesslink1, interface2), is_complete=True, is_active=True)
+        self.assertCurrentPathExists((interface2, wirelesslink1, interface1), is_complete=True, is_active=True)
+        self.assertEqual(CablePath.objects.count(), 2)
+
     def test_401_exclude_midspan_devices(self):
     def test_401_exclude_midspan_devices(self):
         """
         """
         [IF1] --C1-- [FP1][Test Device][RP1] --C2-- [RP2][Test Device][FP2] --C3-- [IF2]
         [IF1] --C1-- [FP1][Test Device][RP1] --C2-- [RP2][Test Device][FP2] --C3-- [IF2]

+ 130 - 3
netbox/dcim/tests/test_channelization.py

@@ -3,7 +3,7 @@ from unittest import mock
 
 
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
-from django.db import connection, router
+from django.db import connection, router, transaction
 from django.test import Client, TestCase, TransactionTestCase
 from django.test import Client, TestCase, TransactionTestCase
 from django.test.utils import CaptureQueriesContext
 from django.test.utils import CaptureQueriesContext
 from django.urls import reverse
 from django.urls import reverse
@@ -850,6 +850,133 @@ class ChannelizedCablePathTestCase(BaseCablePathTestCase):
                 (peer, cable, channel), pk=peer_paths[peer.pk], is_complete=True, is_active=True
                 (peer, cable, channel), pk=peer_paths[peer.pk], is_complete=True, is_active=True
             )
             )
 
 
+    def test_124_update_dependent_objects_mirrors_cable_onto_raw_channel(self):
+        """
+        A channel subinterface written raw must inherit its parent's cable and trace in both directions once
+        Interface.update_dependent_objects() is called. Refs: #23595
+        """
+        parent, channels = self._create_channelized_interface('et0', 4)
+        channels[3].delete()
+        far = [
+            Interface.objects.create(device=self.device, name=f'xe{i}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS)
+            for i in range(4)
+        ]
+        cable = Cable(profile=CableProfileChoices.BREAKOUT_1C4P_4C1P, a_terminations=[parent], b_terminations=far)
+        cable.clean()
+        cable.save()
+
+        channel = Interface(
+            device=self.device, name='et0:4', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=parent, channel_id=4
+        )
+        channel.save_base(raw=True)
+        channel.refresh_from_db()
+        self.assertIsNone(channel.cable_id)
+        self.assertPathIsNotSet(channel)
+
+        self.update_dependent_objects(channel)
+
+        channel.refresh_from_db()
+        self.assertEqual(channel.cable_id, cable.pk)
+        self.assertEqual(channel.cable_positions, [4])
+        self.assertCurrentPathExists((channel, cable, far[3]), is_complete=True, is_active=True)
+        self.assertCurrentPathExists((far[3], cable, channel), is_complete=True, is_active=True)
+        self.assertEqual(CablePath.objects.count(), 8)
+
+    def _assert_hook_matches_save(self, channel, **attrs):
+        """
+        Assert that writing attrs to channel raw and calling update_dependent_objects() leaves the same cable
+        attributes and CablePaths as a normal save().
+        """
+        def snapshot():
+            interfaces = Interface.objects.order_by('pk').values_list(
+                'pk', 'cable_id', 'cable_end', 'cable_connector', 'cable_positions', '_path__path'
+            )
+            paths = sorted((p.path, p.is_complete, p.is_active, p.is_split) for p in CablePath.objects.all())
+            return list(interfaces), paths
+
+        class Rollback(Exception):
+            pass
+
+        try:
+            with transaction.atomic():
+                instance = Interface.objects.get(pk=channel.pk)
+                for name, value in attrs.items():
+                    setattr(instance, name, value)
+                instance.full_clean()
+                instance.save()
+                expected = snapshot()
+                raise Rollback
+        except Rollback:
+            pass
+
+        instance = Interface.objects.get(pk=channel.pk)
+        for name, value in attrs.items():
+            setattr(instance, name, value)
+        instance.save_base(raw=True)
+        self.update_dependent_objects(instance)
+
+        self.assertEqual(snapshot(), expected)
+
+    def _create_breakout(self, name, far_prefix):
+        parent, channels = self._create_channelized_interface(name, 4)
+        far = [
+            Interface.objects.create(
+                device=self.device, name=f'{far_prefix}{i}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
+            )
+            for i in range(4)
+        ]
+        cable = Cable(profile=CableProfileChoices.BREAKOUT_1C4P_4C1P, a_terminations=[parent], b_terminations=far)
+        cable.clean()
+        cable.save()
+        return parent, channels, far, cable
+
+    def test_125_update_dependent_objects_clears_raw_detached_channel(self):
+        """
+        A channel subinterface detached by a raw write must drop its mirrored cable attributes and paths. Refs: #23595
+        """
+        _, channels, far, _ = self._create_breakout('et0', 'xe')
+
+        self._assert_hook_matches_save(
+            channels[3], parent=None, channel_id=None, type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
+        )
+
+        channel = Interface.objects.get(pk=channels[3].pk)
+        self.assertIsNone(channel.cable_id)
+        self.assertPathIsNotSet(channel)
+        self.assertFalse(CablePath.objects.filter(_nodes__contains=channel).exists())
+
+    def test_126_update_dependent_objects_retraces_raw_reparented_channel(self):
+        """
+        A channel subinterface moved to another channelized parent by a raw write must leave no path from its former
+        parent's far end. Refs: #23595
+        """
+        parent1, channels1, far1, cable1 = self._create_breakout('et0', 'xe')
+        parent2, channels2, far2, cable2 = self._create_breakout('et1', 'ge')
+        channels2[3].delete()
+
+        self._assert_hook_matches_save(channels1[3], parent=Interface.objects.get(pk=parent2.pk), name='et1:4')
+
+        channel = Interface.objects.get(pk=channels1[3].pk)
+        self.assertEqual(channel.cable_id, cable2.pk)
+        self.assertCurrentPathExists((channel, cable2, far2[3]), is_complete=True, is_active=True)
+        self.assertCurrentPathExists((far2[3], cable2, channel), is_complete=True, is_active=True)
+        self.assertFalse(CablePath.objects.filter(_nodes__contains=cable1).filter(_nodes__contains=channel).exists())
+
+    def test_127_update_dependent_objects_clears_channel_of_uncabled_parent(self):
+        """
+        A channel subinterface moved by a raw write to a channelized parent with no cable must drop its mirrored
+        cable attributes and paths. Refs: #23595
+        """
+        _, channels, _, _ = self._create_breakout('et0', 'xe')
+        parent2, channels2 = self._create_channelized_interface('et1', 4)
+        channels2[3].delete()
+
+        self._assert_hook_matches_save(channels[3], parent=Interface.objects.get(pk=parent2.pk), name='et1:4')
+
+        channel = Interface.objects.get(pk=channels[3].pk)
+        self.assertIsNone(channel.cable_id)
+        self.assertFalse(CablePath.objects.filter(_nodes__contains=channel).exists())
+
 
 
 class ChannelizedInterfaceTestCase(TestCase):
 class ChannelizedInterfaceTestCase(TestCase):
     """
     """
@@ -1633,7 +1760,7 @@ class ChannelizedBulkCreateTestCase(ViewTestCase):
         parent = Interface.objects.create(
         parent = Interface.objects.create(
             device=self.device, name='et0', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=4
             device=self.device, name='et0', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=4
         )
         )
-        self.add_permissions('dcim.add_interface', 'dcim.view_interface')
+        self.add_permissions('dcim.add_interface', 'dcim.view_interface', 'dcim.view_device')
 
 
         request_data = {
         request_data = {
             'device': self.device.pk,
             'device': self.device.pk,
@@ -1656,7 +1783,7 @@ class ChannelizedBulkCreateTestCase(ViewTestCase):
         parent = InterfaceTemplate.objects.create(
         parent = InterfaceTemplate.objects.create(
             device_type=self.device_type, name='et0', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=4
             device_type=self.device_type, name='et0', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=4
         )
         )
-        self.add_permissions('dcim.add_interfacetemplate', 'dcim.view_interfacetemplate')
+        self.add_permissions('dcim.add_interfacetemplate', 'dcim.view_interfacetemplate', 'dcim.view_devicetype')
 
 
         request_data = {
         request_data = {
             'device_type': self.device_type.pk,
             'device_type': self.device_type.pk,

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

@@ -3009,6 +3009,10 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         params = {'available_for_device_type': device_type.pk}
         params = {'available_for_device_type': device_type.pk}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
 
+        # Reject invalid filter values
+        params = {'available_for_device_type': 'null'}
+        self.assertFalse(self.filterset(params, self.queryset).is_valid())
+
 
 
 class DeviceTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
 class DeviceTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
     queryset = Device.objects.all()
     queryset = Device.objects.all()

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

@@ -664,6 +664,139 @@ class ModuleFormTestCase(TestCase):
         intake.refresh_from_db()
         intake.refresh_from_db()
         self.assertEqual(intake.module, module)
         self.assertEqual(intake.module, module)
 
 
+    def test_module_form_rejects_module_token_without_bay_position(self):
+        """Replication and adoption reject a {module} token in any resolved field when the bay has no position."""
+        interface_type = InterfaceTypeChoices.TYPE_1GE_FIXED
+        templates = (
+            (InterfaceTemplate, {'name': 'eth{module}', 'type': interface_type}),
+            (InterfaceTemplate, {'name': 'eth0', 'label': 'Port {module}', 'type': interface_type}),
+            (ModuleBayTemplate, {'name': 'Bay {module}'}),
+            (ModuleBayTemplate, {'name': 'Bay', 'label': 'Bay {module}'}),
+            (ModuleBayTemplate, {'name': 'Bay', 'position': '{module}'}),
+        )
+        for i, (template_model, attrs) in enumerate(templates, start=1):
+            module_type = ModuleType.objects.create(
+                manufacturer=self.module_type.manufacturer, model=f'Token No-Position Form Type {i}'
+            )
+            template_model.objects.create(module_type=module_type, **attrs)
+            for flags in ({'replicate_components': True}, {'adopt_components': True}):
+                with self.subTest(template=template_model.__name__, attrs=attrs, flags=flags):
+                    form = ModuleForm(data={
+                        'device': self.device.pk,
+                        'module_bay': self.bay_b.pk,
+                        'module_type': module_type.pk,
+                        'status': 'active',
+                        **flags,
+                    })
+                    self.assertFalse(form.is_valid())
+                    self.assertIn('no position defined', str(form.errors))
+
+    def test_module_form_rejects_module_token_depth_mismatch(self):
+        """Replication and adoption reject more {module} tokens in a resolved field than the bay depth."""
+        module_bay = ModuleBay.objects.create(device=self.device, name='Bay D', position='1')
+        interface_type = InterfaceTypeChoices.TYPE_1GE_FIXED
+        tokens = '{module}/{module}'
+        templates = (
+            (InterfaceTemplate, {'name': f'eth{tokens}', 'type': interface_type}),
+            (InterfaceTemplate, {'name': 'eth0', 'label': tokens, 'type': interface_type}),
+            (ModuleBayTemplate, {'name': tokens}),
+            (ModuleBayTemplate, {'name': 'Bay', 'label': tokens}),
+            (ModuleBayTemplate, {'name': 'Bay', 'position': tokens}),
+        )
+        for i, (template_model, attrs) in enumerate(templates, start=1):
+            module_type = ModuleType.objects.create(
+                manufacturer=self.module_type.manufacturer, model=f'Token Depth Form Type {i}'
+            )
+            template_model.objects.create(module_type=module_type, **attrs)
+            for flags in ({'replicate_components': True}, {'adopt_components': True}):
+                with self.subTest(template=template_model.__name__, attrs=attrs, flags=flags):
+                    form = ModuleForm(data={
+                        'device': self.device.pk,
+                        'module_bay': module_bay.pk,
+                        'module_type': module_type.pk,
+                        'status': 'active',
+                        **flags,
+                    })
+                    self.assertFalse(form.is_valid())
+                    self.assertIn('2 placeholders given', str(form.errors))
+
+    def test_module_form_adopt_only_creates_no_components(self):
+        """Adoption without replication adopts matching components and creates no others."""
+        adopt_type = ModuleType.objects.create(
+            manufacturer=self.module_type.manufacturer, model='Adopt Only Form Type'
+        )
+        InterfaceTemplate.objects.create(module_type=adopt_type, name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED)
+        InterfaceTemplate.objects.create(module_type=adopt_type, name='eth1', type=InterfaceTypeChoices.TYPE_1GE_FIXED)
+        interface = Interface.objects.create(
+            device=self.device, name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED
+        )
+        form = ModuleForm(data={
+            'device': self.device.pk,
+            'module_bay': self.bay_b.pk,
+            'module_type': adopt_type.pk,
+            'status': 'active',
+            'adopt_components': True,
+        })
+        self.assertTrue(form.is_valid(), form.errors)
+        module = form.save()
+        interface.refresh_from_db()
+        self.assertEqual(interface.module, module)
+        self.assertFalse(self.device.interfaces.filter(name='eth1').exists())
+
+    def test_module_form_adopt_only_rejects_component_of_another_module(self):
+        """Adoption without replication refuses a component that already belongs to another module."""
+        adopt_type = ModuleType.objects.create(
+            manufacturer=self.module_type.manufacturer, model='Owned Adopt Form Type'
+        )
+        InterfaceTemplate.objects.create(module_type=adopt_type, name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED)
+        Interface.objects.create(
+            device=self.device, module=self.module, name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED
+        )
+        form = ModuleForm(data={
+            'device': self.device.pk,
+            'module_bay': self.bay_b.pk,
+            'module_type': adopt_type.pk,
+            'status': 'active',
+            'adopt_components': True,
+        })
+        self.assertFalse(form.is_valid())
+        self.assertIn('already belongs to a module', str(form.errors))
+
+    def test_module_form_skips_template_checks_without_component_operations(self):
+        """Without replication or adoption, templates are neither checked nor instantiated."""
+        token_type = ModuleType.objects.create(
+            manufacturer=self.module_type.manufacturer, model='No Operations Form Type'
+        )
+        InterfaceTemplate.objects.create(
+            module_type=token_type, name='eth0', label='Port {module}', type=InterfaceTypeChoices.TYPE_1GE_FIXED
+        )
+        form = ModuleForm(data={
+            'device': self.device.pk,
+            'module_bay': self.bay_b.pk,
+            'module_type': token_type.pk,
+            'status': 'active',
+        })
+        self.assertTrue(form.is_valid(), form.errors)
+        module = form.save()
+        self.assertFalse(module.interfaces.exists())
+
+    def test_module_import_form_rejects_module_token_without_bay_position(self):
+        """Bulk import applies the module form's placeholder checks."""
+        token_type = ModuleType.objects.create(
+            manufacturer=self.module_type.manufacturer, model='Token Import Type'
+        )
+        InterfaceTemplate.objects.create(
+            module_type=token_type, name='eth0', label='Port {module}', type=InterfaceTypeChoices.TYPE_1GE_FIXED
+        )
+        form = ModuleImportForm(data={
+            'device': self.device.name,
+            'module_bay': self.bay_b.name,
+            'module_type': token_type.model,
+            'status': 'active',
+        })
+        self.assertFalse(form.is_valid())
+        self.assertIn('no position defined', str(form.errors))
+
 
 
 class VCPositionTokenFormTestCase(TestCase):
 class VCPositionTokenFormTestCase(TestCase):
 
 

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

@@ -17,6 +17,7 @@ from core.choices import ObjectChangeActionChoices
 from core.models import ObjectChange, ObjectType
 from core.models import ObjectChange, ObjectType
 from dcim.choices import *
 from dcim.choices import *
 from dcim.models import *
 from dcim.models import *
+from dcim.utils import resolve_module_placeholder
 from extras.events import serialize_for_event
 from extras.events import serialize_for_event
 from extras.models import CustomField
 from extras.models import CustomField
 from ipam.models import IPAddress, Prefix
 from ipam.models import IPAddress, Prefix
@@ -25,6 +26,7 @@ from netbox.context_managers import event_tracking
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from users.models import User
 from users.models import User
 from utilities.data import drange
 from utilities.data import drange
+from utilities.exceptions import AbortRequest
 from utilities.testing import create_test_device, create_test_image
 from utilities.testing import create_test_device, create_test_image
 from virtualization.models import Cluster, ClusterType
 from virtualization.models import Cluster, ClusterType
 
 
@@ -54,6 +56,49 @@ class MACAddressTestCase(TestCase):
         cls.interface.primary_mac_address = cls.mac_a
         cls.interface.primary_mac_address = cls.mac_a
         cls.interface.save()
         cls.interface.save()
 
 
+    @tag('regression')  # Ref: #23617
+    def test_primary_mac_assignment_on_interface_creation_records_prechange(self):
+        mac = MACAddress.objects.create(mac_address='1234567890cd')
+        request = RequestFactory().get('/')
+        request.id = uuid.uuid4()
+        request.user = User.objects.create_user(username='testuser')
+
+        with event_tracking(request):
+            interface = Interface.objects.create(
+                device=self.interface.device, name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED,
+                primary_mac_address=mac
+            )
+
+        change = ObjectChange.objects.get(
+            changed_object_type=ObjectType.objects.get_for_model(MACAddress),
+            changed_object_id=mac.pk,
+        )
+        self.assertIsNone(change.prechange_data['assigned_object_id'])
+        self.assertEqual(change.postchange_data['assigned_object_id'], interface.pk)
+        self.assertFalse(hasattr(mac, '_prechange_snapshot'))
+
+    @tag('regression')  # Ref: #23617
+    def test_primary_mac_assignment_keeps_existing_snapshot(self):
+        mac = MACAddress.objects.create(mac_address='1234567890ce')
+        request = RequestFactory().get('/')
+        request.id = uuid.uuid4()
+        request.user = User.objects.create_user(username='testuser')
+
+        with event_tracking(request):
+            mac.snapshot()
+            mac.description = 'updated'
+            Interface.objects.create(
+                device=self.interface.device, name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED,
+                primary_mac_address=mac
+            )
+
+        change = ObjectChange.objects.get(
+            changed_object_type=ObjectType.objects.get_for_model(MACAddress),
+            changed_object_id=mac.pk,
+        )
+        self.assertEqual(change.prechange_data['description'], '')
+        self.assertEqual(change.postchange_data['description'], 'updated')
+
     @tag('regression')
     @tag('regression')
     def test_clean_will_not_allow_removal_of_assigned_object_if_primary(self):
     def test_clean_will_not_allow_removal_of_assigned_object_if_primary(self):
         self.mac_a.assigned_object = None
         self.mac_a.assigned_object = None
@@ -1325,6 +1370,29 @@ class DeviceBayTestCase(TestCase):
         self.assertIn('installed_device', cm.exception.message_dict)
         self.assertIn('installed_device', cm.exception.message_dict)
         self.assertIn('disabled device bay', str(cm.exception.message_dict['installed_device']))
         self.assertIn('disabled device bay', str(cm.exception.message_dict['installed_device']))
 
 
+    @tag('regression')  # Ref: #23617
+    def test_moving_parent_device_records_child_prechange(self):
+        DeviceBay.objects.create(device=self.parent_device, name='Bay 1', installed_device=self.child_device)
+        old_site = self.parent_device.site
+        new_site = Site.objects.create(name='Test Site 2', slug='test-site-2')
+        request = RequestFactory().get('/')
+        request.id = uuid.uuid4()
+        request.user = User.objects.create_user(username='testuser')
+
+        with event_tracking(request):
+            parent = Device.objects.get(pk=self.parent_device.pk)
+            parent.snapshot()
+            parent.site = new_site
+            parent.save()
+
+        change = ObjectChange.objects.get(
+            changed_object_type=ObjectType.objects.get_for_model(Device),
+            changed_object_id=self.child_device.pk,
+            action=ObjectChangeActionChoices.ACTION_UPDATE,
+        )
+        self.assertEqual(change.prechange_data['site'], old_site.pk)
+        self.assertEqual(change.postchange_data['site'], new_site.pk)
+
     def test_can_disable_bay_with_existing_device(self):
     def test_can_disable_bay_with_existing_device(self):
         """
         """
         Test that disabling a bay that already has a device installed does NOT raise an error
         Test that disabling a bay that already has a device installed does NOT raise an error
@@ -2157,6 +2225,41 @@ class ModuleBayTestCase(TestCase):
         interface = iface_module.interfaces.first()
         interface = iface_module.interfaces.first()
         self.assertEqual(interface.name, 'Gi1/2')
         self.assertEqual(interface.name, 'Gi1/2')
 
 
+    @tag('regression')  # #23478
+    def test_module_token_depth_mismatch_in_label_raises_abort_request(self):
+        """An unresolvable {module} label aborts a direct module install with AbortRequest."""
+        device = Device.objects.first()
+        module_bay = ModuleBay.objects.create(device=device, name='Token Label Bay', position='1')
+        module_type = ModuleType.objects.create(manufacturer=Manufacturer.objects.first(), model='Token Label Type')
+        InterfaceTemplate.objects.create(
+            module_type=module_type, name='eth0', label='{module}/{module}', type=InterfaceTypeChoices.TYPE_1GE_FIXED
+        )
+        module = Module(device=device, module_bay=module_bay, module_type=module_type)
+        with self.assertRaises(AbortRequest) as cm:
+            module.save()
+        self.assertIn('2 placeholders given', cm.exception.message)
+
+    @tag('regression')  # #23478
+    def test_module_token_without_bay_position_raises_abort_request(self):
+        """A {module} name in a bay without a position aborts a direct module install with AbortRequest."""
+        device = Device.objects.first()
+        module_bay = ModuleBay.objects.create(device=device, name='Token Name Bay')
+        module_type = ModuleType.objects.create(manufacturer=Manufacturer.objects.first(), model='Token Name Type')
+        InterfaceTemplate.objects.create(
+            module_type=module_type, name='eth{module}', type=InterfaceTypeChoices.TYPE_1GE_FIXED
+        )
+        module = Module(device=device, module_bay=module_bay, module_type=module_type)
+        with self.assertRaises(AbortRequest) as cm:
+            module.save()
+        self.assertIn('no position defined', cm.exception.message)
+
+    @tag('regression')  # #23478
+    def test_module_token_without_positions_raises_value_error(self):
+        """A {module} token with no bay position to resolve against raises ValueError."""
+        with self.assertRaises(ValueError) as cm:
+            resolve_module_placeholder('eth{module}', [])
+        self.assertIn('no position defined', str(cm.exception))
+
     @tag('regression')  # #20912
     @tag('regression')  # #20912
     def test_module_bay_parent_cleared_when_module_removed(self):
     def test_module_bay_parent_cleared_when_module_removed(self):
         """Test that the parent field is properly cleared when a module bay's module assignment is removed"""
         """Test that the parent field is properly cleared when a module bay's module assignment is removed"""
@@ -3403,6 +3506,77 @@ class CableDisconnectChangeLoggingTestCase(TestCase):
             action=ObjectChangeActionChoices.ACTION_UPDATE,
             action=ObjectChangeActionChoices.ACTION_UPDATE,
         ).order_by('time')
         ).order_by('time')
 
 
+    @tag('regression')  # Ref: #23617
+    def test_cable_creation_records_termination_prechange(self):
+        cable = self._connect(self.interface1, self.interface2)
+
+        # The update recording the terminations follows the creation of the Cable itself
+        change = self._updates(cable).get()
+        self.assertEqual(change.prechange_data['a_terminations'], [])
+        self.assertEqual(change.prechange_data['b_terminations'], [])
+        self.assertEqual(len(change.postchange_data['a_terminations']), 1)
+        self.assertEqual(len(change.postchange_data['b_terminations']), 1)
+
+    @tag('regression')  # Ref: #23617
+    def test_resaving_new_cable_records_current_prechange(self):
+        # The creation snapshot must not be reused, so the re-save falls back to the database
+        with (
+            patch('core.signals.RUNNING_TESTS', False),
+            self.assertLogs('netbox.models.features', level='WARNING'),
+            event_tracking(self._request()),
+        ):
+            cable = Cable(a_terminations=[self.interface1], b_terminations=[self.interface2])
+            cable.save()
+            cable.label = 'x'
+            cable.save()
+
+        change = self._updates(cable).last()
+        self.assertEqual(len(change.prechange_data['a_terminations']), 1)
+        self.assertEqual(len(change.prechange_data['b_terminations']), 1)
+        self.assertEqual(change.postchange_data['label'], 'x')
+
+    @tag('regression')  # Ref: #23617
+    def test_resaving_new_cable_with_preset_pk_records_current_prechange(self):
+        # A new Cable with a preset pk (e.g. restored by deserialize_object()) must also discard its creation snapshot
+        with (
+            patch('core.signals.RUNNING_TESTS', False),
+            self.assertLogs('netbox.models.features', level='WARNING'),
+            event_tracking(self._request()),
+        ):
+            cable = Cable(pk=1000000, a_terminations=[self.interface1], b_terminations=[self.interface2])
+            cable.save()
+            cable.label = 'x'
+            cable.save()
+
+        change = self._updates(cable).last()
+        self.assertEqual(len(change.prechange_data['a_terminations']), 1)
+        self.assertEqual(len(change.prechange_data['b_terminations']), 1)
+        self.assertEqual(change.postchange_data['label'], 'x')
+
+    @tag('regression')  # Ref: #23617
+    def test_termination_update_without_snapshot_records_prechange(self):
+        cable = self._connect(self.interface1, self.interface2)
+        old_b_termination = CableTermination.objects.get(cable=cable, cable_end=CableEndChoices.SIDE_B)
+
+        request = self._request()
+        with (
+            patch('core.signals.RUNNING_TESTS', False),
+            self.assertLogs('netbox.models.features', level='WARNING') as logs,
+            event_tracking(request),
+        ):
+            cable = Cable.objects.get(pk=cable.pk)
+            cable.label = 'x'
+            cable.b_terminations = [self.interface3]
+            cable.save()
+
+        change = self._updates(cable).get(request_id=request.id)
+        self.assertEqual(change.prechange_data['b_terminations'], [old_b_termination.pk])
+        self.assertEqual(change.prechange_data['label'], '')
+        self.assertNotEqual(change.postchange_data['b_terminations'], [old_b_termination.pk])
+        self.assertEqual(change.postchange_data['label'], 'x')
+        self.assertFalse(hasattr(cable, '_prechange_snapshot'))
+        self.assertEqual(len(logs.records), 1)
+
     @tag('regression')  # Ref: netbox-branching#631
     @tag('regression')  # Ref: netbox-branching#631
     def test_cable_deletion_records_disconnect(self):
     def test_cable_deletion_records_disconnect(self):
         cable = self._connect(self.interface1, self.interface2)
         cable = self._connect(self.interface1, self.interface2)
@@ -3480,6 +3654,7 @@ class CableDisconnectChangeLoggingTestCase(TestCase):
         request = self._request()
         request = self._request()
         with event_tracking(request):
         with event_tracking(request):
             cable = Cable.objects.get(pk=cable_pk)
             cable = Cable.objects.get(pk=cable_pk)
+            cable.snapshot()
             cable.b_terminations = [self.interface3]
             cable.b_terminations = [self.interface3]
             cable.save()
             cable.save()
 
 

+ 38 - 1
netbox/dcim/tests/test_module_moves.py

@@ -1059,6 +1059,15 @@ class ModuleMoveRenameTestCase(TestCase):
         with self.assertRaises(ValidationError):
         with self.assertRaises(ValidationError):
             pos_module.full_clean()
             pos_module.full_clean()
 
 
+    def test_move_to_bay_without_position_rejects_templated_values(self):
+        """A move whose template-derived values would need a missing bay position is rejected."""
+        bare_slot = ModuleBay.objects.create(device=self.device, name='Bare Slot')
+
+        self.line_card.module_bay = bare_slot
+        with self.assertRaises(ValidationError) as cm:
+            self.line_card.full_clean()
+        self.assertIn('cannot be resolved for the destination', str(cm.exception))
+
 
 
 class ModuleCrossDeviceBlockerTestCase(TestCase):
 class ModuleCrossDeviceBlockerTestCase(TestCase):
 
 
@@ -1495,6 +1504,33 @@ class ModuleCrossDeviceMoveTestCase(TestCase):
         mapping.refresh_from_db()
         mapping.refresh_from_db()
         self.assertEqual(mapping.device, self.device_b)
         self.assertEqual(mapping.device, self.device_b)
 
 
+    @override_settings(BULK_UPDATE_CHUNK_SIZE=1)
+    def test_cross_device_move_changelogs_port_mappings(self):
+        front_port = FrontPort.objects.create(
+            device=self.device_a, module=self.line_card, name='Front 2', type=PortTypeChoices.TYPE_LC
+        )
+        rear_port = RearPort.objects.create(
+            device=self.device_a, module=self.line_card, name='Rear 2', type=PortTypeChoices.TYPE_LC, positions=1
+        )
+        PortMapping.objects.create(
+            front_port=front_port, front_port_position=1, rear_port=rear_port, rear_port_position=1
+        )
+        mappings = PortMapping.objects.filter(front_port__module=self.line_card)
+        self.assertEqual(mappings.count(), 2)
+        request = RequestFactory().get('/')
+        request.id = uuid.uuid4()
+        request.user = User.objects.create_user(username='mapping-mover')
+        with event_tracking(request):
+            self.line_card.snapshot()
+            self._move_to_device_b()
+        for mapping in mappings:
+            self.assertEqual(mapping.device, self.device_b)
+            change = ObjectChange.objects.get(
+                changed_object_type=ContentType.objects.get_for_model(PortMapping), changed_object_id=mapping.pk
+            )
+            self.assertEqual(change.prechange_data['device'], self.device_a.pk)
+            self.assertEqual(change.postchange_data['device'], self.device_b.pk)
+
     def test_cross_device_move_keeps_intra_module_power_link(self):
     def test_cross_device_move_keeps_intra_module_power_link(self):
         outlet = self.line_card.poweroutlets.get(name='Outlet 1')
         outlet = self.line_card.poweroutlets.get(name='Outlet 1')
         self._move_to_device_b()
         self._move_to_device_b()
@@ -1541,7 +1577,8 @@ class ModuleCrossDeviceMoveTestCase(TestCase):
         ModuleBay.objects.bulk_create([
         ModuleBay.objects.bulk_create([
             ModuleBay(device=big_device, name=f'Storage Bay {i}') for i in range(1, 201)
             ModuleBay(device=big_device, name=f'Storage Bay {i}') for i in range(1, 201)
         ])
         ])
-        target_bay = ModuleBay.objects.create(device=big_device, name='Target Bay')
+        # Mirrors slot_2_b, the SFP name template needs a bay position
+        target_bay = ModuleBay.objects.create(device=big_device, name='Target Bay', position='2')
         big_count = move_and_count(big_device, target_bay)
         big_count = move_and_count(big_device, target_bay)
         self.assertEqual(small_count, big_count)
         self.assertEqual(small_count, big_count)
 
 

+ 73 - 0
netbox/dcim/tests/test_port_mappings.py

@@ -15,6 +15,9 @@ from dcim.models import (
     FrontPort,
     FrontPort,
     FrontPortTemplate,
     FrontPortTemplate,
     Manufacturer,
     Manufacturer,
+    Module,
+    ModuleBayTemplate,
+    ModuleType,
     PortMapping,
     PortMapping,
     PortTemplateMapping,
     PortTemplateMapping,
     RearPort,
     RearPort,
@@ -258,6 +261,76 @@ class ReconcilePortTemplateMappingsTestCase(TestCase):
         self.assertEqual(PortTemplateMapping.objects.get(front_port=self.front_port).pk, original_pk)
         self.assertEqual(PortTemplateMapping.objects.get(front_port=self.front_port).pk, original_pk)
 
 
 
 
+class InstantiatedPortMappingChangeLoggingTestCase(TestCase):
+    """
+    Port mappings replicated from device/module type templates must be change-logged.
+    """
+    @classmethod
+    def setUpTestData(cls):
+        cls.user = User.objects.create_user(username='testuser', password='pw')
+
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        cls.role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1', color='ff0000')
+        cls.site = Site.objects.create(name='Site 1', slug='site-1')
+
+        cls.device_type = DeviceType.objects.create(
+            manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
+        )
+        ModuleBayTemplate.objects.create(device_type=cls.device_type, name='Slot 1', position='1')
+        cls.module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
+        for type_kwargs in ({'device_type': cls.device_type}, {'module_type': cls.module_type}):
+            front_port = FrontPortTemplate.objects.create(
+                name='Front {module}' if 'module_type' in type_kwargs else 'Front',
+                type=PortTypeChoices.TYPE_8P8C, positions=2, **type_kwargs
+            )
+            rear_port = RearPortTemplate.objects.create(
+                name='Rear {module}' if 'module_type' in type_kwargs else 'Rear',
+                type=PortTypeChoices.TYPE_8P8C, positions=2, **type_kwargs
+            )
+            for position in (1, 2):
+                PortTemplateMapping.objects.create(
+                    front_port=front_port, front_port_position=position,
+                    rear_port=rear_port, rear_port_position=position,
+                    **type_kwargs
+                )
+
+    def _mapping_changes(self, request):
+        return ObjectChange.objects.filter(
+            request_id=request.id,
+            changed_object_type=ContentType.objects.get_for_model(PortMapping),
+            action=ObjectChangeActionChoices.ACTION_CREATE,
+        )
+
+    @tag('regression')  # Ref: #23597
+    def test_device_creation_records_mappings(self):
+        request = _build_request(self.user)
+        with event_tracking(request):
+            device = Device.objects.create(
+                device_type=self.device_type, role=self.role, name='Device 1', site=self.site
+            )
+
+        mapping_pks = set(device.port_mappings.values_list('pk', flat=True))
+        self.assertEqual(len(mapping_pks), 2)
+        self.assertEqual(
+            set(self._mapping_changes(request).values_list('changed_object_id', flat=True)), mapping_pks
+        )
+
+    @tag('regression')  # Ref: #23597
+    def test_module_installation_records_mappings(self):
+        device = Device.objects.create(device_type=self.device_type, role=self.role, name='Device 1', site=self.site)
+        request = _build_request(self.user)
+        with event_tracking(request):
+            module = Module.objects.create(
+                device=device, module_bay=device.modulebays.get(name='Slot 1'), module_type=self.module_type
+            )
+
+        mapping_pks = set(PortMapping.objects.filter(front_port__module=module).values_list('pk', flat=True))
+        self.assertEqual(len(mapping_pks), 2)
+        self.assertEqual(
+            set(self._mapping_changes(request).values_list('changed_object_id', flat=True)), mapping_pks
+        )
+
+
 class PortMappingAPITestCase(APITestCase):
 class PortMappingAPITestCase(APITestCase):
     """
     """
     Exercise the reconcile behaviour through PortSerializer.create()/update() over the REST API,
     Exercise the reconcile behaviour through PortSerializer.create()/update() over the REST API,

+ 122 - 3
netbox/dcim/tests/test_views.py

@@ -2395,6 +2395,7 @@ class ModuleBayTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 
 
 class ConsolePortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
 class ConsolePortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = ConsolePortTemplate
     model = ConsolePortTemplate
+    bulk_create_permissions = ('dcim.view_devicetype',)
     validation_excluded_fields = ('name', 'label')
     validation_excluded_fields = ('name', 'label')
 
 
     @classmethod
     @classmethod
@@ -2428,6 +2429,7 @@ class ConsolePortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestC
 
 
 class ConsoleServerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
 class ConsoleServerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = ConsoleServerPortTemplate
     model = ConsoleServerPortTemplate
+    bulk_create_permissions = ('dcim.view_devicetype',)
     validation_excluded_fields = ('name', 'label')
     validation_excluded_fields = ('name', 'label')
 
 
     @classmethod
     @classmethod
@@ -2460,6 +2462,7 @@ class ConsoleServerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateVie
 
 
 class PowerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
 class PowerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = PowerPortTemplate
     model = PowerPortTemplate
+    bulk_create_permissions = ('dcim.view_devicetype',)
     validation_excluded_fields = ('name', 'label')
     validation_excluded_fields = ('name', 'label')
 
 
     @classmethod
     @classmethod
@@ -2498,6 +2501,7 @@ class PowerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
 
 
 class PowerOutletTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
 class PowerOutletTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = PowerOutletTemplate
     model = PowerOutletTemplate
+    bulk_create_permissions = ('dcim.view_devicetype', 'dcim.view_powerporttemplate')
     validation_excluded_fields = ('name', 'label')
     validation_excluded_fields = ('name', 'label')
 
 
     @classmethod
     @classmethod
@@ -2540,6 +2544,7 @@ class PowerOutletTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestC
 
 
 class InterfaceTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
 class InterfaceTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = InterfaceTemplate
     model = InterfaceTemplate
+    bulk_create_permissions = ('dcim.view_devicetype',)
     validation_excluded_fields = ('name', 'label')
     validation_excluded_fields = ('name', 'label')
 
 
     @classmethod
     @classmethod
@@ -2580,7 +2585,7 @@ class InterfaceTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
         module_type = ModuleType.objects.create(manufacturer=device_type.manufacturer, model='Module Type 1')
         module_type = ModuleType.objects.create(manufacturer=device_type.manufacturer, model='Module Type 1')
         return_url = reverse('dcim:moduletype_interfaces', kwargs={'pk': module_type.pk})
         return_url = reverse('dcim:moduletype_interfaces', kwargs={'pk': module_type.pk})
 
 
-        self.add_permissions('dcim.add_interfacetemplate')
+        self.add_permissions('dcim.add_interfacetemplate', 'dcim.view_moduletype')
 
 
         response = self.client.post(
         response = self.client.post(
             f"{self._get_url('add')}?device_type={device_type.pk}&return_url={return_url}",
             f"{self._get_url('add')}?device_type={device_type.pk}&return_url={return_url}",
@@ -2602,6 +2607,7 @@ class InterfaceTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
 
 
 class FrontPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
 class FrontPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = FrontPortTemplate
     model = FrontPortTemplate
+    bulk_create_permissions = ('dcim.view_devicetype', 'dcim.view_rearporttemplate')
     validation_excluded_fields = ('name', 'label', 'rear_port')
     validation_excluded_fields = ('name', 'label', 'rear_port')
 
 
     @classmethod
     @classmethod
@@ -2653,6 +2659,7 @@ class FrontPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
 
 
 class RearPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
 class RearPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = RearPortTemplate
     model = RearPortTemplate
+    bulk_create_permissions = ('dcim.view_devicetype',)
     validation_excluded_fields = ('name', 'label')
     validation_excluded_fields = ('name', 'label')
 
 
     @classmethod
     @classmethod
@@ -2687,6 +2694,7 @@ class RearPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase
 
 
 class ModuleBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
 class ModuleBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = ModuleBayTemplate
     model = ModuleBayTemplate
+    bulk_create_permissions = ('dcim.view_devicetype',)
     validation_excluded_fields = ('name', 'label')
     validation_excluded_fields = ('name', 'label')
 
 
     @classmethod
     @classmethod
@@ -2768,6 +2776,7 @@ class ModuleBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
 
 
 class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
 class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = DeviceBayTemplate
     model = DeviceBayTemplate
+    bulk_create_permissions = ('dcim.view_devicetype',)
     validation_excluded_fields = ('name', 'label')
     validation_excluded_fields = ('name', 'label')
 
 
     @classmethod
     @classmethod
@@ -2803,6 +2812,7 @@ class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
 
 
 class InventoryItemTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
 class InventoryItemTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = InventoryItemTemplate
     model = InventoryItemTemplate
+    bulk_create_permissions = ('dcim.view_devicetype', 'dcim.view_manufacturer')
     validation_excluded_fields = ('name', 'label')
     validation_excluded_fields = ('name', 'label')
 
 
     @classmethod
     @classmethod
@@ -3718,6 +3728,7 @@ class ModuleTestCase(
 
 
 class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
 class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = ConsolePort
     model = ConsolePort
+    bulk_create_permissions = ('dcim.view_device', 'extras.view_tag')
     validation_excluded_fields = ('name', 'label')
     validation_excluded_fields = ('name', 'label')
 
 
     @classmethod
     @classmethod
@@ -3834,6 +3845,7 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 
 class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
 class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = ConsoleServerPort
     model = ConsoleServerPort
+    bulk_create_permissions = ('dcim.view_device', 'extras.view_tag')
     validation_excluded_fields = ('name', 'label')
     validation_excluded_fields = ('name', 'label')
 
 
     @classmethod
     @classmethod
@@ -3904,6 +3916,7 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 
 class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
 class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = PowerPort
     model = PowerPort
+    bulk_create_permissions = ('dcim.view_device', 'extras.view_tag')
     validation_excluded_fields = ('name', 'label')
     validation_excluded_fields = ('name', 'label')
 
 
     @classmethod
     @classmethod
@@ -3980,6 +3993,7 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 
 class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
 class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = PowerOutlet
     model = PowerOutlet
+    bulk_create_permissions = ('dcim.view_device', 'dcim.view_powerport', 'extras.view_tag', 'users.view_owner')
     validation_excluded_fields = ('name', 'label')
     validation_excluded_fields = ('name', 'label')
 
 
     @classmethod
     @classmethod
@@ -4066,6 +4080,10 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 
 class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
 class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = Interface
     model = Interface
+    bulk_create_permissions = (
+        'dcim.view_device', 'dcim.view_interface', 'extras.view_tag', 'ipam.view_vlan', 'ipam.view_vrf',
+        'wireless.view_wirelesslan',
+    )
     validation_excluded_fields = ('name', 'label')
     validation_excluded_fields = ('name', 'label')
 
 
     @classmethod
     @classmethod
@@ -4284,6 +4302,49 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
             [visible_device.pk]
             [visible_device.pk]
         )
         )
 
 
+    @tag('regression')  # Issue #23585
+    def test_add_interface_excludes_restricted_devices(self):
+        """
+        The interface form only displays and accepts devices the user can view.
+        """
+        visible_device = Device.objects.get(name='Device 1')
+        site = Site.objects.create(name='Site 2', slug='site-2')
+        hidden_device = create_test_device('Hidden Device', site=site)
+
+        obj_perm = ObjectPermission(
+            name='Test permission',
+            actions=['view'],
+            constraints={'site__slug': 'site-1'},
+        )
+        obj_perm.save()
+        obj_perm.users.add(self.user)
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(Device))
+        self.add_permissions('dcim.add_interface')
+        url = reverse('dcim:interface_add')
+        data = {
+            'device': hidden_device.pk,
+            'name': 'Restricted Interface',
+            'type': InterfaceTypeChoices.TYPE_1GE_FIXED,
+        }
+
+        # Creation rejects the hidden device
+        response = self.client.post(url, data=post_data(data))
+        self.assertHttpStatus(response, 200)
+        self.assertIn('device', response.context['form'].errors)
+        self.assertNotContains(response, hidden_device.name)
+        self.assertFalse(Interface.objects.filter(name='Restricted Interface').exists())
+
+        # The form does not display the hidden device
+        response = self.client.get(url, {'device': hidden_device.pk})
+        self.assertHttpStatus(response, 200)
+        self.assertNotContains(response, hidden_device.name)
+
+        # A visible device is accepted
+        data['device'] = visible_device.pk
+        response = self.client.post(url, data=post_data(data))
+        self.assertHttpStatus(response, 302)
+        self.assertTrue(Interface.objects.filter(device=visible_device, name='Restricted Interface').exists())
+
     def test_rename_select_all_spans_pages(self):
     def test_rename_select_all_spans_pages(self):
         """
         """
         Tests the bulk rename functionality for interfaces spanning multiple pages in the UI.
         Tests the bulk rename functionality for interfaces spanning multiple pages in the UI.
@@ -4328,7 +4389,10 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
         Submitting the Interface form with a mac_address string creates a MACAddress
         Submitting the Interface form with a mac_address string creates a MACAddress
         and sets it as primary in one request.
         and sets it as primary in one request.
         """
         """
-        self.add_permissions('dcim.add_interface', 'dcim.add_macaddress')
+        self.add_permissions(
+            'dcim.add_interface', 'dcim.add_macaddress', 'dcim.view_device', 'dcim.view_interface', 'extras.view_tag',
+            'ipam.view_vlan', 'ipam.view_vrf', 'wireless.view_wirelesslan',
+        )
 
 
         data = {**self.form_data, 'mac_address': 'AA:BB:CC:DD:EE:FF', 'changelog_message': 'test'}
         data = {**self.form_data, 'mac_address': 'AA:BB:CC:DD:EE:FF', 'changelog_message': 'test'}
         response = self.client.post(self._get_url('add'), data=post_data(data))
         response = self.client.post(self._get_url('add'), data=post_data(data))
@@ -4478,6 +4542,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 
 class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
 class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = FrontPort
     model = FrontPort
+    bulk_create_permissions = ('dcim.view_device', 'dcim.view_rearport', 'extras.view_tag')
     validation_excluded_fields = ('name', 'label', 'rear_port')
     validation_excluded_fields = ('name', 'label', 'rear_port')
 
 
     @classmethod
     @classmethod
@@ -4600,7 +4665,7 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
             RearPort(device=device, name='Rear Port 8', positions=2),
             RearPort(device=device, name='Rear Port 8', positions=2),
         )
         )
         RearPort.objects.bulk_create(rear_ports)
         RearPort.objects.bulk_create(rear_ports)
-        self.add_permissions('dcim.add_frontport')
+        self.add_permissions('dcim.add_frontport', 'dcim.view_device', 'dcim.view_rearport')
 
 
         response = self.client.post(self._get_url('add'), post_data({
         response = self.client.post(self._get_url('add'), post_data({
             'device': device.pk,
             'device': device.pk,
@@ -4627,6 +4692,50 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
                 [(1, rear_port.pk, 1), (2, rear_port.pk, 2)]
                 [(1, rear_port.pk, 1), (2, rear_port.pk, 2)]
             )
             )
 
 
+    @tag('regression')  # Issue #23585
+    def test_add_front_port_excludes_restricted_rear_ports(self):
+        """
+        The front port form only offers and accepts rear ports the user can view.
+        """
+        device = Device.objects.get(name='Device 1')
+        visible_rear_port = RearPort.objects.create(device=device, name='Visible Rear Port')
+        hidden_rear_port = RearPort.objects.create(device=device, name='Hidden Rear Port')
+
+        obj_perm = ObjectPermission(
+            name='Test permission',
+            actions=['view'],
+            constraints={'pk': visible_rear_port.pk},
+        )
+        obj_perm.save()
+        obj_perm.users.add(self.user)
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(RearPort))
+        self.add_permissions('dcim.add_frontport', 'dcim.view_device')
+        url = reverse('dcim:frontport_add')
+        data = {
+            'device': device.pk,
+            'name': 'Restricted Front Port',
+            'type': PortTypeChoices.TYPE_8P8C,
+            'positions': 1,
+        }
+
+        # Creation rejects the hidden rear port
+        response = self.client.post(url, data=post_data({**data, 'rear_ports': [f'{hidden_rear_port.pk}:1']}))
+        self.assertHttpStatus(response, 200)
+        self.assertIn('rear_ports', response.context['form'].errors)
+        self.assertFalse(FrontPort.objects.filter(name='Restricted Front Port').exists())
+
+        # The form offers only the visible rear port
+        response = self.client.get(url, {'device': device.pk})
+        self.assertContains(response, visible_rear_port.name)
+        self.assertNotContains(response, hidden_rear_port.name)
+
+        # A visible rear port is accepted
+        response = self.client.post(url, data=post_data({**data, 'rear_ports': [f'{visible_rear_port.pk}:1']}))
+        self.assertHttpStatus(response, 302)
+        self.assertTrue(
+            PortMapping.objects.filter(front_port__name='Restricted Front Port', rear_port=visible_rear_port).exists()
+        )
+
     def test_trace(self):
     def test_trace(self):
         self.add_permissions(
         self.add_permissions(
             'dcim.view_frontport',
             'dcim.view_frontport',
@@ -4648,6 +4757,7 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 
 class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
 class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = RearPort
     model = RearPort
+    bulk_create_permissions = ('dcim.view_device', 'extras.view_tag')
     validation_excluded_fields = ('name', 'label')
     validation_excluded_fields = ('name', 'label')
 
 
     @classmethod
     @classmethod
@@ -4721,6 +4831,7 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 
 class ModuleBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
 class ModuleBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = ModuleBay
     model = ModuleBay
+    bulk_create_permissions = ('dcim.view_device', 'extras.view_tag')
     validation_excluded_fields = ('name', 'label')
     validation_excluded_fields = ('name', 'label')
 
 
     @classmethod
     @classmethod
@@ -4874,6 +4985,7 @@ class ModuleBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 
 class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
 class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = DeviceBay
     model = DeviceBay
+    bulk_create_permissions = ('dcim.view_device', 'extras.view_tag')
     validation_excluded_fields = ('name', 'label')
     validation_excluded_fields = ('name', 'label')
 
 
     @classmethod
     @classmethod
@@ -4927,6 +5039,9 @@ class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 
 class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
 class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = InventoryItem
     model = InventoryItem
+    bulk_create_permissions = (
+        'dcim.view_device', 'dcim.view_inventoryitemrole', 'dcim.view_manufacturer', 'extras.view_tag',
+    )
     validation_excluded_fields = ('name', 'label')
     validation_excluded_fields = ('name', 'label')
 
 
     @classmethod
     @classmethod
@@ -6040,6 +6155,7 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 
 
 class CoolingIntakeTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
 class CoolingIntakeTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = CoolingIntakeTemplate
     model = CoolingIntakeTemplate
+    bulk_create_permissions = ('dcim.view_devicetype',)
     validation_excluded_fields = ('name', 'label')
     validation_excluded_fields = ('name', 'label')
 
 
     @classmethod
     @classmethod
@@ -6084,6 +6200,7 @@ class CoolingIntakeTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTes
 
 
 class CoolingOutflowTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
 class CoolingOutflowTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = CoolingOutflowTemplate
     model = CoolingOutflowTemplate
+    bulk_create_permissions = ('dcim.view_coolingintaketemplate', 'dcim.view_devicetype')
     validation_excluded_fields = ('name', 'label')
     validation_excluded_fields = ('name', 'label')
 
 
     @classmethod
     @classmethod
@@ -6129,6 +6246,7 @@ class CoolingOutflowTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTe
 
 
 class CoolingIntakeTestCase(ViewTestCases.DeviceComponentViewTestCase):
 class CoolingIntakeTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = CoolingIntake
     model = CoolingIntake
+    bulk_create_permissions = ('dcim.view_device', 'extras.view_tag')
     validation_excluded_fields = ('name', 'label')
     validation_excluded_fields = ('name', 'label')
 
 
     @classmethod
     @classmethod
@@ -6194,6 +6312,7 @@ class CoolingIntakeTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 
 class CoolingOutflowTestCase(ViewTestCases.DeviceComponentViewTestCase):
 class CoolingOutflowTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = CoolingOutflow
     model = CoolingOutflow
+    bulk_create_permissions = ('dcim.view_coolingintake', 'dcim.view_device', 'extras.view_tag')
     validation_excluded_fields = ('name', 'label')
     validation_excluded_fields = ('name', 'label')
 
 
     @classmethod
     @classmethod

+ 25 - 1
netbox/dcim/tests/utils.py

@@ -1,8 +1,13 @@
-from django.test import TestCase
+import uuid
+
+from django.test import RequestFactory, TestCase
 
 
 from circuits.models import *
 from circuits.models import *
+from core.models import ObjectChange
 from dcim.models import *
 from dcim.models import *
 from dcim.utils import object_to_path_node
 from dcim.utils import object_to_path_node
+from netbox.context_managers import event_tracking
+from utilities.testing.utils import create_test_user
 
 
 __all__ = (
 __all__ = (
     'BaseCablePathTestCase',
     'BaseCablePathTestCase',
@@ -101,3 +106,22 @@ class BaseCablePathTestCase(TestCase):
         if msg is None:
         if msg is None:
             msg = f"Path #{origin._path_id} set as origin on {origin}; should be None!"
             msg = f"Path #{origin._path_id} set as origin on {origin}; should be None!"
         self.assertIsNone(origin._path_id, msg=msg)
         self.assertIsNone(origin._path_id, msg=msg)
+
+    def update_dependent_objects(self, obj):
+        """
+        Call update_dependent_objects() on a fresh copy of obj twice under change tracking, asserting that neither
+        call records an ObjectChange and that the second call leaves the CablePaths unchanged.
+        """
+        request = RequestFactory().get('/')
+        request.id = uuid.uuid4()
+        request.user = create_test_user()
+
+        def get_paths():
+            return sorted((p.path, p.is_complete, p.is_active, p.is_split) for p in CablePath.objects.all())
+
+        with event_tracking(request):
+            type(obj).objects.get(pk=obj.pk).update_dependent_objects()
+            paths = get_paths()
+            type(obj).objects.get(pk=obj.pk).update_dependent_objects()
+        self.assertEqual(get_paths(), paths)
+        self.assertFalse(ObjectChange.objects.filter(request_id=request.id).exists())

+ 33 - 13
netbox/dcim/utils.py

@@ -5,6 +5,7 @@ from django.apps import apps
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.db import router, transaction
 from django.db import router, transaction
 from django.db.models import Q
 from django.db.models import Q
+from django.db.models.signals import post_save
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
 from dcim.constants import MODULE_TOKEN
 from dcim.constants import MODULE_TOKEN
@@ -81,24 +82,31 @@ def resolve_module_placeholder(value, positions):
     Multiple tokens must match the tree depth and resolve level-by-level.
     Multiple tokens must match the tree depth and resolve level-by-level.
 
 
     Returns the resolved string.
     Returns the resolved string.
-    Raises ValueError if token count is greater than 1 and doesn't match tree depth.
+    Raises ValueError if token count is greater than 1 and doesn't match tree depth,
+    or if a position a token resolves to is missing or empty.
     """
     """
     if MODULE_TOKEN not in value:
     if MODULE_TOKEN not in value:
         return value
         return value
 
 
     token_count = value.count(MODULE_TOKEN)
     token_count = value.count(MODULE_TOKEN)
     if token_count == 1:
     if token_count == 1:
-        return value.replace(MODULE_TOKEN, positions[-1])
-    if token_count == len(positions):
-        for pos in positions:
-            value = value.replace(MODULE_TOKEN, pos, 1)
-        return value
-    raise ValueError(
-        _("Cannot install module with placeholder values in a module bay tree "
-          "{level} levels deep but {tokens} placeholders given.").format(
-            level=len(positions), tokens=token_count
+        referenced = positions[-1:]
+    elif token_count == len(positions):
+        referenced = positions
+    else:
+        raise ValueError(
+            _("Cannot install module with placeholder values in a module bay tree "
+              "{level} levels deep but {tokens} placeholders given.").format(
+                level=len(positions), tokens=token_count
+            )
         )
         )
-    )
+    if not referenced or not all(referenced):
+        raise ValueError(
+            _("Cannot install module with placeholder values in a module bay with no position defined.")
+        )
+    for pos in referenced:
+        value = value.replace(MODULE_TOKEN, pos, 1)
+    return value
 
 
 
 
 def compile_path_node(ct_id, object_id):
 def compile_path_node(ct_id, object_id):
@@ -475,6 +483,7 @@ def update_interface_parents(device, interface_templates, module=None):
 
 
     for interface_template in interface_templates.exclude(parent=None):
     for interface_template in interface_templates.exclude(parent=None):
         interface = Interface.objects.get(device=device, name=interface_template.resolve_name(module=module))
         interface = Interface.objects.get(device=device, name=interface_template.resolve_name(module=module))
+        interface.snapshot()
         interface.parent = Interface.objects.get(
         interface.parent = Interface.objects.get(
             device=device,
             device=device,
             name=interface_template.parent.resolve_name(module=module)
             name=interface_template.parent.resolve_name(module=module)
@@ -497,6 +506,7 @@ def update_interface_bridges(device, interface_templates, module=None):
         )
         )
 
 
         if interface_template.bridge:
         if interface_template.bridge:
+            interface.snapshot()
             interface.bridge = Interface.objects.get(
             interface.bridge = Interface.objects.get(
                 device=device,
                 device=device,
                 name=interface_template.bridge.resolve_name(module=module, device=device)
                 name=interface_template.bridge.resolve_name(module=module, device=device)
@@ -535,8 +545,18 @@ def create_port_mappings(device, device_or_module_type, module=None):
                 rear_port_position=template.rear_port_position,
                 rear_port_position=template.rear_port_position,
             )
             )
         )
         )
-    # Bulk-created (no per-mapping ObjectChange) to match how every other component is instantiated.
-    PortMapping.objects.bulk_create(mappings)
+    # Send post_save for each mapping to record its creation
+    mappings = PortMapping.objects.bulk_create(mappings)
+    using = router.db_for_write(PortMapping)
+    for mapping in mappings:
+        post_save.send(
+            sender=PortMapping,
+            instance=mapping,
+            created=True,
+            raw=False,
+            using=using,
+            update_fields=None
+        )
 
 
 
 
 def reconcile_port_mappings(mapping_model, parent_field, parent, desired):
 def reconcile_port_mappings(mapping_model, parent_field, parent, desired):

+ 4 - 2
netbox/extras/dashboard/utils.py

@@ -5,6 +5,7 @@ from django.core.exceptions import ObjectDoesNotExist
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
 from extras.constants import DEFAULT_DASHBOARD
 from extras.constants import DEFAULT_DASHBOARD
+from netbox.config import get_config
 from netbox.registry import registry
 from netbox.registry import registry
 
 
 __all__ = (
 __all__ = (
@@ -46,10 +47,11 @@ def get_dashboard(user):
         try:
         try:
             dashboard = user.dashboard
             dashboard = user.dashboard
         except ObjectDoesNotExist:
         except ObjectDoesNotExist:
-            # Create a dashboard for this user
+            # Create a dashboard for this user (unless the database is read-only)
             dashboard = get_default_dashboard()
             dashboard = get_default_dashboard()
             dashboard.user = user
             dashboard.user = user
-            dashboard.save()
+            if not get_config().MAINTENANCE_MODE:
+                dashboard.save()
 
 
     return dashboard
     return dashboard
 
 

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

@@ -302,7 +302,7 @@ class EventRuleBulkEditForm(OwnerMixin, NetBoxModelBulkEditForm):
         widget=BulkEditNullBooleanSelect()
         widget=BulkEditNullBooleanSelect()
     )
     )
     event_types = forms.MultipleChoiceField(
     event_types = forms.MultipleChoiceField(
-        choices=get_event_type_choices(),
+        choices=get_event_type_choices,
         required=False,
         required=False,
         label=_('Event types')
         label=_('Event types')
     )
     )

+ 5 - 1
netbox/extras/forms/bulk_import.py

@@ -317,7 +317,6 @@ class EventRuleImportForm(OwnerCSVMixin, NetBoxModelImportForm):
         help_text=_("One or more assigned object types")
         help_text=_("One or more assigned object types")
     )
     )
     event_types = CSVMultipleChoiceField(
     event_types = CSVMultipleChoiceField(
-        choices=get_event_type_choices(),
         label=_('Event types'),
         label=_('Event types'),
         help_text=_('The event type(s) which will trigger this rule')
         help_text=_('The event type(s) which will trigger this rule')
     )
     )
@@ -337,6 +336,11 @@ class EventRuleImportForm(OwnerCSVMixin, NetBoxModelImportForm):
             'owner', 'comments', 'tags'
             'owner', 'comments', 'tags'
         )
         )
 
 
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        # Resolved per instance because plugins register event types after this module is imported
+        self.fields['event_types'].choices = get_event_type_choices()
+
     def clean(self):
     def clean(self):
         super().clean()
         super().clean()
 
 

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

@@ -607,7 +607,7 @@ class EventRuleForm(OwnerMixin, NetBoxModelForm):
         queryset=ObjectType.objects.with_feature('event_rules'),
         queryset=ObjectType.objects.with_feature('event_rules'),
     )
     )
     event_types = MultipleChoiceField(
     event_types = MultipleChoiceField(
-        choices=get_event_type_choices(),
+        choices=get_event_type_choices,
         label=_('Event types')
         label=_('Event types')
     )
     )
     action_choice = ChoiceField(
     action_choice = ChoiceField(

+ 10 - 0
netbox/extras/models/customfields.py

@@ -821,6 +821,16 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
                 raise ValidationError({
                 raise ValidationError({
                     'related_object_filter': _("Filter must be defined as a dictionary mapping attributes to values.")
                     'related_object_filter': _("Filter must be defined as a dictionary mapping attributes to values.")
                 })
                 })
+            # Each value must be a scalar or a list of scalars (rendered as query parameters)
+            for attr, value in self.related_object_filter.items():
+                values = value if isinstance(value, (list, tuple)) else [value]
+                if any(isinstance(v, (dict, list, tuple)) for v in values):
+                    raise ValidationError({
+                        'related_object_filter': _(
+                            "Invalid value for filter attribute '{attr}': values must be a scalar or a list of "
+                            "scalars."
+                        ).format(attr=attr)
+                    })
 
 
     def serialize(self, value):
     def serialize(self, value):
         """
         """

+ 4 - 0
netbox/extras/models/models.py

@@ -9,6 +9,7 @@ from django.contrib.postgres.fields import ArrayField
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.db import models
+from django.db.models import FETCH_PEERS
 from django.urls import reverse
 from django.urls import reverse
 from django.utils import timezone
 from django.utils import timezone
 from django.utils.html import escape
 from django.utils.html import escape
@@ -572,6 +573,9 @@ class ExportTemplate(
 
 
     def get_context(self, context=None, queryset=None):
     def get_context(self, context=None, queryset=None):
         _context = super().get_context(context=context, queryset=queryset)
         _context = super().get_context(context=context, queryset=queryset)
+        # Fetch each related object the template reads for the whole queryset at once
+        if queryset is not None:
+            queryset = queryset.fetch_mode(FETCH_PEERS)
         _context['queryset'] = queryset
         _context['queryset'] = queryset
         return _context
         return _context
 
 

+ 37 - 0
netbox/extras/tests/test_customfields.py

@@ -1112,6 +1112,43 @@ class CustomFieldTestCase(TestCase):
                 validation_schema=schema, default={'age': 25}
                 validation_schema=schema, default={'age': 25}
             ).full_clean()
             ).full_clean()
 
 
+    def test_related_object_filter_validation(self):
+        object_type = ObjectType.objects.get_for_model(Site)
+
+        valid_filters = (
+            {'status': 'active'},
+            {'status': ['active', 'planned']},
+            {'id': 1, 'is_active': True, 'tenant': None, 'region_id': '$region'},
+            {'vid': [1, 2.5, False, None]},
+        )
+        for related_object_filter in valid_filters:
+            with self.subTest(related_object_filter=related_object_filter):
+                CustomField(
+                    name='test',
+                    type=CustomFieldTypeChoices.TYPE_OBJECT,
+                    related_object_type=object_type,
+                    related_object_filter=related_object_filter,
+                ).full_clean()
+
+        # Nested dicts/lists cannot be rendered as query parameters (#23635)
+        invalid_filters = (
+            {'and': [{'attr': 'name', 'value': 'a'}, {'attr': 'rd', 'value': 'b'}]},
+            {'status': {'value': 'active'}},
+            {'status': [['active']]},
+            {'status': ('active', ['planned'])},
+            {'status': ['active', ('planned',)]},
+        )
+        for related_object_filter in invalid_filters:
+            with self.subTest(related_object_filter=related_object_filter):
+                with self.assertRaises(ValidationError) as cm:
+                    CustomField(
+                        name='test',
+                        type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
+                        related_object_type=object_type,
+                        related_object_filter=related_object_filter,
+                    ).full_clean()
+                self.assertIn('related_object_filter', cm.exception.message_dict)
+
 
 
 class CustomFieldManagerTestCase(TestCase):
 class CustomFieldManagerTestCase(TestCase):
 
 

+ 37 - 0
netbox/extras/tests/test_forms.py

@@ -12,12 +12,14 @@ from dcim.forms import SiteForm
 from dcim.models import Site
 from dcim.models import Site
 from extras.choices import CustomFieldTypeChoices, EventRuleActionChoices
 from extras.choices import CustomFieldTypeChoices, EventRuleActionChoices
 from extras.forms import SavedFilterForm, TableConfigBulkEditForm, TableConfigForm
 from extras.forms import SavedFilterForm, TableConfigBulkEditForm, TableConfigForm
+from extras.forms.bulk_edit import EventRuleBulkEditForm
 from extras.forms.bulk_import import EventRuleImportForm
 from extras.forms.bulk_import import EventRuleImportForm
 from extras.forms.filtersets import EventRuleFilterForm
 from extras.forms.filtersets import EventRuleFilterForm
 from extras.forms.model_forms import CustomFieldChoiceSetForm, EventRuleForm
 from extras.forms.model_forms import CustomFieldChoiceSetForm, EventRuleForm
 from extras.forms.scripts import ScriptFileForm
 from extras.forms.scripts import ScriptFileForm
 from extras.models import CustomField, CustomFieldChoiceSet, EventRule, NotificationGroup, Script, ScriptModule, Webhook
 from extras.models import CustomField, CustomFieldChoiceSet, EventRule, NotificationGroup, Script, ScriptModule, Webhook
 from netbox.event_rules import EventRuleAction, register_event_rule_action
 from netbox.event_rules import EventRuleAction, register_event_rule_action
+from netbox.events import EventType
 from netbox.registry import registry
 from netbox.registry import registry
 from utilities.forms.widgets import HTMXSelect
 from utilities.forms.widgets import HTMXSelect
 
 
@@ -522,6 +524,41 @@ class EventRuleFilterFormTestCase(TestCase):
         self.assertIn(None, choices)  # The blank choice is retained
         self.assertIn(None, choices)  # The blank choice is retained
 
 
 
 
+class EventRuleEventTypeChoicesTestCase(TestCase):
+    """
+    Event types registered after the forms module is imported (e.g. in a plugin's ready()) must be
+    offered by every event rule form.
+    """
+
+    def setUp(self):
+        EventType('test_late_event', 'Late event').register()
+        self.addCleanup(registry['event_types'].pop, 'test_late_event', None)
+
+    def test_model_form_offers_late_event_type(self):
+        choices = dict(EventRuleForm().fields['event_types'].choices)
+        self.assertEqual(choices.get('test_late_event'), 'Late event')
+
+    def test_bulk_edit_form_offers_late_event_type(self):
+        choices = dict(EventRuleBulkEditForm().fields['event_types'].choices)
+        self.assertEqual(choices.get('test_late_event'), 'Late event')
+
+    def test_filter_form_offers_late_event_type(self):
+        choices = dict(EventRuleFilterForm().fields['event_type'].choices)
+        self.assertEqual(choices.get('test_late_event'), 'Late event')
+
+    def test_import_form_accepts_late_event_type(self):
+        webhook = Webhook.objects.create(name='Late Event Webhook', payload_url='http://localhost:9000/')
+        form = EventRuleImportForm(data={
+            'name': 'Late Event Rule',
+            'object_types': 'dcim.site',
+            'event_types': 'test_late_event',
+            'action_type': EventRuleActionChoices.WEBHOOK,
+            'action_object': webhook.name,
+        })
+        self.assertTrue(form.is_valid(), form.errors)
+        self.assertEqual(form.cleaned_data['event_types'], ['test_late_event'])
+
+
 class EventRuleImportFormTestCase(TestCase):
 class EventRuleImportFormTestCase(TestCase):
     """
     """
     EventRuleImportForm resolves action_object via each registered action's resolve_import_object()
     EventRuleImportForm resolves action_object via each registered action's resolve_import_object()

+ 33 - 1
netbox/extras/tests/test_models.py

@@ -12,6 +12,7 @@ from django.core.files.base import ContentFile
 from django.core.files.storage import Storage
 from django.core.files.storage import Storage
 from django.core.files.uploadedfile import SimpleUploadedFile
 from django.core.files.uploadedfile import SimpleUploadedFile
 from django.db import connection
 from django.db import connection
+from django.db.models import FETCH_PEERS
 from django.forms import ValidationError
 from django.forms import ValidationError
 from django.test import TestCase, override_settings, tag
 from django.test import TestCase, override_settings, tag
 from django.test.utils import CaptureQueriesContext
 from django.test.utils import CaptureQueriesContext
@@ -1343,11 +1344,13 @@ class ExportTemplateContextTestCase(TestCase):
         self.assertIs(ctx['dcim']['Device'], Device)
         self.assertIs(ctx['dcim']['Device'], Device)
 
 
     def test_get_context_includes_queryset(self):
     def test_get_context_includes_queryset(self):
+        Site.objects.create(name='Site 1', slug='site-1')
         et = ExportTemplate(name='test', template_code='test')
         et = ExportTemplate(name='test', template_code='test')
         qs = Site.objects.all()
         qs = Site.objects.all()
         ctx = et.get_context(queryset=qs)
         ctx = et.get_context(queryset=qs)
 
 
-        self.assertIs(ctx['queryset'], qs)
+        self.assertQuerySetEqual(ctx['queryset'], qs)
+        self.assertIs(ctx['queryset'][0]._state.fetch_mode, FETCH_PEERS)
 
 
     def test_get_context_applies_extra_context(self):
     def test_get_context_applies_extra_context(self):
         et = ExportTemplate(name='test', template_code='test')
         et = ExportTemplate(name='test', template_code='test')
@@ -1581,6 +1584,35 @@ class ExportTemplateRenderTestCase(TestCase):
         output = t.render(queryset=queryset)
         output = t.render(queryset=queryset)
         self.assertEqual(output, 'Site A\nSite B\nSite C\n')
         self.assertEqual(output, 'Site A\nSite B\nSite C\n')
 
 
+    def test_render_batches_related_object_queries(self):
+        """
+        Each relation the template reads (including chained relations) costs one query for the whole queryset.
+        """
+        tenant_group = TenantGroup.objects.create(name='Tenant Group 1', slug='tenant-group-1')
+        for i, site in enumerate(Site.objects.order_by('name'), start=1):
+            site.tenant = Tenant.objects.create(name=f'Tenant {i}', slug=f'tenant-{i}', group=tenant_group)
+            site.save()
+
+        baseline = ExportTemplate(
+            name='names',
+            template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
+        )
+        t = ExportTemplate(
+            name='related',
+            template_code='{% for obj in queryset %}{{ obj }},{{ obj.tenant }},{{ obj.tenant.group }}\n{% endfor %}',
+        )
+        with CaptureQueriesContext(connection) as baseline_queries:
+            baseline.render(queryset=Site.objects.order_by('name'))
+        with CaptureQueriesContext(connection) as queries:
+            output = t.render(queryset=Site.objects.order_by('name'))
+
+        self.assertEqual(
+            output,
+            'Site A,Tenant 1,Tenant Group 1\nSite B,Tenant 2,Tenant Group 1\nSite C,Tenant 3,Tenant Group 1\n'
+        )
+        # One query for the tenants and one for their group
+        self.assertEqual(len(queries), len(baseline_queries) + 2)
+
     def test_render_to_response_for_queryset(self):
     def test_render_to_response_for_queryset(self):
         t = ExportTemplate(
         t = ExportTemplate(
             name='sites',
             name='sites',

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

@@ -23,6 +23,7 @@ from extras.scripts import Script as PythonClass
 from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
 from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
 from users.models import Group, ObjectPermission, User
 from users.models import Group, ObjectPermission, User
 from utilities.testing import TestCase, ViewTestCases
 from utilities.testing import TestCase, ViewTestCases
+from utilities.views import get_action_url
 
 
 
 
 class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@@ -563,6 +564,37 @@ class ImageAttachmentTestCase(
             response.content.decode(),
             response.content.decode(),
         )
         )
 
 
+    def test_object_image_attachments_view_with_constrained_permission(self):
+        """The image attachments tab of an object lists only the attachments the user may view."""
+        self.add_permissions('dcim.view_site')
+        obj_perm = ObjectPermission(
+            name='View one image attachment',
+            constraints={'name': 'Image Attachment 1'},
+            actions=['view'],
+        )
+        obj_perm.save()
+        obj_perm.users.add(self.user)
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(ImageAttachment))
+        site = Site.objects.get(slug='site-1')
+
+        response = self.client.get(get_action_url(site, 'image-attachments', kwargs={'pk': site.pk}))
+
+        self.assertHttpStatus(response, 200)
+        self.assertEqual(
+            [image.name for image in response.context['image_attachments']],
+            ['Image Attachment 1'],
+        )
+
+    def test_object_image_attachments_view_without_permission(self):
+        """A user who can view the object but no image attachments sees an empty list."""
+        self.add_permissions('dcim.view_site')
+        site = Site.objects.get(slug='site-1')
+
+        response = self.client.get(get_action_url(site, 'image-attachments', kwargs={'pk': site.pk}))
+
+        self.assertHttpStatus(response, 200)
+        self.assertEqual(list(response.context['image_attachments']), [])
+
 
 
 class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = ExportTemplate
     model = ExportTemplate

+ 4 - 2
netbox/extras/views.py

@@ -26,6 +26,7 @@ from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
 from extras.dashboard.utils import get_widget_class
 from extras.dashboard.utils import get_widget_class
 from extras.scripts import prepare_script_form
 from extras.scripts import prepare_script_form
 from extras.utils import SharedObjectViewMixin
 from extras.utils import SharedObjectViewMixin
+from netbox.config import get_config
 from netbox.object_actions import *
 from netbox.object_actions import *
 from netbox.ui import layout
 from netbox.ui import layout
 from netbox.ui.panels import (
 from netbox.ui.panels import (
@@ -649,8 +650,9 @@ class NotificationReadView(LoginRequiredMixin, View):
     def get(self, request, pk):
     def get(self, request, pk):
         # Mark the Notification as read
         # Mark the Notification as read
         notification = get_object_or_404(request.user.notifications, pk=pk)
         notification = get_object_or_404(request.user.notifications, pk=pk)
-        notification.read = timezone.now()
-        notification.save()
+        if not get_config().MAINTENANCE_MODE:
+            notification.read = timezone.now()
+            notification.save()
 
 
         # Redirect to the object if it has a URL (deleted objects will not)
         # Redirect to the object if it has a URL (deleted objects will not)
         if hasattr(notification.object, 'get_absolute_url'):
         if hasattr(notification.object, 'get_absolute_url'):

+ 13 - 0
netbox/ipam/filtersets.py

@@ -382,11 +382,13 @@ class PrefixFilterSet(PrimaryModelFilterSet, ScopedFilterSet, TenancyFilterSet,
     )
     )
     present_in_vrf_id = django_filters.ModelChoiceFilter(
     present_in_vrf_id = django_filters.ModelChoiceFilter(
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
+        null_label=None,
         method='filter_present_in_vrf_id',
         method='filter_present_in_vrf_id',
         label=_('VRF')
         label=_('VRF')
     )
     )
     present_in_vrf = django_filters.ModelChoiceFilter(
     present_in_vrf = django_filters.ModelChoiceFilter(
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
+        null_label=None,
         method='filter_present_in_vrf',
         method='filter_present_in_vrf',
         to_field_name='rd',
         to_field_name='rd',
         label=_('VRF (RD)'),
         label=_('VRF (RD)'),
@@ -646,11 +648,13 @@ class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFi
     )
     )
     present_in_vrf_id = django_filters.ModelChoiceFilter(
     present_in_vrf_id = django_filters.ModelChoiceFilter(
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
+        null_label=None,
         method='filter_present_in_vrf_id',
         method='filter_present_in_vrf_id',
         label=_('VRF')
         label=_('VRF')
     )
     )
     present_in_vrf = django_filters.ModelChoiceFilter(
     present_in_vrf = django_filters.ModelChoiceFilter(
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
+        null_label=None,
         method='filter_present_in_vrf',
         method='filter_present_in_vrf',
         to_field_name='rd',
         to_field_name='rd',
         label=_('VRF (RD)'),
         label=_('VRF (RD)'),
@@ -844,6 +848,7 @@ class FHRPGroupFilterSet(PrimaryModelFilterSet):
     )
     )
     related_ip = django_filters.ModelMultipleChoiceFilter(
     related_ip = django_filters.ModelMultipleChoiceFilter(
         queryset=IPAddress.objects.all(),
         queryset=IPAddress.objects.all(),
+        null_label=None,
         method='filter_related_ip'
         method='filter_related_ip'
     )
     )
 
 
@@ -1033,12 +1038,14 @@ class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
     )
     )
     related_to_site = django_filters.ModelMultipleChoiceFilter(
     related_to_site = django_filters.ModelMultipleChoiceFilter(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
+        null_label=None,
         method='filter_related_to_site',
         method='filter_related_to_site',
         label=_('Related to site (ID)'),
         label=_('Related to site (ID)'),
     )
     )
     # get_additional_lookups() skips filters with a method.
     # get_additional_lookups() skips filters with a method.
     related_to_site__n = django_filters.ModelMultipleChoiceFilter(
     related_to_site__n = django_filters.ModelMultipleChoiceFilter(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
+        null_label=None,
         method='filter_related_to_site_negated',
         method='filter_related_to_site_negated',
         label=_('Related to site (ID)'),
         label=_('Related to site (ID)'),
     )
     )
@@ -1073,18 +1080,22 @@ class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
     )
     )
     available_at_site = django_filters.ModelChoiceFilter(
     available_at_site = django_filters.ModelChoiceFilter(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
+        null_label=None,
         method='get_for_site'
         method='get_for_site'
     )
     )
     available_at_site_group = django_filters.ModelChoiceFilter(
     available_at_site_group = django_filters.ModelChoiceFilter(
         queryset=SiteGroup.objects.all(),
         queryset=SiteGroup.objects.all(),
+        null_label=None,
         method='get_for_site_group'
         method='get_for_site_group'
     )
     )
     available_on_device = django_filters.ModelChoiceFilter(
     available_on_device = django_filters.ModelChoiceFilter(
         queryset=Device.objects.select_related('cluster'),
         queryset=Device.objects.select_related('cluster'),
+        null_label=None,
         method='get_for_device'
         method='get_for_device'
     )
     )
     available_on_virtualmachine = django_filters.ModelChoiceFilter(
     available_on_virtualmachine = django_filters.ModelChoiceFilter(
         queryset=VirtualMachine.objects.all(),
         queryset=VirtualMachine.objects.all(),
+        null_label=None,
         method='get_for_virtualmachine'
         method='get_for_virtualmachine'
     )
     )
     qinq_role = django_filters.MultipleChoiceFilter(
     qinq_role = django_filters.MultipleChoiceFilter(
@@ -1113,11 +1124,13 @@ class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
     )
     )
     interface_id = django_filters.ModelChoiceFilter(
     interface_id = django_filters.ModelChoiceFilter(
         queryset=Interface.objects.all(),
         queryset=Interface.objects.all(),
+        null_label=None,
         method='filter_interface_id',
         method='filter_interface_id',
         label=_('Assigned interface')
         label=_('Assigned interface')
     )
     )
     vminterface_id = django_filters.ModelChoiceFilter(
     vminterface_id = django_filters.ModelChoiceFilter(
         queryset=VMInterface.objects.all(),
         queryset=VMInterface.objects.all(),
+        null_label=None,
         method='filter_vminterface_id',
         method='filter_vminterface_id',
         label=_('Assigned VM interface')
         label=_('Assigned VM interface')
     )
     )

+ 4 - 8
netbox/ipam/forms/bulk_import.py

@@ -241,14 +241,10 @@ class PrefixImportForm(ScopedImportForm, PrimaryModelImportForm):
         query = Q()
         query = Q()
 
 
         if vlan_site:
         if vlan_site:
-            query |= Q(**{
-                f"site__{self.fields['vlan_site'].to_field_name}": vlan_site
-            })
+            query |= Q(**self.get_related_filter('site', 'vlan_site', vlan_site))
 
 
         if vlan_group:
         if vlan_group:
-            query &= Q(**{
-                f"group__{self.fields['vlan_group'].to_field_name}": vlan_group
-            })
+            query &= Q(**self.get_related_filter('group', 'vlan_group', vlan_group))
 
 
         queryset = self.fields['vlan'].queryset.filter(query)
         queryset = self.fields['vlan'].queryset.filter(query)
         self.fields['vlan'].queryset = queryset
         self.fields['vlan'].queryset = queryset
@@ -370,13 +366,13 @@ class IPAddressImportForm(PrimaryModelImportForm):
             # Limit interface queryset by assigned device
             # Limit interface queryset by assigned device
             if data.get('device'):
             if data.get('device'):
                 self.fields['interface'].queryset = Interface.objects.filter(
                 self.fields['interface'].queryset = Interface.objects.filter(
-                    **{f"device__{self.fields['device'].to_field_name}": data['device']}
+                    **self.get_related_filter('device', 'device', data['device'])
                 )
                 )
 
 
             # Limit interface queryset by assigned VM
             # Limit interface queryset by assigned VM
             elif data.get('virtual_machine'):
             elif data.get('virtual_machine'):
                 self.fields['interface'].queryset = VMInterface.objects.filter(
                 self.fields['interface'].queryset = VMInterface.objects.filter(
-                    **{f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data['virtual_machine']}
+                    **self.get_related_filter('virtual_machine', 'virtual_machine', data['virtual_machine'])
                 )
                 )
 
 
     def clean_is_primary(self):
     def clean_is_primary(self):

+ 56 - 0
netbox/ipam/tests/test_filtersets.py

@@ -862,6 +862,10 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         self.assertEqual(self.filterset({'present_in_vrf': vrf1.rd}, self.queryset).qs.count(), 6)
         self.assertEqual(self.filterset({'present_in_vrf': vrf1.rd}, self.queryset).qs.count(), 6)
         self.assertEqual(self.filterset({'present_in_vrf': vrf2.rd}, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset({'present_in_vrf': vrf2.rd}, self.queryset).qs.count(), 2)
 
 
+        # Reject invalid filter values
+        for params in ({'present_in_vrf_id': 'null'}, {'present_in_vrf': 'null'}):
+            self.assertFalse(self.filterset(params, self.queryset).is_valid(), msg=params)
+
     def test_region(self):
     def test_region(self):
         regions = Region.objects.all()[:2]
         regions = Region.objects.all()[:2]
         params = {'region_id': [regions[0].pk, regions[1].pk]}
         params = {'region_id': [regions[0].pk, regions[1].pk]}
@@ -1448,6 +1452,22 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         params = {'vrf': [vrfs[0].rd, vrfs[1].rd]}
         params = {'vrf': [vrfs[0].rd, vrfs[1].rd]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
 
+    def test_present_in_vrf(self):
+        """Match addresses in the VRF or in a VRF exporting one of its import targets."""
+        vrf1 = VRF.objects.get(name='VRF 1')
+        vrf2 = VRF.objects.get(name='VRF 2')
+        route_target = RouteTarget.objects.create(name='65000:200')
+        vrf1.import_targets.add(route_target)
+        vrf2.export_targets.add(route_target)
+        self.assertEqual(self.filterset({'present_in_vrf_id': vrf1.pk}, self.queryset).qs.count(), 4)
+        self.assertEqual(self.filterset({'present_in_vrf_id': vrf2.pk}, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset({'present_in_vrf': vrf1.rd}, self.queryset).qs.count(), 4)
+        self.assertEqual(self.filterset({'present_in_vrf': vrf2.rd}, self.queryset).qs.count(), 2)
+
+        # Reject invalid filter values
+        for params in ({'present_in_vrf_id': 'null'}, {'present_in_vrf': 'null'}):
+            self.assertFalse(self.filterset(params, self.queryset).is_valid(), msg=params)
+
     def test_device(self):
     def test_device(self):
         devices = Device.objects.all()[:2]
         devices = Device.objects.all()[:2]
         params = {'device_id': [devices[0].pk, devices[1].pk]}
         params = {'device_id': [devices[0].pk, devices[1].pk]}
@@ -1620,6 +1640,10 @@ class FHRPGroupTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         params = {'related_ip': [ipaddresses[0].pk, ipaddresses[1].pk]}
         params = {'related_ip': [ipaddresses[0].pk, ipaddresses[1].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+        # Reject invalid filter values
+        for params in ({'related_ip': ['null']}, {'related_ip': [ipaddresses[0].pk, 'null']}):
+            self.assertFalse(self.filterset(params, self.queryset).is_valid(), msg=params)
+
 
 
 class FHRPGroupAssignmentTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
 class FHRPGroupAssignmentTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
     queryset = FHRPGroupAssignment.objects.all()
     queryset = FHRPGroupAssignment.objects.all()
@@ -2296,6 +2320,10 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
             [2001, 2002, 2003, 3001, 3002, 3003]
             [2001, 2002, 2003, 3001, 3002, 3003]
         )
         )
 
 
+        # Reject invalid filter values
+        for params in ({'related_to_site': ['null']}, {'related_to_site': [site.pk, 'null']}):
+            self.assertFalse(self.filterset(params, self.queryset).is_valid(), msg=params)
+
     def test_related_to_site_matches_any_of_several_sites(self):
     def test_related_to_site_matches_any_of_several_sites(self):
         """Several sites spanning two site groups are ORed together, and a shared VLAN comes back once."""
         """Several sites spanning two site groups are ORed together, and a shared VLAN comes back once."""
         sites = (
         sites = (
@@ -2365,6 +2393,10 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         params = {'related_to_site__n': [site.pk]}
         params = {'related_to_site__n': [site.pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), self.queryset.count() - 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), self.queryset.count() - 2)
 
 
+        # Reject invalid filter values
+        for params in ({'related_to_site__n': ['null']}, {'related_to_site__n': [site.pk, 'null']}):
+            self.assertFalse(self.filterset(params, self.queryset).is_valid(), msg=params)
+
     def test_related_to_site_resolves_site_groups_in_constant_queries(self):
     def test_related_to_site_resolves_site_groups_in_constant_queries(self):
         """The query count is constant in the number of sites, and drops to one without groups."""
         """The query count is constant in the number of sites, and drops to one without groups."""
         sites = list(Site.objects.filter(slug__in=('site-1', 'site-2', 'site-3')))
         sites = list(Site.objects.filter(slug__in=('site-1', 'site-2', 'site-3')))
@@ -2459,6 +2491,10 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         params = {'available_on_device': device_id}
         params = {'available_on_device': device_id}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7)  # 5 scoped + 1 global group + 1 global
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7)  # 5 scoped + 1 global group + 1 global
 
 
+        # Reject invalid filter values
+        params = {'available_on_device': 'null'}
+        self.assertFalse(self.filterset(params, self.queryset).is_valid())
+
     def test_available_on_device_cluster_scopes(self):
     def test_available_on_device_cluster_scopes(self):
         device = Device.objects.get(name='Device 1')
         device = Device.objects.get(name='Device 1')
         device.cluster = Cluster.objects.get(name='Cluster 1')
         device.cluster = Cluster.objects.get(name='Cluster 1')
@@ -2487,16 +2523,28 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         params = {'available_on_virtualmachine': vm_id}
         params = {'available_on_virtualmachine': vm_id}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)  # 6 scoped + 1 global group + 1 global
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)  # 6 scoped + 1 global group + 1 global
 
 
+        # Reject invalid filter values
+        params = {'available_on_virtualmachine': 'null'}
+        self.assertFalse(self.filterset(params, self.queryset).is_valid())
+
     def test_available_at_site(self):
     def test_available_at_site(self):
         site_id = Site.objects.first().pk
         site_id = Site.objects.first().pk
         params = {'available_at_site': site_id}
         params = {'available_at_site': site_id}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)  # 4 scoped + 1 global group + 1 global
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)  # 4 scoped + 1 global group + 1 global
 
 
+        # Reject invalid filter values
+        params = {'available_at_site': 'null'}
+        self.assertFalse(self.filterset(params, self.queryset).is_valid())
+
     def test_available_at_site_group(self):
     def test_available_at_site_group(self):
         site_group = SiteGroup.objects.get(name='Site Group 1')
         site_group = SiteGroup.objects.get(name='Site Group 1')
         params = {'available_at_site_group': site_group.pk}
         params = {'available_at_site_group': site_group.pk}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)  # 1 scoped + 1 global group + 1 global
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)  # 1 scoped + 1 global group + 1 global
 
 
+        # Reject invalid filter values
+        params = {'available_at_site_group': 'null'}
+        self.assertFalse(self.filterset(params, self.queryset).is_valid())
+
     def test_interface(self):
     def test_interface(self):
         interface_id = Interface.objects.first().pk
         interface_id = Interface.objects.first().pk
         params = {'interface_id': interface_id}
         params = {'interface_id': interface_id}
@@ -2522,6 +2570,10 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         self.assertEqual(qs.count(), 2)
         self.assertEqual(qs.count(), 2)
         self.assertEqual(len(qs), len(set(qs.values_list('pk', flat=True))))
         self.assertEqual(len(qs), len(set(qs.values_list('pk', flat=True))))
 
 
+        # Reject invalid filter values
+        params = {'interface_id': 'null'}
+        self.assertFalse(self.filterset(params, self.queryset).is_valid())
+
     def test_vminterface(self):
     def test_vminterface(self):
         vminterface_id = VMInterface.objects.first().pk
         vminterface_id = VMInterface.objects.first().pk
         params = {'vminterface_id': vminterface_id}
         params = {'vminterface_id': vminterface_id}
@@ -2546,6 +2598,10 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         self.assertEqual(qs.count(), 2)
         self.assertEqual(qs.count(), 2)
         self.assertEqual(len(qs), len(set(qs.values_list('pk', flat=True))))
         self.assertEqual(len(qs), len(set(qs.values_list('pk', flat=True))))
 
 
+        # Reject invalid filter values
+        params = {'vminterface_id': 'null'}
+        self.assertFalse(self.filterset(params, self.queryset).is_valid())
+
     def test_qinq_role(self):
     def test_qinq_role(self):
         params = {'qinq_role': [VLANQinQRoleChoices.ROLE_SERVICE, VLANQinQRoleChoices.ROLE_CUSTOMER]}
         params = {'qinq_role': [VLANQinQRoleChoices.ROLE_SERVICE, VLANQinQRoleChoices.ROLE_CUSTOMER]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)

+ 49 - 5
netbox/ipam/tests/test_views.py

@@ -3,7 +3,7 @@ from unittest.mock import patch
 
 
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.db.backends.postgresql.psycopg_any import NumericRange
 from django.db.backends.postgresql.psycopg_any import NumericRange
-from django.test import RequestFactory, override_settings
+from django.test import RequestFactory, override_settings, tag
 from django.urls import reverse
 from django.urls import reverse
 from netaddr import IPNetwork
 from netaddr import IPNetwork
 
 
@@ -989,6 +989,50 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         for objectchange in objectchanges:
         for objectchange in objectchanges:
             self.assertEqual(objectchange.message, changelog_message)
             self.assertEqual(objectchange.message, changelog_message)
 
 
+    @tag('regression')  # Issue #23585
+    def test_bulk_add_prefixes_excludes_restricted_vrfs(self):
+        """
+        The prefix bulk-add form only displays and accepts VRFs the user can view.
+        """
+        visible_vrf = VRF.objects.get(name='VRF 1')
+        hidden_vrf = VRF.objects.get(name='VRF 2')
+
+        obj_perm = ObjectPermission(
+            name='Test permission',
+            actions=['view'],
+            constraints={'pk': visible_vrf.pk},
+        )
+        obj_perm.save()
+        obj_perm.users.add(self.user)
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(VRF))
+        self.add_permissions('ipam.add_prefix')
+        url = reverse('ipam:prefix_bulk_add')
+        data = {
+            'pattern': '198.18.[40-41].0/24',
+            'vrf': hidden_vrf.pk,
+            'status': PrefixStatusChoices.STATUS_ACTIVE,
+        }
+
+        # Creation rejects the hidden VRF
+        response = self.client.post(url, data)
+        self.assertHttpStatus(response, 200)
+        self.assertNotContains(response, hidden_vrf.name)
+        self.assertFalse(Prefix.objects.filter(prefix=IPNetwork('198.18.40.0/24')).exists())
+
+        # Neither the form nor its HTMX re-render displays the hidden VRF
+        for headers in ({}, {'HX-Request': 'true'}):
+            with self.subTest(headers=headers):
+                response = self.client.get(url, {'vrf': hidden_vrf.pk}, headers=headers)
+                self.assertHttpStatus(response, 200)
+                self.assertNotContains(response, hidden_vrf.name)
+
+        # A visible VRF is accepted
+        data['vrf'] = visible_vrf.pk
+        response = self.client.post(url, data)
+        self.assertHttpStatus(response, 302)
+        for i in (40, 41):
+            self.assertTrue(Prefix.objects.filter(prefix=IPNetwork(f'198.18.{i}.0/24'), vrf=visible_vrf).exists())
+
     def test_prefix_prefixes(self):
     def test_prefix_prefixes(self):
         self.add_permissions('ipam.view_prefix')
         self.add_permissions('ipam.view_prefix')
         prefixes = (
         prefixes = (
@@ -2460,7 +2504,7 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
         }
 
 
     def test_bulk_add_vlans(self):
     def test_bulk_add_vlans(self):
-        self.add_permissions('ipam.add_vlan')
+        self.add_permissions('ipam.add_vlan', 'ipam.view_vlangroup')
 
 
         group = VLANGroup.objects.get(name='VLAN Group 1')
         group = VLANGroup.objects.get(name='VLAN Group 1')
         initial_count = VLAN.objects.count()
         initial_count = VLAN.objects.count()
@@ -2488,7 +2532,7 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             )
             )
 
 
     def test_bulk_add_vlans_rolls_back_on_duplicate_name(self):
     def test_bulk_add_vlans_rolls_back_on_duplicate_name(self):
-        self.add_permissions('ipam.add_vlan')
+        self.add_permissions('ipam.add_vlan', 'ipam.view_vlangroup')
 
 
         group = VLANGroup.objects.get(name='VLAN Group 1')
         group = VLANGroup.objects.get(name='VLAN Group 1')
         initial_count = VLAN.objects.count()
         initial_count = VLAN.objects.count()
@@ -2507,7 +2551,7 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         self.assertFalse(VLAN.objects.filter(group=group, vid=110).exists())
         self.assertFalse(VLAN.objects.filter(group=group, vid=110).exists())
 
 
     def test_bulk_add_vlans_rolls_back_when_any_id_outside_group_range(self):
     def test_bulk_add_vlans_rolls_back_when_any_id_outside_group_range(self):
-        self.add_permissions('ipam.add_vlan')
+        self.add_permissions('ipam.add_vlan', 'ipam.view_vlangroup')
 
 
         group = VLANGroup.objects.create(
         group = VLANGroup.objects.create(
             name='Restricted VLAN Group',
             name='Restricted VLAN Group',
@@ -2533,7 +2577,7 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 
 
     def test_bulk_add_vlans_pattern_shapes(self):
     def test_bulk_add_vlans_pattern_shapes(self):
         """Single values, multiple values, ranges, and combinations create the expected VLANs."""
         """Single values, multiple values, ranges, and combinations create the expected VLANs."""
-        self.add_permissions('ipam.add_vlan')
+        self.add_permissions('ipam.add_vlan', 'ipam.view_vlangroup')
         # The combination runs against a second group: subTests share one transaction, and VIDs
         # The combination runs against a second group: subTests share one transaction, and VIDs
         # 10 & 20 would otherwise collide with the multiple-values case via the (group, vid) constraint.
         # 10 & 20 would otherwise collide with the multiple-values case via the (group, vid) constraint.
         cases = (
         cases = (

+ 5 - 1
netbox/netbox/api/authentication.py

@@ -110,7 +110,11 @@ class TokenAuthentication(BaseAuthentication):
 
 
             # Load from LDAP if FIND_GROUP_PERMS is active
             # Load from LDAP if FIND_GROUP_PERMS is active
             # Always query LDAP when user is not active, otherwise it is never activated again
             # Always query LDAP when user is not active, otherwise it is never activated again
-            if ldap_backend.settings.FIND_GROUP_PERMS or not token.user.is_active:
+            # Skip this in maintenance mode, as it updates the user in the database
+            if get_config().MAINTENANCE_MODE:
+                logger = logging.getLogger('netbox.auth.login')
+                logger.debug("Maintenance mode enabled: Disabling update of user from LDAP directory")
+            elif ldap_backend.settings.FIND_GROUP_PERMS or not token.user.is_active:
                 ldap_user = ldap_backend.populate_user(token.user.username)
                 ldap_user = ldap_backend.populate_user(token.user.username)
                 # If the user is found in the LDAP directory use it, if not fallback to the local user
                 # If the user is found in the LDAP directory use it, if not fallback to the local user
                 if ldap_user:
                 if ldap_user:

+ 57 - 0
netbox/netbox/api/filters.py

@@ -0,0 +1,57 @@
+from rest_framework import filters
+
+from netbox.api.fields import RelatedObjectCountField
+from utilities.api import get_annotations_for_serializer
+
+__all__ = (
+    'OrderingFilter',
+)
+
+
+class OrderingFilter(filters.OrderingFilter):
+    """
+    Extends DRF's OrderingFilter to order by a RelatedObjectCountField under its field name rather than its source,
+    annotating the count when the response leaves it out, since its annotation is otherwise applied only when the field
+    is included in the response.
+    """
+    @staticmethod
+    def _get_serializer_class(view):
+        if hasattr(view, 'get_serializer_class'):
+            return view.get_serializer_class()
+        return view.serializer_class
+
+    def _get_count_sources(self, view):
+        """Map the name of each RelatedObjectCountField on the view's serializer to its source."""
+        return {
+            field_name: field.source or field_name
+            for field_name, field in self._get_serializer_class(view)._declared_fields.items()
+            if isinstance(field, RelatedObjectCountField)
+        }
+
+    def get_default_valid_fields(self, queryset, view, context=None):
+        field_names = {source: field_name for field_name, source in self._get_count_sources(view).items()}
+        return [
+            (field_names.get(key, key), label)
+            for key, label in super().get_default_valid_fields(queryset, view, context)
+        ]
+
+    def filter_queryset(self, request, queryset, view):
+        ordering = self.get_ordering(request, queryset, view)
+        if not ordering:
+            return queryset
+
+        count_sources = self._get_count_sources(view)
+        terms = []
+        missing = []
+        for term in ordering:
+            if isinstance(term, str) and (field_name := term.lstrip('-')) in count_sources:
+                source = count_sources[field_name]
+                if source not in queryset.query.annotations:
+                    missing.append(field_name)
+                term = f'-{source}' if term.startswith('-') else source
+            terms.append(term)
+        if missing:
+            serializer_class = self._get_serializer_class(view)
+            queryset = queryset.annotate(**get_annotations_for_serializer(serializer_class, fields=missing))
+
+        return queryset.order_by(*terms)

+ 25 - 22
netbox/netbox/api/viewsets/__init__.py

@@ -16,6 +16,7 @@ from rest_framework.viewsets import GenericViewSet
 from netbox.api.fields import RelatedObjectCountField
 from netbox.api.fields import RelatedObjectCountField
 from netbox.api.serializers.features import ChangeLogMessageSerializer
 from netbox.api.serializers.features import ChangeLogMessageSerializer
 from utilities.api import get_annotations_for_serializer, get_fields_for_serializer, get_prefetches_for_serializer
 from utilities.api import get_annotations_for_serializer, get_fields_for_serializer, get_prefetches_for_serializer
+from utilities.error_handlers import get_viewable_protected_objects
 from utilities.exceptions import AbortRequest, PreconditionFailed
 from utilities.exceptions import AbortRequest, PreconditionFailed
 from utilities.query import reapply_model_ordering
 from utilities.query import reapply_model_ordering
 
 
@@ -188,7 +189,7 @@ class BaseViewSet(GenericViewSet):
         """Return the names of the count fields annotated for reads only."""
         """Return the names of the count fields annotated for reads only."""
         return frozenset(
         return frozenset(
             name for name, field in self.get_serializer_class()._declared_fields.items()
             name for name, field in self.get_serializer_class()._declared_fields.items()
-            if type(field) is RelatedObjectCountField and field.fallback_source
+            if isinstance(field, RelatedObjectCountField) and field.fallback_source
         )
         )
 
 
 
 
@@ -266,19 +267,7 @@ class NetBoxModelViewSet(
         try:
         try:
             return super().dispatch(request, *args, **kwargs)
             return super().dispatch(request, *args, **kwargs)
         except (ProtectedError, RestrictedError) as e:
         except (ProtectedError, RestrictedError) as e:
-            if type(e) is ProtectedError:
-                protected_objects = list(e.protected_objects)
-            else:
-                protected_objects = list(e.restricted_objects)
-            msg = f'Unable to delete object. {len(protected_objects)} dependent objects were found: '
-            msg += ', '.join([f'{obj} ({obj.pk})' for obj in protected_objects])
-            logger.warning(msg)
-            return self.finalize_response(
-                request,
-                Response({'detail': msg}, status=409),
-                *args,
-                **kwargs
-            )
+            return self.finalize_response(request, self._get_protected_error_response(e), *args, **kwargs)
         except AbortRequest as e:
         except AbortRequest as e:
             logger.debug(e.message)
             logger.debug(e.message)
             return self.finalize_response(
             return self.finalize_response(
@@ -288,6 +277,27 @@ class NetBoxModelViewSet(
                 **kwargs
                 **kwargs
             )
             )
 
 
+    def _get_protected_error_response(self, exc):
+        """
+        Log all dependent objects of a ProtectedError or RestrictedError, and return a 409 response listing only those
+        which the user is permitted to view.
+        """
+        def get_message(objects, hidden_count=0):
+            dependent_objects = [f'{obj} ({obj.pk})' for obj in objects]
+            if hidden_count:
+                noun = 'object' if hidden_count == 1 else 'objects'
+                dependent_objects.append(f'{hidden_count} {noun} you do not have permission to view')
+            msg = f'Unable to delete object. {len(objects) + hidden_count} dependent objects were found: '
+            return msg + ', '.join(dependent_objects)
+
+        protected_objects = exc.protected_objects if isinstance(exc, ProtectedError) else exc.restricted_objects
+        logger = logging.getLogger(f'netbox.api.views.{self.__class__.__name__}')
+        logger.warning(get_message(protected_objects))
+
+        return Response({
+            'detail': get_message(*get_viewable_protected_objects(exc, self.request.user)),
+        }, status=409)
+
     def exception_to_response(self, exc):
     def exception_to_response(self, exc):
         """
         """
         Translate a NetBox/Django exception that is not a DRF APIException into the same
         Translate a NetBox/Django exception that is not a DRF APIException into the same
@@ -301,14 +311,7 @@ class NetBoxModelViewSet(
         """
         """
         logger = logging.getLogger(f'netbox.api.views.{self.__class__.__name__}')
         logger = logging.getLogger(f'netbox.api.views.{self.__class__.__name__}')
         if isinstance(exc, (ProtectedError, RestrictedError)):
         if isinstance(exc, (ProtectedError, RestrictedError)):
-            if type(exc) is ProtectedError:
-                protected_objects = list(exc.protected_objects)
-            else:
-                protected_objects = list(exc.restricted_objects)
-            msg = f'Unable to delete object. {len(protected_objects)} dependent objects were found: '
-            msg += ', '.join([f'{obj} ({obj.pk})' for obj in protected_objects])
-            logger.warning(msg)
-            return Response({'detail': msg}, status=409)
+            return self._get_protected_error_response(exc)
         if isinstance(exc, AbortRequest):
         if isinstance(exc, AbortRequest):
             logger.debug(exc.message)
             logger.debug(exc.message)
             return Response({'detail': exc.message}, status=400)
             return Response({'detail': exc.message}, status=400)

+ 1 - 4
netbox/netbox/api/viewsets/mixins.py

@@ -805,10 +805,7 @@ class BulkDestroyModelMixin:
                         e.protected_objects if isinstance(e, ProtectedError) else e.restricted_objects
                         e.protected_objects if isinstance(e, ProtectedError) else e.restricted_objects
                     )
                     )
                     # Report only the count, not names or PKs, to keep each per-object error
                     # Report only the count, not names or PKs, to keep each per-object error
-                    # entry small in a batch response. Note: the single-object delete endpoint
-                    # (NetBoxModelViewSet.dispatch()) does include names and PKs of dependent
-                    # objects, so this is not a hard security boundary — just a narrower
-                    # response shape for the bulk case.
+                    # entry small in a batch response.
                     errors.append({
                     errors.append({
                         'id': pk,
                         'id': pk,
                         'errors': {
                         'errors': {

+ 209 - 0
netbox/netbox/graphql/extensions.py

@@ -0,0 +1,209 @@
+import hashlib
+import json
+import logging
+import threading
+import time
+
+from graphql.language import (
+    FloatValueNode,
+    IntValueNode,
+    ListValueNode,
+    StringValueNode,
+    Visitor,
+    print_ast,
+    visit,
+)
+from graphql.utilities import strip_ignored_characters
+from strawberry.extensions import SchemaExtension
+
+from utilities.request import get_client_ip
+
+__all__ = (
+    'QueryLogger',
+    'log_graphql_queries',
+    'normalize_query',
+)
+
+logger = logging.getLogger('netbox.graphql.query')
+
+# Maximum length of the query and variables written to a log record
+MAX_LOGGED_LENGTH = 4096
+
+# Maximum number of normalized queries to cache
+NORMALIZED_QUERY_CACHE_SIZE = 512
+
+# Attribute on the request where QueryLogger leaves records for the view to complete and emit
+REQUEST_ATTR = '_graphql_query_log'
+
+
+class LiteralValueStripper(Visitor):
+    """
+    Replace literal argument values with fixed placeholders, so that queries differing only in the values they pass
+    produce the same document. Variables, enums, booleans and nulls are kept, as are the keys of input objects.
+    """
+    def enter_string_value(self, node, *args):
+        return StringValueNode(value='')
+
+    def enter_int_value(self, node, *args):
+        return IntValueNode(value='0')
+
+    def enter_float_value(self, node, *args):
+        return FloatValueNode(value='0')
+
+    def leave_list_value(self, node, *args):
+        # A list of placeholders collapses to an empty list so that its length doesn't matter. Lists containing any
+        # other values (variables, enums, input objects, etc.) are kept, since those are part of the query shape.
+        if all(isinstance(value, (StringValueNode, IntValueNode, FloatValueNode)) for value in node.values):
+            return ListValueNode(values=())
+        return None
+
+
+def normalize_query(document):
+    """
+    Return the given GraphQL document as a single line, with its literal argument values replaced by placeholders.
+    """
+    return strip_ignored_characters(print_ast(visit(document, LiteralValueStripper())))
+
+
+_normalized_queries = {}
+_normalized_queries_lock = threading.Lock()
+
+
+def _truncate(value):
+    if value is not None and len(value) > MAX_LOGGED_LENGTH:
+        return f'{value[:MAX_LOGGED_LENGTH]}...'
+    return value
+
+
+def _normalize_query_text(query, document):
+    """
+    Return the normalized form of the given query (and its parsed document), truncated for logging, along with the
+    hash of the full normalized query. Clients typically send the same query text repeatedly (passing values as
+    variables), so the result is cached. The cache is keyed on a digest of the query text, so that large queries don't
+    occupy memory.
+    """
+    key = hashlib.sha256(query.encode()).digest()
+    if (cached := _normalized_queries.get(key)) is not None:
+        return cached
+    normalized = normalize_query(document)
+    cached = (_truncate(normalized), hashlib.sha256(normalized.encode()).hexdigest())
+    with _normalized_queries_lock:
+        if len(_normalized_queries) >= NORMALIZED_QUERY_CACHE_SIZE:
+            # Evict the oldest entry
+            _normalized_queries.pop(next(iter(_normalized_queries)), None)
+        _normalized_queries[key] = cached
+    return cached
+
+
+def _emit(record):
+    logger.info(
+        "user=%s token_id=%s client_ip=%s operation_name=%s query_hash=%s duration_ms=%s error_count=%s "
+        "response_size=%s query=%s variables=%s",
+        json.dumps(record['user']),  # Encode to prevent log forging
+        record['token_id'],
+        record['client_ip'],
+        json.dumps(record['operation_name']),  # Client-supplied; encode to prevent log forging
+        record['query_hash'],
+        record['duration_ms'],
+        record['error_count'],
+        record['response_size'],
+        record['query'],
+        _truncate(json.dumps(record['variables'], default=str)),
+        extra={'graphql': record},
+    )
+
+
+def log_graphql_queries(request, response):
+    """
+    Complete and emit any records left on the request by QueryLogger, adding the response size and the total time
+    taken (including serialization of the response).
+    """
+    records = getattr(request, REQUEST_ATTR, None)
+    if not records:
+        return
+    try:
+        response_size = None if getattr(response, 'streaming', False) else len(response.content)
+        end = time.perf_counter()
+        for record, start in records:
+            record['duration_ms'] = round((end - start) * 1000, 1)
+            record['response_size'] = response_size
+            _emit(record)
+    except Exception:
+        # Never let a logging failure affect the response
+        logger.exception("Failed to log GraphQL operation")
+
+
+class QueryLogger(SchemaExtension):
+    """
+    Log each GraphQL operation to the `netbox.graphql.query` logger. Does nothing unless that logger is enabled for
+    INFO messages.
+
+    The record is left on the request, to be emitted by the view once the response has been rendered (see
+    log_graphql_queries()). Operations executed without a request are logged immediately, without a response size.
+    """
+    def on_operation(self):
+        if not logger.isEnabledFor(logging.INFO):
+            yield
+            return
+
+        start = time.perf_counter()
+        try:
+            yield
+        except Exception:
+            # The operation could not be executed (e.g. the requested operation name doesn't exist in the document)
+            self._record(start, failed=True)
+            raise
+        self._record(start)
+
+    def _record(self, start, failed=False):
+        try:
+            record = self._get_record(failed)
+            request = getattr(self.execution_context.context, 'request', None)
+            if request is None:
+                record['duration_ms'] = round((time.perf_counter() - start) * 1000, 1)
+                _emit(record)
+            else:
+                if not hasattr(request, REQUEST_ATTR):
+                    setattr(request, REQUEST_ATTR, [])
+                getattr(request, REQUEST_ATTR).append((record, start))
+        except Exception:
+            # Never let a logging failure affect the operation (or replace the exception it raised)
+            logger.exception("Failed to log GraphQL operation")
+
+    def _get_record(self, failed):
+        """
+        Return the details of the operation to be logged.
+        """
+        execution_context = self.execution_context
+        request = getattr(execution_context.context, 'request', None)
+
+        # Identify the user, token, and client
+        user = getattr(request, 'user', None)
+        token = getattr(request, 'auth', None)
+        try:
+            client_ip = get_client_ip(request) if request is not None else None
+        except ValueError:
+            client_ip = None
+
+        # Normalize the query (if it could be parsed)
+        if execution_context.graphql_document is not None and execution_context.query:
+            query, query_hash = _normalize_query_text(execution_context.query, execution_context.graphql_document)
+        else:
+            query = query_hash = None
+
+        result = execution_context.result
+        errors = (result.errors if result else None) or execution_context.pre_execution_errors or []
+        error_count = len(errors) or int(failed)
+
+        return {
+            'user': user.username if user is not None and user.is_authenticated else None,
+            'token_id': getattr(token, 'pk', None),
+            'client_ip': str(client_ip) if client_ip else None,
+            'operation_name': execution_context.operation_name,
+            'query_hash': query_hash,
+            'query': query,
+            'variables': execution_context.variables,
+            'duration_ms': None,
+            'error_count': error_count,
+            'response_size': None,
+        }

+ 2 - 0
netbox/netbox/graphql/schema.py

@@ -19,6 +19,7 @@ from virtualization.graphql.schema import VirtualizationQuery
 from vpn.graphql.schema import VPNQuery
 from vpn.graphql.schema import VPNQuery
 from wireless.graphql.schema import WirelessQuery
 from wireless.graphql.schema import WirelessQuery
 
 
+from .extensions import QueryLogger
 from .scalars import BigInt, BigIntScalar
 from .scalars import BigInt, BigIntScalar
 from .utils import validate_extension_targets
 from .utils import validate_extension_targets
 
 
@@ -51,6 +52,7 @@ def get_schema_extensions() -> list[SchemaExtensionFactory]:
     max_depth = settings.GRAPHQL_MAX_QUERY_DEPTH
     max_depth = settings.GRAPHQL_MAX_QUERY_DEPTH
 
 
     extensions: list[SchemaExtensionFactory] = [
     extensions: list[SchemaExtensionFactory] = [
+        QueryLogger,
         lambda: DjangoOptimizerExtension(prefetch_custom_queryset=True),
         lambda: DjangoOptimizerExtension(prefetch_custom_queryset=True),
         lambda: MaxAliasesLimiter(max_alias_count=max_aliases),
         lambda: MaxAliasesLimiter(max_alias_count=max_aliases),
     ]
     ]

+ 9 - 4
netbox/netbox/graphql/types.py

@@ -3,7 +3,7 @@ from copy import copy
 import strawberry
 import strawberry
 import strawberry_django
 import strawberry_django
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
-from django.db.models import BinaryField, ExpressionWrapper, F, Func, IntegerField, Value
+from django.db.models import FETCH_PEERS, BinaryField, ExpressionWrapper, F, Func, IntegerField, Value
 from strawberry.types import Info
 from strawberry.types import Info
 from strawberry_django.optimizer import OptimizerStore
 from strawberry_django.optimizer import OptimizerStore
 
 
@@ -41,7 +41,7 @@ def _model_type(model, **kwargs):
     """
     """
     Wrap `strawberry_django.type()` to give each type's `display` field an optimizer hint. `display` resolves via
     Wrap `strawberry_django.type()` to give each type's `display` field an optimizer hint. `display` resolves via
     `__str__()`, which may read any of the model's columns; without a hint the optimizer defers them, and reading a
     `__str__()`, which may read any of the model's columns; without a hint the optimizer defers them, and reading a
-    deferred column reloads the row with one query per object.
+    deferred column costs an extra query.
     """
     """
     def wrapper(cls):
     def wrapper(cls):
         graphql_type = strawberry_django.type(model, **kwargs)(cls)
         graphql_type = strawberry_django.type(model, **kwargs)(cls)
@@ -66,14 +66,19 @@ def _model_type(model, **kwargs):
 @strawberry.type
 @strawberry.type
 class BaseObjectType:
 class BaseObjectType:
     """
     """
-    Base GraphQL object type for all NetBox objects. Restricts the model queryset to enforce object permissions.
+    Base GraphQL object type for all NetBox objects. Enforces object permissions and batches deferred field and
+    relation loads.
     """
     """
 
 
     @classmethod
     @classmethod
     def get_queryset(cls, queryset, info: Info, **kwargs):
     def get_queryset(cls, queryset, info: Info, **kwargs):
         # Enforce object permissions on the queryset
         # Enforce object permissions on the queryset
         if hasattr(queryset, 'restrict'):
         if hasattr(queryset, 'restrict'):
-            return queryset.restrict(info.context.request.user, 'view')
+            queryset = queryset.restrict(info.context.request.user, 'view')
+        # Batch the loading of deferred fields and relations which the optimizer can't anticipate. Skip a queryset
+        # already evaluated by a prefetch_related hint (e.g. tags): cloning it would discard the prefetched results.
+        if queryset._result_cache is None:
+            queryset = queryset.fetch_mode(FETCH_PEERS)
         return queryset
         return queryset
 
 
     @strawberry_django.field
     @strawberry_django.field

+ 6 - 1
netbox/netbox/graphql/views.py

@@ -8,6 +8,7 @@ from strawberry.django.views import GraphQLView
 
 
 from netbox.api.authentication import TokenAuthentication
 from netbox.api.authentication import TokenAuthentication
 from netbox.config import get_config
 from netbox.config import get_config
+from netbox.graphql.extensions import log_graphql_queries
 
 
 
 
 class NetBoxGraphQLView(GraphQLView):
 class NetBoxGraphQLView(GraphQLView):
@@ -30,6 +31,7 @@ class NetBoxGraphQLView(GraphQLView):
                 auth_info = authenticator.authenticate(request)
                 auth_info = authenticator.authenticate(request)
                 if auth_info is not None:
                 if auth_info is not None:
                     request.user = auth_info[0]  # User object
                     request.user = auth_info[0]  # User object
+                    request.auth = auth_info[1]  # Token object
             except AuthenticationFailed as exc:
             except AuthenticationFailed as exc:
                 return HttpResponseForbidden(exc.detail)
                 return HttpResponseForbidden(exc.detail)
 
 
@@ -39,4 +41,7 @@ class NetBoxGraphQLView(GraphQLView):
                 return redirect_to_login(reverse('graphql'))
                 return redirect_to_login(reverse('graphql'))
             return HttpResponseForbidden("No credentials provided.")
             return HttpResponseForbidden("No credentials provided.")
 
 
-        return super().dispatch(request, *args, **kwargs)
+        response = super().dispatch(request, *args, **kwargs)
+        log_graphql_queries(request, response)
+
+        return response

+ 72 - 62
netbox/netbox/jobs.py

@@ -3,6 +3,7 @@ import logging
 import os
 import os
 import traceback
 import traceback
 from abc import ABC, abstractmethod
 from abc import ABC, abstractmethod
+from contextlib import ExitStack
 from datetime import timedelta
 from datetime import timedelta
 from io import BytesIO
 from io import BytesIO
 from pathlib import Path
 from pathlib import Path
@@ -116,6 +117,8 @@ class JobRunner(ABC):
         """
         """
         logger = logging.getLogger('netbox.jobs')
         logger = logging.getLogger('netbox.jobs')
         skipped = False
         skipped = False
+        status = None
+        error = None
 
 
         try:
         try:
             if not job.start():
             if not job.start():
@@ -123,13 +126,15 @@ class JobRunner(ABC):
                 logger.warning(f"Skipping job {job} (PK: {job.pk}): it could not be claimed for execution")
                 logger.warning(f"Skipping job {job} (PK: {job.pk}): it could not be claimed for execution")
                 return
                 return
             cls(job).run(*args, **kwargs)
             cls(job).run(*args, **kwargs)
-            job.terminate()
+            status = JobStatusChoices.STATUS_COMPLETED
 
 
         except JobFailed:
         except JobFailed:
+            status = JobStatusChoices.STATUS_FAILED
             logger.warning(f"Job {job} failed")
             logger.warning(f"Job {job} failed")
-            job.terminate(status=JobStatusChoices.STATUS_FAILED)
 
 
         except Exception as e:
         except Exception as e:
+            status = JobStatusChoices.STATUS_ERRORED
+            error = repr(e)
             tb_str = traceback.format_exc().replace(_INSTALL_ROOT, '')
             tb_str = traceback.format_exc().replace(_INSTALL_ROOT, '')
             tb_record = logging.makeLogRecord({
             tb_record = logging.makeLogRecord({
                 'levelno': logging.ERROR,
                 'levelno': logging.ERROR,
@@ -137,71 +142,76 @@ class JobRunner(ABC):
                 'msg': tb_str,
                 'msg': tb_str,
             })
             })
             job.log(tb_record)
             job.log(tb_record)
-            job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=repr(e))
             if type(e) is JobTimeoutException:
             if type(e) is JobTimeoutException:
                 logger.error(e)
                 logger.error(e)
 
 
-        # If the executed job is a periodic job, schedule its next execution at the specified interval.
+        # Keep the current job running until the rescheduling attempt finishes (see #23535)
         finally:
         finally:
-            if job.interval and job.started and not skipped:
-                # Determine the new scheduled time. Cannot be earlier than one minute in the future.
-                new_scheduled_time = max(
-                    (job.scheduled or job.started) + timedelta(minutes=job.interval),
-                    timezone.now() + timedelta(minutes=1)
-                )
-                if job.object and getattr(job.object, "python_class", None):
-                    kwargs["job_timeout"] = job.object.python_class.job_timeout
-
-                enqueue_kwargs = dict(
-                    instance=job.object,
-                    name=job.name,
-                    user=job.user,
-                    schedule_at=new_scheduled_time,
+            terminated = False
+            try:
+                with ExitStack() as stack:
+                    try:
+                        if job.interval and job.started and not skipped:
+                            if cls in registry['system_jobs']:
+                                # Held through the terminal save, so overlapping runs cannot both skip rescheduling
+                                stack.enter_context(advisory_lock(ADVISORY_LOCK_KEYS['job-schedules']))
+                            cls._reschedule(job, **kwargs)
+                    finally:
+                        if status is not None:
+                            job.terminate(status=status, error=error, dispatch=False)
+                            terminated = True
+            finally:
+                # Outside the schedule lock, so slow job_end receivers cannot delay enqueue_once() callers
+                if terminated:
+                    job._dispatch_termination()
+
+    @classmethod
+    def _reschedule(cls, job, **kwargs):
+        """
+        Schedule the next occurrence of a periodic job. For a system job, the caller must hold the schedule lock.
+        """
+        logger = logging.getLogger('netbox.jobs')
+
+        # Determine the new scheduled time. Cannot be earlier than one minute in the future.
+        new_scheduled_time = max(
+            (job.scheduled or job.started) + timedelta(minutes=job.interval),
+            timezone.now() + timedelta(minutes=1)
+        )
+        if job.object and getattr(job.object, 'python_class', None):
+            kwargs['job_timeout'] = job.object.python_class.job_timeout
+
+        try:
+            # Only system jobs are deduplicated, as scheduled scripts may run parallel schedules (see #22232)
+            if cls in registry['system_jobs']:
+                successor_exists = Job.objects.filter(
+                    name=cls.name,
+                    object_id__isnull=True,
+                    status__in=JobStatusChoices.ENQUEUED_STATE_CHOICES,
                     interval=job.interval,
                     interval=job.interval,
-                    notifications=job.notifications,
-                    **kwargs,
-                )
-
-                # Reschedule the next occurrence. If the object's configuration has become invalid since this run was
-                # scheduled (e.g. a script's Meta.job_timeout was edited to an invalid value, see #22872), the enqueue
-                # will raise a ValidationError. Record it on this job and decline to reschedule rather than allowing an
-                # unhandled exception to escape the worker's finally block.
-                try:
-                    if cls in registry['system_jobs']:
-                        # System jobs are also scheduled by `enqueue_once()` at worker startup,
-                        # which races with this finally block and can produce duplicate schedules
-                        # (see #22232). Acquire the same advisory lock used by `enqueue_once()`
-                        # and skip rescheduling if a successor is already enqueued.
-                        #
-                        # This branch is limited to system jobs because generic recurring jobs
-                        # (e.g. scheduled scripts) may have multiple legitimate schedules sharing
-                        # the same runner/object/interval but differing in their runtime kwargs.
-                        with advisory_lock(ADVISORY_LOCK_KEYS['job-schedules']):
-                            successor_exists = Job.objects.filter(
-                                name=cls.name,
-                                object_id__isnull=True,
-                                status__in=JobStatusChoices.ENQUEUED_STATE_CHOICES,
-                                interval=job.interval,
-                            ).exclude(pk=job.pk).exists()
-                            if not successor_exists:
-                                cls.enqueue(**enqueue_kwargs)
-                    else:
-                        cls.enqueue(**enqueue_kwargs)
-                except ValidationError as e:
-                    # The successor could not be scheduled because the object's configuration is now invalid. Record
-                    # this against the (already-terminated) job without overwriting the outcome of the run that just
-                    # completed — re-running terminate() here would clobber a successful run's status and fire a
-                    # duplicate notification (see #22872).
-                    error = _("Recurring job not rescheduled due to invalid configuration: {error}").format(
-                        error='; '.join(e.messages)
-                    )
-                    logger.error(f"Job {job}: {error}")
-                    job.log(logging.makeLogRecord({
-                        'levelno': logging.ERROR,
-                        'levelname': 'ERROR',
-                        'msg': error,
-                    }))
-                    job.save()
+                ).exclude(pk=job.pk).exists()
+                if successor_exists:
+                    return
+            cls.enqueue(
+                instance=job.object,
+                name=job.name,
+                user=job.user,
+                schedule_at=new_scheduled_time,
+                interval=job.interval,
+                notifications=job.notifications,
+                **kwargs,
+            )
+        except ValidationError as e:
+            # Keep the run's outcome when invalid configuration blocks the reschedule (see #22872)
+            message = _("Recurring job not rescheduled due to invalid configuration: {error}").format(
+                error='; '.join(e.messages)
+            )
+            logger.error(f"Job {job}: {message}")
+            job.log(logging.makeLogRecord({
+                'levelno': logging.ERROR,
+                'levelname': 'ERROR',
+                'msg': message,
+            }))
+            job.save()
 
 
     @classmethod
     @classmethod
     def get_jobs(cls, instance=None):
     def get_jobs(cls, instance=None):

+ 31 - 7
netbox/netbox/middleware.py

@@ -6,10 +6,11 @@ from django.contrib import auth, messages
 from django.contrib.auth.middleware import RemoteUserMiddleware as RemoteUserMiddleware_
 from django.contrib.auth.middleware import RemoteUserMiddleware as RemoteUserMiddleware_
 from django.core.exceptions import ImproperlyConfigured
 from django.core.exceptions import ImproperlyConfigured
 from django.core.signals import got_request_exception
 from django.core.signals import got_request_exception
-from django.db import ProgrammingError, connection
+from django.db import Error, ProgrammingError, connection
 from django.db.utils import InternalError
 from django.db.utils import InternalError
-from django.http import Http404, HttpResponseRedirect
+from django.http import Http404, HttpResponse, HttpResponseRedirect
 from django.middleware.common import CommonMiddleware as DjangoCommonMiddleware
 from django.middleware.common import CommonMiddleware as DjangoCommonMiddleware
+from django.template import loader
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 from django_prometheus import middleware
 from django_prometheus import middleware
 from social_django.middleware import SocialAuthExceptionMiddleware as SocialAuthExceptionMiddleware_
 from social_django.middleware import SocialAuthExceptionMiddleware as SocialAuthExceptionMiddleware_
@@ -40,6 +41,8 @@ class CommonMiddleware(DjangoCommonMiddleware):
     body (clients downgrade to GET on a 302) or raise a RuntimeError when
     body (clients downgrade to GET on a 302) or raise a RuntimeError when
     DEBUG is enabled. Letting the original 404 propagate gives the caller a
     DEBUG is enabled. Letting the original 404 propagate gives the caller a
     clear, actionable error instead.
     clear, actionable error instead.
+
+    Also reports 404 responses to Sentry after APPEND_SLASH handling.
     """
     """
     UNSAFE_METHODS = frozenset(('DELETE', 'PATCH', 'POST', 'PUT'))
     UNSAFE_METHODS = frozenset(('DELETE', 'PATCH', 'POST', 'PUT'))
 
 
@@ -48,6 +51,15 @@ class CommonMiddleware(DjangoCommonMiddleware):
             return False
             return False
         return super().should_redirect_with_slash(request)
         return super().should_redirect_with_slash(request)
 
 
+    def process_response(self, request, response):
+        response = super().process_response(request, response)
+
+        if settings.SENTRY_ENABLED and response.status_code == 404 and getattr(request, '_sentry_report_404', False):
+            from sentry_sdk import capture_message
+            capture_message("Page not found", level="error")
+
+        return response
+
 
 
 class CoreMiddleware:
 class CoreMiddleware:
 
 
@@ -256,12 +268,20 @@ class MaintenanceModeMiddleware:
         self.get_response = get_response
         self.get_response = get_response
 
 
     def __call__(self, request):
     def __call__(self, request):
-        if get_config().MAINTENANCE_MODE:
-            self._set_session_type(
-                allow_write=request.path_info.startswith(settings.MAINTENANCE_EXEMPT_PATHS)
-            )
+        if not get_config().MAINTENANCE_MODE or request.path_info.startswith(settings.MAINTENANCE_EXEMPT_PATHS):
+            return self.get_response(request)
 
 
-        return self.get_response(request)
+        # The read-only setting persists on the connection, so restore read/write access once the request has been
+        # handled. This also lets outer middleware (e.g. saving the session) write to the database.
+        self._set_session_type(allow_write=False)
+        try:
+            return self.get_response(request)
+        finally:
+            try:
+                self._set_session_type(allow_write=True)
+            except Error:
+                # Never leave a read-only connection to be reused by a later request
+                connection.close()
 
 
     @staticmethod
     @staticmethod
     def _set_session_type(allow_write):
     def _set_session_type(allow_write):
@@ -286,6 +306,10 @@ class MaintenanceModeMiddleware:
             if is_api_request(request) or is_graphql_request(request):
             if is_api_request(request) or is_graphql_request(request):
                 return handle_rest_api_exception(request, error=error_message)
                 return handle_rest_api_exception(request, error=error_message)
 
 
+            # Redirecting a read-only request (e.g. GET) back to the same URL would fail the same way again
+            if request.method in ('GET', 'HEAD', 'OPTIONS'):
+                return HttpResponse(loader.render_to_string('exceptions/maintenance_mode.html'), status=503)
+
             messages.error(request, error_message)
             messages.error(request, error_message)
             return HttpResponseRedirect(request.path_info)
             return HttpResponseRedirect(request.path_info)
         return None
         return None

+ 23 - 0
netbox/netbox/models/features.py

@@ -1,4 +1,5 @@
 import json
 import json
+import logging
 from collections import defaultdict
 from collections import defaultdict
 from functools import cached_property
 from functools import cached_property
 
 
@@ -50,6 +51,8 @@ __all__ = (
     'register_models',
     'register_models',
 )
 )
 
 
+logger = logging.getLogger('netbox.models.features')
+
 
 
 #
 #
 # Feature mixins
 # Feature mixins
@@ -102,6 +105,21 @@ class ChangeLoggingMixin(DeleteMixin, models.Model):
         self._prechange_snapshot = self.serialize_object(exclude=exclude_fields)
         self._prechange_snapshot = self.serialize_object(exclude=exclude_fields)
     snapshot.alters_data = True
     snapshot.alters_data = True
 
 
+    def snapshot_from_db(self, using=None):
+        """
+        Snapshot the stored copy of an object being saved without a snapshot. The snapshot is discarded once the
+        save has been logged.
+        """
+        model = self.__class__
+        db = using or router.db_for_write(model, instance=self)
+        if (original := model._base_manager.using(db).filter(pk=self.pk).first()) is None:
+            return
+        logger.warning(f"No pre-change snapshot taken for {model._meta.label} {self.pk}; reading it from the database")
+        original.snapshot()
+        self._prechange_snapshot = original._prechange_snapshot
+        self._prechange_snapshot_from_db = True
+    snapshot_from_db.alters_data = True
+
     def to_objectchange(self, action):
     def to_objectchange(self, action):
         """
         """
         Return a new ObjectChange representing a change made to this object. This will typically be called automatically
         Return a new ObjectChange representing a change made to this object. This will typically be called automatically
@@ -702,10 +720,15 @@ class SyncedDataMixin(models.Model):
 
 
         :param save: If true, save() will be called after data has been synchronized
         :param save: If true, save() will be called after data has been synchronized
         """
         """
+        # ManagedFile is not change-logged and so has no snapshot(); keep any snapshot already taken
+        if save and hasattr(self, 'snapshot') and not hasattr(self, '_prechange_snapshot'):
+            self.snapshot()
         self.sync_data()
         self.sync_data()
         self.data_synced = timezone.now()
         self.data_synced = timezone.now()
         if save:
         if save:
             self.save()
             self.save()
+            # Discard the snapshot so that a later save of this object takes a fresh one
+            self.__dict__.pop('_prechange_snapshot', None)
     sync.alters_data = True
     sync.alters_data = True
 
 
     def sync_data(self):
     def sync_data(self):

+ 3 - 31
netbox/netbox/search/backends.py

@@ -5,8 +5,7 @@ import netaddr
 from django.conf import settings
 from django.conf import settings
 from django.core.exceptions import ImproperlyConfigured
 from django.core.exceptions import ImproperlyConfigured
 from django.db import DatabaseError, ProgrammingError, transaction
 from django.db import DatabaseError, ProgrammingError, transaction
-from django.db.models import F, Q, Window, prefetch_related_objects
-from django.db.models.fields.related import ForeignKey
+from django.db.models import FETCH_PEERS, F, Q, Window
 from django.db.models.functions import window
 from django.db.models.functions import window
 from django.utils.module_loading import import_string
 from django.utils.module_loading import import_string
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
@@ -15,7 +14,6 @@ from netaddr.core import AddrFormatError
 from core.models import ObjectType
 from core.models import ObjectType
 from extras.models import CachedValue, CustomField
 from extras.models import CachedValue, CustomField
 from netbox.registry import registry
 from netbox.registry import registry
-from utilities.object_types import object_type_identifier
 from utilities.querysets import RestrictedPrefetch
 from utilities.querysets import RestrictedPrefetch
 from utilities.string import title
 from utilities.string import title
 
 
@@ -203,12 +201,6 @@ class CachedValueSearchBackend(SearchBackend):
             )
             )
         )[:MAX_RESULTS]
         )[:MAX_RESULTS]
 
 
-        # Gather all ObjectTypes present in the search results (used for prefetching related
-        # objects). This must be done before generating the final results list, which returns
-        # a RawQuerySet.
-        object_type_ids = set(queryset.values_list('object_type', flat=True))
-        object_types = ObjectType.objects.filter(pk__in=object_type_ids)
-
         # Construct a Prefetch to pre-fetch only those related objects for which the
         # Construct a Prefetch to pre-fetch only those related objects for which the
         # user has permission to view.
         # user has permission to view.
         if user:
         if user:
@@ -218,33 +210,13 @@ class CachedValueSearchBackend(SearchBackend):
 
 
         # Wrap the base query to return only the lowest-weight result for each object
         # Wrap the base query to return only the lowest-weight result for each object
         # Hat-tip to https://blog.oyam.dev/django-filter-by-window-function/ for the solution
         # Hat-tip to https://blog.oyam.dev/django-filter-by-window-function/ for the solution
+        # FETCH_PEERS passes to the prefetched objects, batching the relations read when rendering results
         sql, params = queryset.query.sql_with_params()
         sql, params = queryset.query.sql_with_params()
-        results = CachedValue.objects.prefetch_related(*prefetch).raw(
+        results = CachedValue.objects.fetch_mode(FETCH_PEERS).prefetch_related(*prefetch).raw(
             f"SELECT * FROM ({sql}) t WHERE row_number = 1",
             f"SELECT * FROM ({sql}) t WHERE row_number = 1",
             params
             params
         )
         )
 
 
-        # Iterate through each ObjectType represented in the search results and prefetch any
-        # related objects necessary to render the prescribed display attributes (display_attrs).
-        for object_type in object_types:
-            model = object_type.model_class()
-            indexer = registry['search'].get(object_type_identifier(object_type))
-            if not (display_attrs := getattr(indexer, 'display_attrs', None)):
-                continue
-
-            # Add ForeignKey fields to prefetch list
-            prefetch_fields = []
-            for attr in display_attrs:
-                field = model._meta.get_field(attr)
-                if type(field) is ForeignKey:
-                    prefetch_fields.append(f'object__{attr}')
-
-            # Compile a list of all CachedValues referencing this object type, and prefetch
-            # any related objects
-            if prefetch_fields:
-                objects = [r for r in results if r.object_type_id == object_type.pk]
-                prefetch_related_objects(objects, *prefetch_fields)
-
         # Omit any results pertaining to an object the user does not have permission to view
         # Omit any results pertaining to an object the user does not have permission to view
         ret = []
         ret = []
         for r in results:
         for r in results:

+ 13 - 4
netbox/netbox/settings.py

@@ -671,10 +671,11 @@ EXEMPT_EXCLUDE_MODELS = (
 
 
 # All URLs starting with a string listed here are exempt from maintenance mode enforcement
 # All URLs starting with a string listed here are exempt from maintenance mode enforcement
 MAINTENANCE_EXEMPT_PATHS = (
 MAINTENANCE_EXEMPT_PATHS = (
-    f'/{BASE_PATH}extras/config-revisions/',  # Allow modifying the configuration
+    f'/{BASE_PATH}core/config-revisions/',  # Allow modifying the configuration
     LOGIN_URL,
     LOGIN_URL,
-    LOGIN_REDIRECT_URL,
-    LOGOUT_REDIRECT_URL
+    f'/{BASE_PATH}logout/',
+    f'/{BASE_PATH}oauth/',  # Single sign-on
+    f'/{BASE_PATH}api/users/tokens/provision/',  # Allow provisioning API tokens
 )
 )
 
 
 
 
@@ -737,6 +738,7 @@ NETBOX_COPILOT_URL = 'https://static.copilot.netboxlabs.ai/load.js'
 SOCIAL_AUTH_PIPELINE = (
 SOCIAL_AUTH_PIPELINE = (
     'social_core.pipeline.social_auth.social_details',
     'social_core.pipeline.social_auth.social_details',
     'social_core.pipeline.social_auth.social_uid',
     'social_core.pipeline.social_auth.social_uid',
+    'social_core.pipeline.social_auth.auth_allowed',
     'social_core.pipeline.social_auth.social_user',
     'social_core.pipeline.social_auth.social_user',
     'social_core.pipeline.user.get_username',
     'social_core.pipeline.user.get_username',
     'social_core.pipeline.user.create_user',
     'social_core.pipeline.user.create_user',
@@ -758,6 +760,13 @@ for param in dir(configuration):
     if param.startswith('SOCIAL_AUTH_'):
     if param.startswith('SOCIAL_AUTH_'):
         globals()[param] = getattr(configuration, param)
         globals()[param] = getattr(configuration, param)
 
 
+# Warn if a custom pipeline omits the step which enforces allowlists
+if 'social_core.pipeline.social_auth.auth_allowed' not in SOCIAL_AUTH_PIPELINE:
+    warnings.warn(
+        "SOCIAL_AUTH_PIPELINE does not include social_core.pipeline.social_auth.auth_allowed; allowlist settings "
+        "such as WHITELISTED_EMAILS and WHITELISTED_DOMAINS will not be enforced."
+    )
+
 # Force usage of PostgreSQL's JSONB field for extra data
 # Force usage of PostgreSQL's JSONB field for extra data
 SOCIAL_AUTH_JSONFIELD_ENABLED = True
 SOCIAL_AUTH_JSONFIELD_ENABLED = True
 SOCIAL_AUTH_CLEAN_USERNAME_FUNCTION = 'users.utils.clean_username'
 SOCIAL_AUTH_CLEAN_USERNAME_FUNCTION = 'users.utils.clean_username'
@@ -793,7 +802,7 @@ REST_FRAMEWORK = {
     ),
     ),
     'DEFAULT_FILTER_BACKENDS': (
     'DEFAULT_FILTER_BACKENDS': (
         'django_filters.rest_framework.DjangoFilterBackend',
         'django_filters.rest_framework.DjangoFilterBackend',
-        'rest_framework.filters.OrderingFilter',
+        'netbox.api.filters.OrderingFilter',
     ),
     ),
     'DEFAULT_METADATA_CLASS': 'netbox.api.metadata.BulkOperationMetadata',
     'DEFAULT_METADATA_CLASS': 'netbox.api.metadata.BulkOperationMetadata',
     'DEFAULT_PAGINATION_CLASS': 'netbox.api.pagination.NetBoxPagination',
     'DEFAULT_PAGINATION_CLASS': 'netbox.api.pagination.NetBoxPagination',

+ 51 - 2
netbox/netbox/tests/test_api.py

@@ -6,6 +6,7 @@ from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import NON_FIELD_ERRORS
 from django.core.exceptions import NON_FIELD_ERRORS
 from django.db import DEFAULT_DB_ALIAS, connection, connections
 from django.db import DEFAULT_DB_ALIAS, connection, connections
 from django.db.backends.postgresql.psycopg_any import NumericRange
 from django.db.backends.postgresql.psycopg_any import NumericRange
+from django.db.models import ProtectedError
 from django.test import RequestFactory, TestCase, TransactionTestCase, override_settings, tag
 from django.test import RequestFactory, TestCase, TransactionTestCase, override_settings, tag
 from django.test.utils import CaptureQueriesContext
 from django.test.utils import CaptureQueriesContext
 from django.urls import reverse
 from django.urls import reverse
@@ -17,7 +18,7 @@ from rest_framework.test import APIClient
 
 
 from core.models import DataFile, DataSource, ObjectType
 from core.models import DataFile, DataSource, ObjectType
 from dcim.api.serializers import InterfaceSerializer, RackSerializer
 from dcim.api.serializers import InterfaceSerializer, RackSerializer
-from dcim.api.views import SiteViewSet
+from dcim.api.views import DeviceTypeViewSet, SiteViewSet
 from dcim.models import Device, Rack, Site
 from dcim.models import Device, Rack, Site
 from extras.models import ExportTemplate
 from extras.models import ExportTemplate
 from ipam.api.serializers import RouteTargetSerializer, VRFSerializer
 from ipam.api.serializers import RouteTargetSerializer, VRFSerializer
@@ -28,7 +29,7 @@ from netbox.api.pagination import NetBoxPagination
 from netbox.api.serializers import ValidatedModelSerializer, WritableNestedSerializer
 from netbox.api.serializers import ValidatedModelSerializer, WritableNestedSerializer
 from netbox.api.viewsets.mixins import BulkUpdateModelMixin
 from netbox.api.viewsets.mixins import BulkUpdateModelMixin
 from users.constants import TOKEN_PREFIX
 from users.constants import TOKEN_PREFIX
-from users.models import Token, User
+from users.models import ObjectPermission, Token, User
 from utilities.api import get_serializer_for_model
 from utilities.api import get_serializer_for_model
 from utilities.testing import APITestCase, create_test_device
 from utilities.testing import APITestCase, create_test_device
 from vpn.api.serializers import L2VPNSerializer
 from vpn.api.serializers import L2VPNSerializer
@@ -706,3 +707,51 @@ class ObjectUpdateLockingTestCase(APITestCase):
                 with viewset.lock_object():
                 with viewset.lock_object():
                     self.assertEqual(viewset.locked_pks, locked_pks)
                     self.assertEqual(viewset.locked_pks, locked_pks)
                 self.assertEqual(viewset.locked_pks, set())
                 self.assertEqual(viewset.locked_pks, set())
+
+
+class ProtectedObjectsMessageTestCase(APITestCase):
+    """
+    The error for a deletion prevented by dependent objects must name only those dependent objects which the user is
+    permitted to view. (Ref: #23398)
+    """
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.devices = (
+            create_test_device('Device 1'),
+            create_test_device('Device 2'),
+        )
+        cls.device_type = cls.devices[0].device_type
+        cls.expected_message = (
+            f'Unable to delete object. 2 dependent objects were found: Device 1 ({cls.devices[0].pk}), '
+            '1 object you do not have permission to view'
+        )
+
+    def setUp(self):
+        super().setUp()
+        self.add_permissions('dcim.delete_devicetype')
+
+        # Permit viewing only the first Device
+        obj_perm = ObjectPermission.objects.create(
+            name='View Device 1', actions=['view'], constraints={'pk': self.devices[0].pk}
+        )
+        obj_perm.users.add(self.user)
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(Device))
+
+    def test_delete_object(self):
+        url = reverse('dcim-api:devicetype-detail', kwargs={'pk': self.device_type.pk})
+        with self.assertLogs('netbox.api.views.DeviceTypeViewSet', 'WARNING') as logs:
+            response = self.client.delete(url, **self.header)
+        self.assertHttpStatus(response, 409)
+        self.assertEqual(response.data['detail'], self.expected_message)
+
+        # The log lists all dependent objects
+        self.assertIn(f'Device 2 ({self.devices[1].pk})', logs.output[0])
+
+    def test_exception_to_response(self):
+        viewset = DeviceTypeViewSet()
+        viewset.request = Request(RequestFactory().delete('/'))
+        viewset.request.user = self.user
+        response = viewset.exception_to_response(ProtectedError('', set(self.devices)))
+        self.assertEqual(response.status_code, 409)
+        self.assertEqual(response.data['detail'], self.expected_message)

+ 82 - 0
netbox/netbox/tests/test_authentication.py

@@ -14,7 +14,9 @@ from django.test.utils import override_settings
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.timezone import now
 from django.utils.timezone import now
 from rest_framework.test import APIClient
 from rest_framework.test import APIClient
+from social_core.backends.base import BaseAuth
 from social_core.exceptions import AuthFailed
 from social_core.exceptions import AuthFailed
+from social_django.models import UserSocialAuth
 
 
 from account.views import LoginView
 from account.views import LoginView
 from core.choices import ManagedFileRootPathChoices
 from core.choices import ManagedFileRootPathChoices
@@ -1072,6 +1074,18 @@ class SSOLoginButtonTestCase(DjangoTestCase):
             self.assertEqual(auth_backend['params'].get('next'), '/dcim/sites/')
             self.assertEqual(auth_backend['params'].get('next'), '/dcim/sites/')
 
 
 
 
+class LoginViewTestCase(DjangoTestCase):
+
+    def test_login_missing_username(self):
+        """
+        A login form submission which omits the username field must be rejected as invalid (see #23665).
+        """
+        response = self.client.post(reverse('login'), {'password': 'wrongpassword'})
+
+        self.assertEqual(response.status_code, 200)
+        self.assertTrue(response.context['form'].errors.get('username'))
+
+
 class SocialAuthBeginViewTestCase(DjangoTestCase):
 class SocialAuthBeginViewTestCase(DjangoTestCase):
     """
     """
     Verify the view which initiates an SSO login. Chromium-based browsers evaluate the CSP
     Verify the view which initiates an SSO login. Chromium-based browsers evaluate the CSP
@@ -1275,3 +1289,71 @@ class SocialAuthExceptionMiddlewareTestCase(SimpleTestCase):
         self.assertEqual(response.url, settings.LOGIN_URL)
         self.assertEqual(response.url, settings.LOGIN_URL)
         messages = [str(m) for m in request._messages]
         messages = [str(m) for m in request._messages]
         self.assertEqual(messages, [self.GENERIC_MESSAGE])
         self.assertEqual(messages, [self.GENERIC_MESSAGE])
+
+
+class AllowlistTestBackend(BaseAuth):
+    """
+    Stands in for an identity provider, confirming whichever email address the request names.
+    """
+    name = 'allowlist-test'
+    ID_KEY = 'email'
+
+    def auth_complete(self, *args, **kwargs):
+        kwargs.update(response={'email': self.data['email']}, backend=self)
+        return self.strategy.authenticate(*args, **kwargs)
+
+    def get_user_details(self, response):
+        return {'username': response['email'].split('@')[0], 'email': response['email']}
+
+
+@override_settings(
+    AUTHENTICATION_BACKENDS=[
+        'netbox.tests.test_authentication.AllowlistTestBackend',
+        'netbox.authentication.ObjectPermissionBackend',
+    ],
+    SOCIAL_AUTH_ALLOWLIST_TEST_WHITELISTED_DOMAINS=['example.com'],
+)
+class SocialAuthAllowlistTestCase(DjangoTestCase):
+    """
+    Verify that the SSO login pipeline enforces a backend's allowlist (see #23584).
+    """
+
+    def setUp(self):
+        # load_backends() caches the discovered backends in a module-level dict, so isolate the
+        # backend overridden above from the remainder of the test suite.
+        cache_patcher = patch.dict('social_core.backends.utils.BACKENDSCACHE', {}, clear=True)
+        cache_patcher.start()
+        self.addCleanup(cache_patcher.stop)
+
+        self.url = reverse('social:complete', args=['allowlist-test'])
+
+    def test_email_outside_allowlist_is_refused(self):
+        response = self.client.get(self.url, {'email': 'mallory@evil.test'})
+
+        self.assertEqual(response.status_code, 302)
+        self.assertEqual(response.url, settings.LOGIN_URL)
+        self.assertNotIn('_auth_user_id', self.client.session)
+        self.assertFalse(User.objects.filter(username='mallory').exists())
+
+    def test_existing_user_outside_allowlist_is_refused(self):
+        user = User.objects.create_user(username='mallory', email='mallory@evil.test')
+        UserSocialAuth.objects.create(user=user, provider='allowlist-test', uid='mallory@evil.test')
+
+        response = self.client.get(self.url, {'email': 'mallory@evil.test'})
+
+        self.assertEqual(response.status_code, 302)
+        self.assertEqual(response.url, settings.LOGIN_URL)
+        self.assertNotIn('_auth_user_id', self.client.session)
+
+    def test_email_inside_allowlist_is_allowed(self):
+        self.client.get(self.url, {'email': 'alice@example.com'})
+
+        self.assertIn('_auth_user_id', self.client.session)
+        self.assertTrue(User.objects.filter(username='alice').exists())
+
+    @override_settings(SOCIAL_AUTH_ALLOWLIST_TEST_WHITELISTED_DOMAINS=[])
+    def test_no_allowlist_allows_any_email(self):
+        self.client.get(self.url, {'email': 'alice@evil.test'})
+
+        self.assertIn('_auth_user_id', self.client.session)
+        self.assertTrue(User.objects.filter(username='alice').exists())

+ 336 - 1
netbox/netbox/tests/test_graphql.py

@@ -1,4 +1,6 @@
+import hashlib
 import json
 import json
+import logging
 import re
 import re
 from types import SimpleNamespace
 from types import SimpleNamespace
 from unittest import mock, skipIf
 from unittest import mock, skipIf
@@ -13,6 +15,7 @@ from django.test import override_settings
 from django.test.utils import CaptureQueriesContext
 from django.test.utils import CaptureQueriesContext
 from django.urls import reverse
 from django.urls import reverse
 from django.utils import timezone
 from django.utils import timezone
+from graphql import parse
 from rest_framework import status
 from rest_framework import status
 from strawberry.extensions import QueryDepthLimiter
 from strawberry.extensions import QueryDepthLimiter
 from strawberry.schema.config import StrawberryConfig
 from strawberry.schema.config import StrawberryConfig
@@ -21,6 +24,7 @@ from strawberry_django.optimizer import DjangoOptimizerExtension
 from core.models import DataFile, DataSource, ObjectType
 from core.models import DataFile, DataSource, ObjectType
 from dcim.choices import LocationStatusChoices
 from dcim.choices import LocationStatusChoices
 from dcim.models import (
 from dcim.models import (
+    Cable,
     Device,
     Device,
     DeviceRole,
     DeviceRole,
     DeviceType,
     DeviceType,
@@ -37,6 +41,12 @@ from dcim.models import (
 from extras.choices import CustomFieldTypeChoices
 from extras.choices import CustomFieldTypeChoices
 from extras.models import CustomField, ExportTemplate, JournalEntry, TableConfig, Tag
 from extras.models import CustomField, ExportTemplate, JournalEntry, TableConfig, Tag
 from ipam.models import ASN, RIR, VLAN, Aggregate, IPAddress, Prefix
 from ipam.models import ASN, RIR, VLAN, Aggregate, IPAddress, Prefix
+from netbox.graphql.extensions import (
+    MAX_LOGGED_LENGTH,
+    _normalize_query_text,
+    _normalized_queries,
+    normalize_query,
+)
 from netbox.graphql.pagination import apply_distinct_window_pagination
 from netbox.graphql.pagination import apply_distinct_window_pagination
 from netbox.graphql.scalars import BigInt, BigIntScalar
 from netbox.graphql.scalars import BigInt, BigIntScalar
 from netbox.graphql.schema import Query, get_schema_extensions, schema
 from netbox.graphql.schema import Query, get_schema_extensions, schema
@@ -1039,6 +1049,71 @@ class GraphQLAPITestCase(APITestCase):
             msg=f'Expected batched MAC address lookup, got {mac_queries} MAC address queries for 5 devices',
             msg=f'Expected batched MAC address lookup, got {mac_queries} MAC address queries for 5 devices',
         )
         )
 
 
+    def test_graphql_deferred_fields_are_batched(self):
+        """
+        Deferred columns and relations read by a resolver the optimizer can't analyze (`link_peers` reads `cable`
+        and `wireless_link`) must load with one query for all objects returned, not one query per object.
+        """
+        self.add_permissions('dcim.view_interface')
+
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
+        device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+        devices = Device.objects.bulk_create([
+            Device(name=f'Device {index}', device_type=device_type, role=device_role, site=Site.objects.first())
+            for index in range(1, 4)
+        ])
+        interfaces = {
+            device.name: Interface.objects.bulk_create([
+                Interface(device=device, name=f'eth{index}', type='1000baset') for index in range(5)
+            ])
+            for device in devices
+        }
+        # Device 1's interfaces have no cable; device 2's are each cabled to the interface of the same name on device 3
+        for a_interface, b_interface in zip(interfaces['Device 2'], interfaces['Device 3']):
+            Cable(a_terminations=[a_interface], b_terminations=[b_interface]).save()
+
+        query = """
+        {
+            interface_list(filters: {device: {name: {exact: "%s"}}}, pagination: {limit: %d}) {
+                name
+                link_peers { ... on InterfaceType { name } }
+            }
+        }
+        """
+        url = reverse('graphql')
+
+        def get_link_peers(device_name, limit):
+            with CaptureQueriesContext(connection) as context:
+                response = self.client.post(
+                    url, data={'query': query % (device_name, limit)}, format='json', **self.header
+                )
+            self.assertHttpStatus(response, status.HTTP_200_OK)
+            data = json.loads(response.content)
+            self.assertNotIn('errors', data)
+            link_peers = {
+                interface['name']: [peer['name'] for peer in interface['link_peers']]
+                for interface in data['data']['interface_list']
+            }
+            self.assertEqual(len(link_peers), limit)
+            return link_peers, context.captured_queries
+
+        # Deferred columns (cable, wireless_link) are loaded once, however many interfaces are returned
+        interface_queries = {}
+        for limit in (1, 5):
+            link_peers, queries = get_link_peers('Device 1', limit)
+            self.assertEqual(link_peers, {f'eth{index}': [] for index in range(limit)})
+            interface_queries[limit] = count_primary_table_queries(queries, 'dcim_interface')
+        self.assertEqual(interface_queries[5], interface_queries[1])
+
+        # Cables are loaded once, however many interfaces are returned
+        cable_queries = {}
+        for limit in (1, 5):
+            link_peers, queries = get_link_peers('Device 2', limit)
+            self.assertEqual(link_peers, {f'eth{index}': [f'eth{index}'] for index in range(limit)})
+            cable_queries[limit] = count_primary_table_queries(queries, 'dcim_cable')
+        self.assertEqual(cable_queries[5], cable_queries[1])
+
     def test_offset_pagination(self):
     def test_offset_pagination(self):
         self.add_permissions('dcim.view_site')
         self.add_permissions('dcim.view_site')
         url = reverse('graphql')
         url = reverse('graphql')
@@ -1446,12 +1521,272 @@ class GraphQLAPITestCase(APITestCase):
         self.assertEqual(data['errors'][0]['message'], 'Cannot specify both `start` and `offset` in pagination.')
         self.assertEqual(data['errors'][0]['message'], 'Cannot specify both `start` and `offset` in pagination.')
 
 
 
 
+class GraphQLQueryLoggerTestCase(APITestCase):
+
+    @classmethod
+    def setUpTestData(cls):
+        Site.objects.create(name='Site 1', slug='site-1')
+
+    def setUp(self):
+        super().setUp()
+        self.add_permissions('dcim.view_site')
+
+    def _post(self, data, **kwargs):
+        return self.client.post(reverse('graphql'), data=data, format='json', **kwargs)
+
+    def test_operation_logged(self):
+        """
+        Each operation is logged with the user, token, client, normalized query and result summary.
+        """
+        query = (
+            'query SiteNames($limit: Int) { '
+            'site_list(filters: {name: {exact: "Site 1"}}, pagination: {limit: $limit}) { name } }'
+        )
+        with self.assertLogs('netbox.graphql.query', level='INFO') as cm:
+            response = self._post({'query': query, 'variables': {'limit': 5}}, **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+
+        self.assertEqual(len(cm.records), 1)
+        data = cm.records[0].graphql
+        expected_query = (
+            'query SiteNames($limit:Int){site_list(filters:{name:{exact:""}}pagination:{limit:$limit}){name}}'
+        )
+        self.assertEqual(data['user'], self.user.username)
+        self.assertEqual(data['token_id'], self.token.pk)
+        self.assertEqual(data['client_ip'], '127.0.0.1')
+        self.assertEqual(data['operation_name'], 'SiteNames')
+        self.assertEqual(data['query'], expected_query)
+        self.assertEqual(data['query_hash'], hashlib.sha256(expected_query.encode()).hexdigest())
+        self.assertEqual(data['variables'], {'limit': 5})
+        self.assertEqual(data['error_count'], 0)
+        self.assertEqual(data['response_size'], len(response.content))
+        self.assertGreaterEqual(data['duration_ms'], 0)
+        self.assertIn(f'user={json.dumps(self.user.username)}', cm.output[0])
+        self.assertIn('operation_name="SiteNames"', cm.output[0])
+        self.assertNotIn('Site 1', cm.output[0])
+
+    def test_session_authentication_has_no_token(self):
+        """
+        Operations from a session-authenticated user are logged without a token ID.
+        """
+        self.client.force_login(self.user)
+        with self.assertLogs('netbox.graphql.query', level='INFO') as cm:
+            self._post({'query': '{ site_list { name } }'})
+
+        data = cm.records[0].graphql
+        self.assertEqual(data['user'], self.user.username)
+        self.assertIsNone(data['token_id'])
+        self.assertIsNone(data['operation_name'])
+
+    def test_errors_counted(self):
+        """
+        Errors raised before execution (e.g. validation failures) are counted.
+        """
+        with self.assertLogs('netbox.graphql.query', level='INFO') as cm:
+            response = self._post({'query': '{ site_list { name invalid_field } }'}, **self.header)
+
+        data = cm.records[0].graphql
+        self.assertEqual(data['error_count'], 1)
+        self.assertEqual(data['response_size'], len(response.content))
+        self.assertEqual(data['query'], '{site_list{name invalid_field}}')
+
+    def test_unparseable_query(self):
+        """
+        A query which can't be parsed is logged without a normalized query.
+        """
+        with self.assertLogs('netbox.graphql.query', level='INFO') as cm:
+            self._post({'query': '{ site_list {'}, **self.header)
+
+        data = cm.records[0].graphql
+        self.assertEqual(data['error_count'], 1)
+        self.assertIsNone(data['query'])
+        self.assertIsNone(data['query_hash'])
+
+    def test_operation_name_encoded(self):
+        """
+        A client-supplied operation name can't inject additional lines into the log.
+        """
+        operation_name = 'x\nuser=admin token_id=1 operation_name=ok'
+        with self.assertLogs('netbox.graphql.query', level='INFO') as cm:
+            # The supplied operation name is reported as-is when the query can't be parsed
+            self._post({'query': '{ site_list {', 'operationName': operation_name}, **self.header)
+
+        self.assertEqual(cm.records[0].graphql['operation_name'], operation_name)
+        self.assertNotIn('\n', cm.output[0])
+        self.assertIn(f'operation_name={json.dumps(operation_name)}', cm.output[0])
+
+    def test_unknown_operation_name(self):
+        """
+        An operation which can't be executed (here, because the named operation doesn't exist) is still logged.
+        """
+        with self.assertLogs('netbox.graphql.query', level='INFO') as cm:
+            response = self._post({'query': 'query A { site_list { name } }', 'operationName': 'B'}, **self.header)
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+
+        data = cm.records[0].graphql
+        self.assertEqual(data['operation_name'], 'B')
+        self.assertEqual(data['query'], 'query A{site_list{name}}')
+        self.assertEqual(data['error_count'], 1)
+        self.assertEqual(data['response_size'], len(response.content))
+
+    def test_long_values_truncated(self):
+        """
+        Long queries and variables are truncated in the log message.
+        """
+        name = 'x' * (MAX_LOGGED_LENGTH * 2)
+        query = f'query ($name: [String!]) {{ site_list(filters: {{name: {{in_list: $name}}}}) {{ {name}: name }} }}'
+        with self.assertLogs('netbox.graphql.query', level='INFO') as cm:
+            self._post({'query': query, 'variables': {'name': [name]}}, **self.header)
+
+        data = cm.records[0].graphql
+        self.assertEqual(len(data['query']), MAX_LOGGED_LENGTH + 3)
+        self.assertTrue(data['query'].endswith('...'))
+        self.assertLess(len(cm.output[0]), MAX_LOGGED_LENGTH * 3)
+
+    def test_normalization_cached(self):
+        """
+        Repeated queries are normalized once.
+        """
+        query = '{ site_list(filters: {name: {exact: "cache test"}}) { name } }'
+        with self.assertLogs('netbox.graphql.query', level='INFO'):
+            self._post({'query': query}, **self.header)
+            with mock.patch('netbox.graphql.extensions.normalize_query') as normalize_query:
+                self._post({'query': query}, **self.header)
+        normalize_query.assert_not_called()
+
+    def test_operation_without_request(self):
+        """
+        Operations executed without a request are logged immediately, without a response size.
+        """
+        with self.assertLogs('netbox.graphql.query', level='INFO') as cm:
+            schema.execute_sync('{ __typename }', context_value=SimpleNamespace())
+
+        data = cm.records[0].graphql
+        self.assertEqual(data['query'], '{__typename}')
+        self.assertIsNone(data['response_size'])
+        self.assertIsNotNone(data['duration_ms'])
+
+    def test_disabled_logger_does_nothing(self):
+        """
+        The extension does no work when the logger isn't enabled for INFO messages.
+        """
+        logger = logging.getLogger('netbox.graphql.query')
+        level = logger.level
+        logger.setLevel(logging.WARNING)
+        try:
+            with (
+                mock.patch('netbox.graphql.extensions._normalize_query_text') as normalize_query_text,
+                mock.patch('netbox.graphql.extensions.get_client_ip') as get_client_ip,
+            ):
+                response = self._post({'query': '{ site_list { name } }'}, **self.header)
+        finally:
+            logger.setLevel(level)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        normalize_query_text.assert_not_called()
+        get_client_ip.assert_not_called()
+
+    def test_logging_failure_does_not_affect_operation(self):
+        """
+        An error while logging an operation is logged, and doesn't affect the response.
+        """
+        with (
+            self.assertLogs('netbox.graphql.query', level='INFO') as cm,
+            mock.patch('netbox.graphql.extensions._normalize_query_text', side_effect=RuntimeError),
+        ):
+            response = self._post({'query': '{ site_list { name } }'}, **self.header)
+            # An operation which fails keeps its own error
+            failed_response = self._post(
+                {'query': 'query A { site_list { name } }', 'operationName': 'B'}, **self.header
+            )
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(response.json()['data'], {'site_list': [{'name': 'Site 1'}]})
+        self.assertHttpStatus(failed_response, status.HTTP_400_BAD_REQUEST)
+        self.assertEqual(
+            [record.getMessage() for record in cm.records],
+            ['Failed to log GraphQL operation', 'Failed to log GraphQL operation'],
+        )
+
+    def test_emit_failure_does_not_affect_response(self):
+        with (
+            self.assertLogs('netbox.graphql.query', level='INFO') as cm,
+            mock.patch('netbox.graphql.extensions._emit', side_effect=RuntimeError),
+        ):
+            response = self._post({'query': '{ site_list { name } }'}, **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(cm.records[0].getMessage(), 'Failed to log GraphQL operation')
+
+
+class NormalizeQueryTestCase(TestCase):
+
+    def test_literals_replaced(self):
+        query = (
+            '{ site_list(filters: {name: {exact: "Site 1"}, id: {in_list: [1, 2, 3]}, status: STATUS_ACTIVE}, '
+            'pagination: {limit: 10, offset: 2}) { id } }'
+        )
+        self.assertEqual(
+            normalize_query(parse(query)),
+            '{site_list(filters:{name:{exact:""}id:{in_list:[]}status:STATUS_ACTIVE}'
+            'pagination:{limit:0 offset:0}){id}}',
+        )
+
+    def test_values_do_not_affect_shape(self):
+        query_a = '{ site_list(filters: {name: {exact: "Site 1"}, id: {in_list: [1, 2, 3]}}) { id } }'
+        query_b = '{ site_list(filters: {name: {exact: "Site 2"}, id: {in_list: [4]}}) { id } }'
+        self.assertEqual(normalize_query(parse(query_a)), normalize_query(parse(query_b)))
+
+    def test_structure_affects_shape(self):
+        query_a = '{ site_list(filters: {name: {exact: "Site 1"}}) { id } }'
+        query_b = '{ site_list(filters: {slug: {exact: "Site 1"}}) { id } }'
+        self.assertNotEqual(normalize_query(parse(query_a)), normalize_query(parse(query_b)))
+
+    def test_lists_of_objects_kept(self):
+        query = '{ site_list(filters: {OR: [{name: {exact: "a"}}, {slug: {exact: "b"}}]}) { id } }'
+        self.assertEqual(
+            normalize_query(parse(query)),
+            '{site_list(filters:{OR:[{name:{exact:""}}{slug:{exact:""}}]}){id}}',
+        )
+
+    def test_lists_of_variables_and_enums_kept(self):
+        query = (
+            'query ($status: String) { site_list(filters: {status: {in_list: [$status]}}) { id } '
+            'location_list(filters: {status: {in_list: [STATUS_ACTIVE, STATUS_PLANNED]}}) { id } }'
+        )
+        self.assertEqual(
+            normalize_query(parse(query)),
+            'query($status:String){site_list(filters:{status:{in_list:[$status]}}){id}'
+            'location_list(filters:{status:{in_list:[STATUS_ACTIVE STATUS_PLANNED]}}){id}}',
+        )
+
+    def test_cache_bounded(self):
+        queries = [f'{{ {alias}: site_list {{ id }} }}' for alias in ('a', 'b', 'c')]
+        with (
+            mock.patch('netbox.graphql.extensions.NORMALIZED_QUERY_CACHE_SIZE', 2),
+            mock.patch.dict(_normalized_queries, clear=True),
+        ):
+            for query in queries:
+                _normalize_query_text(query, parse(query))
+            self.assertEqual(
+                list(_normalized_queries), [hashlib.sha256(query.encode()).digest() for query in queries[1:]]
+            )
+
+    def test_cache_holds_truncated_query(self):
+        """
+        The cache holds the normalized query truncated for logging, and the hash of the full normalized query.
+        """
+        query = f'{{ site_list {{ {"x" * MAX_LOGGED_LENGTH * 2}: name }} }}'
+        with mock.patch.dict(_normalized_queries, clear=True):
+            normalized, query_hash = _normalize_query_text(query, parse(query))
+            self.assertEqual(len(normalized), MAX_LOGGED_LENGTH + 3)
+            self.assertEqual(query_hash, hashlib.sha256(normalize_query(parse(query)).encode()).hexdigest())
+            self.assertEqual(list(_normalized_queries.values()), [(normalized, query_hash)])
+
+
 class GraphQLDeferredColumnTestCase(APITestCase):
 class GraphQLDeferredColumnTestCase(APITestCase):
     """
     """
     A GraphQL field backed by a custom resolver is opaque to the query optimizer, which narrows column
     A GraphQL field backed by a custom resolver is opaque to the query optimizer, which narrows column
     selection with .only() based on the fields named in the GraphQL document. Any column such a resolver
     selection with .only() based on the fields named in the GraphQL document. Any column such a resolver
     reads must therefore be declared via an `only` hint; otherwise the column is deferred and reading it
     reads must therefore be declared via an `only` hint; otherwise the column is deferred and reading it
-    reloads the row from the database once per object returned (see #22813).
+    costs an extra query (see #22813).
 
 
     Each test below asserts both that no single-row reload occurs and that the model's table is read
     Each test below asserts both that no single-row reload occurs and that the model's table is read
     exactly once per request, regardless of the number of objects returned.
     exactly once per request, regardless of the number of objects returned.

+ 245 - 5
netbox/netbox/tests/test_jobs.py

@@ -1,23 +1,28 @@
 import uuid
 import uuid
+from contextlib import contextmanager
 from datetime import timedelta
 from datetime import timedelta
 from unittest.mock import patch
 from unittest.mock import patch
 
 
+from django.core.exceptions import ValidationError
 from django.db import DatabaseError
 from django.db import DatabaseError
-from django.test import RequestFactory, TestCase
+from django.db.models.signals import post_save
+from django.test import RequestFactory, TestCase, tag
 from django.urls import reverse
 from django.urls import reverse
 from django.utils import timezone
 from django.utils import timezone
+from django_pg_utils import advisory_lock
 from django_rq import get_queue
 from django_rq import get_queue
 
 
 from core.choices import JobStatusChoices
 from core.choices import JobStatusChoices
 from core.events import JOB_COMPLETED, JOB_FAILED
 from core.events import JOB_COMPLETED, JOB_FAILED
 from core.exceptions import JobFailed
 from core.exceptions import JobFailed
 from core.models import DataSource, Job
 from core.models import DataSource, Job
+from core.signals import job_end
 from dcim.models import Site
 from dcim.models import Site
 from dcim.views import SiteBulkEditView
 from dcim.views import SiteBulkEditView
 from extras.models import Notification
 from extras.models import Notification
 from users.models import User
 from users.models import User
 from utilities.request import copy_safe_request
 from utilities.request import copy_safe_request
-from utilities.testing import disable_warnings
+from utilities.testing import disable_logging, disable_warnings
 from utilities.testing.mixins import RQQueueTestMixin
 from utilities.testing.mixins import RQQueueTestMixin
 
 
 from ..jobs import *
 from ..jobs import *
@@ -240,6 +245,135 @@ class JobRunnerTestCase(BaseJobRunnerTestCase):
         self.assertEqual(job.status, JobStatusChoices.STATUS_ERRORED)
         self.assertEqual(job.status, JobStatusChoices.STATUS_ERRORED)
         self.assertEqual(job.error, repr(error))
         self.assertEqual(job.error, repr(error))
 
 
+    @tag('regression')  # Ref: #23535
+    def test_handle_reschedules_before_terminating(self):
+        """A periodic job's successor exists when job_end is sent."""
+        outcomes = (
+            (None, JobStatusChoices.STATUS_COMPLETED),
+            (JobFailed(), JobStatusChoices.STATUS_FAILED),
+            (RuntimeError('Simulated run error'), JobStatusChoices.STATUS_ERRORED),
+        )
+        for run_error, status in outcomes:
+            with self.subTest(status=status):
+                instance = DataSource.objects.create(name=f'Source {status}', type='local')
+                job = TestJobRunner.enqueue(instance, interval=60, schedule_at=self.get_schedule_at())
+                observed = []
+
+                def record_end(sender, **kwargs):
+                    waiting = TestJobRunner.get_jobs(instance).filter(
+                        status__in=JobStatusChoices.ENQUEUED_STATE_CHOICES,
+                    )
+                    observed.append((Job.objects.get(pk=sender.pk).status, list(waiting.values_list('pk', flat=True))))
+
+                job_end.connect(record_end, sender=job)
+                try:
+                    with patch.object(TestJobRunner, 'run', side_effect=run_error), disable_warnings('netbox.jobs'):
+                        TestJobRunner.handle(job)
+                finally:
+                    job_end.disconnect(record_end, sender=job)
+
+                successor = TestJobRunner.get_jobs(instance).exclude(pk=job.pk).get()
+                self.assertEqual(observed, [(status, [successor.pk])])
+                self.assertEqual((successor.status, successor.interval), (JobStatusChoices.STATUS_SCHEDULED, 60))
+
+    def test_handle_keeps_run_outcome_when_reschedule_is_invalid(self):
+        """A reschedule rejected as invalid keeps the run's outcome and ends the job once."""
+        user = User.objects.create_user(username='job-owner')
+        outcomes = (
+            (None, JobStatusChoices.STATUS_COMPLETED),
+            (JobFailed(), JobStatusChoices.STATUS_FAILED),
+            (RuntimeError('Simulated run error'), JobStatusChoices.STATUS_ERRORED),
+        )
+        for run_error, status in outcomes:
+            with self.subTest(status=status):
+                instance = DataSource.objects.create(name=f'Source {status}', type='local')
+                job = TestJobRunner.enqueue(instance, interval=60, user=user)
+
+                with (
+                    patch.object(TestJobRunner, 'run', side_effect=run_error),
+                    patch.object(TestJobRunner, 'enqueue', side_effect=ValidationError('Simulated validation error')),
+                    patch.object(job_end, 'send', wraps=job_end.send) as send_end,
+                    disable_logging(),
+                ):
+                    TestJobRunner.handle(job)
+
+                job.refresh_from_db()
+                self.assertEqual(job.status, status)
+                self.assertEqual(job.error, repr(run_error) if status == JobStatusChoices.STATUS_ERRORED else '')
+                self.assertEqual(job.log_entries[-1]['level'], 'error')
+                self.assertIn('Simulated validation error', job.log_entries[-1]['message'])
+                self.assertEqual(TestJobRunner.get_jobs(instance).count(), 1)
+                send_end.assert_called_once_with(job)
+                self.assertEqual(Notification.objects.filter(user=user, object_id=job.pk).count(), 1)
+
+    def test_handle_terminates_job_when_reschedule_fails(self):
+        """An unexpected reschedule failure still ends the job once, then propagates."""
+        user = User.objects.create_user(username='job-owner')
+        job = TestJobRunner.enqueue(interval=60, user=user)
+
+        with (
+            patch.object(TestJobRunner, 'run'),
+            patch.object(TestJobRunner, 'enqueue', side_effect=RuntimeError('Simulated queue error')),
+            patch.object(job_end, 'send', wraps=job_end.send) as send_end,
+            self.assertRaisesMessage(RuntimeError, 'Simulated queue error'),
+        ):
+            TestJobRunner.handle(job)
+
+        job.refresh_from_db()
+        self.assertEqual(job.status, JobStatusChoices.STATUS_COMPLETED)
+        self.assertIsNotNone(job.completed)
+        send_end.assert_called_once_with(job)
+        self.assertEqual(Notification.objects.filter(user=user, object_id=job.pk).count(), 1)
+
+    def test_handle_terminates_job_once_when_end_receiver_fails(self):
+        """A failing job_end receiver after a successful run leaves the job completed and propagates."""
+        user = User.objects.create_user(username='job-owner')
+        job = TestJobRunner.enqueue(user=user)
+
+        with (
+            patch.object(TestJobRunner, 'run'),
+            patch.object(job_end, 'send', side_effect=RuntimeError('Simulated receiver error')) as send_end,
+            self.assertRaisesMessage(RuntimeError, 'Simulated receiver error'),
+        ):
+            TestJobRunner.handle(job)
+
+        job.refresh_from_db()
+        self.assertEqual(job.status, JobStatusChoices.STATUS_COMPLETED)
+        send_end.assert_called_once_with(job)
+        self.assertEqual(Notification.objects.filter(user=user, object_id=job.pk).count(), 1)
+
+    def test_handle_propagates_interrupt_and_reschedules(self):
+        """An interrupted run propagates the interrupt, records no outcome and still schedules the successor."""
+        instance = DataSource.objects.create(name='Interrupted Source', type='local')
+        job = TestJobRunner.enqueue(instance, interval=60, schedule_at=self.get_schedule_at())
+
+        with patch.object(TestJobRunner, 'run', side_effect=SystemExit), self.assertRaises(SystemExit):
+            TestJobRunner.handle(job)
+
+        job.refresh_from_db()
+        self.assertEqual(job.status, JobStatusChoices.STATUS_RUNNING)
+        self.assertIsNone(job.completed)
+        successor = TestJobRunner.get_jobs(instance).exclude(pk=job.pk).get()
+        self.assertEqual((successor.status, successor.interval), (JobStatusChoices.STATUS_SCHEDULED, 60))
+
+    def test_handle_terminal_save_error(self):
+        """A failed terminal save propagates and sends no notification or job_end."""
+        user = User.objects.create_user(username='job-owner')
+        job = TestJobRunner.enqueue(user=user)
+
+        with (
+            patch.object(TestJobRunner, 'run'),
+            patch.object(job, 'save', side_effect=DatabaseError('Simulated database error')),
+            patch.object(job_end, 'send', wraps=job_end.send) as send_end,
+            self.assertRaisesMessage(DatabaseError, 'Simulated database error'),
+        ):
+            TestJobRunner.handle(job)
+
+        job.refresh_from_db()
+        self.assertEqual(job.status, JobStatusChoices.STATUS_RUNNING)
+        send_end.assert_not_called()
+        self.assertFalse(Notification.objects.filter(user=user, object_id=job.pk).exists())
+
 
 
 class EnqueueTestCase(BaseJobRunnerTestCase):
 class EnqueueTestCase(BaseJobRunnerTestCase):
     """
     """
@@ -488,9 +622,7 @@ class SystemJobTestCase(BaseJobRunnerTestCase):
         """
         """
         When `handle()` finishes a periodic system job, it must not create a duplicate
         When `handle()` finishes a periodic system job, it must not create a duplicate
         scheduled job if a successor is already enqueued (issue #22232). This guards
         scheduled job if a successor is already enqueued (issue #22232). This guards
-        against the race where a worker starts up between `job.terminate()` and the
-        finally block's reschedule, calling `enqueue_once()` which would create a parallel
-        job.
+        against a successor that another occurrence or `enqueue_once()` already created.
         """
         """
         interval = 60
         interval = 60
 
 
@@ -523,6 +655,34 @@ class SystemJobTestCase(BaseJobRunnerTestCase):
         self.assertEqual(enqueued.count(), 1)
         self.assertEqual(enqueued.count(), 1)
         self.assertEqual(enqueued.first().pk, successor.pk)
         self.assertEqual(enqueued.first().pk, successor.pk)
 
 
+    def test_handle_skips_reschedule_when_successor_is_running(self):
+        """A running occurrence of a periodic system job counts as its successor."""
+        interval = 60
+        successor = Job.objects.create(
+            name=TestSystemJobRunner.name,
+            status=JobStatusChoices.STATUS_RUNNING,
+            interval=interval,
+            started=timezone.now(),
+            job_id=uuid.uuid4(),
+        )
+        job = Job.objects.create(
+            name=TestSystemJobRunner.name,
+            interval=interval,
+            job_id=uuid.uuid4(),
+        )
+
+        TestSystemJobRunner.handle(job)
+        job.refresh_from_db()
+        self.assertEqual(job.status, JobStatusChoices.STATUS_COMPLETED)
+
+        enqueued = Job.objects.filter(
+            name=TestSystemJobRunner.name,
+            status__in=JobStatusChoices.ENQUEUED_STATE_CHOICES,
+            interval=interval,
+        )
+        self.assertEqual(enqueued.count(), 1)
+        self.assertEqual(enqueued.first().pk, successor.pk)
+
     def test_handle_reschedules_when_only_instance_bound_successor_exists(self):
     def test_handle_reschedules_when_only_instance_bound_successor_exists(self):
         """
         """
         For a system (object-less) job, an instance-bound job of the same JobRunner class
         For a system (object-less) job, an instance-bound job of the same JobRunner class
@@ -602,6 +762,86 @@ class SystemJobTestCase(BaseJobRunnerTestCase):
         )
         )
         self.assertEqual(enqueued.count(), 2)
         self.assertEqual(enqueued.count(), 2)
 
 
+    def test_handle_keeps_one_successor_for_overlapping_jobs(self):
+        """Two occurrences of a system job ending back to back leave one successor."""
+        first = TestSystemJobRunner.enqueue(interval=60)
+        second = TestSystemJobRunner.enqueue(interval=60)
+        waiting_runs = [second]
+
+        @contextmanager
+        def end_second_after_unlock(*args, **kwargs):
+            with advisory_lock(*args, **kwargs):
+                yield
+            # Another worker ends the second occurrence as soon as the lock is free
+            while waiting_runs:
+                TestSystemJobRunner.handle(waiting_runs.pop())
+
+        with patch('netbox.jobs.advisory_lock', side_effect=end_second_after_unlock):
+            TestSystemJobRunner.handle(first)
+
+        first.refresh_from_db()
+        second.refresh_from_db()
+        self.assertEqual(first.status, JobStatusChoices.STATUS_COMPLETED)
+        self.assertEqual(second.status, JobStatusChoices.STATUS_COMPLETED)
+        waiting = TestSystemJobRunner.get_jobs().filter(status__in=JobStatusChoices.ENQUEUED_STATE_CHOICES)
+        self.assertEqual(list(waiting.values_list('status', 'interval')), [(JobStatusChoices.STATUS_SCHEDULED, 60)])
+
+    def test_handle_sends_job_end_outside_schedule_lock(self):
+        """A system job is saved as ended under the schedule lock, then notifies and sends job_end outside it."""
+        user = User.objects.create_user(username='job-owner')
+        job = TestSystemJobRunner.enqueue(interval=60, user=user)
+        held = False
+        events = []
+
+        @contextmanager
+        def track_lock(*args, **kwargs):
+            nonlocal held
+            with advisory_lock(*args, **kwargs):
+                held = True
+                try:
+                    yield
+                finally:
+                    held = False
+
+        def record_job_save(sender, instance, **kwargs):
+            if instance.pk == job.pk and instance.status in JobStatusChoices.TERMINAL_STATE_CHOICES:
+                events.append(('saved', held))
+
+        def record_notification(sender, **kwargs):
+            events.append(('notified', held))
+
+        def record_end(sender, **kwargs):
+            events.append(('ended', held))
+
+        post_save.connect(record_job_save, sender=Job)
+        post_save.connect(record_notification, sender=Notification)
+        job_end.connect(record_end, sender=job)
+        try:
+            with patch('netbox.jobs.advisory_lock', side_effect=track_lock):
+                TestSystemJobRunner.handle(job)
+        finally:
+            post_save.disconnect(record_job_save, sender=Job)
+            post_save.disconnect(record_notification, sender=Notification)
+            job_end.disconnect(record_end, sender=job)
+
+        self.assertEqual(events, [('saved', True), ('notified', False), ('ended', False)])
+
+    def test_handle_terminates_job_when_schedule_lock_fails(self):
+        """A failure to take the schedule lock still ends the job and sends job_end, then propagates."""
+        job = TestSystemJobRunner.enqueue(interval=60)
+
+        with (
+            patch('netbox.jobs.advisory_lock') as lock,
+            patch.object(job_end, 'send', wraps=job_end.send) as send_end,
+            self.assertRaisesMessage(RuntimeError, 'Simulated lock error'),
+        ):
+            lock.return_value.__enter__.side_effect = RuntimeError('Simulated lock error')
+            TestSystemJobRunner.handle(job)
+
+        job.refresh_from_db()
+        self.assertEqual(job.status, JobStatusChoices.STATUS_COMPLETED)
+        send_end.assert_called_once_with(job)
+
 
 
 class AsyncViewJobTestCase(TestCase):
 class AsyncViewJobTestCase(TestCase):
     """
     """

+ 344 - 3
netbox/netbox/tests/test_middleware.py

@@ -1,16 +1,27 @@
 import json
 import json
+import sys
 from contextlib import contextmanager
 from contextlib import contextmanager
 from types import SimpleNamespace
 from types import SimpleNamespace
 from unittest.mock import Mock, patch
 from unittest.mock import Mock, patch
 
 
+from django.contrib.messages import get_messages
+from django.core.cache import cache
 from django.core.signals import got_request_exception
 from django.core.signals import got_request_exception
-from django.db.utils import InternalError
-from django.test import RequestFactory, override_settings
+from django.db import connection
+from django.db.utils import InterfaceError, InternalError
+from django.test import RequestFactory, TransactionTestCase, override_settings
 from django.urls import reverse
 from django.urls import reverse
 from rest_framework import status
 from rest_framework import status
 
 
+from core.events import OBJECT_CREATED
+from core.models import ConfigRevision, ObjectType
+from dcim.models import Site
+from extras.models import ConfigContext, Dashboard, Notification, TableConfig
+from netbox.config import clear_config
 from netbox.middleware import CoreMiddleware, MaintenanceModeMiddleware
 from netbox.middleware import CoreMiddleware, MaintenanceModeMiddleware
-from utilities.testing import TestCase
+from users.constants import TOKEN_PREFIX
+from users.models import Token, User
+from utilities.testing import TestCase, disable_warnings
 
 
 
 
 class CoreMiddlewareTestCase(TestCase):
 class CoreMiddlewareTestCase(TestCase):
@@ -140,6 +151,29 @@ class CoreMiddlewareTestCase(TestCase):
 
 
         self.assert_json_500_response(response)
         self.assert_json_500_response(response)
 
 
+    def test_maintenance_mode_handles_ui_post_request(self):
+        request = self.factory.post('/dcim/sites/add/')
+        request._messages = Mock()
+
+        with patch('netbox.middleware.get_config', return_value=SimpleNamespace(MAINTENANCE_MODE=True)):
+            response = self.process_internal_error(request, 'Simulated maintenance mode UI error')
+
+        self.assertEqual(response.status_code, 302)
+        self.assertEqual(response.url, '/dcim/sites/add/')
+
+    def test_maintenance_mode_handles_ui_get_request(self):
+        """
+        A failed write during a GET request must not redirect back to the same URL, which would fail again.
+        """
+        request = self.factory.get('/')
+
+        with patch('netbox.middleware.get_config', return_value=SimpleNamespace(MAINTENANCE_MODE=True)):
+            response = self.process_internal_error(request, 'Simulated maintenance mode UI error')
+
+        self.assertEqual(response.status_code, 503)
+        self.assertIn('maintenance mode', response.content.decode())
+        self.assertNotIn('Server Error', response.content.decode())
+
     def test_maintenance_mode_handles_graphql_json_request(self):
     def test_maintenance_mode_handles_graphql_json_request(self):
         request = self.factory.post(
         request = self.factory.post(
             reverse('graphql'),
             reverse('graphql'),
@@ -156,3 +190,310 @@ class CoreMiddlewareTestCase(TestCase):
             response = self.process_internal_error(request, 'Simulated maintenance mode GraphQL error')
             response = self.process_internal_error(request, 'Simulated maintenance mode GraphQL error')
 
 
         self.assert_json_500_response(response)
         self.assert_json_500_response(response)
+
+
+def is_read_only():
+    with connection.cursor() as cursor:
+        cursor.execute('SHOW transaction_read_only')
+        return cursor.fetchone()[0] == 'on'
+
+
+@override_settings(MAINTENANCE_MODE=True)
+class MaintenanceModeTestCase(TransactionTestCase):
+    """
+    Maintenance mode is enforced on the database session, which has no effect on writes within the transaction that
+    wraps each TestCase test, so these tests run against a real connection.
+    """
+
+    def setUp(self):
+        self.user = User.objects.create_user(username='testuser', password='testpassword', is_superuser=True)
+
+    def tearDown(self):
+        # Don't leave a read-only connection behind for other tests
+        with connection.cursor() as cursor:
+            cursor.execute('SET SESSION CHARACTERISTICS AS TRANSACTION READ WRITE')
+
+    def get_read_only_during_request(self, path):
+        """
+        Return whether the database connection was read-only while handling a request for the given path.
+        """
+        read_only = []
+
+        def get_response(request):
+            read_only.append(is_read_only())
+
+        MaintenanceModeMiddleware(get_response)(RequestFactory().get(path))
+        return read_only[0]
+
+    def test_exempt_paths(self):
+        exempt_paths = (
+            reverse('login'),
+            reverse('logout'),
+            reverse('social:begin', kwargs={'backend': 'github'}),
+            reverse('social:complete', kwargs={'backend': 'github'}),
+            reverse('core:configrevision_list'),
+            reverse('core:configrevision_add'),
+            reverse('users-api:token_provision'),
+        )
+        for path in exempt_paths:
+            with self.subTest(path=path):
+                self.assertFalse(self.get_read_only_during_request(path))
+                self.assertFalse(is_read_only())
+
+    def test_restricted_paths(self):
+        restricted_paths = (
+            reverse('home'),
+            reverse('dcim:site_add'),
+            reverse('account:preferences'),
+            reverse('api-root'),
+            reverse('graphql'),
+        )
+        for path in restricted_paths:
+            with self.subTest(path=path):
+                self.assertTrue(self.get_read_only_during_request(path))
+                self.assertFalse(is_read_only(), "Connection was left read-only after the request")
+
+    def test_maintenance_mode_disabled(self):
+        with override_settings(MAINTENANCE_MODE=False):
+            self.assertFalse(self.get_read_only_during_request(reverse('dcim:site_add')))
+
+    def test_ui_write_refused(self):
+        self.client.force_login(self.user)
+        url = reverse('dcim:site_add')
+
+        response = self.client.post(url, {'name': 'Site 1', 'slug': 'site-1', 'status': 'active'})
+
+        self.assertRedirects(response, url, fetch_redirect_response=False)
+        self.assertIn('maintenance mode', str(list(get_messages(response.wsgi_request))[0]))
+        self.assertFalse(Site.objects.exists())
+
+    def test_api_write_refused(self):
+        token = Token.objects.create(user=self.user)
+        header = {'HTTP_AUTHORIZATION': f'Bearer {TOKEN_PREFIX}{token.key}.{token.token}'}
+
+        response = self.client.post(
+            reverse('dcim-api:site-list'),
+            {'name': 'Site 1', 'slug': 'site-1', 'status': 'active'},
+            content_type='application/json',
+            **header,
+        )
+
+        self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR)
+        self.assertIn('maintenance mode', response.json()['error'])
+        self.assertFalse(Site.objects.exists())
+
+    def test_api_user_config_write_refused(self):
+        """
+        Explicitly saving a user's preferences is refused, unlike the incidental changes made while viewing a page.
+        """
+        token = Token.objects.create(user=self.user)
+        header = {'HTTP_AUTHORIZATION': f'Bearer {TOKEN_PREFIX}{token.key}.{token.token}'}
+
+        response = self.client.patch(
+            reverse('users-api:userconfig-list'),
+            {'data_format': 'yaml'},
+            content_type='application/json',
+            **header,
+        )
+
+        self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR)
+        self.assertIn('maintenance mode', response.json()['error'])
+        self.user.config.refresh_from_db()
+        self.assertIsNone(self.user.config.get('data_format'))
+
+    def test_writes_resume_when_maintenance_mode_disabled(self):
+        self.client.force_login(self.user)
+        data = {'name': 'Site 1', 'slug': 'site-1', 'status': 'active'}
+        self.client.post(reverse('dcim:site_add'), data)
+
+        with override_settings(MAINTENANCE_MODE=False):
+            response = self.client.post(reverse('dcim:site_add'), data)
+
+        self.assertEqual(response.status_code, 302)
+        self.assertTrue(Site.objects.filter(slug='site-1').exists())
+
+    @override_settings(SESSION_SAVE_EVERY_REQUEST=True)
+    def test_session_saved_on_every_request(self):
+        """
+        LOGIN_PERSISTENCE saves the session on every request.
+        """
+        self.client.force_login(self.user)
+
+        response = self.client.get(reverse('dcim:site_list'))
+
+        self.assertEqual(response.status_code, 200)
+
+    def test_login_and_logout(self):
+        response = self.client.post(reverse('login'), {'username': 'testuser', 'password': 'testpassword'})
+        self.assertEqual(response.status_code, 302)
+        self.assertEqual(int(self.client.session['_auth_user_id']), self.user.pk)
+
+        # The login time isn't recorded in maintenance mode
+        self.user.refresh_from_db()
+        self.assertIsNone(self.user.last_login)
+
+        response = self.client.get(reverse('logout'))
+        self.assertEqual(response.status_code, 302)
+        self.assertNotIn('_auth_user_id', self.client.session)
+
+        # The login time is recorded once maintenance mode is disabled
+        with override_settings(MAINTENANCE_MODE=False):
+            self.client.post(reverse('login'), {'username': 'testuser', 'password': 'testpassword'})
+        self.user.refresh_from_db()
+        self.assertIsNotNone(self.user.last_login)
+
+    @override_settings(REMOTE_AUTH_ENABLED=True, LOGIN_REQUIRED=True)
+    def test_remote_auth_login(self):
+        User.objects.create(username='remoteuser1')
+
+        # A refused write must not leave the connection read-only for the next request
+        self.client.force_login(self.user)
+        self.client.post(reverse('dcim:site_add'), {'name': 'Site 1', 'slug': 'site-1', 'status': 'active'})
+        self.client.logout()
+
+        response = self.client.get(reverse('home'), follow=True, HTTP_REMOTE_USER='remoteuser1')
+
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(self.client.session['_auth_user_id'], str(User.objects.get(username='remoteuser1').pk))
+
+    # Use a per-process cache, so that activating the new revision doesn't affect other tests
+    @override_settings(CACHES={
+        'default': {
+            'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
+            'LOCATION': 'netbox-maintenance-mode-tests',
+        },
+    })
+    def test_modify_configuration(self):
+        self.addCleanup(cache.clear)
+        self.addCleanup(clear_config)
+        self.client.force_login(self.user)
+
+        response = self.client.post(reverse('core:configrevision_add'), {'comment': 'Disable maintenance mode'})
+
+        self.assertEqual(response.status_code, 302)
+        self.assertTrue(ConfigRevision.objects.filter(comment='Disable maintenance mode').exists())
+
+    def test_connection_closed_if_restore_fails(self):
+        middleware = MaintenanceModeMiddleware(lambda request: None)
+
+        # Simulate losing the connection before read/write access is restored
+        with (
+            patch.object(MaintenanceModeMiddleware, '_set_session_type', side_effect=[None, InterfaceError]),
+            patch('netbox.middleware.connection') as mock_connection,
+        ):
+            middleware(RequestFactory().get(reverse('home')))
+
+        mock_connection.close.assert_called_once()
+
+    def test_table_ordering_not_saved(self):
+        self.client.force_login(self.user)
+
+        response = self.client.get(reverse('dcim:site_list'), {'sort': 'name'})
+
+        self.assertEqual(response.status_code, 200)
+        self.user.config.refresh_from_db()
+        self.assertIsNone(self.user.config.get('tables.SiteTable.ordering'))
+
+    def test_table_config_not_saved(self):
+        tableconfig = TableConfig.objects.create(
+            name='Table Config 1',
+            object_type=ObjectType.objects.get_for_model(Site),
+            table='SiteTable',
+            user=self.user,
+            columns=['name', 'status'],
+            ordering=['name'],
+        )
+        self.client.force_login(self.user)
+
+        response = self.client.get(reverse('dcim:site_list'), {'tableconfig_id': tableconfig.pk})
+
+        self.assertEqual(response.status_code, 200)
+        self.user.config.refresh_from_db()
+        self.assertIsNone(self.user.config.get('tables.SiteTable.columns'))
+
+    def test_data_format_not_saved(self):
+        configcontext = ConfigContext.objects.create(name='Config Context 1', data={'foo': 'bar'})
+        self.client.force_login(self.user)
+
+        response = self.client.get(configcontext.get_absolute_url(), {'format': 'yaml'})
+
+        self.assertEqual(response.status_code, 200)
+        self.user.config.refresh_from_db()
+        self.assertIsNone(self.user.config.get('data_format'))
+
+    def test_default_dashboard_not_saved(self):
+        self.client.force_login(self.user)
+
+        response = self.client.get(reverse('home'))
+
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(list(get_messages(response.wsgi_request)), [])
+        self.assertFalse(Dashboard.objects.filter(user=self.user).exists())
+
+    def test_notification_not_marked_read(self):
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        notification = Notification.objects.create(user=self.user, object=site, event_type=OBJECT_CREATED)
+        self.client.force_login(self.user)
+
+        response = self.client.get(reverse('extras:notification_read', kwargs={'pk': notification.pk}))
+
+        self.assertRedirects(response, site.get_absolute_url(), fetch_redirect_response=False)
+        notification.refresh_from_db()
+        self.assertIsNone(notification.read)
+
+    @override_settings(REMOTE_AUTH_BACKEND=['netbox.authentication.LDAPBackend'])
+    def test_api_ldap_user_not_updated(self):
+        token = Token.objects.create(user=self.user)
+        header = {'HTTP_AUTHORIZATION': f'Bearer {TOKEN_PREFIX}{token.key}.{token.token}'}
+
+        with patch('netbox.authentication.LDAPBackend') as mock_backend:
+            response = self.client.get(reverse('dcim-api:site-list'), **header)
+
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        mock_backend.return_value.populate_user.assert_not_called()
+
+
+@override_settings(SENTRY_ENABLED=True)
+class SentryNotFoundReportingTestCase(TestCase):
+
+    def setUp(self):
+        super().setUp()
+        self.sentry_sdk = Mock()
+        patcher = patch.dict(sys.modules, {'sentry_sdk': self.sentry_sdk})
+        patcher.start()
+        self.addCleanup(patcher.stop)
+
+    def test_404_reported_to_sentry(self):
+        with disable_warnings('django.request'):
+            response = self.client.get('/nonexistent-path/')
+
+        self.assertEqual(response.status_code, 404)
+        self.sentry_sdk.capture_message.assert_called_once_with('Page not found', level='error')
+
+    def test_append_slash_redirect_not_reported_to_sentry(self):
+        url = reverse('api-status')
+
+        for method in ('get', 'head'):
+            with self.subTest(method=method):
+                response = getattr(self.client, method)(url.rstrip('/'))
+
+                self.assertEqual(response.status_code, 301)
+                self.assertEqual(response.headers['Location'], url)
+                self.sentry_sdk.capture_message.assert_not_called()
+
+    def test_unsafe_api_request_without_slash_reported_to_sentry(self):
+        with disable_warnings('django.request'):
+            response = self.client.post(
+                reverse('api-status').rstrip('/'), data={}, content_type='application/json'
+            )
+
+        self.assertEqual(response.status_code, 404)
+        self.sentry_sdk.capture_message.assert_called_once_with('Page not found', level='error')
+
+    @override_settings(SENTRY_ENABLED=False)
+    def test_404_not_reported_when_sentry_disabled(self):
+        with disable_warnings('django.request'):
+            response = self.client.get('/nonexistent-path/')
+
+        self.assertEqual(response.status_code, 404)
+        self.sentry_sdk.capture_message.assert_not_called()

+ 69 - 2
netbox/netbox/tests/test_model_features.py

@@ -1,15 +1,17 @@
 import hashlib
 import hashlib
+import uuid
 from unittest import skipIf
 from unittest import skipIf
+from unittest.mock import patch
 
 
 from django.apps import apps
 from django.apps import apps
 from django.conf import settings
 from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
-from django.test import TestCase
+from django.test import RequestFactory, TestCase
 from django.utils.timezone import now
 from django.utils.timezone import now
 from taggit.models import Tag
 from taggit.models import Tag
 
 
-from core.models import AutoSyncRecord, DataFile, DataSource
+from core.models import AutoSyncRecord, DataFile, DataSource, ManagedFile, ObjectChange
 from dcim.models import Site
 from dcim.models import Site
 from extras.models import (
 from extras.models import (
     ConfigContext,
     ConfigContext,
@@ -20,7 +22,9 @@ from extras.models import (
 )
 )
 from ipam.models import Prefix
 from ipam.models import Prefix
 from netbox.constants import CORE_APPS
 from netbox.constants import CORE_APPS
+from netbox.context_managers import event_tracking
 from netbox.models.features import CloningMixin, get_model_features, has_feature, model_is_public
 from netbox.models.features import CloningMixin, get_model_features, has_feature, model_is_public
+from users.models import User
 
 
 
 
 class ModelFeaturesTestCase(TestCase):
 class ModelFeaturesTestCase(TestCase):
@@ -149,6 +153,69 @@ class ModelFeaturesTestCase(TestCase):
                 obj.refresh_from_db()
                 obj.refresh_from_db()
                 self.assertEqual(getattr(obj, field_name), {})
                 self.assertEqual(getattr(obj, field_name), {})
 
 
+    def test_synceddatamixin_sync_records_prechange(self):
+        """Saving a sync records the object's state before the synchronized data was applied. Ref: #23617"""
+        datafile = self._create_data_file('template.j2', b'original\n')
+        obj = ConfigTemplate(name='Test', data_file=datafile)
+        obj.clean()
+        obj.save()
+        DataFile.objects.filter(pk=datafile.pk).update(data=b'updated\n')
+        request = RequestFactory().get('/')
+        request.id = uuid.uuid4()
+        request.user = User.objects.create_user(username='testuser')
+
+        template = ConfigTemplate.objects.get(pk=obj.pk)
+        with event_tracking(request):
+            template.sync(save=True)
+
+        change = ObjectChange.objects.get(
+            changed_object_type=ContentType.objects.get_for_model(ConfigTemplate),
+            changed_object_id=obj.pk,
+        )
+        self.assertEqual(change.prechange_data['template_code'], 'original\n')
+        self.assertEqual(change.postchange_data['template_code'], 'updated\n')
+        self.assertFalse(hasattr(template, '_prechange_snapshot'))
+
+    def test_synceddatamixin_sync_keeps_existing_snapshot(self):
+        """Saving a sync records a snapshot taken by the caller, then discards it. Ref: #23617"""
+        datafile = self._create_data_file('template.j2', b'original\n')
+        obj = ConfigTemplate(name='Test', data_file=datafile)
+        obj.clean()
+        obj.save()
+        DataFile.objects.filter(pk=datafile.pk).update(data=b'updated\n')
+        request = RequestFactory().get('/')
+        request.id = uuid.uuid4()
+        request.user = User.objects.create_user(username='testuser')
+
+        template = ConfigTemplate.objects.get(pk=obj.pk)
+        with self.assertNoLogs('netbox.models.features', level='WARNING'), event_tracking(request):
+            template.snapshot()
+            template.description = 'updated'
+            # Without save, sync() leaves the caller's snapshot for its own save
+            template.sync()
+            self.assertTrue(hasattr(template, '_prechange_snapshot'))
+            template.sync(save=True)
+
+        change = ObjectChange.objects.get(
+            changed_object_type=ContentType.objects.get_for_model(ConfigTemplate),
+            changed_object_id=obj.pk,
+        )
+        self.assertEqual(change.prechange_data['description'], '')
+        self.assertEqual(change.postchange_data['description'], 'updated')
+        self.assertFalse(hasattr(template, '_prechange_snapshot'))
+
+    @patch.object(ManagedFile, 'sync_data')
+    def test_synceddatamixin_sync_without_change_logging(self, mock_sync_data):
+        """Saving a sync succeeds for models which are not change-logged. Ref: #23617"""
+        datafile = self._create_data_file('script.py', b'# script\n')
+        obj = ManagedFile.objects.create(file_root='scripts', file_path='script.py', data_file=datafile)
+
+        obj.sync(save=True)
+
+        mock_sync_data.assert_called_once()
+        obj.refresh_from_db()
+        self.assertIsNotNone(obj.data_synced)
+
     def test_synceddatamixin_accepts_plain_text_template(self):
     def test_synceddatamixin_accepts_plain_text_template(self):
         """Text which is not a YAML document is still valid template content."""
         """Text which is not a YAML document is still valid template content."""
         datafile = self._create_data_file('comment-only.j2', b'{# nothing here #}\n')
         datafile = self._create_data_file('comment-only.j2', b'{# nothing here #}\n')

+ 41 - 5
netbox/netbox/tests/test_search.py

@@ -9,11 +9,13 @@ from django.contrib.contenttypes.models import ContentType
 from django.db import DEFAULT_DB_ALIAS, ProgrammingError, connection, transaction
 from django.db import DEFAULT_DB_ALIAS, ProgrammingError, connection, transaction
 from django.db.models.signals import post_delete, post_save
 from django.db.models.signals import post_delete, post_save
 from django.test import TestCase, TransactionTestCase
 from django.test import TestCase, TransactionTestCase
+from django.test.utils import CaptureQueriesContext
 from redis.exceptions import ConnectionError as RedisConnectionError
 from redis.exceptions import ConnectionError as RedisConnectionError
 
 
-from dcim.models import Manufacturer, Site
+from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
 from dcim.search import SiteIndex
 from dcim.search import SiteIndex
 from extras.models import CachedValue
 from extras.models import CachedValue
+from ipam.models import Prefix
 from netbox.search import deferred
 from netbox.search import deferred
 from netbox.search.backends import SearchBackend, search_backend
 from netbox.search.backends import SearchBackend, search_backend
 from netbox.search.jobs import SearchCacheJob
 from netbox.search.jobs import SearchCacheJob
@@ -193,9 +195,9 @@ class SearchBackendTestCase(TestCase):
         results = search_backend.search('xxxxx')
         results = search_backend.search('xxxxx')
         self.assertEqual(len(results), 0)
         self.assertEqual(len(results), 0)
 
 
-    def test_search_prefetch_display_attrs(self):
+    def test_search_batches_display_attrs(self):
         """
         """
-        Check that ForeignKey fields listed in an indexer's display_attrs are prefetched.
+        Check that a ForeignKey listed in an indexer's display_attrs is fetched for all results with one query.
         """
         """
         tenant = Tenant.objects.create(name='Tenant 1', slug='tenant-1')
         tenant = Tenant.objects.create(name='Tenant 1', slug='tenant-1')
         Site.objects.update(tenant=tenant)
         Site.objects.update(tenant=tenant)
@@ -203,8 +205,42 @@ class SearchBackendTestCase(TestCase):
 
 
         results = search_backend.search('site')
         results = search_backend.search('site')
         self.assertEqual(len(results), 3)
         self.assertEqual(len(results), 3)
-        for result in results:
-            self.assertTrue(Site.tenant.field.is_cached(result.object))
+        with self.assertNumQueries(1):
+            for result in results:
+                self.assertIn(tenant, result.display_attrs.values())
+
+    def test_search_batches_related_objects(self):
+        """
+        Check that the number of queries doesn't grow with the number of results when rendering them reads
+        related objects through __str__() or a GenericForeignKey display attribute.
+        """
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
+        role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+
+        def render_results(sites):
+            # Unnamed devices render as "<manufacturer> <model> (<pk>)"; prefixes display their scope (a site)
+            for site in sites:
+                Device.objects.create(site=site, device_type=device_type, role=role, description='Batched')
+                Prefix.objects.create(prefix=f'10.0.{site.pk}.0/24', scope=site, description='Batched')
+            search_backend.cache(Device.objects.all())
+            search_backend.cache(Prefix.objects.all())
+
+            ContentType.objects.clear_cache()
+            with CaptureQueriesContext(connection) as queries:
+                results = search_backend.search('batched')
+                rendered = [(result.name, list(result.display_attrs.values())) for result in results]
+            return rendered, len(queries)
+
+        sites = list(Site.objects.order_by('name'))
+        rendered, query_count = render_results(sites[:1])
+        self.assertEqual(len(rendered), 2)
+        rendered, more_query_count = render_results(sites[1:])
+        self.assertEqual(len(rendered), 6)
+        self.assertEqual(more_query_count, query_count)
+
+        self.assertEqual(sum(name.startswith('Manufacturer 1 Device Type 1') for name, _ in rendered), 3)
+        self.assertCountEqual([attrs[0] for name, attrs in rendered if name.startswith('10.0.')], sites)
 
 
 
 
 class DeferredCachingTestCase(RQQueueTestMixin, TestCase):
 class DeferredCachingTestCase(RQQueueTestMixin, TestCase):

+ 55 - 4
netbox/netbox/tests/test_views.py

@@ -3,6 +3,7 @@ from contextlib import contextmanager
 from unittest.mock import patch
 from unittest.mock import patch
 
 
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
+from django.contrib.messages import get_messages
 from django.core.files.uploadedfile import SimpleUploadedFile
 from django.core.files.uploadedfile import SimpleUploadedFile
 from django.db.models.signals import pre_delete
 from django.db.models.signals import pre_delete
 from django.http import HttpResponse
 from django.http import HttpResponse
@@ -10,7 +11,7 @@ from django.test import Client, TransactionTestCase, override_settings, tag
 from django.urls import reverse
 from django.urls import reverse
 from django.utils import timezone
 from django.utils import timezone
 
 
-from core.models import DataFile, DataSource, ObjectChange
+from core.models import DataFile, DataSource, ObjectChange, ObjectType
 from dcim.choices import DeviceStatusChoices, InterfaceTypeChoices, SiteStatusChoices
 from dcim.choices import DeviceStatusChoices, InterfaceTypeChoices, SiteStatusChoices
 from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Site, VirtualChassis
 from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Site, VirtualChassis
 from extras.events import enqueue_event
 from extras.events import enqueue_event
@@ -22,9 +23,9 @@ from ipam.views import VLANGroupVLANsView
 from netbox.choices import CSVDelimiterChoices, ImportFormatChoices, ImportMethodChoices
 from netbox.choices import CSVDelimiterChoices, ImportFormatChoices, ImportMethodChoices
 from netbox.constants import EMPTY_TABLE_TEXT
 from netbox.constants import EMPTY_TABLE_TEXT
 from netbox.search.backends import search_backend
 from netbox.search.backends import search_backend
-from users.models import User
+from users.models import ObjectPermission, User
 from utilities.exceptions import AbortRequest
 from utilities.exceptions import AbortRequest
-from utilities.testing import TestCase
+from utilities.testing import TestCase, create_test_device
 from utilities.views import get_action_url
 from utilities.views import get_action_url
 
 
 
 
@@ -450,7 +451,7 @@ class RolledBackWriteEventQueueTestCase(DeleteAtomicityMixin, TestCase):
         # created events for the objects written before the abort.
         # created events for the objects written before the abort.
         group = VLANGroup.objects.create(name='Test Group', slug='test-group')
         group = VLANGroup.objects.create(name='Test Group', slug='test-group')
         VLAN.objects.create(group=group, vid=101, name='VLAN-101')
         VLAN.objects.create(group=group, vid=101, name='VLAN-101')
-        self.add_permissions('ipam.add_vlan', 'ipam.view_vlan')
+        self.add_permissions('ipam.add_vlan', 'ipam.view_vlan', 'ipam.view_vlangroup')
 
 
         with self.assertNoEventsDispatched():
         with self.assertNoEventsDispatched():
             response = self.client.post(reverse('ipam:vlan_bulk_add'), data={
             response = self.client.post(reverse('ipam:vlan_bulk_add'), data={
@@ -600,3 +601,53 @@ class DeleteAtomicityTestCase(DeleteAtomicityMixin, TransactionTestCase):
         # Without the view's atomic block, the member device's cleared vc_priority commits here,
         # Without the view's atomic block, the member device's cleared vc_priority commits here,
         # leaving a change which dispatches no events
         # leaving a change which dispatches no events
         self.assertDeleteRolledBack(virtual_chassis, device)
         self.assertDeleteRolledBack(virtual_chassis, device)
+
+
+class ProtectedObjectsMessageTestCase(TestCase):
+    """
+    The error message for a deletion prevented by dependent objects must name only those dependent objects which the
+    user is permitted to view. (Ref: #23398)
+    """
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.devices = (
+            create_test_device('Device 1'),
+            create_test_device('Device 2'),
+        )
+        cls.device_type = cls.devices[0].device_type
+
+    def setUp(self):
+        super().setUp()
+        self.add_permissions('dcim.view_devicetype', 'dcim.delete_devicetype')
+
+        # Permit viewing only the first Device
+        obj_perm = ObjectPermission.objects.create(
+            name='View Device 1', actions=['view'], constraints={'pk': self.devices[0].pk}
+        )
+        obj_perm.users.add(self.user)
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(Device))
+
+    def assertProtectedObjectsMessage(self, response):
+        self.assertHttpStatus(response, 302)
+        self.assertEqual([str(m) for m in get_messages(response.wsgi_request)], [
+            f'Unable to delete <strong>Device Type 1</strong>. 2 dependent objects were found: '
+            f'<a href="{self.devices[0].get_absolute_url()}">Device 1</a>, '
+            '1 object you do not have permission to view'
+        ])
+
+    def test_get_object_delete_view(self):
+        url = get_action_url(DeviceType, action='delete', kwargs={'pk': self.device_type.pk})
+        self.assertProtectedObjectsMessage(self.client.get(url))
+
+    def test_post_object_delete_view(self):
+        url = get_action_url(DeviceType, action='delete', kwargs={'pk': self.device_type.pk})
+        self.assertProtectedObjectsMessage(self.client.post(url, data={'confirm': True}))
+
+    def test_post_bulk_delete_view(self):
+        response = self.client.post(reverse('dcim:devicetype_bulk_delete'), data={
+            'pk': [self.device_type.pk],
+            'confirm': True,
+            '_confirm': True,
+        })
+        self.assertProtectedObjectsMessage(response)

+ 3 - 4
netbox/netbox/views/errors.py

@@ -31,11 +31,10 @@ class StaticMediaFailureView(View):
 
 
 def handler_404(request, exception):
 def handler_404(request, exception):
     """
     """
-    Wrap Django's default 404 handler to enable Sentry reporting.
+    Wrap Django's default 404 handler to enable Sentry reporting. The report is sent by CommonMiddleware
+    once the final response is known (it may be replaced with an APPEND_SLASH redirect).
     """
     """
-    if settings.SENTRY_ENABLED:
-        from sentry_sdk import capture_message
-        capture_message("Page not found", level="error")
+    request._sentry_report_404 = True
 
 
     return page_not_found(request, exception)
     return page_not_found(request, exception)
 
 

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

@@ -376,6 +376,7 @@ class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView):
             # Reinstantiate the model form each time to avoid overwriting the same instance. Use a mutable
             # Reinstantiate the model form each time to avoid overwriting the same instance. Use a mutable
             # copy of the POST QueryDict so that we can update the target field value.
             # copy of the POST QueryDict so that we can update the target field value.
             model_form = self.model_form(self.get_model_form_data(form, request, value))
             model_form = self.model_form(self.get_model_form_data(form, request, value))
+            restrict_form_fields(model_form, request.user)
 
 
             # Validate each new object independently.
             # Validate each new object independently.
             if model_form.is_valid():
             if model_form.is_valid():
@@ -416,6 +417,7 @@ class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView):
 
 
         form = self.form()
         form = self.form()
         model_form = self.model_form(initial=initial)
         model_form = self.model_form(initial=initial)
+        restrict_form_fields(model_form, request.user)
 
 
         # HTMX partial: only re-render the model form fields
         # HTMX partial: only re-render the model form fields
         if htmx_partial(request):
         if htmx_partial(request):
@@ -430,6 +432,7 @@ class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView):
         model = self.queryset.model
         model = self.queryset.model
         form = self.form(request.POST)
         form = self.form(request.POST)
         model_form = self.model_form(request.POST)
         model_form = self.model_form(request.POST)
+        restrict_form_fields(model_form, request.user)
 
 
         # HTMX partial: only re-render the model form fields
         # HTMX partial: only re-render the model form fields
         if htmx_partial(request):
         if htmx_partial(request):

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

@@ -97,7 +97,7 @@ class ObjectImageAttachmentsView(ConditionalLoginRequiredMixin, View):
 
 
     def get(self, request, model, **kwargs):
     def get(self, request, model, **kwargs):
         obj = get_object_or_404(model.objects.restrict(request.user, 'view'), **kwargs)
         obj = get_object_or_404(model.objects.restrict(request.user, 'view'), **kwargs)
-        image_attachments = ImageAttachment.objects.filter(
+        image_attachments = ImageAttachment.objects.restrict(request.user, 'view').filter(
             object_type=ContentType.objects.get_for_model(obj),
             object_type=ContentType.objects.get_for_model(obj),
             object_id=obj.pk,
             object_id=obj.pk,
         )
         )

+ 6 - 6
netbox/netbox/views/generic/object_views.py

@@ -419,11 +419,11 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView):
 
 
         return dependent_objects
         return dependent_objects
 
 
-    def _handle_protected_objects(self, obj, protected_objects, request, exc):
+    def _handle_protected_objects(self, obj, request, exc):
         """
         """
         Handle a ProtectedError or RestrictedError exception raised while attempt to resolve dependent objects.
         Handle a ProtectedError or RestrictedError exception raised while attempt to resolve dependent objects.
         """
         """
-        handle_protectederror(protected_objects, request, exc)
+        handle_protectederror([obj], request, exc)
 
 
         if request.htmx:
         if request.htmx:
             return HttpResponse(headers={
             return HttpResponse(headers={
@@ -447,10 +447,8 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView):
 
 
         try:
         try:
             dependent_objects = self._get_dependent_objects(obj)
             dependent_objects = self._get_dependent_objects(obj)
-        except ProtectedError as e:
-            return self._handle_protected_objects(obj, e.protected_objects, request, e)
-        except RestrictedError as e:
-            return self._handle_protected_objects(obj, e.restricted_objects, request, e)
+        except (ProtectedError, RestrictedError) as e:
+            return self._handle_protected_objects(obj, request, e)
 
 
         # If this is an HTMX request, return only the rendered deletion form as modal content
         # If this is an HTMX request, return only the rendered deletion form as modal content
         if htmx_partial(request):
         if htmx_partial(request):
@@ -553,6 +551,7 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
         initial_data = normalize_querydict(request.GET)
         initial_data = normalize_querydict(request.GET)
 
 
         form = self.form(data=data, initial=initial_data)
         form = self.form(data=data, initial=initial_data)
+        restrict_form_fields(form, request.user)
 
 
         return form
         return form
 
 
@@ -597,6 +596,7 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
                         data.setlist(k, v)
                         data.setlist(k, v)
 
 
                 component_form = self.model_form(data)
                 component_form = self.model_form(data)
+                restrict_form_fields(component_form, request.user)
 
 
                 if component_form.is_valid():
                 if component_form.is_valid():
                     new_components.append(component_form)
                     new_components.append(component_form)

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


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


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


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

@@ -29,11 +29,11 @@
     "bootstrap": "5.3.8",
     "bootstrap": "5.3.8",
     "clipboard": "2.0.11",
     "clipboard": "2.0.11",
     "flatpickr": "4.6.13",
     "flatpickr": "4.6.13",
-    "gridstack": "14.0.0",
+    "gridstack": "14.1.0",
     "htmx.org": "2.0.11",
     "htmx.org": "2.0.11",
     "query-string": "9.5.1",
     "query-string": "9.5.1",
-    "sass": "1.104.1",
-    "tom-select": "2.6.2",
+    "sass": "1.108.1",
+    "tom-select": "2.7.0",
     "typeface-inter": "3.18.1",
     "typeface-inter": "3.18.1",
     "typeface-roboto-mono": "1.1.13"
     "typeface-roboto-mono": "1.1.13"
   },
   },
@@ -43,17 +43,17 @@
     "@eslint/js": "^9.39.2",
     "@eslint/js": "^9.39.2",
     "@types/bootstrap": "5.2.11",
     "@types/bootstrap": "5.2.11",
     "@types/cookie": "^1.0.0",
     "@types/cookie": "^1.0.0",
-    "@types/node": "^24.10.1",
-    "@typescript-eslint/eslint-plugin": "^8.70.0",
-    "@typescript-eslint/parser": "^8.70.0",
+    "@types/node": "^24.19.2",
+    "@typescript-eslint/eslint-plugin": "^8.72.2",
+    "@typescript-eslint/parser": "^8.72.2",
     "esbuild": "^0.28.2",
     "esbuild": "^0.28.2",
     "esbuild-sass-plugin": "^3.7.0",
     "esbuild-sass-plugin": "^3.7.0",
-    "eslint": "^10.10.0",
+    "eslint": "^10.14.0",
     "eslint-config-prettier": "^10.1.8",
     "eslint-config-prettier": "^10.1.8",
     "eslint-import-resolver-typescript": "^4.4.5",
     "eslint-import-resolver-typescript": "^4.4.5",
     "eslint-plugin-import": "^2.32.0",
     "eslint-plugin-import": "^2.32.0",
-    "eslint-plugin-prettier": "^5.5.6",
-    "globals": "^17.11.0",
+    "eslint-plugin-prettier": "^5.5.7",
+    "globals": "^17.14.0",
     "prettier": "^3.9.10",
     "prettier": "^3.9.10",
     "typescript": "^5.9.3"
     "typescript": "^5.9.3"
   },
   },

+ 123 - 117
netbox/project-static/yarn.lock

@@ -20,6 +20,11 @@
     hashery "^1.5.1"
     hashery "^1.5.1"
     keyv "^5.6.0"
     keyv "^5.6.0"
 
 
+"@dual-bundle/import-meta-resolve@^4.2.1":
+  version "4.2.1"
+  resolved "https://registry.yarnpkg.com/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.2.1.tgz#cd0b25b3808cd9e684cd6cd549bbf8e1dcf05ee7"
+  integrity sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==
+
 "@emnapi/core@^1.4.3":
 "@emnapi/core@^1.4.3":
   version "1.7.1"
   version "1.7.1"
   resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.7.1.tgz#3a79a02dbc84f45884a1806ebb98e5746bdfaac4"
   resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.7.1.tgz#3a79a02dbc84f45884a1806ebb98e5746bdfaac4"
@@ -366,7 +371,7 @@
   dependencies:
   dependencies:
     "@humanfs/types" "^0.15.0"
     "@humanfs/types" "^0.15.0"
 
 
-"@humanfs/node@^0.16.6":
+"@humanfs/node@^0.16.8":
   version "0.16.8"
   version "0.16.8"
   resolved "https://registry.yarnpkg.com/@humanfs/node/-/node-0.16.8.tgz#8f800cccc13f4f8cd3116e2d9c0a94939da3e3ed"
   resolved "https://registry.yarnpkg.com/@humanfs/node/-/node-0.16.8.tgz#8f800cccc13f4f8cd3116e2d9c0a94939da3e3ed"
   integrity sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==
   integrity sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==
@@ -941,12 +946,12 @@
   resolved "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz"
   resolved "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz"
   integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==
   integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==
 
 
-"@types/node@^24.10.1":
-  version "24.10.1"
-  resolved "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz"
-  integrity sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==
+"@types/node@^24.19.2":
+  version "24.19.2"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-24.19.2.tgz#8718eedbc0f89648de6525be9f4a9c61cdbaa502"
+  integrity sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==
   dependencies:
   dependencies:
-    undici-types "~7.16.0"
+    undici-types ">=7.24.0 <7.24.7"
 
 
 "@types/tern@*":
 "@types/tern@*":
   version "0.23.9"
   version "0.23.9"
@@ -955,100 +960,100 @@
   dependencies:
   dependencies:
     "@types/estree" "*"
     "@types/estree" "*"
 
 
-"@typescript-eslint/eslint-plugin@^8.70.0":
-  version "8.70.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz#59f635c74dd1e2bffb6aecbda557b24dadffd3dc"
-  integrity sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==
+"@typescript-eslint/eslint-plugin@^8.72.2":
+  version "8.72.2"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.72.2.tgz#df58c2b8a788d981a4c1b303704ad0bcd4311bea"
+  integrity sha512-222PKFXKihmFpi/pS5njoBgdAqsPhj2/GBmFbrNw8Tq8gEG9Sv+kSlKFy4u2q+mJv9ZzJgM2Gkv/bUzRZwZw5g==
   dependencies:
   dependencies:
     "@eslint-community/regexpp" "^4.12.2"
     "@eslint-community/regexpp" "^4.12.2"
-    "@typescript-eslint/scope-manager" "8.70.0"
-    "@typescript-eslint/type-utils" "8.70.0"
-    "@typescript-eslint/utils" "8.70.0"
-    "@typescript-eslint/visitor-keys" "8.70.0"
+    "@typescript-eslint/scope-manager" "8.72.2"
+    "@typescript-eslint/type-utils" "8.72.2"
+    "@typescript-eslint/utils" "8.72.2"
+    "@typescript-eslint/visitor-keys" "8.72.2"
     ignore "^7.0.5"
     ignore "^7.0.5"
     natural-compare "^1.4.0"
     natural-compare "^1.4.0"
     ts-api-utils "^2.5.0"
     ts-api-utils "^2.5.0"
 
 
-"@typescript-eslint/parser@^8.70.0":
-  version "8.70.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.70.0.tgz#96ce2de96c06c8442fea1a8f7b07855a56b3c5e3"
-  integrity sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==
+"@typescript-eslint/parser@^8.72.2":
+  version "8.72.2"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.72.2.tgz#18284d87b3658e37080f738370de80d24cd08706"
+  integrity sha512-llP+T1YuJOLlxEciP6OyQGtz5SMxyn+9LA2BvZw5RmToGVa1y4beEDVQL6Gje7T5WFYh19WREjDCN9Ev4gm5AA==
   dependencies:
   dependencies:
-    "@typescript-eslint/scope-manager" "8.70.0"
-    "@typescript-eslint/types" "8.70.0"
-    "@typescript-eslint/typescript-estree" "8.70.0"
-    "@typescript-eslint/visitor-keys" "8.70.0"
+    "@typescript-eslint/scope-manager" "8.72.2"
+    "@typescript-eslint/types" "8.72.2"
+    "@typescript-eslint/typescript-estree" "8.72.2"
+    "@typescript-eslint/visitor-keys" "8.72.2"
     debug "^4.4.3"
     debug "^4.4.3"
 
 
-"@typescript-eslint/project-service@8.70.0":
-  version "8.70.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.70.0.tgz#a62e837362f26c604ad15b20bacce1c3f4d6b552"
-  integrity sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==
+"@typescript-eslint/project-service@8.72.2":
+  version "8.72.2"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.72.2.tgz#bea3406880fa8f77a334a62e761b7d2399a80e4c"
+  integrity sha512-6y5dLTZdHhI8Eh3yIQ/bfQhRKwn5e+TpJj64o18Cngc0iW/BCrVvrr6zGYqXPnYH31u6YXAYiFh0+qxX5mybJw==
   dependencies:
   dependencies:
-    "@typescript-eslint/tsconfig-utils" "^8.70.0"
-    "@typescript-eslint/types" "^8.70.0"
+    "@typescript-eslint/tsconfig-utils" "^8.72.2"
+    "@typescript-eslint/types" "^8.72.2"
     debug "^4.4.3"
     debug "^4.4.3"
 
 
-"@typescript-eslint/scope-manager@8.70.0":
-  version "8.70.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.70.0.tgz#b77628b03c9c56ef21fb5a6bc2f4a4335163b999"
-  integrity sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==
+"@typescript-eslint/scope-manager@8.72.2":
+  version "8.72.2"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.72.2.tgz#0e914bc2144f1c7779299715bced62222550b643"
+  integrity sha512-iIoMJ6/8c1/QpFNyFjU0d1YzPco4MsZjQw0k3V+vfzNWAFmUmvhlyMoYEoYYDQZXc+J6Gi1X5XcxlzW2taIIGQ==
   dependencies:
   dependencies:
-    "@typescript-eslint/types" "8.70.0"
-    "@typescript-eslint/visitor-keys" "8.70.0"
+    "@typescript-eslint/types" "8.72.2"
+    "@typescript-eslint/visitor-keys" "8.72.2"
 
 
-"@typescript-eslint/tsconfig-utils@8.70.0", "@typescript-eslint/tsconfig-utils@^8.70.0":
-  version "8.70.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.0.tgz#f583ca72159c4fd8e775c153da3241de6b77974f"
-  integrity sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==
+"@typescript-eslint/tsconfig-utils@8.72.2", "@typescript-eslint/tsconfig-utils@^8.72.2":
+  version "8.72.2"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.2.tgz#319c96d5d7cb0274fdfae0f7f081918c7f3791f5"
+  integrity sha512-+h3iCgZXuTNtoLXcnnjP12ZUHLMqoUcIHPfQrFEXTUqvb9dF8QK9b5nv5VtSo5fLkdHuks8ehbTzZj3rImAm9w==
 
 
-"@typescript-eslint/type-utils@8.70.0":
-  version "8.70.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.70.0.tgz#7f9c01c24e56bfad2a089167926c7cdded9de5f6"
-  integrity sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==
+"@typescript-eslint/type-utils@8.72.2":
+  version "8.72.2"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.72.2.tgz#78ce78ba03ee904714ba8c0c25f05ed2ee766fe2"
+  integrity sha512-STfkR38qIdhfkLpgtXnUjAzsNy4xouqiBO0lrVc/Wpk9Kem5/aHXUx1J8dGC16zpdYN1+yc2ILQ28er1kXX7ag==
   dependencies:
   dependencies:
-    "@typescript-eslint/types" "8.70.0"
-    "@typescript-eslint/typescript-estree" "8.70.0"
-    "@typescript-eslint/utils" "8.70.0"
+    "@typescript-eslint/types" "8.72.2"
+    "@typescript-eslint/typescript-estree" "8.72.2"
+    "@typescript-eslint/utils" "8.72.2"
     debug "^4.4.3"
     debug "^4.4.3"
     ts-api-utils "^2.5.0"
     ts-api-utils "^2.5.0"
 
 
-"@typescript-eslint/types@8.70.0", "@typescript-eslint/types@^8.70.0":
-  version "8.70.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.70.0.tgz#9ee52888cdeca604fe9436935219b967fa7f6053"
-  integrity sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==
+"@typescript-eslint/types@8.72.2", "@typescript-eslint/types@^8.72.2":
+  version "8.72.2"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.72.2.tgz#202b5f8aabe040fbfb5c4a8783b03aa6fe1fc113"
+  integrity sha512-Xfn37OLZx03+PCU7qWx9DTv2FCRh1E6fk2+wONeNY15HXIwtcwuveaSwVpGAUptkNmPQegRxW5JyRel9T7wrog==
 
 
-"@typescript-eslint/typescript-estree@8.70.0":
-  version "8.70.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.0.tgz#79cfcd9678ee28ea69cc13032c0f89bb1d298917"
-  integrity sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==
+"@typescript-eslint/typescript-estree@8.72.2":
+  version "8.72.2"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.2.tgz#8ae05eecd55fb6230a66d6479348d7e206561c98"
+  integrity sha512-pQEGqIK/cqEvKivVWaFLYpe7XYejFxfuAOi4mu1oAWFvIo36aRHI9esRFTUxcIy/z8N482RJyH+LpZB0ZIU7Ow==
   dependencies:
   dependencies:
-    "@typescript-eslint/project-service" "8.70.0"
-    "@typescript-eslint/tsconfig-utils" "8.70.0"
-    "@typescript-eslint/types" "8.70.0"
-    "@typescript-eslint/visitor-keys" "8.70.0"
+    "@typescript-eslint/project-service" "8.72.2"
+    "@typescript-eslint/tsconfig-utils" "8.72.2"
+    "@typescript-eslint/types" "8.72.2"
+    "@typescript-eslint/visitor-keys" "8.72.2"
     debug "^4.4.3"
     debug "^4.4.3"
     minimatch "^10.2.2"
     minimatch "^10.2.2"
     semver "^7.7.3"
     semver "^7.7.3"
     tinyglobby "^0.2.15"
     tinyglobby "^0.2.15"
     ts-api-utils "^2.5.0"
     ts-api-utils "^2.5.0"
 
 
-"@typescript-eslint/utils@8.70.0":
-  version "8.70.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.70.0.tgz#78a78b4c52dd8523e5321993cb46ebe6d7934510"
-  integrity sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==
+"@typescript-eslint/utils@8.72.2":
+  version "8.72.2"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.72.2.tgz#4916bd9a3b315e890cb622ed8f777b783c7b6e5b"
+  integrity sha512-hv3eRYbpPbbpezuax1EfGrzpa3C4ySgNZkPUkLOWOAjU8HsOKowEDAr8C7nsNl1C+SBSKDpvno6VKHnlhvp9iA==
   dependencies:
   dependencies:
     "@eslint-community/eslint-utils" "^4.9.1"
     "@eslint-community/eslint-utils" "^4.9.1"
-    "@typescript-eslint/scope-manager" "8.70.0"
-    "@typescript-eslint/types" "8.70.0"
-    "@typescript-eslint/typescript-estree" "8.70.0"
+    "@typescript-eslint/scope-manager" "8.72.2"
+    "@typescript-eslint/types" "8.72.2"
+    "@typescript-eslint/typescript-estree" "8.72.2"
 
 
-"@typescript-eslint/visitor-keys@8.70.0":
-  version "8.70.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.0.tgz#b451c8aea76dc97fc768b8d9d7f61019bf789628"
-  integrity sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==
+"@typescript-eslint/visitor-keys@8.72.2":
+  version "8.72.2"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.2.tgz#d02c039ac3642d3b6038ef85d8b9f13263edbfb5"
+  integrity sha512-NAGrjn0Pitjqzq/GkgMC4kBYm233PQ8GffyQ9+kTA3AlNQYEzOf25EIM02BAHZv5FRJSpHpJk+OGUxVkOB93GQ==
   dependencies:
   dependencies:
-    "@typescript-eslint/types" "8.70.0"
+    "@typescript-eslint/types" "8.72.2"
     eslint-visitor-keys "^5.0.0"
     eslint-visitor-keys "^5.0.0"
 
 
 "@unrs/resolver-binding-android-arm-eabi@1.11.1":
 "@unrs/resolver-binding-android-arm-eabi@1.11.1":
@@ -1316,9 +1321,9 @@ brace-expansion@^1.1.7:
     concat-map "0.0.1"
     concat-map "0.0.1"
 
 
 brace-expansion@^5.0.8:
 brace-expansion@^5.0.8:
-  version "5.0.9"
-  resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.9.tgz#7c72438809b5fa5babf54199a1f1c281a6984fcf"
-  integrity sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==
+  version "5.0.12"
+  resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.12.tgz#995fbb4750a77c4d16a7dc942ff2ca6ef8e675ec"
+  integrity sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==
   dependencies:
   dependencies:
     balanced-match "^4.0.2"
     balanced-match "^4.0.2"
 
 
@@ -1528,9 +1533,9 @@ debug@^4.3.1, debug@^4.3.2, debug@^4.4.1, debug@^4.4.3:
     ms "^2.1.3"
     ms "^2.1.3"
 
 
 decode-uri-component@^0.5.0:
 decode-uri-component@^0.5.0:
-  version "0.5.0"
-  resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.5.0.tgz#4592fa1e1d640ec5e2760e2e168ad2ab5f2c9da1"
-  integrity sha512-1BiQVoK8C9gUbQU6NzAtO/tkz2qOFpEObMWpcFvhx4fYnj4Oc5yzaJN/LD36ihkVUdXyh5ZekzX+yM+ty/SrPg==
+  version "0.5.1"
+  resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.5.1.tgz#7f66b25bf435df723f9af55713dcf860a1c297d2"
+  integrity sha512-vWHQkXgg2cE3BB4rR0c+PYw9BCQzLoNIRMI7AunIkZst74tw/vpPp7VLoYtsR/cy1f55g3XDWUslfeCZjiZJyA==
 
 
 deep-is@^0.1.3:
 deep-is@^0.1.3:
   version "0.1.4"
   version "0.1.4"
@@ -1897,13 +1902,13 @@ eslint-plugin-import@^2.32.0:
     string.prototype.trimend "^1.0.9"
     string.prototype.trimend "^1.0.9"
     tsconfig-paths "^3.15.0"
     tsconfig-paths "^3.15.0"
 
 
-eslint-plugin-prettier@^5.5.6:
-  version "5.5.6"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.6.tgz#363ebe4d769bce157ccdd8129ce3efd91dc62564"
-  integrity sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==
+eslint-plugin-prettier@^5.5.7:
+  version "5.5.7"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.7.tgz#ebef3acc2d664333b1356b717780198abc635398"
+  integrity sha512-WjHyn3GrFd+ZR0rzsLiM4zJZ35PZMOE0SUIxzGmnWNG0126yNfJKJzObmmkYDwHJfGlkz3PSlzyG9ez/LJntwg==
   dependencies:
   dependencies:
     prettier-linter-helpers "^1.0.1"
     prettier-linter-helpers "^1.0.1"
-    synckit "^0.11.13"
+    synckit "^0.12.3"
 
 
 eslint-scope@^9.1.2:
 eslint-scope@^9.1.2:
   version "9.1.2"
   version "9.1.2"
@@ -1930,10 +1935,10 @@ eslint-visitor-keys@^5.0.0, eslint-visitor-keys@^5.0.1:
   resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be"
   resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be"
   integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==
   integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==
 
 
-eslint@^10.10.0:
-  version "10.10.0"
-  resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.10.0.tgz#14b1d1849eedb2ee6805f3759050479ce2b23d71"
-  integrity sha512-NPXn6r5zl4uET1DAVPaOwzX3rut4c0wcmw3dWJAfOsTM5+TogXo0DDjz8pwm/hL8cyVNpHqeK4JpN0NjnyFFNw==
+eslint@^10.14.0:
+  version "10.14.0"
+  resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.14.0.tgz#b7574df08d11d9eaa04222f09606674a0ea51db9"
+  integrity sha512-T0pNsxkb9IKmtQjhpK3d7nWOpFv+YHuMbTJnSVMoQblF0onvXfErrtp4V1jHtsQPan7dKaGVD0q1WCXnzMF31A==
   dependencies:
   dependencies:
     "@eslint-community/eslint-utils" "^4.8.0"
     "@eslint-community/eslint-utils" "^4.8.0"
     "@eslint-community/regexpp" "^4.12.2"
     "@eslint-community/regexpp" "^4.12.2"
@@ -1941,7 +1946,7 @@ eslint@^10.10.0:
     "@eslint/config-helpers" "^0.7.0"
     "@eslint/config-helpers" "^0.7.0"
     "@eslint/core" "^1.2.1"
     "@eslint/core" "^1.2.1"
     "@eslint/plugin-kit" "^0.7.3"
     "@eslint/plugin-kit" "^0.7.3"
-    "@humanfs/node" "^0.16.6"
+    "@humanfs/node" "^0.16.8"
     "@humanwhocodes/module-importer" "^1.0.1"
     "@humanwhocodes/module-importer" "^1.0.1"
     "@humanwhocodes/retry" "^0.4.2"
     "@humanwhocodes/retry" "^0.4.2"
     "@types/estree" "^1.0.6"
     "@types/estree" "^1.0.6"
@@ -1962,7 +1967,7 @@ eslint@^10.10.0:
     imurmurhash "^0.1.4"
     imurmurhash "^0.1.4"
     is-glob "^4.0.0"
     is-glob "^4.0.0"
     json-stable-stringify-without-jsonify "^1.0.1"
     json-stable-stringify-without-jsonify "^1.0.1"
-    minimatch "^10.2.5"
+    minimatch "^10.2.6"
     natural-compare "^1.4.0"
     natural-compare "^1.4.0"
     optionator "^0.9.3"
     optionator "^0.9.3"
 
 
@@ -2218,10 +2223,10 @@ globals@^14.0.0:
   resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e"
   resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e"
   integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==
   integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==
 
 
-globals@^17.11.0:
-  version "17.12.0"
-  resolved "https://registry.yarnpkg.com/globals/-/globals-17.12.0.tgz#910a368b8f093d3feb3f77a56dcefa05647d698e"
-  integrity sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==
+globals@^17.14.0:
+  version "17.14.0"
+  resolved "https://registry.yarnpkg.com/globals/-/globals-17.14.0.tgz#5bbbe515622ba24229bd94d4a7c441740e1b50dd"
+  integrity sha512-UtdkTVyAJePQv0J2M9Xe4L0ruNSDV314XiIJsADI2Zf40bcdAonE/D/OhAU8AT99oij9mhLByb+NvA3QWZYgCg==
 
 
 globalthis@^1.0.3, globalthis@^1.0.4:
 globalthis@^1.0.3, globalthis@^1.0.4:
   version "1.0.4"
   version "1.0.4"
@@ -2279,10 +2284,10 @@ graphql@16.12.0:
   resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.12.0.tgz#28cc2462435b1ac3fdc6976d030cef83a0c13ac7"
   resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.12.0.tgz#28cc2462435b1ac3fdc6976d030cef83a0c13ac7"
   integrity sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==
   integrity sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==
 
 
-gridstack@14.0.0:
-  version "14.0.0"
-  resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-14.0.0.tgz#0970c855c7a7af2c3812c224e3a23fa0840b80c3"
-  integrity sha512-jkoGugExoCUHNbC3IMQEHUtf3+quii8BI+tTsP8h0bpHjf3Jr3KdCUbHpmqGz3VVHEb5tqciL6foBLum8/32FQ==
+gridstack@14.1.0:
+  version "14.1.0"
+  resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-14.1.0.tgz#ddbca94a316b065e8033c32f3edd96fb490bae4b"
+  integrity sha512-nF6asbGVnIJH7N9pa+DCaSBGAoEvTNS7jYHUJfcNN+5bTD3a4pLEKvGia7W/6NbjiJWms9e3Zih1YuU4coT3QA==
 
 
 has-bigints@^1.0.1, has-bigints@^1.0.2:
 has-bigints@^1.0.1, has-bigints@^1.0.2:
   version "1.0.2"
   version "1.0.2"
@@ -2365,9 +2370,9 @@ ignore@^7.0.5:
   integrity sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==
   integrity sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==
 
 
 immutable@^5.1.5:
 immutable@^5.1.5:
-  version "5.1.5"
-  resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.1.5.tgz#93ee4db5c2a9ab42a4a783069f3c5d8847d40165"
-  integrity sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==
+  version "5.1.10"
+  resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.1.10.tgz#0b7eb66f4abb8073449dad6ead9f98167256c007"
+  integrity sha512-Uu72qZXUtm+n98uML3Xr6pVFHl4xnmeCuAsrm0VqFlQim/Ib6NrPSJLpx6CrZ/G6DnoGaCUgtx8gY+o+ovxnCw==
 
 
 import-fresh@^3.2.1:
 import-fresh@^3.2.1:
   version "3.3.1"
   version "3.3.1"
@@ -2815,7 +2820,7 @@ micromatch@^4.0.5:
     braces "^3.0.3"
     braces "^3.0.3"
     picomatch "^2.3.1"
     picomatch "^2.3.1"
 
 
-minimatch@^10.2.2, minimatch@^10.2.4, minimatch@^10.2.5:
+minimatch@^10.2.2, minimatch@^10.2.4, minimatch@^10.2.6:
   version "10.2.6"
   version "10.2.6"
   resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.6.tgz#fd956bbe0b77241e9f15ac5dccb1c638060968ef"
   resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.6.tgz#fd956bbe0b77241e9f15ac5dccb1c638060968ef"
   integrity sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==
   integrity sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==
@@ -3247,10 +3252,10 @@ safe-regex-test@^1.1.0:
     es-errors "^1.3.0"
     es-errors "^1.3.0"
     is-regex "^1.2.1"
     is-regex "^1.2.1"
 
 
-sass@1.104.1:
-  version "1.104.1"
-  resolved "https://registry.yarnpkg.com/sass/-/sass-1.104.1.tgz#29a4bdb33c48a8bb656e300a33afcafbfcbc8491"
-  integrity sha512-yDA+1aIG3EHgN4V/BvuhCvu61FF6hEd4e+9DxikUm9U0CAGuvdIZ/UYy7qbOxjhbbWQraoyLlMuzdRGOSV5Bmw==
+sass@1.108.1:
+  version "1.108.1"
+  resolved "https://registry.yarnpkg.com/sass/-/sass-1.108.1.tgz#169e456df9ad50bac4593435a207106a59b645a1"
+  integrity sha512-sIYgSvOPeD9wABwDS/ofNjHASSck+7h3jLkk7wG1NPFL3XeS6vOQjBlAFSmRV09kZr1yt/Tuayt6kKtBbLjHNQ==
   dependencies:
   dependencies:
     chokidar "^5.0.0"
     chokidar "^5.0.0"
     immutable "^5.1.5"
     immutable "^5.1.5"
@@ -3398,9 +3403,9 @@ side-channel@^1.1.0:
     side-channel-weakmap "^1.0.2"
     side-channel-weakmap "^1.0.2"
 
 
 "source-map-js@>=0.6.2 <2.0.0":
 "source-map-js@>=0.6.2 <2.0.0":
-  version "1.2.0"
-  resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz"
-  integrity sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==
+  version "1.2.2"
+  resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.2.tgz#79fb88d97535a21cb27162cb05d211b67acea4ab"
+  integrity sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==
 
 
 split-on-first@^3.0.0:
 split-on-first@^3.0.0:
   version "3.0.0"
   version "3.0.0"
@@ -3486,11 +3491,12 @@ supports-preserve-symlinks-flag@^1.0.0:
   resolved "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz"
   resolved "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz"
   integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
   integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
 
 
-synckit@^0.11.13:
-  version "0.11.13"
-  resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.11.13.tgz#062a5ea57d81befc35892f8254de5c567e97c80a"
-  integrity sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==
+synckit@^0.12.3:
+  version "0.12.3"
+  resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.12.3.tgz#6e30a3441d095ed04c311077350ce205465512d6"
+  integrity sha512-U+/AXT6LWsxnj2vz9HeUuPWBNgdWxjyIEjEPdbJARC9txmgPzo2VEdqnFo4hHaNyMPtycyVJrSaQYeQv/PoyyA==
   dependencies:
   dependencies:
+    "@dual-bundle/import-meta-resolve" "^4.2.1"
     "@pkgr/core" "^0.3.6"
     "@pkgr/core" "^0.3.6"
 
 
 tabbable@^6.0.0:
 tabbable@^6.0.0:
@@ -3531,10 +3537,10 @@ toggle-selection@^1.0.6:
   resolved "https://registry.yarnpkg.com/toggle-selection/-/toggle-selection-1.0.6.tgz#6e45b1263f2017fa0acc7d89d78b15b8bf77da32"
   resolved "https://registry.yarnpkg.com/toggle-selection/-/toggle-selection-1.0.6.tgz#6e45b1263f2017fa0acc7d89d78b15b8bf77da32"
   integrity sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==
   integrity sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==
 
 
-tom-select@2.6.2:
-  version "2.6.2"
-  resolved "https://registry.yarnpkg.com/tom-select/-/tom-select-2.6.2.tgz#07693442309eefa5b57c6beee6005195f3f956a4"
-  integrity sha512-eiXIkk82XzzUTOVQDFpy+ZGejuqdTvMukyUyeZBQaKnqW42UoTiTZnrod/eX0rkqJEc4rZY9DZ3zdjV07i29wQ==
+tom-select@2.7.0:
+  version "2.7.0"
+  resolved "https://registry.yarnpkg.com/tom-select/-/tom-select-2.7.0.tgz#87325ad8f308c1bdba3d1b8941171c3a485e4a94"
+  integrity sha512-i/iuTfrzYfGPAILfgPdU9/CCSm8XcnomjO2bHjAcFQPV7od0ghVngjaMfB5x5Agkrpe+49/tK6soegrwWyi6Lg==
   dependencies:
   dependencies:
     "@orchidjs/sifter" "^1.1.0"
     "@orchidjs/sifter" "^1.1.0"
     "@orchidjs/unicode-variants" "^1.1.2"
     "@orchidjs/unicode-variants" "^1.1.2"
@@ -3695,10 +3701,10 @@ unbox-primitive@^1.1.0:
     has-symbols "^1.1.0"
     has-symbols "^1.1.0"
     which-boxed-primitive "^1.1.1"
     which-boxed-primitive "^1.1.1"
 
 
-undici-types@~7.16.0:
-  version "7.16.0"
-  resolved "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz"
-  integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==
+"undici-types@>=7.24.0 <7.24.7":
+  version "7.24.6"
+  resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.24.6.tgz#61275b485d7fd4e9d269c7cf04ec2873c9cc0f91"
+  integrity sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==
 
 
 unrs-resolver@^1.7.11:
 unrs-resolver@^1.7.11:
   version "1.11.1"
   version "1.11.1"

+ 2 - 2
netbox/release.yaml

@@ -1,3 +1,3 @@
-version: "4.7.4"
+version: "4.7.5"
 edition: "Community"
 edition: "Community"
-published: "2026-10-27"
+published: "2026-11-10"

+ 2 - 1
netbox/templates/500.html

@@ -5,6 +5,7 @@
 
 
 <head>
 <head>
   <title>{% trans "Server Error" %}</title>
   <title>{% trans "Server Error" %}</title>
+  <link rel="stylesheet" href="{% static 'netbox-external.css' %}" />
   <link rel="stylesheet" href="{% static 'netbox.css'%}" />
   <link rel="stylesheet" href="{% static 'netbox.css'%}" />
   <meta charset="UTF-8">
   <meta charset="UTF-8">
 </head>
 </head>
@@ -15,7 +16,7 @@
       <div class="col col-md-6 offset-md-3">
       <div class="col col-md-6 offset-md-3">
         <div class="card border-danger mt-5">
         <div class="card border-danger mt-5">
           <h2 class="card-header text-bg-danger">
           <h2 class="card-header text-bg-danger">
-            <i class="mdi mdi-alert"></i> {% trans "Server Error" %}
+            <i class="mdi mdi-alert me-1"></i> {% trans "Server Error" %}
           </h2>
           </h2>
           <div class="card-body">
           <div class="card-body">
             {% block message %}
             {% block message %}

+ 35 - 0
netbox/templates/exceptions/maintenance_mode.html

@@ -0,0 +1,35 @@
+{% load i18n %}
+{% load static %}
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+  <title>{% trans "Maintenance Mode" %}</title>
+  <link rel="stylesheet" href="{% static 'netbox-external.css' %}" />
+  <link rel="stylesheet" href="{% static 'netbox.css'%}" />
+  <meta charset="UTF-8">
+</head>
+
+<body>
+  <div class="container-fluid">
+    <div class="row">
+      <div class="col col-md-6 offset-md-3">
+        <div class="card border-warning mt-5">
+          <h2 class="card-header text-bg-warning">
+            <i class="mdi mdi-wrench me-1"></i> {% trans "Maintenance Mode" %}
+          </h2>
+          <div class="card-body">
+            <p>
+              {% trans "NetBox is currently operating in maintenance mode and is unable to perform write operations. Please try again later." %}
+            </p>
+            <div class="text-end">
+              <a href="{% url 'home' %}" class="btn btn-primary">{% trans "Home Page" %}</a>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</body>
+
+</html>

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