Compare commits

...

27 Commits

Author SHA1 Message Date
github-actions
1a603981b2 Update source translation strings 2026-01-28 05:07:33 +00:00
Aditya Sharma
245495b2fe Closes #21228: Add image attachments support to RackType model (#21276) 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
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 2026-01-27 05:07:09 +00:00
Jeremy Stretch
8f5f91fcfe Closes #21259: Cache ObjectType results for the duration of a request (#21287) 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 2026-01-24 05:03:22 +00:00
Arthur Hanson
a9a300197a Clear Rack Face when clear Rack (#21182)
* #20383 clear rack face if no rack on edit

* #20383 clear rack face if no rack on edit

* review changes

* review changes
2026-01-23 12:26:27 -05:00
Jeremy Stretch
3dcca73ecc Fixes #21249: Avoid unneeded user query when no event rules are present (#21250) 2026-01-23 09:44:54 -06: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
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
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
58 changed files with 2025 additions and 1672 deletions

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:
```
./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)
### Python 3.7.10 | Django 3.2.5 | NetBox 3.0
### lsmodels() will show available models. Use help(<model>) for more info.
### Python v3.12.3 | Django v5.2.10 | NetBox Community v4.5.1
### lsapps() & lsmodels() will show available models. Use help(<model>) for more info.
```
The function `lsmodels()` will print a list of all available NetBox models:
```
>>> 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
@@ -114,7 +126,7 @@ Reverse relationships can be traversed as well. For example, the following will
>>> 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")

View File

@@ -15,7 +15,7 @@ Some configuration parameters may alternatively be defined either in `configurat
## 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)
* [`BANNER_BOTTOM`](./miscellaneous.md#banner_bottom)

View File

@@ -51,14 +51,14 @@ You can verify that authentication works by executing the `psql` command and pas
```no-highlight
$ psql --username netbox --password --host localhost netbox
Password for user netbox:
psql (12.5 (Ubuntu 12.5-0ubuntu0.20.04.1))
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off)
Password:
psql (16.11 (Ubuntu 16.11-0ubuntu0.24.04.1))
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, compression: off)
Type "help" for help.
netbox=> \conninfo
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
```

View File

@@ -36,7 +36,7 @@ sudo ln -s /opt/netbox-X.Y.Z/ /opt/netbox
```
!!! 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
@@ -63,12 +63,12 @@ This command should generate output similar to the following:
```
Cloning into '.'...
remote: Enumerating objects: 996, done.
remote: Counting objects: 100% (996/996), done.
remote: Compressing objects: 100% (935/935), done.
remote: Total 996 (delta 148), reused 386 (delta 34), pack-reused 0
Receiving objects: 100% (996/996), 4.26 MiB | 9.81 MiB/s, done.
Resolving deltas: 100% (148/148), done.
remote: Enumerating objects: 148317, done.
remote: Counting objects: 100% (183/183), done.
remote: Compressing objects: 100% (115/115), done.
remote: Total 148317 (delta 127), reused 68 (delta 68), pack-reused 148134 (from 3)
Receiving objects: 100% (148317/148317), 165.12 MiB | 28.71 MiB/s, 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.
@@ -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:
* `ALLOWED_HOSTS`
* `DATABASES` (or `DATABASE`)
* `API_TOKEN_PEPPERS`
* `DATABASES`
* `REDIS`
* `SECRET_KEY`
@@ -158,7 +159,7 @@ DATABASES = {
### 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.
@@ -252,7 +253,7 @@ Once NetBox has been configured, we're ready to proceed with the actual installa
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
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:
```no-highlight
Watching for file changes with StatReloader
Performing system checks...
System check identified no issues (0 silenced).
August 30, 2021 - 18:02:23
Django version 3.2.6, using settings 'netbox.settings'
Starting development server at http://127.0.0.1:8000/
January 26, 2026 - 17:00:00
Django version 5.2.10, using settings 'netbox.settings'
Starting development server at http://0.0.0.0:8000/
Quit the server with CONTROL-C.
```

View File

@@ -43,16 +43,22 @@ You should see output similar to the following:
```no-highlight
● netbox.service - NetBox WSGI Service
Loaded: loaded (/etc/systemd/system/netbox.service; enabled; vendor preset: enabled)
Active: active (running) since Mon 2021-08-30 04:02:36 UTC; 14h ago
Loaded: loaded (/etc/systemd/system/netbox.service; enabled; preset: enabled)
Active: active (running) since Mon 2026-01-26 11:00:00 CST; 7s ago
Docs: https://docs.netbox.dev/
Main PID: 1140492 (gunicorn)
Tasks: 19 (limit: 4683)
Memory: 666.2M
Main PID: 7283 (gunicorn)
Tasks: 6 (limit: 4545)
Memory: 556.1M (peak: 556.3M)
CPU: 3.387s
CGroup: /system.slice/netbox.service
├─1140492 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /va>
├─1140513 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /va>
├─1140514 /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>
├─7285 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox>
├─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.
...
```

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

View File

@@ -12,12 +12,12 @@
</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:
1. [PostgreSQL database](1-postgresql.md)
1. [Redis](2-redis.md)
2. [Redis](2-redis.md)
3. [NetBox components](3-netbox.md)
4. [Gunicorn](4a-gunicorn.md) or [uWSGI](4b-uwsgi.md)
5. [HTTP server](5-http-server.md)

View File

@@ -65,7 +65,7 @@ Download and extract the latest version:
```no-highlight
# 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
sudo tar -xzf v$NEWVER.tar.gz -C /opt
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
# 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/netbox/netbox/configuration.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 && \
sudo git fetch --tags && \
sudo git checkout v4.2.7
sudo git checkout v4.5.0
```
## 4. Run the Upgrade Script
@@ -128,7 +128,7 @@ sudo ./upgrade.sh
```
!!! 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
sudo PYTHON=/usr/bin/python3.12 ./upgrade.sh

View File

@@ -34,9 +34,10 @@ __all__ = (
class ProviderFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
model = Provider
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
FieldSet('asn_id', name=_('ASN')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
)
region_id = DynamicModelMultipleChoiceField(
@@ -69,8 +70,9 @@ class ProviderFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
class ProviderAccountFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
model = ProviderAccount
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('provider_id', 'account', name=_('Attributes')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
)
provider_id = DynamicModelMultipleChoiceField(
@@ -88,8 +90,9 @@ class ProviderAccountFilterForm(ContactModelFilterForm, PrimaryModelFilterSetFor
class ProviderNetworkFilterForm(PrimaryModelFilterSetForm):
model = ProviderNetwork
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('provider_id', 'service_id', name=_('Attributes')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
provider_id = DynamicModelMultipleChoiceField(
queryset=Provider.objects.all(),
@@ -107,8 +110,9 @@ class ProviderNetworkFilterForm(PrimaryModelFilterSetForm):
class CircuitTypeFilterForm(OrganizationalModelFilterSetForm):
model = CircuitType
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('color', name=_('Attributes')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
tag = TagFilterField(model)
@@ -121,7 +125,7 @@ class CircuitTypeFilterForm(OrganizationalModelFilterSetForm):
class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilterSetForm):
model = Circuit
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(
'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('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
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')
@@ -274,8 +279,9 @@ class CircuitTerminationFilterForm(NetBoxModelFilterSetForm):
class CircuitGroupFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm):
model = CircuitGroup
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
tag = TagFilterField(model)
@@ -312,8 +318,9 @@ class CircuitGroupAssignmentFilterForm(NetBoxModelFilterSetForm):
class VirtualCircuitTypeFilterForm(OrganizationalModelFilterSetForm):
model = VirtualCircuitType
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('color', name=_('Attributes')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
tag = TagFilterField(model)
@@ -326,10 +333,11 @@ class VirtualCircuitTypeFilterForm(OrganizationalModelFilterSetForm):
class VirtualCircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilterSetForm):
model = VirtualCircuit
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('type_id', 'status', name=_('Attributes')),
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')
provider_id = DynamicModelMultipleChoiceField(

View File

@@ -26,8 +26,9 @@ __all__ = (
class DataSourceFilterForm(PrimaryModelFilterSetForm):
model = DataSource
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('type', 'status', 'enabled', 'sync_interval', name=_('Data Source')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
type = forms.MultipleChoiceField(
label=_('Type'),

View File

@@ -9,6 +9,7 @@ from django.db import connection, models
from django.db.models import Q
from django.utils.translation import gettext as _
from netbox.context import query_cache
from netbox.plugins import PluginConfig
from netbox.registry import registry
from utilities.string import title
@@ -70,6 +71,12 @@ class ObjectTypeManager(models.Manager):
"""
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
# If the ObjectType table has not yet been provisioned (e.g. because we're in a pre-v4.4 migration),
# fall back to ContentType.
@@ -96,6 +103,10 @@ class ObjectTypeManager(models.Manager):
features=get_model_features(model),
)[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
def get_for_models(self, *models, for_concrete_models=True):

View File

@@ -12,11 +12,12 @@ from netbox.forms import (
NestedGroupModelFilterSetForm, NetBoxModelFilterSetForm, OrganizationalModelFilterSetForm,
PrimaryModelFilterSetForm,
)
from netbox.forms.mixins import OwnerFilterMixin
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
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.fields import ColorField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField
from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import NumberWithOptions
from virtualization.models import Cluster, ClusterGroup, VirtualMachine
@@ -70,11 +71,11 @@ __all__ = (
'SiteFilterForm',
'SiteGroupFilterForm',
'VirtualChassisFilterForm',
'VirtualDeviceContextFilterForm'
'VirtualDeviceContextFilterForm',
)
class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
class DeviceComponentFilterForm(OwnerFilterMixin, NetBoxModelFilterSetForm):
name = forms.CharField(
label=_('Name'),
required=False
@@ -157,18 +158,14 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
required=False,
label=_('Device Status'),
)
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
class RegionFilterForm(ContactModelFilterForm, NestedGroupModelFilterSetForm):
model = Region
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('parent_id', name=_('Region')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts'))
)
parent_id = DynamicModelMultipleChoiceField(
@@ -182,8 +179,9 @@ class RegionFilterForm(ContactModelFilterForm, NestedGroupModelFilterSetForm):
class SiteGroupFilterForm(ContactModelFilterForm, NestedGroupModelFilterSetForm):
model = SiteGroup
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('parent_id', name=_('Site Group')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts'))
)
parent_id = DynamicModelMultipleChoiceField(
@@ -197,9 +195,10 @@ class SiteGroupFilterForm(ContactModelFilterForm, NestedGroupModelFilterSetForm)
class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilterSetForm):
model = Site
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('status', 'region_id', 'group_id', 'asn_id', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
)
selector_fields = ('filter_id', 'q', 'region_id', 'group_id')
@@ -229,9 +228,10 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilt
class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NestedGroupModelFilterSetForm):
model = Location
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('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
)
region_id = DynamicModelMultipleChoiceField(
@@ -277,7 +277,8 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NestedGroupM
class RackRoleFilterForm(OrganizationalModelFilterSetForm):
model = RackRole
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
tag = TagFilterField(model)
@@ -328,10 +329,11 @@ class RackBaseFilterForm(PrimaryModelFilterSetForm):
class RackTypeFilterForm(RackBaseFilterForm):
model = RackType
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('starting_unit', 'desc_units', name=_('Numbering')),
FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
selector_fields = ('filter_id', 'q', 'manufacturer_id')
manufacturer_id = DynamicModelMultipleChoiceField(
@@ -350,13 +352,14 @@ class RackTypeFilterForm(RackBaseFilterForm):
class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, RackBaseFilterForm):
model = Rack
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('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('status', 'role_id', 'manufacturer_id', 'rack_type_id', 'serial', 'asset_tag', name=_('Rack')),
FieldSet('form_factor', 'width', 'u_height', 'airflow', name=_('Hardware')),
FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
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')),
)
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('status', 'role_id', name=_('Function')),
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('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(
queryset=Rack.objects.all(),
@@ -451,10 +455,11 @@ class RackElevationFilterForm(RackFilterForm):
class RackReservationFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = RackReservation
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('status', 'user_id', name=_('Reservation')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Rack')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
@@ -509,7 +514,8 @@ class RackReservationFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
class ManufacturerFilterForm(ContactModelFilterForm, OrganizationalModelFilterSetForm):
model = Manufacturer
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'))
)
tag = TagFilterField(model)
@@ -518,7 +524,7 @@ class ManufacturerFilterForm(ContactModelFilterForm, OrganizationalModelFilterSe
class DeviceTypeFilterForm(PrimaryModelFilterSetForm):
model = DeviceType
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet(
'manufacturer_id', 'default_platform_id', 'part_number', 'device_count',
'subdevice_role', 'airflow', name=_('Hardware')
@@ -529,6 +535,7 @@ class DeviceTypeFilterForm(PrimaryModelFilterSetForm):
'pass_through_ports', 'device_bays', 'module_bays', 'inventory_items', name=_('Components')
),
FieldSet('weight', 'weight_unit', name=_('Weight')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
selector_fields = ('filter_id', 'q', 'manufacturer_id')
manufacturer_id = DynamicModelMultipleChoiceField(
@@ -652,7 +659,8 @@ class DeviceTypeFilterForm(PrimaryModelFilterSetForm):
class ModuleTypeProfileFilterForm(PrimaryModelFilterSetForm):
model = ModuleTypeProfile
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')
tag = TagFilterField(model)
@@ -661,7 +669,7 @@ class ModuleTypeProfileFilterForm(PrimaryModelFilterSetForm):
class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
model = ModuleType
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet(
'profile_id', 'manufacturer_id', 'part_number', 'module_count',
'airflow', name=_('Hardware')
@@ -671,6 +679,7 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
'pass_through_ports', name=_('Components')
),
FieldSet('weight', 'weight_unit', name=_('Weight')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
selector_fields = ('filter_id', 'q', 'manufacturer_id')
profile_id = DynamicModelMultipleChoiceField(
@@ -754,8 +763,9 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
class DeviceRoleFilterForm(NestedGroupModelFilterSetForm):
model = DeviceRole
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('parent_id', 'config_template_id', name=_('Device Role'))
FieldSet('q', 'filter_id', 'tag'),
FieldSet('parent_id', 'config_template_id', name=_('Device Role')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
config_template_id = DynamicModelMultipleChoiceField(
queryset=ConfigTemplate.objects.all(),
@@ -773,8 +783,9 @@ class DeviceRoleFilterForm(NestedGroupModelFilterSetForm):
class PlatformFilterForm(NestedGroupModelFilterSetForm):
model = Platform
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('manufacturer_id', 'parent_id', 'config_template_id', name=_('Platform'))
FieldSet('q', 'filter_id', 'tag'),
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')
parent_id = DynamicModelMultipleChoiceField(
@@ -803,11 +814,12 @@ class DeviceFilterForm(
):
model = Device
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('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address', name=_('Operation')),
FieldSet('manufacturer_id', 'device_type_id', 'platform_id', name=_('Hardware')),
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(
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports',
@@ -996,9 +1008,10 @@ class DeviceFilterForm(
class VirtualDeviceContextFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = VirtualDeviceContext
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('device', 'status', 'has_primary_ip', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
device = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
@@ -1023,9 +1036,10 @@ class VirtualDeviceContextFilterForm(TenancyFilterForm, PrimaryModelFilterSetFor
class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm):
model = Module
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('manufacturer_id', 'module_type_id', 'status', 'serial', 'asset_tag', name=_('Hardware')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
@@ -1106,9 +1120,10 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, PrimaryM
class VirtualChassisFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = VirtualChassis
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
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(
queryset=Region.objects.all(),
@@ -1135,10 +1150,11 @@ class VirtualChassisFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
class CableFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = Cable
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('type', 'status', 'profile', 'color', 'length', 'length_unit', 'unterminated', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
@@ -1224,8 +1240,9 @@ class CableFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
class PowerPanelFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
model = PowerPanel
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('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
)
selector_fields = ('filter_id', 'q', 'site_id', 'location_id')
@@ -1263,10 +1280,11 @@ class PowerPanelFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
class PowerFeedFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = PowerFeed
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('tenant_group_id', 'tenant_id', name=_('Tenant')),
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(
queryset=Region.objects.all(),
@@ -1390,7 +1408,7 @@ class PathEndpointFilterForm(CabledFilterForm):
class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = ConsolePort
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
@@ -1398,6 +1416,7 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
name=_('Device')
),
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
type = forms.MultipleChoiceField(
label=_('Type'),
@@ -1429,7 +1448,7 @@ class ConsolePortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = ConsoleServerPort
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
@@ -1437,6 +1456,7 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF
name=_('Device')
),
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
type = forms.MultipleChoiceField(
label=_('Type'),
@@ -1468,7 +1488,7 @@ class ConsoleServerPortTemplateFilterForm(ModularDeviceComponentTemplateFilterFo
class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = PowerPort
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'type', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
@@ -1476,6 +1496,7 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
name=_('Device')
),
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
type = forms.MultipleChoiceField(
label=_('Type'),
@@ -1502,7 +1523,7 @@ class PowerPortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = PowerOutlet
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'type', 'color', 'status', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
@@ -1510,6 +1531,7 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
name=_('Device')
),
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
type = forms.MultipleChoiceField(
label=_('Type'),
@@ -1545,7 +1567,7 @@ class PowerOutletTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = Interface
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('vrf_id', 'l2vpn_id', 'mac_address', 'wwn', name=_('Addressing')),
FieldSet('poe_mode', 'poe_type', name=_('PoE')),
@@ -1558,6 +1580,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
name=_('Device')
),
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
selector_fields = ('filter_id', 'q', 'device_id')
vdc_id = DynamicModelMultipleChoiceField(
@@ -1716,7 +1739,7 @@ class InterfaceTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
@@ -1724,6 +1747,7 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
name=_('Device')
),
FieldSet('cabled', 'occupied', name=_('Cable')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
model = FrontPort
type = forms.MultipleChoiceField(
@@ -1759,7 +1783,7 @@ class FrontPortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
model = RearPort
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
@@ -1767,6 +1791,7 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
name=_('Device')
),
FieldSet('cabled', 'occupied', name=_('Cable')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
type = forms.MultipleChoiceField(
label=_('Type'),
@@ -1801,13 +1826,14 @@ class RearPortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
class ModuleBayFilterForm(DeviceComponentFilterForm):
model = ModuleBay
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'position', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
name=_('Device')
),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
tag = TagFilterField(model)
position = forms.CharField(
@@ -1832,13 +1858,14 @@ class ModuleBayTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
class DeviceBayFilterForm(DeviceComponentFilterForm):
model = DeviceBay
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
name=_('Device')
),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
tag = TagFilterField(model)
@@ -1855,7 +1882,7 @@ class DeviceBayTemplateFilterForm(DeviceComponentTemplateFilterForm):
class InventoryItemFilterForm(DeviceComponentFilterForm):
model = InventoryItem
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet(
'name', 'label', 'status', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered',
name=_('Attributes')
@@ -1865,6 +1892,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
name=_('Device')
),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
role_id = DynamicModelMultipleChoiceField(
queryset=InventoryItemRole.objects.all(),
@@ -1925,7 +1953,8 @@ class InventoryItemTemplateFilterForm(DeviceComponentTemplateFilterForm):
class InventoryItemRoleFilterForm(OrganizationalModelFilterSetForm):
model = InventoryItemRole
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
tag = TagFilterField(model)
@@ -1937,9 +1966,10 @@ class InventoryItemRoleFilterForm(OrganizationalModelFilterSetForm):
class MACAddressFilterForm(PrimaryModelFilterSetForm):
model = MACAddress
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('mac_address', name=_('Attributes')),
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')
mac_address = forms.CharField(

View File

@@ -75,7 +75,7 @@ class ScopedForm(forms.Form):
except ObjectDoesNotExist:
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
else:

View File

@@ -20,7 +20,9 @@ from utilities.forms.fields import (
DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SlugField,
)
from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups
from utilities.forms.widgets import APISelect, ClearableFileInput, HTMXSelect, NumberWithOptions, SelectWithPK
from utilities.forms.widgets import (
APISelect, ClearableFileInput, ClearableSelect, HTMXSelect, NumberWithOptions, SelectWithPK,
)
from utilities.jsonschema import JSONSchemaProperty
from virtualization.models import Cluster, VMInterface
from wireless.models import WirelessLAN, WirelessLANGroup
@@ -592,6 +594,14 @@ class DeviceForm(TenancyForm, PrimaryModelForm):
},
)
)
face = forms.ChoiceField(
label=_('Face'),
choices=add_blank_choice(DeviceFaceChoices),
required=False,
widget=ClearableSelect(
requires_fields=['rack']
)
)
device_type = DynamicModelChoiceField(
label=_('Device type'),
queryset=DeviceType.objects.all(),

View File

@@ -3,6 +3,7 @@ from django.utils.translation import gettext_lazy as _
from dcim.models import *
from netbox.forms import NetBoxModelForm
from netbox.forms.mixins import OwnerMixin
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField
from utilities.forms.rendering import FieldSet, TabbedGroups
from utilities.forms.widgets import APISelect
@@ -271,7 +272,7 @@ class InventoryItemCreateForm(ComponentCreateForm, model_forms.InventoryItemForm
# Virtual chassis
#
class VirtualChassisCreateForm(NetBoxModelForm):
class VirtualChassisCreateForm(OwnerMixin, NetBoxModelForm):
region = DynamicModelChoiceField(
label=_('Region'),
queryset=Region.objects.all(),

View File

@@ -550,6 +550,10 @@ class InterfaceFilter(
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
def connected(self, queryset, value: bool, prefix: str):
if value is True:
@@ -889,7 +893,7 @@ class PowerPortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedM
@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 = (
strawberry_django.filter_field()
)

View File

@@ -734,7 +734,7 @@ class PowerPortTemplateType(ModularComponentTemplateType):
filters=RackTypeFilter,
pagination=True
)
class RackTypeType(PrimaryObjectType):
class RackTypeType(ImageAttachmentsMixin, PrimaryObjectType):
rack_count: BigInt
manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]

View File

@@ -155,6 +155,8 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
'description': self.description,
'weight': float(self.weight) if self.weight is not None else None,
'weight_unit': self.weight_unit,
'airflow': self.airflow,
'attribute_data': self.attribute_data,
'comments': self.comments,
}

View File

@@ -122,7 +122,7 @@ class RackBase(WeightMixin, PrimaryModel):
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.
Each Rack is assigned to a Site and (optionally) a Location.

View File

@@ -27,6 +27,7 @@ __all__ = (
'DeviceTable',
'FrontPortTable',
'InterfaceTable',
'InterfaceLAGMemberTable',
'InventoryItemRoleTable',
'InventoryItemTable',
'MACAddressTable',
@@ -689,6 +690,33 @@ class InterfaceTable(BaseInterfaceTable, ModularDeviceComponentTable, PathEndpoi
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):
name = tables.TemplateColumn(
verbose_name=_('Name'),

View File

@@ -24,6 +24,24 @@ INTERFACE_LINKTERMINATION = """
{% 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 = """
{% load helpers %}
{% if record.length %}{{ record.length|floatformat:"-2" }} {{ record.length_unit }}{% endif %}

View File

@@ -880,6 +880,7 @@ class RackTypeView(GetRelatedModelsMixin, generic.ObjectView):
panels.RackWeightPanel(title=_('Weight'), exclude=['total_weight']),
CustomFieldsPanel(),
RelatedObjectsPanel(),
ImageAttachmentsPanel(),
],
)
@@ -3135,6 +3136,14 @@ class InterfaceView(generic.ObjectView):
)
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
vlans = []
if instance.untagged_vlan is not None:
@@ -3164,6 +3173,7 @@ class InterfaceView(generic.ObjectView):
'bridge_interfaces': bridge_interfaces,
'bridge_interfaces_table': bridge_interfaces_table,
'child_interfaces_table': child_interfaces_table,
'lag_interfaces_table': lag_interfaces_table,
'vlan_table': vlan_table,
'vlan_translation_table': vlan_translation_table,
}

View File

@@ -1,5 +1,5 @@
import logging
from collections import defaultdict
from collections import UserDict, defaultdict
from django.conf import settings
from django.utils import timezone
@@ -12,7 +12,6 @@ from core.models import ObjectType
from netbox.config import get_config
from netbox.constants import RQ_QUEUE_DEFAULT
from netbox.models.features import has_feature
from users.models import User
from utilities.api import get_serializer_for_model
from utilities.request import copy_safe_request
from utilities.rqworker import get_rq_retry
@@ -23,6 +22,21 @@ from .models import EventRule
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):
"""
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
key = f'{app_label}.{model_name}:{instance.pk}'
if key in queue:
queue[key]['data'] = serialize_for_event(instance)
queue[key]['snapshots']['postchange'] = get_snapshots(instance, event_type)['postchange']
# If the object is being deleted, update any prior "update" event to "delete"
if event_type == OBJECT_DELETED:
queue[key]['event_type'] = event_type
else:
queue[key] = {
'object_type': ObjectType.objects.get_for_model(instance),
'object_id': instance.pk,
'event_type': event_type,
'data': serialize_for_event(instance),
'snapshots': get_snapshots(instance, event_type),
'request': request,
queue[key] = EventContext(
object_type=ObjectType.objects.get_for_model(instance),
object_id=instance.pk,
object=instance,
event_type=event_type,
snapshots=get_snapshots(instance, event_type),
request=request,
user=request.user,
# Legacy request attributes for backward compatibility
'username': request.user.username,
'request_id': request.id,
}
username=request.user.username,
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):
user = User.objects.get(username=username) if username else None
def process_event_rules(event_rules, object_type, event):
"""
Process a list of EventRules against an event.
"""
for event_rule in event_rules:
# Evaluate event rule conditions (if any)
if not event_rule.eval_conditions(data):
if not event_rule.eval_conditions(event['data']):
continue
# Compile event data
event_data = event_rule.action_data or {}
event_data.update(data)
event_data.update(event['data'])
# Webhooks
if event_rule.action_type == EventRuleActionChoices.WEBHOOK:
@@ -109,25 +128,20 @@ def process_event_rules(event_rules, object_type, event_type, data, username=Non
params = {
"event_rule": event_rule,
"object_type": object_type,
"event_type": event_type,
"event_type": event['event_type'],
"data": event_data,
"snapshots": snapshots,
"snapshots": event.get('snapshots'),
"timestamp": timezone.now().isoformat(),
"username": username,
"username": event['username'],
"retry": get_rq_retry()
}
if snapshots:
params["snapshots"] = snapshots
if request:
if 'request' in event:
# Exclude FILES - webhooks don't need uploaded files,
# 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
rq_queue.enqueue(
"extras.webhooks.send_webhook",
**params
)
rq_queue.enqueue('extras.webhooks.send_webhook', **params)
# Scripts
elif event_rule.action_type == EventRuleActionChoices.SCRIPT:
@@ -139,16 +153,16 @@ def process_event_rules(event_rules, object_type, event_type, data, username=Non
params = {
"instance": event_rule.action_object,
"name": script.name,
"user": user,
"user": event['user'],
"data": event_data
}
if snapshots:
params["snapshots"] = snapshots
if request:
params["request"] = copy_safe_request(request)
ScriptJob.enqueue(
**params
)
if 'snapshots' in event:
params['snapshots'] = event['snapshots']
if 'request' in event:
params['request'] = copy_safe_request(event['request'])
# Enqueue the job
ScriptJob.enqueue(**params)
# Notification groups
elif event_rule.action_type == EventRuleActionChoices.NOTIFICATION:
@@ -157,7 +171,7 @@ def process_event_rules(event_rules, object_type, event_type, data, username=Non
object_type=object_type,
object_id=event_data['id'],
object_repr=event_data.get('display'),
event_type=event_type
event_type=event['event_type']
)
else:
@@ -169,6 +183,8 @@ def process_event_rules(event_rules, object_type, event_type, data, username=Non
def process_event_queue(events):
"""
Flush a list of object representation to RQ for EventRule processing.
This is the default processor listed in EVENTS_PIPELINE.
"""
events_cache = defaultdict(dict)
@@ -188,11 +204,7 @@ def process_event_queue(events):
process_event_rules(
event_rules=event_rules,
object_type=object_type,
event_type=event['event_type'],
data=event['data'],
username=event['username'],
snapshots=event['snapshots'],
request=event['request'],
event=event,
)

View File

@@ -7,13 +7,12 @@ from extras.choices import *
from extras.models import *
from netbox.events import get_event_type_choices
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 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.fields import (
ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
TagFilterField,
ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField,
)
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import DateTimePicker
@@ -39,7 +38,7 @@ __all__ = (
)
class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
class CustomFieldFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
model = CustomField
fieldsets = (
FieldSet('q', 'filter_id'),
@@ -47,6 +46,7 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
FieldSet('choice_set_id', 'related_object_type_id', name=_('Type Options')),
FieldSet('ui_visible', 'ui_editable', 'is_cloneable', name=_('Behavior')),
FieldSet('validation_minimum', 'validation_maximum', 'validation_regex', name=_('Validation')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
object_type_id = ContentTypeMultipleChoiceField(
queryset=ObjectType.objects.with_feature('custom_fields'),
@@ -119,18 +119,14 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
label=_('Validation regex'),
required=False
)
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm):
class CustomFieldChoiceSetFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
model = CustomFieldChoiceSet
fieldsets = (
FieldSet('q', 'filter_id'),
FieldSet('base_choices', 'choice', name=_('Choices')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
base_choices = forms.MultipleChoiceField(
choices=CustomFieldChoiceSetBaseChoices,
@@ -139,18 +135,14 @@ class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm):
choice = forms.CharField(
required=False
)
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
class CustomLinkFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
model = CustomLink
fieldsets = (
FieldSet('q', 'filter_id'),
FieldSet('object_type_id', 'enabled', 'new_window', 'weight', name=_('Attributes')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
object_type_id = ContentTypeMultipleChoiceField(
label=_('Object types'),
@@ -175,19 +167,15 @@ class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
label=_('Weight'),
required=False
)
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
class ExportTemplateFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
model = ExportTemplate
fieldsets = (
FieldSet('q', 'filter_id', 'object_type_id'),
FieldSet('data_source_id', 'data_file_id', name=_('Data')),
FieldSet('mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Rendering')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
data_source_id = DynamicModelMultipleChoiceField(
queryset=DataSource.objects.all(),
@@ -226,11 +214,6 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm):
@@ -250,11 +233,12 @@ class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm):
)
class SavedFilterFilterForm(SavedFiltersMixin, FilterForm):
class SavedFilterFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
model = SavedFilter
fieldsets = (
FieldSet('q', 'filter_id'),
FieldSet('object_type_id', 'enabled', 'shared', 'weight', name=_('Attributes')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
object_type_id = ContentTypeMultipleChoiceField(
label=_('Object types'),
@@ -279,11 +263,6 @@ class SavedFilterFilterForm(SavedFiltersMixin, FilterForm):
label=_('Weight'),
required=False
)
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
class TableConfigFilterForm(SavedFiltersMixin, FilterForm):
@@ -317,11 +296,12 @@ class TableConfigFilterForm(SavedFiltersMixin, FilterForm):
)
class WebhookFilterForm(NetBoxModelFilterSetForm):
class WebhookFilterForm(OwnerFilterMixin, NetBoxModelFilterSetForm):
model = Webhook
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('payload_url', 'http_method', 'http_content_type', name=_('Attributes')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
http_content_type = forms.CharField(
label=_('HTTP content type'),
@@ -336,19 +316,15 @@ class WebhookFilterForm(NetBoxModelFilterSetForm):
required=False,
label=_('HTTP method')
)
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
tag = TagFilterField(model)
class EventRuleFilterForm(NetBoxModelFilterSetForm):
class EventRuleFilterForm(OwnerFilterMixin, NetBoxModelFilterSetForm):
model = EventRule
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('owner_group_id', 'owner_id', name=_('Ownership')),
)
object_type_id = ContentTypeMultipleChoiceField(
queryset=ObjectType.objects.with_feature('event_rules'),
@@ -372,16 +348,16 @@ class EventRuleFilterForm(NetBoxModelFilterSetForm):
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
tag = TagFilterField(model)
class TagFilterForm(SavedFiltersMixin, FilterForm):
class TagFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
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(
queryset=ObjectType.objects.with_feature('tags'),
required=False,
@@ -392,11 +368,6 @@ class TagFilterForm(SavedFiltersMixin, FilterForm):
required=False,
label=_('Allowed object type')
)
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
class ConfigContextProfileFilterForm(PrimaryModelFilterSetForm):
@@ -404,6 +375,7 @@ class ConfigContextProfileFilterForm(PrimaryModelFilterSetForm):
fieldsets = (
FieldSet('q', 'filter_id'),
FieldSet('data_source_id', 'data_file_id', name=_('Data')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
data_source_id = DynamicModelMultipleChoiceField(
queryset=DataSource.objects.all(),
@@ -420,16 +392,17 @@ class ConfigContextProfileFilterForm(PrimaryModelFilterSetForm):
)
class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
class ConfigContextFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
model = ConfigContext
fieldsets = (
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('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
FieldSet('device_type_id', 'platform_id', 'device_role_id', name=_('Device')),
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(
queryset=ConfigContextProfile.objects.all(),
@@ -514,19 +487,15 @@ class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
required=False,
label=_('Tags')
)
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
class ConfigTemplateFilterForm(SavedFiltersMixin, FilterForm):
class ConfigTemplateFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
model = ConfigTemplate
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
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(
queryset=DataSource.objects.all(),
@@ -568,11 +537,6 @@ class ConfigTemplateFilterForm(SavedFiltersMixin, FilterForm):
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
class LocalConfigContextFilterForm(forms.Form):

View File

@@ -178,6 +178,13 @@ class CustomFieldChoiceSetForm(ChangelogMessageMixin, OwnerMixin, forms.ModelFor
) + ' <code>choice1:First Choice</code>')
)
fieldsets = (
FieldSet(
'name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically',
name=_('Custom Field Choice Set')
),
)
class Meta:
model = CustomFieldChoiceSet
fields = ('name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically', 'owner')

View File

@@ -137,7 +137,7 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
module = self.get_module()
except Exception as 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
scripts = {}

View File

@@ -4,7 +4,7 @@ from django.dispatch import receiver
from core.events import *
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 netbox.config import get_config
from netbox.models.features import has_feature
@@ -102,14 +102,12 @@ def process_job_start_event_rules(sender, **kwargs):
enabled=True,
object_types=sender.object_type
)
username = sender.user.username if sender.user else None
process_event_rules(
event_rules=event_rules,
object_type=sender.object_type,
event = EventContext(
event_type=JOB_STARTED,
data=sender.data,
username=username
user=sender.user,
)
process_event_rules(event_rules, sender.object_type, event)
@receiver(job_end)
@@ -122,14 +120,12 @@ def process_job_end_event_rules(sender, **kwargs):
enabled=True,
object_types=sender.object_type
)
username = sender.user.username if sender.user else None
process_event_rules(
event_rules=event_rules,
object_type=sender.object_type,
event = EventContext(
event_type=JOB_COMPLETED,
data=sender.data,
username=username
user=sender.user,
)
process_event_rules(event_rules, sender.object_type, event)
#

View File

@@ -45,9 +45,10 @@ IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([
class VRFFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = VRF
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('import_target_id', 'export_target_id', name=_('Route Targets')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
import_target_id = DynamicModelMultipleChoiceField(
queryset=RouteTarget.objects.all(),
@@ -65,9 +66,10 @@ class VRFFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
class RouteTargetFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = RouteTarget
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('importing_vrf_id', 'exporting_vrf_id', name=_('VRF')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
importing_vrf_id = DynamicModelMultipleChoiceField(
queryset=VRF.objects.all(),
@@ -85,8 +87,9 @@ class RouteTargetFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
class RIRFilterForm(OrganizationalModelFilterSetForm):
model = RIR
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('is_private', name=_('RIR')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
is_private = forms.NullBooleanField(
required=False,
@@ -101,9 +104,10 @@ class RIRFilterForm(OrganizationalModelFilterSetForm):
class AggregateFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm):
model = Aggregate
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('family', 'rir_id', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
)
family = forms.ChoiceField(
@@ -122,9 +126,10 @@ class AggregateFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryMode
class ASNRangeFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm):
model = ASNRange
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('rir_id', 'start', 'end', name=_('Range')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
rir_id = DynamicModelMultipleChoiceField(
queryset=RIR.objects.all(),
@@ -145,9 +150,10 @@ class ASNRangeFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm):
class ASNFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = ASN
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('rir_id', 'site_group_id', 'site_id', name=_('Assignment')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
rir_id = DynamicModelMultipleChoiceField(
queryset=RIR.objects.all(),
@@ -170,7 +176,8 @@ class ASNFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
class RoleFilterForm(OrganizationalModelFilterSetForm):
model = Role
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
tag = TagFilterField(model)
@@ -178,7 +185,7 @@ class RoleFilterForm(OrganizationalModelFilterSetForm):
class PrefixFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm):
model = Prefix
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet(
'within_include', 'family', 'status', 'role_id', 'mask_length', 'is_pool', 'mark_utilized',
name=_('Addressing')
@@ -187,6 +194,7 @@ class PrefixFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFi
FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Scope')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
)
mask_length__lte = forms.IntegerField(
@@ -284,9 +292,10 @@ class PrefixFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFi
class IPRangeFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm):
model = IPRange
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('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
)
family = forms.ChoiceField(
@@ -331,14 +340,15 @@ class IPRangeFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelF
class IPAddressFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm):
model = IPAddress
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet(
'parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface', 'dns_name',
name=_('Attributes')
),
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('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
)
selector_fields = ('filter_id', 'q', 'region_id', 'group_id', 'parent', 'status', 'role')
@@ -409,9 +419,10 @@ class IPAddressFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryMode
class FHRPGroupFilterForm(PrimaryModelFilterSetForm):
model = FHRPGroup
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'protocol', 'group_id', name=_('Attributes')),
FieldSet('auth_type', 'auth_key', name=_('Authentication')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
name = forms.CharField(
label=_('Name'),
@@ -441,11 +452,12 @@ class FHRPGroupFilterForm(PrimaryModelFilterSetForm):
class VLANGroupFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm):
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('region', 'site_group', 'site', 'location', 'rack', name=_('Location')),
FieldSet('cluster_group', 'cluster', name=_('Cluster')),
FieldSet('contains_vid', name=_('VLANs')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
model = VLANGroup
region = DynamicModelMultipleChoiceField(
@@ -495,8 +507,9 @@ class VLANGroupFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm):
class VLANTranslationPolicyFilterForm(PrimaryModelFilterSetForm):
model = VLANTranslationPolicy
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', name=_('Attributes')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
name = forms.CharField(
required=False,
@@ -532,11 +545,12 @@ class VLANTranslationRuleFilterForm(NetBoxModelFilterSetForm):
class VLANFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = VLAN
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
FieldSet('group_id', 'status', 'role_id', 'vid', 'l2vpn_id', name=_('Attributes')),
FieldSet('qinq_role', 'qinq_svlan_id', name=_('Q-in-Q/802.1ad')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
selector_fields = ('filter_id', 'q', 'group_id')
region_id = DynamicModelMultipleChoiceField(
@@ -604,8 +618,9 @@ class VLANFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
class ServiceTemplateFilterForm(PrimaryModelFilterSetForm):
model = ServiceTemplate
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('protocol', 'port', name=_('Attributes')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
protocol = forms.ChoiceField(
label=_('Protocol'),
@@ -622,9 +637,10 @@ class ServiceTemplateFilterForm(PrimaryModelFilterSetForm):
class ServiceFilterForm(ContactModelFilterForm, ServiceTemplateFilterForm):
model = Service
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('protocol', 'port', name=_('Attributes')),
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')),
)
device_id = DynamicModelMultipleChoiceField(

View File

@@ -370,6 +370,11 @@ class AnnotatedIPAddressTable(IPAddressTable):
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):
pass

View File

@@ -4,6 +4,7 @@ from django.utils.translation import gettext_lazy as _
from django_tables2.utils import Accessor
from dcim.models import Interface
from dcim.tables.template_code import INTERFACE_LINKTERMINATION, LINKTERMINATION
from ipam.models import *
from netbox.tables import NetBoxTable, OrganizationalModelTable, PrimaryModelTable, columns
from tenancy.tables import TenancyColumnsMixin, TenantColumn
@@ -159,11 +160,26 @@ class VLANDevicesTable(VLANMembersTable):
actions = columns.ActionsColumn(
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):
model = Interface
fields = ('device', 'name', 'tagged', 'actions')
exclude = ('id', )
fields = ('device', 'name', 'link_peer', 'connection', 'tagged', 'actions')
default_columns = ('device', 'name', 'connection', 'tagged', 'actions')
exclude = ('id',)
class VLANVirtualMachinesTable(VLANMembersTable):

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)

View File

@@ -49,6 +49,9 @@ def add_requested_prefixes(parent, prefix_list, show_available=True, show_assign
if prefix_list and show_available:
# 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 = [Prefix(prefix=p, status=None) for p in available_prefixes.iter_cidrs()]
child_prefixes = child_prefixes + available_prefixes

View File

@@ -3,8 +3,10 @@ from contextvars import ContextVar
__all__ = (
'current_request',
'events_queue',
'query_cache',
)
current_request = ContextVar('current_request', default=None)
events_queue = ContextVar('events_queue', default=dict())
query_cache = ContextVar('query_cache', default=None)

View File

@@ -1,6 +1,7 @@
from collections import defaultdict
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 extras.events import flush_events
@@ -16,6 +17,7 @@ def event_tracking(request):
"""
current_request.set(request)
events_queue.set({})
query_cache.set(defaultdict(dict))
yield
@@ -26,3 +28,4 @@ def event_tracking(request):
# Clear context vars
current_request.set(None)
events_queue.set({})
query_cache.set(None)

View File

@@ -3,10 +3,9 @@ from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from extras.choices import *
from users.models import Owner
from utilities.forms.fields import DynamicModelChoiceField, QueryField
from utilities.forms.fields import QueryField
from utilities.forms.mixins import FilterModifierMixin
from .mixins import CustomFieldsMixin, SavedFiltersMixin
from .mixins import CustomFieldsMixin, OwnerFilterMixin, SavedFiltersMixin
__all__ = (
'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):
"""
FilterSet form for models which inherit from PrimaryModel.

View File

@@ -4,13 +4,14 @@ from django.utils.translation import gettext as _
from core.models import ObjectType
from extras.choices import *
from extras.models import *
from users.models import Owner
from users.models import OwnerGroup, Owner
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
__all__ = (
'ChangelogMessageMixin',
'CustomFieldsMixin',
'OwnerMixin',
'OwnerFilterMixin',
'SavedFiltersMixin',
'TagsMixin',
)
@@ -22,7 +23,7 @@ class ChangelogMessageMixin(forms.Form):
"""
changelog_message = forms.CharField(
required=False,
max_length=200
max_length=200,
)
def __init__(self, *args, **kwargs):
@@ -42,6 +43,7 @@ class CustomFieldsMixin:
Attributes:
model: The model class
"""
model = None
def __init__(self, *args, **kwargs):
@@ -86,13 +88,20 @@ class CustomFieldsMixin:
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(
queryset=SavedFilter.objects.all(),
required=False,
label=_('Saved Filter'),
query_params={
'usable': True,
}
},
)
def __init__(self, *args, **kwargs):
@@ -107,6 +116,13 @@ class SavedFiltersMixin(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(
queryset=Tag.objects.all(),
required=False,
@@ -124,10 +140,47 @@ class TagsMixin(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(
queryset=Owner.objects.all(),
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'),
)

View File

@@ -1,3 +1,5 @@
from functools import cache
from django.utils.translation import gettext_lazy as _
from netbox.registry import registry
@@ -409,60 +411,10 @@ ADMIN_MENU = Menu(
MenuGroup(
label=_('Authentication'),
items=(
MenuItem(
link='users:user_list',
link_text=_('Users'),
staff_only=True,
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'])
),
get_model_item('users', 'user', _('Users')),
get_model_item('users', 'group', _('Groups')),
get_model_item('users', 'token', _('API Tokens')),
get_model_item('users', 'objectpermission', _('Permissions'), actions=['add']),
),
),
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
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()
@cache
def get_menus():
"""
Dynamically build and return the list of navigation menus.
This ensures plugin menus registered during app initialization are included.
The result is cached since menus don't change without a Django restart.
"""
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,
]
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)
# Add top-level plugin menus
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

View File

@@ -271,9 +271,14 @@ class NetBoxTable(BaseTable):
class PrimaryModelTable(NetBoxTable):
owner_group = tables.Column(
accessor='owner__group',
linkify=True,
verbose_name=_('Owner Group'),
)
owner = tables.Column(
linkify=True,
verbose_name=_('Owner')
verbose_name=_('Owner'),
)
comments = columns.MarkdownColumn(
verbose_name=_('Comments'),
@@ -281,9 +286,14 @@ class PrimaryModelTable(NetBoxTable):
class OrganizationalModelTable(NetBoxTable):
owner_group = tables.Column(
accessor='owner__group',
linkify=True,
verbose_name=_('Owner Group'),
)
owner = tables.Column(
linkify=True,
verbose_name=_('Owner')
verbose_name=_('Owner'),
)
comments = columns.MarkdownColumn(
verbose_name=_('Comments'),
@@ -291,9 +301,14 @@ class OrganizationalModelTable(NetBoxTable):
class NestedGroupModelTable(NetBoxTable):
owner_group = tables.Column(
accessor='owner__group',
linkify=True,
verbose_name=_('Owner Group'),
)
owner = tables.Column(
linkify=True,
verbose_name=_('Owner')
verbose_name=_('Owner'),
)
name = columns.MPTTColumn(
verbose_name=_('Name'),

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,40 @@
import TomSelect from 'tom-select';
import { getElements } from '../util';
/**
* Initialize clear-field dependencies.
* When a required field is cleared, dependent fields with data-requires-fields attribute will also be cleared.
*/
export function initClearField(): void {
// Find all fields with data-requires-fields attribute
for (const field of getElements<HTMLSelectElement>('[data-requires-fields]')) {
const requiredFieldsAttr = field.getAttribute('data-requires-fields');
if (!requiredFieldsAttr) continue;
// Parse the comma-separated list of required field names
const requiredFields = requiredFieldsAttr.split(',').map(name => name.trim());
// Set up listeners for each required field
for (const requiredFieldName of requiredFields) {
const requiredField = document.querySelector<HTMLSelectElement>(
`[name="${requiredFieldName}"]`,
);
if (!requiredField) continue;
// Listen for changes on the required field
requiredField.addEventListener('change', () => {
// If required field is cleared, also clear this dependent field
if (!requiredField.value || requiredField.value === '') {
// Check if this field uses TomSelect
const tomselect = (field as HTMLSelectElement & { tomselect?: TomSelect }).tomselect;
if (tomselect) {
tomselect.clear();
} else {
// Regular select field
field.value = '';
}
}
});
}
}
}

View File

@@ -1,9 +1,10 @@
import { initClearField } from './clearField';
import { initFormElements } from './elements';
import { initFilterModifiers } from './filterModifiers';
import { initSpeedSelector } from './speedSelector';
export function initForms(): void {
for (const func of [initFormElements, initSpeedSelector, initFilterModifiers]) {
for (const func of [initFormElements, initSpeedSelector, initFilterModifiers, initClearField]) {
func();
}
}

View File

@@ -101,8 +101,9 @@
<div class="field-group mb-5">
<div class="row">
<h2 class="col-9 offset-3">{% trans "Owner" %}</h2>
<h2 class="col-9 offset-3">{% trans "Ownership" %}</h2>
</div>
{% render_field form.owner_group %}
{% render_field form.owner %}
</div>

View File

@@ -80,8 +80,9 @@
<div class="field-group mb-5">
<div class="row">
<h2 class="col-9 offset-3">{% trans "Owner" %}</h2>
<h2 class="col-9 offset-3">{% trans "Ownership" %}</h2>
</div>
{% render_field form.owner_group %}
{% render_field form.owner %}
</div>

View File

@@ -370,33 +370,6 @@
</table>
</div>
{% 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 'dcim/inc/panels/inventory_items.html' %}
{% plugin_right_page object %}
@@ -441,6 +414,13 @@
{% include 'inc/panel_table.html' with table=vlan_table heading="VLANs" %}
</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 %}
<div class="row mb-3">
<div class="col col-md-12">

View File

@@ -36,8 +36,9 @@
<div class="field-group mb-5">
<div class="row">
<h2 class="col-9 offset-3">{% trans "Owner" %}</h2>
<h2 class="col-9 offset-3">{% trans "Ownership" %}</h2>
</div>
{% render_field vc_form.owner_group %}
{% render_field vc_form.owner %}
</div>

View File

@@ -121,6 +121,7 @@
<div class="alert alert-warning" role="alert">
<i class="mdi mdi-alert"></i>
{% blocktrans with module=module.name %}Could not load scripts from module {{ module }}{% endblocktrans %}
{% if module.error %}<code>{{ module.error }}</code>{% endif %}
</div>
</div>
{% endif %}

View File

@@ -62,8 +62,11 @@ Context:
{% if form.owner %}
<div class="field-group mb-5">
<div class="row">
<h2 class="col-9 offset-3">{% trans "Owner" %}</h2>
<h2 class="col-9 offset-3">{% trans "Ownership" %}</h2>
</div>
{% if form.owner_group %}
{% render_field form.owner_group %}
{% endif %}
{% render_field form.owner bulk_nullable=True %}
</div>
{% endif %}

View File

@@ -27,6 +27,9 @@
<div class="row">
<h2 class="col-9 offset-3">{% trans "Ownership" %}</h2>
</div>
{% if form.owner_group %}
{% render_field form.owner_group %}
{% endif %}
{% render_field form.owner %}
</div>
{% endif %}

View File

@@ -67,8 +67,9 @@
<div class="field-group mb-5">
<div class="row">
<h2 class="col-9 offset-3">{% trans "Owner" %}</h2>
<h2 class="col-9 offset-3">{% trans "Ownership" %}</h2>
</div>
{% render_field form.owner_group %}
{% render_field form.owner %}
</div>

View File

@@ -31,8 +31,9 @@ __all__ = (
class TenantGroupFilterForm(NestedGroupModelFilterSetForm):
model = TenantGroup
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('parent_id', name=_('Tenant Group')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
parent_id = DynamicModelMultipleChoiceField(
queryset=TenantGroup.objects.all(),
@@ -45,8 +46,9 @@ class TenantGroupFilterForm(NestedGroupModelFilterSetForm):
class TenantFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
model = Tenant
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('group_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts'))
)
group_id = DynamicModelMultipleChoiceField(
@@ -65,8 +67,9 @@ class TenantFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
class ContactGroupFilterForm(NestedGroupModelFilterSetForm):
model = ContactGroup
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('parent_id', name=_('Contact Group')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
parent_id = DynamicModelMultipleChoiceField(
queryset=ContactGroup.objects.all(),
@@ -79,7 +82,8 @@ class ContactGroupFilterForm(NestedGroupModelFilterSetForm):
class ContactRoleFilterForm(OrganizationalModelFilterSetForm):
model = ContactRole
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
tag = TagFilterField(model)
@@ -87,8 +91,9 @@ class ContactRoleFilterForm(OrganizationalModelFilterSetForm):
class ContactFilterForm(PrimaryModelFilterSetForm):
model = Contact
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('group_id', name=_('Contact')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
group_id = DynamicModelMultipleChoiceField(
queryset=ContactGroup.objects.all(),

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
import django_filters
from django.utils.translation import gettext as _
from users.models import Owner
from users.models import OwnerGroup, Owner
__all__ = (
'OwnerFilterMixin',
@@ -12,6 +12,17 @@ class OwnerFilterMixin(django_filters.FilterSet):
"""
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(
queryset=Owner.objects.all(),
label=_('Owner (ID)'),

View File

@@ -5,6 +5,7 @@ from ..utils import add_blank_choice
__all__ = (
'BulkEditNullBooleanSelect',
'ClearableSelect',
'ColorSelect',
'HTMXSelect',
'SelectWithPK',
@@ -28,6 +29,21 @@ class BulkEditNullBooleanSelect(forms.NullBooleanSelect):
)
class ClearableSelect(forms.Select):
"""
A Select widget that will be automatically cleared when one or more required fields are cleared.
Args:
requires_fields: A list of field names that this field depends on. When any of these fields
are cleared, this field will also be cleared automatically via JavaScript.
"""
def __init__(self, *args, requires_fields=None, **kwargs):
super().__init__(*args, **kwargs)
if requires_fields:
self.attrs['data-requires-fields'] = ','.join(requires_fields)
class ColorSelect(forms.Select):
"""
Extends the built-in Select widget to colorize each <option>.

View File

@@ -1,6 +1,6 @@
from django import template
from netbox.navigation.menu import MENUS
from netbox.navigation.menu import get_menus
__all__ = (
'nav',
@@ -19,7 +19,7 @@ def nav(context):
nav_items = []
# Construct the navigation menu based upon the current user's permissions
for menu in MENUS:
for menu in get_menus():
groups = []
for group in menu.groups:
items = []

View File

@@ -7,10 +7,10 @@ from extras.forms import LocalConfigContextFilterForm
from extras.models import ConfigTemplate
from ipam.models import VRF, VLANTranslationPolicy
from netbox.forms import NetBoxModelFilterSetForm, OrganizationalModelFilterSetForm, PrimaryModelFilterSetForm
from netbox.forms.mixins import OwnerFilterMixin
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
from users.models import Owner
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 virtualization.choices import *
from virtualization.models import *
@@ -29,7 +29,8 @@ __all__ = (
class ClusterTypeFilterForm(OrganizationalModelFilterSetForm):
model = ClusterType
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
tag = TagFilterField(model)
@@ -38,7 +39,8 @@ class ClusterGroupFilterForm(ContactModelFilterForm, OrganizationalModelFilterSe
model = ClusterGroup
tag = TagFilterField(model)
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')),
)
@@ -46,10 +48,11 @@ class ClusterGroupFilterForm(ContactModelFilterForm, OrganizationalModelFilterSe
class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilterSetForm):
model = Cluster
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('group_id', 'type_id', 'status', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Scope')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
)
selector_fields = ('filter_id', 'q', 'group_id')
@@ -105,7 +108,7 @@ class VirtualMachineFilterForm(
):
model = VirtualMachine
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('region_id', 'site_group_id', 'site_id', name=_('Location')),
FieldSet(
@@ -113,6 +116,7 @@ class VirtualMachineFilterForm(
'local_context_data', 'serial', name=_('Attributes')
),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
)
cluster_group_id = DynamicModelMultipleChoiceField(
@@ -205,14 +209,15 @@ class VirtualMachineFilterForm(
tag = TagFilterField(model)
class VMInterfaceFilterForm(NetBoxModelFilterSetForm):
class VMInterfaceFilterForm(OwnerFilterMixin, NetBoxModelFilterSetForm):
model = VMInterface
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('cluster_id', 'virtual_machine_id', name=_('Virtual Machine')),
FieldSet('enabled', name=_('Attributes')),
FieldSet('vrf_id', 'l2vpn_id', 'mac_address', name=_('Addressing')),
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')
cluster_id = DynamicModelMultipleChoiceField(
@@ -259,20 +264,16 @@ class VMInterfaceFilterForm(NetBoxModelFilterSetForm):
required=False,
label=_('VLAN Translation Policy')
)
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
tag = TagFilterField(model)
class VirtualDiskFilterForm(NetBoxModelFilterSetForm):
class VirtualDiskFilterForm(OwnerFilterMixin, NetBoxModelFilterSetForm):
model = VirtualDisk
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('virtual_machine_id', name=_('Virtual Machine')),
FieldSet('size', name=_('Attributes')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
virtual_machine_id = DynamicModelMultipleChoiceField(
queryset=VirtualMachine.objects.all(),
@@ -284,9 +285,4 @@ class VirtualDiskFilterForm(NetBoxModelFilterSetForm):
required=False,
min_value=1
)
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
tag = TagFilterField(model)

View File

@@ -33,7 +33,8 @@ __all__ = (
class TunnelGroupFilterForm(ContactModelFilterForm, OrganizationalModelFilterSetForm):
model = TunnelGroup
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')),
)
tag = TagFilterField(model)
@@ -42,10 +43,11 @@ class TunnelGroupFilterForm(ContactModelFilterForm, OrganizationalModelFilterSet
class TunnelFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm):
model = Tunnel
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('status', 'encapsulation', 'tunnel_id', name=_('Tunnel')),
FieldSet('ipsec_profile_id', name=_('Security')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenancy')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
)
status = forms.MultipleChoiceField(
@@ -97,10 +99,11 @@ class TunnelTerminationFilterForm(NetBoxModelFilterSetForm):
class IKEProposalFilterForm(PrimaryModelFilterSetForm):
model = IKEProposal
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet(
'authentication_method', 'encryption_algorithm', 'authentication_algorithm', 'group', name=_('Parameters')
),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
authentication_method = forms.MultipleChoiceField(
label=_('Authentication method'),
@@ -128,8 +131,9 @@ class IKEProposalFilterForm(PrimaryModelFilterSetForm):
class IKEPolicyFilterForm(PrimaryModelFilterSetForm):
model = IKEPolicy
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('version', 'mode', 'proposal_id', name=_('Parameters')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
version = forms.MultipleChoiceField(
label=_('IKE version'),
@@ -152,8 +156,9 @@ class IKEPolicyFilterForm(PrimaryModelFilterSetForm):
class IPSecProposalFilterForm(PrimaryModelFilterSetForm):
model = IPSecProposal
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('encryption_algorithm', 'authentication_algorithm', name=_('Parameters')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
encryption_algorithm = forms.MultipleChoiceField(
label=_('Encryption algorithm'),
@@ -171,8 +176,9 @@ class IPSecProposalFilterForm(PrimaryModelFilterSetForm):
class IPSecPolicyFilterForm(PrimaryModelFilterSetForm):
model = IPSecPolicy
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('proposal_id', 'pfs_group', name=_('Parameters')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
proposal_id = DynamicModelMultipleChoiceField(
queryset=IKEProposal.objects.all(),
@@ -190,8 +196,9 @@ class IPSecPolicyFilterForm(PrimaryModelFilterSetForm):
class IPSecProfileFilterForm(PrimaryModelFilterSetForm):
model = IPSecProfile
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('mode', 'ike_policy_id', 'ipsec_policy_id', name=_('Profile')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
mode = forms.MultipleChoiceField(
label=_('Mode'),
@@ -214,9 +221,10 @@ class IPSecProfileFilterForm(PrimaryModelFilterSetForm):
class L2VPNFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm):
model = L2VPN
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('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
)
status = forms.MultipleChoiceField(

View File

@@ -22,8 +22,9 @@ __all__ = (
class WirelessLANGroupFilterForm(NestedGroupModelFilterSetForm):
model = WirelessLANGroup
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('parent_id', name=_('Wireless LAN group')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
parent_id = DynamicModelMultipleChoiceField(
queryset=WirelessLANGroup.objects.all(),
@@ -36,11 +37,12 @@ class WirelessLANGroupFilterForm(NestedGroupModelFilterSetForm):
class WirelessLANFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = WirelessLAN
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
FieldSet('ssid', 'group_id', 'status', name=_('Attributes')),
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('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
ssid = forms.CharField(
required=False,
@@ -102,10 +104,11 @@ class WirelessLANFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
class WirelessLinkFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = WirelessLink
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('q', 'filter_id', 'tag'),
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('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
ssid = forms.CharField(
required=False,