Compare commits

..

22 Commits

Author SHA1 Message Date
Arthur
905d17294a Review feedback 2026-01-28 17:27:01 -08:00
Arthur
44e5a4c177 Review feedback 2026-01-28 17:12:13 -08:00
Arthur
de19447317 Merge branch 'main' into 20911-dropdown 2026-01-23 15:59:08 -08:00
Arthur
f195af206b fix csv import 2026-01-23 15:46:26 -08:00
Arthur
b0ac55ed6a cleanup 2026-01-21 16:44:48 -08:00
Arthur
91ab818411 use bulk_update and rebuild 2026-01-21 16:23:24 -08:00
Arthur
62b9367ae3 use bulk_update and rebuild 2026-01-21 16:14:14 -08:00
Arthur
0c091aa80e cleanup 2026-01-21 13:18:34 -08:00
Arthur
94836e5a37 fix migration 2026-01-21 12:55:34 -08:00
Arthur
c92912ff03 fix migration 2026-01-21 12:52:41 -08:00
Arthur
ef0bc18095 fix migration 2026-01-21 12:47:16 -08:00
Arthur
99f727e685 fix migration 2026-01-21 12:41:59 -08:00
Arthur
6a5aced4bc fix migration 2026-01-21 12:28:01 -08:00
Arthur
46f9a12a87 add migration 2026-01-21 11:59:03 -08:00
Arthur
be1a008216 rebuild tree after rename 2026-01-20 15:28:49 -08:00
Arthur
c4c3518bb4 change ordering field, remove front-end changes 2026-01-20 13:45:17 -08:00
Arthur
5a1282e326 Merge branch 'main' into 20911-dropdown 2026-01-14 13:39:45 -08:00
Arthur
cb13eb277f use correct node version 2026-01-14 13:36:33 -08:00
Arthur
24642be351 cleanup 2026-01-08 17:08:10 -08:00
Arthur
89af9efd85 cleanup 2026-01-08 17:04:00 -08:00
Arthur
99d678502f cleanup 2026-01-08 16:23:47 -08:00
Arthur
e6300ee06d Fix TomSelect dropdown ordering 2026-01-08 16:17:40 -08:00
92 changed files with 1935 additions and 2864 deletions

View File

@@ -3,43 +3,31 @@
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:
```
cd /opt/netbox
source /opt/netbox/venv/bin/activate
python3 netbox/manage.py nbshell
./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 preloaded. (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 pre-loaded. (If desired, the stock Django shell is also available by executing `./manage.py shell`.)
```
(venv) $ python3 netbox/manage.py nbshell
$ ./manage.py nbshell
### NetBox interactive shell (localhost)
### 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.
### Python 3.7.10 | Django 3.2.5 | NetBox 3.0
### 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:
dcim.Cable
dcim.CableTermination
dcim.ConsolePort
dcim.ConsolePortTemplate
dcim.ConsoleServerPort
dcim.ConsoleServerPortTemplate
dcim.Device
ConsolePort
ConsolePortTemplate
ConsoleServerPort
ConsoleServerPortTemplate
Device
...
```
To exit the NetBox shell, type `exit()` or press `Ctrl+D`.
```
>>> exit()
(venv) $
```
!!! warning
The NetBox shell affords direct access to NetBox data and function with very little validation in place. As such, it is crucial to ensure that only authorized, knowledgeable users are ever granted access to it. Never perform any action in the management shell without having a full backup in place.
@@ -126,7 +114,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 latter of which is case-insensitive).
Character fields can be filtered against partial matches using the `contains` or `icontains` field lookup (the later of which is case-insensitive).
```
>>> Device.objects.filter(name__icontains="testdevice")

View File

@@ -8,7 +8,7 @@ This is a mapping of models to [custom validators](../customization/custom-valid
```python
CUSTOM_VALIDATORS = {
"dcim.Site": [
"dcim.site": [
{
"name": {
"min_length": 5,
@@ -17,15 +17,12 @@ CUSTOM_VALIDATORS = {
},
"my_plugin.validators.Validator1"
],
"dcim.Device": [
"dcim.device": [
"my_plugin.validators.Validator1"
]
}
```
!!! info "Case-Insensitive Model Names"
Model identifiers are case-insensitive. Both `dcim.site` and `dcim.Site` are valid and equivalent.
---
## FIELD_CHOICES
@@ -56,9 +53,6 @@ FIELD_CHOICES = {
}
```
!!! info "Case-Insensitive Field Identifiers"
Field identifiers are case-insensitive. Both `dcim.Site.status` and `dcim.site.status` are valid and equivalent.
The following model fields support configurable choices:
* `circuits.Circuit.status`
@@ -104,7 +98,7 @@ This is a mapping of models to [custom validators](../customization/custom-valid
```python
PROTECTION_RULES = {
"dcim.Site": [
"dcim.site": [
{
"status": {
"eq": "decommissioning"
@@ -114,6 +108,3 @@ PROTECTION_RULES = {
]
}
```
!!! info "Case-Insensitive Model Names"
Model identifiers are case-insensitive. Both `dcim.site` and `dcim.Site` are valid and equivalent.

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 > 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:
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:
* [`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:
psql (16.11 (Ubuntu 16.11-0ubuntu0.24.04.1))
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, compression: off)
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)
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, compression: off)
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, 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 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.
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.
### Option B: Clone the Git Repository
@@ -63,12 +63,12 @@ This command should generate output similar to the following:
```
Cloning into '.'...
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.
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.
```
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,8 +102,7 @@ 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`
* `API_TOKEN_PEPPERS`
* `DATABASES`
* `DATABASES` (or `DATABASE`)
* `REDIS`
* `SECRET_KEY`
@@ -159,7 +158,7 @@ DATABASES = {
### REDIS
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.
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.
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.
@@ -253,7 +252,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
@@ -296,12 +295,13 @@ 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).
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/
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/
Quit the server with CONTROL-C.
```

View File

@@ -43,22 +43,16 @@ You should see output similar to the following:
```no-highlight
● netbox.service - NetBox WSGI Service
Loaded: loaded (/etc/systemd/system/netbox.service; enabled; preset: enabled)
Active: active (running) since Mon 2026-01-26 11:00:00 CST; 7s ago
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
Docs: https://docs.netbox.dev/
Main PID: 7283 (gunicorn)
Tasks: 6 (limit: 4545)
Memory: 556.1M (peak: 556.3M)
CPU: 3.387s
Main PID: 1140492 (gunicorn)
Tasks: 19 (limit: 4683)
Memory: 666.2M
CGroup: /system.slice/netbox.service
├─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.
├─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>
...
```

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 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.
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.
## Obtain an SSL Certificate

View File

@@ -12,12 +12,12 @@
</div>
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 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 following sections detail how to set up a new instance of NetBox:
1. [PostgreSQL database](1-postgresql.md)
2. [Redis](2-redis.md)
1. [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=4.5.0
NEWVER=3.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=4.4.10
OLDVER=3.4.9
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.5.0
sudo git checkout v4.2.7
```
## 4. Run the Upgrade Script
@@ -128,7 +128,7 @@ sudo ./upgrade.sh
```
!!! warning
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:
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:
```no-highlight
sudo PYTHON=/usr/bin/python3.12 ./upgrade.sh

View File

@@ -133,67 +133,23 @@ The field "class_type" is an easy way to distinguish what type of object it is w
## Pagination
The GraphQL API supports two types of pagination. Offset-based pagination operates using an offset relative to the first record in a set, specified by the `offset` parameter. For example, the response to a request specifying an offset of 100 will contain the 101st and later matching records. Offset-based pagination feels very natural, but its performance can suffer when dealing with large data sets due to the overhead involved in calculating the relative offset.
The alternative approach is cursor-based pagination, which operates using absolute (rather than relative) primary key values. (These are the numeric IDs assigned to each object in the database.) When using cursor-based pagination, the response will contain records with a primary key greater than or equal to the specified start value, up to the maximum number of results. This strategy requires keeping track of the last seen primary key from each response when paginating through data, but is extremely performant. The cursor is specified by passing the starting object ID via the `start` parameter.
To ensure consistent ordering, objects will always be ordered by their primary keys when cursor-based pagination is used.
!!! note "Cursor-based pagination was introduced in NetBox v4.5.2."
Both pagination strategies support passing an optional `limit` parameter. In both approaches, this specifies the maximum number of objects to include in the response. If no limit is specified, a default value of 100 is used.
### Offset Pagination
The first page will have an `offset` of zero, or the `offset` parameter will be omitted:
Queries can be paginated by specifying pagination in the query and supplying an offset and optionaly a limit in the query. If no limit is given, a default of 100 is used. Queries are not paginated unless requested in the query. An example paginated query is shown below:
```
query {
device_list(pagination: {offset: 0, limit: 20}) {
device_list(pagination: { offset: 0, limit: 20 }) {
id
}
}
```
The second page will have an offset equal to the size of the first page. If the number of records is less than the specified limit, there are no more records to process. For example, if a request specifies a `limit` of 20 but returns only 13 records, we can conclude that this is the final page of records.
```
query {
device_list(pagination: {offset: 20, limit: 20}) {
id
}
}
```
### Cursor Pagination
Set the `start` value to zero to fetch the first page. Note that if the `start` parameter is omitted, offset-based pagination will be used by default.
```
query {
device_list(pagination: {start: 0, limit: 20}) {
id
}
}
```
To determine the `start` value for the next page, add 1 to the primary key (`id`) of the last record in the previous page.
For example, if the ID of the last record in the previous response was 123, we would specify a `start` value of 124:
```
query {
device_list(pagination: {start: 124, limit: 20}) {
id
}
}
```
This will return up to 20 records with an ID greater than or equal to 124.
## Authentication
NetBox's GraphQL API uses the same API authentication tokens as its REST API. See the [REST API authentication](./rest-api.md#authentication) documentation for further detail.
NetBox's GraphQL API uses the same API authentication tokens as its REST API. Authentication tokens are included with requests by attaching an `Authorization` HTTP header in the following form:
```
Authorization: Token $TOKEN
```
## Disabling the GraphQL API

View File

@@ -215,51 +215,9 @@ http://netbox/api/ipam/ip-addresses/ \
If we wanted to assign this IP address to a virtual machine interface instead, we would have set `assigned_object_type` to `virtualization.vminterface` and updated the object ID appropriately.
### Specifying Fields
### Brief Format
A REST API response will include all available fields for the object type by default. If you wish to return only a subset of the available fields, you can append `?fields=` to the URL followed by a comma-separated list of field names. For example, the following request will return only the `id`, `name`, `status`, and `region` fields for each site in the response.
```
GET /api/dcim/sites/?fields=id,name,status,region
```
```json
{
"id": 1,
"name": "DM-NYC",
"status": {
"value": "active",
"label": "Active"
},
"region": {
"id": 43,
"url": "http://netbox:8000/api/dcim/regions/43/",
"display": "New York",
"name": "New York",
"slug": "us-ny",
"description": "",
"site_count": 0,
"_depth": 2
}
}
```
Similarly, you can opt to omit only specific fields by passing the `omit` parameter:
```
GET /api/dcim/sites/?omit=circuit_count,device_count,virtualmachine_count
```
!!! note "The `omit` parameter was introduced in NetBox v4.5.2."
Strategic use of the `fields` and `omit` parameters can drastically improve REST API performance, as the exclusion of fields which reference related objects reduces the number and complexity of underlying database queries needed to generate the response.
!!! note
The `fields` and `omit` parameters should be considered mutually exclusive. If both are passed, `fields` takes precedence.
#### Brief Format
Most API endpoints support an optional "brief" format, which returns only a minimal representation of each object in the response. This is useful when you need only a list of available objects without any related data, such as when populating a drop-down list in a form. It's also more convenient than listing out individual fields via the `fields` or `omit` parameters. As an example, the default (complete) format of a prefix looks like this:
Most API endpoints support an optional "brief" format, which returns only a minimal representation of each object in the response. This is useful when you need only a list of available objects without any related data, such as when populating a drop-down list in a form. As an example, the default (complete) format of a prefix looks like this:
```no-highlight
GET /api/ipam/prefixes/13980/
@@ -312,10 +270,10 @@ GET /api/ipam/prefixes/13980/
}
```
The brief format includes only a few fields:
The brief format is much more terse:
```no-highlight
GET /api/ipam/prefixes/13980/?brief=true
GET /api/ipam/prefixes/13980/?brief=1
```
```json

View File

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

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

View File

@@ -129,14 +129,10 @@ class JobFilterSet(BaseFilterSet):
choices=JobStatusChoices,
null_value=None
)
queue_name = django_filters.CharFilter()
class Meta:
model = Job
fields = (
'id', 'object_type', 'object_type_id', 'object_id', 'name', 'interval', 'status', 'user', 'job_id',
'queue_name',
)
fields = ('id', 'object_type', 'object_type_id', 'object_id', 'name', 'interval', 'status', 'user', 'job_id')
def search(self, queryset, name, value):
if not value.strip():

View File

@@ -26,9 +26,8 @@ __all__ = (
class DataSourceFilterForm(PrimaryModelFilterSetForm):
model = DataSource
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('type', 'status', 'enabled', 'sync_interval', name=_('Data Source')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
type = forms.MultipleChoiceField(
label=_('Type'),
@@ -72,7 +71,7 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
model = Job
fieldsets = (
FieldSet('q', 'filter_id'),
FieldSet('object_type_id', 'status', 'queue_name', name=_('Attributes')),
FieldSet('object_type_id', 'status', name=_('Attributes')),
FieldSet(
'created__before', 'created__after', 'scheduled__before', 'scheduled__after', 'started__before',
'started__after', 'completed__before', 'completed__after', 'user', name=_('Creation')
@@ -88,10 +87,6 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
choices=JobStatusChoices,
required=False
)
queue_name = forms.CharField(
label=_('Queue'),
required=False
)
created__after = forms.DateTimeField(
label=_('Created after'),
required=False,

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.2.9 on 2026-01-27 00:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0020_owner'),
]
operations = [
migrations.AddField(
model_name='job',
name='queue_name',
field=models.CharField(blank=True, max_length=100),
),
]

View File

@@ -112,12 +112,6 @@ class Job(models.Model):
verbose_name=_('job ID'),
unique=True
)
queue_name = models.CharField(
verbose_name=_('queue name'),
max_length=100,
blank=True,
help_text=_('Name of the queue in which this job was enqueued')
)
log_entries = ArrayField(
verbose_name=_('log entries'),
base_field=models.JSONField(
@@ -185,15 +179,11 @@ class Job(models.Model):
return f"{int(minutes)} minutes, {seconds:.2f} seconds"
def delete(self, *args, **kwargs):
# Use the stored queue name, or fall back to get_queue_for_model for legacy jobs
rq_queue_name = self.queue_name or get_queue_for_model(self.object_type.model if self.object_type else None)
rq_job_id = str(self.job_id)
super().delete(*args, **kwargs)
# Cancel the RQ job using the stored queue name
rq_queue_name = get_queue_for_model(self.object_type.model if self.object_type else None)
queue = django_rq.get_queue(rq_queue_name)
job = queue.fetch_job(rq_job_id)
job = queue.fetch_job(str(self.job_id))
if job:
try:
@@ -298,8 +288,7 @@ class Job(models.Model):
scheduled=schedule_at,
interval=interval,
user=user,
job_id=uuid.uuid4(),
queue_name=rq_queue_name
job_id=uuid.uuid4()
)
job.full_clean()
job.save()

View File

@@ -9,7 +9,6 @@ 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
@@ -71,12 +70,6 @@ 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.
@@ -103,10 +96,6 @@ 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

@@ -18,7 +18,6 @@ from extras.events import enqueue_event
from extras.models import Tag
from extras.utils import run_validators
from netbox.config import get_config
from utilities.data import get_config_value_ci
from netbox.context import current_request, events_queue
from netbox.models.features import ChangeLoggingMixin, get_model_features, model_is_public
from utilities.exceptions import AbortRequest
@@ -169,7 +168,7 @@ def handle_deleted_object(sender, instance, **kwargs):
# to queueing any events for the object being deleted, in case a validation error is
# raised, causing the deletion to fail.
model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
validators = get_config_value_ci(get_config().PROTECTION_RULES, model_name, default=[])
validators = get_config().PROTECTION_RULES.get(model_name, [])
try:
run_validators(instance, validators)
except ValidationError as e:

View File

@@ -42,9 +42,6 @@ class JobTable(NetBoxTable):
completed = columns.DateTimeColumn(
verbose_name=_('Completed'),
)
queue_name = tables.Column(
verbose_name=_('Queue'),
)
log_entries = tables.Column(
verbose_name=_('Log Entries'),
)
@@ -56,7 +53,7 @@ class JobTable(NetBoxTable):
model = Job
fields = (
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'scheduled', 'interval', 'started',
'completed', 'user', 'queue_name', 'log_entries', 'error', 'job_id',
'completed', 'user', 'error', 'job_id',
)
default_columns = (
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user',

View File

@@ -1,10 +1,8 @@
from unittest.mock import patch, MagicMock
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from django.test import TestCase
from core.models import DataSource, Job, ObjectType
from core.models import DataSource, ObjectType
from core.choices import ObjectChangeActionChoices
from dcim.models import Site, Location, Device
from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED
@@ -202,38 +200,3 @@ class ObjectTypeTest(TestCase):
bookmarks_ots = ObjectType.objects.with_feature('bookmarks')
self.assertIn(ObjectType.objects.get_by_natural_key('dcim', 'site'), bookmarks_ots)
self.assertNotIn(ObjectType.objects.get_by_natural_key('dcim', 'cabletermination'), bookmarks_ots)
class JobTest(TestCase):
@patch('core.models.jobs.django_rq.get_queue')
def test_delete_cancels_job_from_correct_queue(self, mock_get_queue):
"""
Test that when a job is deleted, it's canceled from the correct queue.
"""
mock_queue = MagicMock()
mock_rq_job = MagicMock()
mock_queue.fetch_job.return_value = mock_rq_job
mock_get_queue.return_value = mock_queue
def dummy_func(**kwargs):
pass
# Enqueue a job with a custom queue name
custom_queue = 'my_custom_queue'
job = Job.enqueue(
func=dummy_func,
name='Test Job',
queue_name=custom_queue
)
# Reset mock to clear enqueue call
mock_get_queue.reset_mock()
# Delete the job
job.delete()
# Verify the correct queue was used for cancellation
mock_get_queue.assert_called_with(custom_queue)
mock_queue.fetch_job.assert_called_with(str(job.job_id))
mock_rq_job.cancel.assert_called_once()

View File

@@ -12,12 +12,11 @@ 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 User
from users.models import Owner, User
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField
from utilities.forms.fields import ColorField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import NumberWithOptions
from virtualization.models import Cluster, ClusterGroup, VirtualMachine
@@ -71,11 +70,11 @@ __all__ = (
'SiteFilterForm',
'SiteGroupFilterForm',
'VirtualChassisFilterForm',
'VirtualDeviceContextFilterForm',
'VirtualDeviceContextFilterForm'
)
class DeviceComponentFilterForm(OwnerFilterMixin, NetBoxModelFilterSetForm):
class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
name = forms.CharField(
label=_('Name'),
required=False
@@ -158,14 +157,18 @@ class DeviceComponentFilterForm(OwnerFilterMixin, 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'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('parent_id', name=_('Region')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts'))
)
parent_id = DynamicModelMultipleChoiceField(
@@ -179,9 +182,8 @@ class RegionFilterForm(ContactModelFilterForm, NestedGroupModelFilterSetForm):
class SiteGroupFilterForm(ContactModelFilterForm, NestedGroupModelFilterSetForm):
model = SiteGroup
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
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(
@@ -195,10 +197,9 @@ class SiteGroupFilterForm(ContactModelFilterForm, NestedGroupModelFilterSetForm)
class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilterSetForm):
model = Site
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
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')
@@ -228,10 +229,9 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilt
class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NestedGroupModelFilterSetForm):
model = Location
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
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,8 +277,7 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NestedGroupM
class RackRoleFilterForm(OrganizationalModelFilterSetForm):
model = RackRole
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
)
tag = TagFilterField(model)
@@ -329,11 +328,10 @@ class RackBaseFilterForm(PrimaryModelFilterSetForm):
class RackTypeFilterForm(RackBaseFilterForm):
model = RackType
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
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(
@@ -352,14 +350,13 @@ class RackTypeFilterForm(RackBaseFilterForm):
class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, RackBaseFilterForm):
model = Rack
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
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')
@@ -436,10 +433,9 @@ 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('weight', 'max_weight', 'weight_unit', name=_('Weight')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
)
id = DynamicModelMultipleChoiceField(
queryset=Rack.objects.all(),
@@ -455,11 +451,10 @@ class RackElevationFilterForm(RackFilterForm):
class RackReservationFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = RackReservation
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
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(),
@@ -514,8 +509,7 @@ class RackReservationFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
class ManufacturerFilterForm(ContactModelFilterForm, OrganizationalModelFilterSetForm):
model = Manufacturer
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts'))
)
tag = TagFilterField(model)
@@ -524,7 +518,7 @@ class ManufacturerFilterForm(ContactModelFilterForm, OrganizationalModelFilterSe
class DeviceTypeFilterForm(PrimaryModelFilterSetForm):
model = DeviceType
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet(
'manufacturer_id', 'default_platform_id', 'part_number', 'device_count',
'subdevice_role', 'airflow', name=_('Hardware')
@@ -535,7 +529,6 @@ 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(
@@ -659,8 +652,7 @@ class DeviceTypeFilterForm(PrimaryModelFilterSetForm):
class ModuleTypeProfileFilterForm(PrimaryModelFilterSetForm):
model = ModuleTypeProfile
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
)
selector_fields = ('filter_id', 'q')
tag = TagFilterField(model)
@@ -669,7 +661,7 @@ class ModuleTypeProfileFilterForm(PrimaryModelFilterSetForm):
class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
model = ModuleType
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet(
'profile_id', 'manufacturer_id', 'part_number', 'module_count',
'airflow', name=_('Hardware')
@@ -679,7 +671,6 @@ 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(
@@ -763,9 +754,8 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
class DeviceRoleFilterForm(NestedGroupModelFilterSetForm):
model = DeviceRole
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('parent_id', 'config_template_id', name=_('Device Role')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('parent_id', 'config_template_id', name=_('Device Role'))
)
config_template_id = DynamicModelMultipleChoiceField(
queryset=ConfigTemplate.objects.all(),
@@ -783,9 +773,8 @@ class DeviceRoleFilterForm(NestedGroupModelFilterSetForm):
class PlatformFilterForm(NestedGroupModelFilterSetForm):
model = Platform
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('manufacturer_id', 'parent_id', 'config_template_id', name=_('Platform')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('manufacturer_id', 'parent_id', 'config_template_id', name=_('Platform'))
)
selector_fields = ('filter_id', 'q', 'manufacturer_id')
parent_id = DynamicModelMultipleChoiceField(
@@ -814,12 +803,11 @@ class DeviceFilterForm(
):
model = Device
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
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',
@@ -1008,10 +996,9 @@ class DeviceFilterForm(
class VirtualDeviceContextFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = VirtualDeviceContext
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
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(),
@@ -1036,10 +1023,9 @@ class VirtualDeviceContextFilterForm(TenancyFilterForm, PrimaryModelFilterSetFor
class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm):
model = Module
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
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(),
@@ -1120,10 +1106,9 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, PrimaryM
class VirtualChassisFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = VirtualChassis
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
FieldSet('tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
@@ -1150,11 +1135,10 @@ class VirtualChassisFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
class CableFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = Cable
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
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(),
@@ -1240,9 +1224,8 @@ class CableFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
class PowerPanelFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
model = PowerPanel
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
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')
@@ -1280,11 +1263,10 @@ class PowerPanelFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
class PowerFeedFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = PowerFeed
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('region_id', 'site_group_id', 'site_id', 'power_panel_id', 'rack_id', name=_('Location')),
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')),
FieldSet('status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', name=_('Attributes')),
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
@@ -1408,7 +1390,7 @@ class PathEndpointFilterForm(CabledFilterForm):
class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = ConsolePort
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
@@ -1416,7 +1398,6 @@ 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'),
@@ -1448,7 +1429,7 @@ class ConsolePortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = ConsoleServerPort
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
@@ -1456,7 +1437,6 @@ 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'),
@@ -1488,7 +1468,7 @@ class ConsoleServerPortTemplateFilterForm(ModularDeviceComponentTemplateFilterFo
class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = PowerPort
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('name', 'label', 'type', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
@@ -1496,7 +1476,6 @@ 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'),
@@ -1523,7 +1502,7 @@ class PowerPortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = PowerOutlet
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('name', 'label', 'type', 'color', 'status', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
@@ -1531,7 +1510,6 @@ 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'),
@@ -1567,7 +1545,7 @@ class PowerOutletTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = Interface
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
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')),
@@ -1580,7 +1558,6 @@ 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(
@@ -1739,7 +1716,7 @@ class InterfaceTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
@@ -1747,7 +1724,6 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
name=_('Device')
),
FieldSet('cabled', 'occupied', name=_('Cable')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
model = FrontPort
type = forms.MultipleChoiceField(
@@ -1783,7 +1759,7 @@ class FrontPortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
model = RearPort
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
@@ -1791,7 +1767,6 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
name=_('Device')
),
FieldSet('cabled', 'occupied', name=_('Cable')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
type = forms.MultipleChoiceField(
label=_('Type'),
@@ -1826,14 +1801,13 @@ class RearPortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
class ModuleBayFilterForm(DeviceComponentFilterForm):
model = ModuleBay
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
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(
@@ -1858,14 +1832,13 @@ class ModuleBayTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
class DeviceBayFilterForm(DeviceComponentFilterForm):
model = DeviceBay
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
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)
@@ -1882,7 +1855,7 @@ class DeviceBayTemplateFilterForm(DeviceComponentTemplateFilterForm):
class InventoryItemFilterForm(DeviceComponentFilterForm):
model = InventoryItem
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet(
'name', 'label', 'status', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered',
name=_('Attributes')
@@ -1892,7 +1865,6 @@ 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(),
@@ -1953,8 +1925,7 @@ class InventoryItemTemplateFilterForm(DeviceComponentTemplateFilterForm):
class InventoryItemRoleFilterForm(OrganizationalModelFilterSetForm):
model = InventoryItemRole
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
)
tag = TagFilterField(model)
@@ -1966,10 +1937,9 @@ class InventoryItemRoleFilterForm(OrganizationalModelFilterSetForm):
class MACAddressFilterForm(PrimaryModelFilterSetForm):
model = MACAddress
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
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 self.instance.pk and scope_type_id != self.instance.scope_type_id:
if self.instance and scope_type_id != self.instance.scope_type_id:
self.initial['scope'] = None
else:

View File

@@ -3,7 +3,6 @@ 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
@@ -272,7 +271,7 @@ class InventoryItemCreateForm(ComponentCreateForm, model_forms.InventoryItemForm
# Virtual chassis
#
class VirtualChassisCreateForm(OwnerMixin, NetBoxModelForm):
class VirtualChassisCreateForm(NetBoxModelForm):
region = DynamicModelChoiceField(
label=_('Region'),
queryset=Region.objects.all(),

View File

@@ -550,10 +550,6 @@ 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:
@@ -893,7 +889,7 @@ class PowerPortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedM
@strawberry_django.filter_type(models.RackType, lookups=True)
class RackTypeFilter(ImageAttachmentFilterMixin, RackFilterMixin, WeightFilterMixin, PrimaryModelFilter):
class RackTypeFilter(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(ImageAttachmentsMixin, PrimaryObjectType):
class RackTypeType(PrimaryObjectType):
rack_count: BigInt
manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]

View File

@@ -0,0 +1,32 @@
from django.db import migrations
import mptt.managers
import mptt.models
def rebuild_mptt(apps, schema_editor):
"""
Rebuild the MPTT tree for ModuleBay to apply new ordering.
"""
ModuleBay = apps.get_model('dcim', 'ModuleBay')
# Set MPTTMeta with the correct order_insertion_by
class MPTTMeta:
order_insertion_by = ('module', 'name',)
ModuleBay.MPTTMeta = MPTTMeta
ModuleBay._mptt_meta = mptt.models.MPTTOptions(MPTTMeta)
manager = mptt.managers.TreeManager()
manager.model = ModuleBay
manager.contribute_to_class(ModuleBay, 'objects')
manager.rebuild()
class Migration(migrations.Migration):
dependencies = [
('dcim', '0225_gfk_indexes'),
]
operations = [
migrations.RunPython(code=rebuild_mptt, reverse_code=migrations.RunPython.noop),
]

View File

@@ -1273,7 +1273,7 @@ class ModuleBay(ModularComponentModel, TrackingModelMixin, MPTTModel):
verbose_name_plural = _('module bays')
class MPTTMeta:
order_insertion_by = ('module',)
order_insertion_by = ('module', 'name',)
def clean(self):
super().clean()

View File

@@ -5,9 +5,10 @@ from django.db import models
from django.db.models.signals import post_save
from django.utils.translation import gettext_lazy as _
from jsonschema.exceptions import ValidationError as JSONValidationError
from mptt.models import MPTTModel
from dcim.choices import *
from dcim.utils import create_port_mappings, update_interface_bridges
from dcim.utils import update_interface_bridges
from extras.models import ConfigContextModel, CustomField
from netbox.models import PrimaryModel
from netbox.models.features import ImageAttachmentsMixin
@@ -155,8 +156,6 @@ 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,
}
@@ -331,7 +330,8 @@ class Module(TrackingModelMixin, PrimaryModel, ConfigContextModel):
component._location = self.device.location
component._rack = self.device.rack
if component_model is not ModuleBay:
# we handle create and update separately - this is for create
if not issubclass(component_model, MPTTModel):
component_model.objects.bulk_create(create_instances)
# Emit the post_save signal for each newly created object
for component in create_instances:
@@ -344,11 +344,13 @@ class Module(TrackingModelMixin, PrimaryModel, ConfigContextModel):
update_fields=None
)
else:
# ModuleBays must be saved individually for MPTT
# MPTT models must be saved individually to maintain tree structure
for instance in create_instances:
instance.save()
update_fields = ['module']
# we handle create and update separately - this is for update
component_model.objects.bulk_update(update_instances, update_fields)
# Emit the post_save signal for each updated object
for component in update_instances:
@@ -361,7 +363,9 @@ class Module(TrackingModelMixin, PrimaryModel, ConfigContextModel):
update_fields=update_fields
)
# Replicate any front/rear port mappings from the ModuleType
create_port_mappings(self.device, self.module_type, self)
# Rebuild MPTT tree if needed (bulk_update bypasses model save)
if issubclass(component_model, MPTTModel) and update_instances:
component_model.objects.rebuild()
# Interface bridges have to be set after interface instantiation
update_interface_bridges(self.device, self.module_type.interfacetemplates, self)

View File

@@ -122,7 +122,7 @@ class RackBase(WeightMixin, PrimaryModel):
abstract = True
class RackType(ImageAttachmentsMixin, RackBase):
class RackType(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,7 +27,6 @@ __all__ = (
'DeviceTable',
'FrontPortTable',
'InterfaceTable',
'InterfaceLAGMemberTable',
'InventoryItemRoleTable',
'InventoryItemTable',
'MACAddressTable',
@@ -690,33 +689,6 @@ 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,24 +24,6 @@ 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

@@ -875,142 +875,6 @@ class ModuleBayTestCase(TestCase):
self.assertIsNone(bay2.parent)
self.assertIsNone(bay2.module)
def test_module_installation_creates_port_mappings(self):
"""
Test that installing a module with front/rear port templates correctly
creates PortMapping instances for the device.
"""
device = Device.objects.first()
manufacturer = Manufacturer.objects.first()
module_bay = ModuleBay.objects.create(device=device, name='Test Bay PortMapping 1')
# Create a module type with a rear port template
module_type_with_mappings = ModuleType.objects.create(
manufacturer=manufacturer,
model='Module Type With Mappings',
)
# Create a rear port template with 12 positions (splice)
rear_port_template = RearPortTemplate.objects.create(
module_type=module_type_with_mappings,
name='Rear Port 1',
type=PortTypeChoices.TYPE_SPLICE,
positions=12,
)
# Create 12 front port templates mapped to the rear port
front_port_templates = []
for i in range(1, 13):
front_port_template = FrontPortTemplate.objects.create(
module_type=module_type_with_mappings,
name=f'port {i}',
type=PortTypeChoices.TYPE_LC,
positions=1,
)
front_port_templates.append(front_port_template)
# Create port template mapping
PortTemplateMapping.objects.create(
device_type=None,
module_type=module_type_with_mappings,
front_port=front_port_template,
front_port_position=1,
rear_port=rear_port_template,
rear_port_position=i,
)
# Install the module
module = Module.objects.create(
device=device,
module_bay=module_bay,
module_type=module_type_with_mappings,
status=ModuleStatusChoices.STATUS_ACTIVE,
)
# Verify that front ports were created
front_ports = FrontPort.objects.filter(device=device, module=module)
self.assertEqual(front_ports.count(), 12)
# Verify that the rear port was created
rear_ports = RearPort.objects.filter(device=device, module=module)
self.assertEqual(rear_ports.count(), 1)
rear_port = rear_ports.first()
self.assertEqual(rear_port.positions, 12)
# Verify that port mappings were created
port_mappings = PortMapping.objects.filter(front_port__module=module)
self.assertEqual(port_mappings.count(), 12)
# Verify each mapping is correct
for i, front_port_template in enumerate(front_port_templates, start=1):
front_port = FrontPort.objects.get(
device=device,
name=front_port_template.name,
module=module,
)
# Check that a mapping exists for this front port
mapping = PortMapping.objects.get(
device=device,
front_port=front_port,
front_port_position=1,
)
self.assertEqual(mapping.rear_port, rear_port)
self.assertEqual(mapping.front_port_position, 1)
self.assertEqual(mapping.rear_port_position, i)
def test_module_installation_without_mappings(self):
"""
Test that installing a module without port template mappings
doesn't create any PortMapping instances.
"""
device = Device.objects.first()
manufacturer = Manufacturer.objects.first()
module_bay = ModuleBay.objects.create(device=device, name='Test Bay PortMapping 2')
# Create a module type without any port template mappings
module_type_no_mappings = ModuleType.objects.create(
manufacturer=manufacturer,
model='Module Type Without Mappings',
)
# Create a rear port template
RearPortTemplate.objects.create(
module_type=module_type_no_mappings,
name='Rear Port 1',
type=PortTypeChoices.TYPE_SPLICE,
positions=12,
)
# Create front port templates but DO NOT create PortTemplateMapping rows
for i in range(1, 13):
FrontPortTemplate.objects.create(
module_type=module_type_no_mappings,
name=f'port {i}',
type=PortTypeChoices.TYPE_LC,
positions=1,
)
# Install the module
module = Module.objects.create(
device=device,
module_bay=module_bay,
module_type=module_type_no_mappings,
status=ModuleStatusChoices.STATUS_ACTIVE,
)
# Verify no port mappings were created for this module
port_mappings = PortMapping.objects.filter(
device=device,
front_port__module=module,
front_port_position=1,
)
self.assertEqual(port_mappings.count(), 0)
self.assertEqual(FrontPort.objects.filter(module=module).count(), 12)
self.assertEqual(RearPort.objects.filter(module=module).count(), 1)
self.assertEqual(PortMapping.objects.filter(front_port__module=module).count(), 0)
class CableTestCase(TestCase):

View File

@@ -85,13 +85,13 @@ def update_interface_bridges(device, interface_templates, module=None):
interface.save()
def create_port_mappings(device, device_or_module_type, module=None):
def create_port_mappings(device, device_type, module=None):
"""
Replicate all front/rear port mappings from a DeviceType or ModuleType to the given device.
Replicate all front/rear port mappings from a DeviceType to the given device.
"""
from dcim.models import FrontPort, PortMapping, RearPort
templates = device_or_module_type.port_mappings.prefetch_related('front_port', 'rear_port')
templates = device_type.port_mappings.prefetch_related('front_port', 'rear_port')
# Cache front & rear ports for efficient lookups by name
front_ports = {

View File

@@ -880,7 +880,6 @@ class RackTypeView(GetRelatedModelsMixin, generic.ObjectView):
panels.RackWeightPanel(title=_('Weight'), exclude=['total_weight']),
CustomFieldsPanel(),
RelatedObjectsPanel(),
ImageAttachmentsPanel(),
],
)
@@ -3136,14 +3135,6 @@ 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:
@@ -3173,7 +3164,6 @@ 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

@@ -4,6 +4,7 @@ from drf_spectacular.utils import extend_schema_field
from rest_framework.fields import Field
from rest_framework.serializers import ValidationError
from core.models import ObjectType
from extras.choices import CustomFieldTypeChoices
from extras.constants import CUSTOMFIELD_EMPTY_VALUES
from extras.models import CustomField
@@ -24,7 +25,8 @@ class CustomFieldDefaultValues:
self.model = serializer_field.parent.Meta.model
# Retrieve the CustomFields for the parent model
fields = CustomField.objects.get_for_model(self.model)
object_type = ObjectType.objects.get_for_model(self.model)
fields = CustomField.objects.filter(object_types=object_type)
# Populate the default value for each CustomField
value = {}
@@ -45,7 +47,8 @@ class CustomFieldsDataField(Field):
Cache CustomFields assigned to this model to avoid redundant database queries
"""
if not hasattr(self, '_custom_fields'):
self._custom_fields = CustomField.objects.get_for_model(self.parent.Meta.model)
object_type = ObjectType.objects.get_for_model(self.parent.Meta.model)
self._custom_fields = CustomField.objects.filter(object_types=object_type)
return self._custom_fields
def to_representation(self, obj):

View File

@@ -75,11 +75,10 @@ def get_bookmarks_object_type_choices():
def get_models_from_content_types(content_types):
"""
Return a list of models corresponding to the given content types, identified by natural key.
Accepts both lowercase (e.g. "dcim.site") and PascalCase (e.g. "dcim.Site") model names.
"""
models = []
for content_type_id in content_types:
app_label, model_name = content_type_id.lower().split('.')
app_label, model_name = content_type_id.split('.')
try:
content_type = ObjectType.objects.get_by_natural_key(app_label, model_name)
if content_type.model_class():

View File

@@ -1,5 +1,5 @@
import logging
from collections import UserDict, defaultdict
from collections import defaultdict
from django.conf import settings
from django.utils import timezone
@@ -12,6 +12,7 @@ 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
@@ -22,21 +23,6 @@ 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.
@@ -51,26 +37,18 @@ def serialize_for_event(instance):
def get_snapshots(instance, event_type):
"""
Return a dictionary of pre- and post-change snapshots for the given instance.
"""
if event_type == OBJECT_DELETED:
# Post-change snapshot must be empty for deleted objects
postchange_snapshot = None
elif hasattr(instance, '_postchange_snapshot'):
# Use the cached post-change snapshot if one is available
postchange_snapshot = instance._postchange_snapshot
elif hasattr(instance, 'serialize_object'):
# Use model's serialize_object() method if defined
postchange_snapshot = instance.serialize_object()
else:
# Fall back to the serialize_object() utility function
postchange_snapshot = serialize_object(instance)
return {
snapshots = {
'prechange': getattr(instance, '_prechange_snapshot', None),
'postchange': postchange_snapshot,
'postchange': None,
}
if event_type != OBJECT_DELETED:
# Use model's serialize_object() method if defined; fall back to serialize_object() utility function
if hasattr(instance, 'serialize_object'):
snapshots['postchange'] = instance.serialize_object()
else:
snapshots['postchange'] = serialize_object(instance)
return snapshots
def enqueue_event(queue, instance, request, event_type):
@@ -88,42 +66,37 @@ 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] = 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,
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,
# Legacy request attributes for backward compatibility
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)
'username': request.user.username,
'request_id': request.id,
}
def process_event_rules(event_rules, object_type, event):
"""
Process a list of EventRules against an event.
"""
def process_event_rules(event_rules, object_type, event_type, data, username=None, snapshots=None, request=None):
user = None # To be resolved from the username if needed
for event_rule in event_rules:
# Evaluate event rule conditions (if any)
if not event_rule.eval_conditions(event['data']):
if not event_rule.eval_conditions(data):
continue
# Compile event data
event_data = event_rule.action_data or {}
event_data.update(event['data'])
event_data.update(data)
# Webhooks
if event_rule.action_type == EventRuleActionChoices.WEBHOOK:
@@ -136,41 +109,50 @@ def process_event_rules(event_rules, object_type, event):
params = {
"event_rule": event_rule,
"object_type": object_type,
"event_type": event['event_type'],
"event_type": event_type,
"data": event_data,
"snapshots": event.get('snapshots'),
"snapshots": snapshots,
"timestamp": timezone.now().isoformat(),
"username": event['username'],
"username": username,
"retry": get_rq_retry()
}
if 'request' in event:
if snapshots:
params["snapshots"] = snapshots
if request:
# Exclude FILES - webhooks don't need uploaded files,
# which can cause pickle errors with Pillow.
params['request'] = copy_safe_request(event['request'], include_files=False)
params["request"] = copy_safe_request(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:
# Resolve the script from action parameters
script = event_rule.action_object.python_class()
# Retrieve the User if not already resolved
if user is None:
user = User.objects.get(username=username)
# Enqueue a Job to record the script's execution
from extras.jobs import ScriptJob
params = {
"instance": event_rule.action_object,
"name": script.name,
"user": event['user'],
"user": user,
"data": event_data
}
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)
if snapshots:
params["snapshots"] = snapshots
if request:
params["request"] = copy_safe_request(request)
ScriptJob.enqueue(
**params
)
# Notification groups
elif event_rule.action_type == EventRuleActionChoices.NOTIFICATION:
@@ -179,7 +161,7 @@ def process_event_rules(event_rules, object_type, event):
object_type=object_type,
object_id=event_data['id'],
object_repr=event_data.get('display'),
event_type=event['event_type']
event_type=event_type
)
else:
@@ -191,8 +173,6 @@ def process_event_rules(event_rules, object_type, event):
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)
@@ -212,7 +192,11 @@ def process_event_queue(events):
process_event_rules(
event_rules=event_rules,
object_type=object_type,
event=event,
event_type=event['event_type'],
data=event['data'],
username=event['username'],
snapshots=event['snapshots'],
request=event['request'],
)

View File

@@ -7,12 +7,13 @@ 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 OwnerFilterMixin, SavedFiltersMixin
from netbox.forms.mixins import SavedFiltersMixin
from tenancy.models import Tenant, TenantGroup
from users.models import Group, User
from users.models import Group, Owner, User
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
from utilities.forms.fields import (
ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField,
ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
TagFilterField,
)
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import DateTimePicker
@@ -38,7 +39,7 @@ __all__ = (
)
class CustomFieldFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
model = CustomField
fieldsets = (
FieldSet('q', 'filter_id'),
@@ -46,7 +47,6 @@ class CustomFieldFilterForm(OwnerFilterMixin, 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,14 +119,18 @@ class CustomFieldFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
label=_('Validation regex'),
required=False
)
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
class CustomFieldChoiceSetFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
class CustomFieldChoiceSetFilterForm(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,
@@ -135,14 +139,18 @@ class CustomFieldChoiceSetFilterForm(OwnerFilterMixin, SavedFiltersMixin, Filter
choice = forms.CharField(
required=False
)
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
class CustomLinkFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
class CustomLinkFilterForm(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'),
@@ -167,15 +175,19 @@ class CustomLinkFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
label=_('Weight'),
required=False
)
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
class ExportTemplateFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
class ExportTemplateFilterForm(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(),
@@ -214,6 +226,11 @@ class ExportTemplateFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm):
@@ -233,12 +250,11 @@ class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm):
)
class SavedFilterFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
class SavedFilterFilterForm(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'),
@@ -263,6 +279,11 @@ class SavedFilterFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
label=_('Weight'),
required=False
)
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
class TableConfigFilterForm(SavedFiltersMixin, FilterForm):
@@ -296,12 +317,11 @@ class TableConfigFilterForm(SavedFiltersMixin, FilterForm):
)
class WebhookFilterForm(OwnerFilterMixin, NetBoxModelFilterSetForm):
class WebhookFilterForm(NetBoxModelFilterSetForm):
model = Webhook
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
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'),
@@ -316,15 +336,19 @@ class WebhookFilterForm(OwnerFilterMixin, NetBoxModelFilterSetForm):
required=False,
label=_('HTTP method')
)
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
tag = TagFilterField(model)
class EventRuleFilterForm(OwnerFilterMixin, NetBoxModelFilterSetForm):
class EventRuleFilterForm(NetBoxModelFilterSetForm):
model = EventRule
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
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'),
@@ -348,16 +372,16 @@ class EventRuleFilterForm(OwnerFilterMixin, NetBoxModelFilterSetForm):
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
tag = TagFilterField(model)
class TagFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
class TagFilterForm(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,
@@ -368,6 +392,11 @@ class TagFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
required=False,
label=_('Allowed object type')
)
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
class ConfigContextProfileFilterForm(PrimaryModelFilterSetForm):
@@ -375,7 +404,6 @@ 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(),
@@ -392,17 +420,16 @@ class ConfigContextProfileFilterForm(PrimaryModelFilterSetForm):
)
class ConfigContextFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
model = ConfigContext
fieldsets = (
FieldSet('q', 'filter_id', 'tag_id'),
FieldSet('profile_id', name=_('Config Context')),
FieldSet('profile', 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('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant'))
)
profile_id = DynamicModelMultipleChoiceField(
queryset=ConfigContextProfile.objects.all(),
@@ -487,15 +514,19 @@ class ConfigContextFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
required=False,
label=_('Tags')
)
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
class ConfigTemplateFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
class ConfigTemplateFilterForm(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('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Rendering'))
)
data_source_id = DynamicModelMultipleChoiceField(
queryset=DataSource.objects.all(),
@@ -537,6 +568,11 @@ class ConfigTemplateFilterForm(OwnerFilterMixin, 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,13 +178,6 @@ 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

@@ -19,7 +19,6 @@ from django.utils.translation import gettext_lazy as _
from core.models import ObjectType
from extras.choices import *
from extras.data import CHOICE_SETS
from netbox.context import query_cache
from netbox.models import ChangeLoggedModel
from netbox.models.features import CloningMixin, ExportTemplatesMixin
from netbox.models.mixins import OwnerMixin
@@ -59,20 +58,8 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
"""
Return all CustomFields assigned to the given model.
"""
# Check the request cache before hitting the database
cache = query_cache.get()
if cache is not None:
if custom_fields := cache['custom_fields'].get(model._meta.model):
return custom_fields
content_type = ObjectType.objects.get_for_model(model._meta.concrete_model)
custom_fields = self.get_queryset().filter(object_types=content_type)
# Populate the request cache to avoid redundant lookups
if cache is not None:
cache['custom_fields'][model._meta.model] = custom_fields
return custom_fields
return self.get_queryset().filter(object_types=content_type)
def get_defaults_for_model(self, model):
"""

View File

@@ -137,7 +137,7 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
module = self.get_module()
except Exception as e:
self.error = e
logger.error(f"Failed to load script: {self.python_name} error: {e}")
logger.debug(f"Failed to load script: {self.python_name} error: {e}")
module = None
scripts = {}

View File

@@ -61,7 +61,7 @@ class ScriptVariable:
self.field_attrs['label'] = label
if description:
self.field_attrs['help_text'] = description
if default is not None:
if default:
self.field_attrs['initial'] = default
if widget:
self.field_attrs['widget'] = widget

View File

@@ -4,12 +4,11 @@ from django.dispatch import receiver
from core.events import *
from core.signals import job_end, job_start
from extras.events import EventContext, process_event_rules
from extras.events import process_event_rules
from extras.models import EventRule, Notification, Subscription
from netbox.config import get_config
from netbox.models.features import has_feature
from netbox.signals import post_clean
from utilities.data import get_config_value_ci
from utilities.exceptions import AbortRequest
from .models import CustomField, TaggedItem
from .utils import run_validators
@@ -66,7 +65,7 @@ def run_save_validators(sender, instance, **kwargs):
Run any custom validation rules for the model prior to calling save().
"""
model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
validators = get_config_value_ci(get_config().CUSTOM_VALIDATORS, model_name, default=[])
validators = get_config().CUSTOM_VALIDATORS.get(model_name, [])
run_validators(instance, validators)
@@ -103,12 +102,14 @@ def process_job_start_event_rules(sender, **kwargs):
enabled=True,
object_types=sender.object_type
)
event = EventContext(
username = sender.user.username if sender.user else None
process_event_rules(
event_rules=event_rules,
object_type=sender.object_type,
event_type=JOB_STARTED,
data=sender.data,
user=sender.user,
username=username
)
process_event_rules(event_rules, sender.object_type, event)
@receiver(job_end)
@@ -121,12 +122,14 @@ def process_job_end_event_rules(sender, **kwargs):
enabled=True,
object_types=sender.object_type
)
event = EventContext(
username = sender.user.username if sender.user else None
process_event_rules(
event_rules=event_rules,
object_type=sender.object_type,
event_type=JOB_COMPLETED,
data=sender.data,
user=sender.user,
username=username
)
process_event_rules(event_rules, sender.object_type, event)
#

View File

@@ -1,7 +1,6 @@
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from django.test import tag
from unittest.mock import patch, PropertyMock
from core.choices import ManagedFileRootPathChoices
from core.events import *
@@ -907,7 +906,7 @@ class ScriptValidationErrorTest(TestCase):
user_permissions = ['extras.view_script', 'extras.run_script']
class TestScriptMixin:
bar = IntegerVar(min_value=0, max_value=30)
bar = IntegerVar(min_value=0, max_value=30, default=30)
class TestScriptClass(TestScriptMixin, PythonClass):
class Meta:
@@ -931,6 +930,8 @@ class ScriptValidationErrorTest(TestCase):
@tag('regression')
def test_script_validation_error_displays_message(self):
from unittest.mock import patch
url = reverse('extras:script', kwargs={'pk': self.script.pk})
with patch('extras.views.get_workers_for_queue', return_value=['worker']):
@@ -943,6 +944,8 @@ class ScriptValidationErrorTest(TestCase):
@tag('regression')
def test_script_validation_error_no_toast_for_fieldset_fields(self):
from unittest.mock import patch, PropertyMock
class FieldsetScript(PythonClass):
class Meta:
name = 'Fieldset test'
@@ -964,42 +967,3 @@ class ScriptValidationErrorTest(TestCase):
self.assertEqual(response.status_code, 200)
messages = list(response.context['messages'])
self.assertEqual(len(messages), 0)
class ScriptDefaultValuesTest(TestCase):
user_permissions = ['extras.view_script', 'extras.run_script']
class TestScriptClass(PythonClass):
class Meta:
name = 'Test script'
commit_default = False
bool_default_true = BooleanVar(default=True)
bool_default_false = BooleanVar(default=False)
int_with_default = IntegerVar(default=0)
int_without_default = IntegerVar(required=False)
def run(self, data, commit):
return "Complete"
@classmethod
def setUpTestData(cls):
module = ScriptModule.objects.create(file_root=ManagedFileRootPathChoices.SCRIPTS, file_path='test_script.py')
cls.script = Script.objects.create(module=module, name='Test script', is_executable=True)
def setUp(self):
super().setUp()
Script.python_class = property(lambda self: ScriptDefaultValuesTest.TestScriptClass)
def test_default_values_are_used(self):
url = reverse('extras:script', kwargs={'pk': self.script.pk})
with patch('extras.views.get_workers_for_queue', return_value=['worker']):
with patch('extras.jobs.ScriptJob.enqueue') as mock_enqueue:
mock_enqueue.return_value.pk = 1
self.client.post(url, {})
call_kwargs = mock_enqueue.call_args.kwargs
self.assertEqual(call_kwargs['data']['bool_default_true'], True)
self.assertEqual(call_kwargs['data']['bool_default_false'], False)
self.assertEqual(call_kwargs['data']['int_with_default'], 0)
self.assertIsNone(call_kwargs['data']['int_without_default'])

View File

@@ -1511,13 +1511,7 @@ class ScriptView(BaseScriptView):
'script': script,
})
# Populate missing variables with their default values, if defined
post_data = request.POST.copy()
for name, var in script_class._get_vars().items():
if name not in post_data and (initial := var.field_attrs.get('initial')) is not None:
post_data[name] = initial
form = script_class.as_form(post_data, request.FILES)
form = script_class.as_form(request.POST, request.FILES)
# Allow execution only if RQ worker process is running
if not get_workers_for_queue('default'):

View File

@@ -45,10 +45,9 @@ IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([
class VRFFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = VRF
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
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(),
@@ -66,10 +65,9 @@ class VRFFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
class RouteTargetFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = RouteTarget
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
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(),
@@ -87,9 +85,8 @@ class RouteTargetFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
class RIRFilterForm(OrganizationalModelFilterSetForm):
model = RIR
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('is_private', name=_('RIR')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
is_private = forms.NullBooleanField(
required=False,
@@ -104,10 +101,9 @@ class RIRFilterForm(OrganizationalModelFilterSetForm):
class AggregateFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm):
model = Aggregate
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
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(
@@ -126,10 +122,9 @@ class AggregateFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryMode
class ASNRangeFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm):
model = ASNRange
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
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(),
@@ -150,10 +145,9 @@ class ASNRangeFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm):
class ASNFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = ASN
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
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(),
@@ -176,8 +170,7 @@ class ASNFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
class RoleFilterForm(OrganizationalModelFilterSetForm):
model = Role
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
)
tag = TagFilterField(model)
@@ -185,7 +178,7 @@ class RoleFilterForm(OrganizationalModelFilterSetForm):
class PrefixFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm):
model = Prefix
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet(
'within_include', 'family', 'status', 'role_id', 'mask_length', 'is_pool', 'mark_utilized',
name=_('Addressing')
@@ -194,7 +187,6 @@ 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(
@@ -292,10 +284,9 @@ class PrefixFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFi
class IPRangeFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm):
model = IPRange
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
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(
@@ -340,15 +331,14 @@ class IPRangeFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelF
class IPAddressFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm):
model = IPAddress
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet(
'parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface', 'dns_name',
name=_('Attributes')
),
FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
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('device_id', 'virtual_machine_id', name=_('Device/VM')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
)
selector_fields = ('filter_id', 'q', 'region_id', 'group_id', 'parent', 'status', 'role')
@@ -419,10 +409,9 @@ class IPAddressFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryMode
class FHRPGroupFilterForm(PrimaryModelFilterSetForm):
model = FHRPGroup
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
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'),
@@ -452,12 +441,11 @@ class FHRPGroupFilterForm(PrimaryModelFilterSetForm):
class VLANGroupFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm):
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
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(
@@ -507,9 +495,8 @@ class VLANGroupFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm):
class VLANTranslationPolicyFilterForm(PrimaryModelFilterSetForm):
model = VLANTranslationPolicy
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('name', name=_('Attributes')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
name = forms.CharField(
required=False,
@@ -545,12 +532,11 @@ class VLANTranslationRuleFilterForm(NetBoxModelFilterSetForm):
class VLANFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = VLAN
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
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(
@@ -618,9 +604,8 @@ class VLANFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
class ServiceTemplateFilterForm(PrimaryModelFilterSetForm):
model = ServiceTemplate
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('protocol', 'port', name=_('Attributes')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
protocol = forms.ChoiceField(
label=_('Protocol'),
@@ -637,10 +622,9 @@ class ServiceTemplateFilterForm(PrimaryModelFilterSetForm):
class ServiceFilterForm(ContactModelFilterForm, ServiceTemplateFilterForm):
model = Service
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
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,11 +370,6 @@ 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

@@ -6,7 +6,7 @@ PREFIX_LINK = """
{% if record.pk %}
<a href="{{ record.get_absolute_url }}" id="prefix_{{ record.pk }}">{{ record.prefix }}</a>
{% else %}
<a href="{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.scope %}&scope_type={{ object.scope_type.pk }}&scope={{ object.scope.pk }}{% endif %}{% if object.tenant %}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}{% endif %}">{{ record.prefix }}</a>
<a href="{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.site %}&site={{ object.site.pk }}{% endif %}{% if object.tenant %}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}{% endif %}">{{ record.prefix }}</a>
{% endif %}
"""

View File

@@ -4,7 +4,6 @@ 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
@@ -160,26 +159,11 @@ 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', 'link_peer', 'connection', 'tagged', 'actions')
default_columns = ('device', 'name', 'connection', 'tagged', 'actions')
exclude = ('id',)
fields = ('device', 'name', 'tagged', 'actions')
exclude = ('id', )
class VLANVirtualMachinesTable(VLANMembersTable):

View File

@@ -1,41 +0,0 @@
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,9 +49,6 @@ 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

@@ -1,8 +1,9 @@
from functools import cached_property
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from rest_framework.utils.serializer_helpers import BindingDict
from drf_spectacular.utils import extend_schema_field
from drf_spectacular.types import OpenApiTypes
from utilities.api import get_related_object_by_attrs
from .fields import NetBoxAPIHyperlinkedIdentityField, NetBoxURLHyperlinkedIdentityField
@@ -18,18 +19,16 @@ class BaseModelSerializer(serializers.ModelSerializer):
display_url = NetBoxURLHyperlinkedIdentityField()
display = serializers.SerializerMethodField(read_only=True)
def __init__(self, *args, nested=False, fields=None, omit=None, **kwargs):
def __init__(self, *args, nested=False, fields=None, **kwargs):
"""
Extends the base __init__() method to support dynamic fields.
:param nested: Set to True if this serializer is being employed within a parent serializer
:param fields: An iterable of fields to include when rendering the serialized object, If nested is
True but no fields are specified, Meta.brief_fields will be used.
:param omit: An iterable of fields to omit from the serialized object
"""
self.nested = nested
self._include_fields = fields or []
self._omit_fields = omit or []
self._requested_fields = fields
# Disable validators for nested objects (which already exist)
if self.nested:
@@ -37,8 +36,8 @@ class BaseModelSerializer(serializers.ModelSerializer):
# If this serializer is nested but no fields have been specified,
# default to using Meta.brief_fields (if set)
if self.nested and not fields and not omit:
self._include_fields = getattr(self.Meta, 'brief_fields', None)
if self.nested and not fields:
self._requested_fields = getattr(self.Meta, 'brief_fields', None)
super().__init__(*args, **kwargs)
@@ -55,19 +54,16 @@ class BaseModelSerializer(serializers.ModelSerializer):
@cached_property
def fields(self):
"""
Override the fields property to return only specifically requested fields if needed.
Override the fields property to check for requested fields. If defined,
return only the applicable fields.
"""
fields = super().fields
# Include only requested fields
if self._include_fields:
for field_name in set(fields) - set(self._include_fields):
fields.pop(field_name, None)
# Remove omitted fields
for field_name in set(self._omit_fields):
fields.pop(field_name, None)
if not self._requested_fields:
return super().fields
fields = BindingDict(self)
for key, value in self.get_fields().items():
if key in self._requested_fields:
fields[key] = value
return fields
@extend_schema_field(OpenApiTypes.STR)

View File

@@ -5,13 +5,13 @@ from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.db import router, transaction
from django.db.models import ProtectedError, RestrictedError
from django_pglocks import advisory_lock
from netbox.constants import ADVISORY_LOCK_KEYS
from rest_framework import mixins as drf_mixins
from rest_framework import status
from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet
from netbox.api.serializers.features import ChangeLogMessageSerializer
from netbox.constants import ADVISORY_LOCK_KEYS
from utilities.api import get_annotations_for_serializer, get_prefetches_for_serializer
from utilities.exceptions import AbortRequest
from utilities.query import reapply_model_ordering
@@ -59,38 +59,33 @@ class BaseViewSet(GenericViewSet):
serializer_class = self.get_serializer_class()
# Dynamically resolve prefetches for included serializer fields and attach them to the queryset
if prefetch := get_prefetches_for_serializer(serializer_class, **self.field_kwargs):
if prefetch := get_prefetches_for_serializer(serializer_class, fields_to_include=self.requested_fields):
qs = qs.prefetch_related(*prefetch)
# Dynamically resolve annotations for RelatedObjectCountFields on the serializer and attach them to the queryset
if annotations := get_annotations_for_serializer(serializer_class, **self.field_kwargs):
if annotations := get_annotations_for_serializer(serializer_class, fields_to_include=self.requested_fields):
qs = qs.annotate(**annotations)
return qs
def get_serializer(self, *args, **kwargs):
# Pass the fields/omit kwargs (if specified by the request) to the serializer
kwargs.update(**self.field_kwargs)
# If specific fields have been requested, pass them to the serializer
if self.requested_fields:
kwargs['fields'] = self.requested_fields
return super().get_serializer(*args, **kwargs)
@cached_property
def field_kwargs(self):
"""Return a dictionary of keyword arguments to be passed when instantiating the serializer."""
def requested_fields(self):
# An explicit list of fields was requested
if requested_fields := self.request.query_params.get('fields'):
return {'fields': requested_fields.split(',')}
# An explicit list of fields to omit was requested
if omit_fields := self.request.query_params.get('omit'):
return {'omit': omit_fields.split(',')}
return requested_fields.split(',')
# Brief mode has been enabled for this request
if self.brief:
elif self.brief:
serializer_class = self.get_serializer_class()
if brief_fields := getattr(serializer_class.Meta, 'brief_fields', None):
return {'fields': brief_fields}
return {}
return getattr(serializer_class.Meta, 'brief_fields', None)
return None
class NetBoxReadOnlyModelViewSet(
@@ -170,28 +165,6 @@ class NetBoxModelViewSet(
# Creates
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
bulk_create = getattr(serializer, 'many', False)
self.perform_create(serializer)
# After creating the instance(s), re-initialize the serializer with a queryset
# to ensure related objects are prefetched.
if bulk_create:
instance_pks = [obj.pk for obj in serializer.instance]
# Order by PK to ensure that the ordering of objects in the response
# matches the ordering of those in the request.
qs = self.get_queryset().filter(pk__in=instance_pks).order_by('pk')
else:
qs = self.get_queryset().get(pk=serializer.instance.pk)
# Re-serialize the instance(s) with prefetched data
serializer = self.get_serializer(qs, many=bulk_create)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
def perform_create(self, serializer):
model = self.queryset.model
logger = logging.getLogger(f'netbox.api.views.{self.__class__.__name__}')
@@ -208,20 +181,9 @@ class NetBoxModelViewSet(
# Updates
def update(self, request, *args, **kwargs):
partial = kwargs.pop('partial', False)
instance = self.get_object_with_snapshot()
serializer = self.get_serializer(instance, data=request.data, partial=partial)
serializer.is_valid(raise_exception=True)
self.perform_update(serializer)
# After updating the instance, re-initialize the serializer with a queryset
# to ensure related objects are prefetched.
qs = self.get_queryset().get(pk=serializer.instance.pk)
# Re-serialize the instance(s) with prefetched data
serializer = self.get_serializer(qs)
return Response(serializer.data)
# Hotwire get_object() to ensure we save a pre-change snapshot
self.get_object = self.get_object_with_snapshot
return super().update(request, *args, **kwargs)
def perform_update(self, serializer):
model = self.queryset.model

View File

@@ -108,17 +108,13 @@ class BulkUpdateModelMixin:
obj.pop('id'): obj for obj in request.data
}
object_pks = self.perform_bulk_update(qs, update_data, partial=partial)
data = self.perform_bulk_update(qs, update_data, partial=partial)
# Prefetch related objects for all updated instances
qs = self.get_queryset().filter(pk__in=object_pks)
serializer = self.get_serializer(qs, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(data, status=status.HTTP_200_OK)
def perform_bulk_update(self, objects, update_data, partial):
updated_pks = []
with transaction.atomic(using=router.db_for_write(self.queryset.model)):
data_list = []
for obj in objects:
data = update_data.get(obj.id)
if hasattr(obj, 'snapshot'):
@@ -126,9 +122,9 @@ class BulkUpdateModelMixin:
serializer = self.get_serializer(obj, data=data, partial=partial)
serializer.is_valid(raise_exception=True)
self.perform_update(serializer)
updated_pks.append(obj.pk)
data_list.append(serializer.data)
return updated_pks
return data_list
def bulk_partial_update(self, request, *args, **kwargs):
kwargs['partial'] = True

View File

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

View File

@@ -306,10 +306,11 @@ class NetBoxModelFilterSet(ChangeLoggedModelFilterSet):
super().__init__(*args, **kwargs)
# Dynamically add a Filter for each CustomField applicable to the parent model
custom_fields = [
cf for cf in CustomField.objects.get_for_model(self._meta.model)
if cf.filter_logic != CustomFieldFilterLogicChoices.FILTER_DISABLED
]
custom_fields = CustomField.objects.filter(
object_types=ContentType.objects.get_for_model(self._meta.model)
).exclude(
filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED
)
custom_field_filters = {}
for custom_field in custom_fields:

View File

@@ -31,10 +31,10 @@ class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm):
)
def _get_custom_fields(self, content_type):
return [
cf for cf in CustomField.objects.get_for_model(content_type.model_class())
if cf.ui_editable == CustomFieldUIEditableChoices.YES
]
return CustomField.objects.filter(
object_types=content_type,
ui_editable=CustomFieldUIEditableChoices.YES
)
def _get_form_field(self, customfield):
return customfield.to_form_field(for_csv_import=True)

View File

@@ -1,10 +1,12 @@
from django import forms
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from extras.choices import *
from utilities.forms.fields import QueryField
from users.models import Owner
from utilities.forms.fields import DynamicModelChoiceField, QueryField
from utilities.forms.mixins import FilterModifierMixin
from .mixins import CustomFieldsMixin, OwnerFilterMixin, SavedFiltersMixin
from .mixins import CustomFieldsMixin, SavedFiltersMixin
__all__ = (
'NestedGroupModelFilterSetForm',
@@ -34,12 +36,10 @@ class NetBoxModelFilterSetForm(FilterModifierMixin, CustomFieldsMixin, SavedFilt
selector_fields = ('filter_id', 'q')
def _get_custom_fields(self, content_type):
return [
cf for cf in super()._get_custom_fields(content_type) if (
cf.filter_logic != CustomFieldFilterLogicChoices.FILTER_DISABLED and
cf.type != CustomFieldTypeChoices.TYPE_JSON
)
]
return super()._get_custom_fields(content_type).exclude(
Q(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) |
Q(type=CustomFieldTypeChoices.TYPE_JSON)
)
def _get_form_field(self, customfield):
return customfield.to_form_field(
@@ -47,6 +47,14 @@ 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,14 +4,13 @@ from django.utils.translation import gettext as _
from core.models import ObjectType
from extras.choices import *
from extras.models import *
from users.models import OwnerGroup, Owner
from users.models import Owner
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
__all__ = (
'ChangelogMessageMixin',
'CustomFieldsMixin',
'OwnerMixin',
'OwnerFilterMixin',
'SavedFiltersMixin',
'TagsMixin',
)
@@ -23,7 +22,7 @@ class ChangelogMessageMixin(forms.Form):
"""
changelog_message = forms.CharField(
required=False,
max_length=200,
max_length=200
)
def __init__(self, *args, **kwargs):
@@ -43,7 +42,6 @@ class CustomFieldsMixin:
Attributes:
model: The model class
"""
model = None
def __init__(self, *args, **kwargs):
@@ -65,10 +63,9 @@ class CustomFieldsMixin:
return ObjectType.objects.get_for_model(self.model)
def _get_custom_fields(self, content_type):
return [
cf for cf in CustomField.objects.get_for_model(content_type.model_class())
if cf.ui_editable != CustomFieldUIEditableChoices.HIDDEN
]
return CustomField.objects.filter(object_types=content_type).exclude(
ui_editable=CustomFieldUIEditableChoices.HIDDEN
)
def _get_form_field(self, customfield):
return customfield.to_form_field()
@@ -89,20 +86,13 @@ 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):
@@ -117,13 +107,6 @@ 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,
@@ -141,47 +124,10 @@ class TagsMixin(forms.Form):
class OwnerMixin(forms.Form):
"""
Mixin for forms which adds ownership fields.
Include this mixin in forms for models which
support owner and/or owner group assignment.
Add an `owner` field to forms for models which support Owner 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,50 +0,0 @@
import strawberry
from strawberry.types.unset import UNSET
from strawberry_django.pagination import _QS, apply
__all__ = (
'OffsetPaginationInfo',
'OffsetPaginationInput',
'apply_pagination',
)
@strawberry.type
class OffsetPaginationInfo:
offset: int = 0
limit: int | None = UNSET
start: int | None = UNSET
@strawberry.input
class OffsetPaginationInput(OffsetPaginationInfo):
"""
Customized implementation of OffsetPaginationInput to support cursor-based pagination.
"""
pass
def apply_pagination(
self,
queryset: _QS,
pagination: OffsetPaginationInput | None = None,
*,
related_field_id: str | None = None,
) -> _QS:
"""
Replacement for the `apply_pagination()` method on StrawberryDjangoField to support cursor-based pagination.
"""
if pagination is not None and pagination.start not in (None, UNSET):
if pagination.offset:
raise ValueError('Cannot specify both `start` and `offset` in pagination.')
if pagination.start < 0:
raise ValueError('`start` must be greater than or equal to zero.')
# Filter the queryset to include only records with a primary key greater than or equal to the start value,
# and force ordering by primary key to ensure consistent pagination across all records.
queryset = queryset.filter(pk__gte=pagination.start).order_by('pk')
# Ignore `offset` when `start` is set
pagination.offset = 0
return apply(pagination, queryset, related_field_id=related_field_id)

View File

@@ -121,11 +121,9 @@ class ChangeLoggingMixin(DeleteMixin, models.Model):
if hasattr(self, '_prechange_snapshot'):
objectchange.prechange_data = self._prechange_snapshot
if action in (ObjectChangeActionChoices.ACTION_CREATE, ObjectChangeActionChoices.ACTION_UPDATE):
self._postchange_snapshot = self.serialize_object(exclude=exclude)
objectchange.postchange_data = self._postchange_snapshot
objectchange.postchange_data = self.serialize_object(exclude=exclude)
return objectchange
to_objectchange.alters_data = True
class CloningMixin(models.Model):
@@ -319,11 +317,9 @@ class CustomFieldsMixin(models.Model):
raise ValidationError(_("Missing required custom field '{name}'.").format(name=cf.name))
def save(self, *args, **kwargs):
from extras.models import CustomField
# Populate default values if omitted
for cf in CustomField.objects.get_for_model(self):
if cf.name not in self.custom_field_data and cf.default is not None:
for cf in self.custom_fields.filter(default__isnull=False):
if cf.name not in self.custom_field_data:
self.custom_field_data[cf.name] = cf.default
super().save(*args, **kwargs)

View File

@@ -1,5 +1,3 @@
from functools import cache
from django.utils.translation import gettext_lazy as _
from netbox.registry import registry
@@ -411,10 +409,60 @@ ADMIN_MENU = Menu(
MenuGroup(
label=_('Authentication'),
items=(
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']),
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'])
),
),
),
MenuGroup(
@@ -453,49 +501,40 @@ 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,
]
@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,
# 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 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
# Add the admin menu last
MENUS.append(ADMIN_MENU)

View File

@@ -208,12 +208,9 @@ class CachedValueSearchBackend(SearchBackend):
except KeyError:
break
# Prefetch any associated custom fields (excluding those with a zero search weight)
# Prefetch any associated custom fields
object_type = ObjectType.objects.get_for_model(indexer.model)
custom_fields = [
cf for cf in CustomField.objects.get_for_model(indexer.model)
if cf.search_weight > 0
]
custom_fields = CustomField.objects.filter(object_types=object_type).exclude(search_weight=0)
# Wipe out any previously cached values for the object
if remove_existing:

View File

@@ -12,13 +12,10 @@ from django.core.validators import URLValidator
from django.utils.module_loading import import_string
from django.utils.translation import gettext_lazy as _
from rest_framework.utils import field_mapping
from strawberry_django import pagination
from strawberry_django.fields.field import StrawberryDjangoField
from core.exceptions import IncompatiblePluginError
from netbox.config import PARAMS as CONFIG_PARAMS
from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
from netbox.graphql.pagination import OffsetPaginationInput, apply_pagination
from netbox.plugins import PluginConfig
from netbox.registry import registry
import storages.utils # type: ignore
@@ -36,12 +33,6 @@ from .monkey import get_unique_validators
# Override DRF's get_unique_validators() function with our own (see bug #19302)
field_mapping.get_unique_validators = get_unique_validators
# Override strawberry-django's OffsetPaginationInput class to add the `start` parameter
pagination.OffsetPaginationInput = OffsetPaginationInput
# Patch StrawberryDjangoField to use our custom `apply_pagination()` method with support for cursor-based pagination
StrawberryDjangoField.apply_pagination = apply_pagination
#
# Environment setup

View File

@@ -244,10 +244,9 @@ class NetBoxTable(BaseTable):
# Add custom field & custom link columns
object_type = ObjectType.objects.get_for_model(self._meta.model)
custom_fields = [
cf for cf in CustomField.objects.get_for_model(self._meta.model)
if cf.ui_visible != CustomFieldUIVisibleChoices.HIDDEN
]
custom_fields = CustomField.objects.filter(
object_types=object_type
).exclude(ui_visible=CustomFieldUIVisibleChoices.HIDDEN)
extra_columns.extend([
(f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields
])
@@ -272,14 +271,9 @@ 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'),
@@ -287,14 +281,9 @@ 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'),
@@ -302,14 +291,9 @@ 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'),

View File

@@ -4,8 +4,10 @@ from django.test import override_settings
from django.urls import reverse
from rest_framework import status
from core.models import ObjectType
from dcim.choices import LocationStatusChoices
from dcim.models import Site, Location
from users.models import ObjectPermission
from utilities.testing import disable_warnings, APITestCase, TestCase
@@ -43,28 +45,17 @@ class GraphQLTestCase(TestCase):
class GraphQLAPITestCase(APITestCase):
@classmethod
def setUpTestData(cls):
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
Site(name='Site 3', slug='site-3'),
Site(name='Site 4', slug='site-4'),
Site(name='Site 5', slug='site-5'),
Site(name='Site 6', slug='site-6'),
Site(name='Site 7', slug='site-7'),
)
Site.objects.bulk_create(sites)
@override_settings(LOGIN_REQUIRED=True)
def test_graphql_filter_objects(self):
"""
Test the operation of filters for GraphQL API requests.
"""
self.add_permissions('dcim.view_site', 'dcim.view_location')
url = reverse('graphql')
sites = Site.objects.all()[:3]
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
Site(name='Site 3', slug='site-3'),
)
Site.objects.bulk_create(sites)
Location.objects.create(
site=sites[0],
name='Location 1',
@@ -84,6 +75,18 @@ class GraphQLAPITestCase(APITestCase):
status=LocationStatusChoices.STATUS_ACTIVE
),
# Add object-level permission
obj_perm = ObjectPermission(
name='Test permission',
actions=['view']
)
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ObjectType.objects.get_for_model(Location))
obj_perm.object_types.add(ObjectType.objects.get_for_model(Site))
url = reverse('graphql')
# A valid request should return the filtered list
query = '{location_list(filters: {site_id: "' + str(sites[0].pk) + '"}) {id site {id}}}'
response = self.client.post(url, data={'query': query}, format="json", **self.header)
@@ -130,136 +133,10 @@ class GraphQLAPITestCase(APITestCase):
self.assertEqual(len(data['data']['location_list']), 0)
# Removing the permissions from location should result in an empty locations list
self.remove_permissions('dcim.view_location')
obj_perm.object_types.remove(ObjectType.objects.get_for_model(Location))
query = '{site(id: ' + str(sites[0].pk) + ') {id locations {id}}}'
response = self.client.post(url, data={'query': query}, format="json", **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = json.loads(response.content)
self.assertNotIn('errors', data)
self.assertEqual(len(data['data']['site']['locations']), 0)
def test_offset_pagination(self):
self.add_permissions('dcim.view_site')
url = reverse('graphql')
# Test `limit` only
query = """
{
site_list(pagination: {limit: 3}) {
id name
}
}
"""
response = self.client.post(url, data={'query': query}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = json.loads(response.content)
self.assertNotIn('errors', data)
self.assertEqual(len(data['data']['site_list']), 3)
self.assertEqual(data['data']['site_list'][0]['name'], 'Site 1')
self.assertEqual(data['data']['site_list'][1]['name'], 'Site 2')
self.assertEqual(data['data']['site_list'][2]['name'], 'Site 3')
# Test `offset` only
query = """
{
site_list(pagination: {offset: 3}) {
id name
}
}
"""
response = self.client.post(url, data={'query': query}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = json.loads(response.content)
self.assertNotIn('errors', data)
self.assertEqual(len(data['data']['site_list']), 4)
self.assertEqual(data['data']['site_list'][0]['name'], 'Site 4')
self.assertEqual(data['data']['site_list'][1]['name'], 'Site 5')
self.assertEqual(data['data']['site_list'][2]['name'], 'Site 6')
self.assertEqual(data['data']['site_list'][3]['name'], 'Site 7')
# Test `offset` & `limit`
query = """
{
site_list(pagination: {offset: 3, limit: 3}) {
id name
}
}
"""
response = self.client.post(url, data={'query': query}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = json.loads(response.content)
self.assertNotIn('errors', data)
self.assertEqual(len(data['data']['site_list']), 3)
self.assertEqual(data['data']['site_list'][0]['name'], 'Site 4')
self.assertEqual(data['data']['site_list'][1]['name'], 'Site 5')
self.assertEqual(data['data']['site_list'][2]['name'], 'Site 6')
def test_cursor_pagination(self):
self.add_permissions('dcim.view_site')
url = reverse('graphql')
# Page 1
query = """
{
site_list(pagination: {start: 0, limit: 3}) {
id name
}
}
"""
response = self.client.post(url, data={'query': query}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = json.loads(response.content)
self.assertNotIn('errors', data)
self.assertEqual(len(data['data']['site_list']), 3)
self.assertEqual(data['data']['site_list'][0]['name'], 'Site 1')
self.assertEqual(data['data']['site_list'][1]['name'], 'Site 2')
self.assertEqual(data['data']['site_list'][2]['name'], 'Site 3')
# Page 2
start_id = int(data['data']['site_list'][-1]['id']) + 1
query = """
{
site_list(pagination: {start: """ + str(start_id) + """, limit: 3}) {
id name
}
}
"""
response = self.client.post(url, data={'query': query}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = json.loads(response.content)
self.assertNotIn('errors', data)
self.assertEqual(len(data['data']['site_list']), 3)
self.assertEqual(data['data']['site_list'][0]['name'], 'Site 4')
self.assertEqual(data['data']['site_list'][1]['name'], 'Site 5')
self.assertEqual(data['data']['site_list'][2]['name'], 'Site 6')
# Page 3
start_id = int(data['data']['site_list'][-1]['id']) + 1
query = """
{
site_list(pagination: {start: """ + str(start_id) + """, limit: 3}) {
id name
}
}
"""
response = self.client.post(url, data={'query': query}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = json.loads(response.content)
self.assertNotIn('errors', data)
self.assertEqual(len(data['data']['site_list']), 1)
self.assertEqual(data['data']['site_list'][0]['name'], 'Site 7')
def test_pagination_conflict(self):
url = reverse('graphql')
query = """
{
site_list(pagination: {start: 1, offset: 1}) {
id name
}
}
"""
response = self.client.post(url, data={'query': query}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = json.loads(response.content)
self.assertIn('errors', data)
self.assertEqual(data['errors'][0]['message'], 'Cannot specify both `start` and `offset` in pagination.')

View File

@@ -5,6 +5,7 @@ from copy import deepcopy
from django.contrib import messages
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRel
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError
from django.db import IntegrityError, router, transaction
from django.db.models import ManyToManyField, ProtectedError, RestrictedError
@@ -437,30 +438,12 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
"""
return object_form.save()
def create_and_update_objects(self, form, request):
def _process_import_records(self, form, request, records, prefetched_objects):
"""
Process CSV import records and save objects.
"""
saved_objects = []
records = list(form.cleaned_data['data'])
# Prefetch objects to be updated, if any
prefetch_ids = [int(record['id']) for record in records if record.get('id')]
# check for duplicate IDs
duplicate_pks = [pk for pk, count in Counter(prefetch_ids).items() if count > 1]
if duplicate_pks:
error_msg = _(
"Duplicate objects found: {model} with ID(s) {ids} appears multiple times"
).format(
model=title(self.queryset.model._meta.verbose_name),
ids=', '.join(str(pk) for pk in sorted(duplicate_pks))
)
raise ValidationError(error_msg)
prefetched_objects = {
obj.pk: obj
for obj in self.queryset.model.objects.filter(id__in=prefetch_ids)
} if prefetch_ids else {}
for i, record in enumerate(records, start=1):
object_id = int(record.pop('id')) if record.get('id') else None
@@ -484,10 +467,10 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
instance = self.queryset.model()
# For newly created objects, apply any default custom field values
custom_fields = [
cf for cf in CustomField.objects.get_for_model(self.queryset.model)
if cf.ui_editable == CustomFieldUIEditableChoices.YES
]
custom_fields = CustomField.objects.filter(
object_types=ContentType.objects.get_for_model(self.queryset.model),
ui_editable=CustomFieldUIEditableChoices.YES
)
for cf in custom_fields:
field_name = f'cf_{cf.name}'
if field_name not in record:
@@ -525,6 +508,37 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
return saved_objects
def create_and_update_objects(self, form, request):
records = list(form.cleaned_data['data'])
# Prefetch objects to be updated, if any
prefetch_ids = [int(record['id']) for record in records if record.get('id')]
# check for duplicate IDs
duplicate_pks = [pk for pk, count in Counter(prefetch_ids).items() if count > 1]
if duplicate_pks:
error_msg = _(
"Duplicate objects found: {model} with ID(s) {ids} appears multiple times"
).format(
model=title(self.queryset.model._meta.verbose_name),
ids=', '.join(str(pk) for pk in sorted(duplicate_pks))
)
raise ValidationError(error_msg)
prefetched_objects = {
obj.pk: obj
for obj in self.queryset.model.objects.filter(id__in=prefetch_ids)
} if prefetch_ids else {}
# For MPTT models, delay tree updates until all saves are complete
if issubclass(self.queryset.model, MPTTModel):
with self.queryset.model.objects.delay_mptt_updates():
saved_objects = self._process_import_records(form, request, records, prefetched_objects)
else:
saved_objects = self._process_import_records(form, request, records, prefetched_objects)
return saved_objects
#
# Request handlers
#
@@ -894,9 +908,16 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
renamed_pks = self._rename_objects(form, selected_objects)
if '_apply' in request.POST:
for obj in selected_objects:
setattr(obj, self.field_name, obj.new_name)
obj.save()
# For MPTT models, delay tree updates until all saves are complete
if issubclass(self.queryset.model, MPTTModel):
with self.queryset.model.objects.delay_mptt_updates():
for obj in selected_objects:
setattr(obj, self.field_name, obj.new_name)
obj.save()
else:
for obj in selected_objects:
setattr(obj, self.field_name, obj.new_name)
obj.save()
# Enforce constrained permissions
if self.queryset.filter(pk__in=renamed_pks).count() != len(selected_objects):

View File

@@ -1,6 +1,5 @@
import re
from collections import namedtuple
import logging
from django.conf import settings
from django.contrib import messages
@@ -29,8 +28,6 @@ __all__ = (
'SearchView',
)
logger = logging.getLogger(f'netbox.{__name__}')
Link = namedtuple('Link', ('label', 'viewname', 'permission', 'count'))
@@ -53,14 +50,7 @@ class HomeView(ConditionalLoginRequiredMixin, View):
# Check whether a new release is available. (Only for superusers.)
new_release = None
if request.user.is_superuser:
# cache.get() can raise an exception if the cached value can't be unpickled after dependency upgrades
try:
latest_release = cache.get('latest_release')
except Exception:
logger.debug("Failed to read 'latest_release' from cache; deleting key", exc_info=True)
cache.delete('latest_release')
latest_release = None
latest_release = cache.get('latest_release')
if latest_release:
release_version, release_url = latest_release
if release_version > version.parse(settings.RELEASE.version):

View File

@@ -59,10 +59,6 @@
<th scope="row">{% trans "Completed" %}</th>
<td>{{ object.completed|isodatetime|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Queue" %}</th>
<td>{{ object.queue_name|placeholder }}</td>
</tr>
</table>
</div>
</div>

View File

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

View File

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

View File

@@ -370,6 +370,33 @@
</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 %}
@@ -414,13 +441,6 @@
{% 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,9 +36,8 @@
<div class="field-group mb-5">
<div class="row">
<h2 class="col-9 offset-3">{% trans "Ownership" %}</h2>
<h2 class="col-9 offset-3">{% trans "Owner" %}</h2>
</div>
{% render_field vc_form.owner_group %}
{% render_field vc_form.owner %}
</div>

View File

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

View File

@@ -27,9 +27,6 @@
<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

@@ -6,7 +6,7 @@
{% include 'ipam/inc/max_depth.html' %}
{% include 'ipam/inc/max_length.html' %}
{% if perms.ipam.add_prefix and first_available_prefix %}
<a href="{% url 'ipam:prefix_add' %}?prefix={{ first_available_prefix }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.scope %}&scope_type={{ object.scope_type.pk }}&scope={{ object.scope.pk }}{% endif %}{% if object.tenant %}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}{% endif %}" class="btn btn-primary">
<a href="{% url 'ipam:prefix_add' %}?prefix={{ first_available_prefix }}&vrf={{ object.vrf.pk }}&site={{ object.site.pk }}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}" class="btn btn-primary">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Prefix" %}
</a>
{% endif %}

View File

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

View File

@@ -31,9 +31,8 @@ __all__ = (
class TenantGroupFilterForm(NestedGroupModelFilterSetForm):
model = TenantGroup
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('parent_id', name=_('Tenant Group')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
parent_id = DynamicModelMultipleChoiceField(
queryset=TenantGroup.objects.all(),
@@ -46,9 +45,8 @@ class TenantGroupFilterForm(NestedGroupModelFilterSetForm):
class TenantFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
model = Tenant
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('group_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts'))
)
group_id = DynamicModelMultipleChoiceField(
@@ -67,9 +65,8 @@ class TenantFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
class ContactGroupFilterForm(NestedGroupModelFilterSetForm):
model = ContactGroup
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('parent_id', name=_('Contact Group')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
parent_id = DynamicModelMultipleChoiceField(
queryset=ContactGroup.objects.all(),
@@ -82,8 +79,7 @@ class ContactGroupFilterForm(NestedGroupModelFilterSetForm):
class ContactRoleFilterForm(OrganizationalModelFilterSetForm):
model = ContactRole
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
)
tag = TagFilterField(model)
@@ -91,9 +87,8 @@ class ContactRoleFilterForm(OrganizationalModelFilterSetForm):
class ContactFilterForm(PrimaryModelFilterSetForm):
model = Contact
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
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 OwnerGroup, Owner
from users.models import Owner
__all__ = (
'OwnerFilterMixin',
@@ -12,17 +12,6 @@ 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

@@ -93,23 +93,18 @@ def get_view_name(view):
return drf_get_view_name(view)
def get_prefetches_for_serializer(serializer_class, fields=None, omit=None):
def get_prefetches_for_serializer(serializer_class, fields_to_include=None):
"""
Compile and return a list of fields which should be prefetched on the queryset for a serializer.
"""
if fields is not None and omit is not None:
raise TypeError("Cannot specify both 'fields' and 'omit' parameters.")
model = serializer_class.Meta.model
# If fields are not specified, default to all
fields_to_include = fields or serializer_class.Meta.fields
fields_to_omit = omit or []
if not fields_to_include:
fields_to_include = serializer_class.Meta.fields
prefetch_fields = []
for field_name in fields_to_include:
if field_name in fields_to_omit:
continue
serializer_field = serializer_class._declared_fields.get(field_name)
# Determine the name of the model field referenced by the serializer field
@@ -137,23 +132,19 @@ def get_prefetches_for_serializer(serializer_class, fields=None, omit=None):
return prefetch_fields
def get_annotations_for_serializer(serializer_class, fields=None, omit=None):
def get_annotations_for_serializer(serializer_class, fields_to_include=None):
"""
Return a mapping of field names to annotations to be applied to the queryset for a serializer.
"""
if fields is not None and omit is not None:
raise TypeError("Cannot specify both 'fields' and 'omit' parameters.")
annotations = {}
# If specific fields are not specified, default to all
if not fields_to_include:
fields_to_include = serializer_class.Meta.fields
model = serializer_class.Meta.model
# If fields are not specified, default to all
fields_to_include = fields or serializer_class.Meta.fields
fields_to_omit = omit or []
annotations = {}
for field_name, field in serializer_class._declared_fields.items():
if field_name in fields_to_omit:
continue
if field_name in fields_to_include and type(field) is RelatedObjectCountField:
related_field = getattr(model, field.relation).field
annotations[field_name] = count_related(related_field.model, related_field.name)

View File

@@ -3,7 +3,6 @@ import enum
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from utilities.data import get_config_value_ci
from utilities.string import enum_key
__all__ = (
@@ -25,14 +24,13 @@ class ChoiceSetMeta(type):
).format(name=name)
app = attrs['__module__'].split('.', 1)[0]
replace_key = f'{app}.{key}'
replace_choices = get_config_value_ci(settings.FIELD_CHOICES, replace_key)
if replace_choices is not None:
attrs['CHOICES'] = replace_choices
else:
extend_key = f'{replace_key}+'
extend_choices = get_config_value_ci(settings.FIELD_CHOICES, extend_key)
if extend_choices is not None:
attrs['CHOICES'].extend(extend_choices)
extend_key = f'{replace_key}+' if replace_key else None
if replace_key and replace_key in settings.FIELD_CHOICES:
# Replace the stock choices
attrs['CHOICES'] = settings.FIELD_CHOICES[replace_key]
elif extend_key and extend_key in settings.FIELD_CHOICES:
# Extend the stock choices
attrs['CHOICES'].extend(settings.FIELD_CHOICES[extend_key])
# Define choice tuples and color maps
attrs['_choices'] = []

View File

@@ -10,7 +10,6 @@ __all__ = (
'deepmerge',
'drange',
'flatten_dict',
'get_config_value_ci',
'ranges_to_string',
'ranges_to_string_list',
'resolve_attr_path',
@@ -23,19 +22,6 @@ __all__ = (
# Dictionary utilities
#
def get_config_value_ci(config_dict, key, default=None):
"""
Retrieve a value from a dictionary using case-insensitive key matching.
"""
if key in config_dict:
return config_dict[key]
key_lower = key.lower()
for config_key, value in config_dict.items():
if config_key.lower() == key_lower:
return value
return default
def deepmerge(original, new):
"""
Deep merge two dictionaries (new into original) and return a new dict

View File

@@ -1,6 +1,6 @@
from django import template
from netbox.navigation.menu import get_menus
from netbox.navigation.menu import 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 get_menus():
for menu in MENUS:
groups = []
for group in menu.groups:
items = []

View File

@@ -1,4 +1,4 @@
from django.test import TestCase, override_settings
from django.test import TestCase
from utilities.choices import ChoiceSet
@@ -30,29 +30,3 @@ class ChoiceSetTestCase(TestCase):
def test_values(self):
self.assertListEqual(ExampleChoices.values(), ['a', 'b', 'c', 1, 2, 3])
class FieldChoicesCaseInsensitiveTestCase(TestCase):
"""
Integration tests for FIELD_CHOICES case-insensitive key lookup.
"""
def test_replace_choices_with_different_casing(self):
"""Test that replacement works when config key casing differs."""
# Config uses lowercase, but code constructs PascalCase key
with override_settings(FIELD_CHOICES={'utilities.teststatus': [('new', 'New')]}):
class TestStatusChoices(ChoiceSet):
key = 'TestStatus' # Code will look up 'utilities.TestStatus'
CHOICES = [('old', 'Old')]
self.assertEqual(TestStatusChoices.CHOICES, [('new', 'New')])
def test_extend_choices_with_different_casing(self):
"""Test that extension works with the + suffix under casing differences."""
# Config uses lowercase with + suffix
with override_settings(FIELD_CHOICES={'utilities.teststatus+': [('extra', 'Extra')]}):
class TestStatusChoices(ChoiceSet):
key = 'TestStatus' # Code will look up 'utilities.TestStatus+'
CHOICES = [('base', 'Base')]
self.assertEqual(TestStatusChoices.CHOICES, [('base', 'Base'), ('extra', 'Extra')])

View File

@@ -2,7 +2,6 @@ from django.db.backends.postgresql.psycopg_any import NumericRange
from django.test import TestCase
from utilities.data import (
check_ranges_overlap,
get_config_value_ci,
ranges_to_string,
ranges_to_string_list,
string_to_ranges,
@@ -97,25 +96,3 @@ class RangeFunctionsTestCase(TestCase):
string_to_ranges('2-10, a-b'),
None # Fails to convert
)
class GetConfigValueCITestCase(TestCase):
def test_exact_match(self):
config = {'dcim.site': 'value1', 'dcim.Device': 'value2'}
self.assertEqual(get_config_value_ci(config, 'dcim.site'), 'value1')
self.assertEqual(get_config_value_ci(config, 'dcim.Device'), 'value2')
def test_case_insensitive_match(self):
config = {'dcim.Site': 'value1', 'ipam.IPAddress': 'value2'}
self.assertEqual(get_config_value_ci(config, 'dcim.site'), 'value1')
self.assertEqual(get_config_value_ci(config, 'ipam.ipaddress'), 'value2')
def test_default_value(self):
config = {'dcim.site': 'value1'}
self.assertIsNone(get_config_value_ci(config, 'nonexistent'))
self.assertEqual(get_config_value_ci(config, 'nonexistent', default=[]), [])
def test_empty_dict(self):
self.assertIsNone(get_config_value_ci({}, 'any.key'))
self.assertEqual(get_config_value_ci({}, 'any.key', default=[]), [])

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 DynamicModelMultipleChoiceField, TagFilterField
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField
from utilities.forms.rendering import FieldSet
from virtualization.choices import *
from virtualization.models import *
@@ -29,8 +29,7 @@ __all__ = (
class ClusterTypeFilterForm(OrganizationalModelFilterSetForm):
model = ClusterType
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
)
tag = TagFilterField(model)
@@ -39,8 +38,7 @@ class ClusterGroupFilterForm(ContactModelFilterForm, OrganizationalModelFilterSe
model = ClusterGroup
tag = TagFilterField(model)
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
)
@@ -48,11 +46,10 @@ class ClusterGroupFilterForm(ContactModelFilterForm, OrganizationalModelFilterSe
class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilterSetForm):
model = Cluster
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
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')
@@ -108,7 +105,7 @@ class VirtualMachineFilterForm(
):
model = VirtualMachine
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('cluster_group_id', 'cluster_type_id', 'cluster_id', 'device_id', name=_('Cluster')),
FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
FieldSet(
@@ -116,7 +113,6 @@ 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(
@@ -209,15 +205,14 @@ class VirtualMachineFilterForm(
tag = TagFilterField(model)
class VMInterfaceFilterForm(OwnerFilterMixin, NetBoxModelFilterSetForm):
class VMInterfaceFilterForm(NetBoxModelFilterSetForm):
model = VMInterface
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
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(
@@ -264,16 +259,20 @@ class VMInterfaceFilterForm(OwnerFilterMixin, NetBoxModelFilterSetForm):
required=False,
label=_('VLAN Translation Policy')
)
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
tag = TagFilterField(model)
class VirtualDiskFilterForm(OwnerFilterMixin, NetBoxModelFilterSetForm):
class VirtualDiskFilterForm(NetBoxModelFilterSetForm):
model = VirtualDisk
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
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(),
@@ -285,4 +284,9 @@ class VirtualDiskFilterForm(OwnerFilterMixin, NetBoxModelFilterSetForm):
required=False,
min_value=1
)
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
tag = TagFilterField(model)

View File

@@ -33,8 +33,7 @@ __all__ = (
class TunnelGroupFilterForm(ContactModelFilterForm, OrganizationalModelFilterSetForm):
model = TunnelGroup
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
)
tag = TagFilterField(model)
@@ -43,11 +42,10 @@ class TunnelGroupFilterForm(ContactModelFilterForm, OrganizationalModelFilterSet
class TunnelFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm):
model = Tunnel
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
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(
@@ -99,11 +97,10 @@ class TunnelTerminationFilterForm(NetBoxModelFilterSetForm):
class IKEProposalFilterForm(PrimaryModelFilterSetForm):
model = IKEProposal
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
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'),
@@ -131,9 +128,8 @@ class IKEProposalFilterForm(PrimaryModelFilterSetForm):
class IKEPolicyFilterForm(PrimaryModelFilterSetForm):
model = IKEPolicy
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('version', 'mode', 'proposal_id', name=_('Parameters')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
version = forms.MultipleChoiceField(
label=_('IKE version'),
@@ -156,9 +152,8 @@ class IKEPolicyFilterForm(PrimaryModelFilterSetForm):
class IPSecProposalFilterForm(PrimaryModelFilterSetForm):
model = IPSecProposal
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('encryption_algorithm', 'authentication_algorithm', name=_('Parameters')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
encryption_algorithm = forms.MultipleChoiceField(
label=_('Encryption algorithm'),
@@ -176,9 +171,8 @@ class IPSecProposalFilterForm(PrimaryModelFilterSetForm):
class IPSecPolicyFilterForm(PrimaryModelFilterSetForm):
model = IPSecPolicy
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('proposal_id', 'pfs_group', name=_('Parameters')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
proposal_id = DynamicModelMultipleChoiceField(
queryset=IKEProposal.objects.all(),
@@ -196,9 +190,8 @@ class IPSecPolicyFilterForm(PrimaryModelFilterSetForm):
class IPSecProfileFilterForm(PrimaryModelFilterSetForm):
model = IPSecProfile
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('mode', 'ike_policy_id', 'ipsec_policy_id', name=_('Profile')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
mode = forms.MultipleChoiceField(
label=_('Mode'),
@@ -221,10 +214,9 @@ class IPSecProfileFilterForm(PrimaryModelFilterSetForm):
class L2VPNFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm):
model = L2VPN
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
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,9 +22,8 @@ __all__ = (
class WirelessLANGroupFilterForm(NestedGroupModelFilterSetForm):
model = WirelessLANGroup
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('parent_id', name=_('Wireless LAN group')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
parent_id = DynamicModelMultipleChoiceField(
queryset=WirelessLANGroup.objects.all(),
@@ -37,12 +36,11 @@ class WirelessLANGroupFilterForm(NestedGroupModelFilterSetForm):
class WirelessLANFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = WirelessLAN
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('ssid', 'group_id', 'status', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Scope')),
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')),
FieldSet('auth_type', 'auth_cipher', 'auth_psk', name=_('Authentication')),
)
ssid = forms.CharField(
required=False,
@@ -104,11 +102,10 @@ class WirelessLANFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
class WirelessLinkFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = WirelessLink
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('ssid', 'status', 'distance', 'distance_unit', name=_('Attributes')),
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')),
FieldSet('auth_type', 'auth_cipher', 'auth_psk', name=_('Authentication')),
)
ssid = forms.CharField(
required=False,