Compare commits

...

235 Commits

Author SHA1 Message Date
Jeremy Stretch
e5cdd8f2b0 Release v4.2.9 2025-04-30 14:31:30 -04:00
Jeremy Stretch
6202ae1236 Closes #19358: Move release info from footer to nav menu (#19360) 2025-04-30 10:44:10 -07:00
Jeremy Stretch
15c7a19fb7 Fixes #19365: Omit plugin icon from page title 2025-04-30 13:14:15 -04:00
Martin Hauser
1141ddb22a feat(virtualization): Add Q-in-Q SVLAN to VMinterface template (#19345) 2025-04-30 08:50:28 -07:00
bctiemann
68e9da5cd9 Merge pull request #19333 from netbox-community/17405-plugin-icons
Fixes #17405: Added plugin icon to plugin list/detail
2025-04-30 10:48:42 -04:00
bctiemann
36d71ccdd1 Merge pull request #19251 from netbox-community/17613-columns
17613 Add column support for mobile devices
2025-04-30 09:15:20 -04:00
bctiemann
fadc358329 Merge pull request #19357 from netbox-community/17319-device-and-module-type-behind-tabs
Fixes #17319: Arrange device and module type fields behind tab in com…
2025-04-30 09:09:55 -04:00
bctiemann
5274b3d727 Merge pull request #19352 from netbox-community/19351-pin-actions
Closes #19351: Pin 3rd party GitHub actions to commit hash
2025-04-30 09:02:51 -04:00
bctiemann
c0e6168d34 Merge pull request #19347 from netbox-community/19346-redirect-checks
Fixes #19346: Ensure all redirect URLs are validated
2025-04-30 09:01:55 -04:00
github-actions
01da6186eb Update source translation strings 2025-04-30 05:02:08 +00:00
Markku Leiniö
0466c8ef9b Closes #19167: Update REST API examples (#19353)
* Closes #19167: Update REST API examples

* Fix missing description

---------

Co-authored-by: Markku Leiniö <markkuleinio@users.noreply.github.com>
2025-04-29 13:31:06 -04:00
Jason Novinger
964ae56d34 Fixes #17319: Arrange device and module type fields behind tab in component template forms 2025-04-29 12:16:05 -05:00
Jason Novinger
e2e42acf42 Add grayscale image filter
Hat tip to @pheus. Thanks!

I did end up leaving the filter function arguments as the Sass
processor complains when you try calling a filter function without
an argument. :/
2025-04-29 11:53:12 -05:00
Jason Novinger
32a4d743ee Fix KeyError 2025-04-29 11:43:36 -05:00
Jeremy Stretch
5342552054 Fixes #15739: Account for parallel cables when calculating total path length (#19356) 2025-04-29 11:32:43 -05:00
Jeremy Stretch
732f50d8da Closes #19351: Pin 3rd party GitHub actions to commit hash 2025-04-29 08:42:52 -04:00
github-actions
48a367c409 Update source translation strings 2025-04-29 05:02:15 +00:00
Jeremy Stretch
e44ad8af45 Fixes #19346: Ensure all redirect URLs are validated 2025-04-28 14:27:49 -04:00
Arthur Hanson
81dfaf0d67 18706 Fix VLAN Assignment checking (#19332)
* 18706 Fix VLAN assignment checking

* 18706 add tests

* 18706 review feedback
2025-04-28 11:45:01 -04:00
Renato Almeida de Oliveira
584fff90c7 Closes #18215: Create ScriptJobTable from JobTable and add it to ScriptSourceView (#19337) 2025-04-28 09:57:48 -05:00
Renato Almeida de Oliveira
e345ca2659 Add VirtualCircuit reference to ProviderView related models (#19335) 2025-04-28 07:54:10 -07:00
bctiemann
bdef00f3b0 Merge pull request #19310 from netbox-community/15971-preserve-null-filter
Fixes #15971: Preserve 'none' selection in filter form fields
2025-04-28 08:50:32 -04:00
bctiemann
f652dc7bda Merge pull request #19301 from renatoalmeidaoliveira/19229-cant-unset-a-device-interface-vlan-mode-without-an-error
Fixes: #19229 Validade interface mode changes
2025-04-28 08:48:20 -04:00
Arthur Hanson
18ac29fdd0 18334 add location, device, site to module filters (#19312)
* 18334 add location, device, site to module filters

* 18334 add location, device, site to module filters

* 18334 add tests

* 18334 fix tests

* 18334 add site-group
2025-04-28 08:46:38 -04:00
bctiemann
fdf42860aa Merge pull request #19282 from netbox-community/19281-fix-service-templates
Fixes #19281: Fix service template creation form
2025-04-28 08:45:53 -04:00
Jason Novinger
b31da39c4a Fix CSVModelMultipleChoiceField.clean assuming all values are str/None 2025-04-28 08:41:27 -04:00
Jeremy Stretch
0f4afbca2c Closes #17243: Include example of image upload using REST API (#19330) 2025-04-25 15:33:46 -05:00
Jason Novinger
9173a113b7 Update compiled static files with updated NPM deps 2025-04-25 14:07:03 -05:00
Jason Novinger
afc7b35af0 Added plugin icon to plugin list/detail 2025-04-25 13:50:36 -05:00
Jeremy Stretch
37cfc50202 Fixes #19322: Correct URL paths for bulk import views (#19323) 2025-04-25 12:20:25 -05:00
Jamie (Bear) Murphy
e8dd486132 adds related object to rack reservation changelogs (#19306) 2025-04-25 07:32:18 -07:00
github-actions
e58815bb1a Update source translation strings 2025-04-25 05:02:12 +00:00
bctiemann
bee004fc0c Fixes: #18717 - On delete signal handling, manually save the related object in a ManyToOneRel to trigger a change record (#19308)
* On delete signal handling, manually save the related object in a ManyToOneRel to trigger a change record

* Only set remote field to None if null=True on the relation
2025-04-24 14:24:20 -05:00
Jeremy Stretch
e1b2b4b536 Fixes #17676: Fix support for module bay creation when bulk importing module types (#19311) 2025-04-24 11:07:46 -07:00
Daniel Sheppard
f711e666c5 Fixes: #19228 - Fix ordered_scripts to only return ordered list of script objects (#19293) 2025-04-24 11:00:40 -07:00
Jeremy Stretch
47da880547 Fixes #15971: Preserve 'none' selection in filter form fields 2025-04-24 11:42:30 -04:00
mr1716
02f51bc11b Fixes #19255: Improved documentation consistency for configuration default values (#19258) 2025-04-24 09:57:55 -04:00
Martin Hauser
88dd7a16f8 feat(circuits): Use ColoredLabelColumn for type column
Refactors the 'type' column to use ColoredLabelColumn for improved
visual distinction, aligning it with the rack roles display.
Removes the now redundant 'linkify' attribute from the column
definition.
2025-04-24 08:51:42 -04:00
Arthur Hanson
d0c2e0e52b 16238 map custom-link button colors to tabler (#19290)
* 16238 map custom-link button colors to tabler

* 16238 map custom-link button colors to tabler

* 16238 backout temp code

* 16238 optimize
2025-04-24 08:40:14 -04:00
github-actions
983e544376 Update source translation strings 2025-04-24 05:02:08 +00:00
Renato Almeida de Oliveira Zaroubin
125bce84e4 Validade interface mode changes 2025-04-24 01:47:43 +00:00
Jeremy Stretch
fbf926204e Fixes #19204: Use DjangoJSONEncoder for Job data (#19297) 2025-04-23 15:57:48 -05:00
Arthur Hanson
0ce307c7fd 19217 debug toolbar (#19289)
* 19217 update and re-add django-debug-toolbar to INSTALLED_APPS

* 19217 remove debug if not collectstatic
2025-04-23 13:21:59 -05:00
Jeremy Stretch
6c60a4360b Fixes #19279: Fix missing status field for inventory item bulk creation (#19283) 2025-04-23 10:49:22 -07:00
Artem Kotik
2c3fe9700f Fixes #19110: Add filters by IP addresses to PrimaryIPFilterSet (#19160)
* Add primary ip filters by address

* Add tests for Device and VirtualDeviceContext models
2025-04-23 09:14:33 -05:00
Jeremy Stretch
deaff2dad8 Fixes #19281: Fix service template creation form 2025-04-23 08:47:54 -04:00
github-actions
cd3d91e7c7 Update source translation strings 2025-04-23 05:02:11 +00:00
Jeremy Stretch
d2e74e9d50 Release v4.2.8 2025-04-22 15:44:02 -04:00
bctiemann
02571130b2 Merge pull request #19259 from netbox-community/18961-vc-create-ignore-vc-members
Fixes #18961: Virtual chassis form should exclude members of other VCs when adding members
2025-04-22 14:36:12 -04:00
Jeremy Stretch
46a3ce2559 Fixes #19268: Restore editing conflict protection for several object forms (#19271) 2025-04-22 11:07:03 -07:00
Jeremy Stretch
1850c21714 Fixes #19265: Strip linebreaks from URLs for logging 2025-04-22 12:48:57 -04:00
Jeremy Stretch
13ddd5fd20 Fixes #19230: Allow label reuse when creating multiple components from a pattern (#19261) 2025-04-22 08:55:31 -07:00
Jeremy Stretch
60cdf89cad Fixes #19166: Fix bulk import support for base_choices (#19257) 2025-04-22 08:29:00 -07:00
Jeremy Stretch
77bfc40579 Closes #17136: Add read-only database support to the upgrade script (#19247) 2025-04-22 07:42:54 -07:00
Jeremy Stretch
2f8936d493 Fixes #18961: Virtual chassis form should exclude members of other VCs when adding members 2025-04-22 10:24:43 -04:00
bctiemann
e0b6a31504 Merge pull request #19256 from netbox-community/18959-cable-trace-terminations-ordering
Fixes #18959: Preserve ordering of terminations in cable traces
2025-04-22 10:08:13 -04:00
Jeremy Stretch
8567aa96e4 Remove obsolete comment 2025-04-22 09:42:17 -04:00
Jeremy Stretch
459c4bfd9d Fixes #18959: Preserve ordering of terminations in cable traces 2025-04-22 09:21:14 -04:00
Jeremy Stretch
918470a2bb Closes #19208: Ignore beta releases when fetching latest release tag 2025-04-22 08:18:23 -04:00
Arthur
c73cc0a36a 18500 fix check for cloning mixin 2025-04-22 08:14:32 -04:00
Renato Almeida de Oliveira Zaroubin
6b9b66aecb make INTERFACE_FHRPGROUPS use FHRPGROUPS _str_ representation 2025-04-22 08:06:24 -04:00
github-actions
b6d10ae6d8 Update source translation strings 2025-04-22 05:02:08 +00:00
Arthur
79ece657ec 17613 Add column support for mobile devices 2025-04-21 11:52:19 -07:00
Arthur
717b9d5232 17613 Add column support for mobile devices 2025-04-21 11:41:56 -07:00
Arthur
0fa98d3aef 17613 Add column support for mobile devices 2025-04-21 11:02:56 -07:00
Renato Almeida de Oliveira
7420c25687 Fixes: #18881 Site Groups are missing VLAN and VM related objects (#18932) 2025-04-21 09:11:01 -07:00
github-actions
248c94bd35 Update source translation strings 2025-04-16 05:02:15 +00:00
bctiemann
96cf8d14dc Merge pull request #19194 from netbox-community/17908-cable-trace-buttons
Closes #17908: Add trace buttons to terminations under cable view
2025-04-15 18:13:59 -04:00
bctiemann
2356a3c125 Merge pull request #19162 from alehaa/19040-copy-rendered-config
Fixes #19040: Add button to copy rendered config to clipboard
2025-04-15 18:00:47 -04:00
bctiemann
0d81007fdf Merge pull request #19178 from netbox-community/19139-tx-force-pull
Closes #19139: Use --force when invoking tx pull
2025-04-15 17:59:58 -04:00
atownson
c108c738ae Fixes #18669 - Populate custom field default values (#19115) 2025-04-15 13:26:03 -07:00
Martin Hauser
cac41cd093 Fixes #19196: Add Filtering by VLAN Translation Policy to Interface Filter Forms (#19199)
* feat(dcim): Add VLAN Translation Policy to Filter Form

Introduces support for VLAN Translation Policies in the Interface
Filter Form.

* feat(virtualization): Add VLAN Translation Policy to Filter Form

Introduces support for VLAN Translation Policies in the Interface
Filter Form.
2025-04-15 16:24:45 -04:00
Jeremy Stretch
27b26ec49c Fixes #19195: Language cookie should respect SESSION_COOKIE_SECURE value (#19197) 2025-04-15 13:03:33 -07:00
Jeremy Stretch
7c2776d721 Closes #19201: Include TS source with sourcemap (#19202) 2025-04-15 14:42:40 -05:00
Martin Hauser
1f93471659 Fixes #18978 - Allow filtering of Interfaces in the GUI by 802.1Q Mode (#19183)
* feat(dcim): Add VLAN mode filter to CommonInterface

Introduces a new FilterSet for VLAN mode in CommonInterfaceFilterSet.
This allows filtering interfaces based on their VLAN mode using defined
choices.

* feat(dcim): Add VLAN mode filter to Interface FilterForm

Add a field to InterfaceFilterSet to filter interfaces by 802.1Q VLAN
mode.

* feat(virtualization): Add VLAN mode filter to VMInterface

Add a field to VMInterfaceFilterSet to filter interfaces by 802.1Q VLAN
mode.

* fix(dcim): Correct mode filter parameter type in tests

Updates the `mode` filter parameter to accept a list instead of a single
value in `test_filtersets.py`. Ensures proper count assertion for
accurate test behavior.

* feat(virtualization): Add tests for VLAN mode filtering

Introduces tests to validate filtering by `mode` for VMInterface.
Ensures correct filtering for 802.1Q VLAN mode.

* refactor(virtualization): Reorganize FieldSets in FilterSets

Splits the 'Attributes' FieldSet into two distinct FieldSets for better
clarity: 'Attributes' and 'Addressing'. This improves form organization
and makes it more intuitive for users.
2025-04-15 13:47:51 -04:00
Jeremy Stretch
d3768feb31 Closes #17908: Add trace buttons to terminations under cable view 2025-04-15 11:41:00 -04:00
Jeremy Stretch
70cc7c7563 Fixes #19169: Remove horizontal padding from highlighted text (#19181) 2025-04-15 07:13:58 -07:00
Jeremy Stretch
8b091fb219 Fixes #19189: BaseScript.load_yaml() should use SafeLoader (#19190) 2025-04-15 08:58:13 -05:00
Martin Hauser
44cb1a9139 Fixes #19056 – Add Device filtering by Location slug (#19180)
* feat(dcim): Add filter by location slug for Device

Introduces a TreeNodeMultipleChoiceFilter for filtering locations by
slug. Enhances filtering flexibility in the Device model by supporting
both ID and slug lookups.

Fixes #19056

* feat(dcim): Add Device filtering by location slug in tests

Extend test cases to include filtering by location slug. Ensures the
FilterSet works correctly with slug-based queries for locations.

Fixes #19056
2025-04-15 08:22:59 -04:00
Martin Hauser
bb9b0b8f8a Fixes #18879 - Add prefix filtering by assigned VLAN Group (#19182)
* feat(ipam): Add VLAN group filters to IPAM FilterSet

Introduces filters for VLAN groups using both ID and slug fields.

* feat(ipam): Add VLAN group filter in IPAM FilterForm

Introduces a `vlan_group_id` filter to IPAM forms for filtering based on
VLAN groups.

* feat(ipam): Add VLAN group filtering to tests

Introduces tests for VLAN group filtering in FilterSets. This ensures
correct validation and behavior when filtering by VLAN group.
2025-04-15 07:59:32 -04:00
github-actions
785ad505ba Update source translation strings 2025-04-15 05:02:10 +00:00
Jeremy Stretch
8aacef60a3 Closes #19175: Remove outdated NetBox installation video (#19177) 2025-04-14 15:14:59 -05:00
Jeremy Stretch
d8fc052bbe Closes #19139: Use --force when invoking tx pull 2025-04-14 15:55:08 -04:00
Renato Almeida de Oliveira Zaroubin
1f79411878 Add GetRelatedModelsMixin to ClusterView 2025-04-14 15:39:49 -04:00
bctiemann
94d19e8f15 Remove #18095 from v4.2.5 release notes (#19165) 2025-04-14 07:26:37 -07:00
Alexander Haase
f337ef1134 Add button to copy rendered config 2025-04-13 18:02:21 +02:00
Jeremy Stretch
6ed41f6680 Fixes #18955: Include the "clear" button only for non-required dropdown fields (#18971)
* Include the 'clear' button only for non-required fields

* Apply the 'required' attribute for mandatory <select> fields

* Recompile static assets
2025-04-11 08:20:24 -04:00
github-actions
28e62d21a9 Update source translation strings 2025-04-11 05:02:06 +00:00
Jeremy Stretch
03f3f5c957 Release v4.2.7 2025-04-10 16:07:24 -04:00
Jeremy Stretch
fe7fb94e44 Revert "Fixes: #18783 Add a tag_id filter for all models which support taggin…"
This reverts commit 9a1d9365cd.
2025-04-10 15:42:26 -04:00
github-actions
82b9e4ca26 Update source translation strings 2025-04-10 05:02:11 +00:00
Jeremy Stretch
457fb977a7 Fixes #19122: Fix styling of the server error (500) page (#19126) 2025-04-09 14:57:25 -07:00
Jeremy Stretch
13c20957a6 Closes #18652: Run housekeeping GitHub actions only on the main repository (#19125) 2025-04-09 16:28:00 -05:00
Jason Novinger
30208549ba Fixes #19092: scope type selection lost when editing multiple/all objects (#19102) 2025-04-09 14:55:41 -04:00
atownson
bf286df670 Fixes #19030 - Update z-index of floating buttons (#19118)
* Increase z-index of form floating buttons

* Update netbox.css
2025-04-08 16:02:08 -05:00
Jeremy Stretch
2be257db48 Closes #19112: Configure ruff to target Python 3.10 (#19113) 2025-04-08 09:46:31 -05:00
bctiemann
2207ea1a32 Merge pull request #19046 from pheus/docs/18733-add-version-requirements-matrix
Fixes #18733: Add Dependency Version Matrix for NetBox Versions to the Upgrade Documentation
2025-04-07 14:01:54 -04:00
Martin Hauser
10e1ae8292 docs(release): Update Dependency Requirements Matrix header
Renames the "Adopt the Dependency Requirements Matrix" section to
"Update the Dependency Requirements Matrix" for clarity.
2025-04-07 17:17:26 +02:00
Martin Hauser
f8f5ab8d61 docs(release): Correct formatting in release checklist 2025-04-07 16:55:23 +02:00
github-actions
92317248a3 Update source translation strings 2025-04-05 05:02:12 +00:00
Jeremy Stretch
426e6439e3 Fixes #18553: Update site for VMs only if cluster has a site assigned (#19086) 2025-04-04 10:58:06 -07:00
Jeremy Stretch
621b29cd71 Closes #19035: Move the registration of core event types to the app config (#19088) 2025-04-04 11:18:42 -05:00
github-actions
8f5d273f08 Update source translation strings 2025-04-03 05:02:01 +00:00
Martin Hauser
45779a24a4 docs(release): Update checklist with dependency requirements
Add steps to update the dependency requirements matrix for each minor
release in the release checklist. Clarify how to document changes for
system requirements and linked installation guides.

Fixes #18733
2025-04-02 21:41:36 +02:00
Martin Hauser
f17bbe610e Fixes #19041: Call super().clean() in FrontPortCreateForm (#19051)
* fix(forms): Call super().clean() in clean methods

Adds a call to super().clean() in the clean methods of object creation
forms. This ensures base class validation logic is executed properly
before custom logic is applied.

Fixes #19041

* test(forms): Add tests for front port form validation

Introduces unit tests for validating FrontPortCreateForm behavior.
Tests include scenarios for matching and mismatched name-label pairs
to ensure proper form validation logic.

Fixes #19041

* Omit errant print statement

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2025-04-02 08:29:21 -04:00
bctiemann
bad820001d Merge pull request #19015 from netbox-community/18738-script-list-ignoring-script-order
Fixes #18738: Ensure ScriptList respects script_order option
2025-04-01 10:09:39 -04:00
Martin Hauser
a5106b858d docs(upgrading): Add dependency matrix for NetBox versions
Adds a dependency matrix to the upgrade guide, detailing supported
Python, PostgreSQL, and Redis versions for each NetBox release. This
helps users verify compatibility before upgrading.

Fixes #18733
2025-04-01 07:23:42 +02:00
github-actions
bbd5e9cab9 Update source translation strings 2025-04-01 05:02:06 +00:00
bctiemann
12231ad71a Merge pull request #18997 from antoinekh/18964_fix_bulk_edit_all
Fixes #18964 Select all with bulk edit only changes the currently visible objects
2025-03-31 18:36:04 -04:00
Jeremy Stretch
88ef9ecfa3 Fixes #19023: get_field_value() should respect null values in bound forms (#19024) 2025-03-31 16:34:46 -05:00
github-actions
6f78b3d0cd Update source translation strings 2025-03-29 05:02:03 +00:00
Jason Novinger
d3f42deb32 Fixes #18895: Allows VirtualCircuitTerminations as Interface connected_endpoints (#19027) 2025-03-28 08:58:09 -07:00
Jason Novinger
db4fb8f406 Fixes #18999: Allow GraphQL to represent inventory items with no set manufacturar (#19016) 2025-03-28 07:57:56 -07:00
ZPrimed
5b8eaced1a Update choices.py - add MoCA (Coaxial) 2025-03-28 08:45:04 -04:00
github-actions
ada0c7f687 Update source translation strings 2025-03-28 05:02:14 +00:00
Jeremy Stretch
b750d0dff2 Fixes #19021: Ensure consistent styling for JSON form fields (#19022) 2025-03-27 08:58:37 -05:00
Jason Novinger
e1e514251e Fixes #18965: Ensure script list run buttons respect scripts' commit_default option (#19013)
* Fixes #18965: Script list run buttons respect scripts' commit_default

* Cleanup script .Meta access in template
2025-03-27 08:39:50 -04:00
Renato Almeida de Oliveira
7d80a45bf8 Fixes: #16144 GetReturnURLMixin Support for Plugin Views (#18996)
* Add plugin support to GetReturnURLMixin

* use get_viewname instead of resolving the name
2025-03-27 08:33:09 -04:00
github-actions
09854a3d54 Update source translation strings 2025-03-27 05:02:15 +00:00
Jason Novinger
39a96ddf3a Fixes #18738: Ensure ScriptList respects script_order option 2025-03-26 15:35:06 -05:00
atownson
be26f86b62 Added advanced object selector to custom field object and multi-object inputs (#18830) 2025-03-26 10:42:45 -07:00
Jason Novinger
fd2bcda8b8 Fixes #18991: AttributeError: NoneType object has not attribute model (#19006) 2025-03-26 10:12:45 -07:00
github-actions
817d7efee3 Update source translation strings 2025-03-26 05:03:20 +00:00
Renato Almeida de Oliveira
9a1d9365cd Fixes: #18783 Add a tag_id filter for all models which support tagging (#18889) 2025-03-25 08:06:22 -07:00
Antoine Keranflec'h
ada4a4b93c fix #18964 reinsert else condition 2025-03-25 08:22:59 +00:00
github-actions
64a98fd87f Update source translation strings 2025-03-25 05:02:04 +00:00
Arthur Hanson
bd8e00a935 18904 add tags to config context table (#18938)
* 18904 add tags to config context table

* 18904 tag to correct table
2025-03-24 17:02:36 -04:00
Jeremy Stretch
af5a600583 Closes #18980: Optimize update of object data when adding/removing custom fields (#18983)
* Employ native PostgreSQL functions for updating object JSON data when adding/removing custom fields

* Optimize rename_object_data()

* remove_stale_data() should validate model class
2025-03-24 12:02:54 -05:00
github-actions
8ab73501d1 Update source translation strings 2025-03-22 05:02:10 +00:00
Renato Almeida de Oliveira
447e108d97 Fixes: #18656 Unable to import IP Address and assign to FHRP Group (#18950)
* Add fhrpgroup to IPAddressImportForm

* Change fhrpgroup accessor to name

* rename fhrpgroup to fhrp_group

* Add fhrp_group to  IPAddressTestCase csv_data
2025-03-21 16:44:10 -05:00
bctiemann
e186113cb3 Merge pull request #18986 from netbox-community/release-v4.2.6
Release v4.2.6
2025-03-21 17:17:50 -04:00
Jason Novinger
40452ead62 Cleanup release notes 2025-03-21 16:03:34 -05:00
Jason Novinger
34d80beaa2 Release v4.2.6 2025-03-21 15:49:20 -05:00
Yi
b1d014b520 Fixes #18949: Add missing GraphQL ContactsMixin in types with ContactAssignments 2025-03-21 13:00:50 -05:00
github-actions
7db0765ed2 Update source translation strings 2025-03-21 05:02:02 +00:00
Jason Novinger
b8cc2d7116 Fixes #18887: Allows VMInterface object custom field on Prefix (#18945) 2025-03-20 13:55:12 -07:00
Renato Almeida de Oliveira
d332a0c0d7 Fix sitegroup typo, VLANGroupFilterForm field didn't match between form and filterset (#18947) 2025-03-20 11:30:39 -05:00
Jason Novinger
f07e2dd4e2 Fixes #18944: Clearing widget type field no longer causes 500 error (#18946)
* Fixes #18944: Clearing widget type field no longer causes 500 error

* Address PR feedback, cleanup implementation
2025-03-20 09:12:05 -04:00
Renato Almeida de Oliveira
d7b9b09d56 Fixes: #18939 Allow ASN search by site-group (#18948)
* Add site_group and site_group_id to ASNFilterSet and ASNFilterForm

* Implement ASNTestCase site_group test case
2025-03-20 09:07:48 -04:00
github-actions
9da4cf31ab Update source translation strings 2025-03-20 05:02:02 +00:00
Renato Almeida de Oliveira
bf1a9a6e2d Fixes: #18833 Inventory Item Bulk Import - 'InventoryItemImportForm' has no field named 'component_id'. (#18874)
* Refactor InventoryItemImportForm clean method

* Add super().clean(); renamed content_type; simplified component creation

* Fix missing component_name issue

* Update netbox/dcim/forms/bulk_import.py

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2025-03-19 09:56:42 -04:00
bctiemann
c50b1c989d Merge pull request #18929 from netbox-community/18928-housekeeping-changelog-cleanup
Fixes #18928: Fix support for cascading deletions when cleaning up expired changelog records
2025-03-19 09:51:11 -04:00
bctiemann
79b0c0f5d6 Merge pull request #18901 from netbox-community/18872-JournalEntry-kind-required
Fixes #18872: JournalEntry `kind` is a required field
2025-03-19 09:40:55 -04:00
github-actions
43840e6a72 Update source translation strings 2025-03-19 05:02:13 +00:00
bctiemann
7c152e9234 Merge pull request #18854 from netbox-community/18782-dashboard-broken-on-notification-list-widget
Fixes #18782: Misconfigured `ObjectListWidget`s now degrade gracefully
2025-03-18 14:45:23 -04:00
Jason Novinger
d7709a2a55 Fixes #18926: Uses correct icon for base GitHub auth 2025-03-18 08:48:27 -04:00
github-actions
dce694afa9 Update source translation strings 2025-03-18 05:02:07 +00:00
Jeremy Stretch
c5801f9881 Fixes #18928: Fix support for cascading deletions when cleaning up expired changelog records 2025-03-17 14:16:55 -04:00
Jeremy Stretch
f86647dc28 Fixes #18884: Handle tag assignments manually within deserialize_object (#18908) 2025-03-17 08:43:08 -07:00
Benjamin Dale
0094703609 Closes #18816:Add FC/UPC, FC/APC and FC/PC port types (#18913)
* Closes #18816:Add FC/UPC, FC/APC and FC/PC port types

* Remove release note

---------

Co-authored-by: Ben Dale <7621213-ben_dale@users.noreply.gitlab.com>
Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2025-03-17 10:39:28 -04:00
github-actions
f286449284 Update source translation strings 2025-03-14 05:02:07 +00:00
bctiemann
4f45328c77 Fixes: #18863 - Exempt MPTT-based models from centrally applying ordering on querysets (#18867) 2025-03-13 13:38:33 -07:00
Jeremy Stretch
994e7eb9f4 Fixes #18872: JournalEntry kind is a required field 2025-03-13 16:23:53 -04:00
Jeremy Stretch
ed135102be Fixes #18880: Delay enqueuing of background tasks until the DB transaction has been committed (#18899) 2025-03-13 11:34:12 -05:00
Jason Novinger
78332d44c7 Fixes #18845: restores sort behavior for DeviceTable.name column (#18861)
* Fixes #18845: restores sort behavior for DeviceTable.name column

* Remove accessor/order_by and modify DEVICE_LINK template

Thanks to @alehaa for the suggestion.

This also includes an additional `.select_related()` operation on
`DeviceListView.queryset` to avoid extra queries. Thanks to
@renatoalmeidaoliveira and @jeremystretch for pointing out the need for
this.
2025-03-13 09:22:49 -04:00
github-actions
80926cda8f Update source translation strings 2025-03-13 05:02:07 +00:00
bctiemann
d924d4eb33 Merge pull request #18886 from ITJamie/add-rackname-to-device-rack-view
Add rack title above rack view on device page
2025-03-12 19:11:22 -04:00
bctiemann
b1e7d7c76b Merge pull request #18826 from Tishka17/fix/generic_prefetch_4.2
Prefetch interface data for REST API on netbox 4.2
2025-03-12 18:55:58 -04:00
Jeremy Stretch
092f7549ca Fixes #18869: Ensure to_meters() always returns a clean decimal value (#18883)
* Fixes #18869: Ensure to_meters() always returns a clean decimal value

* Handle float values
2025-03-12 13:42:38 -05:00
Andrey Tikhonov
2f51dfc07a Merge branch 'fix/generic_prefetch_4.2' of github.com:Tishka17/netbox into fix/generic_prefetch_4.2 2025-03-12 19:13:34 +01:00
Andrey Tikhonov
906654d807 Fix empty line, comments 2025-03-12 19:09:46 +01:00
Renato Almeida de Oliveira
749a83d742 Change JSONField, prepare_value to output str with enclosing " (#18846) 2025-03-12 10:00:40 -07:00
Andrey Tikhonov
cdd25368e7 Merge branch 'main' into fix/generic_prefetch_4.2 2025-03-12 16:27:12 +01:00
Andrey Tikhonov
7d64d3b5ed Move GenericArrayForeignKey to fields module 2025-03-12 16:22:11 +01:00
github-actions
5e22ef59c5 Update source translation strings 2025-03-12 05:02:02 +00:00
Fabi
19d1282683 Fixes #18838: Correctly reject invalid falsy local context data (#18860)
* Correctly reject invalid falsy local context data.

* move tests
2025-03-11 14:18:42 -05:00
bctiemann
2266a8af67 Merge pull request #18804 from renatoalmeidaoliveira/18742-Location-List-and-Locations-not-Showing-Associated-VLAN-Groups
Fixes: #18742 Location List and Locations not Showing Associated VLAN Groups
2025-03-11 11:13:24 -04:00
Renato Almeida de Oliveira
5d81f911d6 Fixes: #18582 Bulk import prefixes with associated VLAN not working when multiple VLANs with the same vid exist. (#18844)
* Add site CSVModelChoiceField

* Change site field to vlan_site
2025-03-11 08:46:46 -04:00
github-actions
89e3f3d3e9 Update source translation strings 2025-03-11 05:02:10 +00:00
Jeremy Stretch
292463c0de Closes #18857: Fix deprecation warnings (#18858) 2025-03-10 14:40:08 -07:00
Antoine Keranflec'h
a9fd191086 Fixes: #18744 Add Contact link as hyperlink (#18847)
* contact link as hyperlink

* fix(lint) add missing blank lines

* simplify by using lambda function
2025-03-10 12:49:24 -05:00
Tobias Genannt
1a60cb9884 Fixes: #18568 Update mkdocstrings and adapt config (#18841)
* Fixes: #18568 Update mkdocstrings and adapt config

* Fixed some output formatting errors

When trying to compare the output from "mkdocstrings[python-legacy]==0.27.0" and
"mkdocstrings[python]==0.28.2" I encountered some HTML errors:
- <div> inside a <p>
- unescaped non-tags <pk>
2025-03-10 13:11:35 -04:00
Jason Novinger
76c3c613a9 Adds validation for ObjectListWidget.ConfigForm.model field 2025-03-10 09:57:45 -05:00
Jason Novinger
528248b560 Fixes #18782: properly check if htmx_url is None
If this is done incorrently, then the string formatting operation turns
`htmx_url` into a string and the test in the template fails.
2025-03-10 09:52:59 -05:00
github-actions
8823b07745 Update source translation strings 2025-03-08 05:02:21 +00:00
Mika Busch
29c25e39fc 17686 config option for disk divider (#18011) 2025-03-07 10:47:27 -08:00
Andrey Tikhonov
d103e13732 Removed automatic cache invalidation 2025-03-07 17:29:04 +01:00
github-actions
6d69c76b83 Update source translation strings 2025-03-07 05:02:48 +00:00
bctiemann
f9c8d12a51 Add docstrings for get_queryset base class methods (#18832) 2025-03-06 11:11:55 -06:00
Renato Almeida de Oliveira
3ef7ab4416 Fixes: #18579 Add contact filters to services (#18818)
* Add Contact filter to Services

* Add ContactModelFilterForm to ProviderAccountFilterForm

* Add Contact filter support for Aggregate

* Add Contact filter support for Prefix

* Add Contact filter to IPRange

* Add Contact filter to IPAddress

* Add Contact filter to L2VPN

* Add Contact filter to TunnelGroup

* Add Contact filter to Tunnel

* Add ContactModelFilterSet to ProviderAccountFilterSet

* Fixes classes inheritance order

Setup NetBoxModelFilterSetForm as the last inherited class

Co-authored-by: Jason Novinger <jnovinger@gmail.com>

---------

Co-authored-by: Jason Novinger <jnovinger@gmail.com>
2025-03-06 09:41:41 -06:00
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
Andrey Tikhonov
741645c9f7 Merge branch 'develop' into fix/generic_prefetch_4.2 2025-03-06 16:05:25 +01:00
Andrey Tikhonov
d226af420b Merge branch 'develop' into fix/generic_prefetch_4.2 2025-03-06 16:02:32 +01:00
Andrey Tikhonov
5c88317745 cleanup unused imports and extra line 2025-03-06 16:01:59 +01: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
Renato Almeida de Oliveira Zaroubin
d83c2f45bc Add vlangroup into LocationTable, LocationListView queryset and related_models in LocationView 2025-03-04 18:34:23 +00: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
Andrey Tikhonov
83ca0ef955 Add missed init 2025-02-24 15:43:07 +01:00
Andrey Tikhonov
9c3e7f2c5d Cleanup 2025-02-24 15:38:19 +01:00
Andrey Tikhonov
7794c6cfcb Fix link peers 2025-02-24 15:28:45 +01:00
Andrey Tikhonov
8dc1d68aee port generic field to 4.2 2025-02-24 15:20:18 +01:00
Jamie Murphy
d9066d6cff add device rack title 2025-02-19 22:02:59 +00: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
308 changed files with 66505 additions and 54183 deletions

View File

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

View File

@@ -12,6 +12,7 @@ permissions:
jobs:
stale:
if: github.repository == 'netbox-community/netbox'
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9

View File

@@ -13,6 +13,7 @@ permissions:
jobs:
stale:
if: github.repository == 'netbox-community/netbox'
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9

View File

@@ -13,9 +13,10 @@ permissions:
jobs:
lock:
if: github.repository == 'netbox-community/netbox'
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v5
- uses: dessant/lock-threads@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 # v5.0.1
with:
issue-inactive-days: 90
pr-inactive-days: 30

View File

@@ -13,6 +13,7 @@ env:
jobs:
makemessages:
if: github.repository == 'netbox-community/netbox'
runs-on: ubuntu-latest
env:
NETBOX_CONFIGURATION: netbox.configuration_testing
@@ -47,7 +48,7 @@ jobs:
run: python netbox/manage.py makemessages -l ${{ env.LOCALE }}
- name: Commit changes
uses: EndBug/add-and-commit@v9
uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4
with:
add: 'netbox/translations/'
default_author: github_actions

View File

@@ -88,8 +88,7 @@ mkdocs-material
# Introspection for embedded code
# https://github.com/mkdocstrings/mkdocstrings/blob/main/CHANGELOG.md
# See #18568
mkdocstrings[python-legacy]==0.27.0
mkdocstrings[python]
# Library for manipulating IP prefixes and addresses
# https://github.com/netaddr/netaddr/blob/master/CHANGELOG.rst
@@ -133,8 +132,7 @@ strawberry-graphql
# Strawberry GraphQL Django extension
# https://github.com/strawberry-graphql/strawberry-django/releases
# Pinned to v0.52.0 for suspected upstream bug; see #18329
strawberry-graphql-django==0.52.0
strawberry-graphql-django
# SVG image rendering (used for rack elevations)
# https://github.com/mozman/svgwrite/blob/master/NEWS.rst

View File

@@ -427,6 +427,7 @@
"e3",
"xdsl",
"docsis",
"moca",
"bpon",
"epon",
"10g-epon",
@@ -500,6 +501,9 @@
"n",
"mrj21",
"fc",
"fc-pc",
"fc-upc",
"fc-apc",
"lc",
"lc-pc",
"lc-upc",
@@ -565,6 +569,9 @@
"n",
"mrj21",
"fc",
"fc-pc",
"fc-upc",
"fc-apc",
"lc",
"lc-pc",
"lc-upc",

View File

@@ -54,6 +54,7 @@ Icons](https://github.com/google/material-design-icons) icon's name; or be
`None` for no icon.
For instance, the OIDC backend may be customized with
```python
SOCIAL_AUTH_BACKEND_ATTRS = {
'oidc': ("My awesome SSO", "login"),

View File

@@ -69,7 +69,7 @@ For a complete list of available preferences, log into NetBox and navigate to `/
!!! tip "Dynamic Configuration Parameter"
Default: 50
Default: `50`
The default maximum number of objects to display per page within each list of objects.
@@ -79,7 +79,7 @@ The default maximum number of objects to display per page within each list of ob
!!! tip "Dynamic Configuration Parameter"
Default: 15
Default: `15`
The default value for the `amperage` field when creating new power feeds.
@@ -89,7 +89,7 @@ The default value for the `amperage` field when creating new power feeds.
!!! tip "Dynamic Configuration Parameter"
Default: 80
Default: `80`
The default value (percentage) for the `max_utilization` field when creating new power feeds.
@@ -99,7 +99,7 @@ The default value (percentage) for the `max_utilization` field when creating new
!!! tip "Dynamic Configuration Parameter"
Default: 120
Default: `120`
The default value for the `voltage` field when creating new power feeds.
@@ -109,7 +109,7 @@ The default value for the `voltage` field when creating new power feeds.
!!! tip "Dynamic Configuration Parameter"
Default: 22
Default: `22`
Default height (in pixels) of a unit within a rack elevation. For best results, this should be approximately one tenth of `RACK_ELEVATION_DEFAULT_UNIT_WIDTH`.
@@ -119,6 +119,6 @@ Default height (in pixels) of a unit within a rack elevation. For best results,
!!! tip "Dynamic Configuration Parameter"
Default: 220
Default: `220`
Default width (in pixels) of a unit within a rack elevation.

View File

@@ -2,7 +2,7 @@
## DEBUG
Default: False
Default: `False`
This setting enables debugging. Debugging should be enabled only during development or troubleshooting. Note that only
clients which access NetBox from a recognized [internal IP address](./system.md#internal_ips) will see debugging tools in the user
@@ -16,6 +16,6 @@ interface.
## DEVELOPER
Default: False
Default: `False`
This parameter serves as a safeguard to prevent some potentially dangerous behavior, such as generating new database schema migrations. Additionally, enabling this setting disables the debug warning banner in the UI. Set this to `True` **only** if you are actively developing the NetBox code base.

View File

@@ -2,7 +2,7 @@
## SENTRY_DSN
Default: None
Default: `None`
Defines a Sentry data source name (DSN) for automated error reporting. `SENTRY_ENABLED` must be True for this parameter to take effect. For example:
@@ -14,7 +14,7 @@ SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0"
## SENTRY_ENABLED
Default: False
Default: `False`
Set to True to enable automatic error reporting via [Sentry](https://sentry.io/).
@@ -25,7 +25,7 @@ Set to True to enable automatic error reporting via [Sentry](https://sentry.io/)
## SENTRY_SAMPLE_RATE
Default: 1.0 (all)
Default: `1.0` (all)
The sampling rate for errors. Must be a value between 0 (disabled) and 1.0 (report on all errors).
@@ -33,7 +33,7 @@ The sampling rate for errors. Must be a value between 0 (disabled) and 1.0 (repo
## SENTRY_SEND_DEFAULT_PII
Default: False
Default: `False`
Maps to the Sentry SDK's [`send_default_pii`](https://docs.sentry.io/platforms/python/configuration/options/#send-default-pii) parameter. If enabled, certain personally identifiable information (PII) is added.
@@ -60,7 +60,7 @@ SENTRY_TAGS = {
## SENTRY_TRACES_SAMPLE_RATE
Default: 0 (disabled)
Default: `0` (disabled)
The sampling rate for transactions. Must be a value between 0 (disabled) and 1.0 (report on all transactions).

View File

@@ -4,7 +4,7 @@
!!! tip "Dynamic Configuration Parameter"
Default: True
Default: `True`
Setting this to False will disable the GraphQL API.
@@ -12,6 +12,6 @@ Setting this to False will disable the GraphQL API.
## GRAPHQL_MAX_ALIASES
Default: 10
Default: `10`
The maximum number of queries that a GraphQL API request may contain.

View File

@@ -55,7 +55,7 @@ Sets content for the top banner in the user interface.
## CENSUS_REPORTING_ENABLED
Default: True
Default: `True`
Enables anonymous census reporting. To opt out of census reporting, set this to False.
@@ -67,7 +67,7 @@ This data enables the project maintainers to estimate how many NetBox deployment
!!! tip "Dynamic Configuration Parameter"
Default: 90
Default: `90`
The number of days to retain logged changes (object creations, updates, and deletions). Set this to `0` to retain
changes in the database indefinitely.
@@ -79,7 +79,7 @@ changes in the database indefinitely.
## CHANGELOG_SKIP_EMPTY_CHANGES
Default: True
Default: `True`
If enabled, a change log record will not be created when an object is updated without any changes to its existing field values.
@@ -100,7 +100,7 @@ The maximum size (in bytes) of an incoming HTTP request (i.e. `GET` or `POST` da
!!! tip "Dynamic Configuration Parameter"
Default: True
Default: `True`
By default, NetBox will prevent the creation of duplicate prefixes and IP addresses in the global table (that is, those which are not assigned to any VRF). This validation can be disabled by setting `ENFORCE_GLOBAL_UNIQUE` to False.
@@ -128,7 +128,7 @@ The maximum amount (in bytes) of uploaded data that will be held in memory befor
!!! tip "Dynamic Configuration Parameter"
Default: 90
Default: `90`
The number of days to retain job results (scripts and reports). Set this to `0` to retain job results in the database indefinitely.
@@ -141,7 +141,7 @@ The number of days to retain job results (scripts and reports). Set this to `0`
!!! tip "Dynamic Configuration Parameter"
Default: False
Default: `False`
Setting this to True will display a "maintenance mode" banner at the top of every page. Additionally, NetBox will no longer update a user's "last active" time upon login. This is to allow new logins when the database is in a read-only state. Recording of login times will resume when maintenance mode is disabled.
@@ -161,7 +161,7 @@ This specifies the URL to use when presenting a map of a physical location by st
!!! tip "Dynamic Configuration Parameter"
Default: 1000
Default: `1000`
A web user or API consumer can request an arbitrary number of objects by appending the "limit" parameter to the URL (e.g. `?limit=1000`). This parameter defines the maximum acceptable limit. Setting this to `0` or `None` will allow a client to retrieve _all_ matching objects at once with no limit by specifying `?limit=0`.
@@ -169,7 +169,7 @@ A web user or API consumer can request an arbitrary number of objects by appendi
## METRICS_ENABLED
Default: False
Default: `False`
Toggle the availability Prometheus-compatible metrics at `/metrics`. See the [Prometheus Metrics](../integrations/prometheus-metrics.md) documentation for more details.
@@ -179,7 +179,7 @@ Toggle the availability Prometheus-compatible metrics at `/metrics`. See the [Pr
!!! tip "Dynamic Configuration Parameter"
Default: False
Default: `False`
When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to True to prefer IPv4 instead.
@@ -203,7 +203,7 @@ If no queue is defined the queue named `default` will be used.
## RELEASE_CHECK_URL
Default: None (disabled)
Default: `None` (disabled)
This parameter defines the URL of the repository that will be checked for new NetBox releases. When a new release is detected, a message will be displayed to administrative users on the home page. This can be set to the official repository (`'https://api.github.com/repos/netbox-community/netbox/releases'`) or a custom fork. Set this to `None` to disable automatic update checks.
@@ -233,3 +233,15 @@ This parameter controls how frequently a failed job is retried, up to the maximu
Default: `0` (retries disabled)
The maximum number of times a background task will be retried before being marked as failed.
## DISK_BASE_UNIT
Default: `1000`
The base unit for disk sizes. Set this to `1024` to use binary prefixes (MiB, GiB, etc.) instead of decimal prefixes (MB, GB, etc.).
## RAM_BASE_UNIT
Default: `1000`
The base unit for RAM sizes. Set this to `1024` to use binary prefixes (MiB, GiB, etc.) instead of decimal prefixes (MB, GB, etc.).

View File

@@ -2,7 +2,7 @@
## PLUGINS
Default: Empty
Default: `[]`
A list of installed [NetBox plugins](../plugins/index.md) to enable. Plugins will not take effect unless they are listed here.
@@ -13,7 +13,7 @@ A list of installed [NetBox plugins](../plugins/index.md) to enable. Plugins wil
## PLUGINS_CONFIG
Default: Empty
Default: `[]`
This parameter holds configuration settings for individual NetBox plugins. It is defined as a dictionary, with each key using the name of an installed plugin. The specific parameters supported are unique to each plugin: Reference the plugin's documentation to determine the supported parameters. An example configuration is shown below:

View File

@@ -2,7 +2,7 @@
## ALLOW_TOKEN_RETRIEVAL
Default: True
Default: `True`
If disabled, the values of API tokens will not be displayed after each token's initial creation. A user **must** record the value of a token prior to its creation, or it will be lost. Note that this affects _all_ users, regardless of assigned permissions.
@@ -47,7 +47,7 @@ Although it is not recommended, the default validation rules can be disabled by
## CORS_ORIGIN_ALLOW_ALL
Default: False
Default: `False`
If True, cross-origin resource sharing (CORS) requests will be accepted from all origins. If False, a whitelist will be used (see below).
@@ -79,7 +79,7 @@ The name of the cookie to use for the cross-site request forgery (CSRF) authenti
## CSRF_COOKIE_SECURE
Default: False
Default: `False`
If true, the cookie employed for cross-site request forgery (CSRF) protection will be marked as secure, meaning that it can only be sent across an HTTPS connection.
@@ -159,7 +159,7 @@ EXEMPT_VIEW_PERMISSIONS = ['*']
## LOGIN_PERSISTENCE
Default: False
Default: `False`
If true, the lifetime of a user's authentication session will be automatically reset upon each valid request. For example, if [`LOGIN_TIMEOUT`](#login_timeout) is configured to 14 days (the default), and a user whose session is due to expire in five days makes a NetBox request (with a valid session cookie), the session's lifetime will be reset to 14 days.
@@ -169,7 +169,7 @@ Note that enabling this setting causes NetBox to update a user's session in the
## LOGIN_REQUIRED
Default: True
Default: `True`
When enabled, only authenticated users are permitted to access any part of NetBox. Disabling this will allow unauthenticated users to access most areas of NetBox (but not make any changes).
@@ -180,7 +180,7 @@ When enabled, only authenticated users are permitted to access any part of NetBo
## LOGIN_TIMEOUT
Default: 1209600 seconds (14 days)
Default: `1209600` seconds (14 days)
The lifetime (in seconds) of the authentication cookie issued to a NetBox user upon login.
@@ -196,7 +196,7 @@ The view name or URL to which a user is redirected after logging out.
## SECURE_HSTS_INCLUDE_SUBDOMAINS
Default: False
Default: `False`
If true, the `includeSubDomains` directive will be included in the HTTP Strict Transport Security (HSTS) header. This directive instructs the browser to apply the HSTS policy to all subdomains of the current domain.
@@ -204,7 +204,7 @@ If true, the `includeSubDomains` directive will be included in the HTTP Strict T
## SECURE_HSTS_PRELOAD
Default: False
Default: `False`
If true, the `preload` directive will be included in the HTTP Strict Transport Security (HSTS) header. This directive instructs the browser to preload the site in HTTPS. Browsers that use the HSTS preload list will force the site to be accessed via HTTPS even if the user types HTTP in the address bar.
@@ -212,7 +212,7 @@ If true, the `preload` directive will be included in the HTTP Strict Transport S
## SECURE_HSTS_SECONDS
Default: 0
Default: `0`
If set to a non-zero integer value, the SecurityMiddleware sets the HTTP Strict Transport Security (HSTS) header on all responses that do not already have it. This will instruct the browser that the website must be accessed via HTTPS, blocking any HTTP request.
@@ -220,7 +220,7 @@ If set to a non-zero integer value, the SecurityMiddleware sets the HTTP Strict
## SECURE_SSL_REDIRECT
Default: False
Default: `False`
If true, all non-HTTPS requests will be automatically redirected to use HTTPS.
@@ -239,7 +239,7 @@ The name used for the session cookie. See the [Django documentation](https://doc
## SESSION_COOKIE_SECURE
Default: False
Default: `False`
If true, the cookie employed for session authentication will be marked as secure, meaning that it can only be sent across an HTTPS connection.
@@ -247,6 +247,6 @@ If true, the cookie employed for session authentication will be marked as secure
## SESSION_FILE_PATH
Default: None
Default: `None`
HTTP session data is used to track authenticated users when they access NetBox. By default, NetBox stores session data in its PostgreSQL database. However, this inhibits authentication to a standby instance of NetBox without write access to the database. Alternatively, a local file path may be specified here and NetBox will store session data as files instead of using the database. Note that the NetBox system user must have read and write permissions to this path.

View File

@@ -2,7 +2,7 @@
## BASE_PATH
Default: None
Default: `None`
The base URL path to use when accessing NetBox. Do not include the scheme or domain name. For example, if installed at https://example.com/netbox/, set:
@@ -64,7 +64,7 @@ Email is sent from NetBox only for critical events or if configured for [logging
## HTTP_PROXIES
Default: None
Default: `None`
A dictionary of HTTP proxies to use for outbound requests originating from NetBox (e.g. when sending webhook requests). Proxies should be specified by schema (HTTP and HTTPS) as per the [Python requests library documentation](https://requests.readthedocs.io/en/latest/user/advanced/#proxies). For example:
@@ -89,7 +89,7 @@ addresses (and [`DEBUG`](./development.md#debug) is true).
## ISOLATED_DEPLOYMENT
Default: False
Default: `False`
Set this configuration parameter to True for NetBox deployments which do not have Internet access. This will disable miscellaneous functionality which depends on access to the Internet.
@@ -206,7 +206,7 @@ If `STORAGE_BACKEND` is not defined, this setting will be ignored.
## TIME_ZONE
Default: UTC
Default: `"UTC"`
The time zone NetBox will use when dealing with dates and times. It is recommended to use UTC time unless you have a specific need to use a local time zone. Please see the [list of available time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones).
@@ -214,6 +214,6 @@ The time zone NetBox will use when dealing with dates and times. It is recommend
## TRANSLATION_ENABLED
Default: True
Default: `True`
Enables language translation for the user interface. (This parameter maps to Django's [USE_I18N](https://docs.djangoproject.com/en/stable/ref/settings/#std-setting-USE_I18N) setting.)

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

@@ -25,6 +25,7 @@ Height: {{ rack.u_height }}U
To access custom fields of an object within a template, use the `cf` attribute. For example, `{{ obj.cf.color }}` will return the value (if any) for a custom field named `color` on `obj`.
If you need to use the config context data in an export template, you'll should use the function `get_config_context` to get all the config context data. For example:
```
{% for server in queryset %}
{% set data = server.get_config_context() %}

View File

@@ -1,12 +1,12 @@
# Release Checklist
This documentation describes the process of packaging and publishing a new NetBox release. There are three types of release:
This documentation describes the process of packaging and publishing a new NetBox release. There are three types of releases:
* Major release (e.g. v3.7.8 to v4.0.0)
* Minor release (e.g. v4.0.10 to v4.1.0)
* Patch release (e.g. v4.1.0 to v4.1.1)
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.
While major releases generally introduce some very substantial changes 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.
@@ -31,6 +31,29 @@ Close the [release milestone](https://github.com/netbox-community/netbox/milesto
Check that a link to the release notes for the new version is present in the navigation menu (defined in `mkdocs.yml`), and that a summary of all major new features has been added to `docs/index.md`.
### Update the Dependency Requirements Matrix
For every minor release, update the dependency requirements matrix in `docs/installation/upgrading.md` ("All versions") to reflect the supported versions of Python, PostgreSQL, and Redis:
1. Add a new row with the supported dependency versions.
2. Include a documentation link using the release tag format: `https://github.com/netbox-community/netbox/blob/v4.2.0/docs/installation/index.md`
3. Bold any version changes for clarity.
**Example Update:**
```markdown
| NetBox Version | Python min | Python max | PostgreSQL min | Redis min | Documentation |
|:--------------:|:----------:|:----------:|:--------------:|:---------:|:-------------------------------------------------------------------------------------------------:|
| 4.2 | 3.10 | 3.12 | **13** | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.2.0/docs/installation/index.md) |
```
### Update System Requirements
If a new Django release is adopted or other major dependencies (Python, PostgreSQL, Redis) change:
* Update the installation guide (`docs/installation/index.md`) with the new minimum versions.
* Update the upgrade guide (`docs/installation/upgrading.md`) for the current version accordingly.
### Manually Perform a New Install
Start the documentation server and navigate to the current version of the installation docs:
@@ -39,15 +62,25 @@ Start the documentation server and navigate to the current version of the instal
mkdocs serve
```
Follow these instructions to perform a new installation of NetBox in a temporary environment. This process must not be automated: The goal of this step is to catch any errors or omissions in the documentation, and ensure that it is kept up-to-date for each release. Make any necessary changes to the documentation before proceeding with the release.
Follow these instructions to perform a new installation of NetBox in a temporary environment. This process must not be automated: The goal of this step is to catch any errors or omissions in the documentation and ensure that it is kept up to date for each release. Make any necessary changes to the documentation before proceeding with the release.
### Test Upgrade Paths
Upgrading from a previous version typically involves database migrations, which must work without errors. Supported upgrade paths include from one minor version to another within the same major version (i.e. 4.0 to 4.1), as well as from the latest patch version of the previous minor version (i.e. 3.7 to 4.0 or to 4.1). Prior to release, test all these supported paths by loading demo data from the source version and performing a `./manage.py migrate`.
Upgrading from a previous version typically involves database migrations, which must work without errors.
Test the following supported upgrade paths:
- From one minor version to another within the same major version (e.g. 4.0 to 4.1).
- From the latest patch version of the previous minor version (e.g. 3.7 to 4.0 or 4.1).
Prior to release, test all these supported paths by loading demo data from the source version and performing:
```no-highlight
./manage.py migrate
```
### Merge the `feature` Branch
Submit a pull request to merge the `feature` branch into the `main` branch in preparation for its release. Once it has been merged, continue with the section for patch releases below.
Submit a pull request to merge the `feature` branch into the `main` branch in preparation for its release. Once it has been merged, continue with the section for the patch releases below.
### Rebuild Demo Data (After Release)
@@ -59,7 +92,7 @@ After the release of a new minor version, generate a new demo data snapshot comp
### Create a Release Branch
Begin by creating a new branch (based off of `main`) to effect the release. This will comprise the changes listed below.
Begin by creating a new branch (based on `main`) to effect the release. This will comprise the changes listed below.
```
git checkout main
@@ -117,7 +150,7 @@ This will automatically update the schema file at `contrib/generated_schema.json
Updated language translations should be pulled from [Transifex](https://app.transifex.com/netbox-community/netbox/dashboard/) and re-compiled for each new release. First, retrieve any updated translation files using the Transifex CLI client:
```no-highlight
tx pull
tx pull --force
```
Then, compile these portable (`.po`) files for use in the application:
@@ -136,7 +169,7 @@ Then, compile these portable (`.po`) files for use in the application:
* 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.
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 keywords or phrases (such as exception names) that can be easily searched.
### Submit a Pull Request

View File

@@ -33,10 +33,10 @@ To download translated strings automatically, you'll need to:
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
TX_TOKEN=$TOKEN tx pull --force
```
This will download all portable (`.po`) translation files from Transifex, updating them locally as needed.
This will download all portable (`.po`) translation files from Transifex, updating them locally as needed. (The `--force` argument instructs the client to disregard the timestamps of local translation files.)
Once retrieved, the updated strings need to be compiled into new `.mo` files so they can be used by the application. Run Django's [`compilemessages`](https://docs.djangoproject.com/en/stable/ref/django-admin/#django-admin-compilemessages) management command to compile them:

View File

@@ -246,7 +246,7 @@ Once NetBox has been configured, we're ready to proceed with the actual installa
* Create a Python virtual environment
* Installs all required Python packages
* Run database schema migrations
* Run database schema migrations (skip with `--readonly`)
* Builds the documentation locally (for offline use)
* Aggregate static resource files on disk
@@ -266,6 +266,9 @@ sudo PYTHON=/usr/bin/python3.10 /opt/netbox/upgrade.sh
!!! note
Upon completion, the upgrade script may warn that no existing virtual environment was detected. As this is a new installation, this warning can be safely ignored.
!!! note
To run the script on a node connected to a database in read-only mode, include the `--readonly` parameter. This will skip the application of any database migrations.
## Create a Super User
NetBox does not come with any predefined user accounts. You'll need to create a super user (administrative account) to be able to log into NetBox. First, enter the Python virtual environment created by the upgrade script:

View File

@@ -5,8 +5,6 @@
The installation instructions provided here have been tested to work on Ubuntu 22.04 and CentOS 8.3. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
<iframe width="560" height="315" src="https://www.youtube.com/embed/_y5JRiW_PLM" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
The following sections detail how to set up a new instance of NetBox:
1. [PostgreSQL database](1-postgresql.md)

View File

@@ -17,11 +17,52 @@ Prior to upgrading your NetBox instance, be sure to carefully review all [releas
NetBox requires the following dependencies:
| Dependency | Supported Versions |
|------------|--------------------|
| Python | 3.10, 3.11, 3.12 |
| PostgreSQL | 13+ |
| Redis | 4.0+ |
=== "Current Version"
| Dependency | Supported Versions |
|------------|--------------------|
| Python | 3.10, 3.11, 3.12 |
| PostgreSQL | 13+ |
| Redis | 4.0+ |
=== "All Versions"
| NetBox Version | Python min | Python max | PostgreSQL min | Redis min | Documentation |
|:--------------:|:----------:|:----------:|:--------------:|:---------:|:-------------------------------------------------------------------------------------------------:|
| 4.2 | 3.10 | 3.12 | **13** | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.2.0/docs/installation/index.md) |
| 4.1 | 3.10 | 3.12 | 12 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.1.0/docs/installation/index.md) |
| 4.0 | **3.10** | **3.12** | 12 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.0.0/docs/installation/index.md) |
| 3.7 | 3.8 | 3.11 | 12 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.7.0/docs/installation/index.md) |
| 3.6 | 3.8 | **3.11** | **12** | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.6.0/docs/installation/index.md) |
| 3.5 | 3.8 | 3.10 | 11 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.5.0/docs/installation/index.md) |
| 3.4 | 3.8 | 3.10 | **11** | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.4.0/docs/installation/index.md) |
| 3.3 | 3.8 | 3.10 | 10 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.3.0/docs/installation/index.md) |
| 3.2 | **3.8** | **3.10** | 10 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.2.0/docs/installation/index.md) |
| 3.1 | 3.7 | 3.9 | **10** | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.1.0/docs/installation/index.md) |
| 3.0 | **3.7** | 3.9 | 9.6 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.0.0/docs/installation/index.md) |
| 2.11 | 3.6 | **3.9** | 9.6 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v2.11.0/docs/installation/index.md) |
| 2.10 | 3.6 | 3.8 | **9.6** | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v2.10.0/docs/installation/index.md) |
| 2.9 | 3.6 | 3.8 | 9.5 | **4.0** | [Link](https://github.com/netbox-community/netbox/blob/v2.9.0/docs/installation/index.md) |
| 2.8 | **3.6** | **3.8** | **9.5** | **3.4** | [Link](https://github.com/netbox-community/netbox/blob/v2.8.0/docs/installation/index.md) |
| 2.7 | 3.5 | 3.7 | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.7.0/docs/installation/index.md) |
| 2.6 | 3.5 | 3.7 | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.6.0/docs/installation/index.md) |
| 2.5 | **3.5** | 3.7 | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.5.0/docs/installation/index.md) |
| 2.4 | **3.4** | **3.7** | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.4.0/docs/installation/index.md) |
| 2.3 | 2.7 | 3.6 | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.3.0/docs/installation/postgresql.md) |
| 2.2 | 2.7 | 3.6 | **9.4** | - | [Link](https://github.com/netbox-community/netbox/blob/v2.2.0/docs/installation/postgresql.md) |
| 2.1 | 2.7 | 3.6 | 9.3 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.1.0/docs/installation/postgresql.md) |
| 2.0 | 2.7 | **3.6** | **9.3** | - | [Link](https://github.com/netbox-community/netbox/blob/v2.0.0/docs/installation/postgresql.md) |
| 1.9 | 2.7 | 3.5 | 9.2 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.9.0-r1/docs/installation/postgresql.md) |
| 1.8 | 2.7 | 3.5 | 9.2 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.8.0/docs/installation/postgresql.md) |
| 1.7 | 2.7 | 3.5 | 9.2 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.7.0/docs/installation/postgresql.md) |
| 1.6 | 2.7 | 3.5 | 9.2 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.6.0/docs/installation/postgresql.md) |
| 1.5 | 2.7 | 3.5 | **9.2** | - | [Link](https://github.com/netbox-community/netbox/blob/v1.5.0/docs/installation/postgresql.md) |
| 1.4 | 2.7 | 3.5 | 9.1 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.4.0/docs/installation/postgresql.md) |
| 1.3 | 2.7 | 3.5 | 9.1 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.3.0/docs/installation/postgresql.md) |
| 1.2 | 2.7 | 3.5 | 9.1 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.2.0/docs/installation/postgresql.md) |
| 1.1 | 2.7 | 3.5 | 9.1 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.1.0/docs/getting-started.md) |
| 1.0 | 2.7 | 3.5 | 9.1 | - | [Link](https://github.com/netbox-community/netbox/blob/1.0.0/docs/getting-started.md) |
## 3. Install the Latest Release
@@ -83,17 +124,19 @@ sudo cp /opt/netbox-$OLDVER/gunicorn.py /opt/netbox/
### Option B: Check Out a Git Release
This guide assumes that NetBox is installed at `/opt/netbox`. First, determine the latest release either by visiting our [releases page](https://github.com/netbox-community/netbox/releases) or by running the following `git` commands:
This guide assumes that NetBox is installed at `/opt/netbox`. First, determine the latest release either by visiting our [releases page](https://github.com/netbox-community/netbox/releases) or by running the following command:
```
sudo git fetch --tags
git describe --tags $(git rev-list --tags --max-count=1)
git ls-remote --tags https://github.com/netbox-community/netbox.git \
| grep -o 'refs/tags/v[0-9]*\.[0-9]*\.[0-9]*$' \
| tail -n 1 \
| sed 's|refs/tags/||'
```
Check out the desired release by specifying its tag:
Check out the desired release by specifying its tag. For example:
```
sudo git checkout v4.2.0
sudo git checkout v4.2.7
```
## 4. Run the Upgrade Script
@@ -111,6 +154,9 @@ sudo ./upgrade.sh
sudo PYTHON=/usr/bin/python3.10 ./upgrade.sh
```
!!! note
To run the script on a node connected to a database in read-only mode, include the `--readonly` parameter. This will skip the application of any database migrations.
This script performs the following actions:
* Destroys and rebuilds the Python virtual environment

View File

@@ -60,6 +60,7 @@ query {
}
}
```
In addition, filtering can be done on list of related objects as shown in the following query:
```
@@ -98,8 +99,8 @@ Certain queries can return multiple types of objects, for example cable terminat
}
}
}
```
The field "class_type" is an easy way to distinguish what type of object it is when viewing the returned data, or when filtering. It contains the class name, for example "CircuitTermination" or "ConsoleServerPort".
## Authentication

View File

@@ -217,26 +217,34 @@ If we wanted to assign this IP address to a virtual machine interface instead, w
### Brief Format
Most API endpoints support an optional "brief" format, which returns only a minimal representation of each object in the response. This is useful when you need only a list of available objects without any related data, such as when populating a drop-down list in a form. As an example, the default (complete) format of an IP address looks like this:
Most API endpoints support an optional "brief" format, which returns only a minimal representation of each object in the response. This is useful when you need only a list of available objects without any related data, such as when populating a drop-down list in a form. As an example, the default (complete) format of a prefix looks like this:
```
```no-highlight
GET /api/ipam/prefixes/13980/
```
```json
{
"id": 13980,
"url": "http://netbox/api/ipam/prefixes/13980/",
"display_url": "http://netbox/api/ipam/prefixes/13980/",
"display": "192.0.2.0/24",
"family": {
"value": 4,
"label": "IPv4"
},
"prefix": "192.0.2.0/24",
"site": {
"id": 3,
"url": "http://netbox/api/dcim/sites/17/",
"name": "Site 23A",
"slug": "site-23a"
},
"vrf": null,
"scope_type": "dcim.site",
"scope_id": 3,
"scope": {
"id": 3,
"url": "http://netbox/api/dcim/sites/3/",
"display": "Site 23A",
"name": "Site 23A",
"slug": "site-23a",
"description": ""
},
"tenant": null,
"vlan": null,
"status": {
@@ -250,24 +258,36 @@ GET /api/ipam/prefixes/13980/
"slug": "staging"
},
"is_pool": false,
"mark_utilized": false,
"description": "Example prefix",
"comments": "",
"tags": [],
"custom_fields": {},
"created": "2018-12-10",
"last_updated": "2019-03-01T20:02:46.173540Z"
"created": "2025-03-01T20:01:23.458302Z",
"last_updated": "2025-03-01T20:02:46.173540Z",
"children": 0,
"_depth": 0
}
```
The brief format is much more terse:
```
```no-highlight
GET /api/ipam/prefixes/13980/?brief=1
```
```json
{
"id": 13980,
"url": "http://netbox/api/ipam/prefixes/13980/",
"family": 4,
"prefix": "10.40.3.0/24"
"display": "192.0.2.0/24",
"family": {
"value": 4,
"label": "IPv4"
},
"prefix": "192.0.2.0/24",
"description": "Example prefix",
"_depth": 0
}
```
@@ -400,25 +420,31 @@ curl -s -X POST \
-H "Authorization: Token $TOKEN" \
-H "Content-Type: application/json" \
http://netbox/api/ipam/prefixes/ \
--data '{"prefix": "192.0.2.0/24", "site": 6}' | jq '.'
--data '{"prefix": "192.0.2.0/24", "scope_type": "dcim.site", "scope_id": 6}' | jq '.'
```
```json
{
"id": 18691,
"url": "http://netbox/api/ipam/prefixes/18691/",
"display_url": "http://netbox/api/ipam/prefixes/18691/",
"display": "192.0.2.0/24",
"family": {
"value": 4,
"label": "IPv4"
},
"prefix": "192.0.2.0/24",
"site": {
"vrf": null,
"scope_type": "dcim.site",
"scope_id": 6,
"scope": {
"id": 6,
"url": "http://netbox/api/dcim/sites/6/",
"display": "US-East 4",
"name": "US-East 4",
"slug": "us-east-4"
"slug": "us-east-4",
"description": ""
},
"vrf": null,
"tenant": null,
"vlan": null,
"status": {
@@ -427,11 +453,15 @@ http://netbox/api/ipam/prefixes/ \
},
"role": null,
"is_pool": false,
"mark_utilized": false,
"description": "",
"comments": "",
"tags": [],
"custom_fields": {},
"created": "2020-08-04",
"last_updated": "2020-08-04T20:08:39.007125Z"
"created": "2025-04-29T15:44:47.597092Z",
"last_updated": "2025-04-29T15:44:47.597092Z",
"children": 0,
"_depth": 0
}
```
@@ -490,18 +520,24 @@ http://netbox/api/ipam/prefixes/18691/ \
{
"id": 18691,
"url": "http://netbox/api/ipam/prefixes/18691/",
"display_url": "http://netbox/api/ipam/prefixes/18691/",
"display": "192.0.2.0/24",
"family": {
"value": 4,
"label": "IPv4"
},
"prefix": "192.0.2.0/24",
"site": {
"vrf": null,
"scope_type": "dcim.site",
"scope_id": 6,
"scope": {
"id": 6,
"url": "http://netbox/api/dcim/sites/6/",
"display": "US-East 4",
"name": "US-East 4",
"slug": "us-east-4"
"slug": "us-east-4",
"description": ""
},
"vrf": null,
"tenant": null,
"vlan": null,
"status": {
@@ -510,11 +546,15 @@ http://netbox/api/ipam/prefixes/18691/ \
},
"role": null,
"is_pool": false,
"mark_utilized": false,
"description": "",
"comments": "",
"tags": [],
"custom_fields": {},
"created": "2020-08-04",
"last_updated": "2020-08-04T20:14:55.709430Z"
"created": "2025-04-29T15:44:47.597092Z",
"last_updated": "2025-04-29T15:49:40.689109Z",
"children": 0,
"_depth": 0
}
```
@@ -568,6 +608,23 @@ http://netbox/api/dcim/sites/ \
!!! note
The bulk deletion of objects is an all-or-none operation, meaning that if NetBox fails to delete any of the specified objects (e.g. due a dependency by a related object), the entire operation will be aborted and none of the objects will be deleted.
## Uploading Files
As JSON does not support the inclusion of binary data, files cannot be uploaded using JSON-formatted API requests. Instead, we can use form data encoding to attach a local file.
For example, we can upload an image attachment using the `curl` command shown below. Note that the `@` signifies a local file on disk to be uploaded.
```no-highlight
curl -X POST \
-H "Authorization: Token $TOKEN" \
-H "Accept: application/json; indent=4" \
-F "object_type=dcim.site" \
-F "object_id=2" \
-F "name=attachment1.png" \
-F "image=@local_file.png" \
http://netbox/api/extras/image-attachments/
```
## Authentication
The NetBox REST API primarily employs token-based authentication. For convenience, cookie-based authentication can also be used when navigating the browsable API.
@@ -653,6 +710,7 @@ Note that we are _not_ passing an existing REST API token with this request. If
{
"id": 6,
"url": "https://netbox/api/users/tokens/6/",
"display_url": "https://netbox/api/users/tokens/6/",
"display": "**********************************3c9cb9",
"user": {
"id": 2,

View File

@@ -204,6 +204,7 @@ To ease development, it is recommended to go ahead and install the plugin at thi
```no-highlight
$ pip install -e .
```
More information on editable builds can be found at [Editable installs for pyproject.toml ](https://peps.python.org/pep-0660/).
## Configure NetBox

View File

@@ -150,5 +150,5 @@ The [NAPALM automation](https://github.com/napalm-automation/napalm) library pro
* Modified the interface serializer to include three discrete fields relating to connections: `is_connected` (boolean), `interface_connection`, and `circuit_termination`
* Added two new fields to the inventory item serializer: `asset_tag` and `description`
* Added "wireless" to interface type filter (in addition to physical, virtual, and LAG)
* Added a new endpoint at /api/ipam/prefixes/<pk>/available-ips/ to retrieve or create available IPs within a prefix
* Added a new endpoint at /api/ipam/prefixes/<pk\>/available-ips/ to retrieve or create available IPs within a prefix
* Extended `parent_device` on DeviceSerializer to include the `url` and `display_name` of the parent Device, and the `url` of the DeviceBay

View File

@@ -1,5 +1,166 @@
# NetBox v4.2
## v4.2.9 (2025-04-30)
### Enhancements
* [#17151](https://github.com/netbox-community/netbox/issues/17151) - Display circuit type with background color in circuits list
* [#17319](https://github.com/netbox-community/netbox/issues/17319) - Improve layout of component template edit forms
* [#17405](https://github.com/netbox-community/netbox/issues/17405) - Display plugin icons in plugins list
* [#18215](https://github.com/netbox-community/netbox/issues/18215) - Link to script results list from script history
* [#18334](https://github.com/netbox-community/netbox/issues/18334) - Add region, site group, site, location, and rack filters for modules
* [#18982](https://github.com/netbox-community/netbox/issues/18982) - Reference rack as related object in changelog records for rack reservations
* [#18989](https://github.com/netbox-community/netbox/issues/18989) - List virtual circuits under provider view
* [#19110](https://github.com/netbox-community/netbox/issues/19110) - Enable filtering devices and virtual machines by primary IP address
* [#19358](https://github.com/netbox-community/netbox/issues/19358) - Move release info from footer to the navigation menu
### Bug Fixes
* [#15739](https://github.com/netbox-community/netbox/issues/15739) - Account for parallel cables when calculating total path length
* [#15971](https://github.com/netbox-community/netbox/issues/15971) - Preserve "none" selection in filter form fields
* [#16238](https://github.com/netbox-community/netbox/issues/16238) - Fix styling for white, gray, and black custom link buttons
* [#17613](https://github.com/netbox-community/netbox/issues/17613) - Fix layout of object view content on mobile
* [#17676](https://github.com/netbox-community/netbox/issues/17676) - Fix support for module bay creation when bulk importing module types
* [#18706](https://github.com/netbox-community/netbox/issues/18706) - Fix validation for VLANs assigned to both a group and a site
* [#18717](https://github.com/netbox-community/netbox/issues/18717) - Ensure change logs populated for many-to-one changes
* [#19117](https://github.com/netbox-community/netbox/issues/19117) - Avoid `AttributeError` exception when bulk import objects which have a multi-object custom field with a default value
* [#19204](https://github.com/netbox-community/netbox/issues/19204) - Improve JSON serialization support for data returned by a custom script
* [#19217](https://github.com/netbox-community/netbox/issues/19217) - Ensure static assets for the debug toolbar are installed even if `DEBUG` is false
* [#19228](https://github.com/netbox-community/netbox/issues/19228) - Fix ordering of custom scripts to avoid `NoReverseMatch` exception
* [#19229](https://github.com/netbox-community/netbox/issues/19229) - Fix `ValueError` exception when attempting to nullify interface mode when a VLAN is assigned
* [#19275](https://github.com/netbox-community/netbox/issues/19275) - `type` field should not be required when bulk editing interfaces
* [#19279](https://github.com/netbox-community/netbox/issues/19279) - `status` field should not be required when bulk editing inventory items
* [#19281](https://github.com/netbox-community/netbox/issues/19281) - Fix form validation failure when attempting to create a service from a service template
* [#19320](https://github.com/netbox-community/netbox/issues/19320) - Include Q-in-Q VLAN (if any) in VM interface details
* [#19322](https://github.com/netbox-community/netbox/issues/19322) - Correct URL paths for bulk import views
* [#19346](https://github.com/netbox-community/netbox/issues/19346) - Ensure all redirect URLs are validated before use
---
## v4.2.8 (2025-04-22)
### Enhancements
* [#17136](https://github.com/netbox-community/netbox/issues/17136) - Introduce the `--readonly` flag on upgrade script
* [#17908](https://github.com/netbox-community/netbox/issues/17908) - Add trace buttons to terminations under cable view
* [#18879](https://github.com/netbox-community/netbox/issues/18879) - Enable filtering prefixes by group of assigned VLAN
* [#18976](https://github.com/netbox-community/netbox/issues/18976) - Include FHRP group name on interface lists
* [#18978](https://github.com/netbox-community/netbox/issues/18978) - Add 802.1Q mode to interface filter form
* [#19038](https://github.com/netbox-community/netbox/issues/19038) - Show count of related VLAN groups under cluster view
* [#19040](https://github.com/netbox-community/netbox/issues/19040) - Add "copy to clipboard" button for rendered config
* [#19056](https://github.com/netbox-community/netbox/issues/19056) - Enable filtering devices by location slug
* [#19196](https://github.com/netbox-community/netbox/issues/19196) - Add filtering by VLAN translation policy to interface filter forms
### Bug Fixes
* [#18500](https://github.com/netbox-community/netbox/issues/18500) - `prepare_cloned_fields()` should validate cloning support on model
* [#18669](https://github.com/netbox-community/netbox/issues/18669) - Ensure default custom field values are respected when creating objects via the REST API
* [#18881](https://github.com/netbox-community/netbox/issues/18881) - Include missing related object counts under certain views
* [#18955](https://github.com/netbox-community/netbox/issues/18955) - Omit "clear" button on required choice fields
* [#18959](https://github.com/netbox-community/netbox/issues/18959) - Preserve ordering of terminations in cable traces
* [#18961](https://github.com/netbox-community/netbox/issues/18961) - Virtual chassis form should exclude members of other VCs when adding members
* [#19166](https://github.com/netbox-community/netbox/issues/19166) - Fix custom field choices bulk import support for `base_choices`
* [#19189](https://github.com/netbox-community/netbox/issues/19189) - The `load_yaml()` convenience method on BaseScript should use SafeLoader
* [#19195](https://github.com/netbox-community/netbox/issues/19195) - Language cookie should respect `SESSION_COOKIE_SECURE` value
* [#19230](https://github.com/netbox-community/netbox/issues/19230) - Allow label reuse when creating multiple components from a pattern
* [#19268](https://github.com/netbox-community/netbox/issues/19268) - Restore editing conflict protection for several object forms
---
## v4.2.7 (2025-04-10)
### Enhancements
* [#16144](https://github.com/netbox-community/netbox/issues/16144) - Add support for plugin models to GetReturnURLMixin
* [#18138](https://github.com/netbox-community/netbox/issues/18138) - Enable filtering of ObjectVar and MultiObjectVar input selections for custom fields
* [#18656](https://github.com/netbox-community/netbox/issues/18656) - Enable FHRP group assignment when bulk importing IP addresses
* [#18980](https://github.com/netbox-community/netbox/issues/18980) - Optimize bulk updates of custom field values when custom fields are added/removed
* [#19018](https://github.com/netbox-community/netbox/issues/19018) - Add MoCA interface type
### Bug Fixes
* [#18553](https://github.com/netbox-community/netbox/issues/18553) - Avoid clearing site of assigned virtual machines when editing a cluster
* [#18738](https://github.com/netbox-community/netbox/issues/18738) - Respect declared ordering of custom scripts within a module
* [#18895](https://github.com/netbox-community/netbox/issues/18895) - Fix GraphQL support for interfaces which terminate virtual circuits
* [#18904](https://github.com/netbox-community/netbox/issues/18904) - Add missing tags column to config contexts table
* [#18964](https://github.com/netbox-community/netbox/issues/18964) - Fix "select all" behavior on object lists
* [#18965](https://github.com/netbox-community/netbox/issues/18965) - "Run script" button should respect default commit toggle for custom scripts
* [#18991](https://github.com/netbox-community/netbox/issues/18991) - Fix cable path tracing for pass-through ports in REST API
* [#18999](https://github.com/netbox-community/netbox/issues/18999) - Fix filtering of inventory items with no manufacturer in GraphQL API
* [#19021](https://github.com/netbox-community/netbox/issues/19021) - Preserve JSONField stylign when `help_text` is passed
* [#19023](https://github.com/netbox-community/netbox/issues/19023) - `get_field_value()` should honor null values on bound form fields
* [#19030](https://github.com/netbox-community/netbox/issues/19030) - Prevent pagination buttons from overlapping bulk action buttons on object lists
* [#19041](https://github.com/netbox-community/netbox/issues/19041) - Fix `IndexError` exception when creating multiple front ports with a label
* [#19092](https://github.com/netbox-community/netbox/issues/19092) - Fix clearing of scope field when bulk editing prefixes
* [#19122](https://github.com/netbox-community/netbox/issues/19122) - Fix styling of server error page
---
## v4.2.6 (2025-03-21)
### Enhancements
* [#17503](https://github.com/netbox-community/netbox/issues/17503) - Add rack title above rack on rack detail view
* [#17686](https://github.com/netbox-community/netbox/issues/17686) - Add config option for disk space divisor
* [#18579](https://github.com/netbox-community/netbox/issues/18579) - Update filtersets and filter forms to include contact filters where missing
* [#18744](https://github.com/netbox-community/netbox/issues/18744) - Ensure contact link in tables is hyperlinked
* [#18816](https://github.com/netbox-community/netbox/issues/18816) - Add FC/UPC, FC/APC and FC/PC port types
* [#18880](https://github.com/netbox-community/netbox/issues/18880) - Delay enqueuing background tasks until DB transaction is committed to avoid race condition
* [#18939](https://github.com/netbox-community/netbox/issues/18939) - Support site group search for ASNs
### Bug Fixes
* [#18409](https://github.com/netbox-community/netbox/issues/18409) - Eliminate N+1 issue by adding generic prefetch operation to Interface API endpoint
* [#18557](https://github.com/netbox-community/netbox/issues/18557) - Update JSONField to enclose bare string values in quotes
* [#18582](https://github.com/netbox-community/netbox/issues/18582) - Fix prefix bulk import with associated VLAN and conflicting VLAN IDs
* [#18742](https://github.com/netbox-community/netbox/issues/18742) - Ensure location list and detail views show related VLAN group information
* [#18782](https://github.com/netbox-community/netbox/issues/18782) - Ensure misconfigured object list widgets on the dashboard now degrade gracefully
* [#18833](https://github.com/netbox-community/netbox/issues/18833) - Fix inventory item bulk edit to ensure that component name and type are both validated Ensure
* [#18838](https://github.com/netbox-community/netbox/issues/18838) - Ensure that local context data correctly rejects falsy values
* [#18845](https://github.com/netbox-community/netbox/issues/18845) - Restore default sort behavior of name column on devices list view
* [#18863](https://github.com/netbox-community/netbox/issues/18863) - Exempt MPTT-based models from ordering fix introduced in #18279
* [#18869](https://github.com/netbox-community/netbox/issues/18869) - Ensure numeric conversion helper always return a clean decimal value
* [#18872](https://github.com/netbox-community/netbox/issues/18872) - Ensure that `kind` is a required field when making journal entries
* [#18884](https://github.com/netbox-community/netbox/issues/18884) - Ensure tag deserialization is handled correctly
* [#18887](https://github.com/netbox-community/netbox/issues/18887) - Allow VM interface objects to be set on prefix object-type custom field
* [#18926](https://github.com/netbox-community/netbox/issues/18926) - Fix icon displayed for GitHub authentication on login page
* [#18928](https://github.com/netbox-community/netbox/issues/18928) - Support cascading deletions when cleaning up expired changelog records
* [#18933](https://github.com/netbox-community/netbox/issues/18933) - Allow filtering VLAN groups by associated site groups
* [#18944](https://github.com/netbox-community/netbox/issues/18944) - Ensure clearing "Widget type" field when adding widgets to dashboard does not cause a "ValueError: Unregistered widget class" error
* [#18949](https://github.com/netbox-community/netbox/issues/18949) - Add missing contacts property to GraphQL types where the associated model has a connection to a contact
---
## 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
* [#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

View File

@@ -28,12 +28,7 @@ plugins:
- mkdocstrings:
handlers:
python:
setup_commands:
- import os
- import django
- os.chdir('netbox/')
- os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings")
- django.setup()
paths: ["netbox"]
options:
heading_level: 3
members_order: source

View File

@@ -12,7 +12,7 @@ from django.shortcuts import get_object_or_404, redirect
from django.shortcuts import render, resolve_url
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.utils.http import url_has_allowed_host_and_scheme, urlencode
from django.utils.http import urlencode
from django.utils.translation import gettext_lazy as _
from django.views.decorators.debug import sensitive_post_parameters
from django.views.generic import View
@@ -28,6 +28,8 @@ from netbox.config import get_config
from netbox.views import generic
from users import forms, tables
from users.models import UserConfig
from utilities.request import safe_for_redirect
from utilities.string import remove_linebreaks
from utilities.views import register_model_view
@@ -123,12 +125,18 @@ class LoginView(View):
# Set the user's preferred language (if any)
if language := request.user.config.get('locale.language'):
response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language, max_age=request.session.get_expiry_age())
response.set_cookie(
key=settings.LANGUAGE_COOKIE_NAME,
value=language,
max_age=request.session.get_expiry_age(),
secure=settings.SESSION_COOKIE_SECURE,
)
return response
else:
logger.debug(f"Login form validation failed for username: {form['username'].value()}")
username = form['username'].value()
logger.debug(f"Login form validation failed for username: {remove_linebreaks(username)}")
return render(request, self.template_name, {
'form': form,
@@ -139,11 +147,11 @@ class LoginView(View):
data = request.POST if request.method == "POST" else request.GET
redirect_url = data.get('next', settings.LOGIN_REDIRECT_URL)
if redirect_url and url_has_allowed_host_and_scheme(redirect_url, allowed_hosts=None):
logger.debug(f"Redirecting user to {redirect_url}")
if redirect_url and safe_for_redirect(redirect_url):
logger.debug(f"Redirecting user to {remove_linebreaks(redirect_url)}")
else:
if redirect_url:
logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {redirect_url}")
logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {remove_linebreaks(redirect_url)}")
redirect_url = reverse('home')
return HttpResponseRedirect(redirect_url)
@@ -218,7 +226,12 @@ class UserConfigView(LoginRequiredMixin, View):
# Set/clear language cookie
if language := form.cleaned_data['locale.language']:
response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language, max_age=request.session.get_expiry_age())
response.set_cookie(
key=settings.LANGUAGE_COOKIE_NAME,
value=language,
max_age=request.session.get_expiry_age(),
secure=settings.SESSION_COOKIE_SECURE,
)
else:
response.delete_cookie(settings.LANGUAGE_COOKIE_NAME)

View File

@@ -95,7 +95,7 @@ class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
)
class ProviderAccountFilterSet(NetBoxModelFilterSet):
class ProviderAccountFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
provider_id = django_filters.ModelMultipleChoiceFilter(
queryset=Provider.objects.all(),
label=_('Provider (ID)'),

View File

@@ -66,11 +66,12 @@ class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
tag = TagFilterField(model)
class ProviderAccountFilterForm(NetBoxModelFilterSetForm):
class ProviderAccountFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = ProviderAccount
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('provider_id', 'account', name=_('Attributes')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
)
provider_id = DynamicModelMultipleChoiceField(
queryset=Provider.objects.all(),
@@ -327,7 +328,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

@@ -43,7 +43,7 @@ class ProviderType(NetBoxObjectType, ContactsMixin):
fields='__all__',
filters=ProviderAccountFilter
)
class ProviderAccountType(NetBoxObjectType):
class ProviderAccountType(ContactsMixin, NetBoxObjectType):
provider: Annotated["ProviderType", strawberry.lazy('circuits.graphql.types')]
circuits: List[Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]]

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

@@ -61,9 +61,8 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
linkify=True,
verbose_name=_('Account')
)
type = tables.Column(
type = columns.ColoredLabelColumn(
verbose_name=_('Type'),
linkify=True
)
status = columns.ChoiceFieldColumn()
termination_a = columns.TemplateColumn(
@@ -111,7 +110,7 @@ class CircuitTerminationTable(NetBoxTable):
provider = tables.Column(
verbose_name=_('Provider'),
linkify=True,
accessor='circuit.provider'
accessor='circuit__provider'
)
term_side = tables.Column(
verbose_name=_('Side')

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')

View File

@@ -23,6 +23,7 @@ class ProviderListView(generic.ObjectListView):
queryset = Provider.objects.annotate(
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
@@ -35,7 +36,19 @@ class ProviderView(GetRelatedModelsMixin, generic.ObjectView):
def get_extra_context(self, request, instance):
return {
'related_models': self.get_related_models(request, instance),
'related_models': self.get_related_models(
request,
instance,
omit=(),
extra=(
(
VirtualCircuit.objects.restrict(request.user, 'view').filter(
provider_network__provider=instance
),
'provider_id',
),
),
),
}
@@ -51,7 +64,7 @@ class ProviderDeleteView(generic.ObjectDeleteView):
queryset = Provider.objects.all()
@register_model_view(Provider, 'bulk_import', detail=False)
@register_model_view(Provider, 'bulk_import', path='import', detail=False)
class ProviderBulkImportView(generic.BulkImportView):
queryset = Provider.objects.all()
model_form = forms.ProviderImportForm
@@ -117,7 +130,7 @@ class ProviderAccountDeleteView(generic.ObjectDeleteView):
queryset = ProviderAccount.objects.all()
@register_model_view(ProviderAccount, 'bulk_import', detail=False)
@register_model_view(ProviderAccount, 'bulk_import', path='import', detail=False)
class ProviderAccountBulkImportView(generic.BulkImportView):
queryset = ProviderAccount.objects.all()
model_form = forms.ProviderAccountImportForm
@@ -169,11 +182,16 @@ class ProviderNetworkView(GetRelatedModelsMixin, generic.ObjectView):
'related_models': self.get_related_models(
request,
instance,
omit=(CircuitTermination,),
extra=(
(
Circuit.objects.restrict(request.user, 'view').filter(terminations___provider_network=instance),
'provider_network_id',
),
(
CircuitTermination.objects.restrict(request.user, 'view').filter(_provider_network=instance),
'provider_network_id',
),
),
),
}
@@ -191,7 +209,7 @@ class ProviderNetworkDeleteView(generic.ObjectDeleteView):
queryset = ProviderNetwork.objects.all()
@register_model_view(ProviderNetwork, 'bulk_import', detail=False)
@register_model_view(ProviderNetwork, 'bulk_import', path='import', detail=False)
class ProviderNetworkBulkImportView(generic.BulkImportView):
queryset = ProviderNetwork.objects.all()
model_form = forms.ProviderNetworkImportForm
@@ -248,7 +266,7 @@ class CircuitTypeDeleteView(generic.ObjectDeleteView):
queryset = CircuitType.objects.all()
@register_model_view(CircuitType, 'bulk_import', detail=False)
@register_model_view(CircuitType, 'bulk_import', path='import', detail=False)
class CircuitTypeBulkImportView(generic.BulkImportView):
queryset = CircuitType.objects.all()
model_form = forms.CircuitTypeImportForm
@@ -304,7 +322,7 @@ class CircuitDeleteView(generic.ObjectDeleteView):
queryset = Circuit.objects.all()
@register_model_view(Circuit, 'bulk_import', detail=False)
@register_model_view(Circuit, 'bulk_import', path='import', detail=False)
class CircuitBulkImportView(generic.BulkImportView):
queryset = Circuit.objects.all()
model_form = forms.CircuitImportForm
@@ -449,7 +467,7 @@ class CircuitTerminationDeleteView(generic.ObjectDeleteView):
queryset = CircuitTermination.objects.all()
@register_model_view(CircuitTermination, 'bulk_import', detail=False)
@register_model_view(CircuitTermination, 'bulk_import', path='import', detail=False)
class CircuitTerminationBulkImportView(generic.BulkImportView):
queryset = CircuitTermination.objects.all()
model_form = forms.CircuitTerminationImportForm
@@ -510,7 +528,7 @@ class CircuitGroupDeleteView(generic.ObjectDeleteView):
queryset = CircuitGroup.objects.all()
@register_model_view(CircuitGroup, 'bulk_import', detail=False)
@register_model_view(CircuitGroup, 'bulk_import', path='import', detail=False)
class CircuitGroupBulkImportView(generic.BulkImportView):
queryset = CircuitGroup.objects.all()
model_form = forms.CircuitGroupImportForm
@@ -560,7 +578,7 @@ class CircuitGroupAssignmentDeleteView(generic.ObjectDeleteView):
queryset = CircuitGroupAssignment.objects.all()
@register_model_view(CircuitGroupAssignment, 'bulk_import', detail=False)
@register_model_view(CircuitGroupAssignment, 'bulk_import', path='import', detail=False)
class CircuitGroupAssignmentBulkImportView(generic.BulkImportView):
queryset = CircuitGroupAssignment.objects.all()
model_form = forms.CircuitGroupAssignmentImportForm
@@ -617,7 +635,7 @@ class VirtualCircuitTypeDeleteView(generic.ObjectDeleteView):
queryset = VirtualCircuitType.objects.all()
@register_model_view(VirtualCircuitType, 'bulk_import', detail=False)
@register_model_view(VirtualCircuitType, 'bulk_import', path='import', detail=False)
class VirtualCircuitTypeBulkImportView(generic.BulkImportView):
queryset = VirtualCircuitType.objects.all()
model_form = forms.VirtualCircuitTypeImportForm

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

@@ -3,7 +3,10 @@ from django.conf import settings
from django.core.cache import cache
from django.db import models
from django.db.migrations.operations import AlterModelOptions
from django.utils.translation import gettext as _
from core.events import *
from netbox.events import EventType, EVENT_TYPE_KIND_DANGER, EVENT_TYPE_KIND_SUCCESS, EVENT_TYPE_KIND_WARNING
from utilities.migration import custom_deconstruct
# Ignore verbose_name & verbose_name_plural Meta options when calculating model migrations
@@ -26,6 +29,15 @@ class CoreConfig(AppConfig):
# Register models
register_models(*self.get_models())
# Register core events
EventType(OBJECT_CREATED, _('Object created')).register()
EventType(OBJECT_UPDATED, _('Object updated')).register()
EventType(OBJECT_DELETED, _('Object deleted'), destructive=True).register()
EventType(JOB_STARTED, _('Job started')).register()
EventType(JOB_COMPLETED, _('Job completed'), kind=EVENT_TYPE_KIND_SUCCESS).register()
EventType(JOB_FAILED, _('Job failed'), kind=EVENT_TYPE_KIND_WARNING).register()
EventType(JOB_ERRORED, _('Job errored'), kind=EVENT_TYPE_KIND_DANGER).register()
# Clear Redis cache on startup in development mode
if settings.DEBUG:
try:

View File

@@ -1,7 +1,3 @@
from django.utils.translation import gettext as _
from netbox.events import EventType, EVENT_TYPE_KIND_DANGER, EVENT_TYPE_KIND_SUCCESS, EVENT_TYPE_KIND_WARNING
__all__ = (
'JOB_COMPLETED',
'JOB_ERRORED',
@@ -22,12 +18,3 @@ JOB_STARTED = 'job_started'
JOB_COMPLETED = 'job_completed'
JOB_FAILED = 'job_failed'
JOB_ERRORED = 'job_errored'
# Register core events
EventType(OBJECT_CREATED, _('Object created')).register()
EventType(OBJECT_UPDATED, _('Object updated')).register()
EventType(OBJECT_DELETED, _('Object deleted'), destructive=True).register()
EventType(JOB_STARTED, _('Job started')).register()
EventType(JOB_COMPLETED, _('Job completed'), kind=EVENT_TYPE_KIND_SUCCESS).register()
EventType(JOB_FAILED, _('Job failed'), kind=EVENT_TYPE_KIND_WARNING).register()
EventType(JOB_ERRORED, _('Job errored'), kind=EVENT_TYPE_KIND_DANGER).register()

View File

@@ -0,0 +1,17 @@
import django.core.serializers.json
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0012_job_object_type_optional'),
]
operations = [
migrations.AlterField(
model_name='job',
name='data',
field=models.JSONField(blank=True, encoder=django.core.serializers.json.DjangoJSONEncoder, null=True),
),
]

View File

@@ -1,11 +1,13 @@
import uuid
from functools import partial
import django_rq
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey
from django.core.exceptions import ValidationError
from django.core.serializers.json import DjangoJSONEncoder
from django.core.validators import MinValueValidator
from django.db import models
from django.db import models, transaction
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext as _
@@ -89,8 +91,9 @@ class Job(models.Model):
)
data = models.JSONField(
verbose_name=_('data'),
encoder=DjangoJSONEncoder,
null=True,
blank=True
blank=True,
)
error = models.TextField(
verbose_name=_('error'),
@@ -258,10 +261,12 @@ class Job(models.Model):
# Schedule the job to run at a specific date & time.
elif schedule_at:
queue.enqueue_at(schedule_at, func, job_id=str(job.job_id), job=job, **kwargs)
callback = partial(queue.enqueue_at, schedule_at, func, job_id=str(job.job_id), job=job, **kwargs)
transaction.on_commit(callback)
# Schedule the job to run asynchronously at this first available opportunity.
else:
queue.enqueue(func, job_id=str(job.job_id), job=job, **kwargs)
callback = partial(queue.enqueue, func, job_id=str(job.job_id), job=job, **kwargs)
transaction.on_commit(callback)
return job

View File

@@ -47,6 +47,7 @@ class Plugin:
The representation of a NetBox plugin in the catalog API.
"""
id: str = ''
icon_url: str = ''
status: str = ''
title_short: str = ''
title_long: str = ''
@@ -193,6 +194,7 @@ def get_catalog_plugins():
# Populate plugin data
plugins[data['config_name']] = Plugin(
id=data['id'],
icon_url=data['icon'],
status=data['status'],
title_short=data['title_short'],
title_long=data['title_long'],

View File

@@ -2,7 +2,7 @@ import logging
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db.models.fields.reverse_related import ManyToManyRel
from django.db.models.fields.reverse_related import ManyToManyRel, ManyToOneRel
from django.db.models.signals import m2m_changed, post_save, pre_delete
from django.dispatch import receiver, Signal
from django.utils.translation import gettext_lazy as _
@@ -146,8 +146,10 @@ def handle_deleted_object(sender, instance, **kwargs):
# instance being deleted, and explicitly call .remove() on the remote M2M field to delete
# the association. This triggers an m2m_changed signal with the `post_remove` action type
# for the forward direction of the relationship, ensuring that the change is recorded.
# Similarly, for many-to-one relationships, we set the value on the related object to None
# and save it to trigger a change record on that object.
for relation in instance._meta.related_objects:
if type(relation) is not ManyToManyRel:
if type(relation) not in [ManyToManyRel, ManyToOneRel]:
continue
related_model = relation.related_model
related_field_name = relation.remote_field.name
@@ -157,7 +159,11 @@ def handle_deleted_object(sender, instance, **kwargs):
continue
for obj in related_model.objects.filter(**{related_field_name: instance.pk}):
obj.snapshot() # Ensure the change record includes the "before" state
getattr(obj, related_field_name).remove(instance)
if type(relation) is ManyToManyRel:
getattr(obj, related_field_name).remove(instance)
elif type(relation) is ManyToOneRel and relation.field.null is True:
setattr(obj, related_field_name, None)
obj.save()
# Enqueue the object for event processing
queue = events_queue.get()

View File

@@ -9,6 +9,12 @@ __all__ = (
)
PLUGIN_NAME_TEMPLATE = """
<img class="plugin-icon" src="{{ record.icon_url }}">
<a href="{% url 'core:plugin' record.config_name %}">{{ record.title_long }}</a>
"""
class PluginVersionTable(BaseTable):
version = tables.Column(
verbose_name=_('Version')
@@ -39,8 +45,8 @@ class PluginVersionTable(BaseTable):
class CatalogPluginTable(BaseTable):
title_long = tables.Column(
linkify=('core:plugin', [tables.A('config_name')]),
title_long = columns.TemplateColumn(
template_code=PLUGIN_NAME_TEMPLATE,
verbose_name=_('Name')
)
author = tables.Column(

View File

@@ -102,7 +102,7 @@ class DataSourceDeleteView(generic.ObjectDeleteView):
queryset = DataSource.objects.all()
@register_model_view(DataSource, 'bulk_import', detail=False)
@register_model_view(DataSource, 'bulk_import', path='import', detail=False)
class DataSourceBulkImportView(generic.BulkImportView):
queryset = DataSource.objects.all()
model_form = forms.DataSourceImportForm
@@ -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

@@ -1,3 +1,4 @@
from django.contrib.contenttypes.prefetch import GenericPrefetch
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404
from drf_spectacular.types import OpenApiTypes
@@ -442,7 +443,18 @@ class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet):
class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
queryset = Interface.objects.prefetch_related(
'_path', 'cable__terminations',
GenericPrefetch(
"cable__terminations__termination",
[
Interface.objects.select_related("device", "cable"),
],
),
GenericPrefetch(
"_path__path_objects",
[
Interface.objects.select_related("device", "cable"),
],
),
'l2vpn_terminations', # Referenced by InterfaceSerializer.l2vpn_termination
'ip_addresses', # Referenced by Interface.count_ipaddresses()
'fhrp_group_assignments', # Referenced by Interface.count_fhrp_groups()

View File

@@ -986,6 +986,7 @@ class InterfaceTypeChoices(ChoiceSet):
# Coaxial
TYPE_DOCSIS = 'docsis'
TYPE_MOCA = 'moca'
# PON
TYPE_BPON = 'bpon'
@@ -1182,6 +1183,7 @@ class InterfaceTypeChoices(ChoiceSet):
_('Coaxial'),
(
(TYPE_DOCSIS, 'DOCSIS'),
(TYPE_MOCA, 'MoCA'),
)
),
(
@@ -1345,6 +1347,9 @@ class PortTypeChoices(ChoiceSet):
TYPE_SC_UPC = 'sc-upc'
TYPE_SC_APC = 'sc-apc'
TYPE_FC = 'fc'
TYPE_FC_PC = 'fc-pc'
TYPE_FC_UPC = 'fc-upc'
TYPE_FC_APC = 'fc-apc'
TYPE_LC = 'lc'
TYPE_LC_PC = 'lc-pc'
TYPE_LC_UPC = 'lc-upc'
@@ -1405,6 +1410,9 @@ class PortTypeChoices(ChoiceSet):
_('Fiber Optic'),
(
(TYPE_FC, 'FC'),
(TYPE_FC_PC, 'FC/PC'),
(TYPE_FC_UPC, 'FC/UPC'),
(TYPE_FC_APC, 'FC/APC'),
(TYPE_LC, 'LC'),
(TYPE_LC_PC, 'LC/PC'),
(TYPE_LC_UPC, 'LC/UPC'),

View File

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

View File

@@ -1057,6 +1057,13 @@ class DeviceFilterSet(
lookup_expr='in',
label=_('Location (ID)'),
)
location = TreeNodeMultipleChoiceFilter(
queryset=Location.objects.all(),
field_name='location',
lookup_expr='in',
to_field_name='slug',
label=_('Location (slug)'),
)
rack_id = django_filters.ModelMultipleChoiceFilter(
field_name='rack',
queryset=Rack.objects.all(),
@@ -1193,6 +1200,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()) |
@@ -1329,10 +1337,75 @@ class ModuleFilterSet(NetBoxModelFilterSet):
lookup_expr='in',
label=_('Module bay (ID)'),
)
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='device__site__region',
lookup_expr='in',
label=_('Region (ID)'),
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='device__site__region',
lookup_expr='in',
to_field_name='slug',
label=_('Region (slug)'),
)
site_group_id = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='device__site__group',
lookup_expr='in',
label=_('Site group (ID)'),
)
site_group = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='device__site__group',
lookup_expr='in',
to_field_name='slug',
label=_('Site group (slug)'),
)
site_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__site',
queryset=Site.objects.all(),
label=_('Site (ID)'),
)
site = django_filters.ModelMultipleChoiceFilter(
field_name='device__site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label=_('Site name (slug)'),
)
location_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__location',
queryset=Location.objects.all(),
label=_('Location (ID)'),
)
location = django_filters.ModelMultipleChoiceFilter(
field_name='device__location__slug',
queryset=Location.objects.all(),
to_field_name='slug',
label=_('Location (slug)'),
)
rack_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__rack',
queryset=Rack.objects.all(),
label=_('Rack (ID)'),
)
rack = django_filters.ModelMultipleChoiceFilter(
field_name='device__rack__name',
queryset=Rack.objects.all(),
to_field_name='name',
label=_('Rack (name)'),
)
device_id = django_filters.ModelMultipleChoiceFilter(
queryset=Device.objects.all(),
label=_('Device (ID)'),
)
device = django_filters.ModelMultipleChoiceFilter(
field_name='device__name',
queryset=Device.objects.all(),
to_field_name='name',
label=_('Device (name)'),
)
status = django_filters.MultipleChoiceFilter(
choices=ModuleStatusChoices,
null_value=None
@@ -1681,6 +1754,10 @@ class MACAddressFilterSet(NetBoxModelFilterSet):
class CommonInterfaceFilterSet(django_filters.FilterSet):
mode = django_filters.MultipleChoiceFilter(
choices=InterfaceModeChoices,
label=_('802.1Q Mode')
)
vlan_id = django_filters.CharFilter(
method='filter_vlan_id',
label=_('Assigned VLAN')

View File

@@ -121,11 +121,11 @@ class DeviceBayBulkCreateForm(DeviceBulkAddComponentForm):
class InventoryItemBulkCreateForm(
form_from_model(InventoryItem, ['role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered']),
form_from_model(InventoryItem, ['status', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered']),
DeviceBulkAddComponentForm
):
model = InventoryItem
field_order = (
'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered',
'name', 'label', 'status', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered',
'description', 'tags',
)

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

@@ -1161,27 +1161,45 @@ class InventoryItemImportForm(NetBoxModelImportForm):
else:
self.fields['parent'].queryset = InventoryItem.objects.none()
def clean_component_name(self):
content_type = self.cleaned_data.get('component_type')
component_name = self.cleaned_data.get('component_name')
def clean(self):
super().clean()
cleaned_data = self.cleaned_data
component_type = cleaned_data.get('component_type')
component_name = cleaned_data.get('component_name')
device = self.cleaned_data.get("device")
if not device and hasattr(self, 'instance') and hasattr(self.instance, 'device'):
device = self.instance.device
if not all([device, content_type, component_name]):
return None
model = content_type.model_class()
try:
component = model.objects.get(device=device, name=component_name)
self.instance.component = component
except ObjectDoesNotExist:
raise forms.ValidationError(
_("Component not found: {device} - {component_name}").format(
device=device, component_name=component_name
if component_type:
if device is None:
cleaned_data.pop('component_type', None)
if component_name is None:
cleaned_data.pop('component_type', None)
raise forms.ValidationError(
_("Component name must be specified when component type is specified")
)
)
if all([device, component_name]):
try:
model = component_type.model_class()
self.instance.component = model.objects.get(device=device, name=component_name)
except ObjectDoesNotExist:
cleaned_data.pop('component_type', None)
cleaned_data.pop('component_name', None)
raise forms.ValidationError(
_("Component not found: {device} - {component_name}").format(
device=device, component_name=component_name
)
)
else:
cleaned_data.pop('component_type', None)
if not component_name:
raise forms.ValidationError(
_("Component name must be specified when component type is specified")
)
else:
if component_name:
raise forms.ValidationError(
_("Component type must be specified when component name is specified")
)
return cleaned_data
#

View File

@@ -41,22 +41,15 @@ class InterfaceCommonForm(forms.Form):
def clean(self):
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]
@@ -67,6 +60,12 @@ class InterfaceCommonForm(forms.Form):
"or they must be global"
).format(vlans=', '.join(invalid_vlans))
})
# Validate mode change
if self.instance.pk and (self.instance.mode != self.cleaned_data['mode']):
if 'untagged_vlan' not in self.cleaned_data and self.instance.untagged_vlan is not None:
self.instance.untagged_vlan = None
if 'tagged_vlans' not in self.cleaned_data and self.instance.tagged_vlans is not None:
self.instance.tagged_vlans.clear()
class ModuleCommonForm(forms.Form):

View File

@@ -6,7 +6,7 @@ from dcim.constants import *
from dcim.models import *
from extras.forms import LocalConfigContextFilterForm
from extras.models import ConfigTemplate
from ipam.models import ASN, VRF
from ipam.models import ASN, VRF, VLANTranslationPolicy
from netbox.choices import *
from netbox.forms import NetBoxModelFilterSetForm
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
@@ -940,8 +940,56 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo
model = Module
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'device_id', name=_('Location')),
FieldSet('manufacturer_id', 'module_type_id', 'status', 'serial', 'asset_tag', name=_('Hardware')),
)
device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
required=False,
query_params={
'site_id': '$site_id',
'location_id': '$location_id',
'rack_id': '$rack_id',
},
label=_('Device')
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
label=_('Region')
)
site_group_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label=_('Site group')
)
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
required=False,
query_params={
'region_id': '$region_id',
'group_id': '$site_group_id',
},
label=_('Site')
)
location_id = DynamicModelMultipleChoiceField(
queryset=Location.objects.all(),
required=False,
query_params={
'site_id': '$site_id',
},
label=_('Location')
)
rack_id = DynamicModelMultipleChoiceField(
queryset=Rack.objects.all(),
required=False,
label=_('Rack'),
null_option='None',
query_params={
'site_id': '$site_id',
'location_id': '$location_id',
}
)
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
required=False,
@@ -1332,6 +1380,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
FieldSet('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only', name=_('Attributes')),
FieldSet('vrf_id', 'l2vpn_id', 'mac_address', 'wwn', name=_('Addressing')),
FieldSet('poe_mode', 'poe_type', name=_('PoE')),
FieldSet('mode', 'vlan_translation_policy_id', name=_('802.1Q Switching')),
FieldSet('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power', name=_('Wireless')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
@@ -1403,6 +1452,16 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
required=False,
label=_('PoE type')
)
mode = forms.MultipleChoiceField(
choices=InterfaceModeChoices,
required=False,
label=_('802.1Q mode')
)
vlan_translation_policy_id = DynamicModelMultipleChoiceField(
queryset=VLANTranslationPolicy.objects.all(),
required=False,
label=_('VLAN Translation Policy')
)
rf_role = forms.MultipleChoiceField(
choices=WirelessRoleChoices,
required=False,

View File

@@ -899,7 +899,7 @@ class ComponentTemplateForm(forms.ModelForm):
class ModularComponentTemplateForm(ComponentTemplateForm):
device_type = DynamicModelChoiceField(
label=_('Device type'),
queryset=DeviceType.objects.all().all(),
queryset=DeviceType.objects.all(),
required=False,
context={
'parent': 'manufacturer',
@@ -914,6 +914,16 @@ class ModularComponentTemplateForm(ComponentTemplateForm):
}
)
fieldsets = (
FieldSet(
TabbedGroups(
FieldSet('device_type', name=_('Device Type')),
FieldSet('module_type', name=_('Module Type')),
),
'name', 'label', 'type', 'description'
),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -930,10 +940,6 @@ class ModularComponentTemplateForm(ComponentTemplateForm):
class ConsolePortTemplateForm(ModularComponentTemplateForm):
fieldsets = (
FieldSet('device_type', 'module_type', 'name', 'label', 'type', 'description'),
)
class Meta:
model = ConsolePortTemplate
fields = [
@@ -942,10 +948,6 @@ class ConsolePortTemplateForm(ModularComponentTemplateForm):
class ConsoleServerPortTemplateForm(ModularComponentTemplateForm):
fieldsets = (
FieldSet('device_type', 'module_type', 'name', 'label', 'type', 'description'),
)
class Meta:
model = ConsoleServerPortTemplate
fields = [
@@ -956,7 +958,11 @@ class ConsoleServerPortTemplateForm(ModularComponentTemplateForm):
class PowerPortTemplateForm(ModularComponentTemplateForm):
fieldsets = (
FieldSet(
'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
TabbedGroups(
FieldSet('device_type', name=_('Device Type')),
FieldSet('module_type', name=_('Module Type')),
),
'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
),
)
@@ -978,7 +984,13 @@ class PowerOutletTemplateForm(ModularComponentTemplateForm):
)
fieldsets = (
FieldSet('device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description'),
FieldSet(
TabbedGroups(
FieldSet('device_type', name=_('Device Type')),
FieldSet('module_type', name=_('Module Type')),
),
'name', 'label', 'type', 'power_port', 'feed_leg', 'description',
),
)
class Meta:
@@ -1001,7 +1013,11 @@ class InterfaceTemplateForm(ModularComponentTemplateForm):
fieldsets = (
FieldSet(
'device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description', 'bridge',
TabbedGroups(
FieldSet('device_type', name=_('Device Type')),
FieldSet('module_type', name=_('Module Type')),
),
'name', 'label', 'type', 'enabled', 'mgmt_only', 'description', 'bridge',
),
FieldSet('poe_mode', 'poe_type', name=_('PoE')),
FieldSet('rf_role', name=_('Wireless')),
@@ -1028,8 +1044,11 @@ class FrontPortTemplateForm(ModularComponentTemplateForm):
fieldsets = (
FieldSet(
'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position',
'description',
TabbedGroups(
FieldSet('device_type', name=_('Device Type')),
FieldSet('module_type', name=_('Module Type')),
),
'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
),
)
@@ -1043,7 +1062,13 @@ class FrontPortTemplateForm(ModularComponentTemplateForm):
class RearPortTemplateForm(ModularComponentTemplateForm):
fieldsets = (
FieldSet('device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', 'description'),
FieldSet(
TabbedGroups(
FieldSet('device_type', name=_('Device Type')),
FieldSet('module_type', name=_('Module Type')),
),
'name', 'label', 'type', 'color', 'positions', 'description',
),
)
class Meta:
@@ -1055,7 +1080,13 @@ class RearPortTemplateForm(ModularComponentTemplateForm):
class ModuleBayTemplateForm(ModularComponentTemplateForm):
fieldsets = (
FieldSet('device_type', 'module_type', 'name', 'label', 'position', 'description'),
FieldSet(
TabbedGroups(
FieldSet('device_type', name=_('Device Type')),
FieldSet('module_type', name=_('Module Type')),
),
'name', 'label', 'position', 'description',
),
)
class Meta:

View File

@@ -4,7 +4,7 @@ from django.utils.translation import gettext_lazy as _
from dcim.models import *
from netbox.forms import NetBoxModelForm
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField
from utilities.forms.rendering import FieldSet
from utilities.forms.rendering import FieldSet, TabbedGroups
from utilities.forms.widgets import APISelect
from . import model_forms
@@ -55,19 +55,23 @@ class ComponentCreateForm(forms.Form):
def clean(self):
super().clean()
# Validate that all replication fields generate an equal number of values
# Validate that all replication fields generate an equal number of values (or a single value)
if not (patterns := self.cleaned_data.get(self.replication_fields[0])):
return
pattern_count = len(patterns)
for field_name in self.replication_fields:
value_count = len(self.cleaned_data[field_name])
if self.cleaned_data[field_name] and value_count != pattern_count:
raise forms.ValidationError({
field_name: _(
"The provided pattern specifies {value_count} values, but {pattern_count} are expected."
).format(value_count=value_count, pattern_count=pattern_count)
}, code='label_pattern_mismatch')
if self.cleaned_data[field_name]:
if value_count == 1:
# If the field resolves to a single value (because no pattern was used), multiply it by the number
# of expected values. This allows us to reuse the same label when creating multiple components.
self.cleaned_data[field_name] = self.cleaned_data[field_name] * pattern_count
elif value_count != pattern_count:
raise forms.ValidationError({
field_name: _(
"The provided pattern specifies {value_count} values, but {pattern_count} are expected."
).format(value_count=value_count, pattern_count=pattern_count)
}, code='label_pattern_mismatch')
#
@@ -114,7 +118,13 @@ class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemp
# Override fieldsets from FrontPortTemplateForm to omit rear_port_position
fieldsets = (
FieldSet('device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'description'),
FieldSet(
TabbedGroups(
FieldSet('device_type', name=_('Device Type')),
FieldSet('module_type', name=_('Module Type')),
),
'name', 'label', 'type', 'color', 'rear_port', 'description',
),
)
class Meta(model_forms.FrontPortTemplateForm.Meta):
@@ -153,6 +163,7 @@ class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemp
self.fields['rear_port'].choices = choices
def clean(self):
super().clean()
# Check that the number of FrontPortTemplates to be created matches the selected number of RearPortTemplate
# positions
@@ -302,6 +313,7 @@ class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):
self.fields['rear_port'].choices = choices
def clean(self):
super().clean()
# Check that the number of FrontPorts to be created matches the selected number of RearPort positions
frontport_count = len(self.cleaned_data['name'])
@@ -402,6 +414,7 @@ class VirtualChassisCreateForm(NetBoxModelForm):
queryset=Device.objects.all(),
required=False,
query_params={
'virtual_chassis_id': 'null',
'site_id': '$site',
'rack_id': '$rack',
}

View File

@@ -159,7 +159,7 @@ class ModuleBayTemplateImportForm(forms.ModelForm):
class Meta:
model = ModuleBayTemplate
fields = [
'device_type', 'name', 'label', 'position', 'description',
'device_type', 'module_type', 'name', 'label', 'position', 'description',
]

View File

@@ -30,6 +30,7 @@ class PathEndpointMixin:
connected_endpoints: List[Annotated[Union[
Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')], # noqa: F821
Annotated["VirtualCircuitTerminationType", strawberry.lazy('circuits.graphql.types')], # noqa: F821
Annotated["ConsolePortType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
Annotated["ConsoleServerPortType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')], # noqa: F821

View File

@@ -429,7 +429,7 @@ class InterfaceTemplateType(ModularComponentTemplateType):
)
class InventoryItemType(ComponentType):
role: Annotated["InventoryItemRoleType", strawberry.lazy('dcim.graphql.types')] | None
manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] | None
child_items: List[Annotated["InventoryItemType", strawberry.lazy('dcim.graphql.types')]]

View File

@@ -1,10 +1,8 @@
import itertools
from collections import defaultdict
from django.contrib.contenttypes.fields import GenericForeignKey
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Sum
from django.dispatch import Signal
from django.utils.translation import gettext_lazy as _
@@ -15,7 +13,8 @@ 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.fields import ColorField
from utilities.exceptions import AbortRequest
from utilities.fields import ColorField, GenericArrayForeignKey
from utilities.querysets import RestrictedQuerySet
from wireless.models import WirelessLink
from .device_components import FrontPort, RearPort, PathEndpoint
@@ -26,6 +25,7 @@ __all__ = (
'CableTermination',
)
from ..exceptions import UnsupportedCablePath
trace_paths = Signal()
@@ -236,8 +236,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)
@@ -490,13 +492,16 @@ class CablePath(models.Model):
return ObjectType.objects.get_for_id(ct_id)
@property
def path_objects(self):
"""
Cache and return the complete path as lists of objects, derived from their annotation within the path.
"""
if not hasattr(self, '_path_objects'):
self._path_objects = self._get_path()
return self._path_objects
def _path_decompiled(self):
res = []
for step in self.path:
nodes = []
for node in step:
nodes.append(decompile_path_node(node))
res.append(nodes)
return res
path_objects = GenericArrayForeignKey("_path_decompiled")
@property
def origins(self):
@@ -531,8 +536,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 +548,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 +577,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 +661,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:
@@ -746,42 +758,6 @@ class CablePath(models.Model):
self.delete()
retrace.alters_data = True
def _get_path(self):
"""
Return the path as a list of prefetched objects.
"""
# Compile a list of IDs to prefetch for each type of model in the path
to_prefetch = defaultdict(list)
for node in self._nodes:
ct_id, object_id = decompile_path_node(node)
to_prefetch[ct_id].append(object_id)
# Prefetch path objects using one query per model type. Prefetch related devices where appropriate.
prefetched = {}
for ct_id, object_ids in to_prefetch.items():
model_class = ObjectType.objects.get_for_id(ct_id).model_class()
queryset = model_class.objects.filter(pk__in=object_ids)
if hasattr(model_class, 'device'):
queryset = queryset.prefetch_related('device')
prefetched[ct_id] = {
obj.id: obj for obj in queryset
}
# Replicate the path using the prefetched objects.
path = []
for step in self.path:
nodes = []
for node in step:
ct_id, object_id = decompile_path_node(node)
try:
nodes.append(prefetched[ct_id][object_id])
except KeyError:
# Ignore stale (deleted) object IDs
pass
path.append(nodes)
return path
def get_cable_ids(self):
"""
Return all Cable IDs within the path.
@@ -801,9 +777,28 @@ class CablePath(models.Model):
Return a tuple containing the sum of the length of each cable in the path
and a flag indicating whether the length is definitive.
"""
cable_ct = ObjectType.objects.get_for_model(Cable).pk
# Pre-cache cable lengths by ID
cable_ids = self.get_cable_ids()
cables = Cable.objects.filter(id__in=cable_ids, _abs_length__isnull=False)
total_length = cables.aggregate(total=Sum('_abs_length'))['total']
cables = {
cable['pk']: cable['_abs_length']
for cable in Cable.objects.filter(id__in=cable_ids, _abs_length__isnull=False).values('pk', '_abs_length')
}
# Iterate through each set of nodes in the path. For cables, add the length of the longest cable to the total
# length of the path.
total_length = 0
for node_set in self.path:
hop_length = 0
for node in node_set:
ct, pk = decompile_path_node(node)
if ct != cable_ct:
break # Not a cable
if pk in cables and cables[pk] > hop_length:
hop_length = cables[pk]
total_length += hop_length
is_definitive = len(cables) == len(cable_ids)
return total_length, is_definitive

View File

@@ -184,8 +184,11 @@ class CabledObjectModel(models.Model):
@cached_property
def link_peers(self):
if self.cable:
peers = self.cable.terminations.exclude(cable_end=self.cable_end).prefetch_related('termination')
return [peer.termination for peer in peers]
return [
peer.termination
for peer in self.cable.terminations.all()
if peer.cable_end != self.cable_end
]
return []
@property
@@ -934,6 +937,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):
@@ -1546,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

@@ -725,3 +725,8 @@ class RackReservation(PrimaryModel):
@property
def unit_list(self):
return array_to_string(self.units)
def to_objectchange(self, action):
objectchange = super().to_objectchange(action)
objectchange.related_object = self.rack
return objectchange

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

@@ -225,8 +225,7 @@ class CableTraceSVG:
"""
nodes_height = 0
nodes = []
# Sort them by name to make renders more readable
for i, term in enumerate(sorted(terminations, key=lambda x: str(x))):
for i, term in enumerate(terminations):
node = Node(
position=(offset_x + i * width, self.cursor),
width=width,

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

@@ -144,7 +144,7 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
name = tables.TemplateColumn(
verbose_name=_('Name'),
template_code=DEVICE_LINK,
linkify=True
linkify=True,
)
status = columns.ChoiceFieldColumn(
verbose_name=_('Status'),
@@ -671,7 +671,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

@@ -146,6 +146,11 @@ class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
url_params={'location_id': 'pk'},
verbose_name=_('Devices')
)
vlangroup_count = columns.LinkedCountColumn(
viewname='ipam:vlangroup_list',
url_params={'location': 'pk'},
verbose_name=_('VLAN Groups')
)
tags = columns.TagColumn(
url_name='dcim:location_list'
)
@@ -157,8 +162,9 @@ class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
model = Location
fields = (
'pk', 'id', 'name', 'site', 'status', 'facility', 'tenant', 'tenant_group', 'rack_count', 'device_count',
'description', 'slug', 'contacts', 'tags', 'actions', 'created', 'last_updated',
'description', 'slug', 'contacts', 'tags', 'actions', 'created', 'last_updated', 'vlangroup_count',
)
default_columns = (
'pk', 'name', 'site', 'status', 'facility', 'tenant', 'rack_count', 'device_count', 'description'
'pk', 'name', 'site', 'status', 'facility', 'tenant', 'rack_count', 'device_count', 'vlangroup_count',
'description'
)

View File

@@ -35,7 +35,7 @@ WEIGHT = """
"""
DEVICE_LINK = """
{{ value|default:'<span class="badge text-bg-info">Unnamed device</span>' }}
{{ record.label|default:'<span class="badge text-bg-info">Unnamed device</span>' }}
"""
DEVICEBAY_STATUS = """
@@ -64,7 +64,7 @@ INTERFACE_IPADDRESSES = """
INTERFACE_FHRPGROUPS = """
{% for assignment in value.all %}
<a href="{{ assignment.group.get_absolute_url }}">{{ assignment.group.get_protocol_display }}: {{ assignment.group.group_id }}</a>
<a href="{{ assignment.group.get_absolute_url }}">{{ assignment.group }}</a>
{% endfor %}
"""

View File

@@ -1,4 +1,6 @@
from django.test import override_settings
import json
from django.test import override_settings, tag
from django.urls import reverse
from django.utils.translation import gettext as _
from rest_framework import status
@@ -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
@@ -1834,6 +1904,27 @@ class FrontPortTest(APIViewTestCases.APIViewTestCase):
},
]
@tag('regression') # Issue #18991
def test_front_port_paths(self):
device = Device.objects.first()
rear_port = RearPort.objects.create(
device=device, name='Rear Port 10', type=PortTypeChoices.TYPE_8P8C
)
interface1 = Interface.objects.create(device=device, name='Interface 1')
front_port = FrontPort.objects.create(
device=device,
name='Rear Port 10',
type=PortTypeChoices.TYPE_8P8C,
rear_port=rear_port,
)
Cable.objects.create(a_terminations=[interface1], b_terminations=[front_port])
self.add_permissions(f'dcim.view_{self.model._meta.model_name}')
url = reverse(f'dcim-api:{self.model._meta.model_name}-paths', kwargs={'pk': front_port.pk})
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
class RearPortTest(APIViewTestCases.APIViewTestCase):
model = RearPort
@@ -1877,6 +1968,23 @@ class RearPortTest(APIViewTestCases.APIViewTestCase):
},
]
@tag('regression') # Issue #18991
def test_rear_port_paths(self):
device = Device.objects.first()
interface1 = Interface.objects.create(device=device, name='Interface 1')
rear_port = RearPort.objects.create(
device=device,
name='Rear Port 10',
type=PortTypeChoices.TYPE_8P8C,
)
Cable.objects.create(a_terminations=[interface1], b_terminations=[rear_port])
self.add_permissions(f'dcim.view_{self.model._meta.model_name}')
url = reverse(f'dcim-api:{self.model._meta.model_name}-paths', kwargs={'pk': rear_port.pk})
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
class ModuleBayTest(APIViewTestCases.APIViewTestCase):
model = ModuleBay

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

@@ -2561,6 +2561,8 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
locations = Location.objects.all()[:2]
params = {'location_id': [locations[0].pk, locations[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'location': [locations[0].slug, locations[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_rack(self):
racks = Rack.objects.all()[:2]
@@ -2617,15 +2619,23 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
addresses = IPAddress.objects.filter(address__family=4)
params = {'primary_ip4_id': [addresses[0].pk, addresses[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'primary_ip4': [str(addresses[0].address), str(addresses[1].address)]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'primary_ip4_id': [addresses[2].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
params = {'primary_ip4': [str(addresses[2].address)]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
def test_primary_ip6(self):
addresses = IPAddress.objects.filter(address__family=6)
params = {'primary_ip6_id': [addresses[0].pk, addresses[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'primary_ip6': [str(addresses[0].address), str(addresses[1].address)]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'primary_ip6_id': [addresses[2].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
params = {'primary_ip6': [str(addresses[2].address)]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
def test_virtual_chassis_id(self):
params = {'virtual_chassis_id': [VirtualChassis.objects.first().pk]}
@@ -2719,6 +2729,29 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
@classmethod
def setUpTestData(cls):
regions = (
Region(name='Region 1', slug='region-1'),
Region(name='Region 2', slug='region-2'),
Region(name='Region 3', slug='region-3'),
)
for region in regions:
region.save()
groups = (
SiteGroup(name='Site Group 1', slug='site-group-1'),
SiteGroup(name='Site Group 2', slug='site-group-2'),
SiteGroup(name='Site Group 3', slug='site-group-3'),
)
for group in groups:
group.save()
sites = Site.objects.bulk_create((
Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'),
))
manufacturers = (
Manufacturer(name='Manufacturer 1', slug='manufacturer-1'),
Manufacturer(name='Manufacturer 2', slug='manufacturer-2'),
@@ -2726,11 +2759,65 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
)
Manufacturer.objects.bulk_create(manufacturers)
devices = (
create_test_device('Test Device 1'),
create_test_device('Test Device 2'),
create_test_device('Test Device 3'),
device_types = (
DeviceType(manufacturer=manufacturers[0], model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturers[1], model='Device Type 2', slug='device-type-2'),
DeviceType(manufacturer=manufacturers[2], model='Device Type 3', slug='device-type-3'),
)
DeviceType.objects.bulk_create(device_types)
roles = (
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
DeviceRole.objects.bulk_create(roles)
locations = (
Location(name='Location 1', slug='location-1', site=sites[0]),
Location(name='Location 2', slug='location-2', site=sites[1]),
Location(name='Location 3', slug='location-3', site=sites[2]),
)
for location in locations:
location.save()
racks = (
Rack(name='Rack 1', site=sites[0]),
Rack(name='Rack 2', site=sites[1]),
Rack(name='Rack 3', site=sites[2]),
)
Rack.objects.bulk_create(racks)
devices = (
Device(
name='Test Device 1',
device_type=device_types[0],
role=roles[0],
site=sites[0],
location=locations[0],
rack=racks[0],
status='active',
),
Device(
name='Test Device 2',
device_type=device_types[1],
role=roles[1],
site=sites[1],
location=locations[1],
rack=racks[1],
status='planned',
),
Device(
name='Test Device 3',
device_type=device_types[2],
role=roles[2],
site=sites[2],
location=locations[2],
rack=racks[2],
status='offline',
),
)
Device.objects.bulk_create(devices)
module_types = (
ModuleType(manufacturer=manufacturers[0], model='Module Type 1'),
@@ -2878,6 +2965,41 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'asset_tag': ['A', 'B']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_region(self):
regions = Region.objects.all()[:2]
params = {'region_id': [regions[0].pk, regions[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
params = {'region': [regions[0].slug, regions[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
def test_site_group(self):
site_groups = SiteGroup.objects.all()[:2]
params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
params = {'site': [sites[0].slug, sites[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
def test_location(self):
locations = Location.objects.all()[:2]
params = {'location_id': [locations[0].pk, locations[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
params = {'location': [locations[0].slug, locations[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
def test_rack(self):
racks = Rack.objects.all()[:2]
params = {'rack_id': [racks[0].pk, racks[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
params = {'rack': [racks[0].name, racks[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = ConsolePort.objects.all()
@@ -4151,7 +4273,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_mode(self):
params = {'mode': InterfaceModeChoices.MODE_ACCESS}
params = {'mode': [InterfaceModeChoices.MODE_ACCESS]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_description(self):
@@ -6450,15 +6572,23 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests):
addresses = IPAddress.objects.filter(address__family=4)
params = {'primary_ip4_id': [addresses[0].pk, addresses[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'primary_ip4': [str(addresses[0].address), str(addresses[1].address)]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'primary_ip4_id': [addresses[2].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
params = {'primary_ip4': [str(addresses[2].address)]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
def test_primary_ip6(self):
addresses = IPAddress.objects.filter(address__family=6)
params = {'primary_ip6_id': [addresses[0].pk, addresses[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'primary_ip6': [str(addresses[0].address), str(addresses[1].address)]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'primary_ip6_id': [addresses[2].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
params = {'primary_ip6': [str(addresses[2].address)]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
class MACAddressTestCase(TestCase, ChangeLoggedFilterSetTests):

View File

@@ -1,8 +1,15 @@
from django.test import TestCase
from dcim.choices import DeviceFaceChoices, DeviceStatusChoices, InterfaceTypeChoices
from dcim.choices import (
DeviceFaceChoices,
DeviceStatusChoices,
InterfaceModeChoices,
InterfaceTypeChoices,
PortTypeChoices,
)
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 +124,68 @@ class DeviceTestCase(TestCase):
self.assertIn('position', form.errors)
class LabelTestCase(TestCase):
class FrontPortTestCase(TestCase):
@classmethod
def setUpTestData(cls):
cls.device = create_test_device('Panel Device 1')
cls.rear_ports = (
RearPort(name='RearPort1', device=cls.device, type=PortTypeChoices.TYPE_8P8C),
RearPort(name='RearPort2', device=cls.device, type=PortTypeChoices.TYPE_8P8C),
RearPort(name='RearPort3', device=cls.device, type=PortTypeChoices.TYPE_8P8C),
RearPort(name='RearPort4', device=cls.device, type=PortTypeChoices.TYPE_8P8C),
)
RearPort.objects.bulk_create(cls.rear_ports)
def test_front_port_label_count_valid(self):
"""
Test that generating an equal number of names and labels passes form validation.
"""
front_port_data = {
'device': self.device.pk,
'name': 'FrontPort[1-4]',
'label': 'Port[1-4]',
'type': PortTypeChoices.TYPE_8P8C,
'rear_port': [f'{rear_port.pk}:1' for rear_port in self.rear_ports],
}
form = FrontPortCreateForm(front_port_data)
self.assertTrue(form.is_valid())
def test_front_port_label_count_mismatch(self):
"""
Check that attempting to generate a differing number of names and labels results in a validation error.
"""
bad_front_port_data = {
'device': self.device.pk,
'name': 'FrontPort[1-4]',
'label': 'Port[1-2]',
'type': PortTypeChoices.TYPE_8P8C,
'rear_port': [f'{rear_port.pk}:1' for rear_port in self.rear_ports],
}
form = FrontPortCreateForm(bad_front_port_data)
self.assertFalse(form.is_valid())
self.assertIn('label', form.errors)
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 +215,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

@@ -1205,6 +1205,13 @@ front-ports:
- name: Front Port 3
type: 8p8c
rear_port: Rear Port 3
module-bays:
- name: Module Bay 1
position: 1
- name: Module Bay 2
position: 2
- name: Module Bay 3
position: 3
"""
# Create the manufacturer
@@ -1222,6 +1229,7 @@ front-ports:
'dcim.add_interfacetemplate',
'dcim.add_frontporttemplate',
'dcim.add_rearporttemplate',
'dcim.add_modulebaytemplate',
)
form_data = {
@@ -1276,6 +1284,11 @@ front-ports:
self.assertEqual(fp1.rear_port, rp1)
self.assertEqual(fp1.rear_port_position, 1)
self.assertEqual(module_type.modulebaytemplates.count(), 3)
mb1 = ModuleBayTemplate.objects.first()
self.assertEqual(mb1.name, 'Module Bay 1')
self.assertEqual(mb1.position, '1')
def test_export_objects(self):
url = reverse('dcim:moduletype_list')
self.add_permissions('dcim.view_moduletype')

View File

@@ -13,7 +13,7 @@ from django.views.generic import View
from circuits.models import Circuit, CircuitTermination
from extras.views import ObjectConfigContextView, ObjectRenderConfigView
from ipam.models import ASN, IPAddress, Prefix, VLANGroup
from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
from netbox.views import generic
@@ -23,6 +23,7 @@ from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.permissions import get_permission_for_model
from utilities.query import count_related
from utilities.query_functions import CollateAsChar
from utilities.request import safe_for_redirect
from utilities.views import (
GetRelatedModelsMixin, GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view
)
@@ -237,7 +238,7 @@ class RegionView(GetRelatedModelsMixin, generic.ObjectView):
'related_models': self.get_related_models(
request,
regions,
omit=(Cluster, Prefix, WirelessLAN),
omit=(Cluster, CircuitTermination, Prefix, WirelessLAN),
extra=(
(Location.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
(Rack.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
@@ -247,8 +248,19 @@ class RegionView(GetRelatedModelsMixin, generic.ObjectView):
).distinct(),
'region_id'
),
(
VLANGroup.objects.restrict(request.user, 'view').filter(
scope_type=ContentType.objects.get_for_model(Region),
scope_id__in=regions
).distinct(),
'region'
),
# Handle these relations manually to avoid erroneous filter name resolution
(
CircuitTermination.objects.restrict(request.user, 'view').filter(_region__in=regions),
'region_id'
),
(Cluster.objects.restrict(request.user, 'view').filter(_region__in=regions), 'region_id'),
(Prefix.objects.restrict(request.user, 'view').filter(_region__in=regions), 'region_id'),
(WirelessLAN.objects.restrict(request.user, 'view').filter(_region__in=regions), 'region_id'),
@@ -269,7 +281,7 @@ class RegionDeleteView(generic.ObjectDeleteView):
queryset = Region.objects.all()
@register_model_view(Region, 'bulk_import', detail=False)
@register_model_view(Region, 'bulk_import', path='import', detail=False)
class RegionBulkImportView(generic.BulkImportView):
queryset = Region.objects.all()
model_form = forms.RegionImportForm
@@ -336,10 +348,29 @@ class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView):
'related_models': self.get_related_models(
request,
groups,
omit=(Cluster, Prefix, WirelessLAN),
omit=(Cluster, CircuitTermination, Prefix, WirelessLAN),
extra=(
(Location.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
(Rack.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
(Device.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
(VLAN.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
(
ASN.objects.restrict(request.user, 'view').filter(
sites__group__in=groups
).distinct(),
'site_group_id'),
(
VirtualMachine.objects.restrict(request.user, 'view').filter(
site__group__in=groups),
'site_group_id'
),
(
VLANGroup.objects.restrict(request.user, 'view').filter(
scope_type=ContentType.objects.get_for_model(SiteGroup),
scope_id__in=groups
).distinct(),
'site_group'
),
(
Circuit.objects.restrict(request.user, 'view').filter(
terminations___site_group=instance
@@ -348,6 +379,10 @@ class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView):
),
# Handle these relations manually to avoid erroneous filter name resolution
(
CircuitTermination.objects.restrict(request.user, 'view').filter(_site_group__in=groups),
'site_group_id'
),
(
Cluster.objects.restrict(request.user, 'view').filter(_site_group__in=groups),
'site_group_id'
@@ -377,7 +412,7 @@ class SiteGroupDeleteView(generic.ObjectDeleteView):
queryset = SiteGroup.objects.all()
@register_model_view(SiteGroup, 'bulk_import', detail=False)
@register_model_view(SiteGroup, 'bulk_import', path='import', detail=False)
class SiteGroupBulkImportView(generic.BulkImportView):
queryset = SiteGroup.objects.all()
model_form = forms.SiteGroupImportForm
@@ -455,6 +490,7 @@ class SiteView(GetRelatedModelsMixin, generic.ObjectView):
(Cluster.objects.restrict(request.user, 'view').filter(_site=instance), 'site_id'),
(Prefix.objects.restrict(request.user, 'view').filter(_site=instance), 'site_id'),
(WirelessLAN.objects.restrict(request.user, 'view').filter(_site=instance), 'site_id'),
(CircuitTermination.objects.restrict(request.user, 'view').filter(_site=instance), 'site_id'),
),
),
}
@@ -472,7 +508,7 @@ class SiteDeleteView(generic.ObjectDeleteView):
queryset = Site.objects.all()
@register_model_view(Site, 'bulk_import', detail=False)
@register_model_view(Site, 'bulk_import', path='import', detail=False)
class SiteBulkImportView(generic.BulkImportView):
queryset = Site.objects.all()
model_form = forms.SiteImportForm
@@ -505,18 +541,24 @@ class SiteContactsView(ObjectContactsView):
@register_model_view(Location, 'list', path='', detail=False)
class LocationListView(generic.ObjectListView):
queryset = Location.objects.add_related_count(
Location.objects.add_related_count(
Location.objects.all(),
Device,
'location',
'device_count',
cumulative=True
),
Rack,
'location',
'rack_count',
cumulative=True
)
Location.objects.add_related_count(
Location.objects.add_related_count(
Location.objects.all(),
Device,
'location',
'device_count',
cumulative=True
),
Rack,
'location',
'rack_count',
cumulative=True
),
VLANGroup,
'location',
'vlangroup_count',
cumulative=True
)
filterset = filtersets.LocationFilterSet
filterset_form = forms.LocationFilterForm
table = tables.LocationTable
@@ -528,11 +570,12 @@ class LocationView(GetRelatedModelsMixin, generic.ObjectView):
def get_extra_context(self, request, instance):
locations = instance.get_descendants(include_self=True)
location_content_type = ContentType.objects.get_for_model(instance)
return {
'related_models': self.get_related_models(
request,
locations,
omit=[CableTermination, Cluster, Prefix, WirelessLAN],
omit=[CableTermination, CircuitTermination, Cluster, Prefix, WirelessLAN],
extra=(
(
Circuit.objects.restrict(request.user, 'view').filter(
@@ -542,9 +585,15 @@ class LocationView(GetRelatedModelsMixin, generic.ObjectView):
),
# Handle these relations manually to avoid erroneous filter name resolution
(
CircuitTermination.objects.restrict(request.user, 'view').filter(_location=instance),
'location_id'
),
(Cluster.objects.restrict(request.user, 'view').filter(_location=instance), 'location_id'),
(Prefix.objects.restrict(request.user, 'view').filter(_location=instance), 'location_id'),
(WirelessLAN.objects.restrict(request.user, 'view').filter(_location=instance), 'location_id'),
(VLANGroup.objects.restrict(request.user, 'view').filter(
scope_type_id=location_content_type.id, scope_id=instance.id), 'location'),
),
),
}
@@ -562,7 +611,7 @@ class LocationDeleteView(generic.ObjectDeleteView):
queryset = Location.objects.all()
@register_model_view(Location, 'bulk_import', detail=False)
@register_model_view(Location, 'bulk_import', path='import', detail=False)
class LocationBulkImportView(generic.BulkImportView):
queryset = Location.objects.all()
model_form = forms.LocationImportForm
@@ -636,7 +685,7 @@ class RackRoleDeleteView(generic.ObjectDeleteView):
queryset = RackRole.objects.all()
@register_model_view(RackRole, 'bulk_import', detail=False)
@register_model_view(RackRole, 'bulk_import', path='import', detail=False)
class RackRoleBulkImportView(generic.BulkImportView):
queryset = RackRole.objects.all()
model_form = forms.RackRoleImportForm
@@ -697,7 +746,7 @@ class RackTypeDeleteView(generic.ObjectDeleteView):
queryset = RackType.objects.all()
@register_model_view(RackType, 'bulk_import', detail=False)
@register_model_view(RackType, 'bulk_import', path='import', detail=False)
class RackTypeBulkImportView(generic.BulkImportView):
queryset = RackType.objects.all()
model_form = forms.RackTypeImportForm
@@ -805,7 +854,18 @@ class RackView(GetRelatedModelsMixin, generic.ObjectView):
])
return {
'related_models': self.get_related_models(request, instance, [CableTermination]),
'related_models': self.get_related_models(
request,
instance,
omit=(CableTermination,),
extra=(
(
VLANGroup.objects.restrict(request.user, 'view').filter(
scope_type=ContentType.objects.get_for_model(Rack),
scope_id=instance.pk
), 'rack'),
),
),
'next_rack': next_rack,
'prev_rack': prev_rack,
'svg_extra': svg_extra,
@@ -865,7 +925,7 @@ class RackDeleteView(generic.ObjectDeleteView):
queryset = Rack.objects.all()
@register_model_view(Rack, 'bulk_import', detail=False)
@register_model_view(Rack, 'bulk_import', path='import', detail=False)
class RackBulkImportView(generic.BulkImportView):
queryset = Rack.objects.all()
model_form = forms.RackImportForm
@@ -927,7 +987,7 @@ class RackReservationDeleteView(generic.ObjectDeleteView):
queryset = RackReservation.objects.all()
@register_model_view(RackReservation, 'bulk_import', detail=False)
@register_model_view(RackReservation, 'bulk_import', path='import', detail=False)
class RackReservationImportView(generic.BulkImportView):
queryset = RackReservation.objects.all()
model_form = forms.RackReservationImportForm
@@ -998,7 +1058,7 @@ class ManufacturerDeleteView(generic.ObjectDeleteView):
queryset = Manufacturer.objects.all()
@register_model_view(Manufacturer, 'bulk_import', detail=False)
@register_model_view(Manufacturer, 'bulk_import', path='import', detail=False)
class ManufacturerBulkImportView(generic.BulkImportView):
queryset = Manufacturer.objects.all()
model_form = forms.ManufacturerImportForm
@@ -1224,7 +1284,7 @@ class DeviceTypeInventoryItemsView(DeviceTypeComponentsView):
)
@register_model_view(DeviceType, 'bulk_import', detail=False)
@register_model_view(DeviceType, 'bulk_import', path='import', detail=False)
class DeviceTypeImportView(generic.BulkImportView):
additional_permissions = [
'dcim.add_devicetype',
@@ -1438,7 +1498,7 @@ class ModuleTypeModuleBaysView(ModuleTypeComponentsView):
)
@register_model_view(ModuleType, 'bulk_import', detail=False)
@register_model_view(ModuleType, 'bulk_import', path='import', detail=False)
class ModuleTypeImportView(generic.BulkImportView):
additional_permissions = [
'dcim.add_moduletype',
@@ -1449,6 +1509,7 @@ class ModuleTypeImportView(generic.BulkImportView):
'dcim.add_interfacetemplate',
'dcim.add_frontporttemplate',
'dcim.add_rearporttemplate',
'dcim.add_modulebaytemplate',
]
queryset = ModuleType.objects.all()
model_form = forms.ModuleTypeImportForm
@@ -1460,6 +1521,7 @@ class ModuleTypeImportView(generic.BulkImportView):
'interfaces': forms.InterfaceTemplateImportForm,
'rear-ports': forms.RearPortTemplateImportForm,
'front-ports': forms.FrontPortTemplateImportForm,
'module-bays': forms.ModuleBayTemplateImportForm,
}
def prep_related_object_data(self, parent, data):
@@ -1934,7 +1996,7 @@ class DeviceRoleDeleteView(generic.ObjectDeleteView):
queryset = DeviceRole.objects.all()
@register_model_view(DeviceRole, 'bulk_import', detail=False)
@register_model_view(DeviceRole, 'bulk_import', path='import', detail=False)
class DeviceRoleBulkImportView(generic.BulkImportView):
queryset = DeviceRole.objects.all()
model_form = forms.DeviceRoleImportForm
@@ -1998,7 +2060,7 @@ class PlatformDeleteView(generic.ObjectDeleteView):
queryset = Platform.objects.all()
@register_model_view(Platform, 'bulk_import', detail=False)
@register_model_view(Platform, 'bulk_import', path='import', detail=False)
class PlatformBulkImportView(generic.BulkImportView):
queryset = Platform.objects.all()
model_form = forms.PlatformImportForm
@@ -2025,7 +2087,7 @@ class PlatformBulkDeleteView(generic.BulkDeleteView):
@register_model_view(Device, 'list', path='', detail=False)
class DeviceListView(generic.ObjectListView):
queryset = Device.objects.all()
queryset = Device.objects.select_related('virtual_chassis')
filterset = filtersets.DeviceFilterSet
filterset_form = forms.DeviceFilterForm
table = tables.DeviceTable
@@ -2281,7 +2343,7 @@ class DeviceVirtualMachinesView(generic.ObjectChildrenView):
return self.child_model.objects.restrict(request.user, 'view').filter(cluster=parent.cluster, device=parent)
@register_model_view(Device, 'bulk_import', detail=False)
@register_model_view(Device, 'bulk_import', path='import', detail=False)
class DeviceBulkImportView(generic.BulkImportView):
queryset = Device.objects.all()
model_form = forms.DeviceImportForm
@@ -2359,7 +2421,7 @@ class ModuleDeleteView(generic.ObjectDeleteView):
queryset = Module.objects.all()
@register_model_view(Module, 'bulk_import', detail=False)
@register_model_view(Module, 'bulk_import', path='import', detail=False)
class ModuleBulkImportView(generic.BulkImportView):
queryset = Module.objects.all()
model_form = forms.ModuleImportForm
@@ -2420,7 +2482,7 @@ class ConsolePortDeleteView(generic.ObjectDeleteView):
queryset = ConsolePort.objects.all()
@register_model_view(ConsolePort, 'bulk_import', detail=False)
@register_model_view(ConsolePort, 'bulk_import', path='import', detail=False)
class ConsolePortBulkImportView(generic.BulkImportView):
queryset = ConsolePort.objects.all()
model_form = forms.ConsolePortImportForm
@@ -2495,7 +2557,7 @@ class ConsoleServerPortDeleteView(generic.ObjectDeleteView):
queryset = ConsoleServerPort.objects.all()
@register_model_view(ConsoleServerPort, 'bulk_import', detail=False)
@register_model_view(ConsoleServerPort, 'bulk_import', path='import', detail=False)
class ConsoleServerPortBulkImportView(generic.BulkImportView):
queryset = ConsoleServerPort.objects.all()
model_form = forms.ConsoleServerPortImportForm
@@ -2570,7 +2632,7 @@ class PowerPortDeleteView(generic.ObjectDeleteView):
queryset = PowerPort.objects.all()
@register_model_view(PowerPort, 'bulk_import', detail=False)
@register_model_view(PowerPort, 'bulk_import', path='import', detail=False)
class PowerPortBulkImportView(generic.BulkImportView):
queryset = PowerPort.objects.all()
model_form = forms.PowerPortImportForm
@@ -2645,7 +2707,7 @@ class PowerOutletDeleteView(generic.ObjectDeleteView):
queryset = PowerOutlet.objects.all()
@register_model_view(PowerOutlet, 'bulk_import', detail=False)
@register_model_view(PowerOutlet, 'bulk_import', path='import', detail=False)
class PowerOutletBulkImportView(generic.BulkImportView):
queryset = PowerOutlet.objects.all()
model_form = forms.PowerOutletImportForm
@@ -2777,7 +2839,7 @@ class InterfaceDeleteView(generic.ObjectDeleteView):
queryset = Interface.objects.all()
@register_model_view(Interface, 'bulk_import', detail=False)
@register_model_view(Interface, 'bulk_import', path='import', detail=False)
class InterfaceBulkImportView(generic.BulkImportView):
queryset = Interface.objects.all()
model_form = forms.InterfaceImportForm
@@ -2863,7 +2925,7 @@ class FrontPortDeleteView(generic.ObjectDeleteView):
queryset = FrontPort.objects.all()
@register_model_view(FrontPort, 'bulk_import', detail=False)
@register_model_view(FrontPort, 'bulk_import', path='import', detail=False)
class FrontPortBulkImportView(generic.BulkImportView):
queryset = FrontPort.objects.all()
model_form = forms.FrontPortImportForm
@@ -2938,7 +3000,7 @@ class RearPortDeleteView(generic.ObjectDeleteView):
queryset = RearPort.objects.all()
@register_model_view(RearPort, 'bulk_import', detail=False)
@register_model_view(RearPort, 'bulk_import', path='import', detail=False)
class RearPortBulkImportView(generic.BulkImportView):
queryset = RearPort.objects.all()
model_form = forms.RearPortImportForm
@@ -3013,7 +3075,7 @@ class ModuleBayDeleteView(generic.ObjectDeleteView):
queryset = ModuleBay.objects.all()
@register_model_view(ModuleBay, 'bulk_import', detail=False)
@register_model_view(ModuleBay, 'bulk_import', path='import', detail=False)
class ModuleBayBulkImportView(generic.BulkImportView):
queryset = ModuleBay.objects.all()
model_form = forms.ModuleBayImportForm
@@ -3160,7 +3222,7 @@ class DeviceBayDepopulateView(generic.ObjectEditView):
})
@register_model_view(DeviceBay, 'bulk_import', detail=False)
@register_model_view(DeviceBay, 'bulk_import', path='import', detail=False)
class DeviceBayBulkImportView(generic.BulkImportView):
queryset = DeviceBay.objects.all()
model_form = forms.DeviceBayImportForm
@@ -3226,7 +3288,7 @@ class InventoryItemDeleteView(generic.ObjectDeleteView):
queryset = InventoryItem.objects.all()
@register_model_view(InventoryItem, 'bulk_import', detail=False)
@register_model_view(InventoryItem, 'bulk_import', path='import', detail=False)
class InventoryItemBulkImportView(generic.BulkImportView):
queryset = InventoryItem.objects.all()
model_form = forms.InventoryItemImportForm
@@ -3307,7 +3369,7 @@ class InventoryItemRoleDeleteView(generic.ObjectDeleteView):
queryset = InventoryItemRole.objects.all()
@register_model_view(InventoryItemRole, 'bulk_import', detail=False)
@register_model_view(InventoryItemRole, 'bulk_import', path='import', detail=False)
class InventoryItemRoleBulkImportView(generic.BulkImportView):
queryset = InventoryItemRole.objects.all()
model_form = forms.InventoryItemRoleImportForm
@@ -3503,7 +3565,7 @@ class CableDeleteView(generic.ObjectDeleteView):
queryset = Cable.objects.all()
@register_model_view(Cable, 'bulk_import', detail=False)
@register_model_view(Cable, 'bulk_import', path='import', detail=False)
class CableBulkImportView(generic.BulkImportView):
queryset = Cable.objects.all()
model_form = forms.CableImportForm
@@ -3732,7 +3794,7 @@ class VirtualChassisAddMemberView(ObjectPermissionRequiredMixin, GetReturnURLMix
)
))
if '_addanother' in request.POST:
if '_addanother' in request.POST and safe_for_redirect(request.get_full_path()):
return redirect(request.get_full_path())
return redirect(self.get_return_url(request, device))
@@ -3804,7 +3866,7 @@ class VirtualChassisRemoveMemberView(ObjectPermissionRequiredMixin, GetReturnURL
})
@register_model_view(VirtualChassis, 'bulk_import', detail=False)
@register_model_view(VirtualChassis, 'bulk_import', path='import', detail=False)
class VirtualChassisBulkImportView(generic.BulkImportView):
queryset = VirtualChassis.objects.all()
model_form = forms.VirtualChassisImportForm
@@ -3861,7 +3923,7 @@ class PowerPanelDeleteView(generic.ObjectDeleteView):
queryset = PowerPanel.objects.all()
@register_model_view(PowerPanel, 'bulk_import', detail=False)
@register_model_view(PowerPanel, 'bulk_import', path='import', detail=False)
class PowerPanelBulkImportView(generic.BulkImportView):
queryset = PowerPanel.objects.all()
model_form = forms.PowerPanelImportForm
@@ -3918,7 +3980,7 @@ class PowerFeedDeleteView(generic.ObjectDeleteView):
queryset = PowerFeed.objects.all()
@register_model_view(PowerFeed, 'bulk_import', detail=False)
@register_model_view(PowerFeed, 'bulk_import', path='import', detail=False)
class PowerFeedBulkImportView(generic.BulkImportView):
queryset = PowerFeed.objects.all()
model_form = forms.PowerFeedImportForm
@@ -3990,7 +4052,7 @@ class VirtualDeviceContextDeleteView(generic.ObjectDeleteView):
queryset = VirtualDeviceContext.objects.all()
@register_model_view(VirtualDeviceContext, 'bulk_import', detail=False)
@register_model_view(VirtualDeviceContext, 'bulk_import', path='import', detail=False)
class VirtualDeviceContextBulkImportView(generic.BulkImportView):
queryset = VirtualDeviceContext.objects.all()
model_form = forms.VirtualDeviceContextImportForm
@@ -4040,7 +4102,7 @@ class MACAddressDeleteView(generic.ObjectDeleteView):
queryset = MACAddress.objects.all()
@register_model_view(MACAddress, 'bulk_import', detail=False)
@register_model_view(MACAddress, 'bulk_import', path='import', detail=False)
class MACAddressBulkImportView(generic.BulkImportView):
queryset = MACAddress.objects.all()
model_form = forms.MACAddressImportForm

View File

@@ -9,6 +9,7 @@ import requests
from django import forms
from django.conf import settings
from django.core.cache import cache
from django.db.models import Model
from django.template.loader import render_to_string
from django.urls import NoReverseMatch, resolve, reverse
from django.utils.translation import gettext as _
@@ -42,6 +43,27 @@ def get_object_type_choices():
]
def object_list_widget_supports_model(model: Model) -> bool:
"""Test whether a model is supported by the ObjectListWidget
In theory there could be more than one reason why a model isn't supported by the
ObjectListWidget, although we've only identified one so far--there's no resolve-able 'list' URL
for the model. Add more tests if more conditions arise.
"""
def can_resolve_model_list_view(model: Model) -> bool:
try:
reverse(get_viewname(model, action='list'))
return True
except Exception:
return False
tests = [
can_resolve_model_list_view,
]
return all(test(model) for test in tests)
def get_bookmarks_object_type_choices():
return [
(object_type_identifier(ot), object_type_name(ot))
@@ -234,6 +256,17 @@ class ObjectListWidget(DashboardWidget):
raise forms.ValidationError(_("Invalid format. URL parameters must be passed as a dictionary."))
return data
def clean_model(self):
if model_info := self.cleaned_data['model']:
app_label, model_name = model_info.split('.')
model = ObjectType.objects.get_by_natural_key(app_label, model_name).model_class()
if not object_list_widget_supports_model(model):
raise forms.ValidationError(
_(f"Invalid model selection: {self['model'].data} is not supported.")
)
return model_info
def render(self, request):
app_label, model_name = self.config['model'].split('.')
model = ObjectType.objects.get_by_natural_key(app_label, model_name).model_class()
@@ -257,7 +290,7 @@ class ObjectListWidget(DashboardWidget):
parameters['per_page'] = page_size
parameters['embedded'] = True
if parameters:
if parameters and htmx_url is not None:
try:
htmx_url = f'{htmx_url}?{urlencode(parameters, doseq=True)}'
except ValueError:
@@ -284,7 +317,8 @@ class RSSFeedWidget(DashboardWidget):
class ConfigForm(WidgetConfigForm):
feed_url = forms.URLField(
label=_('Feed URL')
label=_('Feed URL'),
assume_scheme='https'
)
requires_internet = forms.BooleanField(
label=_('Requires external connection'),

View File

@@ -96,7 +96,7 @@ class CustomFieldChoiceSetImportForm(CSVModelForm):
class Meta:
model = CustomFieldChoiceSet
fields = (
'name', 'description', 'extra_choices', 'order_alphabetically',
'name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically',
)
def clean_extra_choices(self):

View File

@@ -14,7 +14,7 @@ from netbox.events import get_event_type_choices
from netbox.forms import NetBoxModelForm
from tenancy.models import Tenant, TenantGroup
from users.models import Group, User
from utilities.forms import add_blank_choice, get_field_value
from utilities.forms import get_field_value
from utilities.forms.fields import (
CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelChoiceField,
DynamicModelMultipleChoiceField, JSONField, SlugField,
@@ -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
@@ -673,8 +687,7 @@ class ImageAttachmentForm(forms.ModelForm):
class JournalEntryForm(NetBoxModelForm):
kind = forms.ChoiceField(
label=_('Kind'),
choices=add_blank_choice(JournalEntryKindChoices),
required=False
choices=JournalEntryKindChoices
)
comments = CommentField()

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

@@ -5,7 +5,6 @@ import requests
from django.conf import settings
from django.core.cache import cache
from django.core.management.base import BaseCommand
from django.db import DEFAULT_DB_ALIAS
from django.utils import timezone
from packaging import version
@@ -53,7 +52,7 @@ class Command(BaseCommand):
ending=""
)
self.stdout.flush()
ObjectChange.objects.filter(time__lt=cutoff)._raw_delete(using=DEFAULT_DB_ALIAS)
ObjectChange.objects.filter(time__lt=cutoff).delete()
if options['verbosity']:
self.stdout.write("Done.", self.style.SUCCESS)
elif options['verbosity']:

View File

@@ -0,0 +1,25 @@
from django.db import migrations
from extras.choices import JournalEntryKindChoices
def set_kind_default(apps, schema_editor):
"""
Set kind to "info" on any entries with no kind assigned.
"""
JournalEntry = apps.get_model('extras', 'JournalEntry')
JournalEntry.objects.filter(kind='').update(kind=JournalEntryKindChoices.KIND_INFO)
class Migration(migrations.Migration):
dependencies = [
('extras', '0122_charfield_null_choices'),
]
operations = [
migrations.RunPython(
code=set_kind_default,
reverse_code=migrations.RunPython.noop
),
]

View File

@@ -200,7 +200,7 @@ class ConfigContextModel(models.Model):
super().clean()
# Verify that JSON data is provided as an object
if self.local_context_data and type(self.local_context_data) is not dict:
if self.local_context_data is not None and type(self.local_context_data) is not dict:
raise ValidationError(
{'local_context_data': _('JSON data must be in object form. Example:') + ' {"foo": 123}'}
)

View File

@@ -9,6 +9,8 @@ from django.conf import settings
from django.contrib.postgres.fields import ArrayField
from django.core.validators import RegexValidator, ValidationError
from django.db import models
from django.db.models import F, Func, Value
from django.db.models.expressions import RawSQL
from django.urls import reverse
from django.utils.html import escape
from django.utils.safestring import mark_safe
@@ -281,12 +283,20 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
Populate initial custom field data upon either a) the creation of a new CustomField, or
b) the assignment of an existing CustomField to new object types.
"""
if self.default is None:
# We have to convert None to a JSON null for jsonb_set()
value = RawSQL("'null'::jsonb", [])
else:
value = Value(self.default, models.JSONField())
for ct in content_types:
model = ct.model_class()
instances = model.objects.exclude(**{'custom_field_data__contains': self.name})
for instance in instances:
instance.custom_field_data[self.name] = self.default
model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
ct.model_class().objects.update(
custom_field_data=Func(
F('custom_field_data'),
Value([self.name]),
value,
function='jsonb_set'
)
)
def remove_stale_data(self, content_types):
"""
@@ -295,22 +305,27 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
"""
for ct in content_types:
if model := ct.model_class():
instances = model.objects.filter(custom_field_data__has_key=self.name)
for instance in instances:
del instance.custom_field_data[self.name]
model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
model.objects.update(
custom_field_data=F('custom_field_data') - self.name
)
def rename_object_data(self, old_name, new_name):
"""
Called when a CustomField has been renamed. Updates all assigned object data.
Called when a CustomField has been renamed. Removes the original key and inserts the new
one, copying the value of the old key.
"""
for ct in self.object_types.all():
model = ct.model_class()
params = {f'custom_field_data__{old_name}__isnull': False}
instances = model.objects.filter(**params)
for instance in instances:
instance.custom_field_data[new_name] = instance.custom_field_data.pop(old_name)
model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
ct.model_class().objects.update(
custom_field_data=Func(
F('custom_field_data') - old_name,
Value([new_name]),
Func(
F('custom_field_data'),
function='jsonb_extract_path_text',
template=f"to_jsonb(%(expressions)s -> '{old_name}')"
),
function='jsonb_set')
)
def clean(self):
super().clean()
@@ -515,7 +530,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
# URL
elif self.type == CustomFieldTypeChoices.TYPE_URL:
field = LaxURLField(required=required, initial=initial)
field = LaxURLField(assume_scheme='https', required=required, initial=initial)
# JSON
elif self.type == CustomFieldTypeChoices.TYPE_JSON:
@@ -532,6 +547,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
}
if not for_csv_import:
kwargs['query_params'] = self.related_object_filter
kwargs['selector'] = True
field = field_class(**kwargs)
@@ -546,6 +562,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
}
if not for_csv_import:
kwargs['query_params'] = self.related_object_filter
kwargs['selector'] = True
field = field_class(**kwargs)

View File

@@ -117,6 +117,15 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
def __str__(self):
return self.python_name
@property
def ordered_scripts(self):
script_objects = {s.name: s for s in self.scripts.all()}
ordered = [
script_objects.pop(sc) for sc in self.module_scripts.keys() if sc in script_objects
]
ordered.extend(script_objects.values())
return ordered
@property
def module_scripts(self):

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,
})
@@ -525,14 +528,9 @@ class BaseScript:
"""
Return data from a YAML file
"""
try:
from yaml import CLoader as Loader
except ImportError:
from yaml import Loader
file_path = os.path.join(settings.SCRIPTS_ROOT, filename)
with open(file_path, 'r') as datafile:
data = yaml.load(datafile, Loader=Loader)
data = yaml.load(datafile, Loader=yaml.SafeLoader)
return data

View File

@@ -5,6 +5,8 @@ from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from extras.models import *
from core.tables import JobTable
from core.models import Job
from netbox.constants import EMPTY_TABLE_TEXT
from netbox.events import get_event_text
from netbox.tables import BaseTable, NetBoxTable, columns
@@ -26,6 +28,7 @@ __all__ = (
'SavedFilterTable',
'ReportResultsTable',
'ScriptResultsTable',
'ScriptJobTable',
'SubscriptionTable',
'TaggedItemTable',
'TagTable',
@@ -498,13 +501,16 @@ class ConfigContextTable(NetBoxTable):
orderable=False,
verbose_name=_('Synced')
)
tags = columns.TagColumn(
url_name='extras:configcontext_list'
)
class Meta(NetBoxTable.Meta):
model = ConfigContext
fields = (
'pk', 'id', 'name', 'weight', 'is_active', 'is_synced', 'description', 'regions', 'sites', 'locations',
'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants',
'data_source', 'data_file', 'data_synced', 'created', 'last_updated',
'data_source', 'data_file', 'data_synced', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'weight', 'is_active', 'is_synced', 'description')
@@ -635,6 +641,23 @@ class ScriptResultsTable(BaseTable):
return format_html("<a href='{}'>{}</a>", value, value)
class ScriptJobTable(JobTable):
id = tables.TemplateColumn(
template_code="""<a href="{% url 'extras:script_result' job_pk=record.pk %}">{{ record.id }}</a>""",
verbose_name=_('ID'),
)
class Meta(NetBoxTable.Meta):
model = Job
fields = (
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'scheduled', 'interval', 'started',
'completed', 'user', 'error', 'job_id',
)
default_columns = (
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user',
)
class ReportResultsTable(BaseTable):
index = tables.Column(
verbose_name=_('Line')

View File

@@ -0,0 +1,48 @@
from django.test import tag, TestCase
from extras.dashboard.widgets import ObjectListWidget
class ObjectListWidgetTests(TestCase):
def test_widget_config_form_validates_model(self):
model_info = 'extras.notification'
form = ObjectListWidget.ConfigForm({'model': model_info})
self.assertFalse(form.is_valid())
@tag('regression')
def test_widget_fails_gracefully(self):
"""
Example:
'2829fd9b-5dee-4c9a-81f2-5bd84c350a27': {
'class': 'extras.ObjectListWidget',
'color': 'indigo',
'title': 'Object List',
'config': {
'model': 'extras.notification',
'page_size': None,
'url_params': None
}
}
"""
config = {
# 'class': 'extras.ObjectListWidget', # normally popped off, left for clarity
'color': 'yellow',
'title': 'this should fail',
'config': {
'model': 'extras.notification',
'page_size': None,
'url_params': None,
},
}
class Request:
class User:
def has_perm(self, *args, **kwargs):
return True
user = User()
mock_request = Request()
widget = ObjectListWidget(id='2829fd9b-5dee-4c9a-81f2-5bd84c350a27', **config)
rendered = widget.render(mock_request)
self.assertTrue('Unable to load content. Invalid view name:' in rendered)

View File

@@ -1,3 +1,4 @@
from django.forms import ValidationError
from django.test import TestCase
from core.models import ObjectType
@@ -478,3 +479,30 @@ class ConfigContextTest(TestCase):
annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data()
self.assertEqual(ConfigContext.objects.get_for_object(device).count(), 2)
self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context())
def test_valid_local_context_data(self):
device = Device.objects.first()
device.local_context_data = None
device.clean()
device.local_context_data = {"foo": "bar"}
device.clean()
def test_invalid_local_context_data(self):
device = Device.objects.first()
device.local_context_data = ""
with self.assertRaises(ValidationError):
device.clean()
device.local_context_data = 0
with self.assertRaises(ValidationError):
device.clean()
device.local_context_data = False
with self.assertRaises(ValidationError):
device.clean()
device.local_context_data = 'foo'
with self.assertRaises(ValidationError):
device.clean()

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

@@ -15,7 +15,6 @@ from jinja2.exceptions import TemplateError
from core.choices import ManagedFileRootPathChoices
from core.forms import ManagedFileForm
from core.models import Job
from core.tables import JobTable
from dcim.models import Device, DeviceRole, Platform
from extras.choices import LogLevelChoices
from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
@@ -36,7 +35,7 @@ from virtualization.models import VirtualMachine
from . import filtersets, forms, tables
from .constants import LOG_LEVEL_RANK
from .models import *
from .tables import ReportResultsTable, ScriptResultsTable
from .tables import ReportResultsTable, ScriptResultsTable, ScriptJobTable
#
@@ -83,7 +82,7 @@ class CustomFieldDeleteView(generic.ObjectDeleteView):
queryset = CustomField.objects.select_related('choice_set')
@register_model_view(CustomField, 'bulk_import', detail=False)
@register_model_view(CustomField, 'bulk_import', path='import', detail=False)
class CustomFieldBulkImportView(generic.BulkImportView):
queryset = CustomField.objects.select_related('choice_set')
model_form = forms.CustomFieldImportForm
@@ -152,7 +151,7 @@ class CustomFieldChoiceSetDeleteView(generic.ObjectDeleteView):
queryset = CustomFieldChoiceSet.objects.all()
@register_model_view(CustomFieldChoiceSet, 'bulk_import', detail=False)
@register_model_view(CustomFieldChoiceSet, 'bulk_import', path='import', detail=False)
class CustomFieldChoiceSetBulkImportView(generic.BulkImportView):
queryset = CustomFieldChoiceSet.objects.all()
model_form = forms.CustomFieldChoiceSetImportForm
@@ -202,7 +201,7 @@ class CustomLinkDeleteView(generic.ObjectDeleteView):
queryset = CustomLink.objects.all()
@register_model_view(CustomLink, 'bulk_import', detail=False)
@register_model_view(CustomLink, 'bulk_import', path='import', detail=False)
class CustomLinkBulkImportView(generic.BulkImportView):
queryset = CustomLink.objects.all()
model_form = forms.CustomLinkImportForm
@@ -257,7 +256,7 @@ class ExportTemplateDeleteView(generic.ObjectDeleteView):
queryset = ExportTemplate.objects.all()
@register_model_view(ExportTemplate, 'bulk_import', detail=False)
@register_model_view(ExportTemplate, 'bulk_import', path='import', detail=False)
class ExportTemplateBulkImportView(generic.BulkImportView):
queryset = ExportTemplate.objects.all()
model_form = forms.ExportTemplateImportForm
@@ -334,7 +333,7 @@ class SavedFilterDeleteView(SavedFilterMixin, generic.ObjectDeleteView):
queryset = SavedFilter.objects.all()
@register_model_view(SavedFilter, 'bulk_import', detail=False)
@register_model_view(SavedFilter, 'bulk_import', path='import', detail=False)
class SavedFilterBulkImportView(SavedFilterMixin, generic.BulkImportView):
queryset = SavedFilter.objects.all()
model_form = forms.SavedFilterImportForm
@@ -415,7 +414,7 @@ class NotificationGroupDeleteView(generic.ObjectDeleteView):
queryset = NotificationGroup.objects.all()
@register_model_view(NotificationGroup, 'bulk_import', detail=False)
@register_model_view(NotificationGroup, 'bulk_import', path='import', detail=False)
class NotificationGroupBulkImportView(generic.BulkImportView):
queryset = NotificationGroup.objects.all()
model_form = forms.NotificationGroupImportForm
@@ -561,7 +560,7 @@ class WebhookDeleteView(generic.ObjectDeleteView):
queryset = Webhook.objects.all()
@register_model_view(Webhook, 'bulk_import', detail=False)
@register_model_view(Webhook, 'bulk_import', path='import', detail=False)
class WebhookBulkImportView(generic.BulkImportView):
queryset = Webhook.objects.all()
model_form = forms.WebhookImportForm
@@ -611,7 +610,7 @@ class EventRuleDeleteView(generic.ObjectDeleteView):
queryset = EventRule.objects.all()
@register_model_view(EventRule, 'bulk_import', detail=False)
@register_model_view(EventRule, 'bulk_import', path='import', detail=False)
class EventRuleBulkImportView(generic.BulkImportView):
queryset = EventRule.objects.all()
model_form = forms.EventRuleImportForm
@@ -684,7 +683,7 @@ class TagDeleteView(generic.ObjectDeleteView):
queryset = Tag.objects.all()
@register_model_view(Tag, 'bulk_import', detail=False)
@register_model_view(Tag, 'bulk_import', path='import', detail=False)
class TagBulkImportView(generic.BulkImportView):
queryset = Tag.objects.all()
model_form = forms.TagImportForm
@@ -860,7 +859,7 @@ class ConfigTemplateDeleteView(generic.ObjectDeleteView):
queryset = ConfigTemplate.objects.all()
@register_model_view(ConfigTemplate, 'bulk_import', detail=False)
@register_model_view(ConfigTemplate, 'bulk_import', path='import', detail=False)
class ConfigTemplateBulkImportView(generic.BulkImportView):
queryset = ConfigTemplate.objects.all()
model_form = forms.ConfigTemplateImportForm
@@ -1039,7 +1038,7 @@ class JournalEntryDeleteView(generic.ObjectDeleteView):
return reverse(viewname, kwargs={'pk': obj.pk})
@register_model_view(JournalEntry, 'bulk_import', detail=False)
@register_model_view(JournalEntry, 'bulk_import', path='import', detail=False)
class JournalEntryBulkImportView(generic.BulkImportView):
queryset = JournalEntry.objects.all()
model_form = forms.JournalEntryImportForm
@@ -1098,8 +1097,8 @@ class DashboardWidgetAddView(LoginRequiredMixin, View):
if not request.htmx:
return redirect('home')
initial = request.GET or {
'widget_class': 'extras.NoteWidget',
initial = {
'widget_class': request.GET.get('widget_class') or 'extras.NoteWidget',
}
widget_form = DashboardWidgetAddForm(initial=initial)
widget_name = get_field_value(widget_form, 'widget_class')
@@ -1251,6 +1250,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
@@ -1343,7 +1350,7 @@ class ScriptJobsView(BaseScriptView):
def get(self, request, **kwargs):
script = self.get_object(**kwargs)
jobs_table = JobTable(
jobs_table = ScriptJobTable(
data=script.jobs.all(),
orderable=False,
user=request.user

View File

@@ -12,7 +12,8 @@ from netaddr.core import AddrFormatError
from circuits.models import Provider
from dcim.models import Device, Interface, Region, Site, SiteGroup
from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet
from tenancy.filtersets import TenancyFilterSet
from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
from utilities.filters import (
ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter,
)
@@ -148,7 +149,7 @@ class RIRFilterSet(OrganizationalModelFilterSet):
fields = ('id', 'name', 'slug', 'is_private', 'description')
class AggregateFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
class AggregateFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
family = django_filters.NumberFilter(
field_name='prefix',
lookup_expr='family'
@@ -231,6 +232,19 @@ class ASNFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
to_field_name='slug',
label=_('RIR (slug)'),
)
site_group_id = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='sites__group',
lookup_expr='in',
label=_('Site group (ID)'),
)
site_group = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='sites__group',
lookup_expr='in',
to_field_name='slug',
label=_('Site group (slug)'),
)
site_id = django_filters.ModelMultipleChoiceFilter(
field_name='sites',
queryset=Site.objects.all(),
@@ -276,7 +290,7 @@ class RoleFilterSet(OrganizationalModelFilterSet):
fields = ('id', 'name', 'slug', 'description', 'weight')
class PrefixFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet):
class PrefixFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet, ContactModelFilterSet):
family = django_filters.NumberFilter(
field_name='prefix',
lookup_expr='family'
@@ -337,6 +351,18 @@ class PrefixFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet):
to_field_name='rd',
label=_('VRF (RD)'),
)
vlan_group_id = django_filters.ModelMultipleChoiceFilter(
field_name='vlan__group',
queryset=VLANGroup.objects.all(),
to_field_name="id",
label=_('VLAN Group (ID)'),
)
vlan_group = django_filters.ModelMultipleChoiceFilter(
field_name='vlan__group__slug',
queryset=VLANGroup.objects.all(),
to_field_name="slug",
label=_('VLAN Group (slug)'),
)
vlan_id = django_filters.ModelMultipleChoiceFilter(
queryset=VLAN.objects.all(),
label=_('VLAN (ID)'),
@@ -430,7 +456,7 @@ class PrefixFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet):
).distinct()
class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet, ContactModelFilterSet):
family = django_filters.NumberFilter(
field_name='start_address',
lookup_expr='family'
@@ -522,7 +548,7 @@ class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
return queryset.filter(q)
class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
family = django_filters.NumberFilter(
field_name='address',
lookup_expr='family'
@@ -1136,7 +1162,7 @@ class ServiceTemplateFilterSet(NetBoxModelFilterSet):
return queryset.filter(qs_filter)
class ServiceFilterSet(NetBoxModelFilterSet):
class ServiceFilterSet(ContactModelFilterSet, NetBoxModelFilterSet):
device_id = django_filters.ModelMultipleChoiceFilter(
queryset=Device.objects.all(),
label=_('Device (ID)'),
@@ -1193,8 +1219,20 @@ class PrimaryIPFilterSet(django_filters.FilterSet):
queryset=IPAddress.objects.all(),
label=_('Primary IPv4 (ID)'),
)
primary_ip4 = django_filters.ModelMultipleChoiceFilter(
field_name='primary_ip4__address',
queryset=IPAddress.objects.all(),
to_field_name='address',
label=_('Primary IPv4 (address)'),
)
primary_ip6_id = django_filters.ModelMultipleChoiceFilter(
field_name='primary_ip6',
queryset=IPAddress.objects.all(),
label=_('Primary IPv6 (ID)'),
)
primary_ip6 = django_filters.ModelMultipleChoiceFilter(
field_name='primary_ip6__address',
queryset=IPAddress.objects.all(),
to_field_name='address',
label=_('Primary IPv6 (address)'),
)

View File

@@ -177,6 +177,13 @@ class PrefixImportForm(ScopedImportForm, NetBoxModelImportForm):
to_field_name='name',
help_text=_("VLAN's group (if any)")
)
vlan_site = CSVModelChoiceField(
label=_('VLAN Site'),
queryset=Site.objects.all(),
required=False,
to_field_name='name',
help_text=_("VLAN's site (if any)")
)
vlan = CSVModelChoiceField(
label=_('VLAN'),
queryset=VLAN.objects.all(),
@@ -200,8 +207,8 @@ class PrefixImportForm(ScopedImportForm, NetBoxModelImportForm):
class Meta:
model = Prefix
fields = (
'prefix', 'vrf', 'tenant', 'vlan_group', 'vlan', 'status', 'role', 'scope_type', 'scope_id', 'is_pool',
'mark_utilized', 'description', 'comments', 'tags',
'prefix', 'vrf', 'tenant', 'vlan_group', 'vlan_site', 'vlan', 'status', 'role', 'scope_type', 'scope_id',
'is_pool', 'mark_utilized', 'description', 'comments', 'tags',
)
labels = {
'scope_id': _('Scope ID'),
@@ -213,19 +220,19 @@ class PrefixImportForm(ScopedImportForm, NetBoxModelImportForm):
if not data:
return
site = data.get('site')
vlan_site = data.get('vlan_site')
vlan_group = data.get('vlan_group')
# Limit VLAN queryset by assigned site and/or group (if specified)
query = Q()
if site:
if vlan_site:
query |= Q(**{
f"site__{self.fields['site'].to_field_name}": site
f"site__{self.fields['vlan_site'].to_field_name}": vlan_site
})
# Don't Forget to include VLANs without a site in the filter
query |= Q(**{
f"site__{self.fields['site'].to_field_name}__isnull": True
f"site__{self.fields['vlan_site'].to_field_name}__isnull": True
})
if vlan_group:
@@ -320,6 +327,13 @@ class IPAddressImportForm(NetBoxModelImportForm):
to_field_name='name',
help_text=_('Assigned interface')
)
fhrp_group = CSVModelChoiceField(
label=_('FHRP Group'),
queryset=FHRPGroup.objects.all(),
required=False,
to_field_name='name',
help_text=_('Assigned FHRP Group name')
)
is_primary = forms.BooleanField(
label=_('Is primary'),
help_text=_('Make this the primary IP for the assigned device'),
@@ -334,8 +348,8 @@ class IPAddressImportForm(NetBoxModelImportForm):
class Meta:
model = IPAddress
fields = [
'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'is_primary',
'is_oob', 'dns_name', 'description', 'comments', 'tags',
'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'fhrp_group',
'is_primary', 'is_oob', 'dns_name', 'description', 'comments', 'tags',
]
def __init__(self, data=None, *args, **kwargs):
@@ -391,6 +405,8 @@ class IPAddressImportForm(NetBoxModelImportForm):
# Set interface assignment
if self.cleaned_data.get('interface'):
self.instance.assigned_object = self.cleaned_data['interface']
if self.cleaned_data.get('fhrp_group'):
self.instance.assigned_object = self.cleaned_data['fhrp_group']
ipaddress = super().save(*args, **kwargs)

View File

@@ -6,7 +6,7 @@ from ipam.choices import *
from ipam.constants import *
from ipam.models import *
from netbox.forms import NetBoxModelFilterSetForm
from tenancy.forms import TenancyFilterForm
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, add_blank_choice
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField
from utilities.forms.rendering import FieldSet
@@ -94,12 +94,13 @@ class RIRFilterForm(NetBoxModelFilterSetForm):
tag = TagFilterField(model)
class AggregateFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
class AggregateFilterForm(ContactModelFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm):
model = Aggregate
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('family', 'rir_id', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
)
family = forms.ChoiceField(
required=False,
@@ -141,7 +142,7 @@ class ASNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = ASN
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('rir_id', 'site_id', name=_('Assignment')),
FieldSet('rir_id', 'site_group_id', 'site_id', name=_('Assignment')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
)
rir_id = DynamicModelMultipleChoiceField(
@@ -149,6 +150,11 @@ class ASNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
required=False,
label=_('RIR')
)
site_group_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label=_('Site group')
)
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
required=False,
@@ -162,7 +168,7 @@ class RoleFilterForm(NetBoxModelFilterSetForm):
tag = TagFilterField(model)
class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
class PrefixFilterForm(ContactModelFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm, ):
model = Prefix
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
@@ -170,10 +176,11 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
'within_include', 'family', 'status', 'role_id', 'mask_length', 'is_pool', 'mark_utilized',
name=_('Addressing')
),
FieldSet('vlan_id', name=_('VLAN Assignment')),
FieldSet('vlan_group_id', 'vlan_id', name=_('VLAN Assignment')),
FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Scope')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
)
mask_length__lte = forms.IntegerField(
widget=forms.HiddenInput()
@@ -253,6 +260,11 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
vlan_group_id = DynamicModelMultipleChoiceField(
queryset=VLANGroup.objects.all(),
required=False,
label=_('VLAN Group'),
)
vlan_id = DynamicModelMultipleChoiceField(
queryset=VLAN.objects.all(),
required=False,
@@ -262,12 +274,13 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
tag = TagFilterField(model)
class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
class IPRangeFilterForm(ContactModelFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm):
model = IPRange
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('family', 'vrf_id', 'status', 'role_id', 'mark_utilized', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
)
family = forms.ChoiceField(
required=False,
@@ -301,7 +314,7 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
tag = TagFilterField(model)
class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
class IPAddressFilterForm(ContactModelFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm):
model = IPAddress
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
@@ -312,6 +325,7 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('device_id', 'virtual_machine_id', name=_('Device/VM')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
)
selector_fields = ('filter_id', 'q', 'region_id', 'group_id', 'parent', 'status', 'role')
parent = forms.CharField(
@@ -414,7 +428,7 @@ class FHRPGroupFilterForm(NetBoxModelFilterSetForm):
class VLANGroupFilterForm(NetBoxModelFilterSetForm):
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('region', 'sitegroup', 'site', 'location', 'rack', name=_('Location')),
FieldSet('region', 'site_group', 'site', 'location', 'rack', name=_('Location')),
FieldSet('cluster_group', 'cluster', name=_('Cluster')),
FieldSet('contains_vid', name=_('VLANs')),
)
@@ -424,7 +438,7 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm):
required=False,
label=_('Region')
)
sitegroup = DynamicModelMultipleChoiceField(
site_group = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label=_('Site group')
@@ -590,12 +604,13 @@ class ServiceTemplateFilterForm(NetBoxModelFilterSetForm):
tag = TagFilterField(model)
class ServiceFilterForm(ServiceTemplateFilterForm):
class ServiceFilterForm(ContactModelFilterForm, ServiceTemplateFilterForm):
model = Service
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('protocol', 'port', name=_('Attributes')),
FieldSet('device_id', 'virtual_machine_id', name=_('Assignment')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
)
device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),

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(
@@ -530,7 +538,6 @@ class FHRPGroupForm(NetBoxModelForm):
role=FHRP_PROTOCOL_ROLE_MAPPINGS.get(self.cleaned_data['protocol'], IPAddressRoleChoices.ROLE_VIP),
assigned_object=instance
)
ipaddress.populate_custom_field_defaults()
ipaddress.save()
# Check that the new IPAddress conforms with any assigned object-level permissions
@@ -833,6 +840,7 @@ class ServiceCreateForm(ServiceForm):
# Fields which may be populated from a ServiceTemplate are not required
for field in ('name', 'protocol', 'ports'):
self.fields[field].required = False
self.fields[field].widget.is_required = False
def clean(self):
super().clean()

View File

@@ -5,6 +5,7 @@ import strawberry_django
from circuits.graphql.types import ProviderType
from dcim.graphql.types import SiteType
from extras.graphql.mixins import ContactsMixin
from ipam import models
from netbox.graphql.scalars import BigInt
from netbox.graphql.types import BaseObjectType, NetBoxObjectType, OrganizationalObjectType
@@ -83,7 +84,7 @@ class ASNRangeType(NetBoxObjectType):
fields='__all__',
filters=AggregateFilter
)
class AggregateType(NetBoxObjectType, BaseIPAddressFamilyType):
class AggregateType(NetBoxObjectType, ContactsMixin, BaseIPAddressFamilyType):
prefix: str
rir: Annotated["RIRType", strawberry.lazy('ipam.graphql.types')] | None
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
@@ -120,7 +121,7 @@ class FHRPGroupAssignmentType(BaseObjectType):
exclude=('assigned_object_type', 'assigned_object_id', 'address'),
filters=IPAddressFilter
)
class IPAddressType(NetBoxObjectType, BaseIPAddressFamilyType):
class IPAddressType(NetBoxObjectType, ContactsMixin, BaseIPAddressFamilyType):
address: str
vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
@@ -144,7 +145,7 @@ class IPAddressType(NetBoxObjectType, BaseIPAddressFamilyType):
fields='__all__',
filters=IPRangeFilter
)
class IPRangeType(NetBoxObjectType):
class IPRangeType(NetBoxObjectType, ContactsMixin):
start_address: str
end_address: str
vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
@@ -157,7 +158,7 @@ class IPRangeType(NetBoxObjectType):
exclude=('scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'),
filters=PrefixFilter
)
class PrefixType(NetBoxObjectType, BaseIPAddressFamilyType):
class PrefixType(NetBoxObjectType, ContactsMixin, BaseIPAddressFamilyType):
prefix: str
vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
@@ -217,7 +218,7 @@ class RouteTargetType(NetBoxObjectType):
fields='__all__',
filters=ServiceFilter
)
class ServiceType(NetBoxObjectType):
class ServiceType(NetBoxObjectType, ContactsMixin):
ports: List[int]
device: Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')] | None
virtual_machine: Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')] | None

View File

@@ -1,4 +1,5 @@
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField, IntegerRangeField
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
@@ -6,7 +7,7 @@ from django.db import models
from django.db.backends.postgresql.psycopg_any import NumericRange
from django.utils.translation import gettext_lazy as _
from dcim.models import Interface
from dcim.models import Interface, Site, SiteGroup
from ipam.choices import *
from ipam.constants import *
from ipam.querysets import VLANQuerySet, VLANGroupQuerySet
@@ -279,12 +280,20 @@ class VLAN(PrimaryModel):
super().clean()
# Validate VLAN group (if assigned)
if self.group and self.site and self.group.scope != self.site:
raise ValidationError(
_(
"VLAN is assigned to group {group} (scope: {scope}); cannot also assign to site {site}."
).format(group=self.group, scope=self.group.scope, site=self.site)
)
if self.group and self.site and self.group.scope_type == ContentType.objects.get_for_model(Site):
if self.site != self.group.scope:
raise ValidationError(
_(
"VLAN is assigned to group {group} (scope: {scope}); cannot also assign to site {site}."
).format(group=self.group, scope=self.group.scope, site=self.site)
)
if self.group and self.site and self.group.scope_type == ContentType.objects.get_for_model(SiteGroup):
if self.site not in self.group.scope.sites.all():
raise ValidationError(
_(
"The assigned site {site} is not a member of the assigned group {group} (scope: {scope})."
).format(group=self.group, scope=self.group.scope, site=self.site)
)
# Check that the VLAN ID is permitted in the assigned group (if any)
if self.group:

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