Compare commits

...

51 Commits

Author SHA1 Message Date
Arthur
4db3d488ad Merge branch 'main' into 02496-max-page 2025-10-07 09:12:33 -07:00
Martin Hauser
b7cae04572 fix(api): Update NumericRange handling to use half-open intervals (#20478) 2025-10-07 09:01:29 -07:00
Martin Hauser
51528ae429 fix(utilities): Enhance ranges_to_string for improved clarity (#20479) 2025-10-07 08:47:01 -07:00
Jeremy Stretch
d5e8480367 Update OpenAPI schema (#20519) 2025-10-07 08:22:24 -07:00
Matthew Papaleo
05e26b82c1 Fixes #20507 Contacts returned for ASN via graphql API 2025-10-07 09:08:04 -04:00
github-actions
faa89a53ff Update source translation strings 2025-10-07 05:02:29 +00:00
Dmitry Smirnov
d18bbe48c1 add tag copy_content and id 'job_data_output' 2025-10-06 15:17:39 -04:00
Martin Hauser
99e367cbaf docs(api): Correct IntegerRangeSerializer schema definition
Adjusts the schema mapping for `IntegerRangeSerializer` by setting
`match_subclasses` to `True` and refining the array definition. Adds
an example field for clarity in generated OpenAPI documentation.

Fixes #20494
2025-10-06 15:09:57 -04:00
Daniel Sheppard
f5ed095738 Fixes: #21040 - Registered denormalized fields (#20503) 2025-10-06 09:12:27 -05:00
Johannes Erwerle
b70f1211ab Fixed wrong link in plugin filtersets documentation 2025-10-06 10:03:47 -04:00
Arthur
10e8e7b071 20496 fix test 2025-10-03 14:54:08 -07:00
Arthur
c770e6b45d 20496 fix max_page_size for REST API 2025-10-03 14:22:55 -07:00
Jason Novinger
c094699dc0 Fixes #20484: Configure CodeQL to exclude URL redirect false positives 2025-10-03 08:48:02 -04:00
Martin Hauser
5f77d684e1 chore(core): Remove unused imports in plugins and migrations
Cleans up unused imports across `plugins.py` and a migration file.

Closes #20482
2025-10-02 17:11:07 -04:00
github-actions
f23eb53312 Update source translation strings 2025-10-02 05:02:10 +00:00
bctiemann
91d5d284ca Merge pull request #20464 from netbox-community/20248-fix-translation-error
Fixes #20248: Tweak help text to avoid error when compiling translations
2025-10-01 20:45:42 -04:00
github-actions
c4dcc62c04 Update source translation strings 2025-10-01 05:02:17 +00:00
Jeremy Stretch
5a96b76cd4 Release v4.4.2 2025-09-30 16:14:35 -04:00
Jeremy Stretch
26fc06b817 Fixes #20248: Tweak help text to avoid error when compiling translations 2025-09-30 15:10:53 -04:00
Jeremy Stretch
9bc60a157b Fixes #20243: Prevent scheduled system jobs from re-running multiple times (#20450) 2025-09-30 13:27:31 -05:00
Jeremy Stretch
28cc8e5c89 Fixes #18878: Automatically assign a designated primary MAC address upon creation of a new interface (#20457) 2025-09-30 13:26:52 -05:00
Martin Hauser
ba1c0d6d84 Closes #20449: Add user preferences documentation (#20460) 2025-09-30 13:16:36 -05:00
Jeremy Stretch
f31a5551ff Closes #19765: Linkify object types under saved filter view (#20458) 2025-09-30 08:29:59 -07:00
Jeremy Stretch
b0a8b86a93 #20382: Additional GraphQL API tips (#20451)
* #20382: Additional GraphQL API tips

* Add graphql hint for syntax highlighting
2025-09-30 11:29:29 -04:00
Jeremy Stretch
d222466882 Fixes #20245: Fix Markdown preview functionality within "quick add" modal 2025-09-30 11:19:50 -04:00
Martin Hauser
9e75a2f955 fix(api): Fix schema and field definitions for OpenAPI
Add `get_internal_type()` to custom field classes for Django compatibility,
annotate path parameters and operation IDs for background endpoints, and
provide serializer context on the RQ base viewset to clear schema warnings.

Fixes #20365
2025-09-30 10:46:03 -04:00
Jeremy Stretch
10e76597a8 Closes #20332: Add a "none" option to object tag filters (#20452) 2025-09-30 09:45:15 -05:00
Martin Hauser
18862586e5 feat(dcim): Add "facility" field to bulk edit forms for Site and Location
Introduces a new "facility" field in the bulk edit forms for Site and
Location models. Updates fieldsets and nullable fields to incorporate
the "facility" field.

Closes #20438
2025-09-30 08:48:26 -04:00
github-actions
69a7c97c3e Update source translation strings 2025-09-30 05:04:06 +00:00
Jeremy Stretch
bfd1adf0b5 Fixes #20441: Fix display of the "groups" column in contact assignments table (#20446) 2025-09-29 13:05:52 -05:00
Robert Drake
030f03b1a8 Typo and alphabetical fixes for Interface choices
This fixes the alphabetical ordering of the interface types, and it
corrects the typo in the BiDi names.

fixes #20392
2025-09-29 13:38:37 -04:00
Jeremy Stretch
6cf6e2cd7f Fixes #20419: Correct action buttons for child object views (#20445) 2025-09-29 09:14:16 -07:00
RasmusThing
0b7baae23c Fixes #20412: linkify cluster type (#20413) 2025-09-29 06:44:15 -05:00
Elliott Balsley
0c22fc9408 Fixes #19590: Display related columns on DeviceComponents table (#20344) 2025-09-29 05:45:37 -05:00
bctiemann
a437931aef Merge pull request #20393 from netbox-community/20390-pagination-dropdown
Fixes #20390: Fix styling of pagination dropdown menu
2025-09-22 07:15:53 -04:00
github-actions
0fac8e671e Update source translation strings 2025-09-20 05:02:20 +00:00
Jeremy Stretch
6547a16ab6 Fixes #20398: Rely on browser-native form field validation (#20401) 2025-09-19 15:13:47 -05:00
Jeremy Stretch
07a53c8315 Closes #17010: Show admin navigation menu items only for staff & superusers (#20386) 2025-09-19 12:52:16 -07:00
Elliott Balsley
55cda3ca45 Fixes #20253: GraphQL filter by contacts (#20288)
* filter models by contacts

* remove unsed import

* simpler solution
2025-09-19 10:52:02 -04:00
github-actions
a173a9b4ac Update source translation strings 2025-09-19 05:03:24 +00:00
bctiemann
d34ce7794c Merge pull request #20381 from netbox-community/20380-sentry_config
Closes #20380: Introduce the `SENTRY_CONFIG` config parameter
2025-09-18 22:15:11 -04:00
Jeremy Stretch
f45a11d079 Closes #20382: Document performance best practices (#20384) 2025-09-18 14:17:06 -05:00
Jeremy Stretch
56db60f8c9 Fixes #20375: Preserve filter params when performing bulk operations (#20387) 2025-09-18 14:08:50 -05:00
Jeremy Stretch
c8b30270a8 Fixes #20390: Fix styling of pagination dropdown menu 2025-09-18 14:05:00 -04:00
Jeremy Stretch
8e332055bc Closes #20380: Introduce the SENTRY_CONFIG config parameter 2025-09-17 14:25:41 -04:00
bctiemann
3c09ee8b11 Merge pull request #20350 from llamafilm/17824-hotkeys
add global search hotkey
2025-09-17 13:59:37 -04:00
Jeremy Stretch
a4f0b76cb5 Closes #20367: Document best practices for modeling SFPs (#20377) 2025-09-17 11:31:11 -05:00
Elliott Balsley
f2097cce33 no search at login page 2025-09-16 20:05:35 -07:00
Elliott Balsley
499ebb8ab4 Merge branch 'main' into 17824-hotkeys 2025-09-16 19:26:14 -07:00
Elliott Balsley
03a6032f36 remove debug line 2025-09-13 11:45:17 -07:00
Elliott Balsley
53b15e3e41 add global search hotkey 2025-09-09 19:07:17 -07:00
102 changed files with 4465 additions and 3503 deletions

View File

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

View File

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

View File

@@ -25,9 +25,12 @@ body:
- Getting started
- Configuration
- Customization
- Best practices
- Integrations/API
- Plugins
- Administration
- Data model
- Reference
- Development
- Other
validations:

View File

@@ -1,3 +1,11 @@
paths-ignore:
# Ignore compiled JS
- netbox/project-static/dist
query-filters:
# Exclude py/url-redirection: NetBox uses safe_for_redirect() wrapper function
# which validates all redirects via Django's url_has_allowed_host_and_scheme().
# CodeQL's taint tracking doesn't recognize wrapper functions without custom
# query configuration. See #20484.
- exclude:
id: py/url-redirection

View File

@@ -30,7 +30,8 @@ django-htmx
# Modified Preorder Tree Traversal (recursive nesting of objects)
# https://github.com/django-mptt/django-mptt/blob/main/CHANGELOG.rst
django-mptt
# v0.18.0 introduces errant migrations which need to be resolved
django-mptt==0.17.0
# Context managers for PostgreSQL advisory locks
# https://github.com/Xof/django-pglocks/blob/master/CHANGES.txt

View File

@@ -332,14 +332,14 @@
"100base-t1",
"1000base-bx10-d",
"1000base-bx10-u",
"1000base-cx",
"1000base-cwdm",
"1000base-cx",
"1000base-dwdm",
"1000base-ex",
"1000base-sx",
"1000base-lsx",
"1000base-lx",
"1000base-lx10",
"1000base-sx",
"1000base-t",
"1000base-tx",
"1000base-zx",
@@ -374,6 +374,7 @@
"100gbase-cr2",
"100gbase-cr4",
"100gbase-cr10",
"100gbase-cwdm4",
"100gbase-dr",
"100gbase-er4",
"100gbase-fr1",
@@ -387,12 +388,12 @@
"100gbase-zr",
"200gbase-cr2",
"200gbase-cr4",
"200gbase-sr2",
"200gbase-sr4",
"200gbase-dr4",
"200gbase-er4",
"200gbase-fr4",
"200gbase-lr4",
"200gbase-sr2",
"200gbase-sr4",
"200gbase-vr2",
"400gbase-cr4",
"400gbase-dr4",
@@ -415,34 +416,34 @@
"1000base-x-gbic",
"1000base-x-sfp",
"10gbase-x-sfpp",
"10gbase-x-xfp",
"10gbase-x-xenpak",
"10gbase-x-xfp",
"10gbase-x-x2",
"25gbase-x-sfp28",
"50gbase-x-sfp56",
"40gbase-x-qsfpp",
"50gbase-x-sfp28",
"50gbase-x-sfp56",
"100gbase-x-cfp",
"100gbase-x-cfp2",
"200gbase-x-cfp2",
"400gbase-x-cfp2",
"100gbase-x-cfp4",
"100gbase-x-cxp",
"100gbase-x-cpak",
"100gbase-x-dsfp",
"100gbase-x-sfpdd",
"100gbase-x-qsfp28",
"100gbase-x-qsfpdd",
"100gbase-x-sfpdd",
"200gbase-x-cfp2",
"200gbase-x-qsfp56",
"200gbase-x-qsfpdd",
"400gbase-x-qsfp112",
"400gbase-x-qsfpdd",
"400gbase-x-cdfp",
"400gbase-x-cfp2",
"400gbase-x-cfp8",
"400gbase-x-osfp",
"400gbase-x-osfp-rhs",
"400gbase-x-cdfp",
"400gbase-x-cfp8",
"800gbase-x-qsfpdd",
"800gbase-x-osfp",
"800gbase-x-qsfpdd",
"1000base-kx",
"2.5gbase-kx",
"5gbase-kr",

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,74 @@
# Modeling Pluggable Transceivers
## Use Case
Many network devices utilize field-swappable [small-form factor pluggable transceivers (SFPs)](https://en.wikipedia.org/wiki/Small_Form-factor_Pluggable) to enable changing the physical media type of a fixed interface. For example, a 10 Gigabit Ethernet interface might be connected using copper, multimode fiber, or single-mode fiber, each of which requires a different type of SFP+ transceiver.
It can be challenging to model SFPs given their dynamic nature. This guide intends to capture the recommended strategy for modeling SFPs on NetBox v4.4 and later.
## Modeling Strategy
Pluggable transceivers are most accurately represented in NetBox as discrete [modules](../models/dcim/module.md) which are installed within [module bays](../models/dcim/modulebay.md). A module can deliver one or more [interfaces](../models/dcim/interface.md) (or other components) to the device in which it is installed. This approach ensures that a new interface is automatically created on the device when the module is installed, and deleted when the module is removed.
```mermaid
flowchart BT
interface1[Interface 1/1]--> module1[SFP]
interface2[Interface 2/1]--> module2[SFP]
interface3[Interface 3/1] & interface4[Interface 3/2]--> module3[SFP]
module1 --> modulebay1[Module Bay 1]
module2 --> modulebay2[Module Bay 2]
module3 --> modulebay3[Module Bay 3]
modulebay1 & modulebay2 & modulebay3 --> device[Device]
```
### 1. Create an SFP Module Type Profile
If one has not already been defined, create a [module type profile](../models/dcim/moduletypeprofile.md) for SFPs. This profile will be assigned for all module types which represent a pluggable transceiver. Typically, you will need only one profile for all pluggable transceivers.
You might opt to define custom attributes for the profile by defining a custom [JSON schema](https://json-schema.org/). Profile attributes might be used to define characteristics unique to transceivers, such as optical wavelength and power ranges. Adding profile attributes is optional, and can be done at a later point.
!!! note
Creating a module type profile is optional, but recommended as it allows for defining custom module attributes.
### 2. Create a Module Type for Each SFP Model in Inventory
Next, create a [module type](../models/dcim/moduletype.md) to represent each unique SFP model present in your network. Each module type should define a manufacturer and a unique model name, and may also include a part number. For example, you might create a module type for each of the following transceivers:
| Manufacturer | Model | Media Type |
|--------------|------------------|------------|
| Cisco | SFP-10G-SR | 10GE MMF |
| Cisco | SFP-10G-LR | 10GE SMF |
| Juniper | QFX-QSFP-40G-SR4 | 40GE MMF |
| Juniper | JNP-QSFP-DAC-5M | 40GE DAC |
### 3. Add an Interface to the Module Type
After creating each module type, create an interface template on it to represent its physical interface. The definition of this interface template will depend on the transceiver's physical media type. (Reference the table above for examples.) When a new module is "installed" within a module bay on a device, its templated interface(s) will be automatically instantiated on that device as child interfaces of the module.
Determining which name to use for the transceiver's interface can be tricky, as the interface name might depend on the type of device in which the SFP is installed. To avoid having to rename interfaces, consider using the `{module}` token in place of a static interface name. The interface's name will inherit the position of the bay in which its parent module is installed. If creating multiple interfaces on a module, be sure to append a unique ID (e.g. `{module}:1`) to ensure each interface gets assigned a unique name.
### 4. Create Device Types
If you haven't already, create a [device type](../models/dcim/devicetype.md) to represent each unique device model in your network.
!!! note
Skip this step if you've already created the necessary device types.
### 5. Add Module Bays to the Device Type
Once you've created a device type, add the appropriate number of module bays on each device type to represent its SFP slots. For example, a Juniper QFX5110 would have module bays numbered `0/0/0` through `0/0/55`: 48 SFP+ bays and 8 QSFP28 bays (56 total).
Be sure to define both the name **and position** of each module bay with a unique value. The module bay's position will be used to automatically name SFP interfaces.
### 6. Create a Device
Create a new device using the device type added in the previous step. The module bays (and any other components) defined on the device type will be instantiated on the new device automatically.
!!! note
If you've already created the necessary devices in NetBox, you'll need to add their module bays manually. You can add multiple module bays at once by selecting the desired devices from the device list and selecting **Add Components > Module Bays** at the bottom of the page.
### 7. Add the SFP Modules
Finally, create each SFP in the new device by "installing" a new module of the appropriate type in each module bay. The interface(s) defined on the selected module type will be automatically populated on the new module. If present, the `{module}` token in the name of each interface template will be replaced with the position of the bay in which the module is being installed. For example, an interface template with the name `et-{module}` being created on a module installed in a bay with position `0/0/14` will create an interface named `et-0/0/14`.
When adding many modules at once, you may find it helpful to utilize NetBox's bulk import functionality. This allows you to create many modules at once from CSV, JSON, or YAML data.

View File

@@ -0,0 +1,187 @@
# Performance Handbook
The purpose of this handbook is to help users and administrators use NetBox efficiently. It contains assorted recommendations and best practices compiled over time, intending to serve a wide variety of use cases.
## Server Configuration
### WSGI Server Configuration
NetBox operates as a [Web Server Gateway Interface (WSGI)](https://en.wikipedia.org/wiki/Web_Server_Gateway_Interface) application, which sits behind a frontend HTTP server such as nginx or Apache. The HTTP server handles low-level HTTP request processing and serving static assets, and forwards application-level requests to NetBox via WSGI.
A backend WSGI server (typically [Gunicorn](https://gunicorn.org/) or [uWSGI](https://uwsgi-docs.readthedocs.io/en/latest/)) is responsible for running the NetBox application. This is accomplished by initializing a number of WSGI worker processes which accept WSGI requests relayed from the frontend HTTP server.
Tuning your WSGI server is crucial to realizing optimal performance from NetBox. Below are some recommended configuration parameters.
#### Provision Multiple Workers
General guidance is to set the number of worker processes to double the number of CPU cores available, plus one (`2 * CPUs + 1`).
#### Limit the Worker Lifetime
Set a maximum number of requests that a worker can service before being respawned. This helps protect against potential memory leaks.
#### Set a Request Timeout
Limit the time a worker may spend processing any request. This prevents a long-running request from tying up a worker beyond an acceptable threshold. We suggest a limit of 120 seconds as a reasonable safeguard.
#### Bind Using a Unix Socket
When running the HTTP frontend and WSGI server on the same machine, binding via a Unix socket (instead of a TCP socket) may yield slight performance gains.
### NetBox Configuration
NetBox ships with a reasonable default configuration for most environments, but administrators are encouraged to explore all the [available parameters](../configuration/index.md) to tune their installation. Some of the most notable parameters impacting performance are called out below.
#### Reduce the Maximum Page Size
NetBox paginates large result sets to reduce the overall response size. The [`MAX_PAGE_SIZE`](../configuration/miscellaneous.md#max_page_size) parameter specifies the maximum number of results per page that a client can request. This is set to 1,000 by default. Consider lowering this number if you find that API clients are frequently requesting very large result sets.
#### Limit GraphQL Aliases
By default, NetBox restricts a GraphQL query to 10 aliases. Consider reducing this number by setting [`GRAPHQL_MAX_ALIASES`](../configuration/graphql-api.md#graphql_max_aliases) to a lower value.
#### Designate Isolated Deployments
If your NetBox installation does not have Internet access, set [`ISOLATED_DEPLOYMENT`](../configuration/system.md#isolated_deployment) to True. This will prevent the application from attempting routine external requests.
#### Reduce Sentry Sampling
If [Sentry](https://sentry.io/) has been enabled for error reporting and analytics, consider lowering its sampling rate. This can be accomplished by modifying the values for `sample_rate` and `traces_sample_rate` under [`SENTRY_CONFIG`](../configuration/error-reporting.md#sentry_config).
#### Remove Unneeded Event Handlers
Check whether any custom event handlers have been added under [`EVENTS_PIPELINE`](../configuration/miscellaneous.md#events_pipeline). Remove any that are no longer needed.
### Background Task Workers
NetBox defers the execution of certain tasks to background workers via Redis queues serviced by one or more background workers. These workers operate asynchronously from the frontend WSGI workers, and process tasks in the order they are enqueued.
NetBox creates three default queues for background tasks: `high`, `default`, and `low`. Additional queues can be configured via the [`QUEUE_MAPPINGS`](../configuration/miscellaneous.md#queue_mappings) configuration parameter.
By default, a background worker (spawned via `manage.py rqworker`) will listen to all available queues. To improve responsiveness to high-priority background tasks, consider dedicating one or more workers to service the `high` queue only:
```
$ ./manage.py rqworker high
19:31:20 Worker 861be45b32214afc95c235beeb19c9fa: started with PID 2300029, version 2.6.0
19:31:20 Worker 861be45b32214afc95c235beeb19c9fa: subscribing to channel rq:pubsub:861be45b32214afc95c235beeb19c9fa
19:31:20 *** Listening on high...
19:31:20 Worker 861be45b32214afc95c235beeb19c9fa: cleaning registries for queue: high
19:31:20 Scheduler for high started with PID 2300096
```
## API Clients
### REST API
NetBox's [REST API](../integrations/rest-api.md) is the primary means of integration with external systems, allowing full create, read, update, and delete (CRUD) operations. There are a few performance considerations to keep in mind when dealing with very large data sets.
#### Use "Brief" Mode for Simple Lists
In cases where you need to retrieve only a minimal representation of objects, append `?brief=True` to the URL. This instructs NetBox to omit all fields except the following:
* ID
* URL
* Display text
* Name (or similar identifier)
* Slug (if present)
* Description
* Counts of notable related objects (where applicable)
For example, a site fetched using brief mode returns only the following:
```json
{
"id": 2,
"url": "https://netbox/api/dcim/sites/2/",
"display": "DM-Akron",
"name": "DM-Akron",
"slug": "dm-akron",
"description": ""
}
```
Omitting all other fields (especially those which fetch and return related objects) often results in much faster queries.
#### Declare Selected Fields
If you need more flexibility regarding the fields to be returned for an object type, you can specify a list of fields to include using the `fields` query parameter. For example, a request for `/api/dcim/sites/?fields=id,name,status,region` will return the following:
```json
{
"id": 2,
"name": "DM-Akron",
"status": {
"value": "active",
"label": "Active"
},
"region": {
"id": 51,
"url": "https://netbox/api/dcim/regions/51/",
"display": "Ohio",
"name": "Ohio",
"slug": "us-oh",
"description": "",
"site_count": 0,
"_depth": 2
}
}
```
Like brief mode, this approach can significantly reduce the response time of an API request by omitting unneeded data.
#### Employ Pagination
Like the user interface, the REST API employs pagination to limit the number of objects returned in a single response. If a page size is not specified by the request (i.e. by passing `?limit=10`), NetBox will use the default size defined by [`PAGINATE_COUNT`](../configuration/default-values.md#paginate_count). The default page size is 50.
For some requests, especially those using brief mode or a minimal selection of fields, it may be desirable to specify a higher page size, so that fewer requests are needed to retrieve all objects. Appending `?limit=0` to the request effectively seeks to disable pagination. (Note, however, that the requested page size cannot exceed the value of [`MAX_PAGE_SIZE`](../configuration/miscellaneous.md#max_page_size), which defaults to 1,000.)
Complex API requests, which pull in many related objects, generate a relatively high load on the application, and generally benefit from reduced page size. If you find that your API requests are taking an inordinate amount of time, try reducing the page size from the default value so that fewer objects need to be returned for each request.
### GraphQL API
NetBox's read-only [GraphQL API](../integrations/graphql-api.md) offers an alternative to its REST API, and provides a very flexible means of retrieving data. GraphQL enables the client to request any object from a single endpoint, specifying only the desired attributes and relations. Many users prefer this to the more rigid structure of the REST API, but it's important to understand the trade-offs of crafting complex queries.
#### Request Only the Necessary Fields
For optimal performance, craft your GraphQL queries to return only the fields needed by the client. This will reduce the overall query time, especially when omitting related objects.
#### Avoid Overly Complex Queries
The primary benefit of the GraphQL API is that it allows the client to offload to the server the work of stitching together various related objects, which would require the client to make multiple requests to different endpoints if using the REST API. However, this advantage does not come for free: The more information that is requested in a single query, the more work the server needs to do to fetch the raw data from the database and render it into a GraphQL response. Very complex queries can yield dozens or hundreds of SQL queries on the backend, which increase the time it takes to render a response.
While it can be tempting to pack as much data as possible into a single GraphQL query, realize that there is a balance to be struck between minimizing the number of queries needed and avoiding complexity in the interest of performance. For example, while it is possible to retrieve via a single GraphQL API request all the IP addresses and all attached cables for every device in a site, it is probably more efficient (often _much_ more efficient) to make two or three separate requests and correlate the data locally.
#### Use Filters
You can specify filters when making a GraphQL query to limit the set of objects returned. This works a bit differently from the REST API, as filters are declared inside the query statement rather than appended to the URL, but the concept is the same. For example, to return only active sites:
```graphql
query {
site_list(
filters: {
status: STATUS_ACTIVE
}
) {
name
}
}
```
This returns only sites with a status of "active" and avoid needing to parse through all the others. For further information about filters, see the [GraphQL API documentation](../integrations/graphql-api.md).
#### Employ Pagination
Like the REST API, the GraphQL API supports pagination. Queries which return a large number of objects should employ pagination to limit the size of each response.
```graphql
{
device_list(
pagination: {limit: 100}
) {
id
name
serial
status
}
}
```

View File

@@ -4,7 +4,7 @@
This parameter controls the content and layout of user's default dashboard. Once the dashboard has been created, the user is free to customize it as they please by adding, removing, and reconfiguring widgets.
This parameter must specify an iterable of dictionaries, each representing a discrete dashboard widget and its configuration. The follow widget attributes are supported:
This parameter must specify an iterable of dictionaries, each representing a discrete dashboard widget and its configuration. The following widget attributes are supported:
* `widget`: Dotted path to the Python class (required)
* `width`: Default widget width (between 1 and 12, inclusive)
@@ -63,6 +63,8 @@ DEFAULT_USER_PREFERENCES = {
For a complete list of available preferences, log into NetBox and navigate to `/user/preferences/`. A period in a preference name indicates a level of nesting in the JSON data. The example above maps to `pagination.per_page`.
See also: [Clearing table preferences](../features/user-preferences.md#clearing-table-preferences) for resolving errors caused by saved table columns or ordering.
---
## PAGINATE_COUNT

View File

@@ -1,7 +1,32 @@
# Error Reporting Settings
## SENTRY_CONFIG
A dictionary mapping keyword arguments to values, to be passed to `sentry_sdk.init()`. See the [Sentry Python SDK documentation](https://docs.sentry.io/platforms/python/) for more information on supported parameters.
The default configuration is shown below:
```python
{
"sample_rate": 1.0,
"send_default_pii": False,
"traces_sample_rate": 0,
}
```
Additionally, `http_proxy` and `https_proxy` are set to the HTTP and HTTPS proxies, respectively, configured for NetBox (if any).
## SENTRY_DSN
!!! warning "This parameter will be removed in NetBox v4.5."
Set this using `SENTRY_CONFIG` instead:
```
SENTRY_CONFIG = {
"dsn": "https://examplePublicKey@o0.ingest.sentry.io/0",
}
```
Default: `None`
Defines a Sentry data source name (DSN) for automated error reporting. `SENTRY_ENABLED` must be `True` for this parameter to take effect. For example:
@@ -25,6 +50,15 @@ Set to `True` to enable automatic error reporting via [Sentry](https://sentry.io
## SENTRY_SAMPLE_RATE
!!! warning "This parameter will be removed in NetBox v4.5."
Set this using `SENTRY_CONFIG` instead:
```
SENTRY_CONFIG = {
"sample_rate": 0.2,
}
```
Default: `1.0` (all)
The sampling rate for errors. Must be a value between 0 (disabled) and 1.0 (report on all errors).
@@ -33,6 +67,15 @@ The sampling rate for errors. Must be a value between 0 (disabled) and 1.0 (repo
## SENTRY_SEND_DEFAULT_PII
!!! warning "This parameter will be removed in NetBox v4.5."
Set this using `SENTRY_CONFIG` instead:
```
SENTRY_CONFIG = {
"send_default_pii": True,
}
```
Default: `False`
Maps to the Sentry SDK's [`send_default_pii`](https://docs.sentry.io/platforms/python/configuration/options/#send-default-pii) parameter. If enabled, certain personally identifiable information (PII) is added.
@@ -60,6 +103,15 @@ SENTRY_TAGS = {
## SENTRY_TRACES_SAMPLE_RATE
!!! warning "This parameter will be removed in NetBox v4.5."
Set this using `SENTRY_CONFIG` instead:
```
SENTRY_CONFIG = {
"traces_sample_rate": 0.2,
}
```
Default: `0` (disabled)
The sampling rate for transactions. Must be a value between 0 (disabled) and 1.0 (report on all transactions).

View File

@@ -2,6 +2,8 @@
The `users.UserConfig` model holds individual preferences for each user in the form of JSON data. This page serves as a manifest of all recognized user preferences in NetBox.
For enduser guidance on resetting saved table layouts, see [Features > User Preferences](../features/user-preferences.md#clearing-table-preferences).
## Available Preferences
| Name | Description |

View File

@@ -2,6 +2,8 @@
While NetBox strives to meet the needs of every network, the needs of users to cater to their own unique environments cannot be ignored. NetBox was built with this in mind, and can be customized in many ways to better suit your particular needs.
For enduser personalization topics (bookmarks, table preferences, language, CSV delimiter, and more), see [Features > User Preferences](../features/user-preferences.md).
## Tags
Most objects in NetBox can be assigned user-created tags to aid with organization and filtering. Tag values are completely arbitrary: They may be used to store data in key-value pairs, or they may be employed simply as labels against which objects can be filtered. Each tag can also be assigned a color for quicker differentiation in the user interface.
@@ -18,10 +20,6 @@ The `tag` filter can be specified multiple times to match only objects which hav
GET /api/dcim/devices/?tag=monitored&tag=deprecated
```
## Bookmarks
Users can bookmark their most commonly visited objects for convenient access. Bookmarks are listed under a user's profile, and can be displayed with custom filtering and ordering on the user's personal dashboard.
## Custom Fields
While NetBox provides a rather extensive data model out of the box, the need may arise to store certain additional data associated with NetBox objects. For example, you might need to record the invoice ID alongside an installed device, or record an approving authority when creating a new IP prefix. NetBox administrators can create custom fields on built-in objects to meet these needs.
@@ -38,7 +36,7 @@ Custom links allow you to conveniently reference external resources related to N
http://server.local/vms/?name={{ object.name }}
```
Now, when viewing a virtual machine in NetBox, a user will see a handy button with the chosen title and link (complete with the name of the VM being viewed). Both the text and URL of custom links can be templatized in this manner, and custom links can be grouped together into dropdowns for more efficient display.
Now, when viewing a virtual machine in NetBox, a user will see a handy button with the chosen title and link (complete with the name of the VM being viewed). Both the text and URL of custom links can be templatized in this manner, and custom links can be grouped together into dropdowns for a more efficient display.
To learn more about this feature, check out the [custom link documentation](../customization/custom-links.md).

View File

@@ -0,0 +1,63 @@
# User Preferences
NetBox stores peruser options that control aspects of the web interface and data display. Preferences persist across sessions and can be managed under **User → Preferences**.
## Table configurations
When a list view is configured using **Configure**, NetBox records the selected columns and ordering as peruser table preferences for that table. These preferences are applied automatically on subsequent visits.
### Clearing table preferences
Saved table preferences may need to be reset, for example, if a table fails to render or after an upgrade that changes available columns.
To clear saved preferences for one or more tables:
1. Click the username in the topright corner.
2. Select **Preferences** from the dropdown.
3. Scroll to the **Table Configurations** section.
4. Select the tables to reset.
5. Click **Submit** to clear the selected preferences.
After clearing preferences, reopen the list view and use **Configure** to set the desired columns and ordering.
!!! note
Peruser table preferences are distinct from **Table Configs**, which are named, reusable configurations managed under *Customization → Table Configs*. Clearing preferences does not delete any Table Configs. See [Table Configs](../models/extras/tableconfig.md) for details.
## Other preferences
### Language
Selects the user interface language from installed translations (subject to system configuration).
### Page length
Sets the default number of rows displayed on paginated tables.
### Paginator placement
Controls where pagination controls are rendered relative to a table.
### HTMX navigation (experimental)
Enables partialpage navigation for supported views. Disable this preference if unexpected behavior is observed.
### Striped table rows
Toggles alternating row backgrounds on tables.
### Data format (raw views)
Sets the default format (JSON or YAML) when rendering raw data blocks.
### CSV delimiter
Overrides the delimiter used when exporting CSV data.
## Bookmarks
Users can bookmark frequently visited objects for convenient access. Bookmarks appear under the user menu and can be displayed on the personal dashboard using the bookmarks' widget. See [Bookmark](../models/extras/bookmark.md) for model details.
## Notifications and subscriptions
Users may subscribe to objects to receive notifications when changes occur. Notifications are listed under the user menu and can be marked as read or deleted. See [Features > Notifications](notifications.md) and the datamodel references for [Subscription](../models/extras/subscription.md) and [Notification](../models/extras/notification.md).
## Admin defaults
Administrators can define defaults for new users via [`DEFAULT_USER_PREFERENCES`](../configuration/default-values.md#default_user_preferences). Users may override these values under their own preferences.
## See also
- [Development > User Preferences](../development/user-preferences.md) (manifest of recognized preference keys)

View File

@@ -4,6 +4,9 @@ This object represents the saved configuration of an object table in NetBox. Tab
For example, you might wish to create a table config for the devices list to assist in inventory tasks. This view might show the device name, location, serial number, and asset tag, but omit operational details like IP addresses. Once applied, this table config can be saved for reuse in future audits.
!!! note
Peruser table preferences (columns and ordering remembered for an individual user) are distinct from Table Configs. If a list view fails to render due to outdated saved preferences, see [Clearing table preferences](../../features/user-preferences.md#clearing-table-preferences).
## Fields
### Name
@@ -20,7 +23,7 @@ The type of NetBox object to which the table config pertains.
### Table
The name of the specific table to which the table config pertains. (Some NetBox object use multiple tables.)
The name of the specific table to which the table config pertains. (Some NetBox objects use multiple tables.)
### Weight

View File

@@ -1,6 +1,6 @@
# Filters & Filter Sets
Filter sets define the mechanisms available for filtering or searching through a set of objects in NetBox. For instance, sites can be filtered by their parent region or group, status, facility ID, and so on. The same filter set is used consistently for a model whether the request is made via the UI or REST API. (Note that the GraphQL API uses a separate filter class.) NetBox employs the [django-filters2](https://django-tables2.readthedocs.io/en/latest/) library to define filter sets.
Filter sets define the mechanisms available for filtering or searching through a set of objects in NetBox. For instance, sites can be filtered by their parent region or group, status, facility ID, and so on. The same filter set is used consistently for a model whether the request is made via the UI or REST API. (Note that the GraphQL API uses a separate filter class.) NetBox employs the [django-filter](https://django-filter.readthedocs.io/en/stable/) library to define filter sets.
## FilterSet Classes

View File

@@ -1,5 +1,34 @@
# NetBox v4.4
## v4.4.2 (2025-09-30)
### Enhancements
* [#17010](https://github.com/netbox-community/netbox/issues/17010) - Show admin navigation menu items only for staff & superusers
* [#19590](https://github.com/netbox-community/netbox/issues/19590) - Add columns for device site & location to device component tables
* [#19765](https://github.com/netbox-community/netbox/issues/19765) - Linkify assigned object types under saved filter view
* [#20308](https://github.com/netbox-community/netbox/issues/20308) - Add a hotkey (`/`) for the global search field
* [#20332](https://github.com/netbox-community/netbox/issues/20332) - Add a "none" option to object tag filters
* [#20380](https://github.com/netbox-community/netbox/issues/20380) - Introduce the `SENTRY_CONFIG` configuration parameter
* [#20412](https://github.com/netbox-community/netbox/issues/20412) - Linkify cluster type on virtual machine detail view
* [#20438](https://github.com/netbox-community/netbox/issues/20438) - Add `facility` field to bulk edit forms for sites and locations
### Bug Fixes
* [#18878](https://github.com/netbox-community/netbox/issues/18878) - Automatically assign a designated primary MAC address upon creation of a new interface
* [#20243](https://github.com/netbox-community/netbox/issues/20243) - Prevent scheduled system jobs from re-running multiple times
* [#20253](https://github.com/netbox-community/netbox/issues/20253) - Fix support for filtering object contact assignments in GraphQL API
* [#20365](https://github.com/netbox-community/netbox/issues/20365) - Address various inaccuracies in generated OpenAPI schema
* [#20375](https://github.com/netbox-community/netbox/issues/20375) - Preserve filter parameters when performing bulk operations
* [#20390](https://github.com/netbox-community/netbox/issues/20390) - Fix styling of page size selection dropdown
* [#20392](https://github.com/netbox-community/netbox/issues/20392) - Clean up ordering of interface type options
* [#20398](https://github.com/netbox-community/netbox/issues/20398) - Fix misleading error reporting for min/max custom field values
* [#20419](https://github.com/netbox-community/netbox/issues/20419) - Correct action buttons for child object views
* [#20425](https://github.com/netbox-community/netbox/issues/20425) - Fix Markdown preview functionality within "quick add" modal
* [#20441](https://github.com/netbox-community/netbox/issues/20441) - Fix display of the "groups" column in contact assignments table
---
## v4.4.1 (2025-09-16)
### Enhancements

View File

@@ -86,6 +86,7 @@ nav:
- Change Logging: 'features/change-logging.md'
- Journaling: 'features/journaling.md'
- Event Rules: 'features/event-rules.md'
- User Preferences: 'features/user-preferences.md'
- Notifications: 'features/notifications.md'
- Background Jobs: 'features/background-jobs.md'
- Auth & Permissions: 'features/authentication-permissions.md'
@@ -124,6 +125,9 @@ nav:
- Export Templates: 'customization/export-templates.md'
- Reports: 'customization/reports.md'
- Custom Scripts: 'customization/custom-scripts.md'
- Best Practices:
- Modeling Pluggable Transceivers: 'best-practices/modeling-pluggable-transceivers.md'
- Performance Handbook: 'best-practices/performance-handbook.md'
- Integrations:
- REST API: 'integrations/rest-api.md'
- GraphQL API: 'integrations/graphql-api.md'

View File

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

View File

@@ -282,18 +282,18 @@ class FixSerializedPKRelatedField(OpenApiSerializerFieldExtension):
class FixIntegerRangeSerializerSchema(OpenApiSerializerExtension):
target_class = 'netbox.api.fields.IntegerRangeSerializer'
match_subclasses = True
def map_serializer(self, auto_schema: 'AutoSchema', direction: Direction) -> _SchemaType:
# One range = two integers; many=True will wrap this in an outer array
return {
'type': 'array',
'items': {
'type': 'array',
'items': {
'type': 'integer',
},
'minItems': 2,
'maxItems': 2,
'type': 'integer',
},
'minItems': 2,
'maxItems': 2,
'example': [10, 20],
}

View File

@@ -13,7 +13,7 @@ class BackgroundTaskSerializer(serializers.Serializer):
url = serializers.HyperlinkedIdentityField(
view_name='core-api:rqtask-detail',
lookup_field='id',
lookup_url_kwarg='pk'
lookup_url_kwarg='id'
)
description = serializers.CharField()
origin = serializers.CharField()

View File

@@ -5,7 +5,7 @@ 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.utils import extend_schema
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
@@ -24,6 +24,7 @@ from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.metadata import ContentTypeMetadata
from netbox.api.pagination import LimitOffsetListPagination
from netbox.api.viewsets import NetBoxModelViewSet, NetBoxReadOnlyModelViewSet
from . import serializers
@@ -117,29 +118,49 @@ class BaseRQViewSet(viewsets.ViewSet):
def get_serializer(self, *args, **kwargs):
"""
Return the serializer instance that should be used for validating and
deserializing input, and for serializing output.
deserializing input and for serializing output.
"""
serializer_class = self.get_serializer_class()
kwargs['context'] = self.get_serializer_context()
return serializer_class(*args, **kwargs)
def get_serializer_class(self):
"""
Return the class to use for the serializer.
"""
return self.serializer_class
def get_serializer_context(self):
"""
Extra context provided to the serializer class.
"""
return {
'request': self.request,
'format': self.format_kwarg,
'view': self,
}
class BackgroundQueueViewSet(BaseRQViewSet):
"""
Retrieve a list of RQ Queues.
Note: Queue names are not URL safe so not returning a detail view.
Note: Queue names are not URL safe, so not returning a detail view.
"""
serializer_class = serializers.BackgroundQueueSerializer
lookup_field = 'name'
lookup_value_regex = r'[\w.@+-]+'
def get_view_name(self):
return "Background Queues"
return 'Background Queues'
def get_data(self):
return get_statistics(run_maintenance_tasks=True)["queues"]
return get_statistics(run_maintenance_tasks=True)['queues']
@extend_schema(responses={200: OpenApiTypes.OBJECT})
@extend_schema(
operation_id='core_background_queues_retrieve_by_name',
parameters=[OpenApiParameter(name='name', type=OpenApiTypes.STR, location=OpenApiParameter.PATH)],
responses={200: OpenApiTypes.OBJECT},
)
def retrieve(self, request, name):
data = self.get_data()
if not data:
@@ -161,12 +182,17 @@ class BackgroundWorkerViewSet(BaseRQViewSet):
lookup_field = 'name'
def get_view_name(self):
return "Background Workers"
return 'Background Workers'
def get_data(self):
config = QUEUES_LIST[0]
return Worker.all(get_redis_connection(config['connection_config']))
@extend_schema(
operation_id='core_background_workers_retrieve_by_name',
parameters=[OpenApiParameter(name='name', type=OpenApiTypes.STR, location=OpenApiParameter.PATH)],
responses={200: OpenApiTypes.OBJECT},
)
def retrieve(self, request, name):
# all the RQ queues should use the same connection
config = QUEUES_LIST[0]
@@ -184,9 +210,10 @@ class BackgroundTaskViewSet(BaseRQViewSet):
Retrieve a list of RQ Tasks.
"""
serializer_class = serializers.BackgroundTaskSerializer
lookup_field = 'id'
def get_view_name(self):
return "Background Tasks"
return 'Background Tasks'
def get_data(self):
return get_rq_jobs()
@@ -199,45 +226,53 @@ class BackgroundTaskViewSet(BaseRQViewSet):
return task
@extend_schema(responses={200: OpenApiTypes.OBJECT})
def retrieve(self, request, pk):
@extend_schema(
operation_id='core_background_tasks_retrieve_by_id',
parameters=[OpenApiParameter(name='id', type=OpenApiTypes.STR, location=OpenApiParameter.PATH)],
responses={200: OpenApiTypes.OBJECT},
)
def retrieve(self, request, id):
"""
Retrieve the details of the specified RQ Task.
"""
task = self.get_task_from_id(pk)
task = self.get_task_from_id(id)
serializer = self.serializer_class(task, context={'request': request})
return Response(serializer.data)
@action(methods=["POST"], detail=True)
def delete(self, request, pk):
@extend_schema(parameters=[OpenApiParameter(name='id', type=OpenApiTypes.STR, location=OpenApiParameter.PATH)])
@action(methods=['POST'], detail=True)
def delete(self, request, id):
"""
Delete the specified RQ Task.
"""
delete_rq_job(pk)
delete_rq_job(id)
return HttpResponse(status=200)
@action(methods=["POST"], detail=True)
def requeue(self, request, pk):
@extend_schema(parameters=[OpenApiParameter(name='id', type=OpenApiTypes.STR, location=OpenApiParameter.PATH)])
@action(methods=['POST'], detail=True)
def requeue(self, request, id):
"""
Requeues the specified RQ Task.
"""
requeue_rq_job(pk)
requeue_rq_job(id)
return HttpResponse(status=200)
@action(methods=["POST"], detail=True)
def enqueue(self, request, pk):
@extend_schema(parameters=[OpenApiParameter(name='id', type=OpenApiTypes.STR, location=OpenApiParameter.PATH)])
@action(methods=['POST'], detail=True)
def enqueue(self, request, id):
"""
Enqueues the specified RQ Task.
"""
enqueue_rq_job(pk)
enqueue_rq_job(id)
return HttpResponse(status=200)
@action(methods=["POST"], detail=True)
def stop(self, request, pk):
@extend_schema(parameters=[OpenApiParameter(name='id', type=OpenApiTypes.STR, location=OpenApiParameter.PATH)])
@action(methods=['POST'], detail=True)
def stop(self, request, id):
"""
Stops the specified RQ Task.
"""
stopped_jobs = stop_rq_job(pk)
stopped_jobs = stop_rq_job(id)
if len(stopped_jobs) == 1:
return HttpResponse(status=200)
else:

View File

@@ -1,6 +1,5 @@
import datetime
import importlib
import importlib.util
from dataclasses import dataclass, field
from typing import Optional

View File

@@ -1163,14 +1163,14 @@ class InterfaceTypeChoices(ChoiceSet):
(
(TYPE_1GE_BX10_D, '1000BASE-BX10-D (1GE BiDi Down)'),
(TYPE_1GE_BX10_U, '1000BASE-BX10-U (1GE BiDi Up)'),
(TYPE_1GE_CX, '1000BASE-CX (1GE DAC)'),
(TYPE_1GE_CWDM, '1000BASE-CWDM (1GE)'),
(TYPE_1GE_CX, '1000BASE-CX (1GE DAC)'),
(TYPE_1GE_DWDM, '1000BASE-DWDM (1GE)'),
(TYPE_1GE_EX, '1000BASE-EX (1GE)'),
(TYPE_1GE_SX_FIXED, '1000BASE-SX (1GE)'),
(TYPE_1GE_LSX, '1000BASE-LSX (1GE)'),
(TYPE_1GE_LX_FIXED, '1000BASE-LX (1GE)'),
(TYPE_1GE_LX10, '1000BASE-LX10/LH (1GE)'),
(TYPE_1GE_SX_FIXED, '1000BASE-SX (1GE)'),
(TYPE_1GE_FIXED, '1000BASE-T (1GE)'),
(TYPE_1GE_TX_FIXED, '1000BASE-TX (1GE)'),
(TYPE_1GE_ZX, '1000BASE-ZX (1GE)'),
@@ -1186,8 +1186,8 @@ class InterfaceTypeChoices(ChoiceSet):
(
_('10 Gbps Ethernet'),
(
(TYPE_10GE_BR_D, '10GBASE-DR-D (10GE BiDi Down)'),
(TYPE_10GE_BR_U, '10GBASE-DR-U (10GE BiDi Up)'),
(TYPE_10GE_BR_D, '10GBASE-BR-D (10GE BiDi Down)'),
(TYPE_10GE_BR_U, '10GBASE-BR-U (10GE BiDi Up)'),
(TYPE_10GE_CX4, '10GBASE-CX4 (10GE DAC)'),
(TYPE_10GE_ER, '10GBASE-ER (10GE)'),
(TYPE_10GE_LR, '10GBASE-LR (10GE)'),
@@ -1235,6 +1235,7 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_100GE_CR2, '100GBASE-CR2 (100GE DAC)'),
(TYPE_100GE_CR4, '100GBASE-CR4 (100GE DAC)'),
(TYPE_100GE_CR10, '100GBASE-CR10 (100GE DAC)'),
(TYPE_100GE_CWDM4, '100GBASE-CWDM4 (100GE)'),
(TYPE_100GE_DR, '100GBASE-DR (100GE)'),
(TYPE_100GE_ER4, '100GBASE-ER4 (100GE)'),
(TYPE_100GE_FR1, '100GBASE-FR1 (100GE)'),
@@ -1253,12 +1254,12 @@ class InterfaceTypeChoices(ChoiceSet):
(
(TYPE_200GE_CR2, '200GBASE-CR2 (200GE)'),
(TYPE_200GE_CR4, '200GBASE-CR4 (200GE)'),
(TYPE_200GE_SR2, '200GBASE-SR2 (200GE)'),
(TYPE_200GE_SR4, '200GBASE-SR4 (200GE)'),
(TYPE_200GE_DR4, '200GBASE-DR4 (200GE)'),
(TYPE_200GE_ER4, '200GBASE-ER4 (200GE)'),
(TYPE_200GE_FR4, '200GBASE-FR4 (200GE)'),
(TYPE_200GE_LR4, '200GBASE-LR4 (200GE)'),
(TYPE_200GE_SR2, '200GBASE-SR2 (200GE)'),
(TYPE_200GE_SR4, '200GBASE-SR4 (200GE)'),
(TYPE_200GE_VR2, '200GBASE-VR2 (200GE)'),
)
),
@@ -1296,34 +1297,34 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_1GE_GBIC, 'GBIC (1GE)'),
(TYPE_1GE_SFP, 'SFP (1GE)'),
(TYPE_10GE_SFP_PLUS, 'SFP+ (10GE)'),
(TYPE_10GE_XFP, 'XFP (10GE)'),
(TYPE_10GE_XENPAK, 'XENPAK (10GE)'),
(TYPE_10GE_XFP, 'XFP (10GE)'),
(TYPE_10GE_X2, 'X2 (10GE)'),
(TYPE_25GE_SFP28, 'SFP28 (25GE)'),
(TYPE_50GE_SFP56, 'SFP56 (50GE)'),
(TYPE_40GE_QSFP_PLUS, 'QSFP+ (40GE)'),
(TYPE_50GE_QSFP28, 'QSFP28 (50GE)'),
(TYPE_50GE_SFP56, 'SFP56 (50GE)'),
(TYPE_100GE_CFP, 'CFP (100GE)'),
(TYPE_100GE_CFP2, 'CFP2 (100GE)'),
(TYPE_200GE_CFP2, 'CFP2 (200GE)'),
(TYPE_400GE_CFP2, 'CFP2 (400GE)'),
(TYPE_100GE_CFP4, 'CFP4 (100GE)'),
(TYPE_100GE_CXP, 'CXP (100GE)'),
(TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'),
(TYPE_100GE_DSFP, 'DSFP (100GE)'),
(TYPE_100GE_SFP_DD, 'SFP-DD (100GE)'),
(TYPE_100GE_QSFP28, 'QSFP28 (100GE)'),
(TYPE_100GE_QSFP_DD, 'QSFP-DD (100GE)'),
(TYPE_100GE_SFP_DD, 'SFP-DD (100GE)'),
(TYPE_200GE_CFP2, 'CFP2 (200GE)'),
(TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
(TYPE_200GE_QSFP_DD, 'QSFP-DD (200GE)'),
(TYPE_400GE_QSFP112, 'QSFP112 (400GE)'),
(TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'),
(TYPE_400GE_CDFP, 'CDFP (400GE)'),
(TYPE_400GE_CFP2, 'CFP2 (400GE)'),
(TYPE_400GE_CFP8, 'CPF8 (400GE)'),
(TYPE_400GE_OSFP, 'OSFP (400GE)'),
(TYPE_400GE_OSFP_RHS, 'OSFP-RHS (400GE)'),
(TYPE_400GE_CDFP, 'CDFP (400GE)'),
(TYPE_400GE_CFP8, 'CPF8 (400GE)'),
(TYPE_800GE_QSFP_DD, 'QSFP-DD (800GE)'),
(TYPE_800GE_OSFP, 'OSFP (800GE)'),
(TYPE_800GE_QSFP_DD, 'QSFP-DD (800GE)'),
)
),
(

View File

@@ -26,7 +26,7 @@ class eui64_unix_expanded_uppercase(eui64_unix_expanded):
#
class MACAddressField(models.Field):
description = "PostgreSQL MAC Address field"
description = 'PostgreSQL MAC Address field'
def python_type(self):
return EUI
@@ -34,6 +34,9 @@ class MACAddressField(models.Field):
def from_db_value(self, value, expression, connection):
return self.to_python(value)
def get_internal_type(self):
return 'CharField'
def to_python(self, value):
if value is None:
return value
@@ -54,7 +57,7 @@ class MACAddressField(models.Field):
class WWNField(models.Field):
description = "World Wide Name field"
description = 'World Wide Name field'
def python_type(self):
return EUI
@@ -62,6 +65,9 @@ class WWNField(models.Field):
def from_db_value(self, value, expression, connection):
return self.to_python(value)
def get_internal_type(self):
return 'CharField'
def to_python(self, value):
if value is None:
return value

View File

@@ -133,6 +133,11 @@ class SiteBulkEditForm(NetBoxModelBulkEditForm):
queryset=Tenant.objects.all(),
required=False
)
facility = forms.CharField(
label=_('Facility'),
max_length=50,
required=False
)
asns = DynamicModelMultipleChoiceField(
queryset=ASN.objects.all(),
label=_('ASNs'),
@@ -166,10 +171,10 @@ class SiteBulkEditForm(NetBoxModelBulkEditForm):
model = Site
fieldsets = (
FieldSet('status', 'region', 'group', 'tenant', 'asns', 'time_zone', 'description'),
FieldSet('status', 'region', 'group', 'tenant', 'facility', 'asns', 'time_zone', 'description'),
)
nullable_fields = (
'region', 'group', 'tenant', 'asns', 'time_zone', 'description', 'comments',
'region', 'group', 'tenant', 'facility', 'asns', 'time_zone', 'description', 'comments',
)
@@ -198,6 +203,11 @@ class LocationBulkEditForm(NetBoxModelBulkEditForm):
queryset=Tenant.objects.all(),
required=False
)
facility = forms.CharField(
label=_('Facility'),
max_length=50,
required=False
)
description = forms.CharField(
label=_('Description'),
max_length=200,
@@ -207,9 +217,9 @@ class LocationBulkEditForm(NetBoxModelBulkEditForm):
model = Location
fieldsets = (
FieldSet('site', 'parent', 'status', 'tenant', 'description'),
FieldSet('site', 'parent', 'status', 'tenant', 'facility', 'description'),
)
nullable_fields = ('parent', 'tenant', 'description', 'comments')
nullable_fields = ('parent', 'tenant', 'facility', 'description', 'comments')
class RackRoleBulkEditForm(NetBoxModelBulkEditForm):

View File

@@ -3,9 +3,7 @@ import django.db.models.deletion
import taggit.managers
from django.db import migrations, models
import utilities.fields
import utilities.json
import utilities.ordering
class Migration(migrations.Migration):

View File

@@ -632,10 +632,17 @@ class BaseInterface(models.Model):
})
# Check that the primary MAC address (if any) is assigned to this interface
if self.primary_mac_address and self.primary_mac_address.assigned_object != self:
if (
self.primary_mac_address and
self.primary_mac_address.assigned_object is not None and
self.primary_mac_address.assigned_object != self
):
raise ValidationError({
'primary_mac_address': _("MAC address {mac_address} is not assigned to this interface.").format(
mac_address=self.primary_mac_address
'primary_mac_address': _(
"MAC address {mac_address} is assigned to a different interface ({interface})."
).format(
mac_address=self.primary_mac_address,
interface=self.primary_mac_address.assigned_object,
)
})

View File

@@ -4,6 +4,7 @@ from django.db.models.signals import post_save, post_delete, pre_delete
from django.dispatch import receiver
from dcim.choices import CableEndChoices, LinkStatusChoices
from virtualization.models import VMInterface
from .models import (
Cable, CablePath, CableTermination, ConsolePort, ConsoleServerPort, Device, DeviceBay, FrontPort, Interface,
InventoryItem, ModuleBay, PathEndpoint, PowerOutlet, PowerPanel, PowerPort, Rack, RearPort, Location,
@@ -170,3 +171,15 @@ def extend_rearport_cable_paths(instance, created, raw, **kwargs):
rearport = instance.rear_port
for cablepath in CablePath.objects.filter(_nodes__contains=rearport):
cablepath.retrace()
@receiver(post_save, sender=Interface)
@receiver(post_save, sender=VMInterface)
def update_mac_address_interface(instance, created, raw, **kwargs):
"""
When creating a new Interface or VMInterface, check whether a MACAddress has been designated as its primary. If so,
assign the MACAddress to the interface.
"""
if created and not raw and instance.primary_mac_address:
instance.primary_mac_address.assigned_object = instance
instance.primary_mac_address.save()

View File

@@ -312,6 +312,16 @@ class DeviceComponentTable(NetBoxTable):
verbose_name=_('Name'),
linkify=True,
)
device_location = tables.Column(
accessor=tables.A('device__location'),
verbose_name=_('Device Location'),
linkify=True,
)
device_site = tables.Column(
accessor=tables.A('device__site'),
verbose_name=_('Device Site'),
linkify=True,
)
device_status = columns.ChoiceFieldColumn(
accessor=tables.A('device__status'),
verbose_name=_('Device Status'),

View File

@@ -26,6 +26,7 @@ class CustomFieldChoiceSetSerializer(ChangeLogMessageSerializer, ValidatedModelS
max_length=2
)
)
choices_count = serializers.IntegerField(read_only=True)
class Meta:
model = CustomFieldChoiceSet

View File

@@ -26,6 +26,9 @@ class BaseIPField(models.Field):
def from_db_value(self, value, expression, connection):
return self.to_python(value)
def get_internal_type(self):
return 'CharField'
def to_python(self, value):
if not value:
return value
@@ -57,7 +60,7 @@ class IPNetworkField(BaseIPField):
"""
IP prefix (network and mask)
"""
description = "PostgreSQL CIDR field"
description = 'PostgreSQL CIDR field'
default_validators = [validators.prefix_validator]
def db_type(self, connection):
@@ -83,7 +86,7 @@ class IPAddressField(BaseIPField):
"""
IP address (host address and mask)
"""
description = "PostgreSQL INET field"
description = 'PostgreSQL INET field'
def db_type(self, connection):
return 'inet'
@@ -110,7 +113,7 @@ IPAddressField.register_lookup(lookups.Inet)
class ASNField(models.BigIntegerField):
description = "32-bit ASN field"
description = '32-bit ASN field'
default_validators = [
MinValueValidator(BGP_ASN_MIN),
MaxValueValidator(BGP_ASN_MAX),

View File

@@ -354,13 +354,13 @@ class PrefixFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet, C
vlan_group_id = django_filters.ModelMultipleChoiceFilter(
field_name='vlan__group',
queryset=VLANGroup.objects.all(),
to_field_name="id",
to_field_name='id',
label=_('VLAN Group (ID)'),
)
vlan_group = django_filters.ModelMultipleChoiceFilter(
field_name='vlan__group__slug',
queryset=VLANGroup.objects.all(),
to_field_name="slug",
to_field_name='slug',
label=_('VLAN Group (slug)'),
)
vlan_id = django_filters.ModelMultipleChoiceFilter(
@@ -695,12 +695,12 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFil
return queryset.filter(q)
def parse_inet_addresses(self, value):
'''
"""
Parse networks or IP addresses and cast to a format
acceptable by the Postgres inet type.
Skips invalid values.
'''
"""
parsed = []
for addr in value:
if netaddr.valid_ipv4(addr) or netaddr.valid_ipv6(addr):
@@ -718,7 +718,7 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFil
# as argument. If they are all invalid,
# we return an empty queryset
value = self.parse_inet_addresses(value)
if (len(value) == 0):
if len(value) == 0:
return queryset.none()
try:
@@ -1079,6 +1079,7 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
def get_for_virtualmachine(self, queryset, name, value):
return queryset.get_for_virtualmachine(value)
@extend_schema_field(OpenApiTypes.INT)
def filter_interface_id(self, queryset, name, value):
if value is None:
return queryset.none()
@@ -1087,6 +1088,7 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
Q(interfaces_as_untagged=value)
).distinct()
@extend_schema_field(OpenApiTypes.INT)
def filter_vminterface_id(self, queryset, name, value):
if value is None:
return queryset.none()

View File

@@ -74,7 +74,7 @@ class BaseIPAddressFamilyType:
filters=ASNFilter,
pagination=True
)
class ASNType(NetBoxObjectType):
class ASNType(NetBoxObjectType, ContactsMixin):
asn: BigInt
rir: Annotated["RIRType", strawberry.lazy('ipam.graphql.types')] | None
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None

View File

@@ -548,7 +548,7 @@ class IPRange(ContactsMixin, PrimaryModel):
mark_utilized = models.BooleanField(
verbose_name=_('mark utilized'),
default=False,
help_text=_("Report space as 100% utilized")
help_text=_("Report space as fully utilized")
)
clone_fields = (

View File

@@ -169,7 +169,7 @@ class IntegerRangeSerializer(serializers.Serializer):
if type(data[0]) is not int or type(data[1]) is not int:
raise ValidationError(_("Range boundaries must be defined as integers."))
return NumericRange(data[0], data[1], bounds='[]')
return NumericRange(data[0], data[1] + 1, bounds='[)')
def to_representation(self, instance):
return instance.lower, instance.upper - 1

View File

@@ -44,22 +44,28 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
return list(queryset[self.offset:])
def get_limit(self, request):
max_limit = self.default_limit
MAX_PAGE_SIZE = get_config().MAX_PAGE_SIZE
if MAX_PAGE_SIZE:
max_limit = min(max_limit, MAX_PAGE_SIZE)
if self.limit_query_param:
MAX_PAGE_SIZE = get_config().MAX_PAGE_SIZE
if MAX_PAGE_SIZE:
MAX_PAGE_SIZE = max(MAX_PAGE_SIZE, self.default_limit)
try:
limit = int(request.query_params[self.limit_query_param])
if limit < 0:
raise ValueError()
# Enforce maximum page size, if defined
if MAX_PAGE_SIZE:
return MAX_PAGE_SIZE if limit == 0 else min(limit, MAX_PAGE_SIZE)
return limit
if limit == 0:
max_limit = MAX_PAGE_SIZE
else:
max_limit = min(MAX_PAGE_SIZE, limit)
else:
max_limit = limit
except (KeyError, ValueError):
pass
return self.default_limit
return max_limit
def get_queryset_count(self, queryset):
return queryset.count()

View File

@@ -4,6 +4,7 @@ from datetime import timedelta
from django.core.exceptions import ImproperlyConfigured
from django.utils.functional import classproperty
from django.utils import timezone
from django_pglocks import advisory_lock
from rq.timeouts import JobTimeoutException
@@ -113,7 +114,11 @@ class JobRunner(ABC):
# If the executed job is a periodic job, schedule its next execution at the specified interval.
finally:
if job.interval:
new_scheduled_time = (job.scheduled or job.started) + timedelta(minutes=job.interval)
# Determine the new scheduled time. Cannot be earlier than one minute in the future.
new_scheduled_time = max(
(job.scheduled or job.started) + timedelta(minutes=job.interval),
timezone.now() + timedelta(minutes=1)
)
if job.object and getattr(job.object, "python_class", None):
kwargs["job_timeout"] = job.object.python_class.job_timeout
cls.enqueue(

View File

@@ -412,7 +412,7 @@ ADMIN_MENU = Menu(
MenuItem(
link='users:user_list',
link_text=_('Users'),
auth_required=True,
staff_only=True,
permissions=['users.view_user'],
buttons=(
MenuItemButton(
@@ -432,7 +432,7 @@ ADMIN_MENU = Menu(
MenuItem(
link='users:group_list',
link_text=_('Groups'),
auth_required=True,
staff_only=True,
permissions=['users.view_group'],
buttons=(
MenuItemButton(
@@ -452,14 +452,14 @@ ADMIN_MENU = Menu(
MenuItem(
link='users:token_list',
link_text=_('API Tokens'),
auth_required=True,
staff_only=True,
permissions=['users.view_token'],
buttons=get_model_buttons('users', 'token')
),
MenuItem(
link='users:objectpermission_list',
link_text=_('Permissions'),
auth_required=True,
staff_only=True,
permissions=['users.view_objectpermission'],
buttons=get_model_buttons('users', 'objectpermission', actions=['add'])
),
@@ -471,23 +471,23 @@ ADMIN_MENU = Menu(
MenuItem(
link='core:system',
link_text=_('System'),
auth_required=True
staff_only=True,
),
MenuItem(
link='core:plugin_list',
link_text=_('Plugins'),
auth_required=True
staff_only=True,
),
MenuItem(
link='core:configrevision_list',
link_text=_('Configuration History'),
auth_required=True,
permissions=['core.view_configrevision']
staff_only=True,
permissions=['core.view_configrevision'],
),
MenuItem(
link='core:background_queue_list',
link_text=_('Background Tasks'),
auth_required=True
staff_only=True,
),
),
),

View File

@@ -50,6 +50,14 @@ class ObjectAction:
except NoReverseMatch:
return
@classmethod
def get_url_params(cls, context):
request = context['request']
params = request.GET.copy()
if 'return_url' in context:
params['return_url'] = context['return_url']
return params
@classmethod
def get_context(cls, context, obj):
"""
@@ -63,6 +71,7 @@ class ObjectAction:
'perms': context['perms'],
'request': context['request'],
'url': cls.get_url(obj),
'url_params': cls.get_url_params(context),
'label': cls.label,
**cls.get_context(context, obj),
**kwargs,

View File

@@ -176,11 +176,16 @@ SECURE_HSTS_INCLUDE_SUBDOMAINS = getattr(configuration, 'SECURE_HSTS_INCLUDE_SUB
SECURE_HSTS_PRELOAD = getattr(configuration, 'SECURE_HSTS_PRELOAD', False)
SECURE_HSTS_SECONDS = getattr(configuration, 'SECURE_HSTS_SECONDS', 0)
SECURE_SSL_REDIRECT = getattr(configuration, 'SECURE_SSL_REDIRECT', False)
SENTRY_CONFIG = getattr(configuration, 'SENTRY_CONFIG', {})
# TODO: Remove in NetBox v4.5
SENTRY_DSN = getattr(configuration, 'SENTRY_DSN', None)
SENTRY_ENABLED = getattr(configuration, 'SENTRY_ENABLED', False)
# TODO: Remove in NetBox v4.5
SENTRY_SAMPLE_RATE = getattr(configuration, 'SENTRY_SAMPLE_RATE', 1.0)
# TODO: Remove in NetBox v4.5
SENTRY_SEND_DEFAULT_PII = getattr(configuration, 'SENTRY_SEND_DEFAULT_PII', False)
SENTRY_TAGS = getattr(configuration, 'SENTRY_TAGS', {})
# TODO: Remove in NetBox v4.5
SENTRY_TRACES_SAMPLE_RATE = getattr(configuration, 'SENTRY_TRACES_SAMPLE_RATE', 0)
SESSION_COOKIE_NAME = getattr(configuration, 'SESSION_COOKIE_NAME', 'sessionid')
SESSION_COOKIE_PATH = CSRF_COOKIE_PATH
@@ -598,18 +603,29 @@ if SENTRY_ENABLED:
import sentry_sdk
except ModuleNotFoundError:
raise ImproperlyConfigured("SENTRY_ENABLED is True but the sentry-sdk package is not installed.")
if not SENTRY_DSN:
raise ImproperlyConfigured("SENTRY_ENABLED is True but SENTRY_DSN has not been defined.")
# Construct default Sentry initialization parameters from legacy SENTRY_* config parameters
sentry_config = {
'dsn': SENTRY_DSN,
'sample_rate': SENTRY_SAMPLE_RATE,
'send_default_pii': SENTRY_SEND_DEFAULT_PII,
'traces_sample_rate': SENTRY_TRACES_SAMPLE_RATE,
# TODO: Support proxy routing
'http_proxy': HTTP_PROXIES.get('http') if HTTP_PROXIES else None,
'https_proxy': HTTP_PROXIES.get('https') if HTTP_PROXIES else None,
}
# Override/extend the default parameters with any provided via SENTRY_CONFIG
sentry_config.update(SENTRY_CONFIG)
# Check for a DSN
if not sentry_config.get('dsn'):
raise ImproperlyConfigured(
"Sentry is enabled but a DSN has not been specified. Set one under the SENTRY_CONFIG parameter."
)
# Initialize the SDK
sentry_sdk.init(
dsn=SENTRY_DSN,
release=RELEASE.full_version,
sample_rate=SENTRY_SAMPLE_RATE,
traces_sample_rate=SENTRY_TRACES_SAMPLE_RATE,
send_default_pii=SENTRY_SEND_DEFAULT_PII,
# TODO: Support proxy routing
http_proxy=HTTP_PROXIES.get('http') if HTTP_PROXIES else None,
https_proxy=HTTP_PROXIES.get('https') if HTTP_PROXIES else None
**sentry_config
)
# Assign any configured tags
for k, v in SENTRY_TAGS.items():

View File

@@ -14,9 +14,7 @@ from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _
from core.signals import clear_events
from netbox.object_actions import (
BulkDelete, BulkEdit, BulkExport, BulkImport, CloneObject, DeleteObject, EditObject,
)
from netbox.object_actions import BulkDelete, BulkEdit, CloneObject, DeleteObject, EditObject
from utilities.error_handlers import handle_protectederror
from utilities.exceptions import AbortRequest, PermissionsViolation
from utilities.forms import DeleteForm, restrict_form_fields
@@ -103,7 +101,7 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
table = None
filterset = None
filterset_form = None
actions = (BulkImport, BulkEdit, BulkExport, BulkDelete)
actions = (CloneObject, EditObject, DeleteObject, BulkEdit, BulkDelete)
template_name = 'generic/object_children.html'
def get_children(self, request, parent):
@@ -240,7 +238,8 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
model = self.queryset.model
initial_data = normalize_querydict(request.GET)
form = self.form(instance=obj, initial=initial_data)
form_prefix = 'quickadd' if request.GET.get('_quickadd') else None
form = self.form(instance=obj, initial=initial_data, prefix=form_prefix)
restrict_form_fields(form, request.user)
context = {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -29,8 +29,8 @@
"flatpickr": "4.6.13",
"gridstack": "12.3.3",
"htmx.org": "2.0.7",
"query-string": "9.3.0",
"sass": "1.92.1",
"query-string": "9.3.1",
"sass": "1.93.2",
"tom-select": "2.4.3",
"typeface-inter": "3.18.1",
"typeface-roboto-mono": "1.1.13"

View File

@@ -1,5 +1,3 @@
import { isTruthy } from 'src/util';
/**
* interface for htmx configRequest event
*/
@@ -17,15 +15,6 @@ function initMarkdownPreview(markdownWidget: HTMLDivElement) {
const textarea = markdownWidget.querySelector('textarea') as HTMLTextAreaElement;
const preview = markdownWidget.querySelector('div.preview') as HTMLDivElement;
/**
* Make sure the textarea has style attribute height
* So that it can be copied over to preview div.
*/
if (!isTruthy(textarea.style.height)) {
const { height } = textarea.getBoundingClientRect();
textarea.style.height = `${height}px`;
}
/**
* Add the value of the textarea to the body of the htmx request
* and copy the height of text are to the preview div

View File

@@ -1,53 +1,23 @@
import { getElements, scrollTo } from '../util';
import { getElements } from '../util';
function handleFormSubmit(event: Event, form: HTMLFormElement): void {
function handleFormSubmit(): void {
// Automatically select all options in any <select> with the "select-all" class. This is useful for
// multi-select fields that are used to add/remove choices.
for (const element of getElements<HTMLOptionElement>('select.select-all option')) {
element.selected = true;
}
// Track the names of each invalid field.
const invalids = new Set<string>();
for (const element of form.querySelectorAll<FormControls>('*[name]')) {
if (!element.validity.valid) {
invalids.add(element.name);
// If the field is invalid, but doesn't contain the .is-invalid class, add it.
if (!element.classList.contains('is-invalid')) {
element.classList.add('is-invalid');
}
} else {
// If the field is valid, but contains the .is-invalid class, remove it.
if (element.classList.contains('is-invalid')) {
element.classList.remove('is-invalid');
}
}
}
if (invalids.size !== 0) {
// If there are invalid fields, pick the first field and scroll to it.
const firstInvalid = form.elements.namedItem(Array.from(invalids)[0]) as Element;
scrollTo(firstInvalid);
// If the form has invalid fields, don't submit it.
event.preventDefault();
}
}
/**
* Attach an event listener to each form's submitter (button[type=submit]). When called, the
* callback checks the validity of each form field and adds the appropriate Bootstrap CSS class
* based on the field's validity.
* Attach event listeners to each form's submit/reset buttons.
*/
export function initFormElements(): void {
for (const form of getElements('form')) {
// Find each of the form's submitters. Most object edit forms have a "Create" and
// a "Create & Add", so we need to add a listener to both.
// Find each of the form's submit buttons.
const submitters = form.querySelectorAll<HTMLButtonElement>('button[type=submit]');
for (const submitter of submitters) {
// Add the event listener to each submitter.
submitter.addEventListener('click', (event: Event) => handleFormSubmit(event, form));
submitter.addEventListener('click', () => handleFormSubmit());
}
// Initialize any reset buttons so that when clicked, the page is reloaded without query parameters.

View File

@@ -0,0 +1,29 @@
const HOTKEYS: Record<string, () => void> = {
'/': focusGlobalSearch,
};
function focusGlobalSearch(): void {
const searchInput = document.querySelector<HTMLInputElement>('header input[name="q"]')!;
if (searchInput) {
searchInput.focus();
}
}
function handleKeydown(event: KeyboardEvent): void {
// Ignore hotkeys when focused on form elements or when modal is open
if ((event.target as Element).matches('input, textarea, select') || document.body.classList.contains('modal-open')) {
return;
}
const handler = HOTKEYS[event.key];
if (!handler) {
return;
}
event.preventDefault();
handler();
}
export function initHotkeys(): void {
document.addEventListener('keydown', handleKeydown);
}

View File

@@ -14,6 +14,7 @@ import { initDashboard } from './dashboard';
import { initRackElevation } from './racks';
import { initHtmx } from './htmx';
import { initSavedFilterSelect } from './forms/savedFiltersSelect';
import { initHotkeys } from './hotkeys';
function initDocument(): void {
for (const init of [
@@ -33,6 +34,7 @@ function initDocument(): void {
initRackElevation,
initHtmx,
initSavedFilterSelect,
initHotkeys,
]) {
init();
}

View File

@@ -2990,10 +2990,10 @@ punycode@^2.1.0:
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
query-string@9.3.0:
version "9.3.0"
resolved "https://registry.yarnpkg.com/query-string/-/query-string-9.3.0.tgz#f2d60d6b4442cb445f374b5ff749b937b2cccd03"
integrity sha512-IQHOQ9aauHAApwAaUYifpEyLHv6fpVGVkMOnwPzcDScLjbLj8tLsILn6unSW79NafOw1llh8oK7Gd0VwmXBFmA==
query-string@9.3.1:
version "9.3.1"
resolved "https://registry.yarnpkg.com/query-string/-/query-string-9.3.1.tgz#d0c93e6c7fb7c17bdf04aa09e382114580ede270"
integrity sha512-5fBfMOcDi5SA9qj5jZhWAcTtDfKF5WFdd2uD9nVNlbxVv1baq65aALy6qofpNEGELHvisjjasxQp7BlM9gvMzw==
dependencies:
decode-uri-component "^0.4.1"
filter-obj "^5.1.0"
@@ -3190,10 +3190,10 @@ safe-regex-test@^1.1.0:
es-errors "^1.3.0"
is-regex "^1.2.1"
sass@1.92.1:
version "1.92.1"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.92.1.tgz#07fb1fec5647d7b712685d1090628bf52456fe86"
integrity sha512-ffmsdbwqb3XeyR8jJR6KelIXARM9bFQe8A6Q3W4Klmwy5Ckd5gz7jgUNHo4UOqutU5Sk1DtKLbpDP0nLCg1xqQ==
sass@1.93.2:
version "1.93.2"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.93.2.tgz#e97d225d60f59a3b3dbb6d2ae3c1b955fd1f2cd1"
integrity sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==
dependencies:
chokidar "^4.0.0"
immutable "^5.0.2"

View File

@@ -1,3 +1,3 @@
version: "4.4.1"
version: "4.4.2"
edition: "Community"
published: "2025-09-16"
published: "2025-09-30"

View File

@@ -1,3 +1,3 @@
<button type="submit" name="_sync" {% formaction %}="{{ url }}{% if return_url %}?return_url={{ return_url }}{% endif %}" class="btn btn-primary">
<button type="submit" name="_sync" {% formaction %}="{{ url }}{% if url_params %}?{{ url_params.urlencode }}{% endif %}" class="btn btn-primary">
<i class="mdi mdi-sync" aria-hidden="true"></i> {{ label }}
</button>

View File

@@ -6,63 +6,63 @@
<ul class="dropdown-menu">
{% if perms.dcim.add_consoleport %}
<li>
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_consoleport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_consoleport' %}{% if url_params %}?{{ url_params.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Console Ports" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_consoleserverport %}
<li>
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_consoleserverport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item ">
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_consoleserverport' %}{% if url_params %}?{{ url_params.urlencode }}{% endif %}" class="dropdown-item ">
{% trans "Console Server Ports" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_powerport %}
<li>
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_powerport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_powerport' %}{% if url_params %}?{{ url_params.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Power Ports" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_poweroutlet %}
<li>
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_poweroutlet' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_poweroutlet' %}{% if url_params %}?{{ url_params.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Power Outlets" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_interface %}
<li>
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_interface' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_interface' %}{% if url_params %}?{{ url_params.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Interfaces" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_rearport %}
<li>
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_rearport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_rearport' %}{% if url_params %}?{{ url_params.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Rear Ports" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_devicebay %}
<li>
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_devicebay' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_devicebay' %}{% if url_params %}?{{ url_params.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Device Bays" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_modulebay %}
<li>
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_modulebay' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_modulebay' %}{% if url_params %}?{{ url_params.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Module Bays" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_inventoryitem %}
<li>
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_inventoryitem' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_inventoryitem' %}{% if url_params %}?{{ url_params.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Inventory Items" %}
</button>
</li>

View File

@@ -1,3 +1,3 @@
<button type="submit" name="_disconnect" {% formaction %}="{{ url }}{% if return_url %}?return_url={{ return_url }}{% endif %}" class="btn btn-red">
<button type="submit" name="_disconnect" {% formaction %}="{{ url }}{% if url_params %}?{{ url_params.urlencode }}{% endif %}" class="btn btn-red">
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i> {{ label }}
</button>

View File

@@ -65,7 +65,6 @@
<div class="col-md-4">
{{ form.length_unit }}
</div>
<div class="invalid-feedback"></div>
</div>
{% render_field form.tags %}
</div>

View File

@@ -44,8 +44,8 @@
<div class="htmx-container table-responsive"
hx-get="{% url 'extras:script_result' job_pk=job.pk %}?embedded=True&log=True&log_threshold={{log_threshold}}"
hx-target="this"
hx-trigger="load" hx-select=".htmx-container" hx-swap="outerHTML"
></div>
hx-trigger="load" hx-select=".htmx-container" hx-swap="outerHTML">
</div>
</div>
</div>
{% endif %}
@@ -60,11 +60,12 @@
<a href="?export=output" class="btn btn-sm btn-primary" role="button">
<i class="mdi mdi-download" aria-hidden="true"></i> {% trans "Download" %}
</a>
{% copy_content "job_data_output" %}
</div>
{% endif %}
</h2>
{% if job.data.output %}
<pre class="card-body font-monospace">{{ job.data.output }}</pre>
<pre class="card-body font-monospace" id="job_data_output">{{ job.data.output }}</pre>
{% else %}
<div class="card-body text-muted">{% trans "None" %}</div>
{% endif %}

View File

@@ -37,20 +37,24 @@
</div>
<div class="card">
<h2 class="card-header">{% trans "Assigned Models" %}</h2>
<table class="table table-hover attr-table">
<div class="list-group list-group-flush" role="presentation">
{% for object_type in object.object_types.all %}
<tr>
<td>{{ object_type }}</td>
</tr>
{% with object_type.model_class|validated_viewname:"list" as viewname %}
{% if viewname %}
<a href="{% url viewname %}?{{ object.url_params }}" class="list-group-item list-group-item-action">{{ object_type }}</a>
{% else %}
<div class="list-group-item list-group-item-action">{{ object_type }}</div>
{% endif %}
{% endwith %}
{% endfor %}
</table>
</div>
</div>
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-7">
<div class="card">
<h2 class="card-header">{% trans "Parameters" %}</h2>
<div class="card-body">
<div class="card-body p-0">
<pre>{{ object.parameters|json }}</pre>
</div>
</div>

View File

@@ -79,21 +79,23 @@
{# /Showing #}
{# Pagination options #}
<nav class="text-end" aria-label="{% trans "Pagination options" %}">
<nav aria-label="{% trans "Pagination options" %}">
{% if page %}
<div class="dropdown">
<div class="dropdown dropdown-menu-end">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
{% trans "Per Page" %}
</button>
<div class="dropdown-menu">
<ul class="dropdown-menu">
{% for n in page.paginator.get_page_lengths %}
{% if htmx %}
<a href="#" hx-get="{{ table.htmx_url }}{% querystring request per_page=n %}" class="dropdown-item">{{ n }}</a>
{% else %}
<a href="{% querystring request per_page=n %}" class="dropdown-item">{{ n }}</a>
{% endif %}
<li>
{% if htmx %}
<a href="#" hx-get="{{ table.htmx_url }}{% querystring request per_page=n %}" class="dropdown-item">{{ n }}</a>
{% else %}
<a href="{% querystring request per_page=n %}" class="dropdown-item">{{ n }}</a>
{% endif %}
</li>
{% endfor %}
</div>
</ul>
</div>
{% endif %}
</nav>

View File

@@ -6,14 +6,14 @@
<ul class="dropdown-menu">
{% if perms.virtualization.add_vminterface %}
<li>
<button type="submit" {% formaction %}="{% url 'virtualization:virtualmachine_bulk_add_vminterface' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
<button type="submit" {% formaction %}="{% url 'virtualization:virtualmachine_bulk_add_vminterface' %}{% if url_params %}?{{ url_params.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Interfaces" %}
</button>
</li>
{% endif %}
{% if perms.virtualization.add_virtualdisk %}
<li>
<button type="submit" {% formaction %}="{% url 'virtualization:virtualmachine_bulk_add_virtualdisk' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
<button type="submit" {% formaction %}="{% url 'virtualization:virtualmachine_bulk_add_virtualdisk' %}{% if url_params %}?{{ url_params.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Virtual Disks" %}
</button>
</li>

View File

@@ -108,7 +108,9 @@
</tr>
<tr>
<th scope="row">{% trans "Cluster Type" %}</th>
<td>{{ object.cluster.type }}</td>
<td>
{{ object.cluster.type|linkify|placeholder }}
</td>
</tr>
<tr>
<th scope="row">{% trans "Device" %}</th>

View File

@@ -9,7 +9,7 @@ from core.graphql.filter_mixins import BaseFilterMixin
if TYPE_CHECKING:
from netbox.graphql.filter_lookups import TreeNodeFilter
from .filters import ContactFilter, TenantFilter, TenantGroupFilter
from .filters import ContactAssignmentFilter, TenantFilter, TenantGroupFilter
__all__ = (
'ContactFilterMixin',
@@ -19,7 +19,7 @@ __all__ = (
@dataclass
class ContactFilterMixin(BaseFilterMixin):
contacts: Annotated['ContactFilter', strawberry.lazy('tenancy.graphql.filters')] | None = (
contacts: Annotated['ContactAssignmentFilter', strawberry.lazy('tenancy.graphql.filters')] | None = (
strawberry_django.filter_field()
)

View File

@@ -110,10 +110,10 @@ class ContactAssignmentTable(NetBoxTable):
verbose_name=_('Role'),
linkify=True
)
contact_group = tables.Column(
accessor=Accessor('contact__group'),
verbose_name=_('Group'),
linkify=True
contact_groups = columns.ManyToManyColumn(
accessor=Accessor('contact__groups'),
verbose_name=_('Groups'),
linkify_item=('tenancy:contactgroup', {'pk': tables.A('pk')})
)
contact_title = tables.Column(
accessor=Accessor('contact__title'),
@@ -152,7 +152,7 @@ class ContactAssignmentTable(NetBoxTable):
model = ContactAssignment
fields = (
'pk', 'object_type', 'object', 'contact', 'role', 'priority', 'contact_title', 'contact_phone',
'contact_email', 'contact_address', 'contact_link', 'contact_description', 'contact_group', 'tags',
'contact_email', 'contact_address', 'contact_link', 'contact_description', 'contact_groups', 'tags',
'actions'
)
default_columns = (

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -137,8 +137,17 @@ def check_ranges_overlap(ranges):
def ranges_to_string(ranges):
"""
Generate a human-friendly string from a set of ranges. Intended for use with ArrayField. For example:
[[1, 100)], [200, 300)] => "1-99,200-299"
Converts a list of ranges into a string representation.
This function takes a list of range objects and produces a string
representation of those ranges. Each range is represented as a
hyphen-separated pair of lower and upper bounds, with inclusive or
exclusive bounds adjusted accordingly. If the lower and upper bounds
of a range are the same, only the single value is added to the string.
Intended for use with ArrayField.
Example:
[NumericRange(1, 5), NumericRange(8, 9), NumericRange(10, 12)] => "1-5,8,10-12"
"""
if not ranges:
return ''
@@ -146,15 +155,22 @@ def ranges_to_string(ranges):
for r in ranges:
lower = r.lower if r.lower_inc else r.lower + 1
upper = r.upper if r.upper_inc else r.upper - 1
output.append(f'{lower}-{upper}')
output.append(f"{lower}-{upper}" if lower != upper else str(lower))
return ','.join(output)
def string_to_ranges(value):
"""
Given a string in the format "1-100, 200-300" return an list of NumericRanges. Intended for use with ArrayField.
For example:
"1-99,200-299" => [NumericRange(1, 100), NumericRange(200, 300)]
Converts a string representation of numeric ranges into a list of NumericRange objects.
This function parses a string containing numeric values and ranges separated by commas (e.g.,
"1-5,8,10-12") and converts it into a list of NumericRange objects.
In the case of a single integer, it is treated as a range where the start and end
are equal. The returned ranges are represented as half-open intervals [lower, upper).
Intended for use with ArrayField.
Example:
"1-5,8,10-12" => [NumericRange(1, 6), NumericRange(8, 9), NumericRange(10, 13)]
"""
if not value:
return None
@@ -172,5 +188,5 @@ def string_to_ranges(value):
upper = dash_range[1]
else:
return None
values.append(NumericRange(int(lower), int(upper), bounds='[]'))
values.append(NumericRange(int(lower), int(upper) + 1, bounds='[)'))
return values

View File

@@ -1,6 +1,7 @@
import json
from django import forms
from django.conf import settings
from django.db.models import Count
from django.forms.fields import JSONField as _JSONField, InvalidJSONInput
from django.templatetags.static import static
@@ -74,7 +75,8 @@ class TagFilterField(forms.MultipleChoiceField):
count=Count('extras_taggeditem_items')
).order_by('name')
return [
(str(tag.slug), '{} ({})'.format(tag.name, tag.count)) for tag in tags
(settings.FILTERS_NULL_CHOICE_VALUE, settings.FILTERS_NULL_CHOICE_LABEL), # "None" option
*[(str(tag.slug), f'{tag.name} ({tag.count})') for tag in tags]
]
# Choices are fetched each time the form is initialized

View File

@@ -1,3 +1,3 @@
<button type="submit" name="_delete" {% formaction %}="{{ url }}{% if return_url %}?return_url={{ return_url }}{% endif %}" class="btn btn-red">
<button type="submit" name="_delete" {% formaction %}="{{ url }}{% if url_params %}?{{ url_params.urlencode }}{% endif %}" class="btn btn-red">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {{ label }}
</button>

View File

@@ -1,3 +1,3 @@
<button type="submit" name="_edit" {% formaction %}="{{ url }}{% if return_url %}?return_url={{ return_url }}{% endif %}" class="btn btn-yellow">
<button type="submit" name="_edit" {% formaction %}="{{ url }}{% if url_params %}?{{ url_params.urlencode }}{% endif %}" class="btn btn-yellow">
<i class="mdi mdi-pencil" aria-hidden="true"></i> {{ label }}
</button>

View File

@@ -1,5 +1,5 @@
{% if url %}
<button type="submit" name="_rename" {% formaction %}="{{ url }}{% if return_url %}?return_url={{ return_url }}{% endif %}" class="btn btn-yellow">
<button type="submit" name="_rename" {% formaction %}="{{ url }}{% if url_params %}?{{ url_params.urlencode }}{% endif %}" class="btn btn-yellow">
<i class="mdi mdi-pencil" aria-hidden="true"></i> {{ label }}
</button>
{% endif %}

View File

@@ -52,10 +52,6 @@
<div class="form-text text-danger">
{% for error in field.errors %}{{ error }}{% if not forloop.last %}<br />{% endif %}{% endfor %}
</div>
{% elif field.field.required %}
<div class="invalid-feedback">
{% trans "This field is required" %}.
</div>
{% endif %}
{# Help text #}

View File

@@ -30,7 +30,7 @@ def nav(context):
continue
if not user.has_perms(item.permissions):
continue
if item.staff_only and not user.is_staff:
if item.staff_only and not any([user.is_staff, user.is_superuser]):
continue
buttons = [
button for button in item.buttons if user.has_perms(button.permissions)

View File

@@ -149,14 +149,13 @@ class APIPaginationTestCase(APITestCase):
def test_default_page_size_with_small_max_page_size(self):
response = self.client.get(self.url, format='json', **self.header)
page_size = get_config().MAX_PAGE_SIZE
paginate_count = get_config().PAGINATE_COUNT
self.assertLess(page_size, 100, "Default page size not sufficient for data set")
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['count'], 100)
self.assertTrue(response.data['next'].endswith(f'?limit={paginate_count}&offset={paginate_count}'))
self.assertTrue(response.data['next'].endswith(f'?limit={page_size}&offset={page_size}'))
self.assertIsNone(response.data['previous'])
self.assertEqual(len(response.data['results']), paginate_count)
self.assertEqual(len(response.data['results']), page_size)
def test_custom_page_size(self):
response = self.client.get(f'{self.url}?limit=10', format='json', **self.header)

View File

@@ -61,18 +61,18 @@ class RangeFunctionsTestCase(TestCase):
self.assertEqual(
string_to_ranges('10-19, 30-39, 100-199'),
[
NumericRange(10, 19, bounds='[]'), # 10-19
NumericRange(30, 39, bounds='[]'), # 30-39
NumericRange(100, 199, bounds='[]'), # 100-199
NumericRange(10, 20, bounds='[)'), # 10-20
NumericRange(30, 40, bounds='[)'), # 30-40
NumericRange(100, 200, bounds='[)'), # 100-200
]
)
self.assertEqual(
string_to_ranges('1-2, 5, 10-12'),
[
NumericRange(1, 2, bounds='[]'), # 1-2
NumericRange(5, 5, bounds='[]'), # 5-5
NumericRange(10, 12, bounds='[]'), # 10-12
NumericRange(1, 3, bounds='[)'), # 1-3
NumericRange(5, 6, bounds='[)'), # 5-6
NumericRange(10, 13, bounds='[)'), # 10-13
]
)

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