Compare commits

..

41 Commits

Author SHA1 Message Date
Jason Novinger
6ca3908715 Release v4.3.5 (#19975)
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
* Release v4.3.5

* Fix missing strawberry-graphql==0.278.0 specifier
2025-07-29 15:28:49 -05:00
Jason Novinger
c736ce3179 Fixes #18900: raise QuerySetNotOrdered exception when trying to paginate unordered API querysets (#19943)
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
* Fixes #18900: introduce/raise QuerySetNotOrdered exception

Defines a new exception, `QuerySetNotOrdered`, and raises it in
`OptionalLimitOffsetPagination.paginate_queryset` in the right
conditions:
- the iterable to be paginated is a QuerySet isinstance
- the `queryset.ordered` flag is not truthy

* Don't try to reapply ordering if ordering is already present

* Add ordering for failing tagged-objects list API endpoint

I chose to implement this here for TaggedItemViewSet, rather than on the
model, because any meaningful ordering is going to be done on the
related Tag instance and I didn't want to introduce potential, not well
understood side-effects by applying a model-wide ordering via a related
model field.

* Add default Token ordering behavior

* Adds basic tests for raising QuerySetNotOrdered

* Note why ordering is not applied in TaggedItem.Meta
2025-07-29 11:49:36 -05:00
Martin Rødvand
111fefdf9c Fix #19910: Add conditional to hide internet dependent links in an isolated deployment (#19951)
* Add conditional to hide internet dependent links in an isolated deployment

* Formatting

* Adjust conditional

* Formatting
2025-07-29 10:41:32 -05:00
Jeremy Stretch
063d1fef7a Closes #18797: Support path import for certain Jinja environment parameters (#19962)
* Closes #18797: Support path import for certain Jinja environment parameters

* Document dotted path support for Jinja env params
2025-07-29 09:09:25 -05:00
Jathn
6ba6ff3fee Fixes #19764: docs/administration replicating netbox - wrong table name 2025-07-29 08:57:49 -04:00
github-actions
7bb7307892 Update source translation strings 2025-07-29 05:10:14 +00:00
Jeremy Stretch
c2d3363930 Closes #18399: Refactor logic for marking data source syncing as queued (#19960)
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-28 09:04:38 -07:00
Jeremy Stretch
6e30c11017 Fixes #19956: Prevent duplicate deletion records from cascading deletions 2025-07-28 09:49:08 -04:00
github-actions
b01c75cf3a Update source translation strings 2025-07-25 05:07:26 +00:00
Jonathan Ramstedt
ffa9a52667 Closes #18936: add color name support for cable bulk import (#19949)
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-24 09:54:49 -07:00
bctiemann
47320f9958 Merge pull request #19912 from miaow2/19903-regexp
Closes #19903: Add `regex` and `iregex` filter lookup expressions and corresponding tests
2025-07-24 12:32:19 -04:00
github-actions
d08a1bd07d Update source translation strings 2025-07-24 05:05:44 +00:00
Martin Hauser
14c4aeca54 Closes #19840 - Enable Site Filtering for Devices in Cable Bulk Import (#19923)
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
* feat(dcim): Add site fields to Cable bulk import form

Introduces `side_a_site` and `side_b_site` fields for the Cable bulk
import form. Limits device choices on both sides to the selected site
for improved input validation and consistency.

* feat(dcim): Enhance test data setup with multiple sites

Refactors tests to create multiple sites and assign devices accordingly.
Updates CSV data to include `side_a_site` and `side_b_site` fields for
scenarios involving multiple sites. This improves test coverage and
alignment with real-world use cases.

* docs(dcim): Update comments explaining indent for CSV import

Improved the inline comments to clarify the rationale behind allowing
devices with duplicate names on different sites during CSV bulk import.
2025-07-23 15:50:05 -05:00
Jason Novinger
26bec1275f Fixes #19934: add description field to Tenant bulk edit form (#19937) 2025-07-23 13:41:00 -07:00
Jason Novinger
fa2d7f6516 Fixes #19916: restore Rack device representation behavior
The select list of 'Images and Label', 'Images Only', and 'Label Only'
was broken during recent work while implementing #19823.

This fixes the issue by placing the `rack_elevation` class attribute on
the <div> element that contains the SVG after being loaded by HTMX. In
addition, we needed to slightly modify the selectors in the frontend
code that looked for the elements within the SVG to hide and/or show.
Previously, it was looking inside of a contentDocument embedded in an
<object> element. The simplified version just looks inside of the
SVG containing div.
2025-07-23 08:45:40 -04:00
Marco Spizzuoco
d571cb4867 Closes #19902: add clip path to avoid overflow of device name, truncate text to improve centering (#19913)
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-22 09:44:14 -07:00
bluikko
2129355c30 Closes #19926: Remove RHEL firewalld note
Closes: #19926
2025-07-22 08:04:53 -04:00
Artem Kotik
c40bfb1445 Add regex and iregex filter lookup expressions and corresponding tests 2025-07-18 16:56:54 +02:00
github-actions
b88b5b0b1b Update source translation strings
Some checks failed
Lock threads / lock (push) Has been cancelled
Close stale issues/PRs / stale (push) Has been cancelled
Close incomplete issues / stale (push) Has been cancelled
Update translation strings / makemessages (push) Has been cancelled
2025-07-16 05:06:12 +00:00
Jason Novinger
6eeb382512 Release v4.3.4 (#19887)
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-15 12:56:11 -05:00
Jeremy Stretch
e5d6c71171 Fixes #19633: Log all evaluations of invalid event rule conditions (#19885)
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
* flush_events() should catch only import errors

* Fixes #19633: Log all evaluations of invalid event rule conditions

* Correct comment
2025-07-15 10:25:25 -05:00
Jeremy Stretch
f777bfee2e Fixes #19876: Remove Markdown rendering from CustomFieldChoiceSet description field (#19877) 2025-07-15 07:55:26 -07:00
bctiemann
8b63eb64c1 Merge pull request #19860 from netbox-community/19839-nested-object-parent-export
Fixes #19839: Enable export of parent assignment for recursively nested objects
2025-07-15 08:42:43 -04:00
Jason Novinger
cff29f9551 Fixes #19413: Group custom fields in filter tab
Replaced manual rendering of custom fields in the filter tab with the
`render_custom_fields` template tag. This change ensures that custom fields are
properly grouped, addressing the issue where they were previously displayed
without their associated groups.
2025-07-15 08:41:38 -04:00
github-actions
a5c0cae112 Update source translation strings 2025-07-15 05:05:26 +00:00
Peter
2a27e475e4 Fixes #19828: Add L2VPNTerminationType to InterfaceType (#19879)
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
Co-authored-by: swoga <3697291+swoga@users.noreply.github.com>
2025-07-14 14:42:53 -05:00
Jason Novinger
44efa037cc Fixes #19800: ModuleType import supports associating ModuleTypeProfile (#19803)
* Fixes #19800: ModuleType import supports associating ModuleTypeProfile

* Fixes up ModuleTypeTestCase to include bulk import testing

Also includes an additional regression assertion.

* Address PR feedback

I ultimately left the extra asserts in for test_bulk_import_objects_with_permissionsince
since the parent test is currently only testing against number of
objects successfully imported. Will file a follow up FR to improve that
test.
2025-07-14 15:22:52 -04:00
Jeremy Stretch
6c17629159 Fixes #19841: Add white background to upgrade paths image 2025-07-14 15:08:27 -04:00
Jeremy Stretch
f13d028c98 Fixes #19827: Enforce uniqueness for device role names & slugs (#19859) 2025-07-14 09:13:44 -07:00
bctiemann
f5d32b1bf1 Closes: #19793 - Nav menu link customization (#19794)
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
* Support menu items that are callables

* Fix quote on add button

* Clarify docstring to differentiate link and url

* Back out support for callables but keep alternate prerendered url param

* Make url a property on MenuItem/PluginMenuItem etc, overridable via a setter

* Use reverse_lazy instead of reverse

* Use reverse_lazy instead of reverse
2025-07-14 10:39:24 -04:00
Jeremy Stretch
f05897d61a Closes #18811: Match full-form IPv6 addresses in global search (#19873)
* Closes #18811: Match full-form IPv6 addresses in global search

* Fix typo
2025-07-14 09:28:30 -05:00
Luke Anderson
b5421f1cd6 Fixes #19870: Correct Documentation Formatting for Public Demo Instance URL 2025-07-14 08:45:26 -04:00
Jeremy Stretch
23cc4f1c41 Fixes #19839: Enable export of parent assignment for recursively nested objects 2025-07-10 12:41:11 -04:00
Olexandr88
9c2cd66162 Update README.md
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-09 10:53:40 -04:00
github-actions
f61a2964c8 Update source translation strings 2025-07-09 05:04:52 +00:00
Jason Novinger
ee94fb0b94 Closes #19550: Enhancement: Refactor rack elevations template for lazy loading /dcim/rack-elevations/ (#19823)
* Refactor rack elevation template to use htmx for dynamic loading and improved user experience

* rework to prevent dup loading

* Update netbox/templates/dcim/inc/rack_elevation.html

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>

* Update netbox/templates/dcim/inc/rack_elevation.html

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>

* Move inline styles to styles/custom/racks.css

---------

Co-authored-by: tony.nealon@wholesailnetworks.com <tony.nealon@wholesailnetworks.com>
Co-authored-by: tbotnz <tonynealon1989@gmail.com>
Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2025-07-08 11:20:04 -04:00
Harry
8fb8f4c75b Closes #19571: Create expansion_card.json (#19689)
* Create expansion_card.json

* Update 0206_load_module_type_profiles.py

* Update expansion_card.json

Fixed
2025-07-08 08:27:48 -05:00
github-actions
e33793dc82 Update source translation strings 2025-07-03 05:04:46 +00:00
Jeremy Stretch
3b8841ee3b Fixes #19806: Introduce JobFailed exception to allow marking background jobs as failed (#19807) 2025-07-02 14:02:49 -05:00
dieck
ea4c205a37 Upgrade documentation: have git fetch new tags
fixes #19778
2025-07-02 13:59:56 -04:00
github-actions
2a5d3abafb Update source translation strings 2025-06-27 05:03:03 +00:00
180 changed files with 11366 additions and 9458 deletions

View File

@@ -15,7 +15,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v4.3.3
placeholder: v4.3.5
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.3
placeholder: v4.3.5
validations:
required: true
- type: dropdown

View File

@@ -6,7 +6,7 @@
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://img.shields.io/github/contributors/netbox-community/netbox?color=blue" alt="Contributors" /></a>
<a href="https://github.com/netbox-community/netbox/stargazers"><img src="https://img.shields.io/github/stars/netbox-community/netbox?style=flat" alt="GitHub stars" /></a>
<a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-15-blue" alt="Languages supported" /></a>
<a href="https://github.com/netbox-community/netbox/actions/workflows/ci.yml"><img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=main" alt="CI status" /></a>
<a href="https://github.com/netbox-community/netbox/actions/workflows/ci.yml"><img src="https://github.com/netbox-community/netbox/actions/workflows/ci.yml/badge.svg" alt="CI status" /></a>
<p>
<strong><a href="https://netboxlabs.com/community/">NetBox Community</a></strong> |
<strong><a href="https://netboxlabs.com/netbox-cloud/">NetBox Cloud</a></strong> |

View File

@@ -8,12 +8,18 @@ django-cors-headers
# Runtime UI tool for debugging Django
# https://github.com/jazzband/django-debug-toolbar/blob/main/docs/changes.rst
django-debug-toolbar
# django-debug-toolbar v6.0.0 raises "Attribute Error at /: 'function' object has no attribute 'set'"
# see https://github.com/netbox-community/netbox/issues/19974
django-debug-toolbar==5.2.0
# Library for writing reusable URL query filters
# https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst
django-filter
# Django Debug Toolbar extension for GraphiQL
# https://github.com/flavors/django-graphiql-debug-toolbar/blob/main/CHANGES.rst
django-graphiql-debug-toolbar
# HTMX utilities for Django
# https://django-htmx.readthedocs.io/en/latest/changelog.html
django-htmx
@@ -108,6 +114,7 @@ nh3
# Fork of PIL (Python Imaging Library) for image processing
# https://github.com/python-pillow/Pillow/releases
# https://pillow.readthedocs.io/en/stable/releasenotes/
Pillow
# PostgreSQL database adapter for Python
@@ -126,22 +133,21 @@ requests
# https://github.com/rq/rq/blob/master/CHANGES.md
rq
# Social authentication framework
# https://github.com/python-social-auth/social-core/blob/master/CHANGELOG.md
social-auth-core
# Django app for social-auth-core
# https://github.com/python-social-auth/social-app-django/blob/master/CHANGELOG.md
social-auth-app-django
# Social authentication framework
# https://github.com/python-social-auth/social-core/blob/master/CHANGELOG.md
social-auth-core
# Strawberry GraphQL
# https://github.com/strawberry-graphql/strawberry/blob/main/CHANGELOG.md
strawberry-graphql
# Strawberry GraphQL Django extension
# https://github.com/strawberry-graphql/strawberry-django/releases
# See #19771
strawberry-graphql-django==0.60.0
strawberry-graphql-django
# SVG image rendering (used for rack elevations)
# https://github.com/mozman/svgwrite/blob/master/NEWS.rst

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 `extras_objectchange` table data from the export. The table will still be included in the output file, but will not be populated with any data.
When replicating a production database for development purposes, you may find it convenient to exclude changelog data, which can easily account for the bulk of a database's size. To do this, exclude the `core_objectchange` table data from the export. The table will still be included in the output file, but will not be populated with any data.
```no-highlight
pg_dump ... --exclude-table-data=extras_objectchange netbox > netbox.sql
pg_dump ... --exclude-table-data=core_objectchange netbox > netbox.sql
```
### Load an Exported Database

View File

@@ -158,6 +158,7 @@ LOGGING = {
* `netbox.<app>.<model>` - Generic form for model-specific log messages
* `netbox.auth.*` - Authentication events
* `netbox.api.views.*` - Views which handle business logic for the REST API
* `netbox.event_rules` - Event rules
* `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

@@ -147,7 +147,7 @@ For UI development you will need to review the [Web UI Development Guide](web-ui
## Populating Demo Data
Once you have your development environment up and running, it might be helpful to populate some "dummy" data to make interacting with the UI and APIs more convenient. Check out the [netbox-demo-data](https://github.com/netbox-community/netbox-demo-data) repo on GitHub, which houses a collection of sample data that can be easily imported to any new NetBox deployment. (This sample data is used to populate the public demo instance at <https://demo.netbox.dev>.)
Once you have your development environment up and running, it might be helpful to populate some "dummy" data to make interacting with the UI and APIs more convenient. Check out the [netbox-demo-data](https://github.com/netbox-community/netbox-demo-data) repo on GitHub, which houses a collection of sample data that can be easily imported to any new NetBox deployment. This sample data is used to populate the [public demo instance](https://demo.netbox.dev).
The demo data is provided in JSON format and loaded into an empty database using Django's `loaddata` management command. Consult the demo data repo's `README` file for complete instructions on populating the data.

View File

@@ -2,9 +2,9 @@
NetBox includes the ability to execute certain functions as background tasks. These include:
* [Report](../customization/reports.md) execution
* [Custom script](../customization/custom-scripts.md) execution
* Synchronization of [remote data sources](../integrations/synchronized-data.md)
* Housekeeping tasks
Additionally, NetBox plugins can enqueue their own background tasks. This is accomplished using the [Job model](../models/core/job.md). Background tasks are executed by the `rqworker` process(es).

View File

@@ -302,13 +302,6 @@ Quit the server with CONTROL-C.
Next, connect to the name or IP of the server (as defined in `ALLOWED_HOSTS`) on port 8000; for example, <http://127.0.0.1:8000/>. You should be greeted with the NetBox home page. Try logging in using the username and password specified when creating a superuser.
!!! note
By default RHEL based distros will likely block your testing attempts with firewalld. The development server port can be opened with `firewall-cmd` (add `--permanent` if you want the rule to survive server restarts):
```no-highlight
firewall-cmd --zone=public --add-port=8000/tcp
```
!!! danger "Not for production use"
The development server is for development and testing purposes only. It is neither performant nor secure enough for production use. **Do not use it in production.**

View File

@@ -135,7 +135,7 @@ Check out the desired release by specifying its tag. For example:
```
cd /opt/netbox && \
sudo git fetch && \
sudo git fetch --tags && \
sudo git checkout v4.2.7
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

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

View File

@@ -24,6 +24,14 @@ Jinja2 template code, if being defined locally rather than replicated from a dat
A dictionary of any additional parameters to pass when instantiating the [Jinja2 environment](https://jinja.palletsprojects.com/en/3.1.x/api/#jinja2.Environment). Jinja2 supports various optional parameters which can be used to modify its default behavior.
The `undefined` and `finalize` Jinja environment parameters, which must reference a Python class or function, can define a dotted path to the desired resource. For example:
```json
{
"undefined": "jinja2.StrictUndefined"
}
```
### MIME Type
!!! info "This field was introduced in NetBox v4.3."

View File

@@ -26,6 +26,14 @@ Jinja2 template code for rendering the exported data.
A dictionary of any additional parameters to pass when instantiating the [Jinja2 environment](https://jinja.palletsprojects.com/en/3.1.x/api/#jinja2.Environment). Jinja2 supports various optional parameters which can be used to modify its default behavior.
The `undefined` and `finalize` Jinja environment parameters, which must reference a Python class or function, can define a dotted path to the desired resource. For example:
```json
{
"undefined": "jinja2.StrictUndefined"
}
```
### MIME Type
The MIME type to indicate in the response when rendering the export template (optional). Defaults to `text/plain`.

View File

@@ -15,7 +15,6 @@ A background job implements a basic [Job](../../models/core/job.md) executor for
```python title="jobs.py"
from netbox.jobs import JobRunner
class MyTestJob(JobRunner):
class Meta:
name = "My Test Job"
@@ -25,6 +24,8 @@ class MyTestJob(JobRunner):
# your logic goes here
```
Completed jobs will have their status updated to "completed" by default, or "errored" if an unhandled exception was raised by the `run()` method. To intentionally mark a job as failed, raise the `core.exceptions.JobFailed` exception. (Note that "failed" differs from "errored" in that a failure may be expected under certain conditions, whereas an error is not.)
You can schedule the background job from within your code (e.g. from a model's `save()` method or a view) by calling `MyTestJob.enqueue()`. This method passes through all arguments to `Job.enqueue()`. However, no `name` argument must be passed, as the background job name will be used instead.
!!! tip

View File

@@ -64,7 +64,6 @@ Generic view classes (documented below) facilitate common operations, such as cr
| `ObjectListView` | View a list of objects |
| `BulkImportView` | Import a set of new objects |
| `BulkEditView` | Edit multiple objects |
| `BulkRenameView` | Rename multiple objects |
| `BulkDeleteView` | Delete multiple objects |
!!! warning
@@ -172,10 +171,6 @@ Below are the class definitions for NetBox's multi-object views. These views han
options:
members: false
::: netbox.views.generic.BulkRenameView
options:
members: false
::: netbox.views.generic.BulkDeleteView
options:
members:

View File

@@ -80,18 +80,20 @@ GET /api/ipam/vlans/?vid__gt=900
String based (char) fields (Name, Address, etc) support these lookup expressions:
| Filter | Description |
|---------|----------------------------------------|
| `n` | Not equal to |
| `ic` | Contains (case-insensitive) |
| `nic` | Does not contain (case-insensitive) |
| `isw` | Starts with (case-insensitive) |
| `nisw` | Does not start with (case-insensitive) |
| `iew` | Ends with (case-insensitive) |
| `niew` | Does not end with (case-insensitive) |
| `ie` | Exact match (case-insensitive) |
| `nie` | Inverse exact match (case-insensitive) |
| `empty` | Is empty/null (boolean) |
| Filter | Description |
|----------|----------------------------------------|
| `n` | Not equal to |
| `ic` | Contains (case-insensitive) |
| `nic` | Does not contain (case-insensitive) |
| `isw` | Starts with (case-insensitive) |
| `nisw` | Does not start with (case-insensitive) |
| `iew` | Ends with (case-insensitive) |
| `niew` | Does not end with (case-insensitive) |
| `ie` | Exact match (case-insensitive) |
| `nie` | Inverse exact match (case-insensitive) |
| `empty` | Is empty/null (boolean) |
| `regex` | Regexp matching |
| `iregex` | Regexp matching (case-insensitive) |
Here is an example of a lookup expression on a string field that will return all devices with `switch` in the name:

View File

@@ -1,5 +1,44 @@
# NetBox v4.3
## v4.3.5 (2025-07-29)
### Enhancements
* [#18399](https://github.com/netbox-community/netbox/issues/18399) - Data source synchronization jobs now properly show "queued" status when enqueued
* [#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
* [#18811](https://github.com/netbox-community/netbox/issues/18811) - Match expanded form IPv6 addresses in global search
* [#19550](https://github.com/netbox-community/netbox/issues/19550) - Enable lazy loading for rack elevations
* [#19571](https://github.com/netbox-community/netbox/issues/19571) - Add a default module type profile for expansion cards
* [#19793](https://github.com/netbox-community/netbox/issues/19793) - Support custom dynamic navigation menu links
* [#19828](https://github.com/netbox-community/netbox/issues/19828) - Expose L2VPN termination in interface GraphQL response
### Bug Fixes
* [#19413](https://github.com/netbox-community/netbox/issues/19413) - Custom fields should be grouped in filter forms
* [#19633](https://github.com/netbox-community/netbox/issues/19633) - Introduce InvalidCondition exception and log all evaluations of invalid event rule conditions
* [#19800](https://github.com/netbox-community/netbox/issues/19800) - Module type bulk import should support profile assignment
* [#19806](https://github.com/netbox-community/netbox/issues/19806) - Introduce JobFailed exception to allow marking background jobs as failed
* [#19827](https://github.com/netbox-community/netbox/issues/19827) - Enforce uniqueness for device role names & slugs
* [#19839](https://github.com/netbox-community/netbox/issues/19839) - Enable export of parent assignment for recursively nested objects
* [#19876](https://github.com/netbox-community/netbox/issues/19876) - Remove Markdown rendering from CustomFieldChoiceSet description field
---
## v4.3.3 (2025-06-26)
### Enhancements

View File

@@ -5,7 +5,6 @@ from django.utils.translation import gettext_lazy as _
from dcim.views import PathTraceView
from ipam.models import ASN
from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport
from netbox.views import generic
from utilities.forms import ConfirmationForm
from utilities.query import count_related
@@ -80,11 +79,6 @@ class ProviderBulkEditView(generic.BulkEditView):
form = forms.ProviderBulkEditForm
@register_model_view(Provider, 'bulk_rename', path='rename', detail=False)
class ProviderBulkRenameView(generic.BulkRenameView):
queryset = Provider.objects.all()
@register_model_view(Provider, 'bulk_delete', path='delete', detail=False)
class ProviderBulkDeleteView(generic.BulkDeleteView):
queryset = Provider.objects.annotate(
@@ -147,11 +141,6 @@ class ProviderAccountBulkEditView(generic.BulkEditView):
form = forms.ProviderAccountBulkEditForm
@register_model_view(ProviderAccount, 'bulk_rename', path='rename', detail=False)
class ProviderAccountBulkRenameView(generic.BulkRenameView):
queryset = ProviderAccount.objects.all()
@register_model_view(ProviderAccount, 'bulk_delete', path='delete', detail=False)
class ProviderAccountBulkDeleteView(generic.BulkDeleteView):
queryset = ProviderAccount.objects.annotate(
@@ -223,11 +212,6 @@ class ProviderNetworkBulkEditView(generic.BulkEditView):
form = forms.ProviderNetworkBulkEditForm
@register_model_view(ProviderNetwork, 'bulk_rename', path='rename', detail=False)
class ProviderNetworkBulkRenameView(generic.BulkRenameView):
queryset = ProviderNetwork.objects.all()
@register_model_view(ProviderNetwork, 'bulk_delete', path='delete', detail=False)
class ProviderNetworkBulkDeleteView(generic.BulkDeleteView):
queryset = ProviderNetwork.objects.all()
@@ -287,11 +271,6 @@ class CircuitTypeBulkEditView(generic.BulkEditView):
form = forms.CircuitTypeBulkEditForm
@register_model_view(CircuitType, 'bulk_rename', path='rename', detail=False)
class CircuitTypeBulkRenameView(generic.BulkRenameView):
queryset = CircuitType.objects.all()
@register_model_view(CircuitType, 'bulk_delete', path='delete', detail=False)
class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
queryset = CircuitType.objects.annotate(
@@ -358,12 +337,6 @@ class CircuitBulkEditView(generic.BulkEditView):
form = forms.CircuitBulkEditForm
@register_model_view(Circuit, 'bulk_rename', path='rename', detail=False)
class CircuitBulkRenameView(generic.BulkRenameView):
queryset = Circuit.objects.all()
field_name = 'cid'
@register_model_view(Circuit, 'bulk_delete', path='delete', detail=False)
class CircuitBulkDeleteView(generic.BulkDeleteView):
queryset = Circuit.objects.prefetch_related(
@@ -459,7 +432,6 @@ class CircuitTerminationListView(generic.ObjectListView):
filterset = filtersets.CircuitTerminationFilterSet
filterset_form = forms.CircuitTerminationFilterForm
table = tables.CircuitTerminationTable
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
@register_model_view(CircuitTermination)
@@ -554,11 +526,6 @@ class CircuitGroupBulkEditView(generic.BulkEditView):
form = forms.CircuitGroupBulkEditForm
@register_model_view(CircuitGroup, 'bulk_rename', path='rename', detail=False)
class CircuitGroupBulkRenameView(generic.BulkRenameView):
queryset = CircuitGroup.objects.all()
@register_model_view(CircuitGroup, 'bulk_delete', path='delete', detail=False)
class CircuitGroupBulkDeleteView(generic.BulkDeleteView):
queryset = CircuitGroup.objects.all()
@@ -576,7 +543,6 @@ class CircuitGroupAssignmentListView(generic.ObjectListView):
filterset = filtersets.CircuitGroupAssignmentFilterSet
filterset_form = forms.CircuitGroupAssignmentFilterForm
table = tables.CircuitGroupAssignmentTable
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
@register_model_view(CircuitGroupAssignment)
@@ -669,11 +635,6 @@ class VirtualCircuitTypeBulkEditView(generic.BulkEditView):
form = forms.VirtualCircuitTypeBulkEditForm
@register_model_view(VirtualCircuitType, 'bulk_rename', path='rename', detail=False)
class VirtualCircuitTypeBulkRenameView(generic.BulkRenameView):
queryset = VirtualCircuitType.objects.all()
@register_model_view(VirtualCircuitType, 'bulk_delete', path='delete', detail=False)
class VirtualCircuitTypeBulkDeleteView(generic.BulkDeleteView):
queryset = VirtualCircuitType.objects.annotate(
@@ -736,12 +697,6 @@ class VirtualCircuitBulkEditView(generic.BulkEditView):
form = forms.VirtualCircuitBulkEditForm
@register_model_view(VirtualCircuit, 'bulk_rename', path='rename', detail=False)
class VirtualCircuitulkRenameView(generic.BulkRenameView):
queryset = VirtualCircuit.objects.all()
field_name = 'cid'
class VirtualCircuitBulkDeleteView(generic.BulkDeleteView):
queryset = VirtualCircuit.objects.annotate(
termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit')
@@ -759,7 +714,6 @@ class VirtualCircuitTerminationListView(generic.ObjectListView):
filterset = filtersets.VirtualCircuitTerminationFilterSet
filterset_form = forms.VirtualCircuitTerminationFilterForm
table = tables.VirtualCircuitTerminationTable
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
@register_model_view(VirtualCircuitTermination)

View File

@@ -1,29 +1,28 @@
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404
from django.utils.translation import gettext_lazy as _
from django_rq.queues import get_redis_connection
from django_rq.settings import QUEUES_LIST
from django_rq.utils import get_statistics
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
from rest_framework.permissions import IsAdminUser
from rest_framework.response import Response
from rest_framework.routers import APIRootView
from rest_framework.viewsets import ReadOnlyModelViewSet
from rq.job import Job as RQ_Job
from rq.worker import Worker
from core import filtersets
from core.choices import DataSourceStatusChoices
from core.jobs import SyncDataSourceJob
from core.models import *
from core.utils import delete_rq_job, enqueue_rq_job, get_rq_jobs, requeue_rq_job, stop_rq_job
from django_rq.queues import get_redis_connection
from django_rq.utils import get_statistics
from django_rq.settings import QUEUES_LIST
from netbox.api.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
@@ -50,10 +49,8 @@ class DataSourceViewSet(NetBoxModelViewSet):
if not request.user.has_perm('core.sync_datasource', obj=datasource):
raise PermissionDenied(_("This user does not have permission to synchronize this data source."))
# Enqueue the sync job & update the DataSource's status
# Enqueue the sync job
SyncDataSourceJob.enqueue(instance=datasource, user=request.user)
datasource.status = DataSourceStatusChoices.QUEUED
DataSource.objects.filter(pk=datasource.pk).update(status=datasource.status)
serializer = serializers.DataSourceSerializer(datasource, context={'request': request})

View File

@@ -1,9 +1,19 @@
from django.core.exceptions import ImproperlyConfigured
class SyncError(Exception):
pass
__all__ = (
'IncompatiblePluginError',
'JobFailed',
'SyncError',
)
class IncompatiblePluginError(ImproperlyConfigured):
pass
class JobFailed(Exception):
pass
class SyncError(Exception):
pass

View File

@@ -21,6 +21,17 @@ 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)

View File

@@ -1,9 +1,7 @@
from django.contrib.contenttypes.models import ContentType, ContentTypeManager
from django.db.models import Q
from netbox.plugins import PluginConfig
from netbox.registry import registry
from utilities.string import title
__all__ = (
'ObjectType',
@@ -50,29 +48,3 @@ class ObjectType(ContentType):
class Meta:
proxy = True
@property
def app_labeled_name(self):
# Override ContentType's "app | model" representation style.
return f"{self.app_verbose_name} > {title(self.model_verbose_name)}"
@property
def app_verbose_name(self):
if model := self.model_class():
return model._meta.app_config.verbose_name
@property
def model_verbose_name(self):
if model := self.model_class():
return model._meta.verbose_name
@property
def model_verbose_name_plural(self):
if model := self.model_class():
return model._meta.verbose_name_plural
@property
def is_plugin_model(self):
if not (model := self.model_class()):
return # Return null if model class is invalid
return isinstance(model._meta.app_config, PluginConfig)

View File

@@ -187,15 +187,14 @@ class Job(models.Model):
"""
Mark the job as completed, optionally specifying a particular termination status.
"""
valid_statuses = JobStatusChoices.TERMINAL_STATE_CHOICES
if status not in valid_statuses:
if status not in JobStatusChoices.TERMINAL_STATE_CHOICES:
raise ValueError(
_("Invalid status for job termination. Choices are: {choices}").format(
choices=', '.join(valid_statuses)
choices=', '.join(JobStatusChoices.TERMINAL_STATE_CHOICES)
)
)
# Mark the job as completed
# Set the job's status and completion time
self.status = status
if error:
self.error = error

View File

@@ -1,18 +0,0 @@
from django.utils.translation import gettext as _
from netbox.object_actions import ObjectAction
__all__ = (
'BulkSync',
)
class BulkSync(ObjectAction):
"""
Synchronize multiple objects at once.
"""
name = 'bulk_sync'
label = _('Sync Data')
multi = True
permissions_required = {'sync'}
template_name = 'core/buttons/bulk_sync.html'

View File

@@ -1,10 +1,12 @@
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
@@ -42,6 +44,10 @@ clear_events = Signal()
# Change logging & event handling
#
# Used to track received signals per object
_signals_received = local()
@receiver((post_save, m2m_changed))
def handle_changed_object(sender, instance, **kwargs):
"""
@@ -130,6 +136,16 @@ def handle_deleted_object(sender, instance, **kwargs):
if request is None:
return
# Check whether we've already processed a pre_delete signal for this object. (This can
# happen e.g. when both a parent object and its child are deleted simultaneously, due
# to cascading deletion.)
if not hasattr(_signals_received, 'pre_delete'):
_signals_received.pre_delete = set()
signature = (ContentType.objects.get_for_model(instance), instance.pk)
if signature in _signals_received.pre_delete:
return
_signals_received.pre_delete.add(signature)
# Record an ObjectChange if applicable
if hasattr(instance, 'to_objectchange'):
if hasattr(instance, 'snapshot') and not getattr(instance, '_prechange_snapshot', None):
@@ -179,6 +195,14 @@ def handle_deleted_object(sender, instance, **kwargs):
model_deletes.labels(instance._meta.model_name).inc()
@receiver(request_finished)
def clear_signal_history(sender, **kwargs):
"""
Clear out the signals history once the request is finished.
"""
_signals_received.pre_delete = set()
@receiver(clear_events)
def clear_events_queue(sender, **kwargs):
"""

View File

@@ -346,6 +346,38 @@ class ChangeLogViewTest(ModelViewTestCase):
self.assertEqual(changes[1].changed_object_type, ContentType.objects.get_for_model(Interface))
self.assertEqual(changes[2].changed_object_type, ContentType.objects.get_for_model(Device))
def test_duplicate_deletions(self):
"""
Check that a cascading deletion event does not generate multiple "deleted" ObjectChange records for
the same object.
"""
role1 = DeviceRole(name='Role 1', slug='role-1')
role1.save()
role2 = DeviceRole(name='Role 2', slug='role-2', parent=role1)
role2.save()
pk_list = [role1.pk, role2.pk]
# Delete both objects simultaneously
form_data = {
'pk': pk_list,
'confirm': True,
'_confirm': True,
}
request = {
'path': reverse('dcim:devicerole_bulk_delete'),
'data': post_data(form_data),
}
self.add_permissions('dcim.delete_devicerole')
self.assertHttpStatus(self.client.post(**request), 302)
# This should result in exactly one change record per object
objectchanges = ObjectChange.objects.filter(
changed_object_type=ContentType.objects.get_for_model(DeviceRole),
changed_object_id__in=pk_list,
action=ObjectChangeActionChoices.ACTION_DELETE
)
self.assertEqual(objectchanges.count(), 2)
class ChangeLogAPITest(APITestCase):

View File

@@ -22,7 +22,6 @@ from rq.worker_registration import clean_worker_registry
from core.utils import delete_rq_job, enqueue_rq_job, get_rq_jobs_from_status, requeue_rq_job, stop_rq_job
from netbox.config import get_config, PARAMS
from netbox.object_actions import AddObject, BulkDelete, BulkExport, DeleteObject
from netbox.registry import registry
from netbox.views import generic
from netbox.views.generic.base import BaseObjectView
@@ -34,7 +33,6 @@ from utilities.json import ConfigJSONEncoder
from utilities.query import count_related
from utilities.views import ContentTypePermissionRequiredMixin, GetRelatedModelsMixin, register_model_view
from . import filtersets, forms, tables
from .choices import DataSourceStatusChoices
from .jobs import SyncDataSourceJob
from .models import *
from .plugins import get_catalog_plugins, get_local_plugins
@@ -79,12 +77,8 @@ class DataSourceSyncView(BaseObjectView):
def post(self, request, pk):
datasource = get_object_or_404(self.queryset, pk=pk)
# Enqueue the sync job & update the DataSource's status
# Enqueue the sync job
job = SyncDataSourceJob.enqueue(instance=datasource, user=request.user)
datasource.status = DataSourceStatusChoices.QUEUED
DataSource.objects.filter(pk=datasource.pk).update(status=datasource.status)
messages.success(
request,
_("Queued job #{id} to sync {datasource}").format(id=job.pk, datasource=datasource)
@@ -120,11 +114,6 @@ class DataSourceBulkEditView(generic.BulkEditView):
form = forms.DataSourceBulkEditForm
@register_model_view(DataSource, 'bulk_rename', path='rename', detail=False)
class DataSourceBulkRenameView(generic.BulkRenameView):
queryset = DataSource.objects.all()
@register_model_view(DataSource, 'bulk_delete', path='delete', detail=False)
class DataSourceBulkDeleteView(generic.BulkDeleteView):
queryset = DataSource.objects.annotate(
@@ -144,13 +133,14 @@ class DataFileListView(generic.ObjectListView):
filterset = filtersets.DataFileFilterSet
filterset_form = forms.DataFileFilterForm
table = tables.DataFileTable
actions = (BulkDelete,)
actions = {
'bulk_delete': {'delete'},
}
@register_model_view(DataFile)
class DataFileView(generic.ObjectView):
queryset = DataFile.objects.all()
actions = (DeleteObject,)
@register_model_view(DataFile, 'delete')
@@ -175,13 +165,15 @@ class JobListView(generic.ObjectListView):
filterset = filtersets.JobFilterSet
filterset_form = forms.JobFilterForm
table = tables.JobTable
actions = (BulkExport, BulkDelete)
actions = {
'export': {'view'},
'bulk_delete': {'delete'},
}
@register_model_view(Job)
class JobView(generic.ObjectView):
queryset = Job.objects.all()
actions = (DeleteObject,)
@register_model_view(Job, 'delete')
@@ -207,7 +199,9 @@ class ObjectChangeListView(generic.ObjectListView):
filterset_form = forms.ObjectChangeFilterForm
table = tables.ObjectChangeTable
template_name = 'core/objectchange_list.html'
actions = (BulkExport,)
actions = {
'export': {'view'},
}
@register_model_view(ObjectChange)
@@ -275,7 +269,6 @@ class ConfigRevisionListView(generic.ObjectListView):
filterset = filtersets.ConfigRevisionFilterSet
filterset_form = forms.ConfigRevisionFilterForm
table = tables.ConfigRevisionTable
actions = (AddObject, BulkExport)
@register_model_view(ConfigRevision)

View File

@@ -470,8 +470,8 @@ class ModuleTypeImportForm(NetBoxModelImportForm):
class Meta:
model = ModuleType
fields = [
'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit', 'comments',
'tags',
'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit', 'profile',
'comments', 'tags'
]
@@ -1335,6 +1335,13 @@ class MACAddressImportForm(NetBoxModelImportForm):
class CableImportForm(NetBoxModelImportForm):
# Termination A
side_a_site = CSVModelChoiceField(
label=_('Side A site'),
queryset=Site.objects.all(),
required=False,
to_field_name='name',
help_text=_('Site of parent device A (if any)'),
)
side_a_device = CSVModelChoiceField(
label=_('Side A device'),
queryset=Device.objects.all(),
@@ -1353,6 +1360,13 @@ class CableImportForm(NetBoxModelImportForm):
)
# Termination B
side_b_site = CSVModelChoiceField(
label=_('Side B site'),
queryset=Site.objects.all(),
required=False,
to_field_name='name',
help_text=_('Site of parent device B (if any)'),
)
side_b_device = CSVModelChoiceField(
label=_('Side B device'),
queryset=Device.objects.all(),
@@ -1396,14 +1410,39 @@ class CableImportForm(NetBoxModelImportForm):
required=False,
help_text=_('Length unit')
)
color = forms.CharField(
label=_('Color'),
required=False,
max_length=16,
help_text=_('Color name (e.g. "Red") or hex code (e.g. "f44336")')
)
class Meta:
model = Cable
fields = [
'side_a_device', 'side_a_type', 'side_a_name', 'side_b_device', 'side_b_type', 'side_b_name', 'type',
'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'comments', 'tags',
'side_a_site', 'side_a_device', 'side_a_type', 'side_a_name', 'side_b_site', 'side_b_device', 'side_b_type',
'side_b_name', 'type', 'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description',
'comments', 'tags',
]
def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs)
if data:
# Limit choices for side_a_device to the assigned side_a_site
if side_a_site := data.get('side_a_site'):
side_a_device_params = {f'site__{self.fields["side_a_site"].to_field_name}': side_a_site}
self.fields['side_a_device'].queryset = self.fields['side_a_device'].queryset.filter(
**side_a_device_params
)
# Limit choices for side_b_device to the assigned side_b_site
if side_b_site := data.get('side_b_site'):
side_b_device_params = {f'site__{self.fields["side_b_site"].to_field_name}': side_b_site}
self.fields['side_b_device'].queryset = self.fields['side_b_device'].queryset.filter(
**side_b_device_params
)
def _clean_side(self, side):
"""
Derive a Cable's A/B termination objects.
@@ -1440,6 +1479,24 @@ class CableImportForm(NetBoxModelImportForm):
setattr(self.instance, f'{side}_terminations', [termination_object])
return termination_object
def _clean_color(self, color):
"""
Derive a colors hex code
:param color: color as hex or color name
"""
color_parsed = color.strip().lower()
for hex_code, label in ColorChoices.CHOICES:
if color.lower() == label.lower():
color_parsed = hex_code
if len(color_parsed) > 6:
raise forms.ValidationError(
_(f"{color} did not match any used color name and was longer than six characters: invalid hex.")
)
return color_parsed
def clean_side_a_name(self):
return self._clean_side('a')
@@ -1451,11 +1508,14 @@ class CableImportForm(NetBoxModelImportForm):
length_unit = self.cleaned_data.get('length_unit', None)
return length_unit if length_unit is not None else ''
def clean_color(self):
color = self.cleaned_data.get('color', None)
return self._clean_color(color) if color is not None else ''
#
# Virtual chassis
#
class VirtualChassisImportForm(NetBoxModelImportForm):
master = CSVModelChoiceField(
label=_('Master'),

View File

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

View File

@@ -33,6 +33,7 @@ if TYPE_CHECKING:
from tenancy.graphql.types import TenantType
from users.graphql.types import UserType
from virtualization.graphql.types import ClusterType, VMInterfaceType, VirtualMachineType
from vpn.graphql.types import L2VPNTerminationType
from wireless.graphql.types import WirelessLANType, WirelessLinkType
__all__ = (
@@ -440,6 +441,7 @@ class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, P
primary_mac_address: Annotated["MACAddressType", strawberry.lazy('dcim.graphql.types')] | None
qinq_svlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
vlan_translation_policy: Annotated["VLANTranslationPolicyType", strawberry.lazy('ipam.graphql.types')] | None
l2vpn_termination: Annotated["L2VPNTerminationType", strawberry.lazy('vpn.graphql.types')] | None
vdcs: List[Annotated["VirtualDeviceContextType", strawberry.lazy('dcim.graphql.types')]]
tagged_vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]]

View File

@@ -19,7 +19,8 @@ def load_initial_data(apps, schema_editor):
'gpu',
'hard_disk',
'memory',
'power_supply'
'power_supply',
'expansion_card'
)
for name in initial_profiles:

View File

@@ -0,0 +1,44 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0207_remove_redundant_indexes'),
('extras', '0129_fix_script_paths'),
]
operations = [
migrations.AddConstraint(
model_name='devicerole',
constraint=models.UniqueConstraint(
fields=('parent', 'name'),
name='dcim_devicerole_parent_name'
),
),
migrations.AddConstraint(
model_name='devicerole',
constraint=models.UniqueConstraint(
condition=models.Q(('parent__isnull', True)),
fields=('name',),
name='dcim_devicerole_name',
violation_error_message='A top-level device role with this name already exists.'
),
),
migrations.AddConstraint(
model_name='devicerole',
constraint=models.UniqueConstraint(
fields=('parent', 'slug'),
name='dcim_devicerole_parent_slug'
),
),
migrations.AddConstraint(
model_name='devicerole',
constraint=models.UniqueConstraint(
condition=models.Q(('parent__isnull', True)),
fields=('slug',),
name='dcim_devicerole_slug',
violation_error_message='A top-level device role with this slug already exists.'
),
),
]

View File

@@ -1,54 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0207_remove_redundant_indexes'),
('extras', '0129_fix_script_paths'),
]
operations = [
migrations.AlterField(
model_name='platform',
name='name',
field=models.CharField(max_length=100),
),
migrations.AlterField(
model_name='platform',
name='slug',
field=models.SlugField(max_length=100),
),
migrations.AddConstraint(
model_name='platform',
constraint=models.UniqueConstraint(
fields=('manufacturer', 'name'),
name='dcim_platform_manufacturer_name'
),
),
migrations.AddConstraint(
model_name='platform',
constraint=models.UniqueConstraint(
condition=models.Q(('manufacturer__isnull', True)),
fields=('name',),
name='dcim_platform_name',
violation_error_message='Platform name must be unique.'
),
),
migrations.AddConstraint(
model_name='platform',
constraint=models.UniqueConstraint(
fields=('manufacturer', 'slug'),
name='dcim_platform_manufacturer_slug'
),
),
migrations.AddConstraint(
model_name='platform',
constraint=models.UniqueConstraint(
condition=models.Q(('manufacturer__isnull', True)),
fields=('slug',),
name='dcim_platform_slug',
violation_error_message='Platform slug must be unique.'
),
),
]

View File

@@ -1,24 +0,0 @@
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0208_platform_manufacturer_uniqueness'),
]
operations = [
migrations.AlterField(
model_name='interface',
name='tx_power',
field=models.SmallIntegerField(
blank=True,
null=True,
validators=[
django.core.validators.MinValueValidator(-40),
django.core.validators.MaxValueValidator(127)
]
),
),
]

View File

@@ -0,0 +1,15 @@
{
"name": "Expansion card",
"schema": {
"properties": {
"connector_type": {
"type": "string",
"description": "Connector type e.g. PCIe x4"
},
"bandwidth": {
"type": "integer",
"description": "Total Bandwidth for this module"
}
}
}
}

View File

@@ -719,13 +719,10 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
verbose_name=('channel width (MHz)'),
help_text=_("Populated by selected channel (if set)")
)
tx_power = models.SmallIntegerField(
tx_power = models.PositiveSmallIntegerField(
blank=True,
null=True,
validators=(
MinValueValidator(-40),
MaxValueValidator(127),
),
validators=(MaxValueValidator(127),),
verbose_name=_('transmit power (dBm)')
)
poe_mode = models.CharField(

View File

@@ -398,6 +398,28 @@ class DeviceRole(NestedGroupModel):
class Meta:
ordering = ('name',)
constraints = (
models.UniqueConstraint(
fields=('parent', 'name'),
name='%(app_label)s_%(class)s_parent_name'
),
models.UniqueConstraint(
fields=('name',),
name='%(app_label)s_%(class)s_name',
condition=Q(parent__isnull=True),
violation_error_message=_("A top-level device role with this name already exists.")
),
models.UniqueConstraint(
fields=('parent', 'slug'),
name='%(app_label)s_%(class)s_parent_slug'
),
models.UniqueConstraint(
fields=('slug',),
name='%(app_label)s_%(class)s_slug',
condition=Q(parent__isnull=True),
violation_error_message=_("A top-level device role with this slug already exists.")
),
)
verbose_name = _('device role')
verbose_name_plural = _('device roles')
@@ -415,15 +437,6 @@ class Platform(OrganizationalModel):
null=True,
help_text=_('Optionally limit this platform to devices of a certain manufacturer')
)
# Override name & slug from OrganizationalModel to not enforce uniqueness
name = models.CharField(
verbose_name=_('name'),
max_length=100
)
slug = models.SlugField(
verbose_name=_('slug'),
max_length=100
)
config_template = models.ForeignKey(
to='extras.ConfigTemplate',
on_delete=models.PROTECT,
@@ -436,28 +449,6 @@ class Platform(OrganizationalModel):
ordering = ('name',)
verbose_name = _('platform')
verbose_name_plural = _('platforms')
constraints = (
models.UniqueConstraint(
fields=('manufacturer', 'name'),
name='%(app_label)s_%(class)s_manufacturer_name',
),
models.UniqueConstraint(
fields=('name',),
name='%(app_label)s_%(class)s_name',
condition=Q(manufacturer__isnull=True),
violation_error_message=_("Platform name must be unique.")
),
models.UniqueConstraint(
fields=('manufacturer', 'slug'),
name='%(app_label)s_%(class)s_manufacturer_slug',
),
models.UniqueConstraint(
fields=('slug',),
name='%(app_label)s_%(class)s_slug',
condition=Q(manufacturer__isnull=True),
violation_error_message=_("Platform slug must be unique.")
),
)
class Device(

View File

@@ -1,38 +0,0 @@
from django.utils.translation import gettext as _
from netbox.object_actions import ObjectAction
__all__ = (
'BulkAddComponents',
'BulkDisconnect',
)
class BulkAddComponents(ObjectAction):
"""
Add components to the selected devices.
"""
label = _('Add Components')
multi = True
permissions_required = {'change'}
template_name = 'dcim/buttons/bulk_add_components.html'
@classmethod
def get_context(cls, context, obj):
return {
'perms': context.get('perms'),
'request': context.get('request'),
'formaction': context.get('formaction'),
'label': cls.label,
}
class BulkDisconnect(ObjectAction):
"""
Disconnect each of a set of objects to which a cable is connected.
"""
name = 'bulk_disconnect'
label = _('Disconnect Selected')
multi = True
permissions_required = {'change'}
template_name = 'dcim/buttons/bulk_disconnect.html'

View File

@@ -3,6 +3,7 @@ import svgwrite
from svgwrite.container import Hyperlink
from svgwrite.image import Image
from svgwrite.gradients import LinearGradient
from svgwrite.masking import ClipPath
from svgwrite.shapes import Rect
from svgwrite.text import Text
@@ -67,6 +68,20 @@ def get_device_description(device):
return description
def truncate_text(text, width, font_size=15):
"""
Truncate text to fit within the width of a rectangle.
:param text: The text to truncate
:param width: Width of rectangle
:param font_size: Font size (default is 15, ~0.875rem)
"""
char_width = font_size * 0.6 # 0.6 is an approximation of the average character width in pixels
max_char = int(width / char_width)
return text if len(text) <= max_char else text[:max_char] + '...'
class RackElevationSVG:
"""
Use this class to render a rack elevation as an SVG image.
@@ -177,12 +192,26 @@ class RackElevationSVG:
link = Hyperlink(href=f'{self.base_url}{device.get_absolute_url()}', target="_parent")
link.set_desc(description)
# Create clipPath element
# This is necessary as fallback because the truncate_text method is an approximation
clip_id = f"clip-{device.id}"
clip_path = ClipPath(id=clip_id)
clip_path.add(Rect(coords, size))
self.drawing.defs.add(clip_path)
# Name to display
display_name = truncate_text(name, size[0])
# Add rect element to hyperlink
if color:
link.add(Rect(coords, size, style=f'fill: #{color}', class_=f'slot{css_extra}'))
else:
link.add(Rect(coords, size, class_=f'slot blocked{css_extra}'))
link.add(Text(name, insert=text_coords, fill=text_color, class_=f'label{css_extra}'))
link.add(
Text(display_name, insert=text_coords, fill=text_color, clip_path=f"url(#{clip_id})",
class_=f'label{css_extra}')
)
# Embed device type image if provided
if self.include_images and image:

View File

@@ -63,6 +63,10 @@ class DeviceRoleTable(NetBoxTable):
verbose_name=_('Name'),
linkify=True
)
parent = tables.Column(
verbose_name=_('Parent'),
linkify=True,
)
device_count = columns.LinkedCountColumn(
viewname='dcim:device_list',
url_params={'role_id': 'pk'},
@@ -88,8 +92,8 @@ class DeviceRoleTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = models.DeviceRole
fields = (
'pk', 'id', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'config_template', 'description',
'slug', 'tags', 'actions', 'created', 'last_updated',
'pk', 'id', 'name', 'parent', 'device_count', 'vm_count', 'color', 'vm_role', 'config_template',
'description', 'slug', 'tags', 'actions', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description')

View File

@@ -24,6 +24,10 @@ class RegionTable(ContactsColumnMixin, NetBoxTable):
verbose_name=_('Name'),
linkify=True
)
parent = tables.Column(
verbose_name=_('Parent'),
linkify=True,
)
site_count = columns.LinkedCountColumn(
viewname='dcim:site_list',
url_params={'region_id': 'pk'},
@@ -39,7 +43,7 @@ class RegionTable(ContactsColumnMixin, NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Region
fields = (
'pk', 'id', 'name', 'slug', 'site_count', 'description', 'comments', 'contacts', 'tags',
'pk', 'id', 'name', 'parent', 'slug', 'site_count', 'description', 'comments', 'contacts', 'tags',
'created', 'last_updated', 'actions',
)
default_columns = ('pk', 'name', 'site_count', 'description')
@@ -54,6 +58,10 @@ class SiteGroupTable(ContactsColumnMixin, NetBoxTable):
verbose_name=_('Name'),
linkify=True
)
parent = tables.Column(
verbose_name=_('Parent'),
linkify=True,
)
site_count = columns.LinkedCountColumn(
viewname='dcim:site_list',
url_params={'group_id': 'pk'},
@@ -69,7 +77,7 @@ class SiteGroupTable(ContactsColumnMixin, NetBoxTable):
class Meta(NetBoxTable.Meta):
model = SiteGroup
fields = (
'pk', 'id', 'name', 'slug', 'site_count', 'description', 'comments', 'contacts', 'tags',
'pk', 'id', 'name', 'parent', 'slug', 'site_count', 'description', 'comments', 'contacts', 'tags',
'created', 'last_updated', 'actions',
)
default_columns = ('pk', 'name', 'site_count', 'description')
@@ -135,6 +143,10 @@ class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
verbose_name=_('Name'),
linkify=True
)
parent = tables.Column(
verbose_name=_('Parent'),
linkify=True,
)
site = tables.Column(
verbose_name=_('Site'),
linkify=True
@@ -170,8 +182,8 @@ class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Location
fields = (
'pk', 'id', 'name', 'site', 'status', 'facility', 'tenant', 'tenant_group', 'rack_count', 'device_count',
'description', 'slug', 'comments', 'contacts', 'tags', 'actions', 'created', 'last_updated',
'pk', 'id', 'name', 'parent', 'site', 'status', 'facility', 'tenant', 'tenant_group', 'rack_count',
'device_count', 'description', 'slug', 'comments', 'contacts', 'tags', 'actions', 'created', 'last_updated',
'vlangroup_count',
)
default_columns = (

View File

@@ -3,7 +3,7 @@ from decimal import Decimal
from zoneinfo import ZoneInfo
import yaml
from django.test import override_settings
from django.test import override_settings, tag
from django.urls import reverse
from netaddr import EUI
@@ -1000,18 +1000,7 @@ inventory-items:
self.assertEqual(response.get('Content-Type'), 'text/csv; charset=utf-8')
# TODO: Change base class to PrimaryObjectViewTestCase
# Blocked by absence of bulk import view for ModuleTypes
class ModuleTypeTestCase(
ViewTestCases.GetObjectViewTestCase,
ViewTestCases.GetObjectChangelogViewTestCase,
ViewTestCases.CreateObjectViewTestCase,
ViewTestCases.EditObjectViewTestCase,
ViewTestCases.DeleteObjectViewTestCase,
ViewTestCases.ListObjectsViewTestCase,
ViewTestCases.BulkEditObjectsViewTestCase,
ViewTestCases.BulkDeleteObjectsViewTestCase
):
class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = ModuleType
@classmethod
@@ -1023,7 +1012,7 @@ class ModuleTypeTestCase(
)
Manufacturer.objects.bulk_create(manufacturers)
ModuleType.objects.bulk_create([
module_types = ModuleType.objects.bulk_create([
ModuleType(model='Module Type 1', manufacturer=manufacturers[0]),
ModuleType(model='Module Type 2', manufacturer=manufacturers[0]),
ModuleType(model='Module Type 3', manufacturer=manufacturers[0]),
@@ -1031,6 +1020,8 @@ class ModuleTypeTestCase(
tags = create_tags('Alpha', 'Bravo', 'Charlie')
fan_module_type_profile = ModuleTypeProfile.objects.get(name='Fan')
cls.form_data = {
'manufacturer': manufacturers[1].pk,
'model': 'Device Type X',
@@ -1044,6 +1035,70 @@ class ModuleTypeTestCase(
'part_number': '456DEF',
}
cls.csv_data = (
"manufacturer,model,part_number,comments,profile",
f"Manufacturer 1,fan0,generic-fan,,{fan_module_type_profile.name}"
)
cls.csv_update_data = (
"id,model",
f"{module_types[0].id},test model",
)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_bulk_update_objects_with_permission(self):
self.add_permissions(
'dcim.add_consoleporttemplate',
'dcim.add_consoleserverporttemplate',
'dcim.add_powerporttemplate',
'dcim.add_poweroutlettemplate',
'dcim.add_interfacetemplate',
'dcim.add_frontporttemplate',
'dcim.add_rearporttemplate',
'dcim.add_modulebaytemplate',
)
# run base test
super().test_bulk_update_objects_with_permission()
@tag('regression')
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
def test_bulk_import_objects_with_permission(self):
self.add_permissions(
'dcim.add_consoleporttemplate',
'dcim.add_consoleserverporttemplate',
'dcim.add_powerporttemplate',
'dcim.add_poweroutlettemplate',
'dcim.add_interfacetemplate',
'dcim.add_frontporttemplate',
'dcim.add_rearporttemplate',
'dcim.add_modulebaytemplate',
)
# run base test
super().test_bulk_import_objects_with_permission()
# TODO: remove extra regression asserts once parent test supports testing all import fields
fan_module_type = ModuleType.objects.get(part_number='generic-fan')
fan_module_type_profile = ModuleTypeProfile.objects.get(name='Fan')
assert fan_module_type.profile == fan_module_type_profile
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
def test_bulk_import_objects_with_constrained_permission(self):
self.add_permissions(
'dcim.add_consoleporttemplate',
'dcim.add_consoleserverporttemplate',
'dcim.add_powerporttemplate',
'dcim.add_poweroutlettemplate',
'dcim.add_interfacetemplate',
'dcim.add_frontporttemplate',
'dcim.add_rearporttemplate',
'dcim.add_modulebaytemplate',
)
super().test_bulk_import_objects_with_constrained_permission()
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_moduletype_consoleports(self):
moduletype = ModuleType.objects.first()
@@ -1804,9 +1859,9 @@ class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
cls.csv_data = (
"name,slug,color",
"Device Role 4,device-role-4,ff0000",
"Device Role 5,device-role-5,00ff00",
"Device Role 6,device-role-6,0000ff",
"Device Role 6,device-role-6,ff0000",
"Device Role 7,device-role-7,00ff00",
"Device Role 8,device-role-8,0000ff",
)
cls.csv_update_data = (
@@ -3211,17 +3266,27 @@ class CableTestCase(
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Site 1', slug='site-1')
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
)
Site.objects.bulk_create(sites)
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
devicetype = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer)
role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
vc = VirtualChassis.objects.create(name='Virtual Chassis')
# NOTE: By design, NetBox now allows for the creation of devices with the same name if they belong to
# different sites.
# The CSV test below demonstrates that devices with identical names on different sites can be created
# and referenced successfully.
devices = (
Device(name='Device 1', site=site, device_type=devicetype, role=role),
Device(name='Device 2', site=site, device_type=devicetype, role=role),
Device(name='Device 3', site=site, device_type=devicetype, role=role),
Device(name='Device 4', site=site, device_type=devicetype, role=role),
# Create 'Device 1' assigned to 'Site 1'
Device(name='Device 1', site=sites[0], device_type=devicetype, role=role),
Device(name='Device 2', site=sites[0], device_type=devicetype, role=role),
Device(name='Device 3', site=sites[0], device_type=devicetype, role=role),
# Create 'Device 1' assigned to 'Site 2' (allowed since the site is different)
Device(name='Device 1', site=sites[1], device_type=devicetype, role=role),
)
Device.objects.bulk_create(devices)
@@ -3272,13 +3337,15 @@ class CableTestCase(
'tags': [t.pk for t in tags],
}
# Ensure that CSV bulk import supports assigning terminations from parent devices that share
# the same device name, provided those devices belong to different sites.
cls.csv_data = (
"side_a_device,side_a_type,side_a_name,side_b_device,side_b_type,side_b_name",
"Device 3,dcim.interface,Interface 1,Device 4,dcim.interface,Interface 1",
"Device 3,dcim.interface,Interface 2,Device 4,dcim.interface,Interface 2",
"Device 3,dcim.interface,Interface 3,Device 4,dcim.interface,Interface 3",
"Device 1,dcim.interface,Device 2 Interface,Device 4,dcim.interface,Interface 4",
"Device 1,dcim.interface,Device 3 Interface,Device 4,dcim.interface,Interface 5",
"side_a_site,side_a_device,side_a_type,side_a_name,side_b_site,side_b_device,side_b_type,side_b_name",
"Site 1,Device 3,dcim.interface,Interface 1,Site 2,Device 1,dcim.interface,Interface 1",
"Site 1,Device 3,dcim.interface,Interface 2,Site 2,Device 1,dcim.interface,Interface 2",
"Site 1,Device 3,dcim.interface,Interface 3,Site 2,Device 1,dcim.interface,Interface 3",
"Site 1,Device 1,dcim.interface,Device 2 Interface,Site 2,Device 1,dcim.interface,Interface 4",
"Site 1,Device 1,dcim.interface,Device 3 Interface,Site 2,Device 1,dcim.interface,Interface 5",
)
cls.csv_update_data = (

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.object_actions import *
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
from netbox.views import generic
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator, get_paginate_count
@@ -34,7 +34,6 @@ from wireless.models import WirelessLAN
from . import filtersets, forms, tables
from .choices import DeviceFaceChoices, InterfaceModeChoices
from .models import *
from .object_actions import BulkAddComponents, BulkDisconnect
CABLE_TERMINATION_TYPES = {
'dcim.consoleport': ConsolePort,
@@ -50,6 +49,11 @@ CABLE_TERMINATION_TYPES = {
class DeviceComponentsView(generic.ObjectChildrenView):
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
'bulk_disconnect': {'change'},
}
queryset = Device.objects.all()
def get_children(self, request, parent):
@@ -57,8 +61,12 @@ class DeviceComponentsView(generic.ObjectChildrenView):
class DeviceTypeComponentsView(generic.ObjectChildrenView):
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete)
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
queryset = DeviceType.objects.all()
template_name = 'dcim/devicetype/component_templates.html'
viewname = None # Used for return_url resolution
def get_children(self, request, parent):
@@ -70,9 +78,9 @@ class DeviceTypeComponentsView(generic.ObjectChildrenView):
}
class ModuleTypeComponentsView(generic.ObjectChildrenView):
class ModuleTypeComponentsView(DeviceComponentsView):
queryset = ModuleType.objects.all()
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete)
template_name = 'dcim/moduletype/component_templates.html'
viewname = None # Used for return_url resolution
def get_children(self, request, parent):
@@ -292,11 +300,6 @@ class RegionBulkEditView(generic.BulkEditView):
form = forms.RegionBulkEditForm
@register_model_view(Region, 'bulk_rename', path='rename', detail=False)
class RegionBulkRenameView(generic.BulkRenameView):
queryset = Region.objects.all()
@register_model_view(Region, 'bulk_delete', path='delete', detail=False)
class RegionBulkDeleteView(generic.BulkDeleteView):
queryset = Region.objects.add_related_count(
@@ -423,11 +426,6 @@ class SiteGroupBulkEditView(generic.BulkEditView):
form = forms.SiteGroupBulkEditForm
@register_model_view(SiteGroup, 'bulk_rename', path='rename', detail=False)
class SiteGroupBulkRenameView(generic.BulkRenameView):
queryset = SiteGroup.objects.all()
@register_model_view(SiteGroup, 'bulk_delete', path='delete', detail=False)
class SiteGroupBulkDeleteView(generic.BulkDeleteView):
queryset = SiteGroup.objects.add_related_count(
@@ -513,11 +511,6 @@ class SiteBulkEditView(generic.BulkEditView):
form = forms.SiteBulkEditForm
@register_model_view(Site, 'bulk_rename', path='rename', detail=False)
class SiteBulkRenameView(generic.BulkRenameView):
queryset = Site.objects.all()
@register_model_view(Site, 'bulk_delete', path='delete', detail=False)
class SiteBulkDeleteView(generic.BulkDeleteView):
queryset = Site.objects.all()
@@ -622,11 +615,6 @@ class LocationBulkEditView(generic.BulkEditView):
form = forms.LocationBulkEditForm
@register_model_view(Location, 'bulk_rename', path='rename', detail=False)
class LocationBulkRenameView(generic.BulkRenameView):
queryset = Location.objects.all()
@register_model_view(Location, 'bulk_delete', path='delete', detail=False)
class LocationBulkDeleteView(generic.BulkDeleteView):
queryset = Location.objects.add_related_count(
@@ -692,11 +680,6 @@ class RackRoleBulkEditView(generic.BulkEditView):
form = forms.RackRoleBulkEditForm
@register_model_view(RackRole, 'bulk_rename', path='rename', detail=False)
class RackRoleBulkRenameView(generic.BulkRenameView):
queryset = RackRole.objects.all()
@register_model_view(RackRole, 'bulk_delete', path='delete', detail=False)
class RackRoleBulkDeleteView(generic.BulkDeleteView):
queryset = RackRole.objects.annotate(
@@ -756,12 +739,6 @@ class RackTypeBulkEditView(generic.BulkEditView):
form = forms.RackTypeBulkEditForm
@register_model_view(RackType, 'bulk_rename', path='rename', detail=False)
class RackTypeBulkRenameView(generic.BulkRenameView):
queryset = RackType.objects.all()
field_name = 'model'
@register_model_view(RackType, 'bulk_delete', path='delete', detail=False)
class RackTypeBulkDeleteView(generic.BulkDeleteView):
queryset = RackType.objects.all()
@@ -941,11 +918,6 @@ class RackBulkEditView(generic.BulkEditView):
form = forms.RackBulkEditForm
@register_model_view(Rack, 'bulk_rename', path='rename', detail=False)
class RackBulkRenameView(generic.BulkRenameView):
queryset = Rack.objects.all()
@register_model_view(Rack, 'bulk_delete', path='delete', detail=False)
class RackBulkDeleteView(generic.BulkDeleteView):
queryset = Rack.objects.all()
@@ -963,7 +935,6 @@ class RackReservationListView(generic.ObjectListView):
filterset = filtersets.RackReservationFilterSet
filterset_form = forms.RackReservationFilterForm
table = tables.RackReservationTable
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
@register_model_view(RackReservation)
@@ -1080,11 +1051,6 @@ class ManufacturerBulkEditView(generic.BulkEditView):
form = forms.ManufacturerBulkEditForm
@register_model_view(Manufacturer, 'bulk_rename', path='rename', detail=False)
class ManufacturerBulkRenameView(generic.BulkRenameView):
queryset = Manufacturer.objects.all()
@register_model_view(Manufacturer, 'bulk_delete', path='delete', detail=False)
class ManufacturerBulkDeleteView(generic.BulkDeleteView):
queryset = Manufacturer.objects.annotate(
@@ -1332,12 +1298,6 @@ class DeviceTypeBulkEditView(generic.BulkEditView):
form = forms.DeviceTypeBulkEditForm
@register_model_view(DeviceType, 'bulk_rename', path='rename', detail=False)
class DeviceTypeBulkRenameView(generic.BulkRenameView):
queryset = DeviceType.objects.all()
field_name = 'model'
@register_model_view(DeviceType, 'bulk_delete', path='delete', detail=False)
class DeviceTypeBulkDeleteView(generic.BulkDeleteView):
queryset = DeviceType.objects.annotate(
@@ -1394,11 +1354,6 @@ class ModuleTypeProfileBulkEditView(generic.BulkEditView):
form = forms.ModuleTypeProfileBulkEditForm
@register_model_view(ModuleTypeProfile, 'bulk_rename', path='rename', detail=False)
class ModuleTypeProfileBulkRenameView(generic.BulkRenameView):
queryset = ModuleTypeProfile.objects.all()
@register_model_view(ModuleTypeProfile, 'bulk_delete', path='delete', detail=False)
class ModuleTypeProfileBulkDeleteView(generic.BulkDeleteView):
queryset = ModuleTypeProfile.objects.annotate(
@@ -1609,11 +1564,6 @@ class ModuleTypeBulkEditView(generic.BulkEditView):
form = forms.ModuleTypeBulkEditForm
@register_model_view(ModuleType, 'bulk_rename', path='rename', detail=False)
class ModuleTypeBulkRenameView(generic.BulkRenameView):
queryset = ModuleType.objects.all()
@register_model_view(ModuleType, 'bulk_delete', path='delete', detail=False)
class ModuleTypeBulkDeleteView(generic.BulkDeleteView):
queryset = ModuleType.objects.annotate(
@@ -2088,11 +2038,6 @@ class DeviceRoleBulkEditView(generic.BulkEditView):
form = forms.DeviceRoleBulkEditForm
@register_model_view(DeviceRole, 'bulk_rename', path='rename', detail=False)
class DeviceRoleBulkRenameView(generic.BulkRenameView):
queryset = DeviceRole.objects.all()
@register_model_view(DeviceRole, 'bulk_delete', path='delete', detail=False)
class DeviceRoleBulkDeleteView(generic.BulkDeleteView):
queryset = DeviceRole.objects.annotate(
@@ -2154,11 +2099,6 @@ class PlatformBulkEditView(generic.BulkEditView):
form = forms.PlatformBulkEditForm
@register_model_view(Platform, 'bulk_rename', path='rename', detail=False)
class PlatformBulkRenameView(generic.BulkRenameView):
queryset = Platform.objects.all()
@register_model_view(Platform, 'bulk_delete', path='delete', detail=False)
class PlatformBulkDeleteView(generic.BulkDeleteView):
queryset = Platform.objects.all()
@@ -2176,7 +2116,7 @@ class DeviceListView(generic.ObjectListView):
filterset = filtersets.DeviceFilterSet
filterset_form = forms.DeviceFilterForm
table = tables.DeviceTable
actions = (AddObject, BulkImport, BulkExport, BulkAddComponents, BulkEdit, BulkRename, BulkDelete)
template_name = 'dcim/device_list.html'
@register_model_view(Device)
@@ -2217,7 +2157,7 @@ class DeviceConsolePortsView(DeviceComponentsView):
table = tables.DeviceConsolePortTable
filterset = filtersets.ConsolePortFilterSet
filterset_form = forms.ConsolePortFilterForm
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
template_name = 'dcim/device/consoleports.html',
tab = ViewTab(
label=_('Console Ports'),
badge=lambda obj: obj.console_port_count,
@@ -2233,7 +2173,7 @@ class DeviceConsoleServerPortsView(DeviceComponentsView):
table = tables.DeviceConsoleServerPortTable
filterset = filtersets.ConsoleServerPortFilterSet
filterset_form = forms.ConsoleServerPortFilterForm
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
template_name = 'dcim/device/consoleserverports.html'
tab = ViewTab(
label=_('Console Server Ports'),
badge=lambda obj: obj.console_server_port_count,
@@ -2249,7 +2189,7 @@ class DevicePowerPortsView(DeviceComponentsView):
table = tables.DevicePowerPortTable
filterset = filtersets.PowerPortFilterSet
filterset_form = forms.PowerPortFilterForm
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
template_name = 'dcim/device/powerports.html'
tab = ViewTab(
label=_('Power Ports'),
badge=lambda obj: obj.power_port_count,
@@ -2265,7 +2205,7 @@ class DevicePowerOutletsView(DeviceComponentsView):
table = tables.DevicePowerOutletTable
filterset = filtersets.PowerOutletFilterSet
filterset_form = forms.PowerOutletFilterForm
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
template_name = 'dcim/device/poweroutlets.html'
tab = ViewTab(
label=_('Power Outlets'),
badge=lambda obj: obj.power_outlet_count,
@@ -2281,7 +2221,6 @@ class DeviceInterfacesView(DeviceComponentsView):
table = tables.DeviceInterfaceTable
filterset = filtersets.InterfaceFilterSet
filterset_form = forms.InterfaceFilterForm
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
template_name = 'dcim/device/interfaces.html'
tab = ViewTab(
label=_('Interfaces'),
@@ -2304,7 +2243,7 @@ class DeviceFrontPortsView(DeviceComponentsView):
table = tables.DeviceFrontPortTable
filterset = filtersets.FrontPortFilterSet
filterset_form = forms.FrontPortFilterForm
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
template_name = 'dcim/device/frontports.html'
tab = ViewTab(
label=_('Front Ports'),
badge=lambda obj: obj.front_port_count,
@@ -2320,7 +2259,7 @@ class DeviceRearPortsView(DeviceComponentsView):
table = tables.DeviceRearPortTable
filterset = filtersets.RearPortFilterSet
filterset_form = forms.RearPortFilterForm
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
template_name = 'dcim/device/rearports.html'
tab = ViewTab(
label=_('Rear Ports'),
badge=lambda obj: obj.rear_port_count,
@@ -2336,7 +2275,11 @@ class DeviceModuleBaysView(DeviceComponentsView):
table = tables.DeviceModuleBayTable
filterset = filtersets.ModuleBayFilterSet
filterset_form = forms.ModuleBayFilterForm
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete)
template_name = 'dcim/device/modulebays.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
tab = ViewTab(
label=_('Module Bays'),
badge=lambda obj: obj.module_bay_count,
@@ -2352,7 +2295,11 @@ class DeviceDeviceBaysView(DeviceComponentsView):
table = tables.DeviceDeviceBayTable
filterset = filtersets.DeviceBayFilterSet
filterset_form = forms.DeviceBayFilterForm
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete)
template_name = 'dcim/device/devicebays.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
tab = ViewTab(
label=_('Device Bays'),
badge=lambda obj: obj.device_bay_count,
@@ -2368,7 +2315,11 @@ class DeviceInventoryView(DeviceComponentsView):
table = tables.DeviceInventoryItemTable
filterset = filtersets.InventoryItemFilterSet
filterset_form = forms.InventoryItemFilterForm
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete)
template_name = 'dcim/device/inventory.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
tab = ViewTab(
label=_('Inventory Items'),
badge=lambda obj: obj.inventory_item_count,
@@ -2442,16 +2393,16 @@ class DeviceBulkEditView(generic.BulkEditView):
form = forms.DeviceBulkEditForm
@register_model_view(Device, 'bulk_rename', path='rename', detail=False)
class DeviceBulkRenameView(generic.BulkRenameView):
queryset = Device.objects.all()
@register_model_view(Device, 'bulk_delete', path='delete', detail=False)
class DeviceBulkDeleteView(generic.BulkDeleteView):
queryset = Device.objects.prefetch_related('device_type__manufacturer')
filterset = filtersets.DeviceFilterSet
table = tables.DeviceTable
@register_model_view(Device, 'bulk_delete', path='delete', detail=False)
class DeviceBulkDeleteView(generic.BulkDeleteView):
queryset = Device.objects.prefetch_related('device_type__manufacturer')
@register_model_view(Device, 'bulk_rename', path='rename', detail=False)
class DeviceBulkRenameView(generic.BulkRenameView):
queryset = Device.objects.all()
filterset = filtersets.DeviceFilterSet
table = tables.DeviceTable
@@ -2466,7 +2417,6 @@ class ModuleListView(generic.ObjectListView):
filterset = filtersets.ModuleFilterSet
filterset_form = forms.ModuleFilterForm
table = tables.ModuleTable
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
@register_model_view(Module)
@@ -2522,6 +2472,11 @@ class ConsolePortListView(generic.ObjectListView):
filterset = filtersets.ConsolePortFilterSet
filterset_form = forms.ConsolePortFilterForm
table = tables.ConsolePortTable
template_name = 'dcim/component_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
@register_model_view(ConsolePort)
@@ -2592,6 +2547,11 @@ class ConsoleServerPortListView(generic.ObjectListView):
filterset = filtersets.ConsoleServerPortFilterSet
filterset_form = forms.ConsoleServerPortFilterForm
table = tables.ConsoleServerPortTable
template_name = 'dcim/component_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
@register_model_view(ConsoleServerPort)
@@ -2662,6 +2622,11 @@ class PowerPortListView(generic.ObjectListView):
filterset = filtersets.PowerPortFilterSet
filterset_form = forms.PowerPortFilterForm
table = tables.PowerPortTable
template_name = 'dcim/component_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
@register_model_view(PowerPort)
@@ -2732,6 +2697,11 @@ class PowerOutletListView(generic.ObjectListView):
filterset = filtersets.PowerOutletFilterSet
filterset_form = forms.PowerOutletFilterForm
table = tables.PowerOutletTable
template_name = 'dcim/component_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
@register_model_view(PowerOutlet)
@@ -2802,6 +2772,11 @@ class InterfaceListView(generic.ObjectListView):
filterset = filtersets.InterfaceFilterSet
filterset_form = forms.InterfaceFilterForm
table = tables.InterfaceTable
template_name = 'dcim/component_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
@register_model_view(Interface)
@@ -2945,6 +2920,11 @@ class FrontPortListView(generic.ObjectListView):
filterset = filtersets.FrontPortFilterSet
filterset_form = forms.FrontPortFilterForm
table = tables.FrontPortTable
template_name = 'dcim/component_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
@register_model_view(FrontPort)
@@ -3015,6 +2995,11 @@ class RearPortListView(generic.ObjectListView):
filterset = filtersets.RearPortFilterSet
filterset_form = forms.RearPortFilterForm
table = tables.RearPortTable
template_name = 'dcim/component_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
@register_model_view(RearPort)
@@ -3085,6 +3070,11 @@ class ModuleBayListView(generic.ObjectListView):
filterset = filtersets.ModuleBayFilterSet
filterset_form = forms.ModuleBayFilterForm
table = tables.ModuleBayTable
template_name = 'dcim/component_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
@register_model_view(ModuleBay)
@@ -3146,6 +3136,11 @@ class DeviceBayListView(generic.ObjectListView):
filterset = filtersets.DeviceBayFilterSet
filterset_form = forms.DeviceBayFilterForm
table = tables.DeviceBayTable
template_name = 'dcim/component_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
@register_model_view(DeviceBay)
@@ -3288,6 +3283,11 @@ class InventoryItemListView(generic.ObjectListView):
filterset = filtersets.InventoryItemFilterSet
filterset_form = forms.InventoryItemFilterForm
table = tables.InventoryItemTable
template_name = 'dcim/component_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
@register_model_view(InventoryItem)
@@ -3410,11 +3410,6 @@ class InventoryItemRoleBulkEditView(generic.BulkEditView):
form = forms.InventoryItemRoleBulkEditForm
@register_model_view(InventoryItemRole, 'bulk_rename', path='rename', detail=False)
class InventoryItemRoleBulkRenameView(generic.BulkRenameView):
queryset = InventoryItemRole.objects.all()
@register_model_view(InventoryItemRole, 'bulk_delete', path='delete', detail=False)
class InventoryItemRoleBulkDeleteView(generic.BulkDeleteView):
queryset = InventoryItemRole.objects.annotate(
@@ -3612,12 +3607,6 @@ class CableBulkEditView(generic.BulkEditView):
form = forms.CableBulkEditForm
@register_model_view(Cable, 'bulk_rename', path='rename', detail=False)
class CableBulkRenameView(generic.BulkRenameView):
queryset = Cable.objects.all()
field_name = 'label'
@register_model_view(Cable, 'bulk_delete', path='delete', detail=False)
class CableBulkDeleteView(generic.BulkDeleteView):
queryset = Cable.objects.prefetch_related(
@@ -3638,7 +3627,9 @@ class ConsoleConnectionsListView(generic.ObjectListView):
filterset_form = forms.ConsoleConnectionFilterForm
table = tables.ConsoleConnectionTable
template_name = 'dcim/connections_list.html'
actions = (BulkExport,)
actions = {
'export': {'view'},
}
def get_extra_context(self, request):
return {
@@ -3652,7 +3643,9 @@ class PowerConnectionsListView(generic.ObjectListView):
filterset_form = forms.PowerConnectionFilterForm
table = tables.PowerConnectionTable
template_name = 'dcim/connections_list.html'
actions = (BulkExport,)
actions = {
'export': {'view'},
}
def get_extra_context(self, request):
return {
@@ -3666,7 +3659,9 @@ class InterfaceConnectionsListView(generic.ObjectListView):
filterset_form = forms.InterfaceConnectionFilterForm
table = tables.InterfaceConnectionTable
template_name = 'dcim/connections_list.html'
actions = (BulkExport,)
actions = {
'export': {'view'},
}
def get_extra_context(self, request):
return {
@@ -3910,11 +3905,6 @@ class VirtualChassisBulkEditView(generic.BulkEditView):
form = forms.VirtualChassisBulkEditForm
@register_model_view(VirtualChassis, 'bulk_rename', path='rename', detail=False)
class VirtualChassisBulkRenameView(generic.BulkRenameView):
queryset = VirtualChassis.objects.all()
@register_model_view(VirtualChassis, 'bulk_delete', path='delete', detail=False)
class VirtualChassisBulkDeleteView(generic.BulkDeleteView):
queryset = VirtualChassis.objects.all()
@@ -3972,11 +3962,6 @@ class PowerPanelBulkEditView(generic.BulkEditView):
form = forms.PowerPanelBulkEditForm
@register_model_view(PowerPanel, 'bulk_rename', path='rename', detail=False)
class PowerPanelBulkRenameView(generic.BulkRenameView):
queryset = PowerPanel.objects.all()
@register_model_view(PowerPanel, 'bulk_delete', path='delete', detail=False)
class PowerPanelBulkDeleteView(generic.BulkDeleteView):
queryset = PowerPanel.objects.annotate(
@@ -4029,11 +4014,6 @@ class PowerFeedBulkEditView(generic.BulkEditView):
form = forms.PowerFeedBulkEditForm
@register_model_view(PowerFeed, 'bulk_rename', path='rename', detail=False)
class PowerFeedBulkRenameView(generic.BulkRenameView):
queryset = PowerFeed.objects.all()
@register_model_view(PowerFeed, 'bulk_disconnect', path='disconnect', detail=False)
class PowerFeedBulkDisconnectView(BulkDisconnectView):
queryset = PowerFeed.objects.all()
@@ -4062,7 +4042,6 @@ class VirtualDeviceContextListView(generic.ObjectListView):
filterset = filtersets.VirtualDeviceContextFilterSet
filterset_form = forms.VirtualDeviceContextFilterForm
table = tables.VirtualDeviceContextTable
actions = (AddObject, BulkImport, BulkEdit, BulkRename, BulkExport, BulkDelete)
@register_model_view(VirtualDeviceContext)
@@ -4107,11 +4086,6 @@ class VirtualDeviceContextBulkEditView(generic.BulkEditView):
form = forms.VirtualDeviceContextBulkEditForm
@register_model_view(VirtualDeviceContext, 'bulk_rename', path='rename', detail=False)
class VirtualDeviceContextBulkRenameView(generic.BulkRenameView):
queryset = VirtualDeviceContext.objects.all()
@register_model_view(VirtualDeviceContext, 'bulk_delete', path='delete', detail=False)
class VirtualDeviceContextBulkDeleteView(generic.BulkDeleteView):
queryset = VirtualDeviceContext.objects.all()
@@ -4129,7 +4103,6 @@ class MACAddressListView(generic.ObjectListView):
filterset = filtersets.MACAddressFilterSet
filterset_form = forms.MACAddressFilterForm
table = tables.MACAddressTable
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
@register_model_view(MACAddress)

View File

@@ -1,13 +1,7 @@
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',
@@ -16,32 +10,7 @@ __all__ = (
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)
fields = ['id', 'url', 'display', 'app_label', 'model']

View File

@@ -185,7 +185,9 @@ class TagViewSet(NetBoxModelViewSet):
class TaggedItemViewSet(RetrieveModelMixin, ListModelMixin, BaseViewSet):
queryset = TaggedItem.objects.prefetch_related('content_type', 'content_object', 'tag')
queryset = TaggedItem.objects.prefetch_related(
'content_type', 'content_object', 'tag'
).order_by('tag__weight', 'tag__name')
serializer_class = serializers.TaggedItemSerializer
filterset_class = filtersets.TaggedItemFilterSet

View File

@@ -1,13 +1,14 @@
import functools
import operator
import re
from django.utils.translation import gettext as _
__all__ = (
'Condition',
'ConditionSet',
'InvalidCondition',
)
AND = 'and'
OR = 'or'
@@ -19,6 +20,10 @@ def is_ruleset(data):
return type(data) is dict and len(data) == 1 and list(data.keys())[0] in (AND, OR)
class InvalidCondition(Exception):
pass
class Condition:
"""
An individual conditional rule that evaluates a single attribute and its value.
@@ -61,6 +66,7 @@ class Condition:
self.attr = attr
self.value = value
self.op = op
self.eval_func = getattr(self, f'eval_{op}')
self.negate = negate
@@ -70,16 +76,17 @@ class Condition:
"""
def _get(obj, key):
if isinstance(obj, list):
return [dict.get(i, key) for i in obj]
return dict.get(obj, key)
return [operator.getitem(item or {}, key) for item in obj]
return operator.getitem(obj or {}, key)
try:
value = functools.reduce(_get, self.attr.split('.'), data)
except TypeError:
# Invalid key path
value = None
result = self.eval_func(value)
except KeyError:
raise InvalidCondition(f"Invalid key path: {self.attr}")
try:
result = self.eval_func(value)
except TypeError as e:
raise InvalidCondition(f"Invalid data type at '{self.attr}' for '{self.op}' evaluation: {e}")
if self.negate:
return not result

View File

@@ -21,6 +21,12 @@ WEBHOOK_EVENT_TYPES = {
JOB_ERRORED: 'job_ended',
}
# Jinja environment parameters which support path imports
JINJA_ENV_PARAMS_WITH_PATH_IMPORT = (
'undefined',
'finalize',
)
# Dashboard
DEFAULT_DASHBOARD = [
{

View File

@@ -192,5 +192,5 @@ def flush_events(events):
try:
func = import_string(name)
func(events)
except Exception as e:
except ImportError as e:
logger.error(_("Cannot import events pipeline {name} error: {error}").format(name=name, error=e))

View File

@@ -18,9 +18,22 @@ class Empty(Lookup):
return f"CAST(LENGTH({sql}) AS BOOLEAN) IS TRUE", params
class NetHost(Lookup):
"""
Similar to ipam.lookups.NetHost, but casts the field to INET.
"""
lookup_name = 'net_host'
def as_sql(self, qn, connection):
lhs, lhs_params = self.process_lhs(qn, connection)
rhs, rhs_params = self.process_rhs(qn, connection)
params = lhs_params + rhs_params
return 'HOST(CAST(%s AS INET)) = HOST(%s)' % (lhs, rhs), params
class NetContainsOrEquals(Lookup):
"""
This lookup has the same functionality as the one from the ipam app except lhs is cast to inet
Similar to ipam.lookups.NetContainsOrEquals, but casts the field to INET.
"""
lookup_name = 'net_contains_or_equals'
@@ -32,4 +45,5 @@ class NetContainsOrEquals(Lookup):
CharField.register_lookup(Empty)
CachedValueField.register_lookup(NetHost)
CachedValueField.register_lookup(NetContainsOrEquals)

View File

@@ -2,16 +2,17 @@ import importlib.abc
import importlib.util
import os
import sys
from django.core.files.storage import storages
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.http import HttpResponse
from django.utils.module_loading import import_string
from django.utils.translation import gettext_lazy as _
from extras.constants import DEFAULT_MIME_TYPE
from extras.constants import DEFAULT_MIME_TYPE, JINJA_ENV_PARAMS_WITH_PATH_IMPORT
from extras.utils import filename_from_model, filename_from_object
from utilities.jinja2 import render_jinja2
__all__ = (
'PythonModuleMixin',
'RenderTemplateMixin',
@@ -125,12 +126,22 @@ class RenderTemplateMixin(models.Model):
class_name=self.__class__
))
def get_environment_params(self):
"""
Pre-processing of any defined Jinja environment parameters (e.g. to support path resolution).
"""
params = self.environment_params or {}
for name, value in params.items():
if name in JINJA_ENV_PARAMS_WITH_PATH_IMPORT and type(value) is str:
params[name] = import_string(value)
return params
def render(self, context=None, queryset=None):
"""
Render the template with the provided context. The context is passed to the Jinja2 environment as a dictionary.
"""
context = self.get_context(context=context, queryset=queryset)
env_params = self.environment_params or {}
env_params = self.get_environment_params()
output = render_jinja2(self.template_code, context, env_params, getattr(self, 'data_file', None))
# Replace CRLF-style line terminators

View File

@@ -13,7 +13,7 @@ from rest_framework.utils.encoders import JSONEncoder
from core.models import ObjectType
from extras.choices import *
from extras.conditions import ConditionSet
from extras.conditions import ConditionSet, InvalidCondition
from extras.constants import *
from extras.utils import image_upload
from extras.models.mixins import RenderTemplateMixin
@@ -142,7 +142,15 @@ class EventRule(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLogged
if not self.conditions:
return True
return ConditionSet(self.conditions).eval(data)
logger = logging.getLogger('netbox.event_rules')
try:
result = ConditionSet(self.conditions).eval(data)
logger.debug(f'{self.name}: Evaluated as {result}')
return result
except InvalidCondition as e:
logger.error(f"{self.name}: Evaluation failed. {e}")
return False
class Webhook(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):

View File

@@ -83,3 +83,6 @@ class TaggedItem(GenericTaggedItemBase):
indexes = [models.Index(fields=["content_type", "object_id"])]
verbose_name = _('tagged item')
verbose_name_plural = _('tagged items')
# Note: while there is no ordering applied here (because it would basically be done on fields
# of the related `tag`), there is an ordering applied to extras.api.views.TaggedItemViewSet
# to allow for proper pagination.

View File

@@ -4,7 +4,7 @@ from django.test import TestCase
from core.events import *
from dcim.choices import SiteStatusChoices
from dcim.models import Site
from extras.conditions import Condition, ConditionSet
from extras.conditions import Condition, ConditionSet, InvalidCondition
from extras.events import serialize_for_event
from extras.forms import EventRuleForm
from extras.models import EventRule, Webhook
@@ -12,16 +12,11 @@ from extras.models import EventRule, Webhook
class ConditionTestCase(TestCase):
def test_dotted_path_access(self):
c = Condition('a.b.c', 1, 'eq')
self.assertTrue(c.eval({'a': {'b': {'c': 1}}}))
self.assertFalse(c.eval({'a': {'b': {'c': 2}}}))
self.assertFalse(c.eval({'a': {'b': {'x': 1}}}))
def test_undefined_attr(self):
c = Condition('x', 1, 'eq')
self.assertFalse(c.eval({}))
self.assertTrue(c.eval({'x': 1}))
with self.assertRaises(InvalidCondition):
c.eval({})
#
# Validation tests
@@ -37,10 +32,13 @@ class ConditionTestCase(TestCase):
# dict type is unsupported
Condition('x', 1, dict())
def test_invalid_op_type(self):
def test_invalid_op_types(self):
with self.assertRaises(ValueError):
# 'gt' supports only numeric values
Condition('x', 'foo', 'gt')
with self.assertRaises(ValueError):
# 'in' supports only iterable values
Condition('x', 123, 'in')
#
# Nested attrs tests
@@ -50,7 +48,10 @@ class ConditionTestCase(TestCase):
c = Condition('x.y.z', 1)
self.assertTrue(c.eval({'x': {'y': {'z': 1}}}))
self.assertFalse(c.eval({'x': {'y': {'z': 2}}}))
self.assertFalse(c.eval({'a': {'b': {'c': 1}}}))
with self.assertRaises(InvalidCondition):
c.eval({'x': {'y': None}})
with self.assertRaises(InvalidCondition):
c.eval({'x': {'y': {'a': 1}}})
#
# Operator tests
@@ -74,23 +75,31 @@ class ConditionTestCase(TestCase):
c = Condition('x', 1, 'gt')
self.assertTrue(c.eval({'x': 2}))
self.assertFalse(c.eval({'x': 1}))
with self.assertRaises(InvalidCondition):
c.eval({'x': 'foo'}) # Invalid type
def test_gte(self):
c = Condition('x', 1, 'gte')
self.assertTrue(c.eval({'x': 2}))
self.assertTrue(c.eval({'x': 1}))
self.assertFalse(c.eval({'x': 0}))
with self.assertRaises(InvalidCondition):
c.eval({'x': 'foo'}) # Invalid type
def test_lt(self):
c = Condition('x', 2, 'lt')
self.assertTrue(c.eval({'x': 1}))
self.assertFalse(c.eval({'x': 2}))
with self.assertRaises(InvalidCondition):
c.eval({'x': 'foo'}) # Invalid type
def test_lte(self):
c = Condition('x', 2, 'lte')
self.assertTrue(c.eval({'x': 1}))
self.assertTrue(c.eval({'x': 2}))
self.assertFalse(c.eval({'x': 3}))
with self.assertRaises(InvalidCondition):
c.eval({'x': 'foo'}) # Invalid type
def test_in(self):
c = Condition('x', [1, 2, 3], 'in')
@@ -106,6 +115,8 @@ class ConditionTestCase(TestCase):
c = Condition('x', 1, 'contains')
self.assertTrue(c.eval({'x': [1, 2, 3]}))
self.assertFalse(c.eval({'x': [2, 3, 4]}))
with self.assertRaises(InvalidCondition):
c.eval({'x': 123}) # Invalid type
def test_contains_negated(self):
c = Condition('x', 1, 'contains', negate=True)

View File

@@ -14,13 +14,12 @@ from jinja2.exceptions import TemplateError
from core.choices import ManagedFileRootPathChoices
from core.models import Job
from core.object_actions import BulkSync
from dcim.models import Device, DeviceRole, Platform
from extras.choices import LogLevelChoices
from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
from extras.dashboard.utils import get_widget_class
from extras.utils import SharedObjectViewMixin
from netbox.object_actions import *
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
from netbox.views import generic
from netbox.views.generic.mixins import TableMixin
from utilities.forms import ConfirmationForm, get_field_value
@@ -97,11 +96,6 @@ class CustomFieldBulkEditView(generic.BulkEditView):
form = forms.CustomFieldBulkEditForm
@register_model_view(CustomField, 'bulk_rename', path='rename', detail=False)
class CustomFieldBulkRenameView(generic.BulkRenameView):
queryset = CustomField.objects.all()
@register_model_view(CustomField, 'bulk_delete', path='delete', detail=False)
class CustomFieldBulkDeleteView(generic.BulkDeleteView):
queryset = CustomField.objects.select_related('choice_set')
@@ -171,11 +165,6 @@ class CustomFieldChoiceSetBulkEditView(generic.BulkEditView):
form = forms.CustomFieldChoiceSetBulkEditForm
@register_model_view(CustomFieldChoiceSet, 'bulk_rename', path='rename', detail=False)
class CustomFieldChoiceSetBulkRenameView(generic.BulkRenameView):
queryset = CustomFieldChoiceSet.objects.all()
@register_model_view(CustomFieldChoiceSet, 'bulk_delete', path='delete', detail=False)
class CustomFieldChoiceSetBulkDeleteView(generic.BulkDeleteView):
queryset = CustomFieldChoiceSet.objects.all()
@@ -226,11 +215,6 @@ class CustomLinkBulkEditView(generic.BulkEditView):
form = forms.CustomLinkBulkEditForm
@register_model_view(CustomLink, 'bulk_rename', path='rename', detail=False)
class CustomLinkBulkRenameView(generic.BulkRenameView):
queryset = CustomLink.objects.all()
@register_model_view(CustomLink, 'bulk_delete', path='delete', detail=False)
class CustomLinkBulkDeleteView(generic.BulkDeleteView):
queryset = CustomLink.objects.all()
@@ -248,7 +232,11 @@ class ExportTemplateListView(generic.ObjectListView):
filterset = filtersets.ExportTemplateFilterSet
filterset_form = forms.ExportTemplateFilterForm
table = tables.ExportTemplateTable
actions = (AddObject, BulkImport, BulkSync, BulkExport, BulkEdit, BulkRename, BulkDelete)
template_name = 'extras/exporttemplate_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_sync': {'sync'},
}
@register_model_view(ExportTemplate)
@@ -282,11 +270,6 @@ class ExportTemplateBulkEditView(generic.BulkEditView):
form = forms.ExportTemplateBulkEditForm
@register_model_view(ExportTemplate, 'bulk_rename', path='rename', detail=False)
class ExportTemplateBulkRenameView(generic.BulkRenameView):
queryset = ExportTemplate.objects.all()
@register_model_view(ExportTemplate, 'bulk_delete', path='delete', detail=False)
class ExportTemplateBulkDeleteView(generic.BulkDeleteView):
queryset = ExportTemplate.objects.all()
@@ -347,11 +330,6 @@ class SavedFilterBulkEditView(SharedObjectViewMixin, generic.BulkEditView):
form = forms.SavedFilterBulkEditForm
@register_model_view(SavedFilter, 'bulk_rename', path='rename', detail=False)
class SavedFilterBulkRenameView(generic.BulkRenameView):
queryset = SavedFilter.objects.all()
@register_model_view(SavedFilter, 'bulk_delete', path='delete', detail=False)
class SavedFilterBulkDeleteView(SharedObjectViewMixin, generic.BulkDeleteView):
queryset = SavedFilter.objects.all()
@@ -369,7 +347,9 @@ class TableConfigListView(SharedObjectViewMixin, generic.ObjectListView):
filterset = filtersets.TableConfigFilterSet
filterset_form = forms.TableConfigFilterForm
table = tables.TableConfigTable
actions = (BulkExport, BulkEdit, BulkRename, BulkDelete)
actions = {
'export': {'view'},
}
@register_model_view(TableConfig)
@@ -409,11 +389,6 @@ class TableConfigBulkEditView(SharedObjectViewMixin, generic.BulkEditView):
form = forms.TableConfigBulkEditForm
@register_model_view(TableConfig, 'bulk_rename', path='rename', detail=False)
class TableConfigBulkRenameView(generic.BulkRenameView):
queryset = TableConfig.objects.all()
@register_model_view(TableConfig, 'bulk_delete', path='delete', detail=False)
class TableConfigBulkDeleteView(SharedObjectViewMixin, generic.BulkDeleteView):
queryset = TableConfig.objects.all()
@@ -495,11 +470,6 @@ class NotificationGroupBulkEditView(generic.BulkEditView):
form = forms.NotificationGroupBulkEditForm
@register_model_view(NotificationGroup, 'bulk_rename', path='rename', detail=False)
class NotificationGroupBulkRenameView(generic.BulkRenameView):
queryset = NotificationGroup.objects.all()
@register_model_view(NotificationGroup, 'bulk_delete', path='delete', detail=False)
class NotificationGroupBulkDeleteView(generic.BulkDeleteView):
queryset = NotificationGroup.objects.all()
@@ -646,11 +616,6 @@ class WebhookBulkEditView(generic.BulkEditView):
form = forms.WebhookBulkEditForm
@register_model_view(Webhook, 'bulk_rename', path='rename', detail=False)
class WebhookBulkRenameView(generic.BulkRenameView):
queryset = Webhook.objects.all()
@register_model_view(Webhook, 'bulk_delete', path='delete', detail=False)
class WebhookBulkDeleteView(generic.BulkDeleteView):
queryset = Webhook.objects.all()
@@ -701,11 +666,6 @@ class EventRuleBulkEditView(generic.BulkEditView):
form = forms.EventRuleBulkEditForm
@register_model_view(EventRule, 'bulk_rename', path='rename', detail=False)
class EventRuleBulkRenameView(generic.BulkRenameView):
queryset = EventRule.objects.all()
@register_model_view(EventRule, 'bulk_delete', path='delete', detail=False)
class EventRuleBulkDeleteView(generic.BulkDeleteView):
queryset = EventRule.objects.all()
@@ -780,11 +740,6 @@ class TagBulkEditView(generic.BulkEditView):
form = forms.TagBulkEditForm
@register_model_view(Tag, 'bulk_rename', path='rename', detail=False)
class TagBulkRenameView(generic.BulkRenameView):
queryset = Tag.objects.all()
@register_model_view(Tag, 'bulk_delete', path='delete', detail=False)
class TagBulkDeleteView(generic.BulkDeleteView):
queryset = Tag.objects.annotate(
@@ -803,7 +758,13 @@ class ConfigContextListView(generic.ObjectListView):
filterset = filtersets.ConfigContextFilterSet
filterset_form = forms.ConfigContextFilterForm
table = tables.ConfigContextTable
actions = (AddObject, BulkSync, BulkEdit, BulkRename, BulkDelete)
template_name = 'extras/configcontext_list.html'
actions = {
'add': {'add'},
'bulk_edit': {'change'},
'bulk_delete': {'delete'},
'bulk_sync': {'sync'},
}
@register_model_view(ConfigContext)
@@ -864,11 +825,6 @@ class ConfigContextBulkEditView(generic.BulkEditView):
form = forms.ConfigContextBulkEditForm
@register_model_view(ConfigContext, 'bulk_rename', path='rename', detail=False)
class ConfigContextBulkRenameView(generic.BulkRenameView):
queryset = ConfigContext.objects.all()
@register_model_view(ConfigContext, 'bulk_delete', path='delete', detail=False)
class ConfigContextBulkDeleteView(generic.BulkDeleteView):
queryset = ConfigContext.objects.all()
@@ -921,7 +877,11 @@ class ConfigTemplateListView(generic.ObjectListView):
filterset = filtersets.ConfigTemplateFilterSet
filterset_form = forms.ConfigTemplateFilterForm
table = tables.ConfigTemplateTable
actions = (AddObject, BulkImport, BulkExport, BulkSync, BulkEdit, BulkRename, BulkDelete)
template_name = 'extras/configtemplate_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_sync': {'sync'},
}
@register_model_view(ConfigTemplate)
@@ -955,11 +915,6 @@ class ConfigTemplateBulkEditView(generic.BulkEditView):
form = forms.ConfigTemplateBulkEditForm
@register_model_view(ConfigTemplate, 'bulk_rename', path='rename', detail=False)
class ConfigTemplateBulkRenameView(generic.BulkRenameView):
queryset = ConfigTemplate.objects.all()
@register_model_view(ConfigTemplate, 'bulk_delete', path='delete', detail=False)
class ConfigTemplateBulkDeleteView(generic.BulkDeleteView):
queryset = ConfigTemplate.objects.all()
@@ -1037,7 +992,9 @@ class ImageAttachmentListView(generic.ObjectListView):
filterset = filtersets.ImageAttachmentFilterSet
filterset_form = forms.ImageAttachmentFilterForm
table = tables.ImageAttachmentTable
actions = (BulkExport,)
actions = {
'export': {'view'},
}
@register_model_view(ImageAttachment, 'add', detail=False)
@@ -1081,7 +1038,12 @@ class JournalEntryListView(generic.ObjectListView):
filterset = filtersets.JournalEntryFilterSet
filterset_form = forms.JournalEntryFilterForm
table = tables.JournalEntryTable
actions = (BulkImport, BulkEdit, BulkDelete)
actions = {
'export': {'view'},
'bulk_import': {'add'},
'bulk_edit': {'change'},
'bulk_delete': {'delete'},
}
@register_model_view(JournalEntry)

View File

@@ -162,6 +162,11 @@ class Aggregate(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel):
return self.prefix.version
return None
@property
def ipv6_full(self):
if self.prefix and self.prefix.version == 6:
return netaddr.IPAddress(self.prefix).format(netaddr.ipv6_full)
def get_child_prefixes(self):
"""
Return all Prefixes within this Aggregate
@@ -330,6 +335,11 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
def mask_length(self):
return self.prefix.prefixlen if self.prefix else None
@property
def ipv6_full(self):
if self.prefix and self.prefix.version == 6:
return netaddr.IPAddress(self.prefix).format(netaddr.ipv6_full)
@property
def depth(self):
return self._depth
@@ -808,6 +818,11 @@ class IPAddress(ContactsMixin, PrimaryModel):
self._original_assigned_object_id = self.__dict__.get('assigned_object_id')
self._original_assigned_object_type_id = self.__dict__.get('assigned_object_type_id')
@property
def ipv6_full(self):
if self.address and self.address.version == 6:
return netaddr.IPAddress(self.address).format(netaddr.ipv6_full)
def get_duplicates(self):
return IPAddress.objects.filter(
vrf=self.vrf,

View File

@@ -10,7 +10,6 @@ from dcim.filtersets import InterfaceFilterSet
from dcim.forms import InterfaceFilterForm
from dcim.models import Device, Interface, Site
from ipam.tables import VLANTranslationRuleTable
from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport
from netbox.views import generic
from utilities.query import count_related
from utilities.tables import get_table_ordering
@@ -87,11 +86,6 @@ class VRFBulkEditView(generic.BulkEditView):
form = forms.VRFBulkEditForm
@register_model_view(VRF, 'bulk_rename', path='rename', detail=False)
class VRFBulkRenameView(generic.BulkRenameView):
queryset = VRF.objects.all()
@register_model_view(VRF, 'bulk_delete', path='delete', detail=False)
class VRFBulkDeleteView(generic.BulkDeleteView):
queryset = VRF.objects.all()
@@ -142,11 +136,6 @@ class RouteTargetBulkEditView(generic.BulkEditView):
form = forms.RouteTargetBulkEditForm
@register_model_view(RouteTarget, 'bulk_rename', path='rename', detail=False)
class RouteTargetBulkRenameView(generic.BulkRenameView):
queryset = RouteTarget.objects.all()
@register_model_view(RouteTarget, 'bulk_delete', path='delete', detail=False)
class RouteTargetBulkDeleteView(generic.BulkDeleteView):
queryset = RouteTarget.objects.all()
@@ -206,11 +195,6 @@ class RIRBulkEditView(generic.BulkEditView):
form = forms.RIRBulkEditForm
@register_model_view(RIR, 'bulk_rename', path='rename', detail=False)
class RIRBulkRenameView(generic.BulkRenameView):
queryset = RIR.objects.all()
@register_model_view(RIR, 'bulk_delete', path='delete', detail=False)
class RIRBulkDeleteView(generic.BulkDeleteView):
queryset = RIR.objects.annotate(
@@ -284,11 +268,6 @@ class ASNRangeBulkEditView(generic.BulkEditView):
form = forms.ASNRangeBulkEditForm
@register_model_view(ASNRange, 'bulk_rename', path='rename', detail=False)
class ASNRangeBulkRenameView(generic.BulkRenameView):
queryset = ASNRange.objects.all()
@register_model_view(ASNRange, 'bulk_delete', path='delete', detail=False)
class ASNRangeBulkDeleteView(generic.BulkDeleteView):
queryset = ASNRange.objects.annotate_asn_counts()
@@ -356,11 +335,6 @@ class ASNBulkEditView(generic.BulkEditView):
form = forms.ASNBulkEditForm
@register_model_view(ASN, 'bulk_rename', path='rename', detail=False)
class ASNBulkRenameView(generic.BulkRenameView):
queryset = ASN.objects.all()
@register_model_view(ASN, 'bulk_delete', path='delete', detail=False)
class ASNBulkDeleteView(generic.BulkDeleteView):
queryset = ASN.objects.annotate(
@@ -382,7 +356,6 @@ class AggregateListView(generic.ObjectListView):
filterset = filtersets.AggregateFilterSet
filterset_form = forms.AggregateFilterForm
table = tables.AggregateTable
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
@register_model_view(Aggregate)
@@ -515,11 +488,6 @@ class RoleBulkEditView(generic.BulkEditView):
form = forms.RoleBulkEditForm
@register_model_view(Role, 'bulk_rename', path='rename', detail=False)
class RoleBulkRenameView(generic.BulkRenameView):
queryset = Role.objects.all()
@register_model_view(Role, 'bulk_delete', path='delete', detail=False)
class RoleBulkDeleteView(generic.BulkDeleteView):
queryset = Role.objects.all()
@@ -538,7 +506,6 @@ class PrefixListView(generic.ObjectListView):
filterset_form = forms.PrefixFilterForm
table = tables.PrefixTable
template_name = 'ipam/prefix_list.html'
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
@register_model_view(Prefix)
@@ -799,11 +766,6 @@ class IPRangeBulkEditView(generic.BulkEditView):
form = forms.IPRangeBulkEditForm
@register_model_view(IPRange, 'bulk_rename', path='rename', detail=False)
class IPRangeBulkRenameView(generic.BulkRenameView):
queryset = IPRange.objects.all()
@register_model_view(IPRange, 'bulk_delete', path='delete', detail=False)
class IPRangeBulkDeleteView(generic.BulkDeleteView):
queryset = IPRange.objects.all()
@@ -821,7 +783,6 @@ class IPAddressListView(generic.ObjectListView):
filterset = filtersets.IPAddressFilterSet
filterset_form = forms.IPAddressFilterForm
table = tables.IPAddressTable
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
@register_model_view(IPAddress)
@@ -1045,11 +1006,6 @@ class VLANGroupBulkEditView(generic.BulkEditView):
form = forms.VLANGroupBulkEditForm
@register_model_view(VLANGroup, 'bulk_rename', path='rename', detail=False)
class VLANGroupBulkRenameView(generic.BulkRenameView):
queryset = VLANGroup.objects.all()
@register_model_view(VLANGroup, 'bulk_delete', path='delete', detail=False)
class VLANGroupBulkDeleteView(generic.BulkDeleteView):
queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
@@ -1139,11 +1095,6 @@ class VLANTranslationPolicyBulkEditView(generic.BulkEditView):
form = forms.VLANTranslationPolicyBulkEditForm
@register_model_view(VLANTranslationPolicy, 'bulk_rename', path='rename', detail=False)
class VLANTranslationPolicyBulkRenameView(generic.BulkRenameView):
queryset = VLANTranslationPolicy.objects.all()
@register_model_view(VLANTranslationPolicy, 'bulk_delete', path='delete', detail=False)
class VLANTranslationPolicyBulkDeleteView(generic.BulkDeleteView):
queryset = VLANTranslationPolicy.objects.all()
@@ -1161,7 +1112,6 @@ class VLANTranslationRuleListView(generic.ObjectListView):
filterset = filtersets.VLANTranslationRuleFilterSet
filterset_form = forms.VLANTranslationRuleFilterForm
table = tables.VLANTranslationRuleTable
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
@register_model_view(VLANTranslationRule)
@@ -1294,11 +1244,6 @@ class FHRPGroupBulkEditView(generic.BulkEditView):
form = forms.FHRPGroupBulkEditForm
@register_model_view(FHRPGroup, 'bulk_rename', path='rename', detail=False)
class FHRPGroupBulkRenameView(generic.BulkRenameView):
queryset = FHRPGroup.objects.all()
@register_model_view(FHRPGroup, 'bulk_delete', path='delete', detail=False)
class FHRPGroupBulkDeleteView(generic.BulkDeleteView):
queryset = FHRPGroup.objects.all()
@@ -1426,11 +1371,6 @@ class VLANBulkEditView(generic.BulkEditView):
form = forms.VLANBulkEditForm
@register_model_view(VLAN, 'bulk_rename', path='rename', detail=False)
class VLANBulkRenameView(generic.BulkRenameView):
queryset = VLAN.objects.all()
@register_model_view(VLAN, 'bulk_delete', path='delete', detail=False)
class VLANBulkDeleteView(generic.BulkDeleteView):
queryset = VLAN.objects.all()
@@ -1481,11 +1421,6 @@ class ServiceTemplateBulkEditView(generic.BulkEditView):
form = forms.ServiceTemplateBulkEditForm
@register_model_view(ServiceTemplate, 'bulk_rename', path='rename', detail=False)
class ServiceTemplateBulkRenameView(generic.BulkRenameView):
queryset = ServiceTemplate.objects.all()
@register_model_view(ServiceTemplate, 'bulk_delete', path='delete', detail=False)
class ServiceTemplateBulkDeleteView(generic.BulkDeleteView):
queryset = ServiceTemplate.objects.all()
@@ -1553,11 +1488,6 @@ class ServiceBulkEditView(generic.BulkEditView):
form = forms.ServiceBulkEditForm
@register_model_view(Service, 'bulk_rename', path='rename', detail=False)
class ServiceBulkRenameView(generic.BulkRenameView):
queryset = Service.objects.all()
@register_model_view(Service, 'bulk_delete', path='delete', detail=False)
class ServiceBulkDeleteView(generic.BulkDeleteView):
queryset = Service.objects.prefetch_related('parent')

View File

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

View File

@@ -1,6 +1,7 @@
from django.db.models import QuerySet
from rest_framework.pagination import LimitOffsetPagination
from netbox.api.exceptions import QuerySetNotOrdered
from netbox.config import get_config
@@ -15,6 +16,12 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
def paginate_queryset(self, queryset, request, view=None):
if isinstance(queryset, QuerySet) and not queryset.ordered:
raise QuerySetNotOrdered(
"Paginating over an unordered queryset is unreliable. Ensure that a minimal "
"ordering has been applied to the queryset for this API endpoint."
)
if isinstance(queryset, QuerySet):
self.count = self.get_queryset_count(queryset)
else:

View File

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

View File

@@ -8,6 +8,7 @@ from django_pglocks import advisory_lock
from rq.timeouts import JobTimeoutException
from core.choices import JobStatusChoices
from core.exceptions import JobFailed
from core.models import Job, ObjectType
from netbox.constants import ADVISORY_LOCK_KEYS
from netbox.registry import registry
@@ -73,15 +74,21 @@ class JobRunner(ABC):
This method is called by the Job Scheduler to handle the execution of all job commands. It will maintain the
job's metadata and handle errors. For periodic jobs, a new job is automatically scheduled using its `interval`.
"""
logger = logging.getLogger('netbox.jobs')
try:
job.start()
cls(job).run(*args, **kwargs)
job.terminate()
except JobFailed:
logger.warning(f"Job {job} failed")
job.terminate(status=JobStatusChoices.STATUS_FAILED)
except Exception as e:
job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=repr(e))
if type(e) is JobTimeoutException:
logging.error(e)
logger.error(e)
# If the executed job is a periodic job, schedule its next execution at the specified interval.
finally:

View File

@@ -1,6 +1,8 @@
from dataclasses import dataclass
from typing import Sequence, Optional
from django.urls import reverse_lazy
__all__ = (
'get_model_item',
@@ -22,20 +24,46 @@ class MenuItemButton:
link: str
title: str
icon_class: str
_url: Optional[str] = None
permissions: Optional[Sequence[str]] = ()
color: Optional[str] = None
def __post_init__(self):
if self.link:
self._url = reverse_lazy(self.link)
@property
def url(self):
return self._url
@url.setter
def url(self, value):
self._url = value
@dataclass
class MenuItem:
link: str
link_text: str
_url: Optional[str] = None
permissions: Optional[Sequence[str]] = ()
auth_required: Optional[bool] = False
staff_only: Optional[bool] = False
buttons: Optional[Sequence[MenuItemButton]] = ()
def __post_init__(self):
if self.link:
self._url = reverse_lazy(self.link)
@property
def url(self):
return self._url
@url.setter
def url(self, value):
self._url = value
@dataclass
class MenuGroup:

View File

@@ -1,180 +0,0 @@
from django.urls import reverse
from django.urls.exceptions import NoReverseMatch
from django.utils.translation import gettext as _
from core.models import ObjectType
from extras.models import ExportTemplate
from utilities.querydict import prepare_cloned_fields
__all__ = (
'AddObject',
'BulkDelete',
'BulkEdit',
'BulkExport',
'BulkImport',
'BulkRename',
'CloneObject',
'DeleteObject',
'EditObject',
'ObjectAction',
)
class ObjectAction:
"""
Base class for single- and multi-object operations.
Params:
name: The action name appended to the module for view resolution
label: Human-friendly label for the rendered button
multi: Set to True if this action is performed by selecting multiple objects (i.e. using a table)
permissions_required: The set of permissions a user must have to perform the action
url_kwargs: The set of URL keyword arguments to pass when resolving the view's URL
"""
name = ''
label = None
multi = False
permissions_required = set()
url_kwargs = []
@classmethod
def get_url(cls, obj):
viewname = f'{obj._meta.app_label}:{obj._meta.model_name}_{cls.name}'
kwargs = {
kwarg: getattr(obj, kwarg) for kwarg in cls.url_kwargs
}
try:
return reverse(viewname, kwargs=kwargs)
except NoReverseMatch:
return
@classmethod
def get_context(cls, context, obj):
return {
'url': cls.get_url(obj),
'label': cls.label,
}
class AddObject(ObjectAction):
"""
Create a new object.
"""
name = 'add'
label = _('Add')
permissions_required = {'add'}
template_name = 'buttons/add.html'
class CloneObject(ObjectAction):
"""
Populate the new object form with select details from an existing object.
"""
name = 'add'
label = _('Clone')
permissions_required = {'add'}
template_name = 'buttons/clone.html'
@classmethod
def get_context(cls, context, obj):
param_string = prepare_cloned_fields(obj).urlencode()
url = f'{cls.get_url(obj)}?{param_string}' if param_string else None
return {
'url': url,
'label': cls.label,
}
class EditObject(ObjectAction):
"""
Edit a single object.
"""
name = 'edit'
label = _('Edit')
permissions_required = {'change'}
url_kwargs = ['pk']
template_name = 'buttons/edit.html'
class DeleteObject(ObjectAction):
"""
Delete a single object.
"""
name = 'delete'
label = _('Delete')
permissions_required = {'delete'}
url_kwargs = ['pk']
template_name = 'buttons/delete.html'
class BulkImport(ObjectAction):
"""
Import multiple objects at once.
"""
name = 'bulk_import'
label = _('Import')
permissions_required = {'add'}
template_name = 'buttons/import.html'
class BulkExport(ObjectAction):
"""
Export multiple objects at once.
"""
name = 'export'
label = _('Export')
permissions_required = {'view'}
template_name = 'buttons/export.html'
@classmethod
def get_context(cls, context, model):
object_type = ObjectType.objects.get_for_model(model)
user = context['request'].user
# Determine if the "all data" export returns CSV or YAML
data_format = 'YAML' if hasattr(object_type.model_class(), 'to_yaml') else 'CSV'
# Retrieve all export templates for this model
export_templates = ExportTemplate.objects.restrict(user, 'view').filter(object_types=object_type)
return {
'label': cls.label,
'perms': context['perms'],
'object_type': object_type,
'url_params': context['request'].GET.urlencode() if context['request'].GET else '',
'export_templates': export_templates,
'data_format': data_format,
}
class BulkEdit(ObjectAction):
"""
Change the value of one or more fields on a set of objects.
"""
name = 'bulk_edit'
label = _('Edit Selected')
multi = True
permissions_required = {'change'}
template_name = 'buttons/bulk_edit.html'
class BulkRename(ObjectAction):
"""
Rename multiple objects at once.
"""
name = 'bulk_rename'
label = _('Rename Selected')
multi = True
permissions_required = {'change'}
template_name = 'buttons/bulk_rename.html'
class BulkDelete(ObjectAction):
"""
Delete each of a set of objects.
"""
name = 'bulk_delete'
label = _('Delete Selected')
multi = True
permissions_required = {'delete'}
template_name = 'buttons/bulk_delete.html'

View File

@@ -1,3 +1,4 @@
from django.urls import reverse_lazy
from django.utils.text import slugify
from django.utils.translation import gettext as _
@@ -32,17 +33,23 @@ class PluginMenuItem:
This class represents a navigation menu item. This constitutes primary link and its text, but also allows for
specifying additional link buttons that appear to the right of the item in the van menu.
Links are specified as Django reverse URL strings.
Links are specified as Django reverse URL strings suitable for rendering via {% url item.link %}.
Alternatively, a pre-generated url can be set on the object which will be rendered literally.
Buttons are each specified as a list of PluginMenuButton instances.
"""
permissions = []
buttons = []
_url = None
def __init__(self, link, link_text, auth_required=False, staff_only=False, permissions=None, buttons=None):
def __init__(
self, link, link_text, auth_required=False, staff_only=False, permissions=None, buttons=None
):
self.link = link
self.link_text = link_text
self.auth_required = auth_required
self.staff_only = staff_only
if link:
self._url = reverse_lazy(link)
if permissions is not None:
if type(permissions) not in (list, tuple):
raise TypeError(_("Permissions must be passed as a tuple or list."))
@@ -52,6 +59,14 @@ class PluginMenuItem:
raise TypeError(_("Buttons must be passed as a tuple or list."))
self.buttons = buttons
@property
def url(self):
return self._url
@url.setter
def url(self, value):
self._url = value
class PluginMenuButton:
"""
@@ -60,11 +75,14 @@ class PluginMenuButton:
"""
color = ButtonColorChoices.DEFAULT
permissions = []
_url = None
def __init__(self, link, title, icon_class, color=None, permissions=None):
self.link = link
self.title = title
self.icon_class = icon_class
if link:
self._url = reverse_lazy(link)
if permissions is not None:
if type(permissions) not in (list, tuple):
raise TypeError(_("Permissions must be passed as a tuple or list."))
@@ -73,3 +91,11 @@ class PluginMenuButton:
if color not in ButtonColorChoices.values():
raise ValueError(_("Button color must be a choice within ButtonColorChoices."))
self.color = color
@property
def url(self):
return self._url
@url.setter
def url(self, value):
self._url = value

View File

@@ -72,16 +72,6 @@ PREFERENCES = {
),
description=_('The preferred syntax for displaying generic data within the UI')
),
'csv_delimiter': UserPreference(
label=_('CSV delimiter'),
choices=(
('comma', 'Comma (,)'),
('semicolon', 'Semicolon (;)'),
('pipe', 'Pipe (|)'),
),
default='comma',
description=_('The character used to separate fields in CSV data')
),
}

View File

@@ -115,11 +115,13 @@ class CachedValueSearchBackend(SearchBackend):
if lookup in (LookupTypes.STARTSWITH, LookupTypes.ENDSWITH):
# "Starts/ends with" matches are valid only on string values
query_filter &= Q(type=FieldTypes.STRING)
elif lookup == LookupTypes.PARTIAL:
elif lookup in (LookupTypes.PARTIAL, LookupTypes.EXACT):
try:
# If the value looks like an IP address, add an extra match for CIDR values
# If the value looks like an IP address, add extra filters for CIDR/INET values
address = str(netaddr.IPNetwork(value.strip()).cidr)
query_filter |= Q(type=FieldTypes.CIDR) & Q(value__net_contains_or_equals=address)
query_filter |= Q(type=FieldTypes.INET) & Q(value__net_host=address)
if lookup == LookupTypes.PARTIAL:
query_filter |= Q(type=FieldTypes.CIDR) & Q(value__net_contains_or_equals=address)
except (AddrFormatError, ValueError):
pass

View File

@@ -1,8 +1,13 @@
import uuid
from django.test import RequestFactory, TestCase
from django.urls import reverse
from rest_framework.request import Request
from netbox.api.exceptions import QuerySetNotOrdered
from netbox.api.pagination import OptionalLimitOffsetPagination
from utilities.testing import APITestCase
from users.models import Token
class AppTest(APITestCase):
@@ -26,3 +31,40 @@ class AppTest(APITestCase):
response = self.client.get(f'{url}?format=api', **self.header)
self.assertEqual(response.status_code, 200)
class OptionalLimitOffsetPaginationTest(TestCase):
def setUp(self):
self.paginator = OptionalLimitOffsetPagination()
self.factory = RequestFactory()
def _make_drf_request(self, path='/', query_params=None):
"""Helper to create a proper DRF Request object"""
return Request(self.factory.get(path, query_params or {}))
def test_raises_exception_for_unordered_queryset(self):
"""Should raise QuerySetNotOrdered for unordered QuerySet"""
queryset = Token.objects.all().order_by()
request = self._make_drf_request()
with self.assertRaises(QuerySetNotOrdered) as cm:
self.paginator.paginate_queryset(queryset, request)
error_msg = str(cm.exception)
self.assertIn("Paginating over an unordered queryset is unreliable", error_msg)
self.assertIn("Ensure that a minimal ordering has been applied", error_msg)
def test_allows_ordered_queryset(self):
"""Should not raise exception for ordered QuerySet"""
queryset = Token.objects.all().order_by('created')
request = self._make_drf_request()
self.paginator.paginate_queryset(queryset, request) # Should not raise exception
def test_allows_non_queryset_iterables(self):
"""Should not raise exception for non-QuerySet iterables"""
iterable = [1, 2, 3, 4, 5]
request = self._make_drf_request()
self.paginator.paginate_queryset(iterable, request) # Should not raise exception

View File

@@ -7,11 +7,15 @@ from django_rq import get_queue
from ..jobs import *
from core.models import DataSource, Job
from core.choices import JobStatusChoices
from core.exceptions import JobFailed
from utilities.testing import disable_warnings
class TestJobRunner(JobRunner):
def run(self, *args, **kwargs):
pass
if kwargs.get('make_fail', False):
raise JobFailed()
class JobRunnerTestCase(TestCase):
@@ -49,6 +53,12 @@ class JobRunnerTest(JobRunnerTestCase):
self.assertEqual(job.status, JobStatusChoices.STATUS_COMPLETED)
def test_handle_failed(self):
with disable_warnings('netbox.jobs'):
job = TestJobRunner.enqueue(immediate=True, make_fail=True)
self.assertEqual(job.status, JobStatusChoices.STATUS_FAILED)
def test_handle_errored(self):
class ErroredJobRunner(TestJobRunner):
EXP = Exception('Test error')

View File

@@ -15,16 +15,15 @@ from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _
from django_tables2.export import TableExport
from mptt.models import MPTTModel
from core.models import ObjectType
from core.signals import clear_events
from extras.choices import CustomFieldUIEditableChoices
from extras.models import CustomField, ExportTemplate
from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport, BulkRename
from utilities.error_handlers import handle_protectederror
from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation
from utilities.export import TableExport
from utilities.forms import BulkRenameForm, ConfirmationForm, restrict_form_fields
from utilities.forms.bulk_import import BulkImportForm
from utilities.htmx import htmx_partial
@@ -55,12 +54,12 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
Attributes:
filterset: A django-filter FilterSet that is applied to the queryset
filterset_form: The form class used to render filter options
actions: An iterable of ObjectAction subclasses (see ActionsMixin)
actions: A mapping of supported actions to their required permissions. When adding custom actions, bulk
action names must be prefixed with `bulk_`. (See ActionsMixin.)
"""
template_name = 'generic/object_list.html'
filterset = None
filterset_form = None
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkRename, BulkDelete)
def get_required_permission(self):
return get_permission_for_model(self.queryset.model, 'view')
@@ -77,7 +76,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
return '---\n'.join(yaml_data)
def export_table(self, table, columns=None, filename=None, delimiter=None):
def export_table(self, table, columns=None, filename=None):
"""
Export all table data in CSV format.
@@ -86,7 +85,6 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
columns: A list of specific columns to include. If None, all columns will be exported.
filename: The name of the file attachment sent to the client. If None, will be determined automatically
from the queryset model name.
delimiter: The character used to separate columns (a comma is used by default)
"""
exclude_columns = {'pk', 'actions'}
if columns:
@@ -97,8 +95,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
exporter = TableExport(
export_format=TableExport.CSV,
table=table,
exclude_columns=exclude_columns,
delimiter=delimiter,
exclude_columns=exclude_columns
)
return exporter.response(
filename=filename or f'netbox_{self.queryset.model._meta.verbose_name_plural}.csv'
@@ -153,16 +150,15 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
# Determine the available actions
actions = self.get_permitted_actions(request.user)
has_table_actions = any(action.multi for action in actions)
has_bulk_actions = any([a.startswith('bulk_') for a in actions])
if 'export' in request.GET:
# Export the current table view
if request.GET['export'] == 'table':
table = self.get_table(self.queryset, request, has_table_actions)
table = self.get_table(self.queryset, request, has_bulk_actions)
columns = [name for name, _ in table.selected_columns]
delimiter = request.user.config.get('csv_delimiter')
return self.export_table(table, columns, delimiter=delimiter)
return self.export_table(table, columns)
# Render an ExportTemplate
elif request.GET['export']:
@@ -178,12 +174,11 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
# Fall back to default table/YAML export
else:
table = self.get_table(self.queryset, request, has_table_actions)
delimiter = request.user.config.get('csv_delimiter')
return self.export_table(table, delimiter=delimiter)
table = self.get_table(self.queryset, request, has_bulk_actions)
return self.export_table(table)
# Render the objects table
table = self.get_table(self.queryset, request, has_table_actions)
table = self.get_table(self.queryset, request, has_bulk_actions)
# If this is an HTMX request, return only the rendered table HTML
if htmx_partial(request):
@@ -734,11 +729,7 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
"""
An extendable view for renaming objects in bulk.
Attributes:
field_name: The name of the object attribute for which the value is being updated (defaults to "name")
"""
field_name = 'name'
template_name = 'generic/bulk_rename.html'
def __init__(self, *args, **kwargs):
@@ -768,12 +759,12 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
replace = form.cleaned_data['replace']
if form.cleaned_data['use_regex']:
try:
obj.new_name = re.sub(find, replace, getattr(obj, self.field_name, ''))
obj.new_name = re.sub(find, replace, obj.name or '')
# Catch regex group reference errors
except re.error:
obj.new_name = getattr(obj, self.field_name)
obj.new_name = obj.name
else:
obj.new_name = getattr(obj, self.field_name, '').replace(find, replace)
obj.new_name = (obj.name or '').replace(find, replace)
renamed_pks.append(obj.pk)
return renamed_pks
@@ -792,7 +783,7 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
if '_apply' in request.POST:
for obj in selected_objects:
setattr(obj, self.field_name, obj.new_name)
obj.name = obj.new_name
obj.save()
# Enforce constrained permissions
@@ -822,7 +813,6 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
selected_objects = self.queryset.filter(pk__in=form.initial['pk'])
return render(request, self.template_name, {
'field_name': self.field_name,
'form': form,
'obj_type_plural': self.queryset.model._meta.verbose_name_plural,
'selected_objects': selected_objects,

View File

@@ -1,7 +1,7 @@
from django.shortcuts import get_object_or_404
from extras.models import TableConfig
from netbox import object_actions
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
from utilities.permissions import get_permission_for_model
__all__ = (
@@ -9,18 +9,6 @@ __all__ = (
'TableMixin',
)
# TODO: Remove in NetBox v4.5
LEGACY_ACTIONS = {
'add': object_actions.AddObject,
'edit': object_actions.EditObject,
'delete': object_actions.DeleteObject,
'export': object_actions.BulkExport,
'bulk_import': object_actions.BulkImport,
'bulk_edit': object_actions.BulkEdit,
'bulk_rename': object_actions.BulkRename,
'bulk_delete': object_actions.BulkDelete,
}
class ActionsMixin:
"""
@@ -31,24 +19,7 @@ class ActionsMixin:
Standard actions include: add, import, export, bulk_edit, and bulk_delete. Some views extend this default map
with custom actions, such as bulk_sync.
"""
actions = tuple()
# TODO: Remove in NetBox v4.5
def _convert_legacy_actions(self):
"""
Convert a legacy dictionary mapping action name to required permissions to a list of ObjectAction subclasses.
"""
if type(self.actions) is not dict:
return
actions = []
for name in self.actions.keys():
try:
actions.append(LEGACY_ACTIONS[name])
except KeyError:
raise ValueError(f"Unsupported legacy action: {name}")
self.actions = actions
actions = DEFAULT_ACTION_PERMISSIONS
def get_permitted_actions(self, user, model=None):
"""
@@ -56,15 +27,11 @@ class ActionsMixin:
"""
model = model or self.queryset.model
# TODO: Remove in NetBox v4.5
# Handle legacy action sets
self._convert_legacy_actions()
# Resolve required permissions for each action
permitted_actions = []
for action in self.actions:
required_permissions = [
get_permission_for_model(model, perm) for perm in action.permissions_required
get_permission_for_model(model, name) for name in self.actions.get(action, set())
]
if not required_permissions or user.has_perms(required_permissions):
permitted_actions.append(action)

View File

@@ -14,9 +14,6 @@ from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _
from core.signals import clear_events
from netbox.object_actions import (
AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport, CloneObject, DeleteObject, EditObject,
)
from utilities.error_handlers import handle_protectederror
from utilities.exceptions import AbortRequest, PermissionsViolation
from utilities.forms import ConfirmationForm, restrict_form_fields
@@ -39,7 +36,7 @@ __all__ = (
)
class ObjectView(ActionsMixin, BaseObjectView):
class ObjectView(BaseObjectView):
"""
Retrieve a single object for display.
@@ -47,10 +44,8 @@ class ObjectView(ActionsMixin, BaseObjectView):
Attributes:
tab: A ViewTab instance for the view
actions: An iterable of ObjectAction subclasses (see ActionsMixin)
"""
tab = None
actions = (CloneObject, EditObject, DeleteObject)
def get_required_permission(self):
return get_permission_for_model(self.queryset.model, 'view')
@@ -77,11 +72,9 @@ class ObjectView(ActionsMixin, BaseObjectView):
request: The current request
"""
instance = self.get_object(**kwargs)
actions = self.get_permitted_actions(request.user, model=instance)
return render(request, self.get_template_name(), {
'object': instance,
'actions': actions,
'tab': self.tab,
**self.get_extra_context(request, instance),
})
@@ -97,13 +90,13 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
table: The django-tables2 Table class used to render the child objects list
filterset: A django-filter FilterSet that is applied to the queryset
filterset_form: The form class used to render filter options
actions: An iterable of ObjectAction subclasses (see ActionsMixin)
actions: A mapping of supported actions to their required permissions. When adding custom actions, bulk
action names must be prefixed with `bulk_`. (See ActionsMixin.)
"""
child_model = None
table = None
filterset = None
filterset_form = None
actions = (AddObject, BulkImport, BulkEdit, BulkExport, BulkDelete)
template_name = 'generic/object_children.html'
def get_children(self, request, parent):
@@ -145,10 +138,10 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
# Determine the available actions
actions = self.get_permitted_actions(request.user, model=self.child_model)
has_table_actions = any(action.multi for action in actions)
has_bulk_actions = any([a.startswith('bulk_') for a in actions])
table_data = self.prep_table_data(request, child_objects, instance)
table = self.get_table(table_data, request, has_table_actions)
table = self.get_table(table_data, request, has_bulk_actions)
# If this is an HTMX request, return only the rendered table HTML
if htmx_partial(request):

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -23,13 +23,13 @@
},
"dependencies": {
"@mdi/font": "7.4.47",
"@tabler/core": "1.3.2",
"@tabler/core": "1.4.0",
"bootstrap": "5.3.7",
"clipboard": "2.0.11",
"flatpickr": "4.6.13",
"gridstack": "12.2.1",
"htmx.org": "2.0.5",
"query-string": "9.2.1",
"gridstack": "12.2.2",
"htmx.org": "2.0.6",
"query-string": "9.2.2",
"sass": "1.89.2",
"tom-select": "2.4.3",
"typeface-inter": "3.18.1",
@@ -39,15 +39,15 @@
"@types/bootstrap": "5.2.10",
"@types/cookie": "^0.6.0",
"@types/node": "^22.3.0",
"@typescript-eslint/eslint-plugin": "^8.1.0",
"@typescript-eslint/parser": "^8.1.0",
"esbuild": "^0.25.3",
"@typescript-eslint/eslint-plugin": "^8.37.0",
"@typescript-eslint/parser": "^8.37.0",
"esbuild": "^0.25.6",
"esbuild-sass-plugin": "^3.3.1",
"eslint": "<9.0",
"eslint-config-prettier": "^9.1.0",
"eslint-import-resolver-typescript": "^3.6.3",
"eslint-plugin-import": "^2.30.0",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-prettier": "^5.5.1",
"prettier": "^3.3.3",
"typescript": "<5.5"
},

View File

@@ -35,7 +35,7 @@ function showRackElements(
selector: string,
elevation: HTMLObjectElement,
): void {
const elements = elevation.contentDocument?.querySelectorAll(selector) ?? [];
const elements = elevation.querySelectorAll(selector) ?? [];
for (const element of elements) {
element.classList.remove('hidden');
}
@@ -45,7 +45,7 @@ function hideRackElements(
selector: string,
elevation: HTMLObjectElement,
): void {
const elements = elevation.contentDocument?.querySelectorAll(selector) ?? [];
const elements = elevation.querySelectorAll(selector) ?? [];
for (const element of elements) {
element.classList.add('hidden');
}

View File

@@ -0,0 +1,4 @@
.rack-loading-container {
min-height: 200px;
margin-left: 30px;
}

View File

@@ -27,3 +27,4 @@
@import 'custom/markdown';
@import 'custom/misc';
@import 'custom/notifications';
@import 'custom/racks';

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,3 @@
version: "4.3.3"
version: "4.3.5"
edition: "Community"
published: "2025-06-26"
published: "2025-07-29"

View File

@@ -55,7 +55,7 @@ Blocks:
{# Release info #}
<div class="text-muted text-center fs-5 my-3">
{{ settings.RELEASE.name }}
{% if not settings.RELEASE.features.commercial %}
{% if not settings.RELEASE.features.commercial and not settings.ISOLATED_DEPLOYMENT %}
<div>
<a href="https://netboxlabs.com/netbox-cloud/" class="text-muted">{% trans "Get" %} Cloud</a> |
<a href="https://netboxlabs.com/netbox-enterprise/" class="text-muted">{% trans "Get" %} Enterprise</a>
@@ -184,7 +184,7 @@ Blocks:
{% endif %}
{# Commercial links #}
{% if settings.RELEASE.features.commercial %}
{% if settings.RELEASE.features.commercial and not settings.ISOLATED_DEPLOYMENT %}
{# LinkedIn #}
<li class="list-inline-item">
<a href="https://www.linkedin.com/company/netboxlabs/" target="_blank" class="link-secondary" rel="noopener" aria-label="LinkedIn">
@@ -199,7 +199,7 @@ Blocks:
</li>
{# Community links #}
{% else %}
{% elif not settings.ISOLATED_DEPLOYMENT %}
{# GitHub #}
<li class="list-inline-item">
<a href="https://github.com/netbox-community/netbox" target="_blank" class="link-secondary" rel="noopener" aria-label="{% trans "Source Code" %}">

View File

@@ -1,3 +0,0 @@
<button type="submit" name="_sync" {% formaction %}="{{ url }}" class="btn btn-primary">
<i class="mdi mdi-sync" aria-hidden="true"></i> {{ label }}
</button>

View File

@@ -11,6 +11,12 @@
<li class="breadcrumb-item"><a href="{% url 'core:datafile_list' %}?source_id={{ object.source.pk }}">{{ object.source }}</a></li>
{% endblock %}
{% block control-buttons %}
{% if request.user|can_delete:object %}
{% delete_button object %}
{% endif %}
{% endblock control-buttons %}
{% block content %}
<div class="row mb-3">
<div class="col">

View File

@@ -22,6 +22,12 @@
{% endif %}
{% endblock breadcrumbs %}
{% block control-buttons %}
{% if request.user|can_delete:object %}
{% delete_button object %}
{% endif %}
{% endblock control-buttons %}
{% block content %}
<div class="row mb-3">
<div class="col col-12 col-md-6">

View File

@@ -1,71 +0,0 @@
{% load i18n %}
<div class="btn-group">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {{ label }}
</button>
<ul class="dropdown-menu">
{% if perms.dcim.add_consoleport %}
<li>
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_consoleport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Console Ports" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_consoleserverport %}
<li>
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_consoleserverport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item ">
{% trans "Console Server Ports" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_powerport %}
<li>
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_powerport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Power Ports" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_poweroutlet %}
<li>
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_poweroutlet' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Power Outlets" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_interface %}
<li>
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_interface' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Interfaces" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_rearport %}
<li>
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_rearport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Rear Ports" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_devicebay %}
<li>
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_devicebay' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Device Bays" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_modulebay %}
<li>
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_modulebay' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Module Bays" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_inventoryitem %}
<li>
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_inventoryitem' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Inventory Items" %}
</button>
</li>
{% endif %}
</ul>
</div>

View File

@@ -1,3 +0,0 @@
<button type="submit" name="_disconnect" {% formaction %}="{{ url }}" class="btn btn-red">
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i> {{ label }}
</button>

View File

@@ -0,0 +1,22 @@
{% extends 'generic/object_list.html' %}
{% load buttons %}
{% load helpers %}
{% load i18n %}
{% block bulk_buttons %}
<div class="btn-group" role="group">
{% if 'bulk_edit' in actions %}
{% bulk_edit_button model query_params=request.GET %}
{% endif %}
{% if 'bulk_rename' in actions %}
{% with bulk_rename_view=model|validated_viewname:"bulk_rename" %}
<button type="submit" name="_rename" {% formaction %}="{% url bulk_rename_view %}" class="btn btn-outline-warning btn-float">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename Selected" %}
</button>
{% endwith %}
{% endif %}
</div>
{% if 'bulk_delete' in actions %}
{% bulk_delete_button model query_params=request.GET %}
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,23 @@
{% extends 'generic/object_children.html' %}
{% load helpers %}
{% block bulk_edit_controls %}
{% with bulk_edit_view=child_model|validated_viewname:"bulk_edit" %}
{% if 'bulk_edit' in actions and bulk_edit_view %}
<button type="submit" name="_edit"
{% formaction %}="{% url bulk_edit_view %}?device={{ object.pk }}&return_url={{ return_url }}"
class="btn btn-warning">
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit Selected
</button>
{% endif %}
{% endwith %}
{% with bulk_rename_view=child_model|validated_viewname:"bulk_rename" %}
{% if 'bulk_rename' in actions and bulk_rename_view %}
<button type="submit" name="_rename"
{% formaction %}="{% url bulk_rename_view %}?return_url={{ return_url }}"
class="btn btn-outline-warning">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
</button>
{% endif %}
{% endwith %}
{% endblock bulk_edit_controls %}

View File

@@ -0,0 +1,28 @@
{% extends 'dcim/device/components_base.html' %}
{% load helpers %}
{% load i18n %}
{% block bulk_delete_controls %}
{{ block.super }}
{% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
{% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
<button type="submit" name="_disconnect"
{% formaction %}="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
class="btn btn-outline-danger">
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
</button>
{% endif %}
{% endwith %}
{% endblock bulk_delete_controls %}
{% block bulk_extra_controls %}
{{ block.super }}
{% if perms.dcim.add_consoleport %}
<div class="btn-group" role="group">
<a href="{% url 'dcim:consoleport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_consoleports' pk=object.pk %}"
class="btn btn-primary">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Console Ports" %}
</a>
</div>
{% endif %}
{% endblock bulk_extra_controls %}

View File

@@ -0,0 +1,28 @@
{% extends 'dcim/device/components_base.html' %}
{% load helpers %}
{% load i18n %}
{% block bulk_delete_controls %}
{{ block.super }}
{% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
{% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
<button type="submit" name="_disconnect"
{% formaction %}="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
class="btn btn-outline-danger">
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
</button>
{% endif %}
{% endwith %}
{% endblock bulk_delete_controls %}
{% block bulk_extra_controls %}
{{ block.super }}
{% if perms.dcim.add_consoleserverport %}
<div class="btn-group" role="group">
<a href="{% url 'dcim:consoleserverport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}"
class="btn btn-primary">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Console Server Ports" %}
</a>
</div>
{% endif %}
{% endblock bulk_extra_controls %}

View File

@@ -0,0 +1,14 @@
{% extends 'dcim/device/components_base.html' %}
{% load i18n %}
{% block bulk_extra_controls %}
{{ block.super }}
{% if perms.dcim.add_devicebay %}
<div class="btn-group" role="group">
<a href="{% url 'dcim:devicebay_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_devicebays' pk=object.pk %}"
class="btn btn-primary">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Device Bays" %}
</a>
</div>
{% endif %}
{% endblock bulk_extra_controls %}

View File

@@ -0,0 +1,28 @@
{% extends 'dcim/device/components_base.html' %}
{% load helpers %}
{% load i18n %}
{% block bulk_delete_controls %}
{{ block.super }}
{% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
{% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
<button type="submit" name="_disconnect"
{% formaction %}="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
class="btn btn-outline-danger">
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
</button>
{% endif %}
{% endwith %}
{% endblock bulk_delete_controls %}
{% block bulk_extra_controls %}
{{ block.super }}
{% if perms.dcim.add_frontport %}
<div class="btn-group" role="group">
<a href="{% url 'dcim:frontport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_frontports' pk=object.pk %}"
class="btn btn-primary">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Front Ports" %}
</a>
</div>
{% endif %}
{% endblock bulk_extra_controls %}

View File

@@ -1,5 +1,30 @@
{% extends 'generic/object_children.html' %}
{% extends 'dcim/device/components_base.html' %}
{% load helpers %}
{% load i18n %}
{% block table_controls %}
{% include 'dcim/device/inc/interface_table_controls.html' with table_modal="DeviceInterfaceTable_config" %}
{% include 'dcim/device/inc/interface_table_controls.html' with table_modal="DeviceInterfaceTable_config" %}
{% endblock table_controls %}
{% block bulk_delete_controls %}
{{ block.super }}
{% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
{% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
<button type="submit" name="_disconnect"
{% formaction %}="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
class="btn btn-outline-danger">
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
</button>
{% endif %}
{% endwith %}
{% endblock bulk_delete_controls %}
{% block bulk_extra_controls %}
{{ block.super }}
{% if perms.dcim.add_interface %}
<a href="{% url 'dcim:interface_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}"
class="btn btn-primary">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Interfaces" %}
</a>
{% endif %}
{% endblock bulk_extra_controls %}

View File

@@ -0,0 +1,14 @@
{% extends 'dcim/device/components_base.html' %}
{% load i18n %}
{% block bulk_extra_controls %}
{{ block.super }}
{% if perms.dcim.add_inventoryitem %}
<div class="btn-group" role="group">
<a href="{% url 'dcim:inventoryitem_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_inventory' pk=object.pk %}"
class="btn btn-primary">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Inventory Item" %}
</a>
</div>
{% endif %}
{% endblock bulk_extra_controls %}

View File

@@ -0,0 +1,14 @@
{% extends 'dcim/device/components_base.html' %}
{% load i18n %}
{% block bulk_extra_controls %}
{{ block.super }}
{% if perms.dcim.add_modulebay %}
<div class="btn-group" role="group">
<a href="{% url 'dcim:modulebay_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_modulebays' pk=object.pk %}"
class="btn btn-primary">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Module Bays" %}
</a>
</div>
{% endif %}
{% endblock bulk_extra_controls %}

View File

@@ -0,0 +1,28 @@
{% extends 'dcim/device/components_base.html' %}
{% load helpers %}
{% load i18n %}
{% block bulk_delete_controls %}
{{ block.super }}
{% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
{% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
<button type="submit" name="_disconnect"
{% formaction %}="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
class="btn btn-outline-danger">
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
</button>
{% endif %}
{% endwith %}
{% endblock bulk_delete_controls %}
{% block bulk_extra_controls %}
{{ block.super }}
{% if perms.dcim.add_poweroutlet %}
<div class="btn-group" role="group">
<a href="{% url 'dcim:poweroutlet_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}"
class="btn btn-primary">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Power Outlets" %}
</a>
</div>
{% endif %}
{% endblock bulk_extra_controls %}

View File

@@ -0,0 +1,28 @@
{% extends 'dcim/device/components_base.html' %}
{% load helpers %}
{% load i18n %}
{% block bulk_delete_controls %}
{{ block.super }}
{% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
{% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
<button type="submit" name="_disconnect"
{% formaction %}="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
class="btn btn-outline-danger">
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
</button>
{% endif %}
{% endwith %}
{% endblock bulk_delete_controls %}
{% block bulk_extra_controls %}
{{ block.super }}
{% if perms.dcim.add_powerport %}
<div class="btn-group" role="group">
<a href="{% url 'dcim:powerport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_powerports' pk=object.pk %}"
class="btn btn-primary">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Power Port" %}
</a>
</div>
{% endif %}
{% endblock bulk_extra_controls %}

View File

@@ -0,0 +1,28 @@
{% extends 'dcim/device/components_base.html' %}
{% load helpers %}
{% load i18n %}
{% block bulk_delete_controls %}
{{ block.super }}
{% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
{% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
<button type="submit" name="_disconnect"
{% formaction %}="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
class="btn btn-outline-danger">
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
</button>
{% endif %}
{% endwith %}
{% endblock bulk_delete_controls %}
{% block bulk_extra_controls %}
{{ block.super }}
{% if perms.dcim.add_rearport %}
<div class="btn-group" role="group">
<a href="{% url 'dcim:rearport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_rearports' pk=object.pk %}"
class="btn btn-primary">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Rear Ports" %}
</a>
</div>
{% endif %}
{% endblock bulk_extra_controls %}

View File

@@ -0,0 +1,89 @@
{% extends 'generic/object_list.html' %}
{% load buttons %}
{% load i18n %}
{% block bulk_buttons %}
{% if perms.dcim.change_device %}
<div class="dropdown">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Components" %}
</button>
<ul class="dropdown-menu">
{% if perms.dcim.add_consoleport %}
<li>
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_consoleport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Console Ports" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_consoleserverport %}
<li>
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_consoleserverport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item ">
{% trans "Console Server Ports" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_powerport %}
<li>
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_powerport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Power Ports" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_poweroutlet %}
<li>
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_poweroutlet' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Power Outlets" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_interface %}
<li>
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_interface' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Interfaces" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_rearport %}
<li>
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_rearport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Rear Ports" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_devicebay %}
<li>
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_devicebay' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Device Bays" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_modulebay %}
<li>
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_modulebay' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Module Bays" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_inventoryitem %}
<li>
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_inventoryitem' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Inventory Items" %}
</button>
</li>
{% endif %}
</ul>
</div>
{% endif %}
{% if 'bulk_edit' in actions %}
<div class="btn-group" role="group">
{% bulk_edit_button model query_params=request.GET %}
<button type="submit" name="_rename" {% formaction %}="{% url 'dcim:device_bulk_rename' %}?return_url={% url 'dcim:device_list' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-outline-warning btn-float">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
</button>
</div>
{% endif %}
{% if 'bulk_delete' in actions %}
{% bulk_delete_button model query_params=request.GET %}
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,25 @@
{% extends 'generic/object_children.html' %}
{% load helpers %}
{% load i18n %}
{% load perms %}
{% block bulk_edit_controls %}
{% with bulk_edit_view=child_model|validated_viewname:"bulk_edit" %}
{% if 'bulk_edit' in actions and bulk_edit_view %}
<button type="submit" name="_edit"
{% formaction %}="{% url bulk_edit_view %}?device={{ object.pk }}&return_url={{ return_url }}"
class="btn btn-warning">
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit Selected
</button>
{% endif %}
{% endwith %}
{% with bulk_rename_view=child_model|validated_viewname:"bulk_rename" %}
{% if 'bulk_rename' in actions and bulk_rename_view %}
<button type="submit" name="_rename"
{% formaction %}="{% url bulk_rename_view %}?return_url={{ return_url }}"
class="btn btn-outline-warning">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename Selected
</button>
{% endif %}
{% endwith %}
{% endblock bulk_edit_controls %}

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