Explorar o código

Merge branch 'feature' into 22486-webhook

Arthur hai 4 semanas
pai
achega
b82016ce0d
Modificáronse 100 ficheiros con 3288 adicións e 498 borrados
  1. 3 0
      docs/configuration/index.md
  2. 2 2
      docs/configuration/required-parameters.md
  3. 1 1
      docs/configuration/security.md
  4. 14 0
      docs/configuration/system.md
  5. 1 1
      docs/customization/custom-fields.md
  6. 3 3
      docs/installation/3-netbox.md
  7. 361 0
      docs/installation/3b-python-package.md
  8. 29 10
      docs/installation/6-ldap.md
  9. 6 1
      docs/installation/index.md
  10. 93 7
      docs/installation/upgrading.md
  11. 15 2
      docs/models/dcim/interface.md
  12. 1 1
      docs/plugins/development/config-templates.md
  13. 90 2
      docs/plugins/development/graphql-api.md
  14. 2 0
      docs/plugins/development/index.md
  15. 2 1
      mkdocs.yml
  16. 17 12
      netbox/circuits/graphql/filters.py
  17. 12 12
      netbox/circuits/graphql/types.py
  18. 1 0
      netbox/core/apps.py
  19. 5 5
      netbox/core/graphql/filters.py
  20. 5 6
      netbox/core/graphql/types.py
  21. 11 5
      netbox/core/models/config.py
  22. 3 1
      netbox/core/models/data.py
  23. 2 1
      netbox/dcim/api/serializers_/device_components.py
  24. 7 2
      netbox/dcim/api/serializers_/devicetype_components.py
  25. 2 0
      netbox/dcim/choices.py
  26. 10 1
      netbox/dcim/constants.py
  27. 9 4
      netbox/dcim/filtersets.py
  28. 17 9
      netbox/dcim/forms/bulk_edit.py
  29. 3 3
      netbox/dcim/forms/bulk_import.py
  30. 12 1
      netbox/dcim/forms/filtersets.py
  31. 21 10
      netbox/dcim/forms/model_forms.py
  32. 18 3
      netbox/dcim/forms/object_create.py
  33. 2 2
      netbox/dcim/forms/object_import.py
  34. 66 49
      netbox/dcim/graphql/filters.py
  35. 52 49
      netbox/dcim/graphql/types.py
  36. 54 0
      netbox/dcim/migrations/0246_interfacetemplate_channels.py
  37. 43 0
      netbox/dcim/migrations/0247_interface_channels.py
  38. 17 3
      netbox/dcim/models/cables.py
  39. 62 0
      netbox/dcim/models/device_component_templates.py
  40. 101 12
      netbox/dcim/models/device_components.py
  41. 4 2
      netbox/dcim/models/devices.py
  42. 77 1
      netbox/dcim/models/mixins.py
  43. 8 4
      netbox/dcim/models/module_moves.py
  44. 8 3
      netbox/dcim/models/modules.py
  45. 76 7
      netbox/dcim/signals.py
  46. 22 5
      netbox/dcim/svg/cables.py
  47. 6 6
      netbox/dcim/tables/devices.py
  48. 2 2
      netbox/dcim/tables/devicetypes.py
  49. 36 2
      netbox/dcim/tests/test_api.py
  50. 630 0
      netbox/dcim/tests/test_channelization.py
  51. 2 0
      netbox/dcim/ui/panels.py
  52. 69 4
      netbox/dcim/utils.py
  53. 11 0
      netbox/extras/api/customfields.py
  54. 3 1
      netbox/extras/cache.py
  55. 0 8
      netbox/extras/constants.py
  56. 18 18
      netbox/extras/graphql/filters.py
  57. 18 19
      netbox/extras/graphql/types.py
  58. 2 1
      netbox/extras/management/commands/renaturalize.py
  59. 15 36
      netbox/extras/models/customfields.py
  60. 34 3
      netbox/extras/tests/test_customfields.py
  61. 19 18
      netbox/ipam/graphql/filters.py
  62. 25 19
      netbox/ipam/graphql/types.py
  63. 2 1
      netbox/ipam/management/commands/rebuild_prefixes.py
  64. 3 1
      netbox/ipam/tests/test_management_commands.py
  65. 12 0
      netbox/netbox/graphql/filters.py
  66. 14 2
      netbox/netbox/graphql/types.py
  67. 126 0
      netbox/netbox/graphql/utils.py
  68. 16 0
      netbox/netbox/plugins/__init__.py
  69. 72 0
      netbox/netbox/plugins/registration.py
  70. 15 11
      netbox/netbox/search/backends.py
  71. 2 8
      netbox/netbox/search/deferred.py
  72. 31 0
      netbox/netbox/search/signals.py
  73. 5 0
      netbox/netbox/settings.py
  74. 7 1
      netbox/netbox/tables/columns.py
  75. 33 0
      netbox/netbox/tests/dummy_plugin/graphql.py
  76. 149 0
      netbox/netbox/tests/test_graphql.py
  77. 111 0
      netbox/netbox/tests/test_plugins.py
  78. 6 4
      netbox/netbox/tests/test_search.py
  79. 28 0
      netbox/netbox/tests/test_tables.py
  80. 7 6
      netbox/tenancy/graphql/filters.py
  81. 13 8
      netbox/tenancy/graphql/types.py
  82. 5 5
      netbox/users/graphql/filters.py
  83. 5 6
      netbox/users/graphql/types.py
  84. 2 1
      netbox/utilities/counters.py
  85. 14 0
      netbox/utilities/forms/fields/expandable.py
  86. 3 1
      netbox/utilities/forms/rendering.py
  87. 64 1
      netbox/utilities/querysets.py
  88. 5 1
      netbox/utilities/templates/builtins/customfield_value.html
  89. 21 13
      netbox/utilities/templates/form_helpers/render_fieldset.html
  90. 9 0
      netbox/utilities/templatetags/builtins/tags.py
  91. 19 6
      netbox/utilities/templatetags/form_helpers.py
  92. 116 0
      netbox/utilities/tests/test_querysets.py
  93. 43 0
      netbox/utilities/tests/test_templatetags.py
  94. 19 0
      netbox/utilities/validators.py
  95. 8 8
      netbox/virtualization/graphql/filters.py
  96. 8 8
      netbox/virtualization/graphql/types.py
  97. 3 1
      netbox/virtualization/signals.py
  98. 11 10
      netbox/vpn/graphql/filters.py
  99. 17 11
      netbox/vpn/graphql/types.py
  100. 4 4
      netbox/wireless/graphql/filters.py

+ 3 - 0
docs/configuration/index.md

@@ -6,6 +6,9 @@ NetBox's configuration file contains all the important parameters which control
 
 The configuration file is loaded from `$INSTALL_ROOT/netbox/netbox/configuration.py` by default. An example configuration is provided at `configuration_example.py`, which you may copy to use as your default config. Note that a configuration file must be defined; NetBox will not run without one.
 
+!!! note "Python package installations (experimental)"
+    An experimental Python package installation loads `$NETBOX_ROOT/conf/configuration.py` by default. `NETBOX_ROOT` defaults to `/opt/netbox`. Use `netbox setup --target <path>` to scaffold the local configuration, and keep configuration and mutable instance data outside the virtual environment and installed package. The setup target is not persisted; set `NETBOX_ROOT` for all commands and services when using a non-default path.
+
 !!! info "Customizing the Configuration Module"
     A custom configuration module may be specified by setting the `NETBOX_CONFIGURATION` environment variable. This must be a dotted path to the desired Python module. For example, a file named `my_config.py` in the same directory as `settings.py` would be referenced as `netbox.my_config`.
 

+ 2 - 2
docs/configuration/required-parameters.md

@@ -39,7 +39,7 @@ API_TOKEN_PEPPERS = {
 !!! warning "Peppers are sensitive"
     Treat pepper values as extremely sensitive. Consider populating peppers from environment variables at initialization time rather than defining them in the configuration file, if feasible. 
 
-Peppers must be at least 50 characters in length and should comprise a random string with a diverse character set. Consider using the Python script at `$INSTALL_ROOT/netbox/generate_secret_key.py` to generate a pepper value.
+Peppers must be at least 50 characters in length and should comprise a random string with a diverse character set. Consider using the Python script at `$INSTALL_ROOT/netbox/generate_secret_key.py` to generate a pepper value. For a Python package installation, run the virtual environment's `netbox secret-key` command instead.
 
 It is recommended to start with a pepper ID of `1`. Additional peppers can be introduced later as needed to begin rotating token hashes.
 
@@ -251,4 +251,4 @@ REDIS = {
 
 This is a secret, pseudorandom string used to assist in the creation new cryptographic hashes for passwords and HTTP cookies. The key defined here should not be shared outside the configuration file. `SECRET_KEY` can be changed at any time without impacting stored data, however be aware that doing so will invalidate all existing user sessions. NetBox deployments comprising multiple nodes must have the same secret key configured on all nodes.
 
-`SECRET_KEY` **must** be at least 50 characters in length, and should contain a mix of letters, digits, and symbols. The script located at `$INSTALL_ROOT/netbox/generate_secret_key.py` may be used to generate a suitable key. Please note that this key is **not** used directly for hashing user passwords or for the encrypted storage of secret data in NetBox.
+`SECRET_KEY` **must** be at least 50 characters in length, and should contain a mix of letters, digits, and symbols. The script located at `$INSTALL_ROOT/netbox/generate_secret_key.py` may be used to generate a suitable key. For a Python package installation, run the virtual environment's `netbox secret-key` command instead. Please note that this key is **not** used directly for hashing user passwords or for the encrypted storage of secret data in NetBox.

+ 1 - 1
docs/configuration/security.md

@@ -6,7 +6,7 @@
 
 Default: `('file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp')`
 
-A list of permitted URL schemes referenced when rendering links within NetBox. Note that only the schemes specified in this list will be accepted: If adding your own, be sure to replicate all the default values as well (excluding those schemes which are not desirable).
+A list of permitted URL schemes referenced when rendering links within NetBox. This list is also enforced when validating the value of URL custom fields. Note that only the schemes specified in this list will be accepted: If adding your own, be sure to replicate all the default values as well (excluding those schemes which are not desirable).
 
 ---
 

+ 14 - 0
docs/configuration/system.md

@@ -12,6 +12,20 @@ BASE_PATH = 'netbox/'
 
 ---
 
+## BULK_UPDATE_CHUNK_SIZE
+
+Default: `5000`
+
+The maximum number of rows to affect in a single SQL `UPDATE` statement when NetBox performs a bulk update across many objects (for example, when recalculating cached counters or backfilling custom field data). On very large tables, an unbounded update spanning millions of rows can exceed the database's configured statement timeout; splitting the work into batches of at most this many rows bounds each statement while keeping the overall operation atomic.
+
+Must be a positive integer, or `None` to disable chunking and issue each bulk update as a single unbounded statement.
+
+```python
+BULK_UPDATE_CHUNK_SIZE = 5000
+```
+
+---
+
 ## DATABASE_ROUTERS
 
 Default: `[]` (empty list)

+ 1 - 1
docs/customization/custom-fields.md

@@ -17,7 +17,7 @@ Custom fields may be created by navigating to Customization > Custom Fields. Net
 * Boolean: True or false
 * Date: A date in ISO 8601 format (YYYY-MM-DD)
 * Date & time: A date and time in ISO 8601 format (YYYY-MM-DD HH:MM:SS)
-* URL: This will be presented as a link in the web UI
+* URL: This will be presented as a link in the web UI. Values are restricted to the schemes permitted by [`ALLOWED_URL_SCHEMES`](../configuration/security.md#allowed_url_schemes). A value entered without a scheme (e.g. `example.com`) is assumed to use `https` and stored as an absolute URL (e.g. `https://example.com`).
 * JSON: Arbitrary data stored in JSON format
 * Selection: A selection of one of several pre-defined custom choices
 * Multiple selection: A selection field which supports the assignment of multiple values

+ 3 - 3
docs/installation/3-netbox.md

@@ -1,6 +1,6 @@
-# NetBox Installation
+# Install NetBox from a Release Archive or Git
 
-This section of the documentation discusses installing and configuring the NetBox application itself.
+This page covers the established release archive and Git installation methods. To install NetBox from the experimental Python package instead, follow the [separate package installation guide](3b-python-package.md).
 
 ## Install System Packages
 
@@ -99,7 +99,7 @@ cd /opt/netbox/netbox/netbox/
 sudo cp configuration_example.py configuration.py
 ```
 
-Open `configuration.py` with your preferred editor to begin configuring NetBox. NetBox offers [many configuration parameters](../configuration/index.md), but only the following four are required for new installations:
+Open `configuration.py` with your preferred editor to begin configuring NetBox. NetBox offers [many configuration parameters](../configuration/index.md), but only the following five are required for new installations:
 
 * `ALLOWED_HOSTS`
 * `API_TOKEN_PEPPERS`

+ 361 - 0
docs/installation/3b-python-package.md

@@ -0,0 +1,361 @@
+# Install NetBox from the Python Package (Experimental)
+
+!!! warning "Experimental in NetBox v4.7"
+    Installing NetBox from the Python package is experimental in NetBox v4.7 and is **not recommended for production use**. Use this workflow to evaluate the packaged installation, test upgrades and rollback procedures, and provide feedback.
+
+    The established [release archive and Git installation methods](3-netbox.md) remain supported and are not replaced by this workflow.
+
+The Python package installs the NetBox application and its Python dependencies into a virtual environment using `pip`. Configuration, uploaded media, custom scripts and reports, collected static files, and deployment configuration remain outside the installed package.
+
+This installation method does **not** configure PostgreSQL, Redis, a WSGI server, an HTTP server, or system services. These remain administrator-managed deployment tasks, just as they are for an archive or Git installation.
+
+## When to Use This Installation Method
+
+Use the Python package for a new test or evaluation deployment when you want `pip` to manage the NetBox application code in a dedicated virtual environment. While this workflow remains experimental, use a [release archive or Git checkout](3-netbox.md) for production deployments.
+
+A package installation is also available as a migration target for an existing deployment, but it is not an in-place conversion. Follow the [migration procedure](#migrate-an-existing-archive-or-git-installation) only after validating the workflow in a separate environment.
+
+## Understand the Installation Layout
+
+A package installation separates the application code from the files that belong to a particular NetBox instance.
+
+| Component | Example Location | Purpose |
+|-----------|------------------|---------|
+| Application code | `<venv>/lib/pythonX.Y/site-packages/` | Installed and replaced by `pip`; do not modify it directly |
+| Python virtual environment | `/opt/netbox/venv/` | Contains NetBox, its dependencies, and any plugins |
+| Instance root | `/opt/netbox/` | Holds local configuration and mutable instance data |
+| Configuration | `/opt/netbox/conf/configuration.py` | Contains settings and credentials for this instance |
+| Mutable data | `/opt/netbox/{media,reports,scripts,static}/` | Persists independently of package upgrades |
+| Deployment examples | `/opt/netbox/contrib/` | Local copies to review and adapt before use |
+
+The instance root defaults to `/opt/netbox` and may be changed with the `NETBOX_ROOT` environment variable. The virtual environment does not need to be located below the instance root; `/opt/netbox/venv` is used throughout this guide only to keep the example straightforward.
+
+!!! note "Custom instance roots"
+    The `--target` option for `netbox setup` selects where the local files are created. It does not permanently set the instance root. When using a location other than `/opt/netbox`, set `NETBOX_ROOT` for all NetBox commands and services.
+
+## Before You Begin
+
+Complete the [PostgreSQL](1-postgresql.md) and [Redis](2-redis.md) installation steps first. Then install the same [required system packages](3-netbox.md#install-system-packages) used by the archive and Git installation methods.
+
+## Create the System User and Instance Root
+
+Create the `netbox` system account and the default instance root:
+
+```no-highlight
+sudo adduser --system --group netbox
+sudo mkdir -p /opt/netbox
+sudo chown root:netbox /opt/netbox
+sudo chmod 755 /opt/netbox
+```
+
+## Create the Virtual Environment
+
+Create a Python virtual environment and update `pip`:
+
+```no-highlight
+sudo python3 -m venv /opt/netbox/venv
+sudo /opt/netbox/venv/bin/python -m pip install --upgrade pip
+```
+
+Install the desired NetBox release. Replace `X.Y.Z` with the exact version to install:
+
+```no-highlight
+sudo /opt/netbox/venv/bin/python -m pip install "netbox==X.Y.Z"
+```
+
+Pinning the version makes the installed release explicit and prevents an unintended upgrade when the command is repeated later.
+
+## Scaffold the Instance Root
+
+Run `netbox setup` to create the local configuration skeleton and copy the bundled deployment examples:
+
+```no-highlight
+sudo /opt/netbox/venv/bin/netbox setup --target /opt/netbox
+```
+
+The command creates the following files when they do not already exist:
+
+```no-highlight
+/opt/netbox/
+├── conf/
+│   ├── __init__.py
+│   └── configuration.py
+├── contrib/
+│   ├── apache.conf
+│   ├── gunicorn.py
+│   ├── netbox-rq.service
+│   ├── netbox.env
+│   ├── netbox.service
+│   ├── nginx.conf
+│   └── uwsgi.ini
+└── local_requirements.txt
+```
+
+`netbox setup` is intentionally non-destructive: existing files are left untouched. It does not install systemd units, configure an HTTP server, rewrite deployment examples for the local paths, or enable plugins.
+
+Create the directories used for mutable instance data and grant the NetBox service account ownership of them:
+
+```no-highlight
+sudo mkdir -p /opt/netbox/{media,reports,scripts,static}
+sudo chown --recursive netbox:netbox \
+    /opt/netbox/media \
+    /opt/netbox/reports \
+    /opt/netbox/scripts \
+    /opt/netbox/static
+```
+
+## Configure NetBox
+
+Open the scaffolded configuration file:
+
+```no-highlight
+sudo ${EDITOR:-vi} /opt/netbox/conf/configuration.py
+```
+
+Define the five [required configuration parameters](../configuration/required-parameters.md):
+
+* `ALLOWED_HOSTS`
+* `API_TOKEN_PEPPERS`
+* `DATABASES`
+* `REDIS`
+* `SECRET_KEY`
+
+Generate a suitable random value for `SECRET_KEY` with the installed command:
+
+```no-highlight
+sudo /opt/netbox/venv/bin/netbox secret-key
+```
+
+Run the command again to generate an independent value for the first entry in `API_TOKEN_PEPPERS`. Treat both values as sensitive and do not reuse the examples from the documentation.
+
+After saving the configuration, restrict access while allowing the NetBox service account to read it:
+
+```no-highlight
+sudo chown --recursive root:netbox /opt/netbox/conf
+sudo chmod 750 /opt/netbox/conf
+sudo chmod 640 /opt/netbox/conf/configuration.py
+```
+
+!!! note "Environment-based configuration"
+    Ensure that any environment variables referenced by `configuration.py` are present when running `netbox upgrade`, `netbox createsuperuser`, and other management commands, and provide the same variables to both NetBox services. The copied `contrib/netbox.env` file is an example only and is not loaded automatically.
+
+## Install Plugins and Optional Python Packages
+
+Plugins and any other local Python requirements must be installed into the **same virtual environment** as NetBox before running the installation or upgrade tasks. Add each package to `/opt/netbox/local_requirements.txt`, then install the file:
+
+```no-highlight
+sudo ${EDITOR:-vi} /opt/netbox/local_requirements.txt
+sudo /opt/netbox/venv/bin/python -m pip install \
+    -r /opt/netbox/local_requirements.txt
+```
+
+Installing a plugin does not enable it. Add the plugin to the `PLUGINS` list in `/opt/netbox/conf/configuration.py` and complete any plugin-specific configuration separately.
+
+NetBox also provides optional package extras for several common integrations. For example, install the LDAP dependencies together with the same pinned NetBox version as follows:
+
+```no-highlight
+sudo /opt/netbox/venv/bin/python -m pip install "netbox[ldap]==X.Y.Z"
+```
+
+Remember which extras are in use and specify them again when upgrading. For LDAP authentication, create `ldap_config.py` beside the active configuration file at `/opt/netbox/conf/ldap_config.py` when following the [LDAP configuration guide](6-ldap.md). Give it the same ownership and permissions as `configuration.py`:
+
+```no-highlight
+sudo chown root:netbox /opt/netbox/conf/ldap_config.py
+sudo chmod 640 /opt/netbox/conf/ldap_config.py
+```
+
+When using uWSGI, install `pyuwsgi` into the same virtual environment and record it as a local requirement:
+
+```no-highlight
+sudo sh -c "echo 'pyuwsgi' >> /opt/netbox/local_requirements.txt"
+sudo /opt/netbox/venv/bin/python -m pip install pyuwsgi
+```
+
+## Run the Installation Tasks
+
+Run the packaged upgrade command to apply database migrations, collect static files, and perform the remaining application installation tasks:
+
+```no-highlight
+sudo -u netbox /opt/netbox/venv/bin/netbox upgrade --no-input
+```
+
+The `netbox upgrade` command is used for both a fresh package installation and future package upgrades. It replaces the source installation's `upgrade.sh` workflow.
+
+For a custom instance root, pass `NETBOX_ROOT` explicitly. The virtual environment may remain elsewhere:
+
+```no-highlight
+sudo -u netbox env NETBOX_ROOT=/srv/netbox \
+    /opt/netbox-venv/bin/netbox upgrade --no-input
+```
+
+## Create a Superuser
+
+Create the first administrative account:
+
+```no-highlight
+sudo -u netbox /opt/netbox/venv/bin/netbox createsuperuser
+```
+
+## Test the Application
+
+Start Django's development server temporarily to confirm that NetBox can load its configuration and connect to its dependencies:
+
+```no-highlight
+sudo -u netbox /opt/netbox/venv/bin/netbox \
+    runserver 0.0.0.0:8000 --insecure
+```
+
+Connect to the server on port 8000 and log in with the superuser account. Type `Ctrl+c` to stop the development server after testing.
+
+!!! danger "Not for production use"
+    The development server is intended only for installation testing. It is neither performant nor secure enough for production use.
+
+## Adapt the Deployment Examples
+
+The files copied to `/opt/netbox/contrib/` are the same deployment examples shipped for archive and Git installations. They are not rewritten for the package layout. Adapt them before following the shared Gunicorn, uWSGI, and HTTP server instructions.
+
+For the default paths used in this guide, the following commands remove the source-tree references:
+
+```no-highlight
+sudo sed -i \
+    's| --pythonpath /opt/netbox/netbox||' \
+    /opt/netbox/contrib/netbox.service
+
+sudo sed -i \
+    's|/opt/netbox/venv/bin/python3 /opt/netbox/netbox/manage.py|/opt/netbox/venv/bin/netbox|' \
+    /opt/netbox/contrib/netbox-rq.service
+
+sudo sed -i \
+    's|chdir = netbox|chdir = /opt/netbox|' \
+    /opt/netbox/contrib/uwsgi.ini
+
+sudo sed -i \
+    's|/opt/netbox/netbox/static|/opt/netbox/static|g' \
+    /opt/netbox/contrib/nginx.conf \
+    /opt/netbox/contrib/apache.conf
+```
+
+These changes have the following effect:
+
+| File | Package Installation Change |
+|------|-----------------------------|
+| `netbox.service` | Imports `netbox.wsgi` from the virtual environment without a source-tree `--pythonpath` |
+| `netbox-rq.service` | Runs the RQ worker through the installed `netbox` command instead of `manage.py` |
+| `uwsgi.ini` | Uses the instance root rather than the absent `/opt/netbox/netbox/` source directory |
+| `nginx.conf` and `apache.conf` | Serve collected static files from `/opt/netbox/static/` |
+
+Review every file before installing it. When using a different instance root or virtual environment, update all `WorkingDirectory`, `ExecStart`, `chdir`, virtual environment, and static-file paths accordingly. Also add the following line to the `[Service]` section of both systemd units, replacing the path as needed:
+
+```ini
+Environment=NETBOX_ROOT=/srv/netbox
+```
+
+When using environment-based configuration, reference an appropriate environment file from both systemd units or define the required variables directly in each unit.
+
+## Continue the Installation
+
+With the deployment examples adapted, continue with either [Gunicorn](4a-gunicorn.md) or [uWSGI](4b-uwsgi.md). When using uWSGI and you installed `pyuwsgi` above, skip the **Installation** subsection on the uWSGI page and begin with its configuration steps. Then configure an [HTTP server](5-http-server.md) and, if needed, [LDAP authentication](6-ldap.md).
+
+The shared pages copy files from `/opt/netbox/contrib/`, so make the package-specific changes above **before** copying those files into their final locations.
+
+## Migrate an Existing Archive or Git Installation
+
+!!! warning "Experimental migration path"
+    Migrating an existing deployment to the Python package changes its filesystem and upgrade model. Take a complete backup, document the current configuration, and verify a rollback procedure before proceeding.
+
+Python package releases begin with NetBox v4.7. Before migrating an older deployment, first upgrade the existing archive or Git installation to a version that is available as a Python package.
+
+Migrate the layout separately from a NetBox version upgrade. Install the **same NetBox version** that is currently running, validate the package-based deployment, and only then upgrade to a newer release.
+
+The following example keeps the existing `/opt/netbox` installation in place during migration. It uses `/srv/netbox` as the new instance root and `/opt/netbox-venv` for the new virtual environment.
+
+1. Stop the existing NetBox services after completing a backup:
+
+    ```no-highlight
+    sudo systemctl stop netbox netbox-rq
+    ```
+
+2. Create the new virtual environment and install the same NetBox version as the existing deployment:
+
+    ```no-highlight
+    sudo python3 -m venv /opt/netbox-venv
+    sudo /opt/netbox-venv/bin/python -m pip install --upgrade pip
+    sudo /opt/netbox-venv/bin/python -m pip install "netbox==X.Y.Z"
+    ```
+
+3. Scaffold the new instance root and create its mutable directories:
+
+    ```no-highlight
+    sudo mkdir -p /srv/netbox
+    sudo chown root:netbox /srv/netbox
+    sudo chmod 755 /srv/netbox
+    sudo /opt/netbox-venv/bin/netbox setup --target /srv/netbox
+    sudo mkdir -p /srv/netbox/{media,reports,scripts,static}
+    sudo chown --recursive netbox:netbox \
+        /srv/netbox/media \
+        /srv/netbox/reports \
+        /srv/netbox/scripts \
+        /srv/netbox/static
+    ```
+
+4. Copy the active configuration from the existing installation. If `local_requirements.txt` exists, copy it over the empty file created by `netbox setup`:
+
+    ```no-highlight
+    sudo cp /opt/netbox/netbox/netbox/configuration.py \
+        /srv/netbox/conf/configuration.py
+
+    if [ -f /opt/netbox/local_requirements.txt ]; then
+        sudo cp /opt/netbox/local_requirements.txt \
+            /srv/netbox/local_requirements.txt
+    fi
+    ```
+
+    When the existing deployment uses `NETBOX_CONFIGURATION`, copy the active configuration module instead, together with any sibling modules or local files it imports. Review the copied configuration and update any filesystem paths that still reference the old source tree.
+
+    If LDAP is configured, also copy the active `ldap_config.py` to `/srv/netbox/conf/ldap_config.py`.
+
+5. Copy locally stored media, reports, and scripts. Do not copy collected static files; `netbox upgrade` will create them again.
+
+    ```no-highlight
+    sudo cp -a /opt/netbox/netbox/media/. /srv/netbox/media/
+    sudo cp -a /opt/netbox/netbox/reports/. /srv/netbox/reports/
+    sudo cp -a /opt/netbox/netbox/scripts/. /srv/netbox/scripts/
+    sudo chown --recursive netbox:netbox \
+        /srv/netbox/media \
+        /srv/netbox/reports \
+        /srv/netbox/scripts
+    ```
+
+    Use the paths configured by `MEDIA_ROOT`, `REPORTS_ROOT`, and `SCRIPTS_ROOT` instead when the existing deployment stores these files elsewhere.
+
+6. Install all plugins and local requirements into the new virtual environment **before** running the upgrade tasks:
+
+    ```no-highlight
+    sudo /opt/netbox-venv/bin/python -m pip install \
+        -r /srv/netbox/local_requirements.txt
+    ```
+
+    Repeat any NetBox package extras used by the deployment, and verify that each plugin supports the installed NetBox version.
+
+7. Secure the configuration and run the package installation tasks against the existing database:
+
+    ```no-highlight
+    sudo chown --recursive root:netbox /srv/netbox/conf
+    sudo chmod 750 /srv/netbox/conf
+    sudo chmod 640 /srv/netbox/conf/configuration.py
+
+    sudo -u netbox env NETBOX_ROOT=/srv/netbox \
+        /opt/netbox-venv/bin/netbox upgrade --no-input
+    ```
+
+    If `ldap_config.py` was copied, also run `sudo chmod 640 /srv/netbox/conf/ldap_config.py`.
+
+8. Follow [Adapt the Deployment Examples](#adapt-the-deployment-examples), substituting `/srv/netbox` and `/opt/netbox-venv` for the example paths. Install the updated systemd and HTTP server configuration, switch the services to the package deployment, and ensure that both systemd units define `NETBOX_ROOT=/srv/netbox`.
+
+9. Start the services, test the web interface and background processing, and retain the previous installation until the new deployment has been validated:
+
+    ```no-highlight
+    sudo systemctl start netbox netbox-rq
+    ```
+
+After the migration is complete, use the [Python package upgrade procedure](upgrading.md#upgrade-a-python-package-installation-experimental) for future releases.

+ 29 - 10
docs/installation/6-ldap.md

@@ -12,18 +12,30 @@ sudo apt install -y libldap2-dev libsasl2-dev libssl-dev
 
 ### Install django-auth-ldap
 
-Activate the Python virtual environment and install the `django-auth-ldap` package using pip:
+=== "Release archive or Git"
 
-```no-highlight
-source /opt/netbox/venv/bin/activate
-pip3 install django-auth-ldap
-```
+    Activate the Python virtual environment and install the `django-auth-ldap` package using pip:
 
-Once installed, add the package to `local_requirements.txt` to ensure it is re-installed during future rebuilds of the virtual environment:
+    ```no-highlight
+    source /opt/netbox/venv/bin/activate
+    pip3 install django-auth-ldap
+    ```
 
-```no-highlight
-sudo sh -c "echo 'django-auth-ldap' >> /opt/netbox/local_requirements.txt"
-```
+    Once installed, add the package to `local_requirements.txt` to ensure it is re-installed during future rebuilds of the virtual environment:
+
+    ```no-highlight
+    sudo sh -c "echo 'django-auth-ldap' >> /opt/netbox/local_requirements.txt"
+    ```
+
+=== "Python package (experimental)"
+
+    Install NetBox's `ldap` optional dependency group, pinned to the installed NetBox version:
+
+    ```no-highlight
+    sudo /opt/netbox/venv/bin/python -m pip install "netbox[ldap]==X.Y.Z"
+    ```
+
+    Specify the `ldap` extra again when upgrading the NetBox package. See the [Python package upgrade procedure](upgrading.md#upgrade-a-python-package-installation-experimental).
 
 ## Configuration
 
@@ -33,7 +45,14 @@ First, enable the LDAP authentication backend in `configuration.py`. (Be sure to
 REMOTE_AUTH_BACKEND = 'netbox.authentication.LDAPBackend'
 ```
 
-Next, create a file in the same directory as `configuration.py` (typically `/opt/netbox/netbox/netbox/`) named `ldap_config.py`. Define all of the parameters required below in `ldap_config.py`. Complete documentation of all `django-auth-ldap` configuration options is included in the project's [official documentation](https://django-auth-ldap.readthedocs.io/).
+Next, create a file named `ldap_config.py` in the same directory as the active `configuration.py`. This is typically `/opt/netbox/netbox/netbox/` for a release archive or Git installation, or `/opt/netbox/conf/` for a Python package installation. Define all of the parameters required below in `ldap_config.py`. Complete documentation of all `django-auth-ldap` configuration options is included in the project's [official documentation](https://django-auth-ldap.readthedocs.io/).
+
+For a Python package installation, protect the file while allowing the NetBox service account to read it:
+
+```no-highlight
+sudo chown root:netbox /opt/netbox/conf/ldap_config.py
+sudo chmod 640 /opt/netbox/conf/ldap_config.py
+```
 
 ### General Server Configuration
 

+ 6 - 1
docs/installation/index.md

@@ -18,11 +18,16 @@ The following sections detail how to set up a new instance of NetBox:
 
 1. [PostgreSQL database](1-postgresql.md)
 2. [Redis](2-redis.md)
-3. [NetBox components](3-netbox.md)
+3. Install the NetBox application using either:
+    * a [release archive or Git checkout](3-netbox.md); or
+    * the [Python package](3b-python-package.md) (experimental)
 4. [Gunicorn](4a-gunicorn.md) or [uWSGI](4b-uwsgi.md)
 5. [HTTP server](5-http-server.md)
 6. [LDAP authentication](6-ldap.md) (optional)
 
+!!! warning "Experimental Python package installation"
+    Installing NetBox from the Python package is experimental in NetBox v4.7 and is not recommended for production use. It is intended for evaluation and feedback. The release archive and Git workflows remain supported and are the established installation methods.
+
 ## Requirements
 
 | Dependency | Supported Versions |

+ 93 - 7
docs/installation/upgrading.md

@@ -22,11 +22,13 @@ block-beta
 !!! warning "Perform a Backup"
     Always be sure to save a backup of your current NetBox deployment prior to starting the upgrade process.
 
-## 1. Review the Release Notes
+## Review the Release Notes
 
 Prior to upgrading your NetBox instance, be sure to carefully review all [release notes](../release-notes/index.md) that have been published since your current version was released. Although the upgrade process typically does not involve additional work, certain releases may introduce breaking or backward-incompatible changes. These are called out in the release notes under the release in which the change went into effect.
 
-## 2. Update Dependencies to Required Versions
+Before proceeding, verify that all installed plugins support the target NetBox release.
+
+## Update Required Dependencies
 
 NetBox requires the following dependencies:
 
@@ -56,7 +58,11 @@ NetBox requires the following dependencies:
 |      3.1       |    3.7     |    3.9     |       10       |    4.0    | [Link](https://github.com/netbox-community/netbox/blob/v3.1.0/docs/installation/index.md) |
 |      3.0       |    3.7     |    3.9     |      9.6       |    4.0    | [Link](https://github.com/netbox-community/netbox/blob/v3.0.0/docs/installation/index.md) |
 
-## 3. Install the Latest Release
+## Upgrade a Release Archive or Git Installation
+
+The following procedure applies to NetBox installations created from a release archive or Git checkout. Complete the preparation steps above, then use the same installation method that was used for the existing deployment.
+
+### 1. Install the Latest Release
 
 As with the initial installation, you can upgrade NetBox by either downloading the latest release package or by checking out the latest production release from the git repository.
 
@@ -71,7 +77,7 @@ ls -ld /opt/netbox /opt/netbox/.git
 
 If NetBox was installed from a release package, then `/opt/netbox` will be a symlink pointing to the current version, and `/opt/netbox/.git` will not exist.  If it was installed from git, then `/opt/netbox` and `/opt/netbox/.git` will both exist as normal directories.
 
-### Option A: Download a Release
+#### Option A: Download a Release
 
 Download the [latest stable release](https://github.com/netbox-community/netbox/releases) from GitHub as a tarball or ZIP archive. Extract it to your desired path. In this example, we'll use `/opt/netbox`.
 
@@ -114,7 +120,7 @@ If you followed the original installation guide to set up gunicorn, be sure to c
 sudo cp /opt/netbox-$OLDVER/gunicorn.py /opt/netbox/
 ```
 
-### Option B: Check Out a Git Release
+#### Option B: Check Out a Git Release
 
 This guide assumes that NetBox is installed in `/opt/netbox`. First, determine the latest release either by visiting our [releases page](https://github.com/netbox-community/netbox/releases) or by running the following command:
 
@@ -133,7 +139,7 @@ sudo git fetch --tags && \
 sudo git checkout v4.5.0
 ```
 
-## 4. Run the Upgrade Script
+### 2. Run the Upgrade Script
 
 Once the new code is in place, verify that any optional Python packages required by your deployment (e.g. `django-auth-ldap`) are listed in `local_requirements.txt`. Then, run the upgrade script:
 
@@ -167,7 +173,7 @@ This script performs the following actions:
     been made to your local codebase and should be investigated. Never attempt to create new migrations unless you are
     intentionally modifying the database schema.
 
-## 5. Restart the NetBox Services
+### 3. Restart the NetBox Services
 
 !!! warning
     If you are upgrading from an installation that does not use a Python virtual environment (any release prior to v2.7.9), you'll need to update the systemd service files to reference the new Python and gunicorn executables before restarting the services. These are located in `/opt/netbox/venv/bin/`. See the example service files in `/opt/netbox/contrib/` for reference.
@@ -177,3 +183,83 @@ Finally, restart the gunicorn and RQ services:
 ```no-highlight
 sudo systemctl restart netbox netbox-rq
 ```
+
+## Upgrade a Python Package Installation (Experimental)
+
+!!! warning "Experimental installation method"
+    Installing NetBox from the Python package is experimental in NetBox v4.7 and is **not recommended for production use**. Test the upgrade and rollback procedures in a non-production environment before relying on them.
+
+This procedure applies only to a deployment created using the [Python package installation method](3b-python-package.md). A package installation does not use `upgrade.sh`; use the installed `netbox upgrade` command instead. For a release archive or Git installation, follow the [procedure above](#upgrade-a-release-archive-or-git-installation).
+
+Complete the preparation steps at the beginning of this page before proceeding.
+
+### 1. Stop the NetBox Services
+
+Stop the web application and background worker services before changing packages in the virtual environment:
+
+```no-highlight
+sudo systemctl stop netbox netbox-rq
+```
+
+### 2. Upgrade NetBox and Local Requirements
+
+Install the target NetBox version into the existing virtual environment. Replace `X.Y.Z` with the exact version being installed:
+
+```no-highlight
+sudo /opt/netbox/venv/bin/python -m pip install --upgrade "netbox==X.Y.Z"
+```
+
+If the deployment uses a package extra, include it in the upgrade command. For example, specify the `ldap` extra again when upgrading a deployment that uses LDAP authentication:
+
+```no-highlight
+sudo /opt/netbox/venv/bin/python -m pip install --upgrade \
+    "netbox[ldap]==X.Y.Z"
+```
+
+Install all plugins and other local Python requirements into the same virtual environment **before** running the NetBox upgrade tasks:
+
+```no-highlight
+sudo /opt/netbox/venv/bin/python -m pip install \
+    -r /opt/netbox/local_requirements.txt
+```
+
+!!! note "Changing the Python version"
+    A virtual environment cannot be moved to a different Python interpreter in place. If the target NetBox release requires another Python version, create a replacement virtual environment, install the target NetBox package and all local requirements into it, and update the service executable paths before restarting NetBox.
+
+### 3. Run the Upgrade Tasks
+
+Run the packaged upgrade command to apply database migrations, collect static files, and perform the remaining application upgrade tasks:
+
+```no-highlight
+sudo -u netbox /opt/netbox/venv/bin/netbox upgrade --no-input
+```
+
+For a non-default instance root or a virtual environment stored elsewhere, use the applicable paths and set `NETBOX_ROOT` explicitly:
+
+```no-highlight
+sudo -u netbox env NETBOX_ROOT=/srv/netbox \
+    /opt/netbox-venv/bin/netbox upgrade --no-input
+```
+
+Ensure that any environment variables referenced by the NetBox configuration are also available when running this command.
+
+### 4. Review the Deployment Configuration
+
+`netbox setup` is not part of a routine upgrade. It leaves existing configuration and deployment examples untouched. To compare the examples bundled with the new package against the local copies without modifying the instance root, scaffold them into a temporary directory:
+
+```no-highlight
+EXAMPLES_DIR=$(mktemp -d)
+/opt/netbox/venv/bin/netbox setup --target "$EXAMPLES_DIR"
+diff --recursive /opt/netbox/contrib "$EXAMPLES_DIR/contrib"
+rm -rf "$EXAMPLES_DIR"
+```
+
+The comparison will also show the package-layout changes made when the deployment examples were first adapted. Distinguish these local changes from updates introduced by the new release, and merge any relevant updates into the administrator-managed systemd, WSGI, and HTTP server configuration.
+
+### 5. Start the NetBox Services
+
+Start the services and verify that both the web application and background workers are operating normally:
+
+```no-highlight
+sudo systemctl start netbox netbox-rq
+```

+ 15 - 2
docs/models/dcim/interface.md

@@ -28,11 +28,17 @@ An alternative physical label identifying the interface.
 
 ### Type
 
-The type of interface. Interfaces may be physical or virtual in nature, but only physical interfaces may be connected via cables.
+The type of interface. Interfaces may be physical or virtual in nature, but only physical interfaces may be connected via cables. The generic **channel** type identifies a [channelized subinterface](#channel-id) bound to a parent interface.
 
 !!! note
     The interface type refers to the physical termination or port on the device. Interfaces which employ a removable optic or similar transceiver should be defined to represent the type of transceiver in use, irrespective of the physical termination to that transceiver.
 
+### Channels
+
+For a channelized (breakout) interface, the number of physical channels into which the interface is divided. For example, a 40GE interface broken out into four 10GE channels would have `channels` set to four. Each channel is modeled as a channel-type subinterface bound to this interface via its [channel ID](#channel-id).
+
+A single physical cable terminates to the channelized (parent) interface, occupying one connector shared by all of its channels; NetBox traces a distinct cable path for each channel subinterface. Only one layer of channelization is supported: an interface cannot be both channelized and itself bound to a channel.
+
 ### Speed
 
 The operating speed, in kilobits per second (kbps).
@@ -78,11 +84,18 @@ If selected, this component will be treated as if a cable has been connected.
 
 ### Parent Interface
 
-Virtual interfaces can be bound to a physical parent interface. This is helpful for modeling virtual interfaces which employ encapsulation on a physical interface, such as an 802.1Q VLAN-tagged subinterface.
+Virtual interfaces can be bound to a physical parent interface. This is helpful for modeling virtual interfaces which employ encapsulation on a physical interface, such as an 802.1Q VLAN-tagged subinterface. Channel-type subinterfaces are likewise bound to their [channelized](#channels) parent interface.
 
 !!! note
     An interface with one or more child interfaces assigned cannot be deleted until all its child interfaces have been deleted or reassigned.
 
+### Channel ID
+
+For a channel-type subinterface, the numeric channel on its [channelized](#channels) parent interface to which this subinterface is bound. The channel ID must fall within the range of channels provided by the parent (e.g. one through four for a parent with four channels). A channel subinterface derives its cable connection from the parent's; it cannot be cabled directly.
+
+!!! note "Channel IDs are one-indexed"
+    Channel IDs increment starting at one, even for interfaces with a zero-based identifier. This ensures that each subinterface maps cleanly to the profile of an attached cable.
+
 ### Bridged Interface
 
 Interfaces can be bridged to other interfaces on a device in two manners: symmetric or grouped.

+ 1 - 1
docs/plugins/development/config-templates.md

@@ -1,6 +1,6 @@
 # Jinja Config Templates
 
-NetBox uses [Jinja](https://jinja.palletsprojects.com/) to render [configuration templates](../../features/configuration-rendering.md#configuration-templates). Plugins can extend this rendering pipeline in two complementary ways:
+NetBox uses [Jinja](https://jinja.palletsprojects.com/) to render [configuration templates](../../features/configuration-rendering.md). Plugins can extend this rendering pipeline in two complementary ways:
 
 1. **Register custom filters** — make new template filters available by name in every config template.
 2. **Inject context variables** — add extra variables that are available inside every config template render.

+ 90 - 2
docs/plugins/development/graphql-api.md

@@ -25,9 +25,9 @@ class MyModelType:
 @strawberry.type
 class MyQuery:
     @strawberry.field
-    def dummymodel(self, id: int) -> DummyModelType:
+    def mymodel(self, id: int) -> MyModelType:
         return None
-    dummymodel_list: list[DummyModelType] = strawberry_django.field()
+    mymodel_list: list[MyModelType] = strawberry_django.field()
 
 
 schema = [
@@ -35,6 +35,94 @@ schema = [
 ]
 ```
 
+## Extending Core Types & Filters
+
+!!! info "This feature was introduced in NetBox v4.6."
+
+In addition to registering its own top-level query fields, a plugin can inject fields and filters onto NetBox's **existing** core GraphQL types (e.g. `DeviceType`). This allows a plugin's related data to be traversed within a single query rooted at a core object, rather than requiring a separate top-level query. This mirrors the `PluginTemplateExtension` mechanism used to extend core object views in the UI.
+
+An extension is a mixin class declaring a `models` attribute: a list of the lowercased `app_label.model` labels of the core types it extends. Output-type extensions are collected from `graphql.type_extensions` and filter extensions from `graphql.filter_extensions` by default; these paths can be overridden via the `graphql_type_extensions` and `graphql_filter_extensions` attributes on the PluginConfig.
+
+Each declared path must resolve to a list named `type_extensions` (or `filter_extensions`) - for example, defined in `graphql.py` alongside the schema, or re-exported from the plugin's `graphql` package.
+
+!!! warning
+    Do not import core GraphQL modules (e.g. `dcim.graphql.types`) from a plugin's `ready()`. Doing so assembles the affected core types before other plugins have registered their extensions, which are then silently dropped. A warning is logged under `netbox.graphql` if this occurs.
+
+### Type Extensions
+
+An output-type extension is a `@strawberry.type` class whose fields and resolvers are spliced into the target type:
+
+```python
+# graphql.py (or graphql/type_extensions.py)
+from typing import Annotated
+
+import strawberry
+import strawberry_django
+
+from utilities.querysets import RestrictedPrefetch
+from my_plugin.models import Widget
+
+
+@strawberry.type
+class DeviceTypeExtension:
+    models = ['dcim.device']
+
+    @strawberry_django.field(
+        prefetch_related=lambda info: RestrictedPrefetch(
+            'widgets', info.context.request.user, 'view', queryset=Widget.objects.all()
+        ),
+    )
+    def widgets(self) -> list[Annotated['WidgetType', strawberry.lazy('my_plugin.graphql.types')]]:
+        return self.widgets.all()
+
+
+type_extensions = [
+    DeviceTypeExtension,
+]
+```
+
+!!! note
+    Scope any related-object resolver with `RestrictedPrefetch(..., info.context.request.user, 'view', ...)`, as shown above. Object permissions are only applied to the top-level queryset, so a plain `prefetch_related='widgets'` returns related objects the requesting user may not be permitted to see.
+
+### Filter Extensions
+
+A filter extension is a `@strawberry.type` class declaring additional filters - either as annotated filter fields or as custom filter methods - which are spliced into the target filter:
+
+```python
+# graphql.py (or graphql/filter_extensions.py)
+import strawberry
+import strawberry_django
+from django.db.models import Q
+
+
+@strawberry.type
+class DeviceFilterExtension:
+    models = ['dcim.device']
+
+    @strawberry_django.filter_field()
+    def has_widgets(self, value: bool, prefix) -> Q:
+        return Q(**{f'{prefix}widgets__isnull': not value})
+
+
+filter_extensions = [
+    DeviceFilterExtension,
+]
+```
+
+With both registered, a client can fetch a device and its plugin-provided data in a single query:
+
+```graphql
+query {
+  device_list(filters: { has_widgets: true }) {
+    name
+    widgets { id name }
+  }
+}
+```
+
+!!! note
+    Extensions are strictly additive: they can only add new fields, never replace existing ones. If an extension declares a name the core type already provides, the core definition always takes precedence and the extension's version is ignored. If two extensions on the same type declare the same new name, the one whose plugin is loaded first (earlier in `PLUGINS`) wins. Both cases are logged as warnings under the `netbox.graphql` logger.
+
 ## GraphQL Objects
 
 NetBox provides two object type classes for use by plugins.

+ 2 - 0
docs/plugins/development/index.md

@@ -123,6 +123,8 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i
 | `menu`                | The dotted path to a top-level navigation menu provided by the plugin (default: `navigation.menu`)                                 |
 | `menu_items`          | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`)                                |
 | `graphql_schema`      | The dotted path to the plugin's GraphQL schema class, if any (default: `graphql.schema`)                                           |
+| `graphql_type_extensions` | The dotted path to the list of GraphQL output-type extension classes, if any (default: `graphql.type_extensions`)              |
+| `graphql_filter_extensions` | The dotted path to the list of GraphQL filter extension classes, if any (default: `graphql.filter_extensions`)              |
 | `user_preferences`    | The dotted path to the dictionary mapping of user preferences defined by the plugin (default: `preferences.preferences`)           |
 
 All required settings must be configured by the user. If a configuration parameter is listed in both `required_settings` and `default_settings`, the default setting will be ignored.

+ 2 - 1
mkdocs.yml

@@ -98,7 +98,8 @@ nav:
         - Installing NetBox: 'installation/index.md'
         - 1. PostgreSQL: 'installation/1-postgresql.md'
         - 2. Redis: 'installation/2-redis.md'
-        - 3. NetBox: 'installation/3-netbox.md'
+        - 3a. Release Archive or Git: 'installation/3-netbox.md'
+        - 3b. Python Package (Experimental): 'installation/3b-python-package.md'
         - 4a. Gunicorn: 'installation/4a-gunicorn.md'
         - 4b. uWSGI: 'installation/4b-uwsgi.md'
         - 5. HTTP Server: 'installation/5-http-server.md'

+ 17 - 12
netbox/circuits/graphql/filters.py

@@ -10,7 +10,12 @@ from circuits.graphql.filter_mixins import CircuitTypeFilterMixin
 from dcim.graphql.filter_mixins import CabledObjectModelFilterMixin
 from extras.graphql.filter_mixins import CustomFieldsFilterMixin, TagsFilterMixin
 from netbox.graphql.filter_mixins import DistanceFilterMixin, ImageAttachmentFilterMixin
-from netbox.graphql.filters import ChangeLoggedModelFilter, OrganizationalModelFilter, PrimaryModelFilter
+from netbox.graphql.filters import (
+    ChangeLoggedModelFilter,
+    OrganizationalModelFilter,
+    PrimaryModelFilter,
+    register_filter,
+)
 from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
 
 if TYPE_CHECKING:
@@ -36,7 +41,7 @@ __all__ = (
 )
 
 
-@strawberry_django.filter_type(models.CircuitTermination, lookups=True)
+@register_filter(models.CircuitTermination, lookups=True)
 class CircuitTerminationFilter(
     CustomFieldsFilterMixin,
     TagsFilterMixin,
@@ -83,7 +88,7 @@ class CircuitTerminationFilter(
     )
 
 
-@strawberry_django.filter_type(models.Circuit, lookups=True)
+@register_filter(models.Circuit, lookups=True)
 class CircuitFilter(
     ContactFilterMixin,
     ImageAttachmentFilterMixin,
@@ -117,17 +122,17 @@ class CircuitFilter(
     )
 
 
-@strawberry_django.filter_type(models.CircuitType, lookups=True)
+@register_filter(models.CircuitType, lookups=True)
 class CircuitTypeFilter(CircuitTypeFilterMixin, OrganizationalModelFilter):
     pass
 
 
-@strawberry_django.filter_type(models.CircuitGroup, lookups=True)
+@register_filter(models.CircuitGroup, lookups=True)
 class CircuitGroupFilter(TenancyFilterMixin, OrganizationalModelFilter):
     pass
 
 
-@strawberry_django.filter_type(models.CircuitGroupAssignment, lookups=True)
+@register_filter(models.CircuitGroupAssignment, lookups=True)
 class CircuitGroupAssignmentFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter):
     member_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
         strawberry_django.filter_field()
@@ -142,7 +147,7 @@ class CircuitGroupAssignmentFilter(CustomFieldsFilterMixin, TagsFilterMixin, Cha
     )
 
 
-@strawberry_django.filter_type(models.Provider, lookups=True)
+@register_filter(models.Provider, lookups=True)
 class ProviderFilter(ContactFilterMixin, PrimaryModelFilter):
     name: StrFilterLookup | None = strawberry_django.filter_field()
     slug: StrFilterLookup | None = strawberry_django.filter_field()
@@ -152,7 +157,7 @@ class ProviderFilter(ContactFilterMixin, PrimaryModelFilter):
     )
 
 
-@strawberry_django.filter_type(models.ProviderAccount, lookups=True)
+@register_filter(models.ProviderAccount, lookups=True)
 class ProviderAccountFilter(ContactFilterMixin, PrimaryModelFilter):
     provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
         strawberry_django.filter_field()
@@ -162,7 +167,7 @@ class ProviderAccountFilter(ContactFilterMixin, PrimaryModelFilter):
     name: StrFilterLookup | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.ProviderNetwork, lookups=True)
+@register_filter(models.ProviderNetwork, lookups=True)
 class ProviderNetworkFilter(PrimaryModelFilter):
     name: StrFilterLookup | None = strawberry_django.filter_field()
     provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
@@ -172,12 +177,12 @@ class ProviderNetworkFilter(PrimaryModelFilter):
     service_id: StrFilterLookup | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.VirtualCircuitType, lookups=True)
+@register_filter(models.VirtualCircuitType, lookups=True)
 class VirtualCircuitTypeFilter(CircuitTypeFilterMixin, OrganizationalModelFilter):
     pass
 
 
-@strawberry_django.filter_type(models.VirtualCircuit, lookups=True)
+@register_filter(models.VirtualCircuit, lookups=True)
 class VirtualCircuitFilter(TenancyFilterMixin, PrimaryModelFilter):
     cid: StrFilterLookup | None = strawberry_django.filter_field()
     provider_network: Annotated['ProviderNetworkFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
@@ -200,7 +205,7 @@ class VirtualCircuitFilter(TenancyFilterMixin, PrimaryModelFilter):
     )
 
 
-@strawberry_django.filter_type(models.VirtualCircuitTermination, lookups=True)
+@register_filter(models.VirtualCircuitTermination, lookups=True)
 class VirtualCircuitTerminationFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter):
     virtual_circuit: Annotated['VirtualCircuitFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
         strawberry_django.filter_field()

+ 12 - 12
netbox/circuits/graphql/types.py

@@ -6,7 +6,7 @@ import strawberry_django
 from circuits import models
 from dcim.graphql.mixins import CabledObjectMixin
 from extras.graphql.mixins import ContactsMixin, CustomFieldsMixin, TagsMixin
-from netbox.graphql.types import BaseObjectType, ObjectType, OrganizationalObjectType, PrimaryObjectType
+from netbox.graphql.types import BaseObjectType, ObjectType, OrganizationalObjectType, PrimaryObjectType, register_type
 from tenancy.graphql.types import TenantType
 
 from .filters import *
@@ -30,7 +30,7 @@ __all__ = (
 )
 
 
-@strawberry_django.type(
+@register_type(
     models.Provider,
     fields='__all__',
     filters=ProviderFilter,
@@ -43,7 +43,7 @@ class ProviderType(ContactsMixin, PrimaryObjectType):
     accounts: list[Annotated["ProviderAccountType", strawberry.lazy('circuits.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.ProviderAccount,
     fields='__all__',
     filters=ProviderAccountFilter,
@@ -54,7 +54,7 @@ class ProviderAccountType(ContactsMixin, PrimaryObjectType):
     circuits: list[Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.ProviderNetwork,
     fields='__all__',
     filters=ProviderNetworkFilter,
@@ -65,7 +65,7 @@ class ProviderNetworkType(PrimaryObjectType):
     circuit_terminations: list[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.CircuitTermination,
     exclude=['termination_type', 'termination_id', '_location', '_region', '_site', '_site_group', '_provider_network'],
     filters=CircuitTerminationFilter,
@@ -86,7 +86,7 @@ class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, Ob
         return self.termination
 
 
-@strawberry_django.type(
+@register_type(
     models.CircuitType,
     fields='__all__',
     filters=CircuitTypeFilter,
@@ -98,7 +98,7 @@ class CircuitTypeType(OrganizationalObjectType):
     circuits: list[Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.Circuit,
     fields='__all__',
     filters=CircuitFilter,
@@ -114,7 +114,7 @@ class CircuitType(PrimaryObjectType, ContactsMixin):
     terminations: list[CircuitTerminationType]
 
 
-@strawberry_django.type(
+@register_type(
     models.CircuitGroup,
     fields='__all__',
     filters=CircuitGroupFilter,
@@ -124,7 +124,7 @@ class CircuitGroupType(OrganizationalObjectType):
     tenant: TenantType | None
 
 
-@strawberry_django.type(
+@register_type(
     models.CircuitGroupAssignment,
     exclude=['member_type', 'member_id'],
     filters=CircuitGroupAssignmentFilter,
@@ -142,7 +142,7 @@ class CircuitGroupAssignmentType(TagsMixin, BaseObjectType):
         return self.member
 
 
-@strawberry_django.type(
+@register_type(
     models.VirtualCircuitType,
     fields='__all__',
     filters=VirtualCircuitTypeFilter,
@@ -154,7 +154,7 @@ class VirtualCircuitTypeType(OrganizationalObjectType):
     virtual_circuits: list[Annotated["VirtualCircuitType", strawberry.lazy('circuits.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.VirtualCircuitTermination,
     fields='__all__',
     filters=VirtualCircuitTerminationFilter,
@@ -171,7 +171,7 @@ class VirtualCircuitTerminationType(CustomFieldsMixin, TagsMixin, ObjectType):
     ] = strawberry_django.field(select_related=["interface"])
 
 
-@strawberry_django.type(
+@register_type(
     models.VirtualCircuit,
     fields='__all__',
     filters=VirtualCircuitFilter,

+ 1 - 0
netbox/core/apps.py

@@ -32,6 +32,7 @@ class CoreConfig(AppConfig):
         from core.checks import check_duplicate_indexes, check_postgresql_version, check_redis_version  # noqa: F401
         from netbox import context_managers  # noqa: F401
         from netbox.models.features import register_models
+        from netbox.search import signals as search_signals  # noqa: F401
 
         from . import data_backends, events, search  # noqa: F401
 

+ 5 - 5
netbox/core/graphql/filters.py

@@ -7,7 +7,7 @@ from strawberry.scalars import ID
 from strawberry_django import BaseFilterLookup, DatetimeFilterLookup, FilterLookup, StrFilterLookup
 
 from core import models
-from netbox.graphql.filters import BaseModelFilter, PrimaryModelFilter
+from netbox.graphql.filters import BaseModelFilter, PrimaryModelFilter, register_filter
 
 from .enums import *
 
@@ -23,7 +23,7 @@ __all__ = (
 )
 
 
-@strawberry_django.filter_type(models.DataFile, lookups=True)
+@register_filter(models.DataFile, lookups=True)
 class DataFileFilter(BaseModelFilter):
     created: DatetimeFilterLookup | None = strawberry_django.filter_field()
     last_updated: DatetimeFilterLookup | None = strawberry_django.filter_field()
@@ -38,7 +38,7 @@ class DataFileFilter(BaseModelFilter):
     hash: StrFilterLookup | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.DataSource, lookups=True)
+@register_filter(models.DataSource, lookups=True)
 class DataSourceFilter(PrimaryModelFilter):
     name: StrFilterLookup | None = strawberry_django.filter_field()
     type: StrFilterLookup | None = strawberry_django.filter_field()
@@ -57,7 +57,7 @@ class DataSourceFilter(PrimaryModelFilter):
     )
 
 
-@strawberry_django.filter_type(models.ObjectChange, lookups=True)
+@register_filter(models.ObjectChange, lookups=True)
 class ObjectChangeFilter(BaseModelFilter):
     time: DatetimeFilterLookup | None = strawberry_django.filter_field()
     user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
@@ -84,7 +84,7 @@ class ObjectChangeFilter(BaseModelFilter):
     )
 
 
-@strawberry_django.filter_type(DjangoContentType, lookups=True)
+@register_filter(DjangoContentType, lookups=True)
 class ContentTypeFilter(BaseModelFilter):
     app_label: StrFilterLookup | None = strawberry_django.filter_field()
     model: StrFilterLookup | None = strawberry_django.filter_field()

+ 5 - 6
netbox/core/graphql/types.py

@@ -1,11 +1,10 @@
 from typing import Annotated
 
 import strawberry
-import strawberry_django
 from django.contrib.contenttypes.models import ContentType as DjangoContentType
 
 from core import models
-from netbox.graphql.types import BaseObjectType, PrimaryObjectType
+from netbox.graphql.types import BaseObjectType, PrimaryObjectType, register_type
 
 from .filters import *
 
@@ -17,7 +16,7 @@ __all__ = (
 )
 
 
-@strawberry_django.type(
+@register_type(
     models.DataFile,
     exclude=['data',],
     filters=DataFileFilter,
@@ -27,7 +26,7 @@ class DataFileType(BaseObjectType):
     source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')]
 
 
-@strawberry_django.type(
+@register_type(
     models.DataSource,
     fields='__all__',
     filters=DataSourceFilter,
@@ -37,7 +36,7 @@ class DataSourceType(PrimaryObjectType):
     datafiles: list[Annotated["DataFileType", strawberry.lazy('core.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.ObjectChange,
     fields='__all__',
     filters=ObjectChangeFilter,
@@ -47,7 +46,7 @@ class ObjectChangeType(BaseObjectType):
     pass
 
 
-@strawberry_django.type(
+@register_type(
     DjangoContentType,
     fields='__all__',
     pagination=True

+ 11 - 5
netbox/core/models/config.py

@@ -1,10 +1,10 @@
 from django.core.cache import cache
-from django.db import models
+from django.db import models, router, transaction
 from django.urls import reverse
 from django.utils.translation import gettext
 from django.utils.translation import gettext_lazy as _
 
-from utilities.querysets import RestrictedQuerySet
+from utilities.querysets import RestrictedQuerySet, chunked_update
 
 __all__ = (
     'ConfigRevision',
@@ -78,9 +78,15 @@ class ConfigRevision(models.Model):
         cache.set('config_version', self.pk, None)
 
         if update_db:
-            # Set all instances of ConfigRevision to false and set this instance to true
-            ConfigRevision.objects.all().update(active=False)
-            ConfigRevision.objects.filter(pk=self.pk).update(active=True)
+            # Set all instances of ConfigRevision to false and set this instance to true. Wrap both
+            # statements in a transaction so the "exactly one active revision" invariant is preserved
+            # even when the deactivation is chunked into multiple statements. Resolve the write alias
+            # once and pin the transaction and both querysets to it, so the transaction genuinely
+            # covers the (potentially router-directed) writes performed by chunked_update().
+            using = router.db_for_write(ConfigRevision)
+            with transaction.atomic(using=using):
+                chunked_update(ConfigRevision.objects.using(using).all(), active=False)
+                ConfigRevision.objects.using(using).filter(pk=self.pk).update(active=True)
 
     activate.alters_data = True
 

+ 3 - 1
netbox/core/models/data.py

@@ -220,7 +220,9 @@ class DataSource(JobsMixin, PrimaryModel):
                     continue
 
             # Bulk update modified files
-            updated_count = DataFile.objects.bulk_update(updated_files, ('last_updated', 'size', 'hash', 'data'))
+            updated_count = DataFile.objects.bulk_update(
+                updated_files, ('last_updated', 'size', 'hash', 'data'), batch_size=settings.BULK_UPDATE_CHUNK_SIZE
+            )
             logger.debug(f"Updated {updated_count} files")
 
             # Bulk delete deleted files

+ 2 - 1
netbox/dcim/api/serializers_/device_components.py

@@ -263,7 +263,8 @@ class InterfaceSerializer(
     class Meta:
         model = Interface
         fields = [
-            'id', 'url', 'display_url', 'display', 'device', 'vdcs', 'module', 'name', 'label', 'type', 'enabled',
+            'id', 'url', 'display_url', 'display', 'device', 'vdcs', 'module', 'name', 'label', 'type', 'channels',
+            'channel_id', 'enabled',
             'parent', 'bridge', 'bridge_interfaces', 'lag', 'mtu', 'mac_address', 'primary_mac_address',
             'mac_addresses', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel',
             'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan',

+ 7 - 2
netbox/dcim/api/serializers_/devicetype_components.py

@@ -184,6 +184,10 @@ class InterfaceTemplateSerializer(ComponentTemplateSerializer):
         default=None
     )
     type = ChoiceField(choices=InterfaceTypeChoices)
+    parent = NestedInterfaceTemplateSerializer(
+        required=False,
+        allow_null=True
+    )
     bridge = NestedInterfaceTemplateSerializer(
         required=False,
         allow_null=True
@@ -210,8 +214,9 @@ class InterfaceTemplateSerializer(ComponentTemplateSerializer):
     class Meta:
         model = InterfaceTemplate
         fields = [
-            'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'enabled',
-            'mgmt_only', 'description', 'bridge', 'poe_mode', 'poe_type', 'rf_role', 'created', 'last_updated',
+            'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'channels', 'channel_id',
+            'enabled', 'mgmt_only', 'description', 'parent', 'bridge', 'poe_mode', 'poe_type', 'rf_role', 'created',
+            'last_updated',
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'description')
 

+ 2 - 0
netbox/dcim/choices.py

@@ -914,6 +914,7 @@ class InterfaceTypeChoices(ChoiceSet):
     TYPE_VIRTUAL = 'virtual'
     TYPE_BRIDGE = 'bridge'
     TYPE_LAG = 'lag'
+    TYPE_CHANNEL = 'channel'
 
     # FastEthernet
     TYPE_100ME_FX = '100base-fx'
@@ -1185,6 +1186,7 @@ class InterfaceTypeChoices(ChoiceSet):
                 Choice(TYPE_VIRTUAL, _('Virtual')),
                 Choice(TYPE_BRIDGE, _('Bridge')),
                 Choice(TYPE_LAG, _('Link Aggregation Group (LAG)')),
+                Choice(TYPE_CHANNEL, _('Channel')),
             ),
         ),
         (

+ 10 - 1
netbox/dcim/constants.py

@@ -48,6 +48,12 @@ PORT_POSITION_MAX = 1024
 INTERFACE_MTU_MIN = 1
 INTERFACE_MTU_MAX = 65536
 
+# The number of channels on a channelized interface, and the channel to which a subinterface is bound. A subinterface's
+# channel_id maps directly to a position on the parent interface's cable connector, so these are bounded by the maximum
+# cable position.
+INTERFACE_CHANNELS_MIN = CABLE_POSITION_MIN
+INTERFACE_CHANNELS_MAX = CABLE_POSITION_MAX
+
 VIRTUAL_IFACE_TYPES = [
     InterfaceTypeChoices.TYPE_VIRTUAL,
     InterfaceTypeChoices.TYPE_LAG,
@@ -73,7 +79,10 @@ WIRELESS_IFACE_TYPES = [
     InterfaceTypeChoices.TYPE_5G,
 ]
 
-NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
+NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES + [
+    # Channel subinterfaces derive their cable from the (channelized) parent interface and cannot be cabled directly
+    InterfaceTypeChoices.TYPE_CHANNEL,
+]
 
 
 #

+ 9 - 4
netbox/dcim/filtersets.py

@@ -1058,6 +1058,11 @@ class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
         distinct=False,
         null_value=None
     )
+    parent_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='parent',
+        queryset=InterfaceTemplate.objects.all(),
+        distinct=False,
+    )
     bridge_id = django_filters.ModelMultipleChoiceFilter(
         field_name='bridge',
         queryset=InterfaceTemplate.objects.all(),
@@ -1078,7 +1083,7 @@ class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
 
     class Meta:
         model = InterfaceTemplate
-        fields = ('id', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description')
+        fields = ('id', 'name', 'label', 'type', 'channels', 'channel_id', 'enabled', 'mgmt_only', 'description')
 
 
 @register_filterset
@@ -2416,9 +2421,9 @@ class InterfaceFilterSet(
     class Meta:
         model = Interface
         fields = (
-            'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'poe_mode', 'poe_type', 'mode', 'rf_role',
-            'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected',
-            'cable_id', 'cable_end', 'cable_connector',
+            'id', 'name', 'label', 'type', 'channels', 'channel_id', 'enabled', 'mtu', 'mgmt_only', 'poe_mode',
+            'poe_type', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
+            'description', 'mark_connected', 'cable_id', 'cable_end', 'cable_connector',
         )
 
     def filter_virtual_chassis_member_or_master(self, queryset, name, value):

+ 17 - 9
netbox/dcim/forms/bulk_edit.py

@@ -1191,6 +1191,14 @@ class InterfaceTemplateBulkEditForm(ComponentTemplateBulkEditForm):
         choices=add_blank_choice(InterfaceTypeChoices),
         required=False
     )
+    channels = forms.IntegerField(
+        label=_('Channels'),
+        required=False
+    )
+    channel_id = forms.IntegerField(
+        label=_('Channel ID'),
+        required=False
+    )
     enabled = forms.NullBooleanField(
         label=_('Enabled'),
         required=False,
@@ -1224,7 +1232,7 @@ class InterfaceTemplateBulkEditForm(ComponentTemplateBulkEditForm):
         label=_('Wireless role')
     )
 
-    nullable_fields = ('label', 'description', 'poe_mode', 'poe_type', 'rf_role')
+    nullable_fields = ('label', 'channels', 'channel_id', 'description', 'poe_mode', 'poe_type', 'rf_role')
 
 
 class FrontPortTemplateBulkEditForm(ComponentTemplateBulkEditForm):
@@ -1477,9 +1485,9 @@ class PowerOutletBulkEditForm(
 class InterfaceBulkEditForm(
     ComponentBulkEditForm,
     form_from_model(Interface, [
-        'label', 'type', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'wwn', 'mtu', 'mgmt_only', 'mark_connected',
-        'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
-        'wireless_lans', 'vlan_translation_policy'
+        'label', 'type', 'channels', 'channel_id', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'wwn', 'mtu',
+        'mgmt_only', 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency',
+        'rf_channel_width', 'tx_power', 'wireless_lans', 'vlan_translation_policy'
     ])
 ):
     enabled = forms.NullBooleanField(
@@ -1627,11 +1635,11 @@ class InterfaceBulkEditForm(
 
     model = Interface
     fieldsets = (
-        FieldSet('module', 'type', 'label', 'speed', 'duplex', 'description'),
+        FieldSet('module', 'type', 'channels', 'label', 'speed', 'duplex', 'description'),
         FieldSet('vrf', 'wwn', name=_('Addressing')),
         FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')),
         FieldSet('poe_mode', 'poe_type', name=_('PoE')),
-        FieldSet('parent', 'bridge', 'lag', name=_('Related Interfaces')),
+        FieldSet('parent', 'channel_id', 'bridge', 'lag', name=_('Related Interfaces')),
         FieldSet(
             'mode', 'vlan_group', 'untagged_vlan', 'qinq_svlan', 'vlan_translation_policy', name=_('802.1Q Switching')
         ),
@@ -1647,9 +1655,9 @@ class InterfaceBulkEditForm(
         ),
     )
     nullable_fields = (
-        'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'wwn', 'vdcs', 'mtu', 'description',
-        'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
-        'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vrf', 'wireless_lans', 'vlan_translation_policy',
+        'module', 'label', 'channels', 'channel_id', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'wwn', 'vdcs',
+        'mtu', 'description', 'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width',
+        'tx_power', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vrf', 'wireless_lans', 'vlan_translation_policy',
     )
 
     def __init__(self, *args, **kwargs):

+ 3 - 3
netbox/dcim/forms/bulk_import.py

@@ -1080,9 +1080,9 @@ class InterfaceImportForm(OwnerCSVMixin, NetBoxModelImportForm):
     class Meta:
         model = Interface
         fields = (
-            'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled',
-            'mark_connected', 'wwn', 'vdcs', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'mode',
-            'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vrf', 'rf_role', 'rf_channel',
+            'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'channels', 'channel_id', 'speed', 'duplex',
+            'enabled', 'mark_connected', 'wwn', 'vdcs', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type',
+            'mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vrf', 'rf_role', 'rf_channel',
             'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'owner', 'tags'
         )
 

+ 12 - 1
netbox/dcim/forms/filtersets.py

@@ -1687,7 +1687,10 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
     model = Interface
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag'),
-        FieldSet('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only', name=_('Attributes')),
+        FieldSet(
+            'name', 'label', 'kind', 'type', 'channels', 'channel_id', 'speed', 'duplex', 'enabled', 'mgmt_only',
+            name=_('Attributes')
+        ),
         FieldSet('vrf_id', 'l2vpn_id', 'mac_address', 'wwn', name=_('Addressing')),
         FieldSet('poe_mode', 'poe_type', name=_('PoE')),
         FieldSet('mode', 'vlan_translation_policy_id', name=_('802.1Q Switching')),
@@ -1720,6 +1723,14 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
         choices=InterfaceTypeChoices,
         required=False
     )
+    channels = forms.IntegerField(
+        label=_('Channels'),
+        required=False
+    )
+    channel_id = forms.IntegerField(
+        label=_('Channel ID'),
+        required=False
+    )
     speed = PositiveBigIntegerField(
         label=_('Speed'),
         required=False,

+ 21 - 10
netbox/dcim/forms/model_forms.py

@@ -1391,6 +1391,15 @@ class InterfaceTemplateForm(ModularComponentTemplateForm):
         choices=add_blank_choice(WirelessRoleChoices),
         required=False,
     )
+    parent = DynamicModelChoiceField(
+        label=_('Parent'),
+        queryset=InterfaceTemplate.objects.all(),
+        required=False,
+        query_params={
+            'device_type_id': '$device_type',
+            'module_type_id': '$module_type',
+        }
+    )
     bridge = DynamicModelChoiceField(
         label=_('Bridge'),
         queryset=InterfaceTemplate.objects.all(),
@@ -1407,7 +1416,8 @@ class InterfaceTemplateForm(ModularComponentTemplateForm):
                 FieldSet('device_type', name=_('Device Type')),
                 FieldSet('module_type', name=_('Module Type')),
             ),
-            'name', 'label', 'type', 'enabled', 'mgmt_only', 'description', 'bridge',
+            'name', 'label', 'type', 'channels', 'channel_id', 'enabled', 'mgmt_only', 'description', 'parent',
+            'bridge',
         ),
         FieldSet('poe_mode', 'poe_type', name=_('PoE')),
         FieldSet('rf_role', name=_('Wireless')),
@@ -1416,8 +1426,8 @@ class InterfaceTemplateForm(ModularComponentTemplateForm):
     class Meta:
         model = InterfaceTemplate
         fields = [
-            'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'enabled', 'description', 'poe_mode',
-            'poe_type', 'bridge', 'rf_role',
+            'device_type', 'module_type', 'name', 'label', 'type', 'channels', 'channel_id', 'mgmt_only', 'enabled',
+            'description', 'poe_mode', 'poe_type', 'parent', 'bridge', 'rf_role',
         ]
 
 
@@ -1939,11 +1949,12 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
 
     fieldsets = (
         FieldSet(
-            'device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'description', 'tags', name=_('Interface')
+            'device', 'module', 'name', 'label', 'type', 'channels', 'speed', 'duplex', 'description', 'tags',
+            name=_('Interface')
         ),
         FieldSet('vrf', 'mac_address', 'wwn', name=_('Addressing')),
         FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')),
-        FieldSet('parent', 'bridge', 'lag', name=_('Related Interfaces')),
+        FieldSet('parent', 'channel_id', 'bridge', 'lag', name=_('Related Interfaces')),
         FieldSet('poe_mode', 'poe_type', name=_('PoE')),
         FieldSet(
             'mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy',
@@ -1959,11 +1970,11 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
     class Meta:
         model = Interface
         fields = [
-            'device', 'module', 'vdcs', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge',
-            'lag', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'poe_mode', 'poe_type', 'mode',
-            'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans',
-            'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'vrf',
-            'owner', 'tags',
+            'device', 'module', 'vdcs', 'name', 'label', 'type', 'channels', 'channel_id', 'speed', 'duplex',
+            'enabled', 'parent', 'bridge', 'lag', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description',
+            'poe_mode', 'poe_type', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width',
+            'tx_power', 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy',
+            'vrf', 'owner', 'tags',
         ]
         widgets = {
             'speed': NumberWithOptions(

+ 18 - 3
netbox/dcim/forms/object_create.py

@@ -4,7 +4,12 @@ from django.utils.translation import gettext_lazy as _
 from dcim.models import *
 from netbox.forms import NetBoxModelForm
 from netbox.forms.mixins import OwnerMixin
-from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField
+from utilities.forms.fields import (
+    DynamicModelChoiceField,
+    DynamicModelMultipleChoiceField,
+    ExpandableNameField,
+    ExpandableNumericField,
+)
 from utilities.forms.rendering import FieldSet, TabbedGroups
 from utilities.forms.widgets import APISelect
 
@@ -105,9 +110,14 @@ class PowerOutletTemplateCreateForm(ComponentCreateForm, model_forms.PowerOutlet
 
 
 class InterfaceTemplateCreateForm(ComponentCreateForm, model_forms.InterfaceTemplateForm):
+    channel_id = ExpandableNumericField(
+        label=_('Channel ID'),
+        required=False
+    )
+    replication_fields = ('name', 'label', 'channel_id')
 
     class Meta(model_forms.InterfaceTemplateForm.Meta):
-        exclude = ('name', 'label')
+        exclude = ('name', 'label', 'channel_id')
 
 
 class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemplateForm):
@@ -197,9 +207,14 @@ class PowerOutletCreateForm(ComponentCreateForm, model_forms.PowerOutletForm):
 
 
 class InterfaceCreateForm(ComponentCreateForm, model_forms.InterfaceForm):
+    channel_id = ExpandableNumericField(
+        label=_('Channel ID'),
+        required=False
+    )
+    replication_fields = ('name', 'label', 'channel_id')
 
     class Meta(model_forms.InterfaceForm.Meta):
-        exclude = ('name', 'label')
+        exclude = ('name', 'label', 'channel_id')
 
 
 class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):

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

@@ -104,8 +104,8 @@ class InterfaceTemplateImportForm(forms.ModelForm):
     class Meta:
         model = InterfaceTemplate
         fields = [
-            'device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description', 'poe_mode',
-            'poe_type', 'rf_role'
+            'device_type', 'module_type', 'name', 'label', 'type', 'channels', 'channel_id', 'enabled', 'mgmt_only',
+            'description', 'poe_mode', 'poe_type', 'rf_role'
         ]
 
 

+ 66 - 49
netbox/dcim/graphql/filters.py

@@ -31,6 +31,7 @@ from netbox.graphql.filters import (
     NetBoxModelFilter,
     OrganizationalModelFilter,
     PrimaryModelFilter,
+    register_filter,
 )
 from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
 from virtualization.models import VMInterface
@@ -121,12 +122,12 @@ __all__ = (
 )
 
 
-@strawberry_django.filter_type(models.CableBundle, lookups=True)
+@register_filter(models.CableBundle, lookups=True)
 class CableBundleFilter(PrimaryModelFilter):
     name: StrFilterLookup | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.Cable, lookups=True)
+@register_filter(models.Cable, lookups=True)
 class CableFilter(TenancyFilterMixin, PrimaryModelFilter):
     type: BaseFilterLookup[Annotated['CableTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
@@ -149,7 +150,7 @@ class CableFilter(TenancyFilterMixin, PrimaryModelFilter):
     )
 
 
-@strawberry_django.filter_type(models.CableTermination, lookups=True)
+@register_filter(models.CableTermination, lookups=True)
 class CableTerminationFilter(ChangeLoggedModelFilter):
     cable: Annotated['CableFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     cable_id: ID | None = strawberry_django.filter_field()
@@ -176,7 +177,7 @@ class CableTerminationFilter(ChangeLoggedModelFilter):
     )
 
 
-@strawberry_django.filter_type(models.ConsolePort, lookups=True)
+@register_filter(models.ConsolePort, lookups=True)
 class ConsolePortFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin, NetBoxModelFilter):
     type: BaseFilterLookup[Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
@@ -186,14 +187,14 @@ class ConsolePortFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixi
     )
 
 
-@strawberry_django.filter_type(models.ConsolePortTemplate, lookups=True)
+@register_filter(models.ConsolePortTemplate, lookups=True)
 class ConsolePortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
     type: BaseFilterLookup[Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
     )
 
 
-@strawberry_django.filter_type(models.ConsoleServerPort, lookups=True)
+@register_filter(models.ConsoleServerPort, lookups=True)
 class ConsoleServerPortFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin, NetBoxModelFilter):
     type: BaseFilterLookup[Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
@@ -203,14 +204,14 @@ class ConsoleServerPortFilter(ModularComponentFilterMixin, CabledObjectModelFilt
     )
 
 
-@strawberry_django.filter_type(models.ConsoleServerPortTemplate, lookups=True)
+@register_filter(models.ConsoleServerPortTemplate, lookups=True)
 class ConsoleServerPortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
     type: BaseFilterLookup[Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
     )
 
 
-@strawberry_django.filter_type(models.Device, lookups=True)
+@register_filter(models.Device, lookups=True)
 class DeviceFilter(
     ContactFilterMixin,
     TenancyFilterMixin,
@@ -329,7 +330,7 @@ class DeviceFilter(
     inventory_item_count: FilterLookup[int] | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.DeviceBay, lookups=True)
+@register_filter(models.DeviceBay, lookups=True)
 class DeviceBayFilter(ComponentModelFilterMixin, NetBoxModelFilter):
     enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
     installed_device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
@@ -338,12 +339,12 @@ class DeviceBayFilter(ComponentModelFilterMixin, NetBoxModelFilter):
     installed_device_id: ID | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.DeviceBayTemplate, lookups=True)
+@register_filter(models.DeviceBayTemplate, lookups=True)
 class DeviceBayTemplateFilter(ComponentTemplateFilterMixin, ChangeLoggedModelFilter):
     enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.InventoryItemTemplate, lookups=True)
+@register_filter(models.InventoryItemTemplate, lookups=True)
 class InventoryItemTemplateFilter(ComponentTemplateFilterMixin, ChangeLoggedModelFilter):
     parent: Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field()
@@ -363,7 +364,7 @@ class InventoryItemTemplateFilter(ComponentTemplateFilterMixin, ChangeLoggedMode
     part_id: StrFilterLookup | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.DeviceRole, lookups=True)
+@register_filter(models.DeviceRole, lookups=True)
 class DeviceRoleFilter(RenderConfigFilterMixin, OrganizationalModelFilter):
     color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
         strawberry_django.filter_field()
@@ -371,7 +372,7 @@ class DeviceRoleFilter(RenderConfigFilterMixin, OrganizationalModelFilter):
     vm_role: FilterLookup[bool] | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.DeviceType, lookups=True)
+@register_filter(models.DeviceType, lookups=True)
 class DeviceTypeFilter(ImageAttachmentFilterMixin, WeightFilterMixin, PrimaryModelFilter):
     manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field()
@@ -448,7 +449,7 @@ class DeviceTypeFilter(ImageAttachmentFilterMixin, WeightFilterMixin, PrimaryMod
     device_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.FrontPort, lookups=True)
+@register_filter(models.FrontPort, lookups=True)
 class FrontPortFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin, NetBoxModelFilter):
     type: BaseFilterLookup[Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
@@ -458,7 +459,7 @@ class FrontPortFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin,
     )
 
 
-@strawberry_django.filter_type(models.FrontPortTemplate, lookups=True)
+@register_filter(models.FrontPortTemplate, lookups=True)
 class FrontPortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
     type: BaseFilterLookup[Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
@@ -468,7 +469,7 @@ class FrontPortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedM
     )
 
 
-@strawberry_django.filter_type(models.PortMapping, lookups=True)
+@register_filter(models.PortMapping, lookups=True)
 class PortMappingFilter(BaseModelFilter):
     device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     front_port: Annotated['FrontPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
@@ -481,7 +482,7 @@ class PortMappingFilter(BaseModelFilter):
     rear_port_position: FilterLookup[int] | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.PortTemplateMapping, lookups=True)
+@register_filter(models.PortTemplateMapping, lookups=True)
 class PortTemplateMappingFilter(BaseModelFilter):
     device_type: Annotated['DeviceTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field()
@@ -499,7 +500,7 @@ class PortTemplateMappingFilter(BaseModelFilter):
     rear_port_position: FilterLookup[int] | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.MACAddress, lookups=True)
+@register_filter(models.MACAddress, lookups=True)
 class MACAddressFilter(PrimaryModelFilter):
     mac_address: StrFilterLookup | None = strawberry_django.filter_field()
     assigned_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
@@ -525,7 +526,7 @@ class MACAddressFilter(PrimaryModelFilter):
         return ~Q(query)
 
 
-@strawberry_django.filter_type(models.Interface, lookups=True)
+@register_filter(models.Interface, lookups=True)
 class InterfaceFilter(
     ModularComponentFilterMixin,
     InterfaceBaseFilterMixin,
@@ -540,6 +541,12 @@ class InterfaceFilter(
     type: BaseFilterLookup[Annotated['InterfaceTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
     )
+    channels: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+        strawberry_django.filter_field()
+    )
+    channel_id: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+        strawberry_django.filter_field()
+    )
     mgmt_only: FilterLookup[bool] | None = strawberry_django.filter_field()
     speed: Annotated['BigIntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
@@ -624,13 +631,23 @@ class InterfaceFilter(
         return queryset, Q()
 
 
-@strawberry_django.filter_type(models.InterfaceTemplate, lookups=True)
+@register_filter(models.InterfaceTemplate, lookups=True)
 class InterfaceTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
     type: BaseFilterLookup[Annotated['InterfaceTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
     )
+    channels: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+        strawberry_django.filter_field()
+    )
+    channel_id: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+        strawberry_django.filter_field()
+    )
     enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
     mgmt_only: FilterLookup[bool] | None = strawberry_django.filter_field()
+    parent: Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field()
+    )
+    parent_id: ID | None = strawberry_django.filter_field()
     bridge: Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field()
     )
@@ -646,7 +663,7 @@ class InterfaceTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedM
     )
 
 
-@strawberry_django.filter_type(models.InventoryItem, lookups=True)
+@register_filter(models.InventoryItem, lookups=True)
 class InventoryItemFilter(ComponentModelFilterMixin, NetBoxModelFilter):
     parent: Annotated['InventoryItemFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field()
@@ -673,14 +690,14 @@ class InventoryItemFilter(ComponentModelFilterMixin, NetBoxModelFilter):
     discovered: FilterLookup[bool] | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.InventoryItemRole, lookups=True)
+@register_filter(models.InventoryItemRole, lookups=True)
 class InventoryItemRoleFilter(OrganizationalModelFilter):
     color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
         strawberry_django.filter_field()
     )
 
 
-@strawberry_django.filter_type(models.Location, lookups=True)
+@register_filter(models.Location, lookups=True)
 class LocationFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, NestedGroupModelFilter):
     site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     site_id: ID | None = strawberry_django.filter_field()
@@ -696,12 +713,12 @@ class LocationFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilt
     )
 
 
-@strawberry_django.filter_type(models.Manufacturer, lookups=True)
+@register_filter(models.Manufacturer, lookups=True)
 class ManufacturerFilter(ContactFilterMixin, OrganizationalModelFilter):
     pass
 
 
-@strawberry_django.filter_type(models.Module, lookups=True)
+@register_filter(models.Module, lookups=True)
 class ModuleFilter(ConfigContextFilterMixin, PrimaryModelFilter):
     device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     device_id: ID | None = strawberry_django.filter_field()
@@ -750,7 +767,7 @@ class ModuleFilter(ConfigContextFilterMixin, PrimaryModelFilter):
     )
 
 
-@strawberry_django.filter_type(models.ModuleBay, lookups=True)
+@register_filter(models.ModuleBay, lookups=True)
 class ModuleBayFilter(ModularComponentFilterMixin, NetBoxModelFilter):
     parent: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field()
@@ -764,7 +781,7 @@ class ModuleBayFilter(ModularComponentFilterMixin, NetBoxModelFilter):
     module_bay_type_id: ID | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.ModuleBayTemplate, lookups=True)
+@register_filter(models.ModuleBayTemplate, lookups=True)
 class ModuleBayTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
     position: StrFilterLookup | None = strawberry_django.filter_field()
     enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
@@ -774,7 +791,7 @@ class ModuleBayTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedM
     module_bay_type_id: ID | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.ModuleBayType, lookups=True)
+@register_filter(models.ModuleBayType, lookups=True)
 class ModuleBayTypeFilter(PrimaryModelFilter):
     name: StrFilterLookup | None = strawberry_django.filter_field()
     slug: StrFilterLookup | None = strawberry_django.filter_field()
@@ -784,12 +801,12 @@ class ModuleBayTypeFilter(PrimaryModelFilter):
     manufacturer_id: ID | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.ModuleTypeProfile, lookups=True)
+@register_filter(models.ModuleTypeProfile, lookups=True)
 class ModuleTypeProfileFilter(PrimaryModelFilter):
     name: StrFilterLookup | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.ModuleType, lookups=True)
+@register_filter(models.ModuleType, lookups=True)
 class ModuleTypeFilter(ImageAttachmentFilterMixin, WeightFilterMixin, PrimaryModelFilter):
     manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field()
@@ -842,7 +859,7 @@ class ModuleTypeFilter(ImageAttachmentFilterMixin, WeightFilterMixin, PrimaryMod
     module_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.Platform, lookups=True)
+@register_filter(models.Platform, lookups=True)
 class PlatformFilter(OrganizationalModelFilter):
     manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field()
@@ -854,7 +871,7 @@ class PlatformFilter(OrganizationalModelFilter):
     config_template_id: ID | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.PowerFeed, lookups=True)
+@register_filter(models.PowerFeed, lookups=True)
 class PowerFeedFilter(CabledObjectModelFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
     power_panel: Annotated['PowerPanelFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field()
@@ -889,7 +906,7 @@ class PowerFeedFilter(CabledObjectModelFilterMixin, TenancyFilterMixin, PrimaryM
     )
 
 
-@strawberry_django.filter_type(models.PowerOutlet, lookups=True)
+@register_filter(models.PowerOutlet, lookups=True)
 class PowerOutletFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin, NetBoxModelFilter):
     type: BaseFilterLookup[Annotated['PowerOutletTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
@@ -909,7 +926,7 @@ class PowerOutletFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixi
     )
 
 
-@strawberry_django.filter_type(models.PowerOutletTemplate, lookups=True)
+@register_filter(models.PowerOutletTemplate, lookups=True)
 class PowerOutletTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
     type: BaseFilterLookup[Annotated['PowerOutletTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
@@ -923,7 +940,7 @@ class PowerOutletTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLogge
     )
 
 
-@strawberry_django.filter_type(models.PowerPanel, lookups=True)
+@register_filter(models.PowerPanel, lookups=True)
 class PowerPanelFilter(ContactFilterMixin, ImageAttachmentFilterMixin, PrimaryModelFilter):
     site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     site_id: ID | None = strawberry_django.filter_field()
@@ -936,7 +953,7 @@ class PowerPanelFilter(ContactFilterMixin, ImageAttachmentFilterMixin, PrimaryMo
     name: StrFilterLookup | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.PowerPort, lookups=True)
+@register_filter(models.PowerPort, lookups=True)
 class PowerPortFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin, NetBoxModelFilter):
     type: BaseFilterLookup[Annotated['PowerPortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
@@ -949,7 +966,7 @@ class PowerPortFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin,
     )
 
 
-@strawberry_django.filter_type(models.PowerPortTemplate, lookups=True)
+@register_filter(models.PowerPortTemplate, lookups=True)
 class PowerPortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
     type: BaseFilterLookup[Annotated['PowerPortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
@@ -962,7 +979,7 @@ class PowerPortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedM
     )
 
 
-@strawberry_django.filter_type(models.RackType, lookups=True)
+@register_filter(models.RackType, lookups=True)
 class RackTypeFilter(ImageAttachmentFilterMixin, RackFilterMixin, WeightFilterMixin, PrimaryModelFilter):
     form_factor: BaseFilterLookup[Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
@@ -977,7 +994,7 @@ class RackTypeFilter(ImageAttachmentFilterMixin, RackFilterMixin, WeightFilterMi
     rack_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.Rack, lookups=True)
+@register_filter(models.Rack, lookups=True)
 class RackFilter(
     ContactFilterMixin,
     ImageAttachmentFilterMixin,
@@ -1022,12 +1039,12 @@ class RackFilter(
     )
 
 
-@strawberry_django.filter_type(models.RackGroup, lookups=True)
+@register_filter(models.RackGroup, lookups=True)
 class RackGroupFilter(OrganizationalModelFilter):
     pass
 
 
-@strawberry_django.filter_type(models.RackReservation, lookups=True)
+@register_filter(models.RackReservation, lookups=True)
 class RackReservationFilter(TenancyFilterMixin, PrimaryModelFilter):
     rack: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     rack_id: ID | None = strawberry_django.filter_field()
@@ -1043,14 +1060,14 @@ class RackReservationFilter(TenancyFilterMixin, PrimaryModelFilter):
     )
 
 
-@strawberry_django.filter_type(models.RackRole, lookups=True)
+@register_filter(models.RackRole, lookups=True)
 class RackRoleFilter(OrganizationalModelFilter):
     color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
         strawberry_django.filter_field()
     )
 
 
-@strawberry_django.filter_type(models.RearPort, lookups=True)
+@register_filter(models.RearPort, lookups=True)
 class RearPortFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin, NetBoxModelFilter):
     type: BaseFilterLookup[Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
@@ -1063,7 +1080,7 @@ class RearPortFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin,
     )
 
 
-@strawberry_django.filter_type(models.RearPortTemplate, lookups=True)
+@register_filter(models.RearPortTemplate, lookups=True)
 class RearPortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
     type: BaseFilterLookup[Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
@@ -1076,7 +1093,7 @@ class RearPortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedMo
     )
 
 
-@strawberry_django.filter_type(models.Region, lookups=True)
+@register_filter(models.Region, lookups=True)
 class RegionFilter(ContactFilterMixin, NestedGroupModelFilter):
     prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
         strawberry_django.filter_field()
@@ -1086,7 +1103,7 @@ class RegionFilter(ContactFilterMixin, NestedGroupModelFilter):
     )
 
 
-@strawberry_django.filter_type(models.Site, lookups=True)
+@register_filter(models.Site, lookups=True)
 class SiteFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
     name: StrFilterLookup | None = strawberry_django.filter_field()
     slug: StrFilterLookup | None = strawberry_django.filter_field()
@@ -1122,7 +1139,7 @@ class SiteFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMi
     )
 
 
-@strawberry_django.filter_type(models.SiteGroup, lookups=True)
+@register_filter(models.SiteGroup, lookups=True)
 class SiteGroupFilter(ContactFilterMixin, NestedGroupModelFilter):
     prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
         strawberry_django.filter_field()
@@ -1132,7 +1149,7 @@ class SiteGroupFilter(ContactFilterMixin, NestedGroupModelFilter):
     )
 
 
-@strawberry_django.filter_type(models.VirtualChassis, lookups=True)
+@register_filter(models.VirtualChassis, lookups=True)
 class VirtualChassisFilter(PrimaryModelFilter):
     master: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     master_id: ID | None = strawberry_django.filter_field()
@@ -1144,7 +1161,7 @@ class VirtualChassisFilter(PrimaryModelFilter):
     member_count: FilterLookup[int] | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.VirtualDeviceContext, lookups=True)
+@register_filter(models.VirtualDeviceContext, lookups=True)
 class VirtualDeviceContextFilter(TenancyFilterMixin, PrimaryModelFilter):
     device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     device_id: ID | None = strawberry_django.filter_field()

+ 52 - 49
netbox/dcim/graphql/types.py

@@ -17,6 +17,7 @@ from netbox.graphql.types import (
     NetBoxObjectType,
     OrganizationalObjectType,
     PrimaryObjectType,
+    register_type,
 )
 from users.graphql.mixins import OwnerMixin
 from utilities.querysets import RestrictedPrefetch
@@ -134,7 +135,7 @@ class ModularComponentTemplateType(ComponentTemplateType):
 #
 
 
-@strawberry_django.type(
+@register_type(
     models.CableBundle,
     fields='__all__',
     filters=CableBundleFilter,
@@ -144,7 +145,7 @@ class CableBundleType(PrimaryObjectType):
     cables: list[Annotated['CableType', strawberry.lazy('dcim.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.CableTermination,
     exclude=['termination_type', 'termination_id', '_device', '_rack', '_location', '_site'],
     filters=CableTerminationFilter,
@@ -166,7 +167,7 @@ class CableTerminationType(NetBoxObjectType):
     ] | None
 
 
-@strawberry_django.type(
+@register_type(
     models.Cable,
     fields='__all__',
     filters=CableFilter,
@@ -206,7 +207,7 @@ class CableType(PrimaryObjectType):
     ]]
 
 
-@strawberry_django.type(
+@register_type(
     models.ConsolePort,
     exclude=['_path'],
     filters=ConsolePortFilter,
@@ -216,7 +217,7 @@ class ConsolePortType(ModularComponentType, CabledObjectMixin, PathEndpointMixin
     pass
 
 
-@strawberry_django.type(
+@register_type(
     models.ConsolePortTemplate,
     fields='__all__',
     filters=ConsolePortTemplateFilter,
@@ -226,7 +227,7 @@ class ConsolePortTemplateType(ModularComponentTemplateType):
     pass
 
 
-@strawberry_django.type(
+@register_type(
     models.ConsoleServerPort,
     exclude=['_path'],
     filters=ConsoleServerPortFilter,
@@ -236,7 +237,7 @@ class ConsoleServerPortType(ModularComponentType, CabledObjectMixin, PathEndpoin
     pass
 
 
-@strawberry_django.type(
+@register_type(
     models.ConsoleServerPortTemplate,
     fields='__all__',
     filters=ConsoleServerPortTemplateFilter,
@@ -246,7 +247,7 @@ class ConsoleServerPortTemplateType(ModularComponentTemplateType):
     pass
 
 
-@strawberry_django.type(
+@register_type(
     models.Device,
     fields='__all__',
     filters=DeviceFilter,
@@ -302,7 +303,7 @@ class DeviceType(ConfigContextMixin, ImageAttachmentsMixin, ContactsMixin, Prima
         return self.parent_bay if hasattr(self, 'parent_bay') else None
 
 
-@strawberry_django.type(
+@register_type(
     models.DeviceBay,
     fields='__all__',
     filters=DeviceBayFilter,
@@ -312,7 +313,7 @@ class DeviceBayType(ComponentType):
     installed_device: Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')] | None
 
 
-@strawberry_django.type(
+@register_type(
     models.DeviceBayTemplate,
     fields='__all__',
     filters=DeviceBayTemplateFilter,
@@ -322,7 +323,7 @@ class DeviceBayTemplateType(ComponentTemplateType):
     pass
 
 
-@strawberry_django.type(
+@register_type(
     models.InventoryItemTemplate,
     exclude=['component_type', 'component_id', 'parent', 'path'],
     filters=InventoryItemTemplateFilter,
@@ -350,7 +351,7 @@ class InventoryItemTemplateType(LtreeNodeMixin, ComponentTemplateType):
     ] | None
 
 
-@strawberry_django.type(
+@register_type(
     models.DeviceRole,
     exclude=['path', 'sort_path'],
     filters=DeviceRoleFilter,
@@ -366,7 +367,7 @@ class DeviceRoleType(NestedLtreeGroupObjectType):
     devices: list[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.DeviceType,
     fields='__all__',
     filters=DeviceTypeFilter,
@@ -402,7 +403,7 @@ class DeviceTypeType(PrimaryObjectType):
     consoleporttemplates: list[Annotated["ConsolePortTemplateType", strawberry.lazy('dcim.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.FrontPort,
     fields='__all__',
     filters=FrontPortFilter,
@@ -414,7 +415,7 @@ class FrontPortType(ModularComponentType, CabledObjectMixin):
     mappings: list[Annotated["PortMappingType", strawberry.lazy('dcim.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.FrontPortTemplate,
     fields='__all__',
     filters=FrontPortTemplateFilter,
@@ -426,7 +427,7 @@ class FrontPortTemplateType(ModularComponentTemplateType):
     mappings: list[Annotated["PortMappingTemplateType", strawberry.lazy('dcim.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.MACAddress,
     exclude=['assigned_object_type', 'assigned_object_id'],
     filters=MACAddressFilter,
@@ -444,7 +445,7 @@ class MACAddressType(PrimaryObjectType):
         return self.assigned_object
 
 
-@strawberry_django.type(
+@register_type(
     models.Interface,
     exclude=['_path'],
     filters=InterfaceFilter,
@@ -474,7 +475,7 @@ class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, P
     mac_addresses: list[Annotated["MACAddressType", strawberry.lazy('dcim.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.InterfaceTemplate,
     fields='__all__',
     filters=InterfaceTemplateFilter,
@@ -482,12 +483,14 @@ class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, P
 )
 class InterfaceTemplateType(ModularComponentTemplateType):
     _name: str
+    parent: Annotated["InterfaceTemplateType", strawberry.lazy('dcim.graphql.types')] | None
     bridge: Annotated["InterfaceTemplateType", strawberry.lazy('dcim.graphql.types')] | None
 
     bridge_interfaces: list[Annotated["InterfaceTemplateType", strawberry.lazy('dcim.graphql.types')]]
+    child_interfaces: list[Annotated["InterfaceTemplateType", strawberry.lazy('dcim.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.InventoryItem,
     exclude=['component_type', 'component_id', 'parent', 'path'],
     filters=InventoryItemFilter,
@@ -515,7 +518,7 @@ class InventoryItemType(LtreeNodeMixin, ComponentType):
     ] | None
 
 
-@strawberry_django.type(
+@register_type(
     models.InventoryItemRole,
     fields='__all__',
     filters=InventoryItemRoleFilter,
@@ -528,7 +531,7 @@ class InventoryItemRoleType(OrganizationalObjectType):
     inventory_item_templates: list[Annotated["InventoryItemTemplateType", strawberry.lazy('dcim.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.Location,
     # fields='__all__',
     exclude=['parent', 'path', 'sort_path'],  # bug - temp
@@ -566,7 +569,7 @@ class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, Nested
         return self.circuit_terminations.all()
 
 
-@strawberry_django.type(
+@register_type(
     models.Manufacturer,
     fields='__all__',
     filters=ManufacturerFilter,
@@ -581,7 +584,7 @@ class ManufacturerType(OrganizationalObjectType, ContactsMixin):
     module_types: list[Annotated["ModuleTypeType", strawberry.lazy('dcim.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.Module,
     fields='__all__',
     filters=ModuleFilter,
@@ -601,7 +604,7 @@ class ModuleType(PrimaryObjectType):
     frontports: list[Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.ModuleBay,
     # fields='__all__',
     exclude=['parent', 'path', 'sort_path'],
@@ -619,7 +622,7 @@ class ModuleBayType(LtreeNodeMixin, ModularComponentType):
         return self.parent
 
 
-@strawberry_django.type(
+@register_type(
     models.ModuleBayTemplate,
     fields='__all__',
     filters=ModuleBayTemplateFilter,
@@ -629,7 +632,7 @@ class ModuleBayTemplateType(ModularComponentTemplateType):
     module_bay_types: list[Annotated["ModuleBayTypeType", strawberry.lazy('dcim.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.ModuleBayType,
     fields='__all__',
     filters=ModuleBayTypeFilter,
@@ -643,7 +646,7 @@ class ModuleBayTypeType(PrimaryObjectType):
     module_bay_templates: list[Annotated["ModuleBayTemplateType", strawberry.lazy('dcim.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.ModuleTypeProfile,
     fields='__all__',
     filters=ModuleTypeProfileFilter,
@@ -653,7 +656,7 @@ class ModuleTypeProfileType(PrimaryObjectType):
     module_types: list[Annotated["ModuleType", strawberry.lazy('dcim.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.ModuleType,
     fields='__all__',
     filters=ModuleTypeFilter,
@@ -683,7 +686,7 @@ class ModuleTypeType(PrimaryObjectType):
     consoleporttemplates: list[Annotated["ConsolePortTemplateType", strawberry.lazy('dcim.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.Platform,
     exclude=['path', 'sort_path'],
     filters=PlatformFilter,
@@ -699,7 +702,7 @@ class PlatformType(NestedLtreeGroupObjectType):
     devices: list[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.PortMapping,
     fields='__all__',
     filters=PortMappingFilter,
@@ -710,7 +713,7 @@ class PortMappingType(ModularComponentTemplateType):
     rear_port: Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')]
 
 
-@strawberry_django.type(
+@register_type(
     models.PortTemplateMapping,
     fields='__all__',
     filters=PortTemplateMappingFilter,
@@ -721,7 +724,7 @@ class PortMappingTemplateType(ModularComponentTemplateType):
     rear_port: Annotated["RearPortTemplateType", strawberry.lazy('dcim.graphql.types')]
 
 
-@strawberry_django.type(
+@register_type(
     models.PowerFeed,
     exclude=['_path'],
     filters=PowerFeedFilter,
@@ -733,7 +736,7 @@ class PowerFeedType(CabledObjectMixin, PathEndpointMixin, PrimaryObjectType):
     tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
 
 
-@strawberry_django.type(
+@register_type(
     models.PowerOutlet,
     exclude=['_path'],
     filters=PowerOutletFilter,
@@ -744,7 +747,7 @@ class PowerOutletType(ModularComponentType, CabledObjectMixin, PathEndpointMixin
     color: str
 
 
-@strawberry_django.type(
+@register_type(
     models.PowerOutletTemplate,
     fields='__all__',
     filters=PowerOutletTemplateFilter,
@@ -755,7 +758,7 @@ class PowerOutletTemplateType(ModularComponentTemplateType):
     color: str
 
 
-@strawberry_django.type(
+@register_type(
     models.PowerPanel,
     fields='__all__',
     filters=PowerPanelFilter,
@@ -768,7 +771,7 @@ class PowerPanelType(ContactsMixin, PrimaryObjectType):
     powerfeeds: list[Annotated["PowerFeedType", strawberry.lazy('dcim.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.PowerPort,
     exclude=['_path'],
     filters=PowerPortFilter,
@@ -779,7 +782,7 @@ class PowerPortType(ModularComponentType, CabledObjectMixin, PathEndpointMixin):
     poweroutlets: list[Annotated["PowerOutletType", strawberry.lazy('dcim.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.PowerPortTemplate,
     fields='__all__',
     filters=PowerPortTemplateFilter,
@@ -789,7 +792,7 @@ class PowerPortTemplateType(ModularComponentTemplateType):
     poweroutlet_templates: list[Annotated["PowerOutletTemplateType", strawberry.lazy('dcim.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.RackGroup,
     fields='__all__',
     filters=RackGroupFilter,
@@ -800,7 +803,7 @@ class RackGroupType(OrganizationalObjectType):
     racks: list[Annotated["RackType", strawberry.lazy('dcim.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.RackType,
     fields='__all__',
     filters=RackTypeFilter,
@@ -811,7 +814,7 @@ class RackTypeType(ImageAttachmentsMixin, PrimaryObjectType):
     manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
 
 
-@strawberry_django.type(
+@register_type(
     models.Rack,
     fields='__all__',
     filters=RackFilter,
@@ -831,7 +834,7 @@ class RackType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, PrimaryObj
     cabletermination_set: list[Annotated["CableTerminationType", strawberry.lazy('dcim.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.RackReservation,
     fields='__all__',
     filters=RackReservationFilter,
@@ -855,7 +858,7 @@ class RackReservationType(PrimaryObjectType):
         return len(self.units)
 
 
-@strawberry_django.type(
+@register_type(
     models.RackRole,
     fields='__all__',
     filters=RackRoleFilter,
@@ -867,7 +870,7 @@ class RackRoleType(OrganizationalObjectType):
     racks: list[Annotated["RackType", strawberry.lazy('dcim.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.RearPort,
     fields='__all__',
     filters=RearPortFilter,
@@ -879,7 +882,7 @@ class RearPortType(ModularComponentType, CabledObjectMixin):
     mappings: list[Annotated["PortMappingType", strawberry.lazy('dcim.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.RearPortTemplate,
     fields='__all__',
     filters=RearPortTemplateFilter,
@@ -891,7 +894,7 @@ class RearPortTemplateType(ModularComponentTemplateType):
     mappings: list[Annotated["PortMappingTemplateType", strawberry.lazy('dcim.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.Region,
     exclude=['parent', 'path', 'sort_path'],
     filters=RegionFilter,
@@ -926,7 +929,7 @@ class RegionType(VLANGroupsMixin, ContactsMixin, NestedLtreeGroupObjectType):
         return self.circuit_terminations.all()
 
 
-@strawberry_django.type(
+@register_type(
     models.Site,
     fields='__all__',
     filters=SiteFilter,
@@ -968,7 +971,7 @@ class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, PrimaryObj
         return self.circuit_terminations.all()
 
 
-@strawberry_django.type(
+@register_type(
     models.SiteGroup,
     exclude=['parent', 'path', 'sort_path'],  # bug - temp
     filters=SiteGroupFilter,
@@ -1003,7 +1006,7 @@ class SiteGroupType(VLANGroupsMixin, ContactsMixin, NestedLtreeGroupObjectType):
         return self.circuit_terminations.all()
 
 
-@strawberry_django.type(
+@register_type(
     models.VirtualChassis,
     fields='__all__',
     filters=VirtualChassisFilter,
@@ -1016,7 +1019,7 @@ class VirtualChassisType(PrimaryObjectType):
     members: list[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.VirtualDeviceContext,
     fields='__all__',
     filters=VirtualDeviceContextFilter,

+ 54 - 0
netbox/dcim/migrations/0246_interfacetemplate_channels.py

@@ -0,0 +1,54 @@
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0245_modulebaytype'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='interfacetemplate',
+            name='parent',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.RESTRICT,
+                related_name='child_interfaces',
+                to='dcim.interfacetemplate',
+            ),
+        ),
+        migrations.AddField(
+            model_name='interfacetemplate',
+            name='channel_id',
+            field=models.PositiveSmallIntegerField(
+                blank=True,
+                null=True,
+                validators=[
+                    django.core.validators.MinValueValidator(1),
+                    django.core.validators.MaxValueValidator(1024),
+                ],
+            ),
+        ),
+        migrations.AddField(
+            model_name='interfacetemplate',
+            name='channels',
+            field=models.PositiveSmallIntegerField(
+                blank=True,
+                null=True,
+                validators=[
+                    django.core.validators.MinValueValidator(1),
+                    django.core.validators.MaxValueValidator(1024),
+                ],
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name='interfacetemplate',
+            constraint=models.UniqueConstraint(
+                fields=('parent', 'channel_id'),
+                name='dcim_interfacetemplate_unique_parent_channel_id',
+            ),
+        ),
+    ]

+ 43 - 0
netbox/dcim/migrations/0247_interface_channels.py

@@ -0,0 +1,43 @@
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0246_interfacetemplate_channels'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='interface',
+            name='channel_id',
+            field=models.PositiveSmallIntegerField(
+                blank=True,
+                null=True,
+                validators=[
+                    django.core.validators.MinValueValidator(1),
+                    django.core.validators.MaxValueValidator(1024),
+                ],
+            ),
+        ),
+        migrations.AddField(
+            model_name='interface',
+            name='channels',
+            field=models.PositiveSmallIntegerField(
+                blank=True,
+                null=True,
+                validators=[
+                    django.core.validators.MinValueValidator(1),
+                    django.core.validators.MaxValueValidator(1024),
+                ],
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name='interface',
+            constraint=models.UniqueConstraint(
+                fields=('parent', 'channel_id'),
+                name='dcim_interface_unique_parent_channel_id',
+            ),
+        ),
+    ]

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

@@ -25,7 +25,7 @@ from netbox.models import ChangeLoggedModel, PrimaryModel
 from utilities.conversion import to_meters
 from utilities.exceptions import AbortRequest
 from utilities.fields import ColorField, GenericArrayForeignKey
-from utilities.querysets import RestrictedQuerySet
+from utilities.querysets import RestrictedQuerySet, chunked_update
 from utilities.serialization import deserialize_object, serialize_object
 from wireless.models import WirelessLink
 
@@ -636,6 +636,14 @@ class CableTermination(ChangeLoggedModel):
                     cable_pk=existing_termination.cable.pk
                 )
             )
+        # A channel subinterface derives its cable from its parent interface and cannot be cabled directly. Checked
+        # ahead of the generic type validation below (channel is a nonconnectable type) to surface the more specific
+        # guidance.
+        if self.termination_type.model == 'interface' and self.termination.channel_id:
+            raise ValidationError(
+                _("Cables cannot be terminated directly to a channel subinterface; cable the parent interface instead.")
+            )
+
         # Validate the interface type (if applicable)
         if self.termination_type.model == 'interface' and self.termination.type in NONCONNECTABLE_IFACE_TYPES:
             raise ValidationError(
@@ -783,7 +791,7 @@ class CablePath(models.Model):
         # Record a direct reference to this CablePath on its originating object(s)
         origin_model = self.origin_type.model_class()
         origin_ids = [decompile_path_node(node)[1] for node in self.path[0]]
-        origin_model.objects.filter(pk__in=origin_ids).update(_path=self.pk)
+        chunked_update(origin_model.objects.filter(pk__in=origin_ids), _path=self.pk)
 
     def delete(self, *args, **kwargs):
         # Mirror save() - clear _path on origins to prevent stale references
@@ -791,7 +799,7 @@ class CablePath(models.Model):
         if self.path:
             origin_model = self.origin_type.model_class()
             origin_ids = [decompile_path_node(node)[1] for node in self.path[0]]
-            origin_model.objects.filter(pk__in=origin_ids, _path=self.pk).update(_path=None)
+            chunked_update(origin_model.objects.filter(pk__in=origin_ids, _path=self.pk), _path=None)
 
         super().delete(*args, **kwargs)
 
@@ -971,6 +979,12 @@ class CablePath(models.Model):
                     peer_results = cable_profile.get_peer_terminations(term_position_pairs)
                     seen = set()
                     for peer, new_pos in peer_results:
+                        # If the far-end termination is a channelized interface, resolve to the specific channel
+                        # subinterface bound to the mapped connector position (the far end is channelized on the same
+                        # physical connector, so the peer lookup returns the parent rather than the channel). A
+                        # channelized parent is never itself a path endpoint, so an unoccupied position yields no peer.
+                        if new_pos is not None and getattr(peer, 'channels', None):
+                            peer = peer.child_interfaces.filter(channel_id=new_pos).first()
                         # Deduplicate peer terminations by model type & PK.
                         key = None if peer is None else (peer._meta.concrete_model, peer.pk)
                         if key not in seen:

+ 62 - 0
netbox/dcim/models/device_component_templates.py

@@ -454,6 +454,26 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel)
         max_length=50,
         choices=InterfaceTypeChoices
     )
+    channels = models.PositiveSmallIntegerField(
+        verbose_name=_('channels'),
+        blank=True,
+        null=True,
+        validators=(
+            MinValueValidator(INTERFACE_CHANNELS_MIN),
+            MaxValueValidator(INTERFACE_CHANNELS_MAX)
+        ),
+        help_text=_('The number of channels into which this interface is channelized')
+    )
+    channel_id = models.PositiveSmallIntegerField(
+        verbose_name=_('channel ID'),
+        blank=True,
+        null=True,
+        validators=(
+            MinValueValidator(INTERFACE_CHANNELS_MIN),
+            MaxValueValidator(INTERFACE_CHANNELS_MAX)
+        ),
+        help_text=_('The channel on the parent interface to which this subinterface is bound')
+    )
     enabled = models.BooleanField(
         verbose_name=_('enabled'),
         default=True
@@ -462,6 +482,14 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel)
         default=False,
         verbose_name=_('management only')
     )
+    parent = models.ForeignKey(
+        to='self',
+        on_delete=models.RESTRICT,
+        related_name='child_interfaces',
+        null=True,
+        blank=True,
+        verbose_name=_('parent interface')
+    )
     bridge = models.ForeignKey(
         to='self',
         on_delete=models.SET_NULL,
@@ -495,12 +523,41 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel)
     component_model = Interface
 
     class Meta(ModularComponentTemplateModel.Meta):
+        constraints = (
+            *ModularComponentTemplateModel.Meta.constraints,
+            models.UniqueConstraint(
+                fields=('parent', 'channel_id'),
+                name='%(app_label)s_%(class)s_unique_parent_channel_id'
+            ),
+        )
         verbose_name = _('interface template')
         verbose_name_plural = _('interface templates')
 
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Cache the original channel count for use by InterfaceValidationMixin.clean() to detect a channel-count
+        # reduction that would orphan a bound subinterface.
+        self._original_channels = self.__dict__.get('channels')
+
     def clean(self):
         super().clean()
 
+        # Self-reference and interface-type restrictions are enforced by InterfaceValidationMixin
+        if self.parent:
+            if self.device_type and self.device_type != self.parent.device_type:
+                raise ValidationError({
+                    'parent': _(
+                        "Parent interface ({parent}) must belong to the same device type"
+                    ).format(parent=self.parent)
+                })
+            if self.module_type and self.module_type != self.parent.module_type:
+                raise ValidationError({
+                    'parent': _(
+                        "Parent interface ({parent}) must belong to the same module type"
+                    ).format(parent=self.parent)
+                })
+
         if self.bridge:
             if self.device_type and self.device_type != self.bridge.device_type:
                 raise ValidationError({
@@ -520,6 +577,8 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel)
             name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
             label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
             type=self.type,
+            channels=self.channels,
+            channel_id=self.channel_id,
             enabled=self.enabled,
             mgmt_only=self.mgmt_only,
             poe_mode=self.poe_mode,
@@ -533,10 +592,13 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel)
         return {
             'name': self.name,
             'type': self.type,
+            'channels': self.channels,
+            'channel_id': self.channel_id,
             'enabled': self.enabled,
             'mgmt_only': self.mgmt_only,
             'label': self.label,
             'description': self.description,
+            'parent': self.parent.name if self.parent else None,
             'bridge': self.bridge.name if self.bridge else None,
             'poe_mode': self.poe_mode,
             'poe_type': self.poe_type,

+ 101 - 12
netbox/dcim/models/device_components.py

@@ -860,6 +860,26 @@ class Interface(
         max_length=50,
         choices=InterfaceTypeChoices
     )
+    channels = models.PositiveSmallIntegerField(
+        verbose_name=_('channels'),
+        blank=True,
+        null=True,
+        validators=(
+            MinValueValidator(INTERFACE_CHANNELS_MIN),
+            MaxValueValidator(INTERFACE_CHANNELS_MAX)
+        ),
+        help_text=_('The number of channels into which this interface is channelized')
+    )
+    channel_id = models.PositiveSmallIntegerField(
+        verbose_name=_('channel ID'),
+        blank=True,
+        null=True,
+        validators=(
+            MinValueValidator(INTERFACE_CHANNELS_MIN),
+            MaxValueValidator(INTERFACE_CHANNELS_MAX)
+        ),
+        help_text=_('The channel on the parent interface to which this subinterface is bound')
+    )
     mgmt_only = models.BooleanField(
         default=False,
         verbose_name=_('management only'),
@@ -989,14 +1009,33 @@ class Interface(
     )
 
     clone_fields = (
-        'device', 'module', 'parent', 'bridge', 'lag', 'type', 'mgmt_only', 'mtu', 'mode', 'speed', 'duplex', 'rf_role',
-        'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'poe_mode', 'poe_type', 'vrf',
+        'device', 'module', 'parent', 'bridge', 'lag', 'type', 'channels', 'mgmt_only', 'mtu', 'mode', 'speed',
+        'duplex', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'poe_mode',
+        'poe_type', 'vrf',
     )
 
     class Meta(ModularComponentModel.Meta):
         ordering = ('device', CollateAsChar('_name'))
         verbose_name = _('interface')
         verbose_name_plural = _('interfaces')
+        constraints = (
+            *ModularComponentModel.Meta.constraints,
+            models.UniqueConstraint(
+                fields=('parent', 'channel_id'),
+                name='%(app_label)s_%(class)s_unique_parent_channel_id'
+            ),
+        )
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Cache channelization-related fields so post-save signal handlers can detect changes which require rebuilding
+        # cable paths (channelization does not involve modifying the Cable itself, so the cable signals do not fire).
+        # _original_channels is additionally used by InterfaceValidationMixin.clean() to detect a channel-count
+        # reduction that would orphan a bound subinterface.
+        self._original_channels = self.__dict__.get('channels')
+        self._original_channel_id = self.__dict__.get('channel_id')
+        self._original_parent_id = self.__dict__.get('parent_id')
 
     def clean(self):
         super().clean()
@@ -1017,15 +1056,7 @@ class Interface(
                 )
             })
 
-        # Parent validation
-
-        # An interface cannot be its own parent
-        if self.pk and self.parent_id == self.pk:
-            raise ValidationError({'parent': _("An interface cannot be its own parent.")})
-
-        # A physical interface cannot have a parent interface
-        if self.type != InterfaceTypeChoices.TYPE_VIRTUAL and self.parent is not None:
-            raise ValidationError({'parent': _("Only virtual interfaces may be assigned to a parent interface.")})
+        # Parent validation (self-reference and interface-type restrictions are enforced by InterfaceValidationMixin)
 
         # An interface's parent must belong to the same device or virtual chassis
         if self.parent and self.parent.device != self.device:
@@ -1147,7 +1178,9 @@ class Interface(
 
     @property
     def is_wired(self):
-        return not self.is_virtual and not self.is_wireless
+        # Excludes virtual, wireless, and channel-type interfaces (channel subinterfaces derive their cable from the
+        # channelized parent and cannot be cabled directly).
+        return self.type not in NONCONNECTABLE_IFACE_TYPES
 
     @property
     def is_virtual(self):
@@ -1165,6 +1198,10 @@ class Interface(
     def is_bridge(self):
         return self.type == InterfaceTypeChoices.TYPE_BRIDGE
 
+    @property
+    def is_channel(self):
+        return self.type == InterfaceTypeChoices.TYPE_CHANNEL
+
     @property
     def link(self):
         return self.cable or self.wireless_link
@@ -1192,6 +1229,58 @@ class Interface(
             return self.virtual_circuit_termination.peer_terminations
         return super().connected_endpoints
 
+    def set_cable_termination(self, termination):
+        super().set_cable_termination(termination)
+
+        # A channelized interface carries no path of its own; instead, its cable is mirrored onto each channel
+        # subinterface (occupying a single position of the shared connector) so that each channel traces independently.
+        if self.channels:
+            self.propagate_channel_cables()
+
+    def clear_cable_termination(self, termination):
+        super().clear_cable_termination(termination)
+
+        if self.channels:
+            self.clear_channel_cables()
+
+    def propagate_channel_cables(self):
+        """
+        Mirror this channelized interface's cable attributes onto each of its channel subinterfaces, restricting each
+        child to the single connector position identified by its channel_id. Only profiled cables map connector
+        positions to channels; a positionless (unprofiled) cable carries no per-channel path, so nothing is mirrored.
+        """
+        # Only a profiled cable defines the connector positions that channels map onto; without one, clear any
+        # previously-mirrored attributes rather than propagate an unusable cable reference.
+        if not (self.cable and self.cable.profile):
+            self.clear_channel_cables()
+            return
+
+        # Mirror via bulk_update() to issue a single UPDATE and, crucially, to bypass the post_save signal — a
+        # per-child save() would re-trigger update_channelized_cable_paths() and recurse indefinitely.
+        children = list(self.child_interfaces.filter(channel_id__isnull=False))
+        for child in children:
+            child.cable = self.cable
+            child.cable_end = self.cable_end
+            child.cable_connector = self.cable_connector
+            child.cable_positions = [child.channel_id]
+        type(self).objects.bulk_update(
+            children, ['cable', 'cable_end', 'cable_connector', 'cable_positions']
+        )
+
+    def clear_channel_cables(self):
+        """
+        Clear the mirrored cable attributes from this channelized interface's channel subinterfaces.
+        """
+        # A queryset update() clears every child in a single query and bypasses the post_save signal (see above).
+        # cable_end is cleared to '' to match the convention used elsewhere when nullifying a termination (see
+        # nullify_connected_endpoints() and update_channelized_cable_paths() in dcim.signals).
+        self.child_interfaces.filter(channel_id__isnull=False).update(
+            cable=None,
+            cable_end='',
+            cable_connector=None,
+            cable_positions=None,
+        )
+
 
 #
 # Pass-through ports

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

@@ -19,7 +19,7 @@ from django.utils.translation import gettext_lazy as _
 from dcim.choices import *
 from dcim.constants import *
 from dcim.fields import MACAddressField
-from dcim.utils import create_port_mappings, update_interface_bridges
+from dcim.utils import create_port_mappings, update_interface_bridges, update_interface_parents
 from extras.models import ConfigContextModel, CustomField
 from extras.querysets import ConfigContextModelQuerySet
 from netbox.choices import ColorChoices
@@ -1070,7 +1070,9 @@ class Device(
             self._instantiate_components(self.device_type.devicebaytemplates.all())
             # Disable bulk_create to accommodate MPTT
             self._instantiate_components(self.device_type.inventoryitemtemplates.all(), bulk_create=False)
-            # Interface bridges have to be set after interface instantiation
+            # Interface parents & bridges have to be set after interface instantiation. Parents are applied first so
+            # that channel subinterfaces validate against a populated parent.
+            update_interface_parents(self, self.device_type.interfacetemplates.all())
             update_interface_bridges(self, self.device_type.interfacetemplates.all())
 
         # Update Site and Rack assignment for any child Devices

+ 77 - 1
netbox/dcim/models/mixins.py

@@ -4,7 +4,8 @@ from django.core.exceptions import ValidationError
 from django.db import models
 from django.utils.translation import gettext_lazy as _
 
-from dcim.constants import VIRTUAL_IFACE_TYPES, WIRELESS_IFACE_TYPES
+from dcim.choices import InterfaceTypeChoices
+from dcim.constants import NONCONNECTABLE_IFACE_TYPES, VIRTUAL_IFACE_TYPES, WIRELESS_IFACE_TYPES
 
 __all__ = (
     'CachedScopeMixin',
@@ -130,6 +131,81 @@ class InterfaceValidationMixin:
     def clean(self):
         super().clean()
 
+        # An interface cannot be its own parent
+        if self.pk and self.parent_id == self.pk:
+            raise ValidationError({'parent': _("An interface cannot be its own parent.")})
+
+        # Only virtual and channel interfaces may have a parent interface
+        if self.parent_id and self.type not in (InterfaceTypeChoices.TYPE_VIRTUAL, InterfaceTypeChoices.TYPE_CHANNEL):
+            raise ValidationError({
+                'parent': _("Only virtual and channel interfaces may be assigned to a parent interface.")
+            })
+
+        # Only one layer of channelization is permitted: an interface cannot be both channelized and a channel
+        if self.channels and self.channel_id:
+            raise ValidationError(
+                _("An interface cannot be both channelized and bound to a channel on a parent interface.")
+            )
+
+        # Only physical interfaces may be channelized
+        if self.channels and self.type in NONCONNECTABLE_IFACE_TYPES:
+            raise ValidationError({
+                'channels': _("{display_type} interfaces cannot be channelized.").format(
+                    display_type=self.get_type_display()
+                )
+            })
+
+        # The channel type and channel_id are mutually dependent. The channel_id requirement is relaxed for a
+        # replication base (bulk creation), where each channel_id is supplied per-instance during expansion.
+        is_channel = self.type == InterfaceTypeChoices.TYPE_CHANNEL
+        if is_channel and self.channel_id is None and not getattr(self, '_replicated_base', False):
+            raise ValidationError({
+                'channel_id': _("Channel interfaces must have a channel ID assigned.")
+            })
+        if self.channel_id is not None and not is_channel:
+            raise ValidationError({
+                'channel_id': _("A channel ID can be assigned only to a channel-type interface.")
+            })
+
+        # A channel subinterface must be bound to a channelized parent interface
+        if is_channel:
+            if self.parent is None:
+                raise ValidationError({
+                    'parent': _("Channel interfaces must be assigned to a parent interface.")
+                })
+            if not self.parent.channels:
+                raise ValidationError({
+                    'parent': _("The parent interface ({interface}) is not channelized.").format(
+                        interface=self.parent
+                    )
+                })
+            if self.channel_id and self.channel_id > self.parent.channels:
+                raise ValidationError({
+                    'channel_id': _(
+                        "Invalid channel ID ({channel_id}): the parent interface provides only {channels} channels."
+                    ).format(channel_id=self.channel_id, channels=self.parent.channels)
+                })
+
+        # Reducing or clearing the channel count cannot orphan an existing channel subinterface bound to a higher
+        # channel (clearing channelization entirely would orphan every bound subinterface). Gated on the current or
+        # original channel count so the child lookup stays off the hot path for ordinary (never-channelized) interfaces.
+        if self.pk and (self.channels or self._original_channels):
+            max_child_channel_id = self.child_interfaces.filter(
+                channel_id__gt=self.channels or 0
+            ).aggregate(models.Max('channel_id'))['channel_id__max']
+            if max_child_channel_id is not None:
+                if self.channels:
+                    message = _(
+                        "Cannot set channels to {channels}: a channel subinterface is bound to channel "
+                        "{channel_id}. Delete or reassign the affected subinterface(s) first."
+                    ).format(channels=self.channels, channel_id=max_child_channel_id)
+                else:
+                    message = _(
+                        "Cannot remove channelization: a channel subinterface is bound to channel {channel_id}. "
+                        "Delete or reassign the affected subinterface(s) first."
+                    ).format(channel_id=max_child_channel_id)
+                raise ValidationError({'channels': message})
+
         # An interface cannot be bridged to itself
         if self.pk and self.bridge_id == self.pk:
             raise ValidationError({'bridge': _("An interface cannot be bridged to itself.")})

+ 8 - 4
netbox/dcim/models/module_moves.py

@@ -17,6 +17,7 @@ from dcim.utils import (
 )
 from utilities.counters import update_counter
 from utilities.exceptions import AbortRequest
+from utilities.querysets import chunked_update
 
 from .device_components import (
     ConsolePort,
@@ -825,10 +826,13 @@ class ModuleMovePlan:
         moved_front_port_pks = [obj.pk for obj in self.components[FrontPort]]
         moved_rear_port_pks = [obj.pk for obj in self.components[RearPort]]
         if moved_front_port_pks and moved_rear_port_pks:
-            PortMapping.objects.filter(
-                front_port_id__in=moved_front_port_pks,
-                rear_port_id__in=moved_rear_port_pks,
-            ).update(device_id=self.new_device_id)
+            chunked_update(
+                PortMapping.objects.filter(
+                    front_port_id__in=moved_front_port_pks,
+                    rear_port_id__in=moved_rear_port_pks,
+                ),
+                device_id=self.new_device_id,
+            )
 
     def _recompute_counters(self):
         # bulk updates bypass the signal-driven counters; apply exact deltas for both devices

+ 8 - 3
netbox/dcim/models/modules.py

@@ -2,6 +2,7 @@ from collections.abc import Iterable, Mapping
 
 import jsonschema
 import yaml
+from django.conf import settings
 from django.core.exceptions import ValidationError
 from django.db import OperationalError, models, router, transaction
 from django.db.models.signals import post_save
@@ -9,7 +10,7 @@ from django.utils.translation import gettext_lazy as _
 from jsonschema.exceptions import ValidationError as JSONValidationError
 
 from dcim.choices import *
-from dcim.utils import create_port_mappings, update_interface_bridges
+from dcim.utils import create_port_mappings, update_interface_bridges, update_interface_parents
 from extras.models import CustomField
 from netbox.models import PrimaryModel
 from netbox.models.features import ImageAttachmentsMixin
@@ -577,7 +578,9 @@ class Module(TrackingModelMixin, PrimaryModel):
                     instance.parent = self.module_bay
                 update_fields = ['module', 'parent']
 
-            component_model.objects.bulk_update(update_instances, update_fields)
+            component_model.objects.bulk_update(
+                update_instances, update_fields, batch_size=settings.BULK_UPDATE_CHUNK_SIZE
+            )
             for component in update_instances:
                 post_save.send(
                     sender=component_model,
@@ -591,7 +594,9 @@ class Module(TrackingModelMixin, PrimaryModel):
         # Replicate any front/rear port mappings from the ModuleType
         create_port_mappings(self.device, self.module_type, self)
 
-        # Interface bridges have to be set after interface instantiation
+        # Interface parents & bridges have to be set after interface instantiation. Parents are applied first so that
+        # channel subinterfaces validate against a populated parent.
+        update_interface_parents(self.device, self.module_type.interfacetemplates, self)
         update_interface_bridges(self.device, self.module_type.interfacetemplates, self)
 
     def _save_existing(self, *args, **kwargs):

+ 76 - 7
netbox/dcim/signals.py

@@ -6,6 +6,7 @@ from django.dispatch import receiver
 
 from dcim.choices import CableEndChoices, LinkStatusChoices
 from netbox.search.backends import search_backend
+from utilities.querysets import chunked_update
 from virtualization.models import VMInterface
 
 from .models import (
@@ -23,7 +24,7 @@ from .models import (
 )
 from .models.cables import trace_paths
 from .search import DeviceIndex
-from .utils import create_cablepaths, rebuild_paths
+from .utils import create_cablepaths, rebuild_cable_paths, rebuild_paths
 
 #
 # Location/rack/device assignment
@@ -37,11 +38,11 @@ def handle_location_site_change(instance, created, **kwargs):
     (and to descendant Locations).
     """
     if not created:
-        instance.get_descendants().update(site=instance.site)
+        chunked_update(instance.get_descendants(), site=instance.site)
         locations = instance.get_descendants(include_self=True).values_list('pk', flat=True)
-        Rack.objects.filter(location__in=locations).update(site=instance.site)
-        Device.objects.filter(location__in=locations).update(site=instance.site)
-        PowerPanel.objects.filter(location__in=locations).update(site=instance.site)
+        chunked_update(Rack.objects.filter(location__in=locations), site=instance.site)
+        chunked_update(Device.objects.filter(location__in=locations), site=instance.site)
+        chunked_update(PowerPanel.objects.filter(location__in=locations), site=instance.site)
 
 
 @receiver(post_save, sender=Rack)
@@ -50,7 +51,7 @@ def handle_rack_site_change(instance, created, **kwargs):
     Cascade a Rack's Site/Location assignment down to the Devices it contains.
     """
     if not created:
-        Device.objects.filter(rack=instance).update(site=instance.site, location=instance.location)
+        chunked_update(Device.objects.filter(rack=instance), site=instance.site, location=instance.location)
 
 
 #
@@ -123,7 +124,7 @@ def update_connected_endpoints(instance, created, raw=False, **kwargs):
     # Update status of CablePaths if Cable status has been changed
     elif instance.status != instance._orig_status:
         if instance.status != LinkStatusChoices.STATUS_CONNECTED:
-            CablePath.objects.filter(_nodes__contains=instance).update(is_active=False)
+            chunked_update(CablePath.objects.filter(_nodes__contains=instance), is_active=False)
         else:
             rebuild_paths([instance])
 
@@ -156,6 +157,14 @@ def nullify_connected_endpoints(instance, **kwargs):
     model = instance.termination_type.model_class()
     model.objects.filter(pk=instance.termination_id).update(cable=None, cable_end='')
 
+    # If the removed termination was a channelized interface, also clear the cable attributes mirrored onto its channel
+    # subinterfaces. This must happen before the retrace below so that each channel's (now dead) path is torn down
+    # rather than rebuilt from a stale cable reference.
+    if model is Interface:
+        Interface.objects.filter(parent_id=instance.termination_id, channel_id__isnull=False).update(
+            cable=None, cable_end='', cable_connector=None, cable_positions=None
+        )
+
     # If the parent Cable is being deleted in this same operation, skip the
     # per-termination retrace; retrace_cable_paths() will retrace each affected
     # path once after the Cable is deleted.
@@ -171,6 +180,66 @@ def nullify_connected_endpoints(instance, **kwargs):
         cablepath.retrace()
 
 
+@receiver(post_save, sender=Interface)
+def update_channelized_cable_paths(instance, created, raw=False, **kwargs):
+    """
+    Rebuild cable paths when an interface's channelization changes without the Cable itself being modified: a channel
+    subinterface is added, moved between parents, or has its channel_id changed, or channelization is toggled on an
+    already-cabled interface. (The cable-tracing signals only fire when a Cable is saved.)
+    """
+    if raw:
+        return
+
+    parent_ids = set()
+
+    # A channel subinterface was added, moved between parents, or had its channel_id changed
+    if instance.channel_id or instance._original_channel_id:
+        parent_ids.update(pk for pk in (instance.parent_id, instance._original_parent_id) if pk)
+
+    # Channelization was toggled on this interface while it carries a cable
+    if instance.channels != instance._original_channels and instance.cable_id:
+        parent_ids.add(instance.pk)
+
+    # select_related('cable') avoids a per-parent round-trip to fetch the Cable, which both
+    # propagate_channel_cables() and rebuild_cable_paths() dereference. (Cable.profile is a plain field, not a
+    # relation, so it needs no prefetching.)
+    parents = Interface.objects.filter(pk__in=parent_ids, cable__isnull=False).select_related('cable')
+    for parent in parents:
+        if parent.channels:
+            parent.propagate_channel_cables()
+        rebuild_cable_paths(parent.cable)
+
+    # A channel subinterface whose parent no longer provides a cable must not retain stale mirrored cable attributes
+    if instance.channel_id and instance.cable_id:
+        parent = instance.parent
+        if not (parent and parent.channels and parent.cable_id):
+            Interface.objects.filter(pk=instance.pk).update(
+                cable=None, cable_end='', cable_connector=None, cable_positions=None
+            )
+            for cablepath in CablePath.objects.filter(_nodes__contains=instance):
+                if instance in cablepath.origins:
+                    cablepath.delete()
+
+    # Refresh the cached channelization state so that saving this same in-memory instance again compares against its
+    # current values rather than re-triggering propagation from a stale baseline.
+    instance._original_channels = instance.channels
+    instance._original_channel_id = instance.channel_id
+    instance._original_parent_id = instance.parent_id
+
+
+@receiver(post_delete, sender=Interface)
+def cleanup_channel_subinterface_paths(instance, **kwargs):
+    """
+    When a channel subinterface is deleted, rebuild its channelized parent's cable paths so the removed channel's path
+    is torn down.
+    """
+    if instance.channel_id and instance.parent_id:
+        parent = Interface.objects.filter(pk=instance.parent_id, cable__isnull=False).first()
+        if parent and parent.channels:
+            parent.propagate_channel_cables()
+            rebuild_cable_paths(parent.cable)
+
+
 @receiver(post_save, sender=Interface)
 @receiver(post_save, sender=VMInterface)
 def update_mac_address_interface(instance, created, raw, **kwargs):

+ 22 - 5
netbox/dcim/svg/cables.py

@@ -19,6 +19,22 @@ FANOUT_LEG_HEIGHT = 15
 CABLE_HEIGHT = 5 * LINE_HEIGHT + FANOUT_HEIGHT + FANOUT_LEG_HEIGHT
 
 
+def _cable_side_nodes(term_nodes, cable_terminations):
+    """
+    Filter a list of termination nodes to those connected to the given side of a Cable. A channel subinterface does
+    not terminate the cable directly; it derives its connection from its (channelized) parent interface, which carries
+    the actual CableTermination, so it is matched via its parent.
+    """
+    def matches(obj):
+        if obj in cable_terminations:
+            return True
+        if getattr(obj, 'channel_id', None):
+            return obj.parent in cable_terminations
+        return False
+
+    return [node for node in term_nodes if matches(node.object)]
+
+
 class Node(Hyperlink):
     """
     Create a node to be represented in the SVG document as a rectangular box with a hyperlink.
@@ -380,13 +396,14 @@ class CableTraceSVG:
                             description.append(f"{cable.length} {cable.get_length_unit_display()}")
                         color = cable.color or '000000'
 
-                        # Collect all connected nodes to this cable
-                        near = [term for term in near_terminations if term.object in cable.a_terminations]
-                        far = [term for term in far_terminations if term.object in cable.b_terminations]
+                        # Collect all connected nodes to this cable. Channel subinterfaces are matched via their
+                        # parent interface, which carries the actual cable termination.
+                        near = _cable_side_nodes(near_terminations, cable.a_terminations)
+                        far = _cable_side_nodes(far_terminations, cable.b_terminations)
                         if not (near and far):
                             # a and b terminations may be swapped
-                            near = [term for term in near_terminations if term.object in cable.b_terminations]
-                            far = [term for term in far_terminations if term.object in cable.a_terminations]
+                            near = _cable_side_nodes(near_terminations, cable.b_terminations)
+                            far = _cable_side_nodes(far_terminations, cable.a_terminations)
                     elif isinstance(cable, WirelessLink):
                         labels = [f"{cable}"] if len(links) > 2 else [f"Wireless {cable}", cable.get_status_display()]
                         if cable.ssid:

+ 6 - 6
netbox/dcim/tables/devices.py

@@ -710,12 +710,12 @@ class InterfaceTable(BaseInterfaceTable, ModularDeviceComponentTable, PathEndpoi
     class Meta(DeviceComponentTable.Meta):
         model = models.Interface
         fields = (
-            'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu',
-            'speed', 'speed_formatted', 'duplex', 'mode', 'mac_addresses', 'primary_mac_address', 'wwn',
-            'poe_mode', 'poe_type', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
-            'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer',
-            'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups',
-            'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'inventory_items', 'created', 'last_updated',
+            'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'channels',
+            'channel_id', 'mgmt_only', 'mtu', 'speed', 'speed_formatted', 'duplex', 'mode', 'mac_addresses',
+            'primary_mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel', 'rf_channel_frequency',
+            'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link',
+            'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'tunnel', 'ip_addresses',
+            'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'inventory_items', 'created', 'last_updated',
             'vlan_translation_policy',
         )
         default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')

+ 2 - 2
netbox/dcim/tables/devicetypes.py

@@ -244,8 +244,8 @@ class InterfaceTemplateTable(ComponentTemplateTable):
     class Meta(ComponentTemplateTable.Meta):
         model = models.InterfaceTemplate
         fields = (
-            'pk', 'name', 'label', 'enabled', 'mgmt_only', 'type', 'description', 'bridge', 'poe_mode', 'poe_type',
-            'rf_role', 'actions',
+            'pk', 'name', 'label', 'enabled', 'mgmt_only', 'type', 'channels', 'channel_id', 'description', 'parent',
+            'bridge', 'poe_mode', 'poe_type', 'rf_role', 'actions',
         )
         empty_text = "None"
 

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

@@ -1372,9 +1372,11 @@ class InterfaceTemplateTestCase(APIViewTestCases.APIViewTestCase):
         interface_templates = (
             InterfaceTemplate(device_type=devicetype, name='Interface Template 1', type='1000base-t'),
             InterfaceTemplate(device_type=devicetype, name='Interface Template 2', type='1000base-t'),
-            InterfaceTemplate(device_type=devicetype, name='Interface Template 3', type='1000base-t'),
+            # Interface Template 3 is channelized, so that channel subinterface templates may be bound to it
+            InterfaceTemplate(device_type=devicetype, name='Interface Template 3', type='1000base-t', channels=4),
         )
         InterfaceTemplate.objects.bulk_create(interface_templates)
+        channelized_parent = interface_templates[2]
 
         cls.create_data = [
             {
@@ -1397,6 +1399,21 @@ class InterfaceTemplateTestCase(APIViewTestCases.APIViewTestCase):
                 'name': 'Interface Template 7',
                 'type': '1000base-t',
             },
+            {
+                # A channelized parent template
+                'device_type': devicetype.pk,
+                'name': 'Interface Template 8',
+                'type': InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS,
+                'channels': 4,
+            },
+            {
+                # A channel subinterface template bound to a channelized parent
+                'device_type': devicetype.pk,
+                'name': 'Interface Template 9',
+                'type': InterfaceTypeChoices.TYPE_CHANNEL,
+                'parent': channelized_parent.pk,
+                'channel_id': 1,
+            },
         ]
 
 
@@ -2923,9 +2940,11 @@ class InterfaceTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTest
         interfaces = (
             Interface(device=device, name='Interface 1', type='1000base-t'),
             Interface(device=device, name='Interface 2', type='1000base-t'),
-            Interface(device=device, name='Interface 3', type='1000base-t'),
+            # Interface 3 is channelized, so that channel subinterfaces may be bound to it
+            Interface(device=device, name='Interface 3', type='1000base-t', channels=4),
         )
         Interface.objects.bulk_create(interfaces)
+        channelized_parent = interfaces[2]
 
         vdcs = (
             VirtualDeviceContext(name='VDC 1', identifier=1, device=device),
@@ -3013,6 +3032,21 @@ class InterfaceTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTest
                 'rf_channel': "",
                 'qinq_svlan': vlans[3].pk,
             },
+            {
+                # A channelized parent interface
+                'device': device.pk,
+                'name': 'Interface 9',
+                'type': InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS,
+                'channels': 4,
+            },
+            {
+                # A channel subinterface bound to a channelized parent
+                'device': device.pk,
+                'name': 'Interface 10',
+                'type': InterfaceTypeChoices.TYPE_CHANNEL,
+                'parent': channelized_parent.pk,
+                'channel_id': 1,
+            },
         ]
 
     def _perform_interface_test_with_invalid_data(self, mode: str = None, invalid_data: dict = {}):

+ 630 - 0
netbox/dcim/tests/test_channelization.py

@@ -0,0 +1,630 @@
+from django.core.exceptions import ValidationError
+from django.test import TestCase
+from django.urls import reverse
+
+from dcim.choices import CableProfileChoices, InterfaceTypeChoices
+from dcim.models import (
+    Cable,
+    CablePath,
+    Device,
+    DeviceRole,
+    DeviceType,
+    Interface,
+    InterfaceTemplate,
+    Manufacturer,
+    Site,
+)
+from dcim.svg import CableTraceSVG
+from dcim.svg.cables import Connector
+from dcim.tests.utils import BaseCablePathTestCase
+from utilities.testing import TestCase as ViewTestCase
+
+
+class ChannelizedCablePathTestCase(BaseCablePathTestCase):
+    """
+    Test cable path tracing for channelized interfaces. A single physical cable terminates to a channelized (parent)
+    interface, and each of the parent's channel subinterfaces traces an independent path from the connector position
+    identified by its channel_id.
+    """
+
+    def _create_channelized_interface(self, name, channels, device=None):
+        """Create a channelized parent interface and its channel subinterfaces."""
+        device = device or self.device
+        parent = Interface.objects.create(
+            device=device, name=name, type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=channels
+        )
+        children = [
+            Interface.objects.create(
+                device=device,
+                name=f'{name}:{i}',
+                type=InterfaceTypeChoices.TYPE_CHANNEL,
+                parent=parent,
+                channel_id=i,
+            )
+            for i in range(1, channels + 1)
+        ]
+        return parent, children
+
+    def test_101_channelized_breakout_to_discrete_interfaces(self):
+        """
+        A 4-channel parent broken out to four discrete far-end interfaces via a 1C4P:4C1P breakout cable. Each channel
+        subinterface traces to its corresponding far-end interface (and vice versa); the parent itself has no path.
+        """
+        parent, channels = self._create_channelized_interface('et0', 4)
+        far = [
+            Interface.objects.create(device=self.device, name=f'xe{i}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS)
+            for i in range(4)
+        ]
+
+        cable = Cable(
+            profile=CableProfileChoices.BREAKOUT_1C4P_4C1P,
+            a_terminations=[parent],
+            b_terminations=far,
+        )
+        cable.clean()
+        cable.save()
+
+        # One forward and one reverse path per channel; the parent originates no path
+        self.assertEqual(CablePath.objects.count(), 8)
+        parent.refresh_from_db()
+        self.assertPathIsNotSet(parent)
+
+        for i, (channel, far_iface) in enumerate(zip(channels, far), start=1):
+            channel.refresh_from_db()
+            far_iface.refresh_from_db()
+
+            # The parent's cable is mirrored onto the channel, restricted to its single connector position
+            self.assertEqual(channel.cable_id, cable.pk)
+            self.assertEqual(channel.cable_connector, 1)
+            self.assertEqual(channel.cable_positions, [i])
+
+            forward = self.assertPathExists((channel, cable, far_iface), is_complete=True, is_active=True)
+            reverse = self.assertPathExists((far_iface, cable, channel), is_complete=True, is_active=True)
+            self.assertPathIsSet(channel, forward)
+            self.assertPathIsSet(far_iface, reverse)
+
+        # The trace SVG must render from both a channel subinterface and a discrete far-end interface
+        CableTraceSVG(channels[0]).render()
+        CableTraceSVG(far[0]).render()
+
+    def test_102_channelized_to_channelized(self):
+        """
+        Two channelized interfaces connected by a single 1C4P cable (both ends channelized on one connector). Each
+        near-end channel traces to the far-end channel bound to the same position.
+        """
+        near_parent, near_channels = self._create_channelized_interface('et0', 4)
+        far_device = Device.objects.create(
+            site=self.site, device_type=self.device.device_type, role=self.device.role, name='Device 2'
+        )
+        far_parent, far_channels = self._create_channelized_interface('et0', 4, device=far_device)
+
+        cable = Cable(
+            profile=CableProfileChoices.SINGLE_1C4P,
+            a_terminations=[near_parent],
+            b_terminations=[far_parent],
+        )
+        cable.clean()
+        cable.save()
+
+        self.assertEqual(CablePath.objects.count(), 8)
+        for near, far in zip(near_channels, far_channels):
+            near.refresh_from_db()
+            far.refresh_from_db()
+            self.assertPathExists((near, cable, far), is_complete=True, is_active=True)
+            self.assertPathExists((far, cable, near), is_complete=True, is_active=True)
+
+        # The trace SVG for a channel subinterface must render, drawing the cable between the two channels. The cable
+        # terminates on the parent interfaces, so the connector is matched to the channels via their parents.
+        svg = CableTraceSVG(near_channels[0])
+        svg.render()
+        self.assertTrue(
+            any(isinstance(c, Connector) for c in svg.connectors),
+            msg="Trace SVG did not render a cable connector for the channelized path"
+        )
+
+    def test_103_add_channel_after_cabling(self):
+        """
+        On an already-cabled parent, deleting a channel subinterface tears down its path, and adding a channel
+        subinterface (re-adding one on the freed position) builds a fresh path for it in both directions.
+        """
+        parent, channels = self._create_channelized_interface('et0', 4)
+        far = [
+            Interface.objects.create(device=self.device, name=f'xe{i}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS)
+            for i in range(4)
+        ]
+        cable = Cable(
+            profile=CableProfileChoices.BREAKOUT_1C4P_4C1P,
+            a_terminations=[parent],
+            b_terminations=far,
+        )
+        cable.clean()
+        cable.save()
+
+        # Removing the fourth channel tears down its complete path in both directions
+        channels[3].delete()
+        self.assertPathDoesNotExist((channels[3], cable, far[3]))
+        self.assertPathDoesNotExist((far[3], cable, channels[3]))
+
+        # Re-adding a channel on position 4 restores the complete path in both directions
+        new_channel = Interface.objects.create(
+            device=self.device, name='et0:4', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=parent, channel_id=4
+        )
+        new_channel.refresh_from_db()
+        self.assertEqual(new_channel.cable_positions, [4])
+        self.assertPathExists((new_channel, cable, far[3]), is_complete=True, is_active=True)
+        self.assertPathExists((far[3], cable, new_channel), is_complete=True, is_active=True)
+
+    def test_104_change_channel_id(self):
+        """
+        Changing a channel's channel_id re-binds it to a different connector position, in both directions.
+        """
+        parent, channels = self._create_channelized_interface('et0', 4)
+        far = [
+            Interface.objects.create(device=self.device, name=f'xe{i}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS)
+            for i in range(4)
+        ]
+        # Delete channels 3 and 4 so their positions are free to reassign to
+        channels[2].delete()
+        channels[3].delete()
+        cable = Cable(
+            profile=CableProfileChoices.BREAKOUT_1C4P_4C1P,
+            a_terminations=[parent],
+            b_terminations=far,
+        )
+        cable.clean()
+        cable.save()
+
+        # Channel 1 initially traces to far[0]
+        self.assertPathExists((channels[0], cable, far[0]), is_complete=True, is_active=True)
+
+        # Move channel 1 to position 3
+        channels[0].channel_id = 3
+        channels[0].save()
+        channels[0].refresh_from_db()
+
+        self.assertEqual(channels[0].cable_positions, [3])
+        self.assertPathDoesNotExist((channels[0], cable, far[0]))
+        self.assertPathExists((channels[0], cable, far[2]), is_complete=True, is_active=True)
+        self.assertPathExists((far[2], cable, channels[0]), is_complete=True, is_active=True)
+
+    def test_105_incomplete_channel(self):
+        """
+        A channel whose position has no far-end termination yields an incomplete path (rather than an error).
+        """
+        parent, channels = self._create_channelized_interface('et0', 4)
+        # Only two far-end interfaces exist, on connectors 1 and 2
+        far = [
+            Interface.objects.create(device=self.device, name=f'xe{i}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS)
+            for i in range(2)
+        ]
+        cable = Cable(
+            profile=CableProfileChoices.BREAKOUT_1C4P_4C1P,
+            a_terminations=[parent],
+            b_terminations=far,
+        )
+        cable.clean()
+        cable.save()
+
+        # Channels 1 & 2 are complete; channels 3 & 4 have no far-end termination and trace an incomplete path
+        channels[0].refresh_from_db()
+        channels[2].refresh_from_db()
+        self.assertPathExists((channels[0], cable, far[0]), is_complete=True)
+        self.assertIsNotNone(channels[2]._path_id)
+        self.assertFalse(channels[2].path.is_complete)
+
+    def test_106_cable_removal_teardown(self):
+        """
+        Removing the cable from a channelized parent tears down every channel's path and clears the mirrored cable
+        attributes from the channels.
+        """
+        parent, channels = self._create_channelized_interface('et0', 4)
+        far = [
+            Interface.objects.create(device=self.device, name=f'xe{i}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS)
+            for i in range(4)
+        ]
+        cable = Cable(
+            profile=CableProfileChoices.BREAKOUT_1C4P_4C1P,
+            a_terminations=[parent],
+            b_terminations=far,
+        )
+        cable.clean()
+        cable.save()
+        self.assertEqual(CablePath.objects.count(), 8)
+
+        cable.delete()
+
+        self.assertEqual(CablePath.objects.count(), 0)
+        for channel in channels:
+            channel.refresh_from_db()
+            self.assertIsNone(channel.cable_id)
+            self.assertIsNone(channel.cable_connector)
+            self.assertIsNone(channel.cable_positions)
+            self.assertPathIsNotSet(channel)
+
+    def test_107_direct_cabling_of_channel_rejected(self):
+        """
+        A cable cannot be terminated directly to a channel subinterface.
+        """
+        parent, channels = self._create_channelized_interface('et0', 4)
+        far = Interface.objects.create(
+            device=self.device, name='xe0', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
+        )
+        cable = Cable(a_terminations=[channels[0]], b_terminations=[far])
+        with self.assertRaises(ValidationError):
+            cable.clean()
+
+    def test_108_unprofiled_cable_not_propagated(self):
+        """
+        An unprofiled cable carries no per-channel positions, so its attributes are not mirrored onto the parent's
+        channel subinterfaces.
+        """
+        parent, channels = self._create_channelized_interface('et0', 4)
+        far = Interface.objects.create(
+            device=self.device, name='xe0', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
+        )
+        cable = Cable(a_terminations=[parent], b_terminations=[far])
+        cable.clean()
+        cable.save()
+
+        # The parent itself is cabled, but no cable attributes are mirrored onto the channels
+        parent.refresh_from_db()
+        self.assertEqual(parent.cable_id, cable.pk)
+        for channel in channels:
+            channel.refresh_from_db()
+            self.assertIsNone(channel.cable_id)
+            self.assertIsNone(channel.cable_positions)
+
+    def test_109_change_channel_count_after_cabling(self):
+        """
+        Increasing the channel count on an already-cabled parent re-propagates the cable to its existing channel
+        subinterfaces and rebuilds their paths (the Cable itself is unchanged, so only the post_save signal fires).
+        """
+        parent, channels = self._create_channelized_interface('et0', 4)
+        far = [
+            Interface.objects.create(device=self.device, name=f'xe{i}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS)
+            for i in range(4)
+        ]
+        cable = Cable(
+            profile=CableProfileChoices.BREAKOUT_1C4P_4C1P,
+            a_terminations=[parent],
+            b_terminations=far,
+        )
+        cable.clean()
+        cable.save()
+        self.assertEqual(CablePath.objects.count(), 8)
+
+        # Increase the channel count; the existing channels' paths must survive
+        parent.refresh_from_db()
+        parent.channels = 8
+        parent.save()
+
+        self.assertEqual(CablePath.objects.count(), 8)
+        for i, (channel, far_iface) in enumerate(zip(channels, far), start=1):
+            channel.refresh_from_db()
+            self.assertEqual(channel.cable_positions, [i])
+            self.assertPathExists((channel, cable, far_iface), is_complete=True, is_active=True)
+
+    def test_110_move_channel_to_uncabled_parent(self):
+        """
+        Moving a channel subinterface from a cabled parent to a channelized-but-uncabled parent tears down the
+        channel's mirrored cable attributes and its (now orphaned) path.
+        """
+        parent, channels = self._create_channelized_interface('et0', 4)
+        far = [
+            Interface.objects.create(device=self.device, name=f'xe{i}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS)
+            for i in range(4)
+        ]
+        cable = Cable(
+            profile=CableProfileChoices.BREAKOUT_1C4P_4C1P,
+            a_terminations=[parent],
+            b_terminations=far,
+        )
+        cable.clean()
+        cable.save()
+
+        # A second channelized parent with no cable
+        uncabled_parent = Interface.objects.create(
+            device=self.device, name='et1', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=4
+        )
+
+        # Move the first channel to the uncabled parent; its mirrored cable & path must be torn down
+        channel = channels[0]
+        channel.refresh_from_db()
+        self.assertEqual(channel.cable_id, cable.pk)
+        channel.parent = uncabled_parent
+        channel.save()
+
+        channel.refresh_from_db()
+        self.assertIsNone(channel.cable_id)
+        self.assertIsNone(channel.cable_connector)
+        self.assertIsNone(channel.cable_positions)
+        self.assertPathIsNotSet(channel)
+        self.assertPathDoesNotExist((channel, cable, far[0]))
+        self.assertPathDoesNotExist((far[0], cable, channel))
+
+
+class ChannelizedInterfaceValidationTestCase(TestCase):
+    """
+    Test validation of the channels and channel_id fields on Interface.
+    """
+
+    @classmethod
+    def setUpTestData(cls):
+        manufacturer = Manufacturer.objects.create(name='Generic', slug='generic')
+        device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Test Device')
+        role = DeviceRole.objects.create(name='Device Role', slug='device-role')
+        site = Site.objects.create(name='Site', slug='site')
+        cls.device = Device.objects.create(site=site, device_type=device_type, role=role, name='Device 1')
+        cls.parent = Interface.objects.create(
+            device=cls.device, name='et0', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=4
+        )
+
+    def test_valid_channel_subinterface(self):
+        interface = Interface(
+            device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=1
+        )
+        interface.full_clean()  # Should not raise
+
+    def test_channel_type_requires_channel_id(self):
+        interface = Interface(
+            device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent
+        )
+        with self.assertRaises(ValidationError):
+            interface.full_clean()
+
+    def test_channel_id_requires_channel_type(self):
+        interface = Interface(
+            device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS,
+            parent=self.parent, channel_id=1
+        )
+        with self.assertRaises(ValidationError):
+            interface.full_clean()
+
+    def test_channel_requires_parent(self):
+        interface = Interface(
+            device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, channel_id=1
+        )
+        with self.assertRaises(ValidationError):
+            interface.full_clean()
+
+    def test_channel_requires_channelized_parent(self):
+        plain_parent = Interface.objects.create(
+            device=self.device, name='xe0', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
+        )
+        interface = Interface(
+            device=self.device, name='xe0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=plain_parent, channel_id=1
+        )
+        with self.assertRaises(ValidationError):
+            interface.full_clean()
+
+    def test_channel_id_within_parent_range(self):
+        interface = Interface(
+            device=self.device, name='et0:5', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=5
+        )
+        with self.assertRaises(ValidationError):
+            interface.full_clean()
+
+    def test_channels_and_channel_id_mutually_exclusive(self):
+        interface = Interface(
+            device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL,
+            parent=self.parent, channel_id=1, channels=4
+        )
+        with self.assertRaises(ValidationError):
+            interface.full_clean()
+
+    def test_channels_not_allowed_on_virtual_type(self):
+        interface = Interface(
+            device=self.device, name='vlan10', type=InterfaceTypeChoices.TYPE_VIRTUAL, channels=4
+        )
+        with self.assertRaises(ValidationError):
+            interface.full_clean()
+
+    def test_reduce_channels_below_bound_child_rejected(self):
+        # Bind a channel to the highest channel of the parent, then attempt to reduce the parent's channel count
+        Interface.objects.create(
+            device=self.device, name='et0:4', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=4
+        )
+        self.parent.channels = 2
+        with self.assertRaises(ValidationError):
+            self.parent.full_clean()
+
+    def test_clear_channels_with_bound_child_rejected(self):
+        # De-channelizing a parent entirely must be rejected while any channel subinterface is still bound to it
+        Interface.objects.create(
+            device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=1
+        )
+        self.parent.channels = None
+        with self.assertRaises(ValidationError):
+            self.parent.full_clean()
+
+    def test_clear_channels_without_bound_child_allowed(self):
+        # De-channelizing is permitted once no channel subinterfaces remain bound to the parent
+        self.parent.channels = None
+        self.parent.full_clean()  # Should not raise
+
+    def test_parent_channel_id_must_be_unique(self):
+        Interface.objects.create(
+            device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=1
+        )
+        duplicate = Interface(
+            device=self.device, name='et0:1b', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=1
+        )
+        with self.assertRaises(ValidationError):
+            duplicate.full_clean()
+
+
+class ChannelizedInterfaceTemplateTestCase(TestCase):
+    """
+    Test that the channels, channel_id, and parent fields are replicated from InterfaceTemplate to the Interfaces
+    instantiated for a new Device, and that parent interfaces are populated before their channel subinterfaces.
+    """
+
+    @classmethod
+    def setUpTestData(cls):
+        manufacturer = Manufacturer.objects.create(name='Generic', slug='generic')
+        cls.device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Test Device', slug='test-device')
+        cls.role = DeviceRole.objects.create(name='Device Role', slug='device-role')
+        cls.site = Site.objects.create(name='Site', slug='site')
+
+        # A channelized parent template broken out into four channel subinterface templates bound to it
+        parent_template = InterfaceTemplate.objects.create(
+            device_type=cls.device_type, name='et0', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=4
+        )
+        for i in range(1, 5):
+            InterfaceTemplate.objects.create(
+                device_type=cls.device_type,
+                name=f'et0:{i}',
+                type=InterfaceTypeChoices.TYPE_CHANNEL,
+                parent=parent_template,
+                channel_id=i,
+            )
+
+    def test_channelization_replicated_on_instantiation(self):
+        device = Device.objects.create(
+            site=self.site, device_type=self.device_type, role=self.role, name='Device 1'
+        )
+
+        # The channelized parent carries its channel count
+        parent = device.interfaces.get(name='et0')
+        self.assertEqual(parent.channels, 4)
+        self.assertIsNone(parent.channel_id)
+
+        # Each channel subinterface carries its channel ID and is bound to the instantiated parent interface
+        for i in range(1, 5):
+            channel = device.interfaces.get(name=f'et0:{i}')
+            self.assertEqual(channel.channel_id, i)
+            self.assertIsNone(channel.channels)
+            self.assertEqual(channel.parent, parent)
+
+    def test_parent_template_validation(self):
+        # A parent template must belong to the same device type
+        other_type = DeviceType.objects.create(
+            manufacturer=self.device_type.manufacturer, model='Other Device', slug='other-device'
+        )
+        foreign_parent = InterfaceTemplate.objects.get(device_type=self.device_type, name='et0')
+        template = InterfaceTemplate(
+            device_type=other_type, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL,
+            parent=foreign_parent, channel_id=1
+        )
+        with self.assertRaises(ValidationError):
+            template.full_clean()
+
+    def test_template_parent_channel_id_must_be_unique(self):
+        parent = InterfaceTemplate.objects.get(device_type=self.device_type, name='et0')
+        # Channel 1 already exists on the parent (created in setUpTestData)
+        duplicate = InterfaceTemplate(
+            device_type=self.device_type, name='et0:1b', type=InterfaceTypeChoices.TYPE_CHANNEL,
+            parent=parent, channel_id=1
+        )
+        with self.assertRaises(ValidationError):
+            duplicate.full_clean()
+
+    def test_template_channel_id_within_parent_range(self):
+        # A channel_id beyond the parent's channel count is rejected at the template level
+        parent = InterfaceTemplate.objects.get(device_type=self.device_type, name='et0')
+        template = InterfaceTemplate(
+            device_type=self.device_type, name='et0:5', type=InterfaceTypeChoices.TYPE_CHANNEL,
+            parent=parent, channel_id=5
+        )
+        with self.assertRaises(ValidationError):
+            template.full_clean()
+
+    def test_template_channel_requires_channelized_parent(self):
+        # A channel template bound to a non-channelized parent template is rejected
+        plain_parent = InterfaceTemplate.objects.create(
+            device_type=self.device_type, name='xe0', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
+        )
+        template = InterfaceTemplate(
+            device_type=self.device_type, name='xe0:1', type=InterfaceTypeChoices.TYPE_CHANNEL,
+            parent=plain_parent, channel_id=1
+        )
+        with self.assertRaises(ValidationError):
+            template.full_clean()
+
+    def test_template_channel_id_requires_channel_type(self):
+        # A channel_id on a non-channel-type template is rejected
+        template = InterfaceTemplate(
+            device_type=self.device_type, name='xe1', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS,
+            channel_id=1
+        )
+        with self.assertRaises(ValidationError):
+            template.full_clean()
+
+    def test_template_reduce_channels_below_bound_child_rejected(self):
+        # Reducing a parent template's channel count below a bound child template's channel_id is rejected (channels
+        # 3 & 4 are bound in setUpTestData)
+        parent = InterfaceTemplate.objects.get(device_type=self.device_type, name='et0')
+        parent.channels = 2
+        with self.assertRaises(ValidationError):
+            parent.full_clean()
+
+    def test_template_clear_channels_with_bound_child_rejected(self):
+        # De-channelizing a parent template entirely is rejected while a channel subinterface template is bound to it
+        parent = InterfaceTemplate.objects.get(device_type=self.device_type, name='et0')
+        parent.channels = None
+        with self.assertRaises(ValidationError):
+            parent.full_clean()
+
+
+class ChannelizedBulkCreateTestCase(ViewTestCase):
+    """
+    Test channel_id pattern expansion when bulk-creating channel subinterfaces (and interface templates) so that each
+    generated object receives a distinct channel_id.
+    """
+
+    def setUp(self):
+        super().setUp()
+        manufacturer = Manufacturer.objects.create(name='Generic', slug='generic')
+        self.device_type = DeviceType.objects.create(
+            manufacturer=manufacturer, model='Test Device', slug='test-device'
+        )
+        role = DeviceRole.objects.create(name='Device Role', slug='device-role')
+        site = Site.objects.create(name='Site', slug='site')
+        self.device = Device.objects.create(
+            site=site, device_type=self.device_type, role=role, name='Device 1'
+        )
+
+    def test_bulk_create_channel_subinterfaces(self):
+        parent = Interface.objects.create(
+            device=self.device, name='et0', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=4
+        )
+        self.add_permissions('dcim.add_interface', 'dcim.view_interface')
+
+        request_data = {
+            'device': self.device.pk,
+            'name': 'et0:[1-4]',
+            'type': InterfaceTypeChoices.TYPE_CHANNEL,
+            'parent': parent.pk,
+            'channel_id': '[1-4]',
+        }
+        response = self.client.post(reverse('dcim:interface_add'), request_data)
+        self.assertHttpStatus(response, 302)
+
+        # Four channel subinterfaces are created, each bound to a distinct channel on the parent
+        channels = Interface.objects.filter(parent=parent).order_by('channel_id')
+        self.assertEqual(channels.count(), 4)
+        for i, channel in enumerate(channels, start=1):
+            self.assertEqual(channel.name, f'et0:{i}')
+            self.assertEqual(channel.channel_id, i)
+
+    def test_bulk_create_channel_subinterface_templates(self):
+        parent = InterfaceTemplate.objects.create(
+            device_type=self.device_type, name='et0', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=4
+        )
+        self.add_permissions('dcim.add_interfacetemplate', 'dcim.view_interfacetemplate')
+
+        request_data = {
+            'device_type': self.device_type.pk,
+            'name': 'et0:[1-4]',
+            'type': InterfaceTypeChoices.TYPE_CHANNEL,
+            'parent': parent.pk,
+            'channel_id': '[1-4]',
+        }
+        response = self.client.post(reverse('dcim:interfacetemplate_add'), request_data)
+        self.assertHttpStatus(response, 302)
+
+        templates = InterfaceTemplate.objects.filter(parent=parent).order_by('channel_id')
+        self.assertEqual(templates.count(), 4)
+        for i, template in enumerate(templates, start=1):
+            self.assertEqual(template.name, f'et0:{i}')
+            self.assertEqual(template.channel_id, i)

+ 2 - 0
netbox/dcim/ui/panels.py

@@ -523,6 +523,7 @@ class InterfacePanel(panels.ObjectAttributesPanel):
     name = attrs.TextAttr('name')
     label = attrs.TextAttr('label')
     type = attrs.ChoiceAttr('type')
+    channels = attrs.NumericAttr('channels')
     speed = attrs.TemplatedAttr('speed', template_name='dcim/interface/attrs/speed.html', label=_('Speed'))
     duplex = attrs.ChoiceAttr('duplex')
     mtu = attrs.NumericAttr('mtu', label=_('MTU'))
@@ -543,6 +544,7 @@ class RelatedInterfacesPanel(panels.ObjectAttributesPanel):
     title = _('Related Interfaces')
 
     parent = attrs.RelatedObjectAttr('parent', linkify=True)
+    channel_id = attrs.NumericAttr('channel_id', label=_('Channel ID'))
     bridge = attrs.RelatedObjectAttr('bridge', linkify=True)
     lag = attrs.RelatedObjectAttr('lag', linkify=True, label=_('LAG'))
 

+ 69 - 4
netbox/dcim/utils.py

@@ -131,12 +131,29 @@ def create_cablepaths(objects):
 
     :param objects: Iterable of cabled objects (e.g. Interfaces)
     """
-    from dcim.models import CablePath
+    from dcim.models import CablePath, Interface
 
-    # Arrange objects by cable connector. All objects with a null connector are grouped together.
-    origins = defaultdict(list)
+    # Expand any channelized interface into its channel subinterfaces. A channelized parent originates no path of its
+    # own; instead, each channel subinterface traces independently from the single connector position it occupies.
+    # Plain (non-channelized) origins pass through unchanged, keeping this expansion re-entrant so that
+    # rebuild_paths() -> create_cablepaths(cp.origins) does not re-expand the channel subinterfaces it already holds.
+    expanded = []
     for obj in objects:
-        origins[obj.cable_connector].append(obj)
+        if isinstance(obj, Interface) and obj.channels:
+            expanded.extend(obj.child_interfaces.filter(channel_id__isnull=False, cable__isnull=False))
+        else:
+            expanded.append(obj)
+
+    # Arrange objects by cable connector. All objects with a null connector are grouped together. Channel
+    # subinterfaces must each originate their own path, as sharing a connector would otherwise collapse a group of
+    # siblings into a single malformed path.
+    origins = defaultdict(list)
+    for obj in expanded:
+        if isinstance(obj, Interface) and obj.channel_id:
+            if cp := CablePath.from_origin([obj]):
+                cp.save()
+        else:
+            origins[obj.cable_connector].append(obj)
 
     for connector, objects in origins.items():
         if cp := CablePath.from_origin(objects):
@@ -158,6 +175,54 @@ def rebuild_paths(terminations):
                 create_cablepaths(cp.origins)
 
 
+def rebuild_cable_paths(cable):
+    """
+    Delete and rebuild every CablePath traversing the given Cable, tracing freshly from the Cable's current
+    terminations in both directions. Used when the channelization of a terminated interface changes (e.g. a channel
+    subinterface is added, moved, or removed) without the Cable itself being modified.
+    """
+    from dcim.choices import CableEndChoices
+    from dcim.models import CablePath, CableTermination, PathEndpoint
+
+    with transaction.atomic(using=router.db_for_write(CablePath)):
+        # Delete existing paths individually so each clears its `_path` back-reference on the originating endpoints.
+        for cp in CablePath.objects.filter(_nodes__contains=cable):
+            cp.delete()
+
+        a_terminations, b_terminations = [], []
+        for ct in CableTermination.objects.filter(cable=cable):
+            if ct.cable_end == CableEndChoices.SIDE_A:
+                a_terminations.append(ct.termination)
+            else:
+                b_terminations.append(ct.termination)
+
+        for nodes in (a_terminations, b_terminations):
+            if not nodes:
+                continue
+            if isinstance(nodes[0], PathEndpoint):
+                create_cablepaths(nodes)
+            else:
+                rebuild_paths(nodes)
+
+
+def update_interface_parents(device, interface_templates, module=None):
+    """
+    Used for device and module instantiation. Iterates all InterfaceTemplates with a parent assigned and applies it to
+    the actual interfaces. Must run after all interfaces have been instantiated (so that every parent interface exists)
+    and before update_interface_bridges() (so that channel subinterfaces validate against a populated parent).
+    """
+    Interface = apps.get_model('dcim', 'Interface')
+
+    for interface_template in interface_templates.exclude(parent=None):
+        interface = Interface.objects.get(device=device, name=interface_template.resolve_name(module=module))
+        interface.parent = Interface.objects.get(
+            device=device,
+            name=interface_template.parent.resolve_name(module=module)
+        )
+        interface.full_clean()
+        interface.save()
+
+
 def update_interface_bridges(device, interface_templates, module=None):
     """
     Used for device and module instantiation. Iterates all InterfaceTemplates with a bridge assigned

+ 11 - 0
netbox/extras/api/customfields.py

@@ -1,3 +1,4 @@
+from django.core.exceptions import ValidationError as DjangoValidationError
 from django.utils.translation import gettext as _
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.utils import extend_schema_field
@@ -8,6 +9,7 @@ from extras.choices import CustomFieldTypeChoices
 from extras.constants import CUSTOMFIELD_EMPTY_VALUES
 from extras.models import CustomField
 from utilities.api import get_serializer_for_model
+from utilities.forms.fields import LaxURLField
 
 #
 # Custom fields
@@ -120,6 +122,15 @@ class CustomFieldsDataField(Field):
                 else:
                     raise ValidationError(_("Unknown related object(s): {name}").format(name=data[cf.name]))
 
+            # Normalize URL values the same way the UI does (LaxURLField with assume_scheme='https'), so a
+            # schemeless value (e.g. "example.com") is stored as an absolute URL ("https://example.com").
+            # Malformed values are left untouched for CustomField.validate() to report.
+            elif cf.type == CustomFieldTypeChoices.TYPE_URL and isinstance(data.get(cf.name), str) and data[cf.name]:
+                try:
+                    data[cf.name] = LaxURLField(assume_scheme='https').to_python(data[cf.name])
+                except DjangoValidationError:
+                    pass
+
         # If updating an existing instance, start with existing custom_field_data
         if self.parent.instance:
             data = {**self.parent.instance.custom_field_data, **data}

+ 3 - 1
netbox/extras/cache.py

@@ -12,6 +12,7 @@ from django.db.models import F, Q
 from dcim.models import Device
 from extras.jobs import RenderConfigContextJob
 from extras.models.tags import TaggedItem
+from utilities.querysets import chunked_update
 from virtualization.models import VirtualMachine
 
 
@@ -30,7 +31,8 @@ def invalidate_config_context_for_objects(model_label, pks):
         return
 
     Model = apps.get_model(model_label)
-    updated = Model.objects.filter(pk__in=pks).update(
+    updated = chunked_update(
+        Model.objects.filter(pk__in=pks),
         _config_context_data=None,
         _config_context_generation=F('_config_context_generation') + 1,
     )

+ 0 - 8
netbox/extras/constants.py

@@ -6,14 +6,6 @@ from extras.choices import LogLevelChoices
 # Custom fields
 CUSTOMFIELD_EMPTY_VALUES = (None, '', [])
 
-# Maximum number of objects to update per query when provisioning, removing, or renaming custom
-# field data. Bounding the number of rows touched by each statement prevents very large tables from
-# exceeding the database statement timeout (JSONB updates rewrite each affected row). This value
-# sits at the throughput "knee": benchmarking jsonb_set() across a 1M-row table showed throughput
-# plateaus by ~5K rows/statement (raising it further yields no meaningful speedup), while keeping
-# each statement orders of magnitude below a typical statement timeout.
-CUSTOMFIELD_DATA_BATCH_SIZE = 5000
-
 # ImageAttachment
 IMAGE_ATTACHMENT_IMAGE_FORMATS = {
     'avif': 'image/avif',

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

@@ -9,7 +9,7 @@ from strawberry_django import BaseFilterLookup, DatetimeFilterLookup, FilterLook
 from extras import models
 from extras.graphql.filter_mixins import CustomFieldsFilterMixin, TagsFilterMixin
 from netbox.graphql.filter_mixins import SyncedDataFilterMixin
-from netbox.graphql.filters import BaseModelFilter, ChangeLoggedModelFilter, PrimaryModelFilter
+from netbox.graphql.filters import BaseModelFilter, ChangeLoggedModelFilter, PrimaryModelFilter, register_filter
 
 if TYPE_CHECKING:
     from core.graphql.filters import ContentTypeFilter
@@ -59,7 +59,7 @@ __all__ = (
 )
 
 
-@strawberry_django.filter_type(models.ConfigContext, lookups=True)
+@register_filter(models.ConfigContext, lookups=True)
 class ConfigContextFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter):
     name: StrFilterLookup | None = strawberry_django.filter_field()
     weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
@@ -116,14 +116,14 @@ class ConfigContextFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter):
     )
 
 
-@strawberry_django.filter_type(models.ConfigContextProfile, lookups=True)
+@register_filter(models.ConfigContextProfile, lookups=True)
 class ConfigContextProfileFilter(SyncedDataFilterMixin, PrimaryModelFilter):
     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()
 
 
-@strawberry_django.filter_type(models.ConfigTemplate, lookups=True)
+@register_filter(models.ConfigTemplate, lookups=True)
 class ConfigTemplateFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter):
     name: StrFilterLookup | None = strawberry_django.filter_field()
     description: StrFilterLookup | None = strawberry_django.filter_field()
@@ -137,7 +137,7 @@ class ConfigTemplateFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter):
     as_attachment: FilterLookup[bool] | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.CustomField, lookups=True)
+@register_filter(models.CustomField, lookups=True)
 class CustomFieldFilter(ChangeLoggedModelFilter):
     type: BaseFilterLookup[Annotated['CustomFieldTypeEnum', strawberry.lazy('extras.graphql.enums')]] | None = (
         strawberry_django.filter_field()
@@ -197,7 +197,7 @@ class CustomFieldFilter(ChangeLoggedModelFilter):
     comments: StrFilterLookup | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.CustomFieldChoiceSet, lookups=True)
+@register_filter(models.CustomFieldChoiceSet, lookups=True)
 class CustomFieldChoiceSetFilter(ChangeLoggedModelFilter):
     name: StrFilterLookup | None = strawberry_django.filter_field()
     description: StrFilterLookup | None = strawberry_django.filter_field()
@@ -239,7 +239,7 @@ class CustomFieldChoiceSetFilter(ChangeLoggedModelFilter):
         return queryset, params
 
 
-@strawberry_django.filter_type(models.CustomLink, lookups=True)
+@register_filter(models.CustomLink, lookups=True)
 class CustomLinkFilter(ChangeLoggedModelFilter):
     name: StrFilterLookup | None = strawberry_django.filter_field()
     enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
@@ -257,7 +257,7 @@ class CustomLinkFilter(ChangeLoggedModelFilter):
     new_window: FilterLookup[bool] | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.ExportTemplate, lookups=True)
+@register_filter(models.ExportTemplate, lookups=True)
 class ExportTemplateFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter):
     name: StrFilterLookup | None = strawberry_django.filter_field()
     description: StrFilterLookup | None = strawberry_django.filter_field()
@@ -271,7 +271,7 @@ class ExportTemplateFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter):
     as_attachment: FilterLookup[bool] | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.ImageAttachment, lookups=True)
+@register_filter(models.ImageAttachment, lookups=True)
 class ImageAttachmentFilter(ChangeLoggedModelFilter):
     object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
         strawberry_django.filter_field()
@@ -289,7 +289,7 @@ class ImageAttachmentFilter(ChangeLoggedModelFilter):
     name: StrFilterLookup | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.JournalEntry, lookups=True)
+@register_filter(models.JournalEntry, lookups=True)
 class JournalEntryFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter):
     assigned_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
         strawberry_django.filter_field()
@@ -305,7 +305,7 @@ class JournalEntryFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedM
     comments: StrFilterLookup | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.Notification, lookups=True)
+@register_filter(models.Notification, lookups=True)
 class NotificationFilter(BaseModelFilter):
     created: DatetimeFilterLookup | None = strawberry_django.filter_field()
     read: DatetimeFilterLookup | None = strawberry_django.filter_field()
@@ -320,7 +320,7 @@ class NotificationFilter(BaseModelFilter):
     event_type: StrFilterLookup | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.NotificationGroup, lookups=True)
+@register_filter(models.NotificationGroup, lookups=True)
 class NotificationGroupFilter(ChangeLoggedModelFilter):
     name: StrFilterLookup | None = strawberry_django.filter_field()
     description: StrFilterLookup | None = strawberry_django.filter_field()
@@ -328,7 +328,7 @@ class NotificationGroupFilter(ChangeLoggedModelFilter):
     users: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.SavedFilter, lookups=True)
+@register_filter(models.SavedFilter, lookups=True)
 class SavedFilterFilter(ChangeLoggedModelFilter):
     name: StrFilterLookup | None = strawberry_django.filter_field()
     slug: StrFilterLookup | None = strawberry_django.filter_field()
@@ -345,7 +345,7 @@ class SavedFilterFilter(ChangeLoggedModelFilter):
     )
 
 
-@strawberry_django.filter_type(models.Subscription, lookups=True)
+@register_filter(models.Subscription, lookups=True)
 class SubscriptionFilter(BaseModelFilter):
     created: DatetimeFilterLookup | None = strawberry_django.filter_field()
     user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
@@ -357,7 +357,7 @@ class SubscriptionFilter(BaseModelFilter):
     object_id: ID | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.TableConfig, lookups=True)
+@register_filter(models.TableConfig, lookups=True)
 class TableConfigFilter(ChangeLoggedModelFilter):
     name: StrFilterLookup | None = strawberry_django.filter_field()
     description: StrFilterLookup | None = strawberry_django.filter_field()
@@ -370,7 +370,7 @@ class TableConfigFilter(ChangeLoggedModelFilter):
     shared: FilterLookup[bool] | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.Tag, lookups=True)
+@register_filter(models.Tag, lookups=True)
 class TagFilter(ChangeLoggedModelFilter):
     name: StrFilterLookup | None = strawberry_django.filter_field()
     slug: StrFilterLookup | None = strawberry_django.filter_field()
@@ -380,7 +380,7 @@ class TagFilter(ChangeLoggedModelFilter):
     description: StrFilterLookup | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.Webhook, lookups=True)
+@register_filter(models.Webhook, lookups=True)
 class WebhookFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter):
     name: StrFilterLookup | None = strawberry_django.filter_field()
     description: StrFilterLookup | None = strawberry_django.filter_field()
@@ -402,7 +402,7 @@ class WebhookFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelF
     )
 
 
-@strawberry_django.filter_type(models.EventRule, lookups=True)
+@register_filter(models.EventRule, lookups=True)
 class EventRuleFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter):
     name: StrFilterLookup | None = strawberry_django.filter_field()
     description: StrFilterLookup | None = strawberry_django.filter_field()

+ 18 - 19
netbox/extras/graphql/types.py

@@ -1,14 +1,13 @@
 from typing import TYPE_CHECKING, Annotated
 
 import strawberry
-import strawberry_django
 from strawberry.scalars import JSON
 from strawberry.types import Info
 
 from core.graphql.mixins import SyncedDataMixin
 from extras import models
 from extras.graphql.mixins import CustomFieldsMixin, TagsMixin
-from netbox.graphql.types import BaseObjectType, ContentTypeType, ObjectType, PrimaryObjectType
+from netbox.graphql.types import BaseObjectType, ContentTypeType, ObjectType, PrimaryObjectType, register_type
 from users.graphql.mixins import OwnerMixin
 
 from .filters import *
@@ -60,7 +59,7 @@ class SharedObjectMixin:
         return queryset.restrict_to_shared(info.context.request.user)
 
 
-@strawberry_django.type(
+@register_type(
     models.ConfigContextProfile,
     fields='__all__',
     filters=ConfigContextProfileFilter,
@@ -70,7 +69,7 @@ class ConfigContextProfileType(SyncedDataMixin, PrimaryObjectType):
     pass
 
 
-@strawberry_django.type(
+@register_type(
     models.ConfigContext,
     fields='__all__',
     filters=ConfigContextFilter,
@@ -93,7 +92,7 @@ class ConfigContextType(SyncedDataMixin, OwnerMixin, ObjectType):
     site_groups: list[Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.ConfigTemplate,
     fields='__all__',
     filters=ConfigTemplateFilter,
@@ -106,7 +105,7 @@ class ConfigTemplateType(SyncedDataMixin, OwnerMixin, TagsMixin, ObjectType):
     device_roles: list[Annotated["DeviceRoleType", strawberry.lazy('dcim.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.CustomField,
     fields='__all__',
     filters=CustomFieldFilter,
@@ -117,7 +116,7 @@ class CustomFieldType(OwnerMixin, ObjectType):
     choice_set: Annotated["CustomFieldChoiceSetType", strawberry.lazy('extras.graphql.types')] | None
 
 
-@strawberry_django.type(
+@register_type(
     models.CustomFieldChoiceSet,
     exclude=['extra_choices', 'choice_colors'],
     filters=CustomFieldChoiceSetFilter,
@@ -130,7 +129,7 @@ class CustomFieldChoiceSetType(OwnerMixin, ObjectType):
     choice_colors: JSON
 
 
-@strawberry_django.type(
+@register_type(
     models.CustomLink,
     fields='__all__',
     filters=CustomLinkFilter,
@@ -140,7 +139,7 @@ class CustomLinkType(OwnerMixin, ObjectType):
     pass
 
 
-@strawberry_django.type(
+@register_type(
     models.ExportTemplate,
     fields='__all__',
     filters=ExportTemplateFilter,
@@ -150,7 +149,7 @@ class ExportTemplateType(SyncedDataMixin, OwnerMixin, ObjectType):
     pass
 
 
-@strawberry_django.type(
+@register_type(
     models.ImageAttachment,
     fields='__all__',
     filters=ImageAttachmentFilter,
@@ -160,7 +159,7 @@ class ImageAttachmentType(BaseObjectType):
     object_type: Annotated["ContentTypeType", strawberry.lazy('netbox.graphql.types')] | None
 
 
-@strawberry_django.type(
+@register_type(
     models.JournalEntry,
     fields='__all__',
     filters=JournalEntryFilter,
@@ -171,7 +170,7 @@ class JournalEntryType(CustomFieldsMixin, TagsMixin, ObjectType):
     created_by: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None
 
 
-@strawberry_django.type(
+@register_type(
     models.Notification,
     filters=NotificationFilter,
     pagination=True
@@ -180,7 +179,7 @@ class NotificationType(ObjectType):
     user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None
 
 
-@strawberry_django.type(
+@register_type(
     models.NotificationGroup,
     filters=NotificationGroupFilter,
     pagination=True
@@ -190,7 +189,7 @@ class NotificationGroupType(ObjectType):
     groups: list[Annotated["GroupType", strawberry.lazy('users.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.SavedFilter,
     exclude=['content_types',],
     filters=SavedFilterFilter,
@@ -200,7 +199,7 @@ class SavedFilterType(SharedObjectMixin, OwnerMixin, ObjectType):
     user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None
 
 
-@strawberry_django.type(
+@register_type(
     models.Subscription,
     filters=SubscriptionFilter,
     pagination=True
@@ -209,7 +208,7 @@ class SubscriptionType(ObjectType):
     user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None
 
 
-@strawberry_django.type(
+@register_type(
     models.TableConfig,
     fields='__all__',
     filters=TableConfigFilter,
@@ -220,7 +219,7 @@ class TableConfigType(SharedObjectMixin, ObjectType):
     user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None
 
 
-@strawberry_django.type(
+@register_type(
     models.Tag,
     exclude=['extras_taggeditem_items', ],
     filters=TagFilter,
@@ -232,7 +231,7 @@ class TagType(OwnerMixin, ObjectType):
     object_types: list[ContentTypeType]
 
 
-@strawberry_django.type(
+@register_type(
     models.Webhook,
     exclude=['content_types',],
     filters=WebhookFilter,
@@ -242,7 +241,7 @@ class WebhookType(OwnerMixin, CustomFieldsMixin, TagsMixin, ObjectType):
     pass
 
 
-@strawberry_django.type(
+@register_type(
     models.EventRule,
     exclude=['content_types',],
     filters=EventRuleFilter,

+ 2 - 1
netbox/extras/management/commands/renaturalize.py

@@ -2,6 +2,7 @@ from django.apps import apps
 from django.core.management.base import BaseCommand, CommandError
 
 from utilities.fields import NaturalOrderingField
+from utilities.querysets import chunked_update
 
 
 class Command(BaseCommand):
@@ -93,7 +94,7 @@ class Command(BaseCommand):
                         self.stdout.flush()
 
                     # Update each unique field value in bulk
-                    changed = model.objects.filter(name=value).update(**{field.name: naturalized_value})
+                    changed = chunked_update(model.objects.filter(name=value), **{field.name: naturalized_value})
 
                     if options['verbosity'] >= 2:
                         self.stdout.write(f" ({changed})")

+ 15 - 36
netbox/extras/models/customfields.py

@@ -8,7 +8,7 @@ import jsonschema
 from django import forms
 from django.conf import settings
 from django.core.validators import RegexValidator, ValidationError
-from django.db import models, transaction
+from django.db import models
 from django.db.models import F, Func, Value
 from django.db.models.expressions import RawSQL
 from django.urls import reverse
@@ -19,7 +19,6 @@ from jsonschema.exceptions import ValidationError as JSONValidationError
 
 from core.models import ObjectType
 from extras.choices import *
-from extras.constants import CUSTOMFIELD_DATA_BATCH_SIZE
 from extras.data import CHOICE_SETS
 from extras.fields import ChoiceSetField
 from netbox.context import query_cache
@@ -44,9 +43,9 @@ from utilities.forms.fields import (
 from utilities.forms.utils import add_blank_choice
 from utilities.forms.widgets import APISelect, APISelectMultiple, DatePicker, DateTimePicker
 from utilities.jsonschema import validate_schema
-from utilities.querysets import RestrictedQuerySet
+from utilities.querysets import RestrictedQuerySet, chunked_update
 from utilities.templatetags.builtins.filters import render_markdown
-from utilities.validators import validate_regex
+from utilities.validators import url_scheme_is_allowed, validate_regex
 
 __all__ = (
     'CustomField',
@@ -329,32 +328,6 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
             return self.choice_set.get_choice_color(value)
         return None
 
-    @staticmethod
-    def _update_object_data(model, **update_kwargs):
-        """
-        Apply an UPDATE to the custom_field_data of every instance of the given model in batches,
-        bounding the number of rows touched by each statement. A single unbounded UPDATE across
-        millions of rows can exceed the database statement timeout, because JSONB updates rewrite
-        each affected row in full. Batches are selected via keyset pagination on the primary key.
-
-        The batched updates are wrapped in a transaction so that the operation remains atomic, as
-        it was when performed by a single UPDATE. This guards against partially-applied data (e.g.
-        a renamed field landing on only some objects) should the loop be interrupted when not
-        already running inside a request's transaction. Batching avoids the statement timeout
-        regardless, as that limit applies per statement rather than per transaction.
-        """
-        with transaction.atomic():
-            last_pk = 0
-            while True:
-                pks = list(
-                    model.objects.filter(pk__gt=last_pk).order_by('pk')
-                    .values_list('pk', flat=True)[:CUSTOMFIELD_DATA_BATCH_SIZE]
-                )
-                if not pks:
-                    break
-                model.objects.filter(pk__in=pks).update(**update_kwargs)
-                last_pk = pks[-1]
-
     def populate_initial_data(self, content_types):
         """
         Populate initial custom field data upon either a) the creation of a new CustomField, or
@@ -367,8 +340,8 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
             value = Value(self.default, models.JSONField())
         for ct in content_types:
             if model := ct.model_class():
-                self._update_object_data(
-                    model,
+                chunked_update(
+                    model.objects.all(),
                     custom_field_data=Func(
                         F('custom_field_data'),
                         Value([self.name]),
@@ -384,8 +357,8 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
         """
         for ct in content_types:
             if model := ct.model_class():
-                self._update_object_data(
-                    model,
+                chunked_update(
+                    model.objects.all(),
                     custom_field_data=F('custom_field_data') - self.name
                 )
 
@@ -396,8 +369,8 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
         """
         for ct in self.object_types.all():
             if model := ct.model_class():
-                self._update_object_data(
-                    model,
+                chunked_update(
+                    model.objects.all(),
                     custom_field_data=Func(
                         F('custom_field_data') - old_name,
                         Value([new_name]),
@@ -796,6 +769,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
             elif self.type == CustomFieldTypeChoices.TYPE_URL:
                 if type(value) is not str:
                     raise ValidationError(_("Value must be a string."))
+                # Enforce ALLOWED_URL_SCHEMES to guard against dangerous schemes (e.g. javascript:). A
+                # schemeless value is permitted and treated as relative.
+                if not url_scheme_is_allowed(value):
+                    raise ValidationError(
+                        _("URLs must use a scheme permitted by ALLOWED_URL_SCHEMES.")
+                    )
                 if self.validation_regex and not re.match(self.validation_regex, value):
                     raise ValidationError(_("Value must match regex '{regex}'").format(regex=self.validation_regex))
 

+ 34 - 3
netbox/extras/tests/test_customfields.py

@@ -1,10 +1,9 @@
 import datetime
 import json
 from decimal import Decimal
-from unittest.mock import patch
 
 from django.core.exceptions import ValidationError
-from django.test import tag
+from django.test import override_settings, tag
 from django.urls import reverse
 from rest_framework import status
 
@@ -674,7 +673,7 @@ class CustomFieldTestCase(TestCase):
         self.assertNotIn('field1', site.custom_field_data)
         self.assertEqual(site.custom_field_data['field2'], FIELD_DATA)
 
-    @patch('extras.models.customfields.CUSTOMFIELD_DATA_BATCH_SIZE', 2)
+    @override_settings(BULK_UPDATE_CHUNK_SIZE=2)
     def test_batched_object_data_updates(self):
         """
         Provisioning, renaming, and removing custom field data is applied in batches. Use a small
@@ -1693,6 +1692,38 @@ class CustomFieldAPITestCase(APITestCase):
         response = self.client.patch(url, data, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_200_OK)
 
+    def test_url_scheme_validation(self):
+        """
+        Test that URL custom field values must use a scheme permitted by ALLOWED_URL_SCHEMES (fixes
+        #22640), and that a schemeless value is normalized to an absolute URL (assume_scheme='https'),
+        consistent with the UI.
+        """
+        site2 = Site.objects.get(name='Site 2')
+        url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
+        self.add_permissions('dcim.change_site')
+
+        # A dangerous scheme (e.g. javascript:) must be rejected
+        data = {'custom_fields': {'url_field': 'javascript:alert(1)'}}
+        response = self.client.patch(url, data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+
+        # A well-formed URL using a scheme outside ALLOWED_URL_SCHEMES must be rejected
+        data = {'custom_fields': {'url_field': 'gopher://example.com'}}
+        response = self.client.patch(url, data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+
+        # An allowed scheme must be accepted
+        data = {'custom_fields': {'url_field': 'https://example.com'}}
+        response = self.client.patch(url, data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+
+        # A schemeless value must be accepted and normalized to https, matching the UI
+        data = {'custom_fields': {'url_field': 'example.com'}}
+        response = self.client.patch(url, data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        site2.refresh_from_db()
+        self.assertEqual(site2.custom_field_data['url_field'], 'https://example.com')
+
     def test_json_schema_validation(self):
         site2 = Site.objects.get(name='Site 2')
         url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})

+ 19 - 18
netbox/ipam/graphql/filters.py

@@ -17,6 +17,7 @@ from netbox.graphql.filters import (
     NetBoxModelFilter,
     OrganizationalModelFilter,
     PrimaryModelFilter,
+    register_filter,
 )
 from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
 from virtualization.models import VMInterface
@@ -52,7 +53,7 @@ __all__ = (
 )
 
 
-@strawberry_django.filter_type(models.ASN, lookups=True)
+@register_filter(models.ASN, lookups=True)
 class ASNFilter(TenancyFilterMixin, PrimaryModelFilter):
     rir: Annotated['RIRFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
     rir_id: ID | None = strawberry_django.filter_field()
@@ -69,7 +70,7 @@ class ASNFilter(TenancyFilterMixin, PrimaryModelFilter):
     ) = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.ASNRange, lookups=True)
+@register_filter(models.ASNRange, lookups=True)
 class ASNRangeFilter(TenancyFilterMixin, OrganizationalModelFilter):
     name: StrFilterLookup | None = strawberry_django.filter_field()
     slug: StrFilterLookup | None = strawberry_django.filter_field()
@@ -83,7 +84,7 @@ class ASNRangeFilter(TenancyFilterMixin, OrganizationalModelFilter):
     )
 
 
-@strawberry_django.filter_type(models.Aggregate, lookups=True)
+@register_filter(models.Aggregate, lookups=True)
 class AggregateFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
     prefix: StrFilterLookup | None = strawberry_django.filter_field()
     rir: Annotated['RIRFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
@@ -116,7 +117,7 @@ class AggregateFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter
         return Q(**{f"{prefix}prefix__family": value.value})
 
 
-@strawberry_django.filter_type(models.FHRPGroup, lookups=True)
+@register_filter(models.FHRPGroup, lookups=True)
 class FHRPGroupFilter(PrimaryModelFilter):
     group_id: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
@@ -134,7 +135,7 @@ class FHRPGroupFilter(PrimaryModelFilter):
     )
 
 
-@strawberry_django.filter_type(models.FHRPGroupAssignment, lookups=True)
+@register_filter(models.FHRPGroupAssignment, lookups=True)
 class FHRPGroupAssignmentFilter(ChangeLoggedModelFilter):
     interface_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
         strawberry_django.filter_field()
@@ -173,7 +174,7 @@ class FHRPGroupAssignmentFilter(ChangeLoggedModelFilter):
         return Q(**{f"{prefix}interface_id__in": interface_ids})
 
 
-@strawberry_django.filter_type(models.IPAddress, lookups=True)
+@register_filter(models.IPAddress, lookups=True)
 class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
     address: StrFilterLookup | None = strawberry_django.filter_field()
     vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
@@ -224,7 +225,7 @@ class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter
         return Q(**{f"{prefix}address__family": value.value})
 
 
-@strawberry_django.filter_type(models.IPRange, lookups=True)
+@register_filter(models.IPRange, lookups=True)
 class IPRangeFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
     start_address: StrFilterLookup | None = strawberry_django.filter_field()
     end_address: StrFilterLookup | None = strawberry_django.filter_field()
@@ -278,7 +279,7 @@ class IPRangeFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
         return q
 
 
-@strawberry_django.filter_type(models.Prefix, lookups=True)
+@register_filter(models.Prefix, lookups=True)
 class PrefixFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
     prefix: StrFilterLookup | None = strawberry_django.filter_field()
     vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
@@ -315,19 +316,19 @@ class PrefixFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, Pr
         return Q(**{f"{prefix}prefix__family": value.value})
 
 
-@strawberry_django.filter_type(models.RIR, lookups=True)
+@register_filter(models.RIR, lookups=True)
 class RIRFilter(OrganizationalModelFilter):
     is_private: FilterLookup[bool] | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.Role, lookups=True)
+@register_filter(models.Role, lookups=True)
 class RoleFilter(OrganizationalModelFilter):
     weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
     )
 
 
-@strawberry_django.filter_type(models.RouteTarget, lookups=True)
+@register_filter(models.RouteTarget, lookups=True)
 class RouteTargetFilter(TenancyFilterMixin, PrimaryModelFilter):
     name: StrFilterLookup | None = strawberry_django.filter_field()
     importing_vrfs: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
@@ -344,7 +345,7 @@ class RouteTargetFilter(TenancyFilterMixin, PrimaryModelFilter):
     )
 
 
-@strawberry_django.filter_type(models.Service, lookups=True)
+@register_filter(models.Service, lookups=True)
 class ServiceFilter(ContactFilterMixin, ServiceFilterMixin, PrimaryModelFilter):
     name: StrFilterLookup | None = strawberry_django.filter_field()
     ip_addresses: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
@@ -356,12 +357,12 @@ class ServiceFilter(ContactFilterMixin, ServiceFilterMixin, PrimaryModelFilter):
     parent_object_id: ID | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.ServiceTemplate, lookups=True)
+@register_filter(models.ServiceTemplate, lookups=True)
 class ServiceTemplateFilter(ServiceFilterMixin, PrimaryModelFilter):
     name: StrFilterLookup | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.VLAN, lookups=True)
+@register_filter(models.VLAN, lookups=True)
 class VLANFilter(TenancyFilterMixin, PrimaryModelFilter):
     site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     site_id: ID | None = strawberry_django.filter_field()
@@ -393,7 +394,7 @@ class VLANFilter(TenancyFilterMixin, PrimaryModelFilter):
     )
 
 
-@strawberry_django.filter_type(models.VLANGroup, lookups=True)
+@register_filter(models.VLANGroup, lookups=True)
 class VLANGroupFilter(ScopedFilterMixin, OrganizationalModelFilter):
     vid_ranges: Annotated['IntegerRangeArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
@@ -401,12 +402,12 @@ class VLANGroupFilter(ScopedFilterMixin, OrganizationalModelFilter):
     total_vlan_ids: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.VLANTranslationPolicy, lookups=True)
+@register_filter(models.VLANTranslationPolicy, lookups=True)
 class VLANTranslationPolicyFilter(PrimaryModelFilter):
     name: StrFilterLookup | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.VLANTranslationRule, lookups=True)
+@register_filter(models.VLANTranslationRule, lookups=True)
 class VLANTranslationRuleFilter(NetBoxModelFilter):
     policy: Annotated['VLANTranslationPolicyFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
         strawberry_django.filter_field()
@@ -421,7 +422,7 @@ class VLANTranslationRuleFilter(NetBoxModelFilter):
     )
 
 
-@strawberry_django.filter_type(models.VRF, lookups=True)
+@register_filter(models.VRF, lookups=True)
 class VRFFilter(TenancyFilterMixin, PrimaryModelFilter):
     name: StrFilterLookup | None = strawberry_django.filter_field()
     rd: StrFilterLookup | None = strawberry_django.filter_field()

+ 25 - 19
netbox/ipam/graphql/types.py

@@ -8,7 +8,13 @@ from dcim.graphql.types import SiteType
 from extras.graphql.mixins import ContactsMixin
 from ipam import models
 from netbox.graphql.scalars import BigInt
-from netbox.graphql.types import BaseObjectType, NetBoxObjectType, OrganizationalObjectType, PrimaryObjectType
+from netbox.graphql.types import (
+    BaseObjectType,
+    NetBoxObjectType,
+    OrganizationalObjectType,
+    PrimaryObjectType,
+    register_type,
+)
 
 from .filters import *
 from .mixins import IPAddressesMixin
@@ -68,7 +74,7 @@ class BaseIPAddressFamilyType:
         return IPAddressFamilyType(value=self.family, label=f'IPv{self.family}')
 
 
-@strawberry_django.type(
+@register_type(
     models.ASN,
     fields='__all__',
     filters=ASNFilter,
@@ -84,7 +90,7 @@ class ASNType(ContactsMixin, PrimaryObjectType):
     providers: list[ProviderType]
 
 
-@strawberry_django.type(
+@register_type(
     models.ASNRange,
     fields='__all__',
     filters=ASNRangeFilter,
@@ -97,7 +103,7 @@ class ASNRangeType(OrganizationalObjectType):
     tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
 
 
-@strawberry_django.type(
+@register_type(
     models.Aggregate,
     fields='__all__',
     filters=AggregateFilter,
@@ -109,7 +115,7 @@ class AggregateType(ContactsMixin, BaseIPAddressFamilyType, PrimaryObjectType):
     tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
 
 
-@strawberry_django.type(
+@register_type(
     models.FHRPGroup,
     fields='__all__',
     filters=FHRPGroupFilter,
@@ -119,7 +125,7 @@ class FHRPGroupType(IPAddressesMixin, PrimaryObjectType):
     fhrpgroupassignment_set: list[Annotated["FHRPGroupAssignmentType", strawberry.lazy('ipam.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.FHRPGroupAssignment,
     exclude=['interface_type', 'interface_id'],
     filters=FHRPGroupAssignmentFilter,
@@ -137,7 +143,7 @@ class FHRPGroupAssignmentType(BaseObjectType):
         return self.interface
 
 
-@strawberry_django.type(
+@register_type(
     models.IPAddress,
     exclude=['assigned_object_type', 'assigned_object_id', 'address'],
     filters=IPAddressFilter,
@@ -163,7 +169,7 @@ class IPAddressType(ContactsMixin, BaseIPAddressFamilyType, PrimaryObjectType):
         return self.assigned_object
 
 
-@strawberry_django.type(
+@register_type(
     models.IPRange,
     fields='__all__',
     filters=IPRangeFilter,
@@ -177,7 +183,7 @@ class IPRangeType(ContactsMixin, PrimaryObjectType):
     role: Annotated["RoleType", strawberry.lazy('ipam.graphql.types')] | None
 
 
-@strawberry_django.type(
+@register_type(
     models.Prefix,
     exclude=['scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'],
     filters=PrefixFilter,
@@ -201,7 +207,7 @@ class PrefixType(ContactsMixin, BaseIPAddressFamilyType, PrimaryObjectType):
         return self.scope
 
 
-@strawberry_django.type(
+@register_type(
     models.RIR,
     fields='__all__',
     filters=RIRFilter,
@@ -214,7 +220,7 @@ class RIRType(OrganizationalObjectType):
     aggregates: list[Annotated["AggregateType", strawberry.lazy('ipam.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.Role,
     fields='__all__',
     filters=RoleFilter,
@@ -227,7 +233,7 @@ class RoleType(OrganizationalObjectType):
     vlans: list[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.RouteTarget,
     fields='__all__',
     filters=RouteTargetFilter,
@@ -242,7 +248,7 @@ class RouteTargetType(PrimaryObjectType):
     exporting_vrfs: list[Annotated["VRFType", strawberry.lazy('ipam.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.Service,
     exclude=('_ports_lowest', 'parent_object_type', 'parent_object_id'),
     filters=ServiceFilter,
@@ -262,7 +268,7 @@ class ServiceType(ContactsMixin, PrimaryObjectType):
         return self.parent
 
 
-@strawberry_django.type(
+@register_type(
     models.ServiceTemplate,
     exclude=('_ports_lowest',),
     filters=ServiceTemplateFilter,
@@ -272,7 +278,7 @@ class ServiceTemplateType(PrimaryObjectType):
     ports: list[int]
 
 
-@strawberry_django.type(
+@register_type(
     models.VLAN,
     exclude=['qinq_svlan'],
     filters=VLANFilter,
@@ -296,7 +302,7 @@ class VLANType(PrimaryObjectType):
         return self.qinq_svlan
 
 
-@strawberry_django.type(
+@register_type(
     models.VLANGroup,
     exclude=['scope_type', 'scope_id'],
     filters=VLANGroupFilter,
@@ -323,7 +329,7 @@ class VLANGroupType(OrganizationalObjectType):
         return self.scope
 
 
-@strawberry_django.type(
+@register_type(
     models.VLANTranslationPolicy,
     fields='__all__',
     filters=VLANTranslationPolicyFilter,
@@ -333,7 +339,7 @@ class VLANTranslationPolicyType(PrimaryObjectType):
     rules: list[Annotated["VLANTranslationRuleType", strawberry.lazy('ipam.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.VLANTranslationRule,
     fields='__all__',
     filters=VLANTranslationRuleFilter,
@@ -346,7 +352,7 @@ class VLANTranslationRuleType(NetBoxObjectType):
     ] = strawberry_django.field(select_related=["policy"])
 
 
-@strawberry_django.type(
+@register_type(
     models.VRF,
     fields='__all__',
     filters=VRFFilter,

+ 2 - 1
netbox/ipam/management/commands/rebuild_prefixes.py

@@ -2,6 +2,7 @@ from django.core.management.base import BaseCommand
 
 from ipam.models import VRF, Prefix
 from ipam.utils import rebuild_prefixes
+from utilities.querysets import chunked_update
 
 
 class Command(BaseCommand):
@@ -11,7 +12,7 @@ class Command(BaseCommand):
         self.stdout.write(f'Rebuilding {Prefix.objects.count()} prefixes...')
 
         # Reset existing counts
-        Prefix.objects.update(_depth=0, _children=0)
+        chunked_update(Prefix.objects.all(), _depth=0, _children=0)
 
         # Rebuild the global table
         global_count = Prefix.objects.filter(vrf__isnull=True).count()

+ 3 - 1
netbox/ipam/tests/test_management_commands.py

@@ -16,6 +16,7 @@ class RebuildPrefixesTestCase(TestCase):
             patch('ipam.management.commands.rebuild_prefixes.Prefix') as prefix_model,
             patch('ipam.management.commands.rebuild_prefixes.VRF') as vrf_model,
             patch('ipam.management.commands.rebuild_prefixes.rebuild_prefixes') as rebuild_prefixes,
+            patch('ipam.management.commands.rebuild_prefixes.chunked_update') as chunked_update,
         ):
             prefix_model.objects.count.return_value = 0
             prefix_model.objects.filter.return_value.count.return_value = 0
@@ -23,7 +24,7 @@ class RebuildPrefixesTestCase(TestCase):
             call_command('rebuild_prefixes', stdout=out)
 
         rebuild_prefixes.assert_called_once_with(None)
-        prefix_model.objects.update.assert_called_once_with(_depth=0, _children=0)
+        chunked_update.assert_called_once_with(prefix_model.objects.all.return_value, _depth=0, _children=0)
         self.assertIn('Rebuilding 0 prefixes', out.getvalue())
         self.assertIn('Finished.', out.getvalue())
 
@@ -68,6 +69,7 @@ class RebuildPrefixesTestCase(TestCase):
             patch('ipam.management.commands.rebuild_prefixes.Prefix') as prefix_model,
             patch('ipam.management.commands.rebuild_prefixes.VRF') as vrf_model,
             patch('ipam.management.commands.rebuild_prefixes.rebuild_prefixes') as rebuild_prefixes,
+            patch('ipam.management.commands.rebuild_prefixes.chunked_update'),
         ):
             prefix_model.objects.count.return_value = 3
             prefix_model.objects.filter.side_effect = [

+ 12 - 0
netbox/netbox/graphql/filters.py

@@ -7,6 +7,7 @@ from strawberry_django import ComparisonFilterLookup, StrFilterLookup
 
 from core.graphql.filter_mixins import ChangeLoggingMixin
 from extras.graphql.filter_mixins import CustomFieldsFilterMixin, JournalEntriesFilterMixin, TagsFilterMixin
+from netbox.graphql.utils import register_model_graphql_type
 
 if TYPE_CHECKING:
     from .filters import *
@@ -18,9 +19,20 @@ __all__ = (
     'NetBoxModelFilter',
     'OrganizationalModelFilter',
     'PrimaryModelFilter',
+    'register_filter',
 )
 
 
+def register_filter(model, **kwargs):
+    """
+    Drop-in replacement for `strawberry_django.filter_type()` for model-bound NetBox GraphQL filters. Before
+    delegating to `strawberry_django.filter_type()`, any plugin-registered filter mixins for the given model are
+    spliced into the decorated class's bases. With no extensions registered this is an exact pass-through, leaving
+    schema output unchanged. See `register_model_graphql_type` for the registry-timing contract.
+    """
+    return register_model_graphql_type(model, strawberry_django.filter_type, 'graphql_filter_extensions', **kwargs)
+
+
 @dataclass
 class BaseModelFilter:
     id: ComparisonFilterLookup[ID] | None = strawberry_django.filter_field()

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

@@ -7,6 +7,7 @@ from strawberry.types import Info
 from core.graphql.mixins import ChangelogMixin
 from core.models import ObjectType as ObjectType_
 from extras.graphql.mixins import CustomFieldsMixin, JournalEntriesMixin, TagsMixin
+from netbox.graphql.utils import register_model_graphql_type
 from users.graphql.mixins import OwnerMixin
 
 __all__ = (
@@ -19,9 +20,20 @@ __all__ = (
     'ObjectType',
     'OrganizationalObjectType',
     'PrimaryObjectType',
+    'register_type',
 )
 
 
+def register_type(model, **kwargs):
+    """
+    Drop-in replacement for `strawberry_django.type()` for model-bound NetBox GraphQL output types. Before delegating
+    to `strawberry_django.type()`, any plugin-registered output-type mixins for the given model are spliced into the
+    decorated class's bases. With no extensions registered this is an exact pass-through, leaving schema output
+    unchanged. See `register_model_graphql_type` for the registry-timing contract.
+    """
+    return register_model_graphql_type(model, strawberry_django.type, 'graphql_type_extensions', **kwargs)
+
+
 #
 # Base types
 #
@@ -159,7 +171,7 @@ class NetBoxObjectType(
 # Miscellaneous types
 #
 
-@strawberry_django.type(
+@register_type(
     ContentType,
     fields=['id', 'app_label', 'model'],
     pagination=True
@@ -168,7 +180,7 @@ class ContentTypeType:
     pass
 
 
-@strawberry_django.type(
+@register_type(
     ObjectType_,
     fields=['id', 'app_label', 'model'],
     pagination=True

+ 126 - 0
netbox/netbox/graphql/utils.py

@@ -0,0 +1,126 @@
+import logging
+
+from netbox.registry import registry
+
+__all__ = (
+    'get_model_label',
+    'register_model_graphql_type',
+    'splice_extension_bases',
+)
+
+
+def get_model_label(model):
+    """
+    Return the canonical `app_label.model_name` label used to key GraphQL extensions in the registry. Both the
+    registration side and the lookup side must derive labels through this helper so they always agree.
+    """
+    return f'{model._meta.app_label}.{model._meta.model_name}'
+
+
+def _own_names(klass):
+    """
+    Return the set of field/attribute names a single class contributes directly: its annotations and its own
+    non-dunder attributes (such as resolver methods), excluding the `models` extension marker.
+    """
+    names = set(getattr(klass, '__annotations__', {}))
+    names |= {name for name in vars(klass) if not name.startswith('__')}
+    names.discard('models')
+    return names
+
+
+def _core_names(cls):
+    """
+    Return every name `cls` resolves (its own body and everything it inherits). Extensions are spliced in *after*
+    these bases, so any name already present here is provided by the core type and an extension cannot override it.
+    """
+    names = set()
+    for klass in cls.__mro__:
+        if klass is object:
+            continue
+        names |= _own_names(klass)
+    return names
+
+
+def splice_extension_bases(cls, extensions):
+    """
+    Return a class equivalent to `cls` but with the given plugin extension mixin classes spliced into its bases,
+    so that fields/filters they declare are picked up when the class is processed by Strawberry.
+
+    If `extensions` is empty, `cls` is returned unchanged (an exact pass-through). Otherwise a new class is built
+    with the same name and namespace as `cls` — preserving its own annotations, fields, and methods — with the
+    extension classes appended to its bases.
+
+    Precedence: extensions are appended *after* `cls`'s base classes in the MRO, so extensions are strictly
+    additive. Any name the core type already provides (its own fields or anything it inherits, including hooks such
+    as `get_queryset`) always wins; an extension declaring such a name is ignored. When two extensions declare the
+    same new name, the one whose plugin loaded first wins (it is registered earlier). Both cases are warned about.
+    """
+    if not extensions:
+        return cls
+
+    # Fetch the logger lazily rather than at module import. Importing this module during settings/app loading
+    # (e.g. via the plugin registration helpers) happens before Django configures logging; creating the logger
+    # then would get it disabled by a `disable_existing_loggers` LOGGING config.
+    logger = logging.getLogger('netbox.graphql')
+
+    # Warn on field-name collisions so they can be diagnosed in deployments with many plugins.
+    core_names = _core_names(cls)
+    seen = {}
+    for extension in extensions:
+        for name in _own_names(extension):
+            if name in core_names:
+                logger.warning(
+                    "GraphQL extension %s declares '%s', which core type %s already provides; the extension's "
+                    "version is ignored (core takes precedence).",
+                    extension, name, cls.__name__,
+                )
+            elif name in seen:
+                logger.warning(
+                    "GraphQL extensions %s and %s both define '%s' on %s; %s takes precedence because its "
+                    "plugin is loaded first.",
+                    seen[name], extension, name, cls.__name__, seen[name],
+                )
+            else:
+                seen[name] = extension
+
+    namespace = dict(cls.__dict__)
+    # Drop the descriptors that cannot (and need not) be copied to the rebuilt class; they are recreated by the
+    # metaclass call below.
+    namespace.pop('__dict__', None)
+    namespace.pop('__weakref__', None)
+
+    bases = (*cls.__bases__, *extensions)
+    # Rebuild via the class's own metaclass rather than the built-in `type`, so a core type using a custom
+    # metaclass is preserved. Pre-decoration Strawberry/dataclass types use the plain `type` metaclass.
+    try:
+        return type(cls)(cls.__name__, bases, namespace)
+    except TypeError as exc:
+        raise TypeError(
+            f"Failed to splice GraphQL extension(s) {[e.__name__ for e in extensions]} into core type "
+            f"'{cls.__name__}': {exc}. A GraphQL extension should be a plain @strawberry.type mixin that only "
+            f"adds fields; inheriting from classes already in the core type's base list can produce an "
+            f"inconsistent MRO."
+        ) from exc
+
+
+def register_model_graphql_type(model, delegate, store_key, **kwargs):
+    """
+    Shared implementation behind `register_type` and `register_filter`. Returns a decorator that splices any
+    plugin extensions for `model` (from `store_key`) into the decorated class, then delegates to `delegate`
+    (`strawberry_django.type` / `filter_type`).
+
+    The registry is read at decoration (import) time. This is safe because the schema is assembled lazily from the
+    URLconf, after every plugin's `ready()` has run; importing a core `graphql/types.py` during app init would read
+    the registry too early and silently drop later-registered extensions.
+    """
+    label = get_model_label(model)
+
+    def wrapper(cls):
+        # Record that this type/filter has been assembled, so a plugin that registers an extension after this
+        # point (e.g. because its ready() imported a core graphql module early) can be warned it is too late.
+        registry['plugins']['graphql_extensions_assembled'].add((store_key, label))
+        extensions = registry['plugins'][store_key].get(label)
+        cls = splice_extension_bases(cls, extensions)
+        return delegate(model, **kwargs)(cls)
+
+    return wrapper

+ 16 - 0
netbox/netbox/plugins/__init__.py

@@ -21,6 +21,11 @@ registry['plugins'].update({
     'installed': [],
     'graphql_schemas': [],
     'jinja_filters': {},
+    'graphql_type_extensions': collections.defaultdict(list),
+    'graphql_filter_extensions': collections.defaultdict(list),
+    # (store_key, label) pairs whose core type/filter has already been assembled, used to detect extensions
+    # registered too late to be spliced in.
+    'graphql_extensions_assembled': set(),
     'menus': [],
     'menu_items': {},
     'preferences': {},
@@ -32,6 +37,8 @@ DEFAULT_RESOURCE_PATHS = {
     'data_backends': 'data_backends.backends',
     'graphql_schema': 'graphql.schema',
     'jinja_filters': 'jinja_env.filters',
+    'graphql_type_extensions': 'graphql.type_extensions',
+    'graphql_filter_extensions': 'graphql.filter_extensions',
     'menu': 'navigation.menu',
     'menu_items': 'navigation.menu_items',
     'template_extensions': 'template_content.template_extensions',
@@ -81,6 +88,8 @@ class PluginConfig(AppConfig):
     data_backends = None
     graphql_schema = None
     jinja_filters = None
+    graphql_type_extensions = None
+    graphql_filter_extensions = None
     menu = None
     menu_items = None
     serializer_resolver = None
@@ -151,6 +160,13 @@ class PluginConfig(AppConfig):
         if graphql_schema := self._load_resource('graphql_schema'):
             register_graphql_schema(graphql_schema)
 
+        # Register GraphQL type & filter extensions (if defined). These must be registered before the GraphQL
+        # schema is assembled (during ROOT_URLCONF loading), which occurs after all apps' ready() methods run.
+        if graphql_type_extensions := self._load_resource('graphql_type_extensions'):
+            register_graphql_type_extensions(graphql_type_extensions)
+        if graphql_filter_extensions := self._load_resource('graphql_filter_extensions'):
+            register_graphql_filter_extensions(graphql_filter_extensions)
+
         # Register user preferences (if defined)
         if user_preferences := self._load_resource('user_preferences'):
             register_user_preferences(plugin_name, user_preferences)

+ 72 - 0
netbox/netbox/plugins/registration.py

@@ -1,8 +1,10 @@
 import inspect
 import logging
 
+from django.apps import apps
 from django.utils.translation import gettext_lazy as _
 
+from netbox.graphql.utils import get_model_label
 from netbox.registry import registry
 
 from .navigation import PluginMenu, PluginMenuButton, PluginMenuItem
@@ -11,7 +13,9 @@ from .templates import PluginTemplateExtension
 logger = logging.getLogger(__name__)
 
 __all__ = (
+    'register_graphql_filter_extensions',
     'register_graphql_schema',
+    'register_graphql_type_extensions',
     'register_jinja_filters',
     'register_menu',
     'register_menu_items',
@@ -102,6 +106,74 @@ def register_graphql_schema(graphql_schema):
     registry['plugins']['graphql_schemas'].extend(graphql_schema)
 
 
+def _register_graphql_extensions(class_list, store):
+    """
+    Collect a list of GraphQL output-type or filter mixin classes into the given registry store, bucketed by the
+    model labels declared on each class's `models` attribute. Each declared label is validated against the app
+    registry and normalized to the canonical `app_label.model_name` form (via `get_model_label`) so that the
+    stored key always matches the label `register_type`/`register_filter` look up.
+    """
+    for extension in class_list:
+        if not inspect.isclass(extension):
+            raise TypeError(
+                _("GraphQL extension {extension} was passed as an instance!").format(extension=extension)
+            )
+        models = getattr(extension, 'models', None)
+        if not models:
+            raise TypeError(
+                _("GraphQL extension {extension} must declare a non-empty 'models' attribute.").format(
+                    extension=extension
+                )
+            )
+        # Must be @strawberry.type-decorated for its fields to be collected. Check the class's own __dict__ (not
+        # hasattr) so an undecorated subclass of a @strawberry.type base is still rejected. `__strawberry_definition__`
+        # is a Strawberry internal (verified against strawberry-graphql 0.321.0); revisit on dependency upgrades.
+        if '__strawberry_definition__' not in vars(extension):
+            raise TypeError(
+                _("GraphQL extension {extension} must be decorated with @strawberry.type.").format(
+                    extension=extension
+                )
+            )
+        for label in models:
+            # Resolve the model to validate the label and derive its canonical key; a bad label would otherwise
+            # register into a bucket that is never looked up, silently dropping the extension.
+            try:
+                model = apps.get_model(label)
+            except (LookupError, ValueError):
+                raise TypeError(
+                    _("GraphQL extension {extension} targets unknown model '{label}'.").format(
+                        extension=extension, label=label
+                    )
+                )
+            canonical_label = get_model_label(model)
+            # If the core type/filter was already assembled (a plugin imported a core graphql module during
+            # ready()), this extension is too late to be spliced in and will not appear in the schema. Fetch the
+            # logger lazily (not the module-level one) so it isn't disabled by a `disable_existing_loggers` config.
+            if (store, canonical_label) in registry['plugins']['graphql_extensions_assembled']:
+                logging.getLogger('netbox.graphql').warning(
+                    "GraphQL extension %s for '%s' was registered after the core type was assembled and will be "
+                    "ignored. Avoid importing core GraphQL modules from a plugin's ready().",
+                    extension, canonical_label,
+                )
+            registry['plugins'][store][canonical_label].append(extension)
+
+
+def register_graphql_type_extensions(class_list):
+    """
+    Register a list of GraphQL output-type mixin classes. Each class must be decorated with @strawberry.type and
+    declare a `models` attribute listing the `app_label.model` labels of the core types it extends.
+    """
+    _register_graphql_extensions(class_list, 'graphql_type_extensions')
+
+
+def register_graphql_filter_extensions(class_list):
+    """
+    Register a list of GraphQL filter mixin classes. Each class must be decorated with @strawberry.type and declare
+    a `models` attribute listing the `app_label.model` labels of the core filters it extends.
+    """
+    _register_graphql_extensions(class_list, 'graphql_filter_extensions')
+
+
 def register_user_preferences(plugin_name, preferences):
     """
     Register a list of user preferences defined by a plugin.

+ 15 - 11
netbox/netbox/search/backends.py

@@ -8,7 +8,6 @@ from django.db import DatabaseError, ProgrammingError, transaction
 from django.db.models import F, Q, Window, prefetch_related_objects
 from django.db.models.fields.related import ForeignKey
 from django.db.models.functions import window
-from django.db.models.signals import post_delete, post_save
 from django.utils.module_loading import import_string
 from django.utils.translation import gettext_lazy as _
 from netaddr.core import AddrFormatError
@@ -21,7 +20,6 @@ from utilities.querysets import RestrictedPrefetch
 from utilities.string import title
 
 from . import FieldTypes, LookupTypes, get_indexer
-from .deferred import OP_CACHE, OP_REMOVE, mark_for_deferred_indexing
 
 DEFAULT_LOOKUP_TYPE = LookupTypes.PARTIAL
 MAX_RESULTS = 1000
@@ -64,11 +62,11 @@ class SearchBackend:
         """
         raise NotImplementedError
 
-    # caching_handler() and removal_handler() are the default, synchronous signal receivers connected
-    # to post_save/post_delete at module load. They are internal plumbing for signal dispatch, not a
-    # documented extension point: the public backend contract is cache()/remove()/clear(). A backend
-    # that needs to do something other than index inline (e.g. defer the work) overrides these in its
-    # subclass; see CachedValueSearchBackend.
+    # caching_handler() and removal_handler() are the default, synchronous signal receivers; they are
+    # connected to post_save/post_delete from netbox.search.signals (wired from CoreConfig.ready()).
+    # They are internal plumbing for signal dispatch, not a documented extension point: the public
+    # backend contract is cache()/remove()/clear(). A backend that needs to do something other than
+    # index inline (e.g. defer the work) overrides these in its subclass; see CachedValueSearchBackend.
     def caching_handler(self, sender, instance, created, **kwargs):
         """
         Receiver for the post_save signal, responsible for caching object creation/changes.
@@ -127,10 +125,18 @@ class CachedValueSearchBackend(SearchBackend):
     # the originating routing context is gone, so the alias must be captured here and replayed on the
     # deferred write to keep cache entries in the originating schema (e.g. a branch schema under
     # netbox-branching). Deferral is internal to this backend; the public contract is unchanged.
+    #
+    # mark_for_deferred_indexing() etc. are imported inside each method rather than at module level:
+    # this module's own top would import deferred.py *before* search_backend is defined further down
+    # this same file, and deferred.py (plus jobs.py) need that singleton at their own module level.
+    # A module-level import here would close that loop into a backends -> deferred -> backends
+    # cycle. See #22485.
     def caching_handler(self, sender, instance, created, using=None, **kwargs):
         """
         Receiver for the post_save signal, responsible for caching object creation/changes.
         """
+        from .deferred import OP_CACHE, mark_for_deferred_indexing
+
         # Skip non-cacheable objects without scheduling any deferred work.
         try:
             indexer = get_indexer(instance)
@@ -150,6 +156,8 @@ class CachedValueSearchBackend(SearchBackend):
         """
         Receiver for the post_delete signal, responsible for caching object deletion.
         """
+        from .deferred import OP_REMOVE, mark_for_deferred_indexing
+
         # Skip non-cacheable objects without scheduling any deferred work.
         try:
             indexer = get_indexer(instance)
@@ -440,7 +448,3 @@ def get_backend():
 
 
 search_backend = get_backend()
-
-# Connect handlers to the appropriate model signals
-post_save.connect(search_backend.caching_handler)
-post_delete.connect(search_backend.removal_handler)

+ 2 - 8
netbox/netbox/search/deferred.py

@@ -4,6 +4,8 @@ from django.db import DEFAULT_DB_ALIAS, connections, transaction
 from redis.exceptions import RedisError
 
 from netbox.constants import RQ_QUEUE_DEFAULT
+from netbox.search.backends import search_backend
+from netbox.search.jobs import SearchCacheJob
 from utilities.rqworker import any_workers_for_queue
 
 # This module is internal plumbing for the search signal handlers; nothing here
@@ -157,14 +159,6 @@ def _flush(batch, using):
         groups = remove_groups if op == OP_REMOVE else cache_groups
         groups.setdefault(object_type_id, []).append(pk)
 
-    # Imported here, not at module load, to avoid an import cycle: backends.py
-    # imports this module at module level (for the signal handlers), and
-    # netbox.search.jobs imports the search_backend singleton from backends.py,
-    # which is bound at the bottom of that module. A proper fix is tracked in
-    # #22485.
-    from netbox.search.backends import search_backend
-    from netbox.search.jobs import SearchCacheJob
-
     try:
         # Both the worker-availability check and the job enqueue talk to Redis,
         # and a worker can die between the two. Treat any Redis failure across the

+ 31 - 0
netbox/netbox/search/signals.py

@@ -0,0 +1,31 @@
+"""
+Connects the global search cache's post_save/post_delete receivers.
+
+Wired explicitly from CoreConfig.ready() (core/apps.py) rather than as a side effect of this
+module being imported, so connection happens deterministically at startup instead of depending
+on which of this subsystem's several consumers (netbox.forms.search, netbox.views.misc,
+extras.management.commands.reindex, netbox.search.jobs, core.jobs, dcim.signals) happens to
+import netbox.search first.
+"""
+from django.db.models.signals import post_delete, post_save
+from django.dispatch import receiver
+
+from .backends import search_backend
+
+
+@receiver(post_save)
+def caching_handler(sender, instance, created, **kwargs):
+    """
+    Update the search cache when an object is created or modified. Delegates to whichever
+    backend is configured; see SearchBackend.caching_handler().
+    """
+    search_backend.caching_handler(sender, instance, created=created, **kwargs)
+
+
+@receiver(post_delete)
+def removal_handler(sender, instance, **kwargs):
+    """
+    Remove an object's cached representation when it is deleted. Delegates to whichever
+    backend is configured; see SearchBackend.removal_handler().
+    """
+    search_backend.removal_handler(sender, instance, **kwargs)

+ 5 - 0
netbox/netbox/settings.py

@@ -89,6 +89,11 @@ AUTH_PASSWORD_VALIDATORS = getattr(configuration, 'AUTH_PASSWORD_VALIDATORS', [
     },
 ])
 BASE_PATH = trailing_slash(getattr(configuration, 'BASE_PATH', ''))
+BULK_UPDATE_CHUNK_SIZE = getattr(configuration, 'BULK_UPDATE_CHUNK_SIZE', 5000)
+if BULK_UPDATE_CHUNK_SIZE is not None and (type(BULK_UPDATE_CHUNK_SIZE) is not int or BULK_UPDATE_CHUNK_SIZE < 1):
+    raise ImproperlyConfigured(
+        f"BULK_UPDATE_CHUNK_SIZE must be a positive integer or None (found {BULK_UPDATE_CHUNK_SIZE!r})"
+    )
 CHANGELOG_SKIP_EMPTY_CHANGES = getattr(configuration, 'CHANGELOG_SKIP_EMPTY_CHANGES', True)
 CENSUS_REPORTING_ENABLED = getattr(configuration, 'CENSUS_REPORTING_ENABLED', True)
 CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False)

+ 7 - 1
netbox/netbox/tables/columns.py

@@ -23,6 +23,7 @@ from utilities.object_types import object_type_identifier, object_type_name
 from utilities.permissions import get_permission_for_model
 from utilities.request import get_safe_request_context
 from utilities.templatetags.builtins.filters import render_markdown
+from utilities.validators import url_scheme_is_allowed
 from utilities.views import get_action_url
 
 __all__ = (
@@ -562,7 +563,12 @@ class CustomFieldColumn(tables.Column):
         if self.customfield.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value is False:
             return mark_safe('<i class="mdi mdi-close-thick text-danger"></i>')
         if self.customfield.type == CustomFieldTypeChoices.TYPE_URL:
-            return mark_safe(f'<a href="{escape(value)}">{escape(value)}</a>')
+            # Only render as a link if the scheme is permitted by ALLOWED_URL_SCHEMES, to guard against
+            # dangerous schemes (e.g. javascript:) in values which bypassed validation. A schemeless
+            # (relative) value is considered safe.
+            if url_scheme_is_allowed(value):
+                return mark_safe(f'<a href="{escape(value)}">{escape(value)}</a>')
+            return escape(value)
         if self.customfield.type == CustomFieldTypeChoices.TYPE_SELECT:
             return self.customfield.get_choice_label(value)
         if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTISELECT:

+ 33 - 0
netbox/netbox/tests/dummy_plugin/graphql.py

@@ -1,6 +1,7 @@
 
 import strawberry
 import strawberry_django
+from django.db.models import Q
 
 from . import models
 
@@ -22,3 +23,35 @@ class DummyQuery:
 schema = [
     DummyQuery,
 ]
+
+
+#
+# Extensions to core GraphQL types & filters (see netbox.graphql.types.register_type /
+# netbox.graphql.filters.register_filter). These exercise the plugin extension point.
+#
+
+@strawberry.type
+class SiteTypeExtension:
+    models = ['dcim.site']
+
+    @strawberry_django.field
+    def dummy_plugin_field(self) -> str:
+        return 'dummy-plugin-value'
+
+
+@strawberry.type
+class SiteFilterExtension:
+    models = ['dcim.site']
+
+    @strawberry_django.filter_field()
+    def dummy_plugin_filter(self, value: str, prefix) -> Q:
+        return Q(**{f'{prefix}name': value})
+
+
+type_extensions = [
+    SiteTypeExtension,
+]
+
+filter_extensions = [
+    SiteFilterExtension,
+]

+ 149 - 0
netbox/netbox/tests/test_graphql.py

@@ -1,7 +1,9 @@
 import json
 import re
+from unittest import skipIf
 
 import strawberry
+from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
 from django.db import connection
 from django.test import override_settings
@@ -133,6 +135,26 @@ class GraphQLAPITestCase(APITestCase):
         )
         Site.objects.bulk_create(sites)
 
+    @skipIf('netbox.tests.dummy_plugin' not in settings.PLUGINS, "dummy_plugin not in settings.PLUGINS")
+    @override_settings(LOGIN_REQUIRED=True)
+    def test_graphql_plugin_extensions_execute(self):
+        """
+        A plugin-provided filter extension and field extension execute end-to-end against a live query,
+        exercising the custom filter method's prefix plumbing and the type extension's resolver.
+        """
+        self.add_permissions('dcim.view_site')
+        url = reverse('graphql')
+
+        query = '{ site_list(filters: {dummy_plugin_filter: "Site 1"}) { name dummy_plugin_field } }'
+        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)
+        sites = data['data']['site_list']
+        self.assertEqual(len(sites), 1)
+        self.assertEqual(sites[0]['name'], 'Site 1')
+        self.assertEqual(sites[0]['dummy_plugin_field'], 'dummy-plugin-value')
+
     @override_settings(LOGIN_REQUIRED=True)
     def test_graphql_filter_objects(self):
         """
@@ -662,3 +684,130 @@ class JSONStringLookupTestCase(TestCase):
                          '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")
+
+
+class SpliceExtensionBasesTestCase(TestCase):
+    """Verify splice_extension_bases() behavior: pass-through, splicing, and collision warnings."""
+
+    @staticmethod
+    def _make_core():
+        @strawberry.type
+        class CoreBase:
+            description: str  # inherited (non-protected) field
+
+            @classmethod
+            def get_queryset(cls, queryset, info, **kwargs):
+                return queryset
+
+        @strawberry.type
+        class CoreType(CoreBase):
+            name: str  # defined directly in the core type's own body
+
+        return CoreType
+
+    def test_no_extensions_is_passthrough(self):
+        from netbox.graphql.utils import splice_extension_bases
+        CoreType = self._make_core()
+        self.assertIs(splice_extension_bases(CoreType, []), CoreType)
+        self.assertIs(splice_extension_bases(CoreType, None), CoreType)
+
+    def test_extension_spliced_into_bases(self):
+        from netbox.graphql.utils import splice_extension_bases
+
+        @strawberry.type
+        class Extension:
+            models = ['dcim.device']
+            extra: str
+
+        CoreType = self._make_core()
+        result = splice_extension_bases(CoreType, [Extension])
+        self.assertIsNot(result, CoreType)
+        self.assertEqual(result.__name__, CoreType.__name__)
+        self.assertIn(Extension, result.__mro__)
+        # The extension is appended *after* the core bases in the MRO (additive, core wins collisions)
+        self.assertGreater(result.__mro__.index(Extension), result.__mro__.index(CoreType.__bases__[0]))
+
+    def test_warns_when_extension_collides_with_core_own_field(self):
+        # A name the core type defines directly always wins; the extension's version is ignored (and warned).
+        from netbox.graphql.utils import splice_extension_bases
+
+        @strawberry.type
+        class Extension:
+            models = ['dcim.device']
+            name: str  # collides with CoreType.name (own body)
+
+        CoreType = self._make_core()
+        with self.assertLogs('netbox.graphql', level='WARNING') as cm:
+            splice_extension_bases(CoreType, [Extension])
+        self.assertTrue(any("already provides" in msg and "core takes precedence" in msg for msg in cm.output))
+
+    def test_warns_when_extension_collides_with_inherited_field(self):
+        # A name the core type inherits also wins over the extension (extensions are strictly additive).
+        from netbox.graphql.utils import splice_extension_bases
+
+        @strawberry.type
+        class Extension:
+            models = ['dcim.device']
+            description: str  # collides with CoreBase.description (inherited)
+
+        CoreType = self._make_core()
+        with self.assertLogs('netbox.graphql', level='WARNING') as cm:
+            splice_extension_bases(CoreType, [Extension])
+        self.assertTrue(any("already provides" in msg for msg in cm.output))
+
+    def test_core_hook_wins_over_extension(self):
+        # An extension declaring get_queryset is ignored; the core permission-enforcing hook is preserved by
+        # ordering (extensions are appended after the core bases).
+        from netbox.graphql.utils import splice_extension_bases
+
+        @strawberry.type
+        class Extension:
+            models = ['dcim.device']
+
+            @classmethod
+            def get_queryset(cls, queryset, info, **kwargs):
+                return 'EXTENSION_WON'
+
+        CoreType = self._make_core()
+        with self.assertLogs('netbox.graphql', level='WARNING') as cm:
+            result = splice_extension_bases(CoreType, [Extension])
+        self.assertTrue(any("already provides" in msg and "get_queryset" in msg for msg in cm.output))
+        # Core's get_queryset (identity) is retained, not the extension's override
+        self.assertEqual(result.get_queryset('CORE_QS', None), 'CORE_QS')
+
+    def test_mro_conflict_raises_clear_error(self):
+        from netbox.graphql.utils import splice_extension_bases
+
+        class A:
+            pass
+
+        class B:
+            pass
+
+        class Core(A, B):
+            name = 'core'
+
+        class Extension(B, A):  # reversed base order -> inconsistent MRO when spliced
+            models = ['dcim.device']
+
+        with self.assertRaises(TypeError) as ctx:
+            splice_extension_bases(Core, [Extension])
+        self.assertIn('Failed to splice', str(ctx.exception))
+
+    def test_warns_on_collision_between_extensions(self):
+        from netbox.graphql.utils import splice_extension_bases
+
+        @strawberry.type
+        class ExtensionA:
+            models = ['dcim.device']
+            widgets: str
+
+        @strawberry.type
+        class ExtensionB:
+            models = ['dcim.device']
+            widgets: str
+
+        CoreType = self._make_core()
+        with self.assertLogs('netbox.graphql', level='WARNING') as cm:
+            splice_extension_bases(CoreType, [ExtensionA, ExtensionB])
+        self.assertTrue(any("both define" in msg and "loaded first" in msg for msg in cm.output))

+ 111 - 0
netbox/netbox/tests/test_plugins.py

@@ -1,3 +1,4 @@
+import re
 from unittest import skipIf
 
 from django.conf import settings
@@ -216,6 +217,26 @@ class PluginTestCase(TestCase):
         self.assertIn(DummyQuery, registry['plugins']['graphql_schemas'])
         self.assertTrue(issubclass(Query, DummyQuery))
 
+    def test_graphql_type_extensions(self):
+        """
+        Validate that plugin GraphQL type & filter extensions are registered and spliced into the built schema.
+        """
+        from netbox.graphql.schema import schema
+        from netbox.tests.dummy_plugin.graphql import SiteFilterExtension, SiteTypeExtension
+
+        # Extensions are registered against the targeted core model
+        self.assertIn(SiteTypeExtension, registry['plugins']['graphql_type_extensions']['dcim.site'])
+        self.assertIn(SiteFilterExtension, registry['plugins']['graphql_filter_extensions']['dcim.site'])
+
+        # The injected field and filter appear in the assembled schema
+        schema_str = schema.as_str()
+        site_type = re.search(r'\ntype SiteType \{.*?\n\}', schema_str, re.DOTALL)
+        self.assertIsNotNone(site_type, "SiteType not found in GraphQL schema")
+        self.assertIn('dummy_plugin_field', site_type.group(0))
+        site_filter = re.search(r'\ninput SiteFilter \{.*?\n\}', schema_str, re.DOTALL)
+        self.assertIsNotNone(site_filter, "SiteFilter not found in GraphQL schema")
+        self.assertIn('dummy_plugin_filter', site_filter.group(0))
+
     @override_settings(PLUGINS_CONFIG={'netbox.tests.dummy_plugin': {'foo': 123}})
     def test_get_plugin_config(self):
         """
@@ -359,3 +380,93 @@ class PluginNavigationTestCase(TestCase):
         self.assertIsNot(item1.permissions, item2.permissions)
         self.assertEqual(item1.permissions, ['explicit_permission'])
         self.assertEqual(item2.permissions, ['different_permission'])
+
+
+class RegisterGraphQLExtensionsTestCase(TestCase):
+    """Validate registration-time checks for GraphQL type/filter extensions."""
+
+    def test_rejects_extension_without_models(self):
+        import strawberry
+
+        from netbox.plugins.registration import register_graphql_type_extensions
+
+        @strawberry.type
+        class NoModels:
+            pass
+
+        with self.assertRaises(TypeError):
+            register_graphql_type_extensions([NoModels])
+
+    def test_rejects_undecorated_extension(self):
+        # A plain class (no @strawberry.type) must be rejected...
+        from netbox.plugins.registration import register_graphql_type_extensions
+
+        class Undecorated:
+            models = ['dcim.device']
+
+        with self.assertRaises(TypeError):
+            register_graphql_type_extensions([Undecorated])
+
+    def test_rejects_undecorated_subclass_of_strawberry_type(self):
+        # ...as must a subclass that only inherits __strawberry_definition__ without its own decoration.
+        import strawberry
+
+        from netbox.plugins.registration import register_graphql_type_extensions
+
+        @strawberry.type
+        class Base:
+            pass
+
+        class Child(Base):
+            models = ['dcim.device']
+
+        with self.assertRaises(TypeError):
+            register_graphql_type_extensions([Child])
+
+    def test_rejects_unknown_model_label(self):
+        import strawberry
+
+        from netbox.plugins.registration import register_graphql_type_extensions
+
+        @strawberry.type
+        class BadTarget:
+            models = ['dcim.notamodel']
+
+        with self.assertRaises(TypeError):
+            register_graphql_type_extensions([BadTarget])
+
+    def test_filter_extension_requires_strawberry_type(self):
+        # The filter path enforces the same @strawberry.type requirement as the type path.
+        from netbox.plugins.registration import register_graphql_filter_extensions
+
+        class UndecoratedFilter:
+            models = ['dcim.device']
+
+        with self.assertRaises(TypeError):
+            register_graphql_filter_extensions([UndecoratedFilter])
+
+    def test_warns_when_registered_after_assembly(self):
+        # An extension registered after its core type was already assembled is warned and will be dropped.
+        import strawberry
+
+        from netbox.plugins.registration import register_graphql_type_extensions
+
+        @strawberry.type
+        class LateExt:
+            models = ['dcim.cable']
+            late_field: str
+
+        store, label = 'graphql_type_extensions', 'dcim.cable'
+        assembled = registry['plugins']['graphql_extensions_assembled']
+        was_present = (store, label) in assembled
+        assembled.add((store, label))
+        # Restore global registry state regardless of outcome so other tests are unaffected.
+        self.addCleanup(lambda: registry['plugins'][store].__setitem__(
+            label, [e for e in registry['plugins'][store][label] if e is not LateExt]
+        ))
+        if not was_present:
+            self.addCleanup(assembled.discard, (store, label))
+
+        with self.assertLogs('netbox.graphql', level='WARNING') as cm:
+            register_graphql_type_extensions([LateExt])
+        self.assertTrue(any('after the core type was assembled' in msg for msg in cm.output))

+ 6 - 4
netbox/netbox/tests/test_search.py

@@ -741,7 +741,8 @@ class CustomBackendContractTestCase(TransactionTestCase):
         backend = _MinimalSearchBackend()
 
         # Connect the custom backend's (inherited, synchronous) handlers, exactly as
-        # backends.py connects the configured backend at import. Same call site, no type-check.
+        # netbox.search.signals connects the configured backend from CoreConfig.ready(). Same
+        # call site, no type-check.
         post_save.connect(backend.caching_handler, sender=Site)
         post_delete.connect(backend.removal_handler, sender=Site)
         self.addCleanup(post_save.disconnect, backend.caching_handler, sender=Site)
@@ -757,9 +758,10 @@ class CustomBackendContractTestCase(TransactionTestCase):
         self.assertEqual(len(backend.removed), 1)
 
     def test_default_backend_defers_via_same_call_path(self):
-        # The default backend (CachedValueSearchBackend) IS connected at import, and reaches the
-        # SAME caching_handler call path -- but its override defers instead of indexing inline.
-        # This contrasts with the custom backend above: identical dispatch, polymorphic behavior.
+        # The default backend (CachedValueSearchBackend) IS connected via netbox.search.signals, and
+        # reaches the SAME caching_handler call path -- but its override defers instead of indexing
+        # inline. This contrasts with the custom backend above: identical dispatch, polymorphic
+        # behavior.
         with transaction.atomic():
             Site.objects.create(name='Default Defers', slug='default-defers')
             scheduled = scheduled_search_flushes()

+ 28 - 0
netbox/netbox/tests/test_tables.py

@@ -4,6 +4,8 @@ from django.test import RequestFactory, TestCase
 
 from dcim.models import Device, Site
 from dcim.tables import DeviceTable
+from extras.choices import CustomFieldTypeChoices
+from extras.models import CustomField
 from netbox.tables import NetBoxTable, columns
 from utilities.testing import create_tags, create_test_device, create_test_user
 
@@ -119,3 +121,29 @@ class TagColumnTestCase(TestCase):
             'table': table
         })
         template.render(context)
+
+
+class CustomFieldColumnTestCase(TestCase):
+    """
+    A URL custom field value is rendered directly into an href, so its scheme must be validated
+    against ALLOWED_URL_SCHEMES to avoid rendering dangerous schemes (e.g. javascript:) as clickable
+    links (fixes #22640).
+    """
+
+    def _render(self, value):
+        customfield = CustomField(name='url_field', type=CustomFieldTypeChoices.TYPE_URL)
+        return columns.CustomFieldColumn(customfield).render(value)
+
+    def test_url_allowed_scheme_rendered_as_link(self):
+        self.assertEqual(self._render('https://example.com'), '<a href="https://example.com">https://example.com</a>')
+
+    def test_url_disallowed_scheme_not_rendered_as_link(self):
+        rendered = self._render('javascript:alert(1)')
+        self.assertNotIn('href', rendered)
+        self.assertIn('javascript:alert(1)', rendered)
+
+    def test_url_percent_encoded_scheme_rendered_as_relative_link(self):
+        # A percent-encoded scheme is inert: a browser will not decode "%3A" to execute javascript:,
+        # so the value has no scheme and is rendered as a link as-is.
+        rendered = self._render('javascript%3Aalert(1)')
+        self.assertEqual(rendered, '<a href="javascript%3Aalert(1)">javascript%3Aalert(1)</a>')

+ 7 - 6
netbox/tenancy/graphql/filters.py

@@ -11,6 +11,7 @@ from netbox.graphql.filters import (
     NestedGroupModelFilter,
     OrganizationalModelFilter,
     PrimaryModelFilter,
+    register_filter,
 )
 from tenancy import models
 
@@ -58,7 +59,7 @@ __all__ = (
 )
 
 
-@strawberry_django.filter_type(models.Tenant, lookups=True)
+@register_filter(models.Tenant, lookups=True)
 class TenantFilter(ContactFilterMixin, PrimaryModelFilter):
     name: StrFilterLookup | None = strawberry_django.filter_field()
     slug: StrFilterLookup | None = strawberry_django.filter_field()
@@ -137,7 +138,7 @@ class TenantFilter(ContactFilterMixin, PrimaryModelFilter):
     )
 
 
-@strawberry_django.filter_type(models.TenantGroup, lookups=True)
+@register_filter(models.TenantGroup, lookups=True)
 class TenantGroupFilter(OrganizationalModelFilter):
     parent: Annotated['TenantGroupFilter', strawberry.lazy('tenancy.graphql.filters')] | None = (
         strawberry_django.filter_field()
@@ -151,7 +152,7 @@ class TenantGroupFilter(OrganizationalModelFilter):
     )
 
 
-@strawberry_django.filter_type(models.Contact, lookups=True)
+@register_filter(models.Contact, lookups=True)
 class ContactFilter(PrimaryModelFilter):
     name: StrFilterLookup | None = strawberry_django.filter_field()
     title: StrFilterLookup | None = strawberry_django.filter_field()
@@ -167,19 +168,19 @@ class ContactFilter(PrimaryModelFilter):
     )
 
 
-@strawberry_django.filter_type(models.ContactRole, lookups=True)
+@register_filter(models.ContactRole, lookups=True)
 class ContactRoleFilter(OrganizationalModelFilter):
     pass
 
 
-@strawberry_django.filter_type(models.ContactGroup, lookups=True)
+@register_filter(models.ContactGroup, lookups=True)
 class ContactGroupFilter(NestedGroupModelFilter):
     parent: Annotated['ContactGroupFilter', strawberry.lazy('tenancy.graphql.filters')] | None = (
         strawberry_django.filter_field()
     )
 
 
-@strawberry_django.filter_type(models.ContactAssignment, lookups=True)
+@register_filter(models.ContactAssignment, lookups=True)
 class ContactAssignmentFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter):
     object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
         strawberry_django.filter_field()

+ 13 - 8
netbox/tenancy/graphql/types.py

@@ -1,10 +1,15 @@
 from typing import TYPE_CHECKING, Annotated
 
 import strawberry
-import strawberry_django
 
 from extras.graphql.mixins import ContactsMixin, CustomFieldsMixin, TagsMixin
-from netbox.graphql.types import BaseObjectType, NestedLtreeGroupObjectType, OrganizationalObjectType, PrimaryObjectType
+from netbox.graphql.types import (
+    BaseObjectType,
+    NestedLtreeGroupObjectType,
+    OrganizationalObjectType,
+    PrimaryObjectType,
+    register_type,
+)
 from tenancy import models
 
 from .filters import *
@@ -52,7 +57,7 @@ __all__ = (
 # Tenants
 #
 
-@strawberry_django.type(
+@register_type(
     models.Tenant,
     fields='__all__',
     filters=TenantFilter,
@@ -86,7 +91,7 @@ class TenantType(ContactsMixin, PrimaryObjectType):
     l2vpns: list[Annotated['L2VPNType', strawberry.lazy('vpn.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.TenantGroup,
     exclude=['path', 'sort_path'],
     filters=TenantGroupFilter,
@@ -103,7 +108,7 @@ class TenantGroupType(NestedLtreeGroupObjectType):
 # Contacts
 #
 
-@strawberry_django.type(
+@register_type(
     models.Contact,
     fields='__all__',
     filters=ContactFilter,
@@ -113,7 +118,7 @@ class ContactType(ContactAssignmentsMixin, PrimaryObjectType):
     groups: list[Annotated['ContactGroupType', strawberry.lazy('tenancy.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.ContactRole,
     fields='__all__',
     filters=ContactRoleFilter,
@@ -123,7 +128,7 @@ class ContactRoleType(ContactAssignmentsMixin, OrganizationalObjectType):
     pass
 
 
-@strawberry_django.type(
+@register_type(
     models.ContactGroup,
     exclude=['path', 'sort_path'],
     filters=ContactGroupFilter,
@@ -136,7 +141,7 @@ class ContactGroupType(NestedLtreeGroupObjectType):
     children: list[Annotated['ContactGroupType', strawberry.lazy('tenancy.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.ContactAssignment,
     fields='__all__',
     filters=ContactAssignmentFilter,

+ 5 - 5
netbox/users/graphql/filters.py

@@ -4,7 +4,7 @@ import strawberry
 import strawberry_django
 from strawberry_django import DatetimeFilterLookup, FilterLookup, StrFilterLookup
 
-from netbox.graphql.filters import BaseModelFilter
+from netbox.graphql.filters import BaseModelFilter, register_filter
 from users import models
 
 __all__ = (
@@ -15,13 +15,13 @@ __all__ = (
 )
 
 
-@strawberry_django.filter_type(models.Group, lookups=True)
+@register_filter(models.Group, lookups=True)
 class GroupFilter(BaseModelFilter):
     name: StrFilterLookup | None = strawberry_django.filter_field()
     description: StrFilterLookup | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.User, lookups=True)
+@register_filter(models.User, lookups=True)
 class UserFilter(BaseModelFilter):
     username: StrFilterLookup | None = strawberry_django.filter_field()
     first_name: StrFilterLookup | None = strawberry_django.filter_field()
@@ -34,7 +34,7 @@ class UserFilter(BaseModelFilter):
     groups: Annotated['GroupFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.Owner, lookups=True)
+@register_filter(models.Owner, lookups=True)
 class OwnerFilter(BaseModelFilter):
     name: StrFilterLookup | None = strawberry_django.filter_field()
     description: StrFilterLookup | None = strawberry_django.filter_field()
@@ -47,7 +47,7 @@ class OwnerFilter(BaseModelFilter):
     users: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.OwnerGroup, lookups=True)
+@register_filter(models.OwnerGroup, lookups=True)
 class OwnerGroupFilter(BaseModelFilter):
     name: StrFilterLookup | None = strawberry_django.filter_field()
     description: StrFilterLookup | None = strawberry_django.filter_field()

+ 5 - 6
netbox/users/graphql/types.py

@@ -1,7 +1,6 @@
 
-import strawberry_django
 
-from netbox.graphql.types import BaseObjectType
+from netbox.graphql.types import BaseObjectType, register_type
 from users.models import Group, Owner, OwnerGroup, User
 
 from .filters import *
@@ -14,7 +13,7 @@ __all__ = (
 )
 
 
-@strawberry_django.type(
+@register_type(
     Group,
     fields=['id', 'name'],
     filters=GroupFilter,
@@ -24,7 +23,7 @@ class GroupType(BaseObjectType):
     pass
 
 
-@strawberry_django.type(
+@register_type(
     User,
     fields=[
         'id', 'username', 'first_name', 'last_name', 'email', 'is_active', 'date_joined', 'groups',
@@ -36,7 +35,7 @@ class UserType(BaseObjectType):
     groups: list[GroupType]
 
 
-@strawberry_django.type(
+@register_type(
     OwnerGroup,
     fields=['id', 'name', 'description'],
     filters=OwnerGroupFilter,
@@ -46,7 +45,7 @@ class OwnerGroupType(BaseObjectType):
     pass
 
 
-@strawberry_django.type(
+@register_type(
     Owner,
     fields=['id', 'group', 'name', 'description', 'user_groups', 'users'],
     filters=OwnerFilter,

+ 2 - 1
netbox/utilities/counters.py

@@ -5,6 +5,7 @@ from django.db.models.signals import post_delete, post_save, pre_delete
 from netbox.registry import registry
 
 from .fields import CounterCacheField
+from .querysets import chunked_update
 
 
 def get_counters_for_model(model):
@@ -37,7 +38,7 @@ def update_counts(model, field_name, related_query):
     subquery = Subquery(
         model.objects.filter(pk=OuterRef('pk')).annotate(_count=Count(related_query)).values('_count')
     )
-    return model.objects.update(**{
+    return chunked_update(model.objects.all(), **{
         field_name: subquery
     })
 

+ 14 - 0
netbox/utilities/forms/fields/expandable.py

@@ -10,6 +10,7 @@ from utilities.forms.utils import expand_alphanumeric_pattern, expand_ipnetwork_
 __all__ = (
     'ExpandableIPNetworkField',
     'ExpandableNameField',
+    'ExpandableNumericField',
 )
 
 
@@ -35,6 +36,19 @@ class ExpandableNameField(forms.CharField):
         return [value]
 
 
+class ExpandableNumericField(ExpandableNameField):
+    """
+    An ExpandableNameField intended for numeric values, yielding integer-compatible strings suitable for bulk creation.
+      Example: '[1-3]' => ['1', '2', '3']
+    """
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        # Replace the inherited alphanumeric default with numeric-specific guidance (unless one was supplied)
+        if not kwargs.get('help_text'):
+            self.help_text = _("Numeric ranges are supported for bulk creation (example: <code>[1-24]</code>).")
+
+
 class ExpandableIPNetworkField(forms.CharField):
     """
     A CharField that expands numeric range patterns in IPv4/IPv6 CIDR notation into multiple entries.

+ 3 - 1
netbox/utilities/forms/rendering.py

@@ -47,10 +47,12 @@ class InlineFields:
     Parameters:
         fields: An iterable of form field names
         label: The label text to render for the row (optional)
+        help_text: Explanatory text rendered beneath the entire set of fields (optional)
     """
-    def __init__(self, *fields, label=None):
+    def __init__(self, *fields, label=None, help_text=None):
         self.fields = fields
         self.label = label
+        self.help_text = help_text
 
 
 class TabbedGroups:

+ 64 - 1
netbox/utilities/querysets.py

@@ -1,4 +1,6 @@
-from django.db.models import Prefetch, QuerySet
+from django.conf import settings
+from django.db import router, transaction
+from django.db.models import Max, Prefetch, QuerySet
 
 from users.constants import CONSTRAINT_TOKEN_USER
 from utilities.permissions import get_permission_for_model, permission_is_exempt, qs_filter_from_constraints
@@ -6,9 +8,70 @@ from utilities.permissions import get_permission_for_model, permission_is_exempt
 __all__ = (
     'RestrictedPrefetch',
     'RestrictedQuerySet',
+    'chunked_update',
 )
 
 
+def chunked_update(queryset, chunk_size=None, **kwargs):
+    """
+    Perform a bulk UPDATE on the given queryset, optionally splitting it into batches of at most
+    `chunk_size` rows. Bounding the number of rows touched by each statement avoids exceeding the
+    database's statement timeout when updating very large tables. Batches are selected via keyset
+    pagination on the primary key and wrapped in a transaction so that the operation remains atomic,
+    as it would be when performed by a single UPDATE. Returns the total number of rows updated
+    (matching the return value of QuerySet.update()).
+
+    If `chunk_size` is None, it falls back to the BULK_UPDATE_CHUNK_SIZE configuration parameter
+    (5000 by default). If that is also None, a single unbounded UPDATE is issued, identical to
+    calling queryset.update(**kwargs) directly.
+
+    :param queryset: The QuerySet identifying the rows to update
+    :param chunk_size: The maximum number of rows to update per statement (defaults to
+        settings.BULK_UPDATE_CHUNK_SIZE)
+    """
+    if chunk_size is None:
+        chunk_size = settings.BULK_UPDATE_CHUNK_SIZE
+    if chunk_size is not None and (type(chunk_size) is not int or chunk_size < 1):
+        raise ValueError(f"chunk_size must be a positive integer or None (found {chunk_size!r})")
+    if chunk_size is None:
+        return queryset.update(**kwargs)
+
+    model = queryset.model
+
+    # Pin the entire operation to a single write database so that the PK lookups, the UPDATE
+    # statements, and the enclosing transaction all use the same connection. This preserves an
+    # explicit .using() on the queryset and otherwise honors the router's write destination.
+    using = queryset._db or router.db_for_write(model)
+
+    count = 0
+    last_pk = 0
+    # Upper bound on the PKs to process. Established lazily (see below) only once a second batch is
+    # known to be needed, so the common single-batch case incurs no extra aggregate query.
+    max_pk = None
+    with transaction.atomic(using=using):
+        while True:
+            batch = queryset.using(using).filter(pk__gt=last_pk).order_by('pk')
+            if max_pk is not None:
+                batch = batch.filter(pk__lte=max_pk)
+            pks = list(batch.values_list('pk', flat=True)[:chunk_size])
+            if not pks:
+                break
+            # Re-filter the original queryset by pk__in (rather than the model's default manager) so
+            # that its own filters are preserved and rows no longer matching them are left untouched.
+            count += queryset.using(using).filter(pk__in=pks).update(**kwargs)
+            last_pk = pks[-1]
+            # A batch shorter than chunk_size means the rows are exhausted; stop without issuing a
+            # trailing (empty) lookup. This keeps a single-batch update to one SELECT and one UPDATE.
+            if len(pks) < chunk_size:
+                break
+            # A full batch means more rows may remain. Capture the current maximum PK as an upper
+            # bound (once) so that rows inserted while the operation runs cannot keep extending it.
+            if max_pk is None:
+                max_pk = queryset.using(using).aggregate(_max=Max('pk'))['_max']
+
+    return count
+
+
 class RestrictedPrefetch(Prefetch):
     """
     Extend Django's Prefetch to accept a user and action to be passed to the

+ 5 - 1
netbox/utilities/templates/builtins/customfield_value.html

@@ -15,7 +15,11 @@
 {% elif customfield.type == 'datetime' and value %}
   {{ value|isodatetime }}
 {% elif customfield.type == 'url' and value %}
-  <a href="{{ value }}">{{ value|truncatechars:70 }}</a>
+  {% if url_allowed %}
+    <a href="{{ value }}">{{ value|truncatechars:70 }}</a>
+  {% else %}
+    {{ value|truncatechars:70 }}
+  {% endif %}
 {% elif customfield.type == 'json' and value is not None %}
   <pre>{{ value|json }}</pre>
 {% elif customfield.type == 'select' and value %}

+ 21 - 13
netbox/utilities/templates/form_helpers/render_fieldset.html

@@ -6,28 +6,28 @@
       <h2 class="col-9 offset-3">{{ heading }}</h2>
     </div>
   {% endif %}
-  {% for layout, title, items in rows %}
+  {% for row in rows %}
 
-    {% if layout == 'field' %}
+    {% if row.layout == 'field' %}
       {# Single form field #}
-      {% render_field items.0 %}
+      {% render_field row.items.0 %}
 
-    {% elif layout == 'attribute' %}
+    {% elif row.layout == 'attribute' %}
       {# A static attribute of the form's instance #}
       <div class="row mb-3">
-        <label class="col-sm-3 col-form-label text-lg-end required">{{ title }}</label>
+        <label class="col-sm-3 col-form-label text-lg-end required">{{ row.title }}</label>
         <div class="col">
           <div class="form-control-plaintext">
-            {{ items.0|linkify }}
+            {{ row.items.0|linkify }}
           </div>
         </div>
       </div>
 
-    {% elif layout == 'inline' %}
+    {% elif row.layout == 'inline' %}
       {# Multiple form fields on the same line #}
-      <div class="row mb-3"{% if title %} role="group" aria-label="{{ title }}"{% endif %}>
-        <label class="col col-3 col-form-label text-lg-end{% if items|any_required %} required{% endif %}">{{ title|default:'' }}</label>
-        {% for field in items %}
+      <div class="row{% if not row.help_text %} mb-3{% endif %}"{% if row.title %} role="group" aria-label="{{ row.title }}"{% endif %}>
+        <label class="col col-3 col-form-label text-lg-end{% if row.items|any_required %} required{% endif %}">{{ row.title|default:'' }}</label>
+        {% for field in row.items %}
           <div class="col mb-1">
             {% render_field_with_aria field has_helptext=True %}
             <div class="form-text" id="{{ field.auto_id }}_helptext">{% trans field.label %}</div>
@@ -39,13 +39,21 @@
           </div>
         {% endfor %}
       </div>
+      {% if row.help_text %}
+        {# Shared help text rendered beneath the entire set of inline fields #}
+        <div class="row mb-3">
+          <div class="col offset-3">
+            <span class="form-text">{{ row.help_text|safe }}</span>
+          </div>
+        </div>
+      {% endif %}
 
-    {% elif layout == 'tabs' %}
+    {% elif row.layout == 'tabs' %}
       {# Tabbed groups of fields #}
       <div class="row">
         <div class="col offset-3">
           <ul class="nav nav-pills mb-1" role="tablist">
-            {% for tab in items %}
+            {% for tab in row.items %}
               <li role="presentation" class="nav-item">
                 <button role="tab" type="button" id="{{ tab.id }}_tab" data-bs-toggle="tab" aria-controls="{{ tab.id }}" aria-selected="{% if tab.active %}true{% else %}false{% endif %}" data-bs-target="#{{ tab.id }}" class="nav-link {% if tab.active %}active{% endif %}">
                   {% trans tab.title %}
@@ -56,7 +64,7 @@
         </div>
       </div>
       <div class="tab-content p-0 border-0">
-        {% for tab in items %}
+        {% for tab in row.items %}
           <div class="tab-pane {% if tab.active %}active{% endif %}" id="{{ tab.id }}" role="tabpanel" aria-labelledby="{{ tab.id }}_tab">
             {% for field in tab.fields %}
               {% render_field field %}

+ 9 - 0
netbox/utilities/templatetags/builtins/tags.py

@@ -7,6 +7,7 @@ from django.utils.safestring import mark_safe
 
 from extras.choices import CustomFieldTypeChoices
 from utilities.querydict import dict_to_querydict
+from utilities.validators import url_scheme_is_allowed
 
 __all__ = (
     'badge',
@@ -48,6 +49,8 @@ def customfield_value(customfield, value):
     """
     color = None
     value_has_colors = False
+    # Determines whether a URL value may be rendered as a clickable link
+    url_allowed = False
 
     if value:
         if customfield.type == CustomFieldTypeChoices.TYPE_SELECT:
@@ -58,11 +61,17 @@ def customfield_value(customfield, value):
             value_has_colors = any(choice_color for _, choice_color in value)
             if not value_has_colors:
                 value = [choice_label for choice_label, _ in value]
+        elif customfield.type == CustomFieldTypeChoices.TYPE_URL:
+            # Only render as a link if the scheme is permitted by ALLOWED_URL_SCHEMES. This guards against
+            # dangerous schemes (e.g. javascript:) in values stored before validation was enforced or via
+            # paths which bypass model validation. A schemeless (relative) value is considered safe.
+            url_allowed = url_scheme_is_allowed(value)
     return {
         'customfield': customfield,
         'value': value,
         'color': color,
         'value_has_colors': value_has_colors,
+        'url_allowed': url_allowed,
     }
 
 

+ 19 - 6
netbox/utilities/templatetags/form_helpers.py

@@ -1,4 +1,6 @@
 import warnings
+from collections.abc import Sequence
+from typing import Any, NamedTuple
 
 from django import forms, template
 from django.conf import settings
@@ -20,6 +22,17 @@ __all__ = (
 register = template.Library()
 
 
+class FieldsetRow(NamedTuple):
+    """
+    A single row within a rendered fieldset. `layout` determines how the row's items are
+    rendered by the template (e.g. 'field', 'inline', 'tabs', 'attribute').
+    """
+    layout: str
+    items: Sequence
+    title: Any = None
+    help_text: Any = None
+
+
 #
 # Filters
 #
@@ -131,7 +144,7 @@ def render_fieldset(form, fieldset):
                 form[name] for name in item.fields if name in form.fields
             ]
             rows.append(
-                ('inline', item.label, fields)
+                FieldsetRow('inline', fields, title=item.label, help_text=item.help_text)
             )
 
         # Tabbed groups of fields
@@ -148,28 +161,28 @@ def render_fieldset(form, fieldset):
             if not any(tab['active'] for tab in tabs):
                 tabs[0]['active'] = True
             rows.append(
-                ('tabs', None, tabs)
+                FieldsetRow('tabs', tabs)
             )
 
         elif type(item) is M2MAddRemoveFields:
             if item.name in form.fields:
                 # Simple mode: render a single multi-select field
                 rows.append(
-                    ('field', None, [form[item.name]])
+                    FieldsetRow('field', [form[item.name]])
                 )
             else:
                 # Add/remove mode: render separate add and remove fields
                 for field_name in (f'add_{item.name}', f'remove_{item.name}'):
                     if field_name in form.fields:
                         rows.append(
-                            ('field', None, [form[field_name]])
+                            FieldsetRow('field', [form[field_name]])
                         )
 
         elif type(item) is ObjectAttribute:
             value = getattr(form.instance, item.name)
             label = value._meta.verbose_name if hasattr(value, '_meta') else item.name
             rows.append(
-                ('attribute', label.title(), [value])
+                FieldsetRow('attribute', [value], title=label.title())
             )
 
         # A single form field
@@ -179,7 +192,7 @@ def render_fieldset(form, fieldset):
             if field.name in getattr(form, 'nullable_fields', []):
                 field._nullable = True
             rows.append(
-                ('field', None, [field])
+                FieldsetRow('field', [field])
             )
 
     return {

+ 116 - 0
netbox/utilities/tests/test_querysets.py

@@ -0,0 +1,116 @@
+from django.db import connection
+from django.db.models import Count, F, IntegerField, OuterRef, Subquery
+from django.db.models.functions import Coalesce
+from django.test import TestCase, override_settings
+from django.test.utils import CaptureQueriesContext
+
+from extras.models import Tag
+from utilities.querysets import chunked_update
+
+
+class ChunkedUpdateTestCase(TestCase):
+    """
+    Tests for the chunked_update() helper, which performs a bulk UPDATE optionally split into
+    batches bounded by the BULK_UPDATE_CHUNK_SIZE configuration parameter.
+    """
+    @classmethod
+    def setUpTestData(cls):
+        Tag.objects.bulk_create([
+            Tag(name=f'Tag {i}', slug=f'tag-{i}', weight=i)
+            for i in range(1, 6)  # Five tags, weights 1..5
+        ])
+
+    @staticmethod
+    def _count_updates(queries):
+        return len([q for q in queries if q['sql'].strip().upper().startswith('UPDATE')])
+
+    @override_settings(BULK_UPDATE_CHUNK_SIZE=None)
+    def test_update_without_chunk_size(self):
+        """
+        With BULK_UPDATE_CHUNK_SIZE set to None, a single unbounded UPDATE is issued.
+        """
+        with CaptureQueriesContext(connection) as queries:
+            count = chunked_update(Tag.objects.all(), weight=100)
+
+        self.assertEqual(count, 5)
+        self.assertEqual(self._count_updates(queries.captured_queries), 1)
+        self.assertEqual(Tag.objects.filter(weight=100).count(), 5)
+
+    @override_settings(BULK_UPDATE_CHUNK_SIZE=2)
+    def test_update_with_chunk_size(self):
+        """
+        With BULK_UPDATE_CHUNK_SIZE set, the update is split into batches; every row is updated
+        exactly once and the total count is returned.
+        """
+        with CaptureQueriesContext(connection) as queries:
+            count = chunked_update(Tag.objects.all(), weight=100)
+
+        self.assertEqual(count, 5)
+        # Five rows in batches of two → three UPDATE statements
+        self.assertEqual(self._count_updates(queries.captured_queries), 3)
+        self.assertEqual(Tag.objects.filter(weight=100).count(), 5)
+
+    def test_explicit_chunk_size_argument(self):
+        """
+        An explicit chunk_size argument takes precedence over the configuration parameter.
+        """
+        with CaptureQueriesContext(connection) as queries:
+            count = chunked_update(Tag.objects.all(), chunk_size=2, weight=100)
+
+        self.assertEqual(count, 5)
+        self.assertEqual(self._count_updates(queries.captured_queries), 3)
+        self.assertEqual(Tag.objects.filter(weight=100).count(), 5)
+
+    @override_settings(BULK_UPDATE_CHUNK_SIZE=2)
+    def test_f_expression_applied_once_per_row(self):
+        """
+        An F() expression referencing the row's own column is applied exactly once per row, even
+        when the update is chunked (chunks are disjoint by primary key).
+        """
+        original = {tag.pk: tag.weight for tag in Tag.objects.all()}
+
+        count = chunked_update(Tag.objects.all(), weight=F('weight') + 1)
+
+        self.assertEqual(count, 5)
+        for tag in Tag.objects.all():
+            self.assertEqual(tag.weight, original[tag.pk] + 1)
+
+    @override_settings(BULK_UPDATE_CHUNK_SIZE=2)
+    def test_correlated_subquery(self):
+        """
+        A correlated subquery (OuterRef) resolves against each chunk's queryset, mirroring the
+        counter-rebuild pattern in utilities.counters.update_counts().
+        """
+        # Set each tag's weight to the number of tags sharing its slug (always 1), proving the
+        # OuterRef binds correctly per-row across chunks.
+        subquery = Subquery(
+            Tag.objects.filter(slug=OuterRef('slug')).values('slug')
+            .annotate(c=Count('pk')).values('c'),
+            output_field=IntegerField()
+        )
+        count = chunked_update(Tag.objects.all(), chunk_size=2, weight=Coalesce(subquery, 0))
+
+        self.assertEqual(count, 5)
+        self.assertEqual(Tag.objects.filter(weight=1).count(), 5)
+
+    @override_settings(BULK_UPDATE_CHUNK_SIZE=2)
+    def test_filtered_queryset(self):
+        """
+        Only rows matching the queryset's filter are updated when chunking.
+        """
+        target_pks = list(Tag.objects.filter(weight__lte=3).values_list('pk', flat=True))
+
+        count = chunked_update(Tag.objects.filter(weight__lte=3), weight=0)
+
+        self.assertEqual(count, len(target_pks))
+        self.assertEqual(Tag.objects.filter(weight=0).count(), len(target_pks))
+        # Rows outside the filter are untouched (weights 4 and 5 remain)
+        self.assertEqual(Tag.objects.filter(weight__gt=3).count(), 2)
+
+    @override_settings(BULK_UPDATE_CHUNK_SIZE=2)
+    def test_empty_queryset(self):
+        """
+        Updating an empty queryset is a no-op that returns zero.
+        """
+        count = chunked_update(Tag.objects.filter(name='nonexistent'), weight=0)
+        self.assertEqual(count, 0)

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

@@ -39,6 +39,15 @@ class CustomFieldValueTagTestCase(TestCase):
         )
         cls.multiselect_field.object_types.set([object_type])
 
+        cls.url_field = CustomField.objects.create(
+            name='url_field',
+            type=CustomFieldTypeChoices.TYPE_URL,
+        )
+        cls.url_field.object_types.set([object_type])
+
+    def _render(self, customfield, value):
+        return render_to_string('builtins/customfield_value.html', customfield_value(customfield, value))
+
     def test_select_choice_context_includes_color(self):
         context = customfield_value(self.select_field, 'a')
 
@@ -63,6 +72,23 @@ class CustomFieldValueTagTestCase(TestCase):
         self.assertFalse(context['value_has_colors'])
         self.assertEqual(context['value'], ['Option B'])
 
+    def test_url_allowed_scheme_rendered_as_link(self):
+        html = self._render(self.url_field, 'https://example.com')
+        self.assertInHTML('<a href="https://example.com">https://example.com</a>', html)
+
+    def test_url_disallowed_scheme_not_rendered_as_link(self):
+        # A dangerous scheme (e.g. one stored before validation was enforced) must not become a
+        # clickable href (fixes #22640).
+        html = self._render(self.url_field, 'javascript:alert(1)')
+        self.assertNotIn('href', html)
+        self.assertIn('javascript:alert(1)', html)
+
+    def test_url_percent_encoded_scheme_rendered_as_relative_link(self):
+        # A percent-encoded scheme is inert: a browser will not decode "%3A" to execute javascript:,
+        # so the value has no scheme and is rendered as a link as-is.
+        html = self._render(self.url_field, 'javascript%3Aalert(1)')
+        self.assertInHTML('<a href="javascript%3Aalert(1)">javascript%3Aalert(1)</a>', html)
+
 
 class StaticWithParamsTestCase(TestCase):
     """
@@ -346,3 +372,20 @@ class RenderFieldsetInlineRequiredTestCase(TestCase):
         )
         html = self._render(fieldset)
         self.assertNotIn('col-form-label text-lg-end required', html)
+
+    def test_inline_help_text_rendered(self):
+        fieldset = FieldSet(
+            InlineFields('optional_field', 'another_optional', label='Combined', help_text='Shared guidance'),
+        )
+        html = self._render(fieldset)
+        # The shared help text is rendered in its own row (col offset-3) beneath the fields
+        self.assertIn('Shared guidance', html)
+        self.assertIn('col offset-3', html)
+
+    def test_inline_help_text_omitted_when_not_provided(self):
+        fieldset = FieldSet(
+            InlineFields('optional_field', 'another_optional', label='Combined'),
+        )
+        html = self._render(fieldset)
+        # With no help text, the shared help-text row (col offset-3) must not be rendered
+        self.assertNotIn('col offset-3', html)

+ 19 - 0
netbox/utilities/validators.py

@@ -1,5 +1,6 @@
 import decimal
 import re
+from urllib.parse import urlparse
 
 from django.core.exceptions import ValidationError
 from django.core.validators import BaseValidator, RegexValidator, URLValidator, _lazy_re_compile
@@ -12,6 +13,7 @@ __all__ = (
     'EnhancedURLValidator',
     'ExclusionValidator',
     'MultipleOfValidator',
+    'url_scheme_is_allowed',
     'validate_regex',
 )
 
@@ -72,6 +74,23 @@ class MultipleOfValidator(BaseValidator):
             )
 
 
+def url_scheme_is_allowed(value):
+    """
+    Return True if the URL's scheme is permitted by ALLOWED_URL_SCHEMES. A schemeless (relative) value
+    is considered permitted.
+
+    The scheme is compared in lower case. A percent-encoded scheme (e.g. "javascript%3A…") yields no
+    scheme, matching browser behavior: a browser does not decode the scheme portion of an href, so such
+    a value is inert and is treated as relative. A malformed URL which cannot be parsed (e.g.
+    "http://[::1/foo") likewise yields no scheme.
+    """
+    try:
+        scheme = urlparse(value).scheme.lower()
+    except ValueError:
+        scheme = ''
+    return not scheme or scheme in get_config().ALLOWED_URL_SCHEMES
+
+
 def validate_regex(value):
     """
     Checks that the value is a valid regular expression. (Don't confuse this with RegexValidator, which *uses* a regex

+ 8 - 8
netbox/virtualization/graphql/filters.py

@@ -8,7 +8,7 @@ from strawberry_django import BaseFilterLookup, ComparisonFilterLookup, FilterLo
 from dcim.graphql.filter_mixins import InterfaceBaseFilterMixin, RenderConfigFilterMixin, ScopedFilterMixin
 from extras.graphql.filter_mixins import ConfigContextFilterMixin
 from netbox.graphql.filter_mixins import ImageAttachmentFilterMixin
-from netbox.graphql.filters import NetBoxModelFilter, OrganizationalModelFilter, PrimaryModelFilter
+from netbox.graphql.filters import NetBoxModelFilter, OrganizationalModelFilter, PrimaryModelFilter, register_filter
 from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
 from virtualization import models
 from virtualization.graphql.filter_mixins import VMComponentFilterMixin
@@ -38,7 +38,7 @@ __all__ = (
 )
 
 
-@strawberry_django.filter_type(models.Cluster, lookups=True)
+@register_filter(models.Cluster, lookups=True)
 class ClusterFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
     name: StrFilterLookup | None = strawberry_django.filter_field()
     type: Annotated['ClusterTypeFilter', strawberry.lazy('virtualization.graphql.filters')] | None = (
@@ -57,19 +57,19 @@ class ClusterFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, P
     )
 
 
-@strawberry_django.filter_type(models.ClusterGroup, lookups=True)
+@register_filter(models.ClusterGroup, lookups=True)
 class ClusterGroupFilter(ContactFilterMixin, OrganizationalModelFilter):
     vlan_groups: Annotated['VLANGroupFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
         strawberry_django.filter_field()
     )
 
 
-@strawberry_django.filter_type(models.ClusterType, lookups=True)
+@register_filter(models.ClusterType, lookups=True)
 class ClusterTypeFilter(OrganizationalModelFilter):
     pass
 
 
-@strawberry_django.filter_type(models.VirtualMachineType, lookups=True)
+@register_filter(models.VirtualMachineType, lookups=True)
 class VirtualMachineTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilter):
     default_platform: Annotated['PlatformFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field()
@@ -87,7 +87,7 @@ class VirtualMachineTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilter):
     virtual_machine_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.VirtualMachine, lookups=True)
+@register_filter(models.VirtualMachine, lookups=True)
 class VirtualMachineFilter(
     ContactFilterMixin,
     ImageAttachmentFilterMixin,
@@ -157,7 +157,7 @@ class VirtualMachineFilter(
     )
 
 
-@strawberry_django.filter_type(models.VMInterface, lookups=True)
+@register_filter(models.VMInterface, lookups=True)
 class VMInterfaceFilter(InterfaceBaseFilterMixin, VMComponentFilterMixin, NetBoxModelFilter):
     ip_addresses: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
         strawberry_django.filter_field()
@@ -182,7 +182,7 @@ class VMInterfaceFilter(InterfaceBaseFilterMixin, VMComponentFilterMixin, NetBox
     )
 
 
-@strawberry_django.filter_type(models.VirtualDisk, lookups=True)
+@register_filter(models.VirtualDisk, lookups=True)
 class VirtualDiskFilter(VMComponentFilterMixin, NetBoxModelFilter):
     size: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()

+ 8 - 8
netbox/virtualization/graphql/types.py

@@ -6,7 +6,7 @@ import strawberry_django
 from extras.graphql.mixins import ConfigContextMixin, ContactsMixin
 from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
 from netbox.graphql.scalars import BigInt
-from netbox.graphql.types import NetBoxObjectType, OrganizationalObjectType, PrimaryObjectType
+from netbox.graphql.types import NetBoxObjectType, OrganizationalObjectType, PrimaryObjectType, register_type
 from users.graphql.mixins import OwnerMixin
 from virtualization import models
 
@@ -46,7 +46,7 @@ class ComponentType(OwnerMixin, NetBoxObjectType):
     virtual_machine: Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')]
 
 
-@strawberry_django.type(
+@register_type(
     models.Cluster,
     exclude=['scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'],
     filters=ClusterFilter,
@@ -70,7 +70,7 @@ class ClusterType(ContactsMixin, VLANGroupsMixin, PrimaryObjectType):
         return self.scope
 
 
-@strawberry_django.type(
+@register_type(
     models.ClusterGroup,
     fields='__all__',
     filters=ClusterGroupFilter,
@@ -81,7 +81,7 @@ class ClusterGroupType(ContactsMixin, VLANGroupsMixin, OrganizationalObjectType)
     clusters: list[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.ClusterType,
     fields='__all__',
     filters=ClusterTypeFilter,
@@ -92,7 +92,7 @@ class ClusterTypeType(OrganizationalObjectType):
     clusters: list[ClusterType]
 
 
-@strawberry_django.type(
+@register_type(
     models.VirtualMachineType,
     fields='__all__',
     filters=VirtualMachineTypeFilter,
@@ -105,7 +105,7 @@ class VirtualMachineTypeType(PrimaryObjectType):
     instances: list[Annotated['VirtualMachineType', strawberry.lazy('virtualization.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.VirtualMachine,
     fields='__all__',
     filters=VirtualMachineFilter,
@@ -131,7 +131,7 @@ class VirtualMachineType(ConfigContextMixin, ContactsMixin, PrimaryObjectType):
     virtualdisks: list[Annotated["VirtualDiskType", strawberry.lazy('virtualization.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.VMInterface,
     fields='__all__',
     filters=VMInterfaceFilter,
@@ -154,7 +154,7 @@ class VMInterfaceType(IPAddressesMixin, ComponentType):
     mac_addresses: list[Annotated["MACAddressType", strawberry.lazy('dcim.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.VirtualDisk,
     fields='__all__',
     filters=VirtualDiskFilter,

+ 3 - 1
netbox/virtualization/signals.py

@@ -2,6 +2,8 @@ from django.db.models import Sum
 from django.db.models.signals import post_delete, post_save
 from django.dispatch import receiver
 
+from utilities.querysets import chunked_update
+
 from .models import Cluster, VirtualDisk, VirtualMachine
 
 
@@ -22,4 +24,4 @@ def update_virtualmachine_site(instance, **kwargs):
     Update the assigned site for all VMs to match that of the Cluster (if any).
     """
     if instance._site:
-        VirtualMachine.objects.filter(cluster=instance).update(site=instance._site)
+        chunked_update(VirtualMachine.objects.filter(cluster=instance), site=instance._site)

+ 11 - 10
netbox/vpn/graphql/filters.py

@@ -11,6 +11,7 @@ from netbox.graphql.filters import (
     NetBoxModelFilter,
     OrganizationalModelFilter,
     PrimaryModelFilter,
+    register_filter,
 )
 from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
 from vpn import models
@@ -36,12 +37,12 @@ __all__ = (
 )
 
 
-@strawberry_django.filter_type(models.TunnelGroup, lookups=True)
+@register_filter(models.TunnelGroup, lookups=True)
 class TunnelGroupFilter(OrganizationalModelFilter):
     pass
 
 
-@strawberry_django.filter_type(models.TunnelTermination, lookups=True)
+@register_filter(models.TunnelTermination, lookups=True)
 class TunnelTerminationFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter):
     tunnel: Annotated['TunnelFilter', strawberry.lazy('vpn.graphql.filters')] | None = strawberry_django.filter_field()
     tunnel_id: ID | None = strawberry_django.filter_field()
@@ -61,7 +62,7 @@ class TunnelTerminationFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLo
     outside_ip_id: ID | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.Tunnel, lookups=True)
+@register_filter(models.Tunnel, lookups=True)
 class TunnelFilter(TenancyFilterMixin, PrimaryModelFilter):
     name: StrFilterLookup | None = strawberry_django.filter_field()
     status: BaseFilterLookup[Annotated['TunnelStatusEnum', strawberry.lazy('vpn.graphql.enums')]] | None = (
@@ -87,7 +88,7 @@ class TunnelFilter(TenancyFilterMixin, PrimaryModelFilter):
     )
 
 
-@strawberry_django.filter_type(models.IKEProposal, lookups=True)
+@register_filter(models.IKEProposal, lookups=True)
 class IKEProposalFilter(PrimaryModelFilter):
     name: StrFilterLookup | None = strawberry_django.filter_field()
     authentication_method: (
@@ -116,7 +117,7 @@ class IKEProposalFilter(PrimaryModelFilter):
     )
 
 
-@strawberry_django.filter_type(models.IKEPolicy, lookups=True)
+@register_filter(models.IKEPolicy, lookups=True)
 class IKEPolicyFilter(PrimaryModelFilter):
     name: StrFilterLookup | None = strawberry_django.filter_field()
     version: BaseFilterLookup[Annotated['IKEVersionEnum', strawberry.lazy('vpn.graphql.enums')]] | None = (
@@ -131,7 +132,7 @@ class IKEPolicyFilter(PrimaryModelFilter):
     preshared_key: StrFilterLookup | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.IPSecProposal, lookups=True)
+@register_filter(models.IPSecProposal, lookups=True)
 class IPSecProposalFilter(PrimaryModelFilter):
     name: StrFilterLookup | None = strawberry_django.filter_field()
     encryption_algorithm: (
@@ -157,7 +158,7 @@ class IPSecProposalFilter(PrimaryModelFilter):
     )
 
 
-@strawberry_django.filter_type(models.IPSecPolicy, lookups=True)
+@register_filter(models.IPSecPolicy, lookups=True)
 class IPSecPolicyFilter(PrimaryModelFilter):
     name: StrFilterLookup | None = strawberry_django.filter_field()
     proposals: Annotated['IPSecProposalFilter', strawberry.lazy('vpn.graphql.filters')] | None = (
@@ -168,7 +169,7 @@ class IPSecPolicyFilter(PrimaryModelFilter):
     )
 
 
-@strawberry_django.filter_type(models.IPSecProfile, lookups=True)
+@register_filter(models.IPSecProfile, lookups=True)
 class IPSecProfileFilter(PrimaryModelFilter):
     name: StrFilterLookup | None = strawberry_django.filter_field()
     mode: BaseFilterLookup[Annotated['IPSecModeEnum', strawberry.lazy('vpn.graphql.enums')]] | None = (
@@ -184,7 +185,7 @@ class IPSecProfileFilter(PrimaryModelFilter):
     ipsec_policy_id: ID | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.L2VPN, lookups=True)
+@register_filter(models.L2VPN, lookups=True)
 class L2VPNFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
     name: StrFilterLookup | None = strawberry_django.filter_field()
     slug: StrFilterLookup | None = strawberry_django.filter_field()
@@ -208,7 +209,7 @@ class L2VPNFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
     )
 
 
-@strawberry_django.filter_type(models.L2VPNTermination, lookups=True)
+@register_filter(models.L2VPNTermination, lookups=True)
 class L2VPNTerminationFilter(NetBoxModelFilter):
     l2vpn: Annotated['L2VPNFilter', strawberry.lazy('vpn.graphql.filters')] | None = strawberry_django.filter_field()
     l2vpn_id: ID | None = strawberry_django.filter_field()

+ 17 - 11
netbox/vpn/graphql/types.py

@@ -4,7 +4,13 @@ import strawberry
 import strawberry_django
 
 from extras.graphql.mixins import ContactsMixin, CustomFieldsMixin, TagsMixin
-from netbox.graphql.types import NetBoxObjectType, ObjectType, OrganizationalObjectType, PrimaryObjectType
+from netbox.graphql.types import (
+    NetBoxObjectType,
+    ObjectType,
+    OrganizationalObjectType,
+    PrimaryObjectType,
+    register_type,
+)
 from vpn import models
 
 from .filters import *
@@ -30,7 +36,7 @@ __all__ = (
 )
 
 
-@strawberry_django.type(
+@register_type(
     models.TunnelGroup,
     fields='__all__',
     filters=TunnelGroupFilter,
@@ -41,7 +47,7 @@ class TunnelGroupType(ContactsMixin, OrganizationalObjectType):
     tunnels: list[Annotated["TunnelType", strawberry.lazy('vpn.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.TunnelTermination,
     fields='__all__',
     filters=TunnelTerminationFilter,
@@ -53,7 +59,7 @@ class TunnelTerminationType(CustomFieldsMixin, TagsMixin, ObjectType):
     outside_ip: Annotated["IPAddressType", strawberry.lazy('ipam.graphql.types')] | None
 
 
-@strawberry_django.type(
+@register_type(
     models.Tunnel,
     fields='__all__',
     filters=TunnelFilter,
@@ -67,7 +73,7 @@ class TunnelType(ContactsMixin, PrimaryObjectType):
     terminations: list[Annotated["TunnelTerminationType", strawberry.lazy('vpn.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.IKEProposal,
     fields='__all__',
     filters=IKEProposalFilter,
@@ -77,7 +83,7 @@ class IKEProposalType(PrimaryObjectType):
     ike_policies: list[Annotated["IKEPolicyType", strawberry.lazy('vpn.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.IKEPolicy,
     fields='__all__',
     filters=IKEPolicyFilter,
@@ -88,7 +94,7 @@ class IKEPolicyType(PrimaryObjectType):
     ipsec_profiles: list[Annotated["IPSecProfileType", strawberry.lazy('vpn.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.IPSecProposal,
     fields='__all__',
     filters=IPSecProposalFilter,
@@ -98,7 +104,7 @@ class IPSecProposalType(PrimaryObjectType):
     ipsec_policies: list[Annotated["IPSecPolicyType", strawberry.lazy('vpn.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.IPSecPolicy,
     fields='__all__',
     filters=IPSecPolicyFilter,
@@ -109,7 +115,7 @@ class IPSecPolicyType(PrimaryObjectType):
     ipsec_profiles: list[Annotated["IPSecProfileType", strawberry.lazy('vpn.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.IPSecProfile,
     fields='__all__',
     filters=IPSecProfileFilter,
@@ -122,7 +128,7 @@ class IPSecProfileType(PrimaryObjectType):
     tunnels: list[Annotated["TunnelType", strawberry.lazy('vpn.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.L2VPN,
     fields='__all__',
     filters=L2VPNFilter,
@@ -136,7 +142,7 @@ class L2VPNType(ContactsMixin, PrimaryObjectType):
     import_targets: list[Annotated["RouteTargetType", strawberry.lazy('ipam.graphql.types')]]
 
 
-@strawberry_django.type(
+@register_type(
     models.L2VPNTermination,
     exclude=['assigned_object_type', 'assigned_object_id'],
     filters=L2VPNTerminationFilter,

+ 4 - 4
netbox/wireless/graphql/filters.py

@@ -7,7 +7,7 @@ from strawberry_django import BaseFilterLookup, StrFilterLookup
 
 from dcim.graphql.filter_mixins import ScopedFilterMixin
 from netbox.graphql.filter_mixins import DistanceFilterMixin
-from netbox.graphql.filters import NestedGroupModelFilter, PrimaryModelFilter
+from netbox.graphql.filters import NestedGroupModelFilter, PrimaryModelFilter, register_filter
 from tenancy.graphql.filter_mixins import TenancyFilterMixin
 from wireless import models
 
@@ -26,12 +26,12 @@ __all__ = (
 )
 
 
-@strawberry_django.filter_type(models.WirelessLANGroup, lookups=True)
+@register_filter(models.WirelessLANGroup, lookups=True)
 class WirelessLANGroupFilter(NestedGroupModelFilter):
     pass
 
 
-@strawberry_django.filter_type(models.WirelessLAN, lookups=True)
+@register_filter(models.WirelessLAN, lookups=True)
 class WirelessLANFilter(
     WirelessAuthenticationFilterMixin,
     ScopedFilterMixin,
@@ -50,7 +50,7 @@ class WirelessLANFilter(
     vlan_id: ID | None = strawberry_django.filter_field()
 
 
-@strawberry_django.filter_type(models.WirelessLink, lookups=True)
+@register_filter(models.WirelessLink, lookups=True)
 class WirelessLinkFilter(
     WirelessAuthenticationFilterMixin,
     DistanceFilterMixin,

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio