mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-19 01:58:43 -06:00
Compare commits
60 Commits
7f2b744a53
...
v4.3.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
290e4afaa0 | ||
|
|
ca95050b7d | ||
|
|
34e4ccb212 | ||
|
|
fcb49f9881 | ||
|
|
7e40f40248 | ||
|
|
8e08524fed | ||
|
|
8bb47dad0f | ||
|
|
5d7c8318aa | ||
|
|
2d495d4f32 | ||
|
|
cea83f31b8 | ||
|
|
6c0dc8b630 | ||
|
|
1c86f81298 | ||
|
|
630d7aa4c2 | ||
|
|
043275df19 | ||
|
|
122f612750 | ||
|
|
65b36fd594 | ||
|
|
e828ca5cb4 | ||
|
|
fce10c73b7 | ||
|
|
0cf76bc5c7 | ||
|
|
11f228cae9 | ||
|
|
a86cd9dfc6 | ||
|
|
15541c6440 | ||
|
|
6ce3012f93 | ||
|
|
fec6cf705f | ||
|
|
9c6d0d1ddc | ||
|
|
47359d9284 | ||
|
|
669df62cde | ||
|
|
9df0bdcfaf | ||
|
|
d222913716 | ||
|
|
2c09973e01 | ||
|
|
4506c809d8 | ||
|
|
5d194214aa | ||
|
|
0827198cad | ||
|
|
bb83187505 | ||
|
|
aa9ee0e5c6 | ||
|
|
35b9d80819 | ||
|
|
d4b30a64ba | ||
|
|
de53fd2bd1 | ||
|
|
c7b68664f9 | ||
|
|
a20715f229 | ||
|
|
1b8767f1e3 | ||
|
|
5acef5038f | ||
|
|
6ca3908715 | ||
|
|
c736ce3179 | ||
|
|
111fefdf9c | ||
|
|
063d1fef7a | ||
|
|
6ba6ff3fee | ||
|
|
7bb7307892 | ||
|
|
c2d3363930 | ||
|
|
6e30c11017 | ||
|
|
b01c75cf3a | ||
|
|
ffa9a52667 | ||
|
|
47320f9958 | ||
|
|
d08a1bd07d | ||
|
|
14c4aeca54 | ||
|
|
26bec1275f | ||
|
|
fa2d7f6516 | ||
|
|
d571cb4867 | ||
|
|
2129355c30 | ||
|
|
c40bfb1445 |
@@ -15,7 +15,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v4.3.4
|
||||
placeholder: v4.3.6
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
@@ -27,7 +27,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox Version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v4.3.4
|
||||
placeholder: v4.3.6
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
3
.github/codeql/codeql-config.yml
vendored
Normal file
3
.github/codeql/codeql-config.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
paths-ignore:
|
||||
# Ignore compiled JS
|
||||
- netbox/project-static/dist
|
||||
42
.github/workflows/codeql.yml
vendored
Normal file
42
.github/workflows/codeql.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main", "feature" ]
|
||||
pull_request:
|
||||
branches: [ "main", "feature" ]
|
||||
schedule:
|
||||
- cron: '38 16 * * 4'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- language: actions
|
||||
build-mode: none
|
||||
- language: javascript-typescript
|
||||
build-mode: none
|
||||
- language: python
|
||||
build-mode: none
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
config-file: .github/codeql/codeql-config.yml
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
@@ -8,7 +8,9 @@ django-cors-headers
|
||||
|
||||
# Runtime UI tool for debugging Django
|
||||
# https://github.com/jazzband/django-debug-toolbar/blob/main/docs/changes.rst
|
||||
django-debug-toolbar
|
||||
# django-debug-toolbar v6.0.0 raises "Attribute Error at /: 'function' object has no attribute 'set'"
|
||||
# see https://github.com/netbox-community/netbox/issues/19974
|
||||
django-debug-toolbar==5.2.0
|
||||
|
||||
# Library for writing reusable URL query filters
|
||||
# https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst
|
||||
@@ -145,8 +147,7 @@ strawberry-graphql
|
||||
|
||||
# Strawberry GraphQL Django extension
|
||||
# https://github.com/strawberry-graphql/strawberry-django/releases
|
||||
# See #19771
|
||||
strawberry-graphql-django==0.60.0
|
||||
strawberry-graphql-django
|
||||
|
||||
# SVG image rendering (used for rack elevations)
|
||||
# https://github.com/mozman/svgwrite/blob/master/NEWS.rst
|
||||
|
||||
@@ -95,6 +95,7 @@
|
||||
"iec-60320-c8",
|
||||
"iec-60320-c14",
|
||||
"iec-60320-c16",
|
||||
"iec-60320-c18",
|
||||
"iec-60320-c20",
|
||||
"iec-60320-c22",
|
||||
"iec-60309-p-n-e-4h",
|
||||
@@ -209,6 +210,7 @@
|
||||
"iec-60320-c7",
|
||||
"iec-60320-c13",
|
||||
"iec-60320-c15",
|
||||
"iec-60320-c17",
|
||||
"iec-60320-c19",
|
||||
"iec-60320-c21",
|
||||
"iec-60309-p-n-e-4h",
|
||||
@@ -474,6 +476,13 @@
|
||||
"passive-48v-2pair",
|
||||
"passive-48v-4pair"
|
||||
]
|
||||
},
|
||||
"rf_role": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ap",
|
||||
"station"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
17
contrib/netbox-housekeeping.service
Normal file
17
contrib/netbox-housekeeping.service
Normal file
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=NetBox Housekeeping Service
|
||||
Documentation=https://docs.netbox.dev/
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
|
||||
User=netbox
|
||||
Group=netbox
|
||||
WorkingDirectory=/opt/netbox
|
||||
|
||||
ExecStart=/opt/netbox/venv/bin/python /opt/netbox/netbox/manage.py housekeeping
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
9
contrib/netbox-housekeeping.sh
Executable file
9
contrib/netbox-housekeeping.sh
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/bin/sh
|
||||
# This shell script invokes NetBox's housekeeping management command, which
|
||||
# intended to be run nightly. This script can be copied into your system's
|
||||
# daily cron directory (e.g. /etc/cron.daily), or referenced directly from
|
||||
# within the cron configuration file.
|
||||
#
|
||||
# If NetBox has been installed into a nonstandard location, update the paths
|
||||
# below.
|
||||
/opt/netbox/venv/bin/python /opt/netbox/netbox/manage.py housekeeping
|
||||
13
contrib/netbox-housekeeping.timer
Normal file
13
contrib/netbox-housekeeping.timer
Normal file
@@ -0,0 +1,13 @@
|
||||
[Unit]
|
||||
Description=NetBox Housekeeping Timer
|
||||
Documentation=https://docs.netbox.dev/
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Timer]
|
||||
OnCalendar=daily
|
||||
AccuracySec=1h
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
49
docs/administration/housekeeping.md
Normal file
49
docs/administration/housekeeping.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Housekeeping
|
||||
|
||||
NetBox includes a `housekeeping` management command that should be run nightly. This command handles:
|
||||
|
||||
* Clearing expired authentication sessions from the database
|
||||
* Deleting changelog records older than the configured [retention time](../configuration/miscellaneous.md#changelog_retention)
|
||||
* Deleting job result records older than the configured [retention time](../configuration/miscellaneous.md#job_retention)
|
||||
* Check for new NetBox releases (if [`RELEASE_CHECK_URL`](../configuration/miscellaneous.md#release_check_url) is set)
|
||||
|
||||
This command can be invoked directly, or by using the shell script provided at `/opt/netbox/contrib/netbox-housekeeping.sh`.
|
||||
|
||||
## Scheduling
|
||||
|
||||
### Using Cron
|
||||
|
||||
This script can be linked from your cron scheduler's daily jobs directory (e.g. `/etc/cron.daily`) or referenced directly within the cron configuration file.
|
||||
|
||||
```shell
|
||||
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
|
||||
```
|
||||
|
||||
!!! note
|
||||
On Debian-based systems, be sure to omit the `.sh` file extension when linking to the script from within a cron directory. Otherwise, the task may not run.
|
||||
|
||||
### Using Systemd
|
||||
|
||||
First, create symbolic links for the systemd service and timer files. Link the existing service and timer files from the `/opt/netbox/contrib/` directory to the `/etc/systemd/system/` directory:
|
||||
|
||||
```bash
|
||||
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.service /etc/systemd/system/netbox-housekeeping.service
|
||||
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.timer /etc/systemd/system/netbox-housekeeping.timer
|
||||
```
|
||||
|
||||
Then, reload the systemd configuration and enable the timer to start automatically at boot:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now netbox-housekeeping.timer
|
||||
```
|
||||
|
||||
Check the status of your timer by running:
|
||||
|
||||
```bash
|
||||
sudo systemctl list-timers --all
|
||||
```
|
||||
|
||||
This command will show a list of all timers, including your `netbox-housekeeping.timer`. Make sure the timer is active and properly scheduled.
|
||||
|
||||
That's it! Your NetBox housekeeping service is now configured to run daily using systemd.
|
||||
@@ -18,10 +18,10 @@ pg_dump --username netbox --password --host localhost netbox > netbox.sql
|
||||
!!! note
|
||||
You may need to change the username, host, and/or database in the command above to match your installation.
|
||||
|
||||
When replicating a production database for development purposes, you may find it convenient to exclude changelog data, which can easily account for the bulk of a database's size. To do this, exclude the `extras_objectchange` table data from the export. The table will still be included in the output file, but will not be populated with any data.
|
||||
When replicating a production database for development purposes, you may find it convenient to exclude changelog data, which can easily account for the bulk of a database's size. To do this, exclude the `core_objectchange` table data from the export. The table will still be included in the output file, but will not be populated with any data.
|
||||
|
||||
```no-highlight
|
||||
pg_dump ... --exclude-table-data=extras_objectchange netbox > netbox.sql
|
||||
pg_dump ... --exclude-table-data=core_objectchange netbox > netbox.sql
|
||||
```
|
||||
|
||||
### Load an Exported Database
|
||||
|
||||
@@ -72,16 +72,6 @@ Email is sent from NetBox only for critical events or if configured for [logging
|
||||
|
||||
---
|
||||
|
||||
## HOSTNAME
|
||||
|
||||
!!! info "This parameter was introduced in NetBox v4.4."
|
||||
|
||||
Default: System hostname
|
||||
|
||||
The hostname displayed in the user interface identifying the system on which NetBox is running. If not defined, this defaults to the system hostname as reported by Python's `platform.node()`.
|
||||
|
||||
---
|
||||
|
||||
## HTTP_PROXIES
|
||||
|
||||
Default: `None`
|
||||
@@ -169,7 +159,6 @@ LOGGING = {
|
||||
* `netbox.auth.*` - Authentication events
|
||||
* `netbox.api.views.*` - Views which handle business logic for the REST API
|
||||
* `netbox.event_rules` - Event rules
|
||||
* `netbox.jobs.*` - Background jobs
|
||||
* `netbox.reports.*` - Report execution (`module.name`)
|
||||
* `netbox.scripts.*` - Custom script execution (`module.name`)
|
||||
* `netbox.views.*` - Views which handle business logic for the web UI
|
||||
|
||||
@@ -264,6 +264,18 @@ cd /opt/netbox/netbox
|
||||
python3 manage.py createsuperuser
|
||||
```
|
||||
|
||||
## Schedule the Housekeeping Task
|
||||
|
||||
NetBox includes a `housekeeping` management command that handles some recurring cleanup tasks, such as clearing out old sessions and expired change records. Although this command may be run manually, it is recommended to configure a scheduled job using the system's `cron` daemon or a similar utility.
|
||||
|
||||
A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be copied to or linked from your system's daily cron task directory, or included within the crontab directly. (If installing NetBox into a nonstandard path, be sure to update the system paths within this script first.)
|
||||
|
||||
```shell
|
||||
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
|
||||
```
|
||||
|
||||
See the [housekeeping documentation](../administration/housekeeping.md) for further details.
|
||||
|
||||
## Test the Application
|
||||
|
||||
At this point, we should be able to run NetBox's development server for testing. We can check by starting a development instance locally.
|
||||
@@ -290,13 +302,6 @@ Quit the server with CONTROL-C.
|
||||
|
||||
Next, connect to the name or IP of the server (as defined in `ALLOWED_HOSTS`) on port 8000; for example, <http://127.0.0.1:8000/>. You should be greeted with the NetBox home page. Try logging in using the username and password specified when creating a superuser.
|
||||
|
||||
!!! note
|
||||
By default RHEL based distros will likely block your testing attempts with firewalld. The development server port can be opened with `firewall-cmd` (add `--permanent` if you want the rule to survive server restarts):
|
||||
|
||||
```no-highlight
|
||||
firewall-cmd --zone=public --add-port=8000/tcp
|
||||
```
|
||||
|
||||
!!! danger "Not for production use"
|
||||
The development server is for development and testing purposes only. It is neither performant nor secure enough for production use. **Do not use it in production.**
|
||||
|
||||
|
||||
@@ -183,3 +183,13 @@ Finally, restart the gunicorn and RQ services:
|
||||
```no-highlight
|
||||
sudo systemctl restart netbox netbox-rq
|
||||
```
|
||||
|
||||
## 6. Verify Housekeeping Scheduling
|
||||
|
||||
If upgrading from a release prior to NetBox v3.0, check that a cron task (or similar scheduled process) has been configured to run NetBox's nightly housekeeping command. A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be linked from your system's daily cron task directory, or included within the crontab directly. (If NetBox has been installed in a nonstandard path, be sure to update the system paths within this script first.)
|
||||
|
||||
```shell
|
||||
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
|
||||
```
|
||||
|
||||
See the [housekeeping documentation](../administration/housekeeping.md) for further details.
|
||||
|
||||
@@ -10,11 +10,11 @@ The assignment of platforms to devices is an optional feature, and may be disreg
|
||||
|
||||
### Name
|
||||
|
||||
A human-friendly name for the platform. Must be unique per manufacturer.
|
||||
A unique human-friendly name.
|
||||
|
||||
### Slug
|
||||
|
||||
A URL-friendly identifier; must be unique per manufacturer. (This value can be used for filtering.)
|
||||
A unique URL-friendly identifier. (This value can be used for filtering.)
|
||||
|
||||
### Manufacturer
|
||||
|
||||
|
||||
@@ -24,6 +24,14 @@ Jinja2 template code, if being defined locally rather than replicated from a dat
|
||||
|
||||
A dictionary of any additional parameters to pass when instantiating the [Jinja2 environment](https://jinja.palletsprojects.com/en/3.1.x/api/#jinja2.Environment). Jinja2 supports various optional parameters which can be used to modify its default behavior.
|
||||
|
||||
The `undefined` and `finalize` Jinja environment parameters, which must reference a Python class or function, can define a dotted path to the desired resource. For example:
|
||||
|
||||
```json
|
||||
{
|
||||
"undefined": "jinja2.StrictUndefined"
|
||||
}
|
||||
```
|
||||
|
||||
### MIME Type
|
||||
|
||||
!!! info "This field was introduced in NetBox v4.3."
|
||||
|
||||
@@ -26,6 +26,14 @@ Jinja2 template code for rendering the exported data.
|
||||
|
||||
A dictionary of any additional parameters to pass when instantiating the [Jinja2 environment](https://jinja.palletsprojects.com/en/3.1.x/api/#jinja2.Environment). Jinja2 supports various optional parameters which can be used to modify its default behavior.
|
||||
|
||||
The `undefined` and `finalize` Jinja environment parameters, which must reference a Python class or function, can define a dotted path to the desired resource. For example:
|
||||
|
||||
```json
|
||||
{
|
||||
"undefined": "jinja2.StrictUndefined"
|
||||
}
|
||||
```
|
||||
|
||||
### MIME Type
|
||||
|
||||
The MIME type to indicate in the response when rendering the export template (optional). Defaults to `text/plain`.
|
||||
|
||||
@@ -25,9 +25,6 @@ The user-defined functional [role](./role.md) assigned to the VLAN.
|
||||
|
||||
### VLAN Group or Site
|
||||
|
||||
!!! warning "Site assignment is deprecated"
|
||||
The assignment of individual VLANs directly to a site has been deprecated. This ability will be removed in a future NetBox release. Users are strongly encouraged to utilize VLAN groups, which have the added benefit of supporting the assignment of a VLAN to multiple sites.
|
||||
|
||||
The [VLAN group](./vlangroup.md) or [site](../dcim/site.md) to which the VLAN is assigned.
|
||||
|
||||
### Q-in-Q Role
|
||||
|
||||
@@ -39,27 +39,6 @@ You can schedule the background job from within your code (e.g. from a model's `
|
||||
|
||||
This is the human-friendly names of your background job. If omitted, the class name will be used.
|
||||
|
||||
### Logging
|
||||
|
||||
!!! info "This feature was introduced in NetBox v4.4."
|
||||
|
||||
A Python logger is instantiated by the runner for each job. It can be utilized within a job's `run()` method as needed:
|
||||
|
||||
```python
|
||||
def run(self, *args, **kwargs):
|
||||
obj = MyModel.objects.get(pk=kwargs.get('pk'))
|
||||
self.logger.info("Retrieved object {obj}")
|
||||
```
|
||||
|
||||
Four of the standard Python logging levels are supported:
|
||||
|
||||
* `debug()`
|
||||
* `info()`
|
||||
* `warning()`
|
||||
* `error()`
|
||||
|
||||
Log entries recorded using the runner's logger will be saved in the job's log in the database in addition to being processed by other [system logging handlers](../../configuration/system.md#logging).
|
||||
|
||||
### Scheduled Jobs
|
||||
|
||||
As described above, jobs can be scheduled for immediate execution or at any later time using the `enqueue()` method. However, for management purposes, the `enqueue_once()` method allows a job to be scheduled exactly once avoiding duplicates. If a job is already scheduled for a particular instance, a second one won't be scheduled, respecting thread safety. An example use case would be to schedule a periodic task that is bound to an instance in general, but not to any event of that instance (such as updates). The parameters of the `enqueue_once()` method are identical to those of `enqueue()`.
|
||||
|
||||
@@ -64,7 +64,6 @@ Generic view classes (documented below) facilitate common operations, such as cr
|
||||
| `ObjectListView` | View a list of objects |
|
||||
| `BulkImportView` | Import a set of new objects |
|
||||
| `BulkEditView` | Edit multiple objects |
|
||||
| `BulkRenameView` | Rename multiple objects |
|
||||
| `BulkDeleteView` | Delete multiple objects |
|
||||
|
||||
!!! warning
|
||||
@@ -172,10 +171,6 @@ Below are the class definitions for NetBox's multi-object views. These views han
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: netbox.views.generic.BulkRenameView
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: netbox.views.generic.BulkDeleteView
|
||||
options:
|
||||
members:
|
||||
|
||||
@@ -80,18 +80,20 @@ GET /api/ipam/vlans/?vid__gt=900
|
||||
|
||||
String based (char) fields (Name, Address, etc) support these lookup expressions:
|
||||
|
||||
| Filter | Description |
|
||||
|---------|----------------------------------------|
|
||||
| `n` | Not equal to |
|
||||
| `ic` | Contains (case-insensitive) |
|
||||
| `nic` | Does not contain (case-insensitive) |
|
||||
| `isw` | Starts with (case-insensitive) |
|
||||
| `nisw` | Does not start with (case-insensitive) |
|
||||
| `iew` | Ends with (case-insensitive) |
|
||||
| `niew` | Does not end with (case-insensitive) |
|
||||
| `ie` | Exact match (case-insensitive) |
|
||||
| `nie` | Inverse exact match (case-insensitive) |
|
||||
| `empty` | Is empty/null (boolean) |
|
||||
| Filter | Description |
|
||||
|----------|----------------------------------------|
|
||||
| `n` | Not equal to |
|
||||
| `ic` | Contains (case-insensitive) |
|
||||
| `nic` | Does not contain (case-insensitive) |
|
||||
| `isw` | Starts with (case-insensitive) |
|
||||
| `nisw` | Does not start with (case-insensitive) |
|
||||
| `iew` | Ends with (case-insensitive) |
|
||||
| `niew` | Does not end with (case-insensitive) |
|
||||
| `ie` | Exact match (case-insensitive) |
|
||||
| `nie` | Inverse exact match (case-insensitive) |
|
||||
| `empty` | Is empty/null (boolean) |
|
||||
| `regex` | Regexp matching |
|
||||
| `iregex` | Regexp matching (case-insensitive) |
|
||||
|
||||
Here is an example of a lookup expression on a string field that will return all devices with `switch` in the name:
|
||||
|
||||
|
||||
@@ -434,7 +434,7 @@ A new management command has been added: `manage.py housekeeping`. This command
|
||||
* Delete change log records which have surpassed the configured retention period (if configured)
|
||||
* Check for new NetBox releases (if enabled)
|
||||
|
||||
A convenience script for calling this command via an automated scheduler has been included at `/contrib/netbox-housekeeping.sh`. Please see the housekeeping documentation for further details.
|
||||
A convenience script for calling this command via an automated scheduler has been included at `/contrib/netbox-housekeeping.sh`. Please see the [housekeeping documentation](../administration/housekeeping.md) for further details.
|
||||
|
||||
#### Custom Queue Support for Plugins ([#6651](https://github.com/netbox-community/netbox/issues/6651))
|
||||
|
||||
|
||||
@@ -1,5 +1,53 @@
|
||||
# NetBox v4.3
|
||||
|
||||
## v4.3.6 (2025-08-12)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#17222](https://github.com/netbox-community/netbox/issues/17222) - Made unread notifications more visible with improved styling and positioning
|
||||
* [#18843](https://github.com/netbox-community/netbox/issues/18843) - Include color name when exporting cables
|
||||
* [#18873](https://github.com/netbox-community/netbox/issues/18873) - Add a request timeout parameter to the RSS feed dashboard widget
|
||||
* [#19622](https://github.com/netbox-community/netbox/issues/19622) - Allow sharing GraphQL queries as links
|
||||
* [#19728](https://github.com/netbox-community/netbox/issues/19728) - Added C18 power port type for audio devices
|
||||
* [#19968](https://github.com/netbox-community/netbox/issues/19968) - Improve object type selection form field when editing permissions
|
||||
* [#19977](https://github.com/netbox-community/netbox/issues/19977) - Improve performance when filtering device components by site, location, or rack
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#19321](https://github.com/netbox-community/netbox/issues/19321) - Reduce redundant database queries when bulk importing devices
|
||||
* [#19379](https://github.com/netbox-community/netbox/issues/19379) - Support singular VLAN IDs in list when editing a VLAN group
|
||||
* [#19812](https://github.com/netbox-community/netbox/issues/19812) - Implement `contains` GraphQL filter for IPAM prefixes and IP ranges
|
||||
* [#19917](https://github.com/netbox-community/netbox/issues/19917) - Ensure deterministic ordering of duplicate MAC addresses
|
||||
* [#19996](https://github.com/netbox-community/netbox/issues/19996) - Correct dynamic query parameters for IP Address field in Add/Edit Service form
|
||||
* [#19998](https://github.com/netbox-community/netbox/issues/19998) - Fix missing changelog records for deleted tags
|
||||
* [#19999](https://github.com/netbox-community/netbox/issues/19999) - Corrected excessive whitespace in script list dashboard widget
|
||||
* [#20001](https://github.com/netbox-community/netbox/issues/20001) - `is_api_request()` should not evaluate a request's content type
|
||||
* [#20009](https://github.com/netbox-community/netbox/issues/20009) - Ensure search parameter is escaped for export links under object list views
|
||||
* [#20017](https://github.com/netbox-community/netbox/issues/20017) - Fix highlighting of changed lines in changelog data
|
||||
* [#20023](https://github.com/netbox-community/netbox/issues/20023) - Add GiST index on prefixes table to vastly improve bulk deletion time
|
||||
* [#20030](https://github.com/netbox-community/netbox/issues/20030) - Fix height of object list action buttons & others
|
||||
* [#20033](https://github.com/netbox-community/netbox/issues/20033) - Fix `TypeError` exception when bulk deleting bookmarks
|
||||
* [#20056](https://github.com/netbox-community/netbox/issues/20056) - Fixed missing RF role options in device type schema validation
|
||||
|
||||
## v4.3.5 (2025-07-29)
|
||||
|
||||
### Enhancements
|
||||
* [#18797](https://github.com/netbox-community/netbox/issues/18797) - Added jinja2.StrictUndefined option for config template rendering to catch undefined variables
|
||||
* [#18936](https://github.com/netbox-community/netbox/issues/18936) - Cable imports now accept color names (e.g. "red", "blue") in addition to hex color codes
|
||||
* [#19840](https://github.com/netbox-community/netbox/issues/19840) - Cable imports now support specifying site information for better organization
|
||||
* [#19902](https://github.com/netbox-community/netbox/issues/19902) - Device names in rack elevation SVG exports are automatically truncated to prevent overflow beyond rack unit boundaries
|
||||
* [#19903](https://github.com/netbox-community/netbox/issues/19903) - String field filters now support `regex` and `iregex` lookups for advanced pattern matching
|
||||
* [#19910](https://github.com/netbox-community/netbox/issues/19910) - Internet-dependent links are no longer visible when running in air-gapped environments
|
||||
|
||||
### Bug Fixes
|
||||
* [#18900](https://github.com/netbox-community/netbox/issues/18900) - REST API paginator now raises proper exceptions when attempting to paginate unordered querysets
|
||||
* [#19916](https://github.com/netbox-community/netbox/issues/19916) - Rack elevation image/label dropdown functionality restored
|
||||
* [#19934](https://github.com/netbox-community/netbox/issues/19934) - Added missing description field to tenant bulk edit form
|
||||
* [#19956](https://github.com/netbox-community/netbox/issues/19956) - Prevent duplicate deletion records in changelog from cascading deletions
|
||||
|
||||
!!! note "Plugin Developer Advisory"
|
||||
The fix for bug [#18900](https://github.com/netbox-community/netbox/issues/18900) now raises explicit exceptions when API endpoints attempt to paginate unordered querysets. Plugin maintainers should review their API viewsets to ensure proper queryset ordering is applied before pagination, either by using `.order_by()` on querysets or by setting `ordering` in model Meta classes. Previously silent pagination issues in plugin code will now raise `QuerySetNotOrdered` exceptions and may require updates to maintain compatibility.
|
||||
|
||||
## v4.3.4 (2025-07-15)
|
||||
|
||||
### Enhancements
|
||||
|
||||
@@ -158,6 +158,7 @@ nav:
|
||||
- Okta: 'administration/authentication/okta.md'
|
||||
- Permissions: 'administration/permissions.md'
|
||||
- Error Reporting: 'administration/error-reporting.md'
|
||||
- Housekeeping: 'administration/housekeeping.md'
|
||||
- Replicating NetBox: 'administration/replicating-netbox.md'
|
||||
- NetBox Shell: 'administration/netbox-shell.md'
|
||||
- Data Model:
|
||||
|
||||
@@ -5,7 +5,6 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from dcim.views import PathTraceView
|
||||
from ipam.models import ASN
|
||||
from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport
|
||||
from netbox.views import generic
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.query import count_related
|
||||
@@ -80,11 +79,6 @@ class ProviderBulkEditView(generic.BulkEditView):
|
||||
form = forms.ProviderBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(Provider, 'bulk_rename', path='rename', detail=False)
|
||||
class ProviderBulkRenameView(generic.BulkRenameView):
|
||||
queryset = Provider.objects.all()
|
||||
|
||||
|
||||
@register_model_view(Provider, 'bulk_delete', path='delete', detail=False)
|
||||
class ProviderBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Provider.objects.annotate(
|
||||
@@ -147,11 +141,6 @@ class ProviderAccountBulkEditView(generic.BulkEditView):
|
||||
form = forms.ProviderAccountBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(ProviderAccount, 'bulk_rename', path='rename', detail=False)
|
||||
class ProviderAccountBulkRenameView(generic.BulkRenameView):
|
||||
queryset = ProviderAccount.objects.all()
|
||||
|
||||
|
||||
@register_model_view(ProviderAccount, 'bulk_delete', path='delete', detail=False)
|
||||
class ProviderAccountBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = ProviderAccount.objects.annotate(
|
||||
@@ -223,11 +212,6 @@ class ProviderNetworkBulkEditView(generic.BulkEditView):
|
||||
form = forms.ProviderNetworkBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(ProviderNetwork, 'bulk_rename', path='rename', detail=False)
|
||||
class ProviderNetworkBulkRenameView(generic.BulkRenameView):
|
||||
queryset = ProviderNetwork.objects.all()
|
||||
|
||||
|
||||
@register_model_view(ProviderNetwork, 'bulk_delete', path='delete', detail=False)
|
||||
class ProviderNetworkBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = ProviderNetwork.objects.all()
|
||||
@@ -287,11 +271,6 @@ class CircuitTypeBulkEditView(generic.BulkEditView):
|
||||
form = forms.CircuitTypeBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(CircuitType, 'bulk_rename', path='rename', detail=False)
|
||||
class CircuitTypeBulkRenameView(generic.BulkRenameView):
|
||||
queryset = CircuitType.objects.all()
|
||||
|
||||
|
||||
@register_model_view(CircuitType, 'bulk_delete', path='delete', detail=False)
|
||||
class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = CircuitType.objects.annotate(
|
||||
@@ -358,12 +337,6 @@ class CircuitBulkEditView(generic.BulkEditView):
|
||||
form = forms.CircuitBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(Circuit, 'bulk_rename', path='rename', detail=False)
|
||||
class CircuitBulkRenameView(generic.BulkRenameView):
|
||||
queryset = Circuit.objects.all()
|
||||
field_name = 'cid'
|
||||
|
||||
|
||||
@register_model_view(Circuit, 'bulk_delete', path='delete', detail=False)
|
||||
class CircuitBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Circuit.objects.prefetch_related(
|
||||
@@ -459,7 +432,6 @@ class CircuitTerminationListView(generic.ObjectListView):
|
||||
filterset = filtersets.CircuitTerminationFilterSet
|
||||
filterset_form = forms.CircuitTerminationFilterForm
|
||||
table = tables.CircuitTerminationTable
|
||||
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
|
||||
|
||||
|
||||
@register_model_view(CircuitTermination)
|
||||
@@ -554,11 +526,6 @@ class CircuitGroupBulkEditView(generic.BulkEditView):
|
||||
form = forms.CircuitGroupBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(CircuitGroup, 'bulk_rename', path='rename', detail=False)
|
||||
class CircuitGroupBulkRenameView(generic.BulkRenameView):
|
||||
queryset = CircuitGroup.objects.all()
|
||||
|
||||
|
||||
@register_model_view(CircuitGroup, 'bulk_delete', path='delete', detail=False)
|
||||
class CircuitGroupBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = CircuitGroup.objects.all()
|
||||
@@ -576,7 +543,6 @@ class CircuitGroupAssignmentListView(generic.ObjectListView):
|
||||
filterset = filtersets.CircuitGroupAssignmentFilterSet
|
||||
filterset_form = forms.CircuitGroupAssignmentFilterForm
|
||||
table = tables.CircuitGroupAssignmentTable
|
||||
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
|
||||
|
||||
|
||||
@register_model_view(CircuitGroupAssignment)
|
||||
@@ -669,11 +635,6 @@ class VirtualCircuitTypeBulkEditView(generic.BulkEditView):
|
||||
form = forms.VirtualCircuitTypeBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(VirtualCircuitType, 'bulk_rename', path='rename', detail=False)
|
||||
class VirtualCircuitTypeBulkRenameView(generic.BulkRenameView):
|
||||
queryset = VirtualCircuitType.objects.all()
|
||||
|
||||
|
||||
@register_model_view(VirtualCircuitType, 'bulk_delete', path='delete', detail=False)
|
||||
class VirtualCircuitTypeBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = VirtualCircuitType.objects.annotate(
|
||||
@@ -736,12 +697,6 @@ class VirtualCircuitBulkEditView(generic.BulkEditView):
|
||||
form = forms.VirtualCircuitBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(VirtualCircuit, 'bulk_rename', path='rename', detail=False)
|
||||
class VirtualCircuitulkRenameView(generic.BulkRenameView):
|
||||
queryset = VirtualCircuit.objects.all()
|
||||
field_name = 'cid'
|
||||
|
||||
|
||||
class VirtualCircuitBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = VirtualCircuit.objects.annotate(
|
||||
termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit')
|
||||
@@ -759,7 +714,6 @@ class VirtualCircuitTerminationListView(generic.ObjectListView):
|
||||
filterset = filtersets.VirtualCircuitTerminationFilterSet
|
||||
filterset_form = forms.VirtualCircuitTerminationFilterForm
|
||||
table = tables.VirtualCircuitTerminationTable
|
||||
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
|
||||
|
||||
|
||||
@register_model_view(VirtualCircuitTermination)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from .serializers_.change_logging import *
|
||||
from .serializers_.data import *
|
||||
from .serializers_.jobs import *
|
||||
from .serializers_.object_types import *
|
||||
from .serializers_.tasks import *
|
||||
|
||||
@@ -23,6 +23,6 @@ class JobSerializer(BaseModelSerializer):
|
||||
model = Job
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'object_type', 'object_id', 'name', 'status', 'created', 'scheduled',
|
||||
'interval', 'started', 'completed', 'user', 'data', 'error', 'job_id', 'log_entries',
|
||||
'interval', 'started', 'completed', 'user', 'data', 'error', 'job_id',
|
||||
]
|
||||
brief_fields = ('url', 'created', 'completed', 'user', 'status')
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import inspect
|
||||
|
||||
from django.urls import NoReverseMatch, reverse
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
|
||||
from core.models import ObjectType
|
||||
from netbox.api.serializers import BaseModelSerializer
|
||||
from utilities.views import get_viewname
|
||||
|
||||
__all__ = (
|
||||
'ObjectTypeSerializer',
|
||||
)
|
||||
|
||||
|
||||
class ObjectTypeSerializer(BaseModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:objecttype-detail')
|
||||
app_name = serializers.CharField(source='app_verbose_name', read_only=True)
|
||||
model_name = serializers.CharField(source='model_verbose_name', read_only=True)
|
||||
model_name_plural = serializers.CharField(source='model_verbose_name_plural', read_only=True)
|
||||
is_plugin_model = serializers.BooleanField(read_only=True)
|
||||
rest_api_endpoint = serializers.SerializerMethodField()
|
||||
description = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = ObjectType
|
||||
fields = [
|
||||
'id', 'url', 'display', 'app_label', 'app_name', 'model', 'model_name', 'model_name_plural',
|
||||
'is_plugin_model', 'rest_api_endpoint', 'description',
|
||||
]
|
||||
|
||||
@extend_schema_field(OpenApiTypes.STR)
|
||||
def get_rest_api_endpoint(self, obj):
|
||||
if not (model := obj.model_class()):
|
||||
return
|
||||
if viewname := get_viewname(model, action='list', rest_api=True):
|
||||
try:
|
||||
return reverse(viewname)
|
||||
except NoReverseMatch:
|
||||
return
|
||||
|
||||
@extend_schema_field(OpenApiTypes.STR)
|
||||
def get_description(self, obj):
|
||||
if not (model := obj.model_class()):
|
||||
return
|
||||
return inspect.getdoc(model)
|
||||
@@ -10,7 +10,6 @@ router.register('data-sources', views.DataSourceViewSet)
|
||||
router.register('data-files', views.DataFileViewSet)
|
||||
router.register('jobs', views.JobViewSet)
|
||||
router.register('object-changes', views.ObjectChangeViewSet)
|
||||
router.register('object-types', views.ObjectTypeViewSet)
|
||||
router.register('background-queues', views.BackgroundQueueViewSet, basename='rqqueue')
|
||||
router.register('background-workers', views.BackgroundWorkerViewSet, basename='rqworker')
|
||||
router.register('background-tasks', views.BackgroundTaskViewSet, basename='rqtask')
|
||||
|
||||
@@ -1,30 +1,28 @@
|
||||
from django.http import Http404, HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_rq.queues import get_redis_connection
|
||||
from django_rq.settings import QUEUES_LIST
|
||||
from django_rq.utils import get_statistics
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.permissions import IsAdminUser
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.routers import APIRootView
|
||||
from rest_framework.viewsets import ReadOnlyModelViewSet
|
||||
from rq.job import Job as RQ_Job
|
||||
from rq.worker import Worker
|
||||
|
||||
from core import filtersets
|
||||
from core.choices import DataSourceStatusChoices
|
||||
from core.jobs import SyncDataSourceJob
|
||||
from core.models import *
|
||||
from core.utils import delete_rq_job, enqueue_rq_job, get_rq_jobs, requeue_rq_job, stop_rq_job
|
||||
from django_rq.queues import get_redis_connection
|
||||
from django_rq.utils import get_statistics
|
||||
from django_rq.settings import QUEUES_LIST
|
||||
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
||||
from netbox.api.metadata import ContentTypeMetadata
|
||||
from netbox.api.pagination import LimitOffsetListPagination
|
||||
from netbox.api.viewsets import NetBoxModelViewSet, NetBoxReadOnlyModelViewSet
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.permissions import IsAdminUser
|
||||
from rq.job import Job as RQ_Job
|
||||
from rq.worker import Worker
|
||||
from . import serializers
|
||||
|
||||
|
||||
@@ -51,10 +49,8 @@ class DataSourceViewSet(NetBoxModelViewSet):
|
||||
if not request.user.has_perm('core.sync_datasource', obj=datasource):
|
||||
raise PermissionDenied(_("This user does not have permission to synchronize this data source."))
|
||||
|
||||
# Enqueue the sync job & update the DataSource's status
|
||||
# Enqueue the sync job
|
||||
SyncDataSourceJob.enqueue(instance=datasource, user=request.user)
|
||||
datasource.status = DataSourceStatusChoices.QUEUED
|
||||
DataSource.objects.filter(pk=datasource.pk).update(status=datasource.status)
|
||||
|
||||
serializer = serializers.DataSourceSerializer(datasource, context={'request': request})
|
||||
|
||||
@@ -86,16 +82,6 @@ class ObjectChangeViewSet(ReadOnlyModelViewSet):
|
||||
filterset_class = filtersets.ObjectChangeFilterSet
|
||||
|
||||
|
||||
class ObjectTypeViewSet(ReadOnlyModelViewSet):
|
||||
"""
|
||||
Read-only list of ObjectTypes.
|
||||
"""
|
||||
permission_classes = [IsAuthenticatedOrLoginNotRequired]
|
||||
queryset = ObjectType.objects.order_by('app_label', 'model')
|
||||
serializer_class = serializers.ObjectTypeSerializer
|
||||
filterset_class = filtersets.ObjectTypeFilterSet
|
||||
|
||||
|
||||
class BaseRQViewSet(viewsets.ViewSet):
|
||||
"""
|
||||
Base class for RQ view sets. Provides a list() method. Subclasses must implement get_data().
|
||||
|
||||
@@ -4,31 +4,23 @@ from django.utils.translation import gettext_lazy as _
|
||||
from rq.job import JobStatus
|
||||
|
||||
__all__ = (
|
||||
'JOB_LOG_ENTRY_LEVELS',
|
||||
'RQ_TASK_STATUSES',
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Badge:
|
||||
class Status:
|
||||
label: str
|
||||
color: str
|
||||
|
||||
|
||||
RQ_TASK_STATUSES = {
|
||||
JobStatus.QUEUED: Badge(_('Queued'), 'cyan'),
|
||||
JobStatus.FINISHED: Badge(_('Finished'), 'green'),
|
||||
JobStatus.FAILED: Badge(_('Failed'), 'red'),
|
||||
JobStatus.STARTED: Badge(_('Started'), 'blue'),
|
||||
JobStatus.DEFERRED: Badge(_('Deferred'), 'gray'),
|
||||
JobStatus.SCHEDULED: Badge(_('Scheduled'), 'purple'),
|
||||
JobStatus.STOPPED: Badge(_('Stopped'), 'orange'),
|
||||
JobStatus.CANCELED: Badge(_('Cancelled'), 'yellow'),
|
||||
}
|
||||
|
||||
JOB_LOG_ENTRY_LEVELS = {
|
||||
'debug': Badge(_('Debug'), 'gray'),
|
||||
'info': Badge(_('Info'), 'blue'),
|
||||
'warning': Badge(_('Warning'), 'orange'),
|
||||
'error': Badge(_('Error'), 'red'),
|
||||
JobStatus.QUEUED: Status(_('Queued'), 'cyan'),
|
||||
JobStatus.FINISHED: Status(_('Finished'), 'green'),
|
||||
JobStatus.FAILED: Status(_('Failed'), 'red'),
|
||||
JobStatus.STARTED: Status(_('Started'), 'blue'),
|
||||
JobStatus.DEFERRED: Status(_('Deferred'), 'gray'),
|
||||
JobStatus.SCHEDULED: Status(_('Scheduled'), 'purple'),
|
||||
JobStatus.STOPPED: Status(_('Stopped'), 'orange'),
|
||||
JobStatus.CANCELED: Status(_('Cancelled'), 'yellow'),
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import logging
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
__all__ = (
|
||||
'JobLogEntry',
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class JobLogEntry:
|
||||
level: str
|
||||
message: str
|
||||
timestamp: datetime = field(default_factory=timezone.now)
|
||||
|
||||
@classmethod
|
||||
def from_logrecord(cls, record: logging.LogRecord):
|
||||
return cls(record.levelname.lower(), record.msg)
|
||||
@@ -1,8 +1,9 @@
|
||||
import django_filters
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
import django_filters
|
||||
|
||||
from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
|
||||
from netbox.utils import get_data_backend_choices
|
||||
from users.models import User
|
||||
@@ -16,7 +17,6 @@ __all__ = (
|
||||
'DataSourceFilterSet',
|
||||
'JobFilterSet',
|
||||
'ObjectChangeFilterSet',
|
||||
'ObjectTypeFilterSet',
|
||||
)
|
||||
|
||||
|
||||
@@ -134,25 +134,6 @@ class JobFilterSet(BaseFilterSet):
|
||||
)
|
||||
|
||||
|
||||
class ObjectTypeFilterSet(django_filters.FilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label=_('Search'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ObjectType
|
||||
fields = ('id', 'app_label', 'model')
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(app_label__icontains=value) |
|
||||
Q(model__icontains=value)
|
||||
)
|
||||
|
||||
|
||||
class ObjectChangeFilterSet(BaseFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
import logging
|
||||
import sys
|
||||
from datetime import timedelta
|
||||
from importlib import import_module
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.utils import timezone
|
||||
from packaging import version
|
||||
import sys
|
||||
|
||||
from core.models import Job, ObjectChange
|
||||
from netbox.config import Config
|
||||
from django.conf import settings
|
||||
from netbox.jobs import JobRunner, system_job
|
||||
from netbox.search.backends import search_backend
|
||||
from utilities.proxy import resolve_proxies
|
||||
from .choices import DataSourceStatusChoices, JobIntervalChoices
|
||||
from .exceptions import SyncError
|
||||
from .models import DataSource
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -28,25 +21,32 @@ class SyncDataSourceJob(JobRunner):
|
||||
class Meta:
|
||||
name = 'Synchronization'
|
||||
|
||||
@classmethod
|
||||
def enqueue(cls, *args, **kwargs):
|
||||
job = super().enqueue(*args, **kwargs)
|
||||
|
||||
# Update the DataSource's synchronization status to queued
|
||||
if datasource := job.object:
|
||||
datasource.status = DataSourceStatusChoices.QUEUED
|
||||
DataSource.objects.filter(pk=datasource.pk).update(status=datasource.status)
|
||||
|
||||
return job
|
||||
|
||||
def run(self, *args, **kwargs):
|
||||
datasource = DataSource.objects.get(pk=self.job.object_id)
|
||||
self.logger.debug(f"Found DataSource ID {datasource.pk}")
|
||||
|
||||
try:
|
||||
self.logger.info(f"Syncing data source {datasource}")
|
||||
datasource.sync()
|
||||
|
||||
# Update the search cache for DataFiles belonging to this source
|
||||
self.logger.debug("Updating search cache for data files")
|
||||
search_backend.cache(datasource.datafiles.iterator())
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error syncing data source: {e}")
|
||||
DataSource.objects.filter(pk=datasource.pk).update(status=DataSourceStatusChoices.FAILED)
|
||||
if type(e) is SyncError:
|
||||
logging.error(e)
|
||||
raise e
|
||||
|
||||
self.logger.info("Syncing completed successfully")
|
||||
|
||||
|
||||
@system_job(interval=JobIntervalChoices.INTERVAL_DAILY)
|
||||
class SystemHousekeepingJob(JobRunner):
|
||||
@@ -61,23 +61,16 @@ class SystemHousekeepingJob(JobRunner):
|
||||
if settings.DEBUG or 'test' in sys.argv:
|
||||
return
|
||||
|
||||
# TODO: Migrate other housekeeping functions from the `housekeeping` management command.
|
||||
self.send_census_report()
|
||||
self.clear_expired_sessions()
|
||||
self.prune_changelog()
|
||||
self.delete_expired_jobs()
|
||||
self.check_for_new_releases()
|
||||
|
||||
@staticmethod
|
||||
def send_census_report():
|
||||
"""
|
||||
Send a census report (if enabled).
|
||||
"""
|
||||
logging.info("Reporting census data...")
|
||||
if settings.ISOLATED_DEPLOYMENT:
|
||||
logging.info("ISOLATED_DEPLOYMENT is enabled; skipping")
|
||||
return
|
||||
if not settings.CENSUS_REPORTING_ENABLED:
|
||||
logging.info("CENSUS_REPORTING_ENABLED is disabled; skipping")
|
||||
# Skip if census reporting is disabled
|
||||
if settings.ISOLATED_DEPLOYMENT or not settings.CENSUS_REPORTING_ENABLED:
|
||||
return
|
||||
|
||||
census_data = {
|
||||
@@ -94,94 +87,3 @@ class SystemHousekeepingJob(JobRunner):
|
||||
)
|
||||
except requests.exceptions.RequestException:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def clear_expired_sessions():
|
||||
"""
|
||||
Clear any expired sessions from the database.
|
||||
"""
|
||||
logging.info("Clearing expired sessions...")
|
||||
engine = import_module(settings.SESSION_ENGINE)
|
||||
try:
|
||||
engine.SessionStore.clear_expired()
|
||||
logging.info("Sessions cleared.")
|
||||
except NotImplementedError:
|
||||
logging.warning(
|
||||
f"The configured session engine ({settings.SESSION_ENGINE}) does not support "
|
||||
f"clearing sessions; skipping."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def prune_changelog():
|
||||
"""
|
||||
Delete any ObjectChange records older than the configured changelog retention time (if any).
|
||||
"""
|
||||
logging.info("Pruning old changelog entries...")
|
||||
config = Config()
|
||||
if not config.CHANGELOG_RETENTION:
|
||||
logging.info("No retention period specified; skipping.")
|
||||
return
|
||||
|
||||
cutoff = timezone.now() - timedelta(days=config.CHANGELOG_RETENTION)
|
||||
logging.debug(f"Retention period: {config.CHANGELOG_RETENTION} days")
|
||||
logging.debug(f"Cut-off time: {cutoff}")
|
||||
|
||||
count = ObjectChange.objects.filter(time__lt=cutoff).delete()[0]
|
||||
logging.info(f"Deleted {count} expired records")
|
||||
|
||||
@staticmethod
|
||||
def delete_expired_jobs():
|
||||
"""
|
||||
Delete any jobs older than the configured retention period (if any).
|
||||
"""
|
||||
logging.info("Deleting expired jobs...")
|
||||
config = Config()
|
||||
if not config.JOB_RETENTION:
|
||||
logging.info("No retention period specified; skipping.")
|
||||
return
|
||||
|
||||
cutoff = timezone.now() - timedelta(days=config.JOB_RETENTION)
|
||||
logging.debug(f"Retention period: {config.CHANGELOG_RETENTION} days")
|
||||
logging.debug(f"Cut-off time: {cutoff}")
|
||||
|
||||
count = Job.objects.filter(created__lt=cutoff).delete()[0]
|
||||
logging.info(f"Deleted {count} expired records")
|
||||
|
||||
@staticmethod
|
||||
def check_for_new_releases():
|
||||
"""
|
||||
Check for new releases and cache the latest release.
|
||||
"""
|
||||
logging.info("Checking for new releases...")
|
||||
if settings.ISOLATED_DEPLOYMENT:
|
||||
logging.info("ISOLATED_DEPLOYMENT is enabled; skipping")
|
||||
return
|
||||
if not settings.RELEASE_CHECK_URL:
|
||||
logging.info("RELEASE_CHECK_URL is not set; skipping")
|
||||
return
|
||||
|
||||
# Fetch the latest releases
|
||||
logging.debug(f"Release check URL: {settings.RELEASE_CHECK_URL}")
|
||||
try:
|
||||
response = requests.get(
|
||||
url=settings.RELEASE_CHECK_URL,
|
||||
headers={'Accept': 'application/vnd.github.v3+json'},
|
||||
proxies=resolve_proxies(url=settings.RELEASE_CHECK_URL)
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.RequestException as exc:
|
||||
logging.error(f"Error fetching release: {exc}")
|
||||
return
|
||||
|
||||
# Determine the most recent stable release
|
||||
releases = []
|
||||
for release in response.json():
|
||||
if 'tag_name' not in release or release.get('devrelease') or release.get('prerelease'):
|
||||
continue
|
||||
releases.append((version.parse(release['tag_name']), release.get('html_url')))
|
||||
latest_release = max(releases)
|
||||
logging.debug(f"Found {len(response.json())} releases; {len(releases)} usable")
|
||||
logging.info(f"Latest release: {latest_release[0]}")
|
||||
|
||||
# Cache the most recent release
|
||||
cache.set('latest_release', latest_release, None)
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import django.contrib.postgres.fields
|
||||
import django.core.serializers.json
|
||||
from django.db import migrations, models
|
||||
|
||||
import utilities.json
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0015_remove_redundant_indexes'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='job',
|
||||
name='log_entries',
|
||||
field=django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.JSONField(
|
||||
decoder=utilities.json.JobLogDecoder,
|
||||
encoder=django.core.serializers.json.DjangoJSONEncoder
|
||||
),
|
||||
blank=True,
|
||||
default=list,
|
||||
size=None
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,9 +1,7 @@
|
||||
from django.contrib.contenttypes.models import ContentType, ContentTypeManager
|
||||
from django.db.models import Q
|
||||
|
||||
from netbox.plugins import PluginConfig
|
||||
from netbox.registry import registry
|
||||
from utilities.string import title
|
||||
|
||||
__all__ = (
|
||||
'ObjectType',
|
||||
@@ -50,29 +48,3 @@ class ObjectType(ContentType):
|
||||
|
||||
class Meta:
|
||||
proxy = True
|
||||
|
||||
@property
|
||||
def app_labeled_name(self):
|
||||
# Override ContentType's "app | model" representation style.
|
||||
return f"{self.app_verbose_name} > {title(self.model_verbose_name)}"
|
||||
|
||||
@property
|
||||
def app_verbose_name(self):
|
||||
if model := self.model_class():
|
||||
return model._meta.app_config.verbose_name
|
||||
|
||||
@property
|
||||
def model_verbose_name(self):
|
||||
if model := self.model_class():
|
||||
return model._meta.verbose_name
|
||||
|
||||
@property
|
||||
def model_verbose_name_plural(self):
|
||||
if model := self.model_class():
|
||||
return model._meta.verbose_name_plural
|
||||
|
||||
@property
|
||||
def is_plugin_model(self):
|
||||
if not (model := self.model_class()):
|
||||
return # Return null if model class is invalid
|
||||
return isinstance(model._meta.app_config, PluginConfig)
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import asdict
|
||||
from functools import partial
|
||||
|
||||
import django_rq
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.core.validators import MinValueValidator
|
||||
@@ -17,10 +14,8 @@ from django.utils.translation import gettext as _
|
||||
from rq.exceptions import InvalidJobOperation
|
||||
|
||||
from core.choices import JobStatusChoices
|
||||
from core.dataclasses import JobLogEntry
|
||||
from core.models import ObjectType
|
||||
from core.signals import job_end, job_start
|
||||
from utilities.json import JobLogDecoder
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.rqworker import get_queue_for_model
|
||||
|
||||
@@ -109,15 +104,6 @@ class Job(models.Model):
|
||||
verbose_name=_('job ID'),
|
||||
unique=True
|
||||
)
|
||||
log_entries = ArrayField(
|
||||
verbose_name=_('log entries'),
|
||||
base_field=models.JSONField(
|
||||
encoder=DjangoJSONEncoder,
|
||||
decoder=JobLogDecoder,
|
||||
),
|
||||
blank=True,
|
||||
default=list,
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
@@ -130,7 +116,7 @@ class Job(models.Model):
|
||||
verbose_name_plural = _('jobs')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
return str(self.job_id)
|
||||
|
||||
def get_absolute_url(self):
|
||||
# TODO: Employ dynamic registration
|
||||
@@ -218,13 +204,6 @@ class Job(models.Model):
|
||||
# Send signal
|
||||
job_end.send(self)
|
||||
|
||||
def log(self, record: logging.LogRecord):
|
||||
"""
|
||||
Record a LogRecord from Python's native logging in the job's log.
|
||||
"""
|
||||
entry = JobLogEntry.from_logrecord(record)
|
||||
self.log_entries.append(asdict(entry))
|
||||
|
||||
@classmethod
|
||||
def enqueue(
|
||||
cls,
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from netbox.object_actions import ObjectAction
|
||||
|
||||
__all__ = (
|
||||
'BulkSync',
|
||||
)
|
||||
|
||||
|
||||
class BulkSync(ObjectAction):
|
||||
"""
|
||||
Synchronize multiple objects at once.
|
||||
"""
|
||||
name = 'bulk_sync'
|
||||
label = _('Sync Data')
|
||||
multi = True
|
||||
permissions_required = {'sync'}
|
||||
template_name = 'core/buttons/bulk_sync.html'
|
||||
@@ -1,16 +1,19 @@
|
||||
import logging
|
||||
from threading import local
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models.fields.reverse_related import ManyToManyRel, ManyToOneRel
|
||||
from django.db.models.signals import m2m_changed, post_save, pre_delete
|
||||
from django.dispatch import receiver, Signal
|
||||
from django.core.signals import request_finished
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_prometheus.models import model_deletes, model_inserts, model_updates
|
||||
|
||||
from core.choices import JobStatusChoices, ObjectChangeActionChoices
|
||||
from core.events import *
|
||||
from extras.events import enqueue_event
|
||||
from extras.models import Tag
|
||||
from extras.utils import run_validators
|
||||
from netbox.config import get_config
|
||||
from netbox.context import current_request, events_queue
|
||||
@@ -42,6 +45,10 @@ clear_events = Signal()
|
||||
# Change logging & event handling
|
||||
#
|
||||
|
||||
# Used to track received signals per object
|
||||
_signals_received = local()
|
||||
|
||||
|
||||
@receiver((post_save, m2m_changed))
|
||||
def handle_changed_object(sender, instance, **kwargs):
|
||||
"""
|
||||
@@ -66,6 +73,17 @@ def handle_changed_object(sender, instance, **kwargs):
|
||||
# m2m_changed with objects added or removed
|
||||
m2m_changed = True
|
||||
event_type = OBJECT_UPDATED
|
||||
elif kwargs.get('action') == 'post_clear':
|
||||
# Handle clearing of an M2M field
|
||||
if kwargs.get('model') == Tag and getattr(instance, '_prechange_snapshot', {}).get('tags'):
|
||||
# Handle generation of M2M changes for Tags which have a previous value (ignoring changes where the
|
||||
# prechange snapshot is empty)
|
||||
m2m_changed = True
|
||||
event_type = OBJECT_UPDATED
|
||||
else:
|
||||
# Other endpoints are unimpacted as they send post_add and post_remove
|
||||
# This will impact changes that utilize clear() however so we may want to give consideration for this branch
|
||||
return
|
||||
else:
|
||||
return
|
||||
|
||||
@@ -130,6 +148,16 @@ def handle_deleted_object(sender, instance, **kwargs):
|
||||
if request is None:
|
||||
return
|
||||
|
||||
# Check whether we've already processed a pre_delete signal for this object. (This can
|
||||
# happen e.g. when both a parent object and its child are deleted simultaneously, due
|
||||
# to cascading deletion.)
|
||||
if not hasattr(_signals_received, 'pre_delete'):
|
||||
_signals_received.pre_delete = set()
|
||||
signature = (ContentType.objects.get_for_model(instance), instance.pk)
|
||||
if signature in _signals_received.pre_delete:
|
||||
return
|
||||
_signals_received.pre_delete.add(signature)
|
||||
|
||||
# Record an ObjectChange if applicable
|
||||
if hasattr(instance, 'to_objectchange'):
|
||||
if hasattr(instance, 'snapshot') and not getattr(instance, '_prechange_snapshot', None):
|
||||
@@ -179,6 +207,14 @@ def handle_deleted_object(sender, instance, **kwargs):
|
||||
model_deletes.labels(instance._meta.model_name).inc()
|
||||
|
||||
|
||||
@receiver(request_finished)
|
||||
def clear_signal_history(sender, **kwargs):
|
||||
"""
|
||||
Clear out the signals history once the request is finished.
|
||||
"""
|
||||
_signals_received.pre_delete = set()
|
||||
|
||||
|
||||
@receiver(clear_events)
|
||||
def clear_events_queue(sender, **kwargs):
|
||||
"""
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import django_tables2 as tables
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from core.constants import RQ_TASK_STATUSES
|
||||
from netbox.registry import registry
|
||||
|
||||
__all__ = (
|
||||
'BackendTypeColumn',
|
||||
'BadgeColumn',
|
||||
'RQJobStatusColumn',
|
||||
)
|
||||
|
||||
|
||||
@@ -22,21 +23,14 @@ class BackendTypeColumn(tables.Column):
|
||||
return value
|
||||
|
||||
|
||||
class BadgeColumn(tables.Column):
|
||||
class RQJobStatusColumn(tables.Column):
|
||||
"""
|
||||
Render a colored badge for a value.
|
||||
|
||||
Args:
|
||||
badges: A dictionary mapping of values to core.constants.Badge instances.
|
||||
Render a colored label for the status of an RQ job.
|
||||
"""
|
||||
def __init__(self, badges, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.badges = badges
|
||||
|
||||
def render(self, value):
|
||||
badge = self.badges.get(value)
|
||||
return mark_safe(f'<span class="badge text-bg-{badge.color}">{badge.label}</span>')
|
||||
status = RQ_TASK_STATUSES.get(value)
|
||||
return mark_safe(f'<span class="badge text-bg-{status.color}">{status.label}</span>')
|
||||
|
||||
def value(self, value):
|
||||
badge = self.badges.get(value)
|
||||
return badge.label
|
||||
status = RQ_TASK_STATUSES.get(value)
|
||||
return status.label
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import django_tables2 as tables
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from netbox.tables import BaseTable, NetBoxTable, columns
|
||||
from core.constants import JOB_LOG_ENTRY_LEVELS
|
||||
from core.models import Job
|
||||
from core.tables.columns import BadgeColumn
|
||||
from netbox.tables import NetBoxTable, columns
|
||||
from ..models import Job
|
||||
|
||||
|
||||
class JobTable(NetBoxTable):
|
||||
@@ -42,9 +40,6 @@ class JobTable(NetBoxTable):
|
||||
completed = columns.DateTimeColumn(
|
||||
verbose_name=_('Completed'),
|
||||
)
|
||||
log_entries = tables.Column(
|
||||
verbose_name=_('Log Entries'),
|
||||
)
|
||||
actions = columns.ActionsColumn(
|
||||
actions=('delete',)
|
||||
)
|
||||
@@ -58,24 +53,3 @@ class JobTable(NetBoxTable):
|
||||
default_columns = (
|
||||
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user',
|
||||
)
|
||||
|
||||
def render_log_entries(self, value):
|
||||
return len(value)
|
||||
|
||||
|
||||
class JobLogEntryTable(BaseTable):
|
||||
timestamp = columns.DateTimeColumn(
|
||||
timespec='milliseconds',
|
||||
verbose_name=_('Time'),
|
||||
)
|
||||
level = BadgeColumn(
|
||||
badges=JOB_LOG_ENTRY_LEVELS,
|
||||
verbose_name=_('Level'),
|
||||
)
|
||||
message = tables.Column(
|
||||
verbose_name=_('Message'),
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
empty_text = _('No log entries')
|
||||
fields = ('timestamp', 'level', 'message')
|
||||
|
||||
@@ -2,8 +2,7 @@ import django_tables2 as tables
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_tables2.utils import A
|
||||
|
||||
from core.constants import RQ_TASK_STATUSES
|
||||
from core.tables.columns import BadgeColumn
|
||||
from core.tables.columns import RQJobStatusColumn
|
||||
from netbox.tables import BaseTable, columns
|
||||
|
||||
|
||||
@@ -85,8 +84,7 @@ class BackgroundTaskTable(BaseTable):
|
||||
ended_at = columns.DateTimeColumn(
|
||||
verbose_name=_("Ended")
|
||||
)
|
||||
status = BadgeColumn(
|
||||
badges=RQ_TASK_STATUSES,
|
||||
status = RQJobStatusColumn(
|
||||
verbose_name=_("Status"),
|
||||
accessor='get_status'
|
||||
)
|
||||
|
||||
@@ -7,7 +7,6 @@ from django.utils import timezone
|
||||
from rq.job import Job as RQ_Job, JobStatus
|
||||
from rq.registry import FailedJobRegistry, StartedJobRegistry
|
||||
|
||||
from rest_framework import status
|
||||
from users.models import Token, User
|
||||
from utilities.testing import APITestCase, APIViewTestCases, TestCase
|
||||
from utilities.testing.utils import disable_logging
|
||||
@@ -102,22 +101,6 @@ class DataFileTest(
|
||||
DataFile.objects.bulk_create(data_files)
|
||||
|
||||
|
||||
class ObjectTypeTest(APITestCase):
|
||||
|
||||
def test_list_objects(self):
|
||||
object_type_count = ObjectType.objects.count()
|
||||
|
||||
response = self.client.get(reverse('extras-api:objecttype-list'), **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data['count'], object_type_count)
|
||||
|
||||
def test_get_object(self):
|
||||
object_type = ObjectType.objects.first()
|
||||
|
||||
url = reverse('extras-api:objecttype-detail', kwargs={'pk': object_type.pk})
|
||||
self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_200_OK)
|
||||
|
||||
|
||||
class BackgroundTaskTestCase(TestCase):
|
||||
user_permissions = ()
|
||||
|
||||
|
||||
@@ -346,6 +346,38 @@ class ChangeLogViewTest(ModelViewTestCase):
|
||||
self.assertEqual(changes[1].changed_object_type, ContentType.objects.get_for_model(Interface))
|
||||
self.assertEqual(changes[2].changed_object_type, ContentType.objects.get_for_model(Device))
|
||||
|
||||
def test_duplicate_deletions(self):
|
||||
"""
|
||||
Check that a cascading deletion event does not generate multiple "deleted" ObjectChange records for
|
||||
the same object.
|
||||
"""
|
||||
role1 = DeviceRole(name='Role 1', slug='role-1')
|
||||
role1.save()
|
||||
role2 = DeviceRole(name='Role 2', slug='role-2', parent=role1)
|
||||
role2.save()
|
||||
pk_list = [role1.pk, role2.pk]
|
||||
|
||||
# Delete both objects simultaneously
|
||||
form_data = {
|
||||
'pk': pk_list,
|
||||
'confirm': True,
|
||||
'_confirm': True,
|
||||
}
|
||||
request = {
|
||||
'path': reverse('dcim:devicerole_bulk_delete'),
|
||||
'data': post_data(form_data),
|
||||
}
|
||||
self.add_permissions('dcim.delete_devicerole')
|
||||
self.assertHttpStatus(self.client.post(**request), 302)
|
||||
|
||||
# This should result in exactly one change record per object
|
||||
objectchanges = ObjectChange.objects.filter(
|
||||
changed_object_type=ContentType.objects.get_for_model(DeviceRole),
|
||||
changed_object_id__in=pk_list,
|
||||
action=ObjectChangeActionChoices.ACTION_DELETE
|
||||
)
|
||||
self.assertEqual(objectchanges.count(), 2)
|
||||
|
||||
|
||||
class ChangeLogAPITest(APITestCase):
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ from rq.worker_registration import clean_worker_registry
|
||||
|
||||
from core.utils import delete_rq_job, enqueue_rq_job, get_rq_jobs_from_status, requeue_rq_job, stop_rq_job
|
||||
from netbox.config import get_config, PARAMS
|
||||
from netbox.object_actions import AddObject, BulkDelete, BulkExport, DeleteObject
|
||||
from netbox.registry import registry
|
||||
from netbox.views import generic
|
||||
from netbox.views.generic.base import BaseObjectView
|
||||
@@ -32,13 +31,12 @@ from utilities.forms import ConfirmationForm
|
||||
from utilities.htmx import htmx_partial
|
||||
from utilities.json import ConfigJSONEncoder
|
||||
from utilities.query import count_related
|
||||
from utilities.views import ContentTypePermissionRequiredMixin, GetRelatedModelsMixin, ViewTab, register_model_view
|
||||
from utilities.views import ContentTypePermissionRequiredMixin, GetRelatedModelsMixin, register_model_view
|
||||
from . import filtersets, forms, tables
|
||||
from .choices import DataSourceStatusChoices
|
||||
from .jobs import SyncDataSourceJob
|
||||
from .models import *
|
||||
from .plugins import get_catalog_plugins, get_local_plugins
|
||||
from .tables import CatalogPluginTable, JobLogEntryTable, PluginVersionTable
|
||||
from .tables import CatalogPluginTable, PluginVersionTable
|
||||
|
||||
|
||||
#
|
||||
@@ -79,12 +77,8 @@ class DataSourceSyncView(BaseObjectView):
|
||||
|
||||
def post(self, request, pk):
|
||||
datasource = get_object_or_404(self.queryset, pk=pk)
|
||||
|
||||
# Enqueue the sync job & update the DataSource's status
|
||||
# Enqueue the sync job
|
||||
job = SyncDataSourceJob.enqueue(instance=datasource, user=request.user)
|
||||
datasource.status = DataSourceStatusChoices.QUEUED
|
||||
DataSource.objects.filter(pk=datasource.pk).update(status=datasource.status)
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
_("Queued job #{id} to sync {datasource}").format(id=job.pk, datasource=datasource)
|
||||
@@ -120,11 +114,6 @@ class DataSourceBulkEditView(generic.BulkEditView):
|
||||
form = forms.DataSourceBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(DataSource, 'bulk_rename', path='rename', detail=False)
|
||||
class DataSourceBulkRenameView(generic.BulkRenameView):
|
||||
queryset = DataSource.objects.all()
|
||||
|
||||
|
||||
@register_model_view(DataSource, 'bulk_delete', path='delete', detail=False)
|
||||
class DataSourceBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = DataSource.objects.annotate(
|
||||
@@ -144,13 +133,14 @@ class DataFileListView(generic.ObjectListView):
|
||||
filterset = filtersets.DataFileFilterSet
|
||||
filterset_form = forms.DataFileFilterForm
|
||||
table = tables.DataFileTable
|
||||
actions = (BulkDelete,)
|
||||
actions = {
|
||||
'bulk_delete': {'delete'},
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(DataFile)
|
||||
class DataFileView(generic.ObjectView):
|
||||
queryset = DataFile.objects.all()
|
||||
actions = (DeleteObject,)
|
||||
|
||||
|
||||
@register_model_view(DataFile, 'delete')
|
||||
@@ -175,32 +165,15 @@ class JobListView(generic.ObjectListView):
|
||||
filterset = filtersets.JobFilterSet
|
||||
filterset_form = forms.JobFilterForm
|
||||
table = tables.JobTable
|
||||
actions = (BulkExport, BulkDelete)
|
||||
actions = {
|
||||
'export': {'view'},
|
||||
'bulk_delete': {'delete'},
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(Job)
|
||||
class JobView(generic.ObjectView):
|
||||
queryset = Job.objects.all()
|
||||
actions = (DeleteObject,)
|
||||
|
||||
|
||||
@register_model_view(Job, 'log')
|
||||
class JobLogView(generic.ObjectView):
|
||||
queryset = Job.objects.all()
|
||||
actions = (DeleteObject,)
|
||||
template_name = 'core/job/log.html'
|
||||
tab = ViewTab(
|
||||
label=_('Log'),
|
||||
badge=lambda obj: len(obj.log_entries),
|
||||
weight=500,
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
table = JobLogEntryTable(instance.log_entries)
|
||||
table.configure(request)
|
||||
return {
|
||||
'table': table,
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(Job, 'delete')
|
||||
@@ -226,7 +199,9 @@ class ObjectChangeListView(generic.ObjectListView):
|
||||
filterset_form = forms.ObjectChangeFilterForm
|
||||
table = tables.ObjectChangeTable
|
||||
template_name = 'core/objectchange_list.html'
|
||||
actions = (BulkExport,)
|
||||
actions = {
|
||||
'export': {'view'},
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(ObjectChange)
|
||||
@@ -294,7 +269,6 @@ class ConfigRevisionListView(generic.ObjectListView):
|
||||
filterset = filtersets.ConfigRevisionFilterSet
|
||||
filterset_form = forms.ConfigRevisionFilterForm
|
||||
table = tables.ConfigRevisionTable
|
||||
actions = (AddObject, BulkExport)
|
||||
|
||||
|
||||
@register_model_view(ConfigRevision)
|
||||
|
||||
@@ -344,6 +344,7 @@ class PowerPortTypeChoices(ChoiceSet):
|
||||
TYPE_IEC_C8 = 'iec-60320-c8'
|
||||
TYPE_IEC_C14 = 'iec-60320-c14'
|
||||
TYPE_IEC_C16 = 'iec-60320-c16'
|
||||
TYPE_IEC_C18 = 'iec-60320-c18'
|
||||
TYPE_IEC_C20 = 'iec-60320-c20'
|
||||
TYPE_IEC_C22 = 'iec-60320-c22'
|
||||
# IEC 60309
|
||||
@@ -462,6 +463,7 @@ class PowerPortTypeChoices(ChoiceSet):
|
||||
(TYPE_IEC_C8, 'C8'),
|
||||
(TYPE_IEC_C14, 'C14'),
|
||||
(TYPE_IEC_C16, 'C16'),
|
||||
(TYPE_IEC_C18, 'C18'),
|
||||
(TYPE_IEC_C20, 'C20'),
|
||||
(TYPE_IEC_C22, 'C22'),
|
||||
)),
|
||||
@@ -599,6 +601,7 @@ class PowerOutletTypeChoices(ChoiceSet):
|
||||
TYPE_IEC_C7 = 'iec-60320-c7'
|
||||
TYPE_IEC_C13 = 'iec-60320-c13'
|
||||
TYPE_IEC_C15 = 'iec-60320-c15'
|
||||
TYPE_IEC_C17 = 'iec-60320-c17'
|
||||
TYPE_IEC_C19 = 'iec-60320-c19'
|
||||
TYPE_IEC_C21 = 'iec-60320-c21'
|
||||
# IEC 60309
|
||||
@@ -711,6 +714,7 @@ class PowerOutletTypeChoices(ChoiceSet):
|
||||
(TYPE_IEC_C7, 'C7'),
|
||||
(TYPE_IEC_C13, 'C13'),
|
||||
(TYPE_IEC_C15, 'C15'),
|
||||
(TYPE_IEC_C17, 'C17'),
|
||||
(TYPE_IEC_C19, 'C19'),
|
||||
(TYPE_IEC_C21, 'C21'),
|
||||
)),
|
||||
|
||||
@@ -1515,34 +1515,34 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
|
||||
label=_('Site group (slug)'),
|
||||
)
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__site',
|
||||
field_name='_site',
|
||||
queryset=Site.objects.all(),
|
||||
label=_('Site (ID)'),
|
||||
)
|
||||
site = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__site__slug',
|
||||
field_name='_site__slug',
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
label=_('Site name (slug)'),
|
||||
)
|
||||
location_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__location',
|
||||
field_name='_location',
|
||||
queryset=Location.objects.all(),
|
||||
label=_('Location (ID)'),
|
||||
)
|
||||
location = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__location__slug',
|
||||
field_name='_location__slug',
|
||||
queryset=Location.objects.all(),
|
||||
to_field_name='slug',
|
||||
label=_('Location (slug)'),
|
||||
)
|
||||
rack_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__rack',
|
||||
field_name='_rack',
|
||||
queryset=Rack.objects.all(),
|
||||
label=_('Rack (ID)'),
|
||||
)
|
||||
rack = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__rack__name',
|
||||
field_name='_rack__name',
|
||||
queryset=Rack.objects.all(),
|
||||
to_field_name='name',
|
||||
label=_('Rack (name)'),
|
||||
|
||||
@@ -1335,6 +1335,13 @@ class MACAddressImportForm(NetBoxModelImportForm):
|
||||
|
||||
class CableImportForm(NetBoxModelImportForm):
|
||||
# Termination A
|
||||
side_a_site = CSVModelChoiceField(
|
||||
label=_('Side A site'),
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text=_('Site of parent device A (if any)'),
|
||||
)
|
||||
side_a_device = CSVModelChoiceField(
|
||||
label=_('Side A device'),
|
||||
queryset=Device.objects.all(),
|
||||
@@ -1353,6 +1360,13 @@ class CableImportForm(NetBoxModelImportForm):
|
||||
)
|
||||
|
||||
# Termination B
|
||||
side_b_site = CSVModelChoiceField(
|
||||
label=_('Side B site'),
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text=_('Site of parent device B (if any)'),
|
||||
)
|
||||
side_b_device = CSVModelChoiceField(
|
||||
label=_('Side B device'),
|
||||
queryset=Device.objects.all(),
|
||||
@@ -1396,14 +1410,39 @@ class CableImportForm(NetBoxModelImportForm):
|
||||
required=False,
|
||||
help_text=_('Length unit')
|
||||
)
|
||||
color = forms.CharField(
|
||||
label=_('Color'),
|
||||
required=False,
|
||||
max_length=16,
|
||||
help_text=_('Color name (e.g. "Red") or hex code (e.g. "f44336")')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Cable
|
||||
fields = [
|
||||
'side_a_device', 'side_a_type', 'side_a_name', 'side_b_device', 'side_b_type', 'side_b_name', 'type',
|
||||
'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'comments', 'tags',
|
||||
'side_a_site', 'side_a_device', 'side_a_type', 'side_a_name', 'side_b_site', 'side_b_device', 'side_b_type',
|
||||
'side_b_name', 'type', 'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description',
|
||||
'comments', 'tags',
|
||||
]
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
super().__init__(data, *args, **kwargs)
|
||||
|
||||
if data:
|
||||
# Limit choices for side_a_device to the assigned side_a_site
|
||||
if side_a_site := data.get('side_a_site'):
|
||||
side_a_device_params = {f'site__{self.fields["side_a_site"].to_field_name}': side_a_site}
|
||||
self.fields['side_a_device'].queryset = self.fields['side_a_device'].queryset.filter(
|
||||
**side_a_device_params
|
||||
)
|
||||
|
||||
# Limit choices for side_b_device to the assigned side_b_site
|
||||
if side_b_site := data.get('side_b_site'):
|
||||
side_b_device_params = {f'site__{self.fields["side_b_site"].to_field_name}': side_b_site}
|
||||
self.fields['side_b_device'].queryset = self.fields['side_b_device'].queryset.filter(
|
||||
**side_b_device_params
|
||||
)
|
||||
|
||||
def _clean_side(self, side):
|
||||
"""
|
||||
Derive a Cable's A/B termination objects.
|
||||
@@ -1440,6 +1479,24 @@ class CableImportForm(NetBoxModelImportForm):
|
||||
setattr(self.instance, f'{side}_terminations', [termination_object])
|
||||
return termination_object
|
||||
|
||||
def _clean_color(self, color):
|
||||
"""
|
||||
Derive a colors hex code
|
||||
|
||||
:param color: color as hex or color name
|
||||
"""
|
||||
color_parsed = color.strip().lower()
|
||||
|
||||
for hex_code, label in ColorChoices.CHOICES:
|
||||
if color.lower() == label.lower():
|
||||
color_parsed = hex_code
|
||||
|
||||
if len(color_parsed) > 6:
|
||||
raise forms.ValidationError(
|
||||
_(f"{color} did not match any used color name and was longer than six characters: invalid hex.")
|
||||
)
|
||||
return color_parsed
|
||||
|
||||
def clean_side_a_name(self):
|
||||
return self._clean_side('a')
|
||||
|
||||
@@ -1451,11 +1508,14 @@ class CableImportForm(NetBoxModelImportForm):
|
||||
length_unit = self.cleaned_data.get('length_unit', None)
|
||||
return length_unit if length_unit is not None else ''
|
||||
|
||||
|
||||
def clean_color(self):
|
||||
color = self.cleaned_data.get('color', None)
|
||||
return self._clean_color(color) if color is not None else ''
|
||||
#
|
||||
# Virtual chassis
|
||||
#
|
||||
|
||||
|
||||
class VirtualChassisImportForm(NetBoxModelImportForm):
|
||||
master = CSVModelChoiceField(
|
||||
label=_('Master'),
|
||||
|
||||
@@ -1507,7 +1507,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
tx_power = forms.IntegerField(
|
||||
required=False,
|
||||
label=_('Transmit power (dBm)'),
|
||||
min_value=-40,
|
||||
min_value=0,
|
||||
max_value=127
|
||||
)
|
||||
vrf_id = DynamicModelMultipleChoiceField(
|
||||
|
||||
@@ -7,6 +7,7 @@ from jinja2 import FileSystemLoader, Environment
|
||||
|
||||
from dcim.choices import *
|
||||
from netbox.choices import WeightUnitChoices
|
||||
from wireless.choices import WirelessRoleChoices
|
||||
|
||||
TEMPLATE_FILENAME = 'devicetype_schema.jinja2'
|
||||
OUTPUT_FILENAME = 'contrib/generated_schema.json'
|
||||
@@ -23,6 +24,7 @@ CHOICES_MAP = {
|
||||
'interface_type_choices': InterfaceTypeChoices,
|
||||
'interface_poe_mode_choices': InterfacePoEModeChoices,
|
||||
'interface_poe_type_choices': InterfacePoETypeChoices,
|
||||
'interface_rf_role_choices': WirelessRoleChoices,
|
||||
'front_port_type_choices': PortTypeChoices,
|
||||
'rear_port_type_choices': PortTypeChoices,
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0209_interface_tx_power_negative'),
|
||||
('dcim', '0207_remove_redundant_indexes'),
|
||||
('extras', '0129_fix_script_paths'),
|
||||
]
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0207_remove_redundant_indexes'),
|
||||
('extras', '0129_fix_script_paths'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='platform',
|
||||
name='name',
|
||||
field=models.CharField(max_length=100),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='platform',
|
||||
name='slug',
|
||||
field=models.SlugField(max_length=100),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='platform',
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=('manufacturer', 'name'),
|
||||
name='dcim_platform_manufacturer_name'
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='platform',
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(('manufacturer__isnull', True)),
|
||||
fields=('name',),
|
||||
name='dcim_platform_name',
|
||||
violation_error_message='Platform name must be unique.'
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='platform',
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=('manufacturer', 'slug'),
|
||||
name='dcim_platform_manufacturer_slug'
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='platform',
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(('manufacturer__isnull', True)),
|
||||
fields=('slug',),
|
||||
name='dcim_platform_slug',
|
||||
violation_error_message='Platform slug must be unique.'
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,287 @@
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
from django.db.models import OuterRef, Subquery
|
||||
|
||||
|
||||
def populate_denormalized_data(apps, schema_editor):
|
||||
Device = apps.get_model('dcim', 'Device')
|
||||
component_models = (
|
||||
apps.get_model('dcim', 'ConsolePort'),
|
||||
apps.get_model('dcim', 'ConsoleServerPort'),
|
||||
apps.get_model('dcim', 'PowerPort'),
|
||||
apps.get_model('dcim', 'PowerOutlet'),
|
||||
apps.get_model('dcim', 'Interface'),
|
||||
apps.get_model('dcim', 'FrontPort'),
|
||||
apps.get_model('dcim', 'RearPort'),
|
||||
apps.get_model('dcim', 'DeviceBay'),
|
||||
apps.get_model('dcim', 'ModuleBay'),
|
||||
apps.get_model('dcim', 'InventoryItem'),
|
||||
)
|
||||
|
||||
for model in component_models:
|
||||
subquery = Device.objects.filter(pk=OuterRef('device_id'))
|
||||
model.objects.update(
|
||||
_site=Subquery(subquery.values('site_id')[:1]),
|
||||
_location=Subquery(subquery.values('location_id')[:1]),
|
||||
_rack=Subquery(subquery.values('rack_id')[:1]),
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('dcim', '0208_devicerole_uniqueness'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='consoleport',
|
||||
name='_location',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='+',
|
||||
to='dcim.location',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='consoleport',
|
||||
name='_rack',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='consoleport',
|
||||
name='_site',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='consoleserverport',
|
||||
name='_location',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='+',
|
||||
to='dcim.location',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='consoleserverport',
|
||||
name='_rack',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='consoleserverport',
|
||||
name='_site',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='devicebay',
|
||||
name='_location',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='+',
|
||||
to='dcim.location',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='devicebay',
|
||||
name='_rack',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='devicebay',
|
||||
name='_site',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='frontport',
|
||||
name='_location',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='+',
|
||||
to='dcim.location',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='frontport',
|
||||
name='_rack',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='frontport',
|
||||
name='_site',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='interface',
|
||||
name='_location',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='+',
|
||||
to='dcim.location',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='interface',
|
||||
name='_rack',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='interface',
|
||||
name='_site',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='inventoryitem',
|
||||
name='_location',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='+',
|
||||
to='dcim.location',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='inventoryitem',
|
||||
name='_rack',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='inventoryitem',
|
||||
name='_site',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='modulebay',
|
||||
name='_location',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='+',
|
||||
to='dcim.location',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='modulebay',
|
||||
name='_rack',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='modulebay',
|
||||
name='_site',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='poweroutlet',
|
||||
name='_location',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='+',
|
||||
to='dcim.location',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='poweroutlet',
|
||||
name='_rack',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='poweroutlet',
|
||||
name='_site',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='powerport',
|
||||
name='_location',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='+',
|
||||
to='dcim.location',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='powerport',
|
||||
name='_rack',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='powerport',
|
||||
name='_site',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rearport',
|
||||
name='_location',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='+',
|
||||
to='dcim.location',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rearport',
|
||||
name='_rack',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rearport',
|
||||
name='_site',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site'
|
||||
),
|
||||
),
|
||||
migrations.RunPython(populate_denormalized_data),
|
||||
]
|
||||
@@ -1,24 +0,0 @@
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0208_platform_manufacturer_uniqueness'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='interface',
|
||||
name='tx_power',
|
||||
field=models.SmallIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(-40),
|
||||
django.core.validators.MaxValueValidator(127)
|
||||
]
|
||||
),
|
||||
),
|
||||
]
|
||||
19
netbox/dcim/migrations/0210_macaddress_ordering.py
Normal file
19
netbox/dcim/migrations/0210_macaddress_ordering.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0209_device_component_denorm_site_location'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='macaddress',
|
||||
options={
|
||||
'ordering': ('mac_address', 'pk'),
|
||||
'verbose_name': 'MAC address',
|
||||
'verbose_name_plural': 'MAC addresses'
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -11,6 +11,7 @@ from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.fields import PathField
|
||||
from dcim.utils import decompile_path_node, object_to_path_node
|
||||
from netbox.choices import ColorChoices
|
||||
from netbox.models import ChangeLoggedModel, PrimaryModel
|
||||
from utilities.conversion import to_meters
|
||||
from utilities.exceptions import AbortRequest
|
||||
@@ -155,6 +156,15 @@ class Cable(PrimaryModel):
|
||||
self._terminations_modified = True
|
||||
self._b_terminations = value
|
||||
|
||||
@property
|
||||
def color_name(self):
|
||||
color_name = ""
|
||||
for hex_code, label in ColorChoices.CHOICES:
|
||||
if hex_code.lower() == self.color.lower():
|
||||
color_name = str(label)
|
||||
|
||||
return color_name
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
|
||||
@@ -65,6 +65,29 @@ class ComponentModel(NetBoxModel):
|
||||
blank=True
|
||||
)
|
||||
|
||||
# Denormalized references replicated from the parent Device
|
||||
_site = models.ForeignKey(
|
||||
to='dcim.Site',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='+',
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
_location = models.ForeignKey(
|
||||
to='dcim.Location',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='+',
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
_rack = models.ForeignKey(
|
||||
to='dcim.Rack',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='+',
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
ordering = ('device', 'name')
|
||||
@@ -100,6 +123,14 @@ class ComponentModel(NetBoxModel):
|
||||
"device": _("Components cannot be moved to a different device.")
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Save denormalized references
|
||||
self._site = self.device.site
|
||||
self._location = self.device.location
|
||||
self._rack = self.device.rack
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def parent_object(self):
|
||||
return self.device
|
||||
@@ -719,13 +750,10 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
|
||||
verbose_name=('channel width (MHz)'),
|
||||
help_text=_("Populated by selected channel (if set)")
|
||||
)
|
||||
tx_power = models.SmallIntegerField(
|
||||
tx_power = models.PositiveSmallIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=(
|
||||
MinValueValidator(-40),
|
||||
MaxValueValidator(127),
|
||||
),
|
||||
validators=(MaxValueValidator(127),),
|
||||
verbose_name=_('transmit power (dBm)')
|
||||
)
|
||||
poe_mode = models.CharField(
|
||||
|
||||
@@ -8,7 +8,7 @@ from django.core.exceptions import ValidationError
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import F, ProtectedError
|
||||
from django.db.models import F, ProtectedError, prefetch_related_objects
|
||||
from django.db.models.functions import Lower
|
||||
from django.db.models.signals import post_save
|
||||
from django.urls import reverse
|
||||
@@ -28,6 +28,7 @@ from netbox.models import NestedGroupModel, OrganizationalModel, PrimaryModel
|
||||
from netbox.models.mixins import WeightMixin
|
||||
from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
|
||||
from utilities.fields import ColorField, CounterCacheField
|
||||
from utilities.prefetch import get_prefetchable_fields
|
||||
from utilities.tracking import TrackingModelMixin
|
||||
from .device_components import *
|
||||
from .mixins import RenderConfigMixin
|
||||
@@ -437,15 +438,6 @@ class Platform(OrganizationalModel):
|
||||
null=True,
|
||||
help_text=_('Optionally limit this platform to devices of a certain manufacturer')
|
||||
)
|
||||
# Override name & slug from OrganizationalModel to not enforce uniqueness
|
||||
name = models.CharField(
|
||||
verbose_name=_('name'),
|
||||
max_length=100
|
||||
)
|
||||
slug = models.SlugField(
|
||||
verbose_name=_('slug'),
|
||||
max_length=100
|
||||
)
|
||||
config_template = models.ForeignKey(
|
||||
to='extras.ConfigTemplate',
|
||||
on_delete=models.PROTECT,
|
||||
@@ -458,28 +450,6 @@ class Platform(OrganizationalModel):
|
||||
ordering = ('name',)
|
||||
verbose_name = _('platform')
|
||||
verbose_name_plural = _('platforms')
|
||||
constraints = (
|
||||
models.UniqueConstraint(
|
||||
fields=('manufacturer', 'name'),
|
||||
name='%(app_label)s_%(class)s_manufacturer_name',
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=('name',),
|
||||
name='%(app_label)s_%(class)s_name',
|
||||
condition=Q(manufacturer__isnull=True),
|
||||
violation_error_message=_("Platform name must be unique.")
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=('manufacturer', 'slug'),
|
||||
name='%(app_label)s_%(class)s_manufacturer_slug',
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=('slug',),
|
||||
name='%(app_label)s_%(class)s_slug',
|
||||
condition=Q(manufacturer__isnull=True),
|
||||
violation_error_message=_("Platform slug must be unique.")
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class Device(
|
||||
@@ -955,7 +925,10 @@ class Device(
|
||||
if cf_defaults := CustomField.objects.get_defaults_for_model(model):
|
||||
for component in components:
|
||||
component.custom_field_data = cf_defaults
|
||||
model.objects.bulk_create(components)
|
||||
components = model.objects.bulk_create(components)
|
||||
# Prefetch related objects to minimize queries needed during post_save
|
||||
prefetch_fields = get_prefetchable_fields(model)
|
||||
prefetch_related_objects(components, *prefetch_fields)
|
||||
# Manually send the post_save signal for each of the newly created components
|
||||
for component in components:
|
||||
post_save.send(
|
||||
@@ -1303,7 +1276,7 @@ class MACAddress(PrimaryModel):
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ('mac_address',)
|
||||
ordering = ('mac_address', 'pk',)
|
||||
verbose_name = _('MAC address')
|
||||
verbose_name_plural = _('MAC addresses')
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from netbox.object_actions import ObjectAction
|
||||
|
||||
__all__ = (
|
||||
'BulkAddComponents',
|
||||
'BulkDisconnect',
|
||||
)
|
||||
|
||||
|
||||
class BulkAddComponents(ObjectAction):
|
||||
"""
|
||||
Add components to the selected devices.
|
||||
"""
|
||||
label = _('Add Components')
|
||||
multi = True
|
||||
permissions_required = {'change'}
|
||||
template_name = 'dcim/buttons/bulk_add_components.html'
|
||||
|
||||
@classmethod
|
||||
def get_context(cls, context, obj):
|
||||
return {
|
||||
'perms': context.get('perms'),
|
||||
'request': context.get('request'),
|
||||
'formaction': context.get('formaction'),
|
||||
'label': cls.label,
|
||||
}
|
||||
|
||||
|
||||
class BulkDisconnect(ObjectAction):
|
||||
"""
|
||||
Disconnect each of a set of objects to which a cable is connected.
|
||||
"""
|
||||
name = 'bulk_disconnect'
|
||||
label = _('Disconnect Selected')
|
||||
multi = True
|
||||
permissions_required = {'change'}
|
||||
template_name = 'dcim/buttons/bulk_disconnect.html'
|
||||
@@ -3,13 +3,28 @@ import logging
|
||||
from django.db.models.signals import post_save, post_delete, pre_delete
|
||||
from django.dispatch import receiver
|
||||
|
||||
from .choices import CableEndChoices, LinkStatusChoices
|
||||
from dcim.choices import CableEndChoices, LinkStatusChoices
|
||||
from .models import (
|
||||
Cable, CablePath, CableTermination, Device, FrontPort, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis,
|
||||
Cable, CablePath, CableTermination, ConsolePort, ConsoleServerPort, Device, DeviceBay, FrontPort, Interface,
|
||||
InventoryItem, ModuleBay, PathEndpoint, PowerOutlet, PowerPanel, PowerPort, Rack, RearPort, Location,
|
||||
VirtualChassis,
|
||||
)
|
||||
from .models.cables import trace_paths
|
||||
from .utils import create_cablepath, rebuild_paths
|
||||
|
||||
COMPONENT_MODELS = (
|
||||
ConsolePort,
|
||||
ConsoleServerPort,
|
||||
DeviceBay,
|
||||
FrontPort,
|
||||
Interface,
|
||||
InventoryItem,
|
||||
ModuleBay,
|
||||
PowerOutlet,
|
||||
PowerPort,
|
||||
RearPort,
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Location/rack/device assignment
|
||||
@@ -39,6 +54,20 @@ def handle_rack_site_change(instance, created, **kwargs):
|
||||
Device.objects.filter(rack=instance).update(site=instance.site, location=instance.location)
|
||||
|
||||
|
||||
@receiver(post_save, sender=Device)
|
||||
def handle_device_site_change(instance, created, **kwargs):
|
||||
"""
|
||||
Update child components to update the parent Site, Location, and Rack when a Device is saved.
|
||||
"""
|
||||
if not created:
|
||||
for model in COMPONENT_MODELS:
|
||||
model.objects.filter(device=instance).update(
|
||||
_site=instance.site,
|
||||
_location=instance.location,
|
||||
_rack=instance.rack,
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Virtual chassis
|
||||
#
|
||||
|
||||
@@ -3,6 +3,7 @@ import svgwrite
|
||||
from svgwrite.container import Hyperlink
|
||||
from svgwrite.image import Image
|
||||
from svgwrite.gradients import LinearGradient
|
||||
from svgwrite.masking import ClipPath
|
||||
from svgwrite.shapes import Rect
|
||||
from svgwrite.text import Text
|
||||
|
||||
@@ -67,6 +68,20 @@ def get_device_description(device):
|
||||
return description
|
||||
|
||||
|
||||
def truncate_text(text, width, font_size=15):
|
||||
"""
|
||||
Truncate text to fit within the width of a rectangle.
|
||||
|
||||
:param text: The text to truncate
|
||||
:param width: Width of rectangle
|
||||
:param font_size: Font size (default is 15, ~0.875rem)
|
||||
"""
|
||||
char_width = font_size * 0.6 # 0.6 is an approximation of the average character width in pixels
|
||||
max_char = int(width / char_width)
|
||||
|
||||
return text if len(text) <= max_char else text[:max_char] + '...'
|
||||
|
||||
|
||||
class RackElevationSVG:
|
||||
"""
|
||||
Use this class to render a rack elevation as an SVG image.
|
||||
@@ -177,12 +192,26 @@ class RackElevationSVG:
|
||||
link = Hyperlink(href=f'{self.base_url}{device.get_absolute_url()}', target="_parent")
|
||||
link.set_desc(description)
|
||||
|
||||
# Create clipPath element
|
||||
# This is necessary as fallback because the truncate_text method is an approximation
|
||||
clip_id = f"clip-{device.id}"
|
||||
clip_path = ClipPath(id=clip_id)
|
||||
clip_path.add(Rect(coords, size))
|
||||
|
||||
self.drawing.defs.add(clip_path)
|
||||
|
||||
# Name to display
|
||||
display_name = truncate_text(name, size[0])
|
||||
|
||||
# Add rect element to hyperlink
|
||||
if color:
|
||||
link.add(Rect(coords, size, style=f'fill: #{color}', class_=f'slot{css_extra}'))
|
||||
else:
|
||||
link.add(Rect(coords, size, class_=f'slot blocked{css_extra}'))
|
||||
link.add(Text(name, insert=text_coords, fill=text_color, class_=f'label{css_extra}'))
|
||||
link.add(
|
||||
Text(display_name, insert=text_coords, fill=text_color, clip_path=f"url(#{clip_id})",
|
||||
class_=f'label{css_extra}')
|
||||
)
|
||||
|
||||
# Embed device type image if provided
|
||||
if self.include_images and image:
|
||||
|
||||
@@ -113,6 +113,10 @@ class CableTable(TenancyColumnsMixin, NetBoxTable):
|
||||
order_by=('_abs_length')
|
||||
)
|
||||
color = columns.ColorColumn()
|
||||
color_name = tables.Column(
|
||||
verbose_name=_('Color Name'),
|
||||
orderable=False
|
||||
)
|
||||
comments = columns.MarkdownColumn()
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:cable_list'
|
||||
@@ -123,7 +127,7 @@ class CableTable(TenancyColumnsMixin, NetBoxTable):
|
||||
fields = (
|
||||
'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'device_a', 'device_b', 'rack_a', 'rack_b',
|
||||
'location_a', 'location_b', 'site_a', 'site_b', 'status', 'type', 'tenant', 'tenant_group', 'color',
|
||||
'length', 'description', 'comments', 'tags', 'created', 'last_updated',
|
||||
'color_name', 'length', 'description', 'comments', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'status', 'type',
|
||||
|
||||
@@ -3367,9 +3367,36 @@ class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
|
||||
ConsoleServerPort.objects.bulk_create(console_server_ports)
|
||||
|
||||
console_ports = (
|
||||
ConsolePort(device=devices[0], module=modules[0], name='Console Port 1', label='A', description='First'),
|
||||
ConsolePort(device=devices[1], module=modules[1], name='Console Port 2', label='B', description='Second'),
|
||||
ConsolePort(device=devices[2], module=modules[2], name='Console Port 3', label='C', description='Third'),
|
||||
ConsolePort(
|
||||
device=devices[0],
|
||||
module=modules[0],
|
||||
name='Console Port 1',
|
||||
label='A',
|
||||
description='First',
|
||||
_site=devices[0].site,
|
||||
_location=devices[0].location,
|
||||
_rack=devices[0].rack,
|
||||
),
|
||||
ConsolePort(
|
||||
device=devices[1],
|
||||
module=modules[1],
|
||||
name='Console Port 2',
|
||||
label='B',
|
||||
description='Second',
|
||||
_site=devices[1].site,
|
||||
_location=devices[1].location,
|
||||
_rack=devices[1].rack,
|
||||
),
|
||||
ConsolePort(
|
||||
device=devices[2],
|
||||
module=modules[2],
|
||||
name='Console Port 3',
|
||||
label='C',
|
||||
description='Third',
|
||||
_site=devices[2].site,
|
||||
_location=devices[2].location,
|
||||
_rack=devices[2].rack,
|
||||
),
|
||||
)
|
||||
ConsolePort.objects.bulk_create(console_ports)
|
||||
|
||||
@@ -3581,13 +3608,34 @@ class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeL
|
||||
|
||||
console_server_ports = (
|
||||
ConsoleServerPort(
|
||||
device=devices[0], module=modules[0], name='Console Server Port 1', label='A', description='First'
|
||||
device=devices[0],
|
||||
module=modules[0],
|
||||
name='Console Server Port 1',
|
||||
label='A',
|
||||
description='First',
|
||||
_site=devices[0].site,
|
||||
_location=devices[0].location,
|
||||
_rack=devices[0].rack,
|
||||
),
|
||||
ConsoleServerPort(
|
||||
device=devices[1], module=modules[1], name='Console Server Port 2', label='B', description='Second'
|
||||
device=devices[1],
|
||||
module=modules[1],
|
||||
name='Console Server Port 2',
|
||||
label='B',
|
||||
description='Second',
|
||||
_site=devices[1].site,
|
||||
_location=devices[1].location,
|
||||
_rack=devices[1].rack,
|
||||
),
|
||||
ConsoleServerPort(
|
||||
device=devices[2], module=modules[2], name='Console Server Port 3', label='C', description='Third'
|
||||
device=devices[2],
|
||||
module=modules[2],
|
||||
name='Console Server Port 3',
|
||||
label='C',
|
||||
description='Third',
|
||||
_site=devices[2].site,
|
||||
_location=devices[2].location,
|
||||
_rack=devices[2].rack,
|
||||
),
|
||||
)
|
||||
ConsoleServerPort.objects.bulk_create(console_server_ports)
|
||||
@@ -3807,6 +3855,9 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
maximum_draw=100,
|
||||
allocated_draw=50,
|
||||
description='First',
|
||||
_site=devices[0].site,
|
||||
_location=devices[0].location,
|
||||
_rack=devices[0].rack,
|
||||
),
|
||||
PowerPort(
|
||||
device=devices[1],
|
||||
@@ -3816,6 +3867,9 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
maximum_draw=200,
|
||||
allocated_draw=100,
|
||||
description='Second',
|
||||
_site=devices[1].site,
|
||||
_location=devices[1].location,
|
||||
_rack=devices[1].rack,
|
||||
),
|
||||
PowerPort(
|
||||
device=devices[2],
|
||||
@@ -3825,6 +3879,9 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
maximum_draw=300,
|
||||
allocated_draw=150,
|
||||
description='Third',
|
||||
_site=devices[2].site,
|
||||
_location=devices[2].location,
|
||||
_rack=devices[2].rack,
|
||||
),
|
||||
)
|
||||
PowerPort.objects.bulk_create(power_ports)
|
||||
@@ -4053,6 +4110,9 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
|
||||
description='First',
|
||||
color='ff0000',
|
||||
status=PowerOutletStatusChoices.STATUS_ENABLED,
|
||||
_site=devices[0].site,
|
||||
_location=devices[0].location,
|
||||
_rack=devices[0].rack,
|
||||
),
|
||||
PowerOutlet(
|
||||
device=devices[1],
|
||||
@@ -4063,6 +4123,9 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
|
||||
description='Second',
|
||||
color='00ff00',
|
||||
status=PowerOutletStatusChoices.STATUS_DISABLED,
|
||||
_site=devices[1].site,
|
||||
_location=devices[1].location,
|
||||
_rack=devices[1].rack,
|
||||
),
|
||||
PowerOutlet(
|
||||
device=devices[2],
|
||||
@@ -4073,6 +4136,9 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
|
||||
description='Third',
|
||||
color='0000ff',
|
||||
status=PowerOutletStatusChoices.STATUS_FAULTY,
|
||||
_site=devices[2].site,
|
||||
_location=devices[2].location,
|
||||
_rack=devices[2].rack,
|
||||
),
|
||||
)
|
||||
PowerOutlet.objects.bulk_create(power_outlets)
|
||||
@@ -4381,13 +4447,19 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
poe_mode=InterfacePoEModeChoices.MODE_PSE,
|
||||
poe_type=InterfacePoETypeChoices.TYPE_1_8023AF,
|
||||
vlan_translation_policy=vlan_translation_policies[0],
|
||||
_site=devices[0].site,
|
||||
_location=devices[0].location,
|
||||
_rack=devices[0].rack,
|
||||
),
|
||||
Interface(
|
||||
device=devices[1],
|
||||
module=modules[1],
|
||||
name='VC Chassis Interface',
|
||||
type=InterfaceTypeChoices.TYPE_1GE_SFP,
|
||||
enabled=True
|
||||
enabled=True,
|
||||
_site=devices[1].site,
|
||||
_location=devices[1].location,
|
||||
_rack=devices[1].rack,
|
||||
),
|
||||
Interface(
|
||||
device=devices[2],
|
||||
@@ -4406,6 +4478,9 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
poe_mode=InterfacePoEModeChoices.MODE_PD,
|
||||
poe_type=InterfacePoETypeChoices.TYPE_1_8023AF,
|
||||
vlan_translation_policy=vlan_translation_policies[0],
|
||||
_site=devices[2].site,
|
||||
_location=devices[2].location,
|
||||
_rack=devices[2].rack,
|
||||
),
|
||||
Interface(
|
||||
device=devices[3],
|
||||
@@ -4424,6 +4499,9 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
poe_mode=InterfacePoEModeChoices.MODE_PSE,
|
||||
poe_type=InterfacePoETypeChoices.TYPE_2_8023AT,
|
||||
vlan_translation_policy=vlan_translation_policies[1],
|
||||
_site=devices[3].site,
|
||||
_location=devices[3].location,
|
||||
_rack=devices[3].rack,
|
||||
),
|
||||
Interface(
|
||||
device=devices[4],
|
||||
@@ -4440,6 +4518,9 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
mode=InterfaceModeChoices.MODE_Q_IN_Q,
|
||||
qinq_svlan=vlans[0],
|
||||
vlan_translation_policy=vlan_translation_policies[1],
|
||||
_site=devices[4].site,
|
||||
_location=devices[4].location,
|
||||
_rack=devices[4].rack,
|
||||
),
|
||||
Interface(
|
||||
device=devices[4],
|
||||
@@ -4450,7 +4531,10 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
mgmt_only=True,
|
||||
tx_power=40,
|
||||
mode=InterfaceModeChoices.MODE_Q_IN_Q,
|
||||
qinq_svlan=vlans[1]
|
||||
qinq_svlan=vlans[1],
|
||||
_site=devices[4].site,
|
||||
_location=devices[4].location,
|
||||
_rack=devices[4].rack,
|
||||
),
|
||||
Interface(
|
||||
device=devices[4],
|
||||
@@ -4461,7 +4545,10 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
mgmt_only=False,
|
||||
tx_power=40,
|
||||
mode=InterfaceModeChoices.MODE_Q_IN_Q,
|
||||
qinq_svlan=vlans[2]
|
||||
qinq_svlan=vlans[2],
|
||||
_site=devices[4].site,
|
||||
_location=devices[4].location,
|
||||
_rack=devices[4].rack,
|
||||
),
|
||||
Interface(
|
||||
device=devices[4],
|
||||
@@ -4470,7 +4557,10 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
rf_role=WirelessRoleChoices.ROLE_AP,
|
||||
rf_channel=WirelessChannelChoices.CHANNEL_24G_1,
|
||||
rf_channel_frequency=2412,
|
||||
rf_channel_width=22
|
||||
rf_channel_width=22,
|
||||
_site=devices[4].site,
|
||||
_location=devices[4].location,
|
||||
_rack=devices[4].rack,
|
||||
),
|
||||
Interface(
|
||||
device=devices[4],
|
||||
@@ -4479,7 +4569,10 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
rf_role=WirelessRoleChoices.ROLE_STATION,
|
||||
rf_channel=WirelessChannelChoices.CHANNEL_5G_32,
|
||||
rf_channel_frequency=5160,
|
||||
rf_channel_width=20
|
||||
rf_channel_width=20,
|
||||
_site=devices[4].site,
|
||||
_location=devices[4].location,
|
||||
_rack=devices[4].rack,
|
||||
),
|
||||
)
|
||||
Interface.objects.bulk_create(interfaces)
|
||||
@@ -4906,6 +4999,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
rear_port=rear_ports[0],
|
||||
rear_port_position=1,
|
||||
description='First',
|
||||
_site=devices[0].site,
|
||||
_location=devices[0].location,
|
||||
_rack=devices[0].rack,
|
||||
),
|
||||
FrontPort(
|
||||
device=devices[1],
|
||||
@@ -4917,6 +5013,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
rear_port=rear_ports[1],
|
||||
rear_port_position=2,
|
||||
description='Second',
|
||||
_site=devices[1].site,
|
||||
_location=devices[1].location,
|
||||
_rack=devices[1].rack,
|
||||
),
|
||||
FrontPort(
|
||||
device=devices[2],
|
||||
@@ -4928,6 +5027,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
rear_port=rear_ports[2],
|
||||
rear_port_position=3,
|
||||
description='Third',
|
||||
_site=devices[2].site,
|
||||
_location=devices[2].location,
|
||||
_rack=devices[2].rack,
|
||||
),
|
||||
FrontPort(
|
||||
device=devices[3],
|
||||
@@ -4936,6 +5038,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
type=PortTypeChoices.TYPE_FC,
|
||||
rear_port=rear_ports[3],
|
||||
rear_port_position=1,
|
||||
_site=devices[3].site,
|
||||
_location=devices[3].location,
|
||||
_rack=devices[3].rack,
|
||||
),
|
||||
FrontPort(
|
||||
device=devices[3],
|
||||
@@ -4944,6 +5049,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
type=PortTypeChoices.TYPE_FC,
|
||||
rear_port=rear_ports[4],
|
||||
rear_port_position=1,
|
||||
_site=devices[3].site,
|
||||
_location=devices[3].location,
|
||||
_rack=devices[3].rack,
|
||||
),
|
||||
FrontPort(
|
||||
device=devices[3],
|
||||
@@ -4952,6 +5060,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
type=PortTypeChoices.TYPE_FC,
|
||||
rear_port=rear_ports[5],
|
||||
rear_port_position=1,
|
||||
_site=devices[3].site,
|
||||
_location=devices[3].location,
|
||||
_rack=devices[3].rack,
|
||||
),
|
||||
)
|
||||
FrontPort.objects.bulk_create(front_ports)
|
||||
@@ -5168,6 +5279,9 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
|
||||
color=ColorChoices.COLOR_RED,
|
||||
positions=1,
|
||||
description='First',
|
||||
_site=devices[0].site,
|
||||
_location=devices[0].location,
|
||||
_rack=devices[0].rack,
|
||||
),
|
||||
RearPort(
|
||||
device=devices[1],
|
||||
@@ -5178,6 +5292,9 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
|
||||
color=ColorChoices.COLOR_GREEN,
|
||||
positions=2,
|
||||
description='Second',
|
||||
_site=devices[1].site,
|
||||
_location=devices[1].location,
|
||||
_rack=devices[1].rack,
|
||||
),
|
||||
RearPort(
|
||||
device=devices[2],
|
||||
@@ -5188,10 +5305,40 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
|
||||
color=ColorChoices.COLOR_BLUE,
|
||||
positions=3,
|
||||
description='Third',
|
||||
_site=devices[2].site,
|
||||
_location=devices[2].location,
|
||||
_rack=devices[2].rack,
|
||||
),
|
||||
RearPort(
|
||||
device=devices[3],
|
||||
name='Rear Port 4',
|
||||
label='D',
|
||||
type=PortTypeChoices.TYPE_FC,
|
||||
positions=4,
|
||||
_site=devices[3].site,
|
||||
_location=devices[3].location,
|
||||
_rack=devices[3].rack,
|
||||
),
|
||||
RearPort(
|
||||
device=devices[3],
|
||||
name='Rear Port 5',
|
||||
label='E',
|
||||
type=PortTypeChoices.TYPE_FC,
|
||||
positions=5,
|
||||
_site=devices[3].site,
|
||||
_location=devices[3].location,
|
||||
_rack=devices[3].rack,
|
||||
),
|
||||
RearPort(
|
||||
device=devices[3],
|
||||
name='Rear Port 6',
|
||||
label='F',
|
||||
type=PortTypeChoices.TYPE_FC,
|
||||
positions=6,
|
||||
_site=devices[3].site,
|
||||
_location=devices[3].location,
|
||||
_rack=devices[3].rack,
|
||||
),
|
||||
RearPort(device=devices[3], name='Rear Port 4', label='D', type=PortTypeChoices.TYPE_FC, positions=4),
|
||||
RearPort(device=devices[3], name='Rear Port 5', label='E', type=PortTypeChoices.TYPE_FC, positions=5),
|
||||
RearPort(device=devices[3], name='Rear Port 6', label='F', type=PortTypeChoices.TYPE_FC, positions=6),
|
||||
)
|
||||
RearPort.objects.bulk_create(rear_ports)
|
||||
|
||||
@@ -5550,9 +5697,33 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
device_bays = (
|
||||
DeviceBay(device=devices[0], name='Device Bay 1', label='A', description='First'),
|
||||
DeviceBay(device=devices[1], name='Device Bay 2', label='B', description='Second'),
|
||||
DeviceBay(device=devices[2], name='Device Bay 3', label='C', description='Third'),
|
||||
DeviceBay(
|
||||
device=devices[0],
|
||||
name='Device Bay 1',
|
||||
label='A',
|
||||
description='First',
|
||||
_site=devices[0].site,
|
||||
_location=devices[0].location,
|
||||
_rack=devices[0].rack,
|
||||
),
|
||||
DeviceBay(
|
||||
device=devices[1],
|
||||
name='Device Bay 2',
|
||||
label='B',
|
||||
description='Second',
|
||||
_site=devices[1].site,
|
||||
_location=devices[1].location,
|
||||
_rack=devices[1].rack,
|
||||
),
|
||||
DeviceBay(
|
||||
device=devices[2],
|
||||
name='Device Bay 3',
|
||||
label='C',
|
||||
description='Third',
|
||||
_site=devices[2].site,
|
||||
_location=devices[2].location,
|
||||
_rack=devices[2].rack,
|
||||
),
|
||||
)
|
||||
DeviceBay.objects.bulk_create(device_bays)
|
||||
|
||||
|
||||
@@ -3266,17 +3266,27 @@ class CableTestCase(
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
sites = (
|
||||
Site(name='Site 1', slug='site-1'),
|
||||
Site(name='Site 2', slug='site-2'),
|
||||
)
|
||||
Site.objects.bulk_create(sites)
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
devicetype = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer)
|
||||
role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
vc = VirtualChassis.objects.create(name='Virtual Chassis')
|
||||
|
||||
# NOTE: By design, NetBox now allows for the creation of devices with the same name if they belong to
|
||||
# different sites.
|
||||
# The CSV test below demonstrates that devices with identical names on different sites can be created
|
||||
# and referenced successfully.
|
||||
devices = (
|
||||
Device(name='Device 1', site=site, device_type=devicetype, role=role),
|
||||
Device(name='Device 2', site=site, device_type=devicetype, role=role),
|
||||
Device(name='Device 3', site=site, device_type=devicetype, role=role),
|
||||
Device(name='Device 4', site=site, device_type=devicetype, role=role),
|
||||
# Create 'Device 1' assigned to 'Site 1'
|
||||
Device(name='Device 1', site=sites[0], device_type=devicetype, role=role),
|
||||
Device(name='Device 2', site=sites[0], device_type=devicetype, role=role),
|
||||
Device(name='Device 3', site=sites[0], device_type=devicetype, role=role),
|
||||
# Create 'Device 1' assigned to 'Site 2' (allowed since the site is different)
|
||||
Device(name='Device 1', site=sites[1], device_type=devicetype, role=role),
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
@@ -3327,13 +3337,15 @@ class CableTestCase(
|
||||
'tags': [t.pk for t in tags],
|
||||
}
|
||||
|
||||
# Ensure that CSV bulk import supports assigning terminations from parent devices that share
|
||||
# the same device name, provided those devices belong to different sites.
|
||||
cls.csv_data = (
|
||||
"side_a_device,side_a_type,side_a_name,side_b_device,side_b_type,side_b_name",
|
||||
"Device 3,dcim.interface,Interface 1,Device 4,dcim.interface,Interface 1",
|
||||
"Device 3,dcim.interface,Interface 2,Device 4,dcim.interface,Interface 2",
|
||||
"Device 3,dcim.interface,Interface 3,Device 4,dcim.interface,Interface 3",
|
||||
"Device 1,dcim.interface,Device 2 Interface,Device 4,dcim.interface,Interface 4",
|
||||
"Device 1,dcim.interface,Device 3 Interface,Device 4,dcim.interface,Interface 5",
|
||||
"side_a_site,side_a_device,side_a_type,side_a_name,side_b_site,side_b_device,side_b_type,side_b_name",
|
||||
"Site 1,Device 3,dcim.interface,Interface 1,Site 2,Device 1,dcim.interface,Interface 1",
|
||||
"Site 1,Device 3,dcim.interface,Interface 2,Site 2,Device 1,dcim.interface,Interface 2",
|
||||
"Site 1,Device 3,dcim.interface,Interface 3,Site 2,Device 1,dcim.interface,Interface 3",
|
||||
"Site 1,Device 1,dcim.interface,Device 2 Interface,Site 2,Device 1,dcim.interface,Interface 4",
|
||||
"Site 1,Device 1,dcim.interface,Device 3 Interface,Site 2,Device 1,dcim.interface,Interface 5",
|
||||
)
|
||||
|
||||
cls.csv_update_data = (
|
||||
|
||||
@@ -15,7 +15,7 @@ from circuits.models import Circuit, CircuitTermination
|
||||
from extras.views import ObjectConfigContextView, ObjectRenderConfigView
|
||||
from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN
|
||||
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
|
||||
from netbox.object_actions import *
|
||||
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
|
||||
from netbox.views import generic
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.paginator import EnhancedPaginator, get_paginate_count
|
||||
@@ -34,7 +34,6 @@ from wireless.models import WirelessLAN
|
||||
from . import filtersets, forms, tables
|
||||
from .choices import DeviceFaceChoices, InterfaceModeChoices
|
||||
from .models import *
|
||||
from .object_actions import BulkAddComponents, BulkDisconnect
|
||||
|
||||
CABLE_TERMINATION_TYPES = {
|
||||
'dcim.consoleport': ConsolePort,
|
||||
@@ -50,6 +49,11 @@ CABLE_TERMINATION_TYPES = {
|
||||
|
||||
|
||||
class DeviceComponentsView(generic.ObjectChildrenView):
|
||||
actions = {
|
||||
**DEFAULT_ACTION_PERMISSIONS,
|
||||
'bulk_rename': {'change'},
|
||||
'bulk_disconnect': {'change'},
|
||||
}
|
||||
queryset = Device.objects.all()
|
||||
|
||||
def get_children(self, request, parent):
|
||||
@@ -57,8 +61,12 @@ class DeviceComponentsView(generic.ObjectChildrenView):
|
||||
|
||||
|
||||
class DeviceTypeComponentsView(generic.ObjectChildrenView):
|
||||
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete)
|
||||
actions = {
|
||||
**DEFAULT_ACTION_PERMISSIONS,
|
||||
'bulk_rename': {'change'},
|
||||
}
|
||||
queryset = DeviceType.objects.all()
|
||||
template_name = 'dcim/devicetype/component_templates.html'
|
||||
viewname = None # Used for return_url resolution
|
||||
|
||||
def get_children(self, request, parent):
|
||||
@@ -70,9 +78,9 @@ class DeviceTypeComponentsView(generic.ObjectChildrenView):
|
||||
}
|
||||
|
||||
|
||||
class ModuleTypeComponentsView(generic.ObjectChildrenView):
|
||||
class ModuleTypeComponentsView(DeviceComponentsView):
|
||||
queryset = ModuleType.objects.all()
|
||||
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete)
|
||||
template_name = 'dcim/moduletype/component_templates.html'
|
||||
viewname = None # Used for return_url resolution
|
||||
|
||||
def get_children(self, request, parent):
|
||||
@@ -292,11 +300,6 @@ class RegionBulkEditView(generic.BulkEditView):
|
||||
form = forms.RegionBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(Region, 'bulk_rename', path='rename', detail=False)
|
||||
class RegionBulkRenameView(generic.BulkRenameView):
|
||||
queryset = Region.objects.all()
|
||||
|
||||
|
||||
@register_model_view(Region, 'bulk_delete', path='delete', detail=False)
|
||||
class RegionBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Region.objects.add_related_count(
|
||||
@@ -423,11 +426,6 @@ class SiteGroupBulkEditView(generic.BulkEditView):
|
||||
form = forms.SiteGroupBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(SiteGroup, 'bulk_rename', path='rename', detail=False)
|
||||
class SiteGroupBulkRenameView(generic.BulkRenameView):
|
||||
queryset = SiteGroup.objects.all()
|
||||
|
||||
|
||||
@register_model_view(SiteGroup, 'bulk_delete', path='delete', detail=False)
|
||||
class SiteGroupBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = SiteGroup.objects.add_related_count(
|
||||
@@ -513,11 +511,6 @@ class SiteBulkEditView(generic.BulkEditView):
|
||||
form = forms.SiteBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(Site, 'bulk_rename', path='rename', detail=False)
|
||||
class SiteBulkRenameView(generic.BulkRenameView):
|
||||
queryset = Site.objects.all()
|
||||
|
||||
|
||||
@register_model_view(Site, 'bulk_delete', path='delete', detail=False)
|
||||
class SiteBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Site.objects.all()
|
||||
@@ -622,11 +615,6 @@ class LocationBulkEditView(generic.BulkEditView):
|
||||
form = forms.LocationBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(Location, 'bulk_rename', path='rename', detail=False)
|
||||
class LocationBulkRenameView(generic.BulkRenameView):
|
||||
queryset = Location.objects.all()
|
||||
|
||||
|
||||
@register_model_view(Location, 'bulk_delete', path='delete', detail=False)
|
||||
class LocationBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Location.objects.add_related_count(
|
||||
@@ -692,11 +680,6 @@ class RackRoleBulkEditView(generic.BulkEditView):
|
||||
form = forms.RackRoleBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(RackRole, 'bulk_rename', path='rename', detail=False)
|
||||
class RackRoleBulkRenameView(generic.BulkRenameView):
|
||||
queryset = RackRole.objects.all()
|
||||
|
||||
|
||||
@register_model_view(RackRole, 'bulk_delete', path='delete', detail=False)
|
||||
class RackRoleBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = RackRole.objects.annotate(
|
||||
@@ -756,12 +739,6 @@ class RackTypeBulkEditView(generic.BulkEditView):
|
||||
form = forms.RackTypeBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(RackType, 'bulk_rename', path='rename', detail=False)
|
||||
class RackTypeBulkRenameView(generic.BulkRenameView):
|
||||
queryset = RackType.objects.all()
|
||||
field_name = 'model'
|
||||
|
||||
|
||||
@register_model_view(RackType, 'bulk_delete', path='delete', detail=False)
|
||||
class RackTypeBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = RackType.objects.all()
|
||||
@@ -941,11 +918,6 @@ class RackBulkEditView(generic.BulkEditView):
|
||||
form = forms.RackBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(Rack, 'bulk_rename', path='rename', detail=False)
|
||||
class RackBulkRenameView(generic.BulkRenameView):
|
||||
queryset = Rack.objects.all()
|
||||
|
||||
|
||||
@register_model_view(Rack, 'bulk_delete', path='delete', detail=False)
|
||||
class RackBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Rack.objects.all()
|
||||
@@ -963,7 +935,6 @@ class RackReservationListView(generic.ObjectListView):
|
||||
filterset = filtersets.RackReservationFilterSet
|
||||
filterset_form = forms.RackReservationFilterForm
|
||||
table = tables.RackReservationTable
|
||||
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
|
||||
|
||||
|
||||
@register_model_view(RackReservation)
|
||||
@@ -1080,11 +1051,6 @@ class ManufacturerBulkEditView(generic.BulkEditView):
|
||||
form = forms.ManufacturerBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(Manufacturer, 'bulk_rename', path='rename', detail=False)
|
||||
class ManufacturerBulkRenameView(generic.BulkRenameView):
|
||||
queryset = Manufacturer.objects.all()
|
||||
|
||||
|
||||
@register_model_view(Manufacturer, 'bulk_delete', path='delete', detail=False)
|
||||
class ManufacturerBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Manufacturer.objects.annotate(
|
||||
@@ -1332,12 +1298,6 @@ class DeviceTypeBulkEditView(generic.BulkEditView):
|
||||
form = forms.DeviceTypeBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(DeviceType, 'bulk_rename', path='rename', detail=False)
|
||||
class DeviceTypeBulkRenameView(generic.BulkRenameView):
|
||||
queryset = DeviceType.objects.all()
|
||||
field_name = 'model'
|
||||
|
||||
|
||||
@register_model_view(DeviceType, 'bulk_delete', path='delete', detail=False)
|
||||
class DeviceTypeBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = DeviceType.objects.annotate(
|
||||
@@ -1394,11 +1354,6 @@ class ModuleTypeProfileBulkEditView(generic.BulkEditView):
|
||||
form = forms.ModuleTypeProfileBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(ModuleTypeProfile, 'bulk_rename', path='rename', detail=False)
|
||||
class ModuleTypeProfileBulkRenameView(generic.BulkRenameView):
|
||||
queryset = ModuleTypeProfile.objects.all()
|
||||
|
||||
|
||||
@register_model_view(ModuleTypeProfile, 'bulk_delete', path='delete', detail=False)
|
||||
class ModuleTypeProfileBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = ModuleTypeProfile.objects.annotate(
|
||||
@@ -1609,11 +1564,6 @@ class ModuleTypeBulkEditView(generic.BulkEditView):
|
||||
form = forms.ModuleTypeBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(ModuleType, 'bulk_rename', path='rename', detail=False)
|
||||
class ModuleTypeBulkRenameView(generic.BulkRenameView):
|
||||
queryset = ModuleType.objects.all()
|
||||
|
||||
|
||||
@register_model_view(ModuleType, 'bulk_delete', path='delete', detail=False)
|
||||
class ModuleTypeBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = ModuleType.objects.annotate(
|
||||
@@ -2088,11 +2038,6 @@ class DeviceRoleBulkEditView(generic.BulkEditView):
|
||||
form = forms.DeviceRoleBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(DeviceRole, 'bulk_rename', path='rename', detail=False)
|
||||
class DeviceRoleBulkRenameView(generic.BulkRenameView):
|
||||
queryset = DeviceRole.objects.all()
|
||||
|
||||
|
||||
@register_model_view(DeviceRole, 'bulk_delete', path='delete', detail=False)
|
||||
class DeviceRoleBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = DeviceRole.objects.annotate(
|
||||
@@ -2154,11 +2099,6 @@ class PlatformBulkEditView(generic.BulkEditView):
|
||||
form = forms.PlatformBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(Platform, 'bulk_rename', path='rename', detail=False)
|
||||
class PlatformBulkRenameView(generic.BulkRenameView):
|
||||
queryset = Platform.objects.all()
|
||||
|
||||
|
||||
@register_model_view(Platform, 'bulk_delete', path='delete', detail=False)
|
||||
class PlatformBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Platform.objects.all()
|
||||
@@ -2176,7 +2116,7 @@ class DeviceListView(generic.ObjectListView):
|
||||
filterset = filtersets.DeviceFilterSet
|
||||
filterset_form = forms.DeviceFilterForm
|
||||
table = tables.DeviceTable
|
||||
actions = (AddObject, BulkImport, BulkExport, BulkAddComponents, BulkEdit, BulkRename, BulkDelete)
|
||||
template_name = 'dcim/device_list.html'
|
||||
|
||||
|
||||
@register_model_view(Device)
|
||||
@@ -2217,7 +2157,7 @@ class DeviceConsolePortsView(DeviceComponentsView):
|
||||
table = tables.DeviceConsolePortTable
|
||||
filterset = filtersets.ConsolePortFilterSet
|
||||
filterset_form = forms.ConsolePortFilterForm
|
||||
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
|
||||
template_name = 'dcim/device/consoleports.html',
|
||||
tab = ViewTab(
|
||||
label=_('Console Ports'),
|
||||
badge=lambda obj: obj.console_port_count,
|
||||
@@ -2233,7 +2173,7 @@ class DeviceConsoleServerPortsView(DeviceComponentsView):
|
||||
table = tables.DeviceConsoleServerPortTable
|
||||
filterset = filtersets.ConsoleServerPortFilterSet
|
||||
filterset_form = forms.ConsoleServerPortFilterForm
|
||||
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
|
||||
template_name = 'dcim/device/consoleserverports.html'
|
||||
tab = ViewTab(
|
||||
label=_('Console Server Ports'),
|
||||
badge=lambda obj: obj.console_server_port_count,
|
||||
@@ -2249,7 +2189,7 @@ class DevicePowerPortsView(DeviceComponentsView):
|
||||
table = tables.DevicePowerPortTable
|
||||
filterset = filtersets.PowerPortFilterSet
|
||||
filterset_form = forms.PowerPortFilterForm
|
||||
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
|
||||
template_name = 'dcim/device/powerports.html'
|
||||
tab = ViewTab(
|
||||
label=_('Power Ports'),
|
||||
badge=lambda obj: obj.power_port_count,
|
||||
@@ -2265,7 +2205,7 @@ class DevicePowerOutletsView(DeviceComponentsView):
|
||||
table = tables.DevicePowerOutletTable
|
||||
filterset = filtersets.PowerOutletFilterSet
|
||||
filterset_form = forms.PowerOutletFilterForm
|
||||
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
|
||||
template_name = 'dcim/device/poweroutlets.html'
|
||||
tab = ViewTab(
|
||||
label=_('Power Outlets'),
|
||||
badge=lambda obj: obj.power_outlet_count,
|
||||
@@ -2281,7 +2221,6 @@ class DeviceInterfacesView(DeviceComponentsView):
|
||||
table = tables.DeviceInterfaceTable
|
||||
filterset = filtersets.InterfaceFilterSet
|
||||
filterset_form = forms.InterfaceFilterForm
|
||||
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
|
||||
template_name = 'dcim/device/interfaces.html'
|
||||
tab = ViewTab(
|
||||
label=_('Interfaces'),
|
||||
@@ -2304,7 +2243,7 @@ class DeviceFrontPortsView(DeviceComponentsView):
|
||||
table = tables.DeviceFrontPortTable
|
||||
filterset = filtersets.FrontPortFilterSet
|
||||
filterset_form = forms.FrontPortFilterForm
|
||||
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
|
||||
template_name = 'dcim/device/frontports.html'
|
||||
tab = ViewTab(
|
||||
label=_('Front Ports'),
|
||||
badge=lambda obj: obj.front_port_count,
|
||||
@@ -2320,7 +2259,7 @@ class DeviceRearPortsView(DeviceComponentsView):
|
||||
table = tables.DeviceRearPortTable
|
||||
filterset = filtersets.RearPortFilterSet
|
||||
filterset_form = forms.RearPortFilterForm
|
||||
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
|
||||
template_name = 'dcim/device/rearports.html'
|
||||
tab = ViewTab(
|
||||
label=_('Rear Ports'),
|
||||
badge=lambda obj: obj.rear_port_count,
|
||||
@@ -2336,7 +2275,11 @@ class DeviceModuleBaysView(DeviceComponentsView):
|
||||
table = tables.DeviceModuleBayTable
|
||||
filterset = filtersets.ModuleBayFilterSet
|
||||
filterset_form = forms.ModuleBayFilterForm
|
||||
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete)
|
||||
template_name = 'dcim/device/modulebays.html'
|
||||
actions = {
|
||||
**DEFAULT_ACTION_PERMISSIONS,
|
||||
'bulk_rename': {'change'},
|
||||
}
|
||||
tab = ViewTab(
|
||||
label=_('Module Bays'),
|
||||
badge=lambda obj: obj.module_bay_count,
|
||||
@@ -2352,7 +2295,11 @@ class DeviceDeviceBaysView(DeviceComponentsView):
|
||||
table = tables.DeviceDeviceBayTable
|
||||
filterset = filtersets.DeviceBayFilterSet
|
||||
filterset_form = forms.DeviceBayFilterForm
|
||||
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete)
|
||||
template_name = 'dcim/device/devicebays.html'
|
||||
actions = {
|
||||
**DEFAULT_ACTION_PERMISSIONS,
|
||||
'bulk_rename': {'change'},
|
||||
}
|
||||
tab = ViewTab(
|
||||
label=_('Device Bays'),
|
||||
badge=lambda obj: obj.device_bay_count,
|
||||
@@ -2368,7 +2315,11 @@ class DeviceInventoryView(DeviceComponentsView):
|
||||
table = tables.DeviceInventoryItemTable
|
||||
filterset = filtersets.InventoryItemFilterSet
|
||||
filterset_form = forms.InventoryItemFilterForm
|
||||
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete)
|
||||
template_name = 'dcim/device/inventory.html'
|
||||
actions = {
|
||||
**DEFAULT_ACTION_PERMISSIONS,
|
||||
'bulk_rename': {'change'},
|
||||
}
|
||||
tab = ViewTab(
|
||||
label=_('Inventory Items'),
|
||||
badge=lambda obj: obj.inventory_item_count,
|
||||
@@ -2442,16 +2393,16 @@ class DeviceBulkEditView(generic.BulkEditView):
|
||||
form = forms.DeviceBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(Device, 'bulk_rename', path='rename', detail=False)
|
||||
class DeviceBulkRenameView(generic.BulkRenameView):
|
||||
queryset = Device.objects.all()
|
||||
@register_model_view(Device, 'bulk_delete', path='delete', detail=False)
|
||||
class DeviceBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Device.objects.prefetch_related('device_type__manufacturer')
|
||||
filterset = filtersets.DeviceFilterSet
|
||||
table = tables.DeviceTable
|
||||
|
||||
|
||||
@register_model_view(Device, 'bulk_delete', path='delete', detail=False)
|
||||
class DeviceBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Device.objects.prefetch_related('device_type__manufacturer')
|
||||
@register_model_view(Device, 'bulk_rename', path='rename', detail=False)
|
||||
class DeviceBulkRenameView(generic.BulkRenameView):
|
||||
queryset = Device.objects.all()
|
||||
filterset = filtersets.DeviceFilterSet
|
||||
table = tables.DeviceTable
|
||||
|
||||
@@ -2466,7 +2417,6 @@ class ModuleListView(generic.ObjectListView):
|
||||
filterset = filtersets.ModuleFilterSet
|
||||
filterset_form = forms.ModuleFilterForm
|
||||
table = tables.ModuleTable
|
||||
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
|
||||
|
||||
|
||||
@register_model_view(Module)
|
||||
@@ -2522,6 +2472,11 @@ class ConsolePortListView(generic.ObjectListView):
|
||||
filterset = filtersets.ConsolePortFilterSet
|
||||
filterset_form = forms.ConsolePortFilterForm
|
||||
table = tables.ConsolePortTable
|
||||
template_name = 'dcim/component_list.html'
|
||||
actions = {
|
||||
**DEFAULT_ACTION_PERMISSIONS,
|
||||
'bulk_rename': {'change'},
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(ConsolePort)
|
||||
@@ -2592,6 +2547,11 @@ class ConsoleServerPortListView(generic.ObjectListView):
|
||||
filterset = filtersets.ConsoleServerPortFilterSet
|
||||
filterset_form = forms.ConsoleServerPortFilterForm
|
||||
table = tables.ConsoleServerPortTable
|
||||
template_name = 'dcim/component_list.html'
|
||||
actions = {
|
||||
**DEFAULT_ACTION_PERMISSIONS,
|
||||
'bulk_rename': {'change'},
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(ConsoleServerPort)
|
||||
@@ -2662,6 +2622,11 @@ class PowerPortListView(generic.ObjectListView):
|
||||
filterset = filtersets.PowerPortFilterSet
|
||||
filterset_form = forms.PowerPortFilterForm
|
||||
table = tables.PowerPortTable
|
||||
template_name = 'dcim/component_list.html'
|
||||
actions = {
|
||||
**DEFAULT_ACTION_PERMISSIONS,
|
||||
'bulk_rename': {'change'},
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(PowerPort)
|
||||
@@ -2732,6 +2697,11 @@ class PowerOutletListView(generic.ObjectListView):
|
||||
filterset = filtersets.PowerOutletFilterSet
|
||||
filterset_form = forms.PowerOutletFilterForm
|
||||
table = tables.PowerOutletTable
|
||||
template_name = 'dcim/component_list.html'
|
||||
actions = {
|
||||
**DEFAULT_ACTION_PERMISSIONS,
|
||||
'bulk_rename': {'change'},
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(PowerOutlet)
|
||||
@@ -2802,6 +2772,11 @@ class InterfaceListView(generic.ObjectListView):
|
||||
filterset = filtersets.InterfaceFilterSet
|
||||
filterset_form = forms.InterfaceFilterForm
|
||||
table = tables.InterfaceTable
|
||||
template_name = 'dcim/component_list.html'
|
||||
actions = {
|
||||
**DEFAULT_ACTION_PERMISSIONS,
|
||||
'bulk_rename': {'change'},
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(Interface)
|
||||
@@ -2945,6 +2920,11 @@ class FrontPortListView(generic.ObjectListView):
|
||||
filterset = filtersets.FrontPortFilterSet
|
||||
filterset_form = forms.FrontPortFilterForm
|
||||
table = tables.FrontPortTable
|
||||
template_name = 'dcim/component_list.html'
|
||||
actions = {
|
||||
**DEFAULT_ACTION_PERMISSIONS,
|
||||
'bulk_rename': {'change'},
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(FrontPort)
|
||||
@@ -3015,6 +2995,11 @@ class RearPortListView(generic.ObjectListView):
|
||||
filterset = filtersets.RearPortFilterSet
|
||||
filterset_form = forms.RearPortFilterForm
|
||||
table = tables.RearPortTable
|
||||
template_name = 'dcim/component_list.html'
|
||||
actions = {
|
||||
**DEFAULT_ACTION_PERMISSIONS,
|
||||
'bulk_rename': {'change'},
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(RearPort)
|
||||
@@ -3085,6 +3070,11 @@ class ModuleBayListView(generic.ObjectListView):
|
||||
filterset = filtersets.ModuleBayFilterSet
|
||||
filterset_form = forms.ModuleBayFilterForm
|
||||
table = tables.ModuleBayTable
|
||||
template_name = 'dcim/component_list.html'
|
||||
actions = {
|
||||
**DEFAULT_ACTION_PERMISSIONS,
|
||||
'bulk_rename': {'change'},
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(ModuleBay)
|
||||
@@ -3146,6 +3136,11 @@ class DeviceBayListView(generic.ObjectListView):
|
||||
filterset = filtersets.DeviceBayFilterSet
|
||||
filterset_form = forms.DeviceBayFilterForm
|
||||
table = tables.DeviceBayTable
|
||||
template_name = 'dcim/component_list.html'
|
||||
actions = {
|
||||
**DEFAULT_ACTION_PERMISSIONS,
|
||||
'bulk_rename': {'change'},
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(DeviceBay)
|
||||
@@ -3288,6 +3283,11 @@ class InventoryItemListView(generic.ObjectListView):
|
||||
filterset = filtersets.InventoryItemFilterSet
|
||||
filterset_form = forms.InventoryItemFilterForm
|
||||
table = tables.InventoryItemTable
|
||||
template_name = 'dcim/component_list.html'
|
||||
actions = {
|
||||
**DEFAULT_ACTION_PERMISSIONS,
|
||||
'bulk_rename': {'change'},
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(InventoryItem)
|
||||
@@ -3410,11 +3410,6 @@ class InventoryItemRoleBulkEditView(generic.BulkEditView):
|
||||
form = forms.InventoryItemRoleBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(InventoryItemRole, 'bulk_rename', path='rename', detail=False)
|
||||
class InventoryItemRoleBulkRenameView(generic.BulkRenameView):
|
||||
queryset = InventoryItemRole.objects.all()
|
||||
|
||||
|
||||
@register_model_view(InventoryItemRole, 'bulk_delete', path='delete', detail=False)
|
||||
class InventoryItemRoleBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = InventoryItemRole.objects.annotate(
|
||||
@@ -3612,12 +3607,6 @@ class CableBulkEditView(generic.BulkEditView):
|
||||
form = forms.CableBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(Cable, 'bulk_rename', path='rename', detail=False)
|
||||
class CableBulkRenameView(generic.BulkRenameView):
|
||||
queryset = Cable.objects.all()
|
||||
field_name = 'label'
|
||||
|
||||
|
||||
@register_model_view(Cable, 'bulk_delete', path='delete', detail=False)
|
||||
class CableBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Cable.objects.prefetch_related(
|
||||
@@ -3638,7 +3627,9 @@ class ConsoleConnectionsListView(generic.ObjectListView):
|
||||
filterset_form = forms.ConsoleConnectionFilterForm
|
||||
table = tables.ConsoleConnectionTable
|
||||
template_name = 'dcim/connections_list.html'
|
||||
actions = (BulkExport,)
|
||||
actions = {
|
||||
'export': {'view'},
|
||||
}
|
||||
|
||||
def get_extra_context(self, request):
|
||||
return {
|
||||
@@ -3652,7 +3643,9 @@ class PowerConnectionsListView(generic.ObjectListView):
|
||||
filterset_form = forms.PowerConnectionFilterForm
|
||||
table = tables.PowerConnectionTable
|
||||
template_name = 'dcim/connections_list.html'
|
||||
actions = (BulkExport,)
|
||||
actions = {
|
||||
'export': {'view'},
|
||||
}
|
||||
|
||||
def get_extra_context(self, request):
|
||||
return {
|
||||
@@ -3666,7 +3659,9 @@ class InterfaceConnectionsListView(generic.ObjectListView):
|
||||
filterset_form = forms.InterfaceConnectionFilterForm
|
||||
table = tables.InterfaceConnectionTable
|
||||
template_name = 'dcim/connections_list.html'
|
||||
actions = (BulkExport,)
|
||||
actions = {
|
||||
'export': {'view'},
|
||||
}
|
||||
|
||||
def get_extra_context(self, request):
|
||||
return {
|
||||
@@ -3910,11 +3905,6 @@ class VirtualChassisBulkEditView(generic.BulkEditView):
|
||||
form = forms.VirtualChassisBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(VirtualChassis, 'bulk_rename', path='rename', detail=False)
|
||||
class VirtualChassisBulkRenameView(generic.BulkRenameView):
|
||||
queryset = VirtualChassis.objects.all()
|
||||
|
||||
|
||||
@register_model_view(VirtualChassis, 'bulk_delete', path='delete', detail=False)
|
||||
class VirtualChassisBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = VirtualChassis.objects.all()
|
||||
@@ -3972,11 +3962,6 @@ class PowerPanelBulkEditView(generic.BulkEditView):
|
||||
form = forms.PowerPanelBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(PowerPanel, 'bulk_rename', path='rename', detail=False)
|
||||
class PowerPanelBulkRenameView(generic.BulkRenameView):
|
||||
queryset = PowerPanel.objects.all()
|
||||
|
||||
|
||||
@register_model_view(PowerPanel, 'bulk_delete', path='delete', detail=False)
|
||||
class PowerPanelBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = PowerPanel.objects.annotate(
|
||||
@@ -4029,11 +4014,6 @@ class PowerFeedBulkEditView(generic.BulkEditView):
|
||||
form = forms.PowerFeedBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(PowerFeed, 'bulk_rename', path='rename', detail=False)
|
||||
class PowerFeedBulkRenameView(generic.BulkRenameView):
|
||||
queryset = PowerFeed.objects.all()
|
||||
|
||||
|
||||
@register_model_view(PowerFeed, 'bulk_disconnect', path='disconnect', detail=False)
|
||||
class PowerFeedBulkDisconnectView(BulkDisconnectView):
|
||||
queryset = PowerFeed.objects.all()
|
||||
@@ -4062,7 +4042,6 @@ class VirtualDeviceContextListView(generic.ObjectListView):
|
||||
filterset = filtersets.VirtualDeviceContextFilterSet
|
||||
filterset_form = forms.VirtualDeviceContextFilterForm
|
||||
table = tables.VirtualDeviceContextTable
|
||||
actions = (AddObject, BulkImport, BulkEdit, BulkRename, BulkExport, BulkDelete)
|
||||
|
||||
|
||||
@register_model_view(VirtualDeviceContext)
|
||||
@@ -4107,11 +4086,6 @@ class VirtualDeviceContextBulkEditView(generic.BulkEditView):
|
||||
form = forms.VirtualDeviceContextBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(VirtualDeviceContext, 'bulk_rename', path='rename', detail=False)
|
||||
class VirtualDeviceContextBulkRenameView(generic.BulkRenameView):
|
||||
queryset = VirtualDeviceContext.objects.all()
|
||||
|
||||
|
||||
@register_model_view(VirtualDeviceContext, 'bulk_delete', path='delete', detail=False)
|
||||
class VirtualDeviceContextBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = VirtualDeviceContext.objects.all()
|
||||
@@ -4129,7 +4103,6 @@ class MACAddressListView(generic.ObjectListView):
|
||||
filterset = filtersets.MACAddressFilterSet
|
||||
filterset_form = forms.MACAddressFilterForm
|
||||
table = tables.MACAddressTable
|
||||
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
|
||||
|
||||
|
||||
@register_model_view(MACAddress)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from .serializers_.objecttypes import *
|
||||
from .serializers_.attachments import *
|
||||
from .serializers_.bookmarks import *
|
||||
from .serializers_.customfields import *
|
||||
|
||||
16
netbox/extras/api/serializers_/objecttypes.py
Normal file
16
netbox/extras/api/serializers_/objecttypes.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from core.models import ObjectType
|
||||
from netbox.api.serializers import BaseModelSerializer
|
||||
|
||||
__all__ = (
|
||||
'ObjectTypeSerializer',
|
||||
)
|
||||
|
||||
|
||||
class ObjectTypeSerializer(BaseModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:objecttype-detail')
|
||||
|
||||
class Meta:
|
||||
model = ObjectType
|
||||
fields = ['id', 'url', 'display', 'app_label', 'model']
|
||||
@@ -1,6 +1,5 @@
|
||||
from django.urls import include, path
|
||||
|
||||
from core.api.views import ObjectTypeViewSet
|
||||
from netbox.api.routers import NetBoxRouter
|
||||
from . import views
|
||||
|
||||
@@ -27,9 +26,7 @@ router.register('journal-entries', views.JournalEntryViewSet)
|
||||
router.register('config-contexts', views.ConfigContextViewSet)
|
||||
router.register('config-templates', views.ConfigTemplateViewSet)
|
||||
router.register('scripts', views.ScriptViewSet, basename='script')
|
||||
|
||||
# TODO: Remove in NetBox v4.5
|
||||
router.register('object-types', ObjectTypeViewSet)
|
||||
router.register('object-types', views.ObjectTypeViewSet)
|
||||
|
||||
app_name = 'extras-api'
|
||||
urlpatterns = [
|
||||
|
||||
@@ -10,9 +10,10 @@ from rest_framework.mixins import ListModelMixin, RetrieveModelMixin
|
||||
from rest_framework.renderers import JSONRenderer
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.routers import APIRootView
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
|
||||
from rq import Worker
|
||||
|
||||
from core.models import ObjectType
|
||||
from extras import filtersets
|
||||
from extras.jobs import ScriptJob
|
||||
from extras.models import *
|
||||
@@ -184,7 +185,9 @@ class TagViewSet(NetBoxModelViewSet):
|
||||
|
||||
|
||||
class TaggedItemViewSet(RetrieveModelMixin, ListModelMixin, BaseViewSet):
|
||||
queryset = TaggedItem.objects.prefetch_related('content_type', 'content_object', 'tag')
|
||||
queryset = TaggedItem.objects.prefetch_related(
|
||||
'content_type', 'content_object', 'tag'
|
||||
).order_by('tag__weight', 'tag__name')
|
||||
serializer_class = serializers.TaggedItemSerializer
|
||||
filterset_class = filtersets.TaggedItemFilterSet
|
||||
|
||||
@@ -313,6 +316,20 @@ class ScriptViewSet(ModelViewSet):
|
||||
return Response(input_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
#
|
||||
# Object types
|
||||
#
|
||||
|
||||
class ObjectTypeViewSet(ReadOnlyModelViewSet):
|
||||
"""
|
||||
Read-only list of ObjectTypes.
|
||||
"""
|
||||
permission_classes = [IsAuthenticatedOrLoginNotRequired]
|
||||
queryset = ObjectType.objects.order_by('app_label', 'model')
|
||||
serializer_class = serializers.ObjectTypeSerializer
|
||||
filterset_class = filtersets.ObjectTypeFilterSet
|
||||
|
||||
|
||||
#
|
||||
# User dashboard
|
||||
#
|
||||
|
||||
@@ -21,6 +21,12 @@ WEBHOOK_EVENT_TYPES = {
|
||||
JOB_ERRORED: 'job_ended',
|
||||
}
|
||||
|
||||
# Jinja environment parameters which support path imports
|
||||
JINJA_ENV_PARAMS_WITH_PATH_IMPORT = (
|
||||
'undefined',
|
||||
'finalize',
|
||||
)
|
||||
|
||||
# Dashboard
|
||||
DEFAULT_DASHBOARD = [
|
||||
{
|
||||
|
||||
@@ -309,6 +309,7 @@ class RSSFeedWidget(DashboardWidget):
|
||||
default_config = {
|
||||
'max_entries': 10,
|
||||
'cache_timeout': 3600, # seconds
|
||||
'request_timeout': 3, # seconds
|
||||
'requires_internet': True,
|
||||
}
|
||||
description = _('Embed an RSS feed from an external website.')
|
||||
@@ -335,6 +336,12 @@ class RSSFeedWidget(DashboardWidget):
|
||||
max_value=86400, # 24 hours
|
||||
help_text=_('How long to stored the cached content (in seconds)')
|
||||
)
|
||||
request_timeout = forms.IntegerField(
|
||||
min_value=1,
|
||||
max_value=60,
|
||||
required=False,
|
||||
help_text=_('Timeout value for fetching the feed (in seconds)')
|
||||
)
|
||||
|
||||
def render(self, request):
|
||||
return render_to_string(self.template_name, {
|
||||
@@ -366,7 +373,7 @@ class RSSFeedWidget(DashboardWidget):
|
||||
url=self.config['feed_url'],
|
||||
headers={'User-Agent': f'NetBox/{settings.RELEASE.version}'},
|
||||
proxies=resolve_proxies(url=self.config['feed_url'], context={'client': self}),
|
||||
timeout=3
|
||||
timeout=self.config.get('request_timeout', 3),
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.RequestException as e:
|
||||
|
||||
@@ -29,6 +29,7 @@ __all__ = (
|
||||
'JournalEntryFilterSet',
|
||||
'LocalConfigContextFilterSet',
|
||||
'NotificationGroupFilterSet',
|
||||
'ObjectTypeFilterSet',
|
||||
'SavedFilterFilterSet',
|
||||
'ScriptFilterSet',
|
||||
'TableConfigFilterSet',
|
||||
@@ -787,3 +788,26 @@ class LocalConfigContextFilterSet(django_filters.FilterSet):
|
||||
|
||||
def _local_context_data(self, queryset, name, value):
|
||||
return queryset.exclude(local_context_data__isnull=value)
|
||||
|
||||
|
||||
#
|
||||
# ContentTypes
|
||||
#
|
||||
|
||||
class ObjectTypeFilterSet(django_filters.FilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label=_('Search'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ObjectType
|
||||
fields = ('id', 'app_label', 'model')
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(app_label__icontains=value) |
|
||||
Q(model__icontains=value)
|
||||
)
|
||||
|
||||
@@ -90,10 +90,7 @@ class ScriptJob(JobRunner):
|
||||
request: The WSGI request associated with this execution (if any)
|
||||
commit: Passed through to Script.run()
|
||||
"""
|
||||
script_model = ScriptModel.objects.get(pk=self.job.object_id)
|
||||
self.logger.debug(f"Found ScriptModel ID {script_model.pk}")
|
||||
script = script_model.python_class()
|
||||
self.logger.debug(f"Loaded script {script.full_name}")
|
||||
script = ScriptModel.objects.get(pk=self.job.object_id).python_class()
|
||||
|
||||
# Add files to form data
|
||||
if request:
|
||||
@@ -103,7 +100,6 @@ class ScriptJob(JobRunner):
|
||||
|
||||
# Add the current request as a property of the script
|
||||
script.request = request
|
||||
self.logger.debug(f"Request ID: {request.id}")
|
||||
|
||||
# Execute the script. If commit is True, wrap it with the event_tracking context manager to ensure we process
|
||||
# change logging, event rules, etc.
|
||||
|
||||
@@ -14,16 +14,9 @@ from utilities.proxy import resolve_proxies
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Perform nightly housekeeping tasks [DEPRECATED]"
|
||||
help = "Perform nightly housekeeping tasks. (This command can be run at any time.)"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.stdout.write(
|
||||
"Running this command is no longer necessary: All housekeeping tasks\n"
|
||||
"are addressed automatically via NetBox's built-in job scheduler. It\n"
|
||||
"will be removed in a future release.",
|
||||
self.style.WARNING
|
||||
)
|
||||
|
||||
config = Config()
|
||||
|
||||
# Clear expired authentication sessions (essentially replicating the `clearsessions` command)
|
||||
|
||||
@@ -2,16 +2,17 @@ import importlib.abc
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
|
||||
from django.core.files.storage import storages
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.http import HttpResponse
|
||||
from django.utils.module_loading import import_string
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from extras.constants import DEFAULT_MIME_TYPE
|
||||
from extras.constants import DEFAULT_MIME_TYPE, JINJA_ENV_PARAMS_WITH_PATH_IMPORT
|
||||
from extras.utils import filename_from_model, filename_from_object
|
||||
from utilities.jinja2 import render_jinja2
|
||||
|
||||
|
||||
__all__ = (
|
||||
'PythonModuleMixin',
|
||||
'RenderTemplateMixin',
|
||||
@@ -125,12 +126,22 @@ class RenderTemplateMixin(models.Model):
|
||||
class_name=self.__class__
|
||||
))
|
||||
|
||||
def get_environment_params(self):
|
||||
"""
|
||||
Pre-processing of any defined Jinja environment parameters (e.g. to support path resolution).
|
||||
"""
|
||||
params = self.environment_params or {}
|
||||
for name, value in params.items():
|
||||
if name in JINJA_ENV_PARAMS_WITH_PATH_IMPORT and type(value) is str:
|
||||
params[name] = import_string(value)
|
||||
return params
|
||||
|
||||
def render(self, context=None, queryset=None):
|
||||
"""
|
||||
Render the template with the provided context. The context is passed to the Jinja2 environment as a dictionary.
|
||||
"""
|
||||
context = self.get_context(context=context, queryset=queryset)
|
||||
env_params = self.environment_params or {}
|
||||
env_params = self.get_environment_params()
|
||||
output = render_jinja2(self.template_code, context, env_params, getattr(self, 'data_file', None))
|
||||
|
||||
# Replace CRLF-style line terminators
|
||||
|
||||
@@ -849,6 +849,9 @@ class Bookmark(models.Model):
|
||||
return str(self.object)
|
||||
return super().__str__()
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('account:bookmarks')
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
|
||||
@@ -83,3 +83,6 @@ class TaggedItem(GenericTaggedItemBase):
|
||||
indexes = [models.Index(fields=["content_type", "object_id"])]
|
||||
verbose_name = _('tagged item')
|
||||
verbose_name_plural = _('tagged items')
|
||||
# Note: while there is no ordering applied here (because it would basically be done on fields
|
||||
# of the related `tag`), there is an ordering applied to extras.api.views.TaggedItemViewSet
|
||||
# to allow for proper pagination.
|
||||
|
||||
@@ -317,11 +317,12 @@ class TableConfigTable(NetBoxTable):
|
||||
|
||||
class BookmarkTable(NetBoxTable):
|
||||
object_type = columns.ContentTypeColumn(
|
||||
verbose_name=_('Object Types'),
|
||||
verbose_name=_('Object Type'),
|
||||
)
|
||||
object = tables.Column(
|
||||
verbose_name=_('Object'),
|
||||
linkify=True
|
||||
linkify=True,
|
||||
orderable=False
|
||||
)
|
||||
actions = columns.ActionsColumn(
|
||||
actions=('delete',)
|
||||
|
||||
@@ -3,6 +3,7 @@ import datetime
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import make_aware, now
|
||||
from rest_framework import status
|
||||
|
||||
from core.choices import ManagedFileRootPathChoices
|
||||
from core.events import *
|
||||
@@ -920,6 +921,22 @@ class CreatedUpdatedFilterTest(APITestCase):
|
||||
self.assertEqual(response.data['results'][0]['id'], rack2.pk)
|
||||
|
||||
|
||||
class ObjectTypeTest(APITestCase):
|
||||
|
||||
def test_list_objects(self):
|
||||
object_type_count = ObjectType.objects.count()
|
||||
|
||||
response = self.client.get(reverse('extras-api:objecttype-list'), **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data['count'], object_type_count)
|
||||
|
||||
def test_get_object(self):
|
||||
object_type = ObjectType.objects.first()
|
||||
|
||||
url = reverse('extras-api:objecttype-detail', kwargs={'pk': object_type.pk})
|
||||
self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_200_OK)
|
||||
|
||||
|
||||
class SubscriptionTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Subscription
|
||||
brief_fields = ['display', 'id', 'object_id', 'object_type', 'url', 'user']
|
||||
|
||||
@@ -807,3 +807,21 @@ class NotificationTestCase(
|
||||
|
||||
def test_list_objects_with_constrained_permission(self):
|
||||
return
|
||||
|
||||
|
||||
class ScriptListViewTest(TestCase):
|
||||
user_permissions = ['extras.view_script']
|
||||
|
||||
def test_script_list_embedded_parameter(self):
|
||||
"""Test that ScriptListView accepts embedded parameter without error"""
|
||||
url = reverse('extras:script_list')
|
||||
|
||||
# Test normal request
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTemplateUsed(response, 'extras/script_list.html')
|
||||
|
||||
# Test embedded request
|
||||
response = self.client.get(url, {'embedded': 'true'})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTemplateUsed(response, 'extras/inc/script_list_content.html')
|
||||
|
||||
@@ -14,13 +14,12 @@ from jinja2.exceptions import TemplateError
|
||||
|
||||
from core.choices import ManagedFileRootPathChoices
|
||||
from core.models import Job
|
||||
from core.object_actions import BulkSync
|
||||
from dcim.models import Device, DeviceRole, Platform
|
||||
from extras.choices import LogLevelChoices
|
||||
from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
|
||||
from extras.dashboard.utils import get_widget_class
|
||||
from extras.utils import SharedObjectViewMixin
|
||||
from netbox.object_actions import *
|
||||
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
|
||||
from netbox.views import generic
|
||||
from netbox.views.generic.mixins import TableMixin
|
||||
from utilities.forms import ConfirmationForm, get_field_value
|
||||
@@ -97,11 +96,6 @@ class CustomFieldBulkEditView(generic.BulkEditView):
|
||||
form = forms.CustomFieldBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(CustomField, 'bulk_rename', path='rename', detail=False)
|
||||
class CustomFieldBulkRenameView(generic.BulkRenameView):
|
||||
queryset = CustomField.objects.all()
|
||||
|
||||
|
||||
@register_model_view(CustomField, 'bulk_delete', path='delete', detail=False)
|
||||
class CustomFieldBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = CustomField.objects.select_related('choice_set')
|
||||
@@ -171,11 +165,6 @@ class CustomFieldChoiceSetBulkEditView(generic.BulkEditView):
|
||||
form = forms.CustomFieldChoiceSetBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(CustomFieldChoiceSet, 'bulk_rename', path='rename', detail=False)
|
||||
class CustomFieldChoiceSetBulkRenameView(generic.BulkRenameView):
|
||||
queryset = CustomFieldChoiceSet.objects.all()
|
||||
|
||||
|
||||
@register_model_view(CustomFieldChoiceSet, 'bulk_delete', path='delete', detail=False)
|
||||
class CustomFieldChoiceSetBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = CustomFieldChoiceSet.objects.all()
|
||||
@@ -226,11 +215,6 @@ class CustomLinkBulkEditView(generic.BulkEditView):
|
||||
form = forms.CustomLinkBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(CustomLink, 'bulk_rename', path='rename', detail=False)
|
||||
class CustomLinkBulkRenameView(generic.BulkRenameView):
|
||||
queryset = CustomLink.objects.all()
|
||||
|
||||
|
||||
@register_model_view(CustomLink, 'bulk_delete', path='delete', detail=False)
|
||||
class CustomLinkBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = CustomLink.objects.all()
|
||||
@@ -248,7 +232,11 @@ class ExportTemplateListView(generic.ObjectListView):
|
||||
filterset = filtersets.ExportTemplateFilterSet
|
||||
filterset_form = forms.ExportTemplateFilterForm
|
||||
table = tables.ExportTemplateTable
|
||||
actions = (AddObject, BulkImport, BulkSync, BulkExport, BulkEdit, BulkRename, BulkDelete)
|
||||
template_name = 'extras/exporttemplate_list.html'
|
||||
actions = {
|
||||
**DEFAULT_ACTION_PERMISSIONS,
|
||||
'bulk_sync': {'sync'},
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(ExportTemplate)
|
||||
@@ -282,11 +270,6 @@ class ExportTemplateBulkEditView(generic.BulkEditView):
|
||||
form = forms.ExportTemplateBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(ExportTemplate, 'bulk_rename', path='rename', detail=False)
|
||||
class ExportTemplateBulkRenameView(generic.BulkRenameView):
|
||||
queryset = ExportTemplate.objects.all()
|
||||
|
||||
|
||||
@register_model_view(ExportTemplate, 'bulk_delete', path='delete', detail=False)
|
||||
class ExportTemplateBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = ExportTemplate.objects.all()
|
||||
@@ -347,11 +330,6 @@ class SavedFilterBulkEditView(SharedObjectViewMixin, generic.BulkEditView):
|
||||
form = forms.SavedFilterBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(SavedFilter, 'bulk_rename', path='rename', detail=False)
|
||||
class SavedFilterBulkRenameView(generic.BulkRenameView):
|
||||
queryset = SavedFilter.objects.all()
|
||||
|
||||
|
||||
@register_model_view(SavedFilter, 'bulk_delete', path='delete', detail=False)
|
||||
class SavedFilterBulkDeleteView(SharedObjectViewMixin, generic.BulkDeleteView):
|
||||
queryset = SavedFilter.objects.all()
|
||||
@@ -369,7 +347,9 @@ class TableConfigListView(SharedObjectViewMixin, generic.ObjectListView):
|
||||
filterset = filtersets.TableConfigFilterSet
|
||||
filterset_form = forms.TableConfigFilterForm
|
||||
table = tables.TableConfigTable
|
||||
actions = (BulkExport, BulkEdit, BulkRename, BulkDelete)
|
||||
actions = {
|
||||
'export': {'view'},
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(TableConfig)
|
||||
@@ -409,11 +389,6 @@ class TableConfigBulkEditView(SharedObjectViewMixin, generic.BulkEditView):
|
||||
form = forms.TableConfigBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(TableConfig, 'bulk_rename', path='rename', detail=False)
|
||||
class TableConfigBulkRenameView(generic.BulkRenameView):
|
||||
queryset = TableConfig.objects.all()
|
||||
|
||||
|
||||
@register_model_view(TableConfig, 'bulk_delete', path='delete', detail=False)
|
||||
class TableConfigBulkDeleteView(SharedObjectViewMixin, generic.BulkDeleteView):
|
||||
queryset = TableConfig.objects.all()
|
||||
@@ -495,11 +470,6 @@ class NotificationGroupBulkEditView(generic.BulkEditView):
|
||||
form = forms.NotificationGroupBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(NotificationGroup, 'bulk_rename', path='rename', detail=False)
|
||||
class NotificationGroupBulkRenameView(generic.BulkRenameView):
|
||||
queryset = NotificationGroup.objects.all()
|
||||
|
||||
|
||||
@register_model_view(NotificationGroup, 'bulk_delete', path='delete', detail=False)
|
||||
class NotificationGroupBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = NotificationGroup.objects.all()
|
||||
@@ -646,11 +616,6 @@ class WebhookBulkEditView(generic.BulkEditView):
|
||||
form = forms.WebhookBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(Webhook, 'bulk_rename', path='rename', detail=False)
|
||||
class WebhookBulkRenameView(generic.BulkRenameView):
|
||||
queryset = Webhook.objects.all()
|
||||
|
||||
|
||||
@register_model_view(Webhook, 'bulk_delete', path='delete', detail=False)
|
||||
class WebhookBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Webhook.objects.all()
|
||||
@@ -701,11 +666,6 @@ class EventRuleBulkEditView(generic.BulkEditView):
|
||||
form = forms.EventRuleBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(EventRule, 'bulk_rename', path='rename', detail=False)
|
||||
class EventRuleBulkRenameView(generic.BulkRenameView):
|
||||
queryset = EventRule.objects.all()
|
||||
|
||||
|
||||
@register_model_view(EventRule, 'bulk_delete', path='delete', detail=False)
|
||||
class EventRuleBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = EventRule.objects.all()
|
||||
@@ -780,11 +740,6 @@ class TagBulkEditView(generic.BulkEditView):
|
||||
form = forms.TagBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(Tag, 'bulk_rename', path='rename', detail=False)
|
||||
class TagBulkRenameView(generic.BulkRenameView):
|
||||
queryset = Tag.objects.all()
|
||||
|
||||
|
||||
@register_model_view(Tag, 'bulk_delete', path='delete', detail=False)
|
||||
class TagBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Tag.objects.annotate(
|
||||
@@ -803,7 +758,13 @@ class ConfigContextListView(generic.ObjectListView):
|
||||
filterset = filtersets.ConfigContextFilterSet
|
||||
filterset_form = forms.ConfigContextFilterForm
|
||||
table = tables.ConfigContextTable
|
||||
actions = (AddObject, BulkSync, BulkEdit, BulkRename, BulkDelete)
|
||||
template_name = 'extras/configcontext_list.html'
|
||||
actions = {
|
||||
'add': {'add'},
|
||||
'bulk_edit': {'change'},
|
||||
'bulk_delete': {'delete'},
|
||||
'bulk_sync': {'sync'},
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(ConfigContext)
|
||||
@@ -864,11 +825,6 @@ class ConfigContextBulkEditView(generic.BulkEditView):
|
||||
form = forms.ConfigContextBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(ConfigContext, 'bulk_rename', path='rename', detail=False)
|
||||
class ConfigContextBulkRenameView(generic.BulkRenameView):
|
||||
queryset = ConfigContext.objects.all()
|
||||
|
||||
|
||||
@register_model_view(ConfigContext, 'bulk_delete', path='delete', detail=False)
|
||||
class ConfigContextBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = ConfigContext.objects.all()
|
||||
@@ -921,7 +877,11 @@ class ConfigTemplateListView(generic.ObjectListView):
|
||||
filterset = filtersets.ConfigTemplateFilterSet
|
||||
filterset_form = forms.ConfigTemplateFilterForm
|
||||
table = tables.ConfigTemplateTable
|
||||
actions = (AddObject, BulkImport, BulkExport, BulkSync, BulkEdit, BulkRename, BulkDelete)
|
||||
template_name = 'extras/configtemplate_list.html'
|
||||
actions = {
|
||||
**DEFAULT_ACTION_PERMISSIONS,
|
||||
'bulk_sync': {'sync'},
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(ConfigTemplate)
|
||||
@@ -955,11 +915,6 @@ class ConfigTemplateBulkEditView(generic.BulkEditView):
|
||||
form = forms.ConfigTemplateBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(ConfigTemplate, 'bulk_rename', path='rename', detail=False)
|
||||
class ConfigTemplateBulkRenameView(generic.BulkRenameView):
|
||||
queryset = ConfigTemplate.objects.all()
|
||||
|
||||
|
||||
@register_model_view(ConfigTemplate, 'bulk_delete', path='delete', detail=False)
|
||||
class ConfigTemplateBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = ConfigTemplate.objects.all()
|
||||
@@ -1037,7 +992,9 @@ class ImageAttachmentListView(generic.ObjectListView):
|
||||
filterset = filtersets.ImageAttachmentFilterSet
|
||||
filterset_form = forms.ImageAttachmentFilterForm
|
||||
table = tables.ImageAttachmentTable
|
||||
actions = (BulkExport,)
|
||||
actions = {
|
||||
'export': {'view'},
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(ImageAttachment, 'add', detail=False)
|
||||
@@ -1081,7 +1038,12 @@ class JournalEntryListView(generic.ObjectListView):
|
||||
filterset = filtersets.JournalEntryFilterSet
|
||||
filterset_form = forms.JournalEntryFilterForm
|
||||
table = tables.JournalEntryTable
|
||||
actions = (BulkImport, BulkEdit, BulkDelete)
|
||||
actions = {
|
||||
'export': {'view'},
|
||||
'bulk_import': {'add'},
|
||||
'bulk_edit': {'change'},
|
||||
'bulk_delete': {'delete'},
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(JournalEntry)
|
||||
@@ -1320,11 +1282,18 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
|
||||
script_modules = ScriptModule.objects.restrict(request.user).prefetch_related(
|
||||
'data_source', 'data_file', 'jobs'
|
||||
)
|
||||
|
||||
return render(request, 'extras/script_list.html', {
|
||||
context = {
|
||||
'model': ScriptModule,
|
||||
'script_modules': script_modules,
|
||||
})
|
||||
}
|
||||
|
||||
# Use partial template for dashboard widgets
|
||||
template_name = 'extras/script_list.html'
|
||||
if request.GET.get('embedded'):
|
||||
template_name = 'extras/inc/script_list_content.html'
|
||||
context['embedded'] = True
|
||||
|
||||
return render(request, template_name, context)
|
||||
|
||||
|
||||
class BaseScriptView(generic.ObjectView):
|
||||
|
||||
@@ -20,9 +20,8 @@ from utilities.forms.fields import (
|
||||
from utilities.forms.rendering import FieldSet, InlineFields, ObjectAttribute, TabbedGroups
|
||||
from utilities.forms.utils import get_field_value
|
||||
from utilities.forms.widgets import DatePicker, HTMXSelect
|
||||
from django.utils.safestring import mark_safe
|
||||
from utilities.templatetags.builtins.filters import bettertitle
|
||||
from virtualization.models import VMInterface
|
||||
from virtualization.models import VMInterface, VirtualMachine
|
||||
|
||||
__all__ = (
|
||||
'AggregateForm',
|
||||
@@ -681,15 +680,7 @@ class VLANForm(TenancyForm, NetBoxModelForm):
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
null_option='None',
|
||||
selector=True,
|
||||
help_text=mark_safe(
|
||||
'<span class="text-warning"><i class="mdi mdi-alert"></i> {text}</span>'.format(
|
||||
text=_(
|
||||
'The direct assignment of VLANs to a site is deprecated and will be removed in a future release. '
|
||||
'Users are encouraged to utilize VLAN groups for this purpose.'
|
||||
)
|
||||
)
|
||||
)
|
||||
selector=True
|
||||
)
|
||||
role = DynamicModelChoiceField(
|
||||
label=_('Role'),
|
||||
@@ -792,10 +783,6 @@ class ServiceForm(NetBoxModelForm):
|
||||
queryset=IPAddress.objects.all(),
|
||||
required=False,
|
||||
label=_('IP Addresses'),
|
||||
query_params={
|
||||
'device_id': '$device',
|
||||
'virtual_machine_id': '$virtual_machine',
|
||||
}
|
||||
)
|
||||
comments = CommentField()
|
||||
|
||||
@@ -824,10 +811,22 @@ class ServiceForm(NetBoxModelForm):
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if (parent_object_type_id := get_field_value(self, 'parent_object_type')):
|
||||
if parent_object_type_id := get_field_value(self, 'parent_object_type'):
|
||||
try:
|
||||
parent_type = ContentType.objects.get(pk=parent_object_type_id)
|
||||
model = parent_type.model_class()
|
||||
if model == Device:
|
||||
self.fields['ipaddresses'].widget.add_query_params({
|
||||
'device_id': '$parent',
|
||||
})
|
||||
elif model == VirtualMachine:
|
||||
self.fields['ipaddresses'].widget.add_query_params({
|
||||
'virtual_machine_id': '$parent',
|
||||
})
|
||||
elif model == FHRPGroup:
|
||||
self.fields['ipaddresses'].widget.add_query_params({
|
||||
'fhrpgroup_id': '$parent',
|
||||
})
|
||||
self.fields['parent'].queryset = model.objects.all()
|
||||
self.fields['parent'].widget.attrs['selector'] = model._meta.label_lower
|
||||
self.fields['parent'].disabled = False
|
||||
|
||||
@@ -222,6 +222,19 @@ class IPRangeFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMi
|
||||
return Q()
|
||||
return q
|
||||
|
||||
@strawberry_django.filter_field()
|
||||
def contains(self, value: list[str], prefix) -> Q:
|
||||
if not value:
|
||||
return Q()
|
||||
q = Q()
|
||||
for subnet in value:
|
||||
net = netaddr.IPNetwork(subnet.strip())
|
||||
q |= Q(
|
||||
start_address__host__inet__lte=str(netaddr.IPAddress(net.first)),
|
||||
end_address__host__inet__gte=str(netaddr.IPAddress(net.last)),
|
||||
)
|
||||
return q
|
||||
|
||||
|
||||
@strawberry_django.filter_type(models.Prefix, lookups=True)
|
||||
class PrefixFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
|
||||
@@ -238,6 +251,16 @@ class PrefixFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, Pr
|
||||
is_pool: FilterLookup[bool] | None = strawberry_django.filter_field()
|
||||
mark_utilized: FilterLookup[bool] | None = strawberry_django.filter_field()
|
||||
|
||||
@strawberry_django.filter_field()
|
||||
def contains(self, value: list[str], prefix) -> Q:
|
||||
if not value:
|
||||
return Q()
|
||||
q = Q()
|
||||
for subnet in value:
|
||||
query = str(netaddr.IPNetwork(subnet.strip()).cidr)
|
||||
q |= Q(prefix__net_contains=query)
|
||||
return q
|
||||
|
||||
|
||||
@strawberry_django.filter_type(models.RIR, lookups=True)
|
||||
class RIRFilter(OrganizationalModelFilterMixin):
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
from django.contrib.postgres.indexes import GistIndex
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('dcim', '0210_macaddress_ordering'),
|
||||
('extras', '0129_fix_script_paths'),
|
||||
('ipam', '0081_remove_service_device_virtual_machine_add_parent_gfk_index'),
|
||||
('tenancy', '0020_remove_contactgroupmembership'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name='prefix',
|
||||
index=GistIndex(fields=['prefix'], name='ipam_prefix_gist_idx', opclasses=['inet_ops']),
|
||||
),
|
||||
]
|
||||
@@ -1,5 +1,6 @@
|
||||
import netaddr
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.postgres.indexes import GistIndex
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.db.models import F
|
||||
@@ -281,6 +282,13 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
|
||||
ordering = (F('vrf').asc(nulls_first=True), 'prefix', 'pk') # (vrf, prefix) may be non-unique
|
||||
verbose_name = _('prefix')
|
||||
verbose_name_plural = _('prefixes')
|
||||
indexes = [
|
||||
GistIndex(
|
||||
fields=['prefix'],
|
||||
name='ipam_prefix_gist_idx',
|
||||
opclasses=['inet_ops'],
|
||||
),
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@@ -10,7 +10,6 @@ from dcim.filtersets import InterfaceFilterSet
|
||||
from dcim.forms import InterfaceFilterForm
|
||||
from dcim.models import Device, Interface, Site
|
||||
from ipam.tables import VLANTranslationRuleTable
|
||||
from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport
|
||||
from netbox.views import generic
|
||||
from utilities.query import count_related
|
||||
from utilities.tables import get_table_ordering
|
||||
@@ -87,11 +86,6 @@ class VRFBulkEditView(generic.BulkEditView):
|
||||
form = forms.VRFBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(VRF, 'bulk_rename', path='rename', detail=False)
|
||||
class VRFBulkRenameView(generic.BulkRenameView):
|
||||
queryset = VRF.objects.all()
|
||||
|
||||
|
||||
@register_model_view(VRF, 'bulk_delete', path='delete', detail=False)
|
||||
class VRFBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = VRF.objects.all()
|
||||
@@ -142,11 +136,6 @@ class RouteTargetBulkEditView(generic.BulkEditView):
|
||||
form = forms.RouteTargetBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(RouteTarget, 'bulk_rename', path='rename', detail=False)
|
||||
class RouteTargetBulkRenameView(generic.BulkRenameView):
|
||||
queryset = RouteTarget.objects.all()
|
||||
|
||||
|
||||
@register_model_view(RouteTarget, 'bulk_delete', path='delete', detail=False)
|
||||
class RouteTargetBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = RouteTarget.objects.all()
|
||||
@@ -206,11 +195,6 @@ class RIRBulkEditView(generic.BulkEditView):
|
||||
form = forms.RIRBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(RIR, 'bulk_rename', path='rename', detail=False)
|
||||
class RIRBulkRenameView(generic.BulkRenameView):
|
||||
queryset = RIR.objects.all()
|
||||
|
||||
|
||||
@register_model_view(RIR, 'bulk_delete', path='delete', detail=False)
|
||||
class RIRBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = RIR.objects.annotate(
|
||||
@@ -284,11 +268,6 @@ class ASNRangeBulkEditView(generic.BulkEditView):
|
||||
form = forms.ASNRangeBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(ASNRange, 'bulk_rename', path='rename', detail=False)
|
||||
class ASNRangeBulkRenameView(generic.BulkRenameView):
|
||||
queryset = ASNRange.objects.all()
|
||||
|
||||
|
||||
@register_model_view(ASNRange, 'bulk_delete', path='delete', detail=False)
|
||||
class ASNRangeBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = ASNRange.objects.annotate_asn_counts()
|
||||
@@ -356,11 +335,6 @@ class ASNBulkEditView(generic.BulkEditView):
|
||||
form = forms.ASNBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(ASN, 'bulk_rename', path='rename', detail=False)
|
||||
class ASNBulkRenameView(generic.BulkRenameView):
|
||||
queryset = ASN.objects.all()
|
||||
|
||||
|
||||
@register_model_view(ASN, 'bulk_delete', path='delete', detail=False)
|
||||
class ASNBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = ASN.objects.annotate(
|
||||
@@ -382,7 +356,6 @@ class AggregateListView(generic.ObjectListView):
|
||||
filterset = filtersets.AggregateFilterSet
|
||||
filterset_form = forms.AggregateFilterForm
|
||||
table = tables.AggregateTable
|
||||
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
|
||||
|
||||
|
||||
@register_model_view(Aggregate)
|
||||
@@ -515,11 +488,6 @@ class RoleBulkEditView(generic.BulkEditView):
|
||||
form = forms.RoleBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(Role, 'bulk_rename', path='rename', detail=False)
|
||||
class RoleBulkRenameView(generic.BulkRenameView):
|
||||
queryset = Role.objects.all()
|
||||
|
||||
|
||||
@register_model_view(Role, 'bulk_delete', path='delete', detail=False)
|
||||
class RoleBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Role.objects.all()
|
||||
@@ -538,7 +506,6 @@ class PrefixListView(generic.ObjectListView):
|
||||
filterset_form = forms.PrefixFilterForm
|
||||
table = tables.PrefixTable
|
||||
template_name = 'ipam/prefix_list.html'
|
||||
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
|
||||
|
||||
|
||||
@register_model_view(Prefix)
|
||||
@@ -799,11 +766,6 @@ class IPRangeBulkEditView(generic.BulkEditView):
|
||||
form = forms.IPRangeBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(IPRange, 'bulk_rename', path='rename', detail=False)
|
||||
class IPRangeBulkRenameView(generic.BulkRenameView):
|
||||
queryset = IPRange.objects.all()
|
||||
|
||||
|
||||
@register_model_view(IPRange, 'bulk_delete', path='delete', detail=False)
|
||||
class IPRangeBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = IPRange.objects.all()
|
||||
@@ -821,7 +783,6 @@ class IPAddressListView(generic.ObjectListView):
|
||||
filterset = filtersets.IPAddressFilterSet
|
||||
filterset_form = forms.IPAddressFilterForm
|
||||
table = tables.IPAddressTable
|
||||
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
|
||||
|
||||
|
||||
@register_model_view(IPAddress)
|
||||
@@ -1045,11 +1006,6 @@ class VLANGroupBulkEditView(generic.BulkEditView):
|
||||
form = forms.VLANGroupBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(VLANGroup, 'bulk_rename', path='rename', detail=False)
|
||||
class VLANGroupBulkRenameView(generic.BulkRenameView):
|
||||
queryset = VLANGroup.objects.all()
|
||||
|
||||
|
||||
@register_model_view(VLANGroup, 'bulk_delete', path='delete', detail=False)
|
||||
class VLANGroupBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
|
||||
@@ -1139,11 +1095,6 @@ class VLANTranslationPolicyBulkEditView(generic.BulkEditView):
|
||||
form = forms.VLANTranslationPolicyBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(VLANTranslationPolicy, 'bulk_rename', path='rename', detail=False)
|
||||
class VLANTranslationPolicyBulkRenameView(generic.BulkRenameView):
|
||||
queryset = VLANTranslationPolicy.objects.all()
|
||||
|
||||
|
||||
@register_model_view(VLANTranslationPolicy, 'bulk_delete', path='delete', detail=False)
|
||||
class VLANTranslationPolicyBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = VLANTranslationPolicy.objects.all()
|
||||
@@ -1161,7 +1112,6 @@ class VLANTranslationRuleListView(generic.ObjectListView):
|
||||
filterset = filtersets.VLANTranslationRuleFilterSet
|
||||
filterset_form = forms.VLANTranslationRuleFilterForm
|
||||
table = tables.VLANTranslationRuleTable
|
||||
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
|
||||
|
||||
|
||||
@register_model_view(VLANTranslationRule)
|
||||
@@ -1294,11 +1244,6 @@ class FHRPGroupBulkEditView(generic.BulkEditView):
|
||||
form = forms.FHRPGroupBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(FHRPGroup, 'bulk_rename', path='rename', detail=False)
|
||||
class FHRPGroupBulkRenameView(generic.BulkRenameView):
|
||||
queryset = FHRPGroup.objects.all()
|
||||
|
||||
|
||||
@register_model_view(FHRPGroup, 'bulk_delete', path='delete', detail=False)
|
||||
class FHRPGroupBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = FHRPGroup.objects.all()
|
||||
@@ -1426,11 +1371,6 @@ class VLANBulkEditView(generic.BulkEditView):
|
||||
form = forms.VLANBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(VLAN, 'bulk_rename', path='rename', detail=False)
|
||||
class VLANBulkRenameView(generic.BulkRenameView):
|
||||
queryset = VLAN.objects.all()
|
||||
|
||||
|
||||
@register_model_view(VLAN, 'bulk_delete', path='delete', detail=False)
|
||||
class VLANBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = VLAN.objects.all()
|
||||
@@ -1481,11 +1421,6 @@ class ServiceTemplateBulkEditView(generic.BulkEditView):
|
||||
form = forms.ServiceTemplateBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(ServiceTemplate, 'bulk_rename', path='rename', detail=False)
|
||||
class ServiceTemplateBulkRenameView(generic.BulkRenameView):
|
||||
queryset = ServiceTemplate.objects.all()
|
||||
|
||||
|
||||
@register_model_view(ServiceTemplate, 'bulk_delete', path='delete', detail=False)
|
||||
class ServiceTemplateBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = ServiceTemplate.objects.all()
|
||||
@@ -1553,11 +1488,6 @@ class ServiceBulkEditView(generic.BulkEditView):
|
||||
form = forms.ServiceBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(Service, 'bulk_rename', path='rename', detail=False)
|
||||
class ServiceBulkRenameView(generic.BulkRenameView):
|
||||
queryset = Service.objects.all()
|
||||
|
||||
|
||||
@register_model_view(Service, 'bulk_delete', path='delete', detail=False)
|
||||
class ServiceBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Service.objects.prefetch_related('parent')
|
||||
|
||||
@@ -12,3 +12,7 @@ class SerializerNotFound(Exception):
|
||||
|
||||
class GraphQLTypeNotFound(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class QuerySetNotOrdered(Exception):
|
||||
pass
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from django.db.models import QuerySet
|
||||
from rest_framework.pagination import LimitOffsetPagination
|
||||
|
||||
from netbox.api.exceptions import QuerySetNotOrdered
|
||||
from netbox.config import get_config
|
||||
|
||||
|
||||
@@ -15,6 +16,12 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
|
||||
|
||||
def paginate_queryset(self, queryset, request, view=None):
|
||||
|
||||
if isinstance(queryset, QuerySet) and not queryset.ordered:
|
||||
raise QuerySetNotOrdered(
|
||||
"Paginating over an unordered queryset is unreliable. Ensure that a minimal "
|
||||
"ordering has been applied to the queryset for this API endpoint."
|
||||
)
|
||||
|
||||
if isinstance(queryset, QuerySet):
|
||||
self.count = self.get_queryset_count(queryset)
|
||||
else:
|
||||
|
||||
@@ -28,8 +28,7 @@ ADVISORY_LOCK_KEYS = {
|
||||
'job-schedules': 110100,
|
||||
}
|
||||
|
||||
# TODO: Remove in NetBox v4.6
|
||||
# Legacy default view action permission mapping
|
||||
# Default view action permission mapping
|
||||
DEFAULT_ACTION_PERMISSIONS = {
|
||||
'add': {'add'},
|
||||
'export': {'view'},
|
||||
@@ -44,10 +43,3 @@ CENSOR_TOKEN_CHANGED = '***CHANGED***'
|
||||
|
||||
# Placeholder text for empty tables
|
||||
EMPTY_TABLE_TEXT = 'No results found'
|
||||
|
||||
# CSV delimiters
|
||||
CSV_DELIMITERS = {
|
||||
'comma': ',',
|
||||
'semicolon': ';',
|
||||
'pipe': '|',
|
||||
}
|
||||
|
||||
@@ -8,16 +8,12 @@ from django_pglocks import advisory_lock
|
||||
from rq.timeouts import JobTimeoutException
|
||||
|
||||
from core.choices import JobStatusChoices
|
||||
from core.events import JOB_COMPLETED, JOB_FAILED
|
||||
from core.exceptions import JobFailed
|
||||
from core.models import Job, ObjectType
|
||||
from extras.models import Notification
|
||||
from netbox.constants import ADVISORY_LOCK_KEYS
|
||||
from netbox.registry import registry
|
||||
from utilities.request import apply_request_processors
|
||||
|
||||
__all__ = (
|
||||
'AsyncViewJob',
|
||||
'JobRunner',
|
||||
'system_job',
|
||||
)
|
||||
@@ -39,19 +35,6 @@ def system_job(interval):
|
||||
return _wrapper
|
||||
|
||||
|
||||
class JobLogHandler(logging.Handler):
|
||||
"""
|
||||
A logging handler which records entries on a Job.
|
||||
"""
|
||||
def __init__(self, job, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.job = job
|
||||
|
||||
def emit(self, record):
|
||||
# Enter the record in the log of the associated Job
|
||||
self.job.log(record)
|
||||
|
||||
|
||||
class JobRunner(ABC):
|
||||
"""
|
||||
Background Job helper class.
|
||||
@@ -70,11 +53,6 @@ class JobRunner(ABC):
|
||||
"""
|
||||
self.job = job
|
||||
|
||||
# Initiate the system logger
|
||||
self.logger = logging.getLogger(f"netbox.jobs.{self.__class__.__name__}")
|
||||
self.logger.setLevel(logging.DEBUG)
|
||||
self.logger.addHandler(JobLogHandler(job))
|
||||
|
||||
@classproperty
|
||||
def name(cls):
|
||||
return getattr(cls.Meta, 'name', cls.__name__)
|
||||
@@ -183,35 +161,3 @@ class JobRunner(ABC):
|
||||
job.delete()
|
||||
|
||||
return cls.enqueue(instance=instance, schedule_at=schedule_at, interval=interval, *args, **kwargs)
|
||||
|
||||
|
||||
class AsyncViewJob(JobRunner):
|
||||
"""
|
||||
Execute a view as a background job.
|
||||
"""
|
||||
class Meta:
|
||||
name = 'Async View'
|
||||
|
||||
def run(self, view_cls, request, **kwargs):
|
||||
view = view_cls.as_view()
|
||||
|
||||
# Apply all registered request processors (e.g. event_tracking)
|
||||
with apply_request_processors(request):
|
||||
data = view(request)
|
||||
|
||||
self.job.data = {
|
||||
'log': data.log,
|
||||
'errors': data.errors,
|
||||
}
|
||||
|
||||
# Notify the user
|
||||
notification = Notification(
|
||||
user=request.user,
|
||||
object=self.job,
|
||||
event_type=JOB_COMPLETED if not data.errors else JOB_FAILED,
|
||||
)
|
||||
notification.save()
|
||||
|
||||
# TODO: Waiting on fix for bug #19806
|
||||
# if errors:
|
||||
# raise JobFailed()
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
from contextlib import ExitStack
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
import warnings
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import auth, messages
|
||||
@@ -10,10 +13,10 @@ from django.db.utils import InternalError
|
||||
from django.http import Http404, HttpResponseRedirect
|
||||
|
||||
from netbox.config import clear_config, get_config
|
||||
from netbox.registry import registry
|
||||
from netbox.views import handler_500
|
||||
from utilities.api import is_api_request
|
||||
from utilities.error_handlers import handle_rest_api_exception
|
||||
from utilities.request import apply_request_processors
|
||||
|
||||
__all__ = (
|
||||
'CoreMiddleware',
|
||||
@@ -33,7 +36,12 @@ class CoreMiddleware:
|
||||
request.id = uuid.uuid4()
|
||||
|
||||
# Apply all registered request processors
|
||||
with apply_request_processors(request):
|
||||
with ExitStack() as stack:
|
||||
for request_processor in registry['request_processors']:
|
||||
try:
|
||||
stack.enter_context(request_processor(request))
|
||||
except Exception as e:
|
||||
warnings.warn(f'Failed to initialize request processor {request_processor}: {e}')
|
||||
response = self.get_response(request)
|
||||
|
||||
# Check if language cookie should be renewed
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
from django.urls import reverse
|
||||
from django.urls.exceptions import NoReverseMatch
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from core.models import ObjectType
|
||||
from extras.models import ExportTemplate
|
||||
from utilities.querydict import prepare_cloned_fields
|
||||
|
||||
__all__ = (
|
||||
'AddObject',
|
||||
'BulkDelete',
|
||||
'BulkEdit',
|
||||
'BulkExport',
|
||||
'BulkImport',
|
||||
'BulkRename',
|
||||
'CloneObject',
|
||||
'DeleteObject',
|
||||
'EditObject',
|
||||
'ObjectAction',
|
||||
)
|
||||
|
||||
|
||||
class ObjectAction:
|
||||
"""
|
||||
Base class for single- and multi-object operations.
|
||||
|
||||
Params:
|
||||
name: The action name appended to the module for view resolution
|
||||
label: Human-friendly label for the rendered button
|
||||
multi: Set to True if this action is performed by selecting multiple objects (i.e. using a table)
|
||||
permissions_required: The set of permissions a user must have to perform the action
|
||||
url_kwargs: The set of URL keyword arguments to pass when resolving the view's URL
|
||||
"""
|
||||
name = ''
|
||||
label = None
|
||||
multi = False
|
||||
permissions_required = set()
|
||||
url_kwargs = []
|
||||
|
||||
@classmethod
|
||||
def get_url(cls, obj):
|
||||
viewname = f'{obj._meta.app_label}:{obj._meta.model_name}_{cls.name}'
|
||||
kwargs = {
|
||||
kwarg: getattr(obj, kwarg) for kwarg in cls.url_kwargs
|
||||
}
|
||||
try:
|
||||
return reverse(viewname, kwargs=kwargs)
|
||||
except NoReverseMatch:
|
||||
return
|
||||
|
||||
@classmethod
|
||||
def get_context(cls, context, obj):
|
||||
return {
|
||||
'url': cls.get_url(obj),
|
||||
'label': cls.label,
|
||||
}
|
||||
|
||||
|
||||
class AddObject(ObjectAction):
|
||||
"""
|
||||
Create a new object.
|
||||
"""
|
||||
name = 'add'
|
||||
label = _('Add')
|
||||
permissions_required = {'add'}
|
||||
template_name = 'buttons/add.html'
|
||||
|
||||
|
||||
class CloneObject(ObjectAction):
|
||||
"""
|
||||
Populate the new object form with select details from an existing object.
|
||||
"""
|
||||
name = 'add'
|
||||
label = _('Clone')
|
||||
permissions_required = {'add'}
|
||||
template_name = 'buttons/clone.html'
|
||||
|
||||
@classmethod
|
||||
def get_context(cls, context, obj):
|
||||
param_string = prepare_cloned_fields(obj).urlencode()
|
||||
url = f'{cls.get_url(obj)}?{param_string}' if param_string else None
|
||||
return {
|
||||
'url': url,
|
||||
'label': cls.label,
|
||||
}
|
||||
|
||||
|
||||
class EditObject(ObjectAction):
|
||||
"""
|
||||
Edit a single object.
|
||||
"""
|
||||
name = 'edit'
|
||||
label = _('Edit')
|
||||
permissions_required = {'change'}
|
||||
url_kwargs = ['pk']
|
||||
template_name = 'buttons/edit.html'
|
||||
|
||||
|
||||
class DeleteObject(ObjectAction):
|
||||
"""
|
||||
Delete a single object.
|
||||
"""
|
||||
name = 'delete'
|
||||
label = _('Delete')
|
||||
permissions_required = {'delete'}
|
||||
url_kwargs = ['pk']
|
||||
template_name = 'buttons/delete.html'
|
||||
|
||||
|
||||
class BulkImport(ObjectAction):
|
||||
"""
|
||||
Import multiple objects at once.
|
||||
"""
|
||||
name = 'bulk_import'
|
||||
label = _('Import')
|
||||
permissions_required = {'add'}
|
||||
template_name = 'buttons/import.html'
|
||||
|
||||
|
||||
class BulkExport(ObjectAction):
|
||||
"""
|
||||
Export multiple objects at once.
|
||||
"""
|
||||
name = 'export'
|
||||
label = _('Export')
|
||||
permissions_required = {'view'}
|
||||
template_name = 'buttons/export.html'
|
||||
|
||||
@classmethod
|
||||
def get_context(cls, context, model):
|
||||
object_type = ObjectType.objects.get_for_model(model)
|
||||
user = context['request'].user
|
||||
|
||||
# Determine if the "all data" export returns CSV or YAML
|
||||
data_format = 'YAML' if hasattr(object_type.model_class(), 'to_yaml') else 'CSV'
|
||||
|
||||
# Retrieve all export templates for this model
|
||||
export_templates = ExportTemplate.objects.restrict(user, 'view').filter(object_types=object_type)
|
||||
|
||||
return {
|
||||
'label': cls.label,
|
||||
'perms': context['perms'],
|
||||
'object_type': object_type,
|
||||
'url_params': context['request'].GET.urlencode() if context['request'].GET else '',
|
||||
'export_templates': export_templates,
|
||||
'data_format': data_format,
|
||||
}
|
||||
|
||||
|
||||
class BulkEdit(ObjectAction):
|
||||
"""
|
||||
Change the value of one or more fields on a set of objects.
|
||||
"""
|
||||
name = 'bulk_edit'
|
||||
label = _('Edit Selected')
|
||||
multi = True
|
||||
permissions_required = {'change'}
|
||||
template_name = 'buttons/bulk_edit.html'
|
||||
|
||||
|
||||
class BulkRename(ObjectAction):
|
||||
"""
|
||||
Rename multiple objects at once.
|
||||
"""
|
||||
name = 'bulk_rename'
|
||||
label = _('Rename Selected')
|
||||
multi = True
|
||||
permissions_required = {'change'}
|
||||
template_name = 'buttons/bulk_rename.html'
|
||||
|
||||
|
||||
class BulkDelete(ObjectAction):
|
||||
"""
|
||||
Delete each of a set of objects.
|
||||
"""
|
||||
name = 'bulk_delete'
|
||||
label = _('Delete Selected')
|
||||
multi = True
|
||||
permissions_required = {'delete'}
|
||||
template_name = 'buttons/bulk_delete.html'
|
||||
@@ -72,16 +72,6 @@ PREFERENCES = {
|
||||
),
|
||||
description=_('The preferred syntax for displaying generic data within the UI')
|
||||
),
|
||||
'csv_delimiter': UserPreference(
|
||||
label=_('CSV delimiter'),
|
||||
choices=(
|
||||
('comma', 'Comma (,)'),
|
||||
('semicolon', 'Semicolon (;)'),
|
||||
('pipe', 'Pipe (|)'),
|
||||
),
|
||||
default='comma',
|
||||
description=_('The character used to separate fields in CSV data')
|
||||
),
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ from utilities.string import trailing_slash
|
||||
|
||||
RELEASE = load_release_data()
|
||||
VERSION = RELEASE.full_version # Retained for backward compatibility
|
||||
HOSTNAME = platform.node()
|
||||
# Set the base directory two levels up
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
@@ -124,7 +125,6 @@ EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', [])
|
||||
FIELD_CHOICES = getattr(configuration, 'FIELD_CHOICES', {})
|
||||
FILE_UPLOAD_MAX_MEMORY_SIZE = getattr(configuration, 'FILE_UPLOAD_MAX_MEMORY_SIZE', 2621440)
|
||||
GRAPHQL_MAX_ALIASES = getattr(configuration, 'GRAPHQL_MAX_ALIASES', 10)
|
||||
HOSTNAME = getattr(configuration, 'HOSTNAME', platform.node())
|
||||
HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', {})
|
||||
INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1'))
|
||||
ISOLATED_DEPLOYMENT = getattr(configuration, 'ISOLATED_DEPLOYMENT', False)
|
||||
|
||||
@@ -237,7 +237,11 @@ class ActionsColumn(tables.Column):
|
||||
:param split_actions: When True, converts the actions dropdown menu into a split button with first action as the
|
||||
direct button link and icon (default: True)
|
||||
"""
|
||||
attrs = {'td': {'class': 'text-end text-nowrap noprint'}}
|
||||
attrs = {
|
||||
'td': {
|
||||
'class': 'text-end text-nowrap noprint p-1'
|
||||
}
|
||||
}
|
||||
empty_values = ()
|
||||
actions = {
|
||||
'edit': ActionsItem('Edit', 'pencil', 'change', 'warning'),
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import uuid
|
||||
|
||||
from django.test import RequestFactory, TestCase
|
||||
from django.urls import reverse
|
||||
from rest_framework.request import Request
|
||||
|
||||
from netbox.api.exceptions import QuerySetNotOrdered
|
||||
from netbox.api.pagination import OptionalLimitOffsetPagination
|
||||
from utilities.testing import APITestCase
|
||||
from users.models import Token
|
||||
|
||||
|
||||
class AppTest(APITestCase):
|
||||
@@ -26,3 +31,40 @@ class AppTest(APITestCase):
|
||||
response = self.client.get(f'{url}?format=api', **self.header)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class OptionalLimitOffsetPaginationTest(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.paginator = OptionalLimitOffsetPagination()
|
||||
self.factory = RequestFactory()
|
||||
|
||||
def _make_drf_request(self, path='/', query_params=None):
|
||||
"""Helper to create a proper DRF Request object"""
|
||||
return Request(self.factory.get(path, query_params or {}))
|
||||
|
||||
def test_raises_exception_for_unordered_queryset(self):
|
||||
"""Should raise QuerySetNotOrdered for unordered QuerySet"""
|
||||
queryset = Token.objects.all().order_by()
|
||||
request = self._make_drf_request()
|
||||
|
||||
with self.assertRaises(QuerySetNotOrdered) as cm:
|
||||
self.paginator.paginate_queryset(queryset, request)
|
||||
|
||||
error_msg = str(cm.exception)
|
||||
self.assertIn("Paginating over an unordered queryset is unreliable", error_msg)
|
||||
self.assertIn("Ensure that a minimal ordering has been applied", error_msg)
|
||||
|
||||
def test_allows_ordered_queryset(self):
|
||||
"""Should not raise exception for ordered QuerySet"""
|
||||
queryset = Token.objects.all().order_by('created')
|
||||
request = self._make_drf_request()
|
||||
|
||||
self.paginator.paginate_queryset(queryset, request) # Should not raise exception
|
||||
|
||||
def test_allows_non_queryset_iterables(self):
|
||||
"""Should not raise exception for non-QuerySet iterables"""
|
||||
iterable = [1, 2, 3, 4, 5]
|
||||
request = self._make_drf_request()
|
||||
|
||||
self.paginator.paginate_queryset(iterable, request) # Should not raise exception
|
||||
|
||||
@@ -16,10 +16,6 @@ class TestJobRunner(JobRunner):
|
||||
def run(self, *args, **kwargs):
|
||||
if kwargs.get('make_fail', False):
|
||||
raise JobFailed()
|
||||
self.logger.debug("Debug message")
|
||||
self.logger.info("Info message")
|
||||
self.logger.warning("Warning message")
|
||||
self.logger.error("Error message")
|
||||
|
||||
|
||||
class JobRunnerTestCase(TestCase):
|
||||
@@ -55,16 +51,8 @@ class JobRunnerTest(JobRunnerTestCase):
|
||||
def test_handle(self):
|
||||
job = TestJobRunner.enqueue(immediate=True)
|
||||
|
||||
# Check job status
|
||||
self.assertEqual(job.status, JobStatusChoices.STATUS_COMPLETED)
|
||||
|
||||
# Check logging
|
||||
self.assertEqual(len(job.log_entries), 4)
|
||||
self.assertEqual(job.log_entries[0]['message'], "Debug message")
|
||||
self.assertEqual(job.log_entries[1]['message'], "Info message")
|
||||
self.assertEqual(job.log_entries[2]['message'], "Warning message")
|
||||
self.assertEqual(job.log_entries[3]['message'], "Error message")
|
||||
|
||||
def test_handle_failed(self):
|
||||
with disable_warnings('netbox.jobs'):
|
||||
job = TestJobRunner.enqueue(immediate=True, make_fail=True)
|
||||
|
||||
@@ -15,20 +15,18 @@ from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext as _
|
||||
from django_tables2.export import TableExport
|
||||
from mptt.models import MPTTModel
|
||||
|
||||
from core.models import ObjectType
|
||||
from core.signals import clear_events
|
||||
from extras.choices import CustomFieldUIEditableChoices
|
||||
from extras.models import CustomField, ExportTemplate
|
||||
from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport, BulkRename
|
||||
from utilities.error_handlers import handle_protectederror
|
||||
from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation
|
||||
from utilities.export import TableExport
|
||||
from utilities.forms import BulkRenameForm, ConfirmationForm, restrict_form_fields
|
||||
from utilities.forms.bulk_import import BulkImportForm
|
||||
from utilities.htmx import htmx_partial
|
||||
from utilities.jobs import AsyncJobData, is_background_request, process_request_as_job
|
||||
from utilities.permissions import get_permission_for_model
|
||||
from utilities.query import reapply_model_ordering
|
||||
from utilities.request import safe_for_redirect
|
||||
@@ -56,12 +54,12 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
|
||||
Attributes:
|
||||
filterset: A django-filter FilterSet that is applied to the queryset
|
||||
filterset_form: The form class used to render filter options
|
||||
actions: An iterable of ObjectAction subclasses (see ActionsMixin)
|
||||
actions: A mapping of supported actions to their required permissions. When adding custom actions, bulk
|
||||
action names must be prefixed with `bulk_`. (See ActionsMixin.)
|
||||
"""
|
||||
template_name = 'generic/object_list.html'
|
||||
filterset = None
|
||||
filterset_form = None
|
||||
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkRename, BulkDelete)
|
||||
|
||||
def get_required_permission(self):
|
||||
return get_permission_for_model(self.queryset.model, 'view')
|
||||
@@ -78,7 +76,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
|
||||
|
||||
return '---\n'.join(yaml_data)
|
||||
|
||||
def export_table(self, table, columns=None, filename=None, delimiter=None):
|
||||
def export_table(self, table, columns=None, filename=None):
|
||||
"""
|
||||
Export all table data in CSV format.
|
||||
|
||||
@@ -87,7 +85,6 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
|
||||
columns: A list of specific columns to include. If None, all columns will be exported.
|
||||
filename: The name of the file attachment sent to the client. If None, will be determined automatically
|
||||
from the queryset model name.
|
||||
delimiter: The character used to separate columns (a comma is used by default)
|
||||
"""
|
||||
exclude_columns = {'pk', 'actions'}
|
||||
if columns:
|
||||
@@ -98,8 +95,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
|
||||
exporter = TableExport(
|
||||
export_format=TableExport.CSV,
|
||||
table=table,
|
||||
exclude_columns=exclude_columns,
|
||||
delimiter=delimiter,
|
||||
exclude_columns=exclude_columns
|
||||
)
|
||||
return exporter.response(
|
||||
filename=filename or f'netbox_{self.queryset.model._meta.verbose_name_plural}.csv'
|
||||
@@ -154,16 +150,15 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
|
||||
|
||||
# Determine the available actions
|
||||
actions = self.get_permitted_actions(request.user)
|
||||
has_table_actions = any(action.multi for action in actions)
|
||||
has_bulk_actions = any([a.startswith('bulk_') for a in actions])
|
||||
|
||||
if 'export' in request.GET:
|
||||
|
||||
# Export the current table view
|
||||
if request.GET['export'] == 'table':
|
||||
table = self.get_table(self.queryset, request, has_table_actions)
|
||||
table = self.get_table(self.queryset, request, has_bulk_actions)
|
||||
columns = [name for name, _ in table.selected_columns]
|
||||
delimiter = request.user.config.get('csv_delimiter')
|
||||
return self.export_table(table, columns, delimiter=delimiter)
|
||||
return self.export_table(table, columns)
|
||||
|
||||
# Render an ExportTemplate
|
||||
elif request.GET['export']:
|
||||
@@ -179,12 +174,11 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
|
||||
|
||||
# Fall back to default table/YAML export
|
||||
else:
|
||||
table = self.get_table(self.queryset, request, has_table_actions)
|
||||
delimiter = request.user.config.get('csv_delimiter')
|
||||
return self.export_table(table, delimiter=delimiter)
|
||||
table = self.get_table(self.queryset, request, has_bulk_actions)
|
||||
return self.export_table(table)
|
||||
|
||||
# Render the objects table
|
||||
table = self.get_table(self.queryset, request, has_table_actions)
|
||||
table = self.get_table(self.queryset, request, has_bulk_actions)
|
||||
|
||||
# If this is an HTMX request, return only the rendered table HTML
|
||||
if htmx_partial(request):
|
||||
@@ -504,32 +498,25 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
|
||||
if form.is_valid():
|
||||
logger.debug("Import form validation was successful")
|
||||
redirect_url = reverse(get_viewname(model, action='list'))
|
||||
new_objects = []
|
||||
|
||||
# If indicated, defer this request to a background job & redirect the user
|
||||
if form.cleaned_data['background_job']:
|
||||
job_name = _('Bulk import {count} {object_type}').format(
|
||||
count=len(form.cleaned_data['data']),
|
||||
object_type=model._meta.verbose_name_plural,
|
||||
)
|
||||
if job := process_request_as_job(self.__class__, request, name=job_name):
|
||||
msg = _('Created background job {job.pk}: <a href="{url}">{job.name}</a>').format(
|
||||
url=job.get_absolute_url(),
|
||||
job=job
|
||||
)
|
||||
messages.info(request, mark_safe(msg))
|
||||
return redirect(redirect_url)
|
||||
|
||||
try:
|
||||
# Iterate through data and bind each record to a new model form instance.
|
||||
with transaction.atomic(using=router.db_for_write(model)):
|
||||
new_objects = self.create_and_update_objects(form, request)
|
||||
new_objs = self.create_and_update_objects(form, request)
|
||||
|
||||
# Enforce object-level permissions
|
||||
if self.queryset.filter(pk__in=[obj.pk for obj in new_objects]).count() != len(new_objects):
|
||||
if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs):
|
||||
raise PermissionsViolation
|
||||
|
||||
if new_objs:
|
||||
msg = f"Imported {len(new_objs)} {model._meta.verbose_name_plural}"
|
||||
logger.info(msg)
|
||||
messages.success(request, msg)
|
||||
|
||||
view_name = get_viewname(model, action='list')
|
||||
results_url = f"{reverse(view_name)}?modified_by_request={request.id}"
|
||||
return redirect(results_url)
|
||||
|
||||
except (AbortTransaction, ValidationError):
|
||||
clear_events.send(sender=self)
|
||||
|
||||
@@ -538,25 +525,6 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
form.add_error(None, e.message)
|
||||
clear_events.send(sender=self)
|
||||
|
||||
# If this request was executed via a background job, return the raw data for logging
|
||||
if is_background_request(request):
|
||||
return AsyncJobData(
|
||||
log=[
|
||||
_('Created {object}').format(object=str(obj))
|
||||
for obj in new_objects
|
||||
],
|
||||
errors=form.errors
|
||||
)
|
||||
|
||||
if new_objects:
|
||||
msg = _("Imported {count} {object_type}").format(
|
||||
count=len(new_objects),
|
||||
object_type=model._meta.verbose_name_plural
|
||||
)
|
||||
logger.info(msg)
|
||||
messages.success(request, msg)
|
||||
return redirect(f"{redirect_url}?modified_by_request={request.id}")
|
||||
|
||||
else:
|
||||
logger.debug("Form validation failed")
|
||||
|
||||
@@ -761,11 +729,7 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
"""
|
||||
An extendable view for renaming objects in bulk.
|
||||
|
||||
Attributes:
|
||||
field_name: The name of the object attribute for which the value is being updated (defaults to "name")
|
||||
"""
|
||||
field_name = 'name'
|
||||
template_name = 'generic/bulk_rename.html'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -795,12 +759,12 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
replace = form.cleaned_data['replace']
|
||||
if form.cleaned_data['use_regex']:
|
||||
try:
|
||||
obj.new_name = re.sub(find, replace, getattr(obj, self.field_name, ''))
|
||||
obj.new_name = re.sub(find, replace, obj.name or '')
|
||||
# Catch regex group reference errors
|
||||
except re.error:
|
||||
obj.new_name = getattr(obj, self.field_name)
|
||||
obj.new_name = obj.name
|
||||
else:
|
||||
obj.new_name = getattr(obj, self.field_name, '').replace(find, replace)
|
||||
obj.new_name = (obj.name or '').replace(find, replace)
|
||||
renamed_pks.append(obj.pk)
|
||||
|
||||
return renamed_pks
|
||||
@@ -819,7 +783,7 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
|
||||
if '_apply' in request.POST:
|
||||
for obj in selected_objects:
|
||||
setattr(obj, self.field_name, obj.new_name)
|
||||
obj.name = obj.new_name
|
||||
obj.save()
|
||||
|
||||
# Enforce constrained permissions
|
||||
@@ -849,7 +813,6 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
selected_objects = self.queryset.filter(pk__in=form.initial['pk'])
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'field_name': self.field_name,
|
||||
'form': form,
|
||||
'obj_type_plural': self.queryset.model._meta.verbose_name_plural,
|
||||
'selected_objects': selected_objects,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
from extras.models import TableConfig
|
||||
from netbox import object_actions
|
||||
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
|
||||
from utilities.permissions import get_permission_for_model
|
||||
|
||||
__all__ = (
|
||||
@@ -9,18 +9,6 @@ __all__ = (
|
||||
'TableMixin',
|
||||
)
|
||||
|
||||
# TODO: Remove in NetBox v4.5
|
||||
LEGACY_ACTIONS = {
|
||||
'add': object_actions.AddObject,
|
||||
'edit': object_actions.EditObject,
|
||||
'delete': object_actions.DeleteObject,
|
||||
'export': object_actions.BulkExport,
|
||||
'bulk_import': object_actions.BulkImport,
|
||||
'bulk_edit': object_actions.BulkEdit,
|
||||
'bulk_rename': object_actions.BulkRename,
|
||||
'bulk_delete': object_actions.BulkDelete,
|
||||
}
|
||||
|
||||
|
||||
class ActionsMixin:
|
||||
"""
|
||||
@@ -31,24 +19,7 @@ class ActionsMixin:
|
||||
Standard actions include: add, import, export, bulk_edit, and bulk_delete. Some views extend this default map
|
||||
with custom actions, such as bulk_sync.
|
||||
"""
|
||||
actions = tuple()
|
||||
|
||||
# TODO: Remove in NetBox v4.5
|
||||
def _convert_legacy_actions(self):
|
||||
"""
|
||||
Convert a legacy dictionary mapping action name to required permissions to a list of ObjectAction subclasses.
|
||||
"""
|
||||
if type(self.actions) is not dict:
|
||||
return
|
||||
|
||||
actions = []
|
||||
for name in self.actions.keys():
|
||||
try:
|
||||
actions.append(LEGACY_ACTIONS[name])
|
||||
except KeyError:
|
||||
raise ValueError(f"Unsupported legacy action: {name}")
|
||||
|
||||
self.actions = actions
|
||||
actions = DEFAULT_ACTION_PERMISSIONS
|
||||
|
||||
def get_permitted_actions(self, user, model=None):
|
||||
"""
|
||||
@@ -56,15 +27,11 @@ class ActionsMixin:
|
||||
"""
|
||||
model = model or self.queryset.model
|
||||
|
||||
# TODO: Remove in NetBox v4.5
|
||||
# Handle legacy action sets
|
||||
self._convert_legacy_actions()
|
||||
|
||||
# Resolve required permissions for each action
|
||||
permitted_actions = []
|
||||
for action in self.actions:
|
||||
required_permissions = [
|
||||
get_permission_for_model(model, perm) for perm in action.permissions_required
|
||||
get_permission_for_model(model, name) for name in self.actions.get(action, set())
|
||||
]
|
||||
if not required_permissions or user.has_perms(required_permissions):
|
||||
permitted_actions.append(action)
|
||||
|
||||
@@ -14,9 +14,6 @@ from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from core.signals import clear_events
|
||||
from netbox.object_actions import (
|
||||
AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport, CloneObject, DeleteObject, EditObject,
|
||||
)
|
||||
from utilities.error_handlers import handle_protectederror
|
||||
from utilities.exceptions import AbortRequest, PermissionsViolation
|
||||
from utilities.forms import ConfirmationForm, restrict_form_fields
|
||||
@@ -39,7 +36,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class ObjectView(ActionsMixin, BaseObjectView):
|
||||
class ObjectView(BaseObjectView):
|
||||
"""
|
||||
Retrieve a single object for display.
|
||||
|
||||
@@ -47,10 +44,8 @@ class ObjectView(ActionsMixin, BaseObjectView):
|
||||
|
||||
Attributes:
|
||||
tab: A ViewTab instance for the view
|
||||
actions: An iterable of ObjectAction subclasses (see ActionsMixin)
|
||||
"""
|
||||
tab = None
|
||||
actions = (CloneObject, EditObject, DeleteObject)
|
||||
|
||||
def get_required_permission(self):
|
||||
return get_permission_for_model(self.queryset.model, 'view')
|
||||
@@ -77,11 +72,9 @@ class ObjectView(ActionsMixin, BaseObjectView):
|
||||
request: The current request
|
||||
"""
|
||||
instance = self.get_object(**kwargs)
|
||||
actions = self.get_permitted_actions(request.user, model=instance)
|
||||
|
||||
return render(request, self.get_template_name(), {
|
||||
'object': instance,
|
||||
'actions': actions,
|
||||
'tab': self.tab,
|
||||
**self.get_extra_context(request, instance),
|
||||
})
|
||||
@@ -97,13 +90,13 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
|
||||
table: The django-tables2 Table class used to render the child objects list
|
||||
filterset: A django-filter FilterSet that is applied to the queryset
|
||||
filterset_form: The form class used to render filter options
|
||||
actions: An iterable of ObjectAction subclasses (see ActionsMixin)
|
||||
actions: A mapping of supported actions to their required permissions. When adding custom actions, bulk
|
||||
action names must be prefixed with `bulk_`. (See ActionsMixin.)
|
||||
"""
|
||||
child_model = None
|
||||
table = None
|
||||
filterset = None
|
||||
filterset_form = None
|
||||
actions = (AddObject, BulkImport, BulkEdit, BulkExport, BulkDelete)
|
||||
template_name = 'generic/object_children.html'
|
||||
|
||||
def get_children(self, request, parent):
|
||||
@@ -145,10 +138,10 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
|
||||
|
||||
# Determine the available actions
|
||||
actions = self.get_permitted_actions(request.user, model=self.child_model)
|
||||
has_table_actions = any(action.multi for action in actions)
|
||||
has_bulk_actions = any([a.startswith('bulk_') for a in actions])
|
||||
|
||||
table_data = self.prep_table_data(request, child_objects, instance)
|
||||
table = self.get_table(table_data, request, has_table_actions)
|
||||
table = self.get_table(table_data, request, has_bulk_actions)
|
||||
|
||||
# If this is an HTMX request, return only the rendered table HTML
|
||||
if htmx_partial(request):
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user