Compare commits

..

19 Commits

Author SHA1 Message Date
Jeremy Stretch
6b70dea18b Fixes #19911: Fix redirect_url support for bulk operations (#19922)
Some checks failed
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
* Establish render() method on ObjectAction

* Restore support for passing return_url
2025-07-24 11:04:41 -05:00
Jeremy Stretch
c047f35c57 Closes #19893: Include hostname in REST API status endpoint (#19895) 2025-07-24 10:42:31 -05:00
Jeremy Stretch
6df0a02d8d Closes #18204: Miscellaneous improvements to the display of image attachments (#19914)
Some checks failed
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
* Show human-friendly values for file size

* Introduce optional dedicated columns for name & filename

* Add combined dimensions column

* Restore image preview on hover

* Remove object_type from default columns list

* Parent column is not orderable

* Filter/search image attachments by filename

* Correct table column name
2025-07-22 09:44:30 -04:00
Jason Novinger
59e1d3a607 Closes: #18588: Relabel Service to Application Service (#19900)
Some checks are pending
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.11) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run
* Closes: #18588: Relabel Service model to Application Service

Updates the `verbose_name` of the `Service` and `ServiceTemplate` models to "Application Service" and
"Application Service Template" respectively. This serves as the foundational change for relabeling
the model throughout the user interface to reduce ambiguity.

To preserve backward compatibility for the REST and GraphQL APIs, the test suites have been updated
to assert the stability of the original field and parameter names. This includes:

*   Using `filter_name_map` in the filterset test case to ensure API query parameters remain
    `service` and `service_id`.
*   Employing the GraphQL test suite's aliasing mechanism to ensure the public schema remains
    unchanged despite the underlying `verbose_name` modification.

Subsequent commits will address UI-specific labels in navigation, tables, forms, and templates.

* Rename to Application Services/Application Service Templates in nav menu

* Rename ~service to ~'Application Service' in templates

This was done for both the Service model and Service Template model
appearances in templates where the word was hardcoded.

* Change ~service to ~'application service' hardcoded strings in Python files

* Update ~service to ~'application service' in docs
2025-07-21 09:22:27 -04:00
Jeremy Stretch
4e0e4598b0 Closes #18990: Add description field to ImageAttachment model (#19907)
Some checks are pending
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.11) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run
2025-07-18 07:58:54 -07:00
Jeremy Stretch
cebc56e5cc Closes #19891: Bulk operation jobs (#19897)
* Add background_job toggle to BulkEditForm

* Account for bug fix in v4.3.4

* Enable background jobs for bulk edit & bulk delete

* Move background_job field to a mixin

* Cosmetic improvements

* Misc cleanup

* Fix BackgroundJobMixin
2025-07-18 08:24:38 -05:00
Jeremy Stretch
7f2b744a53 Closes #18528: Introduce HOSTNAME config parameter (#19894)
Some checks failed
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
2025-07-17 10:09:30 -07:00
Jeremy Stretch
733dd81f0e Closes #19738: Deprecate the direct assignment of a VLAN to a site (#19904) 2025-07-17 08:45:56 -05:00
Jeremy Stretch
32fb3869a4 Closes #19829: Move object types REST API endpoint to core app
Some checks are pending
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.11) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run
2025-07-16 14:54:03 -04:00
Jeremy Stretch
c5ffab0c28 Closes #18349: Replace houskeeping management command with a system job (#19815) 2025-07-16 14:50:11 -04:00
Jeremy Stretch
5f8a4f6c43 Merge branch 'main' into feature
Some checks are pending
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.11) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run
2025-07-16 09:52:58 -04:00
Jeremy Stretch
21a840c32e Closes #19816: Implement a logging mechanism for background jobs (#19838)
Some checks failed
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
* Initial work on #19816

* Use TZ-aware timestamps

* Deserialize JobLogEntry timestamp

* Repurpose RQJobStatusColumn to display job entry level badges

* Misc cleanup

* Test logging

* Refactor HTML templates

* Update documentation
2025-07-14 08:52:50 -05:00
Jeremy Stretch
875a641687 Closes #19589: Background job for bulk operations (#19804)
Some checks failed
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
* Initial work on #19589

* Add tooling for handling background requests

* UI notification should link to enqueued job

* Use an informative name for the job

* Disable background jobs for file uploads
2025-07-10 09:32:35 -05:00
Jeremy Stretch
6022433a40 Closes #19134: Allow negative values for interface TX power (#19847)
Some checks are pending
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.11) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run
2025-07-09 10:17:41 -07:00
Jeremy Stretch
878c624eaf Closes #19722: Extend the object types REST API endpoint (#19826) 2025-07-09 08:43:24 -07:00
Jeremy Stretch
90e8a61670 Closes #19739: Add a user preference for CSV delimiter in table exports (#19824)
* Closes #19739: Add a user preference for CSV delimiter in table exports

* Pass custom delimiter when exporting entire table
2025-07-08 14:11:40 -05:00
Jeremy Stretch
a1cd81ff35 Closes #17413: Permit identical names for platforms belonging to different manufacturers (#19814) 2025-07-07 10:38:01 -07:00
Jeremy Stretch
ce12de8b6d Closes #19231: Add bulk renaming support for all models (#19795)
* Closes #19231: Add bulk renaming support for all models

* Introduce a template filter for getattr()

* Extend BulkRenameView to support arbitrary field names

* Address bulk renaming support for remaining models

* Bulk rename URL resolution should fail silently

* Update documentation

* Fix bulk button rendering for HTMX requests
2025-07-02 13:35:34 -05:00
Jeremy Stretch
601a77ac73 Closes #19735: Implement reuable bulk operations classes (#19774)
* Initial work on #19735

* Work in progress

* Remove ClusterRemoveDevicesView (anti-pattern)

* Misc cleanup

* Fix has_bulk_actions

* Fix has_bulk_actions for ObjectChildrenView

* Restore clone button

* Misc cleanup

* Clean up custom bulk actions

* Rename individual object actions

* Collapse into a single template tag

* Fix support for legacy action dicts

* Rename bulk attr to multi

* clone_button tag should fail silently if view name is invalid

* Clean up action buttons

* Fix export button label

* Replace clone_button with an ObjectAction

* Create object actions for adding device/VM components

* Move core_sync.html to core app

* Remove extra_bulk_buttons from template doc
2025-06-30 13:03:07 -04:00
232 changed files with 7217 additions and 7905 deletions

View File

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

View File

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

View File

@@ -1,3 +0,0 @@
paths-ignore:
# Ignore compiled JS
- netbox/project-static/dist

View File

@@ -1,42 +0,0 @@
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}}"

View File

@@ -8,9 +8,7 @@ django-cors-headers
# Runtime UI tool for debugging Django
# https://github.com/jazzband/django-debug-toolbar/blob/main/docs/changes.rst
# 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
django-debug-toolbar
# Library for writing reusable URL query filters
# https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst
@@ -147,7 +145,8 @@ strawberry-graphql
# Strawberry GraphQL Django extension
# https://github.com/strawberry-graphql/strawberry-django/releases
strawberry-graphql-django
# See #19771
strawberry-graphql-django==0.60.0
# SVG image rendering (used for rack elevations)
# https://github.com/mozman/svgwrite/blob/master/NEWS.rst

View File

@@ -1,17 +0,0 @@
[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

View File

@@ -1,9 +0,0 @@
#!/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

View File

@@ -1,13 +0,0 @@
[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

View File

@@ -1,49 +0,0 @@
# 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.

View File

@@ -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 `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.
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.
```no-highlight
pg_dump ... --exclude-table-data=core_objectchange netbox > netbox.sql
pg_dump ... --exclude-table-data=extras_objectchange netbox > netbox.sql
```
### Load an Exported Database

View File

@@ -72,6 +72,16 @@ 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`
@@ -159,6 +169,7 @@ 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

View File

@@ -62,8 +62,8 @@ VRF modeling in NetBox very closely follows what you find in real-world network
An often overlooked component of IPAM, NetBox also tracks autonomous system (AS) numbers and their assignment to sites. Both 16- and 32-bit AS numbers are supported, and like aggregates each ASN is assigned to an authoritative RIR.
## Service Mapping
## Application Service Mapping
NetBox models network applications as discrete service objects associated with devices and/or virtual machines, and optionally with specific IP addresses attached to those parent objects. These can be used to catalog the applications running on your network for reference by other objects or integrated tools.
To model services in NetBox, begin by creating a service template defining the name, protocol, and port number(s) on which the service listens. This template can then be easily instantiated to "attach" new services to a device or virtual machine. It's also possible to create new services by hand, without a template, however this approach can be tedious.
To model application services in NetBox, begin by creating an application service template defining the name, protocol, and port number(s) on which the service listens. This template can then be easily instantiated to "attach" new services to a device or virtual machine. It's also possible to create new application services by hand, without a template, however this approach can be tedious.

View File

@@ -264,18 +264,6 @@ 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.
@@ -302,6 +290,13 @@ 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.**

View File

@@ -183,13 +183,3 @@ 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.

View File

@@ -10,11 +10,11 @@ The assignment of platforms to devices is an optional feature, and may be disreg
### Name
A unique human-friendly name.
A human-friendly name for the platform. Must be unique per manufacturer.
### Slug
A unique URL-friendly identifier. (This value can be used for filtering.)
A URL-friendly identifier; must be unique per manufacturer. (This value can be used for filtering.)
### Manufacturer

View File

@@ -24,14 +24,6 @@ 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."

View File

@@ -26,14 +26,6 @@ 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`.

View File

@@ -1,14 +1,18 @@
# Services
# Application Services
A service represents a layer seven application available on a device or virtual machine. For example, a service might be created in NetBox to represent an HTTP server running on TCP/8000. Each service may optionally be further bound to one or more specific interfaces assigned to the selected device or virtual machine.
An application service represents a layer seven application available on a device or virtual machine. For example, a service might be created in NetBox to represent an HTTP server running on TCP/8000. Each service may optionally be further bound to one or more specific interfaces assigned to the selected device or virtual machine.
To aid in the efficient creation of services, users may opt to first create a [service template](./servicetemplate.md) from which service definitions can be quickly replicated.
To aid in the efficient creation of application services, users may opt to first create an [application service template](./servicetemplate.md) from which service definitions can be quickly replicated.
!!! note "Changed in NetBox v4.4"
Previously, application services were referred to simply as "services". The name has been changed in the UI to better reflect their intended use. There is no change to the name of the model or in any programmatic NetBox APIs.
## Fields
### Parent
The parent object to which the service is assigned. This must be one of [Device](../dcim/device.md),
The parent object to which the application service is assigned. This must be one of [Device](../dcim/device.md),
[VirtualMachine](../virtualization/virtualmachine.md), or [FHRP Group](./fhrpgroup.md).
!!! note "Changed in NetBox v4.3"

View File

@@ -1,6 +1,10 @@
# Service Templates
# Application Service Templates
Service templates can be used to instantiate [services](./service.md) on [devices](../dcim/device.md) and [virtual machines](../virtualization/virtualmachine.md).
Application service templates can be used to instantiate [application services](./service.md) on [devices](../dcim/device.md) and [virtual machines](../virtualization/virtualmachine.md).
!!! note "Changed in NetBox v4.4"
Previously, application service templates were referred to simply as "service templates". The name has been changed in the UI to better reflect their intended use. There is no change to the name of the model or in any programmatic NetBox APIs.
## Fields

View File

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

View File

@@ -1,6 +1,6 @@
## Interfaces
[Virtual machine](./virtualmachine.md) interfaces behave similarly to device [interfaces](../dcim/interface.md): They can be assigned to VRFs, may have IP addresses, VLANs, and services attached to them, and so on. However, given their virtual nature, they lack properties pertaining to physical attributes. For example, VM interfaces do not have a physical type and cannot have cables attached to them.
[Virtual machine](./virtualmachine.md) interfaces behave similarly to device [interfaces](../dcim/interface.md): They can be assigned to VRFs, may have IP addresses, VLANs, and so on. However, given their virtual nature, they lack properties pertaining to physical attributes. For example, VM interfaces do not have a physical type and cannot have cables attached to them.
## Fields

View File

@@ -39,6 +39,27 @@ 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()`.

View File

@@ -64,6 +64,7 @@ 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
@@ -171,6 +172,10 @@ 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:

View File

@@ -80,20 +80,18 @@ 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) |
| `regex` | Regexp matching |
| `iregex` | Regexp matching (case-insensitive) |
| 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) |
Here is an example of a lookup expression on a string field that will return all devices with `switch` in the name:

View File

@@ -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](../administration/housekeeping.md) 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 for further details.
#### Custom Queue Support for Plugins ([#6651](https://github.com/netbox-community/netbox/issues/6651))

View File

@@ -1,21 +1,5 @@
# NetBox v4.3
## 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
## v4.3.4 (2025-07-15)
### Enhancements

View File

@@ -158,7 +158,6 @@ 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:

View File

@@ -5,6 +5,7 @@ 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
@@ -79,6 +80,11 @@ 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(
@@ -141,6 +147,11 @@ 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(
@@ -212,6 +223,11 @@ 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()
@@ -271,6 +287,11 @@ 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(
@@ -337,6 +358,12 @@ 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(
@@ -432,6 +459,7 @@ class CircuitTerminationListView(generic.ObjectListView):
filterset = filtersets.CircuitTerminationFilterSet
filterset_form = forms.CircuitTerminationFilterForm
table = tables.CircuitTerminationTable
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
@register_model_view(CircuitTermination)
@@ -526,6 +554,11 @@ 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()
@@ -543,6 +576,7 @@ class CircuitGroupAssignmentListView(generic.ObjectListView):
filterset = filtersets.CircuitGroupAssignmentFilterSet
filterset_form = forms.CircuitGroupAssignmentFilterForm
table = tables.CircuitGroupAssignmentTable
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
@register_model_view(CircuitGroupAssignment)
@@ -635,6 +669,11 @@ 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(
@@ -697,6 +736,12 @@ 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')
@@ -714,6 +759,7 @@ class VirtualCircuitTerminationListView(generic.ObjectListView):
filterset = filtersets.VirtualCircuitTerminationFilterSet
filterset_form = forms.VirtualCircuitTerminationFilterForm
table = tables.VirtualCircuitTerminationTable
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
@register_model_view(VirtualCircuitTermination)

View File

@@ -1,4 +1,5 @@
from .serializers_.change_logging import *
from .serializers_.data import *
from .serializers_.jobs import *
from .serializers_.object_types import *
from .serializers_.tasks import *

View File

@@ -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',
'interval', 'started', 'completed', 'user', 'data', 'error', 'job_id', 'log_entries',
]
brief_fields = ('url', 'created', 'completed', 'user', 'status')

View File

@@ -0,0 +1,47 @@
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)

View File

@@ -10,6 +10,7 @@ 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')

View File

@@ -1,28 +1,30 @@
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
@@ -49,8 +51,10 @@ 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
# Enqueue the sync job & update the DataSource's status
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})
@@ -82,6 +86,16 @@ 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().

View File

@@ -4,23 +4,31 @@ from django.utils.translation import gettext_lazy as _
from rq.job import JobStatus
__all__ = (
'JOB_LOG_ENTRY_LEVELS',
'RQ_TASK_STATUSES',
)
@dataclass
class Status:
class Badge:
label: str
color: str
RQ_TASK_STATUSES = {
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'),
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'),
}

View File

@@ -0,0 +1,21 @@
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)

View File

@@ -1,9 +1,8 @@
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
@@ -17,6 +16,7 @@ __all__ = (
'DataSourceFilterSet',
'JobFilterSet',
'ObjectChangeFilterSet',
'ObjectTypeFilterSet',
)
@@ -134,6 +134,25 @@ 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',

View File

@@ -1,13 +1,20 @@
import logging
import requests
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
from core.models import Job, ObjectChange
from netbox.config import Config
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__)
@@ -21,32 +28,25 @@ 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,16 +61,23 @@ 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).
"""
# Skip if census reporting is disabled
if settings.ISOLATED_DEPLOYMENT or not settings.CENSUS_REPORTING_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")
return
census_data = {
@@ -87,3 +94,94 @@ 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)

View File

@@ -0,0 +1,28 @@
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
),
),
]

View File

@@ -1,7 +1,9 @@
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',
@@ -48,3 +50,29 @@ 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)

View File

@@ -1,9 +1,12 @@
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
@@ -14,8 +17,10 @@ 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
@@ -104,6 +109,15 @@ 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()
@@ -116,7 +130,7 @@ class Job(models.Model):
verbose_name_plural = _('jobs')
def __str__(self):
return str(self.job_id)
return self.name
def get_absolute_url(self):
# TODO: Employ dynamic registration
@@ -204,6 +218,13 @@ 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,

View File

@@ -0,0 +1,18 @@
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'

View File

@@ -1,12 +1,10 @@
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
@@ -44,10 +42,6 @@ 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):
"""
@@ -136,16 +130,6 @@ 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):
@@ -195,14 +179,6 @@ 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):
"""

View File

@@ -1,12 +1,11 @@
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',
'RQJobStatusColumn',
'BadgeColumn',
)
@@ -23,14 +22,21 @@ class BackendTypeColumn(tables.Column):
return value
class RQJobStatusColumn(tables.Column):
class BadgeColumn(tables.Column):
"""
Render a colored label for the status of an RQ job.
Render a colored badge for a value.
Args:
badges: A dictionary mapping of values to core.constants.Badge instances.
"""
def __init__(self, badges, *args, **kwargs):
super().__init__(*args, **kwargs)
self.badges = badges
def render(self, value):
status = RQ_TASK_STATUSES.get(value)
return mark_safe(f'<span class="badge text-bg-{status.color}">{status.label}</span>')
badge = self.badges.get(value)
return mark_safe(f'<span class="badge text-bg-{badge.color}">{badge.label}</span>')
def value(self, value):
status = RQ_TASK_STATUSES.get(value)
return status.label
badge = self.badges.get(value)
return badge.label

View File

@@ -1,8 +1,10 @@
import django_tables2 as tables
from django.utils.translation import gettext_lazy as _
from netbox.tables import NetBoxTable, columns
from ..models import Job
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
class JobTable(NetBoxTable):
@@ -40,6 +42,9 @@ class JobTable(NetBoxTable):
completed = columns.DateTimeColumn(
verbose_name=_('Completed'),
)
log_entries = tables.Column(
verbose_name=_('Log Entries'),
)
actions = columns.ActionsColumn(
actions=('delete',)
)
@@ -53,3 +58,24 @@ 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')

View File

@@ -2,7 +2,8 @@ import django_tables2 as tables
from django.utils.translation import gettext_lazy as _
from django_tables2.utils import A
from core.tables.columns import RQJobStatusColumn
from core.constants import RQ_TASK_STATUSES
from core.tables.columns import BadgeColumn
from netbox.tables import BaseTable, columns
@@ -84,7 +85,8 @@ class BackgroundTaskTable(BaseTable):
ended_at = columns.DateTimeColumn(
verbose_name=_("Ended")
)
status = RQJobStatusColumn(
status = BadgeColumn(
badges=RQ_TASK_STATUSES,
verbose_name=_("Status"),
accessor='get_status'
)

View File

@@ -7,6 +7,7 @@ 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
@@ -101,6 +102,22 @@ 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 = ()

View File

@@ -346,38 +346,6 @@ 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):

View File

@@ -22,6 +22,7 @@ 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
@@ -31,12 +32,13 @@ 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, register_model_view
from utilities.views import ContentTypePermissionRequiredMixin, GetRelatedModelsMixin, ViewTab, 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, PluginVersionTable
from .tables import CatalogPluginTable, JobLogEntryTable, PluginVersionTable
#
@@ -77,8 +79,12 @@ class DataSourceSyncView(BaseObjectView):
def post(self, request, pk):
datasource = get_object_or_404(self.queryset, pk=pk)
# Enqueue the sync job
# Enqueue the sync job & update the DataSource's status
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)
@@ -114,6 +120,11 @@ 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(
@@ -133,14 +144,13 @@ class DataFileListView(generic.ObjectListView):
filterset = filtersets.DataFileFilterSet
filterset_form = forms.DataFileFilterForm
table = tables.DataFileTable
actions = {
'bulk_delete': {'delete'},
}
actions = (BulkDelete,)
@register_model_view(DataFile)
class DataFileView(generic.ObjectView):
queryset = DataFile.objects.all()
actions = (DeleteObject,)
@register_model_view(DataFile, 'delete')
@@ -165,15 +175,32 @@ class JobListView(generic.ObjectListView):
filterset = filtersets.JobFilterSet
filterset_form = forms.JobFilterForm
table = tables.JobTable
actions = {
'export': {'view'},
'bulk_delete': {'delete'},
}
actions = (BulkExport, BulkDelete)
@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')
@@ -199,9 +226,7 @@ class ObjectChangeListView(generic.ObjectListView):
filterset_form = forms.ObjectChangeFilterForm
table = tables.ObjectChangeTable
template_name = 'core/objectchange_list.html'
actions = {
'export': {'view'},
}
actions = (BulkExport,)
@register_model_view(ObjectChange)
@@ -269,6 +294,7 @@ class ConfigRevisionListView(generic.ObjectListView):
filterset = filtersets.ConfigRevisionFilterSet
filterset_form = forms.ConfigRevisionFilterForm
table = tables.ConfigRevisionTable
actions = (AddObject, BulkExport)
@register_model_view(ConfigRevision)

View File

@@ -1515,34 +1515,34 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
label=_('Site group (slug)'),
)
site_id = django_filters.ModelMultipleChoiceFilter(
field_name='_site',
field_name='device__site',
queryset=Site.objects.all(),
label=_('Site (ID)'),
)
site = django_filters.ModelMultipleChoiceFilter(
field_name='_site__slug',
field_name='device__site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label=_('Site name (slug)'),
)
location_id = django_filters.ModelMultipleChoiceFilter(
field_name='_location',
field_name='device__location',
queryset=Location.objects.all(),
label=_('Location (ID)'),
)
location = django_filters.ModelMultipleChoiceFilter(
field_name='_location__slug',
field_name='device__location__slug',
queryset=Location.objects.all(),
to_field_name='slug',
label=_('Location (slug)'),
)
rack_id = django_filters.ModelMultipleChoiceFilter(
field_name='_rack',
field_name='device__rack',
queryset=Rack.objects.all(),
label=_('Rack (ID)'),
)
rack = django_filters.ModelMultipleChoiceFilter(
field_name='_rack__name',
field_name='device__rack__name',
queryset=Rack.objects.all(),
to_field_name='name',
label=_('Rack (name)'),

View File

@@ -1335,13 +1335,6 @@ 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(),
@@ -1360,13 +1353,6 @@ 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(),
@@ -1410,39 +1396,14 @@ 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_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',
'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',
]
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.
@@ -1479,24 +1440,6 @@ 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')
@@ -1508,14 +1451,11 @@ 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'),

View File

@@ -1507,7 +1507,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
tx_power = forms.IntegerField(
required=False,
label=_('Transmit power (dBm)'),
min_value=0,
min_value=-40,
max_value=127
)
vrf_id = DynamicModelMultipleChoiceField(

View File

@@ -0,0 +1,54 @@
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.'
),
),
]

View File

@@ -1,287 +0,0 @@
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),
]

View File

@@ -0,0 +1,24 @@
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)
]
),
),
]

View File

@@ -4,7 +4,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0207_remove_redundant_indexes'),
('dcim', '0209_interface_tx_power_negative'),
('extras', '0129_fix_script_paths'),
]

View File

@@ -11,7 +11,6 @@ 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
@@ -156,15 +155,6 @@ 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()

View File

@@ -65,29 +65,6 @@ 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')
@@ -123,14 +100,6 @@ 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
@@ -750,10 +719,13 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
verbose_name=('channel width (MHz)'),
help_text=_("Populated by selected channel (if set)")
)
tx_power = models.PositiveSmallIntegerField(
tx_power = models.SmallIntegerField(
blank=True,
null=True,
validators=(MaxValueValidator(127),),
validators=(
MinValueValidator(-40),
MaxValueValidator(127),
),
verbose_name=_('transmit power (dBm)')
)
poe_mode = models.CharField(

View File

@@ -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, prefetch_related_objects
from django.db.models import F, ProtectedError
from django.db.models.functions import Lower
from django.db.models.signals import post_save
from django.urls import reverse
@@ -28,7 +28,6 @@ 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
@@ -438,6 +437,15 @@ 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,
@@ -450,6 +458,28 @@ 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(
@@ -925,10 +955,7 @@ class Device(
if cf_defaults := CustomField.objects.get_defaults_for_model(model):
for component in components:
component.custom_field_data = cf_defaults
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)
model.objects.bulk_create(components)
# Manually send the post_save signal for each of the newly created components
for component in components:
post_save.send(

View File

@@ -0,0 +1,38 @@
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'

View File

@@ -3,28 +3,13 @@ import logging
from django.db.models.signals import post_save, post_delete, pre_delete
from django.dispatch import receiver
from dcim.choices import CableEndChoices, LinkStatusChoices
from .choices import CableEndChoices, LinkStatusChoices
from .models import (
Cable, CablePath, CableTermination, ConsolePort, ConsoleServerPort, Device, DeviceBay, FrontPort, Interface,
InventoryItem, ModuleBay, PathEndpoint, PowerOutlet, PowerPanel, PowerPort, Rack, RearPort, Location,
VirtualChassis,
Cable, CablePath, CableTermination, Device, FrontPort, PathEndpoint, PowerPanel, Rack, 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
@@ -54,20 +39,6 @@ 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
#

View File

@@ -3,7 +3,6 @@ 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
@@ -68,20 +67,6 @@ 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.
@@ -192,26 +177,12 @@ 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(display_name, insert=text_coords, fill=text_color, clip_path=f"url(#{clip_id})",
class_=f'label{css_extra}')
)
link.add(Text(name, insert=text_coords, fill=text_color, class_=f'label{css_extra}'))
# Embed device type image if provided
if self.include_images and image:

View File

@@ -113,10 +113,6 @@ 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'
@@ -127,7 +123,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',
'color_name', 'length', 'description', 'comments', 'tags', 'created', 'last_updated',
'length', 'description', 'comments', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'status', 'type',

View File

@@ -3367,36 +3367,9 @@ 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',
_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(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.objects.bulk_create(console_ports)
@@ -3608,34 +3581,13 @@ class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeL
console_server_ports = (
ConsoleServerPort(
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,
device=devices[0], module=modules[0], name='Console Server Port 1', label='A', description='First'
),
ConsoleServerPort(
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,
device=devices[1], module=modules[1], name='Console Server Port 2', label='B', description='Second'
),
ConsoleServerPort(
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,
device=devices[2], module=modules[2], name='Console Server Port 3', label='C', description='Third'
),
)
ConsoleServerPort.objects.bulk_create(console_server_ports)
@@ -3855,9 +3807,6 @@ 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],
@@ -3867,9 +3816,6 @@ 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],
@@ -3879,9 +3825,6 @@ 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)
@@ -4110,9 +4053,6 @@ 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],
@@ -4123,9 +4063,6 @@ 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],
@@ -4136,9 +4073,6 @@ 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)
@@ -4447,19 +4381,13 @@ 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,
_site=devices[1].site,
_location=devices[1].location,
_rack=devices[1].rack,
enabled=True
),
Interface(
device=devices[2],
@@ -4478,9 +4406,6 @@ 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],
@@ -4499,9 +4424,6 @@ 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],
@@ -4518,9 +4440,6 @@ 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],
@@ -4531,10 +4450,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
mgmt_only=True,
tx_power=40,
mode=InterfaceModeChoices.MODE_Q_IN_Q,
qinq_svlan=vlans[1],
_site=devices[4].site,
_location=devices[4].location,
_rack=devices[4].rack,
qinq_svlan=vlans[1]
),
Interface(
device=devices[4],
@@ -4545,10 +4461,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
mgmt_only=False,
tx_power=40,
mode=InterfaceModeChoices.MODE_Q_IN_Q,
qinq_svlan=vlans[2],
_site=devices[4].site,
_location=devices[4].location,
_rack=devices[4].rack,
qinq_svlan=vlans[2]
),
Interface(
device=devices[4],
@@ -4557,10 +4470,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
rf_role=WirelessRoleChoices.ROLE_AP,
rf_channel=WirelessChannelChoices.CHANNEL_24G_1,
rf_channel_frequency=2412,
rf_channel_width=22,
_site=devices[4].site,
_location=devices[4].location,
_rack=devices[4].rack,
rf_channel_width=22
),
Interface(
device=devices[4],
@@ -4569,10 +4479,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
rf_role=WirelessRoleChoices.ROLE_STATION,
rf_channel=WirelessChannelChoices.CHANNEL_5G_32,
rf_channel_frequency=5160,
rf_channel_width=20,
_site=devices[4].site,
_location=devices[4].location,
_rack=devices[4].rack,
rf_channel_width=20
),
)
Interface.objects.bulk_create(interfaces)
@@ -4999,9 +4906,6 @@ 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],
@@ -5013,9 +4917,6 @@ 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],
@@ -5027,9 +4928,6 @@ 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],
@@ -5038,9 +4936,6 @@ 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],
@@ -5049,9 +4944,6 @@ 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],
@@ -5060,9 +4952,6 @@ 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)
@@ -5279,9 +5168,6 @@ 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],
@@ -5292,9 +5178,6 @@ 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],
@@ -5305,40 +5188,10 @@ 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)
@@ -5697,33 +5550,9 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
Device.objects.bulk_create(devices)
device_bays = (
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(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.objects.bulk_create(device_bays)

View File

@@ -3266,27 +3266,17 @@ class CableTestCase(
@classmethod
def setUpTestData(cls):
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
)
Site.objects.bulk_create(sites)
site = Site.objects.create(name='Site 1', slug='site-1')
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 = (
# 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(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),
)
Device.objects.bulk_create(devices)
@@ -3337,15 +3327,13 @@ 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_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",
"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",
)
cls.csv_update_data = (

View File

@@ -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.constants import DEFAULT_ACTION_PERMISSIONS
from netbox.object_actions import *
from netbox.views import generic
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator, get_paginate_count
@@ -34,6 +34,7 @@ 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,
@@ -49,11 +50,6 @@ 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):
@@ -61,12 +57,8 @@ class DeviceComponentsView(generic.ObjectChildrenView):
class DeviceTypeComponentsView(generic.ObjectChildrenView):
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete)
queryset = DeviceType.objects.all()
template_name = 'dcim/devicetype/component_templates.html'
viewname = None # Used for return_url resolution
def get_children(self, request, parent):
@@ -78,9 +70,9 @@ class DeviceTypeComponentsView(generic.ObjectChildrenView):
}
class ModuleTypeComponentsView(DeviceComponentsView):
class ModuleTypeComponentsView(generic.ObjectChildrenView):
queryset = ModuleType.objects.all()
template_name = 'dcim/moduletype/component_templates.html'
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete)
viewname = None # Used for return_url resolution
def get_children(self, request, parent):
@@ -300,6 +292,11 @@ 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(
@@ -426,6 +423,11 @@ 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(
@@ -511,6 +513,11 @@ 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()
@@ -615,6 +622,11 @@ 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(
@@ -680,6 +692,11 @@ 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(
@@ -739,6 +756,12 @@ 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()
@@ -918,6 +941,11 @@ 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()
@@ -935,6 +963,7 @@ class RackReservationListView(generic.ObjectListView):
filterset = filtersets.RackReservationFilterSet
filterset_form = forms.RackReservationFilterForm
table = tables.RackReservationTable
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
@register_model_view(RackReservation)
@@ -1051,6 +1080,11 @@ 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(
@@ -1298,6 +1332,12 @@ 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(
@@ -1354,6 +1394,11 @@ 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(
@@ -1564,6 +1609,11 @@ 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(
@@ -2038,6 +2088,11 @@ 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(
@@ -2099,6 +2154,11 @@ 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()
@@ -2116,7 +2176,7 @@ class DeviceListView(generic.ObjectListView):
filterset = filtersets.DeviceFilterSet
filterset_form = forms.DeviceFilterForm
table = tables.DeviceTable
template_name = 'dcim/device_list.html'
actions = (AddObject, BulkImport, BulkExport, BulkAddComponents, BulkEdit, BulkRename, BulkDelete)
@register_model_view(Device)
@@ -2157,7 +2217,7 @@ class DeviceConsolePortsView(DeviceComponentsView):
table = tables.DeviceConsolePortTable
filterset = filtersets.ConsolePortFilterSet
filterset_form = forms.ConsolePortFilterForm
template_name = 'dcim/device/consoleports.html',
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
tab = ViewTab(
label=_('Console Ports'),
badge=lambda obj: obj.console_port_count,
@@ -2173,7 +2233,7 @@ class DeviceConsoleServerPortsView(DeviceComponentsView):
table = tables.DeviceConsoleServerPortTable
filterset = filtersets.ConsoleServerPortFilterSet
filterset_form = forms.ConsoleServerPortFilterForm
template_name = 'dcim/device/consoleserverports.html'
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
tab = ViewTab(
label=_('Console Server Ports'),
badge=lambda obj: obj.console_server_port_count,
@@ -2189,7 +2249,7 @@ class DevicePowerPortsView(DeviceComponentsView):
table = tables.DevicePowerPortTable
filterset = filtersets.PowerPortFilterSet
filterset_form = forms.PowerPortFilterForm
template_name = 'dcim/device/powerports.html'
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
tab = ViewTab(
label=_('Power Ports'),
badge=lambda obj: obj.power_port_count,
@@ -2205,7 +2265,7 @@ class DevicePowerOutletsView(DeviceComponentsView):
table = tables.DevicePowerOutletTable
filterset = filtersets.PowerOutletFilterSet
filterset_form = forms.PowerOutletFilterForm
template_name = 'dcim/device/poweroutlets.html'
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
tab = ViewTab(
label=_('Power Outlets'),
badge=lambda obj: obj.power_outlet_count,
@@ -2221,6 +2281,7 @@ 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'),
@@ -2243,7 +2304,7 @@ class DeviceFrontPortsView(DeviceComponentsView):
table = tables.DeviceFrontPortTable
filterset = filtersets.FrontPortFilterSet
filterset_form = forms.FrontPortFilterForm
template_name = 'dcim/device/frontports.html'
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
tab = ViewTab(
label=_('Front Ports'),
badge=lambda obj: obj.front_port_count,
@@ -2259,7 +2320,7 @@ class DeviceRearPortsView(DeviceComponentsView):
table = tables.DeviceRearPortTable
filterset = filtersets.RearPortFilterSet
filterset_form = forms.RearPortFilterForm
template_name = 'dcim/device/rearports.html'
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
tab = ViewTab(
label=_('Rear Ports'),
badge=lambda obj: obj.rear_port_count,
@@ -2275,11 +2336,7 @@ class DeviceModuleBaysView(DeviceComponentsView):
table = tables.DeviceModuleBayTable
filterset = filtersets.ModuleBayFilterSet
filterset_form = forms.ModuleBayFilterForm
template_name = 'dcim/device/modulebays.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete)
tab = ViewTab(
label=_('Module Bays'),
badge=lambda obj: obj.module_bay_count,
@@ -2295,11 +2352,7 @@ class DeviceDeviceBaysView(DeviceComponentsView):
table = tables.DeviceDeviceBayTable
filterset = filtersets.DeviceBayFilterSet
filterset_form = forms.DeviceBayFilterForm
template_name = 'dcim/device/devicebays.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete)
tab = ViewTab(
label=_('Device Bays'),
badge=lambda obj: obj.device_bay_count,
@@ -2315,11 +2368,7 @@ class DeviceInventoryView(DeviceComponentsView):
table = tables.DeviceInventoryItemTable
filterset = filtersets.InventoryItemFilterSet
filterset_form = forms.InventoryItemFilterForm
template_name = 'dcim/device/inventory.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete)
tab = ViewTab(
label=_('Inventory Items'),
badge=lambda obj: obj.inventory_item_count,
@@ -2393,16 +2442,16 @@ class DeviceBulkEditView(generic.BulkEditView):
form = forms.DeviceBulkEditForm
@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
@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
@@ -2417,6 +2466,7 @@ class ModuleListView(generic.ObjectListView):
filterset = filtersets.ModuleFilterSet
filterset_form = forms.ModuleFilterForm
table = tables.ModuleTable
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
@register_model_view(Module)
@@ -2472,11 +2522,6 @@ 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)
@@ -2547,11 +2592,6 @@ 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)
@@ -2622,11 +2662,6 @@ 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)
@@ -2697,11 +2732,6 @@ 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)
@@ -2772,11 +2802,6 @@ 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)
@@ -2920,11 +2945,6 @@ 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)
@@ -2995,11 +3015,6 @@ 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)
@@ -3070,11 +3085,6 @@ 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)
@@ -3136,11 +3146,6 @@ 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)
@@ -3283,11 +3288,6 @@ 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,6 +3410,11 @@ 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(
@@ -3607,6 +3612,12 @@ 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(
@@ -3627,9 +3638,7 @@ class ConsoleConnectionsListView(generic.ObjectListView):
filterset_form = forms.ConsoleConnectionFilterForm
table = tables.ConsoleConnectionTable
template_name = 'dcim/connections_list.html'
actions = {
'export': {'view'},
}
actions = (BulkExport,)
def get_extra_context(self, request):
return {
@@ -3643,9 +3652,7 @@ class PowerConnectionsListView(generic.ObjectListView):
filterset_form = forms.PowerConnectionFilterForm
table = tables.PowerConnectionTable
template_name = 'dcim/connections_list.html'
actions = {
'export': {'view'},
}
actions = (BulkExport,)
def get_extra_context(self, request):
return {
@@ -3659,9 +3666,7 @@ class InterfaceConnectionsListView(generic.ObjectListView):
filterset_form = forms.InterfaceConnectionFilterForm
table = tables.InterfaceConnectionTable
template_name = 'dcim/connections_list.html'
actions = {
'export': {'view'},
}
actions = (BulkExport,)
def get_extra_context(self, request):
return {
@@ -3905,6 +3910,11 @@ 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()
@@ -3962,6 +3972,11 @@ 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(
@@ -4014,6 +4029,11 @@ 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()
@@ -4042,6 +4062,7 @@ 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)
@@ -4086,6 +4107,11 @@ 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()
@@ -4103,6 +4129,7 @@ class MACAddressListView(generic.ObjectListView):
filterset = filtersets.MACAddressFilterSet
filterset_form = forms.MACAddressFilterForm
table = tables.MACAddressTable
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
@register_model_view(MACAddress)

View File

@@ -1,4 +1,3 @@
from .serializers_.objecttypes import *
from .serializers_.attachments import *
from .serializers_.bookmarks import *
from .serializers_.customfields import *

View File

@@ -24,10 +24,10 @@ class ImageAttachmentSerializer(ValidatedModelSerializer):
class Meta:
model = ImageAttachment
fields = [
'id', 'url', 'display', 'object_type', 'object_id', 'parent', 'name', 'image',
'id', 'url', 'display', 'object_type', 'object_id', 'parent', 'name', 'image', 'description',
'image_height', 'image_width', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'image')
brief_fields = ('id', 'url', 'display', 'name', 'image', 'description')
def validate(self, data):

View File

@@ -1,16 +0,0 @@
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']

View File

@@ -1,5 +1,6 @@
from django.urls import include, path
from core.api.views import ObjectTypeViewSet
from netbox.api.routers import NetBoxRouter
from . import views
@@ -26,7 +27,9 @@ 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')
router.register('object-types', views.ObjectTypeViewSet)
# TODO: Remove in NetBox v4.5
router.register('object-types', ObjectTypeViewSet)
app_name = 'extras-api'
urlpatterns = [

View File

@@ -10,10 +10,9 @@ 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, ReadOnlyModelViewSet
from rest_framework.viewsets import ModelViewSet
from rq import Worker
from core.models import ObjectType
from extras import filtersets
from extras.jobs import ScriptJob
from extras.models import *
@@ -185,9 +184,7 @@ class TagViewSet(NetBoxModelViewSet):
class TaggedItemViewSet(RetrieveModelMixin, ListModelMixin, BaseViewSet):
queryset = TaggedItem.objects.prefetch_related(
'content_type', 'content_object', 'tag'
).order_by('tag__weight', 'tag__name')
queryset = TaggedItem.objects.prefetch_related('content_type', 'content_object', 'tag')
serializer_class = serializers.TaggedItemSerializer
filterset_class = filtersets.TaggedItemFilterSet
@@ -316,20 +313,6 @@ 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
#

View File

@@ -21,12 +21,6 @@ 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 = [
{

View File

@@ -29,7 +29,6 @@ __all__ = (
'JournalEntryFilterSet',
'LocalConfigContextFilterSet',
'NotificationGroupFilterSet',
'ObjectTypeFilterSet',
'SavedFilterFilterSet',
'ScriptFilterSet',
'TableConfigFilterSet',
@@ -452,12 +451,16 @@ class ImageAttachmentFilterSet(ChangeLoggedModelFilterSet):
class Meta:
model = ImageAttachment
fields = ('id', 'object_type_id', 'object_id', 'name', 'image_width', 'image_height')
fields = ('id', 'object_type_id', 'object_id', 'name', 'description', 'image_width', 'image_height')
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(name__icontains=value)
return queryset.filter(
Q(name__icontains=value) |
Q(image__icontains=value) |
Q(description__icontains=value)
)
class JournalEntryFilterSet(NetBoxModelFilterSet):
@@ -788,26 +791,3 @@ 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)
)

View File

@@ -744,14 +744,17 @@ class ConfigTemplateForm(SyncedDataMixin, forms.ModelForm):
class ImageAttachmentForm(forms.ModelForm):
fieldsets = (
FieldSet(ObjectAttribute('parent'), 'name', 'image'),
FieldSet(ObjectAttribute('parent'), 'image', 'name', 'description'),
)
class Meta:
model = ImageAttachment
fields = [
'name', 'image',
'image', 'name', 'description',
]
help_texts = {
'name': _("If no name is specified, the file name will be used.")
}
class JournalEntryForm(NetBoxModelForm):

View File

@@ -90,7 +90,10 @@ class ScriptJob(JobRunner):
request: The WSGI request associated with this execution (if any)
commit: Passed through to Script.run()
"""
script = ScriptModel.objects.get(pk=self.job.object_id).python_class()
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}")
# Add files to form data
if request:
@@ -100,6 +103,7 @@ 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.

View File

@@ -14,9 +14,16 @@ from utilities.proxy import resolve_proxies
class Command(BaseCommand):
help = "Perform nightly housekeeping tasks. (This command can be run at any time.)"
help = "Perform nightly housekeeping tasks [DEPRECATED]"
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)

View File

@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0129_fix_script_paths'),
]
operations = [
migrations.AddField(
model_name='imageattachment',
name='description',
field=models.CharField(blank=True, max_length=200),
),
]

View File

@@ -2,17 +2,16 @@ import importlib.abc
import importlib.util
import os
import sys
from django.core.files.storage import storages
from django.db import models
from django.http import HttpResponse
from django.utils.module_loading import import_string
from django.utils.translation import gettext_lazy as _
from django.http import HttpResponse
from extras.constants import DEFAULT_MIME_TYPE, JINJA_ENV_PARAMS_WITH_PATH_IMPORT
from extras.constants import DEFAULT_MIME_TYPE
from extras.utils import filename_from_model, filename_from_object
from utilities.jinja2 import render_jinja2
__all__ = (
'PythonModuleMixin',
'RenderTemplateMixin',
@@ -126,22 +125,12 @@ 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.get_environment_params()
env_params = self.environment_params or {}
output = render_jinja2(self.template_code, context, env_params, getattr(self, 'data_file', None))
# Replace CRLF-style line terminators

View File

@@ -1,4 +1,5 @@
import json
import os
import urllib.parse
from django.conf import settings
@@ -678,6 +679,11 @@ class ImageAttachment(ChangeLoggedModel):
max_length=50,
blank=True
)
description = models.CharField(
verbose_name=_('description'),
max_length=200,
blank=True
)
objects = RestrictedQuerySet.as_manager()
@@ -692,10 +698,10 @@ class ImageAttachment(ChangeLoggedModel):
verbose_name_plural = _('image attachments')
def __str__(self):
if self.name:
return self.name
filename = self.image.name.rsplit('/', 1)[-1]
return filename.split('_', 2)[2]
return self.name or self.filename
def get_absolute_url(self):
return reverse('extras:imageattachment', args=[self.pk])
def clean(self):
super().clean()
@@ -719,6 +725,10 @@ class ImageAttachment(ChangeLoggedModel):
# before the request finishes. (For example, to display a message indicating the ImageAttachment was deleted.)
self.image.name = _name
@property
def filename(self):
return os.path.basename(self.image.name).split('_', 2)[2]
@property
def size(self):
"""

View File

@@ -83,6 +83,3 @@ 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.

View File

@@ -14,6 +14,17 @@ class CustomFieldIndex(SearchIndex):
display_attrs = ('description',)
@register_search
class ImageAttachmentIndex(SearchIndex):
model = models.ImageAttachment
fields = (
('name', 100),
('filename', 110),
('description', 500),
)
display_attrs = ('description',)
@register_search
class JournalEntryIndex(SearchIndex):
model = models.JournalEntry

View File

@@ -1,6 +1,7 @@
import json
import django_tables2 as tables
from django.template.defaultfilters import filesizeformat
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
@@ -38,10 +39,11 @@ __all__ = (
IMAGEATTACHMENT_IMAGE = """
{% if record.image %}
<a class="image-preview" href="{{ record.image.url }}" target="_blank">{{ record }}</a>
{% else %}
&mdash;
<a class="image-preview" href="{{ record.image.url }}" target="_blank">
<i class="mdi mdi-image"></i>
</a>
{% endif %}
<a href="{{ record.get_absolute_url }}">{{ record }}</a>
"""
NOTIFICATION_ICON = """
@@ -230,29 +232,50 @@ class ImageAttachmentTable(NetBoxTable):
verbose_name=_('ID'),
linkify=False
)
image = columns.TemplateColumn(
verbose_name=_('Image'),
template_code=IMAGEATTACHMENT_IMAGE,
)
name = tables.Column(
verbose_name=_('Name'),
linkify=True,
)
filename = tables.Column(
verbose_name=_('Filename'),
linkify=lambda record: record.image.url,
orderable=False,
)
dimensions = columns.TemplateColumn(
verbose_name=_('Dimensions'),
orderable=False,
template_code="{{ record.image_width }}×{{ record.image_height }}",
)
object_type = columns.ContentTypeColumn(
verbose_name=_('Object Type'),
)
parent = tables.Column(
verbose_name=_('Parent'),
linkify=True
)
image = tables.TemplateColumn(
verbose_name=_('Image'),
template_code=IMAGEATTACHMENT_IMAGE,
linkify=True,
orderable=False,
)
size = tables.Column(
orderable=False,
verbose_name=_('Size (Bytes)')
verbose_name=_('Size')
)
class Meta(NetBoxTable.Meta):
model = ImageAttachment
fields = (
'pk', 'object_type', 'parent', 'image', 'name', 'image_height', 'image_width', 'size', 'created',
'last_updated',
'pk', 'object_type', 'parent', 'image', 'name', 'filename', 'description', 'image_height', 'image_width',
'size', 'created', 'last_updated',
)
default_columns = ('object_type', 'parent', 'image', 'name', 'size', 'created')
default_columns = ('image', 'parent', 'description', 'dimensions', 'size')
def render_size(self, value):
return filesizeformat(value)
def value_size(self, value):
return value
class SavedFilterTable(NetBoxTable):

View File

@@ -3,7 +3,6 @@ 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 *
@@ -580,7 +579,7 @@ class ImageAttachmentTest(
APIViewTestCases.GraphQLTestCase
):
model = ImageAttachment
brief_fields = ['display', 'id', 'image', 'name', 'url']
brief_fields = ['description', 'display', 'id', 'image', 'name', 'url']
@classmethod
def setUpTestData(cls):
@@ -921,22 +920,6 @@ 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']

View File

@@ -14,12 +14,13 @@ 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.constants import DEFAULT_ACTION_PERMISSIONS
from netbox.object_actions import *
from netbox.views import generic
from netbox.views.generic.mixins import TableMixin
from utilities.forms import ConfirmationForm, get_field_value
@@ -96,6 +97,11 @@ 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')
@@ -165,6 +171,11 @@ 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()
@@ -215,6 +226,11 @@ 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()
@@ -232,11 +248,7 @@ class ExportTemplateListView(generic.ObjectListView):
filterset = filtersets.ExportTemplateFilterSet
filterset_form = forms.ExportTemplateFilterForm
table = tables.ExportTemplateTable
template_name = 'extras/exporttemplate_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_sync': {'sync'},
}
actions = (AddObject, BulkImport, BulkSync, BulkExport, BulkEdit, BulkRename, BulkDelete)
@register_model_view(ExportTemplate)
@@ -270,6 +282,11 @@ 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()
@@ -330,6 +347,11 @@ 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()
@@ -347,9 +369,7 @@ class TableConfigListView(SharedObjectViewMixin, generic.ObjectListView):
filterset = filtersets.TableConfigFilterSet
filterset_form = forms.TableConfigFilterForm
table = tables.TableConfigTable
actions = {
'export': {'view'},
}
actions = (BulkExport, BulkEdit, BulkRename, BulkDelete)
@register_model_view(TableConfig)
@@ -389,6 +409,11 @@ 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()
@@ -470,6 +495,11 @@ 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()
@@ -616,6 +646,11 @@ 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()
@@ -666,6 +701,11 @@ 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()
@@ -740,6 +780,11 @@ 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(
@@ -758,13 +803,7 @@ class ConfigContextListView(generic.ObjectListView):
filterset = filtersets.ConfigContextFilterSet
filterset_form = forms.ConfigContextFilterForm
table = tables.ConfigContextTable
template_name = 'extras/configcontext_list.html'
actions = {
'add': {'add'},
'bulk_edit': {'change'},
'bulk_delete': {'delete'},
'bulk_sync': {'sync'},
}
actions = (AddObject, BulkSync, BulkEdit, BulkRename, BulkDelete)
@register_model_view(ConfigContext)
@@ -825,6 +864,11 @@ 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()
@@ -877,11 +921,7 @@ class ConfigTemplateListView(generic.ObjectListView):
filterset = filtersets.ConfigTemplateFilterSet
filterset_form = forms.ConfigTemplateFilterForm
table = tables.ConfigTemplateTable
template_name = 'extras/configtemplate_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_sync': {'sync'},
}
actions = (AddObject, BulkImport, BulkExport, BulkSync, BulkEdit, BulkRename, BulkDelete)
@register_model_view(ConfigTemplate)
@@ -915,6 +955,11 @@ 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()
@@ -992,9 +1037,12 @@ class ImageAttachmentListView(generic.ObjectListView):
filterset = filtersets.ImageAttachmentFilterSet
filterset_form = forms.ImageAttachmentFilterForm
table = tables.ImageAttachmentTable
actions = {
'export': {'view'},
}
actions = (BulkExport,)
@register_model_view(ImageAttachment)
class ImageAttachmentView(generic.ObjectView):
queryset = ImageAttachment.objects.all()
@register_model_view(ImageAttachment, 'add', detail=False)
@@ -1010,9 +1058,6 @@ class ImageAttachmentEditView(generic.ObjectEditView):
instance.parent = get_object_or_404(object_type.model_class(), pk=request.GET.get('object_id'))
return instance
def get_return_url(self, request, obj=None):
return obj.parent.get_absolute_url() if obj else super().get_return_url(request)
def get_extra_addanother_params(self, request):
return {
'object_type': request.GET.get('object_type'),
@@ -1024,9 +1069,6 @@ class ImageAttachmentEditView(generic.ObjectEditView):
class ImageAttachmentDeleteView(generic.ObjectDeleteView):
queryset = ImageAttachment.objects.all()
def get_return_url(self, request, obj=None):
return obj.parent.get_absolute_url() if obj else super().get_return_url(request)
#
# Journal entries
@@ -1038,12 +1080,7 @@ class JournalEntryListView(generic.ObjectListView):
filterset = filtersets.JournalEntryFilterSet
filterset_form = forms.JournalEntryFilterForm
table = tables.JournalEntryTable
actions = {
'export': {'view'},
'bulk_import': {'add'},
'bulk_edit': {'change'},
'bulk_delete': {'delete'},
}
actions = (BulkImport, BulkEdit, BulkDelete)
@register_model_view(JournalEntry)

View File

@@ -660,7 +660,7 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFil
service_id = django_filters.ModelMultipleChoiceFilter(
field_name='services',
queryset=Service.objects.all(),
label=_('Service (ID)'),
label=_('Application Service (ID)'),
)
nat_inside_id = django_filters.ModelMultipleChoiceFilter(
field_name='nat_inside',

View File

@@ -20,6 +20,7 @@ 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
@@ -680,7 +681,15 @@ class VLANForm(TenancyForm, NetBoxModelForm):
queryset=Site.objects.all(),
required=False,
null_option='None',
selector=True
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.'
)
)
)
)
role = DynamicModelChoiceField(
label=_('Role'),
@@ -749,7 +758,7 @@ class ServiceTemplateForm(NetBoxModelForm):
comments = CommentField()
fieldsets = (
FieldSet('name', 'protocol', 'ports', 'description', 'tags', name=_('Service Template')),
FieldSet('name', 'protocol', 'ports', 'description', 'tags', name=_('Application Service Template')),
)
class Meta:
@@ -794,7 +803,7 @@ class ServiceForm(NetBoxModelForm):
FieldSet(
'parent_object_type', 'parent', 'name',
InlineFields('protocol', 'ports', label=_('Port(s)')),
'ipaddresses', 'description', 'tags', name=_('Service')
'ipaddresses', 'description', 'tags', name=_('Application Service')
),
)
@@ -836,7 +845,7 @@ class ServiceForm(NetBoxModelForm):
class ServiceCreateForm(ServiceForm):
service_template = DynamicModelChoiceField(
label=_('Service template'),
label=_('Application Service template'),
queryset=ServiceTemplate.objects.all(),
required=False
)
@@ -848,7 +857,7 @@ class ServiceCreateForm(ServiceForm):
FieldSet('service_template', name=_('From Template')),
FieldSet('name', 'protocol', 'ports', name=_('Custom')),
),
'ipaddresses', 'description', 'tags', name=_('Service')
'ipaddresses', 'description', 'tags', name=_('Application Service')
),
)
@@ -877,4 +886,6 @@ class ServiceCreateForm(ServiceForm):
if not self.cleaned_data['description']:
self.cleaned_data['description'] = service_template.description
elif not all(self.cleaned_data[f] for f in ('name', 'protocol', 'ports')):
raise forms.ValidationError(_("Must specify name, protocol, and port(s) if not using a service template."))
raise forms.ValidationError(
_("Must specify name, protocol, and port(s) if not using an application service template.")
)

View File

@@ -55,8 +55,8 @@ class ServiceTemplate(ServiceBase, PrimaryModel):
class Meta:
ordering = ('name',)
verbose_name = _('service template')
verbose_name_plural = _('service templates')
verbose_name = _('application service template')
verbose_name_plural = _('application service templates')
class Service(ContactsMixin, ServiceBase, PrimaryModel):
@@ -84,7 +84,7 @@ class Service(ContactsMixin, ServiceBase, PrimaryModel):
related_name='services',
blank=True,
verbose_name=_('IP addresses'),
help_text=_("The specific IP addresses (if any) to which this service is bound")
help_text=_("The specific IP addresses (if any) to which this application service is bound")
)
clone_fields = ['protocol', 'ports', 'description', 'parent', 'ipaddresses', ]
@@ -94,5 +94,5 @@ class Service(ContactsMixin, ServiceBase, PrimaryModel):
models.Index(fields=('parent_object_type', 'parent_object_id')),
)
ordering = ('protocol', 'ports', 'pk') # (protocol, port) may be non-unique
verbose_name = _('service')
verbose_name_plural = _('services')
verbose_name = _('application service')
verbose_name_plural = _('application services')

View File

@@ -1162,6 +1162,7 @@ class ServiceTemplateTest(APIViewTestCases.APIViewTestCase):
bulk_update_data = {
'description': 'New description',
}
graphql_base_name = 'service_template'
@classmethod
def setUpTestData(cls):
@@ -1197,6 +1198,7 @@ class ServiceTest(APIViewTestCases.APIViewTestCase):
bulk_update_data = {
'description': 'New description',
}
graphql_base_name = 'service'
@classmethod
def setUpTestData(cls):

View File

@@ -1101,6 +1101,9 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = IPAddress.objects.all()
filterset = IPAddressFilterSet
ignore_fields = ('fhrpgroup',)
filter_name_map = {
'application_service': 'service',
}
@classmethod
def setUpTestData(cls):

View File

@@ -10,6 +10,7 @@ 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
@@ -86,6 +87,11 @@ 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()
@@ -136,6 +142,11 @@ 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()
@@ -195,6 +206,11 @@ 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(
@@ -268,6 +284,11 @@ 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()
@@ -335,6 +356,11 @@ 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(
@@ -356,6 +382,7 @@ class AggregateListView(generic.ObjectListView):
filterset = filtersets.AggregateFilterSet
filterset_form = forms.AggregateFilterForm
table = tables.AggregateTable
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
@register_model_view(Aggregate)
@@ -488,6 +515,11 @@ 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()
@@ -506,6 +538,7 @@ 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)
@@ -766,6 +799,11 @@ 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()
@@ -783,6 +821,7 @@ class IPAddressListView(generic.ObjectListView):
filterset = filtersets.IPAddressFilterSet
filterset_form = forms.IPAddressFilterForm
table = tables.IPAddressTable
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
@register_model_view(IPAddress)
@@ -1006,6 +1045,11 @@ 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')
@@ -1095,6 +1139,11 @@ 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()
@@ -1112,6 +1161,7 @@ class VLANTranslationRuleListView(generic.ObjectListView):
filterset = filtersets.VLANTranslationRuleFilterSet
filterset_form = forms.VLANTranslationRuleFilterForm
table = tables.VLANTranslationRuleTable
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
@register_model_view(VLANTranslationRule)
@@ -1244,6 +1294,11 @@ 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()
@@ -1371,6 +1426,11 @@ 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()
@@ -1421,6 +1481,11 @@ 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()
@@ -1488,6 +1553,11 @@ 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')

View File

@@ -12,7 +12,3 @@ class SerializerNotFound(Exception):
class GraphQLTypeNotFound(Exception):
pass
class QuerySetNotOrdered(Exception):
pass

View File

@@ -1,7 +1,6 @@
from django.db.models import QuerySet
from rest_framework.pagination import LimitOffsetPagination
from netbox.api.exceptions import QuerySetNotOrdered
from netbox.config import get_config
@@ -16,12 +15,6 @@ 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:

View File

@@ -65,6 +65,7 @@ class StatusView(APIView):
return Response({
'django-version': DJANGO_VERSION,
'hostname': settings.HOSTNAME,
'installed-apps': installed_apps,
'netbox-version': settings.RELEASE.version,
'netbox-full-version': settings.RELEASE.full_version,

View File

@@ -28,7 +28,8 @@ ADVISORY_LOCK_KEYS = {
'job-schedules': 110100,
}
# Default view action permission mapping
# TODO: Remove in NetBox v4.6
# Legacy default view action permission mapping
DEFAULT_ACTION_PERMISSIONS = {
'add': {'add'},
'export': {'view'},
@@ -43,3 +44,10 @@ CENSOR_TOKEN_CHANGED = '***CHANGED***'
# Placeholder text for empty tables
EMPTY_TABLE_TEXT = 'No results found'
# CSV delimiters
CSV_DELIMITERS = {
'comma': ',',
'semicolon': ';',
'pipe': '|',
}

View File

@@ -8,7 +8,7 @@ from django.utils.translation import gettext_lazy as _
from core.models import ObjectType
from extras.choices import *
from extras.models import CustomField, Tag
from utilities.forms import CSVModelForm
from utilities.forms import BulkEditForm, CSVModelForm
from utilities.forms.fields import CSVModelMultipleChoiceField, DynamicModelMultipleChoiceField
from utilities.forms.mixins import CheckLastUpdatedMixin
from .mixins import CustomFieldsMixin, SavedFiltersMixin, TagsMixin
@@ -100,7 +100,7 @@ class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm):
return customfield.to_form_field(for_csv_import=True)
class NetBoxModelBulkEditForm(CustomFieldsMixin, forms.Form):
class NetBoxModelBulkEditForm(CustomFieldsMixin, BulkEditForm):
"""
Base form for modifying multiple NetBox objects (of the same type) in bulk via the UI. Adds support for custom
fields and adding/removing tags.
@@ -108,9 +108,8 @@ class NetBoxModelBulkEditForm(CustomFieldsMixin, forms.Form):
Attributes:
fieldsets: An iterable of two-tuples which define a heading and field set to display per section of
the rendered form (optional). If not defined, the all fields will be rendered as a single section.
nullable_fields: A list of field names indicating which fields support being set to null/empty
"""
nullable_fields = ()
fieldsets = None
pk = forms.ModelMultipleChoiceField(
queryset=None, # Set from self.model on init

View File

@@ -8,12 +8,16 @@ 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',
)
@@ -35,6 +39,19 @@ 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.
@@ -53,6 +70,11 @@ 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__)
@@ -161,3 +183,34 @@ 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()
if data.errors:
raise JobFailed()

View File

@@ -1,8 +1,5 @@
from contextlib import ExitStack
import logging
import uuid
import warnings
from django.conf import settings
from django.contrib import auth, messages
@@ -13,10 +10,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',
@@ -36,12 +33,7 @@ class CoreMiddleware:
request.id = uuid.uuid4()
# Apply all registered request processors
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}')
with apply_request_processors(request):
response = self.get_response(request)
# Check if language cookie should be renewed

View File

@@ -209,8 +209,8 @@ IPAM_MENU = Menu(
label=_('Other'),
items=(
get_model_item('ipam', 'fhrpgroup', _('FHRP Groups')),
get_model_item('ipam', 'servicetemplate', _('Service Templates')),
get_model_item('ipam', 'service', _('Services')),
get_model_item('ipam', 'servicetemplate', _('Application Service Templates')),
get_model_item('ipam', 'service', _('Application Services')),
),
),
),

View File

@@ -0,0 +1,185 @@
from django.template import loader
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
template_name: Name of the HTML template which renders the 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
template_name = 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 render(cls, obj, **kwargs):
context = {
'url': cls.get_url(obj),
'label': cls.label,
**kwargs,
}
return loader.render_to_string(cls.template_name, context)
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'

View File

@@ -72,6 +72,16 @@ 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')
),
}

View File

@@ -27,7 +27,6 @@ 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__)))
@@ -125,6 +124,7 @@ 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)

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