Compare commits

...

79 Commits

Author SHA1 Message Date
bctiemann
2d35cc56ed Merge pull request #18823 from netbox-community/release-v4.2.5
Release v4.2.5
2025-03-06 10:10:13 -05:00
Jeremy Stretch
dffa380e5c Tweak issue ordering 2025-03-06 09:50:32 -05:00
Brian Tiemann
6d2426843b Merge remote-tracking branch 'origin/release-v4.2.5' into release-v4.2.5 2025-03-06 09:28:08 -05:00
Brian Tiemann
e72b0606ba Bump Django and add missing PRs 2025-03-06 09:27:44 -05:00
Jeremy Stretch
c933cbf11e Update translations 2025-03-06 09:00:09 -05:00
github-actions
9f1ffb54f5 Update source translation strings 2025-03-06 05:02:11 +00:00
Brian Tiemann
29b8827128 Add translation files 2025-03-05 18:52:34 -05:00
bctiemann
6efc5682cd Override get_queryset on generic ObjectListView and NetBoxModelViewSet to reapply model-level ordering (#18805) 2025-03-05 15:52:00 -08:00
Brian Tiemann
033a960cab Fix strawberry-graphql==0.262.0 2025-03-05 18:46:30 -05:00
Brian Tiemann
9f69c46a99 NetBox v4.2.5 2025-03-05 18:43:41 -05:00
Marcus Weiner
631ff3e702 Allow primary key for nested models in OpenAPI request schemas (#18451) 2025-03-05 11:46:12 -08:00
Renato Almeida de Oliveira Zaroubin
ed6ccfb723 Add commit test in job execution 2025-03-05 09:34:18 -05:00
Renato Almeida de Oliveira Zaroubin
d3a9a6827f fix typo in VirtualCircuitFilterForm 2025-03-05 09:28:00 -05:00
github-actions
057653d362 Update source translation strings 2025-03-05 05:02:11 +00:00
Daniel Sheppard
4ab58f2da9 Fixes: #15016 - Catch AssertionError from cable trace and throw ValidationError (#16384) 2025-03-04 10:57:27 -08:00
github-actions
d208ddde9a Update source translation strings 2025-03-04 05:02:19 +00:00
bctiemann
0fbfc4f38c Merge pull request #18789 from jamestiotio/18774-tag-description
Closes #18774: Set title attribute of each tag to its description
2025-03-03 11:35:33 -05:00
Jason Novinger
e86dba8fc8 Fixes #18768: allow removing secondary MACAddress from interface 2025-03-03 09:35:00 -05:00
atownson
3e1d4369ba Closes #17944: Allow filtering of ObjectVar and MultiObjectVar script inputs (#18725)
* Add the advanced object selector to the ObjectVar and MultiObjectVar script inputs

* Fix formatting issue
2025-03-03 09:20:04 -05:00
James Raphael Tiovalen
06b5ff2e4a Closes #18774: Set title attribute of each tag to its description 2025-03-02 18:40:40 +08:00
github-actions
3b1daaaad6 Update source translation strings 2025-03-01 05:02:06 +00:00
Jeremy Stretch
63a167f130 Fixes #15924: Fix API interface patch tagged all mode (#18759)
* Fixes: #15924 - Prevent API payload from allowing tagged_vlans while interface mode is set to taged-all

* Prevent cleanup of tagged_vlans when no tagged_vlans set on interface

* Fix test errors

* Remove accidental debug statements

* Update validation to model clean method instead of serializer

* Remove clearing of tagged vlans from `save()`

* Make changes to validation to account for M2M not being available under model in addition to not being able to check incoming vlans under same model.

* Optimize untagged vlan check

* Re-ordering statements in validators

* Forgot to call super().clean()

* Adjust logic for form and serializer.  Add tests

* Fix test failure

* Fix ruff errors

* Fix test by removing now invalid test

* Update serializer, form and tests

* Optimize API test for vlan fields

* Optimize API serializer logic

---------

Co-authored-by: Daniel Sheppard <dans@dansheps.com>
2025-02-28 11:01:48 -05:00
Jason Novinger
09d867adc3 Fixes #18758: Enable sorting by Account count on prodiver list (#18763) 2025-02-28 07:28:09 -08:00
Jeremy Stretch
7aba6500dd Fixes #18141: Fix quick-add support for plugin models 2025-02-26 14:25:38 -05:00
github-actions
787a2dd7c2 Update source translation strings 2025-02-26 05:02:07 +00:00
Marcus Weiner
c81f4da780 Fix definition of vid_ranges in VLANGroup so it shows up in the OpenAPI schema (#18237) 2025-02-25 16:34:15 -08:00
Renato Almeida de Oliveira
cffb99cec5 Fixes: #17796 Custom Field Choices -> Create & Add Another causes IndexError (#18631) 2025-02-25 14:44:10 -08:00
Brian Tiemann
3b894f9ccb Handle null table in TableConfigForm 2025-02-25 12:59:26 -05:00
Alexander Haase
bf836c9bc2 Fixes 17357: Use virtual chassis name as fallback for device (#18710) 2025-02-25 07:55:00 -08:00
bctiemann
4a4596d5e8 Merge pull request #18721 from atownson/issue_18403
Closes #18403: Do not retrieve the data field from Job objects unless needed
2025-02-25 10:24:42 -05:00
atownson
48b825c64a Closes #18024: Add URL pattern for scripts to reference them by module.name (#18723)
* Add URL pattern for scripts to reference them by module.name

* Change _get_script function name and syntax

* Fix formatting issue
2025-02-25 09:39:39 -05:00
bctiemann
4fb42ac7b3 Merge pull request #18724 from netbox-community/18605-prefix-vlan-assignment-display
Fixes #18605: only VLANs at selected Site are shown in VLAN select
2025-02-25 09:31:48 -05:00
bctiemann
a8b4024016 Merge pull request #18491 from antoinekh/17542-Contact_Assignment_to_vpn_tunnels
#17542 contact assignment to vpn tunnels
2025-02-25 09:23:14 -05:00
Antoine Keranflec'h
a6c07e6a35 fix contact wrong model assignment 2025-02-25 13:53:50 +01:00
Antoine Keranflec'h
59cd5bc653 fix double line breaks 2025-02-25 09:15:55 +01:00
Antoine Keranflec'h
bda4f314a4 Merge branch 'netbox-community:main' into 17542-Contact_Assignment_to_vpn_tunnels 2025-02-25 09:09:13 +01:00
github-actions
2a56c08bc8 Update source translation strings 2025-02-25 05:02:09 +00:00
Brian Tiemann
beb0aff656 Add VLAN Translation Policy to bulk edit forms and tables for Interface and VMInterface 2025-02-24 13:55:40 -05:00
Jason Novinger
64270d6a4e Fixes #18605: only VLANs at selected Site are shown in VLAN select 2025-02-24 11:29:31 -06:00
github-actions
fba4141ce3 Update source translation strings 2025-02-24 15:18:11 +00:00
Jeremy Stretch
a4ecb82330 Clarify error message 2025-02-24 10:14:52 -05:00
atownson
5a3e213fb4 Do not retrieve the data field from Job objects unless needed 2025-02-24 09:07:16 -06:00
bctiemann
c4304d059c Merge pull request #18703 from netbox-community/release-v4.2.4
Release v4.2.4
2025-02-21 15:34:15 -05:00
Brian Tiemann
fee66438f3 Fix django-htmx version 2025-02-21 15:19:54 -05:00
Brian Tiemann
0f52712468 Release v4.2.4 2025-02-21 15:15:14 -05:00
Jeremy Stretch
fbaa82df7b Fixes #18674: Fix form reset when selecting a value from a speed selection dropdown 2025-02-21 14:04:50 -05:00
mr1716
9c1358e6e7 #18698 Correct REST Wikipedia URL In Documentation 2025-02-21 09:34:16 -05:00
github-actions
63b7145baa Update source translation strings 2025-02-21 05:02:07 +00:00
Jeremy Stretch
bcd974210d Update Transifex resource slug 2025-02-20 12:53:25 -05:00
Jason Novinger
ed79e3bbf4 Fixes #18619: shift-select selects hidden items
This also fixes the inverse, when a range is unselected via shift-click,
previously checked checkboxes that are hidden are not changed.
2025-02-20 10:17:43 -05:00
Alexander Haase
b5bc0bad38 Cover multitable inheritance in serialization
During serialization, custom fields may be available to a model due to
multi-table inheritance, but might not be available in serialized data
because only direct fields of the model are covered. Now this attribute
is only used if available in serialized data. Models using multi-table
inheritance must modify their serialize_object() method to cover parent
serialization.
2025-02-20 10:09:35 -05:00
Jason Novinger
2a44affd03 Fixes #18594: asn_count sort in Sites list (#18634)
* Fixes #18594: asn_count sort in Sites list

* Fixes similar issue in `circuits.views.ProviderListView`

Thanks @bctiemann for point this out!
2025-02-20 10:01:04 -05:00
github-actions
57ef44706a Update source translation strings 2025-02-19 05:02:03 +00:00
Jason Novinger
70dddb673b Fixes #18585: filtering circuits by location (#18641)
* Fixes #18585: filtering circuits by location

This also fixes a related issue where selected filter is not shown in
the filter form.

Changes:
- Adds `CircuitFilterSet.location_id` field to enable filtering with
  incoming GET params
- Adds `CirciotFilterForm.location_id` field to enable filtering from
  list form
- Adds `location_id` to the Location fieldset on `CircuitFilterForm`

* Adds test for new CircuitFilterset.location_id filter
2025-02-18 13:33:05 -05:00
Alexander Haase
6c6cb321bf Fixes 18555: Fix model URL generator for plugins (#18607)
* Fix model URL generator for plugins

* Fix reverse accessor warning

* Revert "Fix reverse accessor warning"

This reverts commit f07642bb99.

* Add URL test case for regular models

* Split dummy models

Instead of using a single model for testing, one is used for testing the
plugin API and a dedicated one is used for testing the NetBox plugin
model features.

* Fix filterset test case error

* Rename test module

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2025-02-18 09:11:32 -05:00
Renato Almeida de Oliveira
11514bfb21 Fixes: #18584 Add rack types column to manufacturers table (#18636)
* Add racktype_count annotation to list view queryset, create the LinkedCountColumn in ManufacturerTable

* Add Manufacturer field to RackTypeFilterForm
2025-02-18 08:41:12 -05:00
github-actions
c324d23634 Update source translation strings 2025-02-15 05:02:03 +00:00
Alexander Haase
f9431f1c29 Replace DurationChoices by JobIntervalChoices 2025-02-14 14:42:27 -05:00
Renato Almeida de Oliveira Zaroubin
b1ac20ac19 Update ModuleBay instance name before saving it 2025-02-14 14:22:29 -05:00
github-actions
f8022040b2 Update source translation strings 2025-02-12 05:02:12 +00:00
Tobias Genannt
8114492673 Close #18357: Display author name for plugins 2025-02-11 12:19:43 -05:00
Renato Almeida de Oliveira
154b3a7abb Fixes: 18593 - "Create & Add Another" broken for new IP addresses (#18602)
* update IPAddressEditView get_extra_addanother_params

* Simplify get_extra_addanother_params
2025-02-11 08:31:40 -05:00
github-actions
015ef25ca0 Update source translation strings 2025-02-10 18:34:35 +00:00
Alexander Haase
3e1cc0d7f3 Fixes 18208: Consolidate rendering configuration templates (#18604) 2025-02-10 11:03:08 -05:00
Alexander Haase
e1d1aab4bd Omit empty related models 2025-02-10 10:56:09 -05:00
Matej Vadnjal
299bde9653 use script form's cleaned data when calling script from CLI
so ObjectVar and other field values have proper types in script's data variable
2025-02-10 09:46:59 -05:00
Alexander Haase
4b98f74943 Fixes 18247: Fix dark mode button classes (#18617) 2025-02-10 09:42:08 -05:00
Jeremy Stretch
a33fb2a0a9 Closes #17918: Fix styling of "tagged items" list under tag view 2025-02-10 09:00:55 -05:00
github-actions
13dc6854c2 Update source translation strings 2025-02-08 05:02:19 +00:00
Antoine Keranflec'h
e475386936 Fixes: #18408 add STATUS_PAUSED to VirtualMachine (#18595)
* feat(vm): add extra status

* keep only usefull status

---------

Co-authored-by: Antoine Keranflec'h <antoine.keranflech@worldline.com>
2025-02-07 16:41:45 -05:00
Jeremy Stretch
0b194e363e Docs cleanup 2025-02-07 15:33:37 -05:00
github-actions
72e93b04da Update source translation strings 2025-02-07 05:02:00 +00:00
Renato Almeida de Oliveira
7794b6718a Fixes: #18013 - All saved filters showing up for Change Log table, regardless of defined object type (#18551)
* Setup ObjectChangeFilterForm __init__ to add query_param to filter_id

* code lint, move __init__ to end of class

* Move filter_id widget setup to SavedFiltersMixin, and add model attributes to Models FilterForms

* Add missing model declarations for filter forms

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2025-02-06 17:21:46 -05:00
Renato Almeida de Oliveira
efa939d0c2 Fixes: #18241 - Script results log_threshold should default to Default (#18501)
* Changed LogLevelChoices order; Changed ScriptResultView to select LogLevelChoices to LOG_DEFAULT and setup the html template to put (All) in the last one

* Change LogLevelChoices in ScriptResultView get_table method

* Remove default option, add Default string to INFO

* Fix scripts.py and reports.py to reflect removing DEFAULT level

* fix linting
2025-02-06 16:30:25 -05:00
Jeremy Stretch
8e91db0394 Misc cleanup of the release checklist 2025-02-05 11:40:53 -05:00
Antoine Keranflec'h
2a8728544c fix(pep) fix pep8 compliancy 2025-01-31 08:48:35 +01:00
Antoine Keranflec'h
f83e55e1db Merge branch 'netbox-community:main' into 17542-Contact_Assignment_to_vpn_tunnels 2025-01-25 14:19:25 +01:00
Antoine Keranflec'h
113c8d1d85 Merge branch 'netbox-community:develop' into 17542-Contact_Assignment_to_vpn_tunnels 2024-11-12 14:17:05 +01:00
Antoine Keranflec'h
5b2241aaaf fix(17542) add contact to tunnels 2024-10-10 15:38:48 +02:00
124 changed files with 54222 additions and 45471 deletions

View File

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

View File

@@ -1,7 +1,7 @@
[main]
host = https://app.transifex.com
[o:netbox-community:p:netbox:r:9cbf4fcf95b3d92e4ebbf1a5e5d1caee]
[o:netbox-community:p:netbox:r:034999968a7366ba27a8bdf1ab63bf42]
file_filter = netbox/translations/<lang>/LC_MESSAGES/django.po
source_file = netbox/translations/en/LC_MESSAGES/django.po
type = PO

View File

@@ -308,6 +308,7 @@ A particular object within NetBox. Each ObjectVar must specify a particular mode
* `query_params` - A dictionary of query parameters to use when retrieving available options (optional)
* `context` - A custom dictionary mapping template context variables to fields, used when rendering `<option>` elements within the dropdown menu (optional; see below)
* `null_option` - A label representing a "null" or empty choice (optional)
* `selector` - A boolean that, when True, includes an advanced object selection widget to assist the user in identifying the desired object (optional; False by default)
To limit the selections available within the list, additional query parameters can be passed as the `query_params` dictionary. For example, to show only devices with an "active" status:

View File

@@ -8,6 +8,8 @@ This documentation describes the process of packaging and publishing a new NetBo
While major releases generally introduce some very substantial change to the application, they are typically treated the same as minor version increments for the purpose of release packaging.
For patch releases (e.g. upgrading from v4.2.2 to v4.2.3), begin at the [patch releases](#patch-releases) heading below. For minor or major releases, complete the entire checklist.
## Minor Version Releases
### Address Constrained Dependencies
@@ -85,7 +87,20 @@ In cases where upgrading a dependency to its most recent release is breaking, it
### Update UI Dependencies
Check whether any UI dependencies (JavaScript packages, fonts, etc.) need to be updated by running `yarn outdated` from within the `project-static/` directory. [Upgrade these dependencies](./web-ui.md#updating-dependencies) as necessary, then run `yarn bundle` to generate the necessary files for distribution.
Check whether any UI dependencies (JavaScript packages, fonts, etc.) need to be updated by running `yarn outdated` from within the `project-static/` directory. [Upgrade these dependencies](./web-ui.md#updating-dependencies) as necessary, then run `yarn bundle` to generate the necessary files for distribution:
```
$ yarn bundle
yarn run v1.22.19
$ node bundle.js
✅ Bundled source file 'styles/external.scss' to 'netbox-external.css'
✅ Bundled source file 'styles/netbox.scss' to 'netbox.css'
✅ Bundled source file 'styles/svg/rack_elevation.scss' to 'rack_elevation.css'
✅ Bundled source file 'styles/svg/cable_trace.scss' to 'cable_trace.css'
✅ Bundled source file 'index.ts' to 'netbox.js'
✅ Copied graphiql files
Done in 1.00s.
```
### Rebuild the Device Type Definition Schema
@@ -116,9 +131,12 @@ Then, compile these portable (`.po`) files for use in the application:
### Update Version and Changelog
* Update the version and published date in `release.yaml` with the current version & date. Add a designation (e.g.g `beta1`) if applicable.
* Update the version number and date in `netbox/release.yaml`. Add or remove the designation (e.g. `beta1`) if applicable.
* Update the example version numbers in the feature request and bug report templates under `.github/ISSUE_TEMPLATES/`.
* Replace the "FUTURE" placeholder in the release notes with the current date.
* Add a section for this release at the top of the changelog page for the minor version (e.g. `docs/release-notes/version-4.2.md`) listing all relevant changes made in this release.
!!! tip
Put yourself in the shoes of the user when recording change notes. Focus on the effect that each change has for the end user, rather than the specific bits of code that were modified in a PR. Ensure that each message conveys meaning absent context of the initial feature request or bug report. Remember to include key words or phrases (such as exception names) that can be easily searched.
### Submit a Pull Request
@@ -126,6 +144,9 @@ Commit the above changes and submit a pull request titled **"Release vX.Y.Z"** t
Once CI has completed and a colleague has reviewed the PR, merge it. This effects a new release in the `main` branch.
!!! warning
To ensure a streamlined review process, the pull request for a release **must** be limited to the changes outlined in this document. A release PR must never include functional changes to the application: Any unrelated "cleanup" needs to be captured in a separate PR prior to the release being shipped.
### Create a New Release
Create a [new release](https://github.com/netbox-community/netbox/releases/new) on GitHub with the following parameters.

View File

@@ -22,7 +22,7 @@ NetBox generally follows the [Django style guide](https://docs.djangoproject.com
### Linting
The [ruff](https://docs.astral.sh/ruff/) linter is used to enforce code style. A [pre-commit hook](./getting-started.md#3-enable-pre-commit-hooks) which runs this automatically is included with NetBox. To invoke `ruff` manually, run:
The [ruff](https://docs.astral.sh/ruff/) linter is used to enforce code style, and is run automatically by [pre-commit](./getting-started.md#5-install-pre-commit). To invoke `ruff` manually, run:
```
ruff check netbox/

View File

@@ -30,7 +30,7 @@ To download translated strings automatically, you'll need to:
1. Install the [Transifex CLI client](https://github.com/transifex/cli)
2. Generate a [Transifex API token](https://app.transifex.com/user/settings/api/)
Once you have the client set up, run the following command:
Once you have the client set up, run the following command from the project root (e.g. `/opt/netbox/`):
```no-highlight
TX_TOKEN=$TOKEN tx pull
@@ -46,6 +46,9 @@ Once retrieved, the updated strings need to be compiled into new `.mo` files so
Once any new `.mo` files have been generated, they need to be committed and pushed back up to GitHub. (Again, this is typically done as part of publishing a new NetBox release.)
!!! tip
Run `git status` to check that both `*.mo` & `*.po` files have been updated as expected.
## Proposing New Languages
If you'd like to add support for a new language to NetBox, the first step is to [submit a GitHub issue](https://github.com/netbox-community/netbox/issues/new?assignees=&labels=type%3A+translation&projects=&template=translation.yaml) to capture the proposal. While we'd like to add as many languages as possible, we do need to limit the rate at which new languages are added. New languages will be selected according to community interest and the number of volunteers who sign up as translators.

View File

@@ -2,7 +2,7 @@
## What is a REST API?
REST stands for [representational state transfer](https://en.wikipedia.org/wiki/Representational_state_transfer). It's a particular type of API which employs HTTP requests and [JavaScript Object Notation (JSON)](https://www.json.org/) to facilitate create, retrieve, update, and delete (CRUD) operations on objects within an application. Each type of operation is associated with a particular HTTP verb:
REST stands for [representational state transfer](https://en.wikipedia.org/wiki/REST). It's a particular type of API which employs HTTP requests and [JavaScript Object Notation (JSON)](https://www.json.org/) to facilitate create, retrieve, update, and delete (CRUD) operations on objects within an application. Each type of operation is associated with a particular HTTP verb:
* `GET`: Retrieve an object or list of objects
* `POST`: Create an object

View File

@@ -1,5 +1,63 @@
# NetBox v4.2
## v4.2.5 (2025-03-06)
### Enhancements
* [#17357](https://github.com/netbox-community/netbox/issues/17357) - Use VirtualChassis name as fallback for unnamed devices
* [#17542](https://github.com/netbox-community/netbox/issues/17542) - Add contact assignments to VPN tunnels
* [#17944](https://github.com/netbox-community/netbox/issues/17944) - Allow script inputs to be filtered on ObjectVar and MultiObjectVar selections
* [#18024](https://github.com/netbox-community/netbox/issues/18024) - Add permalink URL pattern to match a custom script by module and class name
* [#18095](https://github.com/netbox-community/netbox/issues/18095) - Ensure contacts are shown on children of objects with contacts
* [#18141](https://github.com/netbox-community/netbox/issues/18141) - Support "Quick Add" for plugins
* [#18403](https://github.com/netbox-community/netbox/issues/18403) - Improve performance of job list views
* [#18693](https://github.com/netbox-community/netbox/issues/18693) - Support setting VLAN translation on bulk edit of interfaces
* [#18772](https://github.com/netbox-community/netbox/issues/18772) - Add "type" filter for virtual circuits
* [#18774](https://github.com/netbox-community/netbox/issues/18774) - Add tooltip preview of tag descriptions when hovering over tags
### Bug Fixes
* [#15016](https://github.com/netbox-community/netbox/issues/15016) - Prevent AssertionError when adding multiple devices "mid-span" in a cable trace
* [#15924](https://github.com/netbox-community/netbox/issues/15924) - Prevent setting tagged VLANs on interfaces with mode: tagged-all
* [#17488](https://github.com/netbox-community/netbox/issues/17488) - Ensure VLANGroup.vid_ranges shows up in API results
* [#17709](https://github.com/netbox-community/netbox/issues/17709) - Allow primary key for nested models in OpenAPI request schemas
* [#17796](https://github.com/netbox-community/netbox/issues/17796) - Fix IndexError on "Create & Add Another" operation on custom field choices
* [#18605](https://github.com/netbox-community/netbox/issues/18605) - Limit VLAN selection dropdown to choices appropriate to site
* [#18722](https://github.com/netbox-community/netbox/issues/18722) - Improve UI feedback on failed script execution
* [#18729](https://github.com/netbox-community/netbox/issues/18729) - Fix unpredictable ordering on querysets with annotations/groupings
* [#18753](https://github.com/netbox-community/netbox/issues/18753) - Prevent webhooks from being triggered on a script dry-run
* [#18758](https://github.com/netbox-community/netbox/issues/18758) - Fix FieldError when sorting by account count field in providers list
* [#18768](https://github.com/netbox-community/netbox/issues/18768) - Fix removing a secondary MAC address from an interface
---
## v4.2.4 (2025-02-21)
### Enhancements
* [#17309](https://github.com/netbox-community/netbox/issues/17309) - Omit empty counts in related object tables
* [#18277](https://github.com/netbox-community/netbox/issues/18277) - Improve multi-table inheritance in serialization of change-logged models
* [#18286](https://github.com/netbox-community/netbox/issues/18286) - Add more job duration choices
* [#18357](https://github.com/netbox-community/netbox/issues/18357) - Display author name in plugin list for locally installed plugins
* [#18408](https://github.com/netbox-community/netbox/issues/18408) - Add Paused status for virtual machines
* [#18584](https://github.com/netbox-community/netbox/issues/18584) - Add rack type column to manufacturer list
### Bug Fixes
* [#17436](https://github.com/netbox-community/netbox/issues/17436) - Fix {module} replacement in module bays
* [#18013](https://github.com/netbox-community/netbox/issues/18013) - Limit object type to selected object in change log filter
* [#18241](https://github.com/netbox-community/netbox/issues/18241) - Default logging level of custom scripts changed to INFO
* [#18247](https://github.com/netbox-community/netbox/issues/18247) - Fix visibility of disabled cable paths in dark mode
* [#18480](https://github.com/netbox-community/netbox/issues/18480) - Clean data passed to script in runscript command
* [#18555](https://github.com/netbox-community/netbox/issues/18555) - Add default get_absolute_url method to plugin models
* [#18585](https://github.com/netbox-community/netbox/issues/18585) - Fix filtering circuits by location
* [#18593](https://github.com/netbox-community/netbox/issues/18593) - Fix "Create & Add Another" IP Address workflow
* [#18594](https://github.com/netbox-community/netbox/issues/18594) - Enable sorting by ASN count on site and provider lists
* [#18619](https://github.com/netbox-community/netbox/issues/18619) - Ensure shift-click selection selects only visible list items
* [#18674](https://github.com/netbox-community/netbox/issues/18674) - Preserve form values when selecting speed on circuit termination
---
## v4.2.3 (2025-02-04)
### Enhancements

View File

@@ -64,6 +64,8 @@ markdown_extensions:
format: !!python/name:pymdownx.superfences.fence_code_format
- pymdownx.tabbed:
alternate_style: true
not_in_nav: |
/index.md
nav:
- Introduction: 'introduction.md'
- Features:

View File

@@ -234,6 +234,11 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
to_field_name='slug',
label=_('Site (slug)'),
)
location_id = django_filters.ModelMultipleChoiceFilter(
field_name='terminations___location',
label=_('Location (ID)'),
queryset=Location.objects.all(),
)
termination_a_id = django_filters.ModelMultipleChoiceFilter(
queryset=CircuitTermination.objects.all(),
label=_('Termination A (ID)'),

View File

@@ -126,7 +126,7 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
'type_id', 'status', 'install_date', 'termination_date', 'commit_rate', 'distance', 'distance_unit',
name=_('Attributes')
),
FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
)
@@ -181,6 +181,11 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
},
label=_('Site')
)
location_id = DynamicModelMultipleChoiceField(
queryset=Location.objects.all(),
required=False,
label=_('Location')
)
install_date = forms.DateField(
label=_('Install date'),
required=False,
@@ -322,7 +327,7 @@ class VirtualCircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBox
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('provider_id', 'provider_account_id', 'provider_network_id', name=_('Provider')),
FieldSet('type', 'status', name=_('Attributes')),
FieldSet('type_id', 'status', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
)
selector_fields = ('filter_id', 'q', 'provider_id', 'provider_network_id')

View File

@@ -349,9 +349,8 @@ class CircuitTermination(
def clean(self):
super().clean()
# Must define either site *or* provider network
if self.termination is None:
raise ValidationError(_("A circuit termination must attach to termination."))
raise ValidationError(_("A circuit termination must attach to a terminating object."))
def save(self, *args, **kwargs):
# Cache objects associated with the terminating object (for filtering)

View File

@@ -23,7 +23,6 @@ class ProviderTable(ContactsColumnMixin, NetBoxTable):
verbose_name=_('Accounts')
)
account_count = columns.LinkedCountColumn(
accessor=tables.A('accounts__count'),
viewname='circuits:provideraccount_list',
url_params={'provider_id': 'pk'},
verbose_name=_('Account Count')
@@ -33,7 +32,6 @@ class ProviderTable(ContactsColumnMixin, NetBoxTable):
verbose_name=_('ASNs')
)
asn_count = columns.LinkedCountColumn(
accessor=tables.A('asns__count'),
viewname='ipam:asn_list',
url_params={'provider_id': 'pk'},
verbose_name=_('ASN Count')

View File

@@ -3,8 +3,10 @@ from django.test import TestCase
from circuits.choices import *
from circuits.filtersets import *
from circuits.models import *
from dcim.choices import InterfaceTypeChoices
from dcim.models import Cable, Device, DeviceRole, DeviceType, Interface, Manufacturer, Region, Site, SiteGroup
from dcim.choices import InterfaceTypeChoices, LocationStatusChoices
from dcim.models import (
Cable, Device, DeviceRole, DeviceType, Interface, Location, Manufacturer, Region, Site, SiteGroup
)
from ipam.models import ASN, RIR
from netbox.choices import DistanceUnitChoices
from tenancy.models import Tenant, TenantGroup
@@ -225,6 +227,17 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
)
ProviderNetwork.objects.bulk_create(provider_networks)
locations = (
Location.objects.create(
site=sites[0], name='Test Location 1', slug='test-location-1',
status=LocationStatusChoices.STATUS_ACTIVE,
),
Location.objects.create(
site=sites[1], name='Test Location 2', slug='test-location-2',
status=LocationStatusChoices.STATUS_ACTIVE,
),
)
circuits = (
Circuit(
provider=providers[0],
@@ -305,7 +318,9 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
circuit_terminations = ((
CircuitTermination(circuit=circuits[0], termination=sites[0], term_side='A'),
CircuitTermination(circuit=circuits[0], termination=locations[0], term_side='Z'),
CircuitTermination(circuit=circuits[1], termination=sites[1], term_side='A'),
CircuitTermination(circuit=circuits[1], termination=locations[1], term_side='Z'),
CircuitTermination(circuit=circuits[2], termination=sites[2], term_side='A'),
CircuitTermination(circuit=circuits[3], termination=provider_networks[0], term_side='A'),
CircuitTermination(circuit=circuits[4], termination=provider_networks[1], term_side='A'),
@@ -395,6 +410,11 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'site': [sites[0].slug, sites[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_location(self):
location_ids = Location.objects.values_list('id', flat=True)[:2]
params = {'location_id': location_ids}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_tenant(self):
tenants = Tenant.objects.all()[:2]
params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}

View File

@@ -4,6 +4,7 @@ from django.shortcuts import get_object_or_404, redirect, render
from django.utils.translation import gettext_lazy as _
from dcim.views import PathTraceView
from ipam.models import ASN
from netbox.views import generic
from tenancy.views import ObjectContactsView
from utilities.forms import ConfirmationForm
@@ -20,7 +21,9 @@ from .models import *
@register_model_view(Provider, 'list', path='', detail=False)
class ProviderListView(generic.ObjectListView):
queryset = Provider.objects.annotate(
count_circuits=count_related(Circuit, 'provider')
count_circuits=count_related(Circuit, 'provider'),
asn_count=count_related(ASN, 'providers'),
account_count=count_related(ProviderAccount, 'provider'),
)
filterset = filtersets.ProviderFilterSet
filterset_form = forms.ProviderFilterForm

View File

@@ -2,12 +2,13 @@ import re
import typing
from collections import OrderedDict
from drf_spectacular.extensions import OpenApiSerializerFieldExtension
from drf_spectacular.extensions import OpenApiSerializerFieldExtension, OpenApiSerializerExtension, _SchemaType
from drf_spectacular.openapi import AutoSchema
from drf_spectacular.plumbing import (
build_basic_type, build_choice_field, build_media_type_object, build_object_type, get_doc,
)
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import Direction
from netbox.api.fields import ChoiceField
from netbox.api.serializers import WritableNestedSerializer
@@ -277,3 +278,40 @@ class FixSerializedPKRelatedField(OpenApiSerializerFieldExtension):
return component.ref if component else None
else:
return build_basic_type(OpenApiTypes.INT)
class FixIntegerRangeSerializerSchema(OpenApiSerializerExtension):
target_class = 'netbox.api.fields.IntegerRangeSerializer'
def map_serializer(self, auto_schema: 'AutoSchema', direction: Direction) -> _SchemaType:
return {
'type': 'array',
'items': {
'type': 'array',
'items': {
'type': 'integer',
},
'minItems': 2,
'maxItems': 2,
},
}
# Nested models can be passed by ID in requests
# The logic for this is handled in `BaseModelSerializer.to_internal_value`
class FixWritableNestedSerializerAllowPK(OpenApiSerializerFieldExtension):
target_class = 'netbox.api.serializers.BaseModelSerializer'
match_subclasses = True
def map_serializer_field(self, auto_schema, direction):
schema = auto_schema._map_serializer_field(self.target, direction, bypass_extensions=True)
if schema is None:
return schema
if direction == 'request' and self.target.nested:
return {
'oneOf': [
build_basic_type(OpenApiTypes.INT),
schema,
]
}
return schema

View File

@@ -81,8 +81,10 @@ class JobIntervalChoices(ChoiceSet):
CHOICES = (
(INTERVAL_MINUTELY, _('Minutely')),
(INTERVAL_HOURLY, _('Hourly')),
(INTERVAL_HOURLY * 12, _('12 hours')),
(INTERVAL_DAILY, _('Daily')),
(INTERVAL_WEEKLY, _('Weekly')),
(INTERVAL_DAILY * 30, _('30 days')),
)

View File

@@ -62,6 +62,7 @@ class DataFileFilterForm(NetBoxModelFilterSetForm):
class JobFilterForm(SavedFiltersMixin, FilterForm):
model = Job
fieldsets = (
FieldSet('q', 'filter_id'),
FieldSet('object_type', 'status', name=_('Attributes')),
@@ -162,6 +163,7 @@ class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):
class ConfigRevisionFilterForm(SavedFiltersMixin, FilterForm):
model = ConfigRevision
fieldsets = (
FieldSet('q', 'filter_id'),
)

View File

@@ -80,6 +80,13 @@ def get_local_plugins(plugins=None):
plugin = importlib.import_module(plugin_name)
plugin_config: PluginConfig = plugin.config
if plugin_config.author:
author = PluginAuthor(
name=plugin_config.author,
)
else:
author = None
local_plugins[plugin_config.name] = Plugin(
config_name=plugin_config.name,
title_short=plugin_config.verbose_name,
@@ -88,6 +95,7 @@ def get_local_plugins(plugins=None):
description_short=plugin_config.description,
is_local=True,
is_installed=True,
author=author,
installed_version=plugin_config.version,
)

View File

@@ -165,7 +165,7 @@ class DataFileBulkDeleteView(generic.BulkDeleteView):
@register_model_view(Job, 'list', path='', detail=False)
class JobListView(generic.ObjectListView):
queryset = Job.objects.all()
queryset = Job.objects.defer('data')
filterset = filtersets.JobFilterSet
filterset_form = forms.JobFilterForm
table = tables.JobTable
@@ -182,12 +182,12 @@ class JobView(generic.ObjectView):
@register_model_view(Job, 'delete')
class JobDeleteView(generic.ObjectDeleteView):
queryset = Job.objects.all()
queryset = Job.objects.defer('data')
@register_model_view(Job, 'bulk_delete', path='delete', detail=False)
class JobBulkDeleteView(generic.BulkDeleteView):
queryset = Job.objects.all()
queryset = Job.objects.defer('data')
filterset = filtersets.JobFilterSet
table = tables.JobTable

View File

@@ -1,3 +1,4 @@
from django.utils.translation import gettext as _
from django.contrib.contenttypes.models import ContentType
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
@@ -232,8 +233,56 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
def validate(self, data):
# Validate many-to-many VLAN assignments
if not self.nested:
# Validate 802.1q mode and vlan(s)
mode = None
tagged_vlans = []
# Gather Information
if self.instance:
mode = data.get('mode') if 'mode' in data.keys() else self.instance.mode
untagged_vlan = data.get('untagged_vlan') if 'untagged_vlan' in data.keys() else \
self.instance.untagged_vlan
qinq_svlan = data.get('qinq_svlan') if 'qinq_svlan' in data.keys() else \
self.instance.qinq_svlan
tagged_vlans = data.get('tagged_vlans') if 'tagged_vlans' in data.keys() else \
self.instance.tagged_vlans.all()
else:
mode = data.get('mode', None)
untagged_vlan = data.get('untagged_vlan') if 'untagged_vlan' in data.keys() else None
qinq_svlan = data.get('qinq_svlan') if 'qinq_svlan' in data.keys() else None
tagged_vlans = data.get('tagged_vlans') if 'tagged_vlans' in data.keys() else None
errors = {}
# Non Q-in-Q mode with service vlan set
if mode != InterfaceModeChoices.MODE_Q_IN_Q and qinq_svlan:
errors.update({
'qinq_svlan': _("Interface mode does not support q-in-q service vlan")
})
# Routed mode
if not mode:
# Untagged vlan
if untagged_vlan:
errors.update({
'untagged_vlan': _("Interface mode does not support untagged vlan")
})
# Tagged vlan
if tagged_vlans:
errors.update({
'tagged_vlans': _("Interface mode does not support tagged vlans")
})
# Non-tagged mode
elif mode in (InterfaceModeChoices.MODE_TAGGED_ALL, InterfaceModeChoices.MODE_ACCESS) and tagged_vlans:
errors.update({
'tagged_vlans': _("Interface mode does not support tagged vlans")
})
if errors:
raise serializers.ValidationError(errors)
# Validate many-to-many VLAN assignments
device = self.instance.device if self.instance else data.get('device')
for vlan in data.get('tagged_vlans', []):
if vlan.site not in [device.site, None]:

View File

@@ -0,0 +1,2 @@
class UnsupportedCablePath(Exception):
pass

View File

@@ -1193,6 +1193,7 @@ class DeviceFilterSet(
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(virtual_chassis__name__icontains=value) |
Q(serial__icontains=value.strip()) |
Q(inventoryitems__serial__icontains=value.strip()) |
Q(asset_tag__icontains=value.strip()) |

View File

@@ -1411,7 +1411,7 @@ class InterfaceBulkEditForm(
form_from_model(Interface, [
'label', 'type', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'wwn', 'mtu', 'mgmt_only', 'mark_connected',
'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
'wireless_lans'
'wireless_lans', 'vlan_translation_policy'
])
):
enabled = forms.NullBooleanField(
@@ -1564,7 +1564,9 @@ class InterfaceBulkEditForm(
FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')),
FieldSet('poe_mode', 'poe_type', name=_('PoE')),
FieldSet('parent', 'bridge', 'lag', name=_('Related Interfaces')),
FieldSet('mode', 'vlan_group', 'untagged_vlan', 'qinq_svlan', name=_('802.1Q Switching')),
FieldSet(
'mode', 'vlan_group', 'untagged_vlan', 'qinq_svlan', 'vlan_translation_policy', name=_('802.1Q Switching')
),
FieldSet(
TabbedGroups(
FieldSet('tagged_vlans', name=_('Assignment')),
@@ -1579,7 +1581,7 @@ class InterfaceBulkEditForm(
nullable_fields = (
'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'wwn', 'vdcs', 'mtu', 'description',
'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vrf', 'wireless_lans'
'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vrf', 'wireless_lans', 'vlan_translation_policy',
)
def __init__(self, *args, **kwargs):

View File

@@ -43,20 +43,14 @@ class InterfaceCommonForm(forms.Form):
super().clean()
parent_field = 'device' if 'device' in self.cleaned_data else 'virtual_machine'
tagged_vlans = self.cleaned_data.get('tagged_vlans')
# Untagged interfaces cannot be assigned tagged VLANs
if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and tagged_vlans:
raise forms.ValidationError({
'mode': _("An access interface cannot have tagged VLANs assigned.")
})
# Remove all tagged VLAN assignments from "tagged all" interfaces
elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED_ALL:
self.cleaned_data['tagged_vlans'] = []
if 'tagged_vlans' in self.fields.keys():
tagged_vlans = self.cleaned_data.get('tagged_vlans') if self.is_bound else \
self.get_initial_for_field(self.fields['tagged_vlans'], 'tagged_vlans')
else:
tagged_vlans = []
# Validate tagged VLANs; must be a global VLAN or in the same site
elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED and tagged_vlans:
if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED and tagged_vlans:
valid_sites = [None, self.cleaned_data[parent_field].site]
invalid_vlans = [str(v) for v in tagged_vlans if v.site not in valid_sites]

View File

@@ -303,7 +303,7 @@ class RackTypeFilterForm(RackBaseFilterForm):
model = RackType
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('form_factor', 'width', 'u_height', name=_('Rack Type')),
FieldSet('manufacturer_id', 'form_factor', 'width', 'u_height', name=_('Rack Type')),
FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
)

View File

@@ -15,6 +15,7 @@ from dcim.fields import PathField
from dcim.utils import decompile_path_node, object_to_path_node
from netbox.models import ChangeLoggedModel, PrimaryModel
from utilities.conversion import to_meters
from utilities.exceptions import AbortRequest
from utilities.fields import ColorField
from utilities.querysets import RestrictedQuerySet
from wireless.models import WirelessLink
@@ -26,6 +27,7 @@ __all__ = (
'CableTermination',
)
from ..exceptions import UnsupportedCablePath
trace_paths = Signal()
@@ -236,8 +238,10 @@ class Cable(PrimaryModel):
for termination in self.b_terminations:
if not termination.pk or termination not in b_terminations:
CableTermination(cable=self, cable_end='B', termination=termination).save()
trace_paths.send(Cable, instance=self, created=_created)
try:
trace_paths.send(Cable, instance=self, created=_created)
except UnsupportedCablePath as e:
raise AbortRequest(e)
def get_status_color(self):
return LinkStatusChoices.colors.get(self.status)
@@ -531,8 +535,8 @@ class CablePath(models.Model):
return None
# Ensure all originating terminations are attached to the same link
if len(terminations) > 1:
assert all(t.link == terminations[0].link for t in terminations[1:])
if len(terminations) > 1 and not all(t.link == terminations[0].link for t in terminations[1:]):
raise UnsupportedCablePath(_("All originating terminations must be attached to the same link"))
path = []
position_stack = []
@@ -543,12 +547,13 @@ class CablePath(models.Model):
while terminations:
# Terminations must all be of the same type
assert all(isinstance(t, type(terminations[0])) for t in terminations[1:])
if not all(isinstance(t, type(terminations[0])) for t in terminations[1:]):
raise UnsupportedCablePath(_("All mid-span terminations must have the same termination type"))
# All mid-span terminations must all be attached to the same device
if not isinstance(terminations[0], PathEndpoint):
assert all(isinstance(t, type(terminations[0])) for t in terminations[1:])
assert all(t.parent_object == terminations[0].parent_object for t in terminations[1:])
if (not isinstance(terminations[0], PathEndpoint) and not
all(t.parent_object == terminations[0].parent_object for t in terminations[1:])):
raise UnsupportedCablePath(_("All mid-span terminations must have the same parent object"))
# Check for a split path (e.g. rear port fanning out to multiple front ports with
# different cables attached)
@@ -571,8 +576,10 @@ class CablePath(models.Model):
return None
# Otherwise, halt the trace if no link exists
break
assert all(type(link) in (Cable, WirelessLink) for link in links)
assert all(isinstance(link, type(links[0])) for link in links)
if not all(type(link) in (Cable, WirelessLink) for link in links):
raise UnsupportedCablePath(_("All links must be cable or wireless"))
if not all(isinstance(link, type(links[0])) for link in links):
raise UnsupportedCablePath(_("All links must match first link type"))
# Step 3: Record asymmetric paths as split
not_connected_terminations = [termination.link for termination in terminations if termination.link is None]
@@ -653,14 +660,18 @@ class CablePath(models.Model):
positions = position_stack.pop()
# Ensure we have a number of positions equal to the amount of remote terminations
assert len(remote_terminations) == len(positions)
if len(remote_terminations) != len(positions):
raise UnsupportedCablePath(
_("All positions counts within the path on opposite ends of links must match")
)
# Get our front ports
q_filter = Q()
for rt in remote_terminations:
position = positions.pop()
q_filter |= Q(rear_port_id=rt.pk, rear_port_position=position)
assert q_filter is not Q()
if q_filter is Q():
raise UnsupportedCablePath(_("Remote termination position filter is missing"))
front_ports = FrontPort.objects.filter(q_filter)
# Obtain the individual front ports based on the termination and position
elif position_stack:

View File

@@ -934,6 +934,8 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
raise ValidationError({'rf_channel_width': _("Cannot specify custom width with channel selected.")})
# VLAN validation
if not self.mode and self.untagged_vlan:
raise ValidationError({'untagged_vlan': _("Interface mode does not support an untagged vlan.")})
# Validate untagged VLAN
if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]:

View File

@@ -802,14 +802,10 @@ class Device(
verbose_name_plural = _('devices')
def __str__(self):
if self.name and self.asset_tag:
return f'{self.name} ({self.asset_tag})'
elif self.name:
return self.name
elif self.virtual_chassis and self.asset_tag:
return f'{self.virtual_chassis.name}:{self.vc_position} ({self.asset_tag})'
elif self.virtual_chassis:
return f'{self.virtual_chassis.name}:{self.vc_position} ({self.pk})'
if self.label and self.asset_tag:
return f'{self.label} ({self.asset_tag})'
elif self.label:
return self.label
elif self.device_type and self.asset_tag:
return f'{self.device_type.manufacturer} {self.device_type.model} ({self.asset_tag})'
elif self.device_type:
@@ -1073,14 +1069,22 @@ class Device(
device.location = self.location
device.save()
@property
def label(self):
"""
Return the device name if set; otherwise return a generated name if available.
"""
if self.name:
return self.name
if self.virtual_chassis:
return f'{self.virtual_chassis.name}:{self.vc_position}'
@property
def identifier(self):
"""
Return the device name if set; otherwise return the Device's primary key as {pk}
"""
if self.name is not None:
return self.name
return '{{{}}}'.format(self.pk)
return self.label or '{{{}}}'.format(self.pk)
@property
def primary_ip(self):
@@ -1298,6 +1302,7 @@ class Module(PrimaryModel, ConfigContextModel):
else:
# ModuleBays must be saved individually for MPTT
for instance in create_instances:
instance.name = instance.name.replace(MODULE_TOKEN, str(self.module_bay.position))
instance.save()
update_fields = ['module']
@@ -1545,7 +1550,10 @@ class MACAddress(PrimaryModel):
ct = ObjectType.objects.get_for_id(self._original_assigned_object_type_id)
original_assigned_object = ct.get_object_for_this_type(pk=self._original_assigned_object_id)
if original_assigned_object.primary_mac_address:
if (
original_assigned_object.primary_mac_address
and original_assigned_object.primary_mac_address.pk == self.pk
):
if not assigned_object:
raise ValidationError(
_("Cannot unassign MAC Address while it is designated as the primary MAC for an object")

View File

@@ -44,6 +44,7 @@ class DeviceIndex(SearchIndex):
('asset_tag', 50),
('serial', 60),
('name', 100),
('virtual_chassis', 200),
('description', 500),
('comments', 5000),
)

View File

@@ -30,10 +30,8 @@ STROKE_RESERVED = '#4d4dff'
def get_device_name(device):
if device.virtual_chassis:
name = f'{device.virtual_chassis.name}:{device.vc_position}'
elif device.name:
name = device.name
if device.label:
name = device.label
else:
name = str(device.device_type)
if device.devicebay_count:

View File

@@ -143,6 +143,7 @@ class PlatformTable(NetBoxTable):
class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
name = tables.TemplateColumn(
verbose_name=_('Name'),
accessor=Accessor('label'),
template_code=DEVICE_LINK,
linkify=True
)
@@ -671,7 +672,7 @@ class InterfaceTable(BaseInterfaceTable, ModularDeviceComponentTable, PathEndpoi
'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description',
'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection',
'tags', 'vdcs', 'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans',
'qinq_svlan', 'inventory_items', 'created', 'last_updated',
'qinq_svlan', 'inventory_items', 'created', 'last_updated', 'vlan_translation_policy'
)
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')

View File

@@ -31,6 +31,11 @@ class ManufacturerTable(ContactsColumnMixin, NetBoxTable):
verbose_name=_('Name'),
linkify=True
)
racktype_count = columns.LinkedCountColumn(
viewname='dcim:racktype_list',
url_params={'manufacturer_id': 'pk'},
verbose_name=_('Rack Types')
)
devicetype_count = columns.LinkedCountColumn(
viewname='dcim:devicetype_list',
url_params={'manufacturer_id': 'pk'},
@@ -58,12 +63,12 @@ class ManufacturerTable(ContactsColumnMixin, NetBoxTable):
class Meta(NetBoxTable.Meta):
model = models.Manufacturer
fields = (
'pk', 'id', 'name', 'devicetype_count', 'moduletype_count', 'inventoryitem_count', 'platform_count',
'description', 'slug', 'tags', 'contacts', 'actions', 'created', 'last_updated',
'pk', 'id', 'name', 'racktype_count', 'devicetype_count', 'moduletype_count', 'inventoryitem_count',
'platform_count', 'description', 'slug', 'tags', 'contacts', 'actions', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'devicetype_count', 'moduletype_count', 'inventoryitem_count', 'platform_count',
'description', 'slug',
'pk', 'name', 'racktype_count', 'devicetype_count', 'moduletype_count', 'inventoryitem_count',
'platform_count', 'description', 'slug',
)

View File

@@ -94,7 +94,6 @@ class SiteTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
verbose_name=_('ASNs')
)
asn_count = columns.LinkedCountColumn(
accessor=tables.A('asns__count'),
viewname='ipam:asn_list',
url_params={'site_id': 'pk'},
verbose_name=_('ASN Count')

View File

@@ -159,8 +159,8 @@ CONSOLEPORT_BUTTONS = """
</span>
{% endif %}
{% elif perms.dcim.add_cable %}
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
<span class="dropdown">
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
@@ -172,7 +172,7 @@ CONSOLEPORT_BUTTONS = """
</ul>
</span>
{% else %}
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
{% endif %}
"""
@@ -209,8 +209,8 @@ CONSOLESERVERPORT_BUTTONS = """
</span>
{% endif %}
{% elif perms.dcim.add_cable %}
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
<span class="dropdown">
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
@@ -222,7 +222,7 @@ CONSOLESERVERPORT_BUTTONS = """
</ul>
</span>
{% else %}
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
{% endif %}
"""
@@ -259,8 +259,8 @@ POWERPORT_BUTTONS = """
</span>
{% endif %}
{% elif perms.dcim.add_cable %}
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
<span class="dropdown">
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
@@ -271,7 +271,7 @@ POWERPORT_BUTTONS = """
</ul>
</span>
{% else %}
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
{% endif %}
"""
@@ -308,14 +308,14 @@ POWEROUTLET_BUTTONS = """
</span>
{% endif %}
{% elif perms.dcim.add_cable %}
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
{% if not record.mark_connected %}
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.poweroutlet&a_terminations={{ record.pk }}&b_terminations_type=dcim.powerport&termination_b_site={{ object.site.pk }}&termination_b_rack={{ object.rack.pk }}&return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" title="Connect" class="btn btn-success btn-sm">
<i class="mdi mdi-ethernet-cable" aria-hidden="true"></i>
</a>
{% else %}
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
{% endif %}
{% endif %}
"""
@@ -402,8 +402,8 @@ INTERFACE_BUTTONS = """
</a>
{% endif %}
{% elif record.is_wired and perms.dcim.add_cable %}
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
{% if not record.mark_connected %}
<span class="dropdown">
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" title="Connect cable">
@@ -417,7 +417,7 @@ INTERFACE_BUTTONS = """
</ul>
</span>
{% else %}
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
{% endif %}
{% elif record.is_wireless and perms.wireless.add_wirelesslink %}
<a href="{% url 'wireless:wirelesslink_add' %}?site_a={{ record.device.site.pk }}&location_a={{ record.device.location.pk }}&device_a={{ record.device_id }}&interface_a={{ record.pk }}&site_b={{ record.device.site.pk }}&location_b={{ record.device.location.pk }}" class="btn btn-success btn-sm">
@@ -459,8 +459,8 @@ FRONTPORT_BUTTONS = """
</span>
{% endif %}
{% elif perms.dcim.add_cable %}
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
{% if not record.mark_connected %}
<span class="dropdown">
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
@@ -476,7 +476,7 @@ FRONTPORT_BUTTONS = """
</ul>
</span>
{% else %}
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
{% endif %}
{% endif %}
"""
@@ -514,8 +514,8 @@ REARPORT_BUTTONS = """
</span>
{% endif %}
{% elif perms.dcim.add_cable %}
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
{% if not record.mark_connected %}
<span class="dropdown">
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
@@ -531,7 +531,7 @@ REARPORT_BUTTONS = """
</ul>
</span>
{% else %}
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
{% endif %}
{% endif %}
"""

View File

@@ -1,3 +1,5 @@
import json
from django.test import override_settings
from django.urls import reverse
from django.utils.translation import gettext as _
@@ -1748,6 +1750,23 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
},
]
def _perform_interface_test_with_invalid_data(self, mode: str = None, invalid_data: dict = {}):
device = Device.objects.first()
data = {
'device': device.pk,
'name': 'Interface 1',
'type': InterfaceTypeChoices.TYPE_1GE_FIXED,
}
data.update({'mode': mode})
data.update(invalid_data)
response = self.client.post(self._get_list_url(), data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
content = json.loads(response.content)
for key in invalid_data.keys():
self.assertIn(key, content)
self.assertIsNone(content.get('data'))
def test_bulk_delete_child_interfaces(self):
interface1 = Interface.objects.get(name='Interface 1')
device = interface1.device
@@ -1775,6 +1794,57 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
self.client.delete(self._get_list_url(), data, format='json', **self.header)
self.assertEqual(device.interfaces.count(), 2) # Child & parent were both deleted
def test_create_child_interfaces_mode_invalid_data(self):
"""
POST data to test interface mode check and invalid tagged/untagged VLANS.
"""
self.add_permissions('dcim.add_interface')
vlans = VLAN.objects.all()[0:3]
# Routed mode, untagged, tagged and qinq service vlan
invalid_data = {
'untagged_vlan': vlans[0].pk,
'tagged_vlans': [vlans[1].pk, vlans[2].pk],
'qinq_svlan': vlans[2].pk
}
self._perform_interface_test_with_invalid_data(None, invalid_data)
# Routed mode, untagged and tagged vlan
invalid_data = {
'untagged_vlan': vlans[0].pk,
'tagged_vlans': [vlans[1].pk, vlans[2].pk],
}
self._perform_interface_test_with_invalid_data(None, invalid_data)
# Routed mode, untagged vlan
invalid_data = {
'untagged_vlan': vlans[0].pk,
}
self._perform_interface_test_with_invalid_data(None, invalid_data)
invalid_data = {
'tagged_vlans': [vlans[1].pk, vlans[2].pk],
}
# Routed mode, qinq service vlan
self._perform_interface_test_with_invalid_data(None, invalid_data)
# Access mode, tagged vlans
self._perform_interface_test_with_invalid_data(InterfaceModeChoices.MODE_ACCESS, invalid_data)
# All tagged mode, tagged vlans
self._perform_interface_test_with_invalid_data(InterfaceModeChoices.MODE_TAGGED_ALL, invalid_data)
invalid_data = {
'qinq_svlan': vlans[0].pk,
}
# Routed mode, qinq service vlan
self._perform_interface_test_with_invalid_data(None, invalid_data)
# Access mode, qinq service vlan
self._perform_interface_test_with_invalid_data(InterfaceModeChoices.MODE_ACCESS, invalid_data)
# Tagged mode, qinq service vlan
self._perform_interface_test_with_invalid_data(InterfaceModeChoices.MODE_TAGGED, invalid_data)
# Tagged-all mode, qinq service vlan
self._perform_interface_test_with_invalid_data(InterfaceModeChoices.MODE_TAGGED_ALL, invalid_data)
class FrontPortTest(APIViewTestCases.APIViewTestCase):
model = FrontPort

View File

@@ -5,6 +5,7 @@ from dcim.choices import LinkStatusChoices
from dcim.models import *
from dcim.svg import CableTraceSVG
from dcim.utils import object_to_path_node
from utilities.exceptions import AbortRequest
class CablePathTestCase(TestCase):
@@ -2470,7 +2471,7 @@ class CablePathTestCase(TestCase):
b_terminations=[frontport1, frontport3],
label='C1'
)
with self.assertRaises(AssertionError):
with self.assertRaises(AbortRequest):
cable1.save()
self.assertPathDoesNotExist(
@@ -2489,7 +2490,7 @@ class CablePathTestCase(TestCase):
label='C3'
)
with self.assertRaises(AssertionError):
with self.assertRaises(AbortRequest):
cable3.save()
self.assertPathDoesNotExist(

View File

@@ -1,8 +1,9 @@
from django.test import TestCase
from dcim.choices import DeviceFaceChoices, DeviceStatusChoices, InterfaceTypeChoices
from dcim.choices import DeviceFaceChoices, DeviceStatusChoices, InterfaceTypeChoices, InterfaceModeChoices
from dcim.forms import *
from dcim.models import *
from ipam.models import VLAN
from utilities.testing import create_test_device
from virtualization.models import Cluster, ClusterGroup, ClusterType
@@ -117,11 +118,23 @@ class DeviceTestCase(TestCase):
self.assertIn('position', form.errors)
class LabelTestCase(TestCase):
class InterfaceTestCase(TestCase):
@classmethod
def setUpTestData(cls):
cls.device = create_test_device('Device 1')
cls.vlans = (
VLAN(name='VLAN 1', vid=1),
VLAN(name='VLAN 2', vid=2),
VLAN(name='VLAN 3', vid=3),
)
VLAN.objects.bulk_create(cls.vlans)
cls.interface = Interface.objects.create(
device=cls.device,
name='Interface 1',
type=InterfaceTypeChoices.TYPE_1GE_GBIC,
mode=InterfaceModeChoices.MODE_TAGGED,
)
def test_interface_label_count_valid(self):
"""
@@ -151,3 +164,152 @@ class LabelTestCase(TestCase):
self.assertFalse(form.is_valid())
self.assertIn('label', form.errors)
def test_create_interface_mode_valid_data(self):
"""
Test that saving valid interface mode and tagged/untagged vlans works properly
"""
# Validate access mode
data = {
'device': self.device.pk,
'name': 'ethernet1/1',
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
'mode': InterfaceModeChoices.MODE_ACCESS,
'untagged_vlan': self.vlans[0].pk
}
form = InterfaceCreateForm(data)
self.assertTrue(form.is_valid())
# Validate tagged vlans
data = {
'device': self.device.pk,
'name': 'ethernet1/2',
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
'mode': InterfaceModeChoices.MODE_TAGGED,
'untagged_vlan': self.vlans[0].pk,
'tagged_vlans': [self.vlans[1].pk, self.vlans[2].pk]
}
form = InterfaceCreateForm(data)
self.assertTrue(form.is_valid())
# Validate tagged vlans
data = {
'device': self.device.pk,
'name': 'ethernet1/3',
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
'mode': InterfaceModeChoices.MODE_TAGGED_ALL,
'untagged_vlan': self.vlans[0].pk,
}
form = InterfaceCreateForm(data)
self.assertTrue(form.is_valid())
def test_create_interface_mode_access_invalid_data(self):
"""
Test that saving invalid interface mode and tagged/untagged vlans works properly
"""
data = {
'device': self.device.pk,
'name': 'ethernet1/4',
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
'mode': InterfaceModeChoices.MODE_ACCESS,
'untagged_vlan': self.vlans[0].pk,
'tagged_vlans': [self.vlans[1].pk, self.vlans[2].pk]
}
form = InterfaceCreateForm(data)
self.assertTrue(form.is_valid())
self.assertIn('untagged_vlan', form.cleaned_data.keys())
self.assertNotIn('tagged_vlans', form.cleaned_data.keys())
self.assertNotIn('qinq_svlan', form.cleaned_data.keys())
def test_edit_interface_mode_access_invalid_data(self):
"""
Test that saving invalid interface mode and tagged/untagged vlans works properly
"""
data = {
'device': self.device.pk,
'name': 'Ethernet 1/5',
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
'mode': InterfaceModeChoices.MODE_ACCESS,
'tagged_vlans': [self.vlans[0].pk, self.vlans[1].pk, self.vlans[2].pk]
}
form = InterfaceForm(data, instance=self.interface)
self.assertTrue(form.is_valid())
self.assertIn('untagged_vlan', form.cleaned_data.keys())
self.assertNotIn('tagged_vlans', form.cleaned_data.keys())
self.assertNotIn('qinq_svlan', form.cleaned_data.keys())
def test_create_interface_mode_tagged_all_invalid_data(self):
"""
Test that saving invalid interface mode and tagged/untagged vlans works properly
"""
data = {
'device': self.device.pk,
'name': 'ethernet1/6',
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
'mode': InterfaceModeChoices.MODE_TAGGED_ALL,
'tagged_vlans': [self.vlans[0].pk, self.vlans[1].pk, self.vlans[2].pk]
}
form = InterfaceCreateForm(data)
self.assertTrue(form.is_valid())
self.assertIn('untagged_vlan', form.cleaned_data.keys())
self.assertNotIn('tagged_vlans', form.cleaned_data.keys())
self.assertNotIn('qinq_svlan', form.cleaned_data.keys())
def test_edit_interface_mode_tagged_all_invalid_data(self):
"""
Test that saving invalid interface mode and tagged/untagged vlans works properly
"""
data = {
'device': self.device.pk,
'name': 'Ethernet 1/7',
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
'mode': InterfaceModeChoices.MODE_TAGGED_ALL,
'tagged_vlans': [self.vlans[0].pk, self.vlans[1].pk, self.vlans[2].pk]
}
form = InterfaceForm(data)
self.assertTrue(form.is_valid())
self.assertIn('untagged_vlan', form.cleaned_data.keys())
self.assertNotIn('tagged_vlans', form.cleaned_data.keys())
self.assertNotIn('qinq_svlan', form.cleaned_data.keys())
def test_create_interface_mode_routed_invalid_data(self):
"""
Test that saving invalid interface mode (routed) and tagged/untagged vlans works properly
"""
data = {
'device': self.device.pk,
'name': 'ethernet1/6',
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
'mode': None,
'untagged_vlan': self.vlans[0].pk,
'tagged_vlans': [self.vlans[0].pk, self.vlans[1].pk, self.vlans[2].pk]
}
form = InterfaceCreateForm(data)
self.assertTrue(form.is_valid())
self.assertNotIn('untagged_vlan', form.cleaned_data.keys())
self.assertNotIn('tagged_vlans', form.cleaned_data.keys())
self.assertNotIn('qinq_svlan', form.cleaned_data.keys())
def test_edit_interface_mode_routed_invalid_data(self):
"""
Test that saving invalid interface mode (routed) and tagged/untagged vlans works properly
"""
data = {
'device': self.device.pk,
'name': 'Ethernet 1/7',
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
'mode': None,
'untagged_vlan': self.vlans[0].pk,
'tagged_vlans': [self.vlans[0].pk, self.vlans[1].pk, self.vlans[2].pk]
}
form = InterfaceForm(data)
self.assertTrue(form.is_valid())
self.assertNotIn('untagged_vlan', form.cleaned_data.keys())
self.assertNotIn('tagged_vlans', form.cleaned_data.keys())
self.assertNotIn('qinq_svlan', form.cleaned_data.keys())

View File

@@ -1,5 +1,5 @@
from django.core.exceptions import ValidationError
from django.test import TestCase
from django.test import tag, TestCase
from circuits.models import *
from core.models import ObjectType
@@ -12,6 +12,43 @@ from utilities.data import drange
from virtualization.models import Cluster, ClusterType
class MACAddressTestCase(TestCase):
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
device_type = DeviceType.objects.create(
manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
)
device_role = DeviceRole.objects.create(name='Test Role 1', slug='test-role-1')
device = Device.objects.create(
name='Device 1', device_type=device_type, role=device_role, site=site,
)
cls.interface = Interface.objects.create(
device=device,
name='Interface 1',
type=InterfaceTypeChoices.TYPE_1GE_FIXED,
mgmt_only=True
)
cls.mac_a = MACAddress.objects.create(mac_address='1234567890ab', assigned_object=cls.interface)
cls.mac_b = MACAddress.objects.create(mac_address='1234567890ba', assigned_object=cls.interface)
cls.interface.primary_mac_address = cls.mac_a
cls.interface.save()
@tag('regression')
def test_clean_will_not_allow_removal_of_assigned_object_if_primary(self):
self.mac_a.assigned_object = None
with self.assertRaisesMessage(ValidationError, 'Cannot unassign MAC Address while'):
self.mac_a.clean()
@tag('regression')
def test_clean_will_allow_removal_of_assigned_object_if_not_primary(self):
self.mac_b.assigned_object = None
self.mac_b.clean()
class LocationTestCase(TestCase):
def test_change_location_site(self):
@@ -590,6 +627,32 @@ class DeviceTestCase(TestCase):
device2.full_clean()
device2.save()
def test_device_label(self):
device1 = Device(
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
role=DeviceRole.objects.first(),
name=None,
)
self.assertEqual(device1.label, None)
device1.name = 'Test Device 1'
self.assertEqual(device1.label, 'Test Device 1')
virtual_chassis = VirtualChassis.objects.create(name='VC 1')
device2 = Device(
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
role=DeviceRole.objects.first(),
name=None,
virtual_chassis=virtual_chassis,
vc_position=2,
)
self.assertEqual(device2.label, 'VC 1:2')
device2.name = 'Test Device 2'
self.assertEqual(device2.label, 'Test Device 2')
def test_device_mismatched_site_cluster(self):
cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
Cluster.objects.create(name='Cluster 1', type=cluster_type)

View File

@@ -4,17 +4,15 @@ from django.core.paginator import EmptyPage, PageNotAnInteger
from django.db import transaction
from django.db.models import Prefetch
from django.forms import ModelMultipleChoiceField, MultipleHiddenInput, modelformset_factory
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from django.views.generic import View
from jinja2.exceptions import TemplateError
from circuits.models import Circuit, CircuitTermination
from extras.views import ObjectConfigContextView
from extras.views import ObjectConfigContextView, ObjectRenderConfigView
from ipam.models import ASN, IPAddress, Prefix, VLANGroup
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
@@ -424,7 +422,8 @@ class SiteGroupContactsView(ObjectContactsView):
@register_model_view(Site, 'list', path='', detail=False)
class SiteListView(generic.ObjectListView):
queryset = Site.objects.annotate(
device_count=count_related(Device, 'site')
device_count=count_related(Device, 'site'),
asn_count=count_related(ASN, 'sites')
)
filterset = filtersets.SiteFilterSet
filterset_form = forms.SiteFilterForm
@@ -966,6 +965,7 @@ class RackReservationBulkDeleteView(generic.BulkDeleteView):
@register_model_view(Manufacturer, 'list', path='', detail=False)
class ManufacturerListView(generic.ObjectListView):
queryset = Manufacturer.objects.annotate(
racktype_count=count_related(RackType, 'manufacturer'),
devicetype_count=count_related(DeviceType, 'manufacturer'),
moduletype_count=count_related(ModuleType, 'manufacturer'),
inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
@@ -2253,54 +2253,14 @@ class DeviceConfigContextView(ObjectConfigContextView):
@register_model_view(Device, 'render-config')
class DeviceRenderConfigView(generic.ObjectView):
class DeviceRenderConfigView(ObjectRenderConfigView):
queryset = Device.objects.all()
template_name = 'dcim/device/render_config.html'
base_template = 'dcim/device/base.html'
tab = ViewTab(
label=_('Render Config'),
weight=2100
weight=2100,
)
def get(self, request, **kwargs):
instance = self.get_object(**kwargs)
context = self.get_extra_context(request, instance)
# If a direct export has been requested, return the rendered template content as a
# downloadable file.
if request.GET.get('export'):
content = context['rendered_config'] or context['error_message']
response = HttpResponse(content, content_type='text')
filename = f"{instance.name or 'config'}.txt"
response['Content-Disposition'] = f'attachment; filename="{filename}"'
return response
return render(request, self.get_template_name(), {
'object': instance,
'tab': self.tab,
**context,
})
def get_extra_context(self, request, instance):
# Compile context data
context_data = instance.get_config_context()
context_data.update({'device': instance})
# Render the config template
rendered_config = None
error_message = None
if config_template := instance.get_config_template():
try:
rendered_config = config_template.render(context=context_data)
except TemplateError as e:
error_message = _("An error occurred while rendering the template: {error}").format(error=e)
return {
'config_template': config_template,
'context_data': context_data,
'rendered_config': rendered_config,
'error_message': error_message,
}
@register_model_view(Device, 'virtual-machines')
class DeviceVirtualMachinesView(generic.ObjectChildrenView):

View File

@@ -155,7 +155,6 @@ class JournalEntryKindChoices(ChoiceSet):
class LogLevelChoices(ChoiceSet):
LOG_DEBUG = 'debug'
LOG_DEFAULT = 'default'
LOG_INFO = 'info'
LOG_SUCCESS = 'success'
LOG_WARNING = 'warning'
@@ -163,16 +162,15 @@ class LogLevelChoices(ChoiceSet):
CHOICES = (
(LOG_DEBUG, _('Debug'), 'teal'),
(LOG_DEFAULT, _('Default'), 'gray'),
(LOG_INFO, _('Info'), 'cyan'),
(LOG_SUCCESS, _('Success'), 'green'),
(LOG_WARNING, _('Warning'), 'yellow'),
(LOG_FAILURE, _('Failure'), 'red'),
)
SYSTEM_LEVELS = {
LOG_DEBUG: logging.DEBUG,
LOG_DEFAULT: logging.INFO,
LOG_INFO: logging.INFO,
LOG_SUCCESS: logging.INFO,
LOG_WARNING: logging.WARNING,
@@ -180,17 +178,6 @@ class LogLevelChoices(ChoiceSet):
}
class DurationChoices(ChoiceSet):
CHOICES = (
(60, _('Hourly')),
(720, _('12 hours')),
(1440, _('Daily')),
(10080, _('Weekly')),
(43200, _('30 days')),
)
#
# Webhooks
#

View File

@@ -138,9 +138,8 @@ DEFAULT_DASHBOARD = [
LOG_LEVEL_RANK = {
LogLevelChoices.LOG_DEBUG: 0,
LogLevelChoices.LOG_DEFAULT: 1,
LogLevelChoices.LOG_INFO: 2,
LogLevelChoices.LOG_SUCCESS: 3,
LogLevelChoices.LOG_WARNING: 4,
LogLevelChoices.LOG_FAILURE: 5,
LogLevelChoices.LOG_INFO: 1,
LogLevelChoices.LOG_SUCCESS: 2,
LogLevelChoices.LOG_WARNING: 3,
LogLevelChoices.LOG_FAILURE: 4,
}

View File

@@ -37,6 +37,7 @@ __all__ = (
class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
model = CustomField
fieldsets = (
FieldSet('q', 'filter_id'),
FieldSet(
@@ -115,6 +116,7 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm):
model = CustomFieldChoiceSet
fieldsets = (
FieldSet('q', 'filter_id'),
FieldSet('base_choices', 'choice', name=_('Choices')),
@@ -129,6 +131,7 @@ class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm):
class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
model = CustomLink
fieldsets = (
FieldSet('q', 'filter_id'),
FieldSet('object_type', 'enabled', 'new_window', 'weight', name=_('Attributes')),
@@ -159,6 +162,7 @@ class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
model = ExportTemplate
fieldsets = (
FieldSet('q', 'filter_id'),
FieldSet('data_source_id', 'data_file_id', name=_('Data')),
@@ -200,6 +204,7 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm):
model = ImageAttachment
fieldsets = (
FieldSet('q', 'filter_id'),
FieldSet('object_type_id', 'name', name=_('Attributes')),
@@ -216,6 +221,7 @@ class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm):
class SavedFilterFilterForm(SavedFiltersMixin, FilterForm):
model = SavedFilter
fieldsets = (
FieldSet('q', 'filter_id'),
FieldSet('object_type', 'enabled', 'shared', 'weight', name=_('Attributes')),
@@ -314,6 +320,7 @@ class TagFilterForm(SavedFiltersMixin, FilterForm):
class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
model = ConfigContext
fieldsets = (
FieldSet('q', 'filter_id', 'tag_id'),
FieldSet('data_source_id', 'data_file_id', name=_('Data')),
@@ -403,6 +410,7 @@ class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
class ConfigTemplateFilterForm(SavedFiltersMixin, FilterForm):
model = ConfigTemplate
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('data_source_id', 'data_file_id', name=_('Data')),
@@ -469,6 +477,7 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm):
class NotificationGroupFilterForm(SavedFiltersMixin, FilterForm):
model = NotificationGroup
user_id = DynamicModelMultipleChoiceField(
queryset=User.objects.all(),
required=False,

View File

@@ -162,6 +162,7 @@ class CustomFieldForm(forms.ModelForm):
class CustomFieldChoiceSetForm(forms.ModelForm):
# TODO: The extra_choices field definition diverge from the CustomFieldChoiceSet model
extra_choices = forms.CharField(
widget=ChoicesWidget(),
required=False,
@@ -178,12 +179,25 @@ class CustomFieldChoiceSetForm(forms.ModelForm):
def __init__(self, *args, initial=None, **kwargs):
super().__init__(*args, initial=initial, **kwargs)
# Escape colons in extra_choices
# TODO: The check for str / list below is to handle difference in extra_choices field definition
# In CustomFieldChoiceSetForm, extra_choices is a CharField but in CustomFieldChoiceSet, it is an ArrayField
# if standardize these, we can simplify this code
# Convert extra_choices Array Field from model to CharField for form
if 'extra_choices' in self.initial and self.initial['extra_choices']:
choices = []
for choice in self.initial['extra_choices']:
choice = (choice[0].replace(':', '\\:'), choice[1].replace(':', '\\:'))
choices.append(choice)
extra_choices = self.initial['extra_choices']
if isinstance(extra_choices, str):
extra_choices = [extra_choices]
choices = ""
for choice in extra_choices:
# Setup choices in Add Another use case
if isinstance(choice, str):
choice_str = ":".join(choice.replace("'", "").replace(" ", "")[1:-1].split(","))
choices += choice_str + "\n"
# Setup choices in Edit use case
elif isinstance(choice, list):
choice_str = ":".join(choice)
choices += choice_str + "\n"
self.initial['extra_choices'] = choices

View File

@@ -1,7 +1,7 @@
from django import forms
from django.utils.translation import gettext_lazy as _
from extras.choices import DurationChoices
from core.choices import JobIntervalChoices
from utilities.forms.widgets import DateTimePicker, NumberWithOptions
from utilities.datetime import local_now
@@ -22,7 +22,7 @@ class ReportForm(forms.Form):
min_value=1,
label=_("Recurs every"),
widget=NumberWithOptions(
options=DurationChoices
options=JobIntervalChoices
),
help_text=_("Interval at which this report is re-run (in minutes)")
)

View File

@@ -1,7 +1,7 @@
from django import forms
from django.utils.translation import gettext_lazy as _
from extras.choices import DurationChoices
from core.choices import JobIntervalChoices
from utilities.forms.widgets import DateTimePicker, NumberWithOptions
from utilities.datetime import local_now
@@ -28,7 +28,7 @@ class ScriptForm(forms.Form):
min_value=1,
label=_("Recurs every"),
widget=NumberWithOptions(
options=DurationChoices
options=JobIntervalChoices
),
help_text=_("Interval at which this script is re-run (in minutes)")
)

View File

@@ -100,7 +100,10 @@ class ScriptJob(JobRunner):
# Execute the script. If commit is True, wrap it with the event_tracking context manager to ensure we process
# change logging, event rules, etc.
with ExitStack() as stack:
for request_processor in registry['request_processors']:
stack.enter_context(request_processor(request))
if commit:
with ExitStack() as stack:
for request_processor in registry['request_processors']:
stack.enter_context(request_processor(request))
self.run_script(script, request, data, commit)
else:
self.run_script(script, request, data, commit)

View File

@@ -81,12 +81,17 @@ class Command(BaseCommand):
logger.error(f'\t{field}: {error.get("message")}')
raise CommandError()
# Remove extra fields from ScriptForm before passng data to script
form.cleaned_data.pop('_schedule_at')
form.cleaned_data.pop('_interval')
form.cleaned_data.pop('_commit')
# Execute the script.
job = ScriptJob.enqueue(
instance=script_obj,
user=user,
immediate=True,
data=data,
data=form.cleaned_data,
request=NetBoxFakeRequest({
'META': {},
'POST': data,

View File

@@ -15,7 +15,7 @@ class Report(BaseScript):
# There is no generic log() equivalent on BaseScript
def log(self, message):
self._log(message, None, level=LogLevelChoices.LOG_DEFAULT)
self._log(message, None, level=LogLevelChoices.LOG_INFO)
def log_success(self, obj=None, message=None):
super().log_success(message, obj)

View File

@@ -211,10 +211,12 @@ class ObjectVar(ScriptVariable):
:param context: A custom dictionary mapping template context variables to fields, used when rendering <option>
elements within the dropdown menu (optional)
:param null_option: The label to use as a "null" selection option (optional)
:param selector: Include an advanced object selection widget to assist the user in identifying the desired
object (optional)
"""
form_field = DynamicModelChoiceField
def __init__(self, model, query_params=None, context=None, null_option=None, *args, **kwargs):
def __init__(self, model, query_params=None, context=None, null_option=None, selector=False, *args, **kwargs):
super().__init__(*args, **kwargs)
self.field_attrs.update({
@@ -222,6 +224,7 @@ class ObjectVar(ScriptVariable):
'query_params': query_params,
'context': context,
'null_option': null_option,
'selector': selector,
})
@@ -460,7 +463,7 @@ class BaseScript:
# Logging
#
def _log(self, message, obj=None, level=LogLevelChoices.LOG_DEFAULT):
def _log(self, message, obj=None, level=LogLevelChoices.LOG_INFO):
"""
Log a message. Do not call this method directly; use one of the log_* wrappers below.
"""

View File

@@ -1118,6 +1118,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
'devicerole',
'devicetype',
'dummymodel', # From dummy_plugin
'dummynetboxmodel', # From dummy_plugin
'eventrule',
'fhrpgroup',
'frontport',

View File

@@ -75,8 +75,11 @@ urlpatterns = [
path('scripts/add/', views.ScriptModuleCreateView.as_view(), name='scriptmodule_add'),
path('scripts/results/<int:job_pk>/', views.ScriptResultView.as_view(), name='script_result'),
path('scripts/<int:pk>/', views.ScriptView.as_view(), name='script'),
path('scripts/<str:module>.<str:name>/', views.ScriptView.as_view(), name='script'),
path('scripts/<int:pk>/source/', views.ScriptSourceView.as_view(), name='script_source'),
path('scripts/<str:module>.<str:name>/source/', views.ScriptSourceView.as_view(), name='script_source'),
path('scripts/<int:pk>/jobs/', views.ScriptJobsView.as_view(), name='script_jobs'),
path('scripts/<str:module>.<str:name>/jobs/', views.ScriptJobsView.as_view(), name='script_jobs'),
path('script-modules/<int:pk>/', include(get_model_urls('extras', 'scriptmodule'))),
# Markdown

View File

@@ -10,6 +10,7 @@ from django.utils import timezone
from django.utils.module_loading import import_string
from django.utils.translation import gettext as _
from django.views.generic import View
from jinja2.exceptions import TemplateError
from core.choices import ManagedFileRootPathChoices
from core.forms import ManagedFileForm
@@ -885,6 +886,61 @@ class ConfigTemplateBulkSyncDataView(generic.BulkSyncDataView):
queryset = ConfigTemplate.objects.all()
class ObjectRenderConfigView(generic.ObjectView):
base_template = None
template_name = 'extras/object_render_config.html'
def get(self, request, **kwargs):
instance = self.get_object(**kwargs)
context = self.get_extra_context(request, instance)
# If a direct export has been requested, return the rendered template content as a
# downloadable file.
if request.GET.get('export'):
content = context['rendered_config'] or context['error_message']
response = HttpResponse(content, content_type='text')
filename = f"{instance.name or 'config'}.txt"
response['Content-Disposition'] = f'attachment; filename="{filename}"'
return response
return render(
request,
self.get_template_name(),
{
'object': instance,
'tab': self.tab,
**context,
},
)
def get_extra_context_data(self, request, instance):
return {
f'{instance._meta.model_name}': instance,
}
def get_extra_context(self, request, instance):
# Compile context data
context_data = instance.get_config_context()
context_data.update(self.get_extra_context_data(request, instance))
# Render the config template
rendered_config = None
error_message = None
if config_template := instance.get_config_template():
try:
rendered_config = config_template.render(context=context_data)
except TemplateError as e:
error_message = _("An error occurred while rendering the template: {error}").format(error=e)
return {
'base_template': self.base_template,
'config_template': config_template,
'context_data': context_data,
'rendered_config': rendered_config,
'error_message': error_message,
}
#
# Image attachments
#
@@ -1195,6 +1251,14 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
class BaseScriptView(generic.ObjectView):
queryset = Script.objects.all()
def get_object(self, **kwargs):
if pk := kwargs.get('pk', False):
return get_object_or_404(self.queryset, pk=pk)
elif (module := kwargs.get('module')) and (name := kwargs.get('name', False)):
return get_object_or_404(self.queryset, module__file_path=f'{module}.py', name=name)
else:
raise Http404
def _get_script_class(self, script):
"""
Return an instance of the Script's Python class
@@ -1315,9 +1379,9 @@ class ScriptResultView(TableMixin, generic.ObjectView):
index = 0
try:
log_threshold = LOG_LEVEL_RANK[request.GET.get('log_threshold', LogLevelChoices.LOG_DEBUG)]
log_threshold = LOG_LEVEL_RANK[request.GET.get('log_threshold', LogLevelChoices.LOG_INFO)]
except KeyError:
log_threshold = LOG_LEVEL_RANK[LogLevelChoices.LOG_DEBUG]
log_threshold = LOG_LEVEL_RANK[LogLevelChoices.LOG_INFO]
if job.data:
if 'log' in job.data:
@@ -1325,7 +1389,7 @@ class ScriptResultView(TableMixin, generic.ObjectView):
tests = job.data['tests']
for log in job.data['log']:
log_level = LOG_LEVEL_RANK.get(log.get('status'), LogLevelChoices.LOG_DEFAULT)
log_level = LOG_LEVEL_RANK.get(log.get('status'), LogLevelChoices.LOG_INFO)
if log_level >= log_threshold:
index += 1
result = {
@@ -1348,7 +1412,7 @@ class ScriptResultView(TableMixin, generic.ObjectView):
for method, test_data in tests.items():
if 'log' in test_data:
for time, status, obj, url, message in test_data['log']:
log_level = LOG_LEVEL_RANK.get(status, LogLevelChoices.LOG_DEFAULT)
log_level = LOG_LEVEL_RANK.get(status, LogLevelChoices.LOG_INFO)
if log_level >= log_threshold:
index += 1
result = {
@@ -1374,9 +1438,9 @@ class ScriptResultView(TableMixin, generic.ObjectView):
if job.completed:
table = self.get_table(job, request, bulk_actions=False)
log_threshold = request.GET.get('log_threshold', LogLevelChoices.LOG_DEBUG)
log_threshold = request.GET.get('log_threshold', LogLevelChoices.LOG_INFO)
if log_threshold not in LOG_LEVEL_RANK:
log_threshold = LogLevelChoices.LOG_DEBUG
log_threshold = LogLevelChoices.LOG_INFO
context = {
'script': job.object,

View File

@@ -212,7 +212,7 @@ class PrefixForm(TenancyForm, ScopedForm, NetBoxModelForm):
required=False,
selector=True,
query_params={
'available_at_site': '$site',
'available_at_site': '$scope',
},
label=_('VLAN'),
)
@@ -240,6 +240,14 @@ class PrefixForm(TenancyForm, ScopedForm, NetBoxModelForm):
'tenant', 'description', 'comments', 'tags',
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# #18605: only filter VLAN select list if scope field is a Site
if scope_field := self.fields.get('scope', None):
if scope_field.queryset.model is not Site:
self.fields['vlan'].widget.attrs.pop('data-dynamic-params', None)
class IPRangeForm(TenancyForm, NetBoxModelForm):
vrf = DynamicModelChoiceField(

View File

@@ -0,0 +1,43 @@
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from dcim.models import Location, Region, Site, SiteGroup
from ipam.forms import PrefixForm
class PrefixFormTestCase(TestCase):
default_dynamic_params = '[{"fieldName":"scope","queryParam":"available_at_site"}]'
@classmethod
def setUpTestData(cls):
cls.site = Site.objects.create(name='Site 1', slug='site-1')
def test_vlan_field_sets_dynamic_params_by_default(self):
"""data-dynamic-params present when no scope_type selected"""
form = PrefixForm(data={})
assert form.fields['vlan'].widget.attrs['data-dynamic-params'] == self.default_dynamic_params
def test_vlan_field_sets_dynamic_params_for_scope_site(self):
"""data-dynamic-params present when scope type is Site and when scope is specifc site"""
form = PrefixForm(data={
'scope_type': ContentType.objects.get_for_model(Site).id,
'scope': self.site,
})
assert form.fields['vlan'].widget.attrs['data-dynamic-params'] == self.default_dynamic_params
def test_vlan_field_does_not_set_dynamic_params_for_other_scopes(self):
"""data-dynamic-params not present when scope type is populated by is not Site"""
cases = [
Region(name='Region 1', slug='region-1'),
Location(site=self.site, name='Location 1', slug='location-1'),
SiteGroup(name='Site Group 1', slug='site-group-1'),
]
for case in cases:
form = PrefixForm(data={
'scope_type': ContentType.objects.get_for_model(case._meta.model).id,
'scope': case,
})
assert 'data-dynamic-params' not in form.fields['vlan'].widget.attrs

View File

@@ -868,6 +868,7 @@ class IPAddressEditView(generic.ObjectEditView):
return {'interface': request.GET['interface']}
elif 'vminterface' in request.GET:
return {'vminterface': request.GET['vminterface']}
return {}
# TODO: Standardize or remove this view

View File

@@ -121,6 +121,11 @@ class NetBoxModelViewSet(
obj.snapshot()
return obj
def get_queryset(self):
qs = super().get_queryset()
ordering = qs.model._meta.ordering
return qs.order_by(*ordering)
def get_serializer(self, *args, **kwargs):
# If a list of objects has been provided, initialize the serializer with many=True
if isinstance(kwargs.get('data', {}), list):

View File

@@ -169,15 +169,6 @@ class NetBoxModelFilterSetForm(CustomFieldsMixin, SavedFiltersMixin, forms.Form)
selector_fields = ('filter_id', 'q')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Limit saved filters to those applicable to the form's model
object_type = ObjectType.objects.get_for_model(self.model)
self.fields['filter_id'].widget.add_query_params({
'object_type_id': object_type.pk,
})
def _get_custom_fields(self, content_type):
return super()._get_custom_fields(content_type).exclude(
Q(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) |

View File

@@ -73,6 +73,16 @@ class SavedFiltersMixin(forms.Form):
}
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Limit saved filters to those applicable to the form's model
if hasattr(self, 'model'):
object_type = ObjectType.objects.get_for_model(self.model)
self.fields['filter_id'].widget.add_query_params({
'object_type_id': object_type.pk,
})
class TagsMixin(forms.Form):
tags = DynamicModelMultipleChoiceField(

View File

@@ -10,6 +10,7 @@ from mptt.models import MPTTModel, TreeForeignKey
from netbox.models.features import *
from utilities.mptt import TreeManager
from utilities.querysets import RestrictedQuerySet
from utilities.views import get_viewname
__all__ = (
@@ -42,7 +43,7 @@ class NetBoxFeatureSet(
return f'{settings.STATIC_URL}docs/models/{self._meta.app_label}/{self._meta.model_name}/'
def get_absolute_url(self):
return reverse(f'{self._meta.app_label}:{self._meta.model_name}', args=[self.pk])
return reverse(get_viewname(self), args=[self.pk])
#

View File

@@ -0,0 +1,30 @@
import taggit.managers
import utilities.json
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dummy_plugin', '0001_initial'),
('extras', '0122_charfield_null_choices'),
]
operations = [
migrations.CreateModel(
name='DummyNetBoxModel',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
(
'custom_field_data',
models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
],
options={
'abstract': False,
},
),
]

View File

@@ -1,5 +1,7 @@
from django.db import models
from netbox.models import NetBoxModel
class DummyModel(models.Model):
name = models.CharField(
@@ -11,3 +13,7 @@ class DummyModel(models.Model):
class Meta:
ordering = ['name']
class DummyNetBoxModel(NetBoxModel):
pass

View File

@@ -6,4 +6,6 @@ from . import views
urlpatterns = (
path('models/', views.DummyModelsView.as_view(), name='dummy_model_list'),
path('models/add/', views.DummyModelAddView.as_view(), name='dummy_model_add'),
path('netboxmodel/<int:pk>/', views.DummyNetBoxModelView.as_view(), name='dummynetboxmodel'),
)

View File

@@ -5,12 +5,17 @@ from django.http import HttpResponse
from django.views.generic import View
from dcim.models import Site
from netbox.views import generic
from utilities.views import register_model_view
from .models import DummyModel
from .models import DummyModel, DummyNetBoxModel
# Trigger registration of custom column
from .tables import mycol # noqa: F401
#
# DummyModel
#
class DummyModelsView(View):
def get(self, request):
@@ -32,6 +37,18 @@ class DummyModelAddView(View):
return HttpResponse("Instance created")
#
# DummyNetBoxModel
#
class DummyNetBoxModelView(generic.ObjectView):
queryset = DummyNetBoxModel.objects.all()
#
# API
#
@register_model_view(Site, 'extra', path='other-stuff')
class ExtraCoreModelView(View):

View File

@@ -0,0 +1,23 @@
from unittest import skipIf
from django.conf import settings
from django.test import TestCase
from core.models import ObjectChange
from netbox.tests.dummy_plugin.models import DummyNetBoxModel
class ModelTest(TestCase):
def test_get_absolute_url(self):
m = ObjectChange()
m.pk = 123
self.assertEqual(m.get_absolute_url(), f'/core/changelog/{m.pk}/')
@skipIf('netbox.tests.dummy_plugin' not in settings.PLUGINS, "dummy_plugin not in settings.PLUGINS")
def test_get_absolute_url_plugin(self):
m = DummyNetBoxModel()
m.pk = 123
self.assertEqual(m.get_absolute_url(), f'/plugins/dummy-plugin/netboxmodel/{m.pk}/')

View File

@@ -125,6 +125,11 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
# Request handlers
#
def get_queryset(self, request):
qs = super().get_queryset(request)
ordering = qs.model._meta.ordering
return qs.order_by(*ordering)
def get(self, request):
"""
GET request handler.

View File

@@ -166,7 +166,7 @@ class ObjectJobsView(ConditionalLoginRequiredMixin, View):
def get_jobs(self, instance):
object_type = ContentType.objects.get_for_model(instance)
return Job.objects.filter(
return Job.objects.defer('data').filter(
object_type=object_type,
object_id=instance.id
)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -30,8 +30,8 @@
"gridstack": "11.3.0",
"htmx.org": "1.9.12",
"query-string": "9.1.1",
"sass": "1.83.4",
"tom-select": "2.4.2",
"sass": "1.85.0",
"tom-select": "2.4.3",
"typeface-inter": "3.18.1",
"typeface-roboto-mono": "1.1.13"
},
@@ -53,5 +53,6 @@
},
"resolutions": {
"@types/bootstrap/**/@popperjs/core": "^2.11.6"
}
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}

View File

@@ -43,7 +43,9 @@ function toggleCheckboxRange(
const typedElement = element as HTMLInputElement;
//Change loop's current checkbox state to eventTargetElement checkbox state
if (changePkCheckboxState === true) {
typedElement.checked = eventTargetElement.checked;
if (!typedElement.closest('tr')?.classList.contains('d-none')) {
typedElement.checked = eventTargetElement.checked;
}
}
//The previously clicked checkbox was above the shift clicked checkbox
if (element === previousStateElement) {
@@ -52,7 +54,9 @@ function toggleCheckboxRange(
return;
}
changePkCheckboxState = true;
typedElement.checked = eventTargetElement.checked;
if (!typedElement.closest('tr')?.classList.contains('d-none')) {
typedElement.checked = eventTargetElement.checked;
}
}
//The previously clicked checkbox was below the shift clicked checkbox
if (element === eventTargetElement) {

View File

@@ -1,5 +1,6 @@
import { initForms } from './forms';
import { initButtons } from './buttons';
import { initClipboard } from './clipboard'
import { initClipboard } from './clipboard';
import { initSelects } from './select';
import { initObjectSelector } from './objectSelector';
import { initBootstrap } from './bs';
@@ -9,6 +10,7 @@ import { initQuickAdd } from './quickAdd';
function initDepedencies(): void {
initButtons();
initClipboard();
initForms();
initSelects();
initObjectSelector();
initQuickAdd();

View File

@@ -2673,10 +2673,10 @@ safe-regex-test@^1.0.3:
es-errors "^1.3.0"
is-regex "^1.1.4"
sass@1.83.4:
version "1.83.4"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.83.4.tgz#5ccf60f43eb61eeec300b780b8dcb85f16eec6d1"
integrity sha512-B1bozCeNQiOgDcLd33e2Cs2U60wZwjUUXzh900ZyQF5qUasvMdDZYbQ566LJu7cqR+sAHlAfO6RMkaID5s6qpA==
sass@1.85.0:
version "1.85.0"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.85.0.tgz#0127ef697d83144496401553f0a0e87be83df45d"
integrity sha512-3ToiC1xZ1Y8aU7+CkgCI/tqyuPXEmYGJXO7H4uqp0xkLXUqp88rQQ4j1HmP37xSJLbCJPaIiv+cT1y+grssrww==
dependencies:
chokidar "^4.0.0"
immutable "^5.0.2"
@@ -2882,10 +2882,10 @@ toggle-selection@^1.0.6:
resolved "https://registry.yarnpkg.com/toggle-selection/-/toggle-selection-1.0.6.tgz#6e45b1263f2017fa0acc7d89d78b15b8bf77da32"
integrity sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==
tom-select@2.4.2:
version "2.4.2"
resolved "https://registry.yarnpkg.com/tom-select/-/tom-select-2.4.2.tgz#9764faf6cba51f6571d03a79bb7c1cac1cac7a5a"
integrity sha512-2RWjkL3gMDz9E+u8w+tQy9JWsYq8gaSytEVeugKYDeMus6ZtxT1HttLPnXsfHCnBPlsNubVyj5gtUeN+S+bcpA==
tom-select@2.4.3:
version "2.4.3"
resolved "https://registry.yarnpkg.com/tom-select/-/tom-select-2.4.3.tgz#1daa4131cd317de691f39eb5bf41148265986c1f"
integrity sha512-MFFrMxP1bpnAMPbdvPCZk0KwYxLqhYZso39torcdoefeV/NThNyDu8dV96/INJ5XQVTL3O55+GqQ78Pkj5oCfw==
dependencies:
"@orchidjs/sifter" "^1.1.0"
"@orchidjs/unicode-variants" "^1.1.2"

View File

@@ -1,3 +1,3 @@
version: "4.2.3"
version: "4.2.5"
edition: "Community"
published: "2025-02-04"
published: "2025-03-06"

View File

@@ -1,4 +1,4 @@
<div class="btn-group btn-group-sm" role="group">
<a href="?format=json" type="button" class="btn btn-outline-dark{% if format == 'json' %} active{% endif %}">JSON</a>
<a href="?format=yaml" type="button" class="btn btn-outline-dark{% if format == 'yaml' %} active{% endif %}">YAML</a>
<a href="?format=json" type="button" class="btn {% if format == 'json' %}btn-primary{% else %}btn-outline-secondary{% endif %}">JSON</a>
<a href="?format=yaml" type="button" class="btn {% if format == 'yaml' %}btn-primary{% else %}btn-outline-secondary{% endif %}">YAML</a>
</div>

View File

@@ -1,4 +1,5 @@
{% extends 'dcim/device/base.html' %}
{% extends base_template %}
{% load helpers %}
{% load static %}
{% load i18n %}
@@ -67,7 +68,7 @@
{% endif %}
{% else %}
<div class="alert alert-info">
{% trans "No configuration template has been assigned for this device." %}
{% trans "No configuration template has been assigned." %}
</div>
{% endif %}
</div>

View File

@@ -54,3 +54,7 @@
</div>
</div>
{% endblock content %}
{% block modals %}
{% include 'inc/htmx_modal.html' with size='lg' %}
{% endblock %}

View File

@@ -53,7 +53,7 @@
<div class="dropdown-menu">
{% for level, name in log_levels.items %}
<a class="dropdown-item d-flex justify-content-between" href="{% url 'extras:script_result' job_pk=job.pk %}?log_threshold={{ level }}">
{{ name }}{% if forloop.first %} ({% trans "All" %}){% endif %}
{{ name }}{% if forloop.counter == 1 %} ({% trans "All" %}){% elif forloop.counter == 2 %} ({% trans "Default" %}){% endif %}
{% if level == log_threshold %}<span class="badge bg-green ms-auto"></span>{% endif %}
</a>
{% endfor %}

View File

@@ -55,22 +55,23 @@
</div>
<div class="card">
<h2 class="card-header">{% trans "Tagged Item Types" %}</h2>
<table class="table table-hover attr-table">
<ul class="list-group list-group-flush" role="presentation">
{% for object_type in object_types %}
<tr>
<td>{{ object_type.content_type.name|bettertitle }}</td>
<td>
{% with viewname=object_type.content_type.model_class|validated_viewname:"list" %}
{% if viewname %}
<a href="{% url viewname %}?tag={{ object.slug }}">{{ object_type.item_count }}</a>
{% else %}
{{ object_type.item_count }}
{% endif %}
{% endwith %}
</td>
</tr>
{% with viewname=object_type.content_type.model_class|validated_viewname:"list" %}
{% if viewname %}
<a href="{% url viewname %}?tag={{ object.slug }}" class="list-group-item list-group-item-action d-flex justify-content-between">
{{ object_type.content_type.name|bettertitle }}
<span class="badge text-bg-primary rounded-pill">{{ object_type.item_count }}</span>
</a>
{% else %}
<li class="list-group-item list-group-item-action d-flex justify-content-between">
{{ object_type.content_type.name|bettertitle }}
<span class="badge text-bg-primary rounded-pill">{{ object_type.item_count }}</span>
</li>
{% endif %}
{% endwith %}
{% endfor %}
</table>
</ul>
</div>
{% plugin_right_page object %}
</div>
@@ -79,7 +80,7 @@
<div class="col col-md-12">
<div class="card">
<h2 class="card-header">{% trans "Tagged Objects" %}</h2>
<div class="card-body table-responsive">
<div class="table-responsive">
{% render_table taggeditem_table 'inc/table.html' %}
{% include 'inc/paginator.html' with paginator=taggeditem_table.paginator page=taggeditem_table.page %}
</div>

View File

@@ -19,6 +19,8 @@
</a>
{% endif %}
{% endwith %}
{% empty %}
<span class="list-group-item text-muted">{% trans "None" %}</span>
{% endfor %}
</ul>
</div>

View File

@@ -1,75 +0,0 @@
{% extends 'virtualization/virtualmachine/base.html' %}
{% load static %}
{% load i18n %}
{% block title %}{{ object }} - {% trans "Config" %}{% endblock %}
{% block content %}
<div class="row">
<div class="col-5">
<div class="card">
<h2 class="card-header">{% trans "Config Template" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Config Template" %}</th>
<td>{{ config_template|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Data Source" %}</th>
<td>{{ config_template.data_file.source|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Data File" %}</th>
<td>{{ config_template.data_file|linkify|placeholder }}</td>
</tr>
</table>
</div>
</div>
<div class="col-7">
<div class="card">
<div class="accordion accordion-flush" id="renderConfig">
<div class="card-body">
<div class="accordion-item">
<h2 class="accordion-header" id="renderConfigHeading">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapsedRenderConfig" aria-expanded="false" aria-controls="collapsedRenderConfig">
{% trans "Context Data" %}
</button>
</h2>
<div id="collapsedRenderConfig" class="accordion-collapse collapse" aria-labelledby="renderConfigHeading" data-bs-parent="#renderConfig">
<div class="accordion-body">
<pre class="card-body">{{ context_data|pprint }}</pre>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col">
{% if config_template %}
{% if rendered_config %}
<div class="card">
<h2 class="card-header d-flex justify-content-between">
{% trans "Rendered Config" %}
<a href="?export=True" class="btn btn-primary lh-1" role="button">
<i class="mdi mdi-download" aria-hidden="true"></i> {% trans "Download" %}
</a>
</h2>
<pre class="card-body">{{ rendered_config }}</pre>
</div>
{% else %}
<div class="alert alert-warning">
<h4 class="alert-title mb-1">{% trans "Error rendering template" %}</h4>
{% trans error_message %}
</div>
{% endif %}
{% else %}
<div class="alert alert-info">
{% trans "No configuration template has been assigned for this virtual machine." %}
</div>
{% endif %}
</div>
</div>
{% endblock %}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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