Compare commits

...

57 Commits

Author SHA1 Message Date
Martin Hauser
c2a581da47 fix(dcim): Render device height as rack units via floatformat
Use `TemplatedAttr` for device height and render using Django's
`floatformat` filter so 0.0 is displayed as `0U` (and whole-U values
omit the decimal).

Fixes #21267
2026-01-30 16:56:14 +01:00
Aditya Sharma
bec5ecf6a9 Closes #21209: Accept case-insensitive model names in configuration (#21275)
CI / build (20.x, 3.12) (push) Failing after 9s
CI / build (20.x, 3.13) (push) Failing after 8s
CI / build (20.x, 3.14) (push) Failing after 8s
CodeQL / Analyze (actions) (push) Failing after 25s
CodeQL / Analyze (javascript-typescript) (push) Failing after 35s
CodeQL / Analyze (python) (push) Failing after 37s
NetBox now accepts case-insensitive model identifiers in configuration, allowing
both lowercase (e.g. "dcim.site") and PascalCase (e.g. "dcim.Site") for
DEFAULT_DASHBOARD, CUSTOM_VALIDATORS, and PROTECTION_RULES.
This makes model name handling consistent with FIELD_CHOICES.

- Add a shared case-insensitive config lookup helper (get_config_value_ci())
- Use the helper in extras/signals.py and core/signals.py
- Update FIELD_CHOICES ChoiceSetMeta to support case-insensitive replace/extend
  (only compute extend choices if no replacement is defined)
- Add unit tests for get_config_value_ci()
- Add integration tests for case-insensitive FIELD_CHOICES replacement/extension
- Update documentation examples to use PascalCase consistently
2026-01-30 13:48:38 +01:00
github-actions
c98f55dbd2 Update source translation strings
CodeQL / Analyze (actions) (push) Failing after 32s
CodeQL / Analyze (javascript-typescript) (push) Failing after 50s
CodeQL / Analyze (python) (push) Failing after 52s
2026-01-30 05:18:59 +00:00
Martin Hauser
359179fd4a fix(dcim): Add port mapping creation for module install (#21308)
CI / build (20.x, 3.12) (push) Failing after 17s
CI / build (20.x, 3.13) (push) Failing after 19s
CI / build (20.x, 3.14) (push) Failing after 16s
CodeQL / Analyze (actions) (push) Failing after 6m50s
CodeQL / Analyze (javascript-typescript) (push) Failing after 7m4s
CodeQL / Analyze (python) (push) Failing after 7m2s
2026-01-29 14:37:57 -08:00
Arthur Hanson
c44e8606f7 21129 Store queue_name in Job so correctly deleted in RQ (#21309)
* Add queue name to Job

* Add queue name to serializer, filterset, detail view

* fix job queue delete

* fix job queue delete

* review feedback
2026-01-29 15:29:33 -05:00
github-actions
8e620ef325 Update source translation strings
CodeQL / Analyze (actions) (push) Failing after 18s
CodeQL / Analyze (javascript-typescript) (push) Failing after 17s
CodeQL / Analyze (python) (push) Failing after 18s
2026-01-29 05:17:01 +00:00
Arthur
905d17294a Review feedback
CI / build (20.x, 3.12) (push) Failing after 10s
CI / build (20.x, 3.13) (push) Failing after 11s
CI / build (20.x, 3.14) (push) Failing after 11s
2026-01-28 17:27:01 -08:00
Arthur
44e5a4c177 Review feedback 2026-01-28 17:12:13 -08:00
Jeremy Stretch
1526e437f1 Closes #21244: Introduce ability to omit specific fields from REST API responses (#21312)
CI / build (20.x, 3.12) (push) Failing after 14s
CI / build (20.x, 3.13) (push) Failing after 10s
CI / build (20.x, 3.14) (push) Failing after 11s
CodeQL / Analyze (actions) (push) Failing after 21s
CodeQL / Analyze (javascript-typescript) (push) Failing after 17s
CodeQL / Analyze (python) (push) Failing after 17s
Introduce support for omitting specific serializer fields via an
`omit` parameter, acting as the inverse of `fields`.
Wire it through the API viewset and queryset optimization helpers
so omitted fields don’t trigger unnecessary annotations/prefetches,
and document the new behavior.
2026-01-28 22:06:46 +01:00
Martin Hauser
0b507eb207 fix(ipam): Include scope params in Prefix creation links
CI / build (20.x, 3.12) (push) Failing after 11s
CI / build (20.x, 3.13) (push) Failing after 11s
CI / build (20.x, 3.14) (push) Failing after 12s
CodeQL / Analyze (actions) (push) Failing after 7m12s
CodeQL / Analyze (javascript-typescript) (push) Failing after 18s
CodeQL / Analyze (python) (push) Failing after 28s
Update prefix creation URLs to pass `scope_type` and `scope` (replacing
the legacy `site` query parameter) for both the Child Prefixes
"Add Prefix" button and in-table available-prefix links.
Scope parameters are only rendered when a scope is defined, so
unscoped prefixes remain unchanged.

Fixes #21262
2026-01-28 15:19:44 -05:00
Elliott Balsley
5a36e79215 Fixes #20977: Apply defaults for missing script variables (#21295)
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
CI / build (20.x, 3.14) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Ensure script variables fall back to their defined defaults when a value is not
submitted (e.g. via "Run again" or other minimal POSTs).

- Populate omitted script variables with their initial/default values before
  validation and job enqueueing
- Treat falsy defaults (e.g. False/0) as valid defaults
- Add a test asserting defaults are included in enqueued job data
- Remove the redundant default from ScriptValidationErrorTest
2026-01-28 15:35:33 +01:00
Martin Hauser
2a0f26623b Fixes #21254: Fix release check failure when stale latest_release cache can't be unpickled (#21282)
* fix(misc): Handle cache unpickling failure in release check

Guard `cache.get('latest_release')` during release checks to prevent a
500 when stale cached data can't be unpickled after dependency upgrades.
On failure, log at debug level and delete the affected cache key.

Fixes #21254

* Correct comment

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2026-01-28 09:28:20 -05:00
github-actions
1a603981b2 Update source translation strings
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2026-01-28 05:07:33 +00:00
Aditya Sharma
245495b2fe Closes #21228: Add image attachments support to RackType model (#21276)
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
CI / build (20.x, 3.14) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2026-01-27 09:36:11 -08:00
bctiemann
8d3eb69055 Merge pull request #21264 from netbox-community/19869-provide-information-about-lag-targets-in-lag-members-section
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
CI / build (20.x, 3.14) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Fixes #19869: Display peer connections for LAG member interfaces
2026-01-27 10:23:14 -05:00
bctiemann
7e3b60f194 Merge pull request #21299 from netbox-community/20172-ability-to-query-for-cabled-interfaces-via-graphql
Closes #20172: Add `cabled` filter for DCIM interfaces in GraphQL
2026-01-27 10:13:27 -05:00
bctiemann
5338c842b8 Merge pull request #21289 from llamafilm/20052-loglevel
Fixes #20052: improve logging for faulty scripts
2026-01-27 10:10:17 -05:00
bctiemann
9186b0edaa Merge pull request #21281 from netbox-community/21176-remove-iprange-checkboxes
Fixes #21176: Remove checkboxes from IP ranges in mixed-type tables
2026-01-27 10:08:37 -05:00
bctiemann
d883be9e56 Merge pull request #21246 from adionit7/21150-docs-config-menu-path
Fixes #21150: Correct Dynamic Configuration menu path in documentation
2026-01-27 08:43:52 -05:00
bctiemann
6fc7fa6c64 Merge pull request #21220 from netbox-community/15801-vlan-overview-device-interfaces-list-with-connection-link
Closes #15801: Add link peer and connection columns to `VLANDeviceTable`
2026-01-27 08:35:33 -05:00
Martin Hauser
3a33df0e43 feat(forms): Add Owner Group support to Filter Forms
Introduces support for `owner_group` in various filter forms, improving
ownership granularity.
Updates DynamicModel fields to handle relationships
between `owner_group` and `owner` effectively.

Fixes #21081
2026-01-27 08:34:42 -05:00
github-actions
433f46746e Update source translation strings
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2026-01-27 05:07:09 +00:00
Jeremy Stretch
8f5f91fcfe Closes #21259: Cache ObjectType results for the duration of a request (#21287)
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
CI / build (20.x, 3.14) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2026-01-26 15:07:13 -08:00
Martin Hauser
1a2175127e Fixes #21202: Avoid clearing scope on clone (#21265) 2026-01-26 16:14:36 -06:00
Martin Hauser
e859807d1d docs(guides): Update Ubuntu reference to 24.04
Update the installation and administration guides to reference
Ubuntu 24.04 instead of 22.04 where applicable, and refresh examples
to match NetBox v4.5.

This includes updates to Python version requirements, NetBox shell
commands, Redis configuration, and sample outputs to align with current
compatibility and best practices.

Fixes #21297
2026-01-26 15:43:59 -05:00
Jeremy Stretch
a8c997ff29 Closes #21260: Defer object serialization for events pipeline (#21286) 2026-01-26 14:35:00 -06:00
adionit7
4a28ab98f4 Fixes #21115: Include attribute_data in ModuleType YAML export
- Added airflow and attribute_data fields to ModuleType.to_yaml() method
- Ensures custom JSON properties from module type profiles are properly exported
- Maintains consistency with import functionality in ModuleTypeImportForm
2026-01-26 15:01:21 -05:00
Martin Hauser
3636d55017 fix(nav): Show Authentication admin menu items based on object perms (#21283)
Replace hardcoded menu entries for Users, Groups, API Tokens, and
Permissions with `get_model_item()`. This drops the `staff_only` gate
and relies on the standard model permission checks, restoring visibility
of these Admin menu items for non-superusers with the relevant object
permissions.

Fixes #21242
2026-01-26 11:34:46 -08:00
Aditya Sharma
aa69e96818 Fixes #21173: Fix plugin menu registration order timing issue (#21248)
* Fixes #21173: Fix plugin menu registration order timing issue

- Converted static MENUS list to dynamic get_menus() function
- Ensures plugin menus are built at request time after all plugins complete ready()
- Fixes issue where only first few plugin menus appeared in navigation sidebar
- Updated navigation template tag to call get_menus() dynamically

* Fix ruff linting errors

- Add missing blank line before get_menus() function definition
- Remove trailing whitespace

* Add @cache decorator to get_menus() for performance optimization

Per reviewer feedback, the menu list is now cached since it doesn't change
without a Django restart. This eliminates redundant list building on each request.

---------

Co-authored-by: adionit7 <adionit7@users.noreply.github.com>
2026-01-26 10:34:57 -08:00
Martin Hauser
1745d2ae93 feat(dcim): Add filter for cabled objects in GraphQL
Introduces a `cabled` filter to the GraphQL API for DCIM. Allows
filtering objects based on whether they are connected to a cable,
improving query customization.

Fixes #20172
2026-01-26 15:39:56 +01:00
Elliott Balsley
e097a848dc display error in UI 2026-01-24 19:04:14 -08:00
Elliott Balsley
595be6dcd4 log the error with error level instead of debug 2026-01-24 19:04:06 -08:00
github-actions
a9e50238eb Update source translation strings
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Lock threads / lock (push) Has been cancelled
Close stale issues/PRs / stale (push) Has been cancelled
Close incomplete issues / stale (push) Has been cancelled
Update translation strings / makemessages (push) Has been cancelled
2026-01-24 05:03:22 +00:00
Arthur
de19447317 Merge branch 'main' into 20911-dropdown
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
CI / build (20.x, 3.14) (push) Has been cancelled
2026-01-23 15:59:08 -08:00
Arthur
f195af206b fix csv import 2026-01-23 15:46:26 -08:00
Jason Novinger
cedbeb7b19 Fixes #21176: Remove checkboxes from IP ranges in mixed-type tables
When IP addresses and IP ranges are displayed together in a prefix's
  IP Addresses tab, only IP addresses should be selectable for bulk
  operations since the bulk delete form doesn't support mixed object types.

  - Override render_pk() in AnnotatedIPAddressTable to conditionally render
    checkboxes only for the table's primary model type (IPAddress)
  - Add warning comment to add_requested_prefixes() about fake Prefix objects
  - Add regression test to verify IPAddress has checkboxes but IPRange does not
2026-01-23 09:36:15 -06:00
Martin Hauser
a45b6b170d feat(dcim): Show peer connections for LAG members
Add `InterfaceLAGMemberTable` for the LAG Members panel on
LAG interface detail views. The table includes the parent device,
member interface/type, and a peer column which renders
connected endpoints (including the peer LAG when present).

Fixes #19869
2026-01-22 20:41:40 +01:00
Arthur
b0ac55ed6a cleanup
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
CI / build (20.x, 3.14) (push) Has been cancelled
2026-01-21 16:44:48 -08:00
Arthur
91ab818411 use bulk_update and rebuild 2026-01-21 16:23:24 -08:00
Arthur
62b9367ae3 use bulk_update and rebuild 2026-01-21 16:14:14 -08:00
Arthur
0c091aa80e cleanup 2026-01-21 13:18:34 -08:00
Arthur
94836e5a37 fix migration 2026-01-21 12:55:34 -08:00
Arthur
c92912ff03 fix migration 2026-01-21 12:52:41 -08:00
Arthur
ef0bc18095 fix migration 2026-01-21 12:47:16 -08:00
Arthur
99f727e685 fix migration 2026-01-21 12:41:59 -08:00
Arthur
6a5aced4bc fix migration 2026-01-21 12:28:01 -08:00
Arthur
46f9a12a87 add migration 2026-01-21 11:59:03 -08:00
adionit7
42ecf3cac0 Fixes #21150: Correct Dynamic Configuration menu path in documentation
- Updated menu path from 'Admin > Extras > Configuration Revisions'
  to 'Admin > System > Configuration History'
- Reflects actual location in NetBox admin interface
2026-01-21 22:53:29 +05:30
Martin Hauser
af8e53d8fb feat(ipam): Add connection/link peer to VLANDeviceTable
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
CI / build (20.x, 3.14) (push) Has been cancelled
The VLAN Device Interfaces table now includes `connection` and
`link_peer` columns, using the existing interface templates to render
peer/connection context consistently.

Fixes #15801
2026-01-21 13:04:39 +01:00
Arthur
be1a008216 rebuild tree after rename
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
CI / build (20.x, 3.14) (push) Has been cancelled
2026-01-20 15:28:49 -08:00
Arthur
c4c3518bb4 change ordering field, remove front-end changes 2026-01-20 13:45:17 -08:00
Arthur
5a1282e326 Merge branch 'main' into 20911-dropdown
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
CI / build (20.x, 3.14) (push) Has been cancelled
2026-01-14 13:39:45 -08:00
Arthur
cb13eb277f use correct node version 2026-01-14 13:36:33 -08:00
Arthur
24642be351 cleanup 2026-01-08 17:08:10 -08:00
Arthur
89af9efd85 cleanup 2026-01-08 17:04:00 -08:00
Arthur
99d678502f cleanup 2026-01-08 16:23:47 -08:00
Arthur
e6300ee06d Fix TomSelect dropdown ordering 2026-01-08 16:17:40 -08:00
81 changed files with 2471 additions and 1770 deletions
+24 -12
View File
@@ -3,29 +3,41 @@
NetBox includes a Python management shell within which objects can be directly queried, created, modified, and deleted. To enter the shell, run the following command: NetBox includes a Python management shell within which objects can be directly queried, created, modified, and deleted. To enter the shell, run the following command:
``` ```
./manage.py nbshell cd /opt/netbox
source /opt/netbox/venv/bin/activate
python3 netbox/manage.py nbshell
``` ```
This will launch a lightly customized version of [the built-in Django shell](https://docs.djangoproject.com/en/stable/ref/django-admin/#shell) with all relevant NetBox models pre-loaded. (If desired, the stock Django shell is also available by executing `./manage.py shell`.) This will launch a lightly customized version of [the built-in Django shell](https://docs.djangoproject.com/en/stable/ref/django-admin/#shell) with all relevant NetBox models preloaded. (If desired, the stock Django shell is also available by executing `./manage.py shell`.)
``` ```
$ ./manage.py nbshell (venv) $ python3 netbox/manage.py nbshell
### NetBox interactive shell (localhost) ### NetBox interactive shell (localhost)
### Python 3.7.10 | Django 3.2.5 | NetBox 3.0 ### Python v3.12.3 | Django v5.2.10 | NetBox Community v4.5.1
### lsmodels() will show available models. Use help(<model>) for more info. ### lsapps() & lsmodels() will show available models. Use help(<model>) for more info.
``` ```
The function `lsmodels()` will print a list of all available NetBox models: The function `lsmodels()` will print a list of all available NetBox models:
``` ```
>>> lsmodels() >>> lsmodels()
DCIM:
ConsolePort
ConsolePortTemplate
ConsoleServerPort
ConsoleServerPortTemplate
Device
... ...
DCIM:
dcim.Cable
dcim.CableTermination
dcim.ConsolePort
dcim.ConsolePortTemplate
dcim.ConsoleServerPort
dcim.ConsoleServerPortTemplate
dcim.Device
...
```
To exit the NetBox shell, type `exit()` or press `Ctrl+D`.
```
>>> exit()
(venv) $
``` ```
!!! warning !!! warning
@@ -114,7 +126,7 @@ Reverse relationships can be traversed as well. For example, the following will
>>> Device.objects.filter(interfaces__name="em0") >>> Device.objects.filter(interfaces__name="em0")
``` ```
Character fields can be filtered against partial matches using the `contains` or `icontains` field lookup (the later of which is case-insensitive). Character fields can be filtered against partial matches using the `contains` or `icontains` field lookup (the latter of which is case-insensitive).
``` ```
>>> Device.objects.filter(name__icontains="testdevice") >>> Device.objects.filter(name__icontains="testdevice")
+12 -3
View File
@@ -8,7 +8,7 @@ This is a mapping of models to [custom validators](../customization/custom-valid
```python ```python
CUSTOM_VALIDATORS = { CUSTOM_VALIDATORS = {
"dcim.site": [ "dcim.Site": [
{ {
"name": { "name": {
"min_length": 5, "min_length": 5,
@@ -17,12 +17,15 @@ CUSTOM_VALIDATORS = {
}, },
"my_plugin.validators.Validator1" "my_plugin.validators.Validator1"
], ],
"dcim.device": [ "dcim.Device": [
"my_plugin.validators.Validator1" "my_plugin.validators.Validator1"
] ]
} }
``` ```
!!! info "Case-Insensitive Model Names"
Model identifiers are case-insensitive. Both `dcim.site` and `dcim.Site` are valid and equivalent.
--- ---
## FIELD_CHOICES ## FIELD_CHOICES
@@ -53,6 +56,9 @@ FIELD_CHOICES = {
} }
``` ```
!!! info "Case-Insensitive Field Identifiers"
Field identifiers are case-insensitive. Both `dcim.Site.status` and `dcim.site.status` are valid and equivalent.
The following model fields support configurable choices: The following model fields support configurable choices:
* `circuits.Circuit.status` * `circuits.Circuit.status`
@@ -98,7 +104,7 @@ This is a mapping of models to [custom validators](../customization/custom-valid
```python ```python
PROTECTION_RULES = { PROTECTION_RULES = {
"dcim.site": [ "dcim.Site": [
{ {
"status": { "status": {
"eq": "decommissioning" "eq": "decommissioning"
@@ -108,3 +114,6 @@ PROTECTION_RULES = {
] ]
} }
``` ```
!!! info "Case-Insensitive Model Names"
Model identifiers are case-insensitive. Both `dcim.site` and `dcim.Site` are valid and equivalent.
+1 -1
View File
@@ -15,7 +15,7 @@ Some configuration parameters may alternatively be defined either in `configurat
## Dynamic Configuration Parameters ## Dynamic Configuration Parameters
Some configuration parameters are primarily controlled via NetBox's admin interface (under Admin > Extras > Configuration Revisions). These are noted where applicable in the documentation. These settings may also be overridden in `configuration.py` to prevent them from being modified via the UI. A complete list of supported parameters is provided below: Some configuration parameters are primarily controlled via NetBox's admin interface (under Admin > System > Configuration History). These are noted where applicable in the documentation. These settings may also be overridden in `configuration.py` to prevent them from being modified via the UI. A complete list of supported parameters is provided below:
* [`ALLOWED_URL_SCHEMES`](./security.md#allowed_url_schemes) * [`ALLOWED_URL_SCHEMES`](./security.md#allowed_url_schemes)
* [`BANNER_BOTTOM`](./miscellaneous.md#banner_bottom) * [`BANNER_BOTTOM`](./miscellaneous.md#banner_bottom)
+4 -4
View File
@@ -51,14 +51,14 @@ You can verify that authentication works by executing the `psql` command and pas
```no-highlight ```no-highlight
$ psql --username netbox --password --host localhost netbox $ psql --username netbox --password --host localhost netbox
Password for user netbox: Password:
psql (12.5 (Ubuntu 12.5-0ubuntu0.20.04.1)) psql (16.11 (Ubuntu 16.11-0ubuntu0.24.04.1))
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off) SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, compression: off)
Type "help" for help. Type "help" for help.
netbox=> \conninfo netbox=> \conninfo
You are connected to database "netbox" as user "netbox" on host "localhost" (address "127.0.0.1") at port "5432". You are connected to database "netbox" as user "netbox" on host "localhost" (address "127.0.0.1") at port "5432".
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off) SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, compression: off)
netbox=> \q netbox=> \q
``` ```
+14 -14
View File
@@ -36,7 +36,7 @@ sudo ln -s /opt/netbox-X.Y.Z/ /opt/netbox
``` ```
!!! note !!! note
It is recommended to install NetBox in a directory named for its version number. For example, NetBox v3.0.0 would be installed into `/opt/netbox-3.0.0`, and a symlink from `/opt/netbox/` would point to this location. (You can verify this configuration with the command `ls -l /opt | grep netbox`.) This allows for future releases to be installed in parallel without interrupting the current installation. When changing to the new release, only the symlink needs to be updated. It is recommended to install NetBox in a directory named for its version number. For example, NetBox v4.0.0 would be installed into `/opt/netbox-4.0.0`, and a symlink from `/opt/netbox/` would point to this location. (You can verify this configuration with the command `ls -l /opt | grep netbox`.) This allows for future releases to be installed in parallel without interrupting the current installation. When changing to the new release, only the symlink needs to be updated.
### Option B: Clone the Git Repository ### Option B: Clone the Git Repository
@@ -63,12 +63,12 @@ This command should generate output similar to the following:
``` ```
Cloning into '.'... Cloning into '.'...
remote: Enumerating objects: 996, done. remote: Enumerating objects: 148317, done.
remote: Counting objects: 100% (996/996), done. remote: Counting objects: 100% (183/183), done.
remote: Compressing objects: 100% (935/935), done. remote: Compressing objects: 100% (115/115), done.
remote: Total 996 (delta 148), reused 386 (delta 34), pack-reused 0 remote: Total 148317 (delta 127), reused 68 (delta 68), pack-reused 148134 (from 3)
Receiving objects: 100% (996/996), 4.26 MiB | 9.81 MiB/s, done. Receiving objects: 100% (148317/148317), 165.12 MiB | 28.71 MiB/s, done.
Resolving deltas: 100% (148/148), done. Resolving deltas: 100% (116428/116428), done.
``` ```
Finally, check out the tag for the desired release. You can find these on our [releases page](https://github.com/netbox-community/netbox/releases). Replace `vX.Y.Z` with your selected release tag below. Finally, check out the tag for the desired release. You can find these on our [releases page](https://github.com/netbox-community/netbox/releases). Replace `vX.Y.Z` with your selected release tag below.
@@ -102,7 +102,8 @@ sudo cp configuration_example.py configuration.py
Open `configuration.py` with your preferred editor to begin configuring NetBox. NetBox offers [many configuration parameters](../configuration/index.md), but only the following four are required for new installations: Open `configuration.py` with your preferred editor to begin configuring NetBox. NetBox offers [many configuration parameters](../configuration/index.md), but only the following four are required for new installations:
* `ALLOWED_HOSTS` * `ALLOWED_HOSTS`
* `DATABASES` (or `DATABASE`) * `API_TOKEN_PEPPERS`
* `DATABASES`
* `REDIS` * `REDIS`
* `SECRET_KEY` * `SECRET_KEY`
@@ -158,7 +159,7 @@ DATABASES = {
### REDIS ### REDIS
Redis is a in-memory key-value store used by NetBox for caching and background task queuing. Redis typically requires minimal configuration; the values below should suffice for most installations. See the [configuration documentation](../configuration/required-parameters.md#redis) for more detail on individual parameters. Redis is an in-memory key-value store used by NetBox for caching and background task queuing. Redis typically requires minimal configuration; the values below should suffice for most installations. See the [configuration documentation](../configuration/required-parameters.md#redis) for more detail on individual parameters.
Note that NetBox requires the specification of two separate Redis databases: `tasks` and `caching`. These may both be provided by the same Redis service, however each should have a unique numeric database ID. Note that NetBox requires the specification of two separate Redis databases: `tasks` and `caching`. These may both be provided by the same Redis service, however each should have a unique numeric database ID.
@@ -252,7 +253,7 @@ Once NetBox has been configured, we're ready to proceed with the actual installa
sudo /opt/netbox/upgrade.sh sudo /opt/netbox/upgrade.sh
``` ```
Note that **Python 3.12 or later is required** for NetBox v4.5 and later releases. If the default Python installation on your server is set to a lesser version, pass the path to the supported installation as an environment variable named `PYTHON`. (Note that the environment variable must be passed _after_ the `sudo` command.) Note that **Python 3.12 or later is required** for NetBox v4.5 and later releases. If the default Python installation on your server is set to a lesser version, pass the path to the supported installation as an environment variable named `PYTHON`. (Note that the environment variable must be passed _after_ the `sudo` command.)
```no-highlight ```no-highlight
sudo PYTHON=/usr/bin/python3.12 /opt/netbox/upgrade.sh sudo PYTHON=/usr/bin/python3.12 /opt/netbox/upgrade.sh
@@ -295,13 +296,12 @@ python3 manage.py runserver 0.0.0.0:8000 --insecure
If successful, you should see output similar to the following: If successful, you should see output similar to the following:
```no-highlight ```no-highlight
Watching for file changes with StatReloader
Performing system checks... Performing system checks...
System check identified no issues (0 silenced). System check identified no issues (0 silenced).
August 30, 2021 - 18:02:23 January 26, 2026 - 17:00:00
Django version 3.2.6, using settings 'netbox.settings' Django version 5.2.10, using settings 'netbox.settings'
Starting development server at http://127.0.0.1:8000/ Starting development server at http://0.0.0.0:8000/
Quit the server with CONTROL-C. Quit the server with CONTROL-C.
``` ```
+14 -8
View File
@@ -43,16 +43,22 @@ You should see output similar to the following:
```no-highlight ```no-highlight
● netbox.service - NetBox WSGI Service ● netbox.service - NetBox WSGI Service
Loaded: loaded (/etc/systemd/system/netbox.service; enabled; vendor preset: enabled) Loaded: loaded (/etc/systemd/system/netbox.service; enabled; preset: enabled)
Active: active (running) since Mon 2021-08-30 04:02:36 UTC; 14h ago Active: active (running) since Mon 2026-01-26 11:00:00 CST; 7s ago
Docs: https://docs.netbox.dev/ Docs: https://docs.netbox.dev/
Main PID: 1140492 (gunicorn) Main PID: 7283 (gunicorn)
Tasks: 19 (limit: 4683) Tasks: 6 (limit: 4545)
Memory: 666.2M Memory: 556.1M (peak: 556.3M)
CPU: 3.387s
CGroup: /system.slice/netbox.service CGroup: /system.slice/netbox.service
├─1140492 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /va> ├─7283 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox>
├─1140513 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /va> ├─7285 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox>
├─1140514 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /va> ├─7286 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox>
├─7287 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox>
├─7288 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox>
└─7289 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox>
Jan 26 11:00:00 netbox systemd[1]: Started netbox.service - NetBox WSGI Service.
... ...
``` ```
+1 -1
View File
@@ -3,7 +3,7 @@
This documentation provides example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](https://httpd.apache.org/docs/current/), though any HTTP server which supports WSGI should be compatible. This documentation provides example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](https://httpd.apache.org/docs/current/), though any HTTP server which supports WSGI should be compatible.
!!! info !!! info
For the sake of brevity, only Ubuntu 20.04 instructions are provided here. These tasks are not unique to NetBox and should carry over to other distributions with minimal changes. Please consult your distribution's documentation for assistance if needed. For the sake of brevity, only Ubuntu 24.04 instructions are provided here. These tasks are not unique to NetBox and should carry over to other distributions with minimal changes. Please consult your distribution's documentation for assistance if needed.
## Obtain an SSL Certificate ## Obtain an SSL Certificate
+2 -2
View File
@@ -12,12 +12,12 @@
</div> </div>
The installation instructions provided here have been tested to work on Ubuntu 22.04. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors. The installation instructions provided here have been tested to work on Ubuntu 24.04. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
The following sections detail how to set up a new instance of NetBox: The following sections detail how to set up a new instance of NetBox:
1. [PostgreSQL database](1-postgresql.md) 1. [PostgreSQL database](1-postgresql.md)
1. [Redis](2-redis.md) 2. [Redis](2-redis.md)
3. [NetBox components](3-netbox.md) 3. [NetBox components](3-netbox.md)
4. [Gunicorn](4a-gunicorn.md) or [uWSGI](4b-uwsgi.md) 4. [Gunicorn](4a-gunicorn.md) or [uWSGI](4b-uwsgi.md)
5. [HTTP server](5-http-server.md) 5. [HTTP server](5-http-server.md)
+4 -4
View File
@@ -65,7 +65,7 @@ Download and extract the latest version:
```no-highlight ```no-highlight
# Set $NEWVER to the NetBox version being installed # Set $NEWVER to the NetBox version being installed
NEWVER=3.5.0 NEWVER=4.5.0
wget https://github.com/netbox-community/netbox/archive/v$NEWVER.tar.gz wget https://github.com/netbox-community/netbox/archive/v$NEWVER.tar.gz
sudo tar -xzf v$NEWVER.tar.gz -C /opt sudo tar -xzf v$NEWVER.tar.gz -C /opt
sudo ln -sfn /opt/netbox-$NEWVER/ /opt/netbox sudo ln -sfn /opt/netbox-$NEWVER/ /opt/netbox
@@ -75,7 +75,7 @@ Copy `local_requirements.txt`, `configuration.py`, and `ldap_config.py` (if pres
```no-highlight ```no-highlight
# Set $OLDVER to the NetBox version currently installed # Set $OLDVER to the NetBox version currently installed
OLDVER=3.4.9 OLDVER=4.4.10
sudo cp /opt/netbox-$OLDVER/local_requirements.txt /opt/netbox/ sudo cp /opt/netbox-$OLDVER/local_requirements.txt /opt/netbox/
sudo cp /opt/netbox-$OLDVER/netbox/netbox/configuration.py /opt/netbox/netbox/netbox/ sudo cp /opt/netbox-$OLDVER/netbox/netbox/configuration.py /opt/netbox/netbox/netbox/
sudo cp /opt/netbox-$OLDVER/netbox/netbox/ldap_config.py /opt/netbox/netbox/netbox/ sudo cp /opt/netbox-$OLDVER/netbox/netbox/ldap_config.py /opt/netbox/netbox/netbox/
@@ -116,7 +116,7 @@ Check out the desired release by specifying its tag. For example:
``` ```
cd /opt/netbox && \ cd /opt/netbox && \
sudo git fetch --tags && \ sudo git fetch --tags && \
sudo git checkout v4.2.7 sudo git checkout v4.5.0
``` ```
## 4. Run the Upgrade Script ## 4. Run the Upgrade Script
@@ -128,7 +128,7 @@ sudo ./upgrade.sh
``` ```
!!! warning !!! warning
If the default version of Python is not at least 3.10, you'll need to pass the path to a supported Python version as an environment variable when calling the upgrade script. For example: If the default version of Python is not **at least 3.12**, you'll need to pass the path to a supported Python version as an environment variable when calling the upgrade script. For example:
```no-highlight ```no-highlight
sudo PYTHON=/usr/bin/python3.12 ./upgrade.sh sudo PYTHON=/usr/bin/python3.12 ./upgrade.sh
+46 -4
View File
@@ -215,9 +215,51 @@ http://netbox/api/ipam/ip-addresses/ \
If we wanted to assign this IP address to a virtual machine interface instead, we would have set `assigned_object_type` to `virtualization.vminterface` and updated the object ID appropriately. If we wanted to assign this IP address to a virtual machine interface instead, we would have set `assigned_object_type` to `virtualization.vminterface` and updated the object ID appropriately.
### Brief Format ### Specifying Fields
Most API endpoints support an optional "brief" format, which returns only a minimal representation of each object in the response. This is useful when you need only a list of available objects without any related data, such as when populating a drop-down list in a form. As an example, the default (complete) format of a prefix looks like this: A REST API response will include all available fields for the object type by default. If you wish to return only a subset of the available fields, you can append `?fields=` to the URL followed by a comma-separated list of field names. For example, the following request will return only the `id`, `name`, `status`, and `region` fields for each site in the response.
```
GET /api/dcim/sites/?fields=id,name,status,region
```
```json
{
"id": 1,
"name": "DM-NYC",
"status": {
"value": "active",
"label": "Active"
},
"region": {
"id": 43,
"url": "http://netbox:8000/api/dcim/regions/43/",
"display": "New York",
"name": "New York",
"slug": "us-ny",
"description": "",
"site_count": 0,
"_depth": 2
}
}
```
Similarly, you can opt to omit only specific fields by passing the `omit` parameter:
```
GET /api/dcim/sites/?omit=circuit_count,device_count,virtualmachine_count
```
!!! note "The `omit` parameter was introduced in NetBox v4.5.2."
Strategic use of the `fields` and `omit` parameters can drastically improve REST API performance, as the exclusion of fields which reference related objects reduces the number and complexity of underlying database queries needed to generate the response.
!!! note
The `fields` and `omit` parameters should be considered mutually exclusive. If both are passed, `fields` takes precedence.
#### Brief Format
Most API endpoints support an optional "brief" format, which returns only a minimal representation of each object in the response. This is useful when you need only a list of available objects without any related data, such as when populating a drop-down list in a form. It's also more convenient than listing out individual fields via the `fields` or `omit` parameters. As an example, the default (complete) format of a prefix looks like this:
```no-highlight ```no-highlight
GET /api/ipam/prefixes/13980/ GET /api/ipam/prefixes/13980/
@@ -270,10 +312,10 @@ GET /api/ipam/prefixes/13980/
} }
``` ```
The brief format is much more terse: The brief format includes only a few fields:
```no-highlight ```no-highlight
GET /api/ipam/prefixes/13980/?brief=1 GET /api/ipam/prefixes/13980/?brief=true
``` ```
```json ```json
+16 -8
View File
@@ -34,9 +34,10 @@ __all__ = (
class ProviderFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm): class ProviderFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
model = Provider model = Provider
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
FieldSet('asn_id', name=_('ASN')), FieldSet('asn_id', name=_('ASN')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
) )
region_id = DynamicModelMultipleChoiceField( region_id = DynamicModelMultipleChoiceField(
@@ -69,8 +70,9 @@ class ProviderFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
class ProviderAccountFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm): class ProviderAccountFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
model = ProviderAccount model = ProviderAccount
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('provider_id', 'account', name=_('Attributes')), FieldSet('provider_id', 'account', name=_('Attributes')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
) )
provider_id = DynamicModelMultipleChoiceField( provider_id = DynamicModelMultipleChoiceField(
@@ -88,8 +90,9 @@ class ProviderAccountFilterForm(ContactModelFilterForm, PrimaryModelFilterSetFor
class ProviderNetworkFilterForm(PrimaryModelFilterSetForm): class ProviderNetworkFilterForm(PrimaryModelFilterSetForm):
model = ProviderNetwork model = ProviderNetwork
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('provider_id', 'service_id', name=_('Attributes')), FieldSet('provider_id', 'service_id', name=_('Attributes')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
provider_id = DynamicModelMultipleChoiceField( provider_id = DynamicModelMultipleChoiceField(
queryset=Provider.objects.all(), queryset=Provider.objects.all(),
@@ -107,8 +110,9 @@ class ProviderNetworkFilterForm(PrimaryModelFilterSetForm):
class CircuitTypeFilterForm(OrganizationalModelFilterSetForm): class CircuitTypeFilterForm(OrganizationalModelFilterSetForm):
model = CircuitType model = CircuitType
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('color', name=_('Attributes')), FieldSet('color', name=_('Attributes')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
tag = TagFilterField(model) tag = TagFilterField(model)
@@ -121,7 +125,7 @@ class CircuitTypeFilterForm(OrganizationalModelFilterSetForm):
class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilterSetForm): class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilterSetForm):
model = Circuit model = Circuit
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('provider_id', 'provider_account_id', 'provider_network_id', name=_('Provider')), FieldSet('provider_id', 'provider_account_id', 'provider_network_id', name=_('Provider')),
FieldSet( FieldSet(
'type_id', 'status', 'install_date', 'termination_date', 'commit_rate', 'distance', 'distance_unit', 'type_id', 'status', 'install_date', 'termination_date', 'commit_rate', 'distance', 'distance_unit',
@@ -129,6 +133,7 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelF
), ),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
) )
selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'provider_id', 'provider_network_id') selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'provider_id', 'provider_network_id')
@@ -274,8 +279,9 @@ class CircuitTerminationFilterForm(NetBoxModelFilterSetForm):
class CircuitGroupFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm): class CircuitGroupFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm):
model = CircuitGroup model = CircuitGroup
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
tag = TagFilterField(model) tag = TagFilterField(model)
@@ -312,8 +318,9 @@ class CircuitGroupAssignmentFilterForm(NetBoxModelFilterSetForm):
class VirtualCircuitTypeFilterForm(OrganizationalModelFilterSetForm): class VirtualCircuitTypeFilterForm(OrganizationalModelFilterSetForm):
model = VirtualCircuitType model = VirtualCircuitType
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('color', name=_('Attributes')), FieldSet('color', name=_('Attributes')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
tag = TagFilterField(model) tag = TagFilterField(model)
@@ -326,10 +333,11 @@ class VirtualCircuitTypeFilterForm(OrganizationalModelFilterSetForm):
class VirtualCircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilterSetForm): class VirtualCircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilterSetForm):
model = VirtualCircuit model = VirtualCircuit
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('provider_id', 'provider_account_id', 'provider_network_id', name=_('Provider')), FieldSet('provider_id', 'provider_account_id', 'provider_network_id', name=_('Provider')),
FieldSet('type_id', 'status', name=_('Attributes')), FieldSet('type_id', 'status', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
selector_fields = ('filter_id', 'q', 'provider_id', 'provider_network_id') selector_fields = ('filter_id', 'q', 'provider_id', 'provider_network_id')
provider_id = DynamicModelMultipleChoiceField( provider_id = DynamicModelMultipleChoiceField(
+2 -1
View File
@@ -31,7 +31,8 @@ class JobSerializer(BaseModelSerializer):
model = Job model = Job
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'object_type', 'object_id', 'object', 'name', 'status', 'created', 'id', 'url', 'display_url', 'display', 'object_type', 'object_id', 'object', 'name', 'status', 'created',
'scheduled', 'interval', 'started', 'completed', 'user', 'data', 'error', 'job_id', 'log_entries', 'scheduled', 'interval', 'started', 'completed', 'user', 'data', 'error', 'job_id', 'queue_name',
'log_entries',
] ]
brief_fields = ('url', 'created', 'completed', 'user', 'status') brief_fields = ('url', 'created', 'completed', 'user', 'status')
+5 -1
View File
@@ -129,10 +129,14 @@ class JobFilterSet(BaseFilterSet):
choices=JobStatusChoices, choices=JobStatusChoices,
null_value=None null_value=None
) )
queue_name = django_filters.CharFilter()
class Meta: class Meta:
model = Job model = Job
fields = ('id', 'object_type', 'object_type_id', 'object_id', 'name', 'interval', 'status', 'user', 'job_id') fields = (
'id', 'object_type', 'object_type_id', 'object_id', 'name', 'interval', 'status', 'user', 'job_id',
'queue_name',
)
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():
+7 -2
View File
@@ -26,8 +26,9 @@ __all__ = (
class DataSourceFilterForm(PrimaryModelFilterSetForm): class DataSourceFilterForm(PrimaryModelFilterSetForm):
model = DataSource model = DataSource
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('type', 'status', 'enabled', 'sync_interval', name=_('Data Source')), FieldSet('type', 'status', 'enabled', 'sync_interval', name=_('Data Source')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
type = forms.MultipleChoiceField( type = forms.MultipleChoiceField(
label=_('Type'), label=_('Type'),
@@ -71,7 +72,7 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
model = Job model = Job
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id'), FieldSet('q', 'filter_id'),
FieldSet('object_type_id', 'status', name=_('Attributes')), FieldSet('object_type_id', 'status', 'queue_name', name=_('Attributes')),
FieldSet( FieldSet(
'created__before', 'created__after', 'scheduled__before', 'scheduled__after', 'started__before', 'created__before', 'created__after', 'scheduled__before', 'scheduled__after', 'started__before',
'started__after', 'completed__before', 'completed__after', 'user', name=_('Creation') 'started__after', 'completed__before', 'completed__after', 'user', name=_('Creation')
@@ -87,6 +88,10 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
choices=JobStatusChoices, choices=JobStatusChoices,
required=False required=False
) )
queue_name = forms.CharField(
label=_('Queue'),
required=False
)
created__after = forms.DateTimeField( created__after = forms.DateTimeField(
label=_('Created after'), label=_('Created after'),
required=False, required=False,
@@ -0,0 +1,18 @@
# Generated by Django 5.2.9 on 2026-01-27 00:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0020_owner'),
]
operations = [
migrations.AddField(
model_name='job',
name='queue_name',
field=models.CharField(blank=True, max_length=100),
),
]
+14 -3
View File
@@ -112,6 +112,12 @@ class Job(models.Model):
verbose_name=_('job ID'), verbose_name=_('job ID'),
unique=True unique=True
) )
queue_name = models.CharField(
verbose_name=_('queue name'),
max_length=100,
blank=True,
help_text=_('Name of the queue in which this job was enqueued')
)
log_entries = ArrayField( log_entries = ArrayField(
verbose_name=_('log entries'), verbose_name=_('log entries'),
base_field=models.JSONField( base_field=models.JSONField(
@@ -179,11 +185,15 @@ class Job(models.Model):
return f"{int(minutes)} minutes, {seconds:.2f} seconds" return f"{int(minutes)} minutes, {seconds:.2f} seconds"
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
# Use the stored queue name, or fall back to get_queue_for_model for legacy jobs
rq_queue_name = self.queue_name or get_queue_for_model(self.object_type.model if self.object_type else None)
rq_job_id = str(self.job_id)
super().delete(*args, **kwargs) super().delete(*args, **kwargs)
rq_queue_name = get_queue_for_model(self.object_type.model if self.object_type else None) # Cancel the RQ job using the stored queue name
queue = django_rq.get_queue(rq_queue_name) queue = django_rq.get_queue(rq_queue_name)
job = queue.fetch_job(str(self.job_id)) job = queue.fetch_job(rq_job_id)
if job: if job:
try: try:
@@ -288,7 +298,8 @@ class Job(models.Model):
scheduled=schedule_at, scheduled=schedule_at,
interval=interval, interval=interval,
user=user, user=user,
job_id=uuid.uuid4() job_id=uuid.uuid4(),
queue_name=rq_queue_name
) )
job.full_clean() job.full_clean()
job.save() job.save()
+11
View File
@@ -9,6 +9,7 @@ from django.db import connection, models
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 _
from netbox.context import query_cache
from netbox.plugins import PluginConfig from netbox.plugins import PluginConfig
from netbox.registry import registry from netbox.registry import registry
from utilities.string import title from utilities.string import title
@@ -70,6 +71,12 @@ class ObjectTypeManager(models.Manager):
""" """
from netbox.models.features import get_model_features, model_is_public from netbox.models.features import get_model_features, model_is_public
# Check the request cache before hitting the database
cache = query_cache.get()
if cache is not None:
if ot := cache['object_types'].get((model._meta.model, for_concrete_model)):
return ot
# TODO: Remove this in NetBox v5.0 # TODO: Remove this in NetBox v5.0
# If the ObjectType table has not yet been provisioned (e.g. because we're in a pre-v4.4 migration), # If the ObjectType table has not yet been provisioned (e.g. because we're in a pre-v4.4 migration),
# fall back to ContentType. # fall back to ContentType.
@@ -96,6 +103,10 @@ class ObjectTypeManager(models.Manager):
features=get_model_features(model), features=get_model_features(model),
)[0] )[0]
# Populate the request cache to avoid redundant lookups
if cache is not None:
cache['object_types'][(model._meta.model, for_concrete_model)] = ot
return ot return ot
def get_for_models(self, *models, for_concrete_models=True): def get_for_models(self, *models, for_concrete_models=True):
+2 -1
View File
@@ -18,6 +18,7 @@ from extras.events import enqueue_event
from extras.models import Tag from extras.models import Tag
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 utilities.data import get_config_value_ci
from netbox.context import current_request, events_queue from netbox.context import current_request, events_queue
from netbox.models.features import ChangeLoggingMixin, get_model_features, model_is_public from netbox.models.features import ChangeLoggingMixin, get_model_features, model_is_public
from utilities.exceptions import AbortRequest from utilities.exceptions import AbortRequest
@@ -168,7 +169,7 @@ def handle_deleted_object(sender, instance, **kwargs):
# to queueing any events for the object being deleted, in case a validation error is # to queueing any events for the object being deleted, in case a validation error is
# raised, causing the deletion to fail. # raised, causing the deletion to fail.
model_name = f'{sender._meta.app_label}.{sender._meta.model_name}' model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
validators = get_config().PROTECTION_RULES.get(model_name, []) validators = get_config_value_ci(get_config().PROTECTION_RULES, model_name, default=[])
try: try:
run_validators(instance, validators) run_validators(instance, validators)
except ValidationError as e: except ValidationError as e:
+4 -1
View File
@@ -42,6 +42,9 @@ class JobTable(NetBoxTable):
completed = columns.DateTimeColumn( completed = columns.DateTimeColumn(
verbose_name=_('Completed'), verbose_name=_('Completed'),
) )
queue_name = tables.Column(
verbose_name=_('Queue'),
)
log_entries = tables.Column( log_entries = tables.Column(
verbose_name=_('Log Entries'), verbose_name=_('Log Entries'),
) )
@@ -53,7 +56,7 @@ class JobTable(NetBoxTable):
model = Job model = Job
fields = ( fields = (
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'scheduled', 'interval', 'started', 'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'scheduled', 'interval', 'started',
'completed', 'user', 'error', 'job_id', 'completed', 'user', 'queue_name', 'log_entries', 'error', 'job_id',
) )
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',
+38 -1
View File
@@ -1,8 +1,10 @@
from unittest.mock import patch, MagicMock
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.test import TestCase from django.test import TestCase
from core.models import DataSource, ObjectType from core.models import DataSource, Job, ObjectType
from core.choices import ObjectChangeActionChoices from core.choices import ObjectChangeActionChoices
from dcim.models import Site, Location, Device 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
@@ -200,3 +202,38 @@ class ObjectTypeTest(TestCase):
bookmarks_ots = ObjectType.objects.with_feature('bookmarks') bookmarks_ots = ObjectType.objects.with_feature('bookmarks')
self.assertIn(ObjectType.objects.get_by_natural_key('dcim', 'site'), bookmarks_ots) self.assertIn(ObjectType.objects.get_by_natural_key('dcim', 'site'), bookmarks_ots)
self.assertNotIn(ObjectType.objects.get_by_natural_key('dcim', 'cabletermination'), bookmarks_ots) self.assertNotIn(ObjectType.objects.get_by_natural_key('dcim', 'cabletermination'), bookmarks_ots)
class JobTest(TestCase):
@patch('core.models.jobs.django_rq.get_queue')
def test_delete_cancels_job_from_correct_queue(self, mock_get_queue):
"""
Test that when a job is deleted, it's canceled from the correct queue.
"""
mock_queue = MagicMock()
mock_rq_job = MagicMock()
mock_queue.fetch_job.return_value = mock_rq_job
mock_get_queue.return_value = mock_queue
def dummy_func(**kwargs):
pass
# Enqueue a job with a custom queue name
custom_queue = 'my_custom_queue'
job = Job.enqueue(
func=dummy_func,
name='Test Job',
queue_name=custom_queue
)
# Reset mock to clear enqueue call
mock_get_queue.reset_mock()
# Delete the job
job.delete()
# Verify the correct queue was used for cancellation
mock_get_queue.assert_called_with(custom_queue)
mock_queue.fetch_job.assert_called_with(str(job.job_id))
mock_rq_job.cancel.assert_called_once()
+79 -49
View File
@@ -12,11 +12,12 @@ from netbox.forms import (
NestedGroupModelFilterSetForm, NetBoxModelFilterSetForm, OrganizationalModelFilterSetForm, NestedGroupModelFilterSetForm, NetBoxModelFilterSetForm, OrganizationalModelFilterSetForm,
PrimaryModelFilterSetForm, PrimaryModelFilterSetForm,
) )
from netbox.forms.mixins import OwnerFilterMixin
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
from tenancy.models import Tenant from tenancy.models import Tenant
from users.models import Owner, User from users.models import User
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
from utilities.forms.fields import ColorField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField
from utilities.forms.rendering import FieldSet from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import NumberWithOptions from utilities.forms.widgets import NumberWithOptions
from virtualization.models import Cluster, ClusterGroup, VirtualMachine from virtualization.models import Cluster, ClusterGroup, VirtualMachine
@@ -70,11 +71,11 @@ __all__ = (
'SiteFilterForm', 'SiteFilterForm',
'SiteGroupFilterForm', 'SiteGroupFilterForm',
'VirtualChassisFilterForm', 'VirtualChassisFilterForm',
'VirtualDeviceContextFilterForm' 'VirtualDeviceContextFilterForm',
) )
class DeviceComponentFilterForm(NetBoxModelFilterSetForm): class DeviceComponentFilterForm(OwnerFilterMixin, NetBoxModelFilterSetForm):
name = forms.CharField( name = forms.CharField(
label=_('Name'), label=_('Name'),
required=False required=False
@@ -157,18 +158,14 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
required=False, required=False,
label=_('Device Status'), label=_('Device Status'),
) )
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
class RegionFilterForm(ContactModelFilterForm, NestedGroupModelFilterSetForm): class RegionFilterForm(ContactModelFilterForm, NestedGroupModelFilterSetForm):
model = Region model = Region
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('parent_id', name=_('Region')), FieldSet('parent_id', name=_('Region')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')) FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts'))
) )
parent_id = DynamicModelMultipleChoiceField( parent_id = DynamicModelMultipleChoiceField(
@@ -182,8 +179,9 @@ class RegionFilterForm(ContactModelFilterForm, NestedGroupModelFilterSetForm):
class SiteGroupFilterForm(ContactModelFilterForm, NestedGroupModelFilterSetForm): class SiteGroupFilterForm(ContactModelFilterForm, NestedGroupModelFilterSetForm):
model = SiteGroup model = SiteGroup
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('parent_id', name=_('Site Group')), FieldSet('parent_id', name=_('Site Group')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')) FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts'))
) )
parent_id = DynamicModelMultipleChoiceField( parent_id = DynamicModelMultipleChoiceField(
@@ -197,9 +195,10 @@ class SiteGroupFilterForm(ContactModelFilterForm, NestedGroupModelFilterSetForm)
class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilterSetForm): class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilterSetForm):
model = Site model = Site
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('status', 'region_id', 'group_id', 'asn_id', name=_('Attributes')), FieldSet('status', 'region_id', 'group_id', 'asn_id', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
) )
selector_fields = ('filter_id', 'q', 'region_id', 'group_id') selector_fields = ('filter_id', 'q', 'region_id', 'group_id')
@@ -229,9 +228,10 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilt
class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NestedGroupModelFilterSetForm): class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NestedGroupModelFilterSetForm):
model = Location model = Location
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('region_id', 'site_group_id', 'site_id', 'parent_id', 'status', 'facility', name=_('Attributes')), FieldSet('region_id', 'site_group_id', 'site_id', 'parent_id', 'status', 'facility', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
) )
region_id = DynamicModelMultipleChoiceField( region_id = DynamicModelMultipleChoiceField(
@@ -277,7 +277,8 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NestedGroupM
class RackRoleFilterForm(OrganizationalModelFilterSetForm): class RackRoleFilterForm(OrganizationalModelFilterSetForm):
model = RackRole model = RackRole
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
tag = TagFilterField(model) tag = TagFilterField(model)
@@ -328,10 +329,11 @@ class RackBaseFilterForm(PrimaryModelFilterSetForm):
class RackTypeFilterForm(RackBaseFilterForm): class RackTypeFilterForm(RackBaseFilterForm):
model = RackType model = RackType
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('manufacturer_id', 'form_factor', 'width', 'u_height', 'rack_count', name=_('Rack Type')), FieldSet('manufacturer_id', 'form_factor', 'width', 'u_height', 'rack_count', name=_('Rack Type')),
FieldSet('starting_unit', 'desc_units', name=_('Numbering')), FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')), FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
selector_fields = ('filter_id', 'q', 'manufacturer_id') selector_fields = ('filter_id', 'q', 'manufacturer_id')
manufacturer_id = DynamicModelMultipleChoiceField( manufacturer_id = DynamicModelMultipleChoiceField(
@@ -350,13 +352,14 @@ class RackTypeFilterForm(RackBaseFilterForm):
class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, RackBaseFilterForm): class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, RackBaseFilterForm):
model = Rack model = Rack
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('status', 'role_id', 'manufacturer_id', 'rack_type_id', 'serial', 'asset_tag', name=_('Rack')), FieldSet('status', 'role_id', 'manufacturer_id', 'rack_type_id', 'serial', 'asset_tag', name=_('Rack')),
FieldSet('form_factor', 'width', 'u_height', 'airflow', name=_('Hardware')), FieldSet('form_factor', 'width', 'u_height', 'airflow', name=_('Hardware')),
FieldSet('starting_unit', 'desc_units', name=_('Numbering')), FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')), FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
) )
selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'location_id') selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'location_id')
@@ -433,9 +436,10 @@ class RackElevationFilterForm(RackFilterForm):
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'id', name=_('Location')),
FieldSet('status', 'role_id', name=_('Function')), FieldSet('status', 'role_id', name=_('Function')),
FieldSet('type', 'width', 'serial', 'asset_tag', name=_('Hardware')), FieldSet('type', 'width', 'serial', 'asset_tag', name=_('Hardware')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')), FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
) )
id = DynamicModelMultipleChoiceField( id = DynamicModelMultipleChoiceField(
queryset=Rack.objects.all(), queryset=Rack.objects.all(),
@@ -451,10 +455,11 @@ class RackElevationFilterForm(RackFilterForm):
class RackReservationFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm): class RackReservationFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = RackReservation model = RackReservation
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('status', 'user_id', name=_('Reservation')), FieldSet('status', 'user_id', name=_('Reservation')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Rack')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Rack')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
region_id = DynamicModelMultipleChoiceField( region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
@@ -509,7 +514,8 @@ class RackReservationFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
class ManufacturerFilterForm(ContactModelFilterForm, OrganizationalModelFilterSetForm): class ManufacturerFilterForm(ContactModelFilterForm, OrganizationalModelFilterSetForm):
model = Manufacturer model = Manufacturer
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')) FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts'))
) )
tag = TagFilterField(model) tag = TagFilterField(model)
@@ -518,7 +524,7 @@ class ManufacturerFilterForm(ContactModelFilterForm, OrganizationalModelFilterSe
class DeviceTypeFilterForm(PrimaryModelFilterSetForm): class DeviceTypeFilterForm(PrimaryModelFilterSetForm):
model = DeviceType model = DeviceType
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet( FieldSet(
'manufacturer_id', 'default_platform_id', 'part_number', 'device_count', 'manufacturer_id', 'default_platform_id', 'part_number', 'device_count',
'subdevice_role', 'airflow', name=_('Hardware') 'subdevice_role', 'airflow', name=_('Hardware')
@@ -529,6 +535,7 @@ class DeviceTypeFilterForm(PrimaryModelFilterSetForm):
'pass_through_ports', 'device_bays', 'module_bays', 'inventory_items', name=_('Components') 'pass_through_ports', 'device_bays', 'module_bays', 'inventory_items', name=_('Components')
), ),
FieldSet('weight', 'weight_unit', name=_('Weight')), FieldSet('weight', 'weight_unit', name=_('Weight')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
selector_fields = ('filter_id', 'q', 'manufacturer_id') selector_fields = ('filter_id', 'q', 'manufacturer_id')
manufacturer_id = DynamicModelMultipleChoiceField( manufacturer_id = DynamicModelMultipleChoiceField(
@@ -652,7 +659,8 @@ class DeviceTypeFilterForm(PrimaryModelFilterSetForm):
class ModuleTypeProfileFilterForm(PrimaryModelFilterSetForm): class ModuleTypeProfileFilterForm(PrimaryModelFilterSetForm):
model = ModuleTypeProfile model = ModuleTypeProfile
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
selector_fields = ('filter_id', 'q') selector_fields = ('filter_id', 'q')
tag = TagFilterField(model) tag = TagFilterField(model)
@@ -661,7 +669,7 @@ class ModuleTypeProfileFilterForm(PrimaryModelFilterSetForm):
class ModuleTypeFilterForm(PrimaryModelFilterSetForm): class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
model = ModuleType model = ModuleType
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet( FieldSet(
'profile_id', 'manufacturer_id', 'part_number', 'module_count', 'profile_id', 'manufacturer_id', 'part_number', 'module_count',
'airflow', name=_('Hardware') 'airflow', name=_('Hardware')
@@ -671,6 +679,7 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
'pass_through_ports', name=_('Components') 'pass_through_ports', name=_('Components')
), ),
FieldSet('weight', 'weight_unit', name=_('Weight')), FieldSet('weight', 'weight_unit', name=_('Weight')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
selector_fields = ('filter_id', 'q', 'manufacturer_id') selector_fields = ('filter_id', 'q', 'manufacturer_id')
profile_id = DynamicModelMultipleChoiceField( profile_id = DynamicModelMultipleChoiceField(
@@ -754,8 +763,9 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
class DeviceRoleFilterForm(NestedGroupModelFilterSetForm): class DeviceRoleFilterForm(NestedGroupModelFilterSetForm):
model = DeviceRole model = DeviceRole
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('parent_id', 'config_template_id', name=_('Device Role')) FieldSet('parent_id', 'config_template_id', name=_('Device Role')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
config_template_id = DynamicModelMultipleChoiceField( config_template_id = DynamicModelMultipleChoiceField(
queryset=ConfigTemplate.objects.all(), queryset=ConfigTemplate.objects.all(),
@@ -773,8 +783,9 @@ class DeviceRoleFilterForm(NestedGroupModelFilterSetForm):
class PlatformFilterForm(NestedGroupModelFilterSetForm): class PlatformFilterForm(NestedGroupModelFilterSetForm):
model = Platform model = Platform
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('manufacturer_id', 'parent_id', 'config_template_id', name=_('Platform')) FieldSet('manufacturer_id', 'parent_id', 'config_template_id', name=_('Platform')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
selector_fields = ('filter_id', 'q', 'manufacturer_id') selector_fields = ('filter_id', 'q', 'manufacturer_id')
parent_id = DynamicModelMultipleChoiceField( parent_id = DynamicModelMultipleChoiceField(
@@ -803,11 +814,12 @@ class DeviceFilterForm(
): ):
model = Device model = Device
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address', name=_('Operation')), FieldSet('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address', name=_('Operation')),
FieldSet('manufacturer_id', 'device_type_id', 'platform_id', name=_('Hardware')), FieldSet('manufacturer_id', 'device_type_id', 'platform_id', name=_('Hardware')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
FieldSet( FieldSet(
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports', 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports',
@@ -996,9 +1008,10 @@ class DeviceFilterForm(
class VirtualDeviceContextFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm): class VirtualDeviceContextFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = VirtualDeviceContext model = VirtualDeviceContext
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('device', 'status', 'has_primary_ip', name=_('Attributes')), FieldSet('device', 'status', 'has_primary_ip', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
device = DynamicModelMultipleChoiceField( device = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(), queryset=Device.objects.all(),
@@ -1023,9 +1036,10 @@ class VirtualDeviceContextFilterForm(TenancyFilterForm, PrimaryModelFilterSetFor
class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm): class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm):
model = Module model = Module
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'device_id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'device_id', name=_('Location')),
FieldSet('manufacturer_id', 'module_type_id', 'status', 'serial', 'asset_tag', name=_('Hardware')), FieldSet('manufacturer_id', 'module_type_id', 'status', 'serial', 'asset_tag', name=_('Hardware')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
device_id = DynamicModelMultipleChoiceField( device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(), queryset=Device.objects.all(),
@@ -1106,9 +1120,10 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, PrimaryM
class VirtualChassisFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm): class VirtualChassisFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = VirtualChassis model = VirtualChassis
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
region_id = DynamicModelMultipleChoiceField( region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
@@ -1135,10 +1150,11 @@ class VirtualChassisFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
class CableFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm): class CableFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = Cable model = Cable
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('site_id', 'location_id', 'rack_id', 'device_id', name=_('Location')), FieldSet('site_id', 'location_id', 'rack_id', 'device_id', name=_('Location')),
FieldSet('type', 'status', 'profile', 'color', 'length', 'length_unit', 'unterminated', name=_('Attributes')), FieldSet('type', 'status', 'profile', 'color', 'length', 'length_unit', 'unterminated', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
region_id = DynamicModelMultipleChoiceField( region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
@@ -1224,8 +1240,9 @@ class CableFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
class PowerPanelFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm): class PowerPanelFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
model = PowerPanel model = PowerPanel
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
) )
selector_fields = ('filter_id', 'q', 'site_id', 'location_id') selector_fields = ('filter_id', 'q', 'site_id', 'location_id')
@@ -1263,10 +1280,11 @@ class PowerPanelFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
class PowerFeedFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm): class PowerFeedFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = PowerFeed model = PowerFeed
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('region_id', 'site_group_id', 'site_id', 'power_panel_id', 'rack_id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', 'power_panel_id', 'rack_id', name=_('Location')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', name=_('Attributes')), FieldSet('status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
region_id = DynamicModelMultipleChoiceField( region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
@@ -1390,7 +1408,7 @@ class PathEndpointFilterForm(CabledFilterForm):
class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = ConsolePort model = ConsolePort
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')), FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet( FieldSet(
@@ -1398,6 +1416,7 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
name=_('Device') name=_('Device')
), ),
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')), FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
type = forms.MultipleChoiceField( type = forms.MultipleChoiceField(
label=_('Type'), label=_('Type'),
@@ -1429,7 +1448,7 @@ class ConsolePortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = ConsoleServerPort model = ConsoleServerPort
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')), FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet( FieldSet(
@@ -1437,6 +1456,7 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF
name=_('Device') name=_('Device')
), ),
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')), FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
type = forms.MultipleChoiceField( type = forms.MultipleChoiceField(
label=_('Type'), label=_('Type'),
@@ -1468,7 +1488,7 @@ class ConsoleServerPortTemplateFilterForm(ModularDeviceComponentTemplateFilterFo
class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = PowerPort model = PowerPort
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'type', name=_('Attributes')), FieldSet('name', 'label', 'type', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet( FieldSet(
@@ -1476,6 +1496,7 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
name=_('Device') name=_('Device')
), ),
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')), FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
type = forms.MultipleChoiceField( type = forms.MultipleChoiceField(
label=_('Type'), label=_('Type'),
@@ -1502,7 +1523,7 @@ class PowerPortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = PowerOutlet model = PowerOutlet
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'type', 'color', 'status', name=_('Attributes')), FieldSet('name', 'label', 'type', 'color', 'status', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet( FieldSet(
@@ -1510,6 +1531,7 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
name=_('Device') name=_('Device')
), ),
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')), FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
type = forms.MultipleChoiceField( type = forms.MultipleChoiceField(
label=_('Type'), label=_('Type'),
@@ -1545,7 +1567,7 @@ class PowerOutletTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = Interface model = Interface
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only', name=_('Attributes')), FieldSet('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only', name=_('Attributes')),
FieldSet('vrf_id', 'l2vpn_id', 'mac_address', 'wwn', name=_('Addressing')), FieldSet('vrf_id', 'l2vpn_id', 'mac_address', 'wwn', name=_('Addressing')),
FieldSet('poe_mode', 'poe_type', name=_('PoE')), FieldSet('poe_mode', 'poe_type', name=_('PoE')),
@@ -1558,6 +1580,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
name=_('Device') name=_('Device')
), ),
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')), FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
selector_fields = ('filter_id', 'q', 'device_id') selector_fields = ('filter_id', 'q', 'device_id')
vdc_id = DynamicModelMultipleChoiceField( vdc_id = DynamicModelMultipleChoiceField(
@@ -1716,7 +1739,7 @@ class InterfaceTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'type', 'color', name=_('Attributes')), FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet( FieldSet(
@@ -1724,6 +1747,7 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
name=_('Device') name=_('Device')
), ),
FieldSet('cabled', 'occupied', name=_('Cable')), FieldSet('cabled', 'occupied', name=_('Cable')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
model = FrontPort model = FrontPort
type = forms.MultipleChoiceField( type = forms.MultipleChoiceField(
@@ -1759,7 +1783,7 @@ class FrontPortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
model = RearPort model = RearPort
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'type', 'color', name=_('Attributes')), FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet( FieldSet(
@@ -1767,6 +1791,7 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
name=_('Device') name=_('Device')
), ),
FieldSet('cabled', 'occupied', name=_('Cable')), FieldSet('cabled', 'occupied', name=_('Cable')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
type = forms.MultipleChoiceField( type = forms.MultipleChoiceField(
label=_('Type'), label=_('Type'),
@@ -1801,13 +1826,14 @@ class RearPortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
class ModuleBayFilterForm(DeviceComponentFilterForm): class ModuleBayFilterForm(DeviceComponentFilterForm):
model = ModuleBay model = ModuleBay
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'position', name=_('Attributes')), FieldSet('name', 'label', 'position', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet( FieldSet(
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', 'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
name=_('Device') name=_('Device')
), ),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
tag = TagFilterField(model) tag = TagFilterField(model)
position = forms.CharField( position = forms.CharField(
@@ -1832,13 +1858,14 @@ class ModuleBayTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
class DeviceBayFilterForm(DeviceComponentFilterForm): class DeviceBayFilterForm(DeviceComponentFilterForm):
model = DeviceBay model = DeviceBay
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', name=_('Attributes')), FieldSet('name', 'label', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet( FieldSet(
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', 'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
name=_('Device') name=_('Device')
), ),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
tag = TagFilterField(model) tag = TagFilterField(model)
@@ -1855,7 +1882,7 @@ class DeviceBayTemplateFilterForm(DeviceComponentTemplateFilterForm):
class InventoryItemFilterForm(DeviceComponentFilterForm): class InventoryItemFilterForm(DeviceComponentFilterForm):
model = InventoryItem model = InventoryItem
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet( FieldSet(
'name', 'label', 'status', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered', 'name', 'label', 'status', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered',
name=_('Attributes') name=_('Attributes')
@@ -1865,6 +1892,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', 'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
name=_('Device') name=_('Device')
), ),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
role_id = DynamicModelMultipleChoiceField( role_id = DynamicModelMultipleChoiceField(
queryset=InventoryItemRole.objects.all(), queryset=InventoryItemRole.objects.all(),
@@ -1925,7 +1953,8 @@ class InventoryItemTemplateFilterForm(DeviceComponentTemplateFilterForm):
class InventoryItemRoleFilterForm(OrganizationalModelFilterSetForm): class InventoryItemRoleFilterForm(OrganizationalModelFilterSetForm):
model = InventoryItemRole model = InventoryItemRole
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
tag = TagFilterField(model) tag = TagFilterField(model)
@@ -1937,9 +1966,10 @@ class InventoryItemRoleFilterForm(OrganizationalModelFilterSetForm):
class MACAddressFilterForm(PrimaryModelFilterSetForm): class MACAddressFilterForm(PrimaryModelFilterSetForm):
model = MACAddress model = MACAddress
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('mac_address', name=_('Attributes')), FieldSet('mac_address', name=_('Attributes')),
FieldSet('device_id', 'virtual_machine_id', 'assigned', 'primary', name=_('Assignments')), FieldSet('device_id', 'virtual_machine_id', 'assigned', 'primary', name=_('Assignments')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
selector_fields = ('filter_id', 'q', 'device_id', 'virtual_machine_id') selector_fields = ('filter_id', 'q', 'device_id', 'virtual_machine_id')
mac_address = forms.CharField( mac_address = forms.CharField(
+1 -1
View File
@@ -75,7 +75,7 @@ class ScopedForm(forms.Form):
except ObjectDoesNotExist: except ObjectDoesNotExist:
pass pass
if self.instance and scope_type_id != self.instance.scope_type_id: if self.instance and self.instance.pk and scope_type_id != self.instance.scope_type_id:
self.initial['scope'] = None self.initial['scope'] = None
else: else:
+2 -1
View File
@@ -3,6 +3,7 @@ from django.utils.translation import gettext_lazy as _
from dcim.models import * from dcim.models import *
from netbox.forms import NetBoxModelForm from netbox.forms import NetBoxModelForm
from netbox.forms.mixins import OwnerMixin
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField
from utilities.forms.rendering import FieldSet, TabbedGroups from utilities.forms.rendering import FieldSet, TabbedGroups
from utilities.forms.widgets import APISelect from utilities.forms.widgets import APISelect
@@ -271,7 +272,7 @@ class InventoryItemCreateForm(ComponentCreateForm, model_forms.InventoryItemForm
# Virtual chassis # Virtual chassis
# #
class VirtualChassisCreateForm(NetBoxModelForm): class VirtualChassisCreateForm(OwnerMixin, NetBoxModelForm):
region = DynamicModelChoiceField( region = DynamicModelChoiceField(
label=_('Region'), label=_('Region'),
queryset=Region.objects.all(), queryset=Region.objects.all(),
+5 -1
View File
@@ -550,6 +550,10 @@ class InterfaceFilter(
strawberry_django.filter_field() strawberry_django.filter_field()
) )
@strawberry_django.filter_field
def cabled(self, value: bool, prefix: str):
return Q(**{f'{prefix}cable__isnull': (not value)})
@strawberry_django.filter_field @strawberry_django.filter_field
def connected(self, queryset, value: bool, prefix: str): def connected(self, queryset, value: bool, prefix: str):
if value is True: if value is True:
@@ -889,7 +893,7 @@ class PowerPortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedM
@strawberry_django.filter_type(models.RackType, lookups=True) @strawberry_django.filter_type(models.RackType, lookups=True)
class RackTypeFilter(RackFilterMixin, WeightFilterMixin, PrimaryModelFilter): class RackTypeFilter(ImageAttachmentFilterMixin, RackFilterMixin, WeightFilterMixin, PrimaryModelFilter):
form_factor: BaseFilterLookup[Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( form_factor: BaseFilterLookup[Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
) )
+1 -1
View File
@@ -734,7 +734,7 @@ class PowerPortTemplateType(ModularComponentTemplateType):
filters=RackTypeFilter, filters=RackTypeFilter,
pagination=True pagination=True
) )
class RackTypeType(PrimaryObjectType): class RackTypeType(ImageAttachmentsMixin, PrimaryObjectType):
rack_count: BigInt rack_count: BigInt
manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
+5 -1
View File
@@ -7,7 +7,7 @@ from django.utils.translation import gettext_lazy as _
from jsonschema.exceptions import ValidationError as JSONValidationError from jsonschema.exceptions import ValidationError as JSONValidationError
from dcim.choices import * from dcim.choices import *
from dcim.utils import update_interface_bridges from dcim.utils import create_port_mappings, update_interface_bridges
from extras.models import ConfigContextModel, CustomField from extras.models import ConfigContextModel, CustomField
from netbox.models import PrimaryModel from netbox.models import PrimaryModel
from netbox.models.features import ImageAttachmentsMixin from netbox.models.features import ImageAttachmentsMixin
@@ -155,6 +155,8 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
'description': self.description, 'description': self.description,
'weight': float(self.weight) if self.weight is not None else None, 'weight': float(self.weight) if self.weight is not None else None,
'weight_unit': self.weight_unit, 'weight_unit': self.weight_unit,
'airflow': self.airflow,
'attribute_data': self.attribute_data,
'comments': self.comments, 'comments': self.comments,
} }
@@ -359,5 +361,7 @@ class Module(TrackingModelMixin, PrimaryModel, ConfigContextModel):
update_fields=update_fields update_fields=update_fields
) )
# Replicate any front/rear port mappings from the ModuleType
create_port_mappings(self.device, self.module_type, self)
# Interface bridges have to be set after interface instantiation # Interface bridges have to be set after interface instantiation
update_interface_bridges(self.device, self.module_type.interfacetemplates, self) update_interface_bridges(self.device, self.module_type.interfacetemplates, self)
+1 -1
View File
@@ -122,7 +122,7 @@ class RackBase(WeightMixin, PrimaryModel):
abstract = True abstract = True
class RackType(RackBase): class RackType(ImageAttachmentsMixin, RackBase):
""" """
Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face. Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
Each Rack is assigned to a Site and (optionally) a Location. Each Rack is assigned to a Site and (optionally) a Location.
+28
View File
@@ -27,6 +27,7 @@ __all__ = (
'DeviceTable', 'DeviceTable',
'FrontPortTable', 'FrontPortTable',
'InterfaceTable', 'InterfaceTable',
'InterfaceLAGMemberTable',
'InventoryItemRoleTable', 'InventoryItemRoleTable',
'InventoryItemTable', 'InventoryItemTable',
'MACAddressTable', 'MACAddressTable',
@@ -689,6 +690,33 @@ class InterfaceTable(BaseInterfaceTable, ModularDeviceComponentTable, PathEndpoi
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description') default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
class InterfaceLAGMemberTable(PathEndpointTable, NetBoxTable):
parent = tables.Column(
verbose_name=_('Parent'),
accessor=Accessor('device'),
linkify=True,
)
name = tables.Column(
verbose_name=_('Name'),
linkify=True,
order_by=('_name',),
)
connection = columns.TemplateColumn(
accessor='connected_endpoints',
template_code=INTERFACE_LAG_MEMBERS_LINKTERMINATION,
verbose_name=_('Peer'),
orderable=False,
)
tags = columns.TagColumn(
url_name='dcim:interface_list'
)
class Meta(NetBoxTable.Meta):
model = models.Interface
fields = ('pk', 'parent', 'name', 'type', 'connection')
default_columns = ('pk', 'parent', 'name', 'type', 'connection')
class DeviceInterfaceTable(InterfaceTable): class DeviceInterfaceTable(InterfaceTable):
name = tables.TemplateColumn( name = tables.TemplateColumn(
verbose_name=_('Name'), verbose_name=_('Name'),
+18
View File
@@ -24,6 +24,24 @@ INTERFACE_LINKTERMINATION = """
{% else %}""" + LINKTERMINATION + """{% endif %} {% else %}""" + LINKTERMINATION + """{% endif %}
""" """
INTERFACE_LAG_MEMBERS_LINKTERMINATION = """
{% for termination in value %}
{% if termination.parent_object %}
<a href="{{ termination.parent_object.get_absolute_url }}">{{ termination.parent_object }}</a>
<i class="mdi mdi-chevron-right"></i>
{% endif %}
<a href="{{ termination.get_absolute_url }}">{{ termination }}</a>
{% if termination.lag %}
<i class="mdi mdi-chevron-right"></i>
<a href="{{ termination.lag.get_absolute_url }}">{{ termination.lag }}</a>
<span class="text-muted">(LAG)</span>
{% endif %}
{% if not forloop.last %}<br />{% endif %}
{% empty %}
{{ ''|placeholder }}
{% endfor %}
"""
CABLE_LENGTH = """ CABLE_LENGTH = """
{% load helpers %} {% load helpers %}
{% if record.length %}{{ record.length|floatformat:"-2" }} {{ record.length_unit }}{% endif %} {% if record.length %}{{ record.length|floatformat:"-2" }} {{ record.length_unit }}{% endif %}
+136
View File
@@ -875,6 +875,142 @@ class ModuleBayTestCase(TestCase):
self.assertIsNone(bay2.parent) self.assertIsNone(bay2.parent)
self.assertIsNone(bay2.module) self.assertIsNone(bay2.module)
def test_module_installation_creates_port_mappings(self):
"""
Test that installing a module with front/rear port templates correctly
creates PortMapping instances for the device.
"""
device = Device.objects.first()
manufacturer = Manufacturer.objects.first()
module_bay = ModuleBay.objects.create(device=device, name='Test Bay PortMapping 1')
# Create a module type with a rear port template
module_type_with_mappings = ModuleType.objects.create(
manufacturer=manufacturer,
model='Module Type With Mappings',
)
# Create a rear port template with 12 positions (splice)
rear_port_template = RearPortTemplate.objects.create(
module_type=module_type_with_mappings,
name='Rear Port 1',
type=PortTypeChoices.TYPE_SPLICE,
positions=12,
)
# Create 12 front port templates mapped to the rear port
front_port_templates = []
for i in range(1, 13):
front_port_template = FrontPortTemplate.objects.create(
module_type=module_type_with_mappings,
name=f'port {i}',
type=PortTypeChoices.TYPE_LC,
positions=1,
)
front_port_templates.append(front_port_template)
# Create port template mapping
PortTemplateMapping.objects.create(
device_type=None,
module_type=module_type_with_mappings,
front_port=front_port_template,
front_port_position=1,
rear_port=rear_port_template,
rear_port_position=i,
)
# Install the module
module = Module.objects.create(
device=device,
module_bay=module_bay,
module_type=module_type_with_mappings,
status=ModuleStatusChoices.STATUS_ACTIVE,
)
# Verify that front ports were created
front_ports = FrontPort.objects.filter(device=device, module=module)
self.assertEqual(front_ports.count(), 12)
# Verify that the rear port was created
rear_ports = RearPort.objects.filter(device=device, module=module)
self.assertEqual(rear_ports.count(), 1)
rear_port = rear_ports.first()
self.assertEqual(rear_port.positions, 12)
# Verify that port mappings were created
port_mappings = PortMapping.objects.filter(front_port__module=module)
self.assertEqual(port_mappings.count(), 12)
# Verify each mapping is correct
for i, front_port_template in enumerate(front_port_templates, start=1):
front_port = FrontPort.objects.get(
device=device,
name=front_port_template.name,
module=module,
)
# Check that a mapping exists for this front port
mapping = PortMapping.objects.get(
device=device,
front_port=front_port,
front_port_position=1,
)
self.assertEqual(mapping.rear_port, rear_port)
self.assertEqual(mapping.front_port_position, 1)
self.assertEqual(mapping.rear_port_position, i)
def test_module_installation_without_mappings(self):
"""
Test that installing a module without port template mappings
doesn't create any PortMapping instances.
"""
device = Device.objects.first()
manufacturer = Manufacturer.objects.first()
module_bay = ModuleBay.objects.create(device=device, name='Test Bay PortMapping 2')
# Create a module type without any port template mappings
module_type_no_mappings = ModuleType.objects.create(
manufacturer=manufacturer,
model='Module Type Without Mappings',
)
# Create a rear port template
RearPortTemplate.objects.create(
module_type=module_type_no_mappings,
name='Rear Port 1',
type=PortTypeChoices.TYPE_SPLICE,
positions=12,
)
# Create front port templates but DO NOT create PortTemplateMapping rows
for i in range(1, 13):
FrontPortTemplate.objects.create(
module_type=module_type_no_mappings,
name=f'port {i}',
type=PortTypeChoices.TYPE_LC,
positions=1,
)
# Install the module
module = Module.objects.create(
device=device,
module_bay=module_bay,
module_type=module_type_no_mappings,
status=ModuleStatusChoices.STATUS_ACTIVE,
)
# Verify no port mappings were created for this module
port_mappings = PortMapping.objects.filter(
device=device,
front_port__module=module,
front_port_position=1,
)
self.assertEqual(port_mappings.count(), 0)
self.assertEqual(FrontPort.objects.filter(module=module).count(), 12)
self.assertEqual(RearPort.objects.filter(module=module).count(), 1)
self.assertEqual(PortMapping.objects.filter(front_port__module=module).count(), 0)
class CableTestCase(TestCase): class CableTestCase(TestCase):
+2 -2
View File
@@ -125,7 +125,7 @@ class DeviceManagementPanel(panels.ObjectAttributesPanel):
class DeviceDimensionsPanel(panels.ObjectAttributesPanel): class DeviceDimensionsPanel(panels.ObjectAttributesPanel):
title = _('Dimensions') title = _('Dimensions')
height = attrs.TextAttr('device_type.u_height', format_string='{}U') height = attrs.TemplatedAttr('device_type.u_height', template_name='dcim/devicetype/attrs/height.html')
total_weight = attrs.TemplatedAttr('total_weight', template_name='dcim/device/attrs/total_weight.html') total_weight = attrs.TemplatedAttr('total_weight', template_name='dcim/device/attrs/total_weight.html')
@@ -135,7 +135,7 @@ class DeviceTypePanel(panels.ObjectAttributesPanel):
part_number = attrs.TextAttr('part_number') part_number = attrs.TextAttr('part_number')
default_platform = attrs.RelatedObjectAttr('default_platform', linkify=True) default_platform = attrs.RelatedObjectAttr('default_platform', linkify=True)
description = attrs.TextAttr('description') description = attrs.TextAttr('description')
height = attrs.TextAttr('u_height', format_string='{}U', label=_('Height')) height = attrs.TemplatedAttr('u_height', template_name='dcim/devicetype/attrs/height.html')
exclude_from_utilization = attrs.BooleanAttr('exclude_from_utilization') exclude_from_utilization = attrs.BooleanAttr('exclude_from_utilization')
full_depth = attrs.BooleanAttr('is_full_depth') full_depth = attrs.BooleanAttr('is_full_depth')
weight = attrs.NumericAttr('weight', unit_accessor='get_weight_unit_display') weight = attrs.NumericAttr('weight', unit_accessor='get_weight_unit_display')
+3 -3
View File
@@ -85,13 +85,13 @@ def update_interface_bridges(device, interface_templates, module=None):
interface.save() interface.save()
def create_port_mappings(device, device_type, module=None): def create_port_mappings(device, device_or_module_type, module=None):
""" """
Replicate all front/rear port mappings from a DeviceType to the given device. Replicate all front/rear port mappings from a DeviceType or ModuleType to the given device.
""" """
from dcim.models import FrontPort, PortMapping, RearPort from dcim.models import FrontPort, PortMapping, RearPort
templates = device_type.port_mappings.prefetch_related('front_port', 'rear_port') templates = device_or_module_type.port_mappings.prefetch_related('front_port', 'rear_port')
# Cache front & rear ports for efficient lookups by name # Cache front & rear ports for efficient lookups by name
front_ports = { front_ports = {
+10
View File
@@ -880,6 +880,7 @@ class RackTypeView(GetRelatedModelsMixin, generic.ObjectView):
panels.RackWeightPanel(title=_('Weight'), exclude=['total_weight']), panels.RackWeightPanel(title=_('Weight'), exclude=['total_weight']),
CustomFieldsPanel(), CustomFieldsPanel(),
RelatedObjectsPanel(), RelatedObjectsPanel(),
ImageAttachmentsPanel(),
], ],
) )
@@ -3135,6 +3136,14 @@ class InterfaceView(generic.ObjectView):
) )
child_interfaces_table.configure(request) child_interfaces_table.configure(request)
# Get LAG interfaces
lag_interfaces = Interface.objects.restrict(request.user, 'view').filter(lag=instance)
lag_interfaces_table = tables.InterfaceLAGMemberTable(
lag_interfaces,
orderable=False
)
lag_interfaces_table.configure(request)
# Get assigned VLANs and annotate whether each is tagged or untagged # Get assigned VLANs and annotate whether each is tagged or untagged
vlans = [] vlans = []
if instance.untagged_vlan is not None: if instance.untagged_vlan is not None:
@@ -3164,6 +3173,7 @@ class InterfaceView(generic.ObjectView):
'bridge_interfaces': bridge_interfaces, 'bridge_interfaces': bridge_interfaces,
'bridge_interfaces_table': bridge_interfaces_table, 'bridge_interfaces_table': bridge_interfaces_table,
'child_interfaces_table': child_interfaces_table, 'child_interfaces_table': child_interfaces_table,
'lag_interfaces_table': lag_interfaces_table,
'vlan_table': vlan_table, 'vlan_table': vlan_table,
'vlan_translation_table': vlan_translation_table, 'vlan_translation_table': vlan_translation_table,
} }
+2 -1
View File
@@ -75,10 +75,11 @@ def get_bookmarks_object_type_choices():
def get_models_from_content_types(content_types): def get_models_from_content_types(content_types):
""" """
Return a list of models corresponding to the given content types, identified by natural key. Return a list of models corresponding to the given content types, identified by natural key.
Accepts both lowercase (e.g. "dcim.site") and PascalCase (e.g. "dcim.Site") model names.
""" """
models = [] models = []
for content_type_id in content_types: for content_type_id in content_types:
app_label, model_name = content_type_id.split('.') app_label, model_name = content_type_id.lower().split('.')
try: try:
content_type = ObjectType.objects.get_by_natural_key(app_label, model_name) content_type = ObjectType.objects.get_by_natural_key(app_label, model_name)
if content_type.model_class(): if content_type.model_class():
+54 -46
View File
@@ -1,5 +1,5 @@
import logging import logging
from collections import defaultdict from collections import UserDict, defaultdict
from django.conf import settings from django.conf import settings
from django.utils import timezone from django.utils import timezone
@@ -12,7 +12,6 @@ from core.models import ObjectType
from netbox.config import get_config from netbox.config import get_config
from netbox.constants import RQ_QUEUE_DEFAULT from netbox.constants import RQ_QUEUE_DEFAULT
from netbox.models.features import has_feature from netbox.models.features import has_feature
from users.models import User
from utilities.api import get_serializer_for_model from utilities.api import get_serializer_for_model
from utilities.request import copy_safe_request from utilities.request import copy_safe_request
from utilities.rqworker import get_rq_retry from utilities.rqworker import get_rq_retry
@@ -23,6 +22,21 @@ from .models import EventRule
logger = logging.getLogger('netbox.events_processor') logger = logging.getLogger('netbox.events_processor')
class EventContext(UserDict):
"""
A custom dictionary that automatically serializes its associated object on demand.
"""
# We're emulating a dictionary here (rather than using a custom class) because prior to NetBox v4.5.2, events were
# queued as dictionaries for processing by handles in EVENTS_PIPELINE. We need to avoid introducing any breaking
# changes until a suitable minor release.
def __getitem__(self, item):
if item == 'data' and 'data' not in self:
data = serialize_for_event(self['object'])
self.__setitem__('data', data)
return super().__getitem__(item)
def serialize_for_event(instance): def serialize_for_event(instance):
""" """
Return a serialized representation of the given instance suitable for use in a queued event. Return a serialized representation of the given instance suitable for use in a queued event.
@@ -66,37 +80,42 @@ def enqueue_event(queue, instance, request, event_type):
assert instance.pk is not None assert instance.pk is not None
key = f'{app_label}.{model_name}:{instance.pk}' key = f'{app_label}.{model_name}:{instance.pk}'
if key in queue: if key in queue:
queue[key]['data'] = serialize_for_event(instance)
queue[key]['snapshots']['postchange'] = get_snapshots(instance, event_type)['postchange'] queue[key]['snapshots']['postchange'] = get_snapshots(instance, event_type)['postchange']
# If the object is being deleted, update any prior "update" event to "delete" # If the object is being deleted, update any prior "update" event to "delete"
if event_type == OBJECT_DELETED: if event_type == OBJECT_DELETED:
queue[key]['event_type'] = event_type queue[key]['event_type'] = event_type
else: else:
queue[key] = { queue[key] = EventContext(
'object_type': ObjectType.objects.get_for_model(instance), object_type=ObjectType.objects.get_for_model(instance),
'object_id': instance.pk, object_id=instance.pk,
'event_type': event_type, object=instance,
'data': serialize_for_event(instance), event_type=event_type,
'snapshots': get_snapshots(instance, event_type), snapshots=get_snapshots(instance, event_type),
'request': request, request=request,
user=request.user,
# Legacy request attributes for backward compatibility # Legacy request attributes for backward compatibility
'username': request.user.username, username=request.user.username,
'request_id': request.id, request_id=request.id,
} )
# Force serialization of objects prior to them actually being deleted
if event_type == OBJECT_DELETED:
queue[key]['data'] = serialize_for_event(instance)
def process_event_rules(event_rules, object_type, event_type, data, username=None, snapshots=None, request=None): def process_event_rules(event_rules, object_type, event):
user = None # To be resolved from the username if needed """
Process a list of EventRules against an event.
"""
for event_rule in event_rules: for event_rule in event_rules:
# Evaluate event rule conditions (if any) # Evaluate event rule conditions (if any)
if not event_rule.eval_conditions(data): if not event_rule.eval_conditions(event['data']):
continue continue
# Compile event data # Compile event data
event_data = event_rule.action_data or {} event_data = event_rule.action_data or {}
event_data.update(data) event_data.update(event['data'])
# Webhooks # Webhooks
if event_rule.action_type == EventRuleActionChoices.WEBHOOK: if event_rule.action_type == EventRuleActionChoices.WEBHOOK:
@@ -109,50 +128,41 @@ def process_event_rules(event_rules, object_type, event_type, data, username=Non
params = { params = {
"event_rule": event_rule, "event_rule": event_rule,
"object_type": object_type, "object_type": object_type,
"event_type": event_type, "event_type": event['event_type'],
"data": event_data, "data": event_data,
"snapshots": snapshots, "snapshots": event.get('snapshots'),
"timestamp": timezone.now().isoformat(), "timestamp": timezone.now().isoformat(),
"username": username, "username": event['username'],
"retry": get_rq_retry() "retry": get_rq_retry()
} }
if snapshots: if 'request' in event:
params["snapshots"] = snapshots
if request:
# Exclude FILES - webhooks don't need uploaded files, # Exclude FILES - webhooks don't need uploaded files,
# which can cause pickle errors with Pillow. # which can cause pickle errors with Pillow.
params["request"] = copy_safe_request(request, include_files=False) params['request'] = copy_safe_request(event['request'], include_files=False)
# Enqueue the task # Enqueue the task
rq_queue.enqueue( rq_queue.enqueue('extras.webhooks.send_webhook', **params)
"extras.webhooks.send_webhook",
**params
)
# Scripts # Scripts
elif event_rule.action_type == EventRuleActionChoices.SCRIPT: elif event_rule.action_type == EventRuleActionChoices.SCRIPT:
# Resolve the script from action parameters # Resolve the script from action parameters
script = event_rule.action_object.python_class() script = event_rule.action_object.python_class()
# Retrieve the User if not already resolved
if user is None:
user = User.objects.get(username=username)
# Enqueue a Job to record the script's execution # Enqueue a Job to record the script's execution
from extras.jobs import ScriptJob from extras.jobs import ScriptJob
params = { params = {
"instance": event_rule.action_object, "instance": event_rule.action_object,
"name": script.name, "name": script.name,
"user": user, "user": event['user'],
"data": event_data "data": event_data
} }
if snapshots: if 'snapshots' in event:
params["snapshots"] = snapshots params['snapshots'] = event['snapshots']
if request: if 'request' in event:
params["request"] = copy_safe_request(request) params['request'] = copy_safe_request(event['request'])
ScriptJob.enqueue(
**params # Enqueue the job
) ScriptJob.enqueue(**params)
# Notification groups # Notification groups
elif event_rule.action_type == EventRuleActionChoices.NOTIFICATION: elif event_rule.action_type == EventRuleActionChoices.NOTIFICATION:
@@ -161,7 +171,7 @@ def process_event_rules(event_rules, object_type, event_type, data, username=Non
object_type=object_type, object_type=object_type,
object_id=event_data['id'], object_id=event_data['id'],
object_repr=event_data.get('display'), object_repr=event_data.get('display'),
event_type=event_type event_type=event['event_type']
) )
else: else:
@@ -173,6 +183,8 @@ def process_event_rules(event_rules, object_type, event_type, data, username=Non
def process_event_queue(events): def process_event_queue(events):
""" """
Flush a list of object representation to RQ for EventRule processing. Flush a list of object representation to RQ for EventRule processing.
This is the default processor listed in EVENTS_PIPELINE.
""" """
events_cache = defaultdict(dict) events_cache = defaultdict(dict)
@@ -192,11 +204,7 @@ def process_event_queue(events):
process_event_rules( process_event_rules(
event_rules=event_rules, event_rules=event_rules,
object_type=object_type, object_type=object_type,
event_type=event['event_type'], event=event,
data=event['data'],
username=event['username'],
snapshots=event['snapshots'],
request=event['request'],
) )
+33 -69
View File
@@ -7,13 +7,12 @@ from extras.choices import *
from extras.models import * from extras.models import *
from netbox.events import get_event_type_choices from netbox.events import get_event_type_choices
from netbox.forms import NetBoxModelFilterSetForm, PrimaryModelFilterSetForm from netbox.forms import NetBoxModelFilterSetForm, PrimaryModelFilterSetForm
from netbox.forms.mixins import SavedFiltersMixin from netbox.forms.mixins import OwnerFilterMixin, SavedFiltersMixin
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from users.models import Group, Owner, User from users.models import Group, User
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
from utilities.forms.fields import ( from utilities.forms.fields import (
ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField,
TagFilterField,
) )
from utilities.forms.rendering import FieldSet from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import DateTimePicker from utilities.forms.widgets import DateTimePicker
@@ -39,7 +38,7 @@ __all__ = (
) )
class CustomFieldFilterForm(SavedFiltersMixin, FilterForm): class CustomFieldFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
model = CustomField model = CustomField
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id'), FieldSet('q', 'filter_id'),
@@ -47,6 +46,7 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
FieldSet('choice_set_id', 'related_object_type_id', name=_('Type Options')), FieldSet('choice_set_id', 'related_object_type_id', name=_('Type Options')),
FieldSet('ui_visible', 'ui_editable', 'is_cloneable', name=_('Behavior')), FieldSet('ui_visible', 'ui_editable', 'is_cloneable', name=_('Behavior')),
FieldSet('validation_minimum', 'validation_maximum', 'validation_regex', name=_('Validation')), FieldSet('validation_minimum', 'validation_maximum', 'validation_regex', name=_('Validation')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
object_type_id = ContentTypeMultipleChoiceField( object_type_id = ContentTypeMultipleChoiceField(
queryset=ObjectType.objects.with_feature('custom_fields'), queryset=ObjectType.objects.with_feature('custom_fields'),
@@ -119,18 +119,14 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
label=_('Validation regex'), label=_('Validation regex'),
required=False required=False
) )
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm): class CustomFieldChoiceSetFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
model = CustomFieldChoiceSet model = CustomFieldChoiceSet
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id'), FieldSet('q', 'filter_id'),
FieldSet('base_choices', 'choice', name=_('Choices')), FieldSet('base_choices', 'choice', name=_('Choices')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
base_choices = forms.MultipleChoiceField( base_choices = forms.MultipleChoiceField(
choices=CustomFieldChoiceSetBaseChoices, choices=CustomFieldChoiceSetBaseChoices,
@@ -139,18 +135,14 @@ class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm):
choice = forms.CharField( choice = forms.CharField(
required=False required=False
) )
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
class CustomLinkFilterForm(SavedFiltersMixin, FilterForm): class CustomLinkFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
model = CustomLink model = CustomLink
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id'), FieldSet('q', 'filter_id'),
FieldSet('object_type_id', 'enabled', 'new_window', 'weight', name=_('Attributes')), FieldSet('object_type_id', 'enabled', 'new_window', 'weight', name=_('Attributes')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
object_type_id = ContentTypeMultipleChoiceField( object_type_id = ContentTypeMultipleChoiceField(
label=_('Object types'), label=_('Object types'),
@@ -175,19 +167,15 @@ class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
label=_('Weight'), label=_('Weight'),
required=False required=False
) )
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm): class ExportTemplateFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
model = ExportTemplate model = ExportTemplate
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'object_type_id'), FieldSet('q', 'filter_id', 'object_type_id'),
FieldSet('data_source_id', 'data_file_id', name=_('Data')), FieldSet('data_source_id', 'data_file_id', name=_('Data')),
FieldSet('mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Rendering')), FieldSet('mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Rendering')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
data_source_id = DynamicModelMultipleChoiceField( data_source_id = DynamicModelMultipleChoiceField(
queryset=DataSource.objects.all(), queryset=DataSource.objects.all(),
@@ -226,11 +214,6 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
) )
) )
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm): class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm):
@@ -250,11 +233,12 @@ class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm):
) )
class SavedFilterFilterForm(SavedFiltersMixin, FilterForm): class SavedFilterFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
model = SavedFilter model = SavedFilter
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id'), FieldSet('q', 'filter_id'),
FieldSet('object_type_id', 'enabled', 'shared', 'weight', name=_('Attributes')), FieldSet('object_type_id', 'enabled', 'shared', 'weight', name=_('Attributes')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
object_type_id = ContentTypeMultipleChoiceField( object_type_id = ContentTypeMultipleChoiceField(
label=_('Object types'), label=_('Object types'),
@@ -279,11 +263,6 @@ class SavedFilterFilterForm(SavedFiltersMixin, FilterForm):
label=_('Weight'), label=_('Weight'),
required=False required=False
) )
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
class TableConfigFilterForm(SavedFiltersMixin, FilterForm): class TableConfigFilterForm(SavedFiltersMixin, FilterForm):
@@ -317,11 +296,12 @@ class TableConfigFilterForm(SavedFiltersMixin, FilterForm):
) )
class WebhookFilterForm(NetBoxModelFilterSetForm): class WebhookFilterForm(OwnerFilterMixin, NetBoxModelFilterSetForm):
model = Webhook model = Webhook
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('payload_url', 'http_method', 'http_content_type', name=_('Attributes')), FieldSet('payload_url', 'http_method', 'http_content_type', name=_('Attributes')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
http_content_type = forms.CharField( http_content_type = forms.CharField(
label=_('HTTP content type'), label=_('HTTP content type'),
@@ -336,19 +316,15 @@ class WebhookFilterForm(NetBoxModelFilterSetForm):
required=False, required=False,
label=_('HTTP method') label=_('HTTP method')
) )
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
tag = TagFilterField(model) tag = TagFilterField(model)
class EventRuleFilterForm(NetBoxModelFilterSetForm): class EventRuleFilterForm(OwnerFilterMixin, NetBoxModelFilterSetForm):
model = EventRule model = EventRule
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('object_type_id', 'event_type', 'action_type', 'enabled', name=_('Attributes')), FieldSet('object_type_id', 'event_type', 'action_type', 'enabled', name=_('Attributes')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
object_type_id = ContentTypeMultipleChoiceField( object_type_id = ContentTypeMultipleChoiceField(
queryset=ObjectType.objects.with_feature('event_rules'), queryset=ObjectType.objects.with_feature('event_rules'),
@@ -372,16 +348,16 @@ class EventRuleFilterForm(NetBoxModelFilterSetForm):
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
) )
) )
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
tag = TagFilterField(model) tag = TagFilterField(model)
class TagFilterForm(SavedFiltersMixin, FilterForm): class TagFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
model = Tag model = Tag
fieldsets = (
FieldSet('q', 'filter_id'),
FieldSet('content_type_id', 'for_object_type_id', name=_('Attributes')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
content_type_id = ContentTypeMultipleChoiceField( content_type_id = ContentTypeMultipleChoiceField(
queryset=ObjectType.objects.with_feature('tags'), queryset=ObjectType.objects.with_feature('tags'),
required=False, required=False,
@@ -392,11 +368,6 @@ class TagFilterForm(SavedFiltersMixin, FilterForm):
required=False, required=False,
label=_('Allowed object type') label=_('Allowed object type')
) )
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
class ConfigContextProfileFilterForm(PrimaryModelFilterSetForm): class ConfigContextProfileFilterForm(PrimaryModelFilterSetForm):
@@ -404,6 +375,7 @@ class ConfigContextProfileFilterForm(PrimaryModelFilterSetForm):
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id'), FieldSet('q', 'filter_id'),
FieldSet('data_source_id', 'data_file_id', name=_('Data')), FieldSet('data_source_id', 'data_file_id', name=_('Data')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
data_source_id = DynamicModelMultipleChoiceField( data_source_id = DynamicModelMultipleChoiceField(
queryset=DataSource.objects.all(), queryset=DataSource.objects.all(),
@@ -420,16 +392,17 @@ class ConfigContextProfileFilterForm(PrimaryModelFilterSetForm):
) )
class ConfigContextFilterForm(SavedFiltersMixin, FilterForm): class ConfigContextFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
model = ConfigContext model = ConfigContext
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag_id'), FieldSet('q', 'filter_id', 'tag_id'),
FieldSet('profile', name=_('Config Context')), FieldSet('profile_id', name=_('Config Context')),
FieldSet('data_source_id', 'data_file_id', name=_('Data')), FieldSet('data_source_id', 'data_file_id', name=_('Data')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
FieldSet('device_type_id', 'platform_id', 'device_role_id', name=_('Device')), FieldSet('device_type_id', 'platform_id', 'device_role_id', name=_('Device')),
FieldSet('cluster_type_id', 'cluster_group_id', 'cluster_id', name=_('Cluster')), FieldSet('cluster_type_id', 'cluster_group_id', 'cluster_id', name=_('Cluster')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')) FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
profile_id = DynamicModelMultipleChoiceField( profile_id = DynamicModelMultipleChoiceField(
queryset=ConfigContextProfile.objects.all(), queryset=ConfigContextProfile.objects.all(),
@@ -514,19 +487,15 @@ class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
required=False, required=False,
label=_('Tags') label=_('Tags')
) )
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
class ConfigTemplateFilterForm(SavedFiltersMixin, FilterForm): class ConfigTemplateFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
model = ConfigTemplate model = ConfigTemplate
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('data_source_id', 'data_file_id', 'auto_sync_enabled', name=_('Data')), FieldSet('data_source_id', 'data_file_id', 'auto_sync_enabled', name=_('Data')),
FieldSet('mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Rendering')) FieldSet('mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Rendering')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
data_source_id = DynamicModelMultipleChoiceField( data_source_id = DynamicModelMultipleChoiceField(
queryset=DataSource.objects.all(), queryset=DataSource.objects.all(),
@@ -568,11 +537,6 @@ class ConfigTemplateFilterForm(SavedFiltersMixin, FilterForm):
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
) )
) )
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
class LocalConfigContextFilterForm(forms.Form): class LocalConfigContextFilterForm(forms.Form):
+7
View File
@@ -178,6 +178,13 @@ class CustomFieldChoiceSetForm(ChangelogMessageMixin, OwnerMixin, forms.ModelFor
) + ' <code>choice1:First Choice</code>') ) + ' <code>choice1:First Choice</code>')
) )
fieldsets = (
FieldSet(
'name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically',
name=_('Custom Field Choice Set')
),
)
class Meta: class Meta:
model = CustomFieldChoiceSet model = CustomFieldChoiceSet
fields = ('name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically', 'owner') fields = ('name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically', 'owner')
+1 -1
View File
@@ -137,7 +137,7 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
module = self.get_module() module = self.get_module()
except Exception as e: except Exception as e:
self.error = e self.error = e
logger.debug(f"Failed to load script: {self.python_name} error: {e}") logger.error(f"Failed to load script: {self.python_name} error: {e}")
module = None module = None
scripts = {} scripts = {}
+1 -1
View File
@@ -61,7 +61,7 @@ class ScriptVariable:
self.field_attrs['label'] = label self.field_attrs['label'] = label
if description: if description:
self.field_attrs['help_text'] = description self.field_attrs['help_text'] = description
if default: if default is not None:
self.field_attrs['initial'] = default self.field_attrs['initial'] = default
if widget: if widget:
self.field_attrs['widget'] = widget self.field_attrs['widget'] = widget
+9 -12
View File
@@ -4,11 +4,12 @@ from django.dispatch import receiver
from core.events import * from core.events import *
from core.signals import job_end, job_start from core.signals import job_end, job_start
from extras.events import process_event_rules from extras.events import EventContext, process_event_rules
from extras.models import EventRule, Notification, Subscription from extras.models import EventRule, Notification, Subscription
from netbox.config import get_config from netbox.config import get_config
from netbox.models.features import has_feature from netbox.models.features import has_feature
from netbox.signals import post_clean from netbox.signals import post_clean
from utilities.data import get_config_value_ci
from utilities.exceptions import AbortRequest from utilities.exceptions import AbortRequest
from .models import CustomField, TaggedItem from .models import CustomField, TaggedItem
from .utils import run_validators from .utils import run_validators
@@ -65,7 +66,7 @@ def run_save_validators(sender, instance, **kwargs):
Run any custom validation rules for the model prior to calling save(). Run any custom validation rules for the model prior to calling save().
""" """
model_name = f'{sender._meta.app_label}.{sender._meta.model_name}' model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
validators = get_config().CUSTOM_VALIDATORS.get(model_name, []) validators = get_config_value_ci(get_config().CUSTOM_VALIDATORS, model_name, default=[])
run_validators(instance, validators) run_validators(instance, validators)
@@ -102,14 +103,12 @@ def process_job_start_event_rules(sender, **kwargs):
enabled=True, enabled=True,
object_types=sender.object_type object_types=sender.object_type
) )
username = sender.user.username if sender.user else None event = EventContext(
process_event_rules(
event_rules=event_rules,
object_type=sender.object_type,
event_type=JOB_STARTED, event_type=JOB_STARTED,
data=sender.data, data=sender.data,
username=username user=sender.user,
) )
process_event_rules(event_rules, sender.object_type, event)
@receiver(job_end) @receiver(job_end)
@@ -122,14 +121,12 @@ def process_job_end_event_rules(sender, **kwargs):
enabled=True, enabled=True,
object_types=sender.object_type object_types=sender.object_type
) )
username = sender.user.username if sender.user else None event = EventContext(
process_event_rules(
event_rules=event_rules,
object_type=sender.object_type,
event_type=JOB_COMPLETED, event_type=JOB_COMPLETED,
data=sender.data, data=sender.data,
username=username user=sender.user,
) )
process_event_rules(event_rules, sender.object_type, event)
# #
+41 -5
View File
@@ -1,6 +1,7 @@
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.urls import reverse from django.urls import reverse
from django.test import tag from django.test import tag
from unittest.mock import patch, PropertyMock
from core.choices import ManagedFileRootPathChoices from core.choices import ManagedFileRootPathChoices
from core.events import * from core.events import *
@@ -906,7 +907,7 @@ class ScriptValidationErrorTest(TestCase):
user_permissions = ['extras.view_script', 'extras.run_script'] user_permissions = ['extras.view_script', 'extras.run_script']
class TestScriptMixin: class TestScriptMixin:
bar = IntegerVar(min_value=0, max_value=30, default=30) bar = IntegerVar(min_value=0, max_value=30)
class TestScriptClass(TestScriptMixin, PythonClass): class TestScriptClass(TestScriptMixin, PythonClass):
class Meta: class Meta:
@@ -930,8 +931,6 @@ class ScriptValidationErrorTest(TestCase):
@tag('regression') @tag('regression')
def test_script_validation_error_displays_message(self): def test_script_validation_error_displays_message(self):
from unittest.mock import patch
url = reverse('extras:script', kwargs={'pk': self.script.pk}) url = reverse('extras:script', kwargs={'pk': self.script.pk})
with patch('extras.views.get_workers_for_queue', return_value=['worker']): with patch('extras.views.get_workers_for_queue', return_value=['worker']):
@@ -944,8 +943,6 @@ class ScriptValidationErrorTest(TestCase):
@tag('regression') @tag('regression')
def test_script_validation_error_no_toast_for_fieldset_fields(self): def test_script_validation_error_no_toast_for_fieldset_fields(self):
from unittest.mock import patch, PropertyMock
class FieldsetScript(PythonClass): class FieldsetScript(PythonClass):
class Meta: class Meta:
name = 'Fieldset test' name = 'Fieldset test'
@@ -967,3 +964,42 @@ class ScriptValidationErrorTest(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
messages = list(response.context['messages']) messages = list(response.context['messages'])
self.assertEqual(len(messages), 0) self.assertEqual(len(messages), 0)
class ScriptDefaultValuesTest(TestCase):
user_permissions = ['extras.view_script', 'extras.run_script']
class TestScriptClass(PythonClass):
class Meta:
name = 'Test script'
commit_default = False
bool_default_true = BooleanVar(default=True)
bool_default_false = BooleanVar(default=False)
int_with_default = IntegerVar(default=0)
int_without_default = IntegerVar(required=False)
def run(self, data, commit):
return "Complete"
@classmethod
def setUpTestData(cls):
module = ScriptModule.objects.create(file_root=ManagedFileRootPathChoices.SCRIPTS, file_path='test_script.py')
cls.script = Script.objects.create(module=module, name='Test script', is_executable=True)
def setUp(self):
super().setUp()
Script.python_class = property(lambda self: ScriptDefaultValuesTest.TestScriptClass)
def test_default_values_are_used(self):
url = reverse('extras:script', kwargs={'pk': self.script.pk})
with patch('extras.views.get_workers_for_queue', return_value=['worker']):
with patch('extras.jobs.ScriptJob.enqueue') as mock_enqueue:
mock_enqueue.return_value.pk = 1
self.client.post(url, {})
call_kwargs = mock_enqueue.call_args.kwargs
self.assertEqual(call_kwargs['data']['bool_default_true'], True)
self.assertEqual(call_kwargs['data']['bool_default_false'], False)
self.assertEqual(call_kwargs['data']['int_with_default'], 0)
self.assertIsNone(call_kwargs['data']['int_without_default'])
+7 -1
View File
@@ -1511,7 +1511,13 @@ class ScriptView(BaseScriptView):
'script': script, 'script': script,
}) })
form = script_class.as_form(request.POST, request.FILES) # Populate missing variables with their default values, if defined
post_data = request.POST.copy()
for name, var in script_class._get_vars().items():
if name not in post_data and (initial := var.field_attrs.get('initial')) is not None:
post_data[name] = initial
form = script_class.as_form(post_data, request.FILES)
# Allow execution only if RQ worker process is running # Allow execution only if RQ worker process is running
if not get_workers_for_queue('default'): if not get_workers_for_queue('default'):
+33 -17
View File
@@ -45,9 +45,10 @@ IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([
class VRFFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm): class VRFFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = VRF model = VRF
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('import_target_id', 'export_target_id', name=_('Route Targets')), FieldSet('import_target_id', 'export_target_id', name=_('Route Targets')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
import_target_id = DynamicModelMultipleChoiceField( import_target_id = DynamicModelMultipleChoiceField(
queryset=RouteTarget.objects.all(), queryset=RouteTarget.objects.all(),
@@ -65,9 +66,10 @@ class VRFFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
class RouteTargetFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm): class RouteTargetFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = RouteTarget model = RouteTarget
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('importing_vrf_id', 'exporting_vrf_id', name=_('VRF')), FieldSet('importing_vrf_id', 'exporting_vrf_id', name=_('VRF')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
importing_vrf_id = DynamicModelMultipleChoiceField( importing_vrf_id = DynamicModelMultipleChoiceField(
queryset=VRF.objects.all(), queryset=VRF.objects.all(),
@@ -85,8 +87,9 @@ class RouteTargetFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
class RIRFilterForm(OrganizationalModelFilterSetForm): class RIRFilterForm(OrganizationalModelFilterSetForm):
model = RIR model = RIR
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('is_private', name=_('RIR')), FieldSet('is_private', name=_('RIR')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
is_private = forms.NullBooleanField( is_private = forms.NullBooleanField(
required=False, required=False,
@@ -101,9 +104,10 @@ class RIRFilterForm(OrganizationalModelFilterSetForm):
class AggregateFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm): class AggregateFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm):
model = Aggregate model = Aggregate
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('family', 'rir_id', name=_('Attributes')), FieldSet('family', 'rir_id', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
) )
family = forms.ChoiceField( family = forms.ChoiceField(
@@ -122,9 +126,10 @@ class AggregateFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryMode
class ASNRangeFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm): class ASNRangeFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm):
model = ASNRange model = ASNRange
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('rir_id', 'start', 'end', name=_('Range')), FieldSet('rir_id', 'start', 'end', name=_('Range')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
rir_id = DynamicModelMultipleChoiceField( rir_id = DynamicModelMultipleChoiceField(
queryset=RIR.objects.all(), queryset=RIR.objects.all(),
@@ -145,9 +150,10 @@ class ASNRangeFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm):
class ASNFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm): class ASNFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = ASN model = ASN
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('rir_id', 'site_group_id', 'site_id', name=_('Assignment')), FieldSet('rir_id', 'site_group_id', 'site_id', name=_('Assignment')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
rir_id = DynamicModelMultipleChoiceField( rir_id = DynamicModelMultipleChoiceField(
queryset=RIR.objects.all(), queryset=RIR.objects.all(),
@@ -170,7 +176,8 @@ class ASNFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
class RoleFilterForm(OrganizationalModelFilterSetForm): class RoleFilterForm(OrganizationalModelFilterSetForm):
model = Role model = Role
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
tag = TagFilterField(model) tag = TagFilterField(model)
@@ -178,7 +185,7 @@ class RoleFilterForm(OrganizationalModelFilterSetForm):
class PrefixFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm): class PrefixFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm):
model = Prefix model = Prefix
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet( FieldSet(
'within_include', 'family', 'status', 'role_id', 'mask_length', 'is_pool', 'mark_utilized', 'within_include', 'family', 'status', 'role_id', 'mask_length', 'is_pool', 'mark_utilized',
name=_('Addressing') name=_('Addressing')
@@ -187,6 +194,7 @@ class PrefixFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFi
FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')), FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Scope')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Scope')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
) )
mask_length__lte = forms.IntegerField( mask_length__lte = forms.IntegerField(
@@ -284,9 +292,10 @@ class PrefixFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFi
class IPRangeFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm): class IPRangeFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm):
model = IPRange model = IPRange
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('family', 'vrf_id', 'status', 'role_id', 'mark_populated', 'mark_utilized', name=_('Attributes')), FieldSet('family', 'vrf_id', 'status', 'role_id', 'mark_populated', 'mark_utilized', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
) )
family = forms.ChoiceField( family = forms.ChoiceField(
@@ -331,14 +340,15 @@ class IPRangeFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelF
class IPAddressFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm): class IPAddressFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm):
model = IPAddress model = IPAddress
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet( FieldSet(
'parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface', 'dns_name', 'parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface', 'dns_name',
name=_('Attributes') name=_('Attributes')
), ),
FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')), FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('device_id', 'virtual_machine_id', name=_('Device/VM')), FieldSet('device_id', 'virtual_machine_id', name=_('Device/VM')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
) )
selector_fields = ('filter_id', 'q', 'region_id', 'group_id', 'parent', 'status', 'role') selector_fields = ('filter_id', 'q', 'region_id', 'group_id', 'parent', 'status', 'role')
@@ -409,9 +419,10 @@ class IPAddressFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryMode
class FHRPGroupFilterForm(PrimaryModelFilterSetForm): class FHRPGroupFilterForm(PrimaryModelFilterSetForm):
model = FHRPGroup model = FHRPGroup
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'protocol', 'group_id', name=_('Attributes')), FieldSet('name', 'protocol', 'group_id', name=_('Attributes')),
FieldSet('auth_type', 'auth_key', name=_('Authentication')), FieldSet('auth_type', 'auth_key', name=_('Authentication')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
name = forms.CharField( name = forms.CharField(
label=_('Name'), label=_('Name'),
@@ -441,11 +452,12 @@ class FHRPGroupFilterForm(PrimaryModelFilterSetForm):
class VLANGroupFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm): class VLANGroupFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm):
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('region', 'site_group', 'site', 'location', 'rack', name=_('Location')), FieldSet('region', 'site_group', 'site', 'location', 'rack', name=_('Location')),
FieldSet('cluster_group', 'cluster', name=_('Cluster')), FieldSet('cluster_group', 'cluster', name=_('Cluster')),
FieldSet('contains_vid', name=_('VLANs')), FieldSet('contains_vid', name=_('VLANs')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
model = VLANGroup model = VLANGroup
region = DynamicModelMultipleChoiceField( region = DynamicModelMultipleChoiceField(
@@ -495,8 +507,9 @@ class VLANGroupFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm):
class VLANTranslationPolicyFilterForm(PrimaryModelFilterSetForm): class VLANTranslationPolicyFilterForm(PrimaryModelFilterSetForm):
model = VLANTranslationPolicy model = VLANTranslationPolicy
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', name=_('Attributes')), FieldSet('name', name=_('Attributes')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
name = forms.CharField( name = forms.CharField(
required=False, required=False,
@@ -532,11 +545,12 @@ class VLANTranslationRuleFilterForm(NetBoxModelFilterSetForm):
class VLANFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm): class VLANFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = VLAN model = VLAN
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
FieldSet('group_id', 'status', 'role_id', 'vid', 'l2vpn_id', name=_('Attributes')), FieldSet('group_id', 'status', 'role_id', 'vid', 'l2vpn_id', name=_('Attributes')),
FieldSet('qinq_role', 'qinq_svlan_id', name=_('Q-in-Q/802.1ad')), FieldSet('qinq_role', 'qinq_svlan_id', name=_('Q-in-Q/802.1ad')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
selector_fields = ('filter_id', 'q', 'group_id') selector_fields = ('filter_id', 'q', 'group_id')
region_id = DynamicModelMultipleChoiceField( region_id = DynamicModelMultipleChoiceField(
@@ -604,8 +618,9 @@ class VLANFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
class ServiceTemplateFilterForm(PrimaryModelFilterSetForm): class ServiceTemplateFilterForm(PrimaryModelFilterSetForm):
model = ServiceTemplate model = ServiceTemplate
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('protocol', 'port', name=_('Attributes')), FieldSet('protocol', 'port', name=_('Attributes')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
protocol = forms.ChoiceField( protocol = forms.ChoiceField(
label=_('Protocol'), label=_('Protocol'),
@@ -622,9 +637,10 @@ class ServiceTemplateFilterForm(PrimaryModelFilterSetForm):
class ServiceFilterForm(ContactModelFilterForm, ServiceTemplateFilterForm): class ServiceFilterForm(ContactModelFilterForm, ServiceTemplateFilterForm):
model = Service model = Service
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('protocol', 'port', name=_('Attributes')), FieldSet('protocol', 'port', name=_('Attributes')),
FieldSet('device_id', 'virtual_machine_id', 'fhrpgroup_id', name=_('Assignment')), FieldSet('device_id', 'virtual_machine_id', 'fhrpgroup_id', name=_('Assignment')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
) )
device_id = DynamicModelMultipleChoiceField( device_id = DynamicModelMultipleChoiceField(
+5
View File
@@ -370,6 +370,11 @@ class AnnotatedIPAddressTable(IPAddressTable):
verbose_name=_('IP Address') verbose_name=_('IP Address')
) )
def render_pk(self, value, record, bound_column):
if type(record) is not self._meta.model:
return ''
return bound_column.column.render(value, bound_column, record)
class Meta(IPAddressTable.Meta): class Meta(IPAddressTable.Meta):
pass pass
+1 -1
View File
@@ -6,7 +6,7 @@ PREFIX_LINK = """
{% if record.pk %} {% if record.pk %}
<a href="{{ record.get_absolute_url }}" id="prefix_{{ record.pk }}">{{ record.prefix }}</a> <a href="{{ record.get_absolute_url }}" id="prefix_{{ record.pk }}">{{ record.prefix }}</a>
{% else %} {% else %}
<a href="{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.site %}&site={{ object.site.pk }}{% endif %}{% if object.tenant %}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}{% endif %}">{{ record.prefix }}</a> <a href="{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.scope %}&scope_type={{ object.scope_type.pk }}&scope={{ object.scope.pk }}{% endif %}{% if object.tenant %}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}{% endif %}">{{ record.prefix }}</a>
{% endif %} {% endif %}
""" """
+18 -2
View File
@@ -4,6 +4,7 @@ from django.utils.translation import gettext_lazy as _
from django_tables2.utils import Accessor from django_tables2.utils import Accessor
from dcim.models import Interface from dcim.models import Interface
from dcim.tables.template_code import INTERFACE_LINKTERMINATION, LINKTERMINATION
from ipam.models import * from ipam.models import *
from netbox.tables import NetBoxTable, OrganizationalModelTable, PrimaryModelTable, columns from netbox.tables import NetBoxTable, OrganizationalModelTable, PrimaryModelTable, columns
from tenancy.tables import TenancyColumnsMixin, TenantColumn from tenancy.tables import TenancyColumnsMixin, TenantColumn
@@ -159,11 +160,26 @@ class VLANDevicesTable(VLANMembersTable):
actions = columns.ActionsColumn( actions = columns.ActionsColumn(
actions=('edit',) actions=('edit',)
) )
link_peer = columns.TemplateColumn(
accessor='link_peers',
template_code=LINKTERMINATION,
orderable=False,
verbose_name=_('Link Peers'),
)
# Override PathEndpointTable.connection to accommodate virtual circuits
connection = columns.TemplateColumn(
accessor='_path__destinations',
template_code=INTERFACE_LINKTERMINATION,
orderable=False,
verbose_name=_('Connection'),
)
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = Interface model = Interface
fields = ('device', 'name', 'tagged', 'actions') fields = ('device', 'name', 'link_peer', 'connection', 'tagged', 'actions')
exclude = ('id', ) default_columns = ('device', 'name', 'connection', 'tagged', 'actions')
exclude = ('id',)
class VLANVirtualMachinesTable(VLANMembersTable): class VLANVirtualMachinesTable(VLANMembersTable):
+41
View File
@@ -0,0 +1,41 @@
from django.test import RequestFactory, TestCase
from netaddr import IPNetwork
from ipam.models import IPAddress, IPRange, Prefix
from ipam.tables import AnnotatedIPAddressTable
from ipam.utils import annotate_ip_space
class AnnotatedIPAddressTableTest(TestCase):
@classmethod
def setUpTestData(cls):
cls.prefix = Prefix.objects.create(
prefix=IPNetwork('10.1.1.0/24'),
status='active'
)
cls.ip_address = IPAddress.objects.create(
address='10.1.1.1/24',
status='active'
)
cls.ip_range = IPRange.objects.create(
start_address=IPNetwork('10.1.1.2/24'),
end_address=IPNetwork('10.1.1.10/24'),
status='active'
)
def test_ipaddress_has_checkbox_iprange_does_not(self):
data = annotate_ip_space(self.prefix)
table = AnnotatedIPAddressTable(data, orderable=False)
table.columns.show('pk')
request = RequestFactory().get('/')
html = table.as_html(request)
ipaddress_checkbox_count = html.count(f'name="pk" value="{self.ip_address.pk}"')
self.assertEqual(ipaddress_checkbox_count, 1)
iprange_checkbox_count = html.count(f'name="pk" value="{self.ip_range.pk}"')
self.assertEqual(iprange_checkbox_count, 0)
+3
View File
@@ -49,6 +49,9 @@ def add_requested_prefixes(parent, prefix_list, show_available=True, show_assign
if prefix_list and show_available: if prefix_list and show_available:
# Find all unallocated space, add fake Prefix objects to child_prefixes. # Find all unallocated space, add fake Prefix objects to child_prefixes.
# IMPORTANT: These are unsaved Prefix instances (pk=None). If this is ever changed to use
# saved Prefix instances with real pks, bulk delete will fail for mixed-type selections
# due to single-model form validation. See: https://github.com/netbox-community/netbox/issues/21176
available_prefixes = netaddr.IPSet(parent) ^ netaddr.IPSet([p.prefix for p in prefix_list]) available_prefixes = netaddr.IPSet(parent) ^ netaddr.IPSet([p.prefix for p in prefix_list])
available_prefixes = [Prefix(prefix=p, status=None) for p in available_prefixes.iter_cidrs()] available_prefixes = [Prefix(prefix=p, status=None) for p in available_prefixes.iter_cidrs()]
child_prefixes = child_prefixes + available_prefixes child_prefixes = child_prefixes + available_prefixes
+19 -15
View File
@@ -1,9 +1,8 @@
from functools import cached_property from functools import cached_property
from rest_framework import serializers
from rest_framework.utils.serializer_helpers import BindingDict
from drf_spectacular.utils import extend_schema_field
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from utilities.api import get_related_object_by_attrs from utilities.api import get_related_object_by_attrs
from .fields import NetBoxAPIHyperlinkedIdentityField, NetBoxURLHyperlinkedIdentityField from .fields import NetBoxAPIHyperlinkedIdentityField, NetBoxURLHyperlinkedIdentityField
@@ -19,16 +18,18 @@ class BaseModelSerializer(serializers.ModelSerializer):
display_url = NetBoxURLHyperlinkedIdentityField() display_url = NetBoxURLHyperlinkedIdentityField()
display = serializers.SerializerMethodField(read_only=True) display = serializers.SerializerMethodField(read_only=True)
def __init__(self, *args, nested=False, fields=None, **kwargs): def __init__(self, *args, nested=False, fields=None, omit=None, **kwargs):
""" """
Extends the base __init__() method to support dynamic fields. Extends the base __init__() method to support dynamic fields.
:param nested: Set to True if this serializer is being employed within a parent serializer :param nested: Set to True if this serializer is being employed within a parent serializer
:param fields: An iterable of fields to include when rendering the serialized object, If nested is :param fields: An iterable of fields to include when rendering the serialized object, If nested is
True but no fields are specified, Meta.brief_fields will be used. True but no fields are specified, Meta.brief_fields will be used.
:param omit: An iterable of fields to omit from the serialized object
""" """
self.nested = nested self.nested = nested
self._requested_fields = fields self._include_fields = fields or []
self._omit_fields = omit or []
# Disable validators for nested objects (which already exist) # Disable validators for nested objects (which already exist)
if self.nested: if self.nested:
@@ -36,8 +37,8 @@ class BaseModelSerializer(serializers.ModelSerializer):
# If this serializer is nested but no fields have been specified, # If this serializer is nested but no fields have been specified,
# default to using Meta.brief_fields (if set) # default to using Meta.brief_fields (if set)
if self.nested and not fields: if self.nested and not fields and not omit:
self._requested_fields = getattr(self.Meta, 'brief_fields', None) self._include_fields = getattr(self.Meta, 'brief_fields', None)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -54,16 +55,19 @@ class BaseModelSerializer(serializers.ModelSerializer):
@cached_property @cached_property
def fields(self): def fields(self):
""" """
Override the fields property to check for requested fields. If defined, Override the fields property to return only specifically requested fields if needed.
return only the applicable fields.
""" """
if not self._requested_fields: fields = super().fields
return super().fields
# Include only requested fields
if self._include_fields:
for field_name in set(fields) - set(self._include_fields):
fields.pop(field_name, None)
# Remove omitted fields
for field_name in set(self._omit_fields):
fields.pop(field_name, None)
fields = BindingDict(self)
for key, value in self.get_fields().items():
if key in self._requested_fields:
fields[key] = value
return fields return fields
@extend_schema_field(OpenApiTypes.STR) @extend_schema_field(OpenApiTypes.STR)
+18 -13
View File
@@ -5,13 +5,13 @@ from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.db import router, transaction from django.db import router, transaction
from django.db.models import ProtectedError, RestrictedError from django.db.models import ProtectedError, RestrictedError
from django_pglocks import advisory_lock from django_pglocks import advisory_lock
from netbox.constants import ADVISORY_LOCK_KEYS
from rest_framework import mixins as drf_mixins from rest_framework import mixins as drf_mixins
from rest_framework import status from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet from rest_framework.viewsets import GenericViewSet
from netbox.api.serializers.features import ChangeLogMessageSerializer from netbox.api.serializers.features import ChangeLogMessageSerializer
from netbox.constants import ADVISORY_LOCK_KEYS
from utilities.api import get_annotations_for_serializer, get_prefetches_for_serializer from utilities.api import get_annotations_for_serializer, get_prefetches_for_serializer
from utilities.exceptions import AbortRequest from utilities.exceptions import AbortRequest
from utilities.query import reapply_model_ordering from utilities.query import reapply_model_ordering
@@ -59,33 +59,38 @@ class BaseViewSet(GenericViewSet):
serializer_class = self.get_serializer_class() serializer_class = self.get_serializer_class()
# Dynamically resolve prefetches for included serializer fields and attach them to the queryset # Dynamically resolve prefetches for included serializer fields and attach them to the queryset
if prefetch := get_prefetches_for_serializer(serializer_class, fields_to_include=self.requested_fields): if prefetch := get_prefetches_for_serializer(serializer_class, **self.field_kwargs):
qs = qs.prefetch_related(*prefetch) qs = qs.prefetch_related(*prefetch)
# Dynamically resolve annotations for RelatedObjectCountFields on the serializer and attach them to the queryset # Dynamically resolve annotations for RelatedObjectCountFields on the serializer and attach them to the queryset
if annotations := get_annotations_for_serializer(serializer_class, fields_to_include=self.requested_fields): if annotations := get_annotations_for_serializer(serializer_class, **self.field_kwargs):
qs = qs.annotate(**annotations) qs = qs.annotate(**annotations)
return qs return qs
def get_serializer(self, *args, **kwargs): def get_serializer(self, *args, **kwargs):
# Pass the fields/omit kwargs (if specified by the request) to the serializer
# If specific fields have been requested, pass them to the serializer kwargs.update(**self.field_kwargs)
if self.requested_fields:
kwargs['fields'] = self.requested_fields
return super().get_serializer(*args, **kwargs) return super().get_serializer(*args, **kwargs)
@cached_property @cached_property
def requested_fields(self): def field_kwargs(self):
"""Return a dictionary of keyword arguments to be passed when instantiating the serializer."""
# An explicit list of fields was requested # An explicit list of fields was requested
if requested_fields := self.request.query_params.get('fields'): if requested_fields := self.request.query_params.get('fields'):
return requested_fields.split(',') return {'fields': requested_fields.split(',')}
# An explicit list of fields to omit was requested
if omit_fields := self.request.query_params.get('omit'):
return {'omit': omit_fields.split(',')}
# Brief mode has been enabled for this request # Brief mode has been enabled for this request
elif self.brief: if self.brief:
serializer_class = self.get_serializer_class() serializer_class = self.get_serializer_class()
return getattr(serializer_class.Meta, 'brief_fields', None) if brief_fields := getattr(serializer_class.Meta, 'brief_fields', None):
return None return {'fields': brief_fields}
return {}
class NetBoxReadOnlyModelViewSet( class NetBoxReadOnlyModelViewSet(
+2
View File
@@ -3,8 +3,10 @@ from contextvars import ContextVar
__all__ = ( __all__ = (
'current_request', 'current_request',
'events_queue', 'events_queue',
'query_cache',
) )
current_request = ContextVar('current_request', default=None) current_request = ContextVar('current_request', default=None)
events_queue = ContextVar('events_queue', default=dict()) events_queue = ContextVar('events_queue', default=dict())
query_cache = ContextVar('query_cache', default=None)
+4 -1
View File
@@ -1,6 +1,7 @@
from collections import defaultdict
from contextlib import contextmanager from contextlib import contextmanager
from netbox.context import current_request, events_queue from netbox.context import current_request, events_queue, query_cache
from netbox.utils import register_request_processor from netbox.utils import register_request_processor
from extras.events import flush_events from extras.events import flush_events
@@ -16,6 +17,7 @@ def event_tracking(request):
""" """
current_request.set(request) current_request.set(request)
events_queue.set({}) events_queue.set({})
query_cache.set(defaultdict(dict))
yield yield
@@ -26,3 +28,4 @@ def event_tracking(request):
# Clear context vars # Clear context vars
current_request.set(None) current_request.set(None)
events_queue.set({}) events_queue.set({})
query_cache.set(None)
+2 -11
View File
@@ -3,10 +3,9 @@ from django.db.models import Q
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from extras.choices import * from extras.choices import *
from users.models import Owner from utilities.forms.fields import QueryField
from utilities.forms.fields import DynamicModelChoiceField, QueryField
from utilities.forms.mixins import FilterModifierMixin from utilities.forms.mixins import FilterModifierMixin
from .mixins import CustomFieldsMixin, SavedFiltersMixin from .mixins import CustomFieldsMixin, OwnerFilterMixin, SavedFiltersMixin
__all__ = ( __all__ = (
'NestedGroupModelFilterSetForm', 'NestedGroupModelFilterSetForm',
@@ -47,14 +46,6 @@ class NetBoxModelFilterSetForm(FilterModifierMixin, CustomFieldsMixin, SavedFilt
) )
class OwnerFilterMixin(forms.Form):
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
class PrimaryModelFilterSetForm(OwnerFilterMixin, NetBoxModelFilterSetForm): class PrimaryModelFilterSetForm(OwnerFilterMixin, NetBoxModelFilterSetForm):
""" """
FilterSet form for models which inherit from PrimaryModel. FilterSet form for models which inherit from PrimaryModel.
+57 -4
View File
@@ -4,13 +4,14 @@ from django.utils.translation import gettext as _
from core.models import ObjectType from core.models import ObjectType
from extras.choices import * from extras.choices import *
from extras.models import * from extras.models import *
from users.models import Owner from users.models import OwnerGroup, Owner
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
__all__ = ( __all__ = (
'ChangelogMessageMixin', 'ChangelogMessageMixin',
'CustomFieldsMixin', 'CustomFieldsMixin',
'OwnerMixin', 'OwnerMixin',
'OwnerFilterMixin',
'SavedFiltersMixin', 'SavedFiltersMixin',
'TagsMixin', 'TagsMixin',
) )
@@ -22,7 +23,7 @@ class ChangelogMessageMixin(forms.Form):
""" """
changelog_message = forms.CharField( changelog_message = forms.CharField(
required=False, required=False,
max_length=200 max_length=200,
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -42,6 +43,7 @@ class CustomFieldsMixin:
Attributes: Attributes:
model: The model class model: The model class
""" """
model = None model = None
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -86,13 +88,20 @@ class CustomFieldsMixin:
class SavedFiltersMixin(forms.Form): class SavedFiltersMixin(forms.Form):
"""
Form mixin for forms that support saved filters.
Provides a field for selecting a saved filter,
with options limited to those applicable to the form's model.
"""
filter_id = DynamicModelMultipleChoiceField( filter_id = DynamicModelMultipleChoiceField(
queryset=SavedFilter.objects.all(), queryset=SavedFilter.objects.all(),
required=False, required=False,
label=_('Saved Filter'), label=_('Saved Filter'),
query_params={ query_params={
'usable': True, 'usable': True,
} },
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -107,6 +116,13 @@ class SavedFiltersMixin(forms.Form):
class TagsMixin(forms.Form): class TagsMixin(forms.Form):
"""
Mixin for forms that support tagging.
Provides a field for selecting tags,
with options limited to those applicable to the form's model.
"""
tags = DynamicModelMultipleChoiceField( tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(), queryset=Tag.objects.all(),
required=False, required=False,
@@ -124,10 +140,47 @@ class TagsMixin(forms.Form):
class OwnerMixin(forms.Form): class OwnerMixin(forms.Form):
""" """
Add an `owner` field to forms for models which support Owner assignment. Mixin for forms which adds ownership fields.
Include this mixin in forms for models which
support owner and/or owner group assignment.
""" """
owner_group = DynamicModelChoiceField(
label=_('Owner group'),
queryset=OwnerGroup.objects.all(),
required=False,
null_option='None',
initial_params={'members': '$owner'},
)
owner = DynamicModelChoiceField( owner = DynamicModelChoiceField(
queryset=Owner.objects.all(), queryset=Owner.objects.all(),
required=False, required=False,
query_params={'group_id': '$owner_group'},
label=_('Owner'),
)
class OwnerFilterMixin(forms.Form):
"""
Mixin for filterset forms which adds owner and owner group filtering.
Include this mixin in filterset forms for models
which support owner and/or owner group assignment.
"""
owner_group_id = DynamicModelMultipleChoiceField(
queryset=OwnerGroup.objects.all(),
required=False,
null_option='None',
label=_('Owner Group'),
)
owner_id = DynamicModelMultipleChoiceField(
queryset=Owner.objects.all(),
required=False,
null_option='None',
query_params={
'group_id': '$owner_group_id'
},
label=_('Owner'), label=_('Owner'),
) )
+49 -88
View File
@@ -1,3 +1,5 @@
from functools import cache
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from netbox.registry import registry from netbox.registry import registry
@@ -409,60 +411,10 @@ ADMIN_MENU = Menu(
MenuGroup( MenuGroup(
label=_('Authentication'), label=_('Authentication'),
items=( items=(
MenuItem( get_model_item('users', 'user', _('Users')),
link='users:user_list', get_model_item('users', 'group', _('Groups')),
link_text=_('Users'), get_model_item('users', 'token', _('API Tokens')),
staff_only=True, get_model_item('users', 'objectpermission', _('Permissions'), actions=['add']),
permissions=['users.view_user'],
buttons=(
MenuItemButton(
link='users:user_add',
title='Add',
icon_class='mdi mdi-plus-thick',
permissions=['users.add_user']
),
MenuItemButton(
link='users:user_bulk_import',
title='Import',
icon_class='mdi mdi-upload',
permissions=['users.add_user']
)
)
),
MenuItem(
link='users:group_list',
link_text=_('Groups'),
staff_only=True,
permissions=['users.view_group'],
buttons=(
MenuItemButton(
link='users:group_add',
title='Add',
icon_class='mdi mdi-plus-thick',
permissions=['users.add_group']
),
MenuItemButton(
link='users:group_bulk_import',
title='Import',
icon_class='mdi mdi-upload',
permissions=['users.add_group']
)
)
),
MenuItem(
link='users:token_list',
link_text=_('API Tokens'),
staff_only=True,
permissions=['users.view_token'],
buttons=get_model_buttons('users', 'token')
),
MenuItem(
link='users:objectpermission_list',
link_text=_('Permissions'),
staff_only=True,
permissions=['users.view_objectpermission'],
buttons=get_model_buttons('users', 'objectpermission', actions=['add'])
),
), ),
), ),
MenuGroup( MenuGroup(
@@ -501,40 +453,49 @@ ADMIN_MENU = Menu(
), ),
) )
MENUS = [
ORGANIZATION_MENU,
RACKS_MENU,
DEVICES_MENU,
CONNECTIONS_MENU,
WIRELESS_MENU,
IPAM_MENU,
VPN_MENU,
VIRTUALIZATION_MENU,
CIRCUITS_MENU,
POWER_MENU,
PROVISIONING_MENU,
CUSTOMIZATION_MENU,
OPERATIONS_MENU,
]
# Add top-level plugin menus @cache
for menu in registry['plugins']['menus']: def get_menus():
MENUS.append(menu) """
Dynamically build and return the list of navigation menus.
# Add the default "plugins" menu This ensures plugin menus registered during app initialization are included.
if registry['plugins']['menu_items']: The result is cached since menus don't change without a Django restart.
"""
# Build the default plugins menu menus = [
groups = [ ORGANIZATION_MENU,
MenuGroup(label=label, items=items) RACKS_MENU,
for label, items in registry['plugins']['menu_items'].items() DEVICES_MENU,
CONNECTIONS_MENU,
WIRELESS_MENU,
IPAM_MENU,
VPN_MENU,
VIRTUALIZATION_MENU,
CIRCUITS_MENU,
POWER_MENU,
PROVISIONING_MENU,
CUSTOMIZATION_MENU,
OPERATIONS_MENU,
] ]
plugins_menu = Menu(
label=_("Plugins"),
icon_class="mdi mdi-puzzle",
groups=groups
)
MENUS.append(plugins_menu)
# Add the admin menu last # Add top-level plugin menus
MENUS.append(ADMIN_MENU) for menu in registry['plugins']['menus']:
menus.append(menu)
# Add the default "plugins" menu
if registry['plugins']['menu_items']:
# Build the default plugins menu
groups = [
MenuGroup(label=label, items=items)
for label, items in registry['plugins']['menu_items'].items()
]
plugins_menu = Menu(
label=_("Plugins"),
icon_class="mdi mdi-puzzle",
groups=groups
)
menus.append(plugins_menu)
# Add the admin menu last
menus.append(ADMIN_MENU)
return menus
+18 -3
View File
@@ -271,9 +271,14 @@ class NetBoxTable(BaseTable):
class PrimaryModelTable(NetBoxTable): class PrimaryModelTable(NetBoxTable):
owner_group = tables.Column(
accessor='owner__group',
linkify=True,
verbose_name=_('Owner Group'),
)
owner = tables.Column( owner = tables.Column(
linkify=True, linkify=True,
verbose_name=_('Owner') verbose_name=_('Owner'),
) )
comments = columns.MarkdownColumn( comments = columns.MarkdownColumn(
verbose_name=_('Comments'), verbose_name=_('Comments'),
@@ -281,9 +286,14 @@ class PrimaryModelTable(NetBoxTable):
class OrganizationalModelTable(NetBoxTable): class OrganizationalModelTable(NetBoxTable):
owner_group = tables.Column(
accessor='owner__group',
linkify=True,
verbose_name=_('Owner Group'),
)
owner = tables.Column( owner = tables.Column(
linkify=True, linkify=True,
verbose_name=_('Owner') verbose_name=_('Owner'),
) )
comments = columns.MarkdownColumn( comments = columns.MarkdownColumn(
verbose_name=_('Comments'), verbose_name=_('Comments'),
@@ -291,9 +301,14 @@ class OrganizationalModelTable(NetBoxTable):
class NestedGroupModelTable(NetBoxTable): class NestedGroupModelTable(NetBoxTable):
owner_group = tables.Column(
accessor='owner__group',
linkify=True,
verbose_name=_('Owner Group'),
)
owner = tables.Column( owner = tables.Column(
linkify=True, linkify=True,
verbose_name=_('Owner') verbose_name=_('Owner'),
) )
name = columns.MPTTColumn( name = columns.MPTTColumn(
verbose_name=_('Name'), verbose_name=_('Name'),
+1 -1
View File
@@ -103,7 +103,7 @@ class TextAttr(ObjectAttribute):
def get_value(self, obj): def get_value(self, obj):
value = resolve_attr_path(obj, self.accessor) value = resolve_attr_path(obj, self.accessor)
# Apply format string (if any) # Apply format string (if any)
if value and self.format_string: if value is not None and value != '' and self.format_string:
return self.format_string.format(value) return self.format_string.format(value)
return value return value
+11 -1
View File
@@ -1,5 +1,6 @@
import re import re
from collections import namedtuple from collections import namedtuple
import logging
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
@@ -28,6 +29,8 @@ __all__ = (
'SearchView', 'SearchView',
) )
logger = logging.getLogger(f'netbox.{__name__}')
Link = namedtuple('Link', ('label', 'viewname', 'permission', 'count')) Link = namedtuple('Link', ('label', 'viewname', 'permission', 'count'))
@@ -50,7 +53,14 @@ class HomeView(ConditionalLoginRequiredMixin, View):
# Check whether a new release is available. (Only for superusers.) # Check whether a new release is available. (Only for superusers.)
new_release = None new_release = None
if request.user.is_superuser: if request.user.is_superuser:
latest_release = cache.get('latest_release') # cache.get() can raise an exception if the cached value can't be unpickled after dependency upgrades
try:
latest_release = cache.get('latest_release')
except Exception:
logger.debug("Failed to read 'latest_release' from cache; deleting key", exc_info=True)
cache.delete('latest_release')
latest_release = None
if latest_release: if latest_release:
release_version, release_url = latest_release release_version, release_url = latest_release
if release_version > version.parse(settings.RELEASE.version): if release_version > version.parse(settings.RELEASE.version):
+4
View File
@@ -59,6 +59,10 @@
<th scope="row">{% trans "Completed" %}</th> <th scope="row">{% trans "Completed" %}</th>
<td>{{ object.completed|isodatetime|placeholder }}</td> <td>{{ object.completed|isodatetime|placeholder }}</td>
</tr> </tr>
<tr>
<th scope="row">{% trans "Queue" %}</th>
<td>{{ object.queue_name|placeholder }}</td>
</tr>
</table> </table>
</div> </div>
</div> </div>
+2 -1
View File
@@ -101,8 +101,9 @@
<div class="field-group mb-5"> <div class="field-group mb-5">
<div class="row"> <div class="row">
<h2 class="col-9 offset-3">{% trans "Owner" %}</h2> <h2 class="col-9 offset-3">{% trans "Ownership" %}</h2>
</div> </div>
{% render_field form.owner_group %}
{% render_field form.owner %} {% render_field form.owner %}
</div> </div>
@@ -0,0 +1 @@
{{ value|floatformat }}U
+2 -1
View File
@@ -80,8 +80,9 @@
<div class="field-group mb-5"> <div class="field-group mb-5">
<div class="row"> <div class="row">
<h2 class="col-9 offset-3">{% trans "Owner" %}</h2> <h2 class="col-9 offset-3">{% trans "Ownership" %}</h2>
</div> </div>
{% render_field form.owner_group %}
{% render_field form.owner %} {% render_field form.owner %}
</div> </div>
+7 -27
View File
@@ -370,33 +370,6 @@
</table> </table>
</div> </div>
{% endif %} {% endif %}
{% if object.is_lag %}
<div class="card">
<h2 class="card-header">{% trans "LAG Members" %}</h2>
<table class="table table-hover">
<thead>
<tr>
<th>{% trans "Parent" %}</th>
<th>{% trans "Interface" %}</th>
<th>{% trans "Type" %}</th>
</tr>
</thead>
<tbody>
{% for member in object.member_interfaces.all %}
<tr>
<td>{{ member.device|linkify }}</td>
<td>{{ member|linkify }}</td>
<td>{{ member.get_type_display }}</td>
</tr>
{% empty %}
<tr>
<td colspan="3" class="text-muted">{% trans "No member interfaces" %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% include 'ipam/inc/panels/fhrp_groups.html' %} {% include 'ipam/inc/panels/fhrp_groups.html' %}
{% include 'dcim/inc/panels/inventory_items.html' %} {% include 'dcim/inc/panels/inventory_items.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
@@ -441,6 +414,13 @@
{% include 'inc/panel_table.html' with table=vlan_table heading="VLANs" %} {% include 'inc/panel_table.html' with table=vlan_table heading="VLANs" %}
</div> </div>
</div> </div>
{% if object.is_lag %}
<div class="row mb-3">
<div class="col col-md-12">
{% include 'inc/panel_table.html' with table=lag_interfaces_table heading="LAG Members" %}
</div>
</div>
{% endif %}
{% if object.vlan_translation_policy %} {% if object.vlan_translation_policy %}
<div class="row mb-3"> <div class="row mb-3">
<div class="col col-md-12"> <div class="col col-md-12">
@@ -36,8 +36,9 @@
<div class="field-group mb-5"> <div class="field-group mb-5">
<div class="row"> <div class="row">
<h2 class="col-9 offset-3">{% trans "Owner" %}</h2> <h2 class="col-9 offset-3">{% trans "Ownership" %}</h2>
</div> </div>
{% render_field vc_form.owner_group %}
{% render_field vc_form.owner %} {% render_field vc_form.owner %}
</div> </div>
@@ -121,6 +121,7 @@
<div class="alert alert-warning" role="alert"> <div class="alert alert-warning" role="alert">
<i class="mdi mdi-alert"></i> <i class="mdi mdi-alert"></i>
{% blocktrans with module=module.name %}Could not load scripts from module {{ module }}{% endblocktrans %} {% blocktrans with module=module.name %}Could not load scripts from module {{ module }}{% endblocktrans %}
{% if module.error %}<code>{{ module.error }}</code>{% endif %}
</div> </div>
</div> </div>
{% endif %} {% endif %}
+4 -1
View File
@@ -62,8 +62,11 @@ Context:
{% if form.owner %} {% if form.owner %}
<div class="field-group mb-5"> <div class="field-group mb-5">
<div class="row"> <div class="row">
<h2 class="col-9 offset-3">{% trans "Owner" %}</h2> <h2 class="col-9 offset-3">{% trans "Ownership" %}</h2>
</div> </div>
{% if form.owner_group %}
{% render_field form.owner_group %}
{% endif %}
{% render_field form.owner bulk_nullable=True %} {% render_field form.owner bulk_nullable=True %}
</div> </div>
{% endif %} {% endif %}
+3
View File
@@ -27,6 +27,9 @@
<div class="row"> <div class="row">
<h2 class="col-9 offset-3">{% trans "Ownership" %}</h2> <h2 class="col-9 offset-3">{% trans "Ownership" %}</h2>
</div> </div>
{% if form.owner_group %}
{% render_field form.owner_group %}
{% endif %}
{% render_field form.owner %} {% render_field form.owner %}
</div> </div>
{% endif %} {% endif %}
+1 -1
View File
@@ -6,7 +6,7 @@
{% include 'ipam/inc/max_depth.html' %} {% include 'ipam/inc/max_depth.html' %}
{% include 'ipam/inc/max_length.html' %} {% include 'ipam/inc/max_length.html' %}
{% if perms.ipam.add_prefix and first_available_prefix %} {% if perms.ipam.add_prefix and first_available_prefix %}
<a href="{% url 'ipam:prefix_add' %}?prefix={{ first_available_prefix }}&vrf={{ object.vrf.pk }}&site={{ object.site.pk }}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}" class="btn btn-primary"> <a href="{% url 'ipam:prefix_add' %}?prefix={{ first_available_prefix }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.scope %}&scope_type={{ object.scope_type.pk }}&scope={{ object.scope.pk }}{% endif %}{% if object.tenant %}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}{% endif %}" class="btn btn-primary">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Prefix" %} <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Prefix" %}
</a> </a>
{% endif %} {% endif %}
+2 -1
View File
@@ -67,8 +67,9 @@
<div class="field-group mb-5"> <div class="field-group mb-5">
<div class="row"> <div class="row">
<h2 class="col-9 offset-3">{% trans "Owner" %}</h2> <h2 class="col-9 offset-3">{% trans "Ownership" %}</h2>
</div> </div>
{% render_field form.owner_group %}
{% render_field form.owner %} {% render_field form.owner %}
</div> </div>
+10 -5
View File
@@ -31,8 +31,9 @@ __all__ = (
class TenantGroupFilterForm(NestedGroupModelFilterSetForm): class TenantGroupFilterForm(NestedGroupModelFilterSetForm):
model = TenantGroup model = TenantGroup
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('parent_id', name=_('Tenant Group')), FieldSet('parent_id', name=_('Tenant Group')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
parent_id = DynamicModelMultipleChoiceField( parent_id = DynamicModelMultipleChoiceField(
queryset=TenantGroup.objects.all(), queryset=TenantGroup.objects.all(),
@@ -45,8 +46,9 @@ class TenantGroupFilterForm(NestedGroupModelFilterSetForm):
class TenantFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm): class TenantFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
model = Tenant model = Tenant
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('group_id', name=_('Tenant')), FieldSet('group_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')) FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts'))
) )
group_id = DynamicModelMultipleChoiceField( group_id = DynamicModelMultipleChoiceField(
@@ -65,8 +67,9 @@ class TenantFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
class ContactGroupFilterForm(NestedGroupModelFilterSetForm): class ContactGroupFilterForm(NestedGroupModelFilterSetForm):
model = ContactGroup model = ContactGroup
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('parent_id', name=_('Contact Group')), FieldSet('parent_id', name=_('Contact Group')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
parent_id = DynamicModelMultipleChoiceField( parent_id = DynamicModelMultipleChoiceField(
queryset=ContactGroup.objects.all(), queryset=ContactGroup.objects.all(),
@@ -79,7 +82,8 @@ class ContactGroupFilterForm(NestedGroupModelFilterSetForm):
class ContactRoleFilterForm(OrganizationalModelFilterSetForm): class ContactRoleFilterForm(OrganizationalModelFilterSetForm):
model = ContactRole model = ContactRole
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
tag = TagFilterField(model) tag = TagFilterField(model)
@@ -87,8 +91,9 @@ class ContactRoleFilterForm(OrganizationalModelFilterSetForm):
class ContactFilterForm(PrimaryModelFilterSetForm): class ContactFilterForm(PrimaryModelFilterSetForm):
model = Contact model = Contact
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('group_id', name=_('Contact')), FieldSet('group_id', name=_('Contact')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
group_id = DynamicModelMultipleChoiceField( group_id = DynamicModelMultipleChoiceField(
queryset=ContactGroup.objects.all(), queryset=ContactGroup.objects.all(),
File diff suppressed because it is too large Load Diff
+12 -1
View File
@@ -1,7 +1,7 @@
import django_filters import django_filters
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from users.models import Owner from users.models import OwnerGroup, Owner
__all__ = ( __all__ = (
'OwnerFilterMixin', 'OwnerFilterMixin',
@@ -12,6 +12,17 @@ class OwnerFilterMixin(django_filters.FilterSet):
""" """
Adds owner & owner_id filters for models which inherit from OwnerMixin. Adds owner & owner_id filters for models which inherit from OwnerMixin.
""" """
owner_group_id = django_filters.ModelMultipleChoiceFilter(
queryset=OwnerGroup.objects.all(),
field_name='owner__group',
label=_('Owner Group (ID)'),
)
owner_group = django_filters.ModelMultipleChoiceFilter(
queryset=OwnerGroup.objects.all(),
field_name='owner__group__name',
to_field_name='name',
label=_('Owner Group (name)'),
)
owner_id = django_filters.ModelMultipleChoiceFilter( owner_id = django_filters.ModelMultipleChoiceFilter(
queryset=Owner.objects.all(), queryset=Owner.objects.all(),
label=_('Owner (ID)'), label=_('Owner (ID)'),
+18 -9
View File
@@ -93,18 +93,23 @@ def get_view_name(view):
return drf_get_view_name(view) return drf_get_view_name(view)
def get_prefetches_for_serializer(serializer_class, fields_to_include=None): def get_prefetches_for_serializer(serializer_class, fields=None, omit=None):
""" """
Compile and return a list of fields which should be prefetched on the queryset for a serializer. Compile and return a list of fields which should be prefetched on the queryset for a serializer.
""" """
if fields is not None and omit is not None:
raise TypeError("Cannot specify both 'fields' and 'omit' parameters.")
model = serializer_class.Meta.model model = serializer_class.Meta.model
# If fields are not specified, default to all # If fields are not specified, default to all
if not fields_to_include: fields_to_include = fields or serializer_class.Meta.fields
fields_to_include = serializer_class.Meta.fields fields_to_omit = omit or []
prefetch_fields = [] prefetch_fields = []
for field_name in fields_to_include: for field_name in fields_to_include:
if field_name in fields_to_omit:
continue
serializer_field = serializer_class._declared_fields.get(field_name) serializer_field = serializer_class._declared_fields.get(field_name)
# Determine the name of the model field referenced by the serializer field # Determine the name of the model field referenced by the serializer field
@@ -132,19 +137,23 @@ def get_prefetches_for_serializer(serializer_class, fields_to_include=None):
return prefetch_fields return prefetch_fields
def get_annotations_for_serializer(serializer_class, fields_to_include=None): def get_annotations_for_serializer(serializer_class, fields=None, omit=None):
""" """
Return a mapping of field names to annotations to be applied to the queryset for a serializer. Return a mapping of field names to annotations to be applied to the queryset for a serializer.
""" """
annotations = {} if fields is not None and omit is not None:
raise TypeError("Cannot specify both 'fields' and 'omit' parameters.")
# If specific fields are not specified, default to all
if not fields_to_include:
fields_to_include = serializer_class.Meta.fields
model = serializer_class.Meta.model model = serializer_class.Meta.model
# If fields are not specified, default to all
fields_to_include = fields or serializer_class.Meta.fields
fields_to_omit = omit or []
annotations = {}
for field_name, field in serializer_class._declared_fields.items(): for field_name, field in serializer_class._declared_fields.items():
if field_name in fields_to_omit:
continue
if field_name in fields_to_include and type(field) is RelatedObjectCountField: if field_name in fields_to_include and type(field) is RelatedObjectCountField:
related_field = getattr(model, field.relation).field related_field = getattr(model, field.relation).field
annotations[field_name] = count_related(related_field.model, related_field.name) annotations[field_name] = count_related(related_field.model, related_field.name)
+9 -7
View File
@@ -3,6 +3,7 @@ import enum
from django.conf import settings from django.conf import settings
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from utilities.data import get_config_value_ci
from utilities.string import enum_key from utilities.string import enum_key
__all__ = ( __all__ = (
@@ -24,13 +25,14 @@ class ChoiceSetMeta(type):
).format(name=name) ).format(name=name)
app = attrs['__module__'].split('.', 1)[0] app = attrs['__module__'].split('.', 1)[0]
replace_key = f'{app}.{key}' replace_key = f'{app}.{key}'
extend_key = f'{replace_key}+' if replace_key else None replace_choices = get_config_value_ci(settings.FIELD_CHOICES, replace_key)
if replace_key and replace_key in settings.FIELD_CHOICES: if replace_choices is not None:
# Replace the stock choices attrs['CHOICES'] = replace_choices
attrs['CHOICES'] = settings.FIELD_CHOICES[replace_key] else:
elif extend_key and extend_key in settings.FIELD_CHOICES: extend_key = f'{replace_key}+'
# Extend the stock choices extend_choices = get_config_value_ci(settings.FIELD_CHOICES, extend_key)
attrs['CHOICES'].extend(settings.FIELD_CHOICES[extend_key]) if extend_choices is not None:
attrs['CHOICES'].extend(extend_choices)
# Define choice tuples and color maps # Define choice tuples and color maps
attrs['_choices'] = [] attrs['_choices'] = []
+14
View File
@@ -10,6 +10,7 @@ __all__ = (
'deepmerge', 'deepmerge',
'drange', 'drange',
'flatten_dict', 'flatten_dict',
'get_config_value_ci',
'ranges_to_string', 'ranges_to_string',
'ranges_to_string_list', 'ranges_to_string_list',
'resolve_attr_path', 'resolve_attr_path',
@@ -22,6 +23,19 @@ __all__ = (
# Dictionary utilities # Dictionary utilities
# #
def get_config_value_ci(config_dict, key, default=None):
"""
Retrieve a value from a dictionary using case-insensitive key matching.
"""
if key in config_dict:
return config_dict[key]
key_lower = key.lower()
for config_key, value in config_dict.items():
if config_key.lower() == key_lower:
return value
return default
def deepmerge(original, new): def deepmerge(original, new):
""" """
Deep merge two dictionaries (new into original) and return a new dict Deep merge two dictionaries (new into original) and return a new dict
+2 -2
View File
@@ -1,6 +1,6 @@
from django import template from django import template
from netbox.navigation.menu import MENUS from netbox.navigation.menu import get_menus
__all__ = ( __all__ = (
'nav', 'nav',
@@ -19,7 +19,7 @@ def nav(context):
nav_items = [] nav_items = []
# Construct the navigation menu based upon the current user's permissions # Construct the navigation menu based upon the current user's permissions
for menu in MENUS: for menu in get_menus():
groups = [] groups = []
for group in menu.groups: for group in menu.groups:
items = [] items = []
+27 -1
View File
@@ -1,4 +1,4 @@
from django.test import TestCase from django.test import TestCase, override_settings
from utilities.choices import ChoiceSet from utilities.choices import ChoiceSet
@@ -30,3 +30,29 @@ class ChoiceSetTestCase(TestCase):
def test_values(self): def test_values(self):
self.assertListEqual(ExampleChoices.values(), ['a', 'b', 'c', 1, 2, 3]) self.assertListEqual(ExampleChoices.values(), ['a', 'b', 'c', 1, 2, 3])
class FieldChoicesCaseInsensitiveTestCase(TestCase):
"""
Integration tests for FIELD_CHOICES case-insensitive key lookup.
"""
def test_replace_choices_with_different_casing(self):
"""Test that replacement works when config key casing differs."""
# Config uses lowercase, but code constructs PascalCase key
with override_settings(FIELD_CHOICES={'utilities.teststatus': [('new', 'New')]}):
class TestStatusChoices(ChoiceSet):
key = 'TestStatus' # Code will look up 'utilities.TestStatus'
CHOICES = [('old', 'Old')]
self.assertEqual(TestStatusChoices.CHOICES, [('new', 'New')])
def test_extend_choices_with_different_casing(self):
"""Test that extension works with the + suffix under casing differences."""
# Config uses lowercase with + suffix
with override_settings(FIELD_CHOICES={'utilities.teststatus+': [('extra', 'Extra')]}):
class TestStatusChoices(ChoiceSet):
key = 'TestStatus' # Code will look up 'utilities.TestStatus+'
CHOICES = [('base', 'Base')]
self.assertEqual(TestStatusChoices.CHOICES, [('base', 'Base'), ('extra', 'Extra')])
+23
View File
@@ -2,6 +2,7 @@ from django.db.backends.postgresql.psycopg_any import NumericRange
from django.test import TestCase from django.test import TestCase
from utilities.data import ( from utilities.data import (
check_ranges_overlap, check_ranges_overlap,
get_config_value_ci,
ranges_to_string, ranges_to_string,
ranges_to_string_list, ranges_to_string_list,
string_to_ranges, string_to_ranges,
@@ -96,3 +97,25 @@ class RangeFunctionsTestCase(TestCase):
string_to_ranges('2-10, a-b'), string_to_ranges('2-10, a-b'),
None # Fails to convert None # Fails to convert
) )
class GetConfigValueCITestCase(TestCase):
def test_exact_match(self):
config = {'dcim.site': 'value1', 'dcim.Device': 'value2'}
self.assertEqual(get_config_value_ci(config, 'dcim.site'), 'value1')
self.assertEqual(get_config_value_ci(config, 'dcim.Device'), 'value2')
def test_case_insensitive_match(self):
config = {'dcim.Site': 'value1', 'ipam.IPAddress': 'value2'}
self.assertEqual(get_config_value_ci(config, 'dcim.site'), 'value1')
self.assertEqual(get_config_value_ci(config, 'ipam.ipaddress'), 'value2')
def test_default_value(self):
config = {'dcim.site': 'value1'}
self.assertIsNone(get_config_value_ci(config, 'nonexistent'))
self.assertEqual(get_config_value_ci(config, 'nonexistent', default=[]), [])
def test_empty_dict(self):
self.assertIsNone(get_config_value_ci({}, 'any.key'))
self.assertEqual(get_config_value_ci({}, 'any.key', default=[]), [])
+16 -20
View File
@@ -7,10 +7,10 @@ from extras.forms import LocalConfigContextFilterForm
from extras.models import ConfigTemplate from extras.models import ConfigTemplate
from ipam.models import VRF, VLANTranslationPolicy from ipam.models import VRF, VLANTranslationPolicy
from netbox.forms import NetBoxModelFilterSetForm, OrganizationalModelFilterSetForm, PrimaryModelFilterSetForm from netbox.forms import NetBoxModelFilterSetForm, OrganizationalModelFilterSetForm, PrimaryModelFilterSetForm
from netbox.forms.mixins import OwnerFilterMixin
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
from users.models import Owner
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField from utilities.forms.fields import DynamicModelMultipleChoiceField, TagFilterField
from utilities.forms.rendering import FieldSet from utilities.forms.rendering import FieldSet
from virtualization.choices import * from virtualization.choices import *
from virtualization.models import * from virtualization.models import *
@@ -29,7 +29,8 @@ __all__ = (
class ClusterTypeFilterForm(OrganizationalModelFilterSetForm): class ClusterTypeFilterForm(OrganizationalModelFilterSetForm):
model = ClusterType model = ClusterType
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
tag = TagFilterField(model) tag = TagFilterField(model)
@@ -38,7 +39,8 @@ class ClusterGroupFilterForm(ContactModelFilterForm, OrganizationalModelFilterSe
model = ClusterGroup model = ClusterGroup
tag = TagFilterField(model) tag = TagFilterField(model)
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
) )
@@ -46,10 +48,11 @@ class ClusterGroupFilterForm(ContactModelFilterForm, OrganizationalModelFilterSe
class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilterSetForm): class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilterSetForm):
model = Cluster model = Cluster
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('group_id', 'type_id', 'status', name=_('Attributes')), FieldSet('group_id', 'type_id', 'status', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Scope')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Scope')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
) )
selector_fields = ('filter_id', 'q', 'group_id') selector_fields = ('filter_id', 'q', 'group_id')
@@ -105,7 +108,7 @@ class VirtualMachineFilterForm(
): ):
model = VirtualMachine model = VirtualMachine
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('cluster_group_id', 'cluster_type_id', 'cluster_id', 'device_id', name=_('Cluster')), FieldSet('cluster_group_id', 'cluster_type_id', 'cluster_id', 'device_id', name=_('Cluster')),
FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
FieldSet( FieldSet(
@@ -113,6 +116,7 @@ class VirtualMachineFilterForm(
'local_context_data', 'serial', name=_('Attributes') 'local_context_data', 'serial', name=_('Attributes')
), ),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
) )
cluster_group_id = DynamicModelMultipleChoiceField( cluster_group_id = DynamicModelMultipleChoiceField(
@@ -205,14 +209,15 @@ class VirtualMachineFilterForm(
tag = TagFilterField(model) tag = TagFilterField(model)
class VMInterfaceFilterForm(NetBoxModelFilterSetForm): class VMInterfaceFilterForm(OwnerFilterMixin, NetBoxModelFilterSetForm):
model = VMInterface model = VMInterface
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('cluster_id', 'virtual_machine_id', name=_('Virtual Machine')), FieldSet('cluster_id', 'virtual_machine_id', name=_('Virtual Machine')),
FieldSet('enabled', name=_('Attributes')), FieldSet('enabled', name=_('Attributes')),
FieldSet('vrf_id', 'l2vpn_id', 'mac_address', name=_('Addressing')), FieldSet('vrf_id', 'l2vpn_id', 'mac_address', name=_('Addressing')),
FieldSet('mode', 'vlan_translation_policy_id', name=_('802.1Q Switching')), FieldSet('mode', 'vlan_translation_policy_id', name=_('802.1Q Switching')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
selector_fields = ('filter_id', 'q', 'virtual_machine_id') selector_fields = ('filter_id', 'q', 'virtual_machine_id')
cluster_id = DynamicModelMultipleChoiceField( cluster_id = DynamicModelMultipleChoiceField(
@@ -259,20 +264,16 @@ class VMInterfaceFilterForm(NetBoxModelFilterSetForm):
required=False, required=False,
label=_('VLAN Translation Policy') label=_('VLAN Translation Policy')
) )
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
tag = TagFilterField(model) tag = TagFilterField(model)
class VirtualDiskFilterForm(NetBoxModelFilterSetForm): class VirtualDiskFilterForm(OwnerFilterMixin, NetBoxModelFilterSetForm):
model = VirtualDisk model = VirtualDisk
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('virtual_machine_id', name=_('Virtual Machine')), FieldSet('virtual_machine_id', name=_('Virtual Machine')),
FieldSet('size', name=_('Attributes')), FieldSet('size', name=_('Attributes')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
virtual_machine_id = DynamicModelMultipleChoiceField( virtual_machine_id = DynamicModelMultipleChoiceField(
queryset=VirtualMachine.objects.all(), queryset=VirtualMachine.objects.all(),
@@ -284,9 +285,4 @@ class VirtualDiskFilterForm(NetBoxModelFilterSetForm):
required=False, required=False,
min_value=1 min_value=1
) )
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
tag = TagFilterField(model) tag = TagFilterField(model)
+16 -8
View File
@@ -33,7 +33,8 @@ __all__ = (
class TunnelGroupFilterForm(ContactModelFilterForm, OrganizationalModelFilterSetForm): class TunnelGroupFilterForm(ContactModelFilterForm, OrganizationalModelFilterSetForm):
model = TunnelGroup model = TunnelGroup
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
) )
tag = TagFilterField(model) tag = TagFilterField(model)
@@ -42,10 +43,11 @@ class TunnelGroupFilterForm(ContactModelFilterForm, OrganizationalModelFilterSet
class TunnelFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm): class TunnelFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm):
model = Tunnel model = Tunnel
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('status', 'encapsulation', 'tunnel_id', name=_('Tunnel')), FieldSet('status', 'encapsulation', 'tunnel_id', name=_('Tunnel')),
FieldSet('ipsec_profile_id', name=_('Security')), FieldSet('ipsec_profile_id', name=_('Security')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenancy')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenancy')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
) )
status = forms.MultipleChoiceField( status = forms.MultipleChoiceField(
@@ -97,10 +99,11 @@ class TunnelTerminationFilterForm(NetBoxModelFilterSetForm):
class IKEProposalFilterForm(PrimaryModelFilterSetForm): class IKEProposalFilterForm(PrimaryModelFilterSetForm):
model = IKEProposal model = IKEProposal
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet( FieldSet(
'authentication_method', 'encryption_algorithm', 'authentication_algorithm', 'group', name=_('Parameters') 'authentication_method', 'encryption_algorithm', 'authentication_algorithm', 'group', name=_('Parameters')
), ),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
authentication_method = forms.MultipleChoiceField( authentication_method = forms.MultipleChoiceField(
label=_('Authentication method'), label=_('Authentication method'),
@@ -128,8 +131,9 @@ class IKEProposalFilterForm(PrimaryModelFilterSetForm):
class IKEPolicyFilterForm(PrimaryModelFilterSetForm): class IKEPolicyFilterForm(PrimaryModelFilterSetForm):
model = IKEPolicy model = IKEPolicy
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('version', 'mode', 'proposal_id', name=_('Parameters')), FieldSet('version', 'mode', 'proposal_id', name=_('Parameters')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
version = forms.MultipleChoiceField( version = forms.MultipleChoiceField(
label=_('IKE version'), label=_('IKE version'),
@@ -152,8 +156,9 @@ class IKEPolicyFilterForm(PrimaryModelFilterSetForm):
class IPSecProposalFilterForm(PrimaryModelFilterSetForm): class IPSecProposalFilterForm(PrimaryModelFilterSetForm):
model = IPSecProposal model = IPSecProposal
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('encryption_algorithm', 'authentication_algorithm', name=_('Parameters')), FieldSet('encryption_algorithm', 'authentication_algorithm', name=_('Parameters')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
encryption_algorithm = forms.MultipleChoiceField( encryption_algorithm = forms.MultipleChoiceField(
label=_('Encryption algorithm'), label=_('Encryption algorithm'),
@@ -171,8 +176,9 @@ class IPSecProposalFilterForm(PrimaryModelFilterSetForm):
class IPSecPolicyFilterForm(PrimaryModelFilterSetForm): class IPSecPolicyFilterForm(PrimaryModelFilterSetForm):
model = IPSecPolicy model = IPSecPolicy
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('proposal_id', 'pfs_group', name=_('Parameters')), FieldSet('proposal_id', 'pfs_group', name=_('Parameters')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
proposal_id = DynamicModelMultipleChoiceField( proposal_id = DynamicModelMultipleChoiceField(
queryset=IKEProposal.objects.all(), queryset=IKEProposal.objects.all(),
@@ -190,8 +196,9 @@ class IPSecPolicyFilterForm(PrimaryModelFilterSetForm):
class IPSecProfileFilterForm(PrimaryModelFilterSetForm): class IPSecProfileFilterForm(PrimaryModelFilterSetForm):
model = IPSecProfile model = IPSecProfile
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('mode', 'ike_policy_id', 'ipsec_policy_id', name=_('Profile')), FieldSet('mode', 'ike_policy_id', 'ipsec_policy_id', name=_('Profile')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
mode = forms.MultipleChoiceField( mode = forms.MultipleChoiceField(
label=_('Mode'), label=_('Mode'),
@@ -214,9 +221,10 @@ class IPSecProfileFilterForm(PrimaryModelFilterSetForm):
class L2VPNFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm): class L2VPNFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm):
model = L2VPN model = L2VPN
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('type', 'status', 'import_target_id', 'export_target_id', name=_('Attributes')), FieldSet('type', 'status', 'import_target_id', 'export_target_id', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
) )
status = forms.MultipleChoiceField( status = forms.MultipleChoiceField(
+8 -5
View File
@@ -22,8 +22,9 @@ __all__ = (
class WirelessLANGroupFilterForm(NestedGroupModelFilterSetForm): class WirelessLANGroupFilterForm(NestedGroupModelFilterSetForm):
model = WirelessLANGroup model = WirelessLANGroup
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('parent_id', name=_('Wireless LAN group')), FieldSet('parent_id', name=_('Wireless LAN group')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
parent_id = DynamicModelMultipleChoiceField( parent_id = DynamicModelMultipleChoiceField(
queryset=WirelessLANGroup.objects.all(), queryset=WirelessLANGroup.objects.all(),
@@ -36,11 +37,12 @@ class WirelessLANGroupFilterForm(NestedGroupModelFilterSetForm):
class WirelessLANFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm): class WirelessLANFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = WirelessLAN model = WirelessLAN
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('ssid', 'group_id', 'status', name=_('Attributes')), FieldSet('ssid', 'group_id', 'status', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Scope')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Scope')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('auth_type', 'auth_cipher', 'auth_psk', name=_('Authentication')), FieldSet('auth_type', 'auth_cipher', 'auth_psk', name=_('Authentication')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
ssid = forms.CharField( ssid = forms.CharField(
required=False, required=False,
@@ -102,10 +104,11 @@ class WirelessLANFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
class WirelessLinkFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm): class WirelessLinkFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = WirelessLink model = WirelessLink
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('ssid', 'status', 'distance', 'distance_unit', name=_('Attributes')), FieldSet('ssid', 'status', 'distance', 'distance_unit', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('auth_type', 'auth_cipher', 'auth_psk', name=_('Authentication')), FieldSet('auth_type', 'auth_cipher', 'auth_psk', name=_('Authentication')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
ssid = forms.CharField( ssid = forms.CharField(
required=False, required=False,