Merge branch 'feature' into feature-ip-prefix-link
Some checks are pending
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.11) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run

This commit is contained in:
Daniel Sheppard 2025-08-07 21:33:58 -05:00 committed by GitHub
commit b54196f595
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
288 changed files with 13217 additions and 9108 deletions

View File

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

View File

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

1
.gitignore vendored
View File

@ -9,6 +9,7 @@ yarn-error.log*
/netbox/netbox/configuration.py /netbox/netbox/configuration.py
/netbox/netbox/ldap_config.py /netbox/netbox/ldap_config.py
/netbox/local/* /netbox/local/*
/netbox/media
/netbox/reports/* /netbox/reports/*
!/netbox/reports/__init__.py !/netbox/reports/__init__.py
/netbox/scripts/* /netbox/scripts/*

View File

@ -6,7 +6,7 @@
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://img.shields.io/github/contributors/netbox-community/netbox?color=blue" alt="Contributors" /></a> <a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://img.shields.io/github/contributors/netbox-community/netbox?color=blue" alt="Contributors" /></a>
<a href="https://github.com/netbox-community/netbox/stargazers"><img src="https://img.shields.io/github/stars/netbox-community/netbox?style=flat" alt="GitHub stars" /></a> <a href="https://github.com/netbox-community/netbox/stargazers"><img src="https://img.shields.io/github/stars/netbox-community/netbox?style=flat" alt="GitHub stars" /></a>
<a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-15-blue" alt="Languages supported" /></a> <a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-15-blue" alt="Languages supported" /></a>
<a href="https://github.com/netbox-community/netbox/actions/workflows/ci.yml"><img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=main" alt="CI status" /></a> <a href="https://github.com/netbox-community/netbox/actions/workflows/ci.yml"><img src="https://github.com/netbox-community/netbox/actions/workflows/ci.yml/badge.svg" alt="CI status" /></a>
<p> <p>
<strong><a href="https://netboxlabs.com/community/">NetBox Community</a></strong> | <strong><a href="https://netboxlabs.com/community/">NetBox Community</a></strong> |
<strong><a href="https://netboxlabs.com/netbox-cloud/">NetBox Cloud</a></strong> | <strong><a href="https://netboxlabs.com/netbox-cloud/">NetBox Cloud</a></strong> |

View File

@ -1,3 +1,7 @@
# Shell text coloring
# https://github.com/tartley/colorama/blob/master/CHANGELOG.rst
colorama
# The Python web framework on which NetBox is built # The Python web framework on which NetBox is built
# https://docs.djangoproject.com/en/stable/releases/ # https://docs.djangoproject.com/en/stable/releases/
Django==5.2.* Django==5.2.*
@ -8,12 +12,18 @@ django-cors-headers
# Runtime UI tool for debugging Django # Runtime UI tool for debugging Django
# https://github.com/jazzband/django-debug-toolbar/blob/main/docs/changes.rst # https://github.com/jazzband/django-debug-toolbar/blob/main/docs/changes.rst
django-debug-toolbar # django-debug-toolbar v6.0.0 raises "Attribute Error at /: 'function' object has no attribute 'set'"
# see https://github.com/netbox-community/netbox/issues/19974
django-debug-toolbar==5.2.0
# Library for writing reusable URL query filters # Library for writing reusable URL query filters
# https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst # https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst
django-filter django-filter
# Django Debug Toolbar extension for GraphiQL
# https://github.com/flavors/django-graphiql-debug-toolbar/blob/main/CHANGES.rst
django-graphiql-debug-toolbar
# HTMX utilities for Django # HTMX utilities for Django
# https://django-htmx.readthedocs.io/en/latest/changelog.html # https://django-htmx.readthedocs.io/en/latest/changelog.html
django-htmx django-htmx
@ -108,6 +118,7 @@ nh3
# Fork of PIL (Python Imaging Library) for image processing # Fork of PIL (Python Imaging Library) for image processing
# https://github.com/python-pillow/Pillow/releases # https://github.com/python-pillow/Pillow/releases
# https://pillow.readthedocs.io/en/stable/releasenotes/
Pillow Pillow
# PostgreSQL database adapter for Python # PostgreSQL database adapter for Python
@ -126,13 +137,17 @@ requests
# https://github.com/rq/rq/blob/master/CHANGES.md # https://github.com/rq/rq/blob/master/CHANGES.md
rq rq
# Django app for social-auth-core
# https://github.com/python-social-auth/social-app-django/blob/master/CHANGELOG.md
social-auth-app-django
# Social authentication framework # Social authentication framework
# https://github.com/python-social-auth/social-core/blob/master/CHANGELOG.md # https://github.com/python-social-auth/social-core/blob/master/CHANGELOG.md
social-auth-core social-auth-core
# Django app for social-auth-core # Image thumbnail generation
# https://github.com/python-social-auth/social-app-django/blob/master/CHANGELOG.md # https://github.com/jazzband/sorl-thumbnail/blob/master/CHANGES.rst
social-auth-app-django sorl-thumbnail
# Strawberry GraphQL # Strawberry GraphQL
# https://github.com/strawberry-graphql/strawberry/blob/main/CHANGELOG.md # https://github.com/strawberry-graphql/strawberry/blob/main/CHANGELOG.md
@ -140,8 +155,7 @@ strawberry-graphql
# Strawberry GraphQL Django extension # Strawberry GraphQL Django extension
# https://github.com/strawberry-graphql/strawberry-django/releases # https://github.com/strawberry-graphql/strawberry-django/releases
# See #19771 strawberry-graphql-django
strawberry-graphql-django==0.60.0
# SVG image rendering (used for rack elevations) # SVG image rendering (used for rack elevations)
# https://github.com/mozman/svgwrite/blob/master/NEWS.rst # https://github.com/mozman/svgwrite/blob/master/NEWS.rst

View File

@ -1,17 +0,0 @@
[Unit]
Description=NetBox Housekeeping Service
Documentation=https://docs.netbox.dev/
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=netbox
Group=netbox
WorkingDirectory=/opt/netbox
ExecStart=/opt/netbox/venv/bin/python /opt/netbox/netbox/manage.py housekeeping
[Install]
WantedBy=multi-user.target

View File

@ -1,9 +0,0 @@
#!/bin/sh
# This shell script invokes NetBox's housekeeping management command, which
# intended to be run nightly. This script can be copied into your system's
# daily cron directory (e.g. /etc/cron.daily), or referenced directly from
# within the cron configuration file.
#
# If NetBox has been installed into a nonstandard location, update the paths
# below.
/opt/netbox/venv/bin/python /opt/netbox/netbox/manage.py housekeeping

View File

@ -1,13 +0,0 @@
[Unit]
Description=NetBox Housekeeping Timer
Documentation=https://docs.netbox.dev/
After=network-online.target
Wants=network-online.target
[Timer]
OnCalendar=daily
AccuracySec=1h
Persistent=true
[Install]
WantedBy=multi-user.target

View File

@ -1,49 +0,0 @@
# Housekeeping
NetBox includes a `housekeeping` management command that should be run nightly. This command handles:
* Clearing expired authentication sessions from the database
* Deleting changelog records older than the configured [retention time](../configuration/miscellaneous.md#changelog_retention)
* Deleting job result records older than the configured [retention time](../configuration/miscellaneous.md#job_retention)
* Check for new NetBox releases (if [`RELEASE_CHECK_URL`](../configuration/miscellaneous.md#release_check_url) is set)
This command can be invoked directly, or by using the shell script provided at `/opt/netbox/contrib/netbox-housekeeping.sh`.
## Scheduling
### Using Cron
This script can be linked from your cron scheduler's daily jobs directory (e.g. `/etc/cron.daily`) or referenced directly within the cron configuration file.
```shell
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
```
!!! note
On Debian-based systems, be sure to omit the `.sh` file extension when linking to the script from within a cron directory. Otherwise, the task may not run.
### Using Systemd
First, create symbolic links for the systemd service and timer files. Link the existing service and timer files from the `/opt/netbox/contrib/` directory to the `/etc/systemd/system/` directory:
```bash
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.service /etc/systemd/system/netbox-housekeeping.service
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.timer /etc/systemd/system/netbox-housekeeping.timer
```
Then, reload the systemd configuration and enable the timer to start automatically at boot:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now netbox-housekeeping.timer
```
Check the status of your timer by running:
```bash
sudo systemctl list-timers --all
```
This command will show a list of all timers, including your `netbox-housekeeping.timer`. Make sure the timer is active and properly scheduled.
That's it! Your NetBox housekeeping service is now configured to run daily using systemd.

View File

@ -18,10 +18,10 @@ pg_dump --username netbox --password --host localhost netbox > netbox.sql
!!! note !!! note
You may need to change the username, host, and/or database in the command above to match your installation. You may need to change the username, host, and/or database in the command above to match your installation.
When replicating a production database for development purposes, you may find it convenient to exclude changelog data, which can easily account for the bulk of a database's size. To do this, exclude the `extras_objectchange` table data from the export. The table will still be included in the output file, but will not be populated with any data. When replicating a production database for development purposes, you may find it convenient to exclude changelog data, which can easily account for the bulk of a database's size. To do this, exclude the `core_objectchange` table data from the export. The table will still be included in the output file, but will not be populated with any data.
```no-highlight ```no-highlight
pg_dump ... --exclude-table-data=extras_objectchange netbox > netbox.sql pg_dump ... --exclude-table-data=core_objectchange netbox > netbox.sql
``` ```
### Load an Exported Database ### Load an Exported Database

View File

@ -108,8 +108,6 @@ By default, NetBox will prevent the creation of duplicate prefixes and IP addres
## EVENTS_PIPELINE ## EVENTS_PIPELINE
!!! info "This parameter was introduced in NetBox v4.2."
Default: `['extras.events.process_event_queue',]` Default: `['extras.events.process_event_queue',]`
NetBox will call dotted paths to the functions listed here for events (create, update, delete) on models as well as when custom EventRules are fired. NetBox will call dotted paths to the functions listed here for events (create, update, delete) on models as well as when custom EventRules are fired.

View File

@ -34,8 +34,6 @@ See the [`DATABASES`](#databases) configuration below for usage.
## DATABASES ## DATABASES
!!! info "This parameter was introduced in NetBox v4.3."
NetBox requires access to a PostgreSQL 14 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: NetBox requires access to a PostgreSQL 14 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 ```python

View File

@ -14,8 +14,6 @@ BASE_PATH = 'netbox/'
## DATABASE_ROUTERS ## DATABASE_ROUTERS
!!! info "This parameter was introduced in NetBox v4.3."
Default: `[]` (empty list) Default: `[]` (empty list)
An iterable of [database routers](https://docs.djangoproject.com/en/stable/topics/db/multi-db/) to use for automatically selecting the appropriate database(s) for a query. This is useful only when [multiple databases](./required-parameters.md#databases) have been configured. An iterable of [database routers](https://docs.djangoproject.com/en/stable/topics/db/multi-db/) to use for automatically selecting the appropriate database(s) for a query. This is useful only when [multiple databases](./required-parameters.md#databases) have been configured.
@ -72,6 +70,16 @@ Email is sent from NetBox only for critical events or if configured for [logging
--- ---
## HOSTNAME
!!! info "This parameter was introduced in NetBox v4.4."
Default: System hostname
The hostname displayed in the user interface identifying the system on which NetBox is running. If not defined, this defaults to the system hostname as reported by Python's `platform.node()`.
---
## HTTP_PROXIES ## HTTP_PROXIES
Default: `None` Default: `None`
@ -158,6 +166,8 @@ LOGGING = {
* `netbox.<app>.<model>` - Generic form for model-specific log messages * `netbox.<app>.<model>` - Generic form for model-specific log messages
* `netbox.auth.*` - Authentication events * `netbox.auth.*` - Authentication events
* `netbox.api.views.*` - Views which handle business logic for the REST API * `netbox.api.views.*` - Views which handle business logic for the REST API
* `netbox.event_rules` - Event rules
* `netbox.jobs.*` - Background jobs
* `netbox.reports.*` - Report execution (`module.name`) * `netbox.reports.*` - Report execution (`module.name`)
* `netbox.scripts.*` - Custom script execution (`module.name`) * `netbox.scripts.*` - Custom script execution (`module.name`)
* `netbox.views.*` - Views which handle business logic for the web UI * `netbox.views.*` - Views which handle business logic for the web UI
@ -174,8 +184,6 @@ The file path to the location where media files (such as image attachments) are
## PROXY_ROUTERS ## PROXY_ROUTERS
!!! info "This parameter was introduced in NetBox v4.3."
Default: `["utilities.proxy.DefaultProxyRouter"]` Default: `["utilities.proxy.DefaultProxyRouter"]`
A list of Python classes responsible for determining which proxy server(s) to use for outbound HTTP requests. Each item in the list can be the class itself or the dotted path to the class. A list of Python classes responsible for determining which proxy server(s) to use for outbound HTTP requests. Each item in the list can be the class itself or the dotted path to the class.

View File

@ -275,6 +275,15 @@ Stores a numeric integer. Options include:
* `min_value` - Minimum value * `min_value` - Minimum value
* `max_value` - Maximum value * `max_value` - Maximum value
### DecimalVar
Stores a numeric decimal. Options include:
* `min_value` - Minimum value
* `max_value` - Maximum value
* `max_digits` - Maximum number of digits, including decimal places
* `decimal_places` - Number of decimal places
### BooleanVar ### BooleanVar
A true/false flag. This field has no options beyond the defaults listed above. A true/false flag. This field has no options beyond the defaults listed above.

View File

@ -147,7 +147,7 @@ For UI development you will need to review the [Web UI Development Guide](web-ui
## Populating Demo Data ## Populating Demo Data
Once you have your development environment up and running, it might be helpful to populate some "dummy" data to make interacting with the UI and APIs more convenient. Check out the [netbox-demo-data](https://github.com/netbox-community/netbox-demo-data) repo on GitHub, which houses a collection of sample data that can be easily imported to any new NetBox deployment. (This sample data is used to populate the public demo instance at <https://demo.netbox.dev>.) Once you have your development environment up and running, it might be helpful to populate some "dummy" data to make interacting with the UI and APIs more convenient. Check out the [netbox-demo-data](https://github.com/netbox-community/netbox-demo-data) repo on GitHub, which houses a collection of sample data that can be easily imported to any new NetBox deployment. This sample data is used to populate the [public demo instance](https://demo.netbox.dev).
The demo data is provided in JSON format and loaded into an empty database using Django's `loaddata` management command. Consult the demo data repo's `README` file for complete instructions on populating the data. The demo data is provided in JSON format and loaded into an empty database using Django's `loaddata` management command. Consult the demo data repo's `README` file for complete instructions on populating the data.

View File

@ -2,9 +2,9 @@
NetBox includes the ability to execute certain functions as background tasks. These include: NetBox includes the ability to execute certain functions as background tasks. These include:
* [Report](../customization/reports.md) execution
* [Custom script](../customization/custom-scripts.md) execution * [Custom script](../customization/custom-scripts.md) execution
* Synchronization of [remote data sources](../integrations/synchronized-data.md) * Synchronization of [remote data sources](../integrations/synchronized-data.md)
* Housekeeping tasks
Additionally, NetBox plugins can enqueue their own background tasks. This is accomplished using the [Job model](../models/core/job.md). Background tasks are executed by the `rqworker` process(es). Additionally, NetBox plugins can enqueue their own background tasks. This is accomplished using the [Job model](../models/core/job.md). Background tasks are executed by the `rqworker` process(es).

View File

@ -8,6 +8,12 @@ When a request is made, a UUID is generated and attached to any change records r
Change records are exposed in the API via the read-only endpoint `/api/extras/object-changes/`. They may also be exported via the web UI in CSV format. Change records are exposed in the API via the read-only endpoint `/api/extras/object-changes/`. They may also be exported via the web UI in CSV format.
## User Messages
!!! info "This feature was introduced in NetBox v4.4."
When creating, modifying, or deleting an object in NetBox, a user has the option of recording an arbitrary message that will appear in the change record. This can be helpful to capture additional context, such as the reason for the change.
## Correlating Changes by Request ## Correlating Changes by Request
Every request made to NetBox is assigned a random unique ID that can be used to correlate change records. For example, if you change the status of three sites using the UI's bulk edit feature, you will see three new change records (one for each site) all referencing the same request ID. This shows that all three changes were made as part of the same request. Every request made to NetBox is assigned a random unique ID that can be used to correlate change records. For example, if you change the status of three sites using the UI's bulk edit feature, you will see three new change records (one for each site) all referencing the same request ID. This shows that all three changes were made as part of the same request.

View File

@ -62,8 +62,8 @@ VRF modeling in NetBox very closely follows what you find in real-world network
An often overlooked component of IPAM, NetBox also tracks autonomous system (AS) numbers and their assignment to sites. Both 16- and 32-bit AS numbers are supported, and like aggregates each ASN is assigned to an authoritative RIR. An often overlooked component of IPAM, NetBox also tracks autonomous system (AS) numbers and their assignment to sites. Both 16- and 32-bit AS numbers are supported, and like aggregates each ASN is assigned to an authoritative RIR.
## Service Mapping ## Application Service Mapping
NetBox models network applications as discrete service objects associated with devices and/or virtual machines, and optionally with specific IP addresses attached to those parent objects. These can be used to catalog the applications running on your network for reference by other objects or integrated tools. NetBox models network applications as discrete service objects associated with devices and/or virtual machines, and optionally with specific IP addresses attached to those parent objects. These can be used to catalog the applications running on your network for reference by other objects or integrated tools.
To model services in NetBox, begin by creating a service template defining the name, protocol, and port number(s) on which the service listens. This template can then be easily instantiated to "attach" new services to a device or virtual machine. It's also possible to create new services by hand, without a template, however this approach can be tedious. To model application services in NetBox, begin by creating an application service template defining the name, protocol, and port number(s) on which the service listens. This template can then be easily instantiated to "attach" new services to a device or virtual machine. It's also possible to create new application services by hand, without a template, however this approach can be tedious.

View File

@ -264,18 +264,6 @@ cd /opt/netbox/netbox
python3 manage.py createsuperuser python3 manage.py createsuperuser
``` ```
## Schedule the Housekeeping Task
NetBox includes a `housekeeping` management command that handles some recurring cleanup tasks, such as clearing out old sessions and expired change records. Although this command may be run manually, it is recommended to configure a scheduled job using the system's `cron` daemon or a similar utility.
A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be copied to or linked from your system's daily cron task directory, or included within the crontab directly. (If installing NetBox into a nonstandard path, be sure to update the system paths within this script first.)
```shell
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
```
See the [housekeeping documentation](../administration/housekeeping.md) for further details.
## Test the Application ## Test the Application
At this point, we should be able to run NetBox's development server for testing. We can check by starting a development instance locally. At this point, we should be able to run NetBox's development server for testing. We can check by starting a development instance locally.
@ -302,13 +290,6 @@ Quit the server with CONTROL-C.
Next, connect to the name or IP of the server (as defined in `ALLOWED_HOSTS`) on port 8000; for example, <http://127.0.0.1:8000/>. You should be greeted with the NetBox home page. Try logging in using the username and password specified when creating a superuser. Next, connect to the name or IP of the server (as defined in `ALLOWED_HOSTS`) on port 8000; for example, <http://127.0.0.1:8000/>. You should be greeted with the NetBox home page. Try logging in using the username and password specified when creating a superuser.
!!! note
By default RHEL based distros will likely block your testing attempts with firewalld. The development server port can be opened with `firewall-cmd` (add `--permanent` if you want the rule to survive server restarts):
```no-highlight
firewall-cmd --zone=public --add-port=8000/tcp
```
!!! danger "Not for production use" !!! danger "Not for production use"
The development server is for development and testing purposes only. It is neither performant nor secure enough for production use. **Do not use it in production.** The development server is for development and testing purposes only. It is neither performant nor secure enough for production use. **Do not use it in production.**

View File

@ -135,7 +135,7 @@ Check out the desired release by specifying its tag. For example:
``` ```
cd /opt/netbox && \ cd /opt/netbox && \
sudo git fetch && \ sudo git fetch --tags && \
sudo git checkout v4.2.7 sudo git checkout v4.2.7
``` ```
@ -183,13 +183,3 @@ Finally, restart the gunicorn and RQ services:
```no-highlight ```no-highlight
sudo systemctl restart netbox netbox-rq sudo systemctl restart netbox netbox-rq
``` ```
## 6. Verify Housekeeping Scheduling
If upgrading from a release prior to NetBox v3.0, check that a cron task (or similar scheduled process) has been configured to run NetBox's nightly housekeeping command. A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be linked from your system's daily cron task directory, or included within the crontab directly. (If NetBox has been installed in a nonstandard path, be sure to update the system paths within this script first.)
```shell
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
```
See the [housekeeping documentation](../administration/housekeeping.md) for further details.

View File

@ -11,6 +11,8 @@ NetBox makes use of the [django-prometheus](https://github.com/korfuri/django-pr
- Per model insert, update, and delete counters - Per model insert, update, and delete counters
- Per view request counters - Per view request counters
- Per view request latency histograms - Per view request latency histograms
- REST API requests (by endpoint & method)
- GraphQL API requests
- Request body size histograms - Request body size histograms
- Response body size histograms - Response body size histograms
- Response code counters - Response code counters

View File

@ -608,6 +608,28 @@ http://netbox/api/dcim/sites/ \
!!! note !!! 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. 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.
## Changelog Messages
!!! info "This feature was introduced in NetBox v4.4."
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. Beginning in NetBox v4.4, 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.
For example, the following API request will create a new site and record a message in the resulting changelog entry:
```no-highlight
curl -s -X POST \
-H "Authorization: Token $TOKEN" \
-H "Content-Type: application/json" \
http://netbox/api/dcim/sites/ \
--data '{
"name": "Site A",
"slug": "site-a",
"changelog_message": "Adding a site for ticket #4137"
}'
```
This approach works when creating, modifying, or deleting objects, either individually or in bulk.
## Uploading Files ## Uploading Files
As JSON does not support the inclusion of binary data, files cannot be uploaded using JSON-formatted API requests. Instead, we can use form data encoding to attach a local file. As JSON does not support the inclusion of binary data, files cannot be uploaded using JSON-formatted API requests. Instead, we can use form data encoding to attach a local file.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

@ -38,8 +38,6 @@ The operational status of the circuit. By default, the following statuses are av
### Distance ### Distance
!!! info "This field was introduced in NetBox v4.2."
The distance between the circuit's two endpoints, including a unit designation (e.g. 100 meters or 25 feet). The distance between the circuit's two endpoints, including a unit designation (e.g. 100 meters or 25 feet).
### Description ### Description

View File

@ -1,7 +1,5 @@
# Virtual Circuits # Virtual Circuits
!!! info "This feature was introduced in NetBox v4.2."
A virtual circuit can connect two or more interfaces atop a set of decoupled physical connections. For example, it's very common to form a virtual connection between two virtual interfaces, each of which is bound to a physical interface on its respective device and physically connected to a [provider network](./providernetwork.md) via an independent [physical circuit](./circuit.md). A virtual circuit can connect two or more interfaces atop a set of decoupled physical connections. For example, it's very common to form a virtual connection between two virtual interfaces, each of which is bound to a physical interface on its respective device and physically connected to a [provider network](./providernetwork.md) via an independent [physical circuit](./circuit.md).
## Fields ## Fields

View File

@ -1,7 +1,5 @@
# Virtual Circuit Terminations # Virtual Circuit Terminations
!!! info "This feature was introduced in NetBox v4.2."
This model represents the connection of a virtual [interface](../dcim/interface.md) to a [virtual circuit](./virtualcircuit.md). This model represents the connection of a virtual [interface](../dcim/interface.md) to a [virtual circuit](./virtualcircuit.md).
## Fields ## Fields

View File

@ -46,8 +46,6 @@ A set of rules (one per line) identifying filenames to ignore during synchroniza
### Sync Interval ### Sync Interval
!!! info "This field was introduced in NetBox v4.3."
The interval at which the data source should automatically synchronize. If not set, the data source must be synchronized manually. The interval at which the data source should automatically synchronize. If not set, the data source must be synchronized manually.
### Last Synced ### Last Synced

View File

@ -6,8 +6,6 @@ Devices can be organized by functional roles, which are fully customizable by th
### Parent ### Parent
!!! info "This field was introduced in NetBox v4.3."
The parent role of which this role is a child (optional). The parent role of which this role is a child (optional).
### Name ### Name

View File

@ -126,8 +126,6 @@ The tagged VLANs which are configured to be carried by this interface. Valid onl
### Q-in-Q SVLAN ### Q-in-Q SVLAN
!!! info "This field was introduced in NetBox v4.2."
The assigned service VLAN (for Q-in-Q/802.1ad interfaces). The assigned service VLAN (for Q-in-Q/802.1ad interfaces).
### Wireless Role ### Wireless Role
@ -155,6 +153,4 @@ The [wireless LANs](../wireless/wirelesslan.md) for which this interface carries
### VLAN Translation Policy ### VLAN Translation Policy
!!! info "This field was introduced in NetBox v4.2."
The [VLAN translation policy](../ipam/vlantranslationpolicy.md) that applies to this interface (optional). The [VLAN translation policy](../ipam/vlantranslationpolicy.md) that applies to this interface (optional).

View File

@ -30,8 +30,6 @@ An alternative physical label identifying the inventory item.
### Status ### Status
!!! info "This field was introduced in NetBox v4.2."
The inventory item's operational status. The inventory item's operational status.
### Role ### Role

View File

@ -1,7 +1,5 @@
# MAC Addresses # MAC Addresses
!!! info "This feature was introduced in NetBox v4.2."
A MAC address object in NetBox comprises a single Ethernet link layer address, and represents a MAC address as reported by or assigned to a network interface. MAC addresses can be assigned to [device](../dcim/device.md) and [virtual machine](../virtualization/virtualmachine.md) interfaces. A MAC address can be specified as the primary MAC address for a given device or VM interface. A MAC address object in NetBox comprises a single Ethernet link layer address, and represents a MAC address as reported by or assigned to a network interface. MAC addresses can be assigned to [device](../dcim/device.md) and [virtual machine](../virtualization/virtualmachine.md) interfaces. A MAC address can be specified as the primary MAC address for a given device or VM interface.
Most interfaces have only a single MAC address, hard-coded at the factory. However, on some devices (particularly virtual interfaces) it is possible to assign additional MAC addresses or change existing ones. For this reason NetBox allows multiple MACAddress objects to be assigned to a single interface. Most interfaces have only a single MAC address, hard-coded at the factory. However, on some devices (particularly virtual interfaces) it is possible to assign additional MAC addresses or change existing ones. For this reason NetBox allows multiple MACAddress objects to be assigned to a single interface.

View File

@ -1,7 +1,5 @@
# Module Type Profiles # Module Type Profiles
!!! info "This model was introduced in NetBox v4.3."
Each [module type](./moduletype.md) may optionally be assigned a profile according to its classification. A profile can extend module types with user-configured attributes. For example, you might want to specify the input current and voltage of a power supply, or the clock speed and number of cores for a processor. Each [module type](./moduletype.md) may optionally be assigned a profile according to its classification. A profile can extend module types with user-configured attributes. For example, you might want to specify the input current and voltage of a power supply, or the clock speed and number of cores for a processor.
Module type attributes are managed via the configuration of a [JSON schema](https://json-schema.org/) on the profile. For example, the following schema introduces three module type attributes, two of which are designated as required attributes. Module type attributes are managed via the configuration of a [JSON schema](https://json-schema.org/) on the profile. For example, the following schema introduces three module type attributes, two of which are designated as required attributes.

View File

@ -40,12 +40,8 @@ The operational status of the power outlet. By default, the following statuses a
!!! tip "Custom power outlet statuses" !!! tip "Custom power outlet statuses"
Additional power outlet statuses may be defined by setting `PowerOutlet.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter. Additional power outlet statuses may be defined by setting `PowerOutlet.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
!!! info "This field was introduced in NetBox v4.3."
### Color ### Color
!!! info "This field was introduced in NetBox v4.2."
The power outlet's color (optional). The power outlet's color (optional).
### Power Port ### Power Port

View File

@ -42,8 +42,6 @@ The number of the numerically lowest unit in the rack. This value defaults to on
The external width, height and depth of the rack can be tracked to aid in floorplan calculations. These measurements must be designated in either millimeters or inches. The external width, height and depth of the rack can be tracked to aid in floorplan calculations. These measurements must be designated in either millimeters or inches.
!!! info "The `outer_height` field was introduced in NetBox v4.3."
### Mounting Depth ### Mounting Depth
The maximum depth of a mounted device that the rack can accommodate, in millimeters. For four-post frames or cabinets, this is the horizontal distance between the front and rear vertical rails. (Note that this measurement does _not_ include space between the rails and the cabinet doors.) The maximum depth of a mounted device that the rack can accommodate, in millimeters. For four-post frames or cabinets, this is the horizontal distance between the front and rear vertical rails. (Note that this measurement does _not_ include space between the rails and the cabinet doors.)

View File

@ -24,26 +24,25 @@ Jinja2 template code, if being defined locally rather than replicated from a dat
A dictionary of any additional parameters to pass when instantiating the [Jinja2 environment](https://jinja.palletsprojects.com/en/3.1.x/api/#jinja2.Environment). Jinja2 supports various optional parameters which can be used to modify its default behavior. A dictionary of any additional parameters to pass when instantiating the [Jinja2 environment](https://jinja.palletsprojects.com/en/3.1.x/api/#jinja2.Environment). Jinja2 supports various optional parameters which can be used to modify its default behavior.
### MIME Type The `undefined` and `finalize` Jinja environment parameters, which must reference a Python class or function, can define a dotted path to the desired resource. For example:
!!! info "This field was introduced in NetBox v4.3." ```json
{
"undefined": "jinja2.StrictUndefined"
}
```
### MIME Type
The MIME type to indicate in the response when rendering the configuration template (optional). Defaults to `text/plain`. The MIME type to indicate in the response when rendering the configuration template (optional). Defaults to `text/plain`.
### File Name ### File Name
!!! info "This field was introduced in NetBox v4.3."
The file name to give to the rendered export file (optional). The file name to give to the rendered export file (optional).
### File Extension ### File Extension
!!! info "This field was introduced in NetBox v4.3."
The file extension to append to the file name in the response (optional). The file extension to append to the file name in the response (optional).
### As Attachment ### As Attachment
!!! info "This field was introduced in NetBox v4.3."
If selected, the rendered content will be returned as a file attachment, rather than displayed directly in-browser (where supported). If selected, the rendered content will be returned as a file attachment, rather than displayed directly in-browser (where supported).

View File

@ -22,10 +22,16 @@ Jinja2 template code for rendering the exported data.
### Environment Parameters ### Environment Parameters
!!! info "This field was introduced in NetBox v4.3."
A dictionary of any additional parameters to pass when instantiating the [Jinja2 environment](https://jinja.palletsprojects.com/en/3.1.x/api/#jinja2.Environment). Jinja2 supports various optional parameters which can be used to modify its default behavior. A dictionary of any additional parameters to pass when instantiating the [Jinja2 environment](https://jinja.palletsprojects.com/en/3.1.x/api/#jinja2.Environment). Jinja2 supports various optional parameters which can be used to modify its default behavior.
The `undefined` and `finalize` Jinja environment parameters, which must reference a Python class or function, can define a dotted path to the desired resource. For example:
```json
{
"undefined": "jinja2.StrictUndefined"
}
```
### MIME Type ### MIME Type
The MIME type to indicate in the response when rendering the export template (optional). Defaults to `text/plain`. The MIME type to indicate in the response when rendering the export template (optional). Defaults to `text/plain`.

View File

@ -20,8 +20,6 @@ The color to use when displaying the tag in the NetBox UI.
A numeric weight employed to influence the ordering of tags. Tags with a lower weight will be listed before those with higher weights. Values must be within the range **0** to **32767**. A numeric weight employed to influence the ordering of tags. Tags with a lower weight will be listed before those with higher weights. Values must be within the range **0** to **32767**.
!!! info "This field was introduced in NetBox v4.3."
### Object Types ### Object Types
The assignment of a tag may be limited to a prescribed set of objects. For example, it may be desirable to limit the application of a specific tag to only devices and virtual machines. The assignment of a tag may be limited to a prescribed set of objects. For example, it may be desirable to limit the application of a specific tag to only devices and virtual machines.

View File

@ -1,14 +1,18 @@
# Services # Application Services
A service represents a layer seven application available on a device or virtual machine. For example, a service might be created in NetBox to represent an HTTP server running on TCP/8000. Each service may optionally be further bound to one or more specific interfaces assigned to the selected device or virtual machine. An application service represents a layer seven application available on a device or virtual machine. For example, a service might be created in NetBox to represent an HTTP server running on TCP/8000. Each service may optionally be further bound to one or more specific interfaces assigned to the selected device or virtual machine.
To aid in the efficient creation of services, users may opt to first create a [service template](./servicetemplate.md) from which service definitions can be quickly replicated. To aid in the efficient creation of application services, users may opt to first create an [application service template](./servicetemplate.md) from which service definitions can be quickly replicated.
!!! note "Changed in NetBox v4.4"
Previously, application services were referred to simply as "services". The name has been changed in the UI to better reflect their intended use. There is no change to the name of the model or in any programmatic NetBox APIs.
## Fields ## Fields
### Parent ### Parent
The parent object to which the service is assigned. This must be one of [Device](../dcim/device.md), The parent object to which the application service is assigned. This must be one of [Device](../dcim/device.md),
[VirtualMachine](../virtualization/virtualmachine.md), or [FHRP Group](./fhrpgroup.md). [VirtualMachine](../virtualization/virtualmachine.md), or [FHRP Group](./fhrpgroup.md).
!!! note "Changed in NetBox v4.3" !!! note "Changed in NetBox v4.3"

View File

@ -1,6 +1,10 @@
# Service Templates # Application Service Templates
Service templates can be used to instantiate [services](./service.md) on [devices](../dcim/device.md) and [virtual machines](../virtualization/virtualmachine.md). Application service templates can be used to instantiate [application services](./service.md) on [devices](../dcim/device.md) and [virtual machines](../virtualization/virtualmachine.md).
!!! note "Changed in NetBox v4.4"
Previously, application service templates were referred to simply as "service templates". The name has been changed in the UI to better reflect their intended use. There is no change to the name of the model or in any programmatic NetBox APIs.
## Fields ## Fields

View File

@ -25,16 +25,15 @@ The user-defined functional [role](./role.md) assigned to the VLAN.
### VLAN Group or Site ### VLAN Group or Site
!!! warning "Site assignment is deprecated"
The assignment of individual VLANs directly to a site has been deprecated. This ability will be removed in a future NetBox release. Users are strongly encouraged to utilize VLAN groups, which have the added benefit of supporting the assignment of a VLAN to multiple sites.
The [VLAN group](./vlangroup.md) or [site](../dcim/site.md) to which the VLAN is assigned. The [VLAN group](./vlangroup.md) or [site](../dcim/site.md) to which the VLAN is assigned.
### Q-in-Q Role ### Q-in-Q Role
!!! info "This field was introduced in NetBox v4.2."
For VLANs which comprise a Q-in-Q/IEEE 802.1ad topology, this field indicates whether the VLAN is treated as a service or customer VLAN. For VLANs which comprise a Q-in-Q/IEEE 802.1ad topology, this field indicates whether the VLAN is treated as a service or customer VLAN.
### Q-in-Q Service VLAN ### Q-in-Q Service VLAN
!!! info "This field was introduced in NetBox v4.2."
The designated parent service VLAN for a Q-in-Q customer VLAN. This may be set only for Q-in-Q custom VLANs. The designated parent service VLAN for a Q-in-Q customer VLAN. This may be set only for Q-in-Q custom VLANs.

View File

@ -1,7 +1,5 @@
# VLAN Translation Policies # VLAN Translation Policies
!!! info "This feature was introduced in NetBox v4.2."
VLAN translation is a feature that consists of VLAN translation policies and [VLAN translation rules](./vlantranslationrule.md). Many rules can belong to a policy, and each rule defines a mapping of a local to remote VLAN ID (VID). A policy can then be assigned to an [Interface](../dcim/interface.md) or [VMInterface](../virtualization/vminterface.md), and all VLAN translation rules associated with that policy will be visible in the interface details. VLAN translation is a feature that consists of VLAN translation policies and [VLAN translation rules](./vlantranslationrule.md). Many rules can belong to a policy, and each rule defines a mapping of a local to remote VLAN ID (VID). A policy can then be assigned to an [Interface](../dcim/interface.md) or [VMInterface](../virtualization/vminterface.md), and all VLAN translation rules associated with that policy will be visible in the interface details.
There are uniqueness constraints on `(policy, local_vid)` and on `(policy, remote_vid)` in the `VLANTranslationRule` model. Thus, you cannot have multiple rules linked to the same policy that have the same local VID or the same remote VID. A set of policies and rules might look like this: There are uniqueness constraints on `(policy, local_vid)` and on `(policy, remote_vid)` in the `VLANTranslationRule` model. Thus, you cannot have multiple rules linked to the same policy that have the same local VID or the same remote VID. A set of policies and rules might look like this:

View File

@ -1,7 +1,5 @@
# VLAN Translation Rules # VLAN Translation Rules
!!! info "This feature was introduced in NetBox v4.2."
A VLAN translation rule represents a one-to-one mapping of a local VLAN ID (VID) to a remote VID. Many rules can belong to a single policy. A VLAN translation rule represents a one-to-one mapping of a local VLAN ID (VID) to a remote VID. Many rules can belong to a single policy.
See [VLAN translation policies](./vlantranslationpolicy.md) for an overview of the VLAN Translation feature. See [VLAN translation policies](./vlantranslationpolicy.md) for an overview of the VLAN Translation feature.

View File

@ -1,6 +1,6 @@
## Interfaces ## Interfaces
[Virtual machine](./virtualmachine.md) interfaces behave similarly to device [interfaces](../dcim/interface.md): They can be assigned to VRFs, may have IP addresses, VLANs, and services attached to them, and so on. However, given their virtual nature, they lack properties pertaining to physical attributes. For example, VM interfaces do not have a physical type and cannot have cables attached to them. [Virtual machine](./virtualmachine.md) interfaces behave similarly to device [interfaces](../dcim/interface.md): They can be assigned to VRFs, may have IP addresses, VLANs, and so on. However, given their virtual nature, they lack properties pertaining to physical attributes. For example, VM interfaces do not have a physical type and cannot have cables attached to them.
## Fields ## Fields
@ -59,8 +59,6 @@ The tagged VLANs which are configured to be carried by this interface. Valid onl
### Q-in-Q SVLAN ### Q-in-Q SVLAN
!!! info "This field was introduced in NetBox v4.2."
The assigned service VLAN (for Q-in-Q/802.1ad interfaces). The assigned service VLAN (for Q-in-Q/802.1ad interfaces).
### VRF ### VRF
@ -69,6 +67,4 @@ The [virtual routing and forwarding](../ipam/vrf.md) instance to which this inte
### VLAN Translation Policy ### VLAN Translation Policy
!!! info "This field was introduced in NetBox v4.2."
The [VLAN translation policy](../ipam/vlantranslationpolicy.md) that applies to this interface (optional). The [VLAN translation policy](../ipam/vlantranslationpolicy.md) that applies to this interface (optional).

View File

@ -44,8 +44,6 @@ The operational status of the L2VPN. By default, the following statuses are avai
!!! tip "Custom L2VPN statuses" !!! tip "Custom L2VPN statuses"
Additional L2VPN statuses may be defined by setting `L2VPN.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter. Additional L2VPN statuses may be defined by setting `L2VPN.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
!!! info "This field was introduced in NetBox v4.3."
### Identifier ### Identifier
An optional numeric identifier. This can be used to track a pseudowire ID, for example. An optional numeric identifier. This can be used to track a pseudowire ID, for example.

View File

@ -46,6 +46,4 @@ The security key configured on each client to grant access to the secured wirele
### Scope ### Scope
!!! info "This field was introduced in NetBox v4.2."
The [region](../dcim/region.md), [site](../dcim/site.md), [site group](../dcim/sitegroup.md) or [location](../dcim/location.md) with which this wireless LAN is associated. The [region](../dcim/region.md), [site](../dcim/site.md), [site group](../dcim/sitegroup.md) or [location](../dcim/location.md) with which this wireless LAN is associated.

View File

@ -15,7 +15,6 @@ A background job implements a basic [Job](../../models/core/job.md) executor for
```python title="jobs.py" ```python title="jobs.py"
from netbox.jobs import JobRunner from netbox.jobs import JobRunner
class MyTestJob(JobRunner): class MyTestJob(JobRunner):
class Meta: class Meta:
name = "My Test Job" name = "My Test Job"
@ -25,6 +24,8 @@ class MyTestJob(JobRunner):
# your logic goes here # your logic goes here
``` ```
Completed jobs will have their status updated to "completed" by default, or "errored" if an unhandled exception was raised by the `run()` method. To intentionally mark a job as failed, raise the `core.exceptions.JobFailed` exception. (Note that "failed" differs from "errored" in that a failure may be expected under certain conditions, whereas an error is not.)
You can schedule the background job from within your code (e.g. from a model's `save()` method or a view) by calling `MyTestJob.enqueue()`. This method passes through all arguments to `Job.enqueue()`. However, no `name` argument must be passed, as the background job name will be used instead. You can schedule the background job from within your code (e.g. from a model's `save()` method or a view) by calling `MyTestJob.enqueue()`. This method passes through all arguments to `Job.enqueue()`. However, no `name` argument must be passed, as the background job name will be used instead.
!!! tip !!! tip
@ -38,6 +39,27 @@ You can schedule the background job from within your code (e.g. from a model's `
This is the human-friendly names of your background job. If omitted, the class name will be used. This is the human-friendly names of your background job. If omitted, the class name will be used.
### Logging
!!! info "This feature was introduced in NetBox v4.4."
A Python logger is instantiated by the runner for each job. It can be utilized within a job's `run()` method as needed:
```python
def run(self, *args, **kwargs):
obj = MyModel.objects.get(pk=kwargs.get('pk'))
self.logger.info("Retrieved object {obj}")
```
Four of the standard Python logging levels are supported:
* `debug()`
* `info()`
* `warning()`
* `error()`
Log entries recorded using the runner's logger will be saved in the job's log in the database in addition to being processed by other [system logging handlers](../../configuration/system.md#logging).
### Scheduled Jobs ### Scheduled Jobs
As described above, jobs can be scheduled for immediate execution or at any later time using the `enqueue()` method. However, for management purposes, the `enqueue_once()` method allows a job to be scheduled exactly once avoiding duplicates. If a job is already scheduled for a particular instance, a second one won't be scheduled, respecting thread safety. An example use case would be to schedule a periodic task that is bound to an instance in general, but not to any event of that instance (such as updates). The parameters of the `enqueue_once()` method are identical to those of `enqueue()`. As described above, jobs can be scheduled for immediate execution or at any later time using the `enqueue()` method. However, for management purposes, the `enqueue_once()` method allows a job to be scheduled exactly once avoiding duplicates. If a job is already scheduled for a particular instance, a second one won't be scheduled, respecting thread safety. An example use case would be to schedule a periodic task that is bound to an instance in general, but not to any event of that instance (such as updates). The parameters of the `enqueue_once()` method are identical to those of `enqueue()`.
@ -67,8 +89,6 @@ class MyModel(NetBoxModel):
### System Jobs ### System Jobs
!!! info "This feature was introduced in NetBox v4.2."
Some plugins may implement background jobs that are decoupled from the request/response cycle. Typical use cases would be housekeeping tasks or synchronization jobs. These can be registered as _system jobs_ using the `system_job()` decorator. The job interval must be passed as an integer (in minutes) when registering a system job. System jobs are scheduled automatically when the RQ worker (`manage.py rqworker`) is run. Some plugins may implement background jobs that are decoupled from the request/response cycle. Typical use cases would be housekeeping tasks or synchronization jobs. These can be registered as _system jobs_ using the `system_job()` decorator. The job interval must be passed as an integer (in minutes) when registering a system job. System jobs are scheduled automatically when the RQ worker (`manage.py rqworker`) is run.
#### Example #### Example

View File

@ -119,8 +119,6 @@ For more information about database migrations, see the [Django documentation](h
::: netbox.models.features.ContactsMixin ::: netbox.models.features.ContactsMixin
!!! info "Plugin support for ContactsMixin was introduced in NetBox v4.3."
::: netbox.models.features.CustomLinksMixin ::: netbox.models.features.CustomLinksMixin
::: netbox.models.features.CustomFieldsMixin ::: netbox.models.features.CustomFieldsMixin

View File

@ -0,0 +1,14 @@
# User Interface
## Light & Dark Mode
The NetBox user interface supports toggling between light and dark versions of the theme. If needed, a plugin can determine the currently active color theme by inspecting `window.localStorage['netbox-color-mode']`, which will indicate either `light` or `dark`.
Additionally, when the color scheme is toggled by the user, a custom event `netbox.colorModeChanged` indicating the new scheme is dispatched. A plugin can listen for this event if needed to react to the change:
```typescript
window.addEventListener('netbox.colorModeChanged', e => {
const customEvent = e as CustomEvent<ColorModeData>;
console.log('New color mode:', customEvent.detail.netboxColorMode);
});
```

View File

@ -0,0 +1,75 @@
# Webhooks
NetBox supports the configuration of outbound [webhooks](../../integrations/webhooks.md) which can be triggered by custom [event rules](../../features/event-rules.md). By default, a webhook's payload will contain a serialized representation of the object, before & after snapshots (if applicable), and some metadata.
## Callback Registration
Plugins can register callback functions to supplement a webhook's payload with their own data. For example, it might be desirable for a plugin to attach information about the status of some objects at the time a change was made.
This can be accomplished by defining a function which accepts a defined set of keyword arguments and registering it as a webhook callback. Whenever a new webhook is generated, the function will be called, and any data it returns will be attached to the webhook's payload under the `context` key.
### Example
```python
from extras.webhooks import register_webhook_callback
from my_plugin.utilities import get_foo_status
@register_webhook_callback
def set_foo_status(object_type, event_type, data, request):
if status := get_foo_status():
return {
'foo': status
}
```
The resulting webhook payload will look like the following:
```json
{
"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/",
...
},
"snapshots": {...},
"context": {
"foo": 123
}
}
```
!!! 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:
```python
return {
'my_plugin': {
'foo': 123,
'bar': 456,
}
}
```
### Callback Function Arguments
| Name | Type | Description |
|---------------|-------------------|-------------------------------------------------------------------|
| `object_type` | ObjectType | The ObjectType which represents the triggering object |
| `event_type` | String | The type of event which triggered the webhook (see `core.events`) |
| `data` | Dictionary | The serialized representation of the object |
| `request` | NetBoxFakeRequest | A copy of the request (if any) which resulted in the change |
## Where to Define Callbacks
Webhook callbacks can be defined anywhere within a plugin, but must be imported during plugin initialization. If you wish to keep them in a separate module, you can import that module under the PluginConfig's `ready()` method:
```python
def ready(self):
super().ready()
from my_plugin import webhook_callbacks
```

View File

@ -80,18 +80,20 @@ GET /api/ipam/vlans/?vid__gt=900
String based (char) fields (Name, Address, etc) support these lookup expressions: String based (char) fields (Name, Address, etc) support these lookup expressions:
| Filter | Description | | Filter | Description |
|---------|----------------------------------------| |----------|----------------------------------------|
| `n` | Not equal to | | `n` | Not equal to |
| `ic` | Contains (case-insensitive) | | `ic` | Contains (case-insensitive) |
| `nic` | Does not contain (case-insensitive) | | `nic` | Does not contain (case-insensitive) |
| `isw` | Starts with (case-insensitive) | | `isw` | Starts with (case-insensitive) |
| `nisw` | Does not start with (case-insensitive) | | `nisw` | Does not start with (case-insensitive) |
| `iew` | Ends with (case-insensitive) | | `iew` | Ends with (case-insensitive) |
| `niew` | Does not end with (case-insensitive) | | `niew` | Does not end with (case-insensitive) |
| `ie` | Exact match (case-insensitive) | | `ie` | Exact match (case-insensitive) |
| `nie` | Inverse exact match (case-insensitive) | | `nie` | Inverse exact match (case-insensitive) |
| `empty` | Is empty/null (boolean) | | `empty` | Is empty/null (boolean) |
| `regex` | Regexp matching |
| `iregex` | Regexp matching (case-insensitive) |
Here is an example of a lookup expression on a string field that will return all devices with `switch` in the name: Here is an example of a lookup expression on a string field that will return all devices with `switch` in the name:

View File

@ -10,6 +10,12 @@ Minor releases are published in April, August, and December of each calendar yea
This page contains a history of all major and minor releases since NetBox v2.0. For more detail on a specific patch release, please see the release notes page for that specific minor release. This page contains a history of all major and minor releases since NetBox v2.0. For more detail on a specific patch release, please see the release notes page for that specific minor release.
#### [Version 4.4](./version-4.4.md) (September 2025)
* Background Jobs for Bulk Operations ([#19589](https://github.com/netbox-community/netbox/issues/19589), [#19891](https://github.com/netbox-community/netbox/issues/19891))
* Logging Mechanism for Background Jobs ([#19891](https://github.com/netbox-community/netbox/issues/19816))
* Changelog Comments ([#19713](https://github.com/netbox-community/netbox/issues/19713))
#### [Version 4.3](./version-4.3.md) (May 2025) #### [Version 4.3](./version-4.3.md) (May 2025)
* Module Type Profiles & Custom Attributes ([#19002](https://github.com/netbox-community/netbox/issues/19002)) * Module Type Profiles & Custom Attributes ([#19002](https://github.com/netbox-community/netbox/issues/19002))

View File

@ -434,7 +434,7 @@ A new management command has been added: `manage.py housekeeping`. This command
* Delete change log records which have surpassed the configured retention period (if configured) * Delete change log records which have surpassed the configured retention period (if configured)
* Check for new NetBox releases (if enabled) * Check for new NetBox releases (if enabled)
A convenience script for calling this command via an automated scheduler has been included at `/contrib/netbox-housekeeping.sh`. Please see the [housekeeping documentation](../administration/housekeeping.md) for further details. A convenience script for calling this command via an automated scheduler has been included at `/contrib/netbox-housekeeping.sh`. Please see the housekeeping documentation for further details.
#### Custom Queue Support for Plugins ([#6651](https://github.com/netbox-community/netbox/issues/6651)) #### Custom Queue Support for Plugins ([#6651](https://github.com/netbox-community/netbox/issues/6651))

View File

@ -1,5 +1,43 @@
# NetBox v4.3 # NetBox v4.3
## v4.3.5 (2025-07-29)
### Enhancements
* [#18797](https://github.com/netbox-community/netbox/issues/18797) - Added jinja2.StrictUndefined option for config template rendering to catch undefined variables
* [#18936](https://github.com/netbox-community/netbox/issues/18936) - Cable imports now accept color names (e.g. "red", "blue") in addition to hex color codes
* [#19840](https://github.com/netbox-community/netbox/issues/19840) - Cable imports now support specifying site information for better organization
* [#19902](https://github.com/netbox-community/netbox/issues/19902) - Device names in rack elevation SVG exports are automatically truncated to prevent overflow beyond rack unit boundaries
* [#19903](https://github.com/netbox-community/netbox/issues/19903) - String field filters now support `regex` and `iregex` lookups for advanced pattern matching
* [#19910](https://github.com/netbox-community/netbox/issues/19910) - Internet-dependent links are no longer visible when running in air-gapped environments
### Bug Fixes
* [#18900](https://github.com/netbox-community/netbox/issues/18900) - REST API paginator now raises proper exceptions when attempting to paginate unordered querysets
* [#19916](https://github.com/netbox-community/netbox/issues/19916) - Rack elevation image/label dropdown functionality restored
* [#19934](https://github.com/netbox-community/netbox/issues/19934) - Added missing description field to tenant bulk edit form
* [#19956](https://github.com/netbox-community/netbox/issues/19956) - Prevent duplicate deletion records in changelog from cascading deletions
## v4.3.4 (2025-07-15)
### Enhancements
* [#18811](https://github.com/netbox-community/netbox/issues/18811) - Match expanded form IPv6 addresses in global search
* [#19550](https://github.com/netbox-community/netbox/issues/19550) - Enable lazy loading for rack elevations
* [#19571](https://github.com/netbox-community/netbox/issues/19571) - Add a default module type profile for expansion cards
* [#19793](https://github.com/netbox-community/netbox/issues/19793) - Support custom dynamic navigation menu links
* [#19828](https://github.com/netbox-community/netbox/issues/19828) - Expose L2VPN termination in interface GraphQL response
### Bug Fixes
* [#19413](https://github.com/netbox-community/netbox/issues/19413) - Custom fields should be grouped in filter forms
* [#19633](https://github.com/netbox-community/netbox/issues/19633) - Introduce InvalidCondition exception and log all evaluations of invalid event rule conditions
* [#19800](https://github.com/netbox-community/netbox/issues/19800) - Module type bulk import should support profile assignment
* [#19806](https://github.com/netbox-community/netbox/issues/19806) - Introduce JobFailed exception to allow marking background jobs as failed
* [#19827](https://github.com/netbox-community/netbox/issues/19827) - Enforce uniqueness for device role names & slugs
* [#19839](https://github.com/netbox-community/netbox/issues/19839) - Enable export of parent assignment for recursively nested objects
* [#19876](https://github.com/netbox-community/netbox/issues/19876) - Remove Markdown rendering from CustomFieldChoiceSet description field
---
## v4.3.3 (2025-06-26) ## v4.3.3 (2025-06-26)
### Enhancements ### Enhancements

View File

@ -0,0 +1,61 @@
# NetBox v4.4
## v4.4.0 (FUTURE)
### New Features
#### Background Jobs for Bulk Operations ([#19589](https://github.com/netbox-community/netbox/issues/19589), [#19891](https://github.com/netbox-community/netbox/issues/19891))
Most bulk operations, such as the import, modification, or deletion of objects can now be executed as a background job. This frees the user to continue working in NetBox while the bulk operation is processed. Once completed, the user will be notified of the job's result.
#### Logging Mechanism for Background Jobs ([#19891](https://github.com/netbox-community/netbox/issues/19816))
A dedicated logging mechanism has been implemented for background jobs. Jobs can now easily record log messages by calling e.g. `self.logger.info("Log message")` under the `run()` method. These messages are displayed along with the job's resulting data. Supported log levels include `DEBUG`, `INFO`, `WARNING`, and `ERROR`.
#### Changelog Comments ([#19713](https://github.com/netbox-community/netbox/issues/19713))
When creating, editing, or deleting objects in NetBox, users now have the option of providing a short message explaining the change. This message will be recorded on the resulting changelog records for all affected objects.
### Enhancements
* [#17413](https://github.com/netbox-community/netbox/issues/17413) - Platforms belonging to different manufacturers may now have identical names
* [#18204](https://github.com/netbox-community/netbox/issues/18204) - Improved layout of the image attachments view & tables
* [#18528](https://github.com/netbox-community/netbox/issues/18528) - Introduced the `HOSTNAME` configuration parameter to override the system hostname reported by NetBox
* [#18990](https://github.com/netbox-community/netbox/issues/18990) - Image attachments now include an optional description field
* [#19134](https://github.com/netbox-community/netbox/issues/19134) - Interface transmit power now accepts negative values
* [#19231](https://github.com/netbox-community/netbox/issues/19231) - Bulk renaming support has been implemented in the UI for most object types
* [#19591](https://github.com/netbox-community/netbox/issues/19591) - Thumbnails for all images attached to an object are now displayed under a dedicated tab
* [#19722](https://github.com/netbox-community/netbox/issues/19722) - The REST API endpoint for object types has been extended to include additional details
* [#19739](https://github.com/netbox-community/netbox/issues/19739) - Introduced a user preference for CSV delimiter
* [#19893](https://github.com/netbox-community/netbox/issues/19893) - The `/api/status/` REST API endpoint now includes the system hostname
* [#19920](https://github.com/netbox-community/netbox/issues/19920) - Contacts can now be assigned to ASNs
* [#19945](https://github.com/netbox-community/netbox/issues/19945) - Introduce a new custom script variable to represent decimal values
* [#19965](https://github.com/netbox-community/netbox/issues/19965) - Add REST & GraphQL API request counters to the Prometheus metrics exporter
### Plugins
* [#19735](https://github.com/netbox-community/netbox/issues/19735) - Custom individual and bulk operations can now be registered under individual views using `ObjectAction`
### Other Changes
* [#18349](https://github.com/netbox-community/netbox/issues/18349) - The housekeeping script has been replaced with a system job
* [#18588](https://github.com/netbox-community/netbox/issues/18588) - The "Service" model has been renamed to "Application Service" for clarity (UI change only)
* [#19829](https://github.com/netbox-community/netbox/issues/19829) - The REST API endpoint for object types is now available under `/api/core/`
* [#19924](https://github.com/netbox-community/netbox/issues/19924) - ObjectTypes are now tracked as concrete objects in the database (alongside ContentTypes)
* [#19973](https://github.com/netbox-community/netbox/issues/19973) - Miscellaneous improvements to the `nbhshell` management command
### REST API Changes
* The `/api/status/` endpoint now includes the system hostname.
* The `/api/extras/object-types/` endpoint is now available at `/api/core/object-types/`. (The original endpoint will be removed in NetBox v4.5.)
* The `/api/core/object-types/` endpoint has been expanded to include the following read-only fields:
* `app_name`
* `model_name`
* `model_name_plural`
* `is_plugin_model`
* `rest_api_endpoint`
* `description`
* dcim.Interface
* The `tx_power` field now accepts negative values
* extras.ImageAttachment
* Added an optional `description` field

View File

@ -144,6 +144,8 @@ nav:
- Search: 'plugins/development/search.md' - Search: 'plugins/development/search.md'
- Event Types: 'plugins/development/event-types.md' - Event Types: 'plugins/development/event-types.md'
- Data Backends: 'plugins/development/data-backends.md' - Data Backends: 'plugins/development/data-backends.md'
- Webhooks: 'plugins/development/webhooks.md'
- User Interface: 'plugins/development/user-interface.md'
- REST API: 'plugins/development/rest-api.md' - REST API: 'plugins/development/rest-api.md'
- GraphQL API: 'plugins/development/graphql-api.md' - GraphQL API: 'plugins/development/graphql-api.md'
- Background Jobs: 'plugins/development/background-jobs.md' - Background Jobs: 'plugins/development/background-jobs.md'
@ -158,7 +160,6 @@ nav:
- Okta: 'administration/authentication/okta.md' - Okta: 'administration/authentication/okta.md'
- Permissions: 'administration/permissions.md' - Permissions: 'administration/permissions.md'
- Error Reporting: 'administration/error-reporting.md' - Error Reporting: 'administration/error-reporting.md'
- Housekeeping: 'administration/housekeeping.md'
- Replicating NetBox: 'administration/replicating-netbox.md' - Replicating NetBox: 'administration/replicating-netbox.md'
- NetBox Shell: 'administration/netbox-shell.md' - NetBox Shell: 'administration/netbox-shell.md'
- Data Model: - Data Model:
@ -309,6 +310,7 @@ nav:
- git Cheat Sheet: 'development/git-cheat-sheet.md' - git Cheat Sheet: 'development/git-cheat-sheet.md'
- Release Notes: - Release Notes:
- Summary: 'release-notes/index.md' - Summary: 'release-notes/index.md'
- Version 4.4: 'release-notes/version-4.4.md'
- Version 4.3: 'release-notes/version-4.3.md' - Version 4.3: 'release-notes/version-4.3.md'
- Version 4.2: 'release-notes/version-4.2.md' - Version 4.2: 'release-notes/version-4.2.md'
- Version 4.1: 'release-notes/version-4.1.md' - Version 4.1: 'release-notes/version-4.1.md'

View File

@ -1,4 +1,5 @@
from .serializers_.change_logging import * from .serializers_.change_logging import *
from .serializers_.data import * from .serializers_.data import *
from .serializers_.jobs import * from .serializers_.jobs import *
from .serializers_.object_types import *
from .serializers_.tasks import * from .serializers_.tasks import *

View File

@ -44,7 +44,8 @@ class ObjectChangeSerializer(BaseModelSerializer):
model = ObjectChange model = ObjectChange
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'time', 'user', 'user_name', 'request_id', 'action', 'id', 'url', 'display_url', 'display', 'time', 'user', 'user_name', 'request_id', 'action',
'changed_object_type', 'changed_object_id', 'changed_object', 'prechange_data', 'postchange_data', 'changed_object_type', 'changed_object_id', 'changed_object', 'message', 'prechange_data',
'postchange_data',
] ]
@extend_schema_field(serializers.JSONField(allow_null=True)) @extend_schema_field(serializers.JSONField(allow_null=True))

View File

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

View File

@ -10,6 +10,7 @@ router.register('data-sources', views.DataSourceViewSet)
router.register('data-files', views.DataFileViewSet) router.register('data-files', views.DataFileViewSet)
router.register('jobs', views.JobViewSet) router.register('jobs', views.JobViewSet)
router.register('object-changes', views.ObjectChangeViewSet) router.register('object-changes', views.ObjectChangeViewSet)
router.register('object-types', views.ObjectTypeViewSet)
router.register('background-queues', views.BackgroundQueueViewSet, basename='rqqueue') router.register('background-queues', views.BackgroundQueueViewSet, basename='rqqueue')
router.register('background-workers', views.BackgroundWorkerViewSet, basename='rqworker') router.register('background-workers', views.BackgroundWorkerViewSet, basename='rqworker')
router.register('background-tasks', views.BackgroundTaskViewSet, basename='rqtask') router.register('background-tasks', views.BackgroundTaskViewSet, basename='rqtask')

View File

@ -1,29 +1,29 @@
from django.http import Http404, HttpResponse from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_rq.queues import get_redis_connection
from django_rq.settings import QUEUES_LIST
from django_rq.utils import get_statistics
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema from drf_spectacular.utils import extend_schema
from rest_framework import viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
from rest_framework.permissions import IsAdminUser
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.routers import APIRootView from rest_framework.routers import APIRootView
from rest_framework.viewsets import ReadOnlyModelViewSet from rest_framework.viewsets import ReadOnlyModelViewSet
from rq.job import Job as RQ_Job
from rq.worker import Worker
from core import filtersets from core import filtersets
from core.choices import DataSourceStatusChoices
from core.jobs import SyncDataSourceJob from core.jobs import SyncDataSourceJob
from core.models import * from core.models import *
from core.utils import delete_rq_job, enqueue_rq_job, get_rq_jobs, requeue_rq_job, stop_rq_job from core.utils import delete_rq_job, enqueue_rq_job, get_rq_jobs, requeue_rq_job, stop_rq_job
from django_rq.queues import get_redis_connection from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from django_rq.utils import get_statistics
from django_rq.settings import QUEUES_LIST
from netbox.api.metadata import ContentTypeMetadata from netbox.api.metadata import ContentTypeMetadata
from netbox.api.pagination import LimitOffsetListPagination from netbox.api.pagination import LimitOffsetListPagination
from netbox.api.viewsets import NetBoxModelViewSet, NetBoxReadOnlyModelViewSet from netbox.api.viewsets import NetBoxModelViewSet, NetBoxReadOnlyModelViewSet
from rest_framework import viewsets
from rest_framework.permissions import IsAdminUser
from rq.job import Job as RQ_Job
from rq.worker import Worker
from . import serializers from . import serializers
@ -50,10 +50,8 @@ class DataSourceViewSet(NetBoxModelViewSet):
if not request.user.has_perm('core.sync_datasource', obj=datasource): if not request.user.has_perm('core.sync_datasource', obj=datasource):
raise PermissionDenied(_("This user does not have permission to synchronize this data source.")) raise PermissionDenied(_("This user does not have permission to synchronize this data source."))
# Enqueue the sync job & update the DataSource's status # Enqueue the sync job
SyncDataSourceJob.enqueue(instance=datasource, user=request.user) SyncDataSourceJob.enqueue(instance=datasource, user=request.user)
datasource.status = DataSourceStatusChoices.QUEUED
DataSource.objects.filter(pk=datasource.pk).update(status=datasource.status)
serializer = serializers.DataSourceSerializer(datasource, context={'request': request}) serializer = serializers.DataSourceSerializer(datasource, context={'request': request})
@ -85,6 +83,16 @@ class ObjectChangeViewSet(ReadOnlyModelViewSet):
filterset_class = filtersets.ObjectChangeFilterSet filterset_class = filtersets.ObjectChangeFilterSet
class ObjectTypeViewSet(ReadOnlyModelViewSet):
"""
Read-only list of ObjectTypes.
"""
permission_classes = [IsAuthenticatedOrLoginNotRequired]
queryset = ObjectType.objects.order_by('app_label', 'model')
serializer_class = serializers.ObjectTypeSerializer
filterset_class = filtersets.ObjectTypeFilterSet
class BaseRQViewSet(viewsets.ViewSet): class BaseRQViewSet(viewsets.ViewSet):
""" """
Base class for RQ view sets. Provides a list() method. Subclasses must implement get_data(). Base class for RQ view sets. Provides a list() method. Subclasses must implement get_data().

View File

@ -4,23 +4,31 @@ from django.utils.translation import gettext_lazy as _
from rq.job import JobStatus from rq.job import JobStatus
__all__ = ( __all__ = (
'JOB_LOG_ENTRY_LEVELS',
'RQ_TASK_STATUSES', 'RQ_TASK_STATUSES',
) )
@dataclass @dataclass
class Status: class Badge:
label: str label: str
color: str color: str
RQ_TASK_STATUSES = { RQ_TASK_STATUSES = {
JobStatus.QUEUED: Status(_('Queued'), 'cyan'), JobStatus.QUEUED: Badge(_('Queued'), 'cyan'),
JobStatus.FINISHED: Status(_('Finished'), 'green'), JobStatus.FINISHED: Badge(_('Finished'), 'green'),
JobStatus.FAILED: Status(_('Failed'), 'red'), JobStatus.FAILED: Badge(_('Failed'), 'red'),
JobStatus.STARTED: Status(_('Started'), 'blue'), JobStatus.STARTED: Badge(_('Started'), 'blue'),
JobStatus.DEFERRED: Status(_('Deferred'), 'gray'), JobStatus.DEFERRED: Badge(_('Deferred'), 'gray'),
JobStatus.SCHEDULED: Status(_('Scheduled'), 'purple'), JobStatus.SCHEDULED: Badge(_('Scheduled'), 'purple'),
JobStatus.STOPPED: Status(_('Stopped'), 'orange'), JobStatus.STOPPED: Badge(_('Stopped'), 'orange'),
JobStatus.CANCELED: Status(_('Cancelled'), 'yellow'), JobStatus.CANCELED: Badge(_('Cancelled'), 'yellow'),
}
JOB_LOG_ENTRY_LEVELS = {
'debug': Badge(_('Debug'), 'gray'),
'info': Badge(_('Info'), 'blue'),
'warning': Badge(_('Warning'), 'orange'),
'error': Badge(_('Error'), 'red'),
} }

View File

@ -0,0 +1,21 @@
import logging
from dataclasses import dataclass, field
from datetime import datetime
from django.utils import timezone
__all__ = (
'JobLogEntry',
)
@dataclass
class JobLogEntry:
level: str
message: str
timestamp: datetime = field(default_factory=timezone.now)
@classmethod
def from_logrecord(cls, record: logging.LogRecord):
return cls(record.levelname.lower(), record.msg)

View File

@ -1,9 +1,19 @@
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
__all__ = (
class SyncError(Exception): 'IncompatiblePluginError',
pass 'JobFailed',
'SyncError',
)
class IncompatiblePluginError(ImproperlyConfigured): class IncompatiblePluginError(ImproperlyConfigured):
pass pass
class JobFailed(Exception):
pass
class SyncError(Exception):
pass

View File

@ -1,9 +1,8 @@
import django_filters
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models import Q from django.db.models import Q
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
import django_filters
from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
from netbox.utils import get_data_backend_choices from netbox.utils import get_data_backend_choices
from users.models import User from users.models import User
@ -17,6 +16,7 @@ __all__ = (
'DataSourceFilterSet', 'DataSourceFilterSet',
'JobFilterSet', 'JobFilterSet',
'ObjectChangeFilterSet', 'ObjectChangeFilterSet',
'ObjectTypeFilterSet',
) )
@ -134,6 +134,25 @@ class JobFilterSet(BaseFilterSet):
) )
class ObjectTypeFilterSet(django_filters.FilterSet):
q = django_filters.CharFilter(
method='search',
label=_('Search'),
)
class Meta:
model = ObjectType
fields = ('id', 'app_label', 'model')
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(app_label__icontains=value) |
Q(model__icontains=value)
)
class ObjectChangeFilterSet(BaseFilterSet): class ObjectChangeFilterSet(BaseFilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
@ -167,7 +186,8 @@ class ObjectChangeFilterSet(BaseFilterSet):
return queryset return queryset
return queryset.filter( return queryset.filter(
Q(user_name__icontains=value) | Q(user_name__icontains=value) |
Q(object_repr__icontains=value) Q(object_repr__icontains=value) |
Q(message__icontains=value)
) )

View File

@ -1,13 +1,20 @@
import logging import logging
import requests
import sys import sys
from datetime import timedelta
from importlib import import_module
import requests
from django.conf import settings from django.conf import settings
from django.core.cache import cache
from django.utils import timezone
from packaging import version
from core.models import Job, ObjectChange
from netbox.config import Config
from netbox.jobs import JobRunner, system_job from netbox.jobs import JobRunner, system_job
from netbox.search.backends import search_backend from netbox.search.backends import search_backend
from utilities.proxy import resolve_proxies from utilities.proxy import resolve_proxies
from .choices import DataSourceStatusChoices, JobIntervalChoices from .choices import DataSourceStatusChoices, JobIntervalChoices
from .exceptions import SyncError
from .models import DataSource from .models import DataSource
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -21,21 +28,36 @@ class SyncDataSourceJob(JobRunner):
class Meta: class Meta:
name = 'Synchronization' name = 'Synchronization'
@classmethod
def enqueue(cls, *args, **kwargs):
job = super().enqueue(*args, **kwargs)
# Update the DataSource's synchronization status to queued
if datasource := job.object:
datasource.status = DataSourceStatusChoices.QUEUED
DataSource.objects.filter(pk=datasource.pk).update(status=datasource.status)
return job
def run(self, *args, **kwargs): def run(self, *args, **kwargs):
datasource = DataSource.objects.get(pk=self.job.object_id) datasource = DataSource.objects.get(pk=self.job.object_id)
self.logger.debug(f"Found DataSource ID {datasource.pk}")
try: try:
self.logger.info(f"Syncing data source {datasource}")
datasource.sync() datasource.sync()
# Update the search cache for DataFiles belonging to this source # Update the search cache for DataFiles belonging to this source
self.logger.debug("Updating search cache for data files")
search_backend.cache(datasource.datafiles.iterator()) search_backend.cache(datasource.datafiles.iterator())
except Exception as e: except Exception as e:
self.logger.error(f"Error syncing data source: {e}")
DataSource.objects.filter(pk=datasource.pk).update(status=DataSourceStatusChoices.FAILED) DataSource.objects.filter(pk=datasource.pk).update(status=DataSourceStatusChoices.FAILED)
if type(e) is SyncError:
logging.error(e)
raise e raise e
self.logger.info("Syncing completed successfully")
@system_job(interval=JobIntervalChoices.INTERVAL_DAILY) @system_job(interval=JobIntervalChoices.INTERVAL_DAILY)
class SystemHousekeepingJob(JobRunner): class SystemHousekeepingJob(JobRunner):
@ -50,16 +72,23 @@ class SystemHousekeepingJob(JobRunner):
if settings.DEBUG or 'test' in sys.argv: if settings.DEBUG or 'test' in sys.argv:
return return
# TODO: Migrate other housekeeping functions from the `housekeeping` management command.
self.send_census_report() self.send_census_report()
self.clear_expired_sessions()
self.prune_changelog()
self.delete_expired_jobs()
self.check_for_new_releases()
@staticmethod @staticmethod
def send_census_report(): def send_census_report():
""" """
Send a census report (if enabled). Send a census report (if enabled).
""" """
# Skip if census reporting is disabled logging.info("Reporting census data...")
if settings.ISOLATED_DEPLOYMENT or not settings.CENSUS_REPORTING_ENABLED: if settings.ISOLATED_DEPLOYMENT:
logging.info("ISOLATED_DEPLOYMENT is enabled; skipping")
return
if not settings.CENSUS_REPORTING_ENABLED:
logging.info("CENSUS_REPORTING_ENABLED is disabled; skipping")
return return
census_data = { census_data = {
@ -76,3 +105,94 @@ class SystemHousekeepingJob(JobRunner):
) )
except requests.exceptions.RequestException: except requests.exceptions.RequestException:
pass pass
@staticmethod
def clear_expired_sessions():
"""
Clear any expired sessions from the database.
"""
logging.info("Clearing expired sessions...")
engine = import_module(settings.SESSION_ENGINE)
try:
engine.SessionStore.clear_expired()
logging.info("Sessions cleared.")
except NotImplementedError:
logging.warning(
f"The configured session engine ({settings.SESSION_ENGINE}) does not support "
f"clearing sessions; skipping."
)
@staticmethod
def prune_changelog():
"""
Delete any ObjectChange records older than the configured changelog retention time (if any).
"""
logging.info("Pruning old changelog entries...")
config = Config()
if not config.CHANGELOG_RETENTION:
logging.info("No retention period specified; skipping.")
return
cutoff = timezone.now() - timedelta(days=config.CHANGELOG_RETENTION)
logging.debug(f"Retention period: {config.CHANGELOG_RETENTION} days")
logging.debug(f"Cut-off time: {cutoff}")
count = ObjectChange.objects.filter(time__lt=cutoff).delete()[0]
logging.info(f"Deleted {count} expired records")
@staticmethod
def delete_expired_jobs():
"""
Delete any jobs older than the configured retention period (if any).
"""
logging.info("Deleting expired jobs...")
config = Config()
if not config.JOB_RETENTION:
logging.info("No retention period specified; skipping.")
return
cutoff = timezone.now() - timedelta(days=config.JOB_RETENTION)
logging.debug(f"Retention period: {config.CHANGELOG_RETENTION} days")
logging.debug(f"Cut-off time: {cutoff}")
count = Job.objects.filter(created__lt=cutoff).delete()[0]
logging.info(f"Deleted {count} expired records")
@staticmethod
def check_for_new_releases():
"""
Check for new releases and cache the latest release.
"""
logging.info("Checking for new releases...")
if settings.ISOLATED_DEPLOYMENT:
logging.info("ISOLATED_DEPLOYMENT is enabled; skipping")
return
if not settings.RELEASE_CHECK_URL:
logging.info("RELEASE_CHECK_URL is not set; skipping")
return
# Fetch the latest releases
logging.debug(f"Release check URL: {settings.RELEASE_CHECK_URL}")
try:
response = requests.get(
url=settings.RELEASE_CHECK_URL,
headers={'Accept': 'application/vnd.github.v3+json'},
proxies=resolve_proxies(url=settings.RELEASE_CHECK_URL)
)
response.raise_for_status()
except requests.exceptions.RequestException as exc:
logging.error(f"Error fetching release: {exc}")
return
# Determine the most recent stable release
releases = []
for release in response.json():
if 'tag_name' not in release or release.get('devrelease') or release.get('prerelease'):
continue
releases.append((version.parse(release['tag_name']), release.get('html_url')))
latest_release = max(releases)
logging.debug(f"Found {len(response.json())} releases; {len(releases)} usable")
logging.info(f"Latest release: {latest_release[0]}")
# Cache the most recent release
cache.set('latest_release', latest_release, None)

View File

@ -1,29 +1,48 @@
import code import code
import platform import platform
import sys from collections import defaultdict
from types import SimpleNamespace
from colorama import Fore, Style
from django import get_version from django import get_version
from django.apps import apps from django.apps import apps
from django.conf import settings from django.conf import settings
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.utils.module_loading import import_string
from core.models import ObjectType from netbox.constants import CORE_APPS
from users.models import User from netbox.plugins.utils import get_installed_plugins
APPS = ('circuits', 'core', 'dcim', 'extras', 'ipam', 'tenancy', 'users', 'virtualization', 'vpn', 'wireless')
EXCLUDE_MODELS = (
'extras.branch',
'extras.stagedchange',
)
BANNER_TEXT = """### NetBox interactive shell ({node}) def color(color: str, text: str):
### Python {python} | Django {django} | NetBox {netbox} return getattr(Fore, color.upper()) + text + Style.RESET_ALL
### lsmodels() will show available models. Use help(<model>) for more info.""".format(
node=platform.node(),
python=platform.python_version(), def bright(text: str):
django=get_version(), return Style.BRIGHT + text + Style.RESET_ALL
netbox=settings.RELEASE.name
)
def get_models(app_config):
"""
Return a list of all non-private models within an app.
"""
return [
model for model in app_config.get_models()
if not getattr(model, '_netbox_private', False)
]
def get_constants(app_config):
"""
Return a dictionary mapping of all constants defined within an app.
"""
try:
constants = import_string(f'{app_config.name}.constants')
except ImportError:
return {}
return {
name: value for name, value in vars(constants).items()
}
class Command(BaseCommand): class Command(BaseCommand):
@ -36,47 +55,88 @@ class Command(BaseCommand):
help='Python code to execute (instead of starting an interactive shell)', help='Python code to execute (instead of starting an interactive shell)',
) )
def _lsmodels(self): def _lsapps(self):
for app, models in self.django_models.items(): for app_label in self.django_models.keys():
app_name = apps.get_app_config(app).verbose_name app_name = apps.get_app_config(app_label).verbose_name
print(f'{app_label} - {app_name}')
def _lsmodels(self, app_label=None):
"""
Return a list of all models within each app.
Args:
app_label: The name of a specific app
"""
if app_label:
if app_label not in self.django_models:
print(f"No models listed for {app_label}")
return
app_labels = [app_label]
else:
app_labels = self.django_models.keys() # All apps
for app_label in app_labels:
app_name = apps.get_app_config(app_label).verbose_name
print(f'{app_name}:') print(f'{app_name}:')
for m in models: for m in self.django_models[app_label]:
print(f' {m}') print(f' {m}')
def get_namespace(self): def get_namespace(self):
namespace = {} namespace = defaultdict(SimpleNamespace)
# Gather Django models and constants from each app # Iterate through all core apps & plugins to compile namespace of models and constants
for app in APPS: for app_name in [*CORE_APPS, *get_installed_plugins().keys()]:
models = [] app_config = apps.get_app_config(app_name)
# Load models from each app # Populate models
for model in apps.get_app_config(app).get_models(): if models := get_models(app_config):
app_label = model._meta.app_label for model in models:
model_name = model._meta.model_name setattr(namespace[app_name], model.__name__, model)
if f'{app_label}.{model_name}' not in EXCLUDE_MODELS: self.django_models[app_name] = sorted([
namespace[model.__name__] = model model.__name__ for model in models
models.append(model.__name__) ])
self.django_models[app] = sorted(models)
# Constants # Populate constants
try: for const_name, const_value in get_constants(app_config).items():
app_constants = sys.modules[f'{app}.constants'] setattr(namespace[app_name], const_name, const_value)
for name in dir(app_constants):
namespace[name] = getattr(app_constants, name)
except KeyError:
pass
# Additional objects to include return {
namespace['ObjectType'] = ObjectType **namespace,
namespace['User'] = User 'lsapps': self._lsapps,
# Load convenience commands
namespace.update({
'lsmodels': self._lsmodels, 'lsmodels': self._lsmodels,
}) }
return namespace @staticmethod
def get_banner_text():
lines = [
'{title} ({hostname})'.format(
title=bright('NetBox interactive shell'),
hostname=platform.node(),
),
'{python} | {django} | {netbox}'.format(
python=color('green', f'Python v{platform.python_version()}'),
django=color('green', f'Django v{get_version()}'),
netbox=color('green', settings.RELEASE.name),
),
]
if installed_plugins := get_installed_plugins():
plugin_list = ', '.join([
color('cyan', f'{name} v{version}') for name, version in installed_plugins.items()
])
lines.append(
'Plugins: {plugin_list}'.format(
plugin_list=plugin_list
)
)
lines.append(
'lsapps() & lsmodels() will show available models. Use help(<model>) for more info.'
)
return '\n'.join([
f'### {line}' for line in lines
])
def handle(self, **options): def handle(self, **options):
namespace = self.get_namespace() namespace = self.get_namespace()
@ -97,5 +157,4 @@ class Command(BaseCommand):
readline.parse_and_bind('tab: complete') readline.parse_and_bind('tab: complete')
# Run interactive shell # Run interactive shell
shell = code.interact(banner=BANNER_TEXT, local=namespace) return code.interact(banner=self.get_banner_text(), local=namespace)
return shell

View File

@ -1,4 +1,4 @@
import core.models.contenttypes import core.models.object_types
from django.db import migrations from django.db import migrations
@ -19,7 +19,7 @@ class Migration(migrations.Migration):
}, },
bases=('contenttypes.contenttype',), bases=('contenttypes.contenttype',),
managers=[ managers=[
('objects', core.models.contenttypes.ObjectTypeManager()), ('objects', core.models.object_types.ObjectTypeManager()),
], ],
), ),
] ]

View File

@ -0,0 +1,28 @@
import django.contrib.postgres.fields
import django.core.serializers.json
from django.db import migrations, models
import utilities.json
class Migration(migrations.Migration):
dependencies = [
('core', '0015_remove_redundant_indexes'),
]
operations = [
migrations.AddField(
model_name='job',
name='log_entries',
field=django.contrib.postgres.fields.ArrayField(
base_field=models.JSONField(
decoder=utilities.json.JobLogDecoder,
encoder=django.core.serializers.json.DjangoJSONEncoder
),
blank=True,
default=list,
size=None
),
),
]

View File

@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0016_job_log_entries'),
]
operations = [
migrations.AddField(
model_name='objectchange',
name='message',
field=models.CharField(blank=True, editable=False, max_length=200),
),
]

View File

@ -0,0 +1,63 @@
import django.contrib.postgres.fields
import django.contrib.postgres.indexes
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('core', '0017_objectchange_message'),
]
operations = [
# Delete the proxy model from the migration state
migrations.DeleteModel(
name='ObjectType',
),
# Create the new concrete model
migrations.CreateModel(
name='ObjectType',
fields=[
(
'contenttype_ptr',
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to='contenttypes.contenttype',
related_name='object_type'
)
),
(
'public',
models.BooleanField(
default=False
)
),
(
'features',
django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(max_length=50),
default=list,
size=None
)
),
],
options={
'verbose_name': 'object type',
'verbose_name_plural': 'object types',
'ordering': ('app_label', 'model'),
'indexes': [
django.contrib.postgres.indexes.GinIndex(
fields=['features'],
name='core_object_feature_aec4de_gin'
),
]
},
bases=('contenttypes.contenttype',),
managers=[],
),
]

View File

@ -1,4 +1,4 @@
from .contenttypes import * from .object_types import *
from .change_logging import * from .change_logging import *
from .config import * from .config import *
from .data import * from .data import *

View File

@ -11,8 +11,8 @@ from mptt.models import MPTTModel
from core.choices import ObjectChangeActionChoices from core.choices import ObjectChangeActionChoices
from core.querysets import ObjectChangeQuerySet from core.querysets import ObjectChangeQuerySet
from netbox.models.features import ChangeLoggingMixin from netbox.models.features import ChangeLoggingMixin
from netbox.models.features import has_feature
from utilities.data import shallow_compare_dict from utilities.data import shallow_compare_dict
from .contenttypes import ObjectType
__all__ = ( __all__ = (
'ObjectChange', 'ObjectChange',
@ -82,6 +82,12 @@ class ObjectChange(models.Model):
max_length=200, max_length=200,
editable=False editable=False
) )
message = models.CharField(
verbose_name=_('message'),
max_length=200,
editable=False,
blank=True
)
prechange_data = models.JSONField( prechange_data = models.JSONField(
verbose_name=_('pre-change data'), verbose_name=_('pre-change data'),
editable=False, editable=False,
@ -118,7 +124,7 @@ class ObjectChange(models.Model):
super().clean() super().clean()
# Validate the assigned object type # Validate the assigned object type
if self.changed_object_type not in ObjectType.objects.with_feature('change_logging'): if not has_feature(self.changed_object_type, 'change_logging'):
raise ValidationError( raise ValidationError(
_("Change logging is not supported for this object type ({type}).").format( _("Change logging is not supported for this object type ({type}).").format(
type=self.changed_object_type type=self.changed_object_type

View File

@ -1,78 +1,3 @@
from django.contrib.contenttypes.models import ContentType, ContentTypeManager # TODO: Remove this module in NetBox v4.5
from django.db.models import Q # Provided for backward compatibility
from .object_types import *
from netbox.plugins import PluginConfig
from netbox.registry import registry
from utilities.string import title
__all__ = (
'ObjectType',
'ObjectTypeManager',
)
class ObjectTypeManager(ContentTypeManager):
def public(self):
"""
Filter the base queryset to return only ContentTypes corresponding to "public" models; those which are listed
in registry['models'] and intended for reference by other objects.
"""
q = Q()
for app_label, models in registry['models'].items():
q |= Q(app_label=app_label, model__in=models)
return self.get_queryset().filter(q)
def with_feature(self, feature):
"""
Return the ContentTypes only for models which are registered as supporting the specified feature. For example,
we can find all ContentTypes for models which support webhooks with
ContentType.objects.with_feature('event_rules')
"""
if feature not in registry['model_features']:
raise KeyError(
f"{feature} is not a registered model feature! Valid features are: {registry['model_features'].keys()}"
)
q = Q()
for app_label, models in registry['model_features'][feature].items():
q |= Q(app_label=app_label, model__in=models)
return self.get_queryset().filter(q)
class ObjectType(ContentType):
"""
Wrap Django's native ContentType model to use our custom manager.
"""
objects = ObjectTypeManager()
class Meta:
proxy = True
@property
def app_labeled_name(self):
# Override ContentType's "app | model" representation style.
return f"{self.app_verbose_name} > {title(self.model_verbose_name)}"
@property
def app_verbose_name(self):
if model := self.model_class():
return model._meta.app_config.verbose_name
@property
def model_verbose_name(self):
if model := self.model_class():
return model._meta.verbose_name
@property
def model_verbose_name_plural(self):
if model := self.model_class():
return model._meta.verbose_name_plural
@property
def is_plugin_model(self):
if not (model := self.model_class()):
return # Return null if model class is invalid
return isinstance(model._meta.app_config, PluginConfig)

View File

@ -1,9 +1,12 @@
import logging
import uuid import uuid
from dataclasses import asdict
from functools import partial from functools import partial
import django_rq import django_rq
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
@ -14,8 +17,13 @@ from django.utils.translation import gettext as _
from rq.exceptions import InvalidJobOperation from rq.exceptions import InvalidJobOperation
from core.choices import JobStatusChoices from core.choices import JobStatusChoices
from core.dataclasses import JobLogEntry
from core.events import JOB_COMPLETED, JOB_ERRORED, JOB_FAILED
from core.models import ObjectType from core.models import ObjectType
from core.signals import job_end, job_start from core.signals import job_end, job_start
from extras.models import Notification
from netbox.models.features import has_feature
from utilities.json import JobLogDecoder
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
from utilities.rqworker import get_queue_for_model from utilities.rqworker import get_queue_for_model
@ -104,6 +112,15 @@ class Job(models.Model):
verbose_name=_('job ID'), verbose_name=_('job ID'),
unique=True unique=True
) )
log_entries = ArrayField(
verbose_name=_('log entries'),
base_field=models.JSONField(
encoder=DjangoJSONEncoder,
decoder=JobLogDecoder,
),
blank=True,
default=list,
)
objects = RestrictedQuerySet.as_manager() objects = RestrictedQuerySet.as_manager()
@ -116,7 +133,7 @@ class Job(models.Model):
verbose_name_plural = _('jobs') verbose_name_plural = _('jobs')
def __str__(self): def __str__(self):
return str(self.job_id) return self.name
def get_absolute_url(self): def get_absolute_url(self):
# TODO: Employ dynamic registration # TODO: Employ dynamic registration
@ -130,11 +147,18 @@ class Job(models.Model):
def get_status_color(self): def get_status_color(self):
return JobStatusChoices.colors.get(self.status) return JobStatusChoices.colors.get(self.status)
def get_event_type(self):
return {
JobStatusChoices.STATUS_COMPLETED: JOB_COMPLETED,
JobStatusChoices.STATUS_FAILED: JOB_FAILED,
JobStatusChoices.STATUS_ERRORED: JOB_ERRORED,
}.get(self.status)
def clean(self): def clean(self):
super().clean() super().clean()
# Validate the assigned object type # Validate the assigned object type
if self.object_type and self.object_type not in ObjectType.objects.with_feature('jobs'): if self.object_type and not has_feature(self.object_type, 'jobs'):
raise ValidationError( raise ValidationError(
_("Jobs cannot be assigned to this object type ({type}).").format(type=self.object_type) _("Jobs cannot be assigned to this object type ({type}).").format(type=self.object_type)
) )
@ -187,24 +211,38 @@ class Job(models.Model):
""" """
Mark the job as completed, optionally specifying a particular termination status. Mark the job as completed, optionally specifying a particular termination status.
""" """
valid_statuses = JobStatusChoices.TERMINAL_STATE_CHOICES if status not in JobStatusChoices.TERMINAL_STATE_CHOICES:
if status not in valid_statuses:
raise ValueError( raise ValueError(
_("Invalid status for job termination. Choices are: {choices}").format( _("Invalid status for job termination. Choices are: {choices}").format(
choices=', '.join(valid_statuses) choices=', '.join(JobStatusChoices.TERMINAL_STATE_CHOICES)
) )
) )
# Mark the job as completed # Set the job's status and completion time
self.status = status self.status = status
if error: if error:
self.error = error self.error = error
self.completed = timezone.now() self.completed = timezone.now()
self.save() self.save()
# Notify the user (if any) of completion
if self.user:
Notification(
user=self.user,
object=self,
event_type=self.get_event_type(),
).save()
# Send signal # Send signal
job_end.send(self) job_end.send(self)
def log(self, record: logging.LogRecord):
"""
Record a LogRecord from Python's native logging in the job's log.
"""
entry = JobLogEntry.from_logrecord(record)
self.log_entries.append(asdict(entry))
@classmethod @classmethod
def enqueue( def enqueue(
cls, cls,

View File

@ -0,0 +1,205 @@
from collections import defaultdict
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField
from django.contrib.postgres.indexes import GinIndex
from django.core.exceptions import ObjectDoesNotExist
from django.db import models
from django.db.models import Q
from django.utils.translation import gettext as _
from netbox.plugins import PluginConfig
from netbox.registry import registry
from utilities.string import title
__all__ = (
'ObjectType',
'ObjectTypeManager',
'ObjectTypeQuerySet',
)
class ObjectTypeQuerySet(models.QuerySet):
def create(self, **kwargs):
# If attempting to create a new ObjectType for a given app_label & model, replace those kwargs
# with a reference to the ContentType (if one exists).
if (app_label := kwargs.get('app_label')) and (model := kwargs.get('model')):
try:
kwargs['contenttype_ptr'] = ContentType.objects.get(app_label=app_label, model=model)
except ObjectDoesNotExist:
pass
return super().create(**kwargs)
class ObjectTypeManager(models.Manager):
def get_queryset(self):
return ObjectTypeQuerySet(self.model, using=self._db)
def get_by_natural_key(self, app_label, model):
"""
Retrieve an ObjectType by its application label & model name.
This method exists to provide parity with ContentTypeManager.
"""
return self.get(app_label=app_label, model=model)
# TODO: Remove in NetBox v4.5
def get_for_id(self, id):
"""
Retrieve an ObjectType by its primary key (numeric ID).
This method exists to provide parity with ContentTypeManager.
"""
return self.get(pk=id)
def _get_opts(self, model, for_concrete_model):
if for_concrete_model:
model = model._meta.concrete_model
return model._meta
def get_for_model(self, model, for_concrete_model=True):
"""
Retrieve or create and return the ObjectType for a model.
"""
from netbox.models.features import get_model_features, model_is_public
opts = self._get_opts(model, for_concrete_model)
try:
# Use .get() instead of .get_or_create() initially to ensure db_for_read is honored (Django bug #20401).
ot = self.get(app_label=opts.app_label, model=opts.model_name)
except self.model.DoesNotExist:
# If the ObjectType doesn't exist, create it. (Use .get_or_create() to avoid race conditions.)
ot = self.get_or_create(
app_label=opts.app_label,
model=opts.model_name,
public=model_is_public(model),
features=get_model_features(model.__class__),
)[0]
return ot
def get_for_models(self, *models, for_concrete_models=True):
"""
Retrieve or create the ObjectTypes for multiple models, returning a mapping {model: ObjectType}.
This method exists to provide parity with ContentTypeManager.
"""
from netbox.models.features import get_model_features, model_is_public
results = {}
# Compile the model and options mappings
needed_models = defaultdict(set)
needed_opts = defaultdict(list)
for model in models:
opts = self._get_opts(model, for_concrete_models)
needed_models[opts.app_label].add(opts.model_name)
needed_opts[(opts.app_label, opts.model_name)].append(model)
# Fetch existing ObjectType from the database
condition = Q(
*(
Q(('app_label', app_label), ('model__in', model_names))
for app_label, model_names in needed_models.items()
),
_connector=Q.OR,
)
for ot in self.filter(condition):
opts_models = needed_opts.pop((ot.app_label, ot.model), [])
for model in opts_models:
results[model] = ot
# Create any missing ObjectTypes
for (app_label, model_name), opts_models in needed_opts.items():
for model in opts_models:
results[model] = self.create(
app_label=app_label,
model=model_name,
public=model_is_public(model),
features=get_model_features(model.__class__),
)
return results
def public(self):
"""
Includes only ObjectTypes for "public" models.
Filter the base queryset to return only ObjectTypes corresponding to public models; those which are intended
for reference by other objects within the application.
"""
return self.get_queryset().filter(public=True)
def with_feature(self, feature):
"""
Return ObjectTypes only for models which support the given feature.
Only ObjectTypes which list the specified feature will be included. Supported features are declared in
netbox.models.features.FEATURES_MAP. For example, we can find all ObjectTypes for models which support event
rules with:
ObjectType.objects.with_feature('event_rules')
"""
if feature not in registry['model_features']:
raise KeyError(
f"{feature} is not a registered model feature! Valid features are: {registry['model_features'].keys()}"
)
return self.get_queryset().filter(features__contains=[feature])
class ObjectType(ContentType):
"""
Wrap Django's native ContentType model to use our custom manager.
"""
contenttype_ptr = models.OneToOneField(
on_delete=models.CASCADE,
to='contenttypes.ContentType',
parent_link=True,
primary_key=True,
serialize=False,
related_name='object_type',
)
public = models.BooleanField(
default=False,
)
features = ArrayField(
base_field=models.CharField(max_length=50),
default=list,
)
objects = ObjectTypeManager()
class Meta:
verbose_name = _('object type')
verbose_name_plural = _('object types')
ordering = ('app_label', 'model')
indexes = [
GinIndex(fields=['features']),
]
@property
def app_labeled_name(self):
# Override ContentType's "app | model" representation style.
return f"{self.app_verbose_name} > {title(self.model_verbose_name)}"
@property
def app_verbose_name(self):
if model := self.model_class():
return model._meta.app_config.verbose_name
@property
def model_verbose_name(self):
if model := self.model_class():
return model._meta.verbose_name
@property
def model_verbose_name_plural(self):
if model := self.model_class():
return model._meta.verbose_name_plural
@property
def is_plugin_model(self):
if not (model := self.model_class()):
return # Return null if model class is invalid
return isinstance(model._meta.app_config, PluginConfig)

View File

@ -1,20 +1,23 @@
import logging import logging
from threading import local
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db.models.fields.reverse_related import ManyToManyRel, ManyToOneRel from django.db.models.fields.reverse_related import ManyToManyRel, ManyToOneRel
from django.db.models.signals import m2m_changed, post_save, pre_delete from django.db.models.signals import m2m_changed, post_migrate, post_save, pre_delete
from django.dispatch import receiver, Signal from django.dispatch import receiver, Signal
from django.core.signals import request_finished
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_prometheus.models import model_deletes, model_inserts, model_updates from django_prometheus.models import model_deletes, model_inserts, model_updates
from core.choices import JobStatusChoices, ObjectChangeActionChoices from core.choices import JobStatusChoices, ObjectChangeActionChoices
from core.events import * from core.events import *
from core.models import ObjectType
from extras.events import enqueue_event from extras.events import enqueue_event
from extras.utils import run_validators from extras.utils import run_validators
from netbox.config import get_config from netbox.config import get_config
from netbox.context import current_request, events_queue from netbox.context import current_request, events_queue
from netbox.models.features import ChangeLoggingMixin from netbox.models.features import ChangeLoggingMixin, get_model_features, model_is_public
from utilities.exceptions import AbortRequest from utilities.exceptions import AbortRequest
from .models import ConfigRevision, DataSource, ObjectChange from .models import ConfigRevision, DataSource, ObjectChange
@ -38,10 +41,45 @@ post_sync = Signal()
clear_events = Signal() clear_events = Signal()
#
# Object types
#
@receiver(post_migrate)
def update_object_types(sender, **kwargs):
"""
Create or update the corresponding ObjectType for each model within the migrated app.
"""
for model in sender.get_models():
app_label, model_name = model._meta.label_lower.split('.')
# Determine whether model is public and its supported features
is_public = model_is_public(model)
features = get_model_features(model)
# Create/update the ObjectType for the model
try:
ot = ObjectType.objects.get_by_natural_key(app_label=app_label, model=model_name)
ot.public = is_public
ot.features = features
ot.save()
except ObjectDoesNotExist:
ObjectType.objects.create(
app_label=app_label,
model=model_name,
public=is_public,
features=features,
)
# #
# Change logging & event handling # Change logging & event handling
# #
# Used to track received signals per object
_signals_received = local()
@receiver((post_save, m2m_changed)) @receiver((post_save, m2m_changed))
def handle_changed_object(sender, instance, **kwargs): def handle_changed_object(sender, instance, **kwargs):
""" """
@ -98,7 +136,7 @@ def handle_changed_object(sender, instance, **kwargs):
# Enqueue the object for event processing # Enqueue the object for event processing
queue = events_queue.get() queue = events_queue.get()
enqueue_event(queue, instance, request.user, request.id, event_type) enqueue_event(queue, instance, request, event_type)
events_queue.set(queue) events_queue.set(queue)
# Increment metric counters # Increment metric counters
@ -130,6 +168,16 @@ def handle_deleted_object(sender, instance, **kwargs):
if request is None: if request is None:
return return
# Check whether we've already processed a pre_delete signal for this object. (This can
# happen e.g. when both a parent object and its child are deleted simultaneously, due
# to cascading deletion.)
if not hasattr(_signals_received, 'pre_delete'):
_signals_received.pre_delete = set()
signature = (ContentType.objects.get_for_model(instance), instance.pk)
if signature in _signals_received.pre_delete:
return
_signals_received.pre_delete.add(signature)
# Record an ObjectChange if applicable # Record an ObjectChange if applicable
if hasattr(instance, 'to_objectchange'): if hasattr(instance, 'to_objectchange'):
if hasattr(instance, 'snapshot') and not getattr(instance, '_prechange_snapshot', None): if hasattr(instance, 'snapshot') and not getattr(instance, '_prechange_snapshot', None):
@ -172,13 +220,21 @@ def handle_deleted_object(sender, instance, **kwargs):
# Enqueue the object for event processing # Enqueue the object for event processing
queue = events_queue.get() queue = events_queue.get()
enqueue_event(queue, instance, request.user, request.id, OBJECT_DELETED) enqueue_event(queue, instance, request, OBJECT_DELETED)
events_queue.set(queue) events_queue.set(queue)
# Increment metric counters # Increment metric counters
model_deletes.labels(instance._meta.model_name).inc() model_deletes.labels(instance._meta.model_name).inc()
@receiver(request_finished)
def clear_signal_history(sender, **kwargs):
"""
Clear out the signals history once the request is finished.
"""
_signals_received.pre_delete = set()
@receiver(clear_events) @receiver(clear_events)
def clear_events_queue(sender, **kwargs): def clear_events_queue(sender, **kwargs):
""" """

View File

@ -41,6 +41,9 @@ class ObjectChangeTable(NetBoxTable):
template_code=OBJECTCHANGE_REQUEST_ID, template_code=OBJECTCHANGE_REQUEST_ID,
verbose_name=_('Request ID') verbose_name=_('Request ID')
) )
message = tables.Column(
verbose_name=_('Message'),
)
actions = columns.ActionsColumn( actions = columns.ActionsColumn(
actions=() actions=()
) )
@ -49,5 +52,8 @@ class ObjectChangeTable(NetBoxTable):
model = ObjectChange model = ObjectChange
fields = ( fields = (
'pk', 'time', 'user_name', 'full_name', 'action', 'changed_object_type', 'object_repr', 'request_id', 'pk', 'time', 'user_name', 'full_name', 'action', 'changed_object_type', 'object_repr', 'request_id',
'actions', 'message', 'actions',
)
default_columns = (
'pk', 'time', 'user_name', 'action', 'changed_object_type', 'object_repr', 'message', 'actions',
) )

View File

@ -1,12 +1,11 @@
import django_tables2 as tables import django_tables2 as tables
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from core.constants import RQ_TASK_STATUSES
from netbox.registry import registry from netbox.registry import registry
__all__ = ( __all__ = (
'BackendTypeColumn', 'BackendTypeColumn',
'RQJobStatusColumn', 'BadgeColumn',
) )
@ -23,14 +22,21 @@ class BackendTypeColumn(tables.Column):
return value return value
class RQJobStatusColumn(tables.Column): class BadgeColumn(tables.Column):
""" """
Render a colored label for the status of an RQ job. Render a colored badge for a value.
Args:
badges: A dictionary mapping of values to core.constants.Badge instances.
""" """
def __init__(self, badges, *args, **kwargs):
super().__init__(*args, **kwargs)
self.badges = badges
def render(self, value): def render(self, value):
status = RQ_TASK_STATUSES.get(value) badge = self.badges.get(value)
return mark_safe(f'<span class="badge text-bg-{status.color}">{status.label}</span>') return mark_safe(f'<span class="badge text-bg-{badge.color}">{badge.label}</span>')
def value(self, value): def value(self, value):
status = RQ_TASK_STATUSES.get(value) badge = self.badges.get(value)
return status.label return badge.label

View File

@ -1,8 +1,10 @@
import django_tables2 as tables import django_tables2 as tables
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from netbox.tables import NetBoxTable, columns from netbox.tables import BaseTable, NetBoxTable, columns
from ..models import Job from core.constants import JOB_LOG_ENTRY_LEVELS
from core.models import Job
from core.tables.columns import BadgeColumn
class JobTable(NetBoxTable): class JobTable(NetBoxTable):
@ -40,6 +42,9 @@ class JobTable(NetBoxTable):
completed = columns.DateTimeColumn( completed = columns.DateTimeColumn(
verbose_name=_('Completed'), verbose_name=_('Completed'),
) )
log_entries = tables.Column(
verbose_name=_('Log Entries'),
)
actions = columns.ActionsColumn( actions = columns.ActionsColumn(
actions=('delete',) actions=('delete',)
) )
@ -53,3 +58,24 @@ class JobTable(NetBoxTable):
default_columns = ( default_columns = (
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user', 'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user',
) )
def render_log_entries(self, value):
return len(value)
class JobLogEntryTable(BaseTable):
timestamp = columns.DateTimeColumn(
timespec='milliseconds',
verbose_name=_('Time'),
)
level = BadgeColumn(
badges=JOB_LOG_ENTRY_LEVELS,
verbose_name=_('Level'),
)
message = tables.Column(
verbose_name=_('Message'),
)
class Meta(BaseTable.Meta):
empty_text = _('No log entries')
fields = ('timestamp', 'level', 'message')

View File

@ -2,7 +2,8 @@ import django_tables2 as tables
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_tables2.utils import A from django_tables2.utils import A
from core.tables.columns import RQJobStatusColumn from core.constants import RQ_TASK_STATUSES
from core.tables.columns import BadgeColumn
from netbox.tables import BaseTable, columns from netbox.tables import BaseTable, columns
@ -84,7 +85,8 @@ class BackgroundTaskTable(BaseTable):
ended_at = columns.DateTimeColumn( ended_at = columns.DateTimeColumn(
verbose_name=_("Ended") verbose_name=_("Ended")
) )
status = RQJobStatusColumn( status = BadgeColumn(
badges=RQ_TASK_STATUSES,
verbose_name=_("Status"), verbose_name=_("Status"),
accessor='get_status' accessor='get_status'
) )

View File

@ -7,6 +7,7 @@ from django.utils import timezone
from rq.job import Job as RQ_Job, JobStatus from rq.job import Job as RQ_Job, JobStatus
from rq.registry import FailedJobRegistry, StartedJobRegistry from rq.registry import FailedJobRegistry, StartedJobRegistry
from rest_framework import status
from users.models import Token, User from users.models import Token, User
from utilities.testing import APITestCase, APIViewTestCases, TestCase from utilities.testing import APITestCase, APIViewTestCases, TestCase
from utilities.testing.utils import disable_logging from utilities.testing.utils import disable_logging
@ -101,6 +102,22 @@ class DataFileTest(
DataFile.objects.bulk_create(data_files) DataFile.objects.bulk_create(data_files)
class ObjectTypeTest(APITestCase):
def test_list_objects(self):
object_type_count = ObjectType.objects.count()
response = self.client.get(reverse('extras-api:objecttype-list'), **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['count'], object_type_count)
def test_get_object(self):
object_type = ObjectType.objects.first()
url = reverse('extras-api:objecttype-detail', kwargs={'pk': object_type.pk})
self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_200_OK)
class BackgroundTaskTestCase(TestCase): class BackgroundTaskTestCase(TestCase):
user_permissions = () user_permissions = ()

View File

@ -346,6 +346,38 @@ class ChangeLogViewTest(ModelViewTestCase):
self.assertEqual(changes[1].changed_object_type, ContentType.objects.get_for_model(Interface)) self.assertEqual(changes[1].changed_object_type, ContentType.objects.get_for_model(Interface))
self.assertEqual(changes[2].changed_object_type, ContentType.objects.get_for_model(Device)) self.assertEqual(changes[2].changed_object_type, ContentType.objects.get_for_model(Device))
def test_duplicate_deletions(self):
"""
Check that a cascading deletion event does not generate multiple "deleted" ObjectChange records for
the same object.
"""
role1 = DeviceRole(name='Role 1', slug='role-1')
role1.save()
role2 = DeviceRole(name='Role 2', slug='role-2', parent=role1)
role2.save()
pk_list = [role1.pk, role2.pk]
# Delete both objects simultaneously
form_data = {
'pk': pk_list,
'confirm': True,
'_confirm': True,
}
request = {
'path': reverse('dcim:devicerole_bulk_delete'),
'data': post_data(form_data),
}
self.add_permissions('dcim.delete_devicerole')
self.assertHttpStatus(self.client.post(**request), 302)
# This should result in exactly one change record per object
objectchanges = ObjectChange.objects.filter(
changed_object_type=ContentType.objects.get_for_model(DeviceRole),
changed_object_id__in=pk_list,
action=ObjectChangeActionChoices.ACTION_DELETE
)
self.assertEqual(objectchanges.count(), 2)
class ChangeLogAPITest(APITestCase): class ChangeLogAPITest(APITestCase):

View File

@ -150,7 +150,7 @@ class DataFileTestCase(TestCase, ChangeLoggedFilterSetTests):
class ObjectChangeTestCase(TestCase, BaseFilterSetTests): class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
queryset = ObjectChange.objects.all() queryset = ObjectChange.objects.all()
filterset = ObjectChangeFilterSet filterset = ObjectChangeFilterSet
ignore_fields = ('prechange_data', 'postchange_data') ignore_fields = ('message', 'prechange_data', 'postchange_data')
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):

View File

@ -1,7 +1,10 @@
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from django.test import TestCase from django.test import TestCase
from core.models import DataSource from core.models import DataSource, ObjectType
from core.choices import ObjectChangeActionChoices from core.choices import ObjectChangeActionChoices
from dcim.models import Site, Location, Device
from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED
@ -120,3 +123,80 @@ class DataSourceChangeLoggingTestCase(TestCase):
self.assertEqual(objectchange.prechange_data['parameters']['password'], CENSOR_TOKEN) self.assertEqual(objectchange.prechange_data['parameters']['password'], CENSOR_TOKEN)
self.assertEqual(objectchange.postchange_data['parameters']['username'], 'username2') self.assertEqual(objectchange.postchange_data['parameters']['username'], 'username2')
self.assertEqual(objectchange.postchange_data['parameters']['password'], CENSOR_TOKEN) self.assertEqual(objectchange.postchange_data['parameters']['password'], CENSOR_TOKEN)
class ObjectTypeTest(TestCase):
def test_create(self):
"""
Test that an ObjectType created for a given app_label & model name will be automatically assigned to
the appropriate ContentType.
"""
kwargs = {
'app_label': 'foo',
'model': 'bar',
}
ct = ContentType.objects.create(**kwargs)
ot = ObjectType.objects.create(**kwargs)
self.assertEqual(ot.contenttype_ptr, ct)
def test_get_by_natural_key(self):
"""
Test that get_by_natural_key() returns the appropriate ObjectType.
"""
self.assertEqual(
ObjectType.objects.get_by_natural_key('dcim', 'site'),
ObjectType.objects.get(app_label='dcim', model='site')
)
with self.assertRaises(ObjectDoesNotExist):
ObjectType.objects.get_by_natural_key('foo', 'bar')
def test_get_for_id(self):
"""
Test that get_by_id() returns the appropriate ObjectType.
"""
ot = ObjectType.objects.get_by_natural_key('dcim', 'site')
self.assertEqual(
ObjectType.objects.get_for_id(ot.pk),
ObjectType.objects.get(pk=ot.pk)
)
with self.assertRaises(ObjectDoesNotExist):
ObjectType.objects.get_for_id(0)
def test_get_for_model(self):
"""
Test that get_by_model() returns the appropriate ObjectType.
"""
self.assertEqual(
ObjectType.objects.get_for_model(Site),
ObjectType.objects.get_by_natural_key('dcim', 'site')
)
def test_get_for_models(self):
"""
Test that get_by_models() returns the appropriate ObjectType mapping.
"""
self.assertEqual(
ObjectType.objects.get_for_models(Site, Location, Device),
{
Site: ObjectType.objects.get_by_natural_key('dcim', 'site'),
Location: ObjectType.objects.get_by_natural_key('dcim', 'location'),
Device: ObjectType.objects.get_by_natural_key('dcim', 'device'),
}
)
def test_public(self):
"""
Test that public() returns only ObjectTypes for public models.
"""
public_ots = ObjectType.objects.public()
self.assertIn(ObjectType.objects.get_by_natural_key('dcim', 'site'), public_ots)
self.assertNotIn(ObjectType.objects.get_by_natural_key('extras', 'taggeditem'), public_ots)
def test_with_feature(self):
"""
Test that with_feature() returns only ObjectTypes for models which support the specified feature.
"""
bookmarks_ots = ObjectType.objects.with_feature('bookmarks')
self.assertIn(ObjectType.objects.get_by_natural_key('dcim', 'site'), bookmarks_ots)
self.assertNotIn(ObjectType.objects.get_by_natural_key('dcim', 'cabletermination'), bookmarks_ots)

View File

@ -32,13 +32,12 @@ from utilities.forms import ConfirmationForm
from utilities.htmx import htmx_partial from utilities.htmx import htmx_partial
from utilities.json import ConfigJSONEncoder from utilities.json import ConfigJSONEncoder
from utilities.query import count_related from utilities.query import count_related
from utilities.views import ContentTypePermissionRequiredMixin, GetRelatedModelsMixin, register_model_view from utilities.views import ContentTypePermissionRequiredMixin, GetRelatedModelsMixin, ViewTab, register_model_view
from . import filtersets, forms, tables from . import filtersets, forms, tables
from .choices import DataSourceStatusChoices
from .jobs import SyncDataSourceJob from .jobs import SyncDataSourceJob
from .models import * from .models import *
from .plugins import get_catalog_plugins, get_local_plugins from .plugins import get_catalog_plugins, get_local_plugins
from .tables import CatalogPluginTable, PluginVersionTable from .tables import CatalogPluginTable, JobLogEntryTable, PluginVersionTable
# #
@ -79,12 +78,8 @@ class DataSourceSyncView(BaseObjectView):
def post(self, request, pk): def post(self, request, pk):
datasource = get_object_or_404(self.queryset, pk=pk) datasource = get_object_or_404(self.queryset, pk=pk)
# Enqueue the sync job
# Enqueue the sync job & update the DataSource's status
job = SyncDataSourceJob.enqueue(instance=datasource, user=request.user) job = SyncDataSourceJob.enqueue(instance=datasource, user=request.user)
datasource.status = DataSourceStatusChoices.QUEUED
DataSource.objects.filter(pk=datasource.pk).update(status=datasource.status)
messages.success( messages.success(
request, request,
_("Queued job #{id} to sync {datasource}").format(id=job.pk, datasource=datasource) _("Queued job #{id} to sync {datasource}").format(id=job.pk, datasource=datasource)
@ -184,6 +179,25 @@ class JobView(generic.ObjectView):
actions = (DeleteObject,) actions = (DeleteObject,)
@register_model_view(Job, 'log')
class JobLogView(generic.ObjectView):
queryset = Job.objects.all()
actions = (DeleteObject,)
template_name = 'core/job/log.html'
tab = ViewTab(
label=_('Log'),
badge=lambda obj: len(obj.log_entries),
weight=500,
)
def get_extra_context(self, request, instance):
table = JobLogEntryTable(instance.log_entries)
table.configure(request)
return {
'table': table,
}
@register_model_view(Job, 'delete') @register_model_view(Job, 'delete')
class JobDeleteView(generic.ObjectDeleteView): class JobDeleteView(generic.ObjectDeleteView):
queryset = Job.objects.defer('data') queryset = Job.objects.defer('data')

View File

@ -9,7 +9,7 @@ from dcim.models import (
InventoryItemTemplate, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, InventoryItemTemplate, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
) )
from netbox.api.fields import ChoiceField, ContentTypeField from netbox.api.fields import ChoiceField, ContentTypeField
from netbox.api.serializers import ValidatedModelSerializer from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer
from utilities.api import get_serializer_for_model from utilities.api import get_serializer_for_model
from wireless.choices import * from wireless.choices import *
from .devicetypes import DeviceTypeSerializer, ModuleTypeSerializer from .devicetypes import DeviceTypeSerializer, ModuleTypeSerializer
@ -31,7 +31,11 @@ __all__ = (
) )
class ConsolePortTemplateSerializer(ValidatedModelSerializer): class ComponentTemplateSerializer(ChangeLogMessageSerializer, ValidatedModelSerializer):
pass
class ConsolePortTemplateSerializer(ComponentTemplateSerializer):
device_type = DeviceTypeSerializer( device_type = DeviceTypeSerializer(
nested=True, nested=True,
required=False, required=False,
@ -59,7 +63,7 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer):
brief_fields = ('id', 'url', 'display', 'name', 'description') brief_fields = ('id', 'url', 'display', 'name', 'description')
class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): class ConsoleServerPortTemplateSerializer(ComponentTemplateSerializer):
device_type = DeviceTypeSerializer( device_type = DeviceTypeSerializer(
nested=True, nested=True,
required=False, required=False,
@ -87,7 +91,7 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
brief_fields = ('id', 'url', 'display', 'name', 'description') brief_fields = ('id', 'url', 'display', 'name', 'description')
class PowerPortTemplateSerializer(ValidatedModelSerializer): class PowerPortTemplateSerializer(ComponentTemplateSerializer):
device_type = DeviceTypeSerializer( device_type = DeviceTypeSerializer(
nested=True, nested=True,
required=False, required=False,
@ -116,7 +120,7 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer):
brief_fields = ('id', 'url', 'display', 'name', 'description') brief_fields = ('id', 'url', 'display', 'name', 'description')
class PowerOutletTemplateSerializer(ValidatedModelSerializer): class PowerOutletTemplateSerializer(ComponentTemplateSerializer):
device_type = DeviceTypeSerializer( device_type = DeviceTypeSerializer(
nested=True, nested=True,
required=False, required=False,
@ -156,7 +160,7 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
brief_fields = ('id', 'url', 'display', 'name', 'description') brief_fields = ('id', 'url', 'display', 'name', 'description')
class InterfaceTemplateSerializer(ValidatedModelSerializer): class InterfaceTemplateSerializer(ComponentTemplateSerializer):
device_type = DeviceTypeSerializer( device_type = DeviceTypeSerializer(
nested=True, nested=True,
required=False, required=False,
@ -202,7 +206,7 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer):
brief_fields = ('id', 'url', 'display', 'name', 'description') brief_fields = ('id', 'url', 'display', 'name', 'description')
class RearPortTemplateSerializer(ValidatedModelSerializer): class RearPortTemplateSerializer(ComponentTemplateSerializer):
device_type = DeviceTypeSerializer( device_type = DeviceTypeSerializer(
required=False, required=False,
nested=True, nested=True,
@ -226,7 +230,7 @@ class RearPortTemplateSerializer(ValidatedModelSerializer):
brief_fields = ('id', 'url', 'display', 'name', 'description') brief_fields = ('id', 'url', 'display', 'name', 'description')
class FrontPortTemplateSerializer(ValidatedModelSerializer): class FrontPortTemplateSerializer(ComponentTemplateSerializer):
device_type = DeviceTypeSerializer( device_type = DeviceTypeSerializer(
nested=True, nested=True,
required=False, required=False,
@ -251,7 +255,7 @@ class FrontPortTemplateSerializer(ValidatedModelSerializer):
brief_fields = ('id', 'url', 'display', 'name', 'description') brief_fields = ('id', 'url', 'display', 'name', 'description')
class ModuleBayTemplateSerializer(ValidatedModelSerializer): class ModuleBayTemplateSerializer(ComponentTemplateSerializer):
device_type = DeviceTypeSerializer( device_type = DeviceTypeSerializer(
nested=True, nested=True,
required=False, required=False,
@ -274,7 +278,7 @@ class ModuleBayTemplateSerializer(ValidatedModelSerializer):
brief_fields = ('id', 'url', 'display', 'name', 'description') brief_fields = ('id', 'url', 'display', 'name', 'description')
class DeviceBayTemplateSerializer(ValidatedModelSerializer): class DeviceBayTemplateSerializer(ComponentTemplateSerializer):
device_type = DeviceTypeSerializer( device_type = DeviceTypeSerializer(
nested=True nested=True
) )
@ -288,7 +292,7 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer):
brief_fields = ('id', 'url', 'display', 'name', 'description') brief_fields = ('id', 'url', 'display', 'name', 'description')
class InventoryItemTemplateSerializer(ValidatedModelSerializer): class InventoryItemTemplateSerializer(ComponentTemplateSerializer):
device_type = DeviceTypeSerializer( device_type = DeviceTypeSerializer(
nested=True nested=True
) )

View File

@ -11,6 +11,7 @@ from ipam.choices import VLANQinQRoleChoices
from ipam.models import ASN, VLAN, VLANGroup, VRF from ipam.models import ASN, VLAN, VLANGroup, VRF
from netbox.choices import * from netbox.choices import *
from netbox.forms import NetBoxModelBulkEditForm from netbox.forms import NetBoxModelBulkEditForm
from netbox.forms.mixins import ChangelogMessageMixin
from tenancy.models import Tenant from tenancy.models import Tenant
from users.models import User from users.models import User
from utilities.forms import BulkEditForm, add_blank_choice, form_from_model from utilities.forms import BulkEditForm, add_blank_choice, form_from_model
@ -1037,7 +1038,11 @@ class PowerFeedBulkEditForm(NetBoxModelBulkEditForm):
# Device component templates # Device component templates
# #
class ConsolePortTemplateBulkEditForm(BulkEditForm): class ComponentTemplateBulkEditForm(ChangelogMessageMixin, BulkEditForm):
pass
class ConsolePortTemplateBulkEditForm(ComponentTemplateBulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=ConsolePortTemplate.objects.all(), queryset=ConsolePortTemplate.objects.all(),
widget=forms.MultipleHiddenInput() widget=forms.MultipleHiddenInput()
@ -1056,7 +1061,7 @@ class ConsolePortTemplateBulkEditForm(BulkEditForm):
nullable_fields = ('label', 'type', 'description') nullable_fields = ('label', 'type', 'description')
class ConsoleServerPortTemplateBulkEditForm(BulkEditForm): class ConsoleServerPortTemplateBulkEditForm(ComponentTemplateBulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=ConsoleServerPortTemplate.objects.all(), queryset=ConsoleServerPortTemplate.objects.all(),
widget=forms.MultipleHiddenInput() widget=forms.MultipleHiddenInput()
@ -1079,7 +1084,7 @@ class ConsoleServerPortTemplateBulkEditForm(BulkEditForm):
nullable_fields = ('label', 'type', 'description') nullable_fields = ('label', 'type', 'description')
class PowerPortTemplateBulkEditForm(BulkEditForm): class PowerPortTemplateBulkEditForm(ComponentTemplateBulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=PowerPortTemplate.objects.all(), queryset=PowerPortTemplate.objects.all(),
widget=forms.MultipleHiddenInput() widget=forms.MultipleHiddenInput()
@ -1114,7 +1119,7 @@ class PowerPortTemplateBulkEditForm(BulkEditForm):
nullable_fields = ('label', 'type', 'maximum_draw', 'allocated_draw', 'description') nullable_fields = ('label', 'type', 'maximum_draw', 'allocated_draw', 'description')
class PowerOutletTemplateBulkEditForm(BulkEditForm): class PowerOutletTemplateBulkEditForm(ComponentTemplateBulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=PowerOutletTemplate.objects.all(), queryset=PowerOutletTemplate.objects.all(),
widget=forms.MultipleHiddenInput() widget=forms.MultipleHiddenInput()
@ -1165,7 +1170,7 @@ class PowerOutletTemplateBulkEditForm(BulkEditForm):
self.fields['power_port'].widget.attrs['disabled'] = True self.fields['power_port'].widget.attrs['disabled'] = True
class InterfaceTemplateBulkEditForm(BulkEditForm): class InterfaceTemplateBulkEditForm(ComponentTemplateBulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=InterfaceTemplate.objects.all(), queryset=InterfaceTemplate.objects.all(),
widget=forms.MultipleHiddenInput() widget=forms.MultipleHiddenInput()
@ -1216,7 +1221,7 @@ class InterfaceTemplateBulkEditForm(BulkEditForm):
nullable_fields = ('label', 'description', 'poe_mode', 'poe_type', 'rf_role') nullable_fields = ('label', 'description', 'poe_mode', 'poe_type', 'rf_role')
class FrontPortTemplateBulkEditForm(BulkEditForm): class FrontPortTemplateBulkEditForm(ComponentTemplateBulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=FrontPortTemplate.objects.all(), queryset=FrontPortTemplate.objects.all(),
widget=forms.MultipleHiddenInput() widget=forms.MultipleHiddenInput()
@ -1243,7 +1248,7 @@ class FrontPortTemplateBulkEditForm(BulkEditForm):
nullable_fields = ('description',) nullable_fields = ('description',)
class RearPortTemplateBulkEditForm(BulkEditForm): class RearPortTemplateBulkEditForm(ComponentTemplateBulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=RearPortTemplate.objects.all(), queryset=RearPortTemplate.objects.all(),
widget=forms.MultipleHiddenInput() widget=forms.MultipleHiddenInput()
@ -1270,7 +1275,7 @@ class RearPortTemplateBulkEditForm(BulkEditForm):
nullable_fields = ('description',) nullable_fields = ('description',)
class ModuleBayTemplateBulkEditForm(BulkEditForm): class ModuleBayTemplateBulkEditForm(ComponentTemplateBulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=ModuleBayTemplate.objects.all(), queryset=ModuleBayTemplate.objects.all(),
widget=forms.MultipleHiddenInput() widget=forms.MultipleHiddenInput()
@ -1288,7 +1293,7 @@ class ModuleBayTemplateBulkEditForm(BulkEditForm):
nullable_fields = ('label', 'position', 'description') nullable_fields = ('label', 'position', 'description')
class DeviceBayTemplateBulkEditForm(BulkEditForm): class DeviceBayTemplateBulkEditForm(ComponentTemplateBulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=DeviceBayTemplate.objects.all(), queryset=DeviceBayTemplate.objects.all(),
widget=forms.MultipleHiddenInput() widget=forms.MultipleHiddenInput()
@ -1306,7 +1311,7 @@ class DeviceBayTemplateBulkEditForm(BulkEditForm):
nullable_fields = ('label', 'description') nullable_fields = ('label', 'description')
class InventoryItemTemplateBulkEditForm(BulkEditForm): class InventoryItemTemplateBulkEditForm(ComponentTemplateBulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=InventoryItemTemplate.objects.all(), queryset=InventoryItemTemplate.objects.all(),
widget=forms.MultipleHiddenInput() widget=forms.MultipleHiddenInput()

View File

@ -470,8 +470,8 @@ class ModuleTypeImportForm(NetBoxModelImportForm):
class Meta: class Meta:
model = ModuleType model = ModuleType
fields = [ fields = [
'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit', 'comments', 'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit', 'profile',
'tags', 'comments', 'tags'
] ]
@ -1335,6 +1335,13 @@ class MACAddressImportForm(NetBoxModelImportForm):
class CableImportForm(NetBoxModelImportForm): class CableImportForm(NetBoxModelImportForm):
# Termination A # Termination A
side_a_site = CSVModelChoiceField(
label=_('Side A site'),
queryset=Site.objects.all(),
required=False,
to_field_name='name',
help_text=_('Site of parent device A (if any)'),
)
side_a_device = CSVModelChoiceField( side_a_device = CSVModelChoiceField(
label=_('Side A device'), label=_('Side A device'),
queryset=Device.objects.all(), queryset=Device.objects.all(),
@ -1353,6 +1360,13 @@ class CableImportForm(NetBoxModelImportForm):
) )
# Termination B # Termination B
side_b_site = CSVModelChoiceField(
label=_('Side B site'),
queryset=Site.objects.all(),
required=False,
to_field_name='name',
help_text=_('Site of parent device B (if any)'),
)
side_b_device = CSVModelChoiceField( side_b_device = CSVModelChoiceField(
label=_('Side B device'), label=_('Side B device'),
queryset=Device.objects.all(), queryset=Device.objects.all(),
@ -1396,14 +1410,39 @@ class CableImportForm(NetBoxModelImportForm):
required=False, required=False,
help_text=_('Length unit') help_text=_('Length unit')
) )
color = forms.CharField(
label=_('Color'),
required=False,
max_length=16,
help_text=_('Color name (e.g. "Red") or hex code (e.g. "f44336")')
)
class Meta: class Meta:
model = Cable model = Cable
fields = [ fields = [
'side_a_device', 'side_a_type', 'side_a_name', 'side_b_device', 'side_b_type', 'side_b_name', 'type', 'side_a_site', 'side_a_device', 'side_a_type', 'side_a_name', 'side_b_site', 'side_b_device', 'side_b_type',
'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'comments', 'tags', 'side_b_name', 'type', 'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description',
'comments', 'tags',
] ]
def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs)
if data:
# Limit choices for side_a_device to the assigned side_a_site
if side_a_site := data.get('side_a_site'):
side_a_device_params = {f'site__{self.fields["side_a_site"].to_field_name}': side_a_site}
self.fields['side_a_device'].queryset = self.fields['side_a_device'].queryset.filter(
**side_a_device_params
)
# Limit choices for side_b_device to the assigned side_b_site
if side_b_site := data.get('side_b_site'):
side_b_device_params = {f'site__{self.fields["side_b_site"].to_field_name}': side_b_site}
self.fields['side_b_device'].queryset = self.fields['side_b_device'].queryset.filter(
**side_b_device_params
)
def _clean_side(self, side): def _clean_side(self, side):
""" """
Derive a Cable's A/B termination objects. Derive a Cable's A/B termination objects.
@ -1440,6 +1479,24 @@ class CableImportForm(NetBoxModelImportForm):
setattr(self.instance, f'{side}_terminations', [termination_object]) setattr(self.instance, f'{side}_terminations', [termination_object])
return termination_object return termination_object
def _clean_color(self, color):
"""
Derive a colors hex code
:param color: color as hex or color name
"""
color_parsed = color.strip().lower()
for hex_code, label in ColorChoices.CHOICES:
if color.lower() == label.lower():
color_parsed = hex_code
if len(color_parsed) > 6:
raise forms.ValidationError(
_(f"{color} did not match any used color name and was longer than six characters: invalid hex.")
)
return color_parsed
def clean_side_a_name(self): def clean_side_a_name(self):
return self._clean_side('a') return self._clean_side('a')
@ -1451,11 +1508,14 @@ class CableImportForm(NetBoxModelImportForm):
length_unit = self.cleaned_data.get('length_unit', None) length_unit = self.cleaned_data.get('length_unit', None)
return length_unit if length_unit is not None else '' return length_unit if length_unit is not None else ''
def clean_color(self):
color = self.cleaned_data.get('color', None)
return self._clean_color(color) if color is not None else ''
# #
# Virtual chassis # Virtual chassis
# #
class VirtualChassisImportForm(NetBoxModelImportForm): class VirtualChassisImportForm(NetBoxModelImportForm):
master = CSVModelChoiceField( master = CSVModelChoiceField(
label=_('Master'), label=_('Master'),

View File

@ -11,6 +11,7 @@ from extras.models import ConfigTemplate
from ipam.choices import VLANQinQRoleChoices from ipam.choices import VLANQinQRoleChoices
from ipam.models import ASN, IPAddress, VLAN, VLANGroup, VLANTranslationPolicy, VRF from ipam.models import ASN, IPAddress, VLAN, VLANGroup, VLANTranslationPolicy, VRF
from netbox.forms import NetBoxModelForm from netbox.forms import NetBoxModelForm
from netbox.forms.mixins import ChangelogMessageMixin
from tenancy.forms import TenancyForm from tenancy.forms import TenancyForm
from users.models import User from users.models import User
from utilities.forms import add_blank_choice, get_field_value from utilities.forms import add_blank_choice, get_field_value
@ -973,7 +974,7 @@ class VCMemberSelectForm(forms.Form):
# Device component templates # Device component templates
# #
class ComponentTemplateForm(forms.ModelForm): class ComponentTemplateForm(ChangelogMessageMixin, forms.ModelForm):
device_type = DynamicModelChoiceField( device_type = DynamicModelChoiceField(
label=_('Device type'), label=_('Device type'),
queryset=DeviceType.objects.all(), queryset=DeviceType.objects.all(),

View File

@ -426,6 +426,11 @@ class VirtualChassisCreateForm(NetBoxModelForm):
help_text=_('Position of the first member device. Increases by one for each additional member.') help_text=_('Position of the first member device. Increases by one for each additional member.')
) )
fieldsets = (
FieldSet('name', 'domain', 'description', 'tags', name=_('Virtual Chassis')),
FieldSet('region', 'site_group', 'site', 'rack', 'members', 'initial_position', name=_('Member Devices')),
)
class Meta: class Meta:
model = VirtualChassis model = VirtualChassis
fields = [ fields = [

View File

@ -33,6 +33,7 @@ if TYPE_CHECKING:
from tenancy.graphql.types import TenantType from tenancy.graphql.types import TenantType
from users.graphql.types import UserType from users.graphql.types import UserType
from virtualization.graphql.types import ClusterType, VMInterfaceType, VirtualMachineType from virtualization.graphql.types import ClusterType, VMInterfaceType, VirtualMachineType
from vpn.graphql.types import L2VPNTerminationType
from wireless.graphql.types import WirelessLANType, WirelessLinkType from wireless.graphql.types import WirelessLANType, WirelessLinkType
__all__ = ( __all__ = (
@ -440,6 +441,7 @@ class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, P
primary_mac_address: Annotated["MACAddressType", strawberry.lazy('dcim.graphql.types')] | None primary_mac_address: Annotated["MACAddressType", strawberry.lazy('dcim.graphql.types')] | None
qinq_svlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None qinq_svlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
vlan_translation_policy: Annotated["VLANTranslationPolicyType", strawberry.lazy('ipam.graphql.types')] | None vlan_translation_policy: Annotated["VLANTranslationPolicyType", strawberry.lazy('ipam.graphql.types')] | None
l2vpn_termination: Annotated["L2VPNTerminationType", strawberry.lazy('vpn.graphql.types')] | None
vdcs: List[Annotated["VirtualDeviceContextType", strawberry.lazy('dcim.graphql.types')]] vdcs: List[Annotated["VirtualDeviceContextType", strawberry.lazy('dcim.graphql.types')]]
tagged_vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]] tagged_vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]]

View File

@ -19,7 +19,8 @@ def load_initial_data(apps, schema_editor):
'gpu', 'gpu',
'hard_disk', 'hard_disk',
'memory', 'memory',
'power_supply' 'power_supply',
'expansion_card'
) )
for name in initial_profiles: for name in initial_profiles:

View File

@ -0,0 +1,44 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0207_remove_redundant_indexes'),
('extras', '0129_fix_script_paths'),
]
operations = [
migrations.AddConstraint(
model_name='devicerole',
constraint=models.UniqueConstraint(
fields=('parent', 'name'),
name='dcim_devicerole_parent_name'
),
),
migrations.AddConstraint(
model_name='devicerole',
constraint=models.UniqueConstraint(
condition=models.Q(('parent__isnull', True)),
fields=('name',),
name='dcim_devicerole_name',
violation_error_message='A top-level device role with this name already exists.'
),
),
migrations.AddConstraint(
model_name='devicerole',
constraint=models.UniqueConstraint(
fields=('parent', 'slug'),
name='dcim_devicerole_parent_slug'
),
),
migrations.AddConstraint(
model_name='devicerole',
constraint=models.UniqueConstraint(
condition=models.Q(('parent__isnull', True)),
fields=('slug',),
name='dcim_devicerole_slug',
violation_error_message='A top-level device role with this slug already exists.'
),
),
]

View File

@ -4,7 +4,7 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('dcim', '0207_remove_redundant_indexes'), ('dcim', '0208_devicerole_uniqueness'),
('extras', '0129_fix_script_paths'), ('extras', '0129_fix_script_paths'),
] ]

View File

@ -5,7 +5,7 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('dcim', '0208_platform_manufacturer_uniqueness'), ('dcim', '0209_platform_manufacturer_uniqueness'),
] ]
operations = [ operations = [

View File

@ -0,0 +1,15 @@
{
"name": "Expansion card",
"schema": {
"properties": {
"connector_type": {
"type": "string",
"description": "Connector type e.g. PCIe x4"
},
"bandwidth": {
"type": "integer",
"description": "Total Bandwidth for this module"
}
}
}
}

View File

@ -1,6 +1,7 @@
import itertools import itertools
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.dispatch import Signal from django.dispatch import Signal
@ -479,13 +480,13 @@ class CablePath(models.Model):
def origin_type(self): def origin_type(self):
if self.path: if self.path:
ct_id, _ = decompile_path_node(self.path[0][0]) ct_id, _ = decompile_path_node(self.path[0][0])
return ObjectType.objects.get_for_id(ct_id) return ContentType.objects.get_for_id(ct_id)
@property @property
def destination_type(self): def destination_type(self):
if self.is_complete: if self.is_complete:
ct_id, _ = decompile_path_node(self.path[-1][0]) ct_id, _ = decompile_path_node(self.path[-1][0])
return ObjectType.objects.get_for_id(ct_id) return ContentType.objects.get_for_id(ct_id)
@property @property
def _path_decompiled(self): def _path_decompiled(self):

View File

@ -4,6 +4,7 @@ import yaml
from functools import cached_property from functools import cached_property
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage from django.core.files.storage import default_storage
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
@ -15,7 +16,6 @@ from django.urls import reverse
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.models import ObjectType
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.fields import MACAddressField from dcim.fields import MACAddressField
@ -398,6 +398,28 @@ class DeviceRole(NestedGroupModel):
class Meta: class Meta:
ordering = ('name',) ordering = ('name',)
constraints = (
models.UniqueConstraint(
fields=('parent', 'name'),
name='%(app_label)s_%(class)s_parent_name'
),
models.UniqueConstraint(
fields=('name',),
name='%(app_label)s_%(class)s_name',
condition=Q(parent__isnull=True),
violation_error_message=_("A top-level device role with this name already exists.")
),
models.UniqueConstraint(
fields=('parent', 'slug'),
name='%(app_label)s_%(class)s_parent_slug'
),
models.UniqueConstraint(
fields=('slug',),
name='%(app_label)s_%(class)s_slug',
condition=Q(parent__isnull=True),
violation_error_message=_("A top-level device role with this slug already exists.")
),
)
verbose_name = _('device role') verbose_name = _('device role')
verbose_name_plural = _('device roles') verbose_name_plural = _('device roles')
@ -1306,7 +1328,7 @@ class MACAddress(PrimaryModel):
super().clean() super().clean()
if self._original_assigned_object_id and self._original_assigned_object_type_id: if self._original_assigned_object_id and self._original_assigned_object_type_id:
assigned_object = self.assigned_object assigned_object = self.assigned_object
ct = ObjectType.objects.get_for_id(self._original_assigned_object_type_id) ct = ContentType.objects.get_for_id(self._original_assigned_object_type_id)
original_assigned_object = ct.get_object_for_this_type(pk=self._original_assigned_object_id) original_assigned_object = ct.get_object_for_this_type(pk=self._original_assigned_object_id)
if ( if (

View File

@ -3,6 +3,7 @@ import svgwrite
from svgwrite.container import Hyperlink from svgwrite.container import Hyperlink
from svgwrite.image import Image from svgwrite.image import Image
from svgwrite.gradients import LinearGradient from svgwrite.gradients import LinearGradient
from svgwrite.masking import ClipPath
from svgwrite.shapes import Rect from svgwrite.shapes import Rect
from svgwrite.text import Text from svgwrite.text import Text
@ -67,6 +68,20 @@ def get_device_description(device):
return description return description
def truncate_text(text, width, font_size=15):
"""
Truncate text to fit within the width of a rectangle.
:param text: The text to truncate
:param width: Width of rectangle
:param font_size: Font size (default is 15, ~0.875rem)
"""
char_width = font_size * 0.6 # 0.6 is an approximation of the average character width in pixels
max_char = int(width / char_width)
return text if len(text) <= max_char else text[:max_char] + '...'
class RackElevationSVG: class RackElevationSVG:
""" """
Use this class to render a rack elevation as an SVG image. Use this class to render a rack elevation as an SVG image.
@ -177,12 +192,26 @@ class RackElevationSVG:
link = Hyperlink(href=f'{self.base_url}{device.get_absolute_url()}', target="_parent") link = Hyperlink(href=f'{self.base_url}{device.get_absolute_url()}', target="_parent")
link.set_desc(description) link.set_desc(description)
# Create clipPath element
# This is necessary as fallback because the truncate_text method is an approximation
clip_id = f"clip-{device.id}"
clip_path = ClipPath(id=clip_id)
clip_path.add(Rect(coords, size))
self.drawing.defs.add(clip_path)
# Name to display
display_name = truncate_text(name, size[0])
# Add rect element to hyperlink # Add rect element to hyperlink
if color: if color:
link.add(Rect(coords, size, style=f'fill: #{color}', class_=f'slot{css_extra}')) link.add(Rect(coords, size, style=f'fill: #{color}', class_=f'slot{css_extra}'))
else: else:
link.add(Rect(coords, size, class_=f'slot blocked{css_extra}')) link.add(Rect(coords, size, class_=f'slot blocked{css_extra}'))
link.add(Text(name, insert=text_coords, fill=text_color, class_=f'label{css_extra}')) link.add(
Text(display_name, insert=text_coords, fill=text_color, clip_path=f"url(#{clip_id})",
class_=f'label{css_extra}')
)
# Embed device type image if provided # Embed device type image if provided
if self.include_images and image: if self.include_images and image:

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