Merge branch 'feature' into 22896-merge-main-into-feature

Resolves all conflicts between main and feature for #22896. Notable
resolutions:

- dcim/signals.py, dcim/tests/test_signals.py: main's cache_presave_scope_fields
  / sync_cached_scope_fields addition is fully superseded by feature's
  PostgreSQL-trigger-based denormalization (confirmed via feature's own
  migration docstrings); dropped in favor of feature's existing approach.
  Both files now match feature's originals exactly.

- netbox/tables/columns.py: combined main's generic get_ordering_annotation()
  protocol with feature's nulls_first-aware order() override. These two
  mechanisms cannot both apply to the same column (django-tables2 negates an
  entire order_by tuple uniformly on direction toggle, so a fixed nulls_first
  placement and multi-column sort composition are mutually exclusive for one
  column) -- preserved nulls_first (existing, wired through forms/API/GraphQL)
  and removed main's two composition-only tests for CustomFieldColumn. See the
  comment on CustomFieldColumn.order() for full reasoning.

- extras/customfields.py, extras/graphql/mixins.py: combined main's
  request-cache optimization and has_key-scoped batch updates with feature's
  resolve_selection_value() (shared select-field label resolution between
  REST and GraphQL).

- extras/events.py, extras/event_rules.py: main's "Honor Script defaults when
  triggered by Event Rules" (#22852) fix was written against the old inline
  action-type dispatch, which feature had already replaced with a pluggable
  action-provider registry (#22770). Re-applied the same two-line fix
  (notifications/job_timeout) inside ScriptAction.enqueue() in event_rules.py
  instead.

- utilities/jinja2.py: fixed a config-attribute name mismatch the raw merge
  would have introduced (main's JINJA2_FILTERS vs feature's renamed
  JINJA_FILTERS) by updating the shared _jinja2_filters() helper.

- ipam/migrations/: renumbered main's 0094_ipaddress_host_index to 0096 and
  added a merge migration, since main and feature had each independently
  added a migration numbered 0094.

- dcim/tests/query_counts.json: regenerated via UPDATE_QUERY_COUNTS=1 against
  the merged codebase rather than hand-merging counts.

Verified: manage.py check clean, full migration graph applies cleanly from
scratch, ruff clean, and full test suites pass for dcim, ipam, netbox, extras,
circuits, vpn, wireless, tenancy, virtualization, core, users, and account
(fresh databases, no state carried over between runs).
This commit is contained in:
Brian Tiemann 2026-08-10 16:13:01 -04:00
commit fde10cbf22
519 changed files with 42153 additions and 8334 deletions

View File

@ -478,9 +478,9 @@ class MyModelTestCase(ViewTestCases.PrimaryObjectViewTestCase):
**File:** `netbox/<app>/tests/test_filtersets.py`
```python
from utilities.testing import ChangeLoggedFilterSetTests
from utilities.testing import ChangeLoggedFilterSetTestMixin
class MyModelFilterSetTestCase(TestCase, ChangeLoggedFilterSetTests):
class MyModelFilterSetTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
queryset = MyModel.objects.all()
filterset = MyModelFilterSet
@ -496,7 +496,7 @@ class MyModelFilterSetTestCase(TestCase, ChangeLoggedFilterSetTests):
# Test FK and FK_id filters
```
`ChangeLoggedFilterSetTests` provides standard tests for `id`, `created`, `last_updated`, `q` search, etc. Always mix it in.
`ChangeLoggedFilterSetTestMixin` provides standard tests for `id`, `created`, `last_updated`, `q` search, etc. Always mix it in.
## Common Gotchas

View File

@ -44,7 +44,7 @@ jobs:
cache: pip
- name: Install build tooling
run: python -m pip install --upgrade build twine
run: python -m pip install --upgrade build twine packaging
- name: Install documentation toolchain
run: python -m pip install -r requirements.txt
@ -60,6 +60,20 @@ jobs:
- name: Check package metadata
run: twine check dist/*
- name: Verify the release tag
# Both checks run here, in the unprivileged build job, against the wheel that becomes this
# run's artifact, so neither publish job has to check out the repository or execute its
# scripts while holding id-token: write. A failure here skips every downstream job.
if: startsWith(github.ref, 'refs/tags/v')
env:
TAG: ${{ github.ref_name }}
run: |
[[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]] || {
echo "Ref '$TAG' is not a release tag of the form vX.Y.Z[-designation]"
exit 1
}
python scripts/verify_release_tag.py "$TAG" dist/*.whl
- name: Upload package artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
@ -276,15 +290,14 @@ jobs:
name: Publish package to Test PyPI
runs-on: ubuntu-latest
needs: [smoke-test, cli-smoke-test, verify-dependencies, verify-sdist]
# Publishing always requires a v* tag ref: a tag push publishes to Test PyPI
# automatically, and a manual dispatch does the same when the chosen ref is a v* tag.
# Branch dispatches still run the build, verify, and smoke-test jobs (a useful dry run)
# but the publish job is skipped. Production PyPI publishing is intentionally absent
# during the v4.6.x preview; it arrives with the v4.7.0 feature branch.
# startsWith() only routes to this job (workflow `if:` expressions cannot regex-match);
# the exact tag format (v<release.yaml version>) is enforced below by the "Enforce
# release tag format" step and scripts/verify_release_tag.py before any upload.
if: startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
# Test PyPI remains an opt-in rehearsal channel: only a manual dispatch from a v* tag publishes
# here, so the publish path can be exercised against a real index without touching production.
# A branch dispatch still runs the build, verify, and smoke-test jobs as a dry run, with both
# publish jobs skipped.
# startsWith() is only a coarse route to this job; workflow if: expressions cannot regex-match.
# The tag format and the tag-to-wheel version match are enforced in the build job, which fails
# the whole run before anything is uploaded.
if: github.event_name == 'workflow_dispatch' && startsWith(github.ref, 'refs/tags/v')
environment:
name: testpypi
url: https://test.pypi.org/p/netbox
@ -293,36 +306,45 @@ jobs:
id-token: write
steps:
- name: Enforce release tag format
env:
TAG: ${{ github.ref_name }}
run: |
[[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]] || {
echo "Ref '$TAG' is not a release tag of the form vX.Y.Z[-designation]"
exit 1
}
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.12'
- name: Install tooling
run: python -m pip install --upgrade pip packaging
- name: Download package artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: python-package-distributions
path: dist/
- name: Verify the git tag matches the built version
run: python scripts/verify_release_tag.py "${{ github.ref_name }}" dist/*.whl
- name: Publish package distributions to Test PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
with:
repository-url: https://test.pypi.org/legacy/
print-hash: true
publish-pypi:
name: Publish package to PyPI
runs-on: ubuntu-latest
needs: [smoke-test, cli-smoke-test, verify-dependencies, verify-sdist]
# A v* tag push is the production path. Test PyPI is an opt-in rehearsal rather than a promotion
# stage, so it is deliberately absent from this job's needs: an outage, a duplicate filename, or
# a misconfiguration on a test service must not block a verified production release. The four
# verification jobs above already ran against these exact artifacts. The protected pypi
# environment supplies the deliberate approval step, and because accepted PyPI filenames cannot
# be replaced or reused, a filename the index already holds fails the job instead of being
# skipped.
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
environment:
name: pypi
url: https://pypi.org/p/netbox
permissions:
contents: read
id-token: write
steps:
- name: Download package artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: python-package-distributions
path: dist/
- name: Publish package distributions to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
with:
print-hash: true

View File

@ -27,12 +27,15 @@ django-graphiql-debug-toolbar
django-htmx
# Modified Preorder Tree Traversal (recursive nesting of objects)
# Retained primarily for plugin backward compatibility: the deprecated
# NestedGroupModel base remains MPTT-backed for plugins still using it. Also
# required by historical migrations that pre-date the switch to PostgreSQL ltree.
# NetBox core runtime uses netbox.models.ltree.LtreeModel instead.
django-mptt
# Context managers for PostgreSQL advisory locks
# https://github.com/Xof/django-pglocks/blob/main/CHANGELOG.md
# django-pglocks has been merged into django-pgware (see #22571)
django-pglocks==1.0.4
# Context managers for PostgreSQL advisory locks (successor to django-pglocks)
# https://github.com/Xof/django-pgware
django-pgware
# Prometheus metrics library for Django
# https://github.com/korfuri/django-prometheus/blob/master/CHANGELOG.md
@ -58,8 +61,7 @@ django-storages
# Abstraction models for rendering and paginating HTML tables
# https://github.com/jieter/django-tables2/blob/master/CHANGELOG.md
# See #21902 for upgrading to django-tables2 v2.9+
django-tables2<2.9
django-tables2
# User-defined tags for objects
# https://github.com/jazzband/django-taggit/blob/master/CHANGELOG.rst

File diff suppressed because one or more lines are too long

View File

@ -4,11 +4,13 @@
### Enabling Error Reporting
NetBox supports native integration with [Sentry](https://sentry.io/) for automatic error reporting. To enable this functionality, set `SENTRY_ENABLED` to `True` and define your unique [data source name (DSN)](https://docs.sentry.io/product/sentry-basics/concepts/dsn-explainer/) in `configuration.py`.
NetBox supports native integration with [Sentry](https://sentry.io/) for automatic error reporting. To enable this functionality, set `SENTRY_ENABLED` to `True` and define your unique [data source name (DSN)](https://docs.sentry.io/product/sentry-basics/concepts/dsn-explainer/) in `configuration.py` via `SENTRY_CONFIG`.
```python
SENTRY_ENABLED = True
SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0"
SENTRY_CONFIG = {
"dsn": "https://examplePublicKey@o0.ingest.sentry.io/0",
}
```
Setting `SENTRY_ENABLED` to False will disable the Sentry integration.

View File

@ -56,6 +56,20 @@ FIELD_CHOICES = {
}
```
In addition to plain tuples, each choice may be defined as a dictionary, which allows specifying a description (shown as a subtitle beneath the option) alongside the value, label, and color. `value` and `label` are required; `color` and `description` are optional:
```python
FIELD_CHOICES = {
'dcim.Site.status': (
{'value': 'foo', 'label': 'Foo', 'color': 'red', 'description': 'The foo status'},
{'value': 'bar', 'label': 'Bar', 'color': 'green'},
)
}
```
!!! info "New in NetBox v4.7"
The dictionary-based format for declaring choices was introduced in NetBox v4.7. The tuple-based format remains supported, but will be deprecated in a future release and support for it will eventually be removed.
!!! info "Case-Insensitive Field Identifiers"
Field identifiers are case-insensitive. Both `dcim.Site.status` and `dcim.site.status` are valid and equivalent.

View File

@ -16,27 +16,6 @@ The default configuration is shown below:
Additionally, `http_proxy` and `https_proxy` are set to the HTTP and HTTPS proxies, respectively, configured for NetBox (if any).
## SENTRY_DSN
!!! warning "This parameter will be removed in NetBox v4.7."
Set this using `SENTRY_CONFIG` instead:
```
SENTRY_CONFIG = {
"dsn": "https://examplePublicKey@o0.ingest.sentry.io/0",
}
```
Default: `None`
Defines a Sentry data source name (DSN) for automated error reporting. `SENTRY_ENABLED` must be `True` for this parameter to take effect. For example:
```
SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0"
```
---
## SENTRY_ENABLED
Default: `False`
@ -48,43 +27,6 @@ Set to `True` to enable automatic error reporting via [Sentry](https://sentry.io
---
## SENTRY_SAMPLE_RATE
!!! warning "This parameter will be removed in NetBox v4.7."
Set this using `SENTRY_CONFIG` instead:
```
SENTRY_CONFIG = {
"sample_rate": 0.2,
}
```
Default: `1.0` (all)
The sampling rate for errors. Must be a value between 0 (disabled) and 1.0 (report on all errors).
---
## SENTRY_SEND_DEFAULT_PII
!!! warning "This parameter will be removed in NetBox v4.7."
Set this using `SENTRY_CONFIG` instead:
```
SENTRY_CONFIG = {
"send_default_pii": True,
}
```
Default: `False`
Maps to the Sentry SDK's [`send_default_pii`](https://docs.sentry.io/platforms/python/configuration/options/#send-default-pii) parameter. If enabled, certain personally identifiable information (PII) is added.
!!! warning "Sensitive data"
If you enable this option, be aware that sensitive data such as cookies and authentication tokens will be logged.
---
## SENTRY_TAGS
An optional dictionary of tag names and values to apply to Sentry error reports.For example:
@ -99,22 +41,3 @@ SENTRY_TAGS = {
!!! warning "Reserved tag prefixes"
Avoid using any tag names which begin with `netbox.`, as this prefix is reserved by the NetBox application.
---
## SENTRY_TRACES_SAMPLE_RATE
!!! warning "This parameter will be removed in NetBox v4.7."
Set this using `SENTRY_CONFIG` instead:
```
SENTRY_CONFIG = {
"traces_sample_rate": 0.2,
}
```
Default: `0` (disabled)
The sampling rate for transactions. Must be a value between 0 (disabled) and 1.0 (report on all transactions).
!!! warning "Consider performance implications"
A high sampling rate for transactions can induce significant performance penalties. If transaction reporting is desired, it is recommended to use a relatively low sample rate of 10% to 20% (0.1 to 0.2).

View File

@ -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`.

View File

@ -277,7 +277,10 @@ This is a wrapper for passing global configuration parameters to [Django RQ](htt
Default: `300`
The maximum execution time of a background task (such as running a custom script), in seconds.
The maximum execution time of a background task (such as running a custom script), in seconds. This may also be expressed as a duration string such as `1h` or `30m`, which NetBox normalizes to seconds when comparing it against webhook timeouts. Set this to `-1` to disable the job timeout entirely.
!!! note
A value of zero (or `None`) does not disable the timeout: RQ falls back to its own default of 180 seconds, and NetBox validates webhook timeouts against that value accordingly.
---
@ -306,3 +309,19 @@ The base unit for disk sizes. Set this to `1024` to use binary prefixes (MiB, Gi
Default: `1000`
The base unit for RAM sizes. Set this to `1024` to use binary prefixes (MiB, GiB, etc.) instead of decimal prefixes (MB, GB, etc.).
---
## WEBHOOK_DEFAULT_TIMEOUT
Default: `60`
The default maximum time (in seconds) to wait for a response when sending a webhook. This value is used for any webhook which does not define its own timeout. Keeping this below [`RQ_DEFAULT_TIMEOUT`](#rq_default_timeout) gives an unresponsive receiver a chance to be cut off by the request timeout rather than by termination of the background job.
This value must be an integer between 1 and 3600, and must be less than `RQ_DEFAULT_TIMEOUT`; NetBox will refuse to start otherwise. The same upper bound is enforced on the per-webhook [timeout](../models/extras/webhook.md#timeout) field.
!!! warning "Upgrading"
If you have lowered `RQ_DEFAULT_TIMEOUT` to 60 seconds or less and have not set `WEBHOOK_DEFAULT_TIMEOUT`, NetBox will not start until you set `WEBHOOK_DEFAULT_TIMEOUT` to a value below your job timeout.
!!! note
The timeout is applied separately to establishing the connection and to waiting for data, rather than to the request as a whole. A receiver which responds slowly but continuously can therefore keep a request open for longer than the configured value. `RQ_DEFAULT_TIMEOUT` remains the ultimate upper bound on how long a webhook job can occupy a worker.

View File

@ -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.
@ -59,7 +59,7 @@ See the [`DATABASES`](#databases) configuration below for usage.
## DATABASES
NetBox requires access to a PostgreSQL 14 or later database service to store data. Note that support for PostgreSQL 14 is deprecated and will be removed in NetBox v4.7; PostgreSQL 15 or later will be required. This service can run locally on the NetBox server or on a remote system. Databases are defined as named dictionaries:
NetBox requires access to a PostgreSQL 15 or later database service to store data. This service can run locally on the NetBox server or on a remote system. Databases are defined as named dictionaries:
```python
DATABASES = {
@ -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.

View File

@ -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).
---

View File

@ -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)
@ -152,7 +166,7 @@ Set this configuration parameter to `True` for NetBox deployments which do not h
Default: `[]`
A list of system environment variable names which may be referenced from within Jinja2 templates via the built-in [`env`](#jinja2_filters) filter. Patterns may include wildcards (matched using Python's `fnmatch` syntax). Any variable whose name does not match an entry in this list cannot be referenced from a template. For example:
A list of system environment variable names which may be referenced from within Jinja templates via the built-in [`env`](#jinja_filters) filter. Patterns may include wildcards (matched using Python's `fnmatch` syntax). Any variable whose name does not match an entry in this list cannot be referenced from a template. For example:
```python
JINJA_ENVIRONMENT_PARAMS = [
@ -166,33 +180,39 @@ JINJA_ENVIRONMENT_PARAMS = [
---
## JINJA2_FILTERS
## JINJA_FILTERS
!!! info "Renamed in NetBox v4.7"
This parameter was formerly named `JINJA2_FILTERS`. The old name is still supported for backward compatibility but is deprecated and will be removed in NetBox v5.0.
Default: `{}`
A dictionary of custom Jinja2 filters with the key being the filter name and the value being a callable. For more information see the [Jinja2 documentation](https://jinja.palletsprojects.com/en/3.1.x/api/#custom-filters). For example:
A dictionary of custom Jinja filters with the key being the filter name and the value being a callable. For more information see the [Jinja documentation](https://jinja.palletsprojects.com/en/3.1.x/api/#custom-filters). For example:
```python
def uppercase(x):
return str(x).upper()
JINJA2_FILTERS = {
JINJA_FILTERS = {
'uppercase': uppercase,
}
```
NetBox also registers the following filters by default. Any entry defined in `JINJA2_FILTERS` with the same name will override the default.
NetBox also registers the following filters by default. Any entry defined in `JINJA_FILTERS` with the same name will override the default.
| Filter | Description |
|---|---|
| `env` | Returns the value of the system environment variable with the given name, provided its name matches an entry in [`JINJA_ENVIRONMENT_PARAMS`](#jinja_environment_params). Returns `None` if the variable is not defined or its name is not whitelisted. |
For example, given `JINJA_ENVIRONMENT_PARAMS = ['WEBHOOK_TOKEN_*']`, a Jinja2 template may reference an environment variable as:
For example, given `JINJA_ENVIRONMENT_PARAMS = ['WEBHOOK_TOKEN_*']`, a Jinja template may reference an environment variable as:
```
Authorization: Bearer {{ 'WEBHOOK_TOKEN_3' | env }}
```
!!! tip "Plugin-provided filters"
Plugins can also register Jinja filters without requiring instance configuration. See [Jinja Config Templates](../plugins/development/config-templates.md) in the plugin development documentation. Instance-level `JINJA_FILTERS` always takes precedence over plugin-registered filters of the same name.
---
## LOGGING

View File

@ -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
@ -109,6 +109,28 @@ When retrieving an object via the REST API, all of its custom data will be inclu
...
```
Selection and multiple selection fields are returned as objects exposing both the stored value and its human-friendly label, following the same convention used by NetBox's built-in choice fields:
```json
"custom_fields": {
"site_type": {
"value": "datacenter",
"label": "Data Center"
},
"regions": [
{
"value": "us-east",
"label": "US East"
},
{
"value": "us-west",
"label": "US West"
}
]
},
...
```
To set or change these values, simply include nested JSON data. For example:
```json
@ -120,3 +142,7 @@ To set or change these values, simply include nested JSON data. For example:
}
}
```
As with built-in choice fields, selection custom fields are written by passing the raw value (e.g. `"site_type": "datacenter"`), not the `{value, label}` object returned on read.
The GraphQL API's `custom_fields` field resolves selection and multiple selection values to the same `{value, label}` representation.

View File

@ -28,10 +28,13 @@ The following context data is available within the template when rendering a cus
|-----------|-------------------------------------------------------------------------------------------------------------------|
| `object` | The NetBox object being displayed |
| `debug` | A boolean indicating whether debugging is enabled |
| `request` | The current WSGI request |
| `user` | The current user (if authenticated) |
| `request` | A sanitized subset of the current request (see below) |
| `user` | The current user (if authenticated) |
| `perms` | The [permissions](https://docs.djangoproject.com/en/stable/topics/auth/default/#permissions) assigned to the user |
!!! note "Changed in NetBox v4.7"
For security, `request` no longer exposes the full WSGI request object. Only a safe subset of attributes is available: `request.id`, `request.path`, `request.path_info`, `request.method`, `request.GET` (the query parameters), and `request.user` (the username). Sensitive data such as cookies, headers, and session state is no longer accessible from within a custom link template.
While most of the context variables listed above will have consistent attributes, the object will be an instance of the specific object being viewed when the link is rendered. Different models have different fields and properties, so you may need to some research to determine the attributes available for use within your template for a specific object type.
Checking the REST API representation of an object is generally a convenient way to determine what attributes are available. You can also reference the NetBox source code directly for a comprehensive list.

View File

@ -16,10 +16,6 @@ A dictionary mapping of models to foreign keys with which cached counter fields
A dictionary mapping data backend types to their respective classes. These are used to interact with [remote data sources](../models/core/datasource.md).
### `denormalized_fields`
Stores registration made using `netbox.denormalized.register()`. For each model, a list of related models and their field mappings is maintained to facilitate automatic updates.
### `filtersets`
A dictionary mapping each model (identified by its app and label) to its filterset class, if one has been registered for it. Filtersets are registered using the `@register_filterset` decorator.

View File

@ -1,8 +1,10 @@
# Building the Package
NetBox package artifacts (a wheel and a source distribution) can be built and verified locally. During the v4.6.x preview period, published artifacts are for maintainer validation only. Installing NetBox via pip is not a supported installation path yet. Experimental support for installing from production PyPI is planned for NetBox v4.7.0. This page is intended for maintainers and contributors working on the packaging itself; routine development does not require building a package.
NetBox package artifacts (a wheel and a source distribution) can be built and verified locally. Installing NetBox from the Python package is experimental in NetBox v4.7 and is not recommended for production use. This page is intended for maintainers and contributors working on the packaging itself. Routine development does not require building a package.
The artifacts are always built by CI from a clean checkout (see `.github/workflows/release.yml`). A local build is useful for testing packaging changes before they are merged.
Published artifacts are always built by CI from a clean checkout (see `.github/workflows/release.yml`). A local build is useful for testing packaging changes before they are merged.
Release tags trigger the production PyPI publishing workflow. Before a release tag is pushed, confirm that the `pypi` GitHub Actions environment has required reviewers configured so the upload waits for approval after the package checks complete. Referencing the environment in the workflow does not create an approval gate by itself. See [Confirm Package Publishing Prerequisites](./release-checklist.md#confirm-package-publishing-prerequisites) and [Publish to PyPI](./release-checklist.md#publish-to-pypi) for the required repository checks and release procedure.
## Prerequisites
@ -12,7 +14,7 @@ Install the minimum local build tooling (all three are also included in the `dev
python -m pip install --upgrade build packaging twine
```
Building also requires a freshly rendered copy of the documentation site (see [Building](#building) below). The documentation toolchain (`zensical`, `mkdocs`, `mkdocs-material`, `mkdocstrings`) is pinned in `requirements.txt` rather than the `dev` group because it is also needed outside packaging, such as documentation previews and CI's `docs` job.
Building also requires a freshly rendered copy of the documentation site (see [Building](#building) below). The documentation toolchain, including `zensical`, `mkdocs`, `mkdocs-material`, `mkdocstrings`, and `mkdocstrings-python`, is pinned in `requirements.txt` rather than the `dev` group because it is also needed outside packaging, such as documentation previews and CI's `docs` job.
## Building
@ -41,7 +43,7 @@ The package version and the wheel's runtime dependency metadata are both compute
## Clean-tree caveat
Always build release artifacts from a clean checkout. The build configuration keeps deployment-local files out of the artifacts: the Hatch excludes drop every `configuration*.py` and `ldap_config*.py` except the two tracked configuration templates (`configuration_example.py` and `configuration_testing.py`, which are force-included explicitly), and CI verifies the contents of both the wheel and the sdist before anything is published.
Always build release artifacts from a clean checkout. The Hatch configuration excludes `netbox/netbox/configuration*.py` and `netbox/netbox/ldap_config*.py`, then force-includes only the two tracked configuration templates, `configuration_example.py` and `configuration_testing.py`. The sdist additionally excludes the checkout-level `netbox/configuration.py` and `netbox/ldap_config.py` symlinks. CI verifies the complete contents of both distributions before anything is published.
These checks are defense in depth, not a license to build from a dirty tree: other untracked files under `netbox/` can still be picked up by a local build. CI builds from a clean checkout, so the published artifacts are unaffected. For a comparable local build, use a fresh `git clone` or a separate clean worktree rather than your day-to-day development tree.
@ -87,11 +89,11 @@ NETBOX_SMOKETEST_BASE=/tmp/netbox-build-test-root \
/tmp/netbox-build-test/bin/netbox check
```
Without configuration, a wheel-installed NetBox looks for `$NETBOX_ROOT/conf/configuration.py` (default `/opt/netbox/conf/configuration.py`), which normally does not exist on a development workstation. The environment variables above point `netbox check` at the same minimal configuration module used by the release workflow's smoke-test job (`scripts/smoketest_configuration.py`); run the command from the repository root so `PYTHONPATH` can find it. `NETBOX_SMOKETEST_BASE` sets the writable scratch directory under which the module creates its media, reports, and scripts roots; `NETBOX_ROOT` points the fixed collected-static root at the same directory. Any other importable configuration module works the same way via `NETBOX_CONFIGURATION` (and `PYTHONPATH`, if the configuration lives outside the package). To exercise the full post-install task sequence from the wheel, run `netbox upgrade --no-input` with the same environment against a throwaway database (the collected static files land under `$NETBOX_ROOT/static`); this is what the release workflow's smoke-test job does. The documentation ships pre-rendered in the wheel, so there is nothing to build on the instance; `--build-docs` remains a checkout-only convenience for rendering the documentation from its sources.
Without configuration, a wheel-installed NetBox looks for `$NETBOX_ROOT/conf/configuration.py` (default `/opt/netbox/conf/configuration.py`), which normally does not exist on a development workstation. The environment variables above point `netbox check` at the same minimal configuration module used by the release workflow's smoke-test job (`scripts/smoketest_configuration.py`); run the command from the repository root so `PYTHONPATH` can find it. `NETBOX_SMOKETEST_BASE` sets the writable scratch directory under which the module creates its media, reports, and scripts roots. `NETBOX_ROOT` sets the instance root, from which the fixed collected-static path `$NETBOX_ROOT/static` is derived. Any other importable configuration module works the same way via `NETBOX_CONFIGURATION` (and `PYTHONPATH`, if the configuration lives outside the package). To exercise the full post-install task sequence from the wheel, run `netbox upgrade --no-input` with the same environment against a throwaway database (the collected static files land under `$NETBOX_ROOT/static`); this is what the release workflow's smoke-test job does. The documentation ships pre-rendered in the wheel, so there is nothing to build on the instance; `--build-docs` remains a checkout-only convenience for rendering the documentation from its sources.
## Packaging architecture
This section is a developer-facing overview of how the package is assembled and how a pip-installed NetBox behaves at runtime. User-facing installation documentation for the pip install path will be added alongside experimental PyPI support (planned for NetBox v4.7.0); this page does not cover end-user installation steps.
This section is a developer-facing overview of how the package is assembled and how a pip-installed NetBox behaves at runtime. End-user installation steps live in [Install NetBox from the Python Package](../installation/3b-python-package.md).
### Dynamic metadata
@ -99,7 +101,7 @@ This section is a developer-facing overview of how the package is assembled and
### sdist and the sdist-to-wheel guard
`python -m build` produces both an sdist and a wheel, with the wheel built from the sdist. The release workflow's `verify-sdist` job rebuilds a wheel from the candidate sdist and runs `scripts/verify_wheel_metadata.py` and `scripts/verify_wheel_contents.py` against it, so a missing build input (for example the metadata hook or `base_requirements.txt`) cannot regress unnoticed. The rendered documentation site is one such build input: it reaches the sdist through its own force-include (`[tool.hatch.build.targets.sdist.force-include]`), so this guard also fails if that force-include is removed or broken.
`python -m build` produces both an sdist and a wheel, with the wheel built from the sdist. The release workflow's `verify-sdist` job rebuilds a wheel from the candidate sdist and runs `scripts/verify_wheel_metadata.py` and `scripts/verify_wheel_contents.py` against it, so a missing build input, for example the metadata hook, `netbox/release.yaml`, or `requirements.txt`, cannot regress unnoticed. The rendered documentation site is one such build input: it reaches the sdist through its own force-include (`[tool.hatch.build.targets.sdist.force-include]`), so this guard also fails if that force-include is removed or broken.
### Wheel data layout

View File

@ -196,6 +196,16 @@ Once CI has completed and a colleague has reviewed the PR, merge it. This effect
!!! warning
To ensure a streamlined review process, the pull request for a release **must** be limited to the changes outlined in this document. A release PR must never include functional changes to the application: Any unrelated "cleanup" needs to be captured in a separate PR prior to the release being shipped.
### Confirm Package Publishing Prerequisites
Complete these checks before creating the release tag.
Confirm that the existing PyPI trusted publisher still matches this repository, `.github/workflows/release.yml`, and the `pypi` environment name. If a Test PyPI rehearsal is planned, confirm the corresponding Test PyPI trusted publisher and `testpypi` environment as well. The trusted publisher's environment name must match the publish job's `environment.name`, otherwise the index rejects the upload before any file is transferred.
Confirm that the `pypi` GitHub Actions environment has required reviewers configured so the production upload waits for approval after the package checks complete. Enable **Prevent self-review**, restrict deployments to `v*` tags, and leave administrator bypass disabled unless the maintainers deliberately require it. Referencing an environment from the workflow does not configure these protection rules; if the environment does not exist, GitHub creates it without an approval gate. The `testpypi` environment does not need an approval gate because a rehearsal run is dispatched deliberately.
The published package version is derived from `netbox/release.yaml` (the `version` field plus any `designation`, e.g. `beta1` becomes `4.7.0b1`), not from the git tag. Confirm that the intended tag and `netbox/release.yaml` agree before creating the release. The publishing workflow verifies the match again against the built wheel.
### Create a New Release
Create a [new release](https://github.com/netbox-community/netbox/releases/new) on GitHub with the following parameters.
@ -207,23 +217,54 @@ Create a [new release](https://github.com/netbox-community/netbox/releases/new)
Once created, the release will become available for users to install from GitHub.
### Publish to Test PyPI
### Publish to PyPI
Pushing a release tag triggers the Python package publishing workflow, which publishes the tagged release automatically to **Test PyPI** for maintainer validation. Installing NetBox via pip is not a supported installation path during the v4.6.x preview period; production PyPI publishing is planned for the v4.7.0 feature branch. A manual `workflow_dispatch` run publishes to Test PyPI only when the selected ref is a `v*` release tag; dispatching from a branch runs the build and verification jobs as a dry run without publishing.
Creating the GitHub release pushes the new tag and starts the Python package publishing workflow. With the prerequisites above in place, the workflow builds and verifies the wheel and source distribution, then holds the production upload until the `pypi` deployment is approved. Approving the deployment publishes the verified artifacts to **PyPI**. Installing NetBox from the Python package is experimental in NetBox v4.7 and is not recommended for production use.
A manual `workflow_dispatch` run from a `v*` release tag publishes to **Test PyPI** instead. This remains available as an optional rehearsal after packaging or publishing changes, but it is not required for every production release. Dispatching from a branch runs the build and verification jobs as a dry run without publishing anywhere.
Dispatch a rehearsal from the release tag with GitHub CLI:
```no-highlight
gh workflow run release.yml --ref vX.Y.Z
```
When a Test PyPI rehearsal is useful for a release, keep the production deployment awaiting approval while you dispatch the workflow from the same tag and validate the rehearsal. The rehearsal is a separate workflow run and rebuilds the distributions, so it validates the packaging and publishing path rather than the exact files waiting for production. Approve the production deployment after the rehearsal completes.
Test PyPI enforces the same filename immutability. Once it has accepted either distribution generated for a release tag, dispatching that tag again is expected to fail because the workflow rebuilds the same wheel and source distribution filenames. A further rehearsal requires a new package version and matching tag.
Official pre-release tags, including beta and release-candidate versions, are published to PyPI as well. This is intentional. Pip does not select pre-release versions by default unless the user explicitly requests one or no compatible stable release is available.
After a publish run completes:
* Verify that the build, CLI smoke-test (`cli-smoke-test`), smoke-test, dependency-verification (`verify-dependencies`), and sdist-verification (`verify-sdist`) jobs succeeded. The dependency-verification job fails the release if `requirements.txt` has drifted from `base_requirements.txt` or if the built wheel's `Requires-Dist` does not match `requirements.txt`; the sdist-verification job fails it if the sdist ships unexpected configuration files or cannot rebuild a valid wheel.
* Verify that the publish job used the expected trusted-publishing environment (`testpypi`).
* Confirm that the new version is visible on Test PyPI.
* Install the published wheel into a fresh virtual environment and run `netbox check` against a minimal configuration module. The preview artifact is published to Test PyPI while NetBox's pinned runtime dependencies are expected to resolve from PyPI; to avoid mixed-index dependency resolution during validation, install the pinned dependencies from PyPI first, then install the Test PyPI artifact without resolving dependencies again:
* Verify that the publish job used the expected trusted-publishing environment: `pypi` for a production release or `testpypi` for a rehearsal.
* Confirm that the new version is visible on the corresponding package index.
* Test the published wheel using the [wheel smoke-test procedure](./building-the-package.md#test-installing-the-wheel). For a production release, replace the local wheel installation command in that procedure with:
```no-highlight
pip install -r requirements.txt
pip install --no-deps --index-url https://test.pypi.org/simple/ netbox==<version>
/tmp/netbox-build-test/bin/python -m pip install "netbox==<version>"
```
!!! note "Trusted publishing prerequisites"
Publishing requires a one-time setup by the project owners: a `netbox` project and a configured GitHub trusted publisher on Test PyPI, plus the corresponding `testpypi` GitHub Actions environment.
For a Test PyPI rehearsal, install NetBox's pinned runtime dependencies from PyPI first and then install the candidate without resolving dependencies from the test index:
The published package version is derived from `netbox/release.yaml` (the `version` field plus any `designation`, e.g. `beta1` becomes `4.7.0b1`), not from the git tag. Ensure the tag and `release.yaml` agree before tagging a pre-release.
```no-highlight
/tmp/netbox-build-test/bin/python -m pip install -r requirements.txt
/tmp/netbox-build-test/bin/python -m pip install \
--no-deps \
--index-url https://test.pypi.org/simple/ \
"netbox==<version>"
```
Run `netbox check` with the configuration and environment variables shown in the linked procedure.
!!! warning "Production PyPI uploads are final"
Distribution files uploaded to PyPI cannot be replaced. A release may be yanked, and a release or an individual file may be deleted, but an uploaded filename can never be reused. Correcting an accepted distribution file requires publishing a new NetBox version.
If the publish job fails, check PyPI and the job log to determine whether any distribution file was accepted before deciding how to recover.
If no file was accepted and the cause can be corrected without changing the built distributions, correct it and re-run only the failed `publish-pypi` job. That reuses the package artifacts already built and verified in the original workflow run. Do not use **Re-run all jobs**, because it rebuilds the distributions.
If correcting the failure requires changing package contents or metadata, prepare a new NetBox version and release tag instead.
If PyPI accepted either distribution file, do not retry the publish job. Production publishing fails on duplicate filenames by design, so the retry fails when it reaches the already accepted file. Yank the incomplete release, record the accepted filenames and hashes, and publish a new NetBox version rather than combining files from separate builds.

View File

@ -90,3 +90,14 @@ Devices and virtual machines may also have a local context data defined. This lo
A [config context profile](../models/extras/configcontextprofile.md) provides an organizational grouping for related config contexts and may optionally enforce a [JSON schema](https://json-schema.org/) describing the shape of their data. When a profile is assigned to a config context, NetBox validates the context's data against the profile's schema on save and rejects any context that fails validation. This makes it possible to constrain which keys may appear in a context, require certain keys to be present, or limit values to a defined enumeration — guarding against typos and drift as contexts proliferate.
A profile's schema may be authored directly in NetBox or populated from an external [data source](../models/core/datasource.md), enabling teams to maintain schemas alongside the code or configurations that consume them.
## Pre-rendered Caching
!!! info "New in NetBox v4.7"
NetBox pre-renders each device's and virtual machine's merged context data and stores it on the object itself, so most reads can return the result without recomputing the full set of applicable contexts. The cache is initially populated during upgrade (the upgrade script runs the `rebuild_config_context_cache` management command) and is thereafter kept current automatically: whenever an upstream change is detected — a config context being created, modified, or deleted; a device/VM's scope-relevant attribute changing (site, role, tenant, tags, cluster, etc.); or a related object being re-routed in a way that changes which contexts apply — NetBox marks the affected caches invalid and enqueues a non-blocking [background job](./background-jobs.md) to repopulate them.
During the brief window between invalidation and re-render, requests for the affected object's config context fall back to the original on-demand rendering path, so the data returned is always correct — never stale — but may be slightly slower during that window. Once the background job completes, reads are served from the cache.
!!! note
The pre-rendered cache supersedes the previous `?exclude=config_context` REST API query parameter. Config context data is now always returned for devices and virtual machines, and the parameter is silently ignored.

41
docs/features/cooling.md Normal file
View File

@ -0,0 +1,41 @@
# Cooling
As part of its DCIM feature set, NetBox supports modeling data center cooling infrastructure, from facility plant down to the coolant connections on individual devices. This is used to document liquid- and hybrid-cooled environments (chillers, cooling distribution units, manifolds, rear-door heat exchangers, and cold-plate servers) as a source of truth.
## Model Overview
Cooling infrastructure is modeled as a hierarchy running from facility plant down to individual devices:
**cooling source → cooling feed → device cooling intake / cooling outflow**
A few properties of the model are worth noting up front:
- **Connections are direct references, not cables.** Coolant hoses are not modeled as structured cabling; instead, an intake references the outflow that supplies it directly. Tracing a loop is a walk along these references.
- **A single feed represents the entire loop.** A cooling feed covers both the supply (cold) and return (warm) paths of a loop, rather than modeling each direction as a separate object.
- **Intakes and outflows both sit on the supply path.** Both device components describe the cold, coolant-distribution side of the loop: an intake receives coolant and an outflow passes it onward to downstream equipment. The warm return path is not modeled per-component — it is captured by the feed loop.
## Cooling Sources
A [cooling source](../models/dcim/coolingsource.md) is the furthest upstream cooling element modeled in NetBox, representing a chiller, cooling tower, dry cooler, or CRAC/CRAH unit. Each source is associated with a site, and may optionally be associated with a particular location within that site. A cooling source is not a device; it represents external facility plant, and records the coolant (fluid type) and total rated cooling capacity for the loops it originates.
## Cooling Feeds
A [cooling feed](../models/dcim/coolingfeed.md) represents a coolant loop running between a cooling source and a particular rack. Each feed records an operational status, a rated cooling capacity, and a rated (design) flow rate.
## Device Components
Devices participate in cooling through two component types, instantiated from templates defined on the device type:
- A [cooling intake](../models/dcim/coolingintake.md) is a coolant intake on a device, such as a server cold-plate inlet or a CDU facility intake. It records the connector type, diameter, and rated maximum flow, and optionally references the upstream [cooling outflow](../models/dcim/coolingoutflow.md) that supplies it.
- A [cooling outflow](../models/dcim/coolingoutflow.md) is a coolant supply point on a device, such as a CDU or manifold outlet. It optionally references a parent cooling intake on the same device — the device takes coolant in through its intake and passes it back out through its outflow.
!!! tip "In-rack cooling equipment is modeled as a device"
Coolant distribution units (CDUs), manifolds, and rear-door heat exchangers (RDHx) are modeled as ordinary (typically zero-U) [devices](../models/dcim/device.md) installed in the rack — exactly as a PDU is modeled as a device with power ports and outlets. The device's make and model come from its [device type](../models/dcim/devicetype.md), and its cooling connections are represented by cooling intake and outflow components. There is no dedicated CDU or RDHx model.
## Racks and Devices
Racks and devices carry lightweight cooling attributes independent of the feed/component topology:
- A [rack](../models/dcim/rack.md) records a **cooling capability** (air-only, hybrid, or liquid-only) and a **cooling capacity** in kilowatts, typically inherited from its rack type.
- A [device](../models/dcim/device.md) records a **cooling method** (air, liquid, hybrid, or immersion), inherited from its device type and overridable per device.

View File

@ -2,7 +2,7 @@
## Global Search
NetBox includes a powerful global search engine, providing a single convenient interface to search across its complex data model. Relevant fields on each model are indexed according to their precedence, so that the most relevant results are returned first. When objects are created or modified, the search index is updated immediately, ensuring real-time accuracy.
NetBox includes a powerful global search engine, providing a single convenient interface to search across its complex data model. Relevant fields on each model are indexed according to their precedence, so that the most relevant results are returned first. When objects are created, modified, or deleted, the search index is updated by a background task shortly afterward. As a result, a newly created or changed object may not appear in search results for a brief period. (When no background worker is running, the index is updated immediately as part of the request.)
When entering a search query, the user can choose a specific lookup type: exact match, partial match, etc. When a partial match is found, the matching portion of the applicable field value is included with each result so that the user can easily determine its relevance.

View File

@ -26,7 +26,9 @@ When viewing the CSV import form for an object type, you'll notice that the head
<!-- TODO: Screenshot -->
If an "id" field is added the data will be used to update existing records instead of importing new objects.
If an "id" field is added the data will be used to update existing records instead of importing new objects. When updating, only the columns present in the data are applied; all others are left unchanged. Note that some columns are interdependent: for example, updating a cable's terminations requires that the columns identifying their type and parent object be included as well.
Some columns accept multiple values, separated by commas. Because the comma also serves as the CSV field delimiter, such a value must be enclosed in double quotes, e.g. `"tag1,tag2,tag3"`. (When importing JSON- or YAML-formatted data, these columns accept a native list instead.) An object whose name itself contains a comma cannot be referenced by a multi-value column, as there is no way to distinguish it from a separator.
Note that some models (namely device types and module types) do not support CSV import. Instead, they accept YAML-formatted data to facilitate the import of both the parent object as well as child components.

View File

@ -2,8 +2,8 @@
This section entails the installation and configuration of a local PostgreSQL database. If you already have a PostgreSQL database service in place, skip to [the next section](2-redis.md).
!!! warning "PostgreSQL 14 or later required"
NetBox requires PostgreSQL 14 or later. Please note that MySQL and other relational databases are **not** supported.
!!! warning "PostgreSQL 15 or later required"
NetBox requires PostgreSQL 15 or later. Please note that MySQL and other relational databases are **not** supported.
!!! warning "PostgreSQL 14 deprecation notice"
Support for PostgreSQL 14 is deprecated as of NetBox v4.6 and will be removed in NetBox v4.7. Please plan to upgrade to PostgreSQL 15 or later.
@ -15,7 +15,7 @@ sudo apt update
sudo apt install -y postgresql
```
Before continuing, verify that you have installed PostgreSQL 14 or later:
Before continuing, verify that you have installed PostgreSQL 15 or later:
```no-highlight
psql -V
@ -35,7 +35,6 @@ Within the shell, enter the following commands to create the database and user (
CREATE DATABASE netbox;
CREATE USER netbox WITH PASSWORD 'J5brHrAXFLQSif0K';
ALTER DATABASE netbox OWNER TO netbox;
-- the next two commands are needed on PostgreSQL 15 and later
\connect netbox;
GRANT CREATE ON SCHEMA public TO netbox;
```

View File

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

View File

@ -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`

View File

@ -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.

View File

@ -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

View File

@ -18,21 +18,23 @@ 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 |
|------------|--------------------|
| Python | 3.12, 3.13, 3.14 |
| PostgreSQL | 14+ [^1] |
| Redis | 5.0+ [^2] |
[^1]: Support for PostgreSQL 14 is deprecated and will be removed in NetBox v4.7. PostgreSQL 15 or later will be required.
[^2]: Support for Redis versions older than 6.0 is deprecated and will be removed in NetBox v4.7. Redis 6.0 or later will be required.
| PostgreSQL | 15+ |
| Redis | 6.0+ |
Below is a simplified overview of the NetBox application stack for reference:

View File

@ -22,21 +22,21 @@ 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:
| Dependency | Supported Versions |
|------------|--------------------|
| Python | 3.12, 3.13, 3.14 |
| PostgreSQL | 14+ [^1] |
| Redis | 5.0+ |
[^1]: Support for PostgreSQL 14 is deprecated and will be removed in NetBox v4.7. PostgreSQL 15 or later will be required.
| PostgreSQL | 15+ |
| Redis | 6.0+ |
### Version History
@ -58,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.
@ -73,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`.
@ -116,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:
@ -135,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:
@ -169,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.
@ -179,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
```

View File

@ -741,6 +741,53 @@ http://netbox/api/dcim/sites/ \
!!! note
The bulk deletion of objects is an all-or-none operation, meaning that if NetBox fails to delete any of the specified objects (e.g. due a dependency by a related object), the entire operation will be aborted and none of the objects will be deleted.
## Background Processing
!!! info "This feature was introduced in NetBox v4.7."
Bulk write operations (creating, updating, or deleting multiple objects via a model's list endpoint) can optionally be processed as a [background job](../features/background-jobs.md) rather than synchronously. This is useful for large batches that would otherwise hold the connection open long enough to risk a proxy or gateway timeout.
To request background processing, append the `background=true` query parameter to a bulk write request. NetBox enqueues a job and returns an `HTTP 202 Accepted` response containing the job's ID and URL. The actual write is performed later by a worker, running the same logic (and preserving the same all-or-none transaction semantics) as the synchronous path. Note that the request payload is **not** validated before the job is enqueued; validation is deferred to the worker (see below).
```no-highlight
curl -s -X PATCH \
-H "Authorization: Token $TOKEN" \
-H "Content-Type: application/json" \
http://netbox/api/dcim/sites/?background=true \
--data '[{"id": 10, "status": "active"}, {"id": 11, "status": "active"}]'
```
The response identifies the enqueued job:
```json
{
"job": {
"id": 42,
"url": "http://netbox/api/core/jobs/42/",
"status": "pending"
}
}
```
Poll the job's URL to track its progress. When the job reaches a terminal status, its `data` field holds the result and its `error` field describes any failure. The `data` field mirrors the response the synchronous request would have returned, as an object with the HTTP `status_code` and the response `data`. For example, a completed bulk update records:
```json
{
"status_code": 200,
"data": [
{"id": 10, "url": "http://netbox/api/dcim/sites/10/", "status": {"value": "active"}, "...": "..."}
]
}
```
A failed job records the equivalent error response, for instance `{"status_code": 400, "data": {"slug": ["This field may not be blank."]}}`, with a short summary also placed in the job's `error` field.
A `202` response indicates that the request was accepted and queued, not that it succeeded: validation (including malformed or invalid payloads) and the database write all occur when the job runs. A rejected payload is therefore reported as a failed job rather than a synchronous error response. Always inspect the job's final status to confirm the outcome. Because the result is stored on the job, any user permitted to view jobs (`core.view_job`, subject to object permissions) can read the serialized objects it contains.
Background processing applies only to bulk operations (a JSON list) on a model's list endpoint. For a single-object write the `background` parameter is ignored and the request is processed synchronously. It cannot be combined with an [`If-Match`](#if-match) precondition (which cannot be evaluated reliably once execution is deferred); such a request is rejected with an `HTTP 400` response. If no background worker is running to service the queue, the request is rejected with an `HTTP 503` response rather than enqueuing a job that would never run.
Two behaviors differ from a synchronous request and may change in a future release: field selection via [`fields`/`omit`](#specifying-fields) (and brief mode) is not applied to the stored result, and the authorization captured when the request is accepted is not re-checked if the token is later disabled or expires before the job runs.
## Changelog Messages
Most objects in NetBox support [change logging](../features/change-logging.md), which generates a detailed record each time an object is created, modified, or deleted. Additionally, users can attach a message to the change record as well. This is accomplished via the REST API by including a `changelog_message` field in the object representation.
@ -784,7 +831,7 @@ The NetBox REST API primarily employs token-based authentication. For convenienc
### Tokens
A token is a secret, unique identifier mapped to a NetBox user account. Each user may have one or more tokens which he or she can use for authentication when making REST API requests. To create a token, navigate to the API tokens page under your user profile. When creating a token, NetBox will automatically populate a randomly-generated token value.
A token is a secret, unique identifier mapped to a NetBox user account. Each user may have one or more tokens which he or she can use for authentication when making REST API requests. To create a token, navigate to the API tokens page under your user profile. When creating a token, NetBox will automatically generate a random token value. This value is always generated by the server and cannot be specified by the client; any `token` value included in a creation request is ignored.
!!! note "Tokens cannot be retrieved once created"
Once a token has been created, its plaintext value cannot be retrieved. For this reason, you must take care to securely record the token locally immediately upon its creation. If a token plaintext is lost, it cannot be recovered: A new token must be created.

View File

@ -17,7 +17,7 @@ For example, you might create a NetBox webhook to [trigger a Slack message](http
* HTTP method: `POST`
* URL: Slack incoming webhook URL
* HTTP content type: `application/json`
* Body template: `{"text": "IP address {{ data['address'] }} was created by {{ username }}!"}`
* Body template: `{"text": "IP address {{ data['address'] }} was created by {{ request.user }}!"}`
### Available Context
@ -30,16 +30,11 @@ The following data is available as context for Jinja2 templates:
* `request.id` - The UUID associated with the request
* `request.method` - The HTTP method (e.g. `GET` or `POST`)
* `request.path` - The URL path (ex: `/dcim/sites/123/edit/`)
* `request.path_info` - The URL path below the application script prefix
* `request.GET` - The query parameters included in the request
* `request.user` - The name of the authenticated user who made the request (if available)
* `data` - A detailed representation of the object in its current state. This is typically equivalent to the model's representation in NetBox's REST API.
* `snapshots` - Minimal "snapshots" of the object state both before and after the change was made; provided as a dictionary with keys named `prechange` and `postchange`. These are not as extensive as the fully serialized representation, but contain enough information to convey what has changed.
* ⚠️ `request_id` - The unique request ID. This may be used to correlate multiple changes associated with a single request.
* ⚠️ `username` - The name of the user account associated with the change.
!!! warning "Deprecation of legacy keys"
The `request_id` and `username` keys in the webhook payload above are deprecated and should no longer be used. Support for them will be removed in NetBox v4.7.0.
Use `request.user` and `request.id` from the `request` object included in the callback context instead.
### Sanitizing Header Values
@ -60,8 +55,6 @@ If no body template is specified, the request body will be populated with a JSON
"event": "created",
"timestamp": "2026-03-06T15:11:23.503186+00:00",
"object_type": "dcim.site",
"username": "jstretch",
"request_id": "17af32f0-852a-46ca-a7d4-33ecd0c13de6",
"data": {
"id": 4,
"url": "/api/dcim/sites/4/",

View File

@ -79,7 +79,5 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and
| HTTP service | nginx or Apache |
| WSGI service | gunicorn or uWSGI |
| Application | Django/Python |
| Database | PostgreSQL 14+ [^1] |
| Database | PostgreSQL 15+ |
| Task queuing | Redis/django-rq |
[^1]: Support for PostgreSQL 14 is deprecated and will be removed in NetBox v4.7. PostgreSQL 15 or later will be required.

View File

@ -28,6 +28,10 @@ The interval (in minutes) at which a scheduled job should re-execute.
The date and time at which the job completed (if complete).
### Execution Time
The amount of time the job spent executing, calculated as the difference between its start and completion times. This is populated only once a started job has completed.
### User
The user who created the job.

View File

@ -34,7 +34,9 @@ The profile to which the cable conforms. The profile determines the mapping of t
A single-position cable is allowed only one termination point at each end. There is no limit to the number of terminations a multi-position cable may have. Each end of a cable must have the same number of terminations, unless connected to a pass-through port or to a circuit termination.
The assignment of a cable profile is optional. If no profile is assigned, legacy tracing behavior will be preserved.
The assignment of a cable profile is optional. If no profile is assigned, legacy tracing behavior will be preserved. Note that a cable's profile is what maps each termination to a connector and position: a cable carrying multiple terminations on an end but having no profile assigned is permitted, but NetBox cannot map its positions across the cable. Assign a profile to model a breakout cable whose individual positions must be traced.
When creating cables in bulk, each side accepts a comma-separated list of termination names, along with either a single parent device (or power panel) shared by all of them or one parent per name. Terminations are assigned to connectors in the order given, so the order of these lists determines how the cable is wired.
### Type

View File

@ -0,0 +1,37 @@
# Cooling Feed
A cooling feed represents a coolant loop delivered from a [cooling source](./coolingsource.md) to a particular rack or coolant distribution unit (CDU). The [cooling intakes](./coolingintake.md) a feed supplies are derived from the devices installed in the rack it serves, rather than referenced explicitly.
A single feed represents the entire loop, covering both the supply (cold) and return (warm) paths.
!!! tip
In-rack cooling equipment — coolant distribution units (CDUs), manifolds, and rear-door heat exchangers (RDHx) — is modeled as an ordinary (typically zero-U) [device](./device.md) installed in the rack. The device's make and model come from its [device type](./devicetype.md), and a [cooling intake](./coolingintake.md) component connects it to cooling. The feed serving such a device is derived from its rack.
## Fields
### Cooling Source
The [cooling source](./coolingsource.md) which supplies this feed.
### Rack
The [rack](./rack.md) which this feed serves (optional).
### Name
The feed's name or identifier. Must be unique to the assigned cooling source.
### Status
The feed's operational status.
!!! tip
Additional statuses may be defined by setting `CoolingFeed.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
### Cooling Capacity
The heat-removal capacity of the feed, in kilowatts (kW).
### Maximum Flow
The maximum rate of coolant flow supported by the feed, expressed as a numeric value with a selectable unit (liters per minute, cubic meters per hour, or gallons per minute). Must be a positive, non-zero value in the selected unit, or left blank.

View File

@ -0,0 +1,40 @@
# Cooling Intakes
A cooling intake is a device component which consumes coolant, such as a server cold-plate inlet or a coolant distribution unit (CDU) intake. It **receives** coolant from the cold, supply side of a loop (see [cooling](../../features/cooling.md) for the overall flow model). A cooling intake optionally references the upstream [cooling outflow](./coolingoutflow.md) which supplies it.
!!! tip
Like most device components, cooling intakes are instantiated automatically from [cooling intake templates](./coolingintaketemplate.md) assigned to the selected device type when a device is created.
## Fields
### Device
The device to which this cooling intake belongs.
### Module
The installed module within the assigned device to which this cooling intake belongs (optional).
### Name
The name of the cooling intake. Must be unique to the parent device.
### Label
An alternative physical label identifying the cooling intake.
### Connector Type
The physical coolant connector type (e.g. UQD, UQDB, QDC, camlock, or threaded NPT/BSP).
### Diameter
The connector diameter, expressed as a numeric value with a selectable unit (millimeters, centimeters, or inches). Must be a positive, non-zero value in the selected unit, or left blank.
### Maximum Flow
The maximum coolant flow rate this port supports, expressed as a numeric value with a selectable unit (liters per minute, cubic meters per hour, or gallons per minute). Must be a positive, non-zero value in the selected unit, or left blank.
### Cooling Outflow
The upstream [cooling outflow](./coolingoutflow.md) which supplies this intake (optional).

View File

@ -0,0 +1,3 @@
# Cooling Intake Templates
A template for a cooling intake that will be created on all instantiations of the parent device type. See the [cooling intake](./coolingintake.md) documentation for more detail.

View File

@ -0,0 +1,38 @@
# Cooling Outflows
A cooling outflow is a device component which delivers coolant to a downstream [cooling intake](./coolingintake.md), and generally represents an outlet on a coolant distribution unit (CDU) or manifold. A cooling outflow may optionally be associated with an upstream cooling intake on the same device for path tracing.
A cooling outflow is a **supply** point on the cold, coolant-distribution side of a loop: it passes coolant onward to downstream equipment. It does **not** represent the return of warmed coolant back to the cooling source. The return path is not modeled per-component; instead, a single [cooling feed](./coolingfeed.md) represents the entire loop, covering both the supply (cold) and return (warm) paths.
!!! tip
Like most device components, cooling outflows are instantiated automatically from [cooling outflow templates](./coolingoutflowtemplate.md) assigned to the selected device type when a device is created.
## Fields
### Device
The device to which this cooling outflow belongs.
### Module
The installed module within the assigned device to which this cooling outflow belongs (optional).
### Name
The name of the cooling outflow. Must be unique to the parent device.
### Label
An alternative physical label identifying the cooling outflow.
### Connector Type
The physical coolant connector type (e.g. UQD, UQDB, QDC, camlock, or threaded NPT/BSP).
### Diameter
The connector diameter, expressed as a numeric value with a selectable unit (millimeters, centimeters, or inches). Must be a positive, non-zero value in the selected unit, or left blank.
### Cooling Intake
The upstream [cooling intake](./coolingintake.md) on the same device which feeds this outlet (optional).

View File

@ -0,0 +1,3 @@
# Cooling Outflow Templates
A template for a cooling outflow that will be created on all instantiations of the parent device type. See the [cooling outflow](./coolingoutflow.md) documentation for more detail.

View File

@ -0,0 +1,36 @@
# Cooling Source
A cooling source represents a facility-level source of cooling, such as a chiller, cooling tower, or dry cooler. It serves as the upstream origin for one or more [cooling feeds](./coolingfeed.md) which distribute coolant to racks and devices. A cooling source is not modeled as a device; it represents external facility plant.
## Fields
### Site
The [site](./site.md) at which the cooling source is located.
### Location
The [location](./location.md) within the site where the cooling source resides (optional).
### Name
The cooling source's name or identifier. Must be unique to the assigned site.
### Type
The type of cooling plant (e.g. chiller, cooling tower, dry cooler, CRAC, or CRAH).
### Status
The operational status of the cooling source.
!!! tip
Additional statuses may be defined by setting `CoolingSource.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
### Fluid Type
The coolant used by the source (e.g. water, water/glycol, dielectric fluid, or refrigerant).
### Cooling Capacity
The total heat-removal capacity of the source, expressed in kilowatts (kW).

View File

@ -30,6 +30,10 @@ The hardware [device type](./devicetype.md) which defines the device's make & mo
The direction in which air circulates through the device chassis for cooling.
### Cooling Method
The cooling method employed by the device (air, liquid, hybrid, or immersion). If not set, this is inherited from the assigned [device type](./devicetype.md) when the device is created.
### Serial Number
The unique physical serial number assigned to this device by its manufacturer.

View File

@ -57,10 +57,18 @@ Indicates whether this is a parent type (capable of housing child devices), a ch
The default direction in which airflow circulates within the device chassis. This may be configured differently for instantiated devices (e.g. because of different fan modules).
### Cooling Method
The default cooling method employed by devices of this type (air, liquid, hybrid, or immersion). Instantiated devices inherit this value unless overridden.
### Weight
The numeric weight of the device, including a unit designation (e.g. 10 kilograms or 20 pounds).
### End of Life
The date after which this device type is no longer supported by its manufacturer. This can be used to identify devices approaching or past their support horizon to aid in hardware lifecycle planning.
### Front & Rear Images
Users can upload illustrations of the device's front and rear panels. If present, these will be used to render the device in [rack](./rack.md) elevation diagrams.

View File

@ -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.

View File

@ -4,6 +4,16 @@ A module is a field-replaceable hardware component installed within a device whi
Similar to devices, modules are instantiated from [module types](./moduletype.md), and any components associated with the module type are automatically instantiated on the new model. Each module must be installed within a [module bay](./modulebay.md) on a [device](./device.md), and each module bay may have only one module installed in it.
## Moving Modules
An installed module can be moved to a different module bay after creation. The destination bay must be enabled and unoccupied. Moving a module relocates its entire subtree: the components installed by the module, the module bays belonging to it, and any child modules installed within those bays.
Component names, labels, and module bay positions derived from the module type's templates (for example, names containing `{module}`) are re-resolved for the destination bay. A component is renamed only when its current name matches exactly one of the module type's templates as resolved for the source bay; components whose names do not match any template resolution (including manually renamed components) are preserved as-is. All resulting names are validated against the destination device before the move is applied. A move is rejected when a template-derived name, label, or position would exceed the destination field's maximum length. A move is also rejected when a component's current value matched a template for the source bay but that template cannot be resolved for the destination bay's nesting depth.
Moving a module to a different device is supported only when the moved components carry no active topology or device-scoped configuration. A cross-device move is rejected while any moved component is cabled or marked as connected, has attached inventory items, or any moved interface has IP addresses, FHRP group assignments, tunnel terminations, L2VPN terminations, virtual circuit terminations, wireless links, wireless LAN assignments, VLANs (untagged, tagged, or Q-in-Q service), a VLAN translation policy, VDC assignments, or a VRF. A parent, bridge, LAG, power outlet to power port, or front/rear port mapping relation crossing the moved module's boundary in either direction also blocks the move. MAC addresses move together with their interfaces.
Via the REST API, a module can be moved by patching only `module_bay`; the device is derived from the target bay. Changing a module's type and moving it must be performed as separate operations.
## Fields
### Device
@ -40,3 +50,7 @@ Controls whether templates module type components are automatically added when c
### Adopt Components
Controls whether pre-existing components assigned to the device with the same names as components that would be created automatically will be assigned to the new module.
## Bay Type Compatibility
If the module bay has [bay types](./modulebaytype.md) assigned and the module's type also has bay types assigned, NetBox verifies that the two sets share at least one type in common. An installation that fails this check will be rejected. The `is_bay_compatible` flag is exposed in the REST API to indicate compatibility status without performing a write.

View File

@ -30,6 +30,10 @@ An alternative physical label identifying the module bay.
The numeric position in which this module bay is situated. For example, this would be the number assigned to a slot within a chassis-based switch.
### Bay Types
Zero or more [module bay types](./modulebaytype.md) assigned to this bay. When at least one bay type is set, only module types that share a common bay type may be installed. Leave empty to allow any module type.
### Enabled
Whether this module bay is enabled. Disabled module bays are not available for installation.

View File

@ -1,3 +1,5 @@
# Module Bay Templates
A template for a module bay that will be created on all instantiations of the parent device type. See the [module bay](./modulebay.md) documentation for more detail.
[Bay types](./modulebaytype.md) assigned to a module bay template are copied to each instantiated module bay, so constraints defined on the device type propagate automatically to all devices of that type.

View File

@ -0,0 +1,35 @@
# Module Bay Types
Module bay types are user-defined labels that can be assigned to [module bays](./modulebay.md) and [module types](./moduletype.md) to restrict which modules may be installed into which bays. This is useful for modeling chassis hardware where not every bay accepts every type of line card.
When **both** a module bay and the module type being installed have at least one bay type assigned, NetBox will check for a non-empty intersection. If the two sets share no bay types in common, the installation will be rejected as incompatible.
If either the bay or the module type has **no bay types assigned**, the constraint is not applied and any module type may be installed — this preserves backwards compatibility with existing data.
!!! tip
Bay types function as an allow-list: assign the same type to a bay and to the module types that fit it, and leave the type unset on bays or module types where no restriction is needed.
!!! note "GraphQL naming"
In the GraphQL API, the type for the `ModuleBay` *component* is named `ModuleBayType` (following the project's `<Model>Type` suffix convention), while the type for the `ModuleBayType` *model* is named `ModuleBayTypeType`. This is an unavoidable consequence of the naming convention colliding with this model's name.
## Fields
### Name
A unique human-readable name for the bay type (e.g. `LC Line Card`, `Power Supply`, `Fan Tray`).
### Slug
A URL-friendly identifier derived from the name.
### Manufacturer
An optional [manufacturer](./manufacturer.md) associated with this bay type. Useful when a vendor uses proprietary slot designations.
### Description
A brief description of the bay type.
### Comments
Free-form Markdown-supported notes.

View File

@ -75,10 +75,22 @@ The numeric weight of the module, including a unit designation (e.g. 3 kilograms
The direction in which air circulates through the device chassis for cooling.
### Cooling Method
The cooling method employed by modules of this type (air, liquid, hybrid, or immersion). This is useful for liquid-cooled modules such as direct-to-chip accelerator (OAM) modules or liquid-cooled line cards.
### End of Life
The date after which this module type is no longer supported by its manufacturer. This can be used to identify modules approaching or past their support horizon to aid in hardware lifecycle planning.
### Profile
The assigned [profile](./moduletypeprofile.md) for the type of module. Profiles can be used to classify module types by function (e.g. power supply, hard disk, etc.), and they support the addition of user-configurable attributes on module types. The assignment of a module type to a profile is optional.
### Bay Types
Zero or more [module bay types](./modulebaytype.md) that this module type is compatible with. When at least one bay type is set, the module type may only be installed into bays that share a common type. Leave empty to allow installation into any bay.
### Attributes
Depending on the module type's assigned [profile](./moduletypeprofile.md) (if any), one or more user-defined attributes may be available to configure.

View File

@ -28,6 +28,9 @@ The rack's name or identifier. Must be unique to the rack's location, if assigne
The [physical type](./racktype.md) of this rack. The rack type defines physical attributes such as height and weight.
!!! warning "Rack type assignment will become mandatory"
Beginning in NetBox v5.0, the assignment of a rack type will be required, and several physical attributes will be inferred from it rather than being set directly on the rack. See the note under [Physical Attributes](#physical-attributes) below.
### Status
Operational status.
@ -51,5 +54,32 @@ The unique physical serial number assigned to this rack.
A unique, locally-administered label used to identify hardware resources.
!!! note
Some additional fields pertaining to physical attributes such as height and weight can also be defined on each rack, but should generally be defined instead on the [rack type](./racktype.md).
### Cooling Capability
Describes how the rack is able to cool the equipment installed in it, which indicates what kind of equipment it can accommodate:
- **Air-only**: The rack is cooled by airflow only; no coolant is delivered to it. Only air-cooled equipment can be installed.
- **Hybrid**: Coolant can be delivered to the rack (e.g. via a [cooling feed](./coolingfeed.md)), but it can also house air-cooled equipment. Suitable for mixed or hybrid deployments.
- **Liquid-only**: The rack is intended exclusively for liquid-cooled equipment (such as direct-to-chip or immersion systems) and does not provide adequate air cooling on its own.
This attribute documents the rack's intended use so that incompatible equipment—such as high-density liquid-cooled hardware in an air-only rack—can be identified. When the rack is assigned a [rack type](./racktype.md), this value is inherited from the rack type.
### Cooling Capacity
The rack's cooling capacity, expressed in kilowatts (kW). When the rack is assigned a [rack type](./racktype.md), this value is inherited from the rack type.
## Physical Attributes
Several physical attributes may be defined on each rack, including its width, height, outer dimensions, mounting depth, and weight. These should generally be defined on the [rack type](./racktype.md) assigned to the rack rather than on the rack itself.
!!! warning "Some rack fields are deprecated"
The following fields have been **deprecated** on the rack model and are planned for removal in NetBox v5.0:
* Form factor
* Width
* Outer width
* Outer height
* Outer depth
* Outer unit
In a future release, the values for these attributes will be inferred from the rack's assigned [rack type](./racktype.md), which will become a mandatory assignment. Users are strongly encouraged to define these attributes on a rack type and assign it to each rack. (Note that the U height, starting unit, descending units, and mounting depth fields will be retained on the rack model, as these may legitimately vary among individual racks of the same type.)

View File

@ -54,6 +54,14 @@ The numeric weight of the rack, including a unit designation (e.g. 10 kilograms
The maximum total weight capacity for all installed devices, inclusive of the rack itself.
### Cooling Capability
The rack design's coolant capability: air-only, hybrid, or liquid-only. Racks of this type inherit this value.
### Cooling Capacity
The rack design's cooling capacity, expressed in kilowatts (kW). Racks of this type inherit this value.
### Descending Units
If selected, the rack's elevation will display unit 1 at the top of the rack. (Most racks use ascending numbering, with unit 1 assigned to the bottommost position.)

View File

@ -109,6 +109,10 @@ Choice sets may optionally define colors for individual values. Colored choices
If enabled, values from this field will be automatically pre-populated when cloning existing objects.
### Nulls First
When ordering objects by this custom field, controls whether objects with no value (null) are sorted before or after objects that have a value. This option is enabled by default.
### Minimum Value
For numeric custom fields only. The minimum valid value (optional).

View File

@ -47,6 +47,11 @@ The type of action to take when the rule triggers. This must be one of the follo
* Custom script
* Notification
!!! tip "Custom Action Types"
The above list includes only built-in action types. NetBox plugins can also [register their own custom action types](../../plugins/development/event-rule-actions.md).
If the plugin providing an event rule's action type is uninstalled or disabled, the event rule is not deleted, but it is marked as unavailable and will not run. It also cannot be saved -- even to edit an unrelated field -- until either the plugin is reinstalled or the action type is changed to a currently-available one.
### Action Data
An optional dictionary of JSON data to pass when executing the rule. This can be useful to include additional context data, e.g. when transmitting a webhook.

View File

@ -87,6 +87,17 @@ Controls whether validation of the receiver's SSL certificate is enforced when H
The file path to a particular certificate authority (CA) file to use when validating the receiver's SSL certificate (if not using the system defaults).
### Timeout
The maximum time (in seconds) to wait for a response from the receiver before the request is considered failed. If left blank, the global [`WEBHOOK_DEFAULT_TIMEOUT`](../../configuration/miscellaneous.md#webhook_default_timeout) configuration value is used.
The timeout must be less than [`RQ_DEFAULT_TIMEOUT`](../../configuration/miscellaneous.md#rq_default_timeout) (300 seconds by default), and NetBox will refuse to save a webhook which violates this. The background job timeout is a hard ceiling on how long a webhook request can run, so a value at or above it leaves no room for the request's own timeout to apply.
!!! note
Staying below the job timeout makes it *likely*, but does not guarantee, that the request times out on its own. The timeout is applied separately to establishing the connection and to waiting for data, rather than to the request as a whole, so a receiver which stalls at both stages — or which responds slowly but continuously — can still outlast the job timeout and be terminated by the worker instead.
When a request does time out, the failure is recorded by the `netbox.webhooks` logger and the background job is marked as failed.
## Context Data
The following context variables are available to the text and link templates.
@ -96,10 +107,9 @@ The following context variables are available to the text and link templates.
| `event` | The event type (`create`, `update`, or `delete`) |
| `timestamp` | The time at which the event occurred |
| `object_type` | The type of object impacted (`app_label.model_name`) |
| `username` | The name of the user associated with the change |
| `request_id` | The unique request ID |
| `data` | A complete serialized representation of the object |
| `snapshots` | Pre- and post-change snapshots of the object |
| `request` | Data about the triggering request (if available) |
!!! warning "Deprecation of legacy fields"
The `request_id` and `username` fields in the webhook payload above are deprecated and should no longer be used. Support for them will be removed in NetBox v4.7.0. Use `request.user` and `request.id` from the `request` object included in the callback context instead. (Note that `request` is populated in the context only when the webhook is associated with a triggering request.)
!!! note
The `request` variable is populated in the context only when the webhook is associated with a triggering request. It exposes `request.id` (the unique request ID) and `request.user` (the name of the user associated with the change), among other attributes.

View File

@ -23,14 +23,62 @@ The parent object to which the application service is assigned. This must be one
A service or protocol name.
### Protocol
### Port Mappings
The wire protocol on which the service runs. Choices include UDP, TCP, and SCTP.
The protocols and ports on which the service runs. A service may expose the same port on multiple protocols — for example, DNS listening on both `tcp/53` and `udp/53`. In the UI, ports for a given protocol may be entered together using commas and/or hyphens (e.g. `80,8001-8003`).
### Ports
In the REST and GraphQL APIs, port mappings are represented as a flat list of `protocol/port` strings — matching how they are stored:
One or more numeric ports to which the service is bound. Multiple ports can be expressed using commas and/or hyphens. For example, `80,8001-8003` specifies ports 80, 8001, 8002, and 8003.
```json
[
"tcp/80",
"tcp/443",
"udp/53"
]
```
!!! note "Changed in NetBox v4.7"
The single-protocol `protocol` and `ports` fields have been replaced by the unified `port_mappings` field, which supports multiple protocols per service. For backward compatibility, the REST and GraphQL APIs still expose the legacy `protocol` and `ports` fields, and the REST API still accepts them on write as an alternative to `port_mappings`. They are populated for single-protocol services; a service with multiple protocols cannot be represented in the legacy format and returns `null` for both, while a service with no mappings returns `protocol: null` and `ports: []`. In other words, `ports: null` specifically signals "multiple protocols — read `port_mappings` instead." **These legacy fields are deprecated and will be removed in NetBox v5.0; use `port_mappings` instead.**
On write, `port_mappings` and the legacy `protocol`/`ports` fields may be submitted together only when they agree — as in a full-object round-trip that echoes back a read. A request whose legacy fields contradict `port_mappings` (for example, an edited `port_mappings` sent alongside the stale `protocol`/`ports` from the original read) is rejected as ambiguous; send `port_mappings` alone, or keep the legacy fields consistent with it.
At the ORM level (custom scripts and plugins), `protocol` and `ports` are now **read-only** properties derived from `port_mappings`. Assign `port_mappings` directly — e.g. `Service(parent=device, name='http', port_mappings=['tcp/80'])` — since passing `protocol=`/`ports=` to the model raises `TypeError` and setting `service.ports = [...]` raises `AttributeError`.
### Filtering by Port Mapping, Protocol, and Port
`port_mappings`, `protocol`, and `port` are all filtered against the `port_mappings` array. Each accepts multiple values (matching any of them), and `port` supports the usual numeric lookups:
| Parameter | Matches services having a mapping… |
|---|---|
| `?port_mappings=tcp/80` | that is exactly `tcp/80` |
| `?port_mappings__n=tcp/80` | *(negated)* that is exactly `tcp/80` |
| `?protocol=tcp` | whose protocol is TCP |
| `?protocol__n=tcp` | *(negated)* whose protocol is TCP |
| `?port=80` | whose port is 80 |
| `?port__n=80` | *(negated)* whose port is 80 |
| `?port__gt=` / `?port__gte=` / `?port__lt=` / `?port__lte=` | whose port is above/below the given value |
`port_mappings` is the most direct way to ask "which services expose this exact protocol and port?" — `?port_mappings=tcp/80` will not match a service that exposes only `udp/80`. Protocols may be given in any case, and leading zeros are ignored, so `?port_mappings=TCP/080` finds `tcp/80`. A value naming an unknown protocol or a malformed pair simply matches nothing rather than returning an error.
When `protocol` and one or more `port` lookups are combined, they must all be satisfied by a **single** mapping. So `?protocol=tcp&port__gt=1000` does not match a service whose only TCP mapping is `tcp/80` (even if it also exposes `udp/9999`), and `?port__gte=1000&port__lte=2000` does not match a service exposing only ports 500 and 5000. Each `port_mappings` value already names one complete pair, so it needs no such correlation and is simply combined with the other parameters.
All of these parameters are available as GraphQL filters too, under the same names — `port_mappings`, `protocol`, `port`, `port__gt`, `port__gte`, `port__lt`, `port__lte` — each accepting a list of values. For example, `filters: {port_mappings: ["tcp/80"]}` or `filters: {protocol: [TCP], port__gt: [1000]}`. The single-mapping correlation rule described above applies identically.
!!! warning "GraphQL filter change in NetBox v4.7"
The GraphQL filters for `Service` and `ServiceTemplate` have changed shape. The former `protocol` lookup and `ports` integer lookup (which nested their comparisons, e.g. `ports: {gt: 1000}`) are replaced by the flat `protocol`, `port`, `port__gt`, `port__gte`, `port__lt`, `port__lte`, and `port_mappings` parameters, each accepting a list of values and spelled the same way as the corresponding REST query parameter. Rewrite `ports: {gt: 1000}` as `port__gt: [1000]`, and `ports: {exact: 80}` as `port: [80]`. The `range` and `i_exact` lookups previously offered by the integer lookup have no direct equivalent; express a range as `port__gte`/`port__lte`, which — unlike the old lookup — requires a single mapping to satisfy both bounds.
The members of the `ServiceProtocolEnum` used by the `protocol` filter have also been renamed to drop a spurious `ROLE_` prefix: `ROLE_TCP`, `ROLE_UDP`, and `ROLE_SCTP` are now `TCP`, `UDP`, and `SCTP`.
!!! warning "REST filter change in NetBox v4.7"
Because `protocol` is now filtered against the `port_mappings` array rather than a dedicated model field, the character-based lookup variants previously auto-generated for it — `protocol__ic`, `protocol__nic`, `protocol__isw`, `protocol__empty`, etc. — are no longer available; `protocol` and `protocol__n` remain. The `port__empty` lookup is likewise gone, as a service always has at least one port mapping. As with any unrecognized query parameter, the REST API silently ignores a removed lookup rather than raising an error, so update any saved filters or scripts that relied on them.
### IP Addresses
The [IP address(es)](./ipaddress.md) to which this service is bound. If no IP addresses are bound, the service is assumed to be reachable via any assigned IP address.
## Bulk Import (CSV)
When importing application services or [application service templates](./servicetemplate.md) via CSV, all port mappings for a row are given in a single `port_mappings` column as a comma-separated list of `protocol/port` pairs enclosed in double quotes. For example, `"tcp/80,tcp/443,udp/53"`. Protocols may be specified in any case.

View File

@ -12,10 +12,10 @@ Application service templates can be used to instantiate [application services](
A service or protocol name.
### Protocol
### Port Mappings
The wire protocol on which the service runs. Choices include UDP, TCP, and SCTP.
The protocols and ports on which the service runs. See [Port Mappings](./service.md#port-mappings) on the application service model for details.
### Ports
## Bulk Import (CSV)
One or more numeric ports to which the service is bound. Multiple ports can be expressed using commas and/or hyphens. For example, `80,8001-8003` specifies ports 80, 8001, 8002, and 8003.
Application service templates are imported via CSV using the same `port_mappings` column format as application services. See [Bulk Import (CSV)](./service.md#bulk-import-csv) on the application service model for details.

View File

@ -0,0 +1,115 @@
# Jinja Config Templates
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.
---
## Registering Jinja Filters
### Via `jinja_env.py` (auto-discovery)
Create a file named `jinja_env.py` in your plugin root and expose a dict called `filters`. NetBox will auto-discover and register it when the plugin loads.
```python title="my_plugin/jinja_env.py"
def prefix_list(device):
"""Return all prefixes assigned to a device's interfaces."""
return [
str(ip.address)
for iface in device.interfaces.all()
for ip in iface.ip_addresses.all()
]
filters = {
'prefix_list': prefix_list,
}
```
The filter is then available in any config template:
```jinja2
{% for prefix in device | prefix_list %}
network {{ prefix }}
{% endfor %}
```
### Via `register_jinja_filters()`
You can also register filters programmatically inside your plugin's `ready()` method:
```python title="my_plugin/__init__.py"
from netbox.plugins import PluginConfig
class MyPluginConfig(PluginConfig):
name = 'my_plugin'
# ...
def ready(self):
super().ready()
from netbox.plugins.registration import register_jinja_filters
from .jinja_env import filters
register_jinja_filters(filters)
```
`register_jinja_filters()` accepts a `dict` mapping filter names to callables. It raises `TypeError` if passed a non-dict or if any value is not callable.
### Precedence
The full filter precedence from lowest to highest is: **NetBox built-in filters** (e.g. `env`) → **plugin-registered filters****instance [`JINJA_FILTERS`](../../configuration/system.md#jinja_filters)**. Instance-level filters always win, so site admins can override anything without touching a plugin.
If two plugins register a filter with the same name, the later-loaded plugin's version wins and NetBox will log a warning.
For example, if `my_plugin` registers a `prefix_list` filter but a site needs different behaviour, the operator can replace it in `configuration.py` without touching the plugin:
```python title="configuration.py"
def prefix_list(device):
# Site-local override: include only loopback prefixes
return [
str(ip.address)
for iface in device.interfaces.filter(type='loopback')
for ip in iface.ip_addresses.all()
]
JINJA_FILTERS = {
'prefix_list': prefix_list,
}
```
---
## Injecting Context Variables
Override `get_jinja_context()` in your `PluginConfig` subclass to inject additional variables into every config template render context.
```python title="my_plugin/__init__.py"
from netbox.plugins import PluginConfig
class MyPluginConfig(PluginConfig):
name = 'my_plugin'
# ...
def get_jinja_context(self):
from .utils import MyNamespace
return {
'my_plugin': MyNamespace(),
}
```
The returned dict is merged into the template context, so `my_plugin` becomes available by name inside every config template:
```jinja2
{% set records = my_plugin.lookup(device.name) %}
```
!!! warning "Startup cost"
`get_jinja_context()` is called on **every** config template render, not once at startup. Keep it fast. Defer expensive lookups to the object you return rather than performing them in `get_jinja_context()` itself.
!!! note "Conflict avoidance"
Choose context variable names that are unlikely to collide with NetBox's built-in template variables (`device`, `queryset`, etc.) or with those contributed by other plugins. Prefixing with your plugin name is strongly recommended.
In addition, avoid top-level app-label names (`dcim`, `ipam`, `virtualization`, etc.). The auto-populated template context maps each app label to a dict of its public model classes; returning a key like `'dcim'` from `get_jinja_context()` will silently replace that entire namespace.
!!! note "No per-render context"
`get_jinja_context()` receives no arguments — it has no access to the object being rendered or the caller-supplied context. It is intended for plugin-global namespaces (e.g. a lazily-evaluated query helper). Per-object logic belongs in the template itself or in a custom filter.

View File

@ -0,0 +1,62 @@
# Event Rule Actions
[Event rules](../../models/extras/eventrule.md) dispatch to an *action* when a matching event occurs, such as sending a webhook request or running a script. Plugins can register their own action types to extend the list of actions an event rule can perform, by subclassing NetBox's `EventRuleAction` class.
```python title="event_rules.py"
from django.utils.translation import gettext_lazy as _
from netbox.event_rules import EventRuleAction
from .models import Ticket
class OpenTicketAction(EventRuleAction):
slug = 'my_plugin.open_ticket'
label = _('Open ticket')
description = _('Open a ticket in the external ticketing system')
object_model = Ticket
object_required = True
def enqueue(self, *, event_rule, event_context, action_object, action_data):
...
```
To register one or more event rule actions with NetBox, define a list named `event_rule_actions` at the end of this file:
```python title="event_rules.py"
event_rule_actions = [OpenTicketAction]
```
!!! tip
The path to the list of event rule actions can be modified by setting `event_rule_actions` in the PluginConfig instance.
A dotted namespace prefix (e.g. `my_plugin.open_ticket`) is strongly recommended for `slug` to avoid collisions with other plugins or with action types added to NetBox core in the future.
`slug` must begin with a lowercase letter, and may contain only letters, digits, underscores, and dot-separated segments thereafter. **Hyphens are not allowed**, even though they're common in plugin/package names -- use an underscore instead, e.g. `my_plugin.open_ticket` as in the example above. `register_event_rule_action()` raises `ImproperlyConfigured` immediately for a slug outside this pattern, rather than allowing it to fail later during GraphQL schema assembly.
`slug`/`label` are only required at registration time, not at class definition, so an intermediate base class shared by several concrete actions may leave them unset.
!!! warning "Actions must be stateless"
Registration instantiates the class once, and that single instance serves every event rule, request, and background worker thread for the lifetime of the process. Do not stash per-event data on `self` in `enqueue()` or `validate()` -- concurrent dispatches would race over it. Everything an action needs is passed in as an argument.
## Target Objects
If an action operates against a specific object (e.g. a webhook targets a `Webhook` instance, and a script targets a `Script` instance), set `object_model` to the relevant model class. NetBox uses this to render the object-selection field on the event rule form and to validate the selected object's type. `object_required` defaults to `False` (matching `object_model`'s default of `None`); set it to `True` alongside `object_model` if the target object must always be selected. (Setting `object_required` *without* an `object_model` raises `ImproperlyConfigured` at registration, as it could never be satisfied.) Override `get_object_queryset()` to customize which objects are eligible for selection (e.g. to filter or further restrict the queryset).
The object-selection field is labeled with `object_model`'s verbose name; set `object_label` to override it.
If an action leaves `object_model` as `None`, event rules using it must not specify a target object: supplying one is rejected as a validation error rather than being silently stored.
## Bulk Import
To support resolving a target object from a CSV value during bulk import of event rules, override `resolve_import_object()`. Raise `django.core.exceptions.ObjectDoesNotExist` (or a subclass) if the supplied value doesn't resolve to an object. If this method is not overridden, event rules using this action type cannot be targeted at an object via bulk import.
## Unregistered Actions
An event rule's `action_type` is stored as a plain string, and is not validated against the set of currently-registered actions at the database level. This means an event rule can reference an action type provided by a plugin that is later uninstalled or disabled, without the row being deleted or corrupted. While its action type is unavailable:
* The event rule is skipped during event processing (it does not raise an error, and does not prevent other event rules from being processed).
* It is displayed with an "unavailable" indicator in the UI. `action_is_available` is exposed as a read-only field via the REST API, and as a filter (`?action_is_available=false`), so affected event rules can be found in bulk.
* It cannot be saved via the UI or REST API -- even to edit an unrelated field -- until its `action_type` is changed to a currently-registered value.
Reinstalling the plugin (and thereby re-registering the action type) automatically restores the event rule to working order, with no need to re-save it.
::: netbox.event_rules.EventRuleAction

View File

@ -210,6 +210,35 @@ In addition to the [form fields provided by Django](https://docs.djangoproject.c
options:
members: false
## Static Choice Fields
These fields render a standard HTML `<select>` element (as opposed to the API-backed widgets used by the dynamic object fields below). They extend Django's built-in choice fields to optionally display a short **description** beneath each option's label.
For choice set-backed fields, descriptions are defined per choice using a `Choice` object in the `ChoiceSet` and are rendered automatically. Pass `show_descriptions=False` to suppress them for a particular field.
```python
from utilities.choices import Choice, ChoiceSet
from utilities.forms.fields import ChoiceField
class StatusChoices(ChoiceSet):
ACTIVE = 'active'
RETIRED = 'retired'
CHOICES = (
Choice(ACTIVE, 'Active', description='Currently in service'),
Choice(RETIRED, 'Retired', description='No longer in service'),
)
status = ChoiceField(choices=StatusChoices)
```
::: utilities.forms.fields.ChoiceField
options:
members: false
::: utilities.forms.fields.MultipleChoiceField
options:
members: false
## Dynamic Object Fields
::: utilities.forms.fields.DynamicModelChoiceField
@ -230,6 +259,18 @@ In addition to the [form fields provided by Django](https://docs.djangoproject.c
options:
members: false
## Generic Object Fields
`GenericObjectChoiceField` represents a generic foreign key (a `content_type` plus `object_id` pair) as a single, REST API-backed form field. Pair it with `GenericObjectFormMixin` on the form to seed the field's initial value from the model's GFK descriptor and assign the selected object back to it automatically.
::: utilities.forms.fields.GenericObjectChoiceField
options:
members: false
::: utilities.forms.mixins.GenericObjectFormMixin
options:
members: false
## CSV Import Fields
::: utilities.forms.fields.CSVChoiceField

View File

@ -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.

View File

@ -118,10 +118,14 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i
| `events_pipeline` | A list of handlers to add to [`EVENTS_PIPELINE`](../../configuration/miscellaneous.md#events_pipeline), identified by dotted paths |
| `search_indexes` | The dotted path to the list of search index classes (default: `search.indexes`) |
| `data_backends` | The dotted path to the list of data source backend classes (default: `data_backends.backends`) |
| `event_rule_actions` | The dotted path to the list of event rule action classes (default: `event_rules.event_rule_actions`) |
| `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`) |
| `jinja_filters` | The dotted path to a dict of custom Jinja filter functions for use in config templates (default: `jinja_env.filters`) |
| `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.

View File

@ -53,6 +53,73 @@ class MyView(generic.ObjectView):
::: netbox.ui.layout.Column
## Breadcrumbs
Breadcrumbs are rendered at the top of an object's page to convey its position within a hierarchy and to provide quick navigation to related objects. By default, a single breadcrumb linking to the object's list view is shown. To add object-specific breadcrumbs, pass a list of `Breadcrumb` instances to your layout, just as you would its panels.
A `Breadcrumb` typically references an _accessor_ (rather than a static value), which is resolved against the object being viewed when the page is rendered. The accessor may be a dotted attribute path or a callable. (A breadcrumb may instead define a static `label`; see below.)
```python
from netbox.ui import layout
from netbox.ui.breadcrumbs import Breadcrumb
from netbox.views import generic
class MyView(generic.ObjectView):
layout = layout.SimpleLayout(
breadcrumbs=[
Breadcrumb('site'),
Breadcrumb('location'),
Breadcrumb('rack'),
],
left_panels=[...],
right_panels=[...],
)
```
Each breadcrumb renders as a label (the string representation of the resolved object) and an optional link. If no explicit `url` is provided, the object's `get_absolute_url()` is used when available. A breadcrumb whose accessor resolves to `None` (or an empty iterable) renders as an empty string and is omitted, which simplifies conditional breadcrumbs (e.g. where a device may or may not be assigned to a rack).
To link a breadcrumb somewhere other than the related object's own page (for example, to a filtered list view), pass a `url`. A callable `url` receives the resolved object:
```python
from django.urls import reverse
Breadcrumb('rir', url=lambda rir: f"{reverse('ipam:asn_list')}?rir_id={rir.pk}")
```
A callable accessor which returns an iterable renders one breadcrumb per object, which is useful for representing a hierarchy of ancestors:
```python
Breadcrumb(lambda obj: obj.get_ancestors())
```
To render a breadcrumb that isn't tied to a related object, omit the accessor and pass a `label`. This is useful for linking to a parent view that isn't reachable via an attribute on the object (e.g. a user's personal token list):
```python
from django.urls import reverse_lazy
Breadcrumb(label=_('My API Tokens'), url=reverse_lazy('account:usertoken_list'))
```
The `label` may also be a callable, which receives the relevant object (the resolved related object when an accessor is given, otherwise the viewed instance). This is useful for an unlinked descriptive crumb derived from the object:
```python
Breadcrumb(label=lambda obj: f"{_('Units')} {obj.unit_list}")
```
The default root breadcrumb (linking to the object's list view) is prepended to the trail automatically. Where that list view isn't an appropriate root—for example, the global token list is admin-only, so a user's personal token page links to their own token list instead—pass `root_breadcrumb=False` to the layout and supply a replacement as the first breadcrumb:
```python
SimpleLayout(
root_breadcrumb=False,
breadcrumbs=[
Breadcrumb(label=_('My API Tokens'), url=reverse_lazy('account:usertoken_list')),
],
...
)
```
::: netbox.ui.breadcrumbs.Breadcrumb
## Panels
Within each column, related blocks of content are arranged into panels. Each panel has a title and may have a set of associated actions, but the content within is otherwise arbitrary.

View File

@ -29,8 +29,6 @@ The resulting webhook payload will look like the following:
"event": "updated",
"timestamp": "2025-08-07T14:24:30.627321+00:00",
"object_type": "dcim.site",
"username": "admin",
"request_id": "49e3e39e-7333-4b9c-a9af-19f0dc1e7dc9",
"data": {
"id": 2,
"url": "/api/dcim/sites/2/",
@ -44,11 +42,6 @@ The resulting webhook payload will look like the following:
}
```
!!! warning "Deprecation of legacy keys"
The `request_id` and `username` keys in the webhook payload above are deprecated and should no longer be used. Support for them will be removed in NetBox v4.7.0.
Use `request.user` and `request.id` from the `request` object included in the callback context instead.
!!! note "Consider namespacing webhook data"
The data returned from all webhook callbacks will be compiled into a single `context` dictionary. Any existing keys within this dictionary will be overwritten by subsequent callbacks which include those keys. To avoid collisions with webhook data provided by other plugins, consider namespacing your plugin's data within a nested dictionary as such:

View File

@ -9,7 +9,7 @@ A condition is expressed as a JSON object with the following keys:
| Key name | Required | Default | Description |
|----------|----------|---------|-------------|
| attr | Yes | - | Name of the key within the data being evaluated |
| value | Yes | - | The reference value to which the given data will be compared |
| value | See note | - | The reference value to which the given data will be compared. Not used by snapshot operators (`changed`, `unchanged`). |
| op | No | `eq` | The logical operation to be performed |
| negate | No | False | Negate (invert) the result of the condition's evaluation |
@ -22,6 +22,9 @@ A condition is expressed as a JSON object with the following keys:
* `lte`: Less than or equal to
* `in`: Is present within a list of values
* `contains`: Contains the specified value
* `regex`: Matches a regular expression
* `changed`: The attribute's value differs between the pre-change and post-change snapshots (no `value` required)
* `unchanged`: The attribute's value is the same in both snapshots (no `value` required)
### Accessing Nested Keys
@ -91,6 +94,59 @@ The following condition will evaluate as true:
!!! note "Evaluating static choice fields"
Pay close attention when evaluating static choice fields, such as the `status` field above. These fields typically render as a dictionary specifying both the field's raw value (`value`) and its human-friendly label (`label`). Be sure to specify on which of these you want to match.
## Snapshot Conditions (Event Rules)
When used in an [event rule](../features/event-rules.md), conditions can also inspect the **pre-change and post-change snapshots** captured at the time of the event. This allows rules to fire only when a specific field actually changes value, rather than whenever it has a particular value.
### Snapshot Operators
The `changed` and `unchanged` operators compare an attribute's value across the two snapshots. They do not accept a `value` key.
Fire only when `status` changes (to any value):
```json
{
"attr": "status",
"op": "changed"
}
```
### Combining with Standard Conditions
The canonical use case — fire only when `status` changes **to** `active` — combines a standard value check with the `changed` operator:
```json
{
"and": [
{
"attr": "status.value",
"value": "active"
},
{
"attr": "status",
"op": "changed"
}
]
}
```
### Direct Snapshot Path Access
You can also read pre- or post-change values directly using the `snapshots.prechange.<attr>` and `snapshots.postchange.<attr>` dot-path syntax with any standard operator:
```json
{
"attr": "snapshots.prechange.status",
"value": "planned"
}
```
!!! warning "Snapshot serialization format"
Snapshot data uses the **model serializer format**, not the REST API format. Choice fields such as `status` are stored as raw strings (e.g. `"active"`) rather than nested objects (e.g. `{"value": "active", "label": "Active"}`). Use `attr: "snapshots.prechange.status"` — not `"snapshots.prechange.status.value"` — when referencing snapshot attributes. The `changed`/`unchanged` operators compare the same format on both sides, so they are not affected by this distinction.
!!! note "Snapshot availability"
Snapshots are only populated for update and delete events. For create events, `prechange` is `null` — conditions using the `changed` operator on a create event evaluate to `true` (the field transitioned from non-existent to its initial value), while conditions using `snapshots.prechange.*` paths evaluate to `false`. For delete events, `postchange` is `null` — the `changed` operator evaluates to `true` for any attribute present in the prechange snapshot, and `unchanged` evaluates to `false`.
## Condition Sets
Multiple conditions can be combined into nested sets using AND or OR logic. This is done by declaring a JSON object with a single key (`and` or `or`) containing a list of condition objects and/or child condition sets.

View File

@ -71,6 +71,7 @@ nav:
- Facilities: 'features/facilities.md'
- Devices & Cabling: 'features/devices-cabling.md'
- Power Tracking: 'features/power-tracking.md'
- Cooling: 'features/cooling.md'
- IPAM: 'features/ipam.md'
- VLAN Management: 'features/vlan-management.md'
- L2VPN & Overlay: 'features/l2vpn-overlay.md'
@ -98,7 +99,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'
@ -147,11 +149,13 @@ nav:
- UI Components: 'plugins/development/ui-components.md'
- Navigation: 'plugins/development/navigation.md'
- Templates: 'plugins/development/templates.md'
- Config Templates: 'plugins/development/config-templates.md'
- Tables: 'plugins/development/tables.md'
- Forms: 'plugins/development/forms.md'
- Filters & Filter Sets: 'plugins/development/filtersets.md'
- Search: 'plugins/development/search.md'
- Event Types: 'plugins/development/event-types.md'
- Event Rule Actions: 'plugins/development/event-rule-actions.md'
- Permissions: 'plugins/development/permissions.md'
- Data Backends: 'plugins/development/data-backends.md'
- Webhooks: 'plugins/development/webhooks.md'
@ -199,6 +203,12 @@ nav:
- ConsolePortTemplate: 'models/dcim/consoleporttemplate.md'
- ConsoleServerPort: 'models/dcim/consoleserverport.md'
- ConsoleServerPortTemplate: 'models/dcim/consoleserverporttemplate.md'
- CoolingFeed: 'models/dcim/coolingfeed.md'
- CoolingIntake: 'models/dcim/coolingintake.md'
- CoolingIntakeTemplate: 'models/dcim/coolingintaketemplate.md'
- CoolingOutflow: 'models/dcim/coolingoutflow.md'
- CoolingOutflowTemplate: 'models/dcim/coolingoutflowtemplate.md'
- CoolingSource: 'models/dcim/coolingsource.md'
- Device: 'models/dcim/device.md'
- DeviceBay: 'models/dcim/devicebay.md'
- DeviceBayTemplate: 'models/dcim/devicebaytemplate.md'
@ -217,6 +227,7 @@ nav:
- Module: 'models/dcim/module.md'
- ModuleBay: 'models/dcim/modulebay.md'
- ModuleBayTemplate: 'models/dcim/modulebaytemplate.md'
- ModuleBayType: 'models/dcim/modulebaytype.md'
- ModuleType: 'models/dcim/moduletype.md'
- ModuleTypeProfile: 'models/dcim/moduletypeprofile.md'
- Platform: 'models/dcim/platform.md'

View File

@ -11,7 +11,7 @@ from django.contrib.auth.models import update_last_login
from django.contrib.auth.signals import user_logged_in
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect, render, resolve_url
from django.urls import reverse
from django.urls import reverse, reverse_lazy
from django.utils.decorators import method_decorator
from django.utils.http import urlencode
from django.utils.translation import gettext_lazy as _
@ -27,6 +27,7 @@ from extras.tables import BookmarkTable, NotificationTable, SubscriptionTable
from netbox.authentication import get_auth_backend_display, get_saml_idps
from netbox.config import get_config
from netbox.ui import layout
from netbox.ui.breadcrumbs import Breadcrumb
from netbox.views import generic
from users import forms
from users.models import UserConfig
@ -345,6 +346,12 @@ class UserTokenListView(LoginRequiredMixin, View):
@register_model_view(UserToken)
class UserTokenView(LoginRequiredMixin, View):
layout = layout.SimpleLayout(
# The global UserToken list view is admin-only, so substitute the user's personal token list
# for the default root breadcrumb.
root_breadcrumb=False,
breadcrumbs=[
Breadcrumb(label=_('My API Tokens'), url=reverse_lazy('account:usertoken_list')),
],
left_panels=[
TokenPanel(),
],
@ -362,7 +369,7 @@ class UserTokenView(LoginRequiredMixin, View):
plaintext = request.session.pop(f'_token_plaintext_{token.pk}', None)
token_auth_string = f'{token.get_auth_header_prefix()}{plaintext}' if plaintext else None
return render(request, 'account/token.html', {
return render(request, 'users/token.html', {
'object': token,
'layout': self.layout,
'token_auth_string': token_auth_string,

View File

@ -1,7 +1,5 @@
from django.apps import AppConfig
from netbox import denormalized
class CircuitsConfig(AppConfig):
name = "circuits"
@ -11,16 +9,6 @@ class CircuitsConfig(AppConfig):
from netbox.models.features import register_models
from . import search, signals # noqa: F401
from .models import CircuitTermination
# Register models
register_models(*self.get_models())
denormalized.register(CircuitTermination, '_site', {
'_region': 'region',
'_site_group': 'group',
})
denormalized.register(CircuitTermination, '_location', {
'_site': 'site',
})

View File

@ -1,6 +1,6 @@
from django.utils.translation import gettext_lazy as _
from utilities.choices import ChoiceSet
from utilities.choices import Choice, ChoiceSet
#
# Circuits
@ -18,12 +18,18 @@ class CircuitStatusChoices(ChoiceSet):
STATUS_DECOMMISSIONED = 'decommissioned'
CHOICES = [
(STATUS_PLANNED, _('Planned'), 'cyan'),
(STATUS_PROVISIONING, _('Provisioning'), 'blue'),
(STATUS_ACTIVE, _('Active'), 'green'),
(STATUS_OFFLINE, _('Offline'), 'red'),
(STATUS_DEPROVISIONING, _('Deprovisioning'), 'yellow'),
(STATUS_DECOMMISSIONED, _('Decommissioned'), 'gray'),
Choice(
STATUS_PLANNED, _('Planned'), color='cyan',
description=_('Designated for future use but not yet installed')
),
Choice(STATUS_PROVISIONING, _('Provisioning'), color='blue', description=_('Being configured for service')),
Choice(STATUS_ACTIVE, _('Active'), color='green', description=_('Fully operational and in service')),
Choice(STATUS_OFFLINE, _('Offline'), color='red', description=_('Installed but not currently in service')),
Choice(STATUS_DEPROVISIONING, _('Deprovisioning'), color='yellow', description=_('Being removed from service')),
Choice(
STATUS_DECOMMISSIONED, _('Decommissioned'), color='gray',
description=_('Retired and no longer in service')
),
]
@ -31,17 +37,17 @@ class CircuitCommitRateChoices(ChoiceSet):
key = 'Circuit.commit_rate'
CHOICES = [
(10000, '10 Mbps'),
(100000, '100 Mbps'),
(1000000, '1 Gbps'),
(10000000, '10 Gbps'),
(25000000, '25 Gbps'),
(40000000, '40 Gbps'),
(100000000, '100 Gbps'),
(200000000, '200 Gbps'),
(400000000, '400 Gbps'),
(1544, 'T1 (1.544 Mbps)'),
(2048, 'E1 (2.048 Mbps)'),
Choice(10000, '10 Mbps'),
Choice(100000, '100 Mbps'),
Choice(1000000, '1 Gbps'),
Choice(10000000, '10 Gbps'),
Choice(25000000, '25 Gbps'),
Choice(40000000, '40 Gbps'),
Choice(100000000, '100 Gbps'),
Choice(200000000, '200 Gbps'),
Choice(400000000, '400 Gbps'),
Choice(1544, 'T1 (1.544 Mbps)'),
Choice(2048, 'E1 (2.048 Mbps)'),
]
@ -55,8 +61,8 @@ class CircuitTerminationSideChoices(ChoiceSet):
SIDE_Z = 'Z'
CHOICES = (
(SIDE_A, 'A'),
(SIDE_Z, 'Z')
Choice(SIDE_A, 'A'),
Choice(SIDE_Z, 'Z')
)
@ -64,17 +70,17 @@ class CircuitTerminationPortSpeedChoices(ChoiceSet):
key = 'CircuitTermination.port_speed'
CHOICES = [
(10000, '10 Mbps'),
(100000, '100 Mbps'),
(1000000, '1 Gbps'),
(10000000, '10 Gbps'),
(25000000, '25 Gbps'),
(40000000, '40 Gbps'),
(100000000, '100 Gbps'),
(200000000, '200 Gbps'),
(400000000, '400 Gbps'),
(1544, 'T1 (1.544 Mbps)'),
(2048, 'E1 (2.048 Mbps)'),
Choice(10000, '10 Mbps'),
Choice(100000, '100 Mbps'),
Choice(1000000, '1 Gbps'),
Choice(10000000, '10 Gbps'),
Choice(25000000, '25 Gbps'),
Choice(40000000, '40 Gbps'),
Choice(100000000, '100 Gbps'),
Choice(200000000, '200 Gbps'),
Choice(400000000, '400 Gbps'),
Choice(1544, 'T1 (1.544 Mbps)'),
Choice(2048, 'E1 (2.048 Mbps)'),
]
@ -87,10 +93,10 @@ class CircuitPriorityChoices(ChoiceSet):
PRIORITY_INACTIVE = 'inactive'
CHOICES = [
(PRIORITY_PRIMARY, _('Primary')),
(PRIORITY_SECONDARY, _('Secondary')),
(PRIORITY_TERTIARY, _('Tertiary')),
(PRIORITY_INACTIVE, _('Inactive')),
Choice(PRIORITY_PRIMARY, _('Primary')),
Choice(PRIORITY_SECONDARY, _('Secondary')),
Choice(PRIORITY_TERTIARY, _('Tertiary')),
Choice(PRIORITY_INACTIVE, _('Inactive')),
]
@ -104,7 +110,7 @@ class VirtualCircuitTerminationRoleChoices(ChoiceSet):
ROLE_SPOKE = 'spoke'
CHOICES = [
(ROLE_PEER, _('Peer'), 'green'),
(ROLE_HUB, _('Hub'), 'blue'),
(ROLE_SPOKE, _('Spoke'), 'orange'),
Choice(ROLE_PEER, _('Peer'), color='green', description=_('Connects to other peers as an equal endpoint')),
Choice(ROLE_HUB, _('Hub'), color='blue', description=_('Central endpoint to which spokes connect')),
Choice(ROLE_SPOKE, _('Spoke'), color='orange', description=_('Remote endpoint connecting to a hub')),
]

View File

@ -1,6 +1,5 @@
from django import forms
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from django.utils.translation import gettext_lazy as _
from circuits.choices import (
@ -11,21 +10,20 @@ from circuits.choices import (
)
from circuits.constants import CIRCUIT_TERMINATION_TERMINATION_TYPES
from circuits.models import *
from dcim.models import Site
from ipam.models import ASN
from netbox.choices import DistanceUnitChoices
from netbox.forms import NetBoxModelBulkEditForm, OrganizationalModelBulkEditForm, PrimaryModelBulkEditForm
from tenancy.models import Tenant
from utilities.forms import add_blank_choice, get_field_value
from utilities.forms import GenericObjectFormMixin, add_blank_choice
from utilities.forms.fields import (
ChoiceField,
ColorField,
ContentTypeChoiceField,
DynamicModelChoiceField,
DynamicModelMultipleChoiceField,
GenericObjectChoiceField,
)
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import BulkEditNullBooleanSelect, DatePicker, HTMXSelect, NumberWithOptions
from utilities.templatetags.builtins.filters import bettertitle
from utilities.forms.widgets import BulkEditNullBooleanSelect, DatePicker, NumberWithOptions
__all__ = (
'CircuitBulkEditForm',
@ -127,7 +125,7 @@ class CircuitBulkEditForm(PrimaryModelBulkEditForm):
'provider': '$provider'
}
)
status = forms.ChoiceField(
status = ChoiceField(
label=_('Status'),
choices=add_blank_choice(CircuitStatusChoices),
required=False,
@ -160,7 +158,7 @@ class CircuitBulkEditForm(PrimaryModelBulkEditForm):
min_value=0,
required=False
)
distance_unit = forms.ChoiceField(
distance_unit = ChoiceField(
label=_('Distance unit'),
choices=add_blank_choice(DistanceUnitChoices),
required=False,
@ -179,24 +177,18 @@ class CircuitBulkEditForm(PrimaryModelBulkEditForm):
)
class CircuitTerminationBulkEditForm(NetBoxModelBulkEditForm):
class CircuitTerminationBulkEditForm(GenericObjectFormMixin, NetBoxModelBulkEditForm):
description = forms.CharField(
label=_('Description'),
max_length=200,
required=False
)
termination_type = ContentTypeChoiceField(
queryset=ContentType.objects.filter(model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES),
widget=HTMXSelect(method='post', attrs={'hx-select': '#form_fields'}),
required=False,
label=_('Termination type')
)
termination = DynamicModelChoiceField(
termination = GenericObjectChoiceField(
label=_('Termination'),
queryset=Site.objects.none(), # Initial queryset
content_type_queryset=ContentType.objects.filter(model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES),
required=False,
disabled=True,
selector=True
selector=True,
hx_method='post',
)
port_speed = forms.IntegerField(
required=False,
@ -215,28 +207,12 @@ class CircuitTerminationBulkEditForm(NetBoxModelBulkEditForm):
model = CircuitTermination
fieldsets = (
FieldSet(
'description',
'termination_type', 'termination',
'mark_connected', name=_('Circuit Termination')
'description', 'termination', 'mark_connected', name=_('Circuit Termination')
),
FieldSet('port_speed', 'upstream_speed', name=_('Termination Details')),
)
nullable_fields = ('description', 'termination')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if termination_type_id := get_field_value(self, 'termination_type'):
try:
termination_type = ContentType.objects.get(pk=termination_type_id)
model = termination_type.model_class()
self.fields['termination'].queryset = model.objects.all()
self.fields['termination'].widget.attrs['selector'] = model._meta.label_lower
self.fields['termination'].disabled = False
self.fields['termination'].label = _(bettertitle(model._meta.verbose_name))
except ObjectDoesNotExist:
pass
class CircuitGroupBulkEditForm(OrganizationalModelBulkEditForm):
tenant = DynamicModelChoiceField(
@ -257,7 +233,7 @@ class CircuitGroupAssignmentBulkEditForm(NetBoxModelBulkEditForm):
queryset=Circuit.objects.all(),
required=False
)
priority = forms.ChoiceField(
priority = ChoiceField(
label=_('Priority'),
choices=add_blank_choice(CircuitPriorityChoices),
required=False
@ -299,7 +275,7 @@ class VirtualCircuitBulkEditForm(PrimaryModelBulkEditForm):
queryset=VirtualCircuitType.objects.all(),
required=False
)
status = forms.ChoiceField(
status = ChoiceField(
label=_('Status'),
choices=add_blank_choice(CircuitStatusChoices),
required=False,
@ -322,7 +298,7 @@ class VirtualCircuitBulkEditForm(PrimaryModelBulkEditForm):
class VirtualCircuitTerminationBulkEditForm(NetBoxModelBulkEditForm):
role = forms.ChoiceField(
role = ChoiceField(
label=_('Role'),
choices=add_blank_choice(VirtualCircuitTerminationRoleChoices),
required=False,

View File

@ -1,30 +1,33 @@
from django import forms
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.utils.translation import gettext_lazy as _
from circuits.choices import (
CircuitCommitRateChoices,
CircuitPriorityChoices,
CircuitStatusChoices,
CircuitTerminationPortSpeedChoices,
CircuitTerminationSideChoices,
VirtualCircuitTerminationRoleChoices,
)
from circuits.constants import *
from circuits.models import *
from dcim.models import Interface, Site
from dcim.models import Interface
from ipam.models import ASN
from netbox.choices import DistanceUnitChoices
from netbox.forms import NetBoxModelForm, OrganizationalModelForm, PrimaryModelForm
from tenancy.forms import TenancyForm
from utilities.forms import get_field_value
from utilities.forms import GenericObjectFormMixin, add_blank_choice
from utilities.forms.fields import (
ContentTypeChoiceField,
ChoiceField,
DynamicModelChoiceField,
DynamicModelMultipleChoiceField,
GenericObjectChoiceField,
SlugField,
TypedChoiceField,
)
from utilities.forms.mixins import DistanceValidationMixin
from utilities.forms.rendering import FieldSet, InlineFields, M2MAddRemoveFields
from utilities.forms.widgets import DatePicker, HTMXSelect, NumberWithOptions
from utilities.string import title
from utilities.forms.widgets import DatePicker, NumberWithOptions
__all__ = (
'CircuitForm',
@ -136,6 +139,16 @@ class CircuitTypeForm(OrganizationalModelForm):
class CircuitForm(DistanceValidationMixin, TenancyForm, PrimaryModelForm):
status = ChoiceField(
label=_('Status'),
choices=CircuitStatusChoices,
initial=CircuitStatusChoices.STATUS_ACTIVE,
)
distance_unit = TypedChoiceField(
label=_('Distance unit'),
choices=add_blank_choice(DistanceUnitChoices),
required=False,
)
provider = DynamicModelChoiceField(
label=_('Provider'),
queryset=Provider.objects.all(),
@ -186,29 +199,28 @@ class CircuitForm(DistanceValidationMixin, TenancyForm, PrimaryModelForm):
}
class CircuitTerminationForm(NetBoxModelForm):
class CircuitTerminationForm(GenericObjectFormMixin, NetBoxModelForm):
term_side = ChoiceField(
label=_('Termination side'),
choices=CircuitTerminationSideChoices,
)
circuit = DynamicModelChoiceField(
label=_('Circuit'),
queryset=Circuit.objects.all(),
selector=True
)
termination_type = ContentTypeChoiceField(
queryset=ContentType.objects.filter(model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES),
widget=HTMXSelect(),
label=_('Termination type')
)
termination = DynamicModelChoiceField(
termination = GenericObjectChoiceField(
label=_('Termination'),
queryset=Site.objects.none(), # Initial queryset
disabled=True,
selector=True
content_type_queryset=ContentType.objects.filter(model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES),
required=True,
selector=True,
hx_target_id='circuit-termination',
)
fieldsets = (
FieldSet(
'circuit', 'term_side', 'description', 'tags',
'termination_type', 'termination',
'mark_connected', name=_('Circuit Termination')
'circuit', 'term_side', 'description', 'tags', 'termination', 'mark_connected',
name=_('Circuit Termination'), html_id='circuit-termination',
),
FieldSet('port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', name=_('Termination Details')),
)
@ -216,7 +228,7 @@ class CircuitTerminationForm(NetBoxModelForm):
class Meta:
model = CircuitTermination
fields = [
'circuit', 'term_side', 'termination_type', 'mark_connected', 'port_speed', 'upstream_speed',
'circuit', 'term_side', 'mark_connected', 'port_speed', 'upstream_speed',
'xconnect_id', 'pp_info', 'description', 'tags',
]
widgets = {
@ -228,48 +240,6 @@ class CircuitTerminationForm(NetBoxModelForm):
),
}
def __init__(self, *args, **kwargs):
instance = kwargs.get('instance')
initial = kwargs.get('initial', {})
if instance is not None and instance.termination:
initial['termination'] = instance.termination
kwargs['initial'] = initial
super().__init__(*args, **kwargs)
if termination_type_id := get_field_value(self, 'termination_type'):
try:
termination_type = ContentType.objects.get(pk=termination_type_id)
model = termination_type.model_class()
self.fields['termination'].queryset = model.objects.all()
self.fields['termination'].widget.attrs['selector'] = model._meta.label_lower
self.fields['termination'].disabled = False
self.fields['termination'].label = _(title(model._meta.verbose_name))
except ObjectDoesNotExist:
pass
if self.instance and termination_type_id != self.instance.termination_type_id:
self.initial['termination'] = None
else:
# Clear the initial termination value if termination_type is not set
self.initial['termination'] = None
def clean(self):
super().clean()
termination = self.cleaned_data.get('termination')
termination_type = self.cleaned_data.get('termination_type')
if termination_type and not termination:
raise ValidationError({
'termination': _('Please select a {termination_type}.').format(
termination_type=_(title(termination_type.model_class()._meta.verbose_name))
)
})
# Assign the selected termination (if any)
self.instance.termination = self.cleaned_data.get('termination')
class CircuitGroupForm(TenancyForm, OrganizationalModelForm):
fieldsets = (
@ -284,64 +254,37 @@ class CircuitGroupForm(TenancyForm, OrganizationalModelForm):
]
class CircuitGroupAssignmentForm(NetBoxModelForm):
class CircuitGroupAssignmentForm(GenericObjectFormMixin, NetBoxModelForm):
priority = TypedChoiceField(
label=_('Priority'),
choices=add_blank_choice(CircuitPriorityChoices),
required=False,
)
group = DynamicModelChoiceField(
label=_('Group'),
queryset=CircuitGroup.objects.all(),
)
member_type = ContentTypeChoiceField(
queryset=ContentType.objects.filter(CIRCUIT_GROUP_ASSIGNMENT_MEMBER_MODELS),
widget=HTMXSelect(),
member = GenericObjectChoiceField(
label=_('Member'),
content_type_queryset=ContentType.objects.filter(CIRCUIT_GROUP_ASSIGNMENT_MEMBER_MODELS),
required=False,
label=_('Circuit type')
)
member = DynamicModelChoiceField(
label=_('Circuit'),
queryset=Circuit.objects.none(), # Initial queryset
required=False,
disabled=True,
selector=True
selector=True,
hx_target_id='circuit-group-assignment',
)
fieldsets = (
FieldSet('group', 'member_type', 'member', 'priority', 'tags', name=_('Group Assignment')),
FieldSet(
'group', 'member', 'priority', 'tags',
name=_('Group Assignment'), html_id='circuit-group-assignment',
),
)
class Meta:
model = CircuitGroupAssignment
fields = [
'group', 'member_type', 'priority', 'tags',
'group', 'priority', 'tags',
]
def __init__(self, *args, **kwargs):
instance = kwargs.get('instance')
initial = kwargs.get('initial', {})
if instance is not None and instance.member:
initial['member'] = instance.member
kwargs['initial'] = initial
super().__init__(*args, **kwargs)
if member_type_id := get_field_value(self, 'member_type'):
try:
model = ContentType.objects.get(pk=member_type_id).model_class()
self.fields['member'].queryset = model.objects.all()
self.fields['member'].widget.attrs['selector'] = model._meta.label_lower
self.fields['member'].disabled = False
self.fields['member'].label = _(title(model._meta.verbose_name))
except ObjectDoesNotExist:
pass
if self.instance.pk and member_type_id != self.instance.member_type_id:
self.initial['member'] = None
def clean(self):
super().clean()
# Assign the selected circuit (if any)
self.instance.member = self.cleaned_data.get('member')
class VirtualCircuitTypeForm(OrganizationalModelForm):
fieldsets = (
@ -356,6 +299,11 @@ class VirtualCircuitTypeForm(OrganizationalModelForm):
class VirtualCircuitForm(TenancyForm, PrimaryModelForm):
status = ChoiceField(
label=_('Status'),
choices=CircuitStatusChoices,
initial=CircuitStatusChoices.STATUS_ACTIVE,
)
provider_network = DynamicModelChoiceField(
label=_('Provider network'),
queryset=ProviderNetwork.objects.all(),
@ -393,9 +341,8 @@ class VirtualCircuitTerminationForm(NetBoxModelForm):
queryset=VirtualCircuit.objects.all(),
selector=True
)
role = forms.ChoiceField(
role = ChoiceField(
choices=VirtualCircuitTerminationRoleChoices,
widget=HTMXSelect(),
label=_('Role')
)
interface = DynamicModelChoiceField(
@ -412,7 +359,9 @@ class VirtualCircuitTerminationForm(NetBoxModelForm):
)
fieldsets = (
FieldSet('virtual_circuit', 'role', 'interface', 'description', 'tags'),
FieldSet(
'virtual_circuit', 'role', 'interface', 'description', 'tags',
),
)
class Meta:

View File

@ -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()

View File

@ -8,7 +8,7 @@ from dcim.graphql.mixins import CabledObjectMixin
from dcim.models import Location, Region, Site, SiteGroup
from extras.graphql.mixins import ContactsMixin, CustomFieldsMixin, TagsMixin
from netbox.graphql.optimization import build_gfk_prefetch
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 *
@ -32,7 +32,7 @@ __all__ = (
)
@strawberry_django.type(
@register_type(
models.Provider,
fields='__all__',
filters=ProviderFilter,
@ -45,7 +45,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,
@ -56,7 +56,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,
@ -67,7 +67,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,
@ -100,7 +100,7 @@ class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, Ob
return self.termination
@strawberry_django.type(
@register_type(
models.CircuitType,
fields='__all__',
filters=CircuitTypeFilter,
@ -112,7 +112,7 @@ class CircuitTypeType(OrganizationalObjectType):
circuits: list[Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]]
@strawberry_django.type(
@register_type(
models.Circuit,
fields='__all__',
filters=CircuitFilter,
@ -128,7 +128,7 @@ class CircuitType(PrimaryObjectType, ContactsMixin):
terminations: list[CircuitTerminationType]
@strawberry_django.type(
@register_type(
models.CircuitGroup,
fields='__all__',
filters=CircuitGroupFilter,
@ -138,7 +138,7 @@ class CircuitGroupType(OrganizationalObjectType):
tenant: TenantType | None
@strawberry_django.type(
@register_type(
models.CircuitGroupAssignment,
exclude=['member_type', 'member_id'],
filters=CircuitGroupAssignmentFilter,
@ -165,7 +165,7 @@ class CircuitGroupAssignmentType(TagsMixin, BaseObjectType):
return self.member
@strawberry_django.type(
@register_type(
models.VirtualCircuitType,
fields='__all__',
filters=VirtualCircuitTypeFilter,
@ -177,7 +177,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,
@ -194,7 +194,7 @@ class VirtualCircuitTerminationType(CustomFieldsMixin, TagsMixin, ObjectType):
] = strawberry_django.field(select_related=["interface"])
@strawberry_django.type(
@register_type(
models.VirtualCircuit,
fields='__all__',
filters=VirtualCircuitFilter,

View File

@ -13,56 +13,56 @@ class Migration(migrations.Migration):
model_name='circuit',
name='owner',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
),
),
migrations.AddField(
model_name='circuitgroup',
name='owner',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
),
),
migrations.AddField(
model_name='circuittype',
name='owner',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
),
),
migrations.AddField(
model_name='provider',
name='owner',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
),
),
migrations.AddField(
model_name='provideraccount',
name='owner',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
),
),
migrations.AddField(
model_name='providernetwork',
name='owner',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
),
),
migrations.AddField(
model_name='virtualcircuit',
name='owner',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
),
),
migrations.AddField(
model_name='virtualcircuittype',
name='owner',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
),
),
]

View File

@ -0,0 +1,38 @@
"""
Maintain CircuitTermination's denormalized site/region/site-group columns via PostgreSQL triggers instead
of the Python `post_save` handler formerly registered in netbox.denormalized.
"""
from django.db import migrations
from utilities.migration import InstallDenormalizationTrigger
class Migration(migrations.Migration):
dependencies = [
('circuits', '0059_nullify_empty_cable_end'),
# Source tables (dcim_site, dcim_location) must already exist.
('dcim', '0242_ltree_paths'),
]
operations = [
InstallDenormalizationTrigger(
dependent_table='circuits_circuittermination',
source_table='dcim_site',
fk_column='_site_id',
mappings={'_region_id': 'region_id', '_site_group_id': 'group_id'},
),
InstallDenormalizationTrigger(
dependent_table='circuits_circuittermination',
source_table='dcim_location',
fk_column='_location_id',
mappings={'_site_id': 'site_id'},
related_mappings=(
{
'table': 'dcim_site',
'source_fk': 'site_id',
'mappings': {'_region_id': 'region_id', '_site_group_id': 'group_id'},
},
),
),
]

View File

@ -20,10 +20,10 @@ from dcim.models import (
from ipam.models import ASN, RIR
from netbox.choices import DistanceUnitChoices
from tenancy.models import Tenant, TenantGroup
from utilities.testing import ChangeLoggedFilterSetTests
from utilities.testing import ChangeLoggedFilterSetTestMixin
class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests):
class ProviderTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
queryset = Provider.objects.all()
filterset = ProviderFilterSet
@ -135,7 +135,7 @@ class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class CircuitTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
class CircuitTypeTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
queryset = CircuitType.objects.all()
filterset = CircuitTypeFilterSet
@ -165,7 +165,7 @@ class CircuitTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
class CircuitTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
queryset = Circuit.objects.all()
filterset = CircuitFilterSet
@ -440,7 +440,7 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
queryset = CircuitTermination.objects.all()
filterset = CircuitTerminationFilterSet
ignore_fields = ('cable', 'cable_positions')
@ -609,7 +609,7 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7)
class CircuitGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
class CircuitGroupTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
queryset = CircuitGroup.objects.all()
filterset = CircuitGroupFilterSet
@ -667,7 +667,7 @@ class CircuitGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
class CircuitGroupAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests):
class CircuitGroupAssignmentTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
queryset = CircuitGroupAssignment.objects.all()
filterset = CircuitGroupAssignmentFilterSet
@ -812,7 +812,7 @@ class CircuitGroupAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
class ProviderNetworkTestCase(TestCase, ChangeLoggedFilterSetTests):
class ProviderNetworkTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
queryset = ProviderNetwork.objects.all()
filterset = ProviderNetworkFilterSet
@ -853,7 +853,7 @@ class ProviderNetworkTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class ProviderAccountTestCase(TestCase, ChangeLoggedFilterSetTests):
class ProviderAccountTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
queryset = ProviderAccount.objects.all()
filterset = ProviderAccountFilterSet
@ -898,7 +898,7 @@ class ProviderAccountTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class VirtualCircuitTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
class VirtualCircuitTypeTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
queryset = VirtualCircuitType.objects.all()
filterset = VirtualCircuitTypeFilterSet
@ -928,7 +928,7 @@ class VirtualCircuitTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class VirtualCircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
class VirtualCircuitTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
queryset = VirtualCircuit.objects.all()
filterset = VirtualCircuitFilterSet
@ -1064,7 +1064,7 @@ class VirtualCircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class VirtualCircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
class VirtualCircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
queryset = VirtualCircuitTermination.objects.all()
filterset = VirtualCircuitTerminationFilterSet

View File

@ -32,12 +32,12 @@ class CircuitTerminationFormTestCase(TestCase):
data={
'circuit': self.circuit.pk,
'term_side': 'A',
'termination_type': provider_network_type.pk,
'termination': '',
'termination_content_type': provider_network_type.pk,
'termination_object_id': '',
}
)
self.assertFalse(form.is_valid())
self.assertIn('termination', form.errors)
self.assertIn('Please select a Provider Network.', form.errors['termination'])
self.assertNotIn('termination_id', form.errors)
self.assertIn('Please select a provider network.', form.errors['termination'])
self.assertNotIn('termination_object_id', form.errors)

View File

@ -3,7 +3,7 @@ from django.core.exceptions import NON_FIELD_ERRORS, ValidationError
from django.test import TestCase
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider, ProviderNetwork
from dcim.models import Site
from dcim.models import Location, Region, Site, SiteGroup
class CircuitTerminationTestCase(TestCase):
@ -166,3 +166,85 @@ class CircuitTerminationTestCase(TestCase):
self.assertIn(NON_FIELD_ERRORS, errors)
self.assertIn('Please select a Provider Network.', errors[NON_FIELD_ERRORS])
self.assertNotIn('termination_id', errors)
class CircuitTerminationDenormalizationTriggerTestCase(TestCase):
"""
Verify the PostgreSQL triggers (installed by circuits migration 0058) that keep a
CircuitTermination's denormalized scope columns in sync with its Site/Location.
These replace the former Python `post_save` handler in netbox.denormalized. Unlike that
handler, the triggers also fire for bulk QuerySet.update() writes (exercised below).
"""
@classmethod
def setUpTestData(cls):
provider = Provider.objects.create(name='Provider 1', slug='provider-1')
circuit_type = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
cls.circuit = Circuit.objects.create(cid='Circuit 1', provider=provider, type=circuit_type)
def test_site_region_group_change_propagates_to_termination(self):
region_a = Region.objects.create(name='Region A', slug='region-a')
region_b = Region.objects.create(name='Region B', slug='region-b')
group_a = SiteGroup.objects.create(name='Group A', slug='group-a')
group_b = SiteGroup.objects.create(name='Group B', slug='group-b')
site = Site.objects.create(name='Site', slug='site', region=region_a, group=group_a)
termination = CircuitTermination.objects.create(
circuit=self.circuit, term_side='A', termination=site,
)
self.assertEqual(termination._region, region_a)
self.assertEqual(termination._site_group, group_a)
# Reassign the Site's region/group; the trigger should update the termination.
site.region = region_b
site.group = group_b
site.save()
termination.refresh_from_db()
self.assertEqual(termination._region, region_b)
self.assertEqual(termination._site_group, group_b)
def test_location_site_change_propagates_to_termination(self):
region_a = Region.objects.create(name='Region A', slug='region-a')
region_b = Region.objects.create(name='Region B', slug='region-b')
group_a = SiteGroup.objects.create(name='Group A', slug='group-a')
group_b = SiteGroup.objects.create(name='Group B', slug='group-b')
site_a = Site.objects.create(name='Site A', slug='site-a', region=region_a, group=group_a)
site_b = Site.objects.create(name='Site B', slug='site-b', region=region_b, group=group_b)
location = Location.objects.create(name='Loc', slug='loc', site=site_a)
termination = CircuitTermination.objects.create(
circuit=self.circuit, term_side='A', termination=location,
)
self.assertEqual(termination._site, site_a)
self.assertEqual(termination._location, location)
# Move the Location to a different Site; the trigger updates _site and pulls the new
# site's region/group through in the same statement.
location.site = site_b
location.save()
termination.refresh_from_db()
self.assertEqual(termination._site, site_b)
self.assertEqual(termination._region, region_b)
self.assertEqual(termination._site_group, group_b)
def test_bulk_update_of_site_propagates_to_termination(self):
"""
A QuerySet.update() bypasses post_save (the old handler never fired for it); the
DB trigger fires regardless, which is the behavior this change introduces.
"""
region_a = Region.objects.create(name='Region A', slug='region-a')
region_b = Region.objects.create(name='Region B', slug='region-b')
site = Site.objects.create(name='Site', slug='site', region=region_a)
termination = CircuitTermination.objects.create(
circuit=self.circuit, term_side='A', termination=site,
)
self.assertEqual(termination._region, region_a)
Site.objects.filter(pk=site.pk).update(region=region_b)
termination.refresh_from_db()
self.assertEqual(termination._region, region_b)

View File

@ -400,8 +400,8 @@ class CircuitTerminationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
cls.form_data = {
'circuit': circuits[2].pk,
'term_side': 'A',
'termination_type': ContentType.objects.get_for_model(Site).pk,
'termination': sites[2].pk,
'termination_content_type': ContentType.objects.get_for_model(Site).pk,
'termination_object_id': sites[2].pk,
'description': 'New description',
}
@ -541,8 +541,8 @@ class CircuitGroupAssignmentTestCase(
cls.form_data = {
'group': circuit_groups[3].pk,
'member_type': ContentType.objects.get_for_model(Circuit).pk,
'member': circuits[3].pk,
'member_content_type': ContentType.objects.get_for_model(Circuit).pk,
'member_object_id': circuits[3].pk,
'priority': CircuitPriorityChoices.PRIORITY_INACTIVE,
'tags': [t.pk for t in tags],
}

View File

@ -62,8 +62,8 @@ class CircuitGroupAssignmentsPanel(panels.ObjectsTablePanel):
actions.AddObject(
'circuits.CircuitGroupAssignment',
url_params={
'member_type': lambda ctx: ContentType.objects.get_for_model(ctx['object']).pk,
'member': lambda ctx: ctx['object'].pk,
'member_content_type': lambda ctx: ContentType.objects.get_for_model(ctx['object']).pk,
'member_object_id': lambda ctx: ctx['object'].pk,
'return_url': lambda ctx: ctx['object'].get_absolute_url(),
},
label=_('Assign Group'),

View File

@ -5,6 +5,7 @@ from extras.ui.panels import CustomFieldsPanel, ImageAttachmentsPanel, TagsPanel
from ipam.models import ASN
from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport
from netbox.ui import actions, layout
from netbox.ui.breadcrumbs import Breadcrumb, filtered_list_url
from netbox.ui.panels import (
CommentsPanel,
ObjectsTablePanel,
@ -147,8 +148,12 @@ class ProviderAccountListView(generic.ObjectListView):
@register_model_view(ProviderAccount)
class ProviderAccountView(GetRelatedModelsMixin, generic.ObjectView):
template_name = 'generic/object.html'
queryset = ProviderAccount.objects.all()
layout = layout.SimpleLayout(
breadcrumbs=[
Breadcrumb('provider', url=filtered_list_url('circuits:provideraccount_list', 'provider_id')),
],
left_panels=[
panels.ProviderAccountPanel(),
TagsPanel(),
@ -240,8 +245,12 @@ class ProviderNetworkListView(generic.ObjectListView):
@register_model_view(ProviderNetwork)
class ProviderNetworkView(GetRelatedModelsMixin, generic.ObjectView):
template_name = 'generic/object.html'
queryset = ProviderNetwork.objects.all()
layout = layout.SimpleLayout(
breadcrumbs=[
Breadcrumb('provider', url=filtered_list_url('circuits:providernetwork_list', 'provider_id')),
],
left_panels=[
panels.ProviderNetworkPanel(),
TagsPanel(),
@ -422,8 +431,12 @@ class CircuitListView(generic.ObjectListView):
@register_model_view(Circuit)
class CircuitView(generic.ObjectView):
template_name = 'generic/object.html'
queryset = Circuit.objects.all()
layout = layout.SimpleLayout(
breadcrumbs=[
Breadcrumb('provider', url=filtered_list_url('circuits:circuit_list', 'provider_id')),
],
left_panels=[
panels.CircuitPanel(),
panels.CircuitGroupAssignmentsPanel(),
@ -508,8 +521,12 @@ class CircuitTerminationListView(generic.ObjectListView):
@register_model_view(CircuitTermination)
class CircuitTerminationView(generic.ObjectView):
template_name = 'generic/object.html'
queryset = CircuitTermination.objects.all()
layout = layout.SimpleLayout(
breadcrumbs=[
Breadcrumb('circuit.provider', url=filtered_list_url('circuits:circuit_list', 'provider_id')),
],
left_panels=[
panels.CircuitTerminationPanel(),
],
@ -646,8 +663,12 @@ class CircuitGroupAssignmentListView(generic.ObjectListView):
@register_model_view(CircuitGroupAssignment)
class CircuitGroupAssignmentView(generic.ObjectView):
template_name = 'generic/object.html'
queryset = CircuitGroupAssignment.objects.all()
layout = layout.SimpleLayout(
breadcrumbs=[
Breadcrumb('group', url=filtered_list_url('circuits:circuitgroupassignment_list', 'group_id')),
],
left_panels=[
panels.CircuitGroupAssignmentPanel(),
TagsPanel(),
@ -785,8 +806,16 @@ class VirtualCircuitListView(generic.ObjectListView):
@register_model_view(VirtualCircuit)
class VirtualCircuitView(generic.ObjectView):
template_name = 'generic/object.html'
queryset = VirtualCircuit.objects.all()
layout = layout.SimpleLayout(
breadcrumbs=[
Breadcrumb('provider', url=filtered_list_url('circuits:virtualcircuit_list', 'provider_id')),
Breadcrumb(
'provider_network',
url=filtered_list_url('circuits:virtualcircuit_list', 'provider_network_id'),
),
],
left_panels=[
panels.VirtualCircuitPanel(),
TagsPanel(),
@ -881,8 +910,23 @@ class VirtualCircuitTerminationListView(generic.ObjectListView):
@register_model_view(VirtualCircuitTermination)
class VirtualCircuitTerminationView(generic.ObjectView):
template_name = 'generic/object.html'
queryset = VirtualCircuitTermination.objects.all()
layout = layout.SimpleLayout(
breadcrumbs=[
Breadcrumb(
'virtual_circuit.provider',
url=filtered_list_url('circuits:virtualcircuit_list', 'provider_id'),
),
Breadcrumb(
'virtual_circuit.provider_network',
url=filtered_list_url('circuits:virtualcircuit_list', 'provider_network_id'),
),
Breadcrumb(
'virtual_circuit',
url=filtered_list_url('circuits:virtualcircuittermination_list', 'virtual_circuit_id'),
),
],
left_panels=[
panels.VirtualCircuitTerminationPanel(),
TagsPanel(),

View File

@ -32,8 +32,8 @@ class JobSerializer(BaseModelSerializer):
model = Job
fields = [
'id', 'url', 'display_url', 'display', 'object_type', 'object_id', 'object', 'name', 'status', 'created',
'scheduled', 'interval', 'started', 'completed', 'user', 'data', 'error', 'job_id', 'queue_name',
'notifications', 'log_entries',
'scheduled', 'interval', 'started', 'completed', 'execution_time', 'user', 'data', 'error', 'job_id',
'queue_name', 'notifications', 'log_entries',
]
brief_fields = ('url', 'created', 'completed', 'user', 'status')

View File

@ -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

View File

@ -74,22 +74,21 @@ def check_postgresql_version(app_configs, **kwargs):
@register(Tags.caches)
def check_redis_version(app_configs, **kwargs):
"""
Warn if the Redis version is less than 6.0, as support for Redis older than 6.0
will be removed in NetBox v4.7.
Report an error if the Redis version is less than 6.0.
"""
warnings = []
errors = []
try:
client = cache.client.get_client()
redis_version = tuple(int(x) for x in client.info()['redis_version'].split('.'))
if redis_version < (6, 0):
warnings.append(
Warning(
f'Support for Redis {".".join(str(x) for x in redis_version)} is deprecated and will be '
f'removed in NetBox v4.7.',
errors.append(
Error(
f'Redis {".".join(str(x) for x in redis_version)} is not supported. NetBox requires Redis 6.0 '
f'or later.',
hint='Please upgrade to Redis 6.0 or later.',
id='netbox.W002',
id='netbox.E002',
)
)
except Exception:
pass
return warnings
return errors

View File

@ -1,6 +1,6 @@
from django.utils.translation import gettext_lazy as _
from utilities.choices import ChoiceSet
from utilities.choices import Choice, ChoiceSet
#
# Data sources
@ -15,11 +15,11 @@ class DataSourceStatusChoices(ChoiceSet):
FAILED = 'failed'
CHOICES = (
(NEW, _('New'), 'blue'),
(QUEUED, _('Queued'), 'orange'),
(SYNCING, _('Syncing'), 'cyan'),
(COMPLETED, _('Completed'), 'green'),
(FAILED, _('Failed'), 'red'),
Choice(NEW, _('New'), color='blue', description=_('Newly created and not yet synchronized')),
Choice(QUEUED, _('Queued'), color='orange', description=_('Queued for synchronization')),
Choice(SYNCING, _('Syncing'), color='cyan', description=_('Synchronization in progress')),
Choice(COMPLETED, _('Completed'), color='green', description=_('Most recent synchronization succeeded')),
Choice(FAILED, _('Failed'), color='red', description=_('Most recent synchronization failed')),
)
@ -32,8 +32,8 @@ class ManagedFileRootPathChoices(ChoiceSet):
REPORTS = 'reports' # settings.REPORTS_ROOT
CHOICES = (
(SCRIPTS, _('Scripts')),
(REPORTS, _('Reports')),
Choice(SCRIPTS, _('Scripts')),
Choice(REPORTS, _('Reports')),
)
@ -51,12 +51,12 @@ class JobStatusChoices(ChoiceSet):
STATUS_FAILED = 'failed'
CHOICES = (
(STATUS_PENDING, _('Pending'), 'cyan'),
(STATUS_SCHEDULED, _('Scheduled'), 'gray'),
(STATUS_RUNNING, _('Running'), 'blue'),
(STATUS_COMPLETED, _('Completed'), 'green'),
(STATUS_ERRORED, _('Errored'), 'red'),
(STATUS_FAILED, _('Failed'), 'red'),
Choice(STATUS_PENDING, _('Pending'), color='cyan', description=_('Awaiting execution')),
Choice(STATUS_SCHEDULED, _('Scheduled'), color='gray', description=_('Scheduled to run at a future time')),
Choice(STATUS_RUNNING, _('Running'), color='blue', description=_('Currently executing')),
Choice(STATUS_COMPLETED, _('Completed'), color='green', description=_('Finished successfully')),
Choice(STATUS_ERRORED, _('Errored'), color='red', description=_('Terminated due to an unhandled error')),
Choice(STATUS_FAILED, _('Failed'), color='red', description=_('Failed to complete')),
)
ENQUEUED_STATE_CHOICES = (
@ -78,9 +78,9 @@ class JobNotificationChoices(ChoiceSet):
NOTIFICATION_NEVER = 'never'
CHOICES = (
(NOTIFICATION_ALWAYS, _('Always')),
(NOTIFICATION_ON_FAILURE, _('On failure')),
(NOTIFICATION_NEVER, _('Never')),
Choice(NOTIFICATION_ALWAYS, _('Always'), description=_('Notify after every job execution')),
Choice(NOTIFICATION_ON_FAILURE, _('On failure'), description=_('Notify only when a job fails')),
Choice(NOTIFICATION_NEVER, _('Never'), description=_('Never send job notifications')),
)
@ -91,12 +91,12 @@ class JobIntervalChoices(ChoiceSet):
INTERVAL_WEEKLY = 60 * 24 * 7
CHOICES = (
(INTERVAL_MINUTELY, _('Minutely')),
(INTERVAL_HOURLY, _('Hourly')),
(INTERVAL_HOURLY * 12, _('12 hours')),
(INTERVAL_DAILY, _('Daily')),
(INTERVAL_WEEKLY, _('Weekly')),
(INTERVAL_DAILY * 30, _('30 days')),
Choice(INTERVAL_MINUTELY, _('Minutely')),
Choice(INTERVAL_HOURLY, _('Hourly')),
Choice(INTERVAL_HOURLY * 12, _('12 hours')),
Choice(INTERVAL_DAILY, _('Daily')),
Choice(INTERVAL_WEEKLY, _('Weekly')),
Choice(INTERVAL_DAILY * 30, _('30 days')),
)
@ -111,7 +111,7 @@ class ObjectChangeActionChoices(ChoiceSet):
ACTION_DELETE = 'delete'
CHOICES = (
(ACTION_CREATE, _('Created'), 'green'),
(ACTION_UPDATE, _('Updated'), 'blue'),
(ACTION_DELETE, _('Deleted'), 'red'),
Choice(ACTION_CREATE, _('Created'), color='green'),
Choice(ACTION_UPDATE, _('Updated'), color='blue'),
Choice(ACTION_DELETE, _('Deleted'), color='red'),
)

View File

@ -132,6 +132,19 @@ class JobFilterSet(BaseFilterSet):
field_name='completed',
lookup_expr='gte'
)
execution_time = django_filters.DurationFilter(
label=_('Execution time')
)
execution_time__gte = django_filters.DurationFilter(
field_name='execution_time',
lookup_expr='gte',
label=_('Execution time (minimum)')
)
execution_time__lte = django_filters.DurationFilter(
field_name='execution_time',
lookup_expr='lte',
label=_('Execution time (maximum)')
)
status = django_filters.MultipleChoiceFilter(
choices=JobStatusChoices,
distinct=False,
@ -160,7 +173,7 @@ class JobFilterSet(BaseFilterSet):
model = Job
fields = (
'id', 'object_type', 'object_type_id', 'object_id', 'name', 'interval', 'status', 'user', 'job_id',
'queue_name',
'queue_name', 'execution_time',
)
def search(self, queryset, name, value):

View File

@ -5,6 +5,7 @@ from core.choices import JobIntervalChoices
from core.models import *
from netbox.forms import PrimaryModelBulkEditForm
from netbox.utils import get_data_backend_choices
from utilities.forms.fields import ChoiceField
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import BulkEditNullBooleanSelect
@ -14,7 +15,7 @@ __all__ = (
class DataSourceBulkEditForm(PrimaryModelBulkEditForm):
type = forms.ChoiceField(
type = ChoiceField(
label=_('Type'),
choices=get_data_backend_choices,
required=False
@ -24,7 +25,7 @@ class DataSourceBulkEditForm(PrimaryModelBulkEditForm):
widget=BulkEditNullBooleanSelect(),
label=_('Enabled')
)
sync_interval = forms.ChoiceField(
sync_interval = ChoiceField(
choices=JobIntervalChoices,
required=False,
label=_('Sync interval')

View File

@ -75,11 +75,12 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
model = Job
fieldsets = (
FieldSet('q', 'filter_id'),
FieldSet('object_type_id', 'status', 'queue_name', name=_('Attributes')),
FieldSet('object_type_id', 'status', 'queue_name', 'user', name=_('Attributes')),
FieldSet(
'created__before', 'created__after', 'scheduled__before', 'scheduled__after', 'started__before',
'started__after', 'completed__before', 'completed__after', 'user', name=_('Creation')
'started__after', 'completed__before', 'completed__after', name=_('Scheduling')
),
FieldSet('execution_time__gte', 'execution_time__lte', name=_('Execution')),
)
object_type_id = ContentTypeChoiceField(
label=_('Object Type'),
@ -140,6 +141,16 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
required=False,
label=_('User')
)
execution_time__gte = forms.DurationField(
label=_('Execution time (minimum)'),
required=False,
help_text=_('Seconds, or HH:MM:SS')
)
execution_time__lte = forms.DurationField(
label=_('Execution time (maximum)'),
required=False,
help_text=_('Seconds, or HH:MM:SS')
)
class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):

View File

@ -13,7 +13,7 @@ from netbox.forms import NetBoxModelForm, PrimaryModelForm
from netbox.registry import registry
from netbox.utils import get_data_backend_choices
from utilities.forms import get_field_value
from utilities.forms.fields import JSONField
from utilities.forms.fields import ChoiceField, JSONField
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import HTMXSelect
@ -27,8 +27,9 @@ EMPTY_VALUES = ('', None, [], ())
class DataSourceForm(PrimaryModelForm):
type = forms.ChoiceField(
type = ChoiceField(
choices=get_data_backend_choices,
# No hx_target_id: changing type adds/removes the Backend Parameters fieldset entirely.
widget=HTMXSelect()
)

View File

@ -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()

View File

@ -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

View File

@ -13,7 +13,7 @@ class Migration(migrations.Migration):
model_name='datasource',
name='owner',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner'
blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='users.owner'
),
),
]

View File

@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0024_job_notifications"),
]
operations = [
migrations.AddField(
model_name="job",
name="execution_time",
field=models.DurationField(blank=True, editable=False, null=True),
),
]

View File

@ -0,0 +1,46 @@
from django.db import migrations
from django.db.models import DurationField, ExpressionWrapper, F
BATCH_SIZE = 5000
def populate_execution_time(apps, schema_editor):
"""
Populate execution_time for existing jobs which have both a start and completion time recorded.
Updates are performed in batches, as installations which retain job history indefinitely can
accumulate a very large number of rows. Rows which already have a value are skipped, so that an
interrupted run can simply be resumed.
"""
Job = apps.get_model("core", "Job")
queryset = Job.objects.filter(
started__isnull=False, completed__isnull=False, execution_time__isnull=True
)
execution_time = ExpressionWrapper(F("completed") - F("started"), output_field=DurationField())
last_pk = 0
while True:
pks = list(
queryset.filter(pk__gt=last_pk).order_by("pk").values_list("pk", flat=True)[:BATCH_SIZE]
)
if not pks:
break
Job.objects.filter(pk__in=pks).update(execution_time=execution_time)
last_pk = pks[-1]
class Migration(migrations.Migration):
# The backfill is deliberately kept out of the migration which adds the column, so that the
# ACCESS EXCLUSIVE lock taken by ALTER TABLE is not held for its duration. Running without a
# wrapping transaction is what allows the batching above to bound the work actually held open.
atomic = False
dependencies = [
("core", "0025_add_job_execution_time"),
]
operations = [
migrations.RunPython(
code=populate_execution_time,
reverse_code=migrations.RunPython.noop,
),
]

View File

@ -6,7 +6,6 @@ from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from mptt.models import MPTTModel
from core.choices import ObjectChangeActionChoices
from core.querysets import ObjectChangeQuerySet
@ -160,13 +159,26 @@ class ObjectChange(models.Model):
model = self.changed_object_type.model_class()
attrs = set()
# model_class() returns None when the model's app is no longer installed
# (e.g. a removed plugin); there are no fields to exclude, and the
# issubclass() checks below would raise TypeError on None.
if model is None:
return attrs
# Exclude auto-populated change tracking fields
if issubclass(model, ChangeLoggingMixin):
attrs.update({'created', 'last_updated'})
# Exclude MPTT-internal fields
# Exclude trigger-maintained ltree columns (path and the optional sort_path)
from netbox.models.ltree import LtreeModel
if issubclass(model, LtreeModel):
attrs.update({'path', 'sort_path'})
# Exclude MPTT bookkeeping columns for the deprecated MPTT-backed
# NestedGroupModel still shipped for plugin compatibility.
from mptt.models import MPTTModel
if issubclass(model, MPTTModel):
attrs.update({'level', 'lft', 'rght', 'tree_id'})
attrs.update({'lft', 'rght', 'tree_id', 'level'})
return attrs

View File

@ -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

View File

@ -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

View File

@ -84,6 +84,12 @@ class Job(models.Model):
null=True,
blank=True
)
execution_time = models.DurationField(
verbose_name=_('execution time'),
null=True,
blank=True,
editable=False
)
user = models.ForeignKey(
to=settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
@ -241,6 +247,8 @@ class Job(models.Model):
if error:
self.error = error
self.completed = timezone.now()
if self.started:
self.execution_time = self.completed - self.started
self.save()
# Notify the user (if any) of completion

View File

@ -7,6 +7,7 @@ from core.constants import JOB_LOG_ENTRY_LEVELS
from core.models import Job
from core.tables.columns import BadgeColumn
from netbox.tables import BaseTable, NetBoxTable, columns
from utilities.string import humanize_duration
class JobTable(NetBoxTable):
@ -44,6 +45,9 @@ class JobTable(NetBoxTable):
completed = columns.DateTimeColumn(
verbose_name=_('Completed'),
)
execution_time = tables.Column(
verbose_name=_('Execution Time'),
)
queue_name = tables.Column(
verbose_name=_('Queue'),
)
@ -58,15 +62,24 @@ class JobTable(NetBoxTable):
model = Job
fields = (
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'scheduled', 'interval', 'started',
'completed', 'user', 'queue_name', 'log_entries', 'error', 'job_id',
'completed', 'execution_time', 'user', 'queue_name', 'log_entries', 'error', 'job_id',
)
default_columns = (
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user',
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'execution_time',
'user',
)
def render_log_entries(self, value):
return len(value)
def render_execution_time(self, value):
return humanize_duration(value)
def value_execution_time(self, value):
# Export the recorded execution time as a raw number of seconds, rather than the humanized
# string, as an export is intended for analysis
return round(value.total_seconds(), 3)
class JobLogEntryTable(BaseTable):
timestamp = columns.DateTimeColumn(

View File

@ -206,10 +206,26 @@ class JobTestCase(
status='completed',
queue_name='default',
job_id=uuid.uuid4(),
execution_time=timezone.timedelta(seconds=90),
),
]
)
def test_list_objects_by_execution_time(self):
"""The Job list endpoint supports filtering and ordering by execution_time."""
self.add_permissions('core.view_job')
url = reverse('core-api:job-list')
# Filter: only the completed job has a (90s) execution_time
response = self.client.get(f'{url}?execution_time__gte=60', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['count'], 1)
# Ordering by execution_time should be accepted (NULLs sort to one end)
response = self.client.get(f'{url}?ordering=execution_time', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['count'], 3)
class BackgroundTaskTestCase(RQQueueTestMixin, TestCase):
user_permissions = ()

View File

@ -1,5 +1,5 @@
import uuid
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
@ -7,14 +7,14 @@ from django.test import TestCase
from dcim.models import Site
from ipam.models import IPAddress
from users.models import User
from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests
from utilities.testing import BaseFilterSetTestMixin, ChangeLoggedFilterSetTestMixin
from ..choices import *
from ..filtersets import *
from ..models import *
class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests):
class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
queryset = DataSource.objects.all()
filterset = DataSourceFilterSet
ignore_fields = ('ignore_rules', 'parameters')
@ -82,7 +82,7 @@ class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class DataFileTestCase(TestCase, ChangeLoggedFilterSetTests):
class DataFileTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
queryset = DataFile.objects.all()
filterset = DataFileFilterSet
ignore_fields = ('data',)
@ -148,7 +148,7 @@ class DataFileTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
class ObjectChangeTestCase(TestCase, BaseFilterSetTestMixin):
queryset = ObjectChange.objects.all()
filterset = ObjectChangeFilterSet
ignore_fields = ('message', 'prechange_data', 'postchange_data')
@ -244,7 +244,7 @@ class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
class JobTestCase(TestCase, BaseFilterSetTests):
class JobTestCase(TestCase, BaseFilterSetTestMixin):
queryset = Job.objects.all()
filterset = JobFilterSet
ignore_fields = ('data', 'error', 'log_entries')
@ -262,14 +262,17 @@ class JobTestCase(TestCase, BaseFilterSetTests):
Job(
name='Job 1', job_id=uuid.uuid4(), user=users[0],
notifications=JobNotificationChoices.NOTIFICATION_ALWAYS,
execution_time=timedelta(seconds=30),
),
Job(
name='Job 2', job_id=uuid.uuid4(), user=users[0],
notifications=JobNotificationChoices.NOTIFICATION_ALWAYS,
execution_time=timedelta(seconds=60),
),
Job(
name='Job 3', job_id=uuid.uuid4(), user=users[1],
notifications=JobNotificationChoices.NOTIFICATION_ON_FAILURE,
execution_time=timedelta(seconds=120),
),
Job(
name='Job 4', job_id=uuid.uuid4(), user=users[2],
@ -293,8 +296,17 @@ class JobTestCase(TestCase, BaseFilterSetTests):
]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_execution_time(self):
"""Filter Jobs by execution time (exact value and gte/lte range)."""
params = {'execution_time': timedelta(seconds=60)}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'execution_time__gte': timedelta(seconds=60)}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'execution_time__lte': timedelta(seconds=60)}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class ObjectTypeTestCase(TestCase, BaseFilterSetTests):
class ObjectTypeTestCase(TestCase, BaseFilterSetTestMixin):
queryset = ObjectType.objects.all()
filterset = ObjectTypeFilterSet
ignore_fields = (

View File

@ -1,9 +1,11 @@
import uuid
from datetime import timedelta
from unittest.mock import MagicMock, patch
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from django.test import TestCase
from django.utils import timezone
from core.choices import JobNotificationChoices, JobStatusChoices, ObjectChangeActionChoices
from core.models import DataSource, Job, ObjectType
@ -344,3 +346,38 @@ class JobTestCase(TestCase):
0,
msg=f"Expected no notification for status={status} with notifications=never",
)
@patch('core.models.jobs.job_end')
def test_execution_time_set_on_terminate(self, mock_job_end):
"""
terminate() should set execution_time to (completed - started) for a started job.
"""
job = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER)
job.started = timezone.now() - timedelta(seconds=90)
job.save()
job.terminate(status=JobStatusChoices.STATUS_COMPLETED)
self.assertIsNotNone(job.execution_time)
self.assertEqual(job.execution_time, job.completed - job.started)
@patch('core.models.jobs.job_end')
def test_execution_time_none_before_completion(self, mock_job_end):
"""
A job which has not completed should have a null execution_time.
"""
job = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER)
self.assertIsNone(job.execution_time)
@patch('core.models.jobs.job_end')
def test_execution_time_none_when_never_started(self, mock_job_end):
"""
Terminating a job which was never started (no started timestamp) should leave
execution_time null rather than computing a value from a missing start time.
"""
job = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER)
self.assertIsNone(job.started)
job.terminate(status=JobStatusChoices.STATUS_COMPLETED)
self.assertIsNone(job.execution_time)

View File

@ -1,4 +1,11 @@
from core.models import ObjectChange
import uuid
from datetime import timedelta
from django.test import TestCase
from django.utils import timezone
from core.choices import JobStatusChoices
from core.models import Job, ObjectChange
from core.tables import *
from utilities.testing import TableTestCases
@ -15,6 +22,65 @@ class JobTableTestCase(TableTestCases.StandardTableTestCase):
table = JobTable
class JobExecutionTimeColumnTestCase(TestCase):
"""
Test the rendering and export behavior of JobTable's execution_time column.
"""
@classmethod
def setUpTestData(cls):
now = timezone.now()
Job.objects.bulk_create((
Job(
name='completed-90s', job_id=uuid.uuid4(), status=JobStatusChoices.STATUS_COMPLETED,
started=now - timedelta(seconds=90), completed=now, execution_time=timedelta(seconds=90),
),
Job(
name='completed-subsecond', job_id=uuid.uuid4(), status=JobStatusChoices.STATUS_COMPLETED,
started=now - timedelta(milliseconds=430), completed=now,
execution_time=timedelta(milliseconds=430),
),
Job(
name='completed-long', job_id=uuid.uuid4(), status=JobStatusChoices.STATUS_COMPLETED,
started=now - timedelta(days=2, hours=3), completed=now,
execution_time=timedelta(days=2, hours=3),
),
Job(name='pending', job_id=uuid.uuid4(), status=JobStatusChoices.STATUS_PENDING),
))
def _table(self, name):
table = JobTable(Job.objects.filter(name=name))
table.columns.show('execution_time')
return table
def _render(self, name):
table = self._table(name)
return str(next(iter(table.rows)).get_cell('execution_time'))
def test_render_completed_job(self):
self.assertEqual(self._render('completed-90s'), '1m 30s')
self.assertEqual(self._render('completed-long'), '2d 3h')
def test_render_subsecond_job(self):
# Sub-second jobs report millisecond precision rather than reading as zero
self.assertEqual(self._render('completed-subsecond'), '0.43s')
def test_render_job_without_execution_time(self):
table = self._table('pending')
self.assertEqual(str(next(iter(table.rows)).get_cell('execution_time')), table.default)
def _export_value(self, name):
rows = list(self._table(name).as_values())
return rows[1][rows[0].index('Execution Time')]
def test_export_value_is_raw_seconds(self):
# Exports carry the recorded duration in seconds, not the humanized string
self.assertEqual(self._export_value('completed-90s'), 90.0)
self.assertEqual(self._export_value('completed-subsecond'), 0.43)
def test_export_value_of_job_without_execution_time(self):
self.assertIsNone(self._export_value('pending'))
class ObjectChangeTableTestCase(TableTestCases.StandardTableTestCase):
table = ObjectChangeTable
queryset_sources = [

Some files were not shown because too many files have changed in this diff Show More