Compare commits

...

106 Commits

Author SHA1 Message Date
Jeremy Stretch
61739a0bc5 Release v3.7.1 2024-01-17 14:37:27 -05:00
Jeremy Stretch
66db4f3874 Fixes #14827: Ensure all matching event rules are processed in response to an event 2024-01-17 14:09:06 -05:00
Jeremy Stretch
5de2dea8a6 Fixes #14816: Ensure default contact assignment ordering is consistent 2024-01-17 13:29:04 -05:00
Jeremy Stretch
621c3ccfa4 Fixes #14817: Relax required fields for IKE & IPSec models on bulk import 2024-01-17 13:28:27 -05:00
bluikko
530a15e906 Closes 14655: Document raw text configuration render
Also fix a missing character typo.
2024-01-17 10:37:52 -05:00
Jeremy Stretch
1235b496b4 Changelog for #13844, #14778, #14791, #14793 2024-01-16 13:24:55 -05:00
Jeremy Stretch
70dd8f17b6 Fixes #14778: Allow null values in CustomFieldSerializer object_type & choice_set fields 2024-01-16 13:08:02 -05:00
Jeremy Stretch
c173c26e35 Fixes #14791: Do not annotate available IPs when searching 2024-01-16 13:07:13 -05:00
Daniel Sheppard
bb806e21f7 Fixes: #13844 - Change site filter to use available_at_site instead of site_id 2024-01-16 11:28:46 -05:00
Jorik Jonker
c5cbb99bf0 fix: add missing DH group 15 (3072)
DH group 15 was not selectable in the UI, and I strongly suspect this
patch will fix that, as that particular choices was missing in
`choices.py`.

Signed-off-by: Jorik Jonker <jorik@kippendief.biz>

Fixes #14793
2024-01-16 11:05:21 -05:00
Markku Leiniö
3d941411d4 Fixes #14722: Change references to admin UI to Admin menu (#14743)
* Change references to admin UI to Admin menu

* Change also for reports and custom scripts

* Minor tweaks to help text flow better

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-01-10 16:04:46 -05:00
mmahacek
c4c1ddf68d 14660: Update webhook docs (#14661)
* 14660: Update webhook docks

* Update docs/integrations/webhooks.md

Co-authored-by: Jeff Gehlbach <jeffg@jeffg.org>

* #14660: Doc note about webhook receiver on Docker

* Cleanup & remove Docker reference (out of scope for docs)

---------

Co-authored-by: Jeff Gehlbach <jeffg@jeffg.org>
Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-01-10 15:20:36 -05:00
Jeremy Stretch
3645bd770f Add link to Transifex platform 2024-01-10 14:28:38 -05:00
Jeremy Stretch
0f4c25fe49 Changelog for #14663, #14706, #14709, #14749 2024-01-10 14:21:49 -05:00
Jeremy Stretch
2221a9d71f Fixes #14749: Remove errant translation wrapper from DeviceBay installed_device 2024-01-10 14:18:46 -05:00
Jeremy Stretch
edc2e3809d Closes #14765: Add developer documentation for updating translations 2024-01-10 14:03:25 -05:00
Jeremy Stretch
9603644ca2 Update README & UI screenshots (#14763)
* Refresh README content

* Formatting cleanup

* Add badge links

* More cleanup

* Update getting started section

* Add reference architecture diagram

* Add intro docs link

* Rearrange & expand text

* More restructuring

* Update screenshots

* Tweak image widths

* Tweak screenshots

* Add NetBox Cloud logo

* Fix wrapping

* Add titles for screenshots

* Jumping through more formatting hoops

* Final cleanup

* Add links for plugin resources
2024-01-10 13:05:01 -05:00
Jeremy Stretch
e1e198ec4f Fixes #14706: Relax one-to-one mapping of tunnel termination to IP address 2024-01-08 12:32:02 -05:00
Jeremy Stretch
5223486fd8 Fixes #14709: Correct typo in TYPE_VIRTUALMACHINE 2024-01-08 12:31:31 -05:00
Jeremy Stretch
ea5d33f358 Fixes #14663: Fix terminating to a VM interface when creating a new tunnel 2024-01-08 12:31:31 -05:00
Jeremy Stretch
c78a792ccc #14132: Annotate WebhooksMixin renaming under breaking changes 2024-01-03 10:59:32 -05:00
Abraham Vegh
109daca203 Add missing word 2024-01-02 09:01:39 -05:00
Jeremy Stretch
982ef3045d PRVB 2023-12-29 10:06:51 -05:00
Jeremy Stretch
7b90481fc9 Merge pull request #14636 from netbox-community/develop
Release v3.7.0
2023-12-29 10:02:38 -05:00
Jeremy Stretch
d99e6510e1 Release v3.7.0 2023-12-29 09:43:09 -05:00
Jeremy Stretch
7c4b939b59 Revise v3.7 release notes 2023-12-29 09:36:29 -05:00
Jeremy Stretch
c1ff74894c #14036: Update import paths in example plugin code 2023-12-29 09:21:06 -05:00
Jeremy Stretch
33af942571 Closes #14624: Add action object column to EventRuleTable 2023-12-28 15:56:22 -05:00
Jeremy Stretch
224484ebb6 Closes #14434: Add termination object filters for cables (#14617)
* Add termination object filters for cables

* Add tests for new filters
2023-12-28 15:39:14 -05:00
Jeremy Stretch
d9c1ba8972 Add translations to changelog 2023-12-28 14:58:19 -05:00
Jeremy Stretch
d930c4e36e Apply filterset & test changes for #14631 & #14629 2023-12-28 14:43:08 -05:00
Jeremy Stretch
d5c1cb0ef6 Merge branch 'develop' into feature 2023-12-28 14:20:04 -05:00
Jeremy Stretch
0c0672550a Merge pull request #14633 from netbox-community/develop
Release v3.6.9
2023-12-28 14:13:25 -05:00
Jeremy Stretch
199685d98b Release v3.6.9 2023-12-28 13:58:34 -05:00
Jeremy Stretch
3ef2db81e8 Closes #14629: Add filter tests for all q and description filters 2023-12-28 13:53:16 -05:00
Jeremy Stretch
3bacee16bd Closes #14631: Ensure description filters are available on all relevant models 2023-12-28 13:53:16 -05:00
Daniel Sheppard
45c646dcec Fixes #14482 - Fix validation error when primary IP is moved (#14514)
* Fix validation when primary IP is moved.

* Fix views test

* Work on excluding assigned_objects

* Modify clean() on model and form to properly catch error

* Fix test failure

* Fix test to check for PK

* Remove model_form check
2023-12-28 13:28:05 -05:00
Jeremy Stretch
fedcbaf4c8 Fixes #14620: Permit setting device type U height to 0 during bulk edit 2023-12-28 10:06:25 -05:00
MengYX
359c0cf3a0 Fix typo in filtersets.py
fix typo which causing exception `Cannot resolve keyword 'description_icontains' into field`
2023-12-28 08:47:43 -05:00
Jeremy Stretch
11bc460551 Update release notes 2023-12-27 17:22:04 -05:00
Jeremy Stretch
1f2f0860fe Merge branch 'develop' into feature 2023-12-27 16:34:38 -05:00
Jeremy Stretch
46b933a5aa Merge pull request #14616 from netbox-community/develop
Release v3.6.8
2023-12-27 16:12:13 -05:00
Jeremy Stretch
07da3f6d33 Release v3.6.8 2023-12-27 16:00:16 -05:00
Jeremy Stretch
4eadc8cfe4 Closes #14240: Increase min/max validation values for custom fields 2023-12-27 15:41:26 -05:00
Jeremy Stretch
0613e8e95c Fixes #14613: Fix display of current configuration parameters 2023-12-27 15:32:11 -05:00
Jeremy Stretch
113c60a44a Fixes #13909: Ignore empty choices when populating dynamic choice fields from initial data 2023-12-27 14:32:40 -05:00
Jeremy Stretch
8a237561ef Closes #14596: Match against description field when searching for devices 2023-12-27 13:49:39 -05:00
Jeremy Stretch
cc0fc03ec3 Changelog for #11039, #11816, #12731, #13606, #13649, #13812, #14532 2023-12-27 13:45:06 -05:00
Jeremy Stretch
b955751349 Fixes #14517: Ensure reservations tab is always displayed under rack view 2023-12-27 13:42:26 -05:00
Jeremy Stretch
d6c8d1581c Closes #11039: List parent prefixes under IP range view 2023-12-27 12:53:30 -05:00
Jeremy Stretch
e6642b5f5b Fixes #11816: Detach group/site validation error from group field 2023-12-27 12:51:51 -05:00
Jeremy Stretch
a67236fc3c Fixes #13812: Record data source sync failure when run via syncdatasource command 2023-12-27 12:51:03 -05:00
Jeremy Stretch
634681a72e Fixes #13606: Fix filtering by null for multiselect custom fields 2023-12-27 12:49:31 -05:00
Jeremy Stretch
031b7540b3 Fixes #13741: Update docs to correctly reflect inventory item uniqueness requirements 2023-12-26 13:35:03 -05:00
Jeremy Stretch
43909ee33f Fixes #13649: Permit zero-length cables 2023-12-26 09:27:58 -05:00
Jeremy Stretch
99467e8f66 Fixes #12731: Support custom validation for many-to-many fields (#14516)
* WIP

* Enforce custom validators during bulk edit

* Add bulk edit M2M validation test

* Clean up tests

* Add custom validation test for bulk import

* Misc cleanup
2023-12-22 10:01:05 -05:00
Jeremy Stretch
00807d1e52 Fixes #14550: Fix changing event rule action type from webhook to script (#14571)
* Fixes #14550: Fix changing event rule action type from webhook to script

* Remove action_parameters from form; set on instance under save()
2023-12-22 09:54:08 -05:00
Jeremy Stretch
0d08205ab1 Fixes #14532: Device/VM change record should accurately reflect when primary/OOB IP is deleted 2023-12-22 08:47:51 -05:00
Jeremy Stretch
c289dda649 Changelog for #14507, #14538, #14549, #14560, #14575 2023-12-21 16:36:24 -05:00
Daniel Sheppard
169207058f Update search to add note 2023-12-21 16:27:43 -05:00
Jeremy Stretch
e5c565cbf4 Closes #14119: Remove redundant check for to_objectchange() 2023-12-21 16:26:20 -05:00
Jeremy Stretch
f0b9008529 Fixes #14575: Fix display of the tags column under VDC table 2023-12-21 16:00:44 -05:00
Daniel Sheppard
8dfec7e2b2 Closes #14538 - Add available_at_site filter (#14541)
* Closes #14538 - Add available_at_site filter

* Add tests

* Fix tests
2023-12-21 15:40:57 -05:00
Markku Leiniö
c1cf037eaf Print NetBox version in upgrade.sh (#14547) 2023-12-21 15:13:40 -05:00
Azmodeszer
3f4a65cc5c added ! to safe characters 2023-12-21 15:10:38 -05:00
Jeremy Stretch
58f925c261 Closes #14503: Include additional display attributes for search indexers 2023-12-21 14:36:42 -05:00
Jeremy Stretch
326b54b7e0 Closes #14579: Add user language preference 2023-12-21 14:27:52 -05:00
Jeremy Stretch
3905ddf163 Add initial message maps for es, fr, pt, and ru 2023-12-21 13:31:16 -05:00
Jeremy Stretch
3cd2432aa1 Rebuild source messages 2023-12-21 12:58:45 -05:00
Prince Kumar
12beac4f1a fix the result of script jobs #14549 2023-12-20 15:15:02 -05:00
Jeremy Stretch
a233dc91fe Closes #14536: Enable ENFORCE_GLOBAL_UNIQUE by default 2023-12-20 11:09:40 -05:00
Jeremy Stretch
b794bd6fb8 Fixes #14499: Relax requirements for encryption/auth algorithms on IKE & IPSec proposals 2023-12-19 14:44:22 -05:00
Jeremy Stretch
96878cfca6 Closes #14551: Show assigned tunnel (if any) under interface view 2023-12-19 10:31:18 -05:00
Jeremy Stretch
25e67eb555 Merge branch 'develop' into feature 2023-12-15 16:52:42 -05:00
Jeremy Stretch
ec245b968f PRVB 2023-12-15 16:46:53 -05:00
Jeremy Stretch
f1d4011b40 Merge pull request #14542 from netbox-community/develop
Release v3.6.7
2023-12-15 16:44:46 -05:00
Jeremy Stretch
4cdc30a7c5 Release v3.6.7 2023-12-15 16:25:24 -05:00
kkthxbye
8d39181842 Fixes #12751 - Usability improvements for object selector (#14387)
* Usability improvements for object selector:
* Adds preselected filters
* Applies the filter on selection instead of requiring the search button to be pushed

* Declare selector_fields on base form class

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-12-15 16:07:15 -05:00
Jeremy Stretch
3068f2a075 Changelog for #14147, #14424, #14436, #14458 2023-12-15 15:21:38 -05:00
Arthur Hanson
224d64007a 14147 Prevent logging to Change Log when no changes are made (#14477)
* 14147 Prevent logging to Change Log when no changes are made

* 14147 add test

* 14147 add exclude_fields to serialize_object

* 14147 make skip empty default to True

* 14147 remove override of to_objectchange

* Misc cleanup

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-12-15 15:17:45 -05:00
Jeremy Stretch
c81869c795 Fixes #14533: Fix quick search under VLAN group VLANs list 2023-12-15 13:59:31 -05:00
Jeremy Stretch
929d4d2c95 Fixes #14522: Fix filtering contact assignments by group 2023-12-15 13:58:50 -05:00
Jeremy Stretch
d14e4ab52b Changelog for #13983, #14081, #14148, #14467, #14505, #14512, #14515 2023-12-14 17:12:29 -05:00
Daniel Sheppard
8a4233aca1 Update create_userconfig to receive signals from NetBoxUser model in addition to User model. 2023-12-14 17:07:57 -05:00
Jeremy Stretch
5508e125ba Fixes #14512: Omit unused queryset annotations for REST API requests using brief mode 2023-12-14 16:49:18 -05:00
Arthur Hanson
69bf1472d2 13983 Add nested arrays for extra_choices in CustomFieldChoiceSet (#14470)
* 13983 split array fields in CSV data for CustomFieldChoices

* 13983 fix help text

* 13983 update tests

* 13983 use re for split

* 13983 replace escaped chars

* 13983 fix escape handling

* 13983 fix escape handling

* 13983 fix escape handling
2023-12-14 15:18:56 -05:00
Arthur Hanson
b93735861d Fixes #14081: Fix cached counters on delete for parent-child items (#14131)
* 14081 fixed cached counters on delete for parent-child items

* Misc cleanup

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-12-12 16:53:04 -05:00
Arthur Hanson
6939ae4a47 14467 change ChoiceField separator from comma to colon (#14469)
* 14467 change ChoiceField separator from comma to colon

* 14467 fix test

* 14467 fix test

* 14467 use regex for colon detection

* 14467 update tests
2023-12-12 14:31:39 -05:00
Prince Kumar
81fa4265da add tags field in L2VPN Termination 2023-12-12 14:23:16 -05:00
Arthur Hanson
965f2de34b 14424 Remove ChangeLoggedModel from StagedChange (#14476)
* 14424 remove ChangeLoggedModel from StagedChange

* 14424 rename migration
2023-12-11 10:50:07 -05:00
Jeremy Stretch
35be4f05ef Add note to bug reports section 2023-12-11 10:10:28 -05:00
Jeremy Stretch
d428dd172c Fixes #14472: Fix display of hidden custom fields in object edit forms 2023-12-08 08:45:03 -05:00
Jeremy Stretch
2ef023a160 Changelog for #14249, #14390, #14392, #14397, #14401, #14432, #14448 2023-12-07 16:34:49 -05:00
Jeremy Stretch
9d7192202d Fixes #14392: Fix admin UI bulk actions 2023-12-07 16:31:21 -05:00
Jeremy Stretch
95a8415e2d Add deployment type to bug report template 2023-12-07 16:21:15 -05:00
Jeremy Stretch
b532435a6d Closes #14436: Add indexes for all GenericForeignKey fields (#14463)
* Closes #14436: Add PostgreSQL indexes for all GenericForeignKeys

* Add note about GFK indexes to developer docs
2023-12-07 14:02:51 -05:00
Jeremy Stretch
2d1f882724 Closes #14458: Remove the clearcache management command 2023-12-07 13:45:50 -05:00
Jeremy Stretch
e59ee3e01e Fixes #14397: Pass a mutable copy of request data when provisioning available IPs 2023-12-07 11:20:03 -05:00
Jeremy Stretch
5d2f499ffb Fixes #14432: Fix hyperlinks for global search result attributes 2023-12-07 09:52:40 -05:00
Abhimanyu Saharan
92bdaa2120 Fixes IPv6 detection from headers (#14456)
* fixes client ip detection for v6

* adds test for get_client_ip

* Employ urlparse() to strip port numbers from IPs

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-12-07 09:45:30 -05:00
Jeremy Stretch
fe3f21105c Fixes #14448: Fix exception when creating a power feed with rack and panel in different sites 2023-12-06 15:28:47 -05:00
Jeremy Stretch
32264ac3e3 Fixes #14322: Populate default custom field values when instantiating templated device components 2023-12-06 15:21:34 -05:00
Arthur
b34daeaacb 14401 review changes - remove migration 2023-12-06 15:16:03 -05:00
Arthur
d2c3a39ebb 14401 validate rack startion position > 0 2023-12-06 15:16:03 -05:00
Jeremy Stretch
d10ac9b4a7 Closes #12623: Document need for core.sync_datasource permission 2023-12-05 14:03:38 -05:00
Abhimanyu Saharan
b21ed6a334 adds optional classes parameter #14390 2023-12-05 13:51:28 -05:00
178 changed files with 61271 additions and 3175 deletions

View File

@@ -10,16 +10,25 @@ body:
installation. If you're having trouble with installation or just looking for
assistance with using NetBox, please visit our
[discussion forum](https://github.com/netbox-community/netbox/discussions) instead.
- type: dropdown
attributes:
label: Deployment Type
description: How are you running NetBox?
options:
- Self-hosted
- NetBox Cloud
validations:
required: true
- type: input
attributes:
label: NetBox version
label: NetBox Version
description: What version of NetBox are you currently running?
placeholder: v3.6.6
placeholder: v3.7.1
validations:
required: true
- type: dropdown
attributes:
label: Python version
label: Python Version
description: What version of Python are you currently running?
options:
- "3.8"

View File

@@ -7,6 +7,9 @@ contact_links:
- name: ❓ Discussion
url: https://github.com/netbox-community/netbox/discussions
about: "If you're just looking for help, try starting a discussion instead."
- name: 🌎 Correct a Translation
url: https://explore.transifex.com/netbox-community/netbox/
about: "Spot an incorrect translation? You can propose a fix on Transifex."
- name: 💡 Plugin Idea
url: https://plugin-ideas.netbox.dev
about: "Have an idea for a plugin? Head over to the ideas board!"

View File

@@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v3.6.6
placeholder: v3.7.1
validations:
required: true
- type: dropdown

View File

@@ -36,6 +36,8 @@ NetBox users are welcome to participate in either role, on stage or in the crowd
## :bug: Reporting Bugs
:warning: Bug reports are used to call attention to some unintended or unexpected behavior in NetBox, such as when an error occurs or when the result of taking some action is inconsistent with the documentation. **Bug reports may not be used to suggest new functionality**; please see "feature requests" below if that is your goal.
* First, ensure that you're running the [latest stable version](https://github.com/netbox-community/netbox/releases) of NetBox. If you're running an older version, it's likely that the bug has already been fixed.
* Next, search our [issues list](https://github.com/netbox-community/netbox/issues?q=is%3Aissue) to see if the bug you've found has already been reported. If you come across a bug report that seems to match, please click "add a reaction" in the top right corner of the issue and add a thumbs up (:thumbsup:). This will help draw more attention to it. Any comments you can add to provide additional information or context would also be much appreciated.

153
README.md
View File

@@ -1,86 +1,129 @@
<div align="center">
<img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
<p>The premier source of truth powering network automation</p>
<img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master" alt="CI status" />
<p><strong>The cornerstone of every automated network</strong></p>
<a href="https://github.com/netbox-community/netbox/releases"><img src="https://img.shields.io/github/v/release/netbox-community/netbox" alt="Latest release" /></a>
<a href="https://github.com/netbox-community/netbox/blob/master/LICENSE.txt"><img src="https://img.shields.io/badge/license-Apache_2.0-blue.svg" alt="License" /></a>
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://img.shields.io/github/contributors/netbox-community/netbox?color=blue" alt="Contributors" /></a>
<a href="https://github.com/netbox-community/netbox/stargazers"><img src="https://img.shields.io/github/stars/netbox-community/netbox?style=flat" alt="GitHub stars" /></a>
<a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-4-blue" alt="Languages supported" /></a>
<a href="https://github.com/netbox-community/netbox/actions/workflows/ci.yml"><img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master" alt="CI status" /></a>
<p></p>
</div>
NetBox is the leading solution for modeling and documenting modern networks. By
combining the traditional disciplines of IP address management (IPAM) and
datacenter infrastructure management (DCIM) with powerful APIs and extensions,
NetBox provides the ideal "source of truth" to power network automation.
Available as open source software under the Apache 2.0 license, NetBox serves
as the cornerstone for network automation in thousands of organizations.
NetBox exists to empower network engineers. Since its release in 2016, it has become the go-to solution for modeling and documenting network infrastructure for thousands of organizations worldwide. As a successor to legacy IPAM and DCIM applications, NetBox provides a cohesive, extensive, and accessible data model for all things networked. By providing a single robust user interface and programmable APIs for everything from cable maps to device configurations, NetBox serves as the central source of truth for the modern network.
* **Physical infrastructure:** Accurately model the physical world, from global regions down to individual racks of gear. Then connect everything - network, console, and power!
* **Modern IPAM:** All the standard IPAM functionality you expect, plus VRF import/export tracking, VLAN management, and overlay support.
* **Data circuits:** Confidently manage the delivery of critical circuits from various service providers, modeled seamlessly alongside your own infrastructure.
* **Power tracking:** Map the distribution of power from upstream sources to individual feeds and outlets.
* **Organization:** Manage tenant and contact assignments natively.
* **Powerful search:** Easily find anything you need using a single global search function.
* **Comprehensive logging:** Leverage both automatic change logging and user-submitted journal entries to track your network's growth over time.
* **Endless customization:** Custom fields, custom links, tags, export templates, custom validation, reports, scripts, and more!
* **Flexible permissions:** An advanced permissions systems enables very flexible delegation of permissions.
* **Integrations:** Easily connect NetBox to your other tooling via its REST & GraphQL APIs.
* **Plugins:** Not finding what you need in the core application? Try one of many community plugins - or build your own!
<p align="center">
<a href="#netboxs-role">NetBox's Role</a> |
<a href="#why-netbox">Why NetBox?</a> |
<a href="#getting-started">Getting Started</a> |
<a href="#get-involved">Get Involved</a> |
<a href="#project-stats">Project Stats</a> |
<a href="#screenshots">Screenshots</a>
</p>
![Screenshot of NetBox UI](docs/media/screenshots/netbox-ui.png "NetBox UI")
<p align="center">
<img src="docs/media/screenshots/home-light.png" width="600" alt="NetBox user interface screenshot" />
</p>
## NetBox's Role
NetBox functions as the **source of truth** for your network infrastructure. Its job is to define and validate the _intended state_ of all network components and resources. NetBox does not interact with network nodes directly; rather, it makes this data available programmatically to purpose-built automation, monitoring, and assurance tools. This separation of duties enables the construction of a robust yet flexible automation system.
<p align="center">
<img src="docs/media/misc/reference_architecture.png" alt="Reference network automation architecture" />
</p>
The diagram above illustrates the recommended deployment architecture for an automated network, leveraging NetBox as the central authority for network state. This approach allows your team to swap out individual tools to meet changing needs while retaining a predictable, modular workflow.
## Why NetBox?
### Comprehensive Data Model
Racks, devices, cables, IP addresses, VLANs, circuits, power, VPNs, and lots more: NetBox is built for networks. Its comprehensive and thoroughly inter-linked data model provides for natural and highly structured modeling of myriad network primitives that just isn't possible using general-purpose tools. And there's no need to waste time contemplating how to build out a database: Everything is ready to go upon installation.
### Focused Development
NetBox strives to meet a singular goal: Provide the best available solution for making network infrastructure programmatically accessible. Unlike "all-in-one" tools which awkwardly bolt on half-baked features in an attempt to check every box, NetBox is committed to its core function. NetBox provides the best possible solution for modeling network infrastructure, and provides rich APIs for integrating with tools that excel in other areas of network automation.
### Extensible and Customizable
No two networks are exactly the same. Users are empowered to extend NetBox's native data model with custom fields and tags to best suit their unique needs. You can even write your own plugins to introduce entirely new objects and functionality!
### Flexible Permissions
NetBox includes a fully customizable permission system, which affords administrators incredible granularity when assigning roles to users and groups. Want to restrict certain users to working only with cabling and not be able to change IP addresses? Or maybe each team should have access only to a particular tenant? NetBox enables you to craft roles as you see fit.
### Custom Validation & Protection Rules
The data you put into NetBox is crucial to network operations. In addition to its robust native validation rules, NetBox provides mechanisms for administrators to define their own custom validation rules for objects. Custom validation can be used both to ensure new or modified objects adhere to a set of rules, and to prevent the deletion of objects which don't meet certain criteria. (For example, you might want to prevent the deletion of a device with an "active" status.)
### Device Configuration Rendering
NetBox can render user-created Jinja2 templates to generate device configurations from its own data. Configuration templates can be uploaded individually or pulled automatically from an external source, such as a git repository. Rendered configurations can be retrieved via the REST API for application directly to network devices via a provisioning tool such as Ansible or Salt.
### Custom Scripts
Complex workflows, such as provisioning a new branch office, can be tedious to carry out via the user interface. NetBox allows you to write and upload custom scripts that can be run directly from the UI. Scripts prompt users for input and then automate the necessary tasks to greatly simplify otherwise burdensome processes.
### Automated Events
Users can define event rules to automatically trigger a custom script or outbound webhook in response to a NetBox event. For example, you might want to automatically update a network monitoring service whenever a new device is added to NetBox, or update a DHCP server when an IP range is allocated.
### Comprehensive Change Logging
NetBox automatically logs the creation, modification, and deletion of all managed objects, providing a thorough change history. Changes can be attributed to the executing user, and related changes are grouped automatically by request ID.
> [!NOTE]
> A complete list of NetBox's myriad features can be found in [the introductory documentation](https://docs.netbox.dev/en/stable/introduction/).
## Getting Started
<div align="center">
[![NetBox logo](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/deploy/deploy1.png)](https://github.com/netbox-community/netbox)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
[![Docker logo](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/deploy/deploy2.png)](https://github.com/netbox-community/netbox-docker)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
[![NetBox Labs logo](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/deploy/deploy3.png)](https://netboxlabs.com/netbox-cloud/)
</div>
* Just want to explore? Check out [our public demo](https://demo.netbox.dev/) right now!
* The [official documentation](https://docs.netbox.dev) offers a comprehensive introduction.
* Check out [our wiki](https://github.com/netbox-community/netbox/wiki/Community-Contributions) for even more projects to get the most out of NetBox!
<p align="center">
<a href="https://netboxlabs.com/netbox-cloud/"><img src="docs/media/misc/netbox_cloud.png" alt="NetBox Cloud" /></a><br />
Looking for an enterprise solution? Check out <strong><a href="https://netboxlabs.com/netbox-cloud/">NetBox Cloud</a></strong>!
</p>
## Get Involved
* Follow [@NetBoxOfficial](https://twitter.com/NetBoxOfficial) on Twitter!
* Join the conversation on [the discussion forum](https://github.com/netbox-community/netbox/discussions) and [Slack](https://netdev.chat/)!
* Already a power user? You can [suggest a feature](https://github.com/netbox-community/netbox/issues/new?assignees=&labels=type%3A+feature&template=feature_request.yaml) or [report a bug](https://github.com/netbox-community/netbox/issues/new?assignees=&labels=type%3A+bug&template=bug_report.yaml) on GitHub.
* Contributions from the community are encouraged and appreciated! Check out our [contributing guide](CONTRIBUTING.md) to get started.
* [Share your idea](https://plugin-ideas.netbox.dev/) for a new plugin, or [learn how to build one](https://github.com/netbox-community/netbox-plugin-tutorial) yourself!
## Project Stats
<div align="center">
<p align="center">
<a href="https://github.com/netbox-community/netbox/commits"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_timeline.svg" alt="Timeline graph"></a>
<a href="https://github.com/netbox-community/netbox/issues"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_issues.svg" alt="Issues graph"></a>
<a href="https://github.com/netbox-community/netbox/pulls"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_prs.svg" alt="Pull requests graph"></a>
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_users.svg" alt="Top contributors"></a>
<br />Stats via <a href="https://repography.com">Repography</a>
</div>
## Sponsors
<div align="center">
[![NetBox Labs](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/netbox_labs.png)](https://netboxlabs.com)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
[![DigitalOcean](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/digitalocean.png)](https://try.digitalocean.com/developer-cloud)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
[![Sentry](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/sentry.png)](https://sentry.io)
<br />
[![Equinix Metal](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/equinix.png)](https://metal.equinix.com)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
[![OneMind Services](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/onemind_services.png)](https://onemindservices.com)
</div>
</p>
## Screenshots
![Screenshot of main page (dark mode)](docs/media/screenshots/home-dark.png "Main page (dark mode)")
![Screenshot of rack elevation](docs/media/screenshots/rack.png "Rack elevation")
![Screenshot of prefixes hierarchy](docs/media/screenshots/prefixes-list.png "Prefixes hierarchy")
![Screenshot of cable trace](docs/media/screenshots/cable-trace.png "Cable tracing")
<p align="center">
<strong>NetBox Dashboard (Light Mode)</strong><br />
<img src="docs/media/screenshots/home-light.png" width="600" alt="NetBox dashboard (light mode)" />
</p>
<p align="center">
<strong>NetBox Dashboard (Dark Mode)</strong><br />
<img src="docs/media/screenshots/home-dark.png" width="600" alt="NetBox dashboard (dark mode)" />
</p>
<p align="center">
<strong>Prefixes List</strong><br />
<img src="docs/media/screenshots/prefixes-list.png" width="600" alt="Prefixes list" />
</p>
<p align="center">
<strong>Rack View</strong><br />
<img src="docs/media/screenshots/rack.png" width="600" alt="Rack view" />
</p>
<p align="center">
<strong>Cable Trace</strong><br />
<img src="docs/media/screenshots/cable-trace.png" width="600" alt="Cable trace" />
</p>

View File

@@ -73,7 +73,7 @@ You should be redirected to Microsoft's authentication portal. Enter the usernam
If successful, you will be redirected back to the NetBox UI, and will be logged in as the AD user. You can verify this by navigating to your profile (using the button at top right).
This user account has been replicated locally to NetBox, and can now be assigned groups and permissions within the NetBox admin UI.
This user account has been replicated locally to NetBox, and can now be assigned groups and permissions by navigating to Admin > Permissions.
## Troubleshooting

View File

@@ -67,4 +67,4 @@ You should be redirected to Okta's authentication portal. Enter the username/ema
If successful, you will be redirected back to the NetBox UI, and will be logged in as the Okta user. You can verify this by navigating to your profile (using the button at top right).
This user account has been replicated locally to NetBox, and can now be assigned groups and permissions within the NetBox admin UI.
This user account has been replicated locally to NetBox, and can now be assigned groups and permissions by navigating to Admin > Permissions.

View File

@@ -2,9 +2,9 @@
## Local Authentication
Local user accounts and groups can be created in NetBox under the "Authentication and Authorization" section of the administrative user interface. This interface is available only to users with the "staff" permission enabled.
Local user accounts and groups can be created in NetBox under the "Authentication" section in the "Admin" menu. This section is available only to users with the "staff" permission enabled.
At a minimum, each user account must have a username and password set. User accounts may also denote a first name, last name, and email address. [Permissions](../permissions.md) may also be assigned to users and/or groups within the admin UI.
At a minimum, each user account must have a username and password set. User accounts may also denote a first name, last name, and email address. [Permissions](../permissions.md) may also be assigned to users and/or groups under Admin > Permissions.
## Remote Authentication

View File

@@ -46,4 +46,4 @@ The configuration file may be modified at any time. However, the WSGI service (e
$ sudo systemctl restart netbox
```
Configuration parameters which are set via the admin UI (those listed under "dynamic settings") take effect immediately.
Dynamic configuration parameters (those which can be modified via the UI) take effect immediately.

View File

@@ -80,6 +80,17 @@ changes in the database indefinitely.
---
## CHANGELOG_SKIP_EMPTY_CHANGES
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.
!!! note
The object's `last_updated` field will always reflect the time of the most recent update, regardless of this parameter.
---
## DATA_UPLOAD_MAX_MEMORY_SIZE
Default: `2621440` (2.5 MB)
@@ -92,9 +103,12 @@ The maximum size (in bytes) of an incoming HTTP request (i.e. `GET` or `POST` da
!!! tip "Dynamic Configuration Parameter"
Default: False
Default: True
By default, NetBox will permit users to create duplicate prefixes and IP addresses in the global table (that is, those which are not assigned to any VRF). This behavior can be disabled by setting `ENFORCE_GLOBAL_UNIQUE` to 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.
!!! info "Changed in v3.7"
The default value for this parameter was changed from False to True in NetBox v3.7.
---

View File

@@ -288,9 +288,9 @@ An IPv4 or IPv6 network with a mask. Returns a `netaddr.IPNetwork` object. Two a
## Running Custom Scripts
!!! note
To run a custom script, a user must be assigned via permissions for `Extras > Script`, `Extras > ScriptModule`, and `Core > ManagedFile` objects. They must also be assigned the `extras.run_script` permission. This is achieved by assigning the user (or group) a permission on the Script object and specifying the `run` action in the admin UI as shown below.
To run a custom script, a user must be assigned permissions for `Extras > Script`, `Extras > Script Module`, and `Core > Managed File` objects. They must also be assigned the `extras.run_script` permission. This is achieved by assigning the user (or group) a permission on the Script object and specifying the `run` action in "Permissions" as shown below.
![Adding the run action to a permission](../media/admin_ui_run_permission.png)
![Adding the run action to a permission](../media/run_permission.png)
### Via the Web UI

View File

@@ -132,9 +132,9 @@ Once you have created a report, it will appear in the reports list. Initially, r
## Running Reports
!!! note
To run a report, a user must be assigned via permissions for `Extras > Report`, `Extras > ReportModule`, and `Core > ManagedFile` objects. They must also be assigned the `extras.run_report` permission. This is achieved by assigning the user (or group) a permission on the Report object and specifying the `run` action in the admin UI as shown below.
To run a report, a user must be assigned permissions for `Extras > Report`, `Extras > Report Module`, and `Core > Managed File` objects. They must also be assigned the `extras.run_report` permission. This is achieved by assigning the user (or group) a permission on the Report object and specifying the `run` action in "Permissions" as shown below.
![Adding the run action to a permission](../media/admin_ui_run_permission.png)
![Adding the run action to a permission](../media/run_permission.png)
### Via the Web UI

View File

@@ -2,12 +2,25 @@
Below is a list of tasks to consider when adding a new field to a core model.
## 1. Generate and run database migrations
## 1. Add the field to the model class
Add the field to the model, taking care to address any of the following conditions.
* When adding a GenericForeignKey field, also add an index under `Meta` for its two concrete fields. For example:
```python
class Meta:
indexes = (
models.Index(fields=('object_type', 'object_id')),
)
```
## 2. Generate and run database migrations
[Django migrations](https://docs.djangoproject.com/en/stable/topics/migrations/) are used to express changes to the database schema. In most cases, Django can generate these automatically, however very complex changes may require manual intervention. Always remember to specify a short but descriptive name when generating a new migration.
```
./manage.py makemigrations <app> -n <name>
./manage.py makemigrations <app> -n <name> --no-header
./manage.py migrate
```
@@ -16,7 +29,7 @@ Where possible, try to merge related changes into a single migration. For exampl
!!! warning "Do not alter existing migrations"
Migrations can only be merged within a release. Once a new release has been published, its migrations cannot be altered (other than for the purpose of correcting a bug).
## 2. Add validation logic to `clean()`
## 3. Add validation logic to `clean()`
If the new field introduces additional validation requirements (beyond what's included with the field itself), implement them in the model's `clean()` method. Remember to call the model's original method using `super()` before or after your custom validation as appropriate:
@@ -31,15 +44,15 @@ class Foo(models.Model):
raise ValidationError()
```
## 3. Update relevant querysets
## 4. Update relevant querysets
If you're adding a relational field (e.g. `ForeignKey`) and intend to include the data when retrieving a list of objects, be sure to include the field using `prefetch_related()` as appropriate. This will optimize the view and avoid extraneous database queries.
## 4. Update API serializer
## 5. Update API serializer
Extend the model's API serializer in `<app>.api.serializers` to include the new field. In most cases, it will not be necessary to also extend the nested serializer, which produces a minimal representation of the model.
## 5. Add fields to forms
## 6. Add fields to forms
Extend any forms to include the new field(s) as appropriate. These are found under the `forms/` directory within each app. Common forms include:
@@ -48,23 +61,23 @@ Extend any forms to include the new field(s) as appropriate. These are found und
* **CSV import** - The form used when bulk importing objects in CSV format
* **Filter** - Displays the options available for filtering a list of objects (both UI and API)
## 6. Extend object filter set
## 7. Extend object filter set
If the new field should be filterable, add it to the `FilterSet` for the model. If the field should be searchable, remember to query it in the FilterSet's `search()` method.
## 7. Add column to object table
## 8. Add column to object table
If the new field will be included in the object list view, add a column to the model's table. For simple fields, adding the field name to `Meta.fields` will be sufficient. More complex fields may require declaring a custom column. Also add the field name to `default_columns` if the column should be present in the table by default.
## 8. Update the SearchIndex
## 9. Update the SearchIndex
Where applicable, add the new field to the model's SearchIndex for inclusion in global search.
## 9. Update the UI templates
## 10. Update the UI templates
Edit the object's view template to display the new field. There may also be a custom add/edit form template that needs to be updated.
## 10. Create/extend test cases
## 11. Create/extend test cases
Create or extend the relevant test cases to verify that the new field and any accompanying validation logic perform as expected. This is especially important for relational fields. NetBox incorporates various test suites, including:
@@ -74,8 +87,8 @@ Create or extend the relevant test cases to verify that the new field and any ac
* Model tests
* View tests
Be diligent to ensure all of the relevant test suites are adapted or extended as necessary to test any new functionality.
Be diligent to ensure all the relevant test suites are adapted or extended as necessary to test any new functionality.
## 11. Update the model's documentation
## 12. Update the model's documentation
Each model has a dedicated page in the documentation, at `models/<app>/<model>.md`. Update this file to include any relevant information about the new field.

View File

@@ -80,6 +80,18 @@ Run the following command to update the device type definition validation schema
This will automatically update the schema file at `contrib/generated_schema.json`.
### Update & Compile Translations
Log into [Transifex](https://app.transifex.com/netbox-community/netbox/dashboard/) to download the updated string maps. Download the resource (portable object, or `.po`) file for each language and save them to `netbox/translations/$lang/LC_MESSAGES/django.po`, overwriting the current files. (Be sure to click the **Download for use** link.)
![Transifex download](../media/development/transifex_download.png)
Once the resource files for all languages have been updated, compile the machine object (`.mo`) files using the `compilemessages` management command:
```nohighlight
./manage.py compilemessages
```
### Update Version and Changelog
* Update the `VERSION` constant in `settings.py` to the new release version.
@@ -90,7 +102,7 @@ Commit these changes to the `develop` branch and push upstream.
### Verify CI Build Status
Ensure that continuous integration testing on the `develop` branch is completing successfully. If it fails, take action to correct the failure before proceding with the release.
Ensure that continuous integration testing on the `develop` branch is completing successfully. If it fails, take action to correct the failure before proceeding with the release.
### Submit a Pull Request

View File

@@ -0,0 +1,30 @@
# Translations
NetBox coordinates all translation work using the [Transifex](https://explore.transifex.com/netbox-community/netbox/) platform. Signing up for a Transifex account is free.
All language translations in NetBox are generated from the source file found at `netbox/translations/en/LC_MESSAGES/django.po`. This file contains the original English strings with empty mappings, and is generated as part of NetBox's release process. Transifex updates source strings from this file on a recurring basis, so new translation strings will appear in the platform automatically as it is updated in the code base.
Reviewers log into Transifex and navigate to their designated language(s) to translate strings. The initial translation for most strings will be machine-generated via the AWS Translate service. Human reviewers are responsible for reviewing these translations and making corrections where necessary.
Immediately prior to each NetBox release, the translation maps for all completed languages will be downloaded from Transifex, compiled, and checked into the NetBox code base by a maintainer.
## Updating Translation Sources
To update the English `.po` file from which all translations are derived, use the `makemessages` management command:
```nohighlight
./manage.py makemessages -l en
```
Then, commit the change and push to the `develop` branch on GitHub. After some time, any new strings will appear for translation on Transifex automatically.
## Proposing New Languages
If you'd like to add support for a new language to NetBox, the first step is to [submit a GitHub issue](https://github.com/netbox-community/netbox/issues/new?assignees=&labels=type%3A+translation&projects=&template=translation.yaml) to capture the proposal. While we'd like to add as many languages as possible, we do need to limit the rate at which new languages are added. New languages will be selected according to community interest and the number of volunteers who sign up as translators.
Once a proposed language has been approved, a NetBox maintainer will:
* Add it to the Transifex platform
* Designate one or more reviewers
* Create the initial machine-generated translations for review
* Add it to the list of supported languages

View File

@@ -39,7 +39,7 @@ When rendered for a specific NetBox device, the template's `device` variable wil
### Context Data
The objet for which the configuration is being rendered is made available as template context as `device` or `virtualmachine` for devices and virtual machines, respectively. Additionally, NetBox model classes can be accessed by the app or plugin in which they reside. For example:
The object for which the configuration is being rendered is made available as template context as `device` or `virtualmachine` for devices and virtual machines, respectively. Additionally, NetBox model classes can be accessed by the app or plugin in which they reside. For example:
```
There are {{ dcim.Site.objects.count() }} sites.
@@ -70,6 +70,11 @@ This request will trigger resolution of the device's preferred config template i
If no config template has been assigned to any of these three objects, the request will fail.
The configuration can be rendered as JSON or as plaintext by setting the `Accept:` HTTP header. For example:
* `Accept: application/json`
* `Accept: text/plain`
### General Purpose Use
NetBox config templates can also be rendered without being tied to any specific device, using a separate general purpose REST API endpoint. Any data included with a POST request to this endpoint will be passed as context data for the template.

View File

@@ -8,6 +8,9 @@ When entering a search query, the user can choose a specific lookup type: exact
Custom fields defined by NetBox administrators are also included in search results if configured with a search weight. Additionally, NetBox plugins can register their own custom models for inclusion alongside core models.
!!! note
NetBox does not index any static choice field's (including custom fields of type "Selection" or "Multiple selection").
## Saved Filters
Each type of object in NetBox is accompanied by an extensive set of filters, each tied to a specific attribute, which enable the creation of complex queries. Often you'll find that certain queries are used routinely to apply some set of prescribed conditions to a query. Once a set of filters has been applied, NetBox offers the option to save it for future use.

View File

@@ -1,6 +1,6 @@
# Synchronized Data
Several models in NetBox support the automatic synchronization of local data from a designated remote source. For example, [configuration templates](./configuration-rendering.md) defined in NetBox can source their content from text files stored in a remote git repository. This accomplished using the core [data source](../models/core/datasource.md) and [data file](../models/core/datafile.md) models.
Several models in NetBox support the automatic synchronization of local data from a designated remote source. For example, [configuration templates](./configuration-rendering.md) defined in NetBox can source their content from text files stored in a remote git repository. This is accomplished using the core [data source](../models/core/datasource.md) and [data file](../models/core/datafile.md) models.
To enable remote data synchronization, the NetBox administrator first designates one or more remote data sources. NetBox currently supports the following source types:
@@ -10,7 +10,6 @@ To enable remote data synchronization, the NetBox administrator first designates
(Local disk paths are considered "remote" in this context as they exist outside NetBox's database. These paths could also be mapped to external network shares.)
!!! info
Data backends which connect to external sources typically require the installation of one or more supporting Python libraries. The Git backend requires the [`dulwich`](https://www.dulwich.io/) package, and the S3 backend requires the [`boto3`](https://boto3.amazonaws.com/v1/documentation/api/latest/index.html) package. These must be installed within NetBox's environment to enable these backends.
@@ -23,3 +22,6 @@ The following NetBox models can be associated with replicated data files:
* Export templates
Once a data has been designated for a local instance, its data will be replaced with the content of the replicated file. When the replicated file is updated in the future (via synchronization jobs), the local instance will be flagged as having out-of-date data. A user can then synchronize these objects individually or in bulk to effect the update. This two-stage process ensures that automated synchronization tasks do not immediately affect production data.
!!! note "Permissions"
A user must be assigned the `core.sync_datasource` permission in order to synchronize local files from a remote data source.

View File

@@ -4,7 +4,7 @@
NetBox is the leading solution for modeling and documenting modern networks. By combining the traditional disciplines of IP address management (IPAM) and datacenter infrastructure management (DCIM) with powerful APIs and extensions, NetBox provides the ideal "source of truth" to power network automation. Read on to discover why thousands of organizations worldwide put NetBox at the heart of their infrastructure.
[![NetBox UI](./media/screenshots/netbox-ui.png)](./media/screenshots/netbox-ui.png)
[![NetBox UI](./media/screenshots/home-light.png)](./media/screenshots/home-light.png)
## :material-server-network: Built for Networks

View File

@@ -2,6 +2,9 @@
Some NetBox models support automatic synchronization of certain attributes from remote [data sources](../models/core/datasource.md), such as a git repository hosted on GitHub or GitLab. Data from the authoritative remote source is synchronized locally in NetBox as [data files](../models/core/datafile.md).
!!! note "Permissions"
A user must be assigned the `core.sync_datasource` permission in order to synchronize local files from a remote data source. This is accomplished by creating a permission for the "Core > Data Source" object type with the `sync` action, and assigning it to the desired user and/or group.
The following features support the use of synchronized data:
* [Configuration templates](../features/configuration-rendering.md)

View File

@@ -106,6 +106,6 @@ Content-Type: application/x-www-form-urlencoded
------------
```
Note that `webhook_receiver` does not actually _do_ anything with the information received: It merely prints the request headers and body for inspection.
Note that `webhook_receiver` does not actually _do_ anything with the information received: It merely prints the request headers and body for inspection. If you don't see any output, check that the `rqworker` process is running and that webhook events are being placed into the queue.
Now, when the NetBox webhook is triggered and processed, you should see its headers and content appear in the terminal where the webhook receiver is listening. If you don't, check that the `rqworker` process is running and that webhook events are being placed into the queue (visible under the NetBox admin UI).
Webhook results can be found in the NetBox admin UI under the Background Tasks section. You can see any finished or failed runs, as well as the error log for failed webhooks.

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 235 KiB

View File

@@ -19,7 +19,7 @@ The parent inventory item to which this item is assigned (optional).
### Name
The inventory item's name. Must be unique to the parent device.
The inventory item's name. If the inventory item is assigned to a parent item, its name must be unique among its siblings (all items belonging to the same parent item).
### Label

View File

@@ -28,7 +28,7 @@ The protocol employed for data encryption. Options include DES, 3DES, and variou
### Authentication Algorithm
The mechanism employed to ensure data integrity. Options include MD5 and SHA HMAC implementations.
The mechanism employed to ensure data integrity. Options include MD5 and SHA HMAC implementations. Specifying an authentication algorithm is optional, as some encryption algorithms (e.g. AES-GCM) provide authentication natively.
### Group

View File

@@ -12,10 +12,16 @@ The unique user-assigned name for the proposal.
The protocol employed for data encryption. Options include DES, 3DES, and various flavors of AES.
!!! note
If an encryption algorithm is not specified, an authentication algorithm must be specified.
### Authentication Algorithm
The mechanism employed to ensure data integrity. Options include MD5 and SHA HMAC implementations.
!!! note
If an authentication algorithm is not specified, an encryption algorithm must be specified.
### SA Lifetime (Seconds)
The maximum amount of time for which the security association (SA) may be active, in seconds.

View File

@@ -69,7 +69,7 @@ The plugin source directory contains all the actual Python code and other resour
The `PluginConfig` class is a NetBox-specific wrapper around Django's built-in [`AppConfig`](https://docs.djangoproject.com/en/stable/ref/applications/) class. It is used to declare NetBox plugin functionality within a Python package. Each plugin should provide its own subclass, defining its name, metadata, and default and required configuration parameters. An example is below:
```python
from extras.plugins import PluginConfig
from netbox.plugins import PluginConfig
class FooBarConfig(PluginConfig):
name = 'foo_bar'
@@ -121,7 +121,7 @@ All required settings must be configured by the user. If a configuration paramet
Plugin configuration parameters can be accessed using the `get_plugin_config()` function. For example:
```python
from extras.plugins import get_plugin_config
from netbox.plugins import get_plugin_config
get_plugin_config('my_plugin', 'verbose_name')
```

View File

@@ -5,7 +5,7 @@
A plugin can register its own submenu as part of NetBox's navigation menu. This is done by defining a variable named `menu` in `navigation.py`, pointing to an instance of the `PluginMenu` class. Each menu must define a label and grouped menu items (discussed below), and may optionally specify an icon. An example is shown below.
```python title="navigation.py"
from extras.plugins import PluginMenu
from netbox.plugins import PluginMenu
menu = PluginMenu(
label='My Plugin',
@@ -49,7 +49,7 @@ menu_items = (item1, item2, item3)
Each menu item represents a link and (optionally) a set of buttons comprising one entry in NetBox's navigation menu. Menu items are defined as PluginMenuItem instances. An example is shown below.
```python title="navigation.py"
from extras.plugins import PluginMenuButton, PluginMenuItem
from netbox.plugins import PluginMenuButton, PluginMenuItem
from utilities.choices import ButtonColorChoices
item1 = PluginMenuItem(

View File

@@ -206,7 +206,7 @@ For example, accessing `{{ request.user }}` within a template will return the cu
Declared subclasses should be gathered into a list or tuple for integration with NetBox. By default, NetBox looks for an iterable named `template_extensions` within a `template_content.py` file. (This can be overridden by setting `template_extensions` to a custom value on the plugin's PluginConfig.) An example is below.
```python
from extras.plugins import PluginTemplateExtension
from netbox.plugins import PluginTemplateExtension
from .models import Animal
class SiteAnimalCount(PluginTemplateExtension):

View File

@@ -10,7 +10,7 @@ Minor releases are published in April, August, and December of each calendar yea
This page contains a history of all major and minor releases since NetBox v2.0. For more detail on a specific patch release, please see the release notes page for that specific minor release.
#### [Version 3.7](./version-3.6.md) (December 2023)
#### [Version 3.7](./version-3.7.md) (December 2023)
* VPN Tunnels ([#9816](https://github.com/netbox-community/netbox/issues/9816))
* Event Rules ([#14132](https://github.com/netbox-community/netbox/issues/14132))

View File

@@ -1,6 +1,69 @@
# NetBox v3.6
## v3.6.7 (FUTURE)
## v3.6.9 (2023-12-28)
### Enhancements
* [#14631](https://github.com/netbox-community/netbox/issues/14631) - All models can be filtered and searched by their description field (where applicable)
### Bug Fixes
* [#14482](https://github.com/netbox-community/netbox/issues/14482) - Fix validation error when attempting to move a primary IP address to a new parent object
* [#14620](https://github.com/netbox-community/netbox/issues/14620) - Permit setting device type U height to 0 during bulk edit
* [#14621](https://github.com/netbox-community/netbox/issues/14621) - Fix error when using the device search filter
---
## v3.6.8 (2023-12-27)
### Enhancements
* [#11039](https://github.com/netbox-community/netbox/issues/11039) - List parent prefixes under IP range view
* [#14507](https://github.com/netbox-community/netbox/issues/14507) - Print new NetBox version when running upgrade script
* [#14538](https://github.com/netbox-community/netbox/issues/14538) - Add the `available_at_site` filter for VLANs
* [#14596](https://github.com/netbox-community/netbox/issues/14596) - Match against description field when searching for devices
### Bug Fixes
* [#11816](https://github.com/netbox-community/netbox/issues/11816) - Correct display of error message when attempting invalid VLAN site & group assignment
* [#12731](https://github.com/netbox-community/netbox/issues/12731) - Fix custom validation for many-to-many fields
* [#13606](https://github.com/netbox-community/netbox/issues/13606) - Fix filtering custom multi-choice fields by null
* [#13649](https://github.com/netbox-community/netbox/issues/13649) - Correct calculation of absolute lengths for zero-length cables
* [#13812](https://github.com/netbox-community/netbox/issues/13812) - Update status of remote data source when syncing fails via `syncdatasource` management command
* [#13909](https://github.com/netbox-community/netbox/issues/13909) - Fix cloning of objects which have a multi-choice custom field
* [#14517](https://github.com/netbox-community/netbox/issues/14517) - Ensure reservations tab is always displayed under rack view
* [#14532](https://github.com/netbox-community/netbox/issues/14532) - Device/VM change record should accurately reflect when primary/OOB IP is deleted
* [#14549](https://github.com/netbox-community/netbox/issues/14549) - Fix association of job results when executing scripts via `runscript` management command
* [#14560](https://github.com/netbox-community/netbox/issues/14560) - Do not escape exclamation marks in custom link URLs
* [#14575](https://github.com/netbox-community/netbox/issues/14575) - Fix display of the tags column under VDC table
* [#14613](https://github.com/netbox-community/netbox/issues/14613) - Fix display of current configuration parameters in UI
---
## v3.6.7 (2023-12-15)
### Enhancements
* [#12751](https://github.com/netbox-community/netbox/issues/12751) - Designate fields to expand by default for object selector widget
* [#14148](https://github.com/netbox-community/netbox/issues/14148) - Add tags column to L2VPN terminations column
* [#14390](https://github.com/netbox-community/netbox/issues/14390) - Add `classes` parameter to `copy_content` template tag
* [#14467](https://github.com/netbox-community/netbox/issues/14467) - Change custom field choice delimiter from comma to colon
### Bug Fixes
* [#13983](https://github.com/netbox-community/netbox/issues/13983) - Fix bulk import support for custom field choices
* [#14081](https://github.com/netbox-community/netbox/issues/14081) - Ensure accuracy of parent object counters when deleting related objects
* [#14249](https://github.com/netbox-community/netbox/issues/14249) - Fix server error when authenticating via IP-restricted API tokens using IPv6
* [#14392](https://github.com/netbox-community/netbox/issues/14392) - Fix bulk operations for plugin models under admin UI
* [#14397](https://github.com/netbox-community/netbox/issues/14397) - Fix exception on non-JSON request to `/available-ips/` API endpoints
* [#14401](https://github.com/netbox-community/netbox/issues/14401) - Rack `starting_unit` cannot be zero
* [#14432](https://github.com/netbox-community/netbox/issues/14432) - Populate custom field default values for components when creating a device
* [#14448](https://github.com/netbox-community/netbox/issues/14448) - Fix exception when creating a power feed with rack and panel in different sites
* [#14505](https://github.com/netbox-community/netbox/issues/14505) - Fix the assignment of tags to L2VPN terminations
* [#14512](https://github.com/netbox-community/netbox/issues/14512) - Remove unneeded annotations from queries when using REST API brief mode
* [#14515](https://github.com/netbox-community/netbox/issues/14515) - Ensure user config is created automatically for all user accounts
* [#14522](https://github.com/netbox-community/netbox/issues/14522) - Fix filtering contact assignments by group
* [#14533](https://github.com/netbox-community/netbox/issues/14533) - Fix quick search under VLAN group VLANs list
---

View File

@@ -1,37 +1,58 @@
## v3.7-beta1 (2023-12-05)
# NetBox v3.7
## v3.7.1 (2024-01-17)
### Bug Fixes
* [#13844](https://github.com/netbox-community/netbox/issues/13844) - Use `available_at_site` filter when filtering VLANs under prefix form
* [#14663](https://github.com/netbox-community/netbox/issues/14663) - Fix tunnel creation when setting initial termination to a VM interface
* [#14706](https://github.com/netbox-community/netbox/issues/14706) - Relax one-to-one mapping of tunnel termination to IP address
* [#14709](https://github.com/netbox-community/netbox/issues/14709) - Fix typo in tunnel termination type choice name
* [#14749](https://github.com/netbox-community/netbox/issues/14749) - Remove errant translation wrapper from `installed_device` on DeviceBay
* [#14778](https://github.com/netbox-community/netbox/issues/14778) - Custom field API serializer should accept null values for all optional fields
* [#14791](https://github.com/netbox-community/netbox/issues/14791) - Hide available prefixes when searching within a parent prefix
* [#14793](https://github.com/netbox-community/netbox/issues/14793) - Add missing Diffie-Hellman group 15
* [#14816](https://github.com/netbox-community/netbox/issues/14816) - Ensure default contact assignment ordering is consistent
* [#14817](https://github.com/netbox-community/netbox/issues/14817) - Relax required fields for IKE & IPSec models on bulk import
* [#14827](https://github.com/netbox-community/netbox/issues/14827) - Ensure all matching event rules are processed in response to an event
---
## v3.7.0 (2023-12-29)
### Breaking Changes
* The following fields have been removed from the Webhook model: `content_types`, `type_create`, `type_update`, `type_delete`, `type_job_start`, `type_job_end`, `enabled`, and `conditions`. Webhooks are now tied to events via [event rules](../features/event-rules.md). Existing webhooks will have event rules created automatically upon upgrade.
* The `ui_visibility` field on the [custom field model](../models/extras/customfield.md) has been replaced with two new fields: `ui_visible` and `ui_editable`. Existing values will be migrated automatically upon upgrade.
* The `FeatureQuery` class for querying content types by model feature has been removed. Plugins should now use the new `with_feature()` manager method on NetBox's proxy model for ContentType.
* The ConfigRevision model has been moved from `extras` to `core`. Configuration history will be retained throughout the upgrade process.
* The L2VPN and L2VPNTermination models have been moved from the `ipam` app to the new `vpn` app. All object data will be retained, however please note that the relevant API endpoints have moved to `/api/vpn/`.
* The following fields have been removed from the Webhook model: `content_types`, `type_create`, `type_update`, `type_delete`, `type_job_start`, `type_job_end`, `enabled`, and `conditions`. Webhooks are now tied to events via [event rules](../features/event-rules.md). New event rules will be created for any existing webhooks automatically upon upgrade.
* The `ui_visibility` field on the [custom field model](../models/extras/customfield.md) has been replaced with two new fields: `ui_visible` and `ui_editable`. These new fields will have their values mapped from the original field automatically upon upgrade.
* The `FeatureQuery` class used internally for querying content types by model feature has been removed. It has been replaced by the new `with_feature()` manager method on NetBox's proxy model for ContentType (`core.models.ContentType`).
* The internal ConfigRevision model has moved from `extras` to `core`. Configuration history will be retained throughout the upgrade process.
* The [L2VPN](../models/vpn/l2vpn.md) and [L2VPNTermination](../models/vpn/l2vpntermination.md) models have moved from the `ipam` app to the new `vpn` app. All object data will be retained, however please note that the relevant API endpoints have likewise moved to `/api/vpn/`.
* The `CustomFieldsMixin`, `SavedFiltersMixin`, and `TagsMixin` classes have moved from the `extras.forms.mixins` module to `netbox.forms.mixins`.
* The `netbox.models.features.WebhooksMixin` class has been renamed to `EventRulesMixin`.
### New Features
#### VPN Tunnels ([#9816](https://github.com/netbox-community/netbox/issues/9816))
Several new models have been introduced to enable [VPN tunnel management](../features/vpn-tunnels.md). Users can now define tunnels with two or more terminations to replicate peer-to-peer or hub-and-spoke topologies. Each termination is made to a virtual interface on a device or VM. Additionally, users can define IKE and IPSec policies which can be applied to tunnels to document encryption and authentication strategies.
Several new models have been introduced to enable [VPN tunnel management](../features/vpn-tunnels.md). Users can now define tunnels with two or more terminations to represent peer-to-peer or hub-and-spoke topologies. Each termination is made to a virtual interface on a device or virtual machine. Additionally, users can define IKE and IPSec proposals and policies, which can be applied to tunnels to document encryption and authentication strategies.
#### Event Rules ([#14132](https://github.com/netbox-community/netbox/issues/14132))
This release introduces [event rules](../features/event-rules.md), which can be used to send webhooks or execute custom scripts automatically in response to NetBox events. For example, it's now possible to run a custom script whenever a new site is created with a particular status or tag.
This release introduces [event rules](../features/event-rules.md), which can be used to send webhooks or execute custom scripts automatically in response to events that occur in NetBox. For example, it's now possible to run a custom script whenever a new site is created with a particular status or tag.
Event rules replace and extend functionality that was previously built into the webhook model. Event rules will be created for any existing webhooks upon upgrade.
Event rules replace and extend functionality that was previously built into the webhook model. New event rules will be created for any existing webhooks automatically upon upgrade.
#### Virtual Machine Disks ([#8356](https://github.com/netbox-community/netbox/issues/8356))
A new [VirtualDisk](../models/virtualization/virtualdisk.md) model has been introduced to enable tracking the assignment of discrete virtual disks to virtual machines. The original `size` field has been retained on the VirtualMachine model, and will be automatically updated with the aggregate size of all assigned virtual disks. (Users who opt to eschew the new model may continue using the VirtualMachine `size` attribute as before.)
A new [VirtualDisk](../models/virtualization/virtualdisk.md) model has been introduced to enable tracking the assignment of discrete virtual disks to virtual machines. The `size` field has been retained on the VirtualMachine model, and will be populated automatically with the aggregate size of all assigned virtual disks. (Users who opt to eschew the new model may continue using the VirtualMachine `size` attribute independently as in previous releases.)
#### Object Protection Rules ([#10244](https://github.com/netbox-community/netbox/issues/10244))
A new [`PROTECTION_RULES`](../configuration/data-validation.md#protection_rules) configuration parameter is now available. Similar to how [custom validation rules](../customization/custom-validation.md) can be used to enforce certain values for object attributes, protection rules guard against the deletion of objects which do not meet specified criteria. This enables an administrator to prevent, for example, the deletion of a site which has a status of "active."
A new [`PROTECTION_RULES`](../configuration/data-validation.md#protection_rules) configuration parameter has been introduced. Similar to how [custom validation rules](../customization/custom-validation.md) can be used to enforce certain values for object attributes, protection rules guard against the deletion of objects which do not meet specified criteria. This enables an administrator to prevent, for example, the deletion of a site which has a status of "active."
#### Improved Custom Field Visibility Controls ([#13299](https://github.com/netbox-community/netbox/issues/13299))
The old `ui_visible` field on the custom field model](../models/extras/customfield.md) has been replaced by two new fields, `ui_visible` and `ui_editable`, which control how and whether a custom field is displayed when view and editing an object, respectively. Separating these two functions into discrete fields enables more control over how each custom field is presented to users. The values of these fields will be appropriately set automatically during the upgrade process depending on the value of the original field.
The `ui_visible` field on [the custom field model](../models/extras/customfield.md) has been superseded by two new fields, `ui_visible` and `ui_editable`, which control how and whether a custom field is displayed when view and editing an object, respectively. Separating these two functions into discrete fields allows more control over how each custom field is presented to users. The values of these fields will be appropriately set automatically during the upgrade process from the value of the original field.
#### Improved Global Search Results ([#14134](https://github.com/netbox-community/netbox/issues/14134))
@@ -50,26 +71,48 @@ Plugins can now [register their own data backends](../plugins/development/data-b
* [#12135](https://github.com/netbox-community/netbox/issues/12135) - Avoid orphaned interfaces by preventing the deletion of interfaces which have children assigned
* [#12216](https://github.com/netbox-community/netbox/issues/12216) - Add a `color` field for circuit types
* [#13230](https://github.com/netbox-community/netbox/issues/13230) - Allow device types to be excluded from consideration when calculating a rack's utilization
* [#13334](https://github.com/netbox-community/netbox/issues/13334) - Added an `error` field to the Job model to record any errors associated with its execution
* [#13427](https://github.com/netbox-community/netbox/issues/13427) - Introduced a mechanism for omitting models from general-purpose lists of object types
* [#13334](https://github.com/netbox-community/netbox/issues/13334) - Add an `error` field to the Job model to record any errors associated with its execution
* [#13427](https://github.com/netbox-community/netbox/issues/13427) - Introduce a mechanism for excluding models from general-purpose lists of object types
* [#13690](https://github.com/netbox-community/netbox/issues/13690) - Display any dependent objects to be deleted prior to deleting an object via the web UI
* [#13794](https://github.com/netbox-community/netbox/issues/13794) - Any models with a relationship to Tenant are now included automatically in the list of related objects under the tenant view
* [#13808](https://github.com/netbox-community/netbox/issues/13808) - Added a `/render-config` REST API endpoint for virtual machines
* [#13808](https://github.com/netbox-community/netbox/issues/13808) - Add a `/render-config` REST API endpoint for virtual machines
* [#14035](https://github.com/netbox-community/netbox/issues/14035) - Order objects of equivalent weight by value in global search results to improve readability
* [#14147](https://github.com/netbox-community/netbox/issues/14147) - Avoid recording empty changelog entries via the new `CHANGELOG_SKIP_EMPTY_CHANGES` config parameter
* [#14156](https://github.com/netbox-community/netbox/issues/14156) - Enable custom fields for contact assignments
* [#14240](https://github.com/netbox-community/netbox/issues/14240) - Increase maximum values for custom field minimum & maximum numeric validators
* [#14361](https://github.com/netbox-community/netbox/issues/14361) - Add a `description` field for webhooks
* [#14365](https://github.com/netbox-community/netbox/issues/14365) - Introduced `job_start` and `job_end` signals
* [#14365](https://github.com/netbox-community/netbox/issues/14365) - Introduce `job_start` and `job_end` signals to allow automated plugin actions
* [#14434](https://github.com/netbox-community/netbox/issues/14434) - Add model-specific termination object filters for cables (e.g. `interface_id` and `consoleport_id`)
* [#14436](https://github.com/netbox-community/netbox/issues/14436) - Add PostgreSQL indexes for all GenericForeignKey fields
* [#14579](https://github.com/netbox-community/netbox/issues/14579) - Allow users to specify a preferred language for UI translations
### Translations
* [#14075](https://github.com/netbox-community/netbox/issues/14075) - Add Spanish translation
* [#14096](https://github.com/netbox-community/netbox/issues/14096) - Add French translation
* [#14145](https://github.com/netbox-community/netbox/issues/14145) - Add Portuguese translation
* [#14266](https://github.com/netbox-community/netbox/issues/14266) - Add Russian translation
### Bug Fixes
* [#14432](https://github.com/netbox-community/netbox/issues/14432) - Fix hyperlinks for global search result attributes
* [#14472](https://github.com/netbox-community/netbox/issues/14472) - Fix display of hidden custom fields in object edit forms
* [#14499](https://github.com/netbox-community/netbox/issues/14499) - Relax requirements for encryption/auth algorithms on IKE & IPSec proposals
* [#14550](https://github.com/netbox-community/netbox/issues/14550) - Fix changing action type of existing event rule
### Other Changes
* [#13550](https://github.com/netbox-community/netbox/issues/13550) - Optimized the format for declaring view actions under `ActionsMixin` (backward compatibility has been retained)
* [#13550](https://github.com/netbox-community/netbox/issues/13550) - Optimize the format for declaring view actions under `ActionsMixin` (backward compatibility has been retained)
* [#13645](https://github.com/netbox-community/netbox/issues/13645) - Installation of the `sentry-sdk` Python library is now required only if Sentry reporting is enabled
* [#14036](https://github.com/netbox-community/netbox/issues/14036) - Move plugin resources from the `extras` app into `netbox` (backward compatibility has been retained)
* [#14153](https://github.com/netbox-community/netbox/issues/14153) - Replace `FeatureQuery` with new `with_feature()` method on ContentType manager
* [#14153](https://github.com/netbox-community/netbox/issues/14153) - Replace `FeatureQuery` with new `with_feature()` method on proxy ContentType manager
* [#14311](https://github.com/netbox-community/netbox/issues/14311) - Move the L2VPN models from the `ipam` app to the new `vpn` app
* [#14312](https://github.com/netbox-community/netbox/issues/14312) - Move the ConfigRevision model from the `extras` app to `core`
* [#14326](https://github.com/netbox-community/netbox/issues/14326) - Form feature mixin classes have been moved from the `extras` app to `netbox`
* [#14395](https://github.com/netbox-community/netbox/issues/14395) - Moved `extras.webhooks_worker.process_webhook()` to `extras.webhooks.send_webhook()` (backward compatibility has been retained)
* [#14395](https://github.com/netbox-community/netbox/issues/14395) - Move `extras.webhooks_worker.process_webhook()` to `extras.webhooks.send_webhook()` (backward compatibility has been retained)
* [#14424](https://github.com/netbox-community/netbox/issues/14424) - Remove change logging functionality from StagedChange
* [#14458](https://github.com/netbox-community/netbox/issues/14458) - Remove the obsolete `clearcache` management command
* [#14536](https://github.com/netbox-community/netbox/issues/14536) - Enforce uniqueness by default for non-VRF prefixes & IP addresses (`ENFORCE_GLOBAL_UNIQUE` now defaults to true)
### REST API Changes
@@ -91,7 +134,15 @@ Plugins can now [register their own data backends](../plugins/development/data-b
* core.Job
* Added the read-only `error` character field
* extras.Webhook
* Removed the following fields: `content_types`, `type_create`, `type_update`, `type_delete`, `type_job_start`, `type_job_end`, `enabled`, and `conditions` (these have been moved to the new `EventRule` model)
* Removed the following fields (these have been moved to the new `EventRule` model):
* `content_types`
* `type_create`
* `type_update`
* `type_delete`
* `type_job_start`
* `type_job_end`
* `enabled`
* `conditions`
* Add the optional `description` field
* dcim.DeviceType
* Added the `exclude_from_utilization` boolean field

View File

@@ -286,6 +286,7 @@ nav:
- User Preferences: 'development/user-preferences.md'
- Web UI: 'development/web-ui.md'
- Internationalization: 'development/internationalization.md'
- Translations: 'development/translations.md'
- Release Checklist: 'development/release-checklist.md'
- git Cheat Sheet: 'development/git-cheat-sheet.md'
- Release Notes:

View File

@@ -13,6 +13,7 @@ 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.translation import gettext_lazy as _
from django.views.decorators.debug import sensitive_post_parameters
from django.views.generic import View
from social_core.backends.utils import load_backends
@@ -193,8 +194,16 @@ class UserConfigView(LoginRequiredMixin, View):
if form.is_valid():
form.save()
messages.success(request, "Your preferences have been updated.")
return redirect('account:preferences')
messages.success(request, _("Your preferences have been updated."))
response = redirect('account:preferences')
# Set/clear language cookie
if language := form.cleaned_data['locale.language']:
response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language)
else:
response.delete_cookie(settings.LANGUAGE_COOKIE_NAME)
return response
return render(request, self.template_name, {
'form': form,

View File

@@ -67,13 +67,14 @@ class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
class Meta:
model = Provider
fields = ['id', 'name', 'slug']
fields = ['id', 'name', 'slug', 'description']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(description__icontains=value) |
Q(accounts__account__icontains=value) |
Q(accounts__name__icontains=value) |
Q(comments__icontains=value)
@@ -101,6 +102,7 @@ class ProviderAccountFilterSet(NetBoxModelFilterSet):
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(description__icontains=value) |
Q(account__icontains=value) |
Q(comments__icontains=value)
).distinct()

View File

@@ -119,6 +119,7 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
)
selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'provider_id', 'provider_network_id')
type_id = DynamicModelMultipleChoiceField(
queryset=CircuitType.objects.all(),
required=False,

View File

@@ -25,8 +25,8 @@ class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests):
ASN.objects.bulk_create(asns)
providers = (
Provider(name='Provider 1', slug='provider-1'),
Provider(name='Provider 2', slug='provider-2'),
Provider(name='Provider 1', slug='provider-1', description='foobar1'),
Provider(name='Provider 2', slug='provider-2', description='foobar2'),
Provider(name='Provider 3', slug='provider-3'),
Provider(name='Provider 4', slug='provider-4'),
Provider(name='Provider 5', slug='provider-5'),
@@ -74,6 +74,10 @@ class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests):
CircuitTermination(circuit=circuits[1], site=sites[0], term_side='A'),
))
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Provider 1', 'Provider 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -82,6 +86,10 @@ class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'slug': ['provider-1', 'provider-2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_asn_id(self): # ASN object assignment
asns = ASN.objects.all()[:2]
params = {'asn_id': [asns[0].pk, asns[1].pk]}
@@ -122,6 +130,10 @@ class CircuitTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
CircuitType(name='Circuit Type 3', slug='circuit-type-3'),
))
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Circuit Type 1']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -227,6 +239,10 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
))
CircuitTermination.objects.bulk_create(circuit_terminations)
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_cid(self):
params = {'cid': ['Test Circuit 1', 'Test Circuit 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -369,6 +385,10 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
Cable(a_terminations=[circuit_terminations[0]], b_terminations=[circuit_terminations[1]]).save()
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_term_side(self):
params = {'term_side': 'A'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7)
@@ -440,6 +460,10 @@ class ProviderNetworkTestCase(TestCase, ChangeLoggedFilterSetTests):
)
ProviderNetwork.objects.bulk_create(provider_networks)
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Provider Network 1', 'Provider Network 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -477,6 +501,10 @@ class ProviderAccountTestCase(TestCase, ChangeLoggedFilterSetTests):
)
ProviderAccount.objects.bulk_create(provider_accounts)
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Provider Account 1', 'Provider Account 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

View File

@@ -28,7 +28,7 @@ class DataSourceFilterSet(NetBoxModelFilterSet):
class Meta:
model = DataSource
fields = ('id', 'name', 'enabled')
fields = ('id', 'name', 'enabled', 'description')
def search(self, queryset, name, value):
if not value.strip():

View File

@@ -1,20 +0,0 @@
from django.core.cache import cache
from django.core.management.base import BaseCommand
from core.models import ConfigRevision
class Command(BaseCommand):
"""Command to clear the entire cache."""
help = 'Clears the cache.'
def handle(self, *args, **kwargs):
# Fetch the current config revision from the cache
config_version = cache.get('config_version')
# Clear the cache
cache.clear()
self.stdout.write('Cache has been cleared.', ending="\n")
if config_version:
# Activate the current config revision
ConfigRevision.objects.get(id=config_version).activate()
self.stdout.write(f'Config revision ({config_version}) has been restored.', ending="\n")

View File

@@ -1,5 +1,6 @@
from django.core.management.base import BaseCommand, CommandError
from core.choices import DataSourceStatusChoices
from core.models import DataSource
@@ -33,9 +34,13 @@ class Command(BaseCommand):
for i, datasource in enumerate(datasources, start=1):
self.stdout.write(f"[{i}] Syncing {datasource}... ", ending='')
self.stdout.flush()
datasource.sync()
self.stdout.write(datasource.get_status_display())
self.stdout.flush()
try:
datasource.sync()
self.stdout.write(datasource.get_status_display())
self.stdout.flush()
except Exception as e:
DataSource.objects.filter(pk=datasource.pk).update(status=DataSourceStatusChoices.FAILED)
raise e
if len(options['name']) > 1:
self.stdout.write(f"Finished.")

View File

@@ -0,0 +1,17 @@
# Generated by Django 4.2.7 on 2023-12-07 16:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0009_configrevision'),
]
operations = [
migrations.AddIndex(
model_name='job',
index=models.Index(fields=['object_type', 'object_id'], name='core_job_object__c664ac_idx'),
),
]

View File

@@ -106,6 +106,9 @@ class Job(models.Model):
class Meta:
ordering = ['-created']
indexes = (
models.Index(fields=('object_type', 'object_id')),
)
verbose_name = _('job')
verbose_name_plural = _('jobs')

View File

@@ -20,3 +20,4 @@ class DataFileIndex(SearchIndex):
fields = (
('path', 200),
)
display_attrs = ('source',)

View File

@@ -21,14 +21,16 @@ class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests):
type='local',
source_url='file:///var/tmp/source1/',
status=DataSourceStatusChoices.NEW,
enabled=True
enabled=True,
description='foobar1'
),
DataSource(
name='Data Source 2',
type='local',
source_url='file:///var/tmp/source2/',
status=DataSourceStatusChoices.SYNCING,
enabled=True
enabled=True,
description='foobar2'
),
DataSource(
name='Data Source 3',
@@ -40,10 +42,18 @@ class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests):
)
DataSource.objects.bulk_create(data_sources)
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Data Source 1', 'Data Source 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_type(self):
params = {'type': ['local']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -97,6 +107,10 @@ class DataFileTestCase(TestCase, ChangeLoggedFilterSetTests):
)
DataFile.objects.bulk_create(data_files)
def test_q(self):
params = {'q': 'file1.txt'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_source(self):
sources = DataSource.objects.all()
params = {'source_id': [sources[0].pk, sources[1].pk]}

View File

@@ -1,4 +1,5 @@
from django.contrib import messages
from django.core.cache import cache
from django.http import HttpResponseForbidden
from django.shortcuts import get_object_or_404, redirect, render
from django.views.generic import View
@@ -159,12 +160,14 @@ class ConfigView(generic.ObjectView):
queryset = ConfigRevision.objects.all()
def get_object(self, **kwargs):
if config := self.queryset.first():
return config
# Instantiate a dummy default config if none has been created yet
return ConfigRevision(
data=get_config().defaults
)
revision_id = cache.get('config_version')
try:
return ConfigRevision.objects.get(pk=revision_id)
except ConfigRevision.DoesNotExist:
# Fall back to using the active config data if no record is found
return ConfigRevision(
data=get_config()
)
class ConfigRevisionListView(generic.ObjectListView):

View File

@@ -1,7 +1,9 @@
import django_filters
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext as _
from circuits.models import CircuitTermination
from extras.filtersets import LocalConfigContextFilterSet
from extras.models import ConfigTemplate
from ipam.filtersets import PrimaryIPFilterSet
@@ -326,7 +328,7 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe
model = Rack
fields = [
'id', 'name', 'facility_id', 'asset_tag', 'u_height', 'starting_unit', 'desc_units', 'outer_width',
'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit'
'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description',
]
def search(self, queryset, name, value):
@@ -337,6 +339,7 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe
Q(facility_id__icontains=value) |
Q(serial__icontains=value.strip()) |
Q(asset_tag__icontains=value.strip()) |
Q(description__icontains=value) |
Q(comments__icontains=value)
)
@@ -498,8 +501,8 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
class Meta:
model = DeviceType
fields = [
'id', 'model', 'slug', 'part_number', 'u_height', 'exclude_from_utilization', 'is_full_depth', 'subdevice_role',
'airflow', 'weight', 'weight_unit',
'id', 'model', 'slug', 'part_number', 'u_height', 'exclude_from_utilization', 'is_full_depth',
'subdevice_role', 'airflow', 'weight', 'weight_unit', 'description',
]
def search(self, queryset, name, value):
@@ -509,6 +512,7 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
Q(manufacturer__name__icontains=value) |
Q(model__icontains=value) |
Q(part_number__icontains=value) |
Q(description__icontains=value) |
Q(comments__icontains=value)
)
@@ -593,7 +597,7 @@ class ModuleTypeFilterSet(NetBoxModelFilterSet):
class Meta:
model = ModuleType
fields = ['id', 'model', 'part_number', 'weight', 'weight_unit']
fields = ['id', 'model', 'part_number', 'weight', 'weight_unit', 'description']
def search(self, queryset, name, value):
if not value.strip():
@@ -602,6 +606,7 @@ class ModuleTypeFilterSet(NetBoxModelFilterSet):
Q(manufacturer__name__icontains=value) |
Q(model__icontains=value) |
Q(part_number__icontains=value) |
Q(description__icontains=value) |
Q(comments__icontains=value)
)
@@ -641,7 +646,10 @@ class DeviceTypeComponentFilterSet(django_filters.FilterSet):
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(name__icontains=value)
return queryset.filter(
Q(name__icontains=value) |
Q(description__icontains=value)
)
class ModularDeviceTypeComponentFilterSet(DeviceTypeComponentFilterSet):
@@ -656,21 +664,21 @@ class ConsolePortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceType
class Meta:
model = ConsolePortTemplate
fields = ['id', 'name', 'type']
fields = ['id', 'name', 'type', 'description']
class ConsoleServerPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
class Meta:
model = ConsoleServerPortTemplate
fields = ['id', 'name', 'type']
fields = ['id', 'name', 'type', 'description']
class PowerPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
class Meta:
model = PowerPortTemplate
fields = ['id', 'name', 'type', 'maximum_draw', 'allocated_draw']
fields = ['id', 'name', 'type', 'maximum_draw', 'allocated_draw', 'description']
class PowerOutletTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
@@ -681,7 +689,7 @@ class PowerOutletTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceType
class Meta:
model = PowerOutletTemplate
fields = ['id', 'name', 'type', 'feed_leg']
fields = ['id', 'name', 'type', 'feed_leg', 'description']
class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
@@ -705,7 +713,7 @@ class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
class Meta:
model = InterfaceTemplate
fields = ['id', 'name', 'type', 'enabled', 'mgmt_only']
fields = ['id', 'name', 'type', 'enabled', 'mgmt_only', 'description']
class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
@@ -716,7 +724,7 @@ class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
class Meta:
model = FrontPortTemplate
fields = ['id', 'name', 'type', 'color']
fields = ['id', 'name', 'type', 'color', 'description']
class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
@@ -727,21 +735,21 @@ class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCom
class Meta:
model = RearPortTemplate
fields = ['id', 'name', 'type', 'color', 'positions']
fields = ['id', 'name', 'type', 'color', 'positions', 'description']
class ModuleBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
class Meta:
model = ModuleBayTemplate
fields = ['id', 'name']
fields = ['id', 'name', 'description']
class DeviceBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
class Meta:
model = DeviceBayTemplate
fields = ['id', 'name']
fields = ['id', 'name', 'description']
class InventoryItemTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
@@ -774,7 +782,7 @@ class InventoryItemTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeCompo
class Meta:
model = InventoryItemTemplate
fields = ['id', 'name', 'label', 'part_id']
fields = ['id', 'name', 'label', 'part_id', 'description']
def search(self, queryset, name, value):
if not value.strip():
@@ -1010,7 +1018,10 @@ class DeviceFilterSet(
class Meta:
model = Device
fields = ['id', 'asset_tag', 'face', 'position', 'latitude', 'longitude', 'airflow', 'vc_position', 'vc_priority']
fields = [
'id', 'asset_tag', 'face', 'position', 'latitude', 'longitude', 'airflow', 'vc_position', 'vc_priority',
'description',
]
def search(self, queryset, name, value):
if not value.strip():
@@ -1020,6 +1031,7 @@ class DeviceFilterSet(
Q(serial__icontains=value.strip()) |
Q(inventoryitems__serial__icontains=value.strip()) |
Q(asset_tag__icontains=value.strip()) |
Q(description__icontains=value.strip()) |
Q(comments__icontains=value) |
Q(primary_ip4__address__startswith=value) |
Q(primary_ip6__address__startswith=value)
@@ -1089,13 +1101,16 @@ class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet, Prim
class Meta:
model = VirtualDeviceContext
fields = ['id', 'device', 'name']
fields = ['id', 'device', 'name', 'description']
def search(self, queryset, name, value):
if not value.strip():
return queryset
qs_filter = Q(name__icontains=value)
qs_filter = (
Q(name__icontains=value) |
Q(description__icontains=value)
)
try:
qs_filter |= Q(identifier=int(value))
except ValueError:
@@ -1152,7 +1167,7 @@ class ModuleFilterSet(NetBoxModelFilterSet):
class Meta:
model = Module
fields = ['id', 'status', 'asset_tag']
fields = ['id', 'status', 'asset_tag', 'description']
def search(self, queryset, name, value):
if not value.strip():
@@ -1161,6 +1176,7 @@ class ModuleFilterSet(NetBoxModelFilterSet):
Q(device__name__icontains=value.strip()) |
Q(serial__icontains=value.strip()) |
Q(asset_tag__icontains=value.strip()) |
Q(description__icontains=value) |
Q(comments__icontains=value)
).distinct()
@@ -1651,7 +1667,7 @@ class InventoryItemRoleFilterSet(OrganizationalModelFilterSet):
class Meta:
model = InventoryItemRole
fields = ['id', 'name', 'slug', 'color']
fields = ['id', 'name', 'slug', 'color', 'description']
class VirtualChassisFilterSet(NetBoxModelFilterSet):
@@ -1716,13 +1732,14 @@ class VirtualChassisFilterSet(NetBoxModelFilterSet):
class Meta:
model = VirtualChassis
fields = ['id', 'domain', 'name']
fields = ['id', 'domain', 'name', 'description']
def search(self, queryset, name, value):
if not value.strip():
return queryset
qs_filter = (
Q(name__icontains=value) |
Q(description__icontains=value) |
Q(members__name__icontains=value) |
Q(domain__icontains=value)
)
@@ -1789,14 +1806,47 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
field_name='site__slug'
)
# Termination object filters
consoleport_id = MultiValueNumberFilter(
method='filter_by_consoleport'
)
consoleserverport_id = MultiValueNumberFilter(
method='filter_by_consoleserverport'
)
powerport_id = MultiValueNumberFilter(
method='filter_by_powerport'
)
poweroutlet_id = MultiValueNumberFilter(
method='filter_by_poweroutlet'
)
interface_id = MultiValueNumberFilter(
method='filter_by_interface'
)
frontport_id = MultiValueNumberFilter(
method='filter_by_frontport'
)
rearport_id = MultiValueNumberFilter(
method='filter_by_rearport'
)
powerfeed_id = MultiValueNumberFilter(
method='filter_by_powerfeed'
)
circuittermination_id = MultiValueNumberFilter(
method='filter_by_circuittermination'
)
class Meta:
model = Cable
fields = ['id', 'label', 'length', 'length_unit']
fields = ['id', 'label', 'length', 'length_unit', 'description']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(label__icontains=value)
qs_filter = (
Q(label__icontains=value) |
Q(description__icontains=value)
)
return queryset.filter(qs_filter)
def filter_by_termination(self, queryset, name, value):
# Filter by a related object cached on CableTermination. Note the underscore preceding the field name.
@@ -1828,6 +1878,42 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
terminations__cable_end=CableEndChoices.SIDE_B
)
def filter_by_termination_object(self, queryset, model, value):
# Filter by specific termination object(s)
content_type = ContentType.objects.get_for_model(model)
cable_ids = CableTermination.objects.filter(
termination_type=content_type,
termination_id__in=value
).values_list('cable', flat=True)
return queryset.filter(pk__in=cable_ids)
def filter_by_consoleport(self, queryset, name, value):
return self.filter_by_termination_object(queryset, ConsolePort, value)
def filter_by_consoleserverport(self, queryset, name, value):
return self.filter_by_termination_object(queryset, ConsoleServerPort, value)
def filter_by_powerport(self, queryset, name, value):
return self.filter_by_termination_object(queryset, PowerPort, value)
def filter_by_poweroutlet(self, queryset, name, value):
return self.filter_by_termination_object(queryset, PowerOutlet, value)
def filter_by_interface(self, queryset, name, value):
return self.filter_by_termination_object(queryset, Interface, value)
def filter_by_frontport(self, queryset, name, value):
return self.filter_by_termination_object(queryset, FrontPort, value)
def filter_by_rearport(self, queryset, name, value):
return self.filter_by_termination_object(queryset, RearPort, value)
def filter_by_powerfeed(self, queryset, name, value):
return self.filter_by_termination_object(queryset, PowerFeed, value)
def filter_by_circuittermination(self, queryset, name, value):
return self.filter_by_termination_object(queryset, CircuitTermination, value)
class CableTerminationFilterSet(BaseFilterSet):
termination_type = ContentTypeFilter()
@@ -1883,13 +1969,14 @@ class PowerPanelFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
class Meta:
model = PowerPanel
fields = ['id', 'name']
fields = ['id', 'name', 'description']
def search(self, queryset, name, value):
if not value.strip():
return queryset
qs_filter = (
Q(name__icontains=value)
Q(name__icontains=value) |
Q(description__icontains=value)
)
return queryset.filter(qs_filter)
@@ -1950,6 +2037,7 @@ class PowerFeedFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet, PathEndpoi
model = PowerFeed
fields = [
'id', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', 'cable_end',
'description',
]
def search(self, queryset, name, value):
@@ -1957,6 +2045,7 @@ class PowerFeedFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet, PathEndpoi
return queryset
qs_filter = (
Q(name__icontains=value) |
Q(description__icontains=value) |
Q(power_panel__name__icontains=value) |
Q(comments__icontains=value)
)

View File

@@ -412,7 +412,7 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm):
)
u_height = forms.IntegerField(
label=_('U height'),
min_value=1,
min_value=0,
required=False
)
is_full_depth = forms.NullBooleanField(

View File

@@ -165,6 +165,7 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
)
selector_fields = ('filter_id', 'q', 'region_id', 'group_id')
status = forms.MultipleChoiceField(
label=_('Status'),
choices=SiteStatusChoices,
@@ -248,6 +249,7 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
(_('Weight'), ('weight', 'max_weight', 'weight_unit')),
)
selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'location_id')
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -420,6 +422,7 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
)),
(_('Weight'), ('weight', 'weight_unit')),
)
selector_fields = ('filter_id', 'q', 'manufacturer_id')
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
required=False,
@@ -544,6 +547,7 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
)),
(_('Weight'), ('weight', 'weight_unit')),
)
selector_fields = ('filter_id', 'q', 'manufacturer_id')
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
required=False,
@@ -620,6 +624,7 @@ class DeviceRoleFilterForm(NetBoxModelFilterSetForm):
class PlatformFilterForm(NetBoxModelFilterSetForm):
model = Platform
selector_fields = ('filter_id', 'q', 'manufacturer_id')
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
required=False,
@@ -654,6 +659,7 @@ class DeviceFilterForm(
'has_primary_ip', 'has_oob_ip', 'virtual_chassis_member', 'config_template_id', 'local_context_data',
))
)
selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -997,6 +1003,7 @@ class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id')),
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
)
selector_fields = ('filter_id', 'q', 'site_id', 'location_id')
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -1228,6 +1235,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', 'vdc_id')),
(_('Connection'), ('cabled', 'connected', 'occupied')),
)
selector_fields = ('filter_id', 'q', 'device_id')
vdc_id = DynamicModelMultipleChoiceField(
queryset=VirtualDeviceContext.objects.all(),
required=False,

View File

@@ -1,5 +1,6 @@
# Generated by Django 4.1.9 on 2023-05-31 15:47
import django.core.validators
from django.db import migrations, models
@@ -12,6 +13,6 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='rack',
name='starting_unit',
field=models.PositiveSmallIntegerField(default=1),
field=models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1)]),
),
]

View File

@@ -0,0 +1,22 @@
from django.db import migrations
def update_cable_lengths(apps, schema_editor):
Cable = apps.get_model('dcim', 'Cable')
# Set the absolute length for any zero-length Cables
Cable.objects.filter(length=0).update(_abs_length=0)
class Migration(migrations.Migration):
dependencies = [
('dcim', '0181_rename_device_role_device_role'),
]
operations = [
migrations.RunPython(
code=update_cable_lengths,
reverse_code=migrations.RunPython.noop
),
]

View File

@@ -5,7 +5,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0181_rename_device_role_device_role'),
('dcim', '0182_zero_length_cable_fix'),
]
operations = [

View File

@@ -7,7 +7,7 @@ import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('dcim', '0182_devicetype_exclude_from_utilization'),
('dcim', '0183_devicetype_exclude_from_utilization'),
]
operations = [

View File

@@ -0,0 +1,25 @@
# Generated by Django 4.2.7 on 2023-12-07 16:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0184_protect_child_interfaces'),
]
operations = [
migrations.AddIndex(
model_name='cabletermination',
index=models.Index(fields=['termination_type', 'termination_id'], name='dcim_cablet_termina_884752_idx'),
),
migrations.AddIndex(
model_name='inventoryitem',
index=models.Index(fields=['component_type', 'component_id'], name='dcim_invent_compone_0560bb_idx'),
),
migrations.AddIndex(
model_name='inventoryitemtemplate',
index=models.Index(fields=['component_type', 'component_id'], name='dcim_invent_compone_77b5f8_idx'),
),
]

View File

@@ -200,7 +200,7 @@ class Cable(PrimaryModel):
_created = self.pk is None
# Store the given length (if any) in meters for use in database ordering
if self.length and self.length_unit:
if self.length is not None and self.length_unit:
self._abs_length = to_meters(self.length, self.length_unit)
else:
self._abs_length = None
@@ -298,6 +298,9 @@ class CableTermination(ChangeLoggedModel):
class Meta:
ordering = ('cable', 'cable_end', 'pk')
indexes = (
models.Index(fields=('termination_type', 'termination_id')),
)
constraints = (
models.UniqueConstraint(
fields=('termination_type', 'termination_id'),

View File

@@ -749,6 +749,9 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
class Meta:
ordering = ('device_type__id', 'parent__id', '_name')
indexes = (
models.Index(fields=('component_type', 'component_id')),
)
constraints = (
models.UniqueConstraint(
fields=('device_type', 'parent', 'name'),

View File

@@ -1115,7 +1115,7 @@ class DeviceBay(ComponentModel, TrackingModelMixin):
installed_device = models.OneToOneField(
to='dcim.Device',
on_delete=models.SET_NULL,
related_name=_('parent_bay'),
related_name='parent_bay',
blank=True,
null=True
)
@@ -1250,6 +1250,9 @@ class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin):
class Meta:
ordering = ('device__id', 'parent__id', '_name')
indexes = (
models.Index(fields=('component_type', 'component_id')),
)
constraints = (
models.UniqueConstraint(
fields=('device', 'parent', 'name'),

View File

@@ -16,7 +16,7 @@ from django.utils.translation import gettext_lazy as _
from dcim.choices import *
from dcim.constants import *
from extras.models import ConfigContextModel
from extras.models import ConfigContextModel, CustomField
from extras.querysets import ConfigContextModelQuerySet
from netbox.config import ConfigItem
from netbox.models import OrganizationalModel, PrimaryModel
@@ -994,11 +994,17 @@ class Device(
bulk_create: If True, bulk_create() will be called to create all components in a single query
(default). Otherwise, save() will be called on each instance individually.
"""
components = [obj.instantiate(device=self) for obj in queryset]
if not components:
return
# Set default values for any applicable custom fields
model = queryset.model.component_model
if cf_defaults := CustomField.objects.get_defaults_for_model(model):
for component in components:
component.custom_field_data = cf_defaults
if bulk_create:
components = [obj.instantiate(device=self) for obj in queryset]
if not components:
return
model = components[0]._meta.model
model.objects.bulk_create(components)
# Manually send the post_save signal for each of the newly created components
for component in components:
@@ -1011,8 +1017,7 @@ class Device(
update_fields=None
)
else:
for obj in queryset:
component = obj.instantiate(device=self)
for component in components:
component.save()
def save(self, *args, **kwargs):

View File

@@ -175,7 +175,7 @@ class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel):
# Rack must belong to same Site as PowerPanel
if self.rack and self.rack.site != self.power_panel.site:
raise ValidationError(_(
"Rack {rack} ({site}) and power panel {powerpanel} ({powerpanel_site}) are in different sites"
"Rack {rack} ({rack_site}) and power panel {powerpanel} ({powerpanel_site}) are in different sites."
).format(
rack=self.rack,
rack_site=self.rack.site,

View File

@@ -141,6 +141,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, WeightMixin):
starting_unit = models.PositiveSmallIntegerField(
default=RACK_STARTING_UNIT_DEFAULT,
verbose_name=_('starting unit'),
validators=[MinValueValidator(1),],
help_text=_('Starting unit for rack')
)
desc_units = models.BooleanField(

View File

@@ -22,7 +22,7 @@ class ConsolePortIndex(SearchIndex):
('description', 500),
('speed', 2000),
)
display_attrs = ('device', 'label', 'description')
display_attrs = ('device', 'label', 'type', 'description')
@register_search
@@ -34,7 +34,7 @@ class ConsoleServerPortIndex(SearchIndex):
('description', 500),
('speed', 2000),
)
display_attrs = ('device', 'label', 'description')
display_attrs = ('device', 'label', 'type', 'description')
@register_search
@@ -48,7 +48,8 @@ class DeviceIndex(SearchIndex):
('comments', 5000),
)
display_attrs = (
'site', 'location', 'rack', 'device_type', 'role', 'tenant', 'platform', 'serial', 'asset_tag', 'description',
'site', 'location', 'rack', 'status', 'device_type', 'role', 'tenant', 'platform', 'serial', 'asset_tag',
'description',
)
@@ -94,7 +95,7 @@ class FrontPortIndex(SearchIndex):
('label', 200),
('description', 500),
)
display_attrs = ('device', 'label', 'description')
display_attrs = ('device', 'label', 'type', 'description')
@register_search
@@ -109,7 +110,7 @@ class InterfaceIndex(SearchIndex):
('mtu', 2000),
('speed', 2000),
)
display_attrs = ('device', 'label', 'description')
display_attrs = ('device', 'label', 'type', 'mac_address', 'wwn', 'description')
@register_search
@@ -123,7 +124,7 @@ class InventoryItemIndex(SearchIndex):
('description', 500),
('part_id', 2000),
)
display_attrs = ('device', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description')
display_attrs = ('device', 'manufacturer', 'parent', 'part_id', 'serial', 'asset_tag', 'description')
@register_search
@@ -213,7 +214,7 @@ class PowerOutletIndex(SearchIndex):
('label', 200),
('description', 500),
)
display_attrs = ('device', 'label', 'description')
display_attrs = ('device', 'label', 'type', 'description')
@register_search
@@ -237,7 +238,7 @@ class PowerPortIndex(SearchIndex):
('maximum_draw', 2000),
('allocated_draw', 2000),
)
display_attrs = ('device', 'label', 'description')
display_attrs = ('device', 'label', 'type', 'description')
@register_search
@@ -251,7 +252,9 @@ class RackIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
display_attrs = ('site', 'location', 'facility_id', 'tenant', 'status', 'role', 'description')
display_attrs = (
'site', 'location', 'facility_id', 'tenant', 'status', 'role', 'serial', 'asset_tag', 'description',
)
@register_search
@@ -272,7 +275,7 @@ class RackRoleIndex(SearchIndex):
('slug', 110),
('description', 500),
)
display_attrs = ('device', 'label', 'description',)
display_attrs = ('description',)
@register_search
@@ -283,7 +286,7 @@ class RearPortIndex(SearchIndex):
('label', 200),
('description', 500),
)
display_attrs = ('device', 'label', 'description')
display_attrs = ('device', 'label', 'type', 'description')
@register_search
@@ -309,7 +312,7 @@ class SiteIndex(SearchIndex):
('shipping_address', 2000),
('comments', 5000),
)
display_attrs = ('region', 'group', 'status', 'description')
display_attrs = ('region', 'group', 'status', 'tenant', 'facility', 'description')
@register_search
@@ -344,4 +347,4 @@ class VirtualDeviceContextIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
display_attrs = ('device', 'status', 'identifier', 'description')
display_attrs = ('device', 'status', 'identifier', 'tenant', 'description')

View File

@@ -277,7 +277,7 @@ class CableTraceSVG:
if cable.type:
# Include the cable type in the tooltip
description.append(cable.get_type_display())
if cable.length and cable.length_unit:
if cable.length is not None and cable.length_unit:
# Include the cable length in the tooltip
description.append(f'{cable.length} {cable.get_length_unit_display()}')
else:
@@ -288,7 +288,7 @@ class CableTraceSVG:
description = []
if cable.type:
labels.append(cable.get_type_display())
if cable.length and cable.length_unit:
if cable.length is not None and cable.length_unit:
# Include the cable length in the tooltip
labels.append(f'{cable.length} {cable.get_length_unit_display()}')

View File

@@ -1085,7 +1085,7 @@ class VirtualDeviceContextTable(TenancyColumnsMixin, NetBoxTable):
comments = columns.MarkdownColumn()
tags = columns.TagColumn(
url_name='dcim:vdc_list'
url_name='dcim:virtualdevicecontext_list'
)
class Meta(NetBoxTable.Meta):

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,11 @@
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.test import TestCase
from circuits.models import *
from dcim.choices import *
from dcim.models import *
from extras.models import CustomField
from tenancy.models import Tenant
from utilities.utils import drange
@@ -289,6 +291,23 @@ class DeviceTestCase(TestCase):
)
DeviceRole.objects.bulk_create(roles)
# Create a CustomField with a default value & assign it to all component models
cf1 = CustomField.objects.create(name='cf1', default='foo')
cf1.content_types.set(
ContentType.objects.filter(app_label='dcim', model__in=[
'consoleport',
'consoleserverport',
'powerport',
'poweroutlet',
'interface',
'rearport',
'frontport',
'modulebay',
'devicebay',
'inventoryitem',
])
)
# Create DeviceType components
ConsolePortTemplate(
device_type=device_type,
@@ -300,18 +319,18 @@ class DeviceTestCase(TestCase):
name='Console Server Port 1'
).save()
ppt = PowerPortTemplate(
powerport = PowerPortTemplate(
device_type=device_type,
name='Power Port 1',
maximum_draw=1000,
allocated_draw=500
)
ppt.save()
powerport.save()
PowerOutletTemplate(
device_type=device_type,
name='Power Outlet 1',
power_port=ppt,
power_port=powerport,
feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A
).save()
@@ -322,19 +341,19 @@ class DeviceTestCase(TestCase):
mgmt_only=True
).save()
rpt = RearPortTemplate(
rearport = RearPortTemplate(
device_type=device_type,
name='Rear Port 1',
type=PortTypeChoices.TYPE_8P8C,
positions=8
)
rpt.save()
rearport.save()
FrontPortTemplate(
device_type=device_type,
name='Front Port 1',
type=PortTypeChoices.TYPE_8P8C,
rear_port=rpt,
rear_port=rearport,
rear_port_position=2
).save()
@@ -348,73 +367,93 @@ class DeviceTestCase(TestCase):
name='Device Bay 1'
).save()
InventoryItemTemplate(
device_type=device_type,
name='Inventory Item 1'
).save()
def test_device_creation(self):
"""
Ensure that all Device components are copied automatically from the DeviceType.
"""
d = Device(
device = Device(
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
role=DeviceRole.objects.first(),
name='Test Device 1'
)
d.save()
device.save()
ConsolePort.objects.get(
device=d,
consoleport = ConsolePort.objects.get(
device=device,
name='Console Port 1'
)
self.assertEqual(consoleport.cf['cf1'], 'foo')
ConsoleServerPort.objects.get(
device=d,
consoleserverport = ConsoleServerPort.objects.get(
device=device,
name='Console Server Port 1'
)
self.assertEqual(consoleserverport.cf['cf1'], 'foo')
pp = PowerPort.objects.get(
device=d,
powerport = PowerPort.objects.get(
device=device,
name='Power Port 1',
maximum_draw=1000,
allocated_draw=500
)
self.assertEqual(powerport.cf['cf1'], 'foo')
PowerOutlet.objects.get(
device=d,
poweroutlet = PowerOutlet.objects.get(
device=device,
name='Power Outlet 1',
power_port=pp,
power_port=powerport,
feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A
)
self.assertEqual(poweroutlet.cf['cf1'], 'foo')
Interface.objects.get(
device=d,
interface = Interface.objects.get(
device=device,
name='Interface 1',
type=InterfaceTypeChoices.TYPE_1GE_FIXED,
mgmt_only=True
)
self.assertEqual(interface.cf['cf1'], 'foo')
rp = RearPort.objects.get(
device=d,
rearport = RearPort.objects.get(
device=device,
name='Rear Port 1',
type=PortTypeChoices.TYPE_8P8C,
positions=8
)
self.assertEqual(rearport.cf['cf1'], 'foo')
FrontPort.objects.get(
device=d,
frontport = FrontPort.objects.get(
device=device,
name='Front Port 1',
type=PortTypeChoices.TYPE_8P8C,
rear_port=rp,
rear_port=rearport,
rear_port_position=2
)
self.assertEqual(frontport.cf['cf1'], 'foo')
ModuleBay.objects.get(
device=d,
modulebay = ModuleBay.objects.get(
device=device,
name='Module Bay 1'
)
self.assertEqual(modulebay.cf['cf1'], 'foo')
DeviceBay.objects.get(
device=d,
devicebay = DeviceBay.objects.get(
device=device,
name='Device Bay 1'
)
self.assertEqual(devicebay.cf['cf1'], 'foo')
inventoryitem = InventoryItem.objects.get(
device=device,
name='Inventory Item 1'
)
self.assertEqual(inventoryitem.cf['cf1'], 'foo')
def test_multiple_unnamed_devices(self):

View File

@@ -692,8 +692,7 @@ class RackRackReservationsView(generic.ObjectChildrenView):
label=_('Reservations'),
badge=lambda obj: obj.reservations.count(),
permission='dcim.view_rackreservation',
weight=510,
hide_if_empty=True
weight=510
)
def get_children(self, request, parent):

View File

@@ -126,11 +126,15 @@ class CustomFieldSerializer(ValidatedModelSerializer):
type = ChoiceField(choices=CustomFieldTypeChoices)
object_type = ContentTypeField(
queryset=ContentType.objects.all(),
required=False
required=False,
allow_null=True
)
filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False)
data_type = serializers.SerializerMethodField()
choice_set = NestedCustomFieldChoiceSetSerializer(required=False)
choice_set = NestedCustomFieldChoiceSetSerializer(
required=False,
allow_null=True
)
ui_visible = ChoiceField(choices=CustomFieldUIVisibleChoices, required=False)
ui_editable = ChoiceField(choices=CustomFieldUIEditableChoices, required=False)

View File

@@ -81,7 +81,7 @@ def process_event_rules(event_rules, model_name, event, data, username, snapshot
# Evaluate event rule conditions (if any)
if not event_rule.eval_conditions(data):
return
continue
# Webhooks
if event_rule.action_type == EventRuleActionChoices.WEBHOOK:

View File

@@ -50,7 +50,7 @@ class WebhookFilterSet(NetBoxModelFilterSet):
model = Webhook
fields = [
'id', 'name', 'payload_url', 'http_method', 'http_content_type', 'secret', 'ssl_verification',
'ca_file_path',
'ca_file_path', 'description',
]
def search(self, queryset, name, value):
@@ -544,7 +544,7 @@ class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
class Meta:
model = ConfigContext
fields = ['id', 'name', 'is_active', 'data_synced']
fields = ['id', 'name', 'is_active', 'data_synced', 'description']
def search(self, queryset, name, value):
if not value.strip():

View File

@@ -1,3 +1,5 @@
import re
from django import forms
from django.contrib.postgres.forms import SimpleArrayField
from django.core.exceptions import ObjectDoesNotExist
@@ -82,7 +84,10 @@ class CustomFieldChoiceSetImportForm(CSVModelForm):
extra_choices = SimpleArrayField(
base_field=forms.CharField(),
required=False,
help_text=_('Comma-separated list of field choices')
help_text=_(
'Quoted string of comma-separated field choices with optional labels separated by colon: '
'"choice1:First Choice,choice2:Second Choice"'
)
)
class Meta:
@@ -91,6 +96,19 @@ class CustomFieldChoiceSetImportForm(CSVModelForm):
'name', 'description', 'extra_choices', 'order_alphabetically',
)
def clean_extra_choices(self):
if isinstance(self.cleaned_data['extra_choices'], list):
data = []
for line in self.cleaned_data['extra_choices']:
try:
value, label = re.split(r'(?<!\\):', line, maxsplit=1)
value = value.replace('\\:', ':')
label = label.replace('\\:', ':')
except ValueError:
value, label = line, line
data.append((value, label))
return data
class CustomLinkImportForm(CSVModelForm):
content_types = CSVMultipleContentTypeField(

View File

@@ -1,4 +1,5 @@
import json
import re
from django import forms
from django.contrib.contenttypes.models import ContentType
@@ -88,19 +89,33 @@ class CustomFieldChoiceSetForm(BootstrapMixin, forms.ModelForm):
required=False,
help_text=mark_safe(_(
'Enter one choice per line. An optional label may be specified for each choice by appending it with a '
'comma. Example:'
) + ' <code>choice1,First Choice</code>')
'colon. Example:'
) + ' <code>choice1:First Choice</code>')
)
class Meta:
model = CustomFieldChoiceSet
fields = ('name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically')
def __init__(self, *args, initial=None, **kwargs):
super().__init__(*args, initial=initial, **kwargs)
# Escape colons in extra_choices
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)
self.initial['extra_choices'] = choices
def clean_extra_choices(self):
data = []
for line in self.cleaned_data['extra_choices'].splitlines():
try:
value, label = line.split(',', maxsplit=1)
value, label = re.split(r'(?<!\\):', line, maxsplit=1)
value = value.replace('\\:', ':')
label = label.replace('\\:', ':')
except ValueError:
value, label = line, line
data.append((value, label))
@@ -254,8 +269,7 @@ class EventRuleForm(NetBoxModelForm):
(_('Events'), ('type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end')),
(_('Conditions'), ('conditions',)),
(_('Action'), (
'action_type', 'action_choice', 'action_parameters', 'action_object_type', 'action_object_id',
'action_data',
'action_type', 'action_choice', 'action_object_type', 'action_object_id', 'action_data',
)),
)
@@ -264,7 +278,7 @@ class EventRuleForm(NetBoxModelForm):
fields = (
'content_types', 'name', 'description', 'type_create', 'type_update', 'type_delete', 'type_job_start',
'type_job_end', 'enabled', 'conditions', 'action_type', 'action_object_type', 'action_object_id',
'action_parameters', 'action_data', 'comments', 'tags'
'action_data', 'comments', 'tags'
)
labels = {
'type_create': _('Creations'),
@@ -278,7 +292,6 @@ class EventRuleForm(NetBoxModelForm):
'action_type': HTMXSelect(),
'action_object_type': forms.HiddenInput,
'action_object_id': forms.HiddenInput,
'action_parameters': forms.HiddenInput,
}
def init_script_choice(self):
@@ -292,16 +305,16 @@ class EventRuleForm(NetBoxModelForm):
choices.append((str(module), scripts))
self.fields['action_choice'].choices = choices
if self.instance.pk:
if self.instance.action_type == EventRuleActionChoices.SCRIPT and self.instance.action_parameters:
scriptmodule_id = self.instance.action_object_id
script_name = self.instance.action_parameters.get('script_name')
self.fields['action_choice'].initial = f'{scriptmodule_id}:{script_name}'
print(self.fields['action_choice'].initial)
def init_webhook_choice(self):
initial = None
if self.fields['action_object_type'] and get_field_value(self, 'action_object_id'):
initial = Webhook.objects.get(pk=get_field_value(self, 'action_object_id'))
if self.instance.action_type == EventRuleActionChoices.WEBHOOK:
webhook_id = get_field_value(self, 'action_object_id')
initial = Webhook.objects.get(pk=webhook_id) if webhook_id else None
self.fields['action_choice'] = DynamicModelChoiceField(
label=_('Webhook'),
queryset=Webhook.objects.all(),
@@ -338,12 +351,21 @@ class EventRuleForm(NetBoxModelForm):
)
module_id, script_name = action_choice.split(":", maxsplit=1)
self.cleaned_data['action_object_id'] = module_id
self.cleaned_data['action_parameters'] = {
'script_name': script_name,
}
return self.cleaned_data
def save(self, *args, **kwargs):
# Set action_parameters on the instance
if self.cleaned_data['action_type'] == EventRuleActionChoices.SCRIPT:
module_id, script_name = self.cleaned_data.get('action_choice').split(":", maxsplit=1)
self.instance.action_parameters = {
'script_name': script_name,
}
else:
self.instance.action_parameters = None
return super().save(*args, **kwargs)
class TagForm(BootstrapMixin, forms.ModelForm):
slug = SlugField()

View File

@@ -114,7 +114,7 @@ class Command(BaseCommand):
# Create the job
job = Job.objects.create(
object=module,
name=script.name,
name=script.class_name,
user=User.objects.filter(is_superuser=True).order_by('pk')[0],
job_id=uuid.uuid4()
)

View File

@@ -91,6 +91,10 @@ class Migration(migrations.Migration):
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
migrations.AddIndex(
model_name='eventrule',
index=models.Index(fields=['action_object_type', 'action_object_id'], name='extras_even_action__d9e2af_idx'),
),
# Replicate Webhook data
migrations.RunPython(move_webhooks),

View File

@@ -0,0 +1,37 @@
# Generated by Django 4.2.7 on 2023-12-07 16:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0102_move_configrevision'),
]
operations = [
migrations.AddIndex(
model_name='bookmark',
index=models.Index(fields=['object_type', 'object_id'], name='extras_book_object__2df6b4_idx'),
),
migrations.AddIndex(
model_name='imageattachment',
index=models.Index(fields=['content_type', 'object_id'], name='extras_imag_content_94728e_idx'),
),
migrations.AddIndex(
model_name='journalentry',
index=models.Index(fields=['assigned_object_type', 'assigned_object_id'], name='extras_jour_assigne_76510f_idx'),
),
migrations.AddIndex(
model_name='objectchange',
index=models.Index(fields=['changed_object_type', 'changed_object_id'], name='extras_obje_changed_927fe5_idx'),
),
migrations.AddIndex(
model_name='objectchange',
index=models.Index(fields=['related_object_type', 'related_object_id'], name='extras_obje_related_bfcdef_idx'),
),
migrations.AddIndex(
model_name='stagedchange',
index=models.Index(fields=['object_type', 'object_id'], name='extras_stag_object__4734d5_idx'),
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 4.2.5 on 2023-12-08 16:03
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('extras', '0103_gfk_indexes'),
]
operations = [
migrations.RemoveField(
model_name='stagedchange',
name='created',
),
migrations.RemoveField(
model_name='stagedchange',
name='last_updated',
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 4.2.8 on 2023-12-27 20:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0104_stagedchange_remove_change_logging'),
]
operations = [
migrations.AlterField(
model_name='customfield',
name='validation_maximum',
field=models.BigIntegerField(blank=True, null=True),
),
migrations.AlterField(
model_name='customfield',
name='validation_minimum',
field=models.BigIntegerField(blank=True, null=True),
),
]

View File

@@ -94,6 +94,10 @@ class ObjectChange(models.Model):
class Meta:
ordering = ['-time']
indexes = (
models.Index(fields=('changed_object_type', 'changed_object_id')),
models.Index(fields=('related_object_type', 'related_object_id')),
)
verbose_name = _('object change')
verbose_name_plural = _('object changes')
@@ -131,3 +135,7 @@ class ObjectChange(models.Model):
def get_action_color(self):
return ObjectChangeActionChoices.colors.get(self.action)
@property
def has_changes(self):
return self.prechange_data != self.postchange_data

View File

@@ -55,6 +55,15 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
content_type = ContentType.objects.get_for_model(model._meta.concrete_model)
return self.get_queryset().filter(content_types=content_type)
def get_defaults_for_model(self, model):
"""
Return a dictionary of serialized default values for all CustomFields applicable to the given model.
"""
custom_fields = self.get_for_model(model).filter(default__isnull=False)
return {
cf.name: cf.default for cf in custom_fields
}
class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
content_types = models.ManyToManyField(
@@ -147,13 +156,13 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
verbose_name=_('display weight'),
help_text=_('Fields with higher weights appear lower in a form.')
)
validation_minimum = models.IntegerField(
validation_minimum = models.BigIntegerField(
blank=True,
null=True,
verbose_name=_('minimum value'),
help_text=_('Minimum allowed value (for numeric fields)')
)
validation_maximum = models.IntegerField(
validation_maximum = models.BigIntegerField(
blank=True,
null=True,
verbose_name=_('maximum value'),
@@ -570,8 +579,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
# Multiselect
elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
filter_class = filters.MultiValueCharFilter
kwargs['lookup_expr'] = 'has_key'
filter_class = filters.MultiValueArrayFilter
# Object
elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:

View File

@@ -132,6 +132,9 @@ class EventRule(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLogged
class Meta:
ordering = ('name',)
indexes = (
models.Index(fields=('action_object_type', 'action_object_id')),
)
verbose_name = _('event rule')
verbose_name_plural = _('event rules')
@@ -395,7 +398,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
text = clean_html(text, allowed_schemes)
# Sanitize link
link = urllib.parse.quote(link, safe='/:?&=%+[]@#,;')
link = urllib.parse.quote(link, safe='/:?&=%+[]@#,;!')
# Verify link scheme is allowed
result = urllib.parse.urlparse(link)
@@ -631,6 +634,9 @@ class ImageAttachment(ChangeLoggedModel):
class Meta:
ordering = ('name', 'pk') # name may be non-unique
indexes = (
models.Index(fields=('content_type', 'object_id')),
)
verbose_name = _('image attachment')
verbose_name_plural = _('image attachments')
@@ -720,6 +726,9 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat
class Meta:
ordering = ('-created',)
indexes = (
models.Index(fields=('assigned_object_type', 'assigned_object_id')),
)
verbose_name = _('journal entry')
verbose_name_plural = _('journal entries')
@@ -769,6 +778,9 @@ class Bookmark(models.Model):
class Meta:
ordering = ('created', 'pk')
indexes = (
models.Index(fields=('object_type', 'object_id')),
)
constraints = (
models.UniqueConstraint(
fields=('object_type', 'object_id', 'user'),

View File

@@ -7,6 +7,7 @@ from django.utils.translation import gettext_lazy as _
from extras.choices import ChangeActionChoices
from netbox.models import ChangeLoggedModel
from netbox.models.features import *
from utilities.utils import deserialize_object
__all__ = (
@@ -54,7 +55,7 @@ class Branch(ChangeLoggedModel):
self.staged_changes.all().delete()
class StagedChange(ChangeLoggedModel):
class StagedChange(CustomValidationMixin, EventRulesMixin, models.Model):
"""
The prepared creation, modification, or deletion of an object to be applied to the active database at a
future point.
@@ -90,6 +91,9 @@ class StagedChange(ChangeLoggedModel):
class Meta:
ordering = ('pk',)
indexes = (
models.Index(fields=('object_type', 'object_id')),
)
verbose_name = _('staged change')
verbose_name_plural = _('staged changes')

View File

@@ -9,6 +9,7 @@ class JournalEntryIndex(SearchIndex):
('comments', 5000),
)
category = 'Journal'
display_attrs = ('kind', 'created_by')
@register_search
@@ -18,3 +19,4 @@ class WebhookEntryIndex(SearchIndex):
('name', 100),
('description', 500),
)
display_attrs = ('description',)

View File

@@ -69,17 +69,17 @@ def handle_changed_object(sender, instance, **kwargs):
return
# Record an ObjectChange if applicable
if hasattr(instance, 'to_objectchange'):
if m2m_changed:
ObjectChange.objects.filter(
changed_object_type=ContentType.objects.get_for_model(instance),
changed_object_id=instance.pk,
request_id=request.id
).update(
postchange_data=instance.to_objectchange(action).postchange_data
)
else:
objectchange = instance.to_objectchange(action)
if m2m_changed:
ObjectChange.objects.filter(
changed_object_type=ContentType.objects.get_for_model(instance),
changed_object_id=instance.pk,
request_id=request.id
).update(
postchange_data=instance.to_objectchange(action).postchange_data
)
else:
objectchange = instance.to_objectchange(action)
if objectchange and objectchange.has_changes:
objectchange.user = request.user
objectchange.request_id = request.id
objectchange.save()

View File

@@ -275,7 +275,11 @@ class EventRuleTable(NetBoxTable):
linkify=True
)
action_type = tables.Column(
verbose_name=_('Action Type'),
verbose_name=_('Type'),
)
action_object = tables.Column(
linkify=True,
verbose_name=_('Object'),
)
content_types = columns.ContentTypesColumn(
verbose_name=_('Content Types'),
@@ -305,12 +309,13 @@ class EventRuleTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = EventRule
fields = (
'pk', 'id', 'name', 'enabled', 'description', 'action_type', 'content_types', 'type_create', 'type_update',
'type_delete', 'type_job_start', 'type_job_end', 'tags', 'created', 'last_updated',
'pk', 'id', 'name', 'enabled', 'description', 'action_type', 'action_object', 'content_types',
'type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end', 'tags', 'created',
'last_updated',
)
default_columns = (
'pk', 'name', 'enabled', 'action_type', 'content_types', 'type_create', 'type_update', 'type_delete',
'type_job_start', 'type_job_end',
'pk', 'name', 'enabled', 'action_type', 'action_object', 'content_types', 'type_create', 'type_update',
'type_delete', 'type_job_start', 'type_job_end',
)

View File

@@ -1,4 +1,5 @@
from django.contrib.contenttypes.models import ContentType
from django.test import override_settings
from django.urls import reverse
from rest_framework import status
@@ -207,6 +208,66 @@ class ChangeLogViewTest(ModelViewTestCase):
self.assertEqual(objectchange.prechange_data['slug'], sites[0].slug)
self.assertEqual(objectchange.postchange_data, None)
@override_settings(CHANGELOG_SKIP_EMPTY_CHANGES=False)
def test_update_object_change(self):
# Create a Site
site = Site.objects.create(
name='Site 1',
slug='site-1',
status=SiteStatusChoices.STATUS_PLANNED,
custom_field_data={
'cf1': None,
'cf2': None
}
)
# Update it with the same field values
form_data = {
'name': site.name,
'slug': site.slug,
'status': SiteStatusChoices.STATUS_PLANNED,
}
request = {
'path': self._get_url('edit', instance=site),
'data': post_data(form_data),
}
self.add_permissions('dcim.change_site', 'extras.view_tag')
response = self.client.post(**request)
self.assertHttpStatus(response, 302)
# Check that an ObjectChange record has been created
self.assertEqual(ObjectChange.objects.count(), 1)
@override_settings(CHANGELOG_SKIP_EMPTY_CHANGES=True)
def test_update_object_nochange(self):
# Create a Site
site = Site.objects.create(
name='Site 1',
slug='site-1',
status=SiteStatusChoices.STATUS_PLANNED,
custom_field_data={
'cf1': None,
'cf2': None
}
)
# Update it with the same field values
form_data = {
'name': site.name,
'slug': site.slug,
'status': SiteStatusChoices.STATUS_PLANNED,
}
request = {
'path': self._get_url('edit', instance=site),
'data': post_data(form_data),
}
self.add_permissions('dcim.change_site', 'extras.view_tag')
response = self.client.post(**request)
self.assertHttpStatus(response, 302)
# Check that no ObjectChange records have been created
self.assertEqual(ObjectChange.objects.count(), 0)
class ChangeLogAPITest(APITestCase):

View File

@@ -0,0 +1,265 @@
from django.test import TestCase
from django.test import override_settings
from circuits.api.serializers import ProviderSerializer
from circuits.forms import ProviderForm
from circuits.models import Provider
from ipam.models import ASN, RIR
from utilities.choices import CSVDelimiterChoices, ImportFormatChoices
from utilities.testing import APITestCase, ModelViewTestCase, create_tags, post_data
class ModelFormCustomValidationTest(TestCase):
@override_settings(CUSTOM_VALIDATORS={
'circuits.provider': [
{'tags': {'required': True}}
]
})
def test_tags_validation(self):
"""
Check that custom validation rules work for tag assignment.
"""
data = {
'name': 'Provider 1',
'slug': 'provider-1',
}
form = ProviderForm(data)
self.assertFalse(form.is_valid())
tags = create_tags('Tag1', 'Tag2', 'Tag3')
data['tags'] = [tag.pk for tag in tags]
form = ProviderForm(data)
self.assertTrue(form.is_valid())
@override_settings(CUSTOM_VALIDATORS={
'circuits.provider': [
{'asns': {'required': True}}
]
})
def test_m2m_validation(self):
"""
Check that custom validation rules work for many-to-many fields.
"""
data = {
'name': 'Provider 1',
'slug': 'provider-1',
}
form = ProviderForm(data)
self.assertFalse(form.is_valid())
rir = RIR.objects.create(name='RIR 1', slug='rir-1')
asns = ASN.objects.bulk_create((
ASN(rir=rir, asn=65001),
ASN(rir=rir, asn=65002),
ASN(rir=rir, asn=65003),
))
data['asns'] = [asn.pk for asn in asns]
form = ProviderForm(data)
self.assertTrue(form.is_valid())
class BulkEditCustomValidationTest(ModelViewTestCase):
model = Provider
@classmethod
def setUpTestData(cls):
rir = RIR.objects.create(name='RIR 1', slug='rir-1')
asns = ASN.objects.bulk_create((
ASN(rir=rir, asn=65001),
ASN(rir=rir, asn=65002),
ASN(rir=rir, asn=65003),
))
providers = (
Provider(name='Provider 1', slug='provider-1'),
Provider(name='Provider 2', slug='provider-2'),
Provider(name='Provider 3', slug='provider-3'),
)
Provider.objects.bulk_create(providers)
for provider in providers:
provider.asns.set(asns)
@override_settings(CUSTOM_VALIDATORS={
'circuits.provider': [
{'asns': {'required': True}}
]
})
def test_bulk_edit_without_m2m(self):
"""
Check that custom validation rules do not interfere with bulk editing.
"""
data = {
'pk': list(Provider.objects.values_list('pk', flat=True)),
'_apply': '',
'description': 'New description',
}
self.add_permissions(
'circuits.view_provider',
'circuits.change_provider',
)
# Bulk edit the description without changing ASN assignments
request = {
'path': self._get_url('bulk_edit'),
'data': post_data(data),
}
response = self.client.post(**request)
self.assertHttpStatus(response, 302)
self.assertEqual(
Provider.objects.filter(description=data['description']).count(),
len(data['pk'])
)
@override_settings(CUSTOM_VALIDATORS={
'circuits.provider': [
{'asns': {'required': True}}
]
})
def test_bulk_edit_m2m(self):
"""
Test that custom validation rules are enforced during bulk editing.
"""
data = {
'pk': list(Provider.objects.values_list('pk', flat=True)),
'_apply': '',
'description': 'New description',
}
self.add_permissions(
'circuits.view_provider',
'circuits.change_provider',
'ipam.view_asn',
)
# Change the ASN assignments
asn = ASN.objects.first()
data['asns'] = [asn.pk]
request = {
'path': self._get_url('bulk_edit'),
'data': post_data(data),
}
response = self.client.post(**request)
self.assertHttpStatus(response, 302)
for provider in Provider.objects.all():
self.assertEqual(len(provider.asns.all()), 1)
# Attempt to remove the ASN assignments
data.pop('asns')
data['_nullify'] = 'asns'
request = {
'path': self._get_url('bulk_edit'),
'data': post_data(data),
}
response = self.client.post(**request)
self.assertHttpStatus(response, 200)
for provider in Provider.objects.all():
self.assertTrue(provider.asns.exists())
class BulkImportCustomValidationTest(ModelViewTestCase):
model = Provider
@classmethod
def setUpTestData(cls):
create_tags('Tag1', 'Tag2', 'Tag3')
@override_settings(CUSTOM_VALIDATORS={
'circuits.provider': [
{'tags': {'required': True}}
]
})
def test_bulk_import_invalid(self):
"""
Test that custom validation rules are enforced during bulk import.
"""
csv_data = (
"name,slug",
"Provider 1,provider-1",
"Provider 2,provider-2",
"Provider 3,provider-3",
)
data = {
'data': '\n'.join(csv_data),
'format': ImportFormatChoices.CSV,
'csv_delimiter': CSVDelimiterChoices.COMMA,
}
self.add_permissions(
'circuits.view_provider',
'circuits.add_provider',
'extras.view_tag',
)
# Attempt to import providers without tags
request = {
'path': self._get_url('import'),
'data': post_data(data),
}
response = self.client.post(**request)
self.assertHttpStatus(response, 200)
self.assertFalse(Provider.objects.exists())
# Import providers successfully with tag assignments
csv_data = (
"name,slug,tags",
"Provider 1,provider-1,tag1",
"Provider 2,provider-2,tag2",
"Provider 3,provider-3,tag3",
)
data['data'] = '\n'.join(csv_data)
request = {
'path': self._get_url('import'),
'data': post_data(data),
}
response = self.client.post(**request)
self.assertHttpStatus(response, 302)
self.assertTrue(Provider.objects.exists())
class APISerializerCustomValidationTest(APITestCase):
@override_settings(CUSTOM_VALIDATORS={
'circuits.provider': [
{'tags': {'required': True}}
]
})
def test_tags_validation(self):
"""
Check that custom validation rules work for tag assignment.
"""
data = {
'name': 'Provider 1',
'slug': 'provider-1',
}
serializer = ProviderSerializer(data=data)
self.assertFalse(serializer.is_valid())
tags = create_tags('Tag1', 'Tag2', 'Tag3')
data['tags'] = [tag.pk for tag in tags]
serializer = ProviderSerializer(data=data)
self.assertTrue(serializer.is_valid())
@override_settings(CUSTOM_VALIDATORS={
'circuits.provider': [
{'asns': {'required': True}}
]
})
def test_m2m_validation(self):
"""
Check that custom validation rules work for many-to-many fields.
"""
data = {
'name': 'Provider 1',
'slug': 'provider-1',
}
serializer = ProviderSerializer(data=data)
self.assertFalse(serializer.is_valid())
rir = RIR.objects.create(name='RIR 1', slug='rir-1')
asns = ASN.objects.bulk_create((
ASN(rir=rir, asn=65001),
ASN(rir=rir, asn=65002),
ASN(rir=rir, asn=65003),
))
data['asns'] = [asn.pk for asn in asns]
serializer = ProviderSerializer(data=data)
self.assertTrue(serializer.is_valid())

View File

@@ -1329,7 +1329,7 @@ class CustomFieldModelFilterTest(TestCase):
choice_set = CustomFieldChoiceSet.objects.create(
name='Custom Field Choice Set 1',
extra_choices=(('a', 'A'), ('b', 'B'), ('c', 'C'), ('x', 'X'))
extra_choices=(('a', 'A'), ('b', 'B'), ('c', 'C'))
)
# Integer filtering
@@ -1435,7 +1435,7 @@ class CustomFieldModelFilterTest(TestCase):
'cf7': 'http://a.example.com',
'cf8': 'http://a.example.com',
'cf9': 'A',
'cf10': ['A', 'X'],
'cf10': ['A', 'B'],
'cf11': manufacturers[0].pk,
'cf12': [manufacturers[0].pk, manufacturers[3].pk],
}),
@@ -1449,7 +1449,7 @@ class CustomFieldModelFilterTest(TestCase):
'cf7': 'http://b.example.com',
'cf8': 'http://b.example.com',
'cf9': 'B',
'cf10': ['B', 'X'],
'cf10': ['B', 'C'],
'cf11': manufacturers[1].pk,
'cf12': [manufacturers[1].pk, manufacturers[3].pk],
}),
@@ -1463,7 +1463,7 @@ class CustomFieldModelFilterTest(TestCase):
'cf7': 'http://c.example.com',
'cf8': 'http://c.example.com',
'cf9': 'C',
'cf10': ['C', 'X'],
'cf10': None,
'cf11': manufacturers[2].pk,
'cf12': [manufacturers[2].pk, manufacturers[3].pk],
}),
@@ -1531,8 +1531,9 @@ class CustomFieldModelFilterTest(TestCase):
self.assertEqual(self.filterset({'cf_cf9': ['A', 'B']}, self.queryset).qs.count(), 2)
def test_filter_multiselect(self):
self.assertEqual(self.filterset({'cf_cf10': ['A', 'B']}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf10': ['X']}, self.queryset).qs.count(), 3)
self.assertEqual(self.filterset({'cf_cf10': ['A']}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf10': ['A', 'C']}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf10': ['null']}, self.queryset).qs.count(), 1)
def test_filter_object(self):
manufacturer_ids = Manufacturer.objects.values_list('id', flat=True)

View File

@@ -42,7 +42,8 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
weight=100,
filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE,
ui_visible=CustomFieldUIVisibleChoices.ALWAYS,
ui_editable=CustomFieldUIEditableChoices.YES
ui_editable=CustomFieldUIEditableChoices.YES,
description='foobar1'
),
CustomField(
name='Custom Field 2',
@@ -51,7 +52,8 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
weight=200,
filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT,
ui_visible=CustomFieldUIVisibleChoices.IF_SET,
ui_editable=CustomFieldUIEditableChoices.NO
ui_editable=CustomFieldUIEditableChoices.NO,
description='foobar2'
),
CustomField(
name='Custom Field 3',
@@ -60,7 +62,8 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
weight=300,
filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED,
ui_visible=CustomFieldUIVisibleChoices.HIDDEN,
ui_editable=CustomFieldUIEditableChoices.HIDDEN
ui_editable=CustomFieldUIEditableChoices.HIDDEN,
description='foobar3'
),
CustomField(
name='Custom Field 4',
@@ -90,6 +93,10 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
custom_fields[3].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'device'))
custom_fields[4].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'device'))
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Custom Field 1', 'Custom Field 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -126,6 +133,10 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
params = {'choice_set_id': CustomFieldChoiceSet.objects.values_list('pk', flat=True)}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class CustomFieldChoiceSetTestCase(TestCase, BaseFilterSetTests):
queryset = CustomFieldChoiceSet.objects.all()
@@ -134,12 +145,16 @@ class CustomFieldChoiceSetTestCase(TestCase, BaseFilterSetTests):
@classmethod
def setUpTestData(cls):
choice_sets = (
CustomFieldChoiceSet(name='Choice Set 1', extra_choices=['A', 'B', 'C']),
CustomFieldChoiceSet(name='Choice Set 2', extra_choices=['D', 'E', 'F']),
CustomFieldChoiceSet(name='Choice Set 3', extra_choices=['G', 'H', 'I']),
CustomFieldChoiceSet(name='Choice Set 1', extra_choices=['A', 'B', 'C'], description='foobar1'),
CustomFieldChoiceSet(name='Choice Set 2', extra_choices=['D', 'E', 'F'], description='foobar2'),
CustomFieldChoiceSet(name='Choice Set 3', extra_choices=['G', 'H', 'I'], description='foobar3'),
)
CustomFieldChoiceSet.objects.bulk_create(choice_sets)
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Choice Set 1', 'Choice Set 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -148,6 +163,10 @@ class CustomFieldChoiceSetTestCase(TestCase, BaseFilterSetTests):
params = {'choice': ['A', 'D']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class WebhookTestCase(TestCase, BaseFilterSetTests):
queryset = Webhook.objects.all()
@@ -163,18 +182,21 @@ class WebhookTestCase(TestCase, BaseFilterSetTests):
payload_url='http://example.com/?1',
http_method='GET',
ssl_verification=True,
description='foobar1'
),
Webhook(
name='Webhook 2',
payload_url='http://example.com/?2',
http_method='POST',
ssl_verification=True,
description='foobar2'
),
Webhook(
name='Webhook 3',
payload_url='http://example.com/?3',
http_method='PATCH',
ssl_verification=False,
description='foobar3'
),
Webhook(
name='Webhook 4',
@@ -191,10 +213,18 @@ class WebhookTestCase(TestCase, BaseFilterSetTests):
)
Webhook.objects.bulk_create(webhooks)
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Webhook 1', 'Webhook 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_http_method(self):
params = {'http_method': ['GET', 'POST']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -253,6 +283,7 @@ class EventRuleTestCase(TestCase, BaseFilterSetTests):
type_job_start=False,
type_job_end=False,
action_type=EventRuleActionChoices.WEBHOOK,
description='foobar1'
),
EventRule(
name='Event Rule 2',
@@ -264,6 +295,7 @@ class EventRuleTestCase(TestCase, BaseFilterSetTests):
type_job_start=False,
type_job_end=False,
action_type=EventRuleActionChoices.WEBHOOK,
description='foobar2'
),
EventRule(
name='Event Rule 3',
@@ -275,6 +307,7 @@ class EventRuleTestCase(TestCase, BaseFilterSetTests):
type_job_start=False,
type_job_end=False,
action_type=EventRuleActionChoices.WEBHOOK,
description='foobar3'
),
EventRule(
name='Event Rule 4',
@@ -306,10 +339,18 @@ class EventRuleTestCase(TestCase, BaseFilterSetTests):
event_rules[3].content_types.add(content_types[3])
event_rules[4].content_types.add(content_types[4])
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Event Rule 1', 'Event Rule 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_content_types(self):
params = {'content_types': 'dcim.region'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -387,6 +428,10 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
for i, custom_link in enumerate(custom_links):
custom_link.content_types.set([content_types[i]])
def test_q(self):
params = {'q': 'Custom Link 1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Custom Link 1', 'Custom Link 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -437,7 +482,8 @@ class SavedFilterTestCase(TestCase, BaseFilterSetTests):
weight=100,
enabled=True,
shared=True,
parameters={'status': ['active']}
parameters={'status': ['active']},
description='foobar1'
),
SavedFilter(
name='Saved Filter 2',
@@ -446,7 +492,8 @@ class SavedFilterTestCase(TestCase, BaseFilterSetTests):
weight=200,
enabled=True,
shared=True,
parameters={'status': ['planned']}
parameters={'status': ['planned']},
description='foobar2'
),
SavedFilter(
name='Saved Filter 3',
@@ -455,13 +502,18 @@ class SavedFilterTestCase(TestCase, BaseFilterSetTests):
weight=300,
enabled=False,
shared=False,
parameters={'status': ['retired']}
parameters={'status': ['retired']},
description='foobar3'
),
)
SavedFilter.objects.bulk_create(saved_filters)
for i, savedfilter in enumerate(saved_filters):
savedfilter.content_types.set([content_types[i]])
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Saved Filter 1', 'Saved Filter 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -470,6 +522,10 @@ class SavedFilterTestCase(TestCase, BaseFilterSetTests):
params = {'slug': ['saved-filter-1', 'saved-filter-2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_content_types(self):
params = {'content_types': 'dcim.site'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -513,8 +569,6 @@ class BookmarkTestCase(TestCase, BaseFilterSetTests):
@classmethod
def setUpTestData(cls):
content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device'])
users = (
User(username='User 1'),
User(username='User 2'),
@@ -595,6 +649,10 @@ class ExportTemplateTestCase(TestCase, BaseFilterSetTests):
for i, et in enumerate(export_templates):
et.content_types.set([content_types[i]])
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Export Template 1', 'Export Template 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -668,6 +726,10 @@ class ImageAttachmentTestCase(TestCase, BaseFilterSetTests):
)
ImageAttachment.objects.bulk_create(image_attachments)
def test_q(self):
params = {'q': 'Attachment 1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Image Attachment 1', 'Image Attachment 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -720,41 +782,45 @@ class JournalEntryTestCase(TestCase, ChangeLoggedFilterSetTests):
assigned_object=sites[0],
created_by=users[0],
kind=JournalEntryKindChoices.KIND_INFO,
comments='New journal entry'
comments='foobar1'
),
JournalEntry(
assigned_object=sites[0],
created_by=users[1],
kind=JournalEntryKindChoices.KIND_SUCCESS,
comments='New journal entry'
comments='foobar2'
),
JournalEntry(
assigned_object=sites[1],
created_by=users[2],
kind=JournalEntryKindChoices.KIND_WARNING,
comments='New journal entry'
comments='foobar3'
),
JournalEntry(
assigned_object=racks[0],
created_by=users[0],
kind=JournalEntryKindChoices.KIND_INFO,
comments='New journal entry'
comments='foobar4'
),
JournalEntry(
assigned_object=racks[0],
created_by=users[1],
kind=JournalEntryKindChoices.KIND_SUCCESS,
comments='New journal entry'
comments='foobar5'
),
JournalEntry(
assigned_object=racks[1],
created_by=users[2],
kind=JournalEntryKindChoices.KIND_WARNING,
comments='New journal entry'
comments='foobar6'
),
)
JournalEntry.objects.bulk_create(journal_entries)
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_created_by(self):
users = User.objects.filter(username__in=['Alice', 'Bob'])
params = {'created_by': [users[0].username, users[1].username]}
@@ -890,9 +956,10 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
for i in range(0, 3):
is_active = bool(i % 2)
c = ConfigContext.objects.create(
name='Config Context {}'.format(i + 1),
name=f"Config Context {i + 1}",
is_active=is_active,
data='{"foo": 123}'
data='{"foo": 123}',
description=f"foobar{i + 1}"
)
c.regions.set([regions[i]])
c.site_groups.set([site_groups[i]])
@@ -908,6 +975,10 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
c.tenants.set([tenants[i]])
c.tags.set([tags[i]])
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Config Context 1', 'Config Context 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -918,6 +989,10 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'is_active': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
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]}
@@ -1019,6 +1094,10 @@ class ConfigTemplateTestCase(TestCase, BaseFilterSetTests):
)
ConfigTemplate.objects.bulk_create(config_templates)
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Config Template 1', 'Config Template 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -1055,6 +1134,10 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
site.tags.set([tags[0]])
provider.tags.set([tags[1]])
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Tag 1', 'Tag 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -1166,6 +1249,10 @@ class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
)
ObjectChange.objects.bulk_create(object_changes)
def test_q(self):
params = {'q': 'Site 1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_user(self):
params = {'user_id': User.objects.filter(username__in=['user1', 'user2']).values_list('pk', flat=True)}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)

View File

@@ -92,19 +92,24 @@ class CustomFieldChoiceSetTestCase(ViewTestCases.PrimaryObjectViewTestCase):
name='Choice Set 3',
extra_choices=(('C1', 'Choice 1'), ('C2', 'Choice 2'), ('C3', 'Choice 3'))
),
CustomFieldChoiceSet(
name='Choice Set 4',
extra_choices=(('D1', 'Choice 1'), ('D2', 'Choice 2'), ('D3', 'Choice 3'))
),
)
CustomFieldChoiceSet.objects.bulk_create(choice_sets)
cls.form_data = {
'name': 'Choice Set X',
'extra_choices': '\n'.join(['X1,Choice 1', 'X2,Choice 2', 'X3,Choice 3'])
'extra_choices': '\n'.join(['X1:Choice 1', 'X2:Choice 2', 'X3:Choice 3'])
}
cls.csv_data = (
'name,extra_choices',
'Choice Set 4,"D1,D2,D3"',
'Choice Set 5,"E1,E2,E3"',
'Choice Set 6,"F1,F2,F3"',
'Choice Set 5,"D1,D2,D3"',
'Choice Set 6,"E1,E2,E3"',
'Choice Set 7,"F1,F2,F3"',
'Choice Set 8,"F1:L1,F2:L2,F3:L3"',
)
cls.csv_update_data = (
@@ -112,12 +117,20 @@ class CustomFieldChoiceSetTestCase(ViewTestCases.PrimaryObjectViewTestCase):
f'{choice_sets[0].pk},"A,B,C"',
f'{choice_sets[1].pk},"A,B,C"',
f'{choice_sets[2].pk},"A,B,C"',
f'{choice_sets[3].pk},"A:L1,B:L2,C:L3"',
)
cls.bulk_edit_data = {
'description': 'New description',
}
# This is here as extra_choices field splits on colon, but is returned
# from DB as comma separated.
def assertInstanceEqual(self, instance, data, exclude=None, api=False):
if 'extra_choices' in data:
data['extra_choices'] = data['extra_choices'].replace(':', ',')
return super().assertInstanceEqual(instance, data, exclude, api)
class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = CustomLink

View File

@@ -91,8 +91,7 @@ class CustomValidator:
def __call__(self, instance):
# Validate instance attributes per validation rules
for attr_name, rules in self.validation_rules.items():
assert hasattr(instance, attr_name), f"Invalid attribute '{attr_name}' for {instance.__class__.__name__}"
attr = getattr(instance, attr_name)
attr = self._getattr(instance, attr_name)
for descriptor, value in rules.items():
validator = self.get_validator(descriptor, value)
try:
@@ -104,6 +103,26 @@ class CustomValidator:
# Execute custom validation logic (if any)
self.validate(instance)
@staticmethod
def _getattr(instance, name):
# Attempt to resolve many-to-many fields to their stored values
m2m_fields = [f.name for f in instance._meta.local_many_to_many]
if name in m2m_fields:
if name in getattr(instance, '_m2m_values', []):
return instance._m2m_values[name]
if instance.pk:
return list(getattr(instance, name).all())
return []
# Raise a ValidationError for unknown attributes
if not hasattr(instance, name):
raise ValidationError(_('Invalid attribute "{name}" for {model}').format(
name=name,
model=instance.__class__.__name__
))
return getattr(instance, name)
def get_validator(self, descriptor, value):
"""
Instantiate and return the appropriate validator based on the descriptor given. For

View File

@@ -1,3 +1,5 @@
from copy import deepcopy
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.db import transaction
from django.shortcuts import get_object_or_404
@@ -277,7 +279,7 @@ class AvailableObjectsView(ObjectValidationMixin, APIView):
)
# Prepare object data for deserialization
requested_objects = self.prep_object_data(requested_objects, available_objects, parent)
requested_objects = self.prep_object_data(deepcopy(requested_objects), available_objects, parent)
# Initialize the serializer with a list or a single object depending on what was requested
serializer_class = get_serializer_for_model(self.queryset.model)

View File

@@ -758,7 +758,7 @@ class FHRPGroupFilterSet(NetBoxModelFilterSet):
class Meta:
model = FHRPGroup
fields = ['id', 'group_id', 'name', 'auth_key']
fields = ['id', 'group_id', 'name', 'auth_key', 'description']
def search(self, queryset, name, value):
if not value.strip():
@@ -949,6 +949,10 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
choices=VLANStatusChoices,
null_value=None
)
available_at_site = django_filters.ModelChoiceFilter(
queryset=Site.objects.all(),
method='get_for_site'
)
available_on_device = django_filters.ModelChoiceFilter(
queryset=Device.objects.all(),
method='get_for_device'
@@ -983,6 +987,10 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
pass
return queryset.filter(qs_filter)
@extend_schema_field(OpenApiTypes.STR)
def get_for_site(self, queryset, name, value):
return queryset.get_for_site(value)
@extend_schema_field(OpenApiTypes.STR)
def get_for_device(self, queryset, name, value):
return queryset.get_for_device(value)
@@ -1000,12 +1008,15 @@ class ServiceTemplateFilterSet(NetBoxModelFilterSet):
class Meta:
model = ServiceTemplate
fields = ['id', 'name', 'protocol']
fields = ['id', 'name', 'protocol', 'description']
def search(self, queryset, name, value):
if not value.strip():
return queryset
qs_filter = Q(name__icontains=value) | Q(description__icontains=value)
qs_filter = (
Q(name__icontains=value) |
Q(description__icontains=value)
)
return queryset.filter(qs_filter)

View File

@@ -296,6 +296,7 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
(_('Device/VM'), ('device_id', 'virtual_machine_id')),
)
selector_fields = ('filter_id', 'q', 'region_id', 'group_id', 'parent', 'status', 'role')
parent = forms.CharField(
required=False,
widget=forms.TextInput(
@@ -448,6 +449,7 @@ class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
(_('Attributes'), ('group_id', 'status', 'role_id', 'vid', 'l2vpn_id')),
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
)
selector_fields = ('filter_id', 'q', 'site_id')
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,

View File

@@ -214,7 +214,7 @@ class PrefixForm(TenancyForm, NetBoxModelForm):
required=False,
selector=True,
query_params={
'site_id': '$site',
'available_at_site': '$site',
},
label=_('VLAN'),
)

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