Selaa lähdekoodia

Merge branch 'main' into feature

Jeremy Stretch 2 viikkoa sitten
vanhempi
commit
bf954f08d6
100 muutettua tiedostoa jossa 5359 lisäystä ja 483 poistoa
  1. 1 1
      .github/ISSUE_TEMPLATE/01-feature_request.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/02-bug_report.yaml
  3. 1 1
      .github/ISSUE_TEMPLATE/03-performance.yaml
  4. 21 0
      .github/workflows/no-blank-issue.yml
  5. 5 0
      base_requirements.txt
  6. 8 0
      contrib/generated_schema.json
  7. 459 33
      contrib/openapi.json
  8. 4 1
      docs/installation/2-redis.md
  9. 4 4
      docs/installation/upgrading.md
  10. 6 0
      docs/models/ipam/vlan.md
  11. 21 18
      docs/plugins/development/ui-components.md
  12. 46 0
      docs/release-notes/version-4.6.md
  13. 14 15
      netbox/circuits/graphql/filters.py
  14. 51 4
      netbox/core/api/schema.py
  15. 1 1
      netbox/core/apps.py
  16. 26 0
      netbox/core/checks.py
  17. 0 9
      netbox/core/forms/model_forms.py
  18. 2 3
      netbox/core/graphql/filter_mixins.py
  19. 15 16
      netbox/core/graphql/filters.py
  20. 45 4
      netbox/core/tests/test_api.py
  21. 73 3
      netbox/core/tests/test_views.py
  22. 17 4
      netbox/core/views.py
  23. 8 0
      netbox/dcim/choices.py
  24. 2 1
      netbox/dcim/forms/bulk_import.py
  25. 6 6
      netbox/dcim/graphql/filter_mixins.py
  26. 41 41
      netbox/dcim/graphql/filters.py
  27. 1 1
      netbox/dcim/migrations/0240_device__config_context_data.py
  28. 4 1
      netbox/dcim/models/cables.py
  29. 19 4
      netbox/dcim/models/device_components.py
  30. 11 0
      netbox/dcim/models/modules.py
  31. 0 3
      netbox/dcim/models/racks.py
  32. 1 1
      netbox/dcim/tests/query_counts.json
  33. 71 2
      netbox/dcim/tests/test_api.py
  34. 9 0
      netbox/dcim/tests/test_forms.py
  35. 167 0
      netbox/dcim/tests/test_models.py
  36. 3 3
      netbox/dcim/ui/panels.py
  37. 8 2
      netbox/extras/events.py
  38. 22 1
      netbox/extras/fields.py
  39. 1 1
      netbox/extras/forms/bulk_edit.py
  40. 21 2
      netbox/extras/forms/model_forms.py
  41. 1 2
      netbox/extras/forms/scripts.py
  42. 30 0
      netbox/extras/graphql/filter_lookups.py
  43. 58 58
      netbox/extras/graphql/filters.py
  44. 27 1
      netbox/extras/lookups.py
  45. 18 0
      netbox/extras/migrations/0139_alter_customfieldchoiceset_extra_choices.py
  46. 4 6
      netbox/extras/models/customfields.py
  47. 5 1
      netbox/extras/models/models.py
  48. 58 0
      netbox/extras/tests/test_api.py
  49. 206 6
      netbox/extras/tests/test_event_rules.py
  50. 50 1
      netbox/extras/tests/test_forms.py
  51. 31 0
      netbox/extras/tests/test_lookups.py
  52. 20 0
      netbox/extras/tests/test_models.py
  53. 55 3
      netbox/extras/tests/test_views.py
  54. 8 0
      netbox/extras/views.py
  55. 1 1
      netbox/ipam/api/views.py
  56. 5 1
      netbox/ipam/fields.py
  57. 6 8
      netbox/ipam/filtersets.py
  58. 17 1
      netbox/ipam/forms/bulk_create.py
  59. 21 0
      netbox/ipam/forms/model_forms.py
  60. 20 21
      netbox/ipam/graphql/filters.py
  61. 2 2
      netbox/ipam/graphql/types.py
  62. 33 2
      netbox/ipam/lookups.py
  63. 2 2
      netbox/ipam/managers.py
  64. 4 1
      netbox/ipam/migrations/0089_default_ordering_indexes.py
  65. 58 0
      netbox/ipam/migrations/0091_alter_service_index_and_ordering.py
  66. 34 0
      netbox/ipam/migrations/0092_iprange_host_indexes.py
  67. 1 1
      netbox/ipam/migrations/0093_denormalization_triggers.py
  68. 251 72
      netbox/ipam/models/ip.py
  69. 14 3
      netbox/ipam/models/services.py
  70. 1 1
      netbox/ipam/models/vlans.py
  71. 190 1
      netbox/ipam/querysets.py
  72. 29 0
      netbox/ipam/tests/test_fields.py
  73. 40 1
      netbox/ipam/tests/test_filtersets.py
  74. 29 1
      netbox/ipam/tests/test_forms.py
  75. 134 1
      netbox/ipam/tests/test_lookups.py
  76. 1000 3
      netbox/ipam/tests/test_models.py
  77. 344 0
      netbox/ipam/tests/test_querysets.py
  78. 475 0
      netbox/ipam/tests/test_views.py
  79. 11 22
      netbox/ipam/utils.py
  80. 95 10
      netbox/ipam/views.py
  81. 68 0
      netbox/netbox/api/serializers/bulk.py
  82. 15 0
      netbox/netbox/api/viewsets/mixins.py
  83. 114 9
      netbox/netbox/graphql/filter_lookups.py
  84. 2 3
      netbox/netbox/graphql/filter_mixins.py
  85. 9 9
      netbox/netbox/graphql/filters.py
  86. 12 6
      netbox/netbox/graphql/schema.py
  87. 13 0
      netbox/netbox/middleware.py
  88. 9 5
      netbox/netbox/models/deletion.py
  89. 8 0
      netbox/netbox/settings.py
  90. 57 1
      netbox/netbox/tests/test_authentication.py
  91. 195 7
      netbox/netbox/tests/test_graphql.py
  92. 10 0
      netbox/netbox/tests/test_models.py
  93. 24 0
      netbox/netbox/tests/test_ui.py
  94. 68 1
      netbox/netbox/tests/test_views.py
  95. 17 0
      netbox/netbox/ui/attrs.py
  96. 99 14
      netbox/netbox/views/generic/bulk_views.py
  97. 29 2
      netbox/netbox/views/misc.py
  98. 0 0
      netbox/project-static/dist/netbox.js
  99. 0 0
      netbox/project-static/dist/netbox.js.map
  100. 6 6
      netbox/project-static/package.json

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

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

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

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

+ 1 - 1
.github/ISSUE_TEMPLATE/03-performance.yaml

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

+ 21 - 0
.github/workflows/no-blank-issue.yml

@@ -0,0 +1,21 @@
+name: Enforce issue templates
+
+on:
+  issues:
+    types:
+      - opened
+      - reopened
+
+permissions:
+  issues: write
+
+jobs:
+  no-blank-issue:
+    name: No Blank Issue
+    runs-on: ubuntu-slim
+
+    steps:
+      - name: Close new issues without labels
+        uses: ldez/no-blank-issue@800e2d0c81c9e0ca7bdb58f3e7480a74602d91e0  # v1.2.0
+        with:
+          github-token: ${{ secrets.GITHUB_TOKEN }}

+ 5 - 0
base_requirements.txt

@@ -141,6 +141,11 @@ psycopg[c,pool]
 # https://github.com/yaml/pyyaml/blob/master/CHANGES
 # https://github.com/yaml/pyyaml/blob/master/CHANGES
 PyYAML
 PyYAML
 
 
+# redis-py
+# https://github.com/redis/redis-py
+# Default protocol changes to RESP3 in v8.0; see #22388
+redis<8.0
+
 # Requests
 # Requests
 # https://github.com/psf/requests/blob/main/HISTORY.md
 # https://github.com/psf/requests/blob/main/HISTORY.md
 requests
 requests

+ 8 - 0
contrib/generated_schema.json

@@ -604,6 +604,10 @@
                         "lc-pc",
                         "lc-pc",
                         "lc-upc",
                         "lc-upc",
                         "lc-apc",
                         "lc-apc",
+                        "mu",
+                        "mu-pc",
+                        "mu-upc",
+                        "mu-apc",
                         "lsh",
                         "lsh",
                         "lsh-pc",
                         "lsh-pc",
                         "lsh-upc",
                         "lsh-upc",
@@ -672,6 +676,10 @@
                         "lc-pc",
                         "lc-pc",
                         "lc-upc",
                         "lc-upc",
                         "lc-apc",
                         "lc-apc",
+                        "mu",
+                        "mu-pc",
+                        "mu-upc",
+                        "mu-apc",
                         "lsh",
                         "lsh",
                         "lsh-pc",
                         "lsh-pc",
                         "lsh-upc",
                         "lsh-upc",

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 459 - 33
contrib/openapi.json


+ 4 - 1
docs/installation/2-redis.md

@@ -8,7 +8,10 @@
 sudo apt install -y redis-server
 sudo apt install -y redis-server
 ```
 ```
 
 
-Before continuing, verify that your installed version of Redis is at least v4.0:
+Before continuing, verify that your installed version of Redis is at least v6.0:
+
+!!! warning "Redis v5.x is deprecated"
+    Support for Redis versions older than 6.0 is deprecated and will be removed in NetBox v4.7.
 
 
 ```no-highlight
 ```no-highlight
 redis-server -v
 redis-server -v

+ 4 - 4
docs/installation/upgrading.md

@@ -40,10 +40,10 @@ NetBox requires the following dependencies:
 
 
 | NetBox Version | Python min | Python max | PostgreSQL min | Redis min |                                       Documentation                                       |
 | NetBox Version | Python min | Python max | PostgreSQL min | Redis min |                                       Documentation                                       |
 |:--------------:|:----------:|:----------:|:--------------:|:---------:|:-----------------------------------------------------------------------------------------:|
 |:--------------:|:----------:|:----------:|:--------------:|:---------:|:-----------------------------------------------------------------------------------------:|
-|      4.6       |    3.12    |    3.14    |       14       |    4.0    | [Link](https://github.com/netbox-community/netbox/blob/v4.6.0/docs/installation/index.md) |
-|      4.5       |    3.12    |    3.14    |       14       |    4.0    | [Link](https://github.com/netbox-community/netbox/blob/v4.5.0/docs/installation/index.md) |
-|      4.4       |    3.10    |    3.12    |       14       |    4.0    | [Link](https://github.com/netbox-community/netbox/blob/v4.4.0/docs/installation/index.md) |
-|      4.3       |    3.10    |    3.12    |       14       |    4.0    | [Link](https://github.com/netbox-community/netbox/blob/v4.3.0/docs/installation/index.md) |
+|      4.6       |    3.12    |    3.14    |       14       |    5.0    | [Link](https://github.com/netbox-community/netbox/blob/v4.6.0/docs/installation/index.md) |
+|      4.5       |    3.12    |    3.14    |       14       |    5.0    | [Link](https://github.com/netbox-community/netbox/blob/v4.5.0/docs/installation/index.md) |
+|      4.4       |    3.10    |    3.12    |       14       |    5.0    | [Link](https://github.com/netbox-community/netbox/blob/v4.4.0/docs/installation/index.md) |
+|      4.3       |    3.10    |    3.12    |       14       |    5.0    | [Link](https://github.com/netbox-community/netbox/blob/v4.3.0/docs/installation/index.md) |
 |      4.2       |    3.10    |    3.12    |       13       |    4.0    | [Link](https://github.com/netbox-community/netbox/blob/v4.2.0/docs/installation/index.md) |
 |      4.2       |    3.10    |    3.12    |       13       |    4.0    | [Link](https://github.com/netbox-community/netbox/blob/v4.2.0/docs/installation/index.md) |
 |      4.1       |    3.10    |    3.12    |       12       |    4.0    | [Link](https://github.com/netbox-community/netbox/blob/v4.1.0/docs/installation/index.md) |
 |      4.1       |    3.10    |    3.12    |       12       |    4.0    | [Link](https://github.com/netbox-community/netbox/blob/v4.1.0/docs/installation/index.md) |
 |      4.0       |    3.10    |    3.12    |       12       |    4.0    | [Link](https://github.com/netbox-community/netbox/blob/v4.0.0/docs/installation/index.md) |
 |      4.0       |    3.10    |    3.12    |       12       |    4.0    | [Link](https://github.com/netbox-community/netbox/blob/v4.0.0/docs/installation/index.md) |

+ 6 - 0
docs/models/ipam/vlan.md

@@ -2,6 +2,12 @@
 
 
 A Virtual LAN (VLAN) represents an isolated layer two domain, identified by a name and a numeric ID (1-4094) as defined in [IEEE 802.1Q](https://en.wikipedia.org/wiki/IEEE_802.1Q). VLANs are arranged into [VLAN groups](./vlangroup.md) to define scope and to enforce uniqueness.
 A Virtual LAN (VLAN) represents an isolated layer two domain, identified by a name and a numeric ID (1-4094) as defined in [IEEE 802.1Q](https://en.wikipedia.org/wiki/IEEE_802.1Q). VLANs are arranged into [VLAN groups](./vlangroup.md) to define scope and to enforce uniqueness.
 
 
+## Bulk Creation
+
+Multiple VLANs can be created at once by selecting the "Bulk Create" tab on the VLAN creation form. Enter the desired VLAN IDs and/or ID ranges as a comma-separated list (e.g. `100,200-210,4000-4010`). The string `{vid}` may be embedded in the name field as a placeholder for each VLAN's ID; for example, `VLAN-{vid}` yields `VLAN-100`, `VLAN-200`, and so on. All other attributes (status, role, tenant, etc.) are applied to every new VLAN.
+
+The operation is atomic: if any VLAN fails validation (for example, a VLAN ID falling outside the assigned group's permitted ranges), no VLANs are created.
+
 ## Fields
 ## Fields
 
 
 ### ID
 ### ID

+ 21 - 18
docs/plugins/development/ui-components.md

@@ -135,29 +135,32 @@ panels.ObjectsTablePanel(
 
 
 The following classes are available to represent object attributes within an ObjectAttributesPanel. Additionally, plugins can subclass `netbox.ui.attrs.ObjectAttribute` to create custom classes.
 The following classes are available to represent object attributes within an ObjectAttributesPanel. Additionally, plugins can subclass `netbox.ui.attrs.ObjectAttribute` to create custom classes.
 
 
-| Class                                    | Description                                      |
-|------------------------------------------|--------------------------------------------------|
-| `netbox.ui.attrs.AddressAttr`            | A physical or mailing address.                   |
-| `netbox.ui.attrs.BooleanAttr`            | A boolean value                                  |
-| `netbox.ui.attrs.ChoiceAttr`             | A selection from a set of choices                |
-| `netbox.ui.attrs.ColorAttr`              | A color expressed in RGB                         |
-| `netbox.ui.attrs.DateTimeAttr`           | A date or datetime value                         |
-| `netbox.ui.attrs.GenericForeignKeyAttr`  | A related object via a generic foreign key       |
-| `netbox.ui.attrs.GPSCoordinatesAttr`     | GPS coordinates (latitude and longitude)         |
-| `netbox.ui.attrs.ImageAttr`              | An attached image (displays the image)           |
-| `netbox.ui.attrs.NestedObjectAttr`       | A related nested object (includes ancestors)     |
-| `netbox.ui.attrs.NumericAttr`            | An integer or float value                        |
-| `netbox.ui.attrs.RelatedObjectAttr`      | A related object                                 |
-| `netbox.ui.attrs.RelatedObjectListAttr`  | A list of related objects                        |
-| `netbox.ui.attrs.TemplatedAttr`          | Renders an attribute using a custom template     |
-| `netbox.ui.attrs.TextAttr`               | A string (text) value                            |
-| `netbox.ui.attrs.TimezoneAttr`           | A timezone with annotated offset                 |
-| `netbox.ui.attrs.UtilizationAttr`        | A numeric value expressed as a utilization graph |
+| Class                                   | Description                                         |
+|-----------------------------------------|-----------------------------------------------------|
+| `netbox.ui.attrs.AddressAttr`           | A physical or mailing address.                      |
+| `netbox.ui.attrs.ArrayAttr`             | An array of values, shown as a comma-separated list |
+| `netbox.ui.attrs.BooleanAttr`           | A boolean value                                     |
+| `netbox.ui.attrs.ChoiceAttr`            | A selection from a set of choices                   |
+| `netbox.ui.attrs.ColorAttr`             | A color expressed in RGB                            |
+| `netbox.ui.attrs.DateTimeAttr`          | A date or datetime value                            |
+| `netbox.ui.attrs.GenericForeignKeyAttr` | A related object via a generic foreign key          |
+| `netbox.ui.attrs.GPSCoordinatesAttr`    | GPS coordinates (latitude and longitude)            |
+| `netbox.ui.attrs.ImageAttr`             | An attached image (displays the image)              |
+| `netbox.ui.attrs.NestedObjectAttr`      | A related nested object (includes ancestors)        |
+| `netbox.ui.attrs.NumericAttr`           | An integer or float value                           |
+| `netbox.ui.attrs.RelatedObjectAttr`     | A related object                                    |
+| `netbox.ui.attrs.RelatedObjectListAttr` | A list of related objects                           |
+| `netbox.ui.attrs.TemplatedAttr`         | Renders an attribute using a custom template        |
+| `netbox.ui.attrs.TextAttr`              | A string (text) value                               |
+| `netbox.ui.attrs.TimezoneAttr`          | A timezone with annotated offset                    |
+| `netbox.ui.attrs.UtilizationAttr`       | A numeric value expressed as a utilization graph    |
 
 
 ::: netbox.ui.attrs.ObjectAttribute
 ::: netbox.ui.attrs.ObjectAttribute
 
 
 ::: netbox.ui.attrs.AddressAttr
 ::: netbox.ui.attrs.AddressAttr
 
 
+::: netbox.ui.attrs.ArrayAttr
+
 ::: netbox.ui.attrs.BooleanAttr
 ::: netbox.ui.attrs.BooleanAttr
 
 
 ::: netbox.ui.attrs.ChoiceAttr
 ::: netbox.ui.attrs.ChoiceAttr

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

@@ -1,5 +1,51 @@
 # NetBox v4.6
 # NetBox v4.6
 
 
+## v4.6.3 (2026-06-16)
+
+### Enhancements
+
+* [#17598](https://github.com/netbox-community/netbox/issues/17598) - Add bulk creation support for VLANs
+* [#21666](https://github.com/netbox-community/netbox/issues/21666) - Add MU connector type for fiber ports and cables
+* [#22361](https://github.com/netbox-community/netbox/issues/22361) - Introduce an `ArrayAttr` UI panel attribute for rendering array field values
+* [#22457](https://github.com/netbox-community/netbox/issues/22457) - Use `hmac.compare_digest()` for constant-time authentication of API tokens
+
+### Performance Improvements
+
+* [#21870](https://github.com/netbox-community/netbox/issues/21870) - Optimize prefix availability calculations
+* [#22375](https://github.com/netbox-community/netbox/issues/22375) - Improve efficiency of filtering VLANs by interface
+
+### Bug Fixes
+
+* [#21338](https://github.com/netbox-community/netbox/issues/21338) - Include connected endpoint data in interface webhooks generated during cable creation
+* [#21895](https://github.com/netbox-community/netbox/issues/21895) - Restore pagination controls for job log entries (previously limited to 50 rows)
+* [#22210](https://github.com/netbox-community/netbox/issues/22210) - Respect saved filters when rendering IPAM child availability views in additional tabs
+* [#22237](https://github.com/netbox-community/netbox/issues/22237) - Fix server error when opening the standalone "Add Table Configuration" page
+* [#22245](https://github.com/netbox-community/netbox/issues/22245) - Include the `id` field in the OpenAPI request schemas for bulk PATCH/PUT endpoints
+* [#22251](https://github.com/netbox-community/netbox/issues/22251) - Re-parent child module bays when a multi-bay module is moved to a new bay
+* [#22273](https://github.com/netbox-community/netbox/issues/22273) - Fix migration failure when a service has several thousand ports defined
+* [#22303](https://github.com/netbox-community/netbox/issues/22303) - Add the missing `fields` parameter to the OpenAPI schema
+* [#22324](https://github.com/netbox-community/netbox/issues/22324) - Fix GraphQL filtering of custom field choice set extra choices
+* [#22340](https://github.com/netbox-community/netbox/issues/22340) - Display a token's allowed IPs as comma-separated strings rather than `IPNetwork` objects
+* [#22346](https://github.com/netbox-community/netbox/issues/22346) - Render SSO/SAML authentication failures as a login page message instead of an HTTP 500 error
+* [#22357](https://github.com/netbox-community/netbox/issues/22357) - Remove the unused `local_context_data` field from `dcim.Module` (which no longer inherits from `ConfigContextModel`)
+* [#22376](https://github.com/netbox-community/netbox/issues/22376) - Fix `AssertionError` in event rule script jobs when a device type has an image attached
+* [#22388](https://github.com/netbox-community/netbox/issues/22388) - Pin redis-py to <8.0 to avoid a startup failure on older Redis releases
+* [#22397](https://github.com/netbox-community/netbox/issues/22397) - Fix `AttributeError` exception when an unauthenticated user attempts to export devices
+* [#22399](https://github.com/netbox-community/netbox/issues/22399) - Enforce object permissions on the related object when serving static media
+* [#22427](https://github.com/netbox-community/netbox/issues/22427) - Validate `JSONFilter.path` to prevent ORM operator injection over JSONField contents in the GraphQL API
+* [#22429](https://github.com/netbox-community/netbox/issues/22429) - Enforce `ObjectPermission` constraints on `grant_token` in the REST API
+* [#22431](https://github.com/netbox-community/netbox/issues/22431) - Use a cryptographically secure random number generator when generating API tokens
+* [#22444](https://github.com/netbox-community/netbox/issues/22444) - Fix `KeyError` exception on the power feed detail view when the locale is not English
+* [#22448](https://github.com/netbox-community/netbox/issues/22448) - Ensure all object representations are escaped under `handle_protectederror()`
+* [#22454](https://github.com/netbox-community/netbox/issues/22454) - Fix serialization of decimal custom field values to avoid spurious changelog entries
+* [#22466](https://github.com/netbox-community/netbox/issues/22466) - Fix test failure against SSL-enabled PosgtreSQL
+
+### Deprecations
+
+* [#22392](https://github.com/netbox-community/netbox/issues/22392) - Deprecate support for Redis 5.x (to be removed in v4.7)
+
+---
+
 ## v4.6.2 (2026-06-02)
 ## v4.6.2 (2026-06-02)
 
 
 ### Enhancements
 ### Enhancements

+ 14 - 15
netbox/circuits/graphql/filters.py

@@ -1,4 +1,3 @@
-from datetime import date
 from typing import TYPE_CHECKING, Annotated
 from typing import TYPE_CHECKING, Annotated
 
 
 import strawberry
 import strawberry
@@ -62,9 +61,9 @@ class CircuitTerminationFilter(
     upstream_speed: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
     upstream_speed: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
-    xconnect_id: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    pp_info: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    description: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    xconnect_id: StrFilterLookup | None = strawberry_django.filter_field()
+    pp_info: StrFilterLookup | None = strawberry_django.filter_field()
+    description: StrFilterLookup | None = strawberry_django.filter_field()
 
 
     # Cached relations
     # Cached relations
     _provider_network: Annotated['ProviderNetworkFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
     _provider_network: Annotated['ProviderNetworkFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
@@ -92,7 +91,7 @@ class CircuitFilter(
     TenancyFilterMixin,
     TenancyFilterMixin,
     PrimaryModelFilter
     PrimaryModelFilter
 ):
 ):
-    cid: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    cid: StrFilterLookup | None = strawberry_django.filter_field()
     provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
     provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -108,8 +107,8 @@ class CircuitFilter(
     status: BaseFilterLookup[Annotated['CircuitStatusEnum', strawberry.lazy('circuits.graphql.enums')]] | None = (
     status: BaseFilterLookup[Annotated['CircuitStatusEnum', strawberry.lazy('circuits.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
-    install_date: DateFilterLookup[date] | None = strawberry_django.filter_field()
-    termination_date: DateFilterLookup[date] | None = strawberry_django.filter_field()
+    install_date: DateFilterLookup | None = strawberry_django.filter_field()
+    termination_date: DateFilterLookup | None = strawberry_django.filter_field()
     commit_rate: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
     commit_rate: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -145,8 +144,8 @@ class CircuitGroupAssignmentFilter(CustomFieldsFilterMixin, TagsFilterMixin, Cha
 
 
 @strawberry_django.filter_type(models.Provider, lookups=True)
 @strawberry_django.filter_type(models.Provider, lookups=True)
 class ProviderFilter(ContactFilterMixin, PrimaryModelFilter):
 class ProviderFilter(ContactFilterMixin, PrimaryModelFilter):
-    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    slug: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup | None = strawberry_django.filter_field()
+    slug: StrFilterLookup | None = strawberry_django.filter_field()
     asns: Annotated['ASNFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
     asns: Annotated['ASNFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
     circuits: Annotated['CircuitFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
     circuits: Annotated['CircuitFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
@@ -159,18 +158,18 @@ class ProviderAccountFilter(ContactFilterMixin, PrimaryModelFilter):
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
     provider_id: ID | None = strawberry_django.filter_field()
     provider_id: ID | None = strawberry_django.filter_field()
-    account: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    account: StrFilterLookup | None = strawberry_django.filter_field()
+    name: StrFilterLookup | None = strawberry_django.filter_field()
 
 
 
 
 @strawberry_django.filter_type(models.ProviderNetwork, lookups=True)
 @strawberry_django.filter_type(models.ProviderNetwork, lookups=True)
 class ProviderNetworkFilter(PrimaryModelFilter):
 class ProviderNetworkFilter(PrimaryModelFilter):
-    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup | None = strawberry_django.filter_field()
     provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
     provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
     provider_id: ID | None = strawberry_django.filter_field()
     provider_id: ID | None = strawberry_django.filter_field()
-    service_id: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    service_id: StrFilterLookup | None = strawberry_django.filter_field()
 
 
 
 
 @strawberry_django.filter_type(models.VirtualCircuitType, lookups=True)
 @strawberry_django.filter_type(models.VirtualCircuitType, lookups=True)
@@ -180,7 +179,7 @@ class VirtualCircuitTypeFilter(CircuitTypeFilterMixin, OrganizationalModelFilter
 
 
 @strawberry_django.filter_type(models.VirtualCircuit, lookups=True)
 @strawberry_django.filter_type(models.VirtualCircuit, lookups=True)
 class VirtualCircuitFilter(TenancyFilterMixin, PrimaryModelFilter):
 class VirtualCircuitFilter(TenancyFilterMixin, PrimaryModelFilter):
-    cid: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    cid: StrFilterLookup | None = strawberry_django.filter_field()
     provider_network: Annotated['ProviderNetworkFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
     provider_network: Annotated['ProviderNetworkFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -218,4 +217,4 @@ class VirtualCircuitTerminationFilter(CustomFieldsFilterMixin, TagsFilterMixin,
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
     interface_id: ID | None = strawberry_django.filter_field()
     interface_id: ID | None = strawberry_django.filter_field()
-    description: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    description: StrFilterLookup | None = strawberry_django.filter_field()

+ 51 - 4
netbox/core/api/schema.py

@@ -14,7 +14,7 @@ from drf_spectacular.plumbing import (
     get_doc,
     get_doc,
 )
 )
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.types import OpenApiTypes
-from drf_spectacular.utils import Direction
+from drf_spectacular.utils import Direction, OpenApiParameter
 
 
 from netbox.api.fields import ChoiceField
 from netbox.api.fields import ChoiceField
 from netbox.api.serializers import WritableNestedSerializer
 from netbox.api.serializers import WritableNestedSerializer
@@ -138,14 +138,30 @@ class NetBoxAutoSchema(AutoSchema):
         return super().get_operation_id()
         return super().get_operation_id()
 
 
     def get_request_serializer(self) -> typing.Any:
     def get_request_serializer(self) -> typing.Any:
-        # bulk operations should specify a list
         serializer = super().get_request_serializer()
         serializer = super().get_request_serializer()
 
 
+        # Bulk update/partial-update has a special request shape: a list of
+        # writable objects plus a required `id` field. The normal writable
+        # serializer omits `id` because it is read-only, so don't use the generic
+        # bulk handling for these actions.
+        action = getattr(self.view, 'action', None)
+        if action in ('bulk_update', 'bulk_partial_update'):
+            get_bulk_update_request_serializer = getattr(
+                self.view,
+                'get_bulk_update_request_serializer',
+                None,
+            )
+            if get_bulk_update_request_serializer is not None:
+                return get_bulk_update_request_serializer(
+                    partial=(action == 'bulk_partial_update' or self.method == 'PATCH')
+                )
+
+        # Bulk creates/deletes should specify a list.
         if self.is_bulk_action:
         if self.is_bulk_action:
             return type(serializer)(many=True)
             return type(serializer)(many=True)
 
 
-        # handle mapping for Writable serializers - adapted from dansheps original code
-        # for drf-yasg
+        # handle mapping for Writable serializers - adapted from dansheps original
+        # code for drf-yasg.
         if serializer is not None and self.method in WRITABLE_ACTIONS:
         if serializer is not None and self.method in WRITABLE_ACTIONS:
             writable_class = self.get_writable_class(serializer)
             writable_class = self.get_writable_class(serializer)
             if writable_class is not None:
             if writable_class is not None:
@@ -258,6 +274,37 @@ class NetBoxAutoSchema(AutoSchema):
         writable_class = self.writable_serializers[type(serializer)]
         writable_class = self.writable_serializers[type(serializer)]
         return writable_class
         return writable_class
 
 
+    def get_override_parameters(self):
+        params = super().get_override_parameters()
+        # Expose the ?fields, ?omit, and ?brief query parameters supported by NetBoxModelViewSet
+        # for all non-bulk GET operations (both list and detail).
+        if not self.is_bulk_action and self.method == 'GET':
+            params = list(params) + [
+                OpenApiParameter(
+                    name='fields',
+                    location=OpenApiParameter.QUERY,
+                    required=False,
+                    type=OpenApiTypes.STR,
+                    description='Comma-separated list of fields to include in the response. Example: `fields=id,name`.',
+                ),
+                OpenApiParameter(
+                    name='omit',
+                    location=OpenApiParameter.QUERY,
+                    required=False,
+                    type=OpenApiTypes.STR,
+                    description='Comma-separated list of fields to exclude from the response. '
+                                'Example: `omit=description,tags`.',
+                ),
+                OpenApiParameter(
+                    name='brief',
+                    location=OpenApiParameter.QUERY,
+                    required=False,
+                    type=OpenApiTypes.BOOL,
+                    description='Return only brief fields for each object.',
+                ),
+            ]
+        return params
+
     def get_filter_backends(self):
     def get_filter_backends(self):
         # bulk operations don't have filter params
         # bulk operations don't have filter params
         if self.is_bulk_action:
         if self.is_bulk_action:

+ 1 - 1
netbox/core/apps.py

@@ -22,7 +22,7 @@ class CoreConfig(AppConfig):
 
 
     def ready(self):
     def ready(self):
         from core.api import schema  # noqa: F401
         from core.api import schema  # noqa: F401
-        from core.checks import check_duplicate_indexes, check_postgresql_version  # noqa: F401
+        from core.checks import check_duplicate_indexes, check_postgresql_version, check_redis_version  # noqa: F401
         from netbox import context_managers  # noqa: F401
         from netbox import context_managers  # noqa: F401
         from netbox.models.features import register_models
         from netbox.models.features import register_models
 
 

+ 26 - 0
netbox/core/checks.py

@@ -1,4 +1,5 @@
 from django.apps import apps
 from django.apps import apps
+from django.core.cache import cache
 from django.core.checks import Error, Tags, Warning, register
 from django.core.checks import Error, Tags, Warning, register
 from django.db import connection
 from django.db import connection
 from django.db.models import Index, UniqueConstraint
 from django.db.models import Index, UniqueConstraint
@@ -6,6 +7,7 @@ from django.db.models import Index, UniqueConstraint
 __all__ = (
 __all__ = (
     'check_duplicate_indexes',
     'check_duplicate_indexes',
     'check_postgresql_version',
     'check_postgresql_version',
+    'check_redis_version',
 )
 )
 
 
 
 
@@ -67,3 +69,27 @@ def check_postgresql_version(app_configs, **kwargs):
     except Exception:
     except Exception:
         pass
         pass
     return warnings
     return warnings
+
+
+@register(Tags.caches)
+def check_redis_version(app_configs, **kwargs):
+    """
+    Warn if the Redis version is less than 6.0, as support for Redis older than 6.0
+    will be removed in NetBox v4.7.
+    """
+    warnings = []
+    try:
+        client = cache.client.get_client()
+        redis_version = tuple(int(x) for x in client.info()['redis_version'].split('.'))
+        if redis_version < (6, 0):
+            warnings.append(
+                Warning(
+                    f'Support for Redis {".".join(str(x) for x in redis_version)} is deprecated and will be '
+                    f'removed in NetBox v4.7.',
+                    hint='Please upgrade to Redis 6.0 or later.',
+                    id='netbox.W002',
+                )
+            )
+    except Exception:
+        pass
+    return warnings

+ 0 - 9
netbox/core/forms/model_forms.py

@@ -115,15 +115,6 @@ class ManagedFileForm(SyncedDataMixin, NetBoxModelForm):
 
 
         return self.cleaned_data
         return self.cleaned_data
 
 
-    def save(self, *args, **kwargs):
-        # If a file was uploaded, save it to disk
-        if self.cleaned_data['upload_file']:
-            self.instance.file_path = self.cleaned_data['upload_file'].name
-            with open(self.instance.full_path, 'wb+') as new_file:
-                new_file.write(self.cleaned_data['upload_file'].read())
-
-        return super().save(*args, **kwargs)
-
 
 
 class ConfigFormMetaclass(forms.models.ModelFormMetaclass):
 class ConfigFormMetaclass(forms.models.ModelFormMetaclass):
 
 

+ 2 - 3
netbox/core/graphql/filter_mixins.py

@@ -1,5 +1,4 @@
 from dataclasses import dataclass
 from dataclasses import dataclass
-from datetime import datetime
 from typing import TYPE_CHECKING, Annotated
 from typing import TYPE_CHECKING, Annotated
 
 
 import strawberry
 import strawberry
@@ -20,5 +19,5 @@ class ChangeLoggingMixin:
     changelog: Annotated['ObjectChangeFilter', strawberry.lazy('core.graphql.filters')] | None = (
     changelog: Annotated['ObjectChangeFilter', strawberry.lazy('core.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
-    created: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
-    last_updated: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
+    created: DatetimeFilterLookup | None = strawberry_django.filter_field()
+    last_updated: DatetimeFilterLookup | None = strawberry_django.filter_field()

+ 15 - 16
netbox/core/graphql/filters.py

@@ -1,4 +1,3 @@
-from datetime import datetime
 from typing import TYPE_CHECKING, Annotated
 from typing import TYPE_CHECKING, Annotated
 
 
 import strawberry
 import strawberry
@@ -26,33 +25,33 @@ __all__ = (
 
 
 @strawberry_django.filter_type(models.DataFile, lookups=True)
 @strawberry_django.filter_type(models.DataFile, lookups=True)
 class DataFileFilter(BaseModelFilter):
 class DataFileFilter(BaseModelFilter):
-    created: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
-    last_updated: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
+    created: DatetimeFilterLookup | None = strawberry_django.filter_field()
+    last_updated: DatetimeFilterLookup | None = strawberry_django.filter_field()
     source: Annotated['DataSourceFilter', strawberry.lazy('core.graphql.filters')] | None = (
     source: Annotated['DataSourceFilter', strawberry.lazy('core.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
     source_id: ID | None = strawberry_django.filter_field()
     source_id: ID | None = strawberry_django.filter_field()
-    path: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    path: StrFilterLookup | None = strawberry_django.filter_field()
     size: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
     size: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
-    hash: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    hash: StrFilterLookup | None = strawberry_django.filter_field()
 
 
 
 
 @strawberry_django.filter_type(models.DataSource, lookups=True)
 @strawberry_django.filter_type(models.DataSource, lookups=True)
 class DataSourceFilter(PrimaryModelFilter):
 class DataSourceFilter(PrimaryModelFilter):
-    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    type: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    source_url: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup | None = strawberry_django.filter_field()
+    type: StrFilterLookup | None = strawberry_django.filter_field()
+    source_url: StrFilterLookup | None = strawberry_django.filter_field()
     status: (
     status: (
         BaseFilterLookup[Annotated['DataSourceStatusEnum', strawberry.lazy('core.graphql.enums')]] | None
         BaseFilterLookup[Annotated['DataSourceStatusEnum', strawberry.lazy('core.graphql.enums')]] | None
     ) = strawberry_django.filter_field()
     ) = strawberry_django.filter_field()
     enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
     enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
-    ignore_rules: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    ignore_rules: StrFilterLookup | None = strawberry_django.filter_field()
     parameters: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
     parameters: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
-    last_synced: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
+    last_synced: DatetimeFilterLookup | None = strawberry_django.filter_field()
     datafiles: Annotated['DataFileFilter', strawberry.lazy('core.graphql.filters')] | None = (
     datafiles: Annotated['DataFileFilter', strawberry.lazy('core.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -60,10 +59,10 @@ class DataSourceFilter(PrimaryModelFilter):
 
 
 @strawberry_django.filter_type(models.ObjectChange, lookups=True)
 @strawberry_django.filter_type(models.ObjectChange, lookups=True)
 class ObjectChangeFilter(BaseModelFilter):
 class ObjectChangeFilter(BaseModelFilter):
-    time: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
+    time: DatetimeFilterLookup | None = strawberry_django.filter_field()
     user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
     user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
-    user_name: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    request_id: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    user_name: StrFilterLookup | None = strawberry_django.filter_field()
+    request_id: StrFilterLookup | None = strawberry_django.filter_field()
     action: (
     action: (
         BaseFilterLookup[Annotated['ObjectChangeActionEnum', strawberry.lazy('core.graphql.enums')]] | None
         BaseFilterLookup[Annotated['ObjectChangeActionEnum', strawberry.lazy('core.graphql.enums')]] | None
     ) = strawberry_django.filter_field()
     ) = strawberry_django.filter_field()
@@ -76,7 +75,7 @@ class ObjectChangeFilter(BaseModelFilter):
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
     related_object_id: ID | None = strawberry_django.filter_field()
     related_object_id: ID | None = strawberry_django.filter_field()
-    object_repr: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    object_repr: StrFilterLookup | None = strawberry_django.filter_field()
     prechange_data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
     prechange_data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -87,5 +86,5 @@ class ObjectChangeFilter(BaseModelFilter):
 
 
 @strawberry_django.filter_type(DjangoContentType, lookups=True)
 @strawberry_django.filter_type(DjangoContentType, lookups=True)
 class ContentTypeFilter(BaseModelFilter):
 class ContentTypeFilter(BaseModelFilter):
-    app_label: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    model: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    app_label: StrFilterLookup | None = strawberry_django.filter_field()
+    model: StrFilterLookup | None = strawberry_django.filter_field()

+ 45 - 4
netbox/core/tests/test_api.py

@@ -12,7 +12,7 @@ from rq.registry import FailedJobRegistry, StartedJobRegistry
 
 
 from users.constants import TOKEN_PREFIX
 from users.constants import TOKEN_PREFIX
 from users.models import Token
 from users.models import Token
-from utilities.testing import APITestCase, APIViewTestCases, TestCase
+from utilities.testing import APITestCase, APIViewTestCases, GraphQLQueryTest, TestCase
 from utilities.testing.mixins import RQQueueTestMixin
 from utilities.testing.mixins import RQQueueTestMixin
 from utilities.testing.utils import disable_logging
 from utilities.testing.utils import disable_logging
 
 
@@ -39,12 +39,49 @@ class DataSourceTestCase(APIViewTestCases.APIViewTestCase):
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
         data_sources = (
         data_sources = (
-            DataSource(name='Data Source 1', type='local', source_url='file:///var/tmp/source1/'),
+            DataSource(
+                name='Data Source 1', type='local', source_url='file:///var/tmp/source1/',
+                parameters={
+                    'sync_date': '2024-01-01',
+                    'sync_datetime': '2024-01-01T12:30:00+00:00',
+                    'sync_time': '12:30:00',
+                },
+            ),
             DataSource(name='Data Source 2', type='local', source_url='file:///var/tmp/source2/'),
             DataSource(name='Data Source 2', type='local', source_url='file:///var/tmp/source2/'),
             DataSource(name='Data Source 3', type='local', source_url='file:///var/tmp/source3/'),
             DataSource(name='Data Source 3', type='local', source_url='file:///var/tmp/source3/'),
         )
         )
         DataSource.objects.bulk_create(data_sources)
         DataSource.objects.bulk_create(data_sources)
 
 
+        cls.graphql_query_tests = (
+            GraphQLQueryTest(
+                name='parameters_json_date_lookup',
+                query=(
+                    '{ data_source_list(filters: {parameters: '
+                    '{path: "sync_date", lookup: {date_lookup: {exact: "2024-01-01"}}}}) '
+                    '{ id } }'
+                ),
+                assert_result=cls.assert_only_source_1,
+            ),
+            GraphQLQueryTest(
+                name='parameters_json_datetime_lookup',
+                query=(
+                    '{ data_source_list(filters: {parameters: '
+                    '{path: "sync_datetime", lookup: {datetime_lookup: {exact: "2024-01-01T12:30:00+00:00"}}}}) '
+                    '{ id } }'
+                ),
+                assert_result=cls.assert_only_source_1,
+            ),
+            GraphQLQueryTest(
+                name='parameters_json_time_lookup',
+                query=(
+                    '{ data_source_list(filters: {parameters: '
+                    '{path: "sync_time", lookup: {time_lookup: {exact: "12:30:00"}}}}) '
+                    '{ id } }'
+                ),
+                assert_result=cls.assert_only_source_1,
+            ),
+        )
+
         cls.create_data = [
         cls.create_data = [
             {
             {
                 'name': 'Data Source 4',
                 'name': 'Data Source 4',
@@ -63,6 +100,11 @@ class DataSourceTestCase(APIViewTestCases.APIViewTestCase):
             },
             },
         ]
         ]
 
 
+    def assert_only_source_1(self, data):
+        """The JSON lookup returns exactly the source carrying the matching value."""
+        ids = sorted(result['id'] for result in data['data_source_list'])
+        self.assertEqual(ids, [str(DataSource.objects.get(name='Data Source 1').pk)])
+
 
 
 class DataFileTestCase(
 class DataFileTestCase(
     APIViewTestCases.GetObjectViewTestCase,
     APIViewTestCases.GetObjectViewTestCase,
@@ -279,9 +321,8 @@ class BackgroundTaskTestCase(RQQueueTestMixin, TestCase):
         # Enqueue & run a job that will fail
         # Enqueue & run a job that will fail
         queue = get_queue('default')
         queue = get_queue('default')
         job = queue.enqueue(self.dummy_job_failing)
         job = queue.enqueue(self.dummy_job_failing)
-        worker = get_worker('default')
         with disable_logging():
         with disable_logging():
-            worker.work(burst=True)
+            self.run_rq_jobs('default')
         self.assertTrue(job.is_failed)
         self.assertTrue(job.is_failed)
         url = reverse('core-api:rqtask-requeue', args=[job.id])
         url = reverse('core-api:rqtask-requeue', args=[job.id])
 
 

+ 73 - 3
netbox/core/tests/test_views.py

@@ -1,7 +1,7 @@
 import json
 import json
 import urllib.parse
 import urllib.parse
 import uuid
 import uuid
-from datetime import datetime
+from datetime import UTC, datetime
 
 
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.urls import reverse
 from django.urls import reverse
@@ -152,6 +152,77 @@ class JobTestCase(
         )
         )
 
 
 
 
+class JobLogViewTestCase(TestCase):
+    user_permissions = (
+        'core.view_job',
+    )
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.job = Job.objects.create(
+            name='Test Job',
+            job_id=uuid.uuid4(),
+        )
+        cls.job.log_entries = [
+            {
+                'level': 'info',
+                'message': f'log line {i}',
+                'timestamp': datetime(2026, 1, 1, tzinfo=UTC),
+            }
+            for i in range(120)
+        ]
+        cls.job.save()
+
+    def setUp(self):
+        super().setUp()
+        # UserConfig.set() mutates self.data in place, which can mutate DEFAULT_USER_PREFERENCES
+        # (the signal in users/signals.py initializes data with a shared reference). Assign a
+        # fresh literal instead. Pin per_page so page-boundary assertions don't depend on PAGINATE_COUNT.
+        self.user.config.data = {'pagination': {'per_page': 50}}
+        self.user.config.save()
+
+    def test_log_page_renders_table_inline(self):
+        """The full page renders the first log page inside an HTMX container."""
+        url = reverse('core:job_log', kwargs={'pk': self.job.pk})
+        response = self.client.get(url)
+        self.assertHttpStatus(response, 200)
+        self.assertContains(response, 'htmx-container')
+        self.assertContains(response, 'log line 0')
+        self.assertContains(response, 'Showing 1-50 of 120')
+
+    def test_log_page_table_is_embedded(self):
+        """The embedded table never pushes page/per_page into the browser URL."""
+        url = reverse('core:job_log', kwargs={'pk': self.job.pk})
+        response = self.client.get(url)
+        self.assertHttpStatus(response, 200)
+        self.assertNotContains(response, 'hx-push-url="true"')
+
+    def test_log_table_htmx_renders_partial(self):
+        """An HTMX request returns the paginated table partial."""
+        url = reverse('core:job_log', kwargs={'pk': self.job.pk})
+        response = self.client.get(url, headers={'hx-request': 'true'})
+        self.assertHttpStatus(response, 200)
+        self.assertContains(response, 'log line 0')
+        self.assertContains(response, 'Showing 1-50 of 120')
+        self.assertContains(response, 'Per Page')
+
+    def test_log_table_htmx_page_navigation(self):
+        """`?page=2` advances the embedded table to the second page."""
+        url = reverse('core:job_log', kwargs={'pk': self.job.pk})
+        response = self.client.get(f'{url}?page=2', headers={'hx-request': 'true'})
+        self.assertHttpStatus(response, 200)
+        self.assertContains(response, 'log line 50')
+        self.assertNotContains(response, 'log line 49')
+
+    def test_log_table_htmx_per_page(self):
+        """`?per_page=100` widens the embedded table page size."""
+        url = reverse('core:job_log', kwargs={'pk': self.job.pk})
+        response = self.client.get(f'{url}?per_page=100', headers={'hx-request': 'true'})
+        self.assertHttpStatus(response, 200)
+        self.assertContains(response, 'log line 99')
+        self.assertNotContains(response, 'log line 100')
+
+
 # TODO: Convert to StandardTestCases.Views
 # TODO: Convert to StandardTestCases.Views
 class ObjectChangeTestCase(TestCase):
 class ObjectChangeTestCase(TestCase):
     user_permissions = (
     user_permissions = (
@@ -315,9 +386,8 @@ class BackgroundTaskTestCase(RQQueueTestMixin, TestCase):
 
 
         # Enqueue & run a job that will fail
         # Enqueue & run a job that will fail
         job = queue.enqueue(self.dummy_job_failing)
         job = queue.enqueue(self.dummy_job_failing)
-        worker = get_worker('default')
         with disable_logging():
         with disable_logging():
-            worker.work(burst=True)
+            self.run_rq_jobs('default')
         self.assertTrue(job.is_failed)
         self.assertTrue(job.is_failed)
 
 
         # Re-enqueue the failed job and check that its status has been reset
         # Re-enqueue the failed job and check that its status has been reset

+ 17 - 4
netbox/core/views.py

@@ -39,7 +39,6 @@ from netbox.plugins.utils import get_installed_plugins
 from netbox.ui import layout
 from netbox.ui import layout
 from netbox.ui.panels import (
 from netbox.ui.panels import (
     CommentsPanel,
     CommentsPanel,
-    ContextTablePanel,
     JSONPanel,
     JSONPanel,
     ObjectsTablePanel,
     ObjectsTablePanel,
     PluginContentPanel,
     PluginContentPanel,
@@ -269,7 +268,7 @@ class JobLogView(generic.ObjectView):
     layout = layout.Layout(
     layout = layout.Layout(
         layout.Row(
         layout.Row(
             layout.Column(
             layout.Column(
-                ContextTablePanel('table', title=_('Log Entries')),
+                TemplatePanel('core/job/log_entries.html', title=_('Log Entries')),
                 PluginContentPanel('left_page'),
                 PluginContentPanel('left_page'),
             ),
             ),
         ),
         ),
@@ -280,13 +279,27 @@ class JobLogView(generic.ObjectView):
         ),
         ),
     )
     )
 
 
-    def get_extra_context(self, request, instance):
+    def get_table(self, request, instance):
         table = JobLogEntryTable(instance.log_entries)
         table = JobLogEntryTable(instance.log_entries)
+        table.embedded = True
+        table.htmx_url = reverse('core:job_log', kwargs={'pk': instance.pk})
         table.configure(request)
         table.configure(request)
+        return table
+
+    def get_extra_context(self, request, instance):
         return {
         return {
-            'table': table,
+            'table': self.get_table(request, instance),
         }
         }
 
 
+    def get(self, request, **kwargs):
+        if htmx_partial(request):
+            instance = self.get_object(**kwargs)
+            return render(request, 'htmx/table.html', {
+                'object': instance,
+                'table': self.get_table(request, instance),
+            })
+        return super().get(request, **kwargs)
+
 
 
 @register_model_view(Job, 'delete')
 @register_model_view(Job, 'delete')
 class JobDeleteView(generic.ObjectDeleteView):
 class JobDeleteView(generic.ObjectDeleteView):

+ 8 - 0
netbox/dcim/choices.py

@@ -1637,6 +1637,10 @@ class PortTypeChoices(ChoiceSet):
     TYPE_LC_PC = 'lc-pc'
     TYPE_LC_PC = 'lc-pc'
     TYPE_LC_UPC = 'lc-upc'
     TYPE_LC_UPC = 'lc-upc'
     TYPE_LC_APC = 'lc-apc'
     TYPE_LC_APC = 'lc-apc'
+    TYPE_MU = 'mu'
+    TYPE_MU_PC = 'mu-pc'
+    TYPE_MU_UPC = 'mu-upc'
+    TYPE_MU_APC = 'mu-apc'
     TYPE_MTRJ = 'mtrj'
     TYPE_MTRJ = 'mtrj'
     TYPE_MPO = 'mpo'
     TYPE_MPO = 'mpo'
     TYPE_LSH = 'lsh'
     TYPE_LSH = 'lsh'
@@ -1700,6 +1704,10 @@ class PortTypeChoices(ChoiceSet):
                 (TYPE_LC_PC, 'LC/PC'),
                 (TYPE_LC_PC, 'LC/PC'),
                 (TYPE_LC_UPC, 'LC/UPC'),
                 (TYPE_LC_UPC, 'LC/UPC'),
                 (TYPE_LC_APC, 'LC/APC'),
                 (TYPE_LC_APC, 'LC/APC'),
+                (TYPE_MU, 'MU'),
+                (TYPE_MU_PC, 'MU/PC'),
+                (TYPE_MU_UPC, 'MU/UPC'),
+                (TYPE_MU_APC, 'MU/APC'),
                 (TYPE_LSH, 'LSH'),
                 (TYPE_LSH, 'LSH'),
                 (TYPE_LSH_PC, 'LSH/PC'),
                 (TYPE_LSH_PC, 'LSH/PC'),
                 (TYPE_LSH_UPC, 'LSH/UPC'),
                 (TYPE_LSH_UPC, 'LSH/UPC'),

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

@@ -1583,7 +1583,8 @@ class CableImportForm(PrimaryModelImportForm):
 
 
         :param side: 'a' or 'b'
         :param side: 'a' or 'b'
         """
         """
-        assert side in 'ab', f"Invalid side designation: {side}"
+        if side not in ('a', 'b'):
+            raise ValueError(_("Invalid side designation: {side}").format(side=side))
 
 
         device = self.cleaned_data.get(f'side_{side}_device')
         device = self.cleaned_data.get(f'side_{side}_device')
         power_panel = self.cleaned_data.get(f'side_{side}_power_panel')
         power_panel = self.cleaned_data.get(f'side_{side}_power_panel')

+ 6 - 6
netbox/dcim/graphql/filter_mixins.py

@@ -66,9 +66,9 @@ class ComponentModelFilterMixin:
     )
     )
     device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     device_id: ID | None = strawberry_django.filter_field()
     device_id: ID | None = strawberry_django.filter_field()
-    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    label: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    description: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup | None = strawberry_django.filter_field()
+    label: StrFilterLookup | None = strawberry_django.filter_field()
+    description: StrFilterLookup | None = strawberry_django.filter_field()
 
 
 
 
 @dataclass
 @dataclass
@@ -96,9 +96,9 @@ class ComponentTemplateFilterMixin:
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
     device_type_id: ID | None = strawberry_django.filter_field()
     device_type_id: ID | None = strawberry_django.filter_field()
-    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    label: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    description: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup | None = strawberry_django.filter_field()
+    label: StrFilterLookup | None = strawberry_django.filter_field()
+    description: StrFilterLookup | None = strawberry_django.filter_field()
 
 
 
 
 @dataclass
 @dataclass

+ 41 - 41
netbox/dcim/graphql/filters.py

@@ -116,7 +116,7 @@ __all__ = (
 
 
 @strawberry_django.filter_type(models.CableBundle, lookups=True)
 @strawberry_django.filter_type(models.CableBundle, lookups=True)
 class CableBundleFilter(PrimaryModelFilter):
 class CableBundleFilter(PrimaryModelFilter):
-    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup | None = strawberry_django.filter_field()
 
 
 
 
 @strawberry_django.filter_type(models.Cable, lookups=True)
 @strawberry_django.filter_type(models.Cable, lookups=True)
@@ -127,7 +127,7 @@ class CableFilter(TenancyFilterMixin, PrimaryModelFilter):
     status: BaseFilterLookup[Annotated['LinkStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
     status: BaseFilterLookup[Annotated['LinkStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
-    label: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    label: StrFilterLookup | None = strawberry_django.filter_field()
     color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
     color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -223,9 +223,9 @@ class DeviceFilter(
     platform: Annotated['PlatformFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
     platform: Annotated['PlatformFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
-    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    serial: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    asset_tag: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup | None = strawberry_django.filter_field()
+    serial: StrFilterLookup | None = strawberry_django.filter_field()
+    asset_tag: StrFilterLookup | None = strawberry_django.filter_field()
     site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     site_id: ID | None = strawberry_django.filter_field()
     site_id: ID | None = strawberry_django.filter_field()
     location: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
     location: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
@@ -353,7 +353,7 @@ class InventoryItemTemplateFilter(ComponentTemplateFilterMixin, ChangeLoggedMode
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
     manufacturer_id: ID | None = strawberry_django.filter_field()
     manufacturer_id: ID | None = strawberry_django.filter_field()
-    part_id: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    part_id: StrFilterLookup | None = strawberry_django.filter_field()
 
 
 
 
 @strawberry_django.filter_type(models.DeviceRole, lookups=True)
 @strawberry_django.filter_type(models.DeviceRole, lookups=True)
@@ -370,13 +370,13 @@ class DeviceTypeFilter(ImageAttachmentFilterMixin, WeightFilterMixin, PrimaryMod
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
     manufacturer_id: ID | None = strawberry_django.filter_field()
     manufacturer_id: ID | None = strawberry_django.filter_field()
-    model: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    slug: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    model: StrFilterLookup | None = strawberry_django.filter_field()
+    slug: StrFilterLookup | None = strawberry_django.filter_field()
     default_platform: Annotated['PlatformFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
     default_platform: Annotated['PlatformFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
     default_platform_id: ID | None = strawberry_django.filter_field()
     default_platform_id: ID | None = strawberry_django.filter_field()
-    part_number: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    part_number: StrFilterLookup | None = strawberry_django.filter_field()
     instances: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
     instances: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -493,7 +493,7 @@ class PortTemplateMappingFilter(BaseModelFilter):
 
 
 @strawberry_django.filter_type(models.MACAddress, lookups=True)
 @strawberry_django.filter_type(models.MACAddress, lookups=True)
 class MACAddressFilter(PrimaryModelFilter):
 class MACAddressFilter(PrimaryModelFilter):
-    mac_address: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    mac_address: StrFilterLookup | None = strawberry_django.filter_field()
     assigned_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
     assigned_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -539,7 +539,7 @@ class InterfaceFilter(
     duplex: BaseFilterLookup[Annotated['InterfaceDuplexEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
     duplex: BaseFilterLookup[Annotated['InterfaceDuplexEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
-    wwn: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    wwn: StrFilterLookup | None = strawberry_django.filter_field()
     parent: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
     parent: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -659,9 +659,9 @@ class InventoryItemFilter(ComponentModelFilterMixin, NetBoxModelFilter):
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
     manufacturer_id: ID | None = strawberry_django.filter_field()
     manufacturer_id: ID | None = strawberry_django.filter_field()
-    part_id: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    serial: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    asset_tag: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    part_id: StrFilterLookup | None = strawberry_django.filter_field()
+    serial: StrFilterLookup | None = strawberry_django.filter_field()
+    asset_tag: StrFilterLookup | None = strawberry_django.filter_field()
     discovered: FilterLookup[bool] | None = strawberry_django.filter_field()
     discovered: FilterLookup[bool] | None = strawberry_django.filter_field()
 
 
 
 
@@ -679,7 +679,7 @@ class LocationFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilt
     status: BaseFilterLookup[Annotated['LocationStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
     status: BaseFilterLookup[Annotated['LocationStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
-    facility: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    facility: StrFilterLookup | None = strawberry_django.filter_field()
     prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
     prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -708,8 +708,8 @@ class ModuleFilter(ConfigContextFilterMixin, PrimaryModelFilter):
     status: BaseFilterLookup[Annotated['ModuleStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
     status: BaseFilterLookup[Annotated['ModuleStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
-    serial: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    asset_tag: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    serial: StrFilterLookup | None = strawberry_django.filter_field()
+    asset_tag: StrFilterLookup | None = strawberry_django.filter_field()
     consoleports: Annotated['ConsolePortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
     consoleports: Annotated['ConsolePortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field(name='console_ports')
         strawberry_django.filter_field(name='console_ports')
     )
     )
@@ -748,19 +748,19 @@ class ModuleBayFilter(ModularComponentFilterMixin, NetBoxModelFilter):
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
     parent_id: ID | None = strawberry_django.filter_field()
     parent_id: ID | None = strawberry_django.filter_field()
-    position: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    position: StrFilterLookup | None = strawberry_django.filter_field()
     enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
     enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
 
 
 
 
 @strawberry_django.filter_type(models.ModuleBayTemplate, lookups=True)
 @strawberry_django.filter_type(models.ModuleBayTemplate, lookups=True)
 class ModuleBayTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
 class ModuleBayTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
-    position: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    position: StrFilterLookup | None = strawberry_django.filter_field()
     enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
     enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
 
 
 
 
 @strawberry_django.filter_type(models.ModuleTypeProfile, lookups=True)
 @strawberry_django.filter_type(models.ModuleTypeProfile, lookups=True)
 class ModuleTypeProfileFilter(PrimaryModelFilter):
 class ModuleTypeProfileFilter(PrimaryModelFilter):
-    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup | None = strawberry_django.filter_field()
 
 
 
 
 @strawberry_django.filter_type(models.ModuleType, lookups=True)
 @strawberry_django.filter_type(models.ModuleType, lookups=True)
@@ -773,8 +773,8 @@ class ModuleTypeFilter(ImageAttachmentFilterMixin, WeightFilterMixin, PrimaryMod
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
     profile_id: ID | None = strawberry_django.filter_field()
     profile_id: ID | None = strawberry_django.filter_field()
-    model: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    part_number: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    model: StrFilterLookup | None = strawberry_django.filter_field()
+    part_number: StrFilterLookup | None = strawberry_django.filter_field()
     instances: Annotated['ModuleFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
     instances: Annotated['ModuleFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -831,7 +831,7 @@ class PowerFeedFilter(CabledObjectModelFilterMixin, TenancyFilterMixin, PrimaryM
     power_panel_id: ID | None = strawberry_django.filter_field()
     power_panel_id: ID | None = strawberry_django.filter_field()
     rack: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     rack: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     rack_id: ID | None = strawberry_django.filter_field()
     rack_id: ID | None = strawberry_django.filter_field()
-    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup | None = strawberry_django.filter_field()
     status: BaseFilterLookup[Annotated['PowerFeedStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
     status: BaseFilterLookup[Annotated['PowerFeedStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -902,7 +902,7 @@ class PowerPanelFilter(ContactFilterMixin, ImageAttachmentFilterMixin, PrimaryMo
     location_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
     location_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
-    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup | None = strawberry_django.filter_field()
 
 
 
 
 @strawberry_django.filter_type(models.PowerPort, lookups=True)
 @strawberry_django.filter_type(models.PowerPort, lookups=True)
@@ -940,8 +940,8 @@ class RackTypeFilter(ImageAttachmentFilterMixin, RackFilterMixin, WeightFilterMi
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
     manufacturer_id: ID | None = strawberry_django.filter_field()
     manufacturer_id: ID | None = strawberry_django.filter_field()
-    model: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    slug: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    model: StrFilterLookup | None = strawberry_django.filter_field()
+    slug: StrFilterLookup | None = strawberry_django.filter_field()
     racks: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     racks: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     rack_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
     rack_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
 
 
@@ -962,8 +962,8 @@ class RackFilter(
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
     rack_type_id: ID | None = strawberry_django.filter_field()
     rack_type_id: ID | None = strawberry_django.filter_field()
-    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    facility_id: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup | None = strawberry_django.filter_field()
+    facility_id: StrFilterLookup | None = strawberry_django.filter_field()
     site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     site_id: ID | None = strawberry_django.filter_field()
     site_id: ID | None = strawberry_django.filter_field()
     location: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
     location: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
@@ -981,8 +981,8 @@ class RackFilter(
     )
     )
     role: Annotated['RackRoleFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     role: Annotated['RackRoleFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     role_id: ID | None = strawberry_django.filter_field()
     role_id: ID | None = strawberry_django.filter_field()
-    serial: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    asset_tag: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    serial: StrFilterLookup | None = strawberry_django.filter_field()
+    asset_tag: StrFilterLookup | None = strawberry_django.filter_field()
     airflow: BaseFilterLookup[Annotated['RackAirflowEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
     airflow: BaseFilterLookup[Annotated['RackAirflowEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -1006,7 +1006,7 @@ class RackReservationFilter(TenancyFilterMixin, PrimaryModelFilter):
     unit_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
     unit_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
     user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
     user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
     user_id: ID | None = strawberry_django.filter_field()
     user_id: ID | None = strawberry_django.filter_field()
-    description: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    description: StrFilterLookup | None = strawberry_django.filter_field()
     status: BaseFilterLookup[Annotated['RackReservationStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
     status: BaseFilterLookup[Annotated['RackReservationStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -1057,8 +1057,8 @@ class RegionFilter(ContactFilterMixin, NestedGroupModelFilter):
 
 
 @strawberry_django.filter_type(models.Site, lookups=True)
 @strawberry_django.filter_type(models.Site, lookups=True)
 class SiteFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
 class SiteFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
-    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    slug: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup | None = strawberry_django.filter_field()
+    slug: StrFilterLookup | None = strawberry_django.filter_field()
     status: BaseFilterLookup[Annotated['SiteStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
     status: BaseFilterLookup[Annotated['SiteStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -1072,11 +1072,11 @@ class SiteFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMi
     group_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
     group_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
-    facility: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    facility: StrFilterLookup | None = strawberry_django.filter_field()
     asns: Annotated['ASNFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
     asns: Annotated['ASNFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
-    time_zone: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    physical_address: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    shipping_address: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    time_zone: StrFilterLookup | None = strawberry_django.filter_field()
+    physical_address: StrFilterLookup | None = strawberry_django.filter_field()
+    shipping_address: StrFilterLookup | None = strawberry_django.filter_field()
     latitude: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
     latitude: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -1105,8 +1105,8 @@ class SiteGroupFilter(ContactFilterMixin, NestedGroupModelFilter):
 class VirtualChassisFilter(PrimaryModelFilter):
 class VirtualChassisFilter(PrimaryModelFilter):
     master: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     master: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     master_id: ID | None = strawberry_django.filter_field()
     master_id: ID | None = strawberry_django.filter_field()
-    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    domain: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup | None = strawberry_django.filter_field()
+    domain: StrFilterLookup | None = strawberry_django.filter_field()
     members: (
     members: (
         Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None
         Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None
     ) = strawberry_django.filter_field()
     ) = strawberry_django.filter_field()
@@ -1117,7 +1117,7 @@ class VirtualChassisFilter(PrimaryModelFilter):
 class VirtualDeviceContextFilter(TenancyFilterMixin, PrimaryModelFilter):
 class VirtualDeviceContextFilter(TenancyFilterMixin, PrimaryModelFilter):
     device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     device_id: ID | None = strawberry_django.filter_field()
     device_id: ID | None = strawberry_django.filter_field()
-    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup | None = strawberry_django.filter_field()
     status: (
     status: (
         BaseFilterLookup[Annotated['VirtualDeviceContextStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None
         BaseFilterLookup[Annotated['VirtualDeviceContextStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None
     ) = (
     ) = (
@@ -1134,7 +1134,7 @@ class VirtualDeviceContextFilter(TenancyFilterMixin, PrimaryModelFilter):
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
     primary_ip6_id: ID | None = strawberry_django.filter_field()
     primary_ip6_id: ID | None = strawberry_django.filter_field()
-    comments: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    comments: StrFilterLookup | None = strawberry_django.filter_field()
     interfaces: (
     interfaces: (
         Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None
         Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None
     ) = strawberry_django.filter_field()
     ) = strawberry_django.filter_field()

+ 1 - 1
netbox/dcim/migrations/0239_device__config_context_data.py → netbox/dcim/migrations/0240_device__config_context_data.py

@@ -3,7 +3,7 @@ from django.db import migrations, models
 
 
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
     dependencies = [
     dependencies = [
-        ('dcim', '0238_ltree_paths'),
+        ('dcim', '0239_denormalization_triggers'),
     ]
     ]
 
 
     operations = [
     operations = [

+ 4 - 1
netbox/dcim/models/cables.py

@@ -675,7 +675,10 @@ class CableTermination(ChangeLoggedModel):
         Cache objects related to the termination (e.g. device, rack, site) directly on the object to
         Cache objects related to the termination (e.g. device, rack, site) directly on the object to
         enable efficient filtering.
         enable efficient filtering.
         """
         """
-        assert self.termination is not None
+        if self.termination is None:
+            raise ValueError(
+                _("Invalid cable termination: the assigned termination object does not exist.")
+            )
 
 
         # Device components
         # Device components
         if getattr(self.termination, 'device', None):
         if getattr(self.termination, 'device', None):

+ 19 - 4
netbox/dcim/models/device_components.py

@@ -384,12 +384,27 @@ class PathEndpoint(models.Model):
         a stale in-memory `_path` relation while the database already points to
         a stale in-memory `_path` relation while the database already points to
         a different CablePath (or to no path at all).
         a different CablePath (or to no path at all).
 
 
-        If the cached relation points to a CablePath that has just been
-        deleted, refresh only the `_path` field from the database and retry.
-        This keeps the fix cheap and narrowly scoped to the denormalized FK.
+        Two stale cases are repaired by refreshing only the `_path` field
+        from the database:
+
+        1. The endpoint is linked (by cable or wireless link) but `_path` is
+           unset, because the instance was loaded before its path was traced
+           (e.g. while queued for event serialization during link creation).
+        2. The cached relation points to a CablePath row that has just been
+           deleted.
+
+        Repairing case 1 costs one query per access for a linked endpoint
+        whose path is genuinely absent in the database. That state is
+        transient outside of tracing failures, so no result caching is
+        attempted here.
         """
         """
         if self._path_id is None:
         if self._path_id is None:
-            return None
+            has_link = self.cable_id is not None or getattr(self, 'wireless_link_id', None) is not None
+            if self.pk and has_link:
+                self.refresh_from_db(fields=['_path'])
+
+            if self._path_id is None:
+                return None
 
 
         try:
         try:
             return self._path
             return self._path

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

@@ -325,9 +325,20 @@ class Module(TrackingModelMixin, PrimaryModel):
 
 
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
         is_new = self.pk is None
         is_new = self.pk is None
+        old_module_bay_id = None
+
+        if not is_new:
+            old_module_bay_id = Module.objects.filter(pk=self.pk).values_list(
+                'module_bay_id', flat=True
+            ).first()
 
 
         super().save(*args, **kwargs)
         super().save(*args, **kwargs)
 
 
+        if old_module_bay_id is not None and old_module_bay_id != self.module_bay_id:
+            for child_bay in self.modulebays.select_related('module__module_bay'):
+                child_bay.snapshot()
+                child_bay.save()
+
         adopt_components = getattr(self, '_adopt_components', False)
         adopt_components = getattr(self, '_adopt_components', False)
         disable_replication = getattr(self, '_disable_replication', False)
         disable_replication = getattr(self, '_disable_replication', False)
 
 

+ 0 - 3
netbox/dcim/models/racks.py

@@ -638,9 +638,6 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, TrackingModelMixin, RackBase):
 
 
         return elevation.render(face)
         return elevation.render(face)
 
 
-    def get_0u_devices(self):
-        return self.devices.filter(position=0)
-
     def get_utilization(self):
     def get_utilization(self):
         """
         """
         Determine the utilization rate of the rack and return it as a percentage. Occupied and reserved units both count
         Determine the utilization rate of the rack and return it as a percentage. Occupied and reserved units both count

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

@@ -72,7 +72,7 @@
   "rearporttemplate:api_list_objects": 12,
   "rearporttemplate:api_list_objects": 12,
   "region:api_list_objects": 13,
   "region:api_list_objects": 13,
   "region:list_objects_with_permission": 20,
   "region:list_objects_with_permission": 20,
-  "site:api_list_objects": 16,
+  "site:api_list_objects": 17,
   "site:list_objects_with_permission": 22,
   "site:list_objects_with_permission": 22,
   "sitegroup:api_list_objects": 13,
   "sitegroup:api_list_objects": 13,
   "sitegroup:list_objects_with_permission": 20,
   "sitegroup:list_objects_with_permission": 20,

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

@@ -20,6 +20,8 @@ from users.models import ObjectPermission, Token, User
 from utilities.testing import (
 from utilities.testing import (
     APITestCase,
     APITestCase,
     APIViewTestCases,
     APIViewTestCases,
+    GraphQLFilterTest,
+    GraphQLQueryTest,
     create_test_device,
     create_test_device,
     create_test_nat_ip_pair,
     create_test_nat_ip_pair,
     disable_logging,
     disable_logging,
@@ -146,6 +148,19 @@ class SiteTestCase(APIViewTestCases.APIViewTestCase):
     bulk_update_data = {
     bulk_update_data = {
         'status': 'planned',
         'status': 'planned',
     }
     }
+    graphql_filter_tests = (
+        GraphQLFilterTest(
+            name='tenant__name__exact',
+            filters='tenant: {name: {exact: "Tenant 1"}}',
+            expected=lambda qs: qs.filter(tenant__name='Tenant 1'),
+            permissions=('tenancy.view_tenant',),
+        ),
+    )
+
+    def assert_nested_locations_active(self, data):
+        site_data = data.get('site') or {}
+        location_names = sorted(location['name'] for location in site_data.get('locations', []))
+        self.assertEqual(location_names, ['Site1 Active A', 'Site1 Active B'])
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -160,15 +175,32 @@ class SiteTestCase(APIViewTestCases.APIViewTestCase):
             SiteGroup.objects.create(name='Site Group 2', slug='site-group-2'),
             SiteGroup.objects.create(name='Site Group 2', slug='site-group-2'),
         )
         )
 
 
+        tenant = Tenant.objects.create(name='Tenant 1', slug='tenant-1')
+
+        # Site 1's tenant activates the dynamic tenant prefetch (+1 in api_list_objects baseline).
         sites = (
         sites = (
-            Site(region=regions[0], group=groups[0], name='Site 1', slug='site-1'),
+            Site(region=regions[0], group=groups[0], tenant=tenant, name='Site 1', slug='site-1'),
             Site(region=regions[0], group=groups[0], name='Site 2', slug='site-2'),
             Site(region=regions[0], group=groups[0], name='Site 2', slug='site-2'),
             Site(region=regions[0], group=groups[0], name='Site 3', slug='site-3'),
             Site(region=regions[0], group=groups[0], name='Site 3', slug='site-3'),
         )
         )
         Site.objects.bulk_create(sites)
         Site.objects.bulk_create(sites)
 
 
+        nested_site = Site.objects.get(slug='site-1')
+        cls.nested_site_pk = nested_site.pk
+        Location.objects.create(
+            site=nested_site, name='Site1 Active A', slug='site1-active-a',
+            status=LocationStatusChoices.STATUS_ACTIVE,
+        )
+        Location.objects.create(
+            site=nested_site, name='Site1 Active B', slug='site1-active-b',
+            status=LocationStatusChoices.STATUS_ACTIVE,
+        )
+        Location.objects.create(
+            site=nested_site, name='Site1 Planned', slug='site1-planned',
+            status=LocationStatusChoices.STATUS_PLANNED,
+        )
+
         rir = RIR.objects.create(name='RFC 6996', is_private=True)
         rir = RIR.objects.create(name='RFC 6996', is_private=True)
-        tenant = Tenant.objects.create(name='Tenant 1', slug='tenant-1')
 
 
         asns = [
         asns = [
             ASN(asn=65000 + i, rir=rir) for i in range(8)
             ASN(asn=65000 + i, rir=rir) for i in range(8)
@@ -203,6 +235,19 @@ class SiteTestCase(APIViewTestCases.APIViewTestCase):
             },
             },
         ]
         ]
 
 
+        cls.graphql_query_tests = (
+            GraphQLQueryTest(
+                name='nested_locations_by_status',
+                query=(
+                    '{ site(id: ' + str(cls.nested_site_pk) + ') { '
+                    'locations(filters: {status: {exact: STATUS_ACTIVE}}) { name } '
+                    '} }'
+                ),
+                assert_result=cls.assert_nested_locations_active,
+                permissions=('dcim.view_location',),
+            ),
+        )
+
     def test_add_tags(self):
     def test_add_tags(self):
         """
         """
         Add tags to an existing object via the add_tags field.
         Add tags to an existing object via the add_tags field.
@@ -427,6 +472,16 @@ class LocationTestCase(APIViewTestCases.APIViewTestCase):
         'description': 'New description',
         'description': 'New description',
     }
     }
     user_permissions = ('dcim.view_site',)
     user_permissions = ('dcim.view_site',)
+    graphql_filter_tests = (
+        GraphQLFilterTest(
+            name='status__in_list',
+            filters='status: {in_list: [STATUS_PLANNED, STATUS_STAGING]}',
+            expected=lambda qs: qs.filter(status__in=[
+                LocationStatusChoices.STATUS_PLANNED,
+                LocationStatusChoices.STATUS_STAGING,
+            ]),
+        ),
+    )
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -476,6 +531,20 @@ class LocationTestCase(APIViewTestCases.APIViewTestCase):
             parent=parent_locations[0],
             parent=parent_locations[0],
             status=LocationStatusChoices.STATUS_ACTIVE,
             status=LocationStatusChoices.STATUS_ACTIVE,
         )
         )
+        Location.objects.create(
+            site=sites[0],
+            name='GraphQL Planned Location',
+            slug='graphql-planned-location',
+            parent=parent_locations[0],
+            status=LocationStatusChoices.STATUS_PLANNED,
+        )
+        Location.objects.create(
+            site=sites[0],
+            name='GraphQL Staging Location',
+            slug='graphql-staging-location',
+            parent=parent_locations[0],
+            status=LocationStatusChoices.STATUS_STAGING,
+        )
 
 
         cls.create_data = [
         cls.create_data = [
             {
             {

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

@@ -503,6 +503,15 @@ class InterfaceTestCase(TestCase):
         self.assertNotIn('qinq_svlan', form.cleaned_data.keys())
         self.assertNotIn('qinq_svlan', form.cleaned_data.keys())
 
 
 
 
+class CableTestCase(TestCase):
+
+    def test_invalid_side_designation_raises_value_error(self):
+        """_clean_side rejects a side other than 'a' or 'b' with ValueError."""
+        form = CableImportForm.__new__(CableImportForm)
+        with self.assertRaisesMessage(ValueError, "Invalid side designation: c"):
+            form._clean_side('c')
+
+
 class SiteFormTestCase(TestCase):
 class SiteFormTestCase(TestCase):
     """
     """
     Tests for M2MAddRemoveFields using Site ASN assignments as the test case.
     Tests for M2MAddRemoveFields using Site ASN assignments as the test case.

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

@@ -1,4 +1,5 @@
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
+from django.db.models.signals import post_save
 from django.test import TestCase, tag
 from django.test import TestCase, tag
 
 
 from circuits.models import *
 from circuits.models import *
@@ -1187,6 +1188,105 @@ class ModuleBayTestCase(TestCase):
         # tree: its path is now a strict descendant of host_bay's path.
         # tree: its path is now a strict descendant of host_bay's path.
         self.assertTrue(str(movable_bay.path).startswith(f'{host_bay.path}.'))
         self.assertTrue(str(movable_bay.path).startswith(f'{host_bay.path}.'))
 
 
+    @tag('regression')  # #22251
+    def test_moving_module_reparents_child_module_bays(self):
+        """
+        When a module is moved to a different module bay, each child ModuleBay
+        (a bay that belongs to the module) must have its parent updated to the
+        new host bay. Without the fix the children stay parented to the old bay
+        even though Module.module_bay_id has changed.
+        """
+        device_type = DeviceType.objects.first()
+        device_role = DeviceRole.objects.first()
+        site = Site.objects.first()
+        device = Device.objects.create(
+            name='Move Module Device',
+            device_type=device_type,
+            role=device_role,
+            site=site,
+        )
+        bay_a = ModuleBay.objects.create(device=device, name='Bay A')
+        bay_b = ModuleBay.objects.create(device=device, name='Bay B')
+
+        manufacturer = Manufacturer.objects.first()
+        module_type = ModuleType.objects.create(
+            manufacturer=manufacturer, model='Move Module Type'
+        )
+        module = Module.objects.create(
+            device=device, module_bay=bay_a, module_type=module_type
+        )
+
+        child_1 = ModuleBay.objects.create(device=device, module=module, name='Child Bay 1')
+        child_2 = ModuleBay.objects.create(device=device, module=module, name='Child Bay 2')
+        self.assertEqual(child_1.parent_id, bay_a.pk)
+        self.assertEqual(child_2.parent_id, bay_a.pk)
+
+        # Move the module to bay_b.
+        module.module_bay = bay_b
+        module.save()
+
+        child_1.refresh_from_db()
+        child_2.refresh_from_db()
+        self.assertEqual(child_1.parent_id, bay_b.pk)
+        self.assertEqual(child_2.parent_id, bay_b.pk)
+        # Children must be re-rooted under bay_b in the ltree hierarchy.
+        bay_b.refresh_from_db()
+        self.assertTrue(str(child_1.path).startswith(f'{bay_b.path}.'))
+        self.assertTrue(str(child_2.path).startswith(f'{bay_b.path}.'))
+
+    @tag('regression')  # #22251
+    def test_moving_module_reparents_grandchild_module_bays(self):
+        """
+        When a module is moved, grandchild ModuleBays (bays inside a module
+        that is itself installed inside a child bay of the moved module) must
+        also land in the new ltree subtree. The trigger cascade moves subtrees
+        atomically, so calling save() only on direct children is sufficient —
+        this test documents and preserves that invariant for future tree-backend
+        changes.
+        """
+        device_type = DeviceType.objects.first()
+        device_role = DeviceRole.objects.first()
+        site = Site.objects.first()
+        device = Device.objects.create(
+            name='Grandchild Move Device',
+            device_type=device_type,
+            role=device_role,
+            site=site,
+        )
+        bay_a = ModuleBay.objects.create(device=device, name='Bay A')
+        bay_b = ModuleBay.objects.create(device=device, name='Bay B')
+
+        manufacturer = Manufacturer.objects.first()
+        module_type = ModuleType.objects.create(
+            manufacturer=manufacturer, model='Grandchild Move Type'
+        )
+        # Depth-1: module installed in bay_a, with one child bay.
+        module_1 = Module.objects.create(device=device, module_bay=bay_a, module_type=module_type)
+        child_bay = ModuleBay.objects.create(device=device, module=module_1, name='Child Bay')
+
+        # Depth-2: module installed in child_bay, with one grandchild bay.
+        module_2 = Module.objects.create(device=device, module_bay=child_bay, module_type=module_type)
+        grandchild_bay = ModuleBay.objects.create(device=device, module=module_2, name='Grandchild Bay')
+
+        self.assertEqual(child_bay.parent_id, bay_a.pk)
+        self.assertEqual(grandchild_bay.parent_id, child_bay.pk)
+        bay_a.refresh_from_db()
+        self.assertTrue(str(grandchild_bay.path).startswith(f'{bay_a.path}.'))
+
+        # Move the top-level module to bay_b.
+        module_1.module_bay = bay_b
+        module_1.save()
+
+        child_bay.refresh_from_db()
+        grandchild_bay.refresh_from_db()
+        bay_b.refresh_from_db()
+
+        self.assertEqual(child_bay.parent_id, bay_b.pk)
+        self.assertTrue(str(child_bay.path).startswith(f'{bay_b.path}.'))
+        # Grandchild's direct parent (child_bay) is unchanged; only tree placement moves.
+        self.assertEqual(grandchild_bay.parent_id, child_bay.pk)
+        self.assertTrue(str(grandchild_bay.path).startswith(f'{bay_b.path}.'))
+
     def test_single_module_token(self):
     def test_single_module_token(self):
         device_type = DeviceType.objects.first()
         device_type = DeviceType.objects.first()
         device_role = DeviceRole.objects.first()
         device_role = DeviceRole.objects.first()
@@ -2066,6 +2166,73 @@ class CableTestCase(TestCase):
         self.assertIsNone(data['connected_endpoints_type'])
         self.assertIsNone(data['connected_endpoints_type'])
         self.assertFalse(data['connected_endpoints_reachable'])
         self.assertFalse(data['connected_endpoints_reachable'])
 
 
+    @tag('regression')  # #21338
+    def test_path_refreshes_unset_cablepath_reference(self):
+        """
+        An endpoint instance saved during cable creation, before path tracing,
+        should resolve its path and connected endpoints.
+
+        The stale-instance preconditions rely on Cable.save() saving each
+        CableTermination (which re-saves the endpoint) before trace_paths
+        creates the CablePath records.
+        """
+        device = Device.objects.get(name='TestDevice2')
+        interface_a = Interface.objects.create(device=device, name='eth2')
+        interface_b = Interface.objects.create(device=device, name='eth3')
+
+        # Capture the instances handed to the event machinery on save
+        saved_instances = []
+
+        def capture(sender, instance, **kwargs):
+            saved_instances.append(instance)
+
+        post_save.connect(capture, sender=Interface)
+        try:
+            Cable(a_terminations=[interface_a], b_terminations=[interface_b]).save()
+        finally:
+            post_save.disconnect(capture, sender=Interface)
+
+        self.assertEqual(len(saved_instances), 2)
+        captured_a = next(i for i in saved_instances if i.pk == interface_a.pk)
+        captured_b = next(i for i in saved_instances if i.pk == interface_b.pk)
+
+        # The captured instances predate path tracing: cabled, but no path yet
+        self.assertIsNotNone(captured_a.cable_id)
+        self.assertIsNone(captured_a._path_id)
+        self.assertIsNone(captured_b._path_id)
+
+        # The accessor must repair the unset denormalized reference
+        self.assertIsNotNone(captured_a.path)
+        self.assertEqual(captured_a.connected_endpoints, [interface_b])
+
+        # Serialization as performed by the event queue must see the peer
+        data = serialize_for_event(captured_b)
+        self.assertEqual([endpoint['id'] for endpoint in data['connected_endpoints']], [interface_a.pk])
+        self.assertEqual([peer['id'] for peer in data['link_peers']], [interface_a.pk])
+        self.assertTrue(data['connected_endpoints_reachable'])
+
+    def test_path_returns_none_for_unsaved_endpoint(self):
+        """
+        An unsaved endpoint with a link assigned should report no path rather
+        than attempting a database refresh.
+        """
+        device = Device.objects.get(name='TestDevice1')
+        cable = Cable.objects.first()
+        interface = Interface(device=device, name='tmp', cable=cable)
+        self.assertIsNone(interface.path)
+
+
+class CableTerminationTestCase(TestCase):
+
+    def test_cache_related_objects_requires_resolvable_termination(self):
+        """cache_related_objects raises ValueError when the termination cannot be resolved."""
+        cable_termination = CableTermination(
+            termination_type=ObjectType.objects.get_for_model(Interface),
+            termination_id=0,
+        )
+        with self.assertRaises(ValueError):
+            cable_termination.cache_related_objects()
+
 
 
 class VirtualDeviceContextTestCase(TestCase):
 class VirtualDeviceContextTestCase(TestCase):
 
 

+ 3 - 3
netbox/dcim/ui/panels.py

@@ -32,7 +32,7 @@ class RackDimensionsPanel(panels.ObjectAttributesPanel):
     outer_width = attrs.NumericAttr('outer_width', unit_accessor='get_outer_unit_display')
     outer_width = attrs.NumericAttr('outer_width', unit_accessor='get_outer_unit_display')
     outer_height = attrs.NumericAttr('outer_height', unit_accessor='get_outer_unit_display')
     outer_height = attrs.NumericAttr('outer_height', unit_accessor='get_outer_unit_display')
     outer_depth = attrs.NumericAttr('outer_depth', unit_accessor='get_outer_unit_display')
     outer_depth = attrs.NumericAttr('outer_depth', unit_accessor='get_outer_unit_display')
-    mounting_depth = attrs.TextAttr('mounting_depth', format_string=_('{} millimeters'))
+    mounting_depth = attrs.TextAttr('mounting_depth', format_string=_('{0} millimeters'))
 
 
 
 
 class RackNumberingPanel(panels.ObjectAttributesPanel):
 class RackNumberingPanel(panels.ObjectAttributesPanel):
@@ -355,8 +355,8 @@ class PowerFeedElectricalPanel(panels.ObjectAttributesPanel):
     title = _('Electrical Characteristics')
     title = _('Electrical Characteristics')
 
 
     supply = attrs.ChoiceAttr('supply')
     supply = attrs.ChoiceAttr('supply')
-    voltage = attrs.TextAttr('voltage', format_string=_('{}V'))
-    amperage = attrs.TextAttr('amperage', format_string=_('{}A'))
+    voltage = attrs.TextAttr('voltage', format_string='{}V')
+    amperage = attrs.TextAttr('amperage', format_string='{}A')
     phase = attrs.ChoiceAttr('phase')
     phase = attrs.ChoiceAttr('phase')
     max_utilization = attrs.TextAttr('max_utilization', format_string='{}%')
     max_utilization = attrs.TextAttr('max_utilization', format_string='{}%')
 
 

+ 8 - 2
netbox/extras/events.py

@@ -125,7 +125,13 @@ def enqueue_event(queue, instance, request, event_type):
     app_label = instance._meta.app_label
     app_label = instance._meta.app_label
     model_name = instance._meta.model_name
     model_name = instance._meta.model_name
 
 
-    assert instance.pk is not None
+    if instance.pk is None:
+        raise ValueError(
+            _("Cannot enqueue an event for an unsaved {app_label}.{model} instance.").format(
+                app_label=app_label,
+                model=model_name,
+            )
+        )
     key = f'{app_label}.{model_name}:{instance.pk}'
     key = f'{app_label}.{model_name}:{instance.pk}'
 
 
     if key in queue:
     if key in queue:
@@ -251,7 +257,7 @@ def process_event_rules(event_rules, object_type, event):
             if 'snapshots' in event:
             if 'snapshots' in event:
                 params['snapshots'] = event['snapshots']
                 params['snapshots'] = event['snapshots']
             if 'request' in event:
             if 'request' in event:
-                params['request'] = copy_safe_request(event['request'])
+                params['request'] = copy_safe_request(event['request'], include_files=False)
 
 
             # Enqueue the job
             # Enqueue the job
             ScriptJob.enqueue(**params)
             ScriptJob.enqueue(**params)

+ 22 - 1
netbox/extras/fields.py

@@ -1,4 +1,10 @@
-from django.db.models import TextField
+from django.contrib.postgres.fields import ArrayField
+from django.db.models import CharField, TextField
+
+__all__ = (
+    'CachedValueField',
+    'ChoiceSetField',
+)
 
 
 
 
 class CachedValueField(TextField):
 class CachedValueField(TextField):
@@ -6,3 +12,18 @@ class CachedValueField(TextField):
     Currently a dummy field to prevent custom lookups being applied globally to TextField.
     Currently a dummy field to prevent custom lookups being applied globally to TextField.
     """
     """
     pass
     pass
+
+
+class ChoiceSetField(ArrayField):
+    """
+    An ArrayField of two-element [value, label] string pairs representing custom field choices.
+    """
+    def __init__(self, **kwargs):
+        kwargs['base_field'] = ArrayField(base_field=CharField(max_length=100), size=2)
+        super().__init__(**kwargs)
+
+    def deconstruct(self):
+        name, path, args, kwargs = super().deconstruct()
+        # base_field is fixed by __init__ and omitted from migrations
+        del kwargs['base_field']
+        return name, path, args, kwargs

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

@@ -212,7 +212,7 @@ class SavedFilterBulkEditForm(ChangelogMessageMixin, OwnerMixin, BulkEditForm):
     nullable_fields = ('description',)
     nullable_fields = ('description',)
 
 
 
 
-class TableConfigBulkEditForm(BulkEditForm):
+class TableConfigBulkEditForm(ChangelogMessageMixin, BulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=TableConfig.objects.all(),
         queryset=TableConfig.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput

+ 21 - 2
netbox/extras/forms/model_forms.py

@@ -403,7 +403,7 @@ class SavedFilterForm(ChangelogMessageMixin, OwnerMixin, forms.ModelForm):
         super().__init__(*args, initial=initial, **kwargs)
         super().__init__(*args, initial=initial, **kwargs)
 
 
 
 
-class TableConfigForm(forms.ModelForm):
+class TableConfigForm(ChangelogMessageMixin, forms.ModelForm):
     object_type = ContentTypeChoiceField(
     object_type = ContentTypeChoiceField(
         label=_('Object type'),
         label=_('Object type'),
         queryset=ObjectType.objects.all()
         queryset=ObjectType.objects.all()
@@ -439,10 +439,29 @@ class TableConfigForm(forms.ModelForm):
     def __init__(self, data=None, *args, **kwargs):
     def __init__(self, data=None, *args, **kwargs):
         super().__init__(data, *args, **kwargs)
         super().__init__(data, *args, **kwargs)
 
 
-        object_type = ObjectType.objects.get(pk=get_field_value(self, 'object_type'))
+        self.fields['available_columns'].widget.choices = ()
+        self.fields['columns'].widget.choices = ()
+
+        # Table context may be absent e.g. when the add view is requested directly
+        object_type_pk = get_field_value(self, 'object_type')
+        object_type_pk = getattr(object_type_pk, 'pk', object_type_pk)
+        if not object_type_pk:
+            return
+
+        try:
+            object_type = ObjectType.objects.get(pk=object_type_pk)
+        except (ObjectType.DoesNotExist, TypeError, ValueError):
+            return
+
         model = object_type.model_class()
         model = object_type.model_class()
+        if model is None:
+            return
+
         table_name = get_field_value(self, 'table')
         table_name = get_field_value(self, 'table')
         table_class = get_table_for_model(model, table_name)
         table_class = get_table_for_model(model, table_name)
+        if table_class is None:
+            return
+
         table = table_class([])
         table = table_class([])
 
 
         if columns := self._get_columns():
         if columns := self._get_columns():

+ 1 - 2
netbox/extras/forms/scripts.py

@@ -112,5 +112,4 @@ class ScriptFileForm(ManagedFileForm):
             data = self.cleaned_data['upload_file']
             data = self.cleaned_data['upload_file']
             storage.save(filename, data)
             storage.save(filename, data)
 
 
-        # need to skip ManagedFileForm save method
-        return super(ManagedFileForm, self).save(*args, **kwargs)
+        return super().save(*args, **kwargs)

+ 30 - 0
netbox/extras/graphql/filter_lookups.py

@@ -0,0 +1,30 @@
+import strawberry
+import strawberry_django
+from django.db.models import Q, QuerySet
+from strawberry.directive import DirectiveValue
+from strawberry.types import Info
+
+__all__ = (
+    'ExtraChoicesLookup',
+)
+
+
+@strawberry.input(
+    one_of=True,
+    description='Lookup for extra choices defined on a choice set. Only one of the lookup fields can be set.',
+)
+class ExtraChoicesLookup:
+    contains: str | None = strawberry.field(
+        default=strawberry.UNSET, description='Has an extra choice with this value'
+    )
+    length: int | None = strawberry.field(
+        default=strawberry.UNSET, description='Number of extra choices'
+    )
+
+    @strawberry_django.filter_field
+    def filter(self, info: Info, queryset: QuerySet, prefix: DirectiveValue[str] = '') -> tuple[QuerySet, Q]:
+        if self.contains is not strawberry.UNSET and self.contains is not None:
+            return queryset, Q(**{f'{prefix}choice_value': self.contains})
+        if self.length is not strawberry.UNSET and self.length is not None:
+            return queryset, Q(**{f'{prefix}len': self.length})
+        return queryset, Q()

+ 58 - 58
netbox/extras/graphql/filters.py

@@ -1,4 +1,3 @@
-from datetime import datetime
 from typing import TYPE_CHECKING, Annotated
 from typing import TYPE_CHECKING, Annotated
 
 
 import strawberry
 import strawberry
@@ -23,6 +22,7 @@ if TYPE_CHECKING:
         SiteFilter,
         SiteFilter,
         SiteGroupFilter,
         SiteGroupFilter,
     )
     )
+    from extras.graphql.filter_lookups import ExtraChoicesLookup
     from netbox.graphql.enums import ColorEnum
     from netbox.graphql.enums import ColorEnum
     from netbox.graphql.filter_lookups import FloatLookup, IntegerLookup, JSONFilter, StringArrayLookup, TreeNodeFilter
     from netbox.graphql.filter_lookups import FloatLookup, IntegerLookup, JSONFilter, StringArrayLookup, TreeNodeFilter
     from tenancy.graphql.filters import TenantFilter, TenantGroupFilter
     from tenancy.graphql.filters import TenantFilter, TenantGroupFilter
@@ -54,11 +54,11 @@ __all__ = (
 
 
 @strawberry_django.filter_type(models.ConfigContext, lookups=True)
 @strawberry_django.filter_type(models.ConfigContext, lookups=True)
 class ConfigContextFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter):
 class ConfigContextFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter):
-    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup | None = strawberry_django.filter_field()
     weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
     weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
-    description: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    description: StrFilterLookup | None = strawberry_django.filter_field()
     is_active: FilterLookup[bool] | None = strawberry_django.filter_field()
     is_active: FilterLookup[bool] | None = strawberry_django.filter_field()
     regions: Annotated['RegionFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
     regions: Annotated['RegionFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
@@ -111,22 +111,22 @@ class ConfigContextFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter):
 
 
 @strawberry_django.filter_type(models.ConfigContextProfile, lookups=True)
 @strawberry_django.filter_type(models.ConfigContextProfile, lookups=True)
 class ConfigContextProfileFilter(SyncedDataFilterMixin, PrimaryModelFilter):
 class ConfigContextProfileFilter(SyncedDataFilterMixin, PrimaryModelFilter):
-    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    description: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup | None = strawberry_django.filter_field()
+    description: StrFilterLookup | None = strawberry_django.filter_field()
     tags: Annotated['TagFilter', strawberry.lazy('extras.graphql.filters')] | None = strawberry_django.filter_field()
     tags: Annotated['TagFilter', strawberry.lazy('extras.graphql.filters')] | None = strawberry_django.filter_field()
 
 
 
 
 @strawberry_django.filter_type(models.ConfigTemplate, lookups=True)
 @strawberry_django.filter_type(models.ConfigTemplate, lookups=True)
 class ConfigTemplateFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter):
 class ConfigTemplateFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter):
-    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    description: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    template_code: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup | None = strawberry_django.filter_field()
+    description: StrFilterLookup | None = strawberry_django.filter_field()
+    template_code: StrFilterLookup | None = strawberry_django.filter_field()
     environment_params: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
     environment_params: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
-    mime_type: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    file_name: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    file_extension: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    mime_type: StrFilterLookup | None = strawberry_django.filter_field()
+    file_name: StrFilterLookup | None = strawberry_django.filter_field()
+    file_extension: StrFilterLookup | None = strawberry_django.filter_field()
     as_attachment: FilterLookup[bool] | None = strawberry_django.filter_field()
     as_attachment: FilterLookup[bool] | None = strawberry_django.filter_field()
 
 
 
 
@@ -141,10 +141,10 @@ class CustomFieldFilter(ChangeLoggedModelFilter):
     related_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
     related_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
-    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    label: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    group_name: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    description: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup | None = strawberry_django.filter_field()
+    label: StrFilterLookup | None = strawberry_django.filter_field()
+    group_name: StrFilterLookup | None = strawberry_django.filter_field()
+    description: StrFilterLookup | None = strawberry_django.filter_field()
     required: FilterLookup[bool] | None = strawberry_django.filter_field()
     required: FilterLookup[bool] | None = strawberry_django.filter_field()
     unique: FilterLookup[bool] | None = strawberry_django.filter_field()
     unique: FilterLookup[bool] | None = strawberry_django.filter_field()
     search_weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
     search_weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
@@ -170,7 +170,7 @@ class CustomFieldFilter(ChangeLoggedModelFilter):
     validation_maximum: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
     validation_maximum: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
-    validation_regex: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    validation_regex: StrFilterLookup | None = strawberry_django.filter_field()
     choice_set: Annotated['CustomFieldChoiceSetFilter', strawberry.lazy('extras.graphql.filters')] | None = (
     choice_set: Annotated['CustomFieldChoiceSetFilter', strawberry.lazy('extras.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -186,19 +186,19 @@ class CustomFieldFilter(ChangeLoggedModelFilter):
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
     is_cloneable: FilterLookup[bool] | None = strawberry_django.filter_field()
     is_cloneable: FilterLookup[bool] | None = strawberry_django.filter_field()
-    comments: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    comments: StrFilterLookup | None = strawberry_django.filter_field()
 
 
 
 
 @strawberry_django.filter_type(models.CustomFieldChoiceSet, lookups=True)
 @strawberry_django.filter_type(models.CustomFieldChoiceSet, lookups=True)
 class CustomFieldChoiceSetFilter(ChangeLoggedModelFilter):
 class CustomFieldChoiceSetFilter(ChangeLoggedModelFilter):
-    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    description: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup | None = strawberry_django.filter_field()
+    description: StrFilterLookup | None = strawberry_django.filter_field()
     base_choices: (
     base_choices: (
         BaseFilterLookup[Annotated['CustomFieldChoiceSetBaseEnum', strawberry.lazy('extras.graphql.enums')]] | None
         BaseFilterLookup[Annotated['CustomFieldChoiceSetBaseEnum', strawberry.lazy('extras.graphql.enums')]] | None
     ) = (
     ) = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
-    extra_choices: Annotated['StringArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+    extra_choices: Annotated['ExtraChoicesLookup', strawberry.lazy('extras.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
     order_alphabetically: FilterLookup[bool] | None = strawberry_django.filter_field()
     order_alphabetically: FilterLookup[bool] | None = strawberry_django.filter_field()
@@ -233,14 +233,14 @@ class CustomFieldChoiceSetFilter(ChangeLoggedModelFilter):
 
 
 @strawberry_django.filter_type(models.CustomLink, lookups=True)
 @strawberry_django.filter_type(models.CustomLink, lookups=True)
 class CustomLinkFilter(ChangeLoggedModelFilter):
 class CustomLinkFilter(ChangeLoggedModelFilter):
-    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup | None = strawberry_django.filter_field()
     enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
     enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
-    link_text: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    link_url: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    link_text: StrFilterLookup | None = strawberry_django.filter_field()
+    link_url: StrFilterLookup | None = strawberry_django.filter_field()
     weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
     weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
-    group_name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    group_name: StrFilterLookup | None = strawberry_django.filter_field()
     button_class: (
     button_class: (
         BaseFilterLookup[Annotated['CustomLinkButtonClassEnum', strawberry.lazy('extras.graphql.enums')]] | None
         BaseFilterLookup[Annotated['CustomLinkButtonClassEnum', strawberry.lazy('extras.graphql.enums')]] | None
     ) = (
     ) = (
@@ -251,15 +251,15 @@ class CustomLinkFilter(ChangeLoggedModelFilter):
 
 
 @strawberry_django.filter_type(models.ExportTemplate, lookups=True)
 @strawberry_django.filter_type(models.ExportTemplate, lookups=True)
 class ExportTemplateFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter):
 class ExportTemplateFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter):
-    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    description: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    template_code: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup | None = strawberry_django.filter_field()
+    description: StrFilterLookup | None = strawberry_django.filter_field()
+    template_code: StrFilterLookup | None = strawberry_django.filter_field()
     environment_params: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
     environment_params: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
-    mime_type: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    file_name: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    file_extension: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    mime_type: StrFilterLookup | None = strawberry_django.filter_field()
+    file_name: StrFilterLookup | None = strawberry_django.filter_field()
+    file_extension: StrFilterLookup | None = strawberry_django.filter_field()
     as_attachment: FilterLookup[bool] | None = strawberry_django.filter_field()
     as_attachment: FilterLookup[bool] | None = strawberry_django.filter_field()
 
 
 
 
@@ -275,7 +275,7 @@ class ImageAttachmentFilter(ChangeLoggedModelFilter):
     image_width: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
     image_width: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
-    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup | None = strawberry_django.filter_field()
 
 
 
 
 @strawberry_django.filter_type(models.JournalEntry, lookups=True)
 @strawberry_django.filter_type(models.JournalEntry, lookups=True)
@@ -291,13 +291,13 @@ class JournalEntryFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedM
     kind: BaseFilterLookup[Annotated['JournalEntryKindEnum', strawberry.lazy('extras.graphql.enums')]] | None = (
     kind: BaseFilterLookup[Annotated['JournalEntryKindEnum', strawberry.lazy('extras.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
-    comments: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    comments: StrFilterLookup | None = strawberry_django.filter_field()
 
 
 
 
 @strawberry_django.filter_type(models.Notification, lookups=True)
 @strawberry_django.filter_type(models.Notification, lookups=True)
 class NotificationFilter(BaseModelFilter):
 class NotificationFilter(BaseModelFilter):
-    created: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
-    read: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
+    created: DatetimeFilterLookup | None = strawberry_django.filter_field()
+    read: DatetimeFilterLookup | None = strawberry_django.filter_field()
     user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
     user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
     user_id: ID | None = strawberry_django.filter_field()
     user_id: ID | None = strawberry_django.filter_field()
     object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
     object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
@@ -305,23 +305,23 @@ class NotificationFilter(BaseModelFilter):
     )
     )
     object_type_id: ID | None = strawberry_django.filter_field()
     object_type_id: ID | None = strawberry_django.filter_field()
     object_id: ID | None = strawberry_django.filter_field()
     object_id: ID | None = strawberry_django.filter_field()
-    object_repr: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    event_type: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    object_repr: StrFilterLookup | None = strawberry_django.filter_field()
+    event_type: StrFilterLookup | None = strawberry_django.filter_field()
 
 
 
 
 @strawberry_django.filter_type(models.NotificationGroup, lookups=True)
 @strawberry_django.filter_type(models.NotificationGroup, lookups=True)
 class NotificationGroupFilter(ChangeLoggedModelFilter):
 class NotificationGroupFilter(ChangeLoggedModelFilter):
-    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    description: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup | None = strawberry_django.filter_field()
+    description: StrFilterLookup | None = strawberry_django.filter_field()
     groups: Annotated['GroupFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
     groups: Annotated['GroupFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
     users: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
     users: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
 
 
 
 
 @strawberry_django.filter_type(models.SavedFilter, lookups=True)
 @strawberry_django.filter_type(models.SavedFilter, lookups=True)
 class SavedFilterFilter(ChangeLoggedModelFilter):
 class SavedFilterFilter(ChangeLoggedModelFilter):
-    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    slug: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    description: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup | None = strawberry_django.filter_field()
+    slug: StrFilterLookup | None = strawberry_django.filter_field()
+    description: StrFilterLookup | None = strawberry_django.filter_field()
     user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
     user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
     user_id: ID | None = strawberry_django.filter_field()
     user_id: ID | None = strawberry_django.filter_field()
     weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
     weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
@@ -336,7 +336,7 @@ class SavedFilterFilter(ChangeLoggedModelFilter):
 
 
 @strawberry_django.filter_type(models.Subscription, lookups=True)
 @strawberry_django.filter_type(models.Subscription, lookups=True)
 class SubscriptionFilter(BaseModelFilter):
 class SubscriptionFilter(BaseModelFilter):
-    created: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
+    created: DatetimeFilterLookup | None = strawberry_django.filter_field()
     user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
     user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
     user_id: ID | None = strawberry_django.filter_field()
     user_id: ID | None = strawberry_django.filter_field()
     object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
     object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
@@ -348,8 +348,8 @@ class SubscriptionFilter(BaseModelFilter):
 
 
 @strawberry_django.filter_type(models.TableConfig, lookups=True)
 @strawberry_django.filter_type(models.TableConfig, lookups=True)
 class TableConfigFilter(ChangeLoggedModelFilter):
 class TableConfigFilter(ChangeLoggedModelFilter):
-    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    description: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup | None = strawberry_django.filter_field()
+    description: StrFilterLookup | None = strawberry_django.filter_field()
     user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
     user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
     user_id: ID | None = strawberry_django.filter_field()
     user_id: ID | None = strawberry_django.filter_field()
     weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
     weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
@@ -361,30 +361,30 @@ class TableConfigFilter(ChangeLoggedModelFilter):
 
 
 @strawberry_django.filter_type(models.Tag, lookups=True)
 @strawberry_django.filter_type(models.Tag, lookups=True)
 class TagFilter(ChangeLoggedModelFilter):
 class TagFilter(ChangeLoggedModelFilter):
-    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    slug: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup | None = strawberry_django.filter_field()
+    slug: StrFilterLookup | None = strawberry_django.filter_field()
     color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
     color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
-    description: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    description: StrFilterLookup | None = strawberry_django.filter_field()
 
 
 
 
 @strawberry_django.filter_type(models.Webhook, lookups=True)
 @strawberry_django.filter_type(models.Webhook, lookups=True)
 class WebhookFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter):
 class WebhookFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter):
-    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    description: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    payload_url: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup | None = strawberry_django.filter_field()
+    description: StrFilterLookup | None = strawberry_django.filter_field()
+    payload_url: StrFilterLookup | None = strawberry_django.filter_field()
     http_method: (
     http_method: (
         BaseFilterLookup[Annotated['WebhookHttpMethodEnum', strawberry.lazy('extras.graphql.enums')]] | None
         BaseFilterLookup[Annotated['WebhookHttpMethodEnum', strawberry.lazy('extras.graphql.enums')]] | None
     ) = (
     ) = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
-    http_content_type: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    additional_headers: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    body_template: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    secret: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    http_content_type: StrFilterLookup | None = strawberry_django.filter_field()
+    additional_headers: StrFilterLookup | None = strawberry_django.filter_field()
+    body_template: StrFilterLookup | None = strawberry_django.filter_field()
+    secret: StrFilterLookup | None = strawberry_django.filter_field()
     ssl_verification: FilterLookup[bool] | None = strawberry_django.filter_field()
     ssl_verification: FilterLookup[bool] | None = strawberry_django.filter_field()
-    ca_file_path: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    ca_file_path: StrFilterLookup | None = strawberry_django.filter_field()
     events: Annotated['EventRuleFilter', strawberry.lazy('extras.graphql.filters')] | None = (
     events: Annotated['EventRuleFilter', strawberry.lazy('extras.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -392,8 +392,8 @@ class WebhookFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelF
 
 
 @strawberry_django.filter_type(models.EventRule, lookups=True)
 @strawberry_django.filter_type(models.EventRule, lookups=True)
 class EventRuleFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter):
 class EventRuleFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter):
-    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    description: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup | None = strawberry_django.filter_field()
+    description: StrFilterLookup | None = strawberry_django.filter_field()
     event_types: Annotated['StringArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
     event_types: Annotated['StringArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -412,4 +412,4 @@ class EventRuleFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedMode
     action_data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
     action_data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
-    comments: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    comments: StrFilterLookup | None = strawberry_django.filter_field()

+ 27 - 1
netbox/extras/lookups.py

@@ -3,7 +3,16 @@ from django.contrib.postgres.fields.ranges import RangeField
 from django.db.models import CharField, JSONField, Lookup
 from django.db.models import CharField, JSONField, Lookup
 from django.db.models.fields.json import KeyTextTransform
 from django.db.models.fields.json import KeyTextTransform
 
 
-from .fields import CachedValueField
+from .fields import CachedValueField, ChoiceSetField
+
+__all__ = (
+    'ChoiceValueLookup',
+    'Empty',
+    'JSONEmpty',
+    'NetContainsOrEquals',
+    'NetHost',
+    'RangeContains',
+)
 
 
 
 
 class RangeContains(Lookup):
 class RangeContains(Lookup):
@@ -34,6 +43,22 @@ class RangeContains(Lookup):
         return sql, params
         return sql, params
 
 
 
 
+class ChoiceValueLookup(Lookup):
+    """
+    Match rows where any [value, label] pair in a ChoiceSetField has the given value.
+
+    Compares the RHS against the first element (the value) of each pair.
+    """
+    lookup_name = 'choice_value'
+    prepare_rhs = False
+
+    def as_sql(self, compiler, connection):
+        lhs, lhs_params = self.process_lhs(compiler, connection)
+        rhs, rhs_params = self.process_rhs(compiler, connection)
+        # Slice the value column of the two-dimensional array and match any element
+        return f'{rhs} = ANY({lhs}[:][1:1])', [*rhs_params, *lhs_params]
+
+
 class Empty(Lookup):
 class Empty(Lookup):
     """
     """
     Filter on whether a string is empty.
     Filter on whether a string is empty.
@@ -99,6 +124,7 @@ class NetContainsOrEquals(Lookup):
 
 
 
 
 ArrayField.register_lookup(RangeContains)
 ArrayField.register_lookup(RangeContains)
+ChoiceSetField.register_lookup(ChoiceValueLookup)
 CharField.register_lookup(Empty)
 CharField.register_lookup(Empty)
 JSONField.register_lookup(JSONEmpty)
 JSONField.register_lookup(JSONEmpty)
 CachedValueField.register_lookup(NetHost)
 CachedValueField.register_lookup(NetHost)

+ 18 - 0
netbox/extras/migrations/0139_alter_customfieldchoiceset_extra_choices.py

@@ -0,0 +1,18 @@
+from django.db import migrations
+
+import extras.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0138_customfieldchoiceset_choice_colors'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='customfieldchoiceset',
+            name='extra_choices',
+            field=extras.fields.ChoiceSetField(blank=True, null=True),
+        ),
+    ]

+ 4 - 6
netbox/extras/models/customfields.py

@@ -7,7 +7,6 @@ import django_filters
 import jsonschema
 import jsonschema
 from django import forms
 from django import forms
 from django.conf import settings
 from django.conf import settings
-from django.contrib.postgres.fields import ArrayField
 from django.core.validators import RegexValidator, ValidationError
 from django.core.validators import RegexValidator, ValidationError
 from django.db import models
 from django.db import models
 from django.db.models import F, Func, Value
 from django.db.models import F, Func, Value
@@ -21,6 +20,7 @@ from jsonschema.exceptions import ValidationError as JSONValidationError
 from core.models import ObjectType
 from core.models import ObjectType
 from extras.choices import *
 from extras.choices import *
 from extras.data import CHOICE_SETS
 from extras.data import CHOICE_SETS
+from extras.fields import ChoiceSetField
 from netbox.context import query_cache
 from netbox.context import query_cache
 from netbox.models import ChangeLoggedModel
 from netbox.models import ChangeLoggedModel
 from netbox.models.features import CloningMixin, ExportTemplatesMixin
 from netbox.models.features import CloningMixin, ExportTemplatesMixin
@@ -461,6 +461,8 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
         """
         """
         if value is None:
         if value is None:
             return value
             return value
+        if self.type == CustomFieldTypeChoices.TYPE_DECIMAL:
+            return float(value)
         if self.type == CustomFieldTypeChoices.TYPE_DATE and type(value) is date:
         if self.type == CustomFieldTypeChoices.TYPE_DATE and type(value) is date:
             return value.isoformat()
             return value.isoformat()
         if self.type == CustomFieldTypeChoices.TYPE_DATETIME and type(value) is datetime:
         if self.type == CustomFieldTypeChoices.TYPE_DATETIME and type(value) is datetime:
@@ -877,11 +879,7 @@ class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, OwnerMixin, Chang
         null=True,
         null=True,
         help_text=_('Base set of predefined choices (optional)')
         help_text=_('Base set of predefined choices (optional)')
     )
     )
-    extra_choices = ArrayField(
-        ArrayField(
-            base_field=models.CharField(max_length=100),
-            size=2
-        ),
+    extra_choices = ChoiceSetField(
         blank=True,
         blank=True,
         null=True
         null=True
     )
     )

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

@@ -649,6 +649,10 @@ class TableConfig(CloningMixin, ChangeLoggedModel):
     def clean(self):
     def clean(self):
         super().clean()
         super().clean()
 
 
+        # Skip table validation until the object type and table have been set
+        if not self.object_type_id or not self.table:
+            return
+
         # Validate table
         # Validate table
         if self.table_class is None:
         if self.table_class is None:
             raise ValidationError({
             raise ValidationError({
@@ -667,7 +671,7 @@ class TableConfig(CloningMixin, ChangeLoggedModel):
                 })
                 })
 
 
         # Validate selected columns
         # Validate selected columns
-        for name in self.columns:
+        for name in self.columns or []:
             if name not in table.columns:
             if name not in table.columns:
                 raise ValidationError({
                 raise ValidationError({
                     'columns': _('Unknown column: {name}').format(name=name)
                     'columns': _('Unknown column: {name}').format(name=name)

+ 58 - 0
netbox/extras/tests/test_api.py

@@ -325,6 +325,64 @@ class CustomFieldChoiceSetTestCase(APIViewTestCases.APIViewTestCase):
         response = self.client.post(self._get_list_url(), data, format='json', **self.header)
         response = self.client.post(self._get_list_url(), data, format='json', **self.header)
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
 
 
+    def test_graphql_filter_extra_choices(self):
+        """Filter choice sets by choice value and by number of choices."""
+        self.add_permissions('extras.view_customfieldchoiceset')
+
+        # '1A' appears here only as a label, so it must not match contains
+        CustomFieldChoiceSet.objects.create(
+            name='Choice Set Labels',
+            extra_choices=[['sel1', 'Selection 1'], ['other', '1A']],
+        )
+
+        def run(lookup):
+            query = '{ custom_field_choice_set_list(filters: {extra_choices: ' + lookup + '}) { name } }'
+            response = self.client.post(reverse('graphql'), data={'query': query}, format='json', **self.header)
+            self.assertHttpStatus(response, status.HTTP_200_OK)
+            data = response.json()
+            self.assertNotIn('errors', data)
+            return sorted(row['name'] for row in data['data']['custom_field_choice_set_list'])
+
+        # contains matches choice values only, never labels
+        self.assertEqual(run('{contains: "1A"}'), ['Choice Set 1'])
+        self.assertEqual(run('{contains: "sel1"}'), ['Choice Set Labels'])
+        self.assertEqual(run('{contains: "Selection 1"}'), [])
+        # length is the number of [value, label] pairs
+        self.assertEqual(run('{length: 2}'), ['Choice Set Labels'])
+        self.assertEqual(run('{length: 1}'), [])
+
+    def test_graphql_filter_extra_choices_rejects_array_operands(self):
+        """The legacy flat and nested array operand shapes fail schema validation."""
+        self.add_permissions('extras.view_customfieldchoiceset')
+
+        def run_invalid(lookup):
+            query = '{ custom_field_choice_set_list(filters: {extra_choices: ' + lookup + '}) { name } }'
+            response = self.client.post(reverse('graphql'), data={'query': query}, format='json', **self.header)
+            self.assertHttpStatus(response, status.HTTP_200_OK)
+            self.assertIn('errors', response.json())
+
+        # shapes advertised or attempted before #22324
+        run_invalid('{contains: ["1A"]}')
+        run_invalid('{contains: [["1A", "Choice 1A"]]}')
+
+    def test_graphql_filter_extra_choices_via_relation(self):
+        """The extra_choices lookup composes through the choice_set relation prefix."""
+        self.add_permissions('extras.view_customfield')
+
+        for choice_set in CustomFieldChoiceSet.objects.filter(name__in=['Choice Set 1', 'Choice Set 2']):
+            CustomField.objects.create(
+                name=f'cf_{choice_set.name[-1]}',
+                type=CustomFieldTypeChoices.TYPE_SELECT,
+                choice_set=choice_set,
+            )
+
+        query = '{ custom_field_list(filters: {choice_set: {extra_choices: {contains: "1A"}}}) { name } }'
+        response = self.client.post(reverse('graphql'), data={'query': query}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        data = response.json()
+        self.assertNotIn('errors', data)
+        self.assertEqual([row['name'] for row in data['data']['custom_field_list']], ['cf_1'])
+
 
 
 class CustomLinkTestCase(APIViewTestCases.APIViewTestCase):
 class CustomLinkTestCase(APIViewTestCases.APIViewTestCase):
     model = CustomLink
     model = CustomLink

+ 206 - 6
netbox/extras/tests/test_event_rules.py

@@ -1,30 +1,36 @@
 import json
 import json
+import logging
 import uuid
 import uuid
+from io import BytesIO
 from unittest import skipIf
 from unittest import skipIf
 from unittest.mock import Mock, patch
 from unittest.mock import Mock, patch
 
 
 import django_rq
 import django_rq
 from django.conf import settings
 from django.conf import settings
 from django.http import HttpResponse
 from django.http import HttpResponse
-from django.test import RequestFactory
+from django.test import RequestFactory, tag
 from django.urls import reverse
 from django.urls import reverse
+from PIL import Image
 from requests import Session
 from requests import Session
 from rest_framework import status
 from rest_framework import status
 
 
+from core.choices import ManagedFileRootPathChoices
 from core.events import *
 from core.events import *
-from core.models import ObjectType
+from core.models import Job, ObjectType
 from dcim.choices import SiteStatusChoices
 from dcim.choices import SiteStatusChoices
-from dcim.models import Site
+from dcim.models import DeviceType, Interface, Manufacturer, Site
 from extras.choices import EventRuleActionChoices
 from extras.choices import EventRuleActionChoices
 from extras.events import enqueue_event, flush_events, serialize_for_event
 from extras.events import enqueue_event, flush_events, serialize_for_event
-from extras.models import EventRule, Script, Tag, Webhook
+from extras.models import EventRule, Script, ScriptModule, Tag, Webhook
+from extras.scripts import Script as ScriptBase
 from extras.signals import process_job_end_event_rules
 from extras.signals import process_job_end_event_rules
 from extras.webhooks import generate_signature, send_webhook
 from extras.webhooks import generate_signature, send_webhook
 from netbox.context_managers import event_tracking
 from netbox.context_managers import event_tracking
-from utilities.testing import APITestCase
+from utilities.testing import APITestCase, create_test_device
+from utilities.testing.mixins import RQQueueTestMixin
 
 
 
 
-class EventRuleTestCase(APITestCase):
+class EventRuleTestCase(RQQueueTestMixin, APITestCase):
 
 
     def setUp(self):
     def setUp(self):
         super().setUp()
         super().setUp()
@@ -39,6 +45,16 @@ class EventRuleTestCase(APITestCase):
         # Clear the queue so leftover jobs do not leak to the next test suite
         # Clear the queue so leftover jobs do not leak to the next test suite
         self.queue.empty()
         self.queue.empty()
 
 
+    def test_enqueue_event_requires_saved_instance(self):
+        """enqueue_event raises ValueError for an unsaved instance."""
+        request = RequestFactory().get('/')
+        request.id = uuid.uuid4()
+        request.user = self.user
+        site = Site(name='Site 1', slug='site-1')
+        with patch('extras.events.has_feature', return_value=True):
+            with self.assertRaises(ValueError):
+                enqueue_event({}, site, request, OBJECT_CREATED)
+
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
 
 
@@ -531,6 +547,48 @@ class EventRuleTestCase(APITestCase):
         self.assertEqual(event['data']['name'], 'Site 1')
         self.assertEqual(event['data']['name'], 'Site 1')
         self.assertIsNone(event['snapshots']['postchange'])
         self.assertIsNone(event['snapshots']['postchange'])
 
 
+    @tag('regression')  # #21338
+    def test_cable_creation_event_payload_includes_connected_endpoints(self):
+        """
+        Interface update events queued during cable creation must include the
+        peer interface in connected_endpoints and link_peers.
+        """
+        webhook = Webhook.objects.get(name='Webhook 1')
+        event_rule = EventRule.objects.create(
+            name='Interface Update Rule',
+            event_types=[OBJECT_UPDATED],
+            action_type=EventRuleActionChoices.WEBHOOK,
+            action_object_type=ObjectType.objects.get_for_model(Webhook),
+            action_object_id=webhook.id,
+        )
+        event_rule.object_types.set([ObjectType.objects.get_for_model(Interface)])
+
+        device = create_test_device('Device 1')
+        interface_a = Interface.objects.create(device=device, name='eth0')
+        interface_b = Interface.objects.create(device=device, name='eth1')
+
+        # Create a cable between the two interfaces via the REST API
+        data = {
+            'a_terminations': [{'object_type': 'dcim.interface', 'object_id': interface_a.pk}],
+            'b_terminations': [{'object_type': 'dcim.interface', 'object_id': interface_b.pk}],
+        }
+        url = reverse('dcim-api:cable-list')
+        self.add_permissions('dcim.add_cable')
+        response = self.client.post(url, data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+
+        # One update event was queued for each interface
+        self.assertEqual(self.queue.count, 2)
+        payloads = {job.kwargs['data']['id']: job.kwargs['data'] for job in self.queue.jobs}
+        peers = {interface_a.pk: interface_b.pk, interface_b.pk: interface_a.pk}
+        self.assertEqual(set(payloads), set(peers))
+        for interface_id, payload in payloads.items():
+            peer_id = peers[interface_id]
+            self.assertIsNotNone(payload['connected_endpoints'])
+            self.assertEqual([endpoint['id'] for endpoint in payload['connected_endpoints']], [peer_id])
+            self.assertEqual([peer['id'] for peer in payload['link_peers']], [peer_id])
+            self.assertTrue(payload['connected_endpoints_reachable'])
+
     def test_duplicate_triggers(self):
     def test_duplicate_triggers(self):
         """
         """
         Test for erroneous duplicate event triggers resulting from saving an object multiple times
         Test for erroneous duplicate event triggers resulting from saving an object multiple times
@@ -601,3 +659,145 @@ class EventRuleTestCase(APITestCase):
         self.add_permissions('dcim.add_site')
         self.add_permissions('dcim.add_site')
         response = self.client.post(url, {'name': 'Site X', 'slug': 'site-x'}, format='json', **self.header)
         response = self.client.post(url, {'name': 'Site X', 'slug': 'site-x'}, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
+
+    @tag('regression')
+    def test_eventrule_script_action_with_object_image_files(self):
+        """
+        Verify that a Script event-rule action can be enqueued and executed cleanly when the
+        triggering object carries uploaded files (e.g. DeviceType images).
+        This is a regression test for issue #22376.
+
+        """
+        # Create a dummy script class and an instance of it
+        class DummyScript(ScriptBase):
+            class Meta:
+                name = "Dummy Script"
+
+            def run(self, data, commit=True):
+                return "finished successfully"
+
+        dummy_script = DummyScript()
+
+        # Create ScriptModule and Script
+        with patch.object(ScriptModule, 'sync_classes'):
+            module = ScriptModule.objects.create(
+                file_root=ManagedFileRootPathChoices.SCRIPTS,
+                file_path='dummy_script.py',
+            )
+        script = Script.objects.create(
+            module=module,
+            name='Dummy Script',
+            is_executable=True,
+        )
+        script_type = ObjectType.objects.get_for_model(Script)
+
+        # Create an event rule that triggers on DeviceType update with Script action
+        devicetype_type = ObjectType.objects.get_for_model(DeviceType)
+        event_rule = EventRule.objects.create(
+            name='Test Script Event Rule with Files',
+            event_types=[OBJECT_UPDATED],
+            action_type=EventRuleActionChoices.SCRIPT,
+            action_object_type=script_type,
+            action_object_id=script.pk,
+        )
+        event_rule.object_types.set([devicetype_type])
+
+        # Create a manufacturer and DeviceType
+        manufacturer = Manufacturer.objects.create(
+            name='Test Manufacturer',
+            slug='test-manufacturer',
+        )
+        devicetype = DeviceType.objects.create(
+            model='Test DeviceType',
+            slug="test-devicetype",
+            manufacturer=manufacturer,
+        )
+
+        # Create an image file
+        image = BytesIO()
+        Image.new('RGB', (1, 1)).save(image, format='PNG')
+        image.name = 'test_image.png'
+        image.seek(0)
+
+        # PATCH the DeviceType via REST API to add the image
+        data = {
+            'front_image': image,
+        }
+        url = reverse('dcim-api:devicetype-detail', kwargs={'pk': devicetype.pk})
+        self.add_permissions('dcim.change_devicetype')
+
+        # Mock the script's python_class to prevent the test from trying to load from disk
+        with patch.object(Script, 'python_class') as mock:
+            mock.return_value = dummy_script
+            # Since in core/models/jobs.py Jobs are enqueued with a transaction.on_commit-handler
+            # we simulate commit by using captureOnCommitCallbacks context manager
+            with self.captureOnCommitCallbacks(execute=True):
+                response = self.client.patch(url, data, format='multipart', **self.header)
+            self.assertHttpStatus(response, status.HTTP_200_OK)
+
+            # Assert that the script job was enqueued cleanly and is waiting for execution
+            self.assertEqual(self.queue.count, 1)
+            script_job = Job.objects.filter(name=dummy_script.name).last()
+            self.assertEqual(script_job.status, "pending")
+
+            # silence rqworker (cleaner output) and trigger job execution
+            logging.getLogger('rq.worker').setLevel(logging.ERROR)
+            self.run_rq_jobs('default')
+
+        # Assert that our script was executed without any errors
+        script_job.refresh_from_db()
+        self.assertEqual(script_job.status, "completed")
+        self.assertEqual(script_job.data.get('output', ''), "finished successfully")
+
+    @tag('regression')
+    def test_eventrule_webhook_action_with_object_image_files(self):
+        """
+        Verify that a Webhook event-rule action can be enqueued and executed cleanly when
+        the triggering object carries uploaded files (e.g. DeviceType images).
+        This is a regression test for issue #20873.
+        """
+        # Create an event rule that triggers on DeviceType update with Script action
+        webhook = Webhook.objects.get(name='Webhook 1')
+        webhook_type = ObjectType.objects.get_for_model(Webhook)
+        devicetype_type = ObjectType.objects.get_for_model(DeviceType)
+        event_rule = EventRule.objects.create(
+            name='Test Webhook Event Rule with Files',
+            event_types=[OBJECT_UPDATED],
+            action_type=EventRuleActionChoices.WEBHOOK,
+            action_object_type=webhook_type,
+            action_object_id=webhook.pk,
+        )
+        event_rule.object_types.set([devicetype_type])
+
+        # Create a manufacturer and DeviceType
+        manufacturer = Manufacturer.objects.create(
+            name='Test Manufacturer',
+            slug='test-manufacturer',
+        )
+        devicetype = DeviceType.objects.create(
+            model='Test DeviceType',
+            slug="test-devicetype",
+            manufacturer=manufacturer,
+        )
+
+        # Create an image file
+        image = BytesIO()
+        Image.new('RGB', (1, 1)).save(image, format='PNG')
+        image.name = 'test_image.png'
+        image.seek(0)
+
+        # PATCH the DeviceType via REST API to add the image
+        data = {
+            'front_image': image,
+        }
+        url = reverse('dcim-api:devicetype-detail', kwargs={'pk': devicetype.pk})
+        self.add_permissions('dcim.change_devicetype')
+
+        response = self.client.patch(url, data, format='multipart', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+
+        # Assert that the webhook job was enqueued cleanly
+        self.assertEqual(self.queue.count, 1)
+        job = self.queue.jobs[0]
+        self.assertEqual(job.kwargs['event_rule'], event_rule)
+        self.assertEqual(job.kwargs['event_type'], OBJECT_UPDATED)

+ 50 - 1
netbox/extras/tests/test_forms.py

@@ -10,7 +10,7 @@ from core.models import DataSource, ObjectType
 from dcim.forms import SiteForm
 from dcim.forms import SiteForm
 from dcim.models import Site
 from dcim.models import Site
 from extras.choices import CustomFieldTypeChoices
 from extras.choices import CustomFieldTypeChoices
-from extras.forms import SavedFilterForm
+from extras.forms import SavedFilterForm, TableConfigBulkEditForm, TableConfigForm
 from extras.forms.model_forms import CustomFieldChoiceSetForm
 from extras.forms.model_forms import CustomFieldChoiceSetForm
 from extras.forms.scripts import ScriptFileForm
 from extras.forms.scripts import ScriptFileForm
 from extras.models import CustomField, CustomFieldChoiceSet, ScriptModule
 from extras.models import CustomField, CustomFieldChoiceSet, ScriptModule
@@ -288,3 +288,52 @@ class ScriptFileFormTestCase(TestCase):
         form = ScriptFileForm(files={'upload_file': upload_file}, instance=self._new_module())
         form = ScriptFileForm(files={'upload_file': upload_file}, instance=self._new_module())
 
 
         self.assertTrue(form.is_valid())
         self.assertTrue(form.is_valid())
+
+
+class TableConfigFormTestCase(TestCase):
+
+    def test_form_without_table_context(self):
+        """The form must be constructible without an object type."""
+        form = TableConfigForm()
+        self.assertEqual(list(form.fields['available_columns'].widget.choices), [])
+        self.assertEqual(list(form.fields['columns'].widget.choices), [])
+
+    def test_form_with_invalid_object_type(self):
+        """An unknown object type must yield empty column choices."""
+        last_pk = ObjectType.objects.order_by('pk').last().pk
+        form = TableConfigForm(initial={'object_type': last_pk + 1})
+        self.assertEqual(list(form.fields['available_columns'].widget.choices), [])
+        self.assertEqual(list(form.fields['columns'].widget.choices), [])
+
+    def test_form_with_unknown_table(self):
+        """An unresolvable table name must yield empty column choices."""
+        object_type = ObjectType.objects.get_for_model(Site)
+        form = TableConfigForm(initial={'object_type': object_type.pk, 'table': 'NoSuchTable'})
+        self.assertEqual(list(form.fields['columns'].widget.choices), [])
+
+    def test_form_with_table_context(self):
+        """Column choices must be populated from the resolved table."""
+        object_type = ObjectType.objects.get_for_model(Site)
+        form = TableConfigForm(initial={
+            'object_type': object_type.pk,
+            'table': 'SiteTable',
+            'columns': ['name', 'status'],
+        })
+        self.assertEqual(
+            [name for name, _ in form.fields['columns'].widget.choices],
+            ['name', 'status']
+        )
+        self.assertIn('region', dict(form.fields['available_columns'].widget.choices))
+
+    def test_form_includes_changelog_message(self):
+        """The model form must expose the changelog_message meta field."""
+        object_type = ObjectType.objects.get_for_model(Site)
+        form = TableConfigForm(initial={'object_type': object_type.pk, 'table': 'SiteTable'})
+        self.assertIn('changelog_message', form.fields)
+        self.assertIn('changelog_message', form.meta_fields)
+
+    def test_bulk_edit_form_includes_changelog_message(self):
+        """The bulk edit form must expose the changelog_message meta field."""
+        form = TableConfigBulkEditForm()
+        self.assertIn('changelog_message', form.fields)
+        self.assertIn('changelog_message', form.meta_fields)

+ 31 - 0
netbox/extras/tests/test_lookups.py

@@ -0,0 +1,31 @@
+from django.core.exceptions import FieldError
+from django.test import TestCase
+
+from extras.choices import CustomFieldChoiceSetBaseChoices
+from extras.models import CustomFieldChoiceSet, EventRule
+
+
+class ChoiceValueLookupTestCase(TestCase):
+
+    def test_choice_value_matches_values_only(self):
+        """choice_value matches the value element of a pair, never the label."""
+        CustomFieldChoiceSet.objects.create(
+            name='Choice Set 1',
+            extra_choices=[['sel1', 'Selection 1'], ['other', 'sel2']],
+        )
+        self.assertEqual(CustomFieldChoiceSet.objects.filter(extra_choices__choice_value='sel1').count(), 1)
+        self.assertEqual(CustomFieldChoiceSet.objects.filter(extra_choices__choice_value='sel2').count(), 0)
+
+    def test_choice_value_excludes_null_extra_choices(self):
+        """Choice sets without extra choices are excluded without raising."""
+        CustomFieldChoiceSet.objects.create(
+            name='Base Only',
+            base_choices=CustomFieldChoiceSetBaseChoices.IATA,
+        )
+        self.assertEqual(CustomFieldChoiceSet.objects.filter(extra_choices__choice_value='sel1').count(), 0)
+        self.assertEqual(CustomFieldChoiceSet.objects.filter(extra_choices__len=2).count(), 0)
+
+    def test_choice_value_not_registered_on_plain_array_fields(self):
+        """choice_value is scoped to ChoiceSetField and unavailable on other ArrayFields."""
+        with self.assertRaises(FieldError):
+            EventRule.objects.filter(event_types__choice_value='x').exists()

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

@@ -210,6 +210,26 @@ class TableConfigTestCase(TestCase):
         # Must not raise TypeError: 'NoneType' object is not iterable
         # Must not raise TypeError: 'NoneType' object is not iterable
         tc.full_clean()
         tc.full_clean()
 
 
+    def test_clean_without_object_type(self):
+        """full_clean() on an instance missing its object type must raise ValidationError."""
+        tc = TableConfig(
+            table=self.table_name,
+            name='No object type',
+            columns=['name'],
+        )
+        with self.assertRaises(ValidationError):
+            tc.full_clean()
+
+    def test_clean_accepts_columns_none(self):
+        """full_clean() must report missing columns rather than raise TypeError."""
+        tc = TableConfig(
+            object_type=self.site_ct,
+            table=self.table_name,
+            name='No columns',
+        )
+        with self.assertRaises(ValidationError):
+            tc.full_clean()
+
 
 
 class TagTestCase(TestCase):
 class TagTestCase(TestCase):
 
 

+ 55 - 3
netbox/extras/tests/test_views.py

@@ -2,6 +2,7 @@ import uuid
 from unittest.mock import PropertyMock, patch
 from unittest.mock import PropertyMock, patch
 
 
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
+from django.contrib.messages import get_messages
 from django.test import tag
 from django.test import tag
 from django.urls import reverse
 from django.urls import reverse
 
 
@@ -277,13 +278,16 @@ class SavedFilterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 class TableConfigTestCase(
 class TableConfigTestCase(
     ViewTestCases.GetObjectViewTestCase,
     ViewTestCases.GetObjectViewTestCase,
     ViewTestCases.GetObjectChangelogViewTestCase,
     ViewTestCases.GetObjectChangelogViewTestCase,
-    ViewTestCases.ListObjectsViewTestCase,
+    ViewTestCases.CreateObjectViewTestCase,
+    ViewTestCases.EditObjectViewTestCase,
     ViewTestCases.DeleteObjectViewTestCase,
     ViewTestCases.DeleteObjectViewTestCase,
+    ViewTestCases.ListObjectsViewTestCase,
+    ViewTestCases.BulkEditObjectsViewTestCase,
     ViewTestCases.BulkDeleteObjectsViewTestCase,
     ViewTestCases.BulkDeleteObjectsViewTestCase,
 ):
 ):
-    # Add/Edit/BulkEdit views require an object_type pre-context from the source
-    # table view, so they are not exercised here.
     model = TableConfig
     model = TableConfig
+    # Selected columns are POSTed as a list but compared as a CSV string
+    validation_excluded_fields = ('columns',)
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -320,6 +324,45 @@ class TableConfigTestCase(
         )
         )
         TableConfig.objects.bulk_create(table_configs)
         TableConfig.objects.bulk_create(table_configs)
 
 
+        cls.form_data = {
+            'name': 'Table Config X',
+            'object_type': site_type.pk,
+            'table': 'SiteTable',
+            'description': 'A table config',
+            'weight': 100,
+            'enabled': True,
+            'shared': True,
+            'columns': ['name', 'status'],
+            'ordering': 'name',
+        }
+        cls.bulk_edit_data = {
+            'weight': 999,
+        }
+
+    def _get_url(self, action, instance=None):
+        url = super()._get_url(action, instance)
+        # The add view requires the table context from the source table view
+        if action == 'add':
+            site_type = ObjectType.objects.get_for_model(Site)
+            url = f'{url}?object_type={site_type.pk}&table=SiteTable'
+        return url
+
+    def test_add_view_without_table_context(self):
+        """A GET without the table context params must redirect to the home page."""
+        self.add_permissions('extras.add_tableconfig')
+        response = self.client.get(reverse('extras:tableconfig_add'))
+        self.assertRedirects(response, reverse('home'))
+
+        messages_list = list(get_messages(response.wsgi_request))
+        self.assertEqual(len(messages_list), 1)
+        self.assertEqual(str(messages_list[0]), 'Table configurations must be created from an object list view.')
+
+    def test_add_view_post_without_table_context(self):
+        """A POST without the table context must return form errors rather than a server error."""
+        self.add_permissions('extras.add_tableconfig')
+        response = self.client.post(reverse('extras:tableconfig_add'), data={})
+        self.assertHttpStatus(response, 200)
+
 
 
 class BookmarkTestCase(
 class BookmarkTestCase(
     ViewTestCases.DeleteObjectViewTestCase,
     ViewTestCases.DeleteObjectViewTestCase,
@@ -365,6 +408,9 @@ class BookmarkTestCase(
     def test_list_objects_anonymous(self):
     def test_list_objects_anonymous(self):
         return
         return
 
 
+    def test_export_objects_anonymous(self):
+        return
+
     def test_list_objects_with_constrained_permission(self):
     def test_list_objects_with_constrained_permission(self):
         return
         return
 
 
@@ -919,6 +965,9 @@ class SubscriptionTestCase(
         login_url = reverse('login')
         login_url = reverse('login')
         self.assertRedirects(self.client.get(url), f'{login_url}?next={url}')
         self.assertRedirects(self.client.get(url), f'{login_url}?next={url}')
 
 
+    def test_export_objects_anonymous(self):
+        return
+
     def test_list_objects_with_permission(self):
     def test_list_objects_with_permission(self):
         return
         return
 
 
@@ -1027,6 +1076,9 @@ class NotificationTestCase(
         login_url = reverse('login')
         login_url = reverse('login')
         self.assertRedirects(self.client.get(url), f'{login_url}?next={url}')
         self.assertRedirects(self.client.get(url), f'{login_url}?next={url}')
 
 
+    def test_export_objects_anonymous(self):
+        return
+
     def test_list_objects_with_permission(self):
     def test_list_objects_with_permission(self):
         return
         return
 
 

+ 8 - 0
netbox/extras/views.py

@@ -481,6 +481,14 @@ class TableConfigEditView(SharedObjectViewMixin, generic.ObjectEditView):
     form = forms.TableConfigForm
     form = forms.TableConfigForm
     template_name = 'extras/tableconfig_edit.html'
     template_name = 'extras/tableconfig_edit.html'
 
 
+    def get(self, request, *args, **kwargs):
+        # The add view requires the object_type & table parameters from the source table view
+        if not kwargs and not (request.GET.get('object_type') and request.GET.get('table')):
+            messages.warning(request, _('Table configurations must be created from an object list view.'))
+            return redirect('home')
+
+        return super().get(request, *args, **kwargs)
+
     def alter_object(self, obj, request, url_args, url_kwargs):
     def alter_object(self, obj, request, url_args, url_kwargs):
         if not obj.pk:
         if not obj.pk:
             obj.user = request.user
             obj.user = request.user

+ 1 - 1
netbox/ipam/api/views.py

@@ -407,7 +407,7 @@ class AvailableIPAddressesView(AvailableObjectsView):
     def get_available_objects(self, parent, limit=None):
     def get_available_objects(self, parent, limit=None):
         # Calculate available IPs within the parent
         # Calculate available IPs within the parent
         ip_list = []
         ip_list = []
-        for index, ip in enumerate(parent.get_available_ips(), start=1):
+        for index, ip in enumerate(parent.iter_available_ips(), start=1):
             ip_list.append(ip)
             ip_list.append(ip)
             if index == limit:
             if index == limit:
                 break
                 break

+ 5 - 1
netbox/ipam/fields.py

@@ -42,7 +42,10 @@ class BaseIPField(models.Field):
             raise ValidationError(e)
             raise ValidationError(e)
 
 
     def get_prep_value(self, value):
     def get_prep_value(self, value):
-        if not value:
+        # Membership check; `not value` incorrectly treats the valid zero addresses
+        # 0.0.0.0 and :: as empty. netaddr objects compare unequal to all three
+        # sentinels; raw int 0 stays "empty" for backward compatibility.
+        if value in (None, '', 0):
             return None
             return None
         if isinstance(value, list):
         if isinstance(value, list):
             return [str(self.to_python(v)) for v in value]
             return [str(self.to_python(v)) for v in value]
@@ -107,6 +110,7 @@ IPAddressField.register_lookup(lookups.NetContainsOrEquals)
 IPAddressField.register_lookup(lookups.NetHost)
 IPAddressField.register_lookup(lookups.NetHost)
 IPAddressField.register_lookup(lookups.NetIn)
 IPAddressField.register_lookup(lookups.NetIn)
 IPAddressField.register_lookup(lookups.NetHostContained)
 IPAddressField.register_lookup(lookups.NetHostContained)
+IPAddressField.register_lookup(lookups.NetHostBetween)
 IPAddressField.register_lookup(lookups.NetFamily)
 IPAddressField.register_lookup(lookups.NetFamily)
 IPAddressField.register_lookup(lookups.NetMaskLength)
 IPAddressField.register_lookup(lookups.NetMaskLength)
 IPAddressField.register_lookup(lookups.Host)
 IPAddressField.register_lookup(lookups.Host)

+ 6 - 8
netbox/ipam/filtersets.py

@@ -1142,19 +1142,17 @@ class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
     def filter_interface_id(self, queryset, name, value):
     def filter_interface_id(self, queryset, name, value):
         if value is None:
         if value is None:
             return queryset.none()
             return queryset.none()
-        return queryset.filter(
-            Q(interfaces_as_tagged=value) |
-            Q(interfaces_as_untagged=value)
-        ).distinct()
+        tagged = queryset.filter(interfaces_as_tagged=value)
+        untagged = queryset.filter(interfaces_as_untagged=value)
+        return queryset.filter(pk__in=tagged.union(untagged).values('pk'))
 
 
     @extend_schema_field(OpenApiTypes.INT)
     @extend_schema_field(OpenApiTypes.INT)
     def filter_vminterface_id(self, queryset, name, value):
     def filter_vminterface_id(self, queryset, name, value):
         if value is None:
         if value is None:
             return queryset.none()
             return queryset.none()
-        return queryset.filter(
-            Q(vminterfaces_as_tagged=value) |
-            Q(vminterfaces_as_untagged=value)
-        ).distinct()
+        tagged = queryset.filter(vminterfaces_as_tagged=value)
+        untagged = queryset.filter(vminterfaces_as_untagged=value)
+        return queryset.filter(pk__in=tagged.union(untagged).values('pk'))
 
 
 
 
 @register_filterset
 @register_filterset

+ 17 - 1
netbox/ipam/forms/bulk_create.py

@@ -1,10 +1,12 @@
 from django import forms
 from django import forms
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
-from utilities.forms.fields import ExpandableIPNetworkField
+from ipam.constants import VLAN_VID_MAX, VLAN_VID_MIN
+from utilities.forms.fields import ExpandableIPNetworkField, NumericArrayField
 
 
 __all__ = (
 __all__ = (
     'IPNetworkBulkCreateForm',
     'IPNetworkBulkCreateForm',
+    'VLANIDBulkCreateForm',
 )
 )
 
 
 
 
@@ -15,3 +17,17 @@ class IPNetworkBulkCreateForm(forms.Form):
     pattern = ExpandableIPNetworkField(
     pattern = ExpandableIPNetworkField(
         label=_('Pattern')
         label=_('Pattern')
     )
     )
+
+
+class VLANIDBulkCreateForm(forms.Form):
+    pattern = NumericArrayField(
+        base_field=forms.IntegerField(
+            min_value=VLAN_VID_MIN,
+            max_value=VLAN_VID_MAX
+        ),
+        label=_('VLAN IDs'),
+        help_text=_(
+            'Enter VLAN IDs and ranges separated by commas. '
+            'Example: 100,200-210,3100-3299'
+        )
+    )

+ 21 - 0
netbox/ipam/forms/model_forms.py

@@ -45,6 +45,7 @@ __all__ = (
     'ServiceCreateForm',
     'ServiceCreateForm',
     'ServiceForm',
     'ServiceForm',
     'ServiceTemplateForm',
     'ServiceTemplateForm',
+    'VLANBulkAddForm',
     'VLANForm',
     'VLANForm',
     'VLANGroupForm',
     'VLANGroupForm',
     'VLANTranslationPolicyForm',
     'VLANTranslationPolicyForm',
@@ -727,6 +728,26 @@ class VLANForm(TenancyForm, PrimaryModelForm):
         ]
         ]
 
 
 
 
+class VLANBulkAddForm(VLANForm):
+    """
+    Subclass of VLANForm for bulk creation.
+
+    The VID field is inherited but excluded from the visible fieldsets, as it is
+    populated programmatically by BulkCreateView from the expanded pattern.
+    """
+    fieldsets = (
+        FieldSet('group', 'site', 'name', 'status', 'role', 'description', 'tags', name=_('VLAN')),
+        FieldSet('qinq_role', 'qinq_svlan', name=_('Q-in-Q/802.1ad')),
+        FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
+    )
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.fields['name'].help_text = _(
+            'Use {vid} as a placeholder for the VLAN ID. Example: VLAN-{vid}.'
+        )
+
+
 class VLANTranslationPolicyForm(PrimaryModelForm):
 class VLANTranslationPolicyForm(PrimaryModelForm):
 
 
     fieldsets = (
     fieldsets = (

+ 20 - 21
netbox/ipam/graphql/filters.py

@@ -1,4 +1,3 @@
-from datetime import date
 from typing import TYPE_CHECKING, Annotated
 from typing import TYPE_CHECKING, Annotated
 
 
 import netaddr
 import netaddr
@@ -72,8 +71,8 @@ class ASNFilter(TenancyFilterMixin, PrimaryModelFilter):
 
 
 @strawberry_django.filter_type(models.ASNRange, lookups=True)
 @strawberry_django.filter_type(models.ASNRange, lookups=True)
 class ASNRangeFilter(TenancyFilterMixin, OrganizationalModelFilter):
 class ASNRangeFilter(TenancyFilterMixin, OrganizationalModelFilter):
-    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    slug: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup | None = strawberry_django.filter_field()
+    slug: StrFilterLookup | None = strawberry_django.filter_field()
     rir: Annotated['RIRFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
     rir: Annotated['RIRFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
     rir_id: ID | None = strawberry_django.filter_field()
     rir_id: ID | None = strawberry_django.filter_field()
     start: Annotated['BigIntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
     start: Annotated['BigIntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
@@ -86,10 +85,10 @@ class ASNRangeFilter(TenancyFilterMixin, OrganizationalModelFilter):
 
 
 @strawberry_django.filter_type(models.Aggregate, lookups=True)
 @strawberry_django.filter_type(models.Aggregate, lookups=True)
 class AggregateFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
 class AggregateFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
-    prefix: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    prefix: StrFilterLookup | None = strawberry_django.filter_field()
     rir: Annotated['RIRFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
     rir: Annotated['RIRFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
     rir_id: ID | None = strawberry_django.filter_field()
     rir_id: ID | None = strawberry_django.filter_field()
-    date_added: DateFilterLookup[date] | None = strawberry_django.filter_field()
+    date_added: DateFilterLookup | None = strawberry_django.filter_field()
 
 
     @strawberry_django.filter_field()
     @strawberry_django.filter_field()
     def contains(self, value: list[str], prefix) -> Q:
     def contains(self, value: list[str], prefix) -> Q:
@@ -122,14 +121,14 @@ class FHRPGroupFilter(PrimaryModelFilter):
     group_id: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
     group_id: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
-    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup | None = strawberry_django.filter_field()
     protocol: BaseFilterLookup[Annotated['FHRPGroupProtocolEnum', strawberry.lazy('ipam.graphql.enums')]] | None = (
     protocol: BaseFilterLookup[Annotated['FHRPGroupProtocolEnum', strawberry.lazy('ipam.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
     auth_type: BaseFilterLookup[Annotated['FHRPGroupAuthTypeEnum', strawberry.lazy('ipam.graphql.enums')]] | None = (
     auth_type: BaseFilterLookup[Annotated['FHRPGroupAuthTypeEnum', strawberry.lazy('ipam.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
-    auth_key: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    auth_key: StrFilterLookup | None = strawberry_django.filter_field()
     ip_addresses: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
     ip_addresses: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -140,7 +139,7 @@ class FHRPGroupAssignmentFilter(ChangeLoggedModelFilter):
     interface_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
     interface_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
-    interface_id: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    interface_id: StrFilterLookup | None = strawberry_django.filter_field()
     group: Annotated['FHRPGroupFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
     group: Annotated['FHRPGroupFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -176,7 +175,7 @@ class FHRPGroupAssignmentFilter(ChangeLoggedModelFilter):
 
 
 @strawberry_django.filter_type(models.IPAddress, lookups=True)
 @strawberry_django.filter_type(models.IPAddress, lookups=True)
 class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
 class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
-    address: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    address: StrFilterLookup | None = strawberry_django.filter_field()
     vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
     vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
     vrf_id: ID | None = strawberry_django.filter_field()
     vrf_id: ID | None = strawberry_django.filter_field()
     status: BaseFilterLookup[Annotated['IPAddressStatusEnum', strawberry.lazy('ipam.graphql.enums')]] | None = (
     status: BaseFilterLookup[Annotated['IPAddressStatusEnum', strawberry.lazy('ipam.graphql.enums')]] | None = (
@@ -197,7 +196,7 @@ class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
     nat_outside_id: ID | None = strawberry_django.filter_field()
     nat_outside_id: ID | None = strawberry_django.filter_field()
-    dns_name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    dns_name: StrFilterLookup | None = strawberry_django.filter_field()
 
 
     @strawberry_django.filter_field()
     @strawberry_django.filter_field()
     def assigned(self, value: bool, prefix) -> Q:
     def assigned(self, value: bool, prefix) -> Q:
@@ -227,8 +226,8 @@ class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter
 
 
 @strawberry_django.filter_type(models.IPRange, lookups=True)
 @strawberry_django.filter_type(models.IPRange, lookups=True)
 class IPRangeFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
 class IPRangeFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
-    start_address: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    end_address: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    start_address: StrFilterLookup | None = strawberry_django.filter_field()
+    end_address: StrFilterLookup | None = strawberry_django.filter_field()
     size: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
     size: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -281,7 +280,7 @@ class IPRangeFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
 
 
 @strawberry_django.filter_type(models.Prefix, lookups=True)
 @strawberry_django.filter_type(models.Prefix, lookups=True)
 class PrefixFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
 class PrefixFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
-    prefix: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    prefix: StrFilterLookup | None = strawberry_django.filter_field()
     vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
     vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
     vrf_id: ID | None = strawberry_django.filter_field()
     vrf_id: ID | None = strawberry_django.filter_field()
     vlan: Annotated['VLANFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
     vlan: Annotated['VLANFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
@@ -330,7 +329,7 @@ class RoleFilter(OrganizationalModelFilter):
 
 
 @strawberry_django.filter_type(models.RouteTarget, lookups=True)
 @strawberry_django.filter_type(models.RouteTarget, lookups=True)
 class RouteTargetFilter(TenancyFilterMixin, PrimaryModelFilter):
 class RouteTargetFilter(TenancyFilterMixin, PrimaryModelFilter):
-    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup | None = strawberry_django.filter_field()
     importing_vrfs: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
     importing_vrfs: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -347,7 +346,7 @@ class RouteTargetFilter(TenancyFilterMixin, PrimaryModelFilter):
 
 
 @strawberry_django.filter_type(models.Service, lookups=True)
 @strawberry_django.filter_type(models.Service, lookups=True)
 class ServiceFilter(ContactFilterMixin, ServiceFilterMixin, PrimaryModelFilter):
 class ServiceFilter(ContactFilterMixin, ServiceFilterMixin, PrimaryModelFilter):
-    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup | None = strawberry_django.filter_field()
     ip_addresses: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
     ip_addresses: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -359,7 +358,7 @@ class ServiceFilter(ContactFilterMixin, ServiceFilterMixin, PrimaryModelFilter):
 
 
 @strawberry_django.filter_type(models.ServiceTemplate, lookups=True)
 @strawberry_django.filter_type(models.ServiceTemplate, lookups=True)
 class ServiceTemplateFilter(ServiceFilterMixin, PrimaryModelFilter):
 class ServiceTemplateFilter(ServiceFilterMixin, PrimaryModelFilter):
-    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup | None = strawberry_django.filter_field()
 
 
 
 
 @strawberry_django.filter_type(models.VLAN, lookups=True)
 @strawberry_django.filter_type(models.VLAN, lookups=True)
@@ -373,7 +372,7 @@ class VLANFilter(TenancyFilterMixin, PrimaryModelFilter):
     vid: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
     vid: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
-    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup | None = strawberry_django.filter_field()
     status: BaseFilterLookup[Annotated['VLANStatusEnum', strawberry.lazy('ipam.graphql.enums')]] | None = (
     status: BaseFilterLookup[Annotated['VLANStatusEnum', strawberry.lazy('ipam.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -404,7 +403,7 @@ class VLANGroupFilter(ScopedFilterMixin, OrganizationalModelFilter):
 
 
 @strawberry_django.filter_type(models.VLANTranslationPolicy, lookups=True)
 @strawberry_django.filter_type(models.VLANTranslationPolicy, lookups=True)
 class VLANTranslationPolicyFilter(PrimaryModelFilter):
 class VLANTranslationPolicyFilter(PrimaryModelFilter):
-    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup | None = strawberry_django.filter_field()
 
 
 
 
 @strawberry_django.filter_type(models.VLANTranslationRule, lookups=True)
 @strawberry_django.filter_type(models.VLANTranslationRule, lookups=True)
@@ -413,7 +412,7 @@ class VLANTranslationRuleFilter(NetBoxModelFilter):
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
     policy_id: ID | None = strawberry_django.filter_field()
     policy_id: ID | None = strawberry_django.filter_field()
-    description: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    description: StrFilterLookup | None = strawberry_django.filter_field()
     local_vid: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
     local_vid: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -424,8 +423,8 @@ class VLANTranslationRuleFilter(NetBoxModelFilter):
 
 
 @strawberry_django.filter_type(models.VRF, lookups=True)
 @strawberry_django.filter_type(models.VRF, lookups=True)
 class VRFFilter(TenancyFilterMixin, PrimaryModelFilter):
 class VRFFilter(TenancyFilterMixin, PrimaryModelFilter):
-    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    rd: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup | None = strawberry_django.filter_field()
+    rd: StrFilterLookup | None = strawberry_django.filter_field()
     enforce_unique: FilterLookup[bool] | None = strawberry_django.filter_field()
     enforce_unique: FilterLookup[bool] | None = strawberry_django.filter_field()
     import_targets: Annotated['RouteTargetFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
     import_targets: Annotated['RouteTargetFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()

+ 2 - 2
netbox/ipam/graphql/types.py

@@ -244,7 +244,7 @@ class RouteTargetType(PrimaryObjectType):
 
 
 @strawberry_django.type(
 @strawberry_django.type(
     models.Service,
     models.Service,
-    exclude=('parent_object_type', 'parent_object_id'),
+    exclude=('_ports_lowest', 'parent_object_type', 'parent_object_id'),
     filters=ServiceFilter,
     filters=ServiceFilter,
     pagination=True
     pagination=True
 )
 )
@@ -264,7 +264,7 @@ class ServiceType(ContactsMixin, PrimaryObjectType):
 
 
 @strawberry_django.type(
 @strawberry_django.type(
     models.ServiceTemplate,
     models.ServiceTemplate,
-    fields='__all__',
+    exclude=('_ports_lowest',),
     filters=ServiceTemplateFilter,
     filters=ServiceTemplateFilter,
     pagination=True
     pagination=True
 )
 )

+ 33 - 2
netbox/ipam/lookups.py

@@ -1,3 +1,4 @@
+import netaddr
 from django.db.models import IntegerField, Lookup, Transform, lookups
 from django.db.models import IntegerField, Lookup, Transform, lookups
 
 
 
 
@@ -99,7 +100,8 @@ class NetHost(Lookup):
         if rhs_params:
         if rhs_params:
             rhs_params[0] = rhs_params[0].split('/')[0]
             rhs_params[0] = rhs_params[0].split('/')[0]
         params = list(lhs_params) + rhs_params
         params = list(lhs_params) + rhs_params
-        return f'HOST({lhs}) = {rhs}', params
+        # Cast to INET so the predicate matches the inet ipam_ipaddress_host index.
+        return f'CAST(HOST({lhs}) AS INET) = {rhs}', params
 
 
 
 
 class NetIn(Lookup):
 class NetIn(Lookup):
@@ -120,7 +122,8 @@ class NetIn(Lookup):
                 without_mask.append(address)
                 without_mask.append(address)
 
 
         address_in_clause = self.create_in_clause('{} IN ('.format(lhs), len(with_mask))
         address_in_clause = self.create_in_clause('{} IN ('.format(lhs), len(with_mask))
-        host_in_clause = self.create_in_clause('HOST({}) IN ('.format(lhs), len(without_mask))
+        # Cast to INET so the predicate matches the inet ipam_ipaddress_host index.
+        host_in_clause = self.create_in_clause('CAST(HOST({}) AS INET) IN ('.format(lhs), len(without_mask))
 
 
         if with_mask and not without_mask:
         if with_mask and not without_mask:
             return address_in_clause, with_mask
             return address_in_clause, with_mask
@@ -156,6 +159,34 @@ class NetHostContained(Lookup):
         return f'CAST(HOST({lhs}) AS INET) <<= {rhs}', params
         return f'CAST(HOST({lhs}) AS INET) <<= {rhs}', params
 
 
 
 
+class NetHostBetween(Lookup):
+    """
+    Match host addresses (mask ignored) falling inclusively between two bounds. The left-hand
+    side is kept as an inet-typed host expression so PostgreSQL can use the host expression
+    indexes on the IPAM address and range tables; the CAST(HOST(...) AS INET) spelling matches
+    NetHost/NetIn for consistency (PostgreSQL canonicalizes the INET(HOST(...)) function form
+    to the same expression).
+    """
+    lookup_name = 'host_between'
+
+    def get_prep_lookup(self):
+        if not isinstance(self.rhs, (list, tuple)) or len(self.rhs) != 2:
+            raise ValueError('The host_between lookup requires a (lower, upper) pair of bounds')
+        try:
+            # Normalize to bare hosts; reject malformed values before they reach SQL.
+            lower, upper = (netaddr.IPNetwork(str(bound)).ip for bound in self.rhs)
+        except (netaddr.AddrFormatError, ValueError) as e:
+            raise ValueError(f'Invalid host_between bound: {e}') from e
+        if lower.version != upper.version:
+            raise ValueError('host_between bounds must not mix address families')
+        return lower, upper
+
+    def as_sql(self, qn, connection):
+        lhs, lhs_params = self.process_lhs(qn, connection)
+        params = list(lhs_params) + [str(bound) for bound in self.rhs]
+        return f'CAST(HOST({lhs}) AS INET) BETWEEN %s AND %s', params
+
+
 class NetFamily(Transform):
 class NetFamily(Transform):
     lookup_name = 'family'
     lookup_name = 'family'
     function = 'FAMILY'
     function = 'FAMILY'

+ 2 - 2
netbox/ipam/managers.py

@@ -1,10 +1,10 @@
 from django.db.models import Manager
 from django.db.models import Manager
 
 
 from ipam.lookups import Host, Inet
 from ipam.lookups import Host, Inet
-from utilities.querysets import RestrictedQuerySet
+from ipam.querysets import IPAddressQuerySet
 
 
 
 
-class IPAddressManager(Manager.from_queryset(RestrictedQuerySet)):
+class IPAddressManager(Manager.from_queryset(IPAddressQuerySet)):
 
 
     def get_queryset(self):
     def get_queryset(self):
         """
         """

+ 4 - 1
netbox/ipam/migrations/0089_default_ordering_indexes.py

@@ -32,9 +32,12 @@ class Migration(migrations.Migration):
             model_name='role',
             model_name='role',
             index=models.Index(fields=['weight', 'name'], name='ipam_role_weight_01396b_idx'),
             index=models.Index(fields=['weight', 'name'], name='ipam_role_weight_01396b_idx'),
         ),
         ),
+        # Adding a dummy index, to allow a safe migration in case updating users already have services
+        # with a large number of ports configured (see issue #22273)
+        # Will get removed in 0091_alter_service_index_and_ordering
         migrations.AddIndex(
         migrations.AddIndex(
             model_name='service',
             model_name='service',
-            index=models.Index(fields=['protocol', 'ports', 'id'], name='ipam_servic_protoco_687d13_idx'),
+            index=models.Index(fields=['id'], name='ipam_servic_protoco_687d13_idx'),
         ),
         ),
         migrations.AddIndex(
         migrations.AddIndex(
             model_name='vlangroup',
             model_name='vlangroup',

+ 58 - 0
netbox/ipam/migrations/0091_alter_service_index_and_ordering.py

@@ -0,0 +1,58 @@
+from django.db import migrations, models
+
+
+def populate__ports_lowest(apps, schema_editor):
+    Service = apps.get_model('ipam', 'Service')
+    ServiceTemplate = apps.get_model('ipam', 'ServiceTemplate')
+    CHUNK_SIZE = 500
+
+    for model in (Service, ServiceTemplate):
+        chunk = []
+        qs = model.objects.filter(_ports_lowest__isnull=True).only('id', 'ports', '_ports_lowest')
+        for obj in qs.iterator(chunk_size=CHUNK_SIZE):
+            if obj.ports:
+                obj._ports_lowest = min(obj.ports)
+                chunk.append(obj)
+            if len(chunk) >= CHUNK_SIZE:
+                model.objects.bulk_update(chunk, ['_ports_lowest'])
+                chunk = []
+        if chunk:
+            model.objects.bulk_update(chunk, ['_ports_lowest'])
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ipam', '0090_vlangroup_recompute_total_vlan_ids'),
+    ]
+
+    operations = [
+        migrations.RemoveIndex(
+            model_name='service',
+            name='ipam_servic_protoco_687d13_idx',
+        ),
+        migrations.AddField(
+            model_name='service',
+            name='_ports_lowest',
+            field=models.PositiveIntegerField(blank=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='servicetemplate',
+            name='_ports_lowest',
+            field=models.PositiveIntegerField(blank=True, null=True),
+        ),
+        migrations.RunPython(populate__ports_lowest, migrations.RunPython.noop),
+        migrations.AddIndex(
+            model_name='service',
+            index=models.Index(
+                fields=['protocol', '_ports_lowest', 'id'],
+                name='ipam_servic_protoco_e2901d_idx'
+            ),
+        ),
+        migrations.AlterModelOptions(
+            name='service',
+            options={
+                'ordering': ('protocol', '_ports_lowest', 'id')
+            },
+        ),
+    ]

+ 34 - 0
netbox/ipam/migrations/0092_iprange_host_indexes.py

@@ -0,0 +1,34 @@
+import django.db.models.functions.comparison
+from django.db import migrations, models
+
+import ipam.fields
+import ipam.lookups
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('ipam', '0091_alter_service_index_and_ordering'),
+    ]
+
+    operations = [
+        migrations.AddIndex(
+            model_name='iprange',
+            index=models.Index(
+                django.db.models.functions.comparison.Cast(
+                    ipam.lookups.Host('start_address'),
+                    output_field=ipam.fields.IPAddressField(),
+                ),
+                name='ipam_iprange_start_host',
+            ),
+        ),
+        migrations.AddIndex(
+            model_name='iprange',
+            index=models.Index(
+                django.db.models.functions.comparison.Cast(
+                    ipam.lookups.Host('end_address'),
+                    output_field=ipam.fields.IPAddressField(),
+                ),
+                name='ipam_iprange_end_host',
+            ),
+        ),
+    ]

+ 1 - 1
netbox/ipam/migrations/0091_denormalization_triggers.py → netbox/ipam/migrations/0093_denormalization_triggers.py

@@ -10,7 +10,7 @@ from utilities.migration import cached_scope_triggers
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
-        ('ipam', '0090_vlangroup_recompute_total_vlan_ids'),
+        ('ipam', '0092_iprange_host_indexes'),
         # Source tables (dcim_site, dcim_location) must already exist.
         # Source tables (dcim_site, dcim_location) must already exist.
         ('dcim', '0238_ltree_paths'),
         ('dcim', '0238_ltree_paths'),
     ]
     ]

+ 251 - 72
netbox/ipam/models/ip.py

@@ -15,7 +15,7 @@ from ipam.constants import *
 from ipam.fields import IPAddressField, IPNetworkField
 from ipam.fields import IPAddressField, IPNetworkField
 from ipam.lookups import Host
 from ipam.lookups import Host
 from ipam.managers import IPAddressManager
 from ipam.managers import IPAddressManager
-from ipam.querysets import PrefixQuerySet
+from ipam.querysets import IPRangeQuerySet, PrefixQuerySet
 from ipam.validators import DNSValidator
 from ipam.validators import DNSValidator
 from netbox.config import get_config
 from netbox.config import get_config
 from netbox.models import OrganizationalModel, PrimaryModel
 from netbox.models import OrganizationalModel, PrimaryModel
@@ -425,14 +425,63 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
             return Prefix.objects.filter(prefix__net_contained=str(self.prefix))
             return Prefix.objects.filter(prefix__net_contained=str(self.prefix))
         return Prefix.objects.filter(prefix__net_contained=str(self.prefix), vrf=self.vrf)
         return Prefix.objects.filter(prefix__net_contained=str(self.prefix), vrf=self.vrf)
 
 
+    @property
+    def usable_ip_bounds(self):
+        """
+        Return the first and last IPs considered usable for available-IP calculations.
+
+        Pools and IPv4 /31-/32 / IPv6 /127-/128 are fully usable; otherwise IPv4 excludes
+        network and broadcast, IPv6 excludes the subnet-router anycast address.
+        """
+        network = netaddr.IPNetwork(self.prefix)
+        family = network.version
+        first = network.first
+        last = network.last
+        mask_length = network.prefixlen
+
+        if (
+            self.is_pool
+            or (family == 4 and mask_length >= 31)
+            or (family == 6 and mask_length >= 127)
+        ):
+            return (
+                netaddr.IPAddress(first, version=family),
+                netaddr.IPAddress(last, version=family),
+            )
+
+        if family == 4:
+            return (
+                netaddr.IPAddress(first + 1, version=family),
+                netaddr.IPAddress(last - 1, version=family),
+            )
+
+        return (
+            netaddr.IPAddress(first + 1, version=family),
+            netaddr.IPAddress(last, version=family),
+        )
+
+    @property
+    def usable_size(self):
+        """
+        The number of usable host addresses within the prefix (excludes reserved addresses).
+        """
+        first_ip, last_ip = self.usable_ip_bounds
+        return int(last_ip) - int(first_ip) + 1
+
     def get_child_ranges(self, **kwargs):
     def get_child_ranges(self, **kwargs):
         """
         """
         Return all IPRanges within this Prefix and VRF.
         Return all IPRanges within this Prefix and VRF.
         """
         """
+        # A host BETWEEN over the prefix span uses the ipam_iprange_*_host btree indexes.
+        prefix = netaddr.IPNetwork(self.prefix)
+        bounds = (
+            netaddr.IPAddress(prefix.first, version=prefix.version),
+            netaddr.IPAddress(prefix.last, version=prefix.version),
+        )
         return IPRange.objects.filter(
         return IPRange.objects.filter(
             vrf=self.vrf,
             vrf=self.vrf,
-            start_address__net_host_contained=str(self.prefix),
-            end_address__net_host_contained=str(self.prefix),
+            start_address__host_between=bounds,
+            end_address__host_between=bounds,
             **kwargs
             **kwargs
         )
         )
 
 
@@ -441,52 +490,106 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
         Return all IPAddresses within this Prefix and VRF. If this Prefix is a container in the global table, return
         Return all IPAddresses within this Prefix and VRF. If this Prefix is a container in the global table, return
         child IPAddresses belonging to any VRF.
         child IPAddresses belonging to any VRF.
         """
         """
+        # A host BETWEEN over the prefix span is index-sargable without the <<= containment recheck.
+        prefix = netaddr.IPNetwork(self.prefix)
+        bounds = (
+            netaddr.IPAddress(prefix.first, version=prefix.version),
+            netaddr.IPAddress(prefix.last, version=prefix.version),
+        )
         if self.vrf is None and self.status == PrefixStatusChoices.STATUS_CONTAINER:
         if self.vrf is None and self.status == PrefixStatusChoices.STATUS_CONTAINER:
-            return IPAddress.objects.filter(address__net_host_contained=str(self.prefix))
-        return IPAddress.objects.filter(address__net_host_contained=str(self.prefix), vrf=self.vrf)
+            return IPAddress.objects.filter(address__host_between=bounds)
+        return IPAddress.objects.filter(address__host_between=bounds, vrf=self.vrf)
 
 
     def get_available_ips(self):
     def get_available_ips(self):
         """
         """
         Return all available IPs within this prefix as an IPSet.
         Return all available IPs within this prefix as an IPSet.
         """
         """
-        prefix = netaddr.IPSet(self.prefix)
-        child_ips = netaddr.IPSet([
-            ip.address.ip for ip in self.get_child_ips()
-        ])
-        child_ranges = netaddr.IPSet([
-            iprange.range for iprange in self.get_child_ranges().filter(mark_populated=True)
-        ])
-        available_ips = prefix - child_ips - child_ranges
+        return netaddr.IPSet(
+            cidr
+            for start, end in self._available_intervals()
+            for cidr in netaddr.iprange_to_cidrs(start, end)
+        )
 
 
-        # Pool, IPv4 /31-/32 or IPv6 /127-/128 sets are fully usable
-        if (
-            self.is_pool
-            or (self.family == 4 and self.prefix.prefixlen >= 31)
-            or (self.family == 6 and self.prefix.prefixlen >= 127)
-        ):
-            return available_ips
-
-        if self.family == 4:
-            # For "normal" IPv4 prefixes, omit first and last addresses
-            available_ips -= netaddr.IPSet([
-                netaddr.IPAddress(self.prefix.first),
-                netaddr.IPAddress(self.prefix.last),
-            ])
-        else:
-            # For IPv6 prefixes, omit the Subnet-Router anycast address
-            # per RFC 4291
-            available_ips -= netaddr.IPSet([netaddr.IPAddress(self.prefix.first)])
+    def iter_available_ips(self):
+        """
+        Yield the available IPs within this prefix as netaddr.IPAddress objects, in
+        ascending order. Unlike get_available_ips(), consumption is lazy: stopping
+        early stops reading from the database.
+        """
+        for start, end in self._available_intervals():
+            yield from netaddr.iter_iprange(start, end)
+
+    def get_available_ip_count(self):
+        """
+        Return the number of available IPs within the prefix.
+        """
+        first_ip, last_ip = self.usable_ip_bounds
+        usable_size = int(last_ip) - int(first_ip) + 1
+
+        populated_intervals = self.get_child_ranges(mark_populated=True).get_intervals(first_ip, last_ip)
+        populated_count = sum(int(end) - int(start) + 1 for start, end in populated_intervals)
+
+        # Populated ranges already cover the usable span; skip the child-IP count entirely.
+        if populated_count >= usable_size:
+            return 0
+
+        child_ip_count = (
+            self.get_child_ips()
+            .filter(address__host_between=(first_ip, last_ip))
+            .count_distinct_hosts(exclude_intervals=populated_intervals)
+        )
+
+        return max(usable_size - populated_count - child_ip_count, 0)
+
+    def get_ip_usage_summary(self):
+        """
+        Return the available IP count and utilization together as a dict, sharing a
+        single distinct-host scan. Intended for detail views rendering both values;
+        list views should call get_utilization() alone, which is cheaper per row.
+        """
+        # Marked-utilized and container utilization need no host scan; delegate.
+        if self.mark_utilized or self.status == PrefixStatusChoices.STATUS_CONTAINER:
+            return {
+                'available_ip_count': self.get_available_ip_count(),
+                'utilization': self.get_utilization(),
+            }
+
+        first_ip, last_ip = self.usable_ip_bounds
+        usable_size = int(last_ip) - int(first_ip) + 1
+
+        populated_intervals = self.get_child_ranges(mark_populated=True).get_intervals(first_ip, last_ip)
+        utilized_intervals = self.get_child_ranges(mark_utilized=True).get_intervals()
+
+        counts = self.get_child_ips().count_distinct_hosts_pair(
+            bounds=(first_ip, last_ip),
+            bounded_exclude=populated_intervals,
+            total_exclude=utilized_intervals,
+        )
 
 
-        return available_ips
+        populated_count = sum(int(end) - int(start) + 1 for start, end in populated_intervals)
+        utilized_range_count = sum(int(end) - int(start) + 1 for start, end in utilized_intervals)
+
+        prefix_size = self._get_utilization_denominator()
+
+        return {
+            'available_ip_count': max(usable_size - populated_count - counts['bounded'], 0),
+            'utilization': min(float(utilized_range_count + counts['total']) / prefix_size * 100, 100),
+        }
 
 
     def get_first_available_ip(self):
     def get_first_available_ip(self):
         """
         """
         Return the first available IP within the prefix (or None).
         Return the first available IP within the prefix (or None).
         """
         """
-        available_ips = self.get_available_ips()
-        if not available_ips:
+        first_ip, last_ip = self.usable_ip_bounds
+        populated_intervals = self.get_child_ranges(mark_populated=True).get_intervals(first_ip, last_ip)
+
+        first_available_ip = self.get_child_ips().first_available_host(
+            first_ip, last_ip, exclude_intervals=populated_intervals,
+        )
+
+        if first_available_ip is None:
             return None
             return None
-        return '{}/{}'.format(next(available_ips.__iter__()), self.prefix.prefixlen)
+        return f'{first_available_ip}/{self.prefix.prefixlen}'
 
 
     def get_utilization(self):
     def get_utilization(self):
         """
         """
@@ -504,20 +607,43 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
             child_prefixes = netaddr.IPSet([p.prefix for p in queryset])
             child_prefixes = netaddr.IPSet([p.prefix for p in queryset])
             utilization = float(child_prefixes.size) / self.prefix.size * 100
             utilization = float(child_prefixes.size) / self.prefix.size * 100
         else:
         else:
-            # Compile an IPSet to avoid counting duplicate IPs
-            child_ips = netaddr.IPSet()
-            for iprange in self.get_child_ranges().filter(mark_utilized=True):
-                child_ips.add(iprange.range)
-            for ip in self.get_child_ips():
-                child_ips.add(ip.address.ip)
-
-            prefix_size = self.prefix.size
-            if self.prefix.version == 4 and self.prefix.prefixlen < 31 and not self.is_pool:
-                prefix_size -= 2
-            utilization = float(child_ips.size) / prefix_size * 100
+            prefix_size = self._get_utilization_denominator()
+            utilized_intervals = self.get_child_ranges(mark_utilized=True).get_intervals()
+            utilized_range_count = sum(int(end) - int(start) + 1 for start, end in utilized_intervals)
+
+            # Utilized ranges already saturate the prefix; skip the child-IP count.
+            if utilized_range_count >= prefix_size:
+                return 100
+
+            child_ip_count = self.get_child_ips().count_distinct_hosts(
+                exclude_intervals=utilized_intervals,
+            )
+
+            utilization = float(utilized_range_count + child_ip_count) / prefix_size * 100
 
 
         return min(utilization, 100)
         return min(utilization, 100)
 
 
+    def _available_intervals(self):
+        """
+        Yield the available (start, end) host intervals within the prefix.
+        """
+        first_ip, last_ip = self.usable_ip_bounds
+        populated_intervals = self.get_child_ranges(mark_populated=True).get_intervals(first_ip, last_ip)
+
+        return self.get_child_ips().available_intervals(
+            first_ip, last_ip, exclude_intervals=populated_intervals,
+        )
+
+    def _get_utilization_denominator(self):
+        """
+        The address count utilization is measured against (IPv4 non-pool prefixes
+        exclude the network and broadcast addresses; IPv6 uses the full prefix size).
+        """
+        prefix_size = self.prefix.size
+        if self.prefix.version == 4 and self.prefix.prefixlen < 31 and not self.is_pool:
+            return prefix_size - 2
+        return prefix_size
+
 
 
 class IPRange(ContactsMixin, PrimaryModel):
 class IPRange(ContactsMixin, PrimaryModel):
     """
     """
@@ -576,12 +702,24 @@ class IPRange(ContactsMixin, PrimaryModel):
         help_text=_("Report space as fully utilized")
         help_text=_("Report space as fully utilized")
     )
     )
 
 
+    objects = IPRangeQuerySet.as_manager()
+
     clone_fields = (
     clone_fields = (
         'vrf', 'tenant', 'status', 'role', 'description', 'mark_populated', 'mark_utilized',
         'vrf', 'tenant', 'status', 'role', 'description', 'mark_populated', 'mark_utilized',
     )
     )
 
 
     class Meta:
     class Meta:
         ordering = (F('vrf').asc(nulls_first=True), 'start_address', 'pk')  # (vrf, start_address) may be non-unique
         ordering = (F('vrf').asc(nulls_first=True), 'start_address', 'pk')  # (vrf, start_address) may be non-unique
+        indexes = (
+            models.Index(
+                Cast(Host('start_address'), output_field=IPAddressField()),
+                name='ipam_iprange_start_host',
+            ),
+            models.Index(
+                Cast(Host('end_address'), output_field=IPAddressField()),
+                name='ipam_iprange_end_host',
+            ),
+        )
         verbose_name = _('IP range')
         verbose_name = _('IP range')
         verbose_name_plural = _('IP ranges')
         verbose_name_plural = _('IP ranges')
 
 
@@ -709,53 +847,94 @@ class IPRange(ContactsMixin, PrimaryModel):
     def get_status_color(self):
     def get_status_color(self):
         return IPRangeStatusChoices.colors.get(self.status)
         return IPRangeStatusChoices.colors.get(self.status)
 
 
+    @cached_property
+    def first_available_ip(self):
+        """
+        Return the first available IP within the range (or None).
+        """
+        return self.get_first_available_ip()
+
+    @property
+    def utilization(self):
+        """
+        Determine the utilization of the range and return it as a percentage.
+        """
+        if self.mark_utilized:
+            return 100
+
+        return min(float(self._occupied_host_count) / self.size * 100, 100)
+
     def get_child_ips(self):
     def get_child_ips(self):
         """
         """
         Return all IPAddresses within this IPRange and VRF.
         Return all IPAddresses within this IPRange and VRF.
         """
         """
         return IPAddress.objects.filter(
         return IPAddress.objects.filter(
-            address__gte=self.start_address,
-            address__lte=self.end_address,
-            vrf=self.vrf
+            vrf=self.vrf,
+            address__host_between=(self.start_address.ip, self.end_address.ip),
         )
         )
 
 
     def get_available_ips(self):
     def get_available_ips(self):
         """
         """
         Return all available IPs within this range as an IPSet.
         Return all available IPs within this range as an IPSet.
         """
         """
-        if self.mark_populated:
-            return netaddr.IPSet()
+        return netaddr.IPSet(
+            cidr
+            for start, end in self._available_intervals()
+            for cidr in netaddr.iprange_to_cidrs(start, end)
+        )
 
 
-        range = netaddr.IPRange(self.start_address.ip, self.end_address.ip)
-        child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()])
+    def iter_available_ips(self):
+        """
+        Yield the available IPs within this range as netaddr.IPAddress objects, in
+        ascending order. Unlike get_available_ips(), consumption is lazy: stopping
+        early stops reading from the database.
+        """
+        for start, end in self._available_intervals():
+            yield from netaddr.iter_iprange(start, end)
 
 
-        return netaddr.IPSet(range) - child_ips
+    def get_available_ip_count(self):
+        """
+        Return the number of available IPs within the range.
+        """
+        if self.mark_populated:
+            return 0
 
 
-    @cached_property
-    def first_available_ip(self):
+        return max(self.size - self._occupied_host_count, 0)
+
+    def get_first_available_ip(self):
         """
         """
         Return the first available IP within the range (or None).
         Return the first available IP within the range (or None).
         """
         """
-        available_ips = self.get_available_ips()
-        if not available_ips:
+        if self.mark_populated:
             return None
             return None
 
 
-        return '{}/{}'.format(next(available_ips.__iter__()), self.start_address.prefixlen)
+        first_available_ip = self.get_child_ips().first_available_host(
+            self.start_address.ip, self.end_address.ip,
+        )
 
 
-    @cached_property
-    def utilization(self):
+        if first_available_ip is None:
+            return None
+
+        return f'{first_available_ip}/{self.start_address.prefixlen}'
+
+    def _available_intervals(self):
         """
         """
-        Determine the utilization of the range and return it as a percentage.
+        Yield the available (start, end) host intervals within the range.
         """
         """
-        if self.mark_utilized:
-            return 100
+        if self.mark_populated:
+            return iter(())
 
 
-        # Compile an IPSet to avoid counting duplicate IPs
-        child_count = netaddr.IPSet([
-            ip.address.ip for ip in self.get_child_ips()
-        ]).size
+        return self.get_child_ips().available_intervals(
+            self.start_address.ip, self.end_address.ip,
+        )
 
 
-        return min(float(child_count) / self.size * 100, 100)
+    @cached_property
+    def _occupied_host_count(self):
+        """
+        The number of distinct occupied hosts within the range, cached for the
+        lifetime of the instance.
+        """
+        return self.get_child_ips().count_distinct_hosts()
 
 
 
 
 class IPAddress(ContactsMixin, PrimaryModel):
 class IPAddress(ContactsMixin, PrimaryModel):
@@ -948,10 +1127,10 @@ class IPAddress(ContactsMixin, PrimaryModel):
 
 
             # Disallow the creation of IPAddresses within an IPRange with mark_populated=True
             # Disallow the creation of IPAddresses within an IPRange with mark_populated=True
             parent_range_qs = IPRange.objects.filter(
             parent_range_qs = IPRange.objects.filter(
-                start_address__lte=self.address,
-                end_address__gte=self.address,
+                start_address__host__inet__lte=self.address.ip,
+                end_address__host__inet__gte=self.address.ip,
                 vrf=self.vrf,
                 vrf=self.vrf,
-                mark_populated=True
+                mark_populated=True,
             )
             )
             if not self.pk and (parent_range := parent_range_qs.first()):
             if not self.pk and (parent_range := parent_range_qs.first()):
                 raise ValidationError({
                 raise ValidationError({

+ 14 - 3
netbox/ipam/models/services.py

@@ -31,10 +31,22 @@ class ServiceBase(models.Model):
         ),
         ),
         verbose_name=_('port numbers')
         verbose_name=_('port numbers')
     )
     )
+    _ports_lowest = models.PositiveIntegerField(
+        null=True,
+        blank=True,
+    )
 
 
     class Meta:
     class Meta:
         abstract = True
         abstract = True
 
 
+    def save(self, *args, **kwargs):
+        # On saving find the smallest port and save for default ordering
+        self._ports_lowest = min(self.ports) if self.ports else None
+        update_fields = kwargs.get('update_fields')
+        if update_fields is not None and '_ports_lowest' not in update_fields:
+            kwargs['update_fields'] = list(update_fields) + ['_ports_lowest']
+        super().save(*args, **kwargs)
+
     def __str__(self):
     def __str__(self):
         return f'{self.name} ({self.get_protocol_display()}/{self.port_list})'
         return f'{self.name} ({self.get_protocol_display()}/{self.port_list})'
 
 
@@ -74,7 +86,6 @@ class Service(ContactsMixin, ServiceBase, PrimaryModel):
         ct_field='parent_object_type',
         ct_field='parent_object_type',
         fk_field='parent_object_id'
         fk_field='parent_object_id'
     )
     )
-
     name = models.CharField(
     name = models.CharField(
         max_length=100,
         max_length=100,
         verbose_name=_('name')
         verbose_name=_('name')
@@ -93,9 +104,9 @@ class Service(ContactsMixin, ServiceBase, PrimaryModel):
 
 
     class Meta:
     class Meta:
         indexes = (
         indexes = (
-            models.Index(fields=('protocol', 'ports', 'id')),  # Default ordering
+            models.Index(fields=('protocol', '_ports_lowest', 'id')),  # Default ordering
             models.Index(fields=('parent_object_type', 'parent_object_id')),
             models.Index(fields=('parent_object_type', 'parent_object_id')),
         )
         )
-        ordering = ('protocol', 'ports', 'pk')  # (protocol, port) may be non-unique
+        ordering = ('protocol', '_ports_lowest', 'id')
         verbose_name = _('application service')
         verbose_name = _('application service')
         verbose_name_plural = _('application services')
         verbose_name_plural = _('application services')

+ 1 - 1
netbox/ipam/models/vlans.py

@@ -330,7 +330,7 @@ class VLAN(PrimaryModel):
                 )
                 )
 
 
         # Check that the VLAN ID is permitted in the assigned group (if any)
         # Check that the VLAN ID is permitted in the assigned group (if any)
-        if self.group:
+        if self.group and self.vid is not None:
             if not any([self.vid in r for r in self.group.vid_ranges]):
             if not any([self.vid in r for r in self.group.vid_ranges]):
                 raise ValidationError({
                 raise ValidationError({
                     'vid': _(
                     'vid': _(

+ 190 - 1
netbox/ipam/querysets.py

@@ -1,18 +1,51 @@
+import heapq
+
+import netaddr
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.db.models import Count, F, OuterRef, Q, Subquery, Value
 from django.db.models import Count, F, OuterRef, Q, Subquery, Value
 from django.db.models.expressions import RawSQL
 from django.db.models.expressions import RawSQL
-from django.db.models.functions import NullIf, Round
+from django.db.models.functions import Cast, NullIf, Round
 
 
 from utilities.query import count_related
 from utilities.query import count_related
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
 
 
+from .fields import IPAddressField
+from .lookups import Host
+
 __all__ = (
 __all__ = (
     'ASNRangeQuerySet',
     'ASNRangeQuerySet',
+    'IPAddressQuerySet',
+    'IPRangeQuerySet',
     'PrefixQuerySet',
     'PrefixQuerySet',
     'VLANGroupQuerySet',
     'VLANGroupQuerySet',
     'VLANQuerySet',
     'VLANQuerySet',
 )
 )
 
 
+# The host portion of an IP address (mask ignored), in the same form as the
+# ipam_ipaddress_host expression index.
+HOST_ADDRESS = Cast(Host('address'), output_field=IPAddressField())
+
+
+def _merge_intervals(intervals):
+    """
+    Return the union of (start, end) netaddr.IPAddress intervals, merged and sorted.
+    """
+    if not intervals:
+        return []
+
+    intervals = sorted(intervals)
+    merged = [intervals[0]]
+
+    for start, end in intervals[1:]:
+        current_start, current_end = merged[-1]
+        # Adjacency math in int space; netaddr raises at the address-space maximum.
+        if start.version == current_end.version and int(start) <= int(current_end) + 1:
+            merged[-1] = (current_start, max(current_end, end))
+        else:
+            merged.append((start, end))
+
+    return merged
+
 
 
 class ASNRangeQuerySet(RestrictedQuerySet):
 class ASNRangeQuerySet(RestrictedQuerySet):
 
 
@@ -32,6 +65,162 @@ class ASNRangeQuerySet(RestrictedQuerySet):
         return self.annotate(asn_count=Subquery(asns))
         return self.annotate(asn_count=Subquery(asns))
 
 
 
 
+class IPAddressQuerySet(RestrictedQuerySet):
+
+    def count_distinct_hosts(self, exclude_intervals=()):
+        """
+        Count distinct host addresses, optionally excluding (start, end) netaddr.IPAddress intervals.
+        """
+        queryset = self
+        for start, end in exclude_intervals:
+            queryset = queryset.exclude(address__host_between=(start, end))
+
+        return queryset.aggregate(count=Count(HOST_ADDRESS, distinct=True))['count']
+
+    def count_distinct_hosts_pair(self, bounds, bounded_exclude=(), total_exclude=()):
+        """
+        Return two distinct host counts computed in a single scan, as a dict:
+        'bounded' counts hosts within the (first_ip, last_ip) bounds excluding the
+        bounded_exclude intervals; 'total' counts all hosts excluding the
+        total_exclude intervals. Interval arguments match the output of
+        IPRangeQuerySet.get_intervals(). Avoids a second scan of the host expression
+        index when both counts are needed. Use only when both counts are needed (e.g.
+        Prefix.get_ip_usage_summary()); single-purpose callers should prefer
+        count_distinct_hosts().
+        """
+        # The deduplicated column is already a bare host; plain comparisons beat
+        # the host_between lookup here, which would re-wrap it in HOST()::inet.
+        bounded_q = Q(host_address__range=(str(bounds[0]), str(bounds[1])))
+        for start, end in bounded_exclude:
+            bounded_q &= ~Q(host_address__range=(str(start), str(end)))
+        total_q = Q()
+        for start, end in total_exclude:
+            total_q &= ~Q(host_address__range=(str(start), str(end)))
+
+        hosts = self.order_by().annotate(host_address=HOST_ADDRESS).values('host_address').distinct()
+        return hosts.aggregate(
+            bounded=Count('host_address', filter=bounded_q),
+            # An empty Q is falsy; fall back to a plain count of all hosts.
+            total=Count('host_address', filter=total_q or None),
+        )
+
+    def _iter_distinct_hosts(self, first_ip, last_ip, batch_size):
+        """
+        Yield the distinct occupied hosts in [first_ip, last_ip] in ascending order,
+        fetched in LIMIT batches that resume just past the last seen host. (A
+        server-side cursor is unsuitable here: on autocommit connections Django
+        declares it WITH HOLD, which materializes the full result at DECLARE.)
+        """
+        resume = first_ip
+        while True:
+            # order_by() first clears the default ordering, which would otherwise
+            # leak into SELECT and break distinct().
+            hosts = list(
+                self.filter(address__host_between=(resume, last_ip))
+                .order_by()
+                .annotate(host_address=HOST_ADDRESS)
+                .values_list('host_address', flat=True)
+                .distinct()
+                .order_by('host_address')[:batch_size]
+            )
+            for host in hosts:
+                yield host.ip
+            if len(hosts) < batch_size:
+                return
+            last_host = hosts[-1].ip
+            if int(last_host) >= int(last_ip):
+                return
+            resume = netaddr.IPAddress(int(last_host) + 1, version=last_host.version)
+
+    def available_intervals(self, first_ip, last_ip, exclude_intervals=(), batch_size=5000):
+        """
+        Yield the unoccupied (start, end) netaddr.IPAddress intervals (inclusive)
+        within [first_ip, last_ip], in ascending order. exclude_intervals are
+        (start, end) netaddr.IPAddress pairs; they are merged and sorted internally,
+        intervals of a foreign address family are ignored, and addresses they cover
+        count as occupied. Consumption is lazy: a caller that stops early stops
+        fetching host batches.
+        """
+        if batch_size < 1:
+            raise ValueError('batch_size must be greater than zero')
+
+        first_int, last_int = int(first_ip), int(last_ip)
+        version = first_ip.version
+
+        if first_int > last_int:
+            return
+        # Normalize: the sweep below requires sorted, non-overlapping, same-family intervals.
+        exclude_intervals = _merge_intervals([
+            (start, end)
+            for start, end in exclude_intervals
+            if start.version == end.version == version
+        ])
+        intervals = [(int(start), int(end)) for start, end in exclude_intervals]
+
+        # Fast path: one merged excluded interval covers the entire span.
+        if intervals and intervals[0][0] <= first_int and intervals[0][1] >= last_int:
+            return
+
+        hosts = (
+            (int(host), int(host))
+            for host in self._iter_distinct_hosts(first_ip, last_ip, batch_size)
+        )
+
+        candidate = first_int
+        # Ties on `start` are harmless; the sweep handles overlapping intervals.
+        for start, end in heapq.merge(intervals, hosts):
+            if end < candidate:
+                continue
+            if start > candidate:
+                yield (
+                    netaddr.IPAddress(candidate, version=version),
+                    netaddr.IPAddress(min(start - 1, last_int), version=version),
+                )
+            candidate = max(candidate, end + 1)
+            if candidate > last_int:
+                return
+
+        if candidate <= last_int:
+            yield (
+                netaddr.IPAddress(candidate, version=version),
+                netaddr.IPAddress(last_int, version=version),
+            )
+
+    def first_available_host(self, first_ip, last_ip, exclude_intervals=()):
+        """
+        Return the first host in [first_ip, last_ip] neither present nor in an excluded interval (or None).
+        """
+        interval = next(self.available_intervals(first_ip, last_ip, exclude_intervals), None)
+        return interval[0] if interval else None
+
+
+class IPRangeQuerySet(RestrictedQuerySet):
+
+    def get_intervals(self, first_ip=None, last_ip=None):
+        """
+        Return ranges as merged (start, end) netaddr.IPAddress intervals, optionally clipped to the bounds.
+        """
+        intervals = []
+
+        # order_by() clears the default ordering; _merge_intervals() sorts anyway.
+        for start_address, end_address in self.order_by().values_list('start_address', 'end_address'):
+            start, end = start_address.ip, end_address.ip
+
+            if first_ip is not None:
+                if end < first_ip:
+                    continue
+                start = max(start, first_ip)
+
+            if last_ip is not None:
+                if start > last_ip:
+                    continue
+                end = min(end, last_ip)
+
+            intervals.append((start, end))
+
+        return _merge_intervals(intervals)
+
+
 class PrefixQuerySet(RestrictedQuerySet):
 class PrefixQuerySet(RestrictedQuerySet):
 
 
     def annotate_hierarchy(self):
     def annotate_hierarchy(self):

+ 29 - 0
netbox/ipam/tests/test_fields.py

@@ -0,0 +1,29 @@
+from django.test import TestCase
+from netaddr import IPAddress
+
+from ipam.fields import IPAddressField, IPNetworkField
+
+
+class BaseIPFieldTestCase(TestCase):
+    """
+    Regression coverage for BaseIPField.get_prep_value() — zero addresses such as
+    0.0.0.0 and :: are valid hosts and must not be treated as empty values.
+    """
+
+    def test_get_prep_value_accepts_ipv4_zero_address(self):
+        # Regression: 0.0.0.0 is a valid host, not an empty value.
+        self.assertEqual(IPAddressField().get_prep_value(IPAddress('0.0.0.0')), '0.0.0.0')
+
+    def test_get_prep_value_accepts_ipv6_zero_address(self):
+        # Regression: :: is a valid host, not an empty value.
+        self.assertEqual(IPAddressField().get_prep_value(IPAddress('::')), '::')
+
+    def test_get_prep_value_passes_through_empty(self):
+        self.assertIsNone(IPNetworkField().get_prep_value(None))
+        self.assertIsNone(IPAddressField().get_prep_value(''))
+
+    def test_get_prep_value_preserves_raw_zero_as_empty(self):
+        # Raw int 0 is preserved as the legacy "empty" sentinel; Django's ORM never
+        # passes it directly, but the previous `not value` check returned None for it.
+        self.assertIsNone(IPAddressField().get_prep_value(0))
+        self.assertIsNone(IPNetworkField().get_prep_value(0))

+ 40 - 1
netbox/ipam/tests/test_filtersets.py

@@ -4,7 +4,7 @@ from django.test import TestCase
 from netaddr import IPNetwork
 from netaddr import IPNetwork
 
 
 from circuits.models import Provider
 from circuits.models import Provider
-from dcim.choices import InterfaceTypeChoices
+from dcim.choices import InterfaceModeChoices, InterfaceTypeChoices
 from dcim.models import Device, DeviceRole, DeviceType, Interface, Location, Manufacturer, Rack, Region, Site, SiteGroup
 from dcim.models import Device, DeviceRole, DeviceType, Interface, Location, Manufacturer, Rack, Region, Site, SiteGroup
 from ipam.choices import *
 from ipam.choices import *
 from ipam.filtersets import *
 from ipam.filtersets import *
@@ -2206,11 +2206,50 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'interface_id': interface_id}
         params = {'interface_id': interface_id}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
+        # An interface untagged on one VLAN and tagged on a different VLAN should return both (UNION across paths)
+        vlans = self.queryset.all()[:2]
+        interface = Interface.objects.create(
+            device=Device.objects.first(),
+            name='Interface X',
+            type=InterfaceTypeChoices.TYPE_1GE_FIXED,
+            mode=InterfaceModeChoices.MODE_TAGGED,
+            untagged_vlan=vlans[0],
+        )
+        interface.tagged_vlans.add(vlans[1])
+        params = {'interface_id': interface.pk}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+        # A VLAN that is both untagged and tagged on the same interface should be returned only once (deduplication)
+        interface.tagged_vlans.add(vlans[0])
+        params = {'interface_id': interface.pk}
+        qs = self.filterset(params, self.queryset).qs
+        self.assertEqual(qs.count(), 2)
+        self.assertEqual(len(qs), len(set(qs.values_list('pk', flat=True))))
+
     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}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
+        # A VM interface untagged on one VLAN and tagged on a different VLAN should return both (UNION across paths)
+        vlans = self.queryset.all()[:2]
+        vminterface = VMInterface.objects.create(
+            virtual_machine=VirtualMachine.objects.first(),
+            name='VM Interface X',
+            mode=InterfaceModeChoices.MODE_TAGGED,
+            untagged_vlan=vlans[0],
+        )
+        vminterface.tagged_vlans.add(vlans[1])
+        params = {'vminterface_id': vminterface.pk}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+        # A VLAN that is both untagged and tagged on the same interface should be returned only once (deduplication)
+        vminterface.tagged_vlans.add(vlans[0])
+        params = {'vminterface_id': vminterface.pk}
+        qs = self.filterset(params, self.queryset).qs
+        self.assertEqual(qs.count(), 2)
+        self.assertEqual(len(qs), len(set(qs.values_list('pk', flat=True))))
+
     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)

+ 29 - 1
netbox/ipam/tests/test_forms.py

@@ -3,7 +3,7 @@ from django.test import TestCase
 
 
 from dcim.constants import InterfaceTypeChoices
 from dcim.constants import InterfaceTypeChoices
 from dcim.models import Device, DeviceRole, DeviceType, Interface, Location, Manufacturer, Region, Site, SiteGroup
 from dcim.models import Device, DeviceRole, DeviceType, Interface, Location, Manufacturer, Region, Site, SiteGroup
-from ipam.forms import PrefixForm
+from ipam.forms import PrefixForm, VLANIDBulkCreateForm
 from ipam.forms.bulk_import import IPAddressImportForm
 from ipam.forms.bulk_import import IPAddressImportForm
 
 
 
 
@@ -96,3 +96,31 @@ class IPAddressImportFormTestCase(TestCase):
 
 
         self.device.refresh_from_db()
         self.device.refresh_from_db()
         self.assertEqual(self.device.oob_ip, ip1, "OOB IP was incorrectly cleared by a row with is_oob=False")
         self.assertEqual(self.device.oob_ip, ip1, "OOB IP was incorrectly cleared by a row with is_oob=False")
+
+
+class VLANFormTestCase(TestCase):
+
+    def test_bulk_create_valid_patterns(self):
+        """Single values, ranges, and combinations expand to sorted, deduplicated VLAN IDs."""
+        cases = (
+            ('100', [100]),
+            ('5,10,20', [5, 10, 20]),
+            ('10-20', list(range(10, 21))),
+            ('1,10-20,300-305', [1, *range(10, 21), *range(300, 306)]),
+            (' 5 , 7 - 9 ', [5, 7, 8, 9]),
+            ('5,5,4-6', [4, 5, 6]),
+        )
+        for pattern, expected in cases:
+            with self.subTest(pattern=pattern):
+                form = VLANIDBulkCreateForm({'pattern': pattern})
+                self.assertTrue(form.is_valid(), form.errors)
+                self.assertEqual(form.cleaned_data['pattern'], expected)
+
+    def test_bulk_create_invalid_patterns(self):
+        """Malformed, descending, or out-of-range patterns are rejected with an error on the pattern field."""
+        cases = ('', 'abc', '10,abc', '20-10', '10-', '5,', '-5', '0', '4095')
+        for pattern in cases:
+            with self.subTest(pattern=pattern):
+                form = VLANIDBulkCreateForm({'pattern': pattern})
+                self.assertFalse(form.is_valid())
+                self.assertIn('pattern', form.errors)

+ 134 - 1
netbox/ipam/tests/test_lookups.py

@@ -1,7 +1,9 @@
+import netaddr
 from django.db.backends.postgresql.psycopg_any import NumericRange
 from django.db.backends.postgresql.psycopg_any import NumericRange
 from django.test import TestCase
 from django.test import TestCase
+from netaddr import IPNetwork
 
 
-from ipam.models import VLANGroup
+from ipam.models import IPAddress, VLANGroup
 
 
 
 
 class VLANGroupRangeContainsLookupTestCase(TestCase):
 class VLANGroupRangeContainsLookupTestCase(TestCase):
@@ -65,3 +67,134 @@ class VLANGroupRangeContainsLookupTestCase(TestCase):
         specific condition.
         specific condition.
         """
         """
         self.assertFalse(VLANGroup.objects.filter(pk=self.g_empty.pk, vid_ranges__range_contains=1).exists())
         self.assertFalse(VLANGroup.objects.filter(pk=self.g_empty.pk, vid_ranges__range_contains=1).exists())
+
+
+class IPAddressHostBetweenLookupTestCase(TestCase):
+    @classmethod
+    def setUpTestData(cls):
+        IPAddress.objects.bulk_create((
+            IPAddress(address=IPNetwork('192.0.2.0/24')),
+            IPAddress(address=IPNetwork('192.0.2.1/24')),
+            IPAddress(address=IPNetwork('192.0.2.5/32')),
+            IPAddress(address=IPNetwork('192.0.2.10/25')),
+            IPAddress(address=IPNetwork('192.0.2.11/24')),
+            IPAddress(address=IPNetwork('2001:db8::1/64')),
+            IPAddress(address=IPNetwork('2001:db8::5/128')),
+            IPAddress(address=IPNetwork('2001:db8::10/64')),
+        ))
+
+    def test_ipv4_boundaries_inclusive(self):
+        """
+        Tests that both bounds are included and hosts outside the window are excluded.
+        """
+        queryset = IPAddress.objects.filter(
+            address__host_between=(netaddr.IPAddress('192.0.2.1'), netaddr.IPAddress('192.0.2.10'))
+        )
+        self.assertEqual(
+            sorted(str(ip.address) for ip in queryset),
+            ['192.0.2.1/24', '192.0.2.10/25', '192.0.2.5/32'],
+        )
+
+    def test_mask_insensitive(self):
+        """
+        Tests that hosts match regardless of their mask length.
+        """
+        queryset = IPAddress.objects.filter(
+            address__host_between=(netaddr.IPAddress('192.0.2.5'), netaddr.IPAddress('192.0.2.5'))
+        )
+        self.assertEqual(queryset.count(), 1)
+
+    def test_ipv6(self):
+        """
+        Tests that IPv6 hosts filter by host portion.
+        """
+        queryset = IPAddress.objects.filter(
+            address__host_between=(netaddr.IPAddress('2001:db8::1'), netaddr.IPAddress('2001:db8::5'))
+        )
+        self.assertEqual(queryset.count(), 2)
+
+    def test_bounds_mask_stripped(self):
+        """
+        Tests that bounds supplied with a mask compare by host portion only.
+        """
+        queryset = IPAddress.objects.filter(
+            address__host_between=(IPNetwork('192.0.2.1/24'), IPNetwork('192.0.2.10/24'))
+        )
+        self.assertEqual(queryset.count(), 3)
+
+    def test_invalid_bounds_raise(self):
+        """
+        Tests that a bounds value which is not a two-item pair raises ValueError.
+        """
+        with self.assertRaises(ValueError):
+            IPAddress.objects.filter(address__host_between=(netaddr.IPAddress('192.0.2.1'),))
+
+    def test_invalid_bound_value_raises(self):
+        """
+        Tests that a bound which is not a valid IP address raises ValueError.
+        """
+        with self.assertRaises(ValueError):
+            IPAddress.objects.filter(address__host_between=('invalid', netaddr.IPAddress('192.0.2.10')))
+
+    def test_mixed_family_bounds_raise(self):
+        """
+        Tests that bounds from different address families raise ValueError.
+        """
+        with self.assertRaises(ValueError):
+            IPAddress.objects.filter(
+                address__host_between=(netaddr.IPAddress('192.0.2.1'), netaddr.IPAddress('2001:db8::1'))
+            )
+
+    def test_sql_uses_cast_host_expression(self):
+        """
+        Tests that the compiled SQL matches the ipam_ipaddress_host index expression.
+        """
+        queryset = IPAddress.objects.filter(
+            address__host_between=(netaddr.IPAddress('192.0.2.1'), netaddr.IPAddress('192.0.2.10'))
+        )
+        self.assertIn('CAST(HOST(', str(queryset.query))
+
+
+class IPAddressNetLookupsTestCase(TestCase):
+    @classmethod
+    def setUpTestData(cls):
+        IPAddress.objects.bulk_create((
+            IPAddress(address='10.0.0.1/24'),
+            IPAddress(address='10.0.0.2/24'),
+            IPAddress(address='10.0.0.1/25'),  # Same host as the first, different mask
+            IPAddress(address='2001:db8::1/64'),
+        ))
+
+    def test_net_host_matches_host_ignoring_mask(self):
+        """net_host matches every address whose host portion equals the value."""
+        qs = IPAddress.objects.filter(address__net_host='10.0.0.1')
+        self.assertEqual(qs.count(), 2)
+
+    def test_net_host_predicate_is_inet_typed(self):
+        """net_host casts the host expression to inet so the inet host index applies."""
+        sql = str(IPAddress.objects.filter(address__net_host='10.0.0.1').query)
+        self.assertIn('CAST(HOST(', sql)
+        self.assertIn('AS INET) =', sql)
+
+    def test_net_in_without_mask(self):
+        """net_in matches host values supplied without a mask."""
+        qs = IPAddress.objects.filter(address__net_in=['10.0.0.1', '10.0.0.2'])
+        self.assertEqual(qs.count(), 3)
+
+    def test_net_in_with_mask(self):
+        """net_in matches an exact address/mask value."""
+        qs = IPAddress.objects.filter(address__net_in=['10.0.0.1/25'])
+        self.assertEqual(qs.count(), 1)
+
+    def test_net_in_normalizes_ipv6(self):
+        """net_in matches an expanded IPv6 form against the canonical host value."""
+        qs = IPAddress.objects.filter(
+            address__net_in=['2001:0db8:0000:0000:0000:0000:0000:0001']
+        )
+        self.assertEqual(qs.count(), 1)
+
+    def test_net_in_predicate_is_inet_typed(self):
+        """net_in casts the host expression to inet so the inet host index applies."""
+        sql = str(IPAddress.objects.filter(address__net_in=['10.0.0.1']).query)
+        self.assertIn('CAST(HOST(', sql)
+        self.assertIn('AS INET) IN', sql)

+ 1000 - 3
netbox/ipam/tests/test_models.py

@@ -1,3 +1,4 @@
+import netaddr
 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.backends.postgresql.psycopg_any import NumericRange
 from django.db.backends.postgresql.psycopg_any import NumericRange
@@ -6,8 +7,11 @@ from netaddr import IPNetwork, IPSet
 
 
 from dcim.models import Site, SiteGroup
 from dcim.models import Site, SiteGroup
 from ipam.choices import *
 from ipam.choices import *
+from ipam.constants import SERVICE_PORT_MAX, SERVICE_PORT_MIN
 from ipam.models import *
 from ipam.models import *
+from ipam.utils import rebuild_prefixes
 from utilities.data import string_to_ranges
 from utilities.data import string_to_ranges
+from virtualization.models import VirtualMachine
 
 
 
 
 class AggregateTestCase(TestCase):
 class AggregateTestCase(TestCase):
@@ -113,7 +117,7 @@ class IPRangeTestCase(TestCase):
 
 
         self.assertEqual(iprange.size, 1)
         self.assertEqual(iprange.size, 1)
         self.assertEqual(str(iprange), '192.0.2.10-192.0.2.10/24')
         self.assertEqual(str(iprange), '192.0.2.10-192.0.2.10/24')
-        self.assertEqual(iprange.first_available_ip, '192.0.2.10/24')
+        self.assertEqual(iprange.get_first_available_ip(), '192.0.2.10/24')
 
 
     def test_first_available_ip_consumed_single_address_range(self):
     def test_first_available_ip_consumed_single_address_range(self):
         iprange = IPRange.objects.create(
         iprange = IPRange.objects.create(
@@ -123,7 +127,7 @@ class IPRangeTestCase(TestCase):
         IPAddress.objects.create(address=IPNetwork('192.0.2.10/24'))
         IPAddress.objects.create(address=IPNetwork('192.0.2.10/24'))
 
 
         # The sole address in the range is now assigned, so no IPs remain available.
         # The sole address in the range is now assigned, so no IPs remain available.
-        self.assertIsNone(iprange.first_available_ip)
+        self.assertIsNone(iprange.get_first_available_ip())
 
 
     def test_single_address_range_ipv6(self):
     def test_single_address_range_ipv6(self):
         # IPRange.name has IPv4/IPv6-specific formatting; exercise the IPv6 branch
         # IPRange.name has IPv4/IPv6-specific formatting; exercise the IPv6 branch
@@ -138,7 +142,7 @@ class IPRangeTestCase(TestCase):
 
 
         self.assertEqual(iprange.size, 1)
         self.assertEqual(iprange.size, 1)
         self.assertEqual(str(iprange), '2001:db8::10-2001:db8::10/64')
         self.assertEqual(str(iprange), '2001:db8::10-2001:db8::10/64')
-        self.assertEqual(iprange.first_available_ip, '2001:db8::10/64')
+        self.assertEqual(iprange.get_first_available_ip(), '2001:db8::10/64')
 
 
     def test_reversed_range(self):
     def test_reversed_range(self):
         iprange = IPRange(
         iprange = IPRange(
@@ -165,9 +169,207 @@ class IPRangeTestCase(TestCase):
         with self.assertRaisesMessage(ValidationError, 'Defined addresses overlap'):
         with self.assertRaisesMessage(ValidationError, 'Defined addresses overlap'):
             iprange.clean()
             iprange.clean()
 
 
+    def test_get_child_ips_host_portion(self):
+        iprange = IPRange.objects.create(
+            start_address=IPNetwork('10.0.0.2/24'),
+            end_address=IPNetwork('10.0.0.254/24'),
+        )
+
+        ip1 = IPAddress.objects.create(address=IPNetwork('10.0.0.2/32'))
+        ip2 = IPAddress.objects.create(address=IPNetwork('10.0.0.3/24'))
+
+        self.assertEqual(set(iprange.get_child_ips()), {ip1, ip2})
+
+    def test_get_available_ips(self):
+        """
+        Tests that occupied hosts are deduplicated and excluded from the available set.
+        """
+        iprange = IPRange.objects.create(
+            start_address=IPNetwork('192.0.2.10/24'),
+            end_address=IPNetwork('192.0.2.13/24'),
+        )
+        IPAddress.objects.bulk_create((
+            IPAddress(address=IPNetwork('192.0.2.10/24')),
+            IPAddress(address=IPNetwork('192.0.2.10/32')),
+        ))
+
+        self.assertEqual(iprange.get_available_ips(), IPSet(['192.0.2.11/32', '192.0.2.12/31']))
+
+    def test_get_available_ips_mark_populated(self):
+        """
+        Tests that a populated range reports no available IPs.
+        """
+        iprange = IPRange.objects.create(
+            start_address=IPNetwork('192.0.2.10/24'),
+            end_address=IPNetwork('192.0.2.13/24'),
+            mark_populated=True,
+        )
+
+        self.assertEqual(iprange.get_available_ips(), IPSet())
+
+    def test_get_available_ips_vrf(self):
+        """
+        Tests that IPs in other VRFs do not consume range space.
+        """
+        vrf1 = VRF.objects.create(name='VRF 1')
+        vrf2 = VRF.objects.create(name='VRF 2')
+        iprange = IPRange.objects.create(
+            start_address=IPNetwork('192.0.2.10/24'),
+            end_address=IPNetwork('192.0.2.11/24'),
+            vrf=vrf1,
+        )
+        IPAddress.objects.create(address=IPNetwork('192.0.2.10/24'), vrf=vrf2)
+
+        self.assertEqual(iprange.get_available_ips(), IPSet(['192.0.2.10/31']))
+
+    def test_iter_available_ips(self):
+        """
+        Tests that iter_available_ips() yields the same addresses as get_available_ips() in order.
+        """
+        iprange = IPRange.objects.create(
+            start_address=IPNetwork('192.0.2.10/24'),
+            end_address=IPNetwork('192.0.2.13/24'),
+        )
+        IPAddress.objects.create(address=IPNetwork('192.0.2.11/24'))
+
+        self.assertEqual(list(iprange.iter_available_ips()), sorted(iprange.get_available_ips()))
+
+    def test_available_ip_count(self):
+        iprange = IPRange.objects.create(
+            start_address=IPNetwork('192.0.2.10/24'),
+            end_address=IPNetwork('192.0.2.19/24'),
+        )
+
+        IPAddress.objects.create(address=IPNetwork('192.0.2.12/24'))
+
+        self.assertEqual(iprange.get_available_ip_count(), 9)
+
+    def test_available_ip_count_distinct_hosts(self):
+        iprange = IPRange.objects.create(
+            start_address=IPNetwork('192.0.2.10/24'),
+            end_address=IPNetwork('192.0.2.19/24'),
+        )
+
+        # Two rows for .10 (different masks) must dedupe to a single occupied host.
+        IPAddress.objects.bulk_create((
+            IPAddress(address=IPNetwork('192.0.2.10/24')),
+            IPAddress(address=IPNetwork('192.0.2.10/32')),
+            IPAddress(address=IPNetwork('192.0.2.11/24')),
+        ))
+
+        self.assertEqual(iprange.get_available_ip_count(), 8)
+
+    def test_available_ip_count_vrf(self):
+        vrf1 = VRF.objects.create(name='VRF 1')
+        vrf2 = VRF.objects.create(name='VRF 2')
+
+        iprange = IPRange.objects.create(
+            start_address=IPNetwork('192.0.2.10/24'),
+            end_address=IPNetwork('192.0.2.19/24'),
+            vrf=vrf1,
+        )
+
+        IPAddress.objects.create(address=IPNetwork('192.0.2.12/24'), vrf=vrf1)
+        IPAddress.objects.create(address=IPNetwork('192.0.2.13/24'), vrf=vrf2)
+
+        # Only the VRF 1 IP should count.
+        self.assertEqual(iprange.get_available_ip_count(), 9)
+
+    def test_available_ip_count_populated(self):
+        iprange = IPRange.objects.create(
+            start_address=IPNetwork('192.0.2.10/24'),
+            end_address=IPNetwork('192.0.2.19/24'),
+            mark_populated=True,
+        )
+
+        self.assertEqual(iprange.get_available_ip_count(), 0)
+
+    def test_first_available_ip_full(self):
+        iprange = IPRange.objects.create(
+            start_address=IPNetwork('192.0.2.10/24'),
+            end_address=IPNetwork('192.0.2.11/24'),
+        )
+
+        IPAddress.objects.create(address=IPNetwork('192.0.2.10/24'))
+        IPAddress.objects.create(address=IPNetwork('192.0.2.11/24'))
+
+        self.assertIsNone(iprange.get_first_available_ip())
+
+    def test_first_available_ip_populated(self):
+        iprange = IPRange.objects.create(
+            start_address=IPNetwork('192.0.2.10/24'),
+            end_address=IPNetwork('192.0.2.19/24'),
+            mark_populated=True,
+        )
+
+        self.assertIsNone(iprange.get_first_available_ip())
+
+    def test_first_available_ip_ipv6(self):
+        iprange = IPRange.objects.create(
+            start_address=IPNetwork('::/126'),
+            end_address=IPNetwork('::3/126'),
+        )
+
+        self.assertEqual(iprange.get_first_available_ip(), '::/126')
+
+    def test_utilization_distinct_hosts(self):
+        iprange = IPRange.objects.create(
+            start_address=IPNetwork('192.0.2.10/24'),
+            end_address=IPNetwork('192.0.2.19/24'),
+        )
+
+        IPAddress.objects.bulk_create((
+            IPAddress(address=IPNetwork('192.0.2.10/24')),
+            IPAddress(address=IPNetwork('192.0.2.10/32')),
+            IPAddress(address=IPNetwork('192.0.2.11/24')),
+        ))
+
+        # Two distinct hosts in a 10-address range.
+        self.assertEqual(iprange.utilization, 2 / 10 * 100)
+
+    def test_utilization_vrf(self):
+        vrf1 = VRF.objects.create(name='VRF 1')
+        vrf2 = VRF.objects.create(name='VRF 2')
+
+        iprange = IPRange.objects.create(
+            start_address=IPNetwork('192.0.2.10/24'),
+            end_address=IPNetwork('192.0.2.19/24'),
+            vrf=vrf1,
+        )
+
+        IPAddress.objects.create(address=IPNetwork('192.0.2.12/24'), vrf=vrf1)
+        IPAddress.objects.create(address=IPNetwork('192.0.2.13/24'), vrf=vrf2)
+
+        # Only the VRF 1 IP counts toward utilization.
+        self.assertEqual(iprange.utilization, 1 / 10 * 100)
+
+    def test_utilization_duplicate_ips_vrf(self):
+        """
+        Tests that identical IPs in a non-unique VRF count once toward range utilization.
+        """
+        vrf = VRF.objects.create(name='VRF 1', enforce_unique=False)
+        iprange = IPRange.objects.create(
+            start_address=IPNetwork('192.0.2.10/24'),
+            end_address=IPNetwork('192.0.2.19/24'),
+            vrf=vrf,
+        )
+
+        IPAddress.objects.bulk_create((
+            IPAddress(address=IPNetwork('192.0.2.12/24'), vrf=vrf),
+            IPAddress(address=IPNetwork('192.0.2.12/24'), vrf=vrf),
+        ))
+
+        self.assertEqual(iprange.utilization, 1 / 10 * 100)
+
 
 
 class PrefixTestCase(TestCase):
 class PrefixTestCase(TestCase):
 
 
+    def assertAvailableIPCountMatchesIPSet(self, prefix):
+        """
+        Confirm that get_available_ip_count() matches get_available_ips().size for the supplied prefix.
+        """
+        self.assertEqual(prefix.get_available_ip_count(), prefix.get_available_ips().size)
+
     def test_family_string(self):
     def test_family_string(self):
         # Test property when prefix is a string
         # Test property when prefix is a string
         prefix = Prefix(prefix='10.0.0.0/8')
         prefix = Prefix(prefix='10.0.0.0/8')
@@ -251,6 +453,23 @@ class PrefixTestCase(TestCase):
         self.assertEqual(child_ranges[0], ranges[2])
         self.assertEqual(child_ranges[0], ranges[2])
         self.assertEqual(child_ranges[1], ranges[3])
         self.assertEqual(child_ranges[1], ranges[3])
 
 
+    def test_get_child_ranges_other_family(self):
+        """
+        Tests that ranges of a different address family are not returned.
+        """
+        prefix = Prefix.objects.create(prefix=IPNetwork('192.168.0.16/28'))
+        IPRange.objects.bulk_create((
+            IPRange(
+                start_address=IPNetwork('192.168.0.18/28'), end_address=IPNetwork('192.168.0.20/28'), size=3
+            ),
+            IPRange(start_address=IPNetwork('::1/64'), end_address=IPNetwork('::2/64'), size=2),
+        ))
+
+        child_ranges = prefix.get_child_ranges()
+
+        self.assertEqual(len(child_ranges), 1)
+        self.assertEqual(child_ranges[0].start_address, IPNetwork('192.168.0.18/28'))
+
     def test_get_child_ips(self):
     def test_get_child_ips(self):
         vrfs = VRF.objects.bulk_create((
         vrfs = VRF.objects.bulk_create((
             VRF(name='VRF 1'),
             VRF(name='VRF 1'),
@@ -330,6 +549,319 @@ class PrefixTestCase(TestCase):
 
 
         self.assertEqual(available_ips, missing_ips)
         self.assertEqual(available_ips, missing_ips)
 
 
+    def test_iter_available_ips(self):
+        """
+        Tests that iter_available_ips() yields the same addresses as get_available_ips() in order.
+        """
+        parent_prefix = Prefix.objects.create(prefix=IPNetwork('10.0.0.0/28'))
+        IPAddress.objects.bulk_create((
+            IPAddress(address=IPNetwork('10.0.0.1/28')),
+            IPAddress(address=IPNetwork('10.0.0.5/28')),
+        ))
+        IPRange.objects.create(
+            start_address=IPNetwork('10.0.0.8/28'),
+            end_address=IPNetwork('10.0.0.9/28'),
+            mark_populated=True,
+        )
+
+        available_ips = list(parent_prefix.iter_available_ips())
+
+        self.assertEqual(available_ips, sorted(parent_prefix.get_available_ips()))
+        self.assertEqual(available_ips[0], netaddr.IPAddress('10.0.0.2'))
+        self.assertEqual(available_ips[-1], netaddr.IPAddress('10.0.0.14'))
+
+    def test_get_available_ips_ipv6(self):
+        """
+        Tests that the subnet-router anycast address is excluded and the last address included.
+        """
+        parent_prefix = Prefix.objects.create(prefix=IPNetwork('2001:db8::/126'))
+        IPAddress.objects.create(address=IPNetwork('2001:db8::1/126'))
+
+        self.assertEqual(parent_prefix.get_available_ips(), IPSet(['2001:db8::2/127']))
+
+    def test_get_available_ips_pool(self):
+        """
+        Tests that pool prefixes include the network and broadcast addresses.
+        """
+        parent_prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/30'), is_pool=True)
+        IPAddress.objects.create(address=IPNetwork('192.0.2.1/30'))
+
+        self.assertEqual(parent_prefix.get_available_ips(), IPSet(['192.0.2.0/32', '192.0.2.2/31']))
+
+    def test_available_ip_count_distinct_hosts(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.0/29'),
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+        )
+
+        IPAddress.objects.bulk_create((
+            IPAddress(address=IPNetwork('192.0.2.1/29')),
+            IPAddress(address=IPNetwork('192.0.2.1/32')),
+            IPAddress(address=IPNetwork('192.0.2.3/29')),
+        ))
+
+        # Usable hosts in /29: 6. Two unique hosts occupy .1 and .3.
+        self.assertEqual(prefix.get_available_ip_count(), 4)
+        self.assertAvailableIPCountMatchesIPSet(prefix)
+
+    def test_available_ip_count_populated_ranges(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.0/29'),
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+        )
+
+        IPAddress.objects.bulk_create((
+            IPAddress(address=IPNetwork('192.0.2.1/29')),
+            IPAddress(address=IPNetwork('192.0.2.3/29')),  # Inside the populated range; not double-counted.
+        ))
+
+        IPRange.objects.create(
+            start_address=IPNetwork('192.0.2.3/29'),
+            end_address=IPNetwork('192.0.2.4/29'),
+            mark_populated=True,
+        )
+
+        # Usable 6, one IP outside the range at .1, populated range covers .3-.4.
+        # Available: .2, .5, .6.
+        self.assertEqual(prefix.get_available_ip_count(), 3)
+        self.assertAvailableIPCountMatchesIPSet(prefix)
+
+    def test_available_ip_count_ipv4_pool(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.0/30'),
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+            is_pool=True,
+        )
+
+        self.assertEqual(prefix.get_available_ip_count(), 4)
+        self.assertAvailableIPCountMatchesIPSet(prefix)
+
+    def test_available_ip_count_ipv4_non_pool(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.0/30'),
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+            is_pool=False,
+        )
+
+        self.assertEqual(prefix.get_available_ip_count(), 2)
+        self.assertAvailableIPCountMatchesIPSet(prefix)
+
+    def test_available_ip_count_ipv4_non_pool_ignores_unusable_ips(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.0/30'),
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+        )
+
+        # Network and broadcast addresses are unusable for non-pool IPv4 prefixes;
+        # an IP assigned to either must not reduce the available count.
+        IPAddress.objects.create(address=IPNetwork('192.0.2.0/30'))
+        IPAddress.objects.create(address=IPNetwork('192.0.2.3/30'))
+
+        self.assertEqual(prefix.get_available_ip_count(), 2)
+        self.assertEqual(prefix.get_first_available_ip(), '192.0.2.1/30')
+        self.assertAvailableIPCountMatchesIPSet(prefix)
+
+    def test_available_ip_count_ipv6(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('2001:db8::/126'),
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+        )
+
+        # /126 has 4 addresses; normal IPv6 prefix excludes the first.
+        self.assertEqual(prefix.get_available_ip_count(), 3)
+        self.assertAvailableIPCountMatchesIPSet(prefix)
+
+    def test_available_ip_count_ipv6_ignores_subnet_router_anycast(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('2001:db8::/126'),
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+        )
+
+        # The subnet-router anycast (::) address is unusable for normal IPv6 prefixes;
+        # an IP assigned there must not reduce the available count.
+        IPAddress.objects.create(address=IPNetwork('2001:db8::/126'))
+
+        self.assertEqual(prefix.get_available_ip_count(), 3)
+        self.assertEqual(prefix.get_first_available_ip(), '2001:db8::1/126')
+        self.assertAvailableIPCountMatchesIPSet(prefix)
+
+    def test_available_ip_count_ipv6_127(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('2001:db8::/127'),
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+        )
+
+        self.assertEqual(prefix.get_available_ip_count(), 2)
+        self.assertAvailableIPCountMatchesIPSet(prefix)
+
+    def test_available_ip_count_ipv6_populated_range(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('2001:db8::/126'),
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+        )
+
+        IPRange.objects.create(
+            start_address=IPNetwork('2001:db8::1/126'),
+            end_address=IPNetwork('2001:db8::2/126'),
+            mark_populated=True,
+        )
+
+        # Usable IPv6 hosts in /126: ::1, ::2, ::3. Populated: ::1-::2.
+        self.assertEqual(prefix.get_available_ip_count(), 1)
+        self.assertEqual(prefix.get_first_available_ip(), '2001:db8::3/126')
+        self.assertAvailableIPCountMatchesIPSet(prefix)
+
+    def test_available_ip_count_overlapping_ranges(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.0/29'),
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+        )
+
+        IPRange.objects.create(
+            start_address=IPNetwork('192.0.2.1/29'),
+            end_address=IPNetwork('192.0.2.3/29'),
+            mark_populated=True,
+        )
+        IPRange.objects.create(
+            start_address=IPNetwork('192.0.2.2/29'),
+            end_address=IPNetwork('192.0.2.4/29'),
+            mark_populated=True,
+        )
+
+        # Usable hosts: .1-.6 => 6. Populated union: .1-.4 => 4. Available: .5-.6 => 2.
+        self.assertEqual(prefix.get_available_ip_count(), 2)
+        self.assertEqual(prefix.get_first_available_ip(), '192.0.2.5/29')
+        self.assertAvailableIPCountMatchesIPSet(prefix)
+
+    def test_available_ip_count_vrf(self):
+        vrf1 = VRF.objects.create(name='VRF 1')
+        vrf2 = VRF.objects.create(name='VRF 2')
+
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.0/29'),
+            vrf=vrf1,
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+        )
+
+        IPAddress.objects.create(address=IPNetwork('192.0.2.1/29'), vrf=vrf1)
+        IPAddress.objects.create(address=IPNetwork('192.0.2.2/29'), vrf=vrf2)
+
+        # Usable .1-.6 => 6. Only the VRF 1 IP should count.
+        self.assertEqual(prefix.get_available_ip_count(), 5)
+        self.assertAvailableIPCountMatchesIPSet(prefix)
+
+    def test_available_ip_count_vrf_ranges(self):
+        vrf1 = VRF.objects.create(name='VRF 1')
+        vrf2 = VRF.objects.create(name='VRF 2')
+
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.0/29'),
+            vrf=vrf1,
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+        )
+
+        # Covers every usable host, but in a different VRF.
+        IPRange.objects.create(
+            start_address=IPNetwork('192.0.2.1/29'),
+            end_address=IPNetwork('192.0.2.6/29'),
+            vrf=vrf2,
+            mark_populated=True,
+        )
+
+        self.assertEqual(prefix.get_available_ip_count(), 6)
+        self.assertEqual(prefix.get_first_available_ip(), '192.0.2.1/29')
+        self.assertAvailableIPCountMatchesIPSet(prefix)
+
+    def test_available_ip_count_fully_populated(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.0/30'),
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+        )
+
+        # Populated range covers every usable address (.1-.2 in a non-pool /30).
+        IPRange.objects.create(
+            start_address=IPNetwork('192.0.2.1/30'),
+            end_address=IPNetwork('192.0.2.2/30'),
+            mark_populated=True,
+        )
+
+        # Exercises the early-return paths that skip the child-IP count and
+        # the host-stream iterator entirely.
+        self.assertEqual(prefix.get_available_ip_count(), 0)
+        self.assertIsNone(prefix.get_first_available_ip())
+        self.assertAvailableIPCountMatchesIPSet(prefix)
+
+    def test_available_ip_count_query_count(self):
+        """
+        Tests that the count runs one interval query plus exactly one host scan.
+        """
+        prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/24'))
+
+        IPAddress.objects.bulk_create(
+            IPAddress(address=IPNetwork(f'192.0.2.{i}/24')) for i in range(1, 11)
+        )
+
+        IPRange.objects.create(
+            start_address=IPNetwork('192.0.2.20/24'),
+            end_address=IPNetwork('192.0.2.29/24'),
+            mark_populated=True,
+        )
+
+        with self.assertNumQueries(2):
+            prefix.get_available_ip_count()
+
+    def test_available_ip_count_container(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.0/24'),
+            status=PrefixStatusChoices.STATUS_CONTAINER,
+        )
+
+        # A child prefix exists but does not reduce the available IP count.
+        Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.0/26'),
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+        )
+
+        self.assertEqual(prefix.get_available_ip_count(), 254)
+        self.assertAvailableIPCountMatchesIPSet(prefix)
+
+    def test_available_ip_count_container_vrf_duplicate_hosts(self):
+        vrf1 = VRF.objects.create(name='VRF 1')
+        vrf2 = VRF.objects.create(name='VRF 2')
+
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.0/24'),
+            status=PrefixStatusChoices.STATUS_CONTAINER,
+        )
+
+        IPAddress.objects.create(address=IPNetwork('192.0.2.1/24'), vrf=vrf1)
+        IPAddress.objects.create(address=IPNetwork('192.0.2.1/24'), vrf=vrf2)
+        IPAddress.objects.create(address=IPNetwork('192.0.2.2/24'), vrf=vrf2)
+
+        # A global container counts child IPs from all VRFs; the duplicate host
+        # counts once. 254 usable - 2 distinct hosts.
+        self.assertEqual(prefix.get_available_ip_count(), 252)
+        self.assertAvailableIPCountMatchesIPSet(prefix)
+
+    def test_available_ip_count_container_vrf_ip_in_populated_range(self):
+        vrf1 = VRF.objects.create(name='VRF 1')
+
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.0/24'),
+            status=PrefixStatusChoices.STATUS_CONTAINER,
+        )
+
+        IPRange.objects.create(
+            start_address=IPNetwork('192.0.2.10/24'),
+            end_address=IPNetwork('192.0.2.19/24'),
+            mark_populated=True,
+        )
+        IPAddress.objects.create(address=IPNetwork('192.0.2.15/24'), vrf=vrf1)
+
+        # The range covers 10 hosts; the VRF 1 IP inside it is not counted again.
+        self.assertEqual(prefix.get_available_ip_count(), 244)
+        self.assertAvailableIPCountMatchesIPSet(prefix)
+
     def test_get_first_available_prefix(self):
     def test_get_first_available_prefix(self):
 
 
         prefixes = Prefix.objects.bulk_create((
         prefixes = Prefix.objects.bulk_create((
@@ -368,6 +900,42 @@ class PrefixTestCase(TestCase):
         parent_prefix = Prefix.objects.create(prefix=IPNetwork('2001:db8:500:5::/127'))
         parent_prefix = Prefix.objects.create(prefix=IPNetwork('2001:db8:500:5::/127'))
         self.assertEqual(parent_prefix.get_first_available_ip(), '2001:db8:500:5::/127')
         self.assertEqual(parent_prefix.get_first_available_ip(), '2001:db8:500:5::/127')
 
 
+    def test_get_first_available_ip_ipv6_zero_address(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('::/126'),
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+        )
+
+        # Normal IPv6 prefixes exclude the subnet-router anycast address ::.
+        self.assertEqual(prefix.get_first_available_ip(), '::1/126')
+
+    def test_get_first_available_ip_populated_ranges(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.0/29'),
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+        )
+
+        IPAddress.objects.create(address=IPNetwork('192.0.2.1/29'))
+
+        IPRange.objects.create(
+            start_address=IPNetwork('192.0.2.2/29'),
+            end_address=IPNetwork('192.0.2.3/29'),
+            mark_populated=True,
+        )
+
+        self.assertEqual(prefix.get_first_available_ip(), '192.0.2.4/29')
+
+    def test_get_first_available_ip_full(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.0/30'),
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+        )
+
+        IPAddress.objects.create(address=IPNetwork('192.0.2.1/30'))
+        IPAddress.objects.create(address=IPNetwork('192.0.2.2/30'))
+
+        self.assertIsNone(prefix.get_first_available_ip())
+
     def test_get_utilization_container(self):
     def test_get_utilization_container(self):
         prefixes = (
         prefixes = (
             Prefix(prefix=IPNetwork('10.0.0.0/24'), status=PrefixStatusChoices.STATUS_CONTAINER),
             Prefix(prefix=IPNetwork('10.0.0.0/24'), status=PrefixStatusChoices.STATUS_CONTAINER),
@@ -397,6 +965,276 @@ class PrefixTestCase(TestCase):
         )
         )
         self.assertEqual(prefix.get_utilization(), 64 / 254 * 100)  # ~25% utilization
         self.assertEqual(prefix.get_utilization(), 64 / 254 * 100)  # ~25% utilization
 
 
+    def test_get_utilization_distinct_hosts(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.0/24'),
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+        )
+
+        IPAddress.objects.bulk_create((
+            IPAddress(address=IPNetwork('192.0.2.10/24')),
+            IPAddress(address=IPNetwork('192.0.2.10/32')),
+            IPAddress(address=IPNetwork('192.0.2.11/24')),
+        ))
+
+        # Two unique occupied hosts over 254 usable IPv4 addresses.
+        self.assertEqual(prefix.get_utilization(), 2 / 254 * 100)
+
+    @override_settings(ENFORCE_GLOBAL_UNIQUE=False)
+    def test_get_utilization_duplicate_ips_global(self):
+        """
+        Tests that identical global IPs permitted by disabled uniqueness count as one host.
+        """
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.0/24'),
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+        )
+
+        IPAddress.objects.create(address=IPNetwork('192.0.2.10/24'))
+        duplicate_ip = IPAddress(address=IPNetwork('192.0.2.10/24'))
+        self.assertIsNone(duplicate_ip.clean())
+        duplicate_ip.save()
+
+        self.assertEqual(prefix.get_utilization(), 1 / 254 * 100)
+
+    def test_get_utilization_duplicate_ips_vrf(self):
+        """
+        Tests that identical IPs in a non-unique VRF count as one host.
+        """
+        vrf = VRF.objects.create(name='VRF 1', enforce_unique=False)
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.0/24'),
+            vrf=vrf,
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+        )
+
+        IPAddress.objects.create(address=IPNetwork('192.0.2.10/24'), vrf=vrf)
+        duplicate_ip = IPAddress(address=IPNetwork('192.0.2.10/24'), vrf=vrf)
+        self.assertIsNone(duplicate_ip.clean())
+        duplicate_ip.save()
+
+        self.assertEqual(prefix.get_utilization(), 1 / 254 * 100)
+
+    def test_available_ip_count_duplicate_ips_vrf(self):
+        """
+        Tests that identical IPs in a non-unique VRF reduce availability once.
+        """
+        vrf = VRF.objects.create(name='VRF 1', enforce_unique=False)
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.0/29'),
+            vrf=vrf,
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+        )
+
+        IPAddress.objects.bulk_create((
+            IPAddress(address=IPNetwork('192.0.2.1/29'), vrf=vrf),
+            IPAddress(address=IPNetwork('192.0.2.1/29'), vrf=vrf),
+        ))
+
+        # Usable hosts in /29: 6. The duplicate occupies a single host.
+        self.assertEqual(prefix.get_available_ip_count(), 5)
+        self.assertAvailableIPCountMatchesIPSet(prefix)
+
+    @override_settings(ENFORCE_GLOBAL_UNIQUE=False)
+    def test_get_ip_usage_summary_duplicate_ips_global(self):
+        """
+        Tests that the usage summary deduplicates identical global IPs in both values.
+        """
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.0/24'),
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+        )
+
+        IPAddress.objects.bulk_create((
+            IPAddress(address=IPNetwork('192.0.2.10/24')),
+            IPAddress(address=IPNetwork('192.0.2.10/24')),
+        ))
+
+        summary = prefix.get_ip_usage_summary()
+
+        self.assertEqual(summary['available_ip_count'], 253)
+        self.assertEqual(summary['utilization'], 1 / 254 * 100)
+
+    def test_get_utilization_utilized_ranges(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.0/24'),
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+        )
+
+        IPRange.objects.create(
+            start_address=IPNetwork('192.0.2.10/24'),
+            end_address=IPNetwork('192.0.2.19/24'),
+            mark_utilized=True,
+        )
+
+        IPAddress.objects.bulk_create((
+            IPAddress(address=IPNetwork('192.0.2.1/24')),
+            IPAddress(address=IPNetwork('192.0.2.10/24')),
+            IPAddress(address=IPNetwork('192.0.2.11/24')),
+            IPAddress(address=IPNetwork('192.0.2.20/24')),
+        ))
+
+        # Utilized range contributes 10 hosts; IPs inside the range are not double-counted.
+        # Outside IPs: .1 and .20 => 2 more.
+        self.assertEqual(prefix.get_utilization(), 12 / 254 * 100)
+
+    def test_get_utilization_overlapping_utilized_ranges(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.0/24'),
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+        )
+
+        IPRange.objects.create(
+            start_address=IPNetwork('192.0.2.10/24'),
+            end_address=IPNetwork('192.0.2.19/24'),
+            mark_utilized=True,
+        )
+        IPRange.objects.create(
+            start_address=IPNetwork('192.0.2.15/24'),
+            end_address=IPNetwork('192.0.2.24/24'),
+            mark_utilized=True,
+        )
+
+        # Union is .10-.24 => 15 hosts, not 20.
+        self.assertEqual(prefix.get_utilization(), 15 / 254 * 100)
+
+    def test_get_utilization_fully_utilized_range(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.0/24'),
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+        )
+
+        # Utilized range covers every usable host (.1-.254 in a non-pool /24).
+        IPRange.objects.create(
+            start_address=IPNetwork('192.0.2.1/24'),
+            end_address=IPNetwork('192.0.2.254/24'),
+            mark_utilized=True,
+        )
+
+        # Exercises the early-return path that skips the child-IP count entirely.
+        self.assertEqual(prefix.get_utilization(), 100)
+
+    def test_get_utilization_ipv6_utilized_range(self):
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('2001:db8::/126'),
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+        )
+
+        IPRange.objects.create(
+            start_address=IPNetwork('2001:db8::1/126'),
+            end_address=IPNetwork('2001:db8::2/126'),
+            mark_utilized=True,
+        )
+
+        self.assertEqual(prefix.get_utilization(), 2 / 4 * 100)
+
+    def test_get_utilization_vrf(self):
+        vrf1 = VRF.objects.create(name='VRF 1')
+        vrf2 = VRF.objects.create(name='VRF 2')
+
+        prefix = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.0/24'),
+            vrf=vrf1,
+            status=PrefixStatusChoices.STATUS_ACTIVE,
+        )
+
+        IPAddress.objects.create(address=IPNetwork('192.0.2.1/24'), vrf=vrf1)
+        IPAddress.objects.create(address=IPNetwork('192.0.2.15/24'), vrf=vrf1)
+        IPAddress.objects.create(address=IPNetwork('192.0.2.2/24'), vrf=vrf2)
+        IPRange.objects.create(
+            start_address=IPNetwork('192.0.2.10/24'),
+            end_address=IPNetwork('192.0.2.19/24'),
+            vrf=vrf2,
+            mark_utilized=True,
+        )
+
+        # VRF 2 objects are ignored entirely; the VRF 1 IP at .15 still counts even
+        # though it falls inside the VRF 2 range's host span (exclusion intervals are
+        # built only from same-VRF ranges).
+        self.assertEqual(prefix.get_utilization(), 2 / 254 * 100)
+
+    def test_get_utilization_query_count(self):
+        """
+        Tests that utilization for a non-container prefix uses two queries.
+        """
+        prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/24'))
+
+        with self.assertNumQueries(2):
+            prefix.get_utilization()
+
+    def test_get_ip_usage_summary(self):
+        """
+        Tests that the combined summary matches the independent methods.
+        """
+        prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/24'))
+        IPAddress.objects.bulk_create((
+            IPAddress(address=IPNetwork('192.0.2.1/24')),
+            IPAddress(address=IPNetwork('192.0.2.2/24')),
+        ))
+        IPRange.objects.create(
+            start_address=IPNetwork('192.0.2.10/24'),
+            end_address=IPNetwork('192.0.2.19/24'),
+            mark_utilized=True,
+        )
+        IPRange.objects.create(
+            start_address=IPNetwork('192.0.2.30/24'),
+            end_address=IPNetwork('192.0.2.39/24'),
+            mark_populated=True,
+        )
+
+        summary = prefix.get_ip_usage_summary()
+
+        self.assertEqual(summary['available_ip_count'], prefix.get_available_ip_count())
+        self.assertEqual(summary['utilization'], prefix.get_utilization())
+
+    def test_get_ip_usage_summary_query_count(self):
+        """
+        Tests that the combined summary uses a single distinct-host scan (three queries).
+        """
+        prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/24'))
+
+        with self.assertNumQueries(3):
+            prefix.get_ip_usage_summary()
+
+    def test_get_ip_usage_summary_container(self):
+        """
+        Tests that the summary delegates to the independent methods for containers.
+        """
+        container = Prefix.objects.create(
+            prefix=IPNetwork('192.0.2.0/24'),
+            status=PrefixStatusChoices.STATUS_CONTAINER,
+        )
+
+        summary = container.get_ip_usage_summary()
+
+        self.assertEqual(summary['available_ip_count'], container.get_available_ip_count())
+        self.assertEqual(summary['utilization'], container.get_utilization())
+
+    def test_get_ip_usage_summary_mark_utilized(self):
+        """
+        Tests that a marked-utilized prefix reports 100% utilization in the summary.
+        """
+        prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/24'), mark_utilized=True)
+
+        summary = prefix.get_ip_usage_summary()
+
+        self.assertEqual(summary['utilization'], 100)
+        self.assertEqual(summary['available_ip_count'], prefix.get_available_ip_count())
+
+    def test_usable_size(self):
+        self.assertEqual(Prefix(prefix=IPNetwork('192.0.2.0/24')).usable_size, 254)
+        self.assertEqual(Prefix(prefix=IPNetwork('192.0.2.0/24'), is_pool=True).usable_size, 256)
+        self.assertEqual(Prefix(prefix=IPNetwork('2001:db8::/126')).usable_size, 3)
+
+    def test_usable_ip_bounds_string_prefix(self):
+        """
+        Tests that usable bounds are computed for a string-assigned prefix.
+        """
+        first_ip, last_ip = Prefix(prefix='192.0.2.0/24').usable_ip_bounds
+
+        self.assertEqual(first_ip, netaddr.IPAddress('192.0.2.1'))
+        self.assertEqual(last_ip, netaddr.IPAddress('192.0.2.254'))
+
     #
     #
     # Uniqueness enforcement tests
     # Uniqueness enforcement tests
     #
     #
@@ -622,6 +1460,35 @@ class PrefixHierarchyTestCase(TestCase):
         self.assertEqual(prefixes[3]._depth, 2)
         self.assertEqual(prefixes[3]._depth, 2)
         self.assertEqual(prefixes[3]._children, 0)
         self.assertEqual(prefixes[3]._children, 0)
 
 
+    def test_rebuild_prefixes_accepts_vrf_identifier(self):
+        # None means "global table". Wipe the precomputed hierarchy so the rebuild is observable.
+        Prefix.objects.update(_depth=0, _children=0)
+
+        rebuild_prefixes(None)
+
+        top = Prefix.objects.get(prefix='10.0.0.0/8')
+        mid = Prefix.objects.get(prefix='10.0.0.0/16')
+        leaf = Prefix.objects.get(prefix='10.0.0.0/24')
+        self.assertEqual((top._depth, top._children), (0, 2))
+        self.assertEqual((mid._depth, mid._children), (1, 1))
+        self.assertEqual((leaf._depth, leaf._children), (2, 0))
+
+    def test_rebuild_prefixes_accepts_vrf_pk(self):
+        # A VRF pk filters to that VRF's prefixes.
+        vrf = VRF.objects.create(name='VRF 1')
+        Prefix.objects.create(prefix=IPNetwork('192.0.2.0/24'), vrf=vrf)
+        Prefix.objects.create(prefix=IPNetwork('192.0.2.0/25'), vrf=vrf)
+
+        # Reset depth/children so the rebuild has something to restore.
+        Prefix.objects.filter(vrf=vrf).update(_depth=0, _children=0)
+
+        rebuild_prefixes(vrf.pk)
+
+        parent = Prefix.objects.get(prefix='192.0.2.0/24', vrf=vrf)
+        child = Prefix.objects.get(prefix='192.0.2.0/25', vrf=vrf)
+        self.assertEqual((parent._depth, parent._children), (0, 1))
+        self.assertEqual((child._depth, child._children), (1, 0))
+
 
 
 class IPAddressTestCase(TestCase):
 class IPAddressTestCase(TestCase):
 
 
@@ -717,6 +1584,20 @@ class IPAddressTestCase(TestCase):
         with self.assertRaisesMessage(ValidationError, 'Cannot create IP address'):
         with self.assertRaisesMessage(ValidationError, 'Cannot create IP address'):
             ipaddress.clean()
             ipaddress.clean()
 
 
+    def test_populated_range_blocks_ip_with_different_mask(self):
+        # The populated-range check compares by host portion, so a different mask
+        # must not let an IPAddress slip past validation.
+        IPRange.objects.create(
+            start_address=IPNetwork('10.0.0.2/24'),
+            end_address=IPNetwork('10.0.0.254/24'),
+            mark_populated=True,
+        )
+
+        ip = IPAddress(address=IPNetwork('10.0.0.2/32'))
+
+        with self.assertRaises(ValidationError):
+            ip.full_clean()
+
 
 
 class VLANGroupTestCase(TestCase):
 class VLANGroupTestCase(TestCase):
 
 
@@ -926,3 +1807,119 @@ class VLANTestCase(TestCase):
         vlan.group = vlangroups[2]
         vlan.group = vlangroups[2]
         with self.assertRaises(ValidationError):
         with self.assertRaises(ValidationError):
             vlan.full_clean()
             vlan.full_clean()
+
+    def test_vlan_group_vid_validation_with_null_vid(self):
+        """A missing VID on a grouped VLAN raises a ValidationError, not a TypeError."""
+        group = VLANGroup.objects.create(name='VLAN Group 1', slug='vlan-group-1')
+        vlan = VLAN(name='VLAN X', vid=None, group=group)
+        with self.assertRaises(ValidationError):
+            vlan.full_clean()
+
+
+class PrefixGetChildIPsTestCase(TestCase):
+    @classmethod
+    def setUpTestData(cls):
+        cls.prefix = Prefix.objects.create(prefix='10.0.0.0/24')
+        IPAddress.objects.bulk_create((
+            IPAddress(address='10.0.0.0/24'),    # Network address (inside containment)
+            IPAddress(address='10.0.0.1/24'),
+            IPAddress(address='10.0.0.255/24'),  # Broadcast address (inside containment)
+            IPAddress(address='10.0.1.1/24'),    # Outside the prefix
+        ))
+
+    def test_get_child_ips_matches_net_host_contained(self):
+        """get_child_ips returns the same IPs as the net_host_contained containment lookup."""
+        expected = set(
+            IPAddress.objects.filter(
+                address__net_host_contained=str(self.prefix.prefix), vrf=None
+            ).values_list('pk', flat=True)
+        )
+        actual = set(self.prefix.get_child_ips().values_list('pk', flat=True))
+        self.assertEqual(actual, expected)
+        self.assertEqual(len(actual), 3)
+
+    def test_get_child_ips_sql_avoids_containment_recheck(self):
+        """get_child_ips filters on an inet host range, not the <<= containment operator."""
+        sql = str(self.prefix.get_child_ips().query)
+        self.assertNotIn('<<=', sql)
+
+    def test_get_child_ips_container_in_global_table_spans_vrfs(self):
+        """A container prefix in the global table returns child IPs from any VRF."""
+        vrf = VRF.objects.create(name='VRF 1')
+        container = Prefix.objects.create(
+            prefix='10.1.0.0/24', status=PrefixStatusChoices.STATUS_CONTAINER,
+        )
+        in_vrf = IPAddress.objects.create(address='10.1.0.5/24', vrf=vrf)
+        in_global = IPAddress.objects.create(address='10.1.0.6/24')
+        child_pks = set(container.get_child_ips().values_list('pk', flat=True))
+        self.assertEqual(child_pks, {in_vrf.pk, in_global.pk})
+
+
+class ServiceTemplateTestCase(TestCase):
+
+    def test_servicetemplate_lowest_port(self):
+        """
+        Test lowest port setting for servicetemplate
+        """
+        template = ServiceTemplate(
+            name='Template 1',
+            protocol=ServiceProtocolChoices.PROTOCOL_TCP,
+            ports=[80, 443, 22, 8080],  # small test list
+        )
+        template.full_clean()
+        template.save()
+        self.assertEqual(template._ports_lowest, 22)
+
+    def test_servicetemplate_single_port(self):
+        """
+        Test with a single port
+        """
+        template = ServiceTemplate(
+            name='Template 2',
+            protocol=ServiceProtocolChoices.PROTOCOL_UDP,
+            ports=[53],
+        )
+        template.full_clean()
+        template.save()
+        self.assertEqual(template._ports_lowest, 53)
+
+    def test_servicetemplate_empty_ports(self):
+        """
+        Test with empty ports list
+        """
+        template = ServiceTemplate(
+            name='Template 3',
+            protocol=ServiceProtocolChoices.PROTOCOL_TCP,
+            ports=[],
+        )
+        self.assertRaises(ValidationError, template.full_clean)
+
+
+class ServiceTestCase(TestCase):
+
+    @classmethod
+    def setUpTestData(cls):
+        site = Site.objects.create(
+            name='Site 1',
+            slug='site-1',
+        )
+        VirtualMachine.objects.create(
+            name='virtual machine 1',
+            site=site,
+        )
+
+    def test_large_service(self):
+        """
+        Test creation of service with large number of ports.
+        Related to issue #22273
+        """
+        service = Service(
+            name='Service 1',
+            protocol=ServiceProtocolChoices.PROTOCOL_TCP,
+            ports=list(range(SERVICE_PORT_MIN, SERVICE_PORT_MAX)),
+            parent=VirtualMachine.objects.first(),
+        )
+        service.full_clean()
+        # Testing .save() is the important part, to check for database problems
+        service.save()
+        self.assertEqual(service._ports_lowest, SERVICE_PORT_MIN)

+ 344 - 0
netbox/ipam/tests/test_querysets.py

@@ -0,0 +1,344 @@
+import netaddr
+from django.test import TestCase
+from netaddr import IPNetwork
+
+from ipam.models import IPAddress, IPRange
+
+
+class IPAddressQuerySetTestCase(TestCase):
+    @classmethod
+    def setUpTestData(cls):
+        IPAddress.objects.bulk_create((
+            IPAddress(address=IPNetwork('192.0.2.1/24')),
+            IPAddress(address=IPNetwork('192.0.2.1/32')),
+            IPAddress(address=IPNetwork('192.0.2.2/24')),
+        ))
+
+    def test_count_distinct_hosts(self):
+        """
+        Tests that duplicate hosts with different masks are counted once.
+        """
+        self.assertEqual(IPAddress.objects.count_distinct_hosts(), 2)
+
+    def test_count_distinct_hosts_empty(self):
+        """
+        Tests that an empty queryset counts zero hosts.
+        """
+        self.assertEqual(IPAddress.objects.none().count_distinct_hosts(), 0)
+
+    def test_count_distinct_hosts_exclude_intervals(self):
+        """
+        Tests that hosts covered by an excluded interval are not counted.
+        """
+        interval = (netaddr.IPAddress('192.0.2.1'), netaddr.IPAddress('192.0.2.1'))
+        self.assertEqual(IPAddress.objects.count_distinct_hosts(exclude_intervals=[interval]), 1)
+
+    def test_count_distinct_hosts_pair(self):
+        """
+        Tests that the bounded and total distinct host counts are computed correctly.
+        """
+        counts = IPAddress.objects.count_distinct_hosts_pair(
+            bounds=(netaddr.IPAddress('192.0.2.2'), netaddr.IPAddress('192.0.2.10')),
+            bounded_exclude=[(netaddr.IPAddress('192.0.2.2'), netaddr.IPAddress('192.0.2.2'))],
+            total_exclude=[(netaddr.IPAddress('192.0.2.1'), netaddr.IPAddress('192.0.2.1'))],
+        )
+        self.assertEqual(counts, {'bounded': 0, 'total': 1})
+
+    def test_count_distinct_hosts_pair_no_excludes(self):
+        """
+        Tests that both counts dedupe hosts and respect the bounds without excludes.
+        """
+        counts = IPAddress.objects.count_distinct_hosts_pair(
+            bounds=(netaddr.IPAddress('192.0.2.2'), netaddr.IPAddress('192.0.2.10')),
+        )
+        self.assertEqual(counts, {'bounded': 1, 'total': 2})
+
+    def test_first_available_host(self):
+        """
+        Tests that occupied hosts and excluded intervals are skipped, including hosts behind the sweep.
+        """
+        interval = (netaddr.IPAddress('192.0.2.1'), netaddr.IPAddress('192.0.2.5'))
+        self.assertEqual(
+            IPAddress.objects.first_available_host(
+                netaddr.IPAddress('192.0.2.1'), netaddr.IPAddress('192.0.2.10'), exclude_intervals=[interval]
+            ),
+            netaddr.IPAddress('192.0.2.6'),
+        )
+
+    def test_first_available_host_inverted_bounds(self):
+        """
+        Tests that an inverted bounds pair yields None.
+        """
+        self.assertIsNone(
+            IPAddress.objects.first_available_host(netaddr.IPAddress('192.0.2.10'), netaddr.IPAddress('192.0.2.5'))
+        )
+
+    def test_available_intervals(self):
+        """
+        Tests that gaps around occupied hosts and excluded intervals are yielded in order.
+        """
+        interval = (netaddr.IPAddress('192.0.2.5'), netaddr.IPAddress('192.0.2.6'))
+        self.assertEqual(
+            list(IPAddress.objects.available_intervals(
+                netaddr.IPAddress('192.0.2.1'), netaddr.IPAddress('192.0.2.10'), exclude_intervals=[interval]
+            )),
+            [
+                (netaddr.IPAddress('192.0.2.3'), netaddr.IPAddress('192.0.2.4')),
+                (netaddr.IPAddress('192.0.2.7'), netaddr.IPAddress('192.0.2.10')),
+            ],
+        )
+
+    def test_available_intervals_leading_gap(self):
+        """
+        Tests that the gap before the first occupied host is yielded.
+        """
+        self.assertEqual(
+            list(IPAddress.objects.available_intervals(
+                netaddr.IPAddress('192.0.2.0'), netaddr.IPAddress('192.0.2.2')
+            )),
+            [(netaddr.IPAddress('192.0.2.0'), netaddr.IPAddress('192.0.2.0'))],
+        )
+
+    def test_available_intervals_empty_queryset(self):
+        """
+        Tests that an empty queryset yields the full span.
+        """
+        self.assertEqual(
+            list(IPAddress.objects.none().available_intervals(
+                netaddr.IPAddress('192.0.2.1'), netaddr.IPAddress('192.0.2.3')
+            )),
+            [(netaddr.IPAddress('192.0.2.1'), netaddr.IPAddress('192.0.2.3'))],
+        )
+
+    def test_available_intervals_inverted_bounds(self):
+        """
+        Tests that an inverted bounds pair yields nothing.
+        """
+        self.assertEqual(
+            list(IPAddress.objects.available_intervals(
+                netaddr.IPAddress('192.0.2.10'), netaddr.IPAddress('192.0.2.5')
+            )),
+            [],
+        )
+
+    def test_available_intervals_fully_excluded(self):
+        """
+        Tests that a span covered by an excluded interval yields nothing.
+        """
+        interval = (netaddr.IPAddress('192.0.2.0'), netaddr.IPAddress('192.0.2.20'))
+        self.assertEqual(
+            list(IPAddress.objects.available_intervals(
+                netaddr.IPAddress('192.0.2.1'), netaddr.IPAddress('192.0.2.10'), exclude_intervals=[interval]
+            )),
+            [],
+        )
+
+    def test_available_intervals_mixed_family_exclude(self):
+        """
+        Tests that an exclude interval spanning address families is ignored.
+        """
+        interval = (netaddr.IPAddress('192.0.2.5'), netaddr.IPAddress('2001:db8::5'))
+        self.assertEqual(
+            list(IPAddress.objects.available_intervals(
+                netaddr.IPAddress('192.0.2.1'), netaddr.IPAddress('192.0.2.10'), exclude_intervals=[interval]
+            )),
+            [(netaddr.IPAddress('192.0.2.3'), netaddr.IPAddress('192.0.2.10'))],
+        )
+
+    def test_available_intervals_invalid_batch_size(self):
+        """
+        Tests that a non-positive batch size raises ValueError.
+        """
+        intervals = IPAddress.objects.available_intervals(
+            netaddr.IPAddress('192.0.2.1'), netaddr.IPAddress('192.0.2.10'), batch_size=0
+        )
+        with self.assertRaises(ValueError):
+            next(intervals)
+
+    def test_available_intervals_first_interval_single_query(self):
+        """
+        Tests that consuming only the first interval issues a single batch query.
+        """
+        IPAddress.objects.bulk_create((
+            IPAddress(address=IPNetwork('192.0.2.12/24')),
+            IPAddress(address=IPNetwork('192.0.2.14/24')),
+            IPAddress(address=IPNetwork('192.0.2.16/24')),
+        ))
+
+        intervals = IPAddress.objects.available_intervals(
+            netaddr.IPAddress('192.0.2.10'), netaddr.IPAddress('192.0.2.20'), batch_size=1
+        )
+
+        with self.assertNumQueries(1):
+            self.assertEqual(
+                next(intervals),
+                (netaddr.IPAddress('192.0.2.10'), netaddr.IPAddress('192.0.2.11')),
+            )
+
+    def test_available_intervals_unsorted_exclude_intervals(self):
+        """
+        Tests that unsorted, overlapping exclude intervals are normalized internally.
+        """
+        intervals = list(IPAddress.objects.none().available_intervals(
+            netaddr.IPAddress('192.0.2.1'),
+            netaddr.IPAddress('192.0.2.40'),
+            exclude_intervals=[
+                (netaddr.IPAddress('192.0.2.20'), netaddr.IPAddress('192.0.2.30')),
+                (netaddr.IPAddress('192.0.2.1'), netaddr.IPAddress('192.0.2.10')),
+                (netaddr.IPAddress('192.0.2.25'), netaddr.IPAddress('192.0.2.30')),
+            ],
+        ))
+
+        self.assertEqual(intervals, [
+            (netaddr.IPAddress('192.0.2.11'), netaddr.IPAddress('192.0.2.19')),
+            (netaddr.IPAddress('192.0.2.31'), netaddr.IPAddress('192.0.2.40')),
+        ])
+
+    def test_available_intervals_batching(self):
+        """
+        Tests that gaps spanning multiple fetch batches are yielded completely and in order.
+        """
+        IPAddress.objects.bulk_create(
+            IPAddress(address=IPNetwork(f'192.0.3.{i}/24')) for i in range(2, 82, 2)
+        )
+        expected = [
+            (netaddr.IPAddress(f'192.0.3.{i}'), netaddr.IPAddress(f'192.0.3.{i}'))
+            for i in range(1, 83, 2)
+        ]
+        self.assertEqual(
+            list(IPAddress.objects.available_intervals(
+                netaddr.IPAddress('192.0.3.1'), netaddr.IPAddress('192.0.3.81'), batch_size=8
+            )),
+            expected,
+        )
+
+    def test_iter_distinct_hosts_stops_at_upper_bound(self):
+        """
+        Tests that batch resumption stops once the last fetched host reaches the upper bound.
+        """
+        IPAddress.objects.bulk_create(
+            IPAddress(address=IPNetwork(f'192.0.4.{i}/24')) for i in (2, 4)
+        )
+        self.assertEqual(
+            list(IPAddress.objects.all()._iter_distinct_hosts(
+                netaddr.IPAddress('192.0.4.2'), netaddr.IPAddress('192.0.4.4'), batch_size=1
+            )),
+            [netaddr.IPAddress('192.0.4.2'), netaddr.IPAddress('192.0.4.4')],
+        )
+
+    def test_available_intervals_batch_size_one(self):
+        """
+        Tests that fetching one host per batch still terminates and yields every gap.
+        """
+        IPAddress.objects.bulk_create(
+            IPAddress(address=IPNetwork(f'192.0.3.{i}/24')) for i in (2, 3, 5)
+        )
+        self.assertEqual(
+            list(IPAddress.objects.available_intervals(
+                netaddr.IPAddress('192.0.3.1'), netaddr.IPAddress('192.0.3.6'), batch_size=1
+            )),
+            [
+                (netaddr.IPAddress('192.0.3.1'), netaddr.IPAddress('192.0.3.1')),
+                (netaddr.IPAddress('192.0.3.4'), netaddr.IPAddress('192.0.3.4')),
+                (netaddr.IPAddress('192.0.3.6'), netaddr.IPAddress('192.0.3.6')),
+            ],
+        )
+
+
+class IPRangeQuerySetTestCase(TestCase):
+    @classmethod
+    def setUpTestData(cls):
+        IPRange.objects.bulk_create((
+            IPRange(start_address=IPNetwork('192.0.2.10/24'), end_address=IPNetwork('192.0.2.19/24'), size=10),
+            IPRange(start_address=IPNetwork('192.0.2.15/24'), end_address=IPNetwork('192.0.2.24/24'), size=10),
+            IPRange(start_address=IPNetwork('192.0.2.40/24'), end_address=IPNetwork('192.0.2.49/24'), size=10),
+        ))
+
+    def test_get_intervals_merges_overlaps(self):
+        """
+        Tests that overlapping ranges merge and disjoint ranges stay separate.
+        """
+        self.assertEqual(
+            IPRange.objects.get_intervals(),
+            [
+                (netaddr.IPAddress('192.0.2.10'), netaddr.IPAddress('192.0.2.24')),
+                (netaddr.IPAddress('192.0.2.40'), netaddr.IPAddress('192.0.2.49')),
+            ],
+        )
+
+    def test_get_intervals_clips_to_bounds(self):
+        """
+        Tests that ranges are clipped to the bounds and out-of-bounds ranges are dropped.
+        """
+        self.assertEqual(
+            IPRange.objects.get_intervals(netaddr.IPAddress('192.0.2.20'), netaddr.IPAddress('192.0.2.30')),
+            [(netaddr.IPAddress('192.0.2.20'), netaddr.IPAddress('192.0.2.24'))],
+        )
+
+    def test_get_intervals_drops_ranges_below_bounds(self):
+        """
+        Tests that ranges entirely below the lower bound are dropped.
+        """
+        self.assertEqual(
+            IPRange.objects.get_intervals(netaddr.IPAddress('192.0.2.30'), netaddr.IPAddress('192.0.2.60')),
+            [(netaddr.IPAddress('192.0.2.40'), netaddr.IPAddress('192.0.2.49'))],
+        )
+
+    def test_get_intervals_drops_ranges_above_bounds(self):
+        """
+        Tests that ranges entirely above the upper bound are dropped.
+        """
+        self.assertEqual(
+            IPRange.objects.get_intervals(netaddr.IPAddress('192.0.2.0'), netaddr.IPAddress('192.0.2.30')),
+            [(netaddr.IPAddress('192.0.2.10'), netaddr.IPAddress('192.0.2.24'))],
+        )
+
+    def test_get_intervals_clips_to_upper_bound(self):
+        """
+        Tests that a range straddling the upper bound is clipped to it.
+        """
+        self.assertEqual(
+            IPRange.objects.get_intervals(netaddr.IPAddress('192.0.2.0'), netaddr.IPAddress('192.0.2.15')),
+            [(netaddr.IPAddress('192.0.2.10'), netaddr.IPAddress('192.0.2.15'))],
+        )
+
+    def test_get_intervals_mixed_families(self):
+        """
+        Tests that int-adjacent intervals of different address families are not merged.
+        """
+        IPRange.objects.bulk_create((
+            IPRange(
+                start_address=IPNetwork('255.255.255.254/32'),
+                end_address=IPNetwork('255.255.255.255/32'),
+                size=2,
+            ),
+            IPRange(start_address=IPNetwork('::1/128'), end_address=IPNetwork('::2/128'), size=2),
+        ))
+
+        self.assertEqual(
+            IPRange.objects.get_intervals(),
+            [
+                (netaddr.IPAddress('192.0.2.10'), netaddr.IPAddress('192.0.2.24')),
+                (netaddr.IPAddress('192.0.2.40'), netaddr.IPAddress('192.0.2.49')),
+                (netaddr.IPAddress('255.255.255.254'), netaddr.IPAddress('255.255.255.255')),
+                (netaddr.IPAddress('::1'), netaddr.IPAddress('::2')),
+            ],
+        )
+
+    def test_get_intervals_ipv6(self):
+        """
+        Tests that IPv6 ranges merge and clip by host address.
+        """
+        IPRange.objects.create(
+            start_address=IPNetwork('2001:db8::10/64'),
+            end_address=IPNetwork('2001:db8::1f/64'),
+        )
+        IPRange.objects.create(
+            start_address=IPNetwork('2001:db8::18/64'),
+            end_address=IPNetwork('2001:db8::2f/64'),
+        )
+
+        self.assertEqual(
+            IPRange.objects.get_intervals(netaddr.IPAddress('2001:db8::'), netaddr.IPAddress('2001:db8::ffff')),
+            [(netaddr.IPAddress('2001:db8::10'), netaddr.IPAddress('2001:db8::2f'))],
+        )

+ 475 - 0
netbox/ipam/tests/test_views.py

@@ -1,6 +1,8 @@
 import datetime
 import datetime
 
 
 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.test import RequestFactory
 from django.urls import reverse
 from django.urls import reverse
 from netaddr import IPNetwork
 from netaddr import IPNetwork
 
 
@@ -8,8 +10,10 @@ from core.choices import ObjectChangeActionChoices
 from core.models import ObjectChange, ObjectType
 from core.models import ObjectChange, ObjectType
 from dcim.constants import InterfaceTypeChoices
 from dcim.constants import InterfaceTypeChoices
 from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Site
 from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Site
+from extras.models import SavedFilter
 from ipam.choices import *
 from ipam.choices import *
 from ipam.models import *
 from ipam.models import *
+from ipam.views import AggregatePrefixesView
 from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
 from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from users.models import ObjectPermission
 from users.models import ObjectPermission
@@ -353,6 +357,101 @@ class AggregateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         url = reverse('ipam:aggregate_prefixes', kwargs={'pk': aggregate.pk})
         url = reverse('ipam:aggregate_prefixes', kwargs={'pk': aggregate.pk})
         self.assertHttpStatus(self.client.get(url), 200)
         self.assertHttpStatus(self.client.get(url), 200)
 
 
+    def test_aggregate_prefixes_filter_suppresses_available_prefixes(self):
+        self.add_permissions('ipam.view_aggregate', 'ipam.view_prefix')
+
+        tenants = (
+            Tenant(name='Aggregate Tenant 1', slug='aggregate-tenant-1'),
+            Tenant(name='Aggregate Tenant 2', slug='aggregate-tenant-2'),
+        )
+        Tenant.objects.bulk_create(tenants)
+
+        aggregate = Aggregate.objects.create(
+            prefix=IPNetwork('203.0.113.0/24'),
+            rir=RIR.objects.first()
+        )
+        prefixes = (
+            Prefix(prefix=IPNetwork('203.0.113.0/26'), tenant=tenants[0]),
+            Prefix(prefix=IPNetwork('203.0.113.64/26'), tenant=tenants[1]),
+        )
+        Prefix.objects.bulk_create(prefixes)
+
+        url = reverse('ipam:aggregate_prefixes', kwargs={'pk': aggregate.pk})
+        response = self.client.get(url, {'tenant_id': tenants[0].pk})
+
+        self.assertHttpStatus(response, 200)
+        self.assertEqual(len(response.context['table'].data), 1)
+        self.assertContains(response, '203.0.113.0/26')
+        self.assertNotContains(response, '203.0.113.64/26')
+
+    def test_aggregate_prefixes_saved_filter(self):
+        self.add_permissions('ipam.view_aggregate', 'ipam.view_prefix')
+
+        tenants = (
+            Tenant(name='Aggregate Saved Tenant 1', slug='aggregate-saved-tenant-1'),
+            Tenant(name='Aggregate Saved Tenant 2', slug='aggregate-saved-tenant-2'),
+        )
+        Tenant.objects.bulk_create(tenants)
+
+        aggregate = Aggregate.objects.create(
+            prefix=IPNetwork('203.0.114.0/24'),
+            rir=RIR.objects.first()
+        )
+        prefixes = (
+            Prefix(prefix=IPNetwork('203.0.114.0/26'), tenant=tenants[0]),
+            Prefix(prefix=IPNetwork('203.0.114.64/26'), tenant=tenants[1]),
+        )
+        Prefix.objects.bulk_create(prefixes)
+
+        saved_filter = SavedFilter.objects.create(
+            name='Aggregate Tenant 1 prefixes',
+            slug='aggregate-tenant-1-prefixes',
+            parameters={
+                'tenant_id': [str(tenants[0].pk)],
+            },
+        )
+        saved_filter.object_types.add(ObjectType.objects.get_for_model(Prefix))
+
+        url = reverse('ipam:aggregate_prefixes', kwargs={'pk': aggregate.pk})
+        response = self.client.get(url, {'filter_id': saved_filter.pk})
+
+        self.assertHttpStatus(response, 200)
+        self.assertEqual(len(response.context['table'].data), 1)
+        self.assertContains(response, '203.0.114.0/26')
+        self.assertNotContains(response, '203.0.114.64/26')
+
+    def test_children_are_filtered_fallback(self):
+        """_children_are_filtered() rebuilds the queryset when prep_table_data() has not cached a result."""
+        self.add_permissions('ipam.view_aggregate', 'ipam.view_prefix')
+
+        aggregate = Aggregate.objects.create(
+            prefix=IPNetwork('203.0.115.0/24'),
+            rir=RIR.objects.first()
+        )
+        tenant = Tenant.objects.create(name='Aggregate Fallback Tenant', slug='aggregate-fallback-tenant')
+        Prefix.objects.create(prefix=IPNetwork('203.0.115.0/26'), tenant=tenant)
+        Prefix.objects.create(prefix=IPNetwork('203.0.115.64/26'))
+
+        # No cached value: the fallback path rebuilds the filtered queryset and detects the filter.
+        view = AggregatePrefixesView()
+        request = RequestFactory().get('/', {'tenant_id': tenant.pk})
+        request.user = self.user
+        self.assertFalse(hasattr(view, '_child_queryset_is_filtered'))
+        self.assertTrue(view._children_are_filtered(request, aggregate))
+
+        # No cached value and no filter: the fallback path reports no filtering.
+        view = AggregatePrefixesView()
+        request = RequestFactory().get('/')
+        request.user = self.user
+        self.assertFalse(view._children_are_filtered(request, aggregate))
+
+        # A cached value takes precedence over the actual request state.
+        view = AggregatePrefixesView()
+        view._set_children_filtered(False)
+        request = RequestFactory().get('/', {'tenant_id': tenant.pk})
+        request.user = self.user
+        self.assertFalse(view._children_are_filtered(request, aggregate))
+
 
 
 class RoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
 class RoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
     model = Role
     model = Role
@@ -588,6 +687,63 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         url = reverse('ipam:prefix_prefixes', kwargs={'pk': prefixes[0].pk})
         url = reverse('ipam:prefix_prefixes', kwargs={'pk': prefixes[0].pk})
         self.assertHttpStatus(self.client.get(url), 200)
         self.assertHttpStatus(self.client.get(url), 200)
 
 
+    def test_prefix_prefixes_filter_suppresses_available_prefixes(self):
+        self.add_permissions('ipam.view_prefix')
+
+        tenants = (
+            Tenant(name='Prefix Tenant 1', slug='prefix-tenant-1'),
+            Tenant(name='Prefix Tenant 2', slug='prefix-tenant-2'),
+        )
+        Tenant.objects.bulk_create(tenants)
+
+        parent = Prefix.objects.create(prefix=IPNetwork('198.51.100.0/24'))
+        prefixes = (
+            Prefix(prefix=IPNetwork('198.51.100.0/26'), tenant=tenants[0]),
+            Prefix(prefix=IPNetwork('198.51.100.64/26'), tenant=tenants[1]),
+        )
+        Prefix.objects.bulk_create(prefixes)
+
+        url = reverse('ipam:prefix_prefixes', kwargs={'pk': parent.pk})
+        response = self.client.get(url, {'tenant_id': tenants[0].pk})
+
+        self.assertHttpStatus(response, 200)
+        self.assertEqual(len(response.context['table'].data), 1)
+        self.assertContains(response, '198.51.100.0/26')
+        self.assertNotContains(response, '198.51.100.64/26')
+
+    def test_prefix_prefixes_saved_filter_suppresses_available_prefixes(self):
+        self.add_permissions('ipam.view_prefix')
+
+        tenants = (
+            Tenant(name='Prefix Saved Tenant 1', slug='prefix-saved-tenant-1'),
+            Tenant(name='Prefix Saved Tenant 2', slug='prefix-saved-tenant-2'),
+        )
+        Tenant.objects.bulk_create(tenants)
+
+        parent = Prefix.objects.create(prefix=IPNetwork('198.51.101.0/24'))
+        prefixes = (
+            Prefix(prefix=IPNetwork('198.51.101.0/26'), tenant=tenants[0]),
+            Prefix(prefix=IPNetwork('198.51.101.64/26'), tenant=tenants[1]),
+        )
+        Prefix.objects.bulk_create(prefixes)
+
+        saved_filter = SavedFilter.objects.create(
+            name='Prefix Tenant 1 prefixes',
+            slug='prefix-tenant-1-prefixes',
+            parameters={
+                'tenant_id': [str(tenants[0].pk)],
+            },
+        )
+        saved_filter.object_types.add(ObjectType.objects.get_for_model(Prefix))
+
+        url = reverse('ipam:prefix_prefixes', kwargs={'pk': parent.pk})
+        response = self.client.get(url, {'filter_id': saved_filter.pk})
+
+        self.assertHttpStatus(response, 200)
+        self.assertEqual(len(response.context['table'].data), 1)
+        self.assertContains(response, '198.51.101.0/26')
+        self.assertNotContains(response, '198.51.101.64/26')
+
     def test_prefix_ipranges(self):
     def test_prefix_ipranges(self):
         self.add_permissions('ipam.view_prefix', 'ipam.view_iprange')
         self.add_permissions('ipam.view_prefix', 'ipam.view_iprange')
         prefix = Prefix.objects.create(prefix=IPNetwork('192.168.0.0/16'))
         prefix = Prefix.objects.create(prefix=IPNetwork('192.168.0.0/16'))
@@ -616,6 +772,89 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         url = reverse('ipam:prefix_ipaddresses', kwargs={'pk': prefix.pk})
         url = reverse('ipam:prefix_ipaddresses', kwargs={'pk': prefix.pk})
         self.assertHttpStatus(self.client.get(url), 200)
         self.assertHttpStatus(self.client.get(url), 200)
 
 
+    def test_prefix_ipaddresses_filter(self):
+        self.add_permissions('ipam.view_prefix', 'ipam.view_ipaddress', 'ipam.view_iprange')
+
+        tenants = (
+            Tenant(name='IP Address Tenant 1', slug='ip-address-tenant-1'),
+            Tenant(name='IP Address Tenant 2', slug='ip-address-tenant-2'),
+        )
+        Tenant.objects.bulk_create(tenants)
+
+        prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/24'))
+        ip_addresses = (
+            IPAddress(address=IPNetwork('192.0.2.1/24'), tenant=tenants[0]),
+            IPAddress(address=IPNetwork('192.0.2.2/24'), tenant=tenants[1]),
+        )
+        IPAddress.objects.bulk_create(ip_addresses)
+
+        url = reverse('ipam:prefix_ipaddresses', kwargs={'pk': prefix.pk})
+        response = self.client.get(url, {'tenant_id': tenants[0].pk})
+
+        self.assertHttpStatus(response, 200)
+        self.assertEqual(len(response.context['table'].data), 1)
+        self.assertContains(response, '192.0.2.1/24')
+        self.assertNotContains(response, '192.0.2.2/24')
+
+    def test_prefix_ipaddresses_saved_filter(self):
+        self.add_permissions('ipam.view_prefix', 'ipam.view_ipaddress', 'ipam.view_iprange')
+
+        tenants = (
+            Tenant(name='Saved Filter Tenant 1', slug='saved-filter-tenant-1'),
+            Tenant(name='Saved Filter Tenant 2', slug='saved-filter-tenant-2'),
+        )
+        Tenant.objects.bulk_create(tenants)
+
+        prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/24'))
+        ip_addresses = (
+            IPAddress(address=IPNetwork('192.0.2.1/24'), tenant=tenants[0]),
+            IPAddress(address=IPNetwork('192.0.2.2/24'), tenant=tenants[1]),
+        )
+        IPAddress.objects.bulk_create(ip_addresses)
+
+        saved_filter = SavedFilter.objects.create(
+            name='Tenant 1 IP addresses',
+            slug='tenant-1-ip-addresses',
+            parameters={
+                'tenant_id': [str(tenants[0].pk)],
+            },
+        )
+        saved_filter.object_types.add(ObjectType.objects.get_for_model(IPAddress))
+
+        url = reverse('ipam:prefix_ipaddresses', kwargs={'pk': prefix.pk})
+        response = self.client.get(url, {'filter_id': saved_filter.pk})
+
+        self.assertHttpStatus(response, 200)
+        self.assertEqual(len(response.context['table'].data), 1)
+        self.assertContains(response, '192.0.2.1/24')
+        self.assertNotContains(response, '192.0.2.2/24')
+
+    def test_prefix_ipaddresses_unfiltered_shows_available_space(self):
+        """An unfiltered IP Addresses tab injects synthetic available-space rows."""
+        self.add_permissions('ipam.view_prefix', 'ipam.view_ipaddress', 'ipam.view_iprange')
+
+        prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/29'))
+        IPAddress.objects.create(address=IPNetwork('192.0.2.1/29'))
+
+        url = reverse('ipam:prefix_ipaddresses', kwargs={'pk': prefix.pk})
+        response = self.client.get(url)
+
+        self.assertHttpStatus(response, 200)
+        self.assertGreater(len(response.context['table'].data), 1)
+
+    def test_prefix_prefixes_unfiltered_shows_available_prefixes(self):
+        """An unfiltered Child Prefixes tab injects synthetic available-prefix rows."""
+        self.add_permissions('ipam.view_prefix')
+
+        parent = Prefix.objects.create(prefix=IPNetwork('198.51.102.0/24'))
+        Prefix.objects.create(prefix=IPNetwork('198.51.102.0/26'))
+
+        url = reverse('ipam:prefix_prefixes', kwargs={'pk': parent.pk})
+        response = self.client.get(url)
+
+        self.assertHttpStatus(response, 200)
+        self.assertGreater(len(response.context['table'].data), 1)
+
     def test_prefix_ipaddresses_with_single_address_range(self):
     def test_prefix_ipaddresses_with_single_address_range(self):
         self.add_permissions('ipam.view_prefix', 'ipam.view_ipaddress', 'ipam.view_iprange')
         self.add_permissions('ipam.view_prefix', 'ipam.view_ipaddress', 'ipam.view_iprange')
         # The IP Addresses tab annotates child IP addresses alongside any
         # The IP Addresses tab annotates child IP addresses alongside any
@@ -1130,6 +1369,70 @@ class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             'description': 'New description',
             'description': 'New description',
         }
         }
 
 
+    def test_vlans_filter_suppresses_available_vlans(self):
+        self.add_permissions('ipam.view_vlangroup', 'ipam.view_vlan')
+
+        group = VLANGroup.objects.create(
+            name='Filtered VLAN Group',
+            slug='filtered-vlan-group'
+        )
+        vlans = (
+            VLAN(group=group, vid=100, name='VLAN100'),
+            VLAN(group=group, vid=200, name='VLAN200'),
+        )
+        VLAN.objects.bulk_create(vlans)
+
+        url = reverse('ipam:vlangroup_vlans', kwargs={'pk': group.pk})
+        response = self.client.get(url, {'vid': 100})
+
+        self.assertHttpStatus(response, 200)
+        self.assertEqual(len(response.context['table'].data), 1)
+        self.assertContains(response, 'VLAN100')
+        self.assertNotContains(response, 'VLAN200')
+
+    def test_vlans_saved_filter_suppresses_available_vlans(self):
+        self.add_permissions('ipam.view_vlangroup', 'ipam.view_vlan')
+
+        group = VLANGroup.objects.create(
+            name='Saved Filter VLAN Group',
+            slug='saved-filter-vlan-group'
+        )
+        vlans = (
+            VLAN(group=group, vid=100, name='VLAN100'),
+            VLAN(group=group, vid=200, name='VLAN200'),
+        )
+        VLAN.objects.bulk_create(vlans)
+
+        saved_filter = SavedFilter.objects.create(
+            name='VLAN 100',
+            slug='vlan-100',
+            parameters={
+                'vid': ['100'],
+            },
+        )
+        saved_filter.object_types.add(ObjectType.objects.get_for_model(VLAN))
+
+        url = reverse('ipam:vlangroup_vlans', kwargs={'pk': group.pk})
+        response = self.client.get(url, {'filter_id': saved_filter.pk})
+
+        self.assertHttpStatus(response, 200)
+        self.assertEqual(len(response.context['table'].data), 1)
+        self.assertContains(response, 'VLAN100')
+        self.assertNotContains(response, 'VLAN200')
+
+    def test_vlans_unfiltered_shows_available_vlans(self):
+        """An unfiltered VLANs tab injects synthetic available-VLAN rows."""
+        self.add_permissions('ipam.view_vlangroup', 'ipam.view_vlan')
+
+        group = VLANGroup.objects.create(name='Unfiltered VLAN Group', slug='unfiltered-vlan-group')
+        VLAN.objects.create(group=group, vid=1, name='VLAN0001')
+
+        url = reverse('ipam:vlangroup_vlans', kwargs={'pk': group.pk})
+        response = self.client.get(url)
+
+        self.assertHttpStatus(response, 200)
+        self.assertGreater(len(response.context['table'].data), 1)
+
 
 
 class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = VLAN
     model = VLAN
@@ -1199,6 +1502,178 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'description': 'New description',
             'description': 'New description',
         }
         }
 
 
+    def test_bulk_add_vlans(self):
+        self.add_permissions('ipam.add_vlan')
+
+        group = VLANGroup.objects.get(name='VLAN Group 1')
+        initial_count = VLAN.objects.count()
+        expected_vids = (110, 120, 121, 122)
+
+        form_data = {
+            'pattern': '110,120-122',
+            'group': group.pk,
+            'name': 'Pool-{vid}',
+            'status': VLANStatusChoices.STATUS_RESERVED,
+        }
+
+        response = self.client.post(reverse('ipam:vlan_bulk_add'), form_data)
+
+        self.assertHttpStatus(response, 302)
+        self.assertEqual(VLAN.objects.count(), initial_count + len(expected_vids))
+
+        for vid in expected_vids:
+            self.assertTrue(
+                VLAN.objects.filter(
+                    group=group,
+                    vid=vid,
+                    name=f'Pool-{vid}'
+                ).exists()
+            )
+
+    def test_bulk_add_vlans_rolls_back_on_duplicate_name(self):
+        self.add_permissions('ipam.add_vlan')
+
+        group = VLANGroup.objects.get(name='VLAN Group 1')
+        initial_count = VLAN.objects.count()
+
+        form_data = {
+            'pattern': '110-112',
+            'group': group.pk,
+            'name': 'Duplicate name',
+            'status': VLANStatusChoices.STATUS_RESERVED,
+        }
+
+        response = self.client.post(reverse('ipam:vlan_bulk_add'), form_data)
+
+        self.assertHttpStatus(response, 200)
+        self.assertEqual(VLAN.objects.count(), initial_count)
+        self.assertFalse(VLAN.objects.filter(group=group, vid=110).exists())
+
+    def test_bulk_add_vlans_rolls_back_when_any_id_outside_group_range(self):
+        self.add_permissions('ipam.add_vlan')
+
+        group = VLANGroup.objects.create(
+            name='Restricted VLAN Group',
+            slug='restricted-vlan-group',
+            vid_ranges=[NumericRange(200, 204)]  # Valid VIDs: 200-203
+        )
+        initial_count = VLAN.objects.count()
+
+        form_data = {
+            'pattern': '200-203,500',
+            'group': group.pk,
+            'name': 'Restricted-{vid}',
+            'status': VLANStatusChoices.STATUS_RESERVED,
+        }
+
+        response = self.client.post(reverse('ipam:vlan_bulk_add'), form_data)
+
+        self.assertHttpStatus(response, 200)
+        self.assertEqual(VLAN.objects.count(), initial_count)
+        self.assertFalse(VLAN.objects.filter(group=group, vid=200).exists())
+        self.assertFalse(VLAN.objects.filter(group=group, vid=203).exists())
+        self.assertFalse(VLAN.objects.filter(group=group, vid=500).exists())
+
+    def test_bulk_add_vlans_pattern_shapes(self):
+        """Single values, multiple values, ranges, and combinations create the expected VLANs."""
+        self.add_permissions('ipam.add_vlan')
+        # 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.
+        cases = (
+            ('500', (500,), 'VLAN Group 1'),
+            ('5,10,20', (5, 10, 20), 'VLAN Group 1'),
+            ('600-605', tuple(range(600, 606)), 'VLAN Group 1'),
+            ('1,10-20,300-305', (1, *range(10, 21), *range(300, 306)), 'VLAN Group 2'),
+        )
+        for pattern, expected_vids, group_name in cases:
+            with self.subTest(pattern=pattern):
+                group = VLANGroup.objects.get(name=group_name)
+                initial_count = VLAN.objects.count()
+                form_data = {
+                    'pattern': pattern,
+                    'group': group.pk,
+                    'name': 'Pool-{vid}',
+                    'status': VLANStatusChoices.STATUS_ACTIVE,
+                }
+                response = self.client.post(reverse('ipam:vlan_bulk_add'), form_data)
+                self.assertHttpStatus(response, 302)
+                self.assertEqual(VLAN.objects.count(), initial_count + len(expected_vids))
+                for vid in expected_vids:
+                    self.assertTrue(VLAN.objects.filter(group=group, vid=vid, name=f'Pool-{vid}').exists())
+
+    def test_bulk_add_vlans_invalid_pattern(self):
+        """An invalid pattern re-renders the form with a pattern error and creates nothing."""
+        self.add_permissions('ipam.add_vlan')
+        initial_count = VLAN.objects.count()
+
+        for pattern in ('abc', '20-10', '0', '4095', '10-'):
+            with self.subTest(pattern=pattern):
+                form_data = {
+                    'pattern': pattern,
+                    'name': 'Pool-{vid}',
+                    'status': VLANStatusChoices.STATUS_ACTIVE,
+                }
+                response = self.client.post(reverse('ipam:vlan_bulk_add'), form_data)
+                self.assertHttpStatus(response, 200)
+                self.assertIn('pattern', response.context['form'].errors)
+                self.assertEqual(VLAN.objects.count(), initial_count)
+
+    def test_bulk_add_vlans_static_name_without_group(self):
+        """A static name (no {vid} placeholder) is permitted across VLANs not assigned to a group."""
+        self.add_permissions('ipam.add_vlan')
+        initial_count = VLAN.objects.count()
+
+        form_data = {
+            'pattern': '710-712',
+            'name': 'Same name',
+            'status': VLANStatusChoices.STATUS_ACTIVE,
+        }
+        response = self.client.post(reverse('ipam:vlan_bulk_add'), form_data)
+
+        self.assertHttpStatus(response, 302)
+        self.assertEqual(VLAN.objects.count(), initial_count + 3)
+        self.assertEqual(VLAN.objects.filter(name='Same name').count(), 3)
+
+    def test_bulk_add_vlans_rolls_back_on_constrained_permission(self):
+        """Bulk creation rolls back when a generated VLAN falls outside the user's add constraints."""
+        obj_perm = ObjectPermission(
+            name='Test permission',
+            actions=['add'],
+            constraints={'vid__lt': 120}
+        )
+        obj_perm.save()
+        obj_perm.users.add(self.user)
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(VLAN))
+
+        initial_count = VLAN.objects.count()
+        form_data = {
+            'pattern': '110,120-122',
+            'name': 'Pool-{vid}',
+            'status': VLANStatusChoices.STATUS_ACTIVE,
+        }
+        response = self.client.post(reverse('ipam:vlan_bulk_add'), form_data)
+
+        self.assertHttpStatus(response, 200)
+        self.assertEqual(VLAN.objects.count(), initial_count)
+        self.assertTrue(response.context['form'].non_field_errors())
+
+    def test_bulk_add_vlans_propagates_field_errors(self):
+        """A per-object validation error on a non-pattern field is reported on the bulk-create form."""
+        self.add_permissions('ipam.add_vlan')
+        initial_count = VLAN.objects.count()
+
+        form_data = {
+            'pattern': '800',
+            'name': 'Pool-{vid}',
+            'status': VLANStatusChoices.STATUS_ACTIVE,
+            'qinq_role': VLANQinQRoleChoices.ROLE_CUSTOMER,  # Requires an SVLAN
+        }
+        response = self.client.post(reverse('ipam:vlan_bulk_add'), form_data)
+
+        self.assertHttpStatus(response, 200)
+        self.assertEqual(VLAN.objects.count(), initial_count)
+        self.assertTrue(response.context['form'].non_field_errors())
+
 
 
 class VLANTranslationPolicyTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 class VLANTranslationPolicyTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = VLANTranslationPolicy
     model = VLANTranslationPolicy

+ 11 - 22
netbox/ipam/utils.py

@@ -1,10 +1,10 @@
 from dataclasses import dataclass
 from dataclasses import dataclass
 
 
 import netaddr
 import netaddr
+from django.apps import apps
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
 from .constants import *
 from .constants import *
-from .models import VLAN, Prefix
 
 
 __all__ = (
 __all__ = (
     'AvailableIPSpace',
     'AvailableIPSpace',
@@ -39,7 +39,7 @@ def add_requested_prefixes(parent, prefix_list, show_available=True, show_assign
     requested, create fake Prefix objects for all unallocated space within a prefix.
     requested, create fake Prefix objects for all unallocated space within a prefix.
 
 
     :param parent: Parent Prefix instance
     :param parent: Parent Prefix instance
-    :param prefix_list: Child prefixes list
+    :param prefix_list: Child prefixes list (or queryset)
     :param show_available: Include available prefixes.
     :param show_available: Include available prefixes.
     :param show_assigned: Show assigned prefixes.
     :param show_assigned: Show assigned prefixes.
     """
     """
@@ -47,6 +47,7 @@ def add_requested_prefixes(parent, prefix_list, show_available=True, show_assign
 
 
     # Add available prefixes to the table if requested
     # Add available prefixes to the table if requested
     if prefix_list and show_available:
     if prefix_list and show_available:
+        Prefix = apps.get_model('ipam', 'Prefix')
 
 
         # Find all unallocated space, add fake Prefix objects to child_prefixes.
         # Find all unallocated space, add fake Prefix objects to child_prefixes.
         # IMPORTANT: These are unsaved Prefix instances (pk=None). If this is ever changed to use
         # IMPORTANT: These are unsaved Prefix instances (pk=None). If this is ever changed to use
@@ -78,22 +79,7 @@ def annotate_ip_space(prefix):
     records = sorted(records, key=lambda x: x[0])
     records = sorted(records, key=lambda x: x[0])
 
 
     # Determine the first & last valid IP addresses in the prefix
     # Determine the first & last valid IP addresses in the prefix
-    if (
-        prefix.is_pool
-        or (prefix.family == 4 and prefix.mask_length >= 31)
-        or (prefix.family == 6 and prefix.mask_length >= 127)
-    ):
-        # Pool, IPv4 /31-/32 or IPv6 /127-/128 sets are fully usable
-        first_ip_in_prefix = netaddr.IPAddress(prefix.prefix.first)
-        last_ip_in_prefix = netaddr.IPAddress(prefix.prefix.last)
-    elif prefix.family == 4:
-        # Ignore the network and broadcast addresses for non-pool IPv4 prefixes larger than /31
-        first_ip_in_prefix = netaddr.IPAddress(prefix.prefix.first + 1)
-        last_ip_in_prefix = netaddr.IPAddress(prefix.prefix.last - 1)
-    else:
-        # For IPv6 prefixes, omit the Subnet-Router anycast address (RFC 4291)
-        first_ip_in_prefix = netaddr.IPAddress(prefix.prefix.first + 1)
-        last_ip_in_prefix = netaddr.IPAddress(prefix.prefix.last)
+    first_ip_in_prefix, last_ip_in_prefix = prefix.usable_ip_bounds
 
 
     if not records:
     if not records:
         return [
         return [
@@ -195,7 +181,7 @@ def add_available_vlans(vlans, vlan_group):
         new_vlans.extend(available_vlans_from_range(vlans, vlan_group, vid_range))
         new_vlans.extend(available_vlans_from_range(vlans, vlan_group, vid_range))
 
 
     vlans = list(vlans) + new_vlans
     vlans = list(vlans) + new_vlans
-    vlans.sort(key=lambda v: v.vid if type(v) is VLAN else v['vid'])
+    vlans.sort(key=lambda v: v['vid'] if isinstance(v, dict) else v.vid)
 
 
     return vlans
     return vlans
 
 
@@ -204,6 +190,9 @@ def rebuild_prefixes(vrf):
     """
     """
     Rebuild the prefix hierarchy for all prefixes in the specified VRF (or global table).
     Rebuild the prefix hierarchy for all prefixes in the specified VRF (or global table).
     """
     """
+    Prefix = apps.get_model('ipam', 'Prefix')
+    prefix_queryset = Prefix.objects.filter(vrf=vrf)
+
     def contains(parent, child):
     def contains(parent, child):
         return child in parent and child != parent
         return child in parent and child != parent
 
 
@@ -219,10 +208,10 @@ def rebuild_prefixes(vrf):
 
 
     stack = []
     stack = []
     update_queue = []
     update_queue = []
-    prefixes = Prefix.objects.filter(vrf=vrf).values('pk', 'prefix')
+    prefixes = prefix_queryset.order_by('prefix', 'pk').values('pk', 'prefix')
 
 
-    # Iterate through all Prefixes in the VRF, growing and shrinking the stack as we go
-    for i, p in enumerate(prefixes):
+    # Iterate through all Prefixes in the table, growing and shrinking the stack as we go
+    for p in prefixes:
 
 
         # Grow the stack if this is a child of the most recent prefix
         # Grow the stack if this is a child of the most recent prefix
         if not stack or contains(stack[-1]['prefix'], p['prefix']):
         if not stack or contains(stack[-1]['prefix'], p['prefix']):

+ 95 - 10
netbox/ipam/views.py

@@ -547,8 +547,64 @@ class AggregateView(generic.ObjectView):
     )
     )
 
 
 
 
+class ChildAvailabilityMixin:
+    """
+    Mixin for ObjectChildrenView subclasses that render synthetic "available" rows
+    (available IP space, prefixes, or VLANs) and must suppress them when the child
+    queryset has been narrowed by a direct or saved filter.
+    """
+
+    @staticmethod
+    def _where_signature(queryset):
+        # query.where is Django-internal, but it is the closest signal for "narrowed by a filter".
+        return str(queryset.query.where)
+
+    def _set_children_filtered(self, is_filtered):
+        self._child_queryset_is_filtered = is_filtered
+        return is_filtered
+
+    def _queryset_is_filtered(self, request, queryset, parent):
+        """
+        Return True if the filtered child queryset differs from the unfiltered one.
+
+        Compares WHERE clauses rather than testing queryset.query.where for truthiness,
+        because child querysets are already scoped to their parent object and carry WHERE
+        clauses before any user filter is applied. The result is cached on the view instance
+        so get_extra_context() can reuse it without rebuilding the queryset.
+        """
+        if self.filterset is None:
+            return self._set_children_filtered(False)
+
+        unfiltered = self.get_children(request, parent)
+
+        return self._set_children_filtered(
+            self._where_signature(queryset) != self._where_signature(unfiltered)
+        )
+
+    def _children_are_filtered(self, request, parent):
+        """
+        Return whether child objects are filtered.
+
+        In the normal ObjectChildrenView flow prep_table_data() runs first and caches the
+        result, so this returns the cached value. Fall back to rebuilding the queryset for
+        direct calls where prep_table_data() has not run.
+        """
+        if hasattr(self, '_child_queryset_is_filtered'):
+            return self._child_queryset_is_filtered
+
+        if self.filterset is None:
+            return self._set_children_filtered(False)
+
+        unfiltered = self.get_children(request, parent)
+        filtered = self.filterset(request.GET, unfiltered, request=request).qs
+
+        return self._set_children_filtered(
+            self._where_signature(filtered) != self._where_signature(unfiltered)
+        )
+
+
 @register_model_view(Aggregate, 'prefixes')
 @register_model_view(Aggregate, 'prefixes')
-class AggregatePrefixesView(generic.ObjectChildrenView):
+class AggregatePrefixesView(ChildAvailabilityMixin, generic.ObjectChildrenView):
     queryset = Aggregate.objects.all()
     queryset = Aggregate.objects.all()
     child_model = Prefix
     child_model = Prefix
     table = tables.PrefixTable
     table = tables.PrefixTable
@@ -572,13 +628,21 @@ class AggregatePrefixesView(generic.ObjectChildrenView):
         show_available = bool(request.GET.get('show_available', 'true') == 'true')
         show_available = bool(request.GET.get('show_available', 'true') == 'true')
         show_assigned = bool(request.GET.get('show_assigned', 'true') == 'true')
         show_assigned = bool(request.GET.get('show_assigned', 'true') == 'true')
 
 
+        if self._queryset_is_filtered(request, queryset, parent):
+            show_available = False
+
         return add_requested_prefixes(parent.prefix, queryset, show_available, show_assigned)
         return add_requested_prefixes(parent.prefix, queryset, show_available, show_assigned)
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
+        show_available = (
+            bool(request.GET.get('show_available', 'true') == 'true') and
+            not self._children_are_filtered(request, instance)
+        )
+
         return {
         return {
             'bulk_querystring': f'within={instance.prefix}',
             'bulk_querystring': f'within={instance.prefix}',
             'first_available_prefix': instance.get_first_available_prefix(),
             'first_available_prefix': instance.get_first_available_prefix(),
-            'show_available': bool(request.GET.get('show_available', 'true') == 'true'),
+            'show_available': show_available,
             'show_assigned': bool(request.GET.get('show_assigned', 'true') == 'true'),
             'show_assigned': bool(request.GET.get('show_assigned', 'true') == 'true'),
         }
         }
 
 
@@ -770,7 +834,7 @@ class PrefixView(generic.ObjectView):
 
 
 
 
 @register_model_view(Prefix, 'prefixes')
 @register_model_view(Prefix, 'prefixes')
-class PrefixPrefixesView(generic.ObjectChildrenView):
+class PrefixPrefixesView(ChildAvailabilityMixin, generic.ObjectChildrenView):
     queryset = Prefix.objects.all()
     queryset = Prefix.objects.all()
     child_model = Prefix
     child_model = Prefix
     table = tables.PrefixTable
     table = tables.PrefixTable
@@ -794,13 +858,21 @@ class PrefixPrefixesView(generic.ObjectChildrenView):
         show_available = bool(request.GET.get('show_available', 'true') == 'true')
         show_available = bool(request.GET.get('show_available', 'true') == 'true')
         show_assigned = bool(request.GET.get('show_assigned', 'true') == 'true')
         show_assigned = bool(request.GET.get('show_assigned', 'true') == 'true')
 
 
+        if self._queryset_is_filtered(request, queryset, parent):
+            show_available = False
+
         return add_requested_prefixes(parent.prefix, queryset, show_available, show_assigned)
         return add_requested_prefixes(parent.prefix, queryset, show_available, show_assigned)
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
+        show_available = (
+            bool(request.GET.get('show_available', 'true') == 'true') and
+            not self._children_are_filtered(request, instance)
+        )
+
         return {
         return {
             'bulk_querystring': f"vrf_id={instance.vrf.pk if instance.vrf else '0'}&within={instance.prefix}",
             'bulk_querystring': f"vrf_id={instance.vrf.pk if instance.vrf else '0'}&within={instance.prefix}",
             'first_available_prefix': instance.get_first_available_prefix(),
             'first_available_prefix': instance.get_first_available_prefix(),
-            'show_available': bool(request.GET.get('show_available', 'true') == 'true'),
+            'show_available': show_available,
             'show_assigned': bool(request.GET.get('show_assigned', 'true') == 'true'),
             'show_assigned': bool(request.GET.get('show_assigned', 'true') == 'true'),
         }
         }
 
 
@@ -833,7 +905,7 @@ class PrefixIPRangesView(generic.ObjectChildrenView):
 
 
 
 
 @register_model_view(Prefix, 'ipaddresses', path='ip-addresses')
 @register_model_view(Prefix, 'ipaddresses', path='ip-addresses')
-class PrefixIPAddressesView(generic.ObjectChildrenView):
+class PrefixIPAddressesView(ChildAvailabilityMixin, generic.ObjectChildrenView):
     queryset = Prefix.objects.all()
     queryset = Prefix.objects.all()
     child_model = IPAddress
     child_model = IPAddress
     table = tables.AnnotatedIPAddressTable
     table = tables.AnnotatedIPAddressTable
@@ -851,9 +923,10 @@ class PrefixIPAddressesView(generic.ObjectChildrenView):
         return parent.get_child_ips().restrict(request.user, 'view').prefetch_related('vrf', 'tenant', 'tenant__group')
         return parent.get_child_ips().restrict(request.user, 'view').prefetch_related('vrf', 'tenant', 'tenant__group')
 
 
     def prep_table_data(self, request, queryset, parent):
     def prep_table_data(self, request, queryset, parent):
-        if not request.GET.get('q') and not get_table_ordering(request, self.table):
+        if not self._queryset_is_filtered(request, queryset, parent) and not get_table_ordering(request, self.table):
             return annotate_ip_space(parent)
             return annotate_ip_space(parent)
-        return queryset
+
+        return super().prep_table_data(request, queryset, parent)
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         return {
         return {
@@ -1292,7 +1365,7 @@ class VLANGroupBulkDeleteView(generic.BulkDeleteView):
 
 
 
 
 @register_model_view(VLANGroup, 'vlans')
 @register_model_view(VLANGroup, 'vlans')
-class VLANGroupVLANsView(generic.ObjectChildrenView):
+class VLANGroupVLANsView(ChildAvailabilityMixin, generic.ObjectChildrenView):
     queryset = VLANGroup.objects.all()
     queryset = VLANGroup.objects.all()
     child_model = VLAN
     child_model = VLAN
     table = tables.VLANTable
     table = tables.VLANTable
@@ -1312,9 +1385,11 @@ class VLANGroupVLANsView(generic.ObjectChildrenView):
         )
         )
 
 
     def prep_table_data(self, request, queryset, parent):
     def prep_table_data(self, request, queryset, parent):
-        if not get_table_ordering(request, self.table):
+        # Skip synthetic available rows under active filters: filtered-out VLANs would otherwise look available.
+        if not self._queryset_is_filtered(request, queryset, parent) and not get_table_ordering(request, self.table):
             return add_available_vlans(queryset, parent)
             return add_available_vlans(queryset, parent)
-        return queryset
+
+        return super().prep_table_data(request, queryset, parent)
 
 
 
 
 #
 #
@@ -1718,6 +1793,16 @@ class VLANDeleteView(generic.ObjectDeleteView):
     queryset = VLAN.objects.all()
     queryset = VLAN.objects.all()
 
 
 
 
+@register_model_view(VLAN, 'bulk_add', path='bulk-add', detail=False)
+class VLANBulkCreateView(generic.BulkCreateView):
+    queryset = VLAN.objects.all()
+    form = forms.VLANIDBulkCreateForm
+    model_form = forms.VLANBulkAddForm
+    pattern_target = 'vid'
+    pattern_template_fields = ('name',)
+    template_name = 'ipam/vlan_bulk_add.html'
+
+
 @register_model_view(VLAN, 'bulk_import', path='import', detail=False)
 @register_model_view(VLAN, 'bulk_import', path='import', detail=False)
 class VLANBulkImportView(generic.BulkImportView):
 class VLANBulkImportView(generic.BulkImportView):
     queryset = VLAN.objects.all()
     queryset = VLAN.objects.all()

+ 68 - 0
netbox/netbox/api/serializers/bulk.py

@@ -1,11 +1,79 @@
+import copy
+import functools
+
 from rest_framework import serializers
 from rest_framework import serializers
 
 
 from .features import ChangeLogMessageSerializer
 from .features import ChangeLogMessageSerializer
 
 
 __all__ = (
 __all__ = (
     'BulkOperationSerializer',
     'BulkOperationSerializer',
+    'BulkPartialUpdateSchemaMixin',
+    'BulkUpdateSchemaMixin',
+    'get_bulk_update_serializer_class'
 )
 )
 
 
 
 
 class BulkOperationSerializer(ChangeLogMessageSerializer):
 class BulkOperationSerializer(ChangeLogMessageSerializer):
     id = serializers.IntegerField()
     id = serializers.IntegerField()
+
+
+class BulkUpdateSchemaMixin:
+    def get_fields(self):
+        fields = super().get_fields()
+        # Reuse the runtime bulk-operation ID field so the schema stays in sync
+        # with the validator that consumes `id` before model serialization.
+        _id = copy.deepcopy(BulkOperationSerializer().fields['id'])
+        _id.required = True
+        fields['id'] = _id
+
+        return fields
+
+
+class BulkPartialUpdateSchemaMixin(BulkUpdateSchemaMixin):
+    def get_fields(self):
+        fields = super().get_fields()
+
+        for name, field in fields.items():
+            if name != 'id':
+                field.required = False
+
+        return fields
+
+
+@functools.cache
+def get_bulk_update_serializer_class(serializer_class, *, partial=False):
+    """
+    Return a schema-only serializer for bulk PUT/PATCH requests.
+
+    Bulk update requests to a list endpoint require each object to include
+    the target object's numeric ID, even though `id` is read-only on the
+    normal model serializer. The runtime code consumes `id` before invoking
+    the model serializer for each object.
+    """
+
+    meta = getattr(serializer_class, 'Meta')
+
+    if meta.fields == '__all__':
+        fields = '__all__'
+    else:
+        fields = ('id', *[f for f in meta.fields if f != 'id'])
+
+    class Meta(meta):
+        pass
+
+    # intentional; this is different than setting fields = fields within class Meta above
+    Meta.fields = fields
+
+    bases = (
+        (BulkPartialUpdateSchemaMixin, serializer_class)
+        if partial
+        else (BulkUpdateSchemaMixin, serializer_class)
+    )
+
+    attrs = {
+        'Meta': Meta,
+        '__module__': serializer_class.__module__,
+    }
+
+    prefix = 'PatchedBulk' if partial else 'Bulk'
+    return type(f'{prefix}{serializer_class.__name__}', bases, attrs)

+ 15 - 0
netbox/netbox/api/viewsets/mixins.py

@@ -10,6 +10,7 @@ from rest_framework.reverse import reverse
 from core.models import ObjectType
 from core.models import ObjectType
 from extras.models import ExportTemplate
 from extras.models import ExportTemplate
 from netbox.api.serializers import BulkOperationSerializer
 from netbox.api.serializers import BulkOperationSerializer
+from netbox.api.serializers.bulk import get_bulk_update_serializer_class
 from netbox.jobs import AsyncAPIJob
 from netbox.jobs import AsyncAPIJob
 from utilities.exceptions import RQWorkerNotRunningException
 from utilities.exceptions import RQWorkerNotRunningException
 from utilities.rqworker import any_workers_for_queue
 from utilities.rqworker import any_workers_for_queue
@@ -240,6 +241,20 @@ class BulkUpdateModelMixin:
 
 
         return updated_pks
         return updated_pks
 
 
+    def get_bulk_update_serializer_class(self, *, partial=False):
+        return get_bulk_update_serializer_class(
+                self.get_serializer_class(),
+                partial=partial,
+            )
+
+    def get_bulk_update_request_serializer(self, *, partial=False):
+        serializer_class = self.get_bulk_update_serializer_class(partial=partial)
+
+        # Important: do NOT pass partial=True here. The partial schema class already
+        # makes non-id fields optional, and passing partial=True would also make id
+        # appear optional in OpenAPI.
+        return serializer_class(many=True)
+
     def bulk_partial_update(self, request, *args, **kwargs):
     def bulk_partial_update(self, request, *args, **kwargs):
         kwargs['partial'] = True
         kwargs['partial'] = True
         return self.bulk_update(request, *args, **kwargs)
         return self.bulk_update(request, *args, **kwargs)

+ 114 - 9
netbox/netbox/graphql/filter_lookups.py

@@ -1,3 +1,4 @@
+import re
 from enum import Enum
 from enum import Enum
 from typing import Generic, TypeVar
 from typing import Generic, TypeVar
 
 
@@ -11,17 +12,48 @@ from strawberry.directive import DirectiveValue
 from strawberry.types import Info
 from strawberry.types import Info
 from strawberry_django import (
 from strawberry_django import (
     ComparisonFilterLookup,
     ComparisonFilterLookup,
-    DateFilterLookup,
-    DatetimeFilterLookup,
     FilterLookup,
     FilterLookup,
     RangeLookup,
     RangeLookup,
-    StrFilterLookup,
-    TimeFilterLookup,
     process_filters,
     process_filters,
 )
 )
 
 
 from netbox.graphql.scalars import BigInt
 from netbox.graphql.scalars import BigInt
 
 
+# ------------------------------------------------------------------
+# JSON path validation (VM-323)
+# ------------------------------------------------------------------
+
+# Each segment of a JSON path may only contain alphanumerics, underscores, and
+# hyphens.  Hyphens are included because JSON keys commonly use them; leading
+# underscores are permitted (e.g. _foo is a valid key name).
+_JSON_PATH_SEGMENT_RE = re.compile(r'^[A-Za-z0-9_][A-Za-z0-9_-]*$')
+
+
+def _validate_json_path(path: str) -> str:
+    """Validate a JSON traversal path for use in ORM lookups.
+
+    Each ``__``-separated segment must match ``[A-Za-z0-9_][A-Za-z0-9_-]*``.
+    Raises ``ValueError`` on an empty path, empty segment, or segment with
+    disallowed characters.
+
+    ORM operator names (``date``, ``regex``, etc.) are intentionally *not*
+    blocked here: ``JSONFilter.filter()`` always appends ``__`` to the path
+    before handing it to ``process_filters``, so a segment named ``regex``
+    becomes another level of JSON key traversal (``data__key__regex__exact``),
+    not the ORM regex transform (``data__key__regex=…``).
+    """
+    if not path:
+        raise ValueError("JSON path cannot be empty")
+
+    for segment in path.split('__'):
+        if not segment:
+            raise ValueError("JSON path contains consecutive or trailing '__'")
+        if not _JSON_PATH_SEGMENT_RE.match(segment):
+            raise ValueError(f"Invalid JSON path segment: {segment!r}")
+
+    return path
+
+
 __all__ = (
 __all__ = (
     'ArrayLookup',
     'ArrayLookup',
     'BigIntegerLookup',
     'BigIntegerLookup',
@@ -31,6 +63,8 @@ __all__ = (
     'IntegerLookup',
     'IntegerLookup',
     'IntegerRangeArrayLookup',
     'IntegerRangeArrayLookup',
     'JSONFilter',
     'JSONFilter',
+    'JSONLookup',
+    'JSONStringLookup',
     'StringArrayLookup',
     'StringArrayLookup',
     'TreeNodeFilter',
     'TreeNodeFilter',
 )
 )
@@ -39,16 +73,82 @@ T = TypeVar('T')
 SKIP_MSG = 'Filter will be skipped on `null` value'
 SKIP_MSG = 'Filter will be skipped on `null` value'
 
 
 
 
+# These JSON lookup types intentionally mirror the legacy DateFilterLookup[str],
+# TimeFilterLookup[str], and DatetimeFilterLookup[str] schema. JSON values are
+# string-backed, so the concrete strawberry-django date/time lookup classes
+# (which now ignore type parameters and warn) are deliberately not used here.
+@strawberry.input(name='StrDateFilterLookup')
+class JSONDateFilterLookup(ComparisonFilterLookup[str]):
+    year: ComparisonFilterLookup[int] | None = strawberry.UNSET
+    month: ComparisonFilterLookup[int] | None = strawberry.UNSET
+    day: ComparisonFilterLookup[int] | None = strawberry.UNSET
+    week_day: ComparisonFilterLookup[int] | None = strawberry.UNSET
+    iso_week_day: ComparisonFilterLookup[int] | None = strawberry.UNSET
+    week: ComparisonFilterLookup[int] | None = strawberry.UNSET
+    iso_year: ComparisonFilterLookup[int] | None = strawberry.UNSET
+    quarter: ComparisonFilterLookup[int] | None = strawberry.UNSET
+
+
+@strawberry.input(name='StrTimeFilterLookup')
+class JSONTimeFilterLookup(ComparisonFilterLookup[str]):
+    hour: ComparisonFilterLookup[int] | None = strawberry.UNSET
+    minute: ComparisonFilterLookup[int] | None = strawberry.UNSET
+    second: ComparisonFilterLookup[int] | None = strawberry.UNSET
+    date: ComparisonFilterLookup[int] | None = strawberry.UNSET
+    time: ComparisonFilterLookup[int] | None = strawberry.UNSET
+
+
+@strawberry.input(name='StrDatetimeFilterLookup')
+class JSONDatetimeFilterLookup(ComparisonFilterLookup[str]):
+    year: ComparisonFilterLookup[int] | None = strawberry.UNSET
+    month: ComparisonFilterLookup[int] | None = strawberry.UNSET
+    day: ComparisonFilterLookup[int] | None = strawberry.UNSET
+    week_day: ComparisonFilterLookup[int] | None = strawberry.UNSET
+    iso_week_day: ComparisonFilterLookup[int] | None = strawberry.UNSET
+    week: ComparisonFilterLookup[int] | None = strawberry.UNSET
+    iso_year: ComparisonFilterLookup[int] | None = strawberry.UNSET
+    quarter: ComparisonFilterLookup[int] | None = strawberry.UNSET
+    hour: ComparisonFilterLookup[int] | None = strawberry.UNSET
+    minute: ComparisonFilterLookup[int] | None = strawberry.UNSET
+    second: ComparisonFilterLookup[int] | None = strawberry.UNSET
+    date: ComparisonFilterLookup[int] | None = strawberry.UNSET
+    time: ComparisonFilterLookup[int] | None = strawberry.UNSET
+
+
+@strawberry.input(description='String lookups for JSON field values.')
+class JSONStringLookup:
+    """
+    String-filter type for use inside JSONLookup.
+
+    Equivalent to ``StrFilterLookup`` but defined explicitly so that the type
+    name remains stable and any future per-field restrictions are easy to add.
+    ``regex`` / ``i_regex`` are included: they provide no additional oracle
+    power beyond ``starts_with``, which is also present.
+    """
+    exact: str | None = strawberry_django.filter_field()
+    i_exact: str | None = strawberry_django.filter_field()
+    contains: str | None = strawberry_django.filter_field()
+    i_contains: str | None = strawberry_django.filter_field()
+    starts_with: str | None = strawberry_django.filter_field()
+    i_starts_with: str | None = strawberry_django.filter_field()
+    ends_with: str | None = strawberry_django.filter_field()
+    i_ends_with: str | None = strawberry_django.filter_field()
+    in_: list[str] | None = strawberry_django.filter_field()
+    isnull: bool | None = strawberry_django.filter_field()
+    regex: str | None = strawberry_django.filter_field()
+    i_regex: str | None = strawberry_django.filter_field()
+
+
 @strawberry.input(one_of=True, description='Lookup for JSON field. Only one of the lookup fields can be set.')
 @strawberry.input(one_of=True, description='Lookup for JSON field. Only one of the lookup fields can be set.')
 class JSONLookup:
 class JSONLookup:
-    string_lookup: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    string_lookup: JSONStringLookup | None = strawberry_django.filter_field()
     int_range_lookup: RangeLookup[int] | None = strawberry_django.filter_field()
     int_range_lookup: RangeLookup[int] | None = strawberry_django.filter_field()
     int_comparison_lookup: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
     int_comparison_lookup: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
     float_range_lookup: RangeLookup[float] | None = strawberry_django.filter_field()
     float_range_lookup: RangeLookup[float] | None = strawberry_django.filter_field()
     float_comparison_lookup: ComparisonFilterLookup[float] | None = strawberry_django.filter_field()
     float_comparison_lookup: ComparisonFilterLookup[float] | None = strawberry_django.filter_field()
-    date_lookup: DateFilterLookup[str] | None = strawberry_django.filter_field()
-    datetime_lookup: DatetimeFilterLookup[str] | None = strawberry_django.filter_field()
-    time_lookup: TimeFilterLookup[str] | None = strawberry_django.filter_field()
+    date_lookup: JSONDateFilterLookup | None = strawberry_django.filter_field()
+    datetime_lookup: JSONDatetimeFilterLookup | None = strawberry_django.filter_field()
+    time_lookup: JSONTimeFilterLookup | None = strawberry_django.filter_field()
     boolean_lookup: FilterLookup[bool] | None = strawberry_django.filter_field()
     boolean_lookup: FilterLookup[bool] | None = strawberry_django.filter_field()
 
 
     def get_filter(self):
     def get_filter(self):
@@ -119,7 +219,12 @@ class JSONFilter:
         if not filters:
         if not filters:
             return queryset, Q()
             return queryset, Q()
 
 
-        json_path = f'{prefix}{self.path}__'
+        try:
+            safe_path = _validate_json_path(self.path)
+        except ValueError:
+            return queryset, Q()
+
+        json_path = f'{prefix}{safe_path}__'
         return process_filters(filters=filters, queryset=queryset, info=info, prefix=json_path)
         return process_filters(filters=filters, queryset=queryset, info=info, prefix=json_path)
 
 
 
 

+ 2 - 3
netbox/netbox/graphql/filter_mixins.py

@@ -1,5 +1,4 @@
 from dataclasses import dataclass
 from dataclasses import dataclass
-from datetime import datetime
 from typing import TYPE_CHECKING, Annotated, TypeVar
 from typing import TYPE_CHECKING, Annotated, TypeVar
 
 
 import strawberry
 import strawberry
@@ -48,9 +47,9 @@ class SyncedDataFilterMixin:
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
     data_file_id: FilterLookup[int] | None = strawberry_django.filter_field()
     data_file_id: FilterLookup[int] | None = strawberry_django.filter_field()
-    data_path: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    data_path: StrFilterLookup | None = strawberry_django.filter_field()
     auto_sync_enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
     auto_sync_enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
-    data_synced: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
+    data_synced: DatetimeFilterLookup | None = strawberry_django.filter_field()
 
 
 
 
 @dataclass
 @dataclass

+ 9 - 9
netbox/netbox/graphql/filters.py

@@ -42,21 +42,21 @@ class NetBoxModelFilter(
 
 
 @dataclass
 @dataclass
 class NestedGroupModelFilter(NetBoxModelFilter):
 class NestedGroupModelFilter(NetBoxModelFilter):
-    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    slug: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    description: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup | None = strawberry_django.filter_field()
+    slug: StrFilterLookup | None = strawberry_django.filter_field()
+    description: StrFilterLookup | None = strawberry_django.filter_field()
     parent_id: ID | None = strawberry_django.filter_field()
     parent_id: ID | None = strawberry_django.filter_field()
 
 
 
 
 @dataclass
 @dataclass
 class OrganizationalModelFilter(NetBoxModelFilter):
 class OrganizationalModelFilter(NetBoxModelFilter):
-    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    slug: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    description: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    comments: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    name: StrFilterLookup | None = strawberry_django.filter_field()
+    slug: StrFilterLookup | None = strawberry_django.filter_field()
+    description: StrFilterLookup | None = strawberry_django.filter_field()
+    comments: StrFilterLookup | None = strawberry_django.filter_field()
 
 
 
 
 @dataclass
 @dataclass
 class PrimaryModelFilter(NetBoxModelFilter):
 class PrimaryModelFilter(NetBoxModelFilter):
-    description: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    comments: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    description: StrFilterLookup | None = strawberry_django.filter_field()
+    comments: StrFilterLookup | None = strawberry_django.filter_field()

+ 12 - 6
netbox/netbox/graphql/schema.py

@@ -1,3 +1,5 @@
+from collections.abc import Callable
+
 import strawberry
 import strawberry
 from django.conf import settings
 from django.conf import settings
 from strawberry.extensions import MaxAliasesLimiter, QueryDepthLimiter, SchemaExtension
 from strawberry.extensions import MaxAliasesLimiter, QueryDepthLimiter, SchemaExtension
@@ -18,6 +20,8 @@ from wireless.graphql.schema import WirelessQuery
 
 
 from .scalars import BigInt, BigIntScalar
 from .scalars import BigInt, BigIntScalar
 
 
+SchemaExtensionFactory = type[SchemaExtension] | Callable[[], SchemaExtension]
+
 
 
 @strawberry.type
 @strawberry.type
 class Query(
 class Query(
@@ -36,14 +40,16 @@ class Query(
     pass
     pass
 
 
 
 
-def get_schema_extensions() -> list[SchemaExtension]:
-    extensions: list[SchemaExtension] = [
-        DjangoOptimizerExtension(prefetch_custom_queryset=True),
-        MaxAliasesLimiter(max_alias_count=settings.GRAPHQL_MAX_ALIASES),
-    ]
+def get_schema_extensions() -> list[SchemaExtensionFactory]:
+    max_aliases = settings.GRAPHQL_MAX_ALIASES
     max_depth = settings.GRAPHQL_MAX_QUERY_DEPTH
     max_depth = settings.GRAPHQL_MAX_QUERY_DEPTH
+
+    extensions: list[SchemaExtensionFactory] = [
+        lambda: DjangoOptimizerExtension(prefetch_custom_queryset=True),
+        lambda: MaxAliasesLimiter(max_alias_count=max_aliases),
+    ]
     if max_depth and max_depth > 0:
     if max_depth and max_depth > 0:
-        extensions.append(QueryDepthLimiter(max_depth=max_depth))
+        extensions.append(lambda: QueryDepthLimiter(max_depth=max_depth))
     return extensions
     return extensions
 
 
 
 

+ 13 - 0
netbox/netbox/middleware.py

@@ -10,7 +10,9 @@ from django.db import 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, HttpResponseRedirect
 from django.middleware.common import CommonMiddleware as DjangoCommonMiddleware
 from django.middleware.common import CommonMiddleware as DjangoCommonMiddleware
+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 netbox.config import clear_config, get_config
 from netbox.config import clear_config, get_config
 from netbox.metrics import Metrics
 from netbox.metrics import Metrics
@@ -26,6 +28,7 @@ __all__ = (
     'PrometheusAfterMiddleware',
     'PrometheusAfterMiddleware',
     'PrometheusBeforeMiddleware',
     'PrometheusBeforeMiddleware',
     'RemoteUserMiddleware',
     'RemoteUserMiddleware',
+    'SocialAuthExceptionMiddleware',
 )
 )
 
 
 
 
@@ -286,3 +289,13 @@ class MaintenanceModeMiddleware:
             messages.error(request, error_message)
             messages.error(request, error_message)
             return HttpResponseRedirect(request.path_info)
             return HttpResponseRedirect(request.path_info)
         return None
         return None
+
+
+class SocialAuthExceptionMiddleware(SocialAuthExceptionMiddleware_):
+    """
+    Subclass of python-social-auth's exception middleware which surfaces a generic, user-friendly
+    message rather than exposing the raw social_core exception text to (typically unauthenticated)
+    users when an SSO/SAML login fails.
+    """
+    def get_message(self, request, exception):
+        return _("Single sign-on failed. Please try again or contact your administrator.")

+ 9 - 5
netbox/netbox/models/deletion.py

@@ -3,6 +3,7 @@ import logging
 from django.contrib.contenttypes.fields import GenericRelation
 from django.contrib.contenttypes.fields import GenericRelation
 from django.db import router
 from django.db import router
 from django.db.models.deletion import CASCADE, Collector
 from django.db.models.deletion import CASCADE, Collector
+from django.utils.translation import gettext as _
 
 
 logger = logging.getLogger("netbox.models.deletion")
 logger = logging.getLogger("netbox.models.deletion")
 
 
@@ -45,7 +46,7 @@ class CustomCollector(Collector):
 
 
         # Add GenericRelations to the dependency graph
         # Add GenericRelations to the dependency graph
         processed_relations = set()
         processed_relations = set()
-        for _, instances in list(self.data.items()):
+        for _model, instances in list(self.data.items()):
             for instance in instances:
             for instance in instances:
                 # Get all GenericRelations for this model
                 # Get all GenericRelations for this model
                 for field in instance._meta.private_fields:
                 for field in instance._meta.private_fields:
@@ -70,10 +71,13 @@ class DeleteMixin:
         Override delete to use our custom collector.
         Override delete to use our custom collector.
         """
         """
         using = using or router.db_for_write(self.__class__, instance=self)
         using = using or router.db_for_write(self.__class__, instance=self)
-        assert self._get_pk_val() is not None, (
-            f"{self._meta.object_name} object can't be deleted because its "
-            f"{self._meta.pk.attname} attribute is set to None."
-        )
+        if self._get_pk_val() is None:
+            raise ValueError(
+                _("{object_name} object can't be deleted because its {pk_attname} attribute is set to None.").format(
+                    object_name=self._meta.object_name,
+                    pk_attname=self._meta.pk.attname,
+                )
+            )
 
 
         collector = CustomCollector(using=using)
         collector = CustomCollector(using=using)
         collector.collect([self], keep_parents=keep_parents)
         collector.collect([self], keep_parents=keep_parents)

+ 8 - 0
netbox/netbox/settings.py

@@ -514,6 +514,7 @@ MIDDLEWARE = [
     'netbox.middleware.RemoteUserMiddleware',
     'netbox.middleware.RemoteUserMiddleware',
     'netbox.middleware.CoreMiddleware',
     'netbox.middleware.CoreMiddleware',
     'netbox.middleware.MaintenanceModeMiddleware',
     'netbox.middleware.MaintenanceModeMiddleware',
+    'netbox.middleware.SocialAuthExceptionMiddleware',
 ]
 ]
 
 
 if DEBUG:
 if DEBUG:
@@ -713,6 +714,13 @@ SOCIAL_AUTH_PIPELINE = (
     'social_core.pipeline.user.user_details',
     'social_core.pipeline.user.user_details',
 )
 )
 
 
+# Redirect users back to the login page (surfacing the error via the messages framework) when an
+# SSO/SAML authentication failure occurs, rather than raising an HTTP 500. Full exceptions are still
+# raised when DEBUG is enabled. LOGIN_URL is an absolute path which respects BASE_PATH; the social
+# auth middleware passes this value directly to an HttpResponseRedirect without reversing it.
+SOCIAL_AUTH_LOGIN_ERROR_URL = LOGIN_URL
+SOCIAL_AUTH_RAISE_EXCEPTIONS = DEBUG
+
 # Load all SOCIAL_AUTH_* settings from the user configuration
 # Load all SOCIAL_AUTH_* settings from the user configuration
 for param in dir(configuration):
 for param in dir(configuration):
     if param.startswith('SOCIAL_AUTH_'):
     if param.startswith('SOCIAL_AUTH_'):

+ 57 - 1
netbox/netbox/tests/test_authentication.py

@@ -1,13 +1,16 @@
 import datetime
 import datetime
 
 
 from django.conf import settings
 from django.conf import settings
-from django.test import Client
+from django.contrib.messages.storage.fallback import FallbackStorage
+from django.test import Client, RequestFactory, SimpleTestCase
 from django.test.utils import override_settings
 from django.test.utils import override_settings
 from django.urls import reverse
 from django.urls import reverse
 from rest_framework.test import APIClient
 from rest_framework.test import APIClient
+from social_core.exceptions import AuthFailed
 
 
 from core.models import ObjectType
 from core.models import ObjectType
 from dcim.models import Rack, Site
 from dcim.models import Rack, Site
+from netbox.middleware import SocialAuthExceptionMiddleware
 from users.constants import TOKEN_PREFIX
 from users.constants import TOKEN_PREFIX
 from users.models import Group, ObjectPermission, Token, User
 from users.models import Group, ObjectPermission, Token, User
 from utilities.testing import TestCase
 from utilities.testing import TestCase
@@ -697,3 +700,56 @@ class ObjectPermissionAPIViewTestCase(TestCase):
         url = reverse('dcim-api:rack-detail', kwargs={'pk': self.racks[0].pk})
         url = reverse('dcim-api:rack-detail', kwargs={'pk': self.racks[0].pk})
         response = self.client.delete(url, format='json', **self.header)
         response = self.client.delete(url, format='json', **self.header)
         self.assertEqual(response.status_code, 204)
         self.assertEqual(response.status_code, 204)
+
+
+class SocialAuthExceptionMiddlewareTestCase(SimpleTestCase):
+    """
+    Verify that SSO/SAML authentication failures are surfaced as a login-page message rather than
+    bubbling up as an HTTP 500 (see #22346).
+    """
+    GENERIC_MESSAGE = "Single sign-on failed. Please try again or contact your administrator."
+
+    class FakeStrategy:
+        # Mirror social_core's DjangoStrategy.setting(), which reads SOCIAL_AUTH_<NAME> from Django
+        # settings. This ensures the test exercises the real configured values (e.g.
+        # SOCIAL_AUTH_LOGIN_ERROR_URL) rather than hardcoded stand-ins.
+        def setting(self, name, default=None, backend=None):
+            return getattr(settings, f'SOCIAL_AUTH_{name}', default)
+
+    class FakeBackend:
+        name = 'saml'
+
+    def setUp(self):
+        self.factory = RequestFactory()
+        self.middleware = SocialAuthExceptionMiddleware(lambda request: None)
+
+    def _make_request(self):
+        request = self.factory.get('/')
+        request.social_strategy = self.FakeStrategy()
+        request.backend = self.FakeBackend()
+        # Attach message storage (normally provided by MessageMiddleware)
+        setattr(request, 'session', {})
+        request._messages = FallbackStorage(request)
+        return request
+
+    def test_generic_message(self):
+        """
+        The raw exception text should never be surfaced to the user.
+        """
+        request = self._make_request()
+        exception = AuthFailed(self.FakeBackend(), 'raw internal SAML detail')
+        self.assertEqual(self.middleware.get_message(request, exception), self.GENERIC_MESSAGE)
+
+    def test_redirect_on_failure(self):
+        """
+        A SocialAuthBaseException should redirect to the login page with the generic message set.
+        """
+        request = self._make_request()
+        exception = AuthFailed(self.FakeBackend(), 'raw internal SAML detail')
+        response = self.middleware.process_exception(request, exception)
+
+        self.assertEqual(response.status_code, 302)
+        self.assertEqual(response.url, settings.SOCIAL_AUTH_LOGIN_ERROR_URL)
+        self.assertEqual(response.url, settings.LOGIN_URL)
+        messages = [str(m) for m in request._messages]
+        self.assertEqual(messages, [self.GENERIC_MESSAGE])

+ 195 - 7
netbox/netbox/tests/test_graphql.py

@@ -1,4 +1,5 @@
 import json
 import json
+import re
 
 
 import strawberry
 import strawberry
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
@@ -10,15 +11,18 @@ from strawberry.schema.config import StrawberryConfig
 
 
 from dcim.choices import LocationStatusChoices
 from dcim.choices import LocationStatusChoices
 from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Site, VirtualChassis
 from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Site, VirtualChassis
-from extras.models import TableConfig
+from extras.models import TableConfig, Tag
 from netbox.graphql.scalars import BigInt, BigIntScalar
 from netbox.graphql.scalars import BigInt, BigIntScalar
-from netbox.graphql.schema import Query, get_schema_extensions
+from netbox.graphql.schema import Query, get_schema_extensions, schema
 from utilities.tables import get_table_for_model
 from utilities.tables import get_table_for_model
 from utilities.testing import APITestCase, TestCase, disable_warnings
 from utilities.testing import APITestCase, TestCase, disable_warnings
 
 
 
 
 class GraphQLTestCase(TestCase):
 class GraphQLTestCase(TestCase):
 
 
+    def _schema_extension_instances(self):
+        return [factory() for factory in get_schema_extensions()]
+
     @override_settings(GRAPHQL_ENABLED=False)
     @override_settings(GRAPHQL_ENABLED=False)
     def test_graphql_enabled(self):
     def test_graphql_enabled(self):
         """
         """
@@ -32,21 +36,21 @@ class GraphQLTestCase(TestCase):
         """
         """
         QueryDepthLimiter should not be installed when GRAPHQL_MAX_QUERY_DEPTH is unset.
         QueryDepthLimiter should not be installed when GRAPHQL_MAX_QUERY_DEPTH is unset.
         """
         """
-        self.assertFalse(any(isinstance(ext, QueryDepthLimiter) for ext in get_schema_extensions()))
+        self.assertFalse(any(isinstance(ext, QueryDepthLimiter) for ext in self._schema_extension_instances()))
 
 
     @override_settings(GRAPHQL_MAX_QUERY_DEPTH=0)
     @override_settings(GRAPHQL_MAX_QUERY_DEPTH=0)
     def test_graphql_max_query_depth_disabled_when_zero(self):
     def test_graphql_max_query_depth_disabled_when_zero(self):
         """
         """
         QueryDepthLimiter should not be installed when GRAPHQL_MAX_QUERY_DEPTH is zero.
         QueryDepthLimiter should not be installed when GRAPHQL_MAX_QUERY_DEPTH is zero.
         """
         """
-        self.assertFalse(any(isinstance(ext, QueryDepthLimiter) for ext in get_schema_extensions()))
+        self.assertFalse(any(isinstance(ext, QueryDepthLimiter) for ext in self._schema_extension_instances()))
 
 
     @override_settings(GRAPHQL_MAX_QUERY_DEPTH=-1)
     @override_settings(GRAPHQL_MAX_QUERY_DEPTH=-1)
     def test_graphql_max_query_depth_disabled_when_negative(self):
     def test_graphql_max_query_depth_disabled_when_negative(self):
         """
         """
         QueryDepthLimiter should not be installed when GRAPHQL_MAX_QUERY_DEPTH is negative.
         QueryDepthLimiter should not be installed when GRAPHQL_MAX_QUERY_DEPTH is negative.
         """
         """
-        self.assertFalse(any(isinstance(ext, QueryDepthLimiter) for ext in get_schema_extensions()))
+        self.assertFalse(any(isinstance(ext, QueryDepthLimiter) for ext in self._schema_extension_instances()))
 
 
     @override_settings(GRAPHQL_MAX_QUERY_DEPTH=3)
     @override_settings(GRAPHQL_MAX_QUERY_DEPTH=3)
     def test_graphql_max_query_depth_enforced(self):
     def test_graphql_max_query_depth_enforced(self):
@@ -54,9 +58,9 @@ class GraphQLTestCase(TestCase):
         Queries exceeding GRAPHQL_MAX_QUERY_DEPTH should be rejected.
         Queries exceeding GRAPHQL_MAX_QUERY_DEPTH should be rejected.
         """
         """
         extensions = get_schema_extensions()
         extensions = get_schema_extensions()
-        self.assertTrue(any(isinstance(ext, QueryDepthLimiter) for ext in extensions))
+        self.assertTrue(any(isinstance(ext, QueryDepthLimiter) for ext in self._schema_extension_instances()))
 
 
-        # Build a temporary schema with the configured extensions and execute a deep query
+        # Build a temporary schema with the configured extension factories and execute a deep query
         test_schema = strawberry.Schema(
         test_schema = strawberry.Schema(
             query=Query,
             query=Query,
             config=StrawberryConfig(auto_camel_case=False, scalar_map={BigInt: BigIntScalar}),
             config=StrawberryConfig(auto_camel_case=False, scalar_map={BigInt: BigIntScalar}),
@@ -87,6 +91,30 @@ class GraphQLTestCase(TestCase):
         with disable_warnings('django.request'):
         with disable_warnings('django.request'):
             self.assertHttpStatus(response, 302)  # Redirect to login page
             self.assertHttpStatus(response, 302)  # Redirect to login page
 
 
+    def test_json_lookup_schema_is_string_backed(self):
+        """JSONLookup date/time lookups keep the legacy string-backed input types and fields."""
+        sdl = schema.as_str()
+
+        def input_block(name):
+            match = re.search(rf'^input {re.escape(name)}\b.*?^\}}', sdl, re.DOTALL | re.MULTILINE)
+            self.assertIsNotNone(match, f'{name} not found in schema')
+            return match.group(0)
+
+        # JSONLookup points at the legacy string-backed lookup type names
+        json_lookup = input_block('JSONLookup')
+        self.assertIn('date_lookup: StrDateFilterLookup', json_lookup)
+        self.assertIn('datetime_lookup: StrDatetimeFilterLookup', json_lookup)
+        self.assertIn('time_lookup: StrTimeFilterLookup', json_lookup)
+
+        # Value fields are string-backed, not Date/DateTime/Time scalars
+        self.assertIn('exact: String', input_block('StrDateFilterLookup'))
+
+        # Legacy date/time sub-lookups remain integer comparison lookups
+        for name in ('StrTimeFilterLookup', 'StrDatetimeFilterLookup'):
+            block = input_block(name)
+            self.assertIn('date: IntComparisonFilterLookup', block)
+            self.assertIn('time: IntComparisonFilterLookup', block)
+
 
 
 class GraphQLAPITestCase(APITestCase):
 class GraphQLAPITestCase(APITestCase):
 
 
@@ -185,6 +213,72 @@ class GraphQLAPITestCase(APITestCase):
         self.assertNotIn('errors', data)
         self.assertNotIn('errors', data)
         self.assertEqual(len(data['data']['site']['locations']), 0)
         self.assertEqual(len(data['data']['site']['locations']), 0)
 
 
+    @override_settings(LOGIN_REQUIRED=True)
+    def test_graphql_nested_filter_objects(self):
+        """
+        Test filtering of nested GraphQL object lists.
+        """
+        self.add_permissions('dcim.view_site', 'dcim.view_location', 'extras.view_tag')
+
+        site = Site.objects.create(
+            name='Nested Filter Site',
+            slug='nested-filter-site'
+        )
+
+        # Location is MPTT-managed; bulk_create skips tree-init hooks. Use per-instance create.
+        Location.objects.create(
+            site=site,
+            name='Nested Active 1',
+            slug='nested-active-1',
+            status=LocationStatusChoices.STATUS_ACTIVE,
+        )
+        Location.objects.create(
+            site=site,
+            name='Nested Active 2',
+            slug='nested-active-2',
+            status=LocationStatusChoices.STATUS_ACTIVE,
+        )
+        Location.objects.create(
+            site=site,
+            name='Nested Planned',
+            slug='nested-planned',
+            status=LocationStatusChoices.STATUS_PLANNED,
+        )
+
+        planned = Tag.objects.create(name='Planned', slug='planned')
+        production = Tag.objects.create(name='Production', slug='production')
+        staging = Tag.objects.create(name='Staging', slug='staging')
+        site.tags.add(planned, production, staging)
+
+        url = reverse('graphql')
+        query = f"""
+        {{
+          site(id: {site.pk}) {{
+            locations(filters: {{status: {{exact: STATUS_ACTIVE}}}}) {{
+              name
+            }}
+            tags(filters: {{name: {{i_starts_with: "P"}}}}) {{
+              name
+            }}
+          }}
+        }}
+        """
+
+        response = self.client.post(url, data={'query': query}, format="json", **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+
+        data = json.loads(response.content)
+        self.assertNotIn('errors', data)
+
+        self.assertEqual(
+            {location['name'] for location in data['data']['site']['locations']},
+            {'Nested Active 1', 'Nested Active 2'}
+        )
+        self.assertEqual(
+            {tag['name'] for tag in data['data']['site']['tags']},
+            {'Planned', 'Production'}
+        )
+
     def test_graphql_integer_range_lookup(self):
     def test_graphql_integer_range_lookup(self):
         """
         """
         Test that range_lookup works for integer fields (e.g. vc_position). Regression test for #20468.
         Test that range_lookup works for integer fields (e.g. vc_position). Regression test for #20468.
@@ -411,3 +505,97 @@ class GraphQLAPITestCase(APITestCase):
         data = json.loads(response.content)
         data = json.loads(response.content)
         self.assertIn('errors', data)
         self.assertIn('errors', data)
         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 JSONPathValidationTestCase(TestCase):
+    """Unit tests for _validate_json_path (VM-323 security fix)."""
+
+    def setUp(self):
+        from netbox.graphql.filter_lookups import _validate_json_path
+        self.validate = _validate_json_path
+
+    # --- Valid paths ---
+
+    def test_single_key(self):
+        self.assertEqual(self.validate('key'), 'key')
+
+    def test_nested_key(self):
+        self.assertEqual(self.validate('parent__child'), 'parent__child')
+
+    def test_deeply_nested(self):
+        self.assertEqual(self.validate('a__b__c'), 'a__b__c')
+
+    def test_key_with_underscores(self):
+        self.assertEqual(self.validate('my_key'), 'my_key')
+
+    def test_key_with_hyphens(self):
+        self.assertEqual(self.validate('my-key'), 'my-key')
+
+    def test_numeric_array_index(self):
+        self.assertEqual(self.validate('items__0'), 'items__0')
+
+    def test_alphanumeric_segment(self):
+        self.assertEqual(self.validate('key123'), 'key123')
+
+    def test_key_with_leading_underscore(self):
+        # JSON keys may start with underscore (e.g. _foo)
+        self.assertEqual(self.validate('_key'), '_key')
+
+    def test_orm_operator_name_as_key(self):
+        # 'date', 'regex' etc. are valid JSON key names; the path validator
+        # must not block them.  The ORM injection risk is neutralised by the
+        # trailing __ that JSONFilter always appends before process_filters.
+        self.assertEqual(self.validate('date'), 'date')
+        self.assertEqual(self.validate('key__regex'), 'key__regex')
+        self.assertEqual(self.validate('key__exact'), 'key__exact')
+
+    # --- Invalid paths ---
+
+    def test_rejects_empty_string(self):
+        with self.assertRaises(ValueError):
+            self.validate('')
+
+    def test_rejects_all_underscores(self):
+        # '___' splits into segments ['', '', ''] via '__' — empty segments rejected
+        with self.assertRaises(ValueError):
+            self.validate('___')
+
+    def test_accepts_trailing_single_underscore(self):
+        # A single trailing underscore is a valid JSON key character
+        self.assertEqual(self.validate('key_'), 'key_')
+
+    def test_rejects_trailing_double_underscore(self):
+        with self.assertRaises(ValueError):
+            self.validate('key__')
+
+    def test_rejects_leading_double_underscore(self):
+        with self.assertRaises(ValueError):
+            self.validate('__key')
+
+    def test_rejects_consecutive_double_underscores(self):
+        with self.assertRaises(ValueError):
+            self.validate('key1____key2')
+
+    def test_rejects_segment_starting_with_special_char(self):
+        with self.assertRaises(ValueError):
+            self.validate('$secret')
+
+    def test_rejects_path_with_spaces(self):
+        with self.assertRaises(ValueError):
+            self.validate('key one')
+
+    def test_rejects_path_with_dot(self):
+        with self.assertRaises(ValueError):
+            self.validate('key.subkey')
+
+
+class JSONStringLookupTestCase(TestCase):
+    """Verify JSONStringLookup exposes the expected set of string operators."""
+
+    def test_string_operators_present(self):
+        from netbox.graphql.filter_lookups import JSONStringLookup
+        field_names = {f.name for f in JSONStringLookup.__strawberry_definition__.fields}
+        for expected in ('exact', 'i_exact', 'contains', 'i_contains',
+                         'starts_with', 'i_starts_with', 'ends_with', 'i_ends_with',
+                         'in_', 'isnull', 'regex', 'i_regex'):
+            self.assertIn(expected, field_names, f"{expected!r} must be present on JSONStringLookup")

+ 10 - 0
netbox/netbox/tests/test_models.py

@@ -4,6 +4,7 @@ from django.conf import settings
 from django.test import TestCase
 from django.test import TestCase
 
 
 from core.models import ObjectChange
 from core.models import ObjectChange
+from dcim.models import Site
 from netbox.tests.dummy_plugin.models import DummyNetBoxModel
 from netbox.tests.dummy_plugin.models import DummyNetBoxModel
 
 
 
 
@@ -21,3 +22,12 @@ class ModelTestCase(TestCase):
         m.pk = 123
         m.pk = 123
 
 
         self.assertEqual(m.get_absolute_url(), f'/plugins/dummy-plugin/netboxmodel/{m.pk}/')
         self.assertEqual(m.get_absolute_url(), f'/plugins/dummy-plugin/netboxmodel/{m.pk}/')
+
+
+class DeleteMixinTestCase(TestCase):
+
+    def test_delete_unsaved_instance_raises_value_error(self):
+        """Deleting an instance with no primary key raises ValueError."""
+        site = Site(name='Site 1', slug='site-1')
+        with self.assertRaises(ValueError):
+            site.delete()

+ 24 - 0
netbox/netbox/tests/test_ui.py

@@ -3,6 +3,7 @@ from types import SimpleNamespace
 
 
 from django.template import Context, Template
 from django.template import Context, Template
 from django.test import RequestFactory, SimpleTestCase, TestCase
 from django.test import RequestFactory, SimpleTestCase, TestCase
+from netaddr import IPNetwork
 
 
 from circuits.choices import CircuitStatusChoices, VirtualCircuitTerminationRoleChoices
 from circuits.choices import CircuitStatusChoices, VirtualCircuitTerminationRoleChoices
 from circuits.models import (
 from circuits.models import (
@@ -250,6 +251,29 @@ class TextAttrTestCase(TestCase):
         self.assertTrue(context['copy_button'])
         self.assertTrue(context['copy_button'])
 
 
 
 
+class ArrayAttrTestCase(TestCase):
+
+    def test_get_value(self):
+        attr = attrs.ArrayAttr('allowed_ips')
+        obj = SimpleNamespace(allowed_ips=[IPNetwork('192.168.1.1/32'), IPNetwork('2001:db8::/64')])
+        self.assertEqual(attr.get_value(obj), '192.168.1.1/32, 2001:db8::/64')
+
+    def test_get_value_empty(self):
+        attr = attrs.ArrayAttr('allowed_ips')
+        obj = SimpleNamespace(allowed_ips=[])
+        self.assertIsNone(attr.get_value(obj))
+
+    def test_get_value_none(self):
+        attr = attrs.ArrayAttr('allowed_ips')
+        obj = SimpleNamespace(allowed_ips=None)
+        self.assertIsNone(attr.get_value(obj))
+
+    def test_get_value_with_format_string(self):
+        attr = attrs.ArrayAttr('ports', format_string='{}/tcp')
+        obj = SimpleNamespace(ports=[80, 443])
+        self.assertEqual(attr.get_value(obj), '80/tcp, 443/tcp')
+
+
 class NumericAttrTestCase(TestCase):
 class NumericAttrTestCase(TestCase):
 
 
     def test_get_context_with_unit_accessor(self):
     def test_get_context_with_unit_accessor(self):

+ 68 - 1
netbox/netbox/tests/test_views.py

@@ -1,9 +1,13 @@
 import urllib.parse
 import urllib.parse
+from unittest.mock import patch
 
 
+from django.contrib.contenttypes.models import ContentType
+from django.http import HttpResponse
 from django.test import Client, override_settings
 from django.test import Client, override_settings
 from django.urls import reverse
 from django.urls import reverse
 
 
-from dcim.models import Site
+from dcim.models import DeviceType, Manufacturer, Site
+from extras.models import ImageAttachment
 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 utilities.testing import TestCase
 from utilities.testing import TestCase
@@ -78,6 +82,27 @@ class SearchViewTestCase(TestCase):
 
 
 class MediaViewTestCase(TestCase):
 class MediaViewTestCase(TestCase):
 
 
+    @classmethod
+    def setUpTestData(cls):
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        ct = ContentType.objects.get_for_model(Site)
+        cls.image_attachment = ImageAttachment.objects.create(
+            object_type=ct,
+            object_id=site.pk,
+            name='Test Image',
+            image='image-attachments/site_1_test.jpg',
+            image_height=100,
+            image_width=100,
+        )
+
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        cls.device_type = DeviceType.objects.create(
+            model='Device Type 1',
+            slug='device-type-1',
+            manufacturer=manufacturer,
+            front_image='devicetype-images/front.jpg',
+        )
+
     def test_media_login_required(self):
     def test_media_login_required(self):
         url = reverse('media', kwargs={'path': 'foo.txt'})
         url = reverse('media', kwargs={'path': 'foo.txt'})
         response = Client().get(url)
         response = Client().get(url)
@@ -92,3 +117,45 @@ class MediaViewTestCase(TestCase):
 
 
         # Unauthenticated request should return a 404 (not found)
         # Unauthenticated request should return a 404 (not found)
         self.assertHttpStatus(response, 404)
         self.assertHttpStatus(response, 404)
+
+    def test_image_attachment_with_permission(self):
+        self.add_permissions('extras.view_imageattachment')
+        url = reverse('media', kwargs={'path': self.image_attachment.image.name})
+        with patch('netbox.views.misc.serve', return_value=HttpResponse(status=200)):
+            response = self.client.get(url)
+        self.assertHttpStatus(response, 200)
+        self.assertEqual(response['Content-Disposition'], 'attachment')
+        self.assertEqual(response['X-Content-Type-Options'], 'nosniff')
+
+    def test_image_attachment_without_permission(self):
+        url = reverse('media', kwargs={'path': self.image_attachment.image.name})
+        response = self.client.get(url)
+        self.assertHttpStatus(response, 404)
+
+    def test_image_attachment_traversal_without_permission(self):
+        # A traversal path that normalizes to a protected directory must still be denied.
+        traversal_path = 'foo/../' + self.image_attachment.image.name
+        url = reverse('media', kwargs={'path': traversal_path})
+        response = self.client.get(url)
+        self.assertHttpStatus(response, 404)
+
+    def test_device_type_with_permission(self):
+        self.add_permissions('dcim.view_devicetype')
+        url = reverse('media', kwargs={'path': self.device_type.front_image.name})
+        with patch('netbox.views.misc.serve', return_value=HttpResponse(status=200)):
+            response = self.client.get(url)
+        self.assertHttpStatus(response, 200)
+        self.assertEqual(response['Content-Disposition'], 'attachment')
+        self.assertEqual(response['X-Content-Type-Options'], 'nosniff')
+
+    def test_device_type_without_permission(self):
+        url = reverse('media', kwargs={'path': self.device_type.front_image.name})
+        response = self.client.get(url)
+        self.assertHttpStatus(response, 404)
+
+    def test_device_type_traversal_without_permission(self):
+        # A traversal path that normalizes to a protected directory must still be denied.
+        traversal_path = 'foo/../' + self.device_type.front_image.name
+        url = reverse('media', kwargs={'path': traversal_path})
+        response = self.client.get(url)
+        self.assertHttpStatus(response, 404)

+ 17 - 0
netbox/netbox/ui/attrs.py

@@ -8,6 +8,7 @@ from utilities.data import resolve_attr_path
 
 
 __all__ = (
 __all__ = (
     'AddressAttr',
     'AddressAttr',
+    'ArrayAttr',
     'BooleanAttr',
     'BooleanAttr',
     'ChoiceAttr',
     'ChoiceAttr',
     'ColorAttr',
     'ColorAttr',
@@ -141,6 +142,22 @@ class TextAttr(ObjectAttribute):
         }
         }
 
 
 
 
+class ArrayAttr(TextAttr):
+    """
+    An attribute comprising an array of values, rendered as a comma-separated list. If specified, `format_string`
+    is applied to each item individually. Null and empty arrays are treated as equivalent: both render as the
+    placeholder.
+    """
+
+    def get_value(self, obj):
+        value = resolve_attr_path(obj, self.accessor)
+        if not value:
+            return None
+        if self.format_string:
+            return ', '.join(self.format_string.format(v) for v in value)
+        return ', '.join(str(v) for v in value)
+
+
 class NumericAttr(ObjectAttribute):
 class NumericAttr(ObjectAttribute):
     """
     """
     An integer or float attribute.
     An integer or float attribute.

+ 99 - 14
netbox/netbox/views/generic/bulk_views.py

@@ -8,7 +8,7 @@ from types import SimpleNamespace
 from django.conf import settings
 from django.conf import settings
 from django.contrib import messages
 from django.contrib import messages
 from django.contrib.contenttypes.fields import GenericForeignKey, GenericRel
 from django.contrib.contenttypes.fields import GenericForeignKey, GenericRel
-from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError
+from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured, ObjectDoesNotExist, ValidationError
 from django.db import IntegrityError, router, transaction
 from django.db import IntegrityError, router, transaction
 from django.db.models import ManyToManyField, ProtectedError, RestrictedError
 from django.db.models import ManyToManyField, ProtectedError, RestrictedError
 from django.db.models.fields.reverse_related import ManyToManyRel
 from django.db.models.fields.reverse_related import ManyToManyRel
@@ -27,7 +27,7 @@ from netbox.forms.bulk_rename import NetBoxModelBulkRenameForm
 from netbox.models.features import ChangeLoggingMixin
 from netbox.models.features import ChangeLoggingMixin
 from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport, BulkRename
 from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport, BulkRename
 from utilities.error_handlers import handle_protectederror
 from utilities.error_handlers import handle_protectederror
-from utilities.exceptions import AbortRequest, PermissionsViolation
+from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation
 from utilities.export import TableExport, stream_table_csv_response
 from utilities.export import TableExport, stream_table_csv_response
 from utilities.forms import BulkDeleteForm, BulkRenameForm, restrict_form_fields
 from utilities.forms import BulkDeleteForm, BulkRenameForm, restrict_form_fields
 from utilities.forms.bulk_import import BulkImportForm
 from utilities.forms.bulk_import import BulkImportForm
@@ -210,7 +210,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
             if request.GET['export'] == 'table':
             if request.GET['export'] == 'table':
                 table = self.get_table(self.queryset, request, has_table_actions)
                 table = self.get_table(self.queryset, request, has_table_actions)
                 columns = [name for name, _ in table.selected_columns]
                 columns = [name for name, _ in table.selected_columns]
-                delimiter = request.user.config.get('csv_delimiter')
+                delimiter = request.user.config.get('csv_delimiter') if request.user.is_authenticated else None
                 return self.export_table(table, columns, delimiter=delimiter)
                 return self.export_table(table, columns, delimiter=delimiter)
 
 
             # Render an ExportTemplate
             # Render an ExportTemplate
@@ -231,7 +231,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
 
 
             # Fall back to default table/YAML export
             # Fall back to default table/YAML export
             table = self.get_table(self.queryset, request, has_table_actions)
             table = self.get_table(self.queryset, request, has_table_actions)
-            delimiter = request.user.config.get('csv_delimiter')
+            delimiter = request.user.config.get('csv_delimiter') if request.user.is_authenticated else None
             return self.export_table(table, delimiter=delimiter)
             return self.export_table(table, delimiter=delimiter)
 
 
         # Render the objects table
         # Render the objects table
@@ -274,11 +274,96 @@ class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView):
     form = None
     form = None
     model_form = None
     model_form = None
     pattern_target = ''
     pattern_target = ''
+    pattern_template_fields = ()
     htmx_template_name = 'htmx/bulk_add_form.html'
     htmx_template_name = 'htmx/bulk_add_form.html'
 
 
     def get_required_permission(self):
     def get_required_permission(self):
         return get_permission_for_model(self.queryset.model, 'add')
         return get_permission_for_model(self.queryset.model, 'add')
 
 
+    def get_pattern_context(self, value):
+        """
+        Return a context mapping for substituting the generated pattern value into
+        model form fields.
+
+        By default, the field named by ``pattern_target`` is supported as a
+        placeholder, e.g. ``{vid}``.
+        """
+        if not self.pattern_target:
+            return {}
+
+        return {
+            self.pattern_target: str(value),
+        }
+
+    def render_pattern_template(self, template, value):
+        """
+        Replace pattern placeholders in a single form field value.
+        """
+        rendered = str(template)
+
+        for key, replacement in self.get_pattern_context(value).items():
+            rendered = rendered.replace(f'{{{key}}}', replacement)
+
+        return rendered
+
+    def apply_pattern_template_fields(self, data, value):
+        """
+        Apply the generated pattern value to any configured template fields.
+        """
+        for field_name in self.pattern_template_fields:
+            if field_name not in data:
+                continue
+
+            # QueryDict values may be multi-valued; preserve that behavior.
+            if hasattr(data, 'getlist') and hasattr(data, 'setlist'):
+                data.setlist(field_name, [
+                    self.render_pattern_template(field_value, value)
+                    for field_value in data.getlist(field_name)
+                ])
+            else:
+                data[field_name] = self.render_pattern_template(data[field_name], value)
+
+        return data
+
+    def get_model_form_data(self, form, request, value):
+        """
+        Return the submitted data to use when instantiating the model form for a
+        single generated pattern value.
+        """
+        data = request.POST.copy()
+        data[self.pattern_target] = value
+
+        return self.apply_pattern_template_fields(data, value)
+
+    def add_model_form_errors(self, form, model_form, value):
+        """
+        Copy validation errors from the generated object's model form back onto
+        the pattern form for display.
+        """
+        errors = model_form.errors.as_data()
+
+        if errors.get(self.pattern_target):
+            form.add_error('pattern', errors.pop(self.pattern_target))
+
+        for field_name, field_errors in errors.items():
+            if field_name == '__all__':
+                field_label = _('General')
+            elif field_name in model_form.fields:
+                field_label = model_form.fields[field_name].label
+            else:
+                field_label = field_name
+
+            for error in field_errors:
+                for message in error.messages:
+                    form.add_error(
+                        None,
+                        _('{value}: {field}: {error}').format(
+                            value=value,
+                            field=field_label,
+                            error=message,
+                        )
+                    )
+
     def _create_objects(self, form, request):
     def _create_objects(self, form, request):
         new_objects = []
         new_objects = []
 
 
@@ -287,8 +372,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(request.POST.copy())
-            model_form.data[self.pattern_target] = value
+            model_form = self.model_form(self.get_model_form_data(form, request, value))
 
 
             # Validate each new object independently.
             # Validate each new object independently.
             if model_form.is_valid():
             if model_form.is_valid():
@@ -296,12 +380,10 @@ class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView):
                 obj = model_form.save()
                 obj = model_form.save()
                 new_objects.append(obj)
                 new_objects.append(obj)
             else:
             else:
-                # Copy any errors on the pattern target field to the pattern form.
-                errors = model_form.errors.as_data()
-                if errors.get(self.pattern_target):
-                    form.add_error('pattern', errors[self.pattern_target])
-                # Raise an IntegrityError to break the for loop and abort the transaction.
-                raise IntegrityError()
+                self.add_model_form_errors(form, model_form, value)
+
+                # Abort the transaction and break out of the loop.
+                raise AbortTransaction()
 
 
         return new_objects
         return new_objects
 
 
@@ -372,7 +454,7 @@ class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView):
                     return redirect(request.path)
                     return redirect(request.path)
                 return redirect(self.get_return_url(request))
                 return redirect(self.get_return_url(request))
 
 
-            except IntegrityError:
+            except (AbortTransaction, IntegrityError):
                 pass
                 pass
 
 
             except (AbortRequest, PermissionsViolation) as e:
             except (AbortRequest, PermissionsViolation) as e:
@@ -758,7 +840,10 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
 
 
             # Update custom fields
             # Update custom fields
             for name, customfield in custom_fields.items():
             for name, customfield in custom_fields.items():
-                assert name.startswith('cf_')
+                if not name.startswith('cf_'):
+                    raise ImproperlyConfigured(
+                        _("Custom field form field name must begin with 'cf_': {name}").format(name=name)
+                    )
                 cf_name = name[3:]  # Strip cf_ prefix
                 cf_name = name[3:]  # Strip cf_ prefix
                 if name in form.nullable_fields and name in nullified_fields:
                 if name in form.nullable_fields and name in nullified_fields:
                     obj.custom_field_data[cf_name] = None
                     obj.custom_field_data[cf_name] = None

+ 29 - 2
netbox/netbox/views/misc.py

@@ -1,4 +1,5 @@
 import logging
 import logging
+import posixpath
 import re
 import re
 from collections import namedtuple
 from collections import namedtuple
 
 
@@ -6,6 +7,8 @@ from django.conf import settings
 from django.contrib import messages
 from django.contrib import messages
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core.cache import cache
 from django.core.cache import cache
+from django.db.models import Q
+from django.http import Http404
 from django.shortcuts import redirect, render
 from django.shortcuts import redirect, render
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 from django.views.generic import View
 from django.views.generic import View
@@ -13,8 +16,10 @@ from django.views.static import serve
 from django_tables2 import RequestConfig
 from django_tables2 import RequestConfig
 from packaging import version
 from packaging import version
 
 
+from dcim.models import DeviceType
 from extras.constants import DEFAULT_DASHBOARD
 from extras.constants import DEFAULT_DASHBOARD
 from extras.dashboard.utils import get_dashboard, get_default_dashboard
 from extras.dashboard.utils import get_dashboard, get_default_dashboard
+from extras.models import ImageAttachment
 from netbox.forms import SearchForm
 from netbox.forms import SearchForm
 from netbox.search import LookupTypes
 from netbox.search import LookupTypes
 from netbox.search.backends import search_backend
 from netbox.search.backends import search_backend
@@ -131,7 +136,29 @@ class SearchView(ConditionalLoginRequiredMixin, View):
 
 
 class MediaView(TokenConditionalLoginRequiredMixin, View):
 class MediaView(TokenConditionalLoginRequiredMixin, View):
     """
     """
-    Wrap Django's serve() view to enforce LOGIN_REQUIRED for static media.
+    Serve uploaded media files, enforcing authentication and view permission on the associated object.
     """
     """
     def get(self, request, path):
     def get(self, request, path):
-        return serve(request, path, document_root=settings.MEDIA_ROOT)
+
+        # Normalize the path to prevent traversal sequences (e.g. "foo/../image-attachments/...")
+        # from bypassing the directory checks below.
+        path = posixpath.normpath(path).lstrip('/')
+
+        # For known upload directories, resolve the path to an owning record and
+        # enforce object-level view permission. restrict() returns .none() when the
+        # user lacks permission, so a denial and a missing file are both 404s.
+        # Paths outside these directories (e.g. plugin uploads) fall through
+        # to the original behaviour.
+        if path.startswith('image-attachments/'):
+            if not ImageAttachment.objects.restrict(request.user, 'view').filter(image=path).exists():
+                raise Http404
+        elif path.startswith('devicetype-images/'):
+            if not DeviceType.objects.restrict(request.user, 'view').filter(
+                Q(front_image=path) | Q(rear_image=path)
+            ).exists():
+                raise Http404
+
+        response = serve(request, path, document_root=settings.MEDIA_ROOT)
+        response['Content-Disposition'] = 'attachment'
+        response['X-Content-Type-Options'] = 'nosniff'
+        return response

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
netbox/project-static/dist/netbox.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
netbox/project-static/dist/netbox.js.map


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

@@ -31,7 +31,7 @@
     "gridstack": "12.6.0",
     "gridstack": "12.6.0",
     "htmx.org": "2.0.10",
     "htmx.org": "2.0.10",
     "query-string": "9.4.0",
     "query-string": "9.4.0",
-    "sass": "1.100.0",
+    "sass": "1.101.0",
     "tom-select": "2.6.1",
     "tom-select": "2.6.1",
     "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 @@
     "@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",
     "@types/node": "^24.10.1",
-    "@typescript-eslint/eslint-plugin": "^8.60.1",
-    "@typescript-eslint/parser": "^8.60.1",
-    "esbuild": "^0.28.0",
+    "@typescript-eslint/eslint-plugin": "^8.61.1",
+    "@typescript-eslint/parser": "^8.61.1",
+    "esbuild": "^0.28.1",
     "esbuild-sass-plugin": "^3.7.0",
     "esbuild-sass-plugin": "^3.7.0",
-    "eslint": "^10.4.1",
+    "eslint": "^10.5.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",
     "eslint-plugin-prettier": "^5.5.6",
     "globals": "^17.5.0",
     "globals": "^17.5.0",
-    "prettier": "^3.8.3",
+    "prettier": "^3.8.4",
     "typescript": "^5.9.3"
     "typescript": "^5.9.3"
   },
   },
   "resolutions": {
   "resolutions": {

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä