Compare commits

...

140 Commits

Author SHA1 Message Date
Jeremy Stretch
98571c62a6 Merge pull request #8372 from netbox-community/develop
Release v3.1.6
2022-01-17 10:15:24 -05:00
jeremystretch
69f525bfd3 Release v3.1.6 2022-01-17 09:49:16 -05:00
jeremystretch
2b31154834 Fixes #8358: Fix inconsistent styling of custom fields on filter & bulk edit forms 2022-01-14 14:23:58 -05:00
jeremystretch
b0948ea018 Changelog for #8342, #8357 2022-01-14 11:51:02 -05:00
Jeremy Stretch
a50e4e3380 Merge pull request #8352 from jasonyates/8342-created-updated
Fixes #8342
2022-01-14 11:48:52 -05:00
Jeremy Stretch
5564664b13 Merge pull request #8360 from jasonyates/8357-location-filter
Fixes #8357 - Filter view for Locations is missing tags field
2022-01-14 11:48:36 -05:00
Jason Yates
1ae5a2c808 Fixes #8357 - Filter view for Locations is missing tags field
Adding tag field to Locations filter view
2022-01-14 06:19:25 -08:00
Jason Yates
0181a25d70 Fixes #8342
created & last_updated fields are missing from some REST API calls. Added missing fields to the following API calls

/api/dcim/virtual-chassis/
/api/dcim/cables/
/api/dcim/power-panels/
/api/dcim/rack-reservations/
/api/circuits/circuit-terminations/
/api/extras/webhooks/
/api/extras/custom-fields/
/api/extras/custom-links/
/api/extras/export-templates/
/api/extras/tags/
2022-01-13 19:13:28 -08:00
jeremystretch
60ba4a9830 Changelog for #8337 2022-01-13 15:24:15 -05:00
Jeremy Stretch
3802a78c9d Merge pull request #8341 from jasonyates/8337-created-updated
Add created & last updated as available fields to all tables
2022-01-13 15:23:12 -05:00
jeremystretch
0ca6d73614 #8293: Tweak table column output & add changelog 2022-01-13 15:10:06 -05:00
Jeremy Stretch
aa77f8f0d2 Merge pull request #8329 from jasonyates/8293-asdot
Adding asdot notation to ASN views
2022-01-13 15:02:21 -05:00
Jason Yates
381796e708 Add created & last updated as available fields to all tables
Adds two fields to all relevant tables to allow the addition of Created & Last Updated columns.

All tables with a Configure Table option were updated.

Some sections reformatted to comply with E501 line length as a result of changes
2022-01-13 09:22:32 +00:00
Jason Yates
62fc7717c8 Suggested changes
* Updating asdot computation to use an fstring
* Cleaning code. Custom property now returns either the ASN with ASDOT notation or just the ASN. asn_with_asdot can now be referenced in ASNTable & objet template.
2022-01-13 04:58:51 +00:00
jeremystretch
e19451bb4f Plug WG8333 in the plugins development docs 2022-01-12 14:40:33 -05:00
Jason Yates
85f588e8c9 Updating page title to include asdot notation 2022-01-12 16:44:22 +00:00
Jason Yates
ea644868a6 Adding asdot notation to ASN views
Adds custom property to asn model to compute asdot notation if required.
Updates asn view to show asdot notation if one exists in the format xxxxx (yyy.yyy)
Adds a custom column renderer to asn table to display asdot notation if one exists
2022-01-12 14:06:22 +00:00
jeremystretch
d08accaaf1 Changelog for #8279 2022-01-11 16:27:30 -05:00
Jeremy Stretch
f49272cacb Merge pull request #8321 from jasonyates/8279-vc-rack-view
Fixes #8279 - No virtual chassis name in rack view
2022-01-11 16:25:50 -05:00
Jason Yates
be8fef0228 Fixes #8279
A device that is part of a VC that has no name should display [virtual-chassis name]:[virtual-chassis position] as opposed to [device_type] in the rack rendering.
2022-01-11 21:03:18 +00:00
jeremystretch
b584f09223 Fixes #8319: Custom URL fields should honor ALLOWED_URL_SCHEMES config parameter 2022-01-11 15:32:04 -05:00
jeremystretch
d2968c95df Fixes #8314: Prevent custom fields with default values from appearing as applied filters erroneously 2022-01-11 15:02:10 -05:00
jeremystretch
7421e5f7d7 Fixes #8317: Fix CSV import of multi-select custom field values 2022-01-11 14:52:47 -05:00
jeremystretch
0b2a43cfcc Document formal release cycle 2022-01-11 12:54:07 -05:00
jeremystretch
50309d3ab3 Reference netbox-demo-data repo in development guide 2022-01-10 15:34:27 -05:00
jeremystretch
dd0b16bff5 Fixes #8305: Fix assignment of custom field data to FHRP groups via UI 2022-01-10 15:26:01 -05:00
jeremystretch
d5443adc74 Tweak sidebar colors & remove hover delay 2022-01-10 15:13:12 -05:00
jeremystretch
9152ba72f1 Fixes #8306: Redirect user to previous page after login 2022-01-10 14:44:25 -05:00
jeremystretch
076ca46ab4 Closes #8302: Linkify role column in device & VM tables 2022-01-10 09:48:14 -05:00
jeremystretch
02519b270e Fixes #8301: Fix delete button for various object children views 2022-01-10 09:30:50 -05:00
jeremystretch
5aa7dedccb Changelog for #8246, #8285 2022-01-10 08:38:08 -05:00
Jeremy Stretch
6383dfa854 Merge pull request #8292 from jasonyates/8246-commit-rate
Fixes #8246 - Circuits list view to display formatted commit rate
2022-01-10 08:36:47 -05:00
Jeremy Stretch
5a4fb0323b Merge pull request #8286 from jasonyates/8285-cluster-count-tenant
Fixes #8285 tenant cluster count
2022-01-10 08:34:02 -05:00
jeremystretch
e84a282aa6 Revert REST API changes from #8284 2022-01-10 08:24:45 -05:00
Jason Yates
f732493473 Fixing code style E302 2022-01-08 22:24:25 +00:00
Jason Yates
f66a265fcf Fixes #8246 - Circuits list view to display formatted commit rate
Adds a custom column class to format the commit rate in the circuits table view using humanize_speed template helper. Export still exports the raw number.
2022-01-08 21:55:07 +00:00
Daniel Sheppard
f1472d218e Update changelog for #8262 and #8265 2022-01-08 00:21:38 -06:00
Daniel Sheppard
d65c05aacd Merge pull request #8269 from bluikko/cisco-stackwise-n
Merge PR from bluikko for #8265
2022-01-08 00:20:43 -06:00
Daniel Sheppard
2b28ffa2f4 Merge pull request #8284 from jasonyates/8262-tenant-cable-stat
Fixes #8262 - Add Cable stat for Tenant
2022-01-08 00:15:35 -06:00
Daniel Sheppard
10ec31df3e Fix #8287 - Correct label in export template form 2022-01-08 00:13:58 -06:00
Jason Yates
184b1055dc Fixes #8285 - Cluster count missing from tenant api output 2022-01-07 20:17:43 +00:00
Jason Yates
eaec25e6c2 Fixes #8262 - Add Cable stat for Tenant 2022-01-07 20:02:45 +00:00
bluikko
b63e29610e Add Cisco StackWise-n choices 2022-01-07 11:56:54 +07:00
jeremystretch
b0db5a8b0a PRVB 2022-01-06 09:58:50 -05:00
Jeremy Stretch
d3e2241ff7 Merge pull request #8257 from netbox-community/develop
Release v3.1.5
2022-01-06 09:52:54 -05:00
jeremystretch
e90b9f6c19 Release v3.1.5 2022-01-06 09:24:28 -05:00
jeremystretch
4c1199e009 Fixes #8255: Fix bulk editing of authentication parameters for wireless LANs and links 2022-01-06 08:54:05 -05:00
jeremystretch
65471068b6 Closes #8252: Linkify type and group columns in clusters table 2022-01-05 21:36:20 -05:00
jeremystretch
c6467a824b #8228: Always add a blank choice 2022-01-05 17:10:59 -05:00
jeremystretch
b1d1f3c6b2 Fixes #8228: Optional ChoiceVar fields should not force a selection 2022-01-05 15:46:04 -05:00
jeremystretch
574c2e2770 Closes #8244: Add length & length unit fields to cable filter form 2022-01-05 15:32:34 -05:00
jeremystretch
aec2d233c9 Changelog for #8231 2022-01-05 15:18:49 -05:00
Jeremy Stretch
39418f2bbe Merge pull request #8247 from netbox-community/8231-htmx-confirmation-dialogs
Closes #8231: Use HTMX for object deletion confirmations
2022-01-05 15:14:51 -05:00
jeremystretch
ccda73494f Center modal dialog vertically 2022-01-05 14:57:56 -05:00
jeremystretch
443b4ccc57 Initial work on #8231 2022-01-05 14:06:56 -05:00
jeremystretch
511aedd5db Omit table configuration form from rack elevations view 2022-01-05 11:39:58 -05:00
jeremystretch
2524290099 Introduce modals template block 2022-01-05 09:21:48 -05:00
jeremystretch
01e8017265 Clean up template blocks 2022-01-05 09:09:39 -05:00
jeremystretch
8338fc405f Simplify theme color palette 2022-01-04 20:51:10 -05:00
jeremystretch
0a22b3990f #7450: Clean up footer and navbar styles 2022-01-04 20:42:44 -05:00
jeremystretch
662cafe416 Form widgets & style cleanup 2022-01-04 15:01:16 -05:00
jeremystretch
ea961ba8f2 Fixes #8224: Fix KeyError exception when creating FHRP group with IP address and protocol "other" 2022-01-04 13:49:07 -05:00
jeremystretch
8c8774cd2f Fixes #8226: Honor return URL after populating a device bay 2022-01-04 13:24:15 -05:00
jeremystretch
2fe02ddb1f Add tests for IPAM object children views 2022-01-04 09:32:41 -05:00
jeremystretch
e11e8a5d64 Fixes #8213: Fix ValueError exception under prefix IP addresses view 2022-01-04 09:15:25 -05:00
jeremystretch
79bebf7c9b PRVB 2022-01-03 11:18:46 -05:00
Jeremy Stretch
8d3b660ce0 Merge pull request #8212 from netbox-community/develop
Release v3.1.4
2022-01-03 11:16:27 -05:00
jeremystretch
9de53fe070 Release v3.1.4 2022-01-03 11:00:23 -05:00
jeremystretch
ecb9fc65b7 Closes #8197: Allow filtering sites by group when connecting a cable 2022-01-03 10:41:43 -05:00
Jeremy Stretch
7b25d0379f Merge pull request #8202 from netbja/patch-1
Small syntax error
2022-01-03 10:39:56 -05:00
jeremystretch
05d4176d34 Fixes #8201: Custom integer fields should allow negative integers as minimum/maximum values 2022-01-03 10:07:19 -05:00
jeremystretch
7b0dff88ae Closes #8210: Establish netbox/local/ as a path for local resources 2022-01-03 09:45:30 -05:00
jeremystretch
1c7604e0fe Fixes #8200: Correct typo in navigation menu 2022-01-03 09:20:26 -05:00
jeremystretch
e18dc43aae Fixes #8196: Fix IndexError exception when viewing large IPv6 prefixes in UI 2022-01-03 09:17:15 -05:00
netbja
caaad684a4 Small syntax error
No double quotes after password.
2021-12-31 11:25:12 +01:00
jeremystretch
cdd51aee75 Closes #8194: Enable bulk user assignment to groups under admin UI 2021-12-30 13:19:18 -05:00
jeremystretch
51851f6c99 Refactor users.admin 2021-12-30 13:08:09 -05:00
jeremystretch
ab98aa489c Related objects should be prefetched for Prefix/IPRange child object views 2021-12-30 12:43:37 -05:00
jeremystretch
5829985ca8 Remove power utilization as default column from racks table 2021-12-30 12:02:20 -05:00
jeremystretch
2fa8e27f05 Fixes #8192: Add "add prefix" button to aggregate child prefixes view 2021-12-30 12:00:37 -05:00
jeremystretch
68f92dfd5d Fix redirection URL for prefix IP ranges view 2021-12-30 11:47:21 -05:00
jeremystretch
67aeb380e7 Fix DNS name label in IP address bulk edit form 2021-12-30 11:46:09 -05:00
jeremystretch
f7d91b7139 Extend "Adding models" documentation 2021-12-30 10:12:28 -05:00
jeremystretch
b6e157f393 Add features summary to README 2021-12-30 10:08:31 -05:00
jeremystretch
2319fce092 Add tab to cable connect view 2021-12-30 09:51:30 -05:00
jeremystretch
a5f1707662 Fixes #8191: Fix return URL when adding IP addresses to VM interfaces 2021-12-30 09:46:02 -05:00
jeremystretch
6cda55da06 Fixes #8187: Fix rendering of tags column in object tables 2021-12-30 09:41:35 -05:00
jeremystretch
c3f2fee633 PRVB 2021-12-29 12:40:04 -05:00
Jeremy Stretch
1f575a2a47 Merge pull request #8185 from netbox-community/develop
Release v3.1.3
2021-12-29 12:31:07 -05:00
jeremystretch
13c4d13157 Release NetBox v3.1.3 2021-12-29 12:10:46 -05:00
jeremystretch
43fadab3bb Closes #8034: Enable specifying custom field validators during CSV import 2021-12-29 11:57:27 -05:00
jeremystretch
82a0240d2e Closes #8182: Introduce checkmark template tag 2021-12-29 10:26:42 -05:00
jeremystretch
f2aa35d3d2 Closes #7600: Include count of available IPs on prefix view 2021-12-29 09:59:25 -05:00
jeremystretch
9c9fcaf42f Fixes #7290: Defer loading API-backed form fields 2021-12-29 09:30:43 -05:00
jeremystretch
146a51ceba Clean up API tokens view 2021-12-29 09:10:56 -05:00
jeremystretch
b0350e9e96 Remove navbar background color 2021-12-29 08:56:59 -05:00
jeremystretch
35e346c4b9 Fix circuit termination button style 2021-12-28 16:13:58 -05:00
jeremystretch
1987647cc3 Closes #8175: Display parent object when attaching an image 2021-12-28 13:06:27 -05:00
jeremystretch
542534aeba Add direct link to preferences in user menu 2021-12-23 14:41:39 -05:00
jeremystretch
908a2824ba Reduce saturation of 'info' theme color 2021-12-23 14:34:09 -05:00
Jeremy Stretch
cab9733b60 Merge pull request #8159 from netbox-community/6782-custom-link-columns
Closes #6782: Custom link columns
2021-12-22 21:13:13 -05:00
jeremystretch
99e0dcec76 Changelog & docs for #6782 2021-12-22 20:57:59 -05:00
jeremystretch
9dafb36c88 Introduce CustomLinkColumn 2021-12-22 20:56:11 -05:00
jeremystretch
3d7d19b608 Move rendering logic under CustomLink class 2021-12-22 20:25:57 -05:00
jeremystretch
d650d10cb2 #7449: Apply distinctive styling to top navbar 2021-12-22 15:32:35 -05:00
jeremystretch
7fe45018e9 #7449: Remove red color from logout link 2021-12-22 15:22:06 -05:00
jeremystretch
4c4cab87fb #7449: Don't color valid form fields 2021-12-22 15:18:24 -05:00
jeremystretch
94c7f64baf Relocate confirmation_form.html 2021-12-22 15:08:04 -05:00
jeremystretch
f369b5f588 Reorganize & clean up templatetag templates 2021-12-22 15:05:24 -05:00
jeremystretch
37065b7c50 Remove obsolete template 2021-12-22 14:47:42 -05:00
jeremystretch
0a7372460f Changelog for #7887 2021-12-22 12:48:24 -05:00
Jeremy Stretch
063abc8ef7 Merge pull request #8153 from davama/develop
Add missing HTTP_X_FORWARDED_FOR
2021-12-22 12:46:22 -05:00
jeremystretch
fb4511d099 Fixes #8140: Restore missing fields on wireless LAN & link REST API serializers 2021-12-22 10:55:06 -05:00
jeremystretch
275560698f Fixes #8139: Fix rendering of table configuration form under VM interfaces view 2021-12-21 14:10:12 -05:00
jeremystretch
d4b6fe14c3 Fixes #8138: Fix alignment of tags panel within IP address view 2021-12-21 14:04:15 -05:00
jeremystretch
f1350a1022 FIxes #7972: Standardize name of RemoteUserBackend logger 2021-12-21 13:57:12 -05:00
jeremystretch
344fb638fd Fixes #8127: Fix disassociation of interface under IP address edit view 2021-12-21 13:17:54 -05:00
thatmattlove
373cc74a33 Fixes #8134: reinitialize event listeners when HTMX swaps elements 2021-12-21 11:11:33 -07:00
jeremystretch
8e95ac42c2 Closes #8100: Add "other" choice for FHRP group protocol 2021-12-21 13:05:38 -05:00
jeremystretch
ceb941df81 Closes #8135: Append version when fetching static assets 2021-12-21 13:00:52 -05:00
jeremystretch
d275538116 Changelog & cleanup for #7246, #8097 2021-12-21 11:53:31 -05:00
Jeremy Stretch
fa38cdbc0d Merge pull request #8121 from kkthxbye-code/fix-8097
Fix #8097: Re-fix markdown table rendering
2021-12-21 11:50:24 -05:00
Jeremy Stretch
7569544b7b Merge pull request #8063 from rizlas/develop
Get_Environment from napalm should not need any decoding
2021-12-21 11:43:23 -05:00
Jeremy Stretch
853a52f3ca Merge branch 'develop' into fix-8097 2021-12-21 11:37:58 -05:00
rizlas
39a0b15df4 Update netbox/dcim/api/views.py
Test without decode_dict function

Co-authored-by: Jeremy Stretch <jstretch@ns1.com>
2021-12-21 17:15:54 +01:00
jeremystretch
a0db10838b Fixes #8131: Restore annotation of available IPs under prefix IPs view 2021-12-21 11:09:30 -05:00
jeremystretch
f2f10dff92 Fix RearPortTemplateTable buttons 2021-12-21 10:57:46 -05:00
jeremystretch
7ba45b2887 Clean up imports 2021-12-21 10:48:10 -05:00
jeremystretch
c91eb8f406 Remove extraneous output from service edit template 2021-12-21 10:30:30 -05:00
jeremystretch
57a78b3cad Clean up device/devicetype tab views 2021-12-21 10:28:28 -05:00
jeremystretch
b755c7dab3 Add changelog for #7962 (via #8114) 2021-12-21 09:03:36 -05:00
Jeremy Stretch
9ffd791ae4 Merge pull request #8130 from netbox-community/8114-htmx-jobs
Closes #8114: Use HTMX to update report/script results
2021-12-21 09:01:15 -05:00
jeremystretch
8af12b22bb Clean up report & script templates 2021-12-21 08:43:01 -05:00
jeremystretch
17ba0a97d5 Remove jobs Javascript 2021-12-20 20:59:14 -05:00
jeremystretch
4ae2b4e0b9 Convert reports to use HTMX 2021-12-20 20:52:29 -05:00
jeremystretch
872691a138 Convert scripts to use HTMX 2021-12-20 20:45:32 -05:00
kkthxbye-code
3a54ecb522 Fix #8097: Re-fix markdown table rendering 2021-12-20 23:31:24 +01:00
jeremystretch
42b590af77 PRVB 2021-12-20 16:06:42 -05:00
rizlas
2ec64a2ea2 Get_Environment from napalm should not need any decoding 2021-12-14 10:17:00 +01:00
Dave
038d7e0fa6 Add missing HTTP_X_FORWARDED_FOR
See discussion [here](https://github.com/netbox-community/netbox/discussions/7876) for background.

From the [doc](https://netbox.readthedocs.io/en/stable/customization/custom-scripts/) i should be able to access `META.HTTP_X_FORWARDED_FOR` but i was not able to since they were not being sent downstream
2021-11-19 15:20:00 -05:00
207 changed files with 2564 additions and 2286 deletions

View File

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

View File

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

1
.gitignore vendored
View File

@@ -8,6 +8,7 @@ yarn-error.log*
!/netbox/project-static/docs/.info
/netbox/netbox/configuration.py
/netbox/netbox/ldap_config.py
/netbox/local/*
/netbox/reports/*
!/netbox/reports/__init__.py
/netbox/scripts/*

View File

@@ -5,11 +5,46 @@
![Master branch build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master)
NetBox is an infrastructure resource modeling (IRM) tool designed to empower
network automation. Initially conceived by the network engineering team at
network automation, used by thousands of organizations around the world.
Initially conceived by the network engineering team at
[DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically
to address the needs of network and infrastructure engineers. It is intended to
function as a domain-specific source of truth for network operations.
Myriad infrastructure components can be modeled in NetBox, including:
* Hierarchical regions, site groups, sites, and locations
* Racks, devices, and device components
* Cables and wireless connections
* Power distribution
* Data circuits and providers
* Virtual machines and clusters
* IP prefixes, ranges, and addresses
* VRFs and route targets
* FHRP groups (VRRP, HSRP, etc.)
* AS numbers
* VLANs and scoped VLAN groups
* Organizational tenants and contacts
In addition to its extensive built-in models and functionality, NetBox can be
customized and extended through the use of:
* Custom fields
* Custom links
* Configuration contexts
* Custom model validation rules
* Reports
* Custom scripts
* Export templates
* Conditional webhooks
* Plugins
* Single sign-on (SSO) authentication
* NAPALM integration
* Detailed change logging
NetBox also features a complete REST API as well as a GraphQL API for easily
integrating with other tools and systems.
NetBox runs as a web application atop the [Django](https://www.djangoproject.com/)
Python framework with a [PostgreSQL](https://www.postgresql.org/) database. For a
complete list of requirements, see `requirements.txt`. The code is available [on GitHub](https://github.com/netbox-community/netbox).

View File

@@ -98,10 +98,6 @@ psycopg2-binary
# https://github.com/yaml/pyyaml
PyYAML
# In-memory key/value store used for caching and queuing
# https://github.com/andymccurdy/redis-py
redis
# Social authentication framework
# https://github.com/python-social-auth/social-core
social-auth-core[all]

View File

@@ -37,23 +37,32 @@ Most models will need view classes created in `views.py` to serve the following
Add the relevant URL path for each view created in the previous step to `urls.py`.
## 6. Create the FilterSet
## 6. Add relevant forms
Depending on the type of model being added, you may need to define several types of form classes. These include:
* A base model form (for creating/editing individual objects)
* A bulk edit form
* A bulk import form (for CSV-based import)
* A filterset form (for filtering the object list view)
## 7. Create the FilterSet
Each model should have a corresponding FilterSet class defined. This is used to filter UI and API queries. Subclass the appropriate class from `netbox.filtersets` that matches the model's parent class.
## 7. Create the table class
## 8. Create the table class
Create a table class for the model in `tables.py` by subclassing `utilities.tables.BaseTable`. Under the table's `Meta` class, be sure to list both the fields and default columns.
## 8. Create the object template
## 9. Create the object template
Create the HTML template for the object view. (The other views each typically employ a generic template.) This template should extend `generic/object.html`.
## 9. Add the model to the navigation menu
## 10. Add the model to the navigation menu
Add the relevant navigation menu items in `netbox/netbox/navigation_menu.py`.
## 10. REST API components
## 11. REST API components
Create the following for each model:
@@ -62,13 +71,13 @@ Create the following for each model:
* API view in `api/views.py`
* Endpoint route in `api/urls.py`
## 11. GraphQL API components
## 12. GraphQL API components
Create a Graphene object type for the model in `graphql/types.py` by subclassing the appropriate class from `netbox.graphql.types`.
Also extend the schema class defined in `graphql/schema.py` with the individual object and object list fields per the established convention.
## 12. Add tests
## 13. Add tests
Add tests for the following:
@@ -76,7 +85,7 @@ Add tests for the following:
* API views
* Filter sets
## 13. Documentation
## 14. Documentation
Create a new documentation page for the model in `docs/models/<app_label>/<model_name>.md`. Include this file under the "features" documentation where appropriate.

View File

@@ -114,6 +114,12 @@ This ensures that your development environment is now complete and operational.
!!! info "IDE Integration"
Some IDEs, such as PyCharm, will integrate with Django's development server and allow you to run it directly within the IDE. This is strongly encouraged as it makes for a much more convenient development environment.
## Populating Demo Data
Once you have your development environment up and running, it might be helpful to populate some "dummy" data to make interacting with the UI and APIs more convenient. Check out the [netbox-demo-data](https://github.com/netbox-community/netbox-demo-data) repo on GitHub, which houses a collection of sample data that can be easily imported to any new NetBox deployment. (This sample data is used to populate the public demo instance at <https://demo.netbox.dev>.)
The demo data is provided in JSON format and loaded into an empty database using Django's `loaddata` management command. Consult the demo data repo's `README` file for complete instructions on populating the data.
## Running Tests
Prior to committing any substantial changes to the code base, be sure to run NetBox's test suite to catch any potential errors. Tests are run using the `test` management command. Remember to ensure the Python virtual environment is active before running this command.

View File

@@ -50,7 +50,7 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and
| Application | Django/Python |
| Database | PostgreSQL 10+ |
| Task queuing | Redis/django-rq |
| Live device access | NAPALM |
| Live device access | NAPALM (optional) |
## Supported Python Versions
@@ -58,4 +58,6 @@ NetBox supports Python 3.7, 3.8, and 3.9 environments currently. (Support for Py
## Getting Started
See the [installation guide](installation/index.md) for help getting NetBox up and running quickly.
Minor NetBox releases (e.g. v3.1) are published three times a year; in April, August, and December. These typically introduce major new features and may contain breaking API changes. Patch releases are published roughly every one to two weeks to resolve bugs and fulfill minor feature requests. These are backward-compatible with previous releases unless otherwise noted. The NetBox maintainers strongly recommend running the latest stable release whenever possible.
Please see the [official installation guide](installation/index.md) for detailed instructions on obtaining and installing NetBox.

View File

@@ -152,7 +152,7 @@ LOGGING = {
'netbox_auth_log': {
'level': 'DEBUG',
'class': 'logging.handlers.RotatingFileHandler',
'filename': '/opt/netbox/logs/django-ldap-debug.log',
'filename': '/opt/netbox/local/logs/django-ldap-debug.log',
'maxBytes': 1024 * 500,
'backupCount': 5,
},

View File

@@ -55,3 +55,7 @@ The link will only appear when viewing a device with a manufacturer name of "Cis
## Link Groups
Group names can be specified to organize links into groups. Links with the same group name will render as a dropdown menu beneath a single button bearing the name of the group.
## Table Columns
Custom links can also be included in object tables by selecting the desired links from the table configuration form. When displayed, each link will render as a hyperlink for its corresponding object. When exported (e.g. as CSV data), each link render only its URL.

View File

@@ -1,5 +1,8 @@
# Plugin Development
!!! info "Help Improve the NetBox Plugins Framework!"
We're looking for volunteers to help improve NetBox's plugins framework. If you have experience developing plugins, we'd love to hear from you! You can find more information about this initiative [here](https://github.com/netbox-community/netbox/discussions/8338).
This documentation covers the development of custom plugins for NetBox. Plugins are essentially self-contained [Django apps](https://docs.djangoproject.com/en/stable/) which integrate with NetBox to provide custom functionality. Since the development of Django apps is already very well-documented, we'll only be covering the aspects that are specific to NetBox.
Plugins can do a lot, including:

View File

@@ -1,6 +1,14 @@
# Release Notes
Listed below are the major features introduced in each NetBox release. For more detail on a specific release train, see its individual release notes page.
NetBox releases are numbered as major, minor, and patch releases. For example, version 3.1.0 is a minor release, and v3.1.5 is a patch release. Briefly, these can be described as follows:
* **Major** - Introduces or removes an entire API or other core functionality
* **Minor** - Implements major new features but may include breaking changes for API consumers or other integrations
* **Patch** - A maintenance release which fixes bugs and may introduce backward-compatible enhancements
Minor releases are published in April, August, and December of each calendar year. Patch releases are published as needed to address bugs and fulfill minor feature requests, typically around every one to two weeks.
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.1](./version-3.1.md) (December 2021)

View File

@@ -1,5 +1,95 @@
# NetBox v3.1
## v3.1.6 (2022-01-17)
### Enhancements
* [#8246](https://github.com/netbox-community/netbox/issues/8246) - Show human-friendly values for commit rates in circuits table
* [#8262](https://github.com/netbox-community/netbox/issues/8262) - Add cable count to tenant stats
* [#8265](https://github.com/netbox-community/netbox/issues/8265) - Add Stackwise-n interface types
* [#8293](https://github.com/netbox-community/netbox/issues/8293) - Show 4-byte ASNs in ASDOT notation
* [#8302](https://github.com/netbox-community/netbox/issues/8302) - Linkify role column in device & VM tables
* [#8337](https://github.com/netbox-community/netbox/issues/8337) - Enable sorting object tables by created & updated times
### Bug Fixes
* [#8279](https://github.com/netbox-community/netbox/issues/8279) - Fix display of virtual chassis members in rack elevations
* [#8285](https://github.com/netbox-community/netbox/issues/8285) - Fix `cluster_count` under tenant REST API serializer
* [#8287](https://github.com/netbox-community/netbox/issues/8287) - Correct label in export template form
* [#8301](https://github.com/netbox-community/netbox/issues/8301) - Fix delete button for various object children views
* [#8305](https://github.com/netbox-community/netbox/issues/8305) - Fix assignment of custom field data to FHRP groups via UI
* [#8306](https://github.com/netbox-community/netbox/issues/8306) - Redirect user to previous page after login
* [#8314](https://github.com/netbox-community/netbox/issues/8314) - Prevent custom fields with default values from appearing as applied filters erroneously
* [#8317](https://github.com/netbox-community/netbox/issues/8317) - Fix CSV import of multi-select custom field values
* [#8319](https://github.com/netbox-community/netbox/issues/8319) - Custom URL fields should honor `ALLOWED_URL_SCHEMES` config parameter
* [#8342](https://github.com/netbox-community/netbox/issues/8342) - Restore `created` & `last_updated` fields missing from several REST API serializers
* [#8357](https://github.com/netbox-community/netbox/issues/8357) - Add missing tags field to location filter form
* [#8358](https://github.com/netbox-community/netbox/issues/8358) - Fix inconsistent styling of custom fields on filter & bulk edit forms
---
## v3.1.5 (2022-01-06)
### Enhancements
* [#8231](https://github.com/netbox-community/netbox/issues/8231) - Use in-page dialogs for confirming object deletion
* [#8244](https://github.com/netbox-community/netbox/issues/8244) - Add length & length unit fields to cable filter form
* [#8252](https://github.com/netbox-community/netbox/issues/8252) - Linkify type and group columns in clusters table
### Bug Fixes
* [#8213](https://github.com/netbox-community/netbox/issues/8213) - Fix ValueError exception under prefix IP addresses view
* [#8224](https://github.com/netbox-community/netbox/issues/8224) - Fix KeyError exception when creating FHRP group with IP address and protocol "other"
* [#8226](https://github.com/netbox-community/netbox/issues/8226) - Honor return URL after populating a device bay
* [#8228](https://github.com/netbox-community/netbox/issues/8228) - Optional ChoiceVar fields should not force a selection
* [#8255](https://github.com/netbox-community/netbox/issues/8255) - Fix bulk editing of authentication parameters for wireless LANs and links
---
## v3.1.4 (2022-01-03)
### Enhancements
* [#8192](https://github.com/netbox-community/netbox/issues/8192) - Add "add prefix" button to aggregate child prefixes view
* [#8194](https://github.com/netbox-community/netbox/issues/8194) - Enable bulk user assignment to groups under admin UI
* [#8197](https://github.com/netbox-community/netbox/issues/8197) - Allow filtering sites by group when connecting a cable
* [#8210](https://github.com/netbox-community/netbox/issues/8210) - Establish `netbox/local/` as a path for local resources
### Bug Fixes
* [#8187](https://github.com/netbox-community/netbox/issues/8187) - Fix rendering of tags column in object tables
* [#8191](https://github.com/netbox-community/netbox/issues/8191) - Fix return URL when adding IP addresses to VM interfaces
* [#8196](https://github.com/netbox-community/netbox/issues/8196) - Fix IndexError exception when viewing large IPv6 prefixes in UI
* [#8201](https://github.com/netbox-community/netbox/issues/8201) - Custom integer fields should allow negative integers as minimum/maximum values
---
## v3.1.3 (2021-12-29)
### Enhancements
* [#6782](https://github.com/netbox-community/netbox/issues/6782) - Enable the inclusion of custom links in tables
* [#7600](https://github.com/netbox-community/netbox/issues/7600) - Include count of available IPs on prefix view
* [#8034](https://github.com/netbox-community/netbox/issues/8034) - Enable specifying custom field validators during CSV import
* [#8100](https://github.com/netbox-community/netbox/issues/8100) - Add "other" choice for FHRP group protocol
* [#8175](https://github.com/netbox-community/netbox/issues/8175) - Display parent object when attaching an image
### Bug Fixes
* [#7246](https://github.com/netbox-community/netbox/issues/7246) - Don't attempt to URL-decode NAPALM response payloads
* [#7290](https://github.com/netbox-community/netbox/issues/7290) - Defer loading API-backed form fields
* [#7887](https://github.com/netbox-community/netbox/issues/7887) - Forward `HTTP_X_FORWARDED_FOR` to custom scripts
* [#7962](https://github.com/netbox-community/netbox/issues/7962) - Fix user menu under report/script result view
* [#7972](https://github.com/netbox-community/netbox/issues/7972) - Standardize name of `RemoteUserBackend` logger
* [#8097](https://github.com/netbox-community/netbox/issues/8097) - Fix styling of Markdown tables
* [#8127](https://github.com/netbox-community/netbox/issues/8127) - Fix disassociation of interface under IP address edit view
* [#8131](https://github.com/netbox-community/netbox/issues/8131) - Restore annotation of available IPs under prefix IPs view
* [#8134](https://github.com/netbox-community/netbox/issues/8134) - Fix bulk editing of objects within dynamic tables
* [#8139](https://github.com/netbox-community/netbox/issues/8139) - Fix rendering of table configuration form under VM interfaces view
* [#8140](https://github.com/netbox-community/netbox/issues/8140) - Restore missing fields on wireless LAN & link REST API serializers
---
## v3.1.2 (2021-12-20)
### Enhancements

View File

@@ -42,7 +42,7 @@ $ curl -X POST \
https://netbox/api/users/tokens/provision/ \
--data '{
"username": "hankhill",
"password: "I<3C3H8",
"password": "I<3C3H8",
}'
```

View File

@@ -100,5 +100,5 @@ class CircuitTerminationSerializer(ValidatedModelSerializer, LinkTerminationSeri
fields = [
'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed',
'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type',
'_occupied',
'_occupied', 'created', 'last_updated',
]

View File

@@ -26,14 +26,12 @@ class ProviderFilterForm(CustomFieldModelFilterForm):
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
label=_('Region'),
fetch_trigger='open'
label=_('Region')
)
site_group_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label=_('Site group'),
fetch_trigger='open'
label=_('Site group')
)
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
@@ -42,8 +40,7 @@ class ProviderFilterForm(CustomFieldModelFilterForm):
'region_id': '$region_id',
'site_group_id': '$site_group_id',
},
label=_('Site'),
fetch_trigger='open'
label=_('Site')
)
asn = forms.IntegerField(
required=False,
@@ -61,8 +58,7 @@ class ProviderNetworkFilterForm(CustomFieldModelFilterForm):
provider_id = DynamicModelMultipleChoiceField(
queryset=Provider.objects.all(),
required=False,
label=_('Provider'),
fetch_trigger='open'
label=_('Provider')
)
tag = TagFilterField(model)
@@ -84,14 +80,12 @@ class CircuitFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
type_id = DynamicModelMultipleChoiceField(
queryset=CircuitType.objects.all(),
required=False,
label=_('Type'),
fetch_trigger='open'
label=_('Type')
)
provider_id = DynamicModelMultipleChoiceField(
queryset=Provider.objects.all(),
required=False,
label=_('Provider'),
fetch_trigger='open'
label=_('Provider')
)
provider_network_id = DynamicModelMultipleChoiceField(
queryset=ProviderNetwork.objects.all(),
@@ -99,8 +93,7 @@ class CircuitFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
query_params={
'provider_id': '$provider_id'
},
label=_('Provider network'),
fetch_trigger='open'
label=_('Provider network')
)
status = forms.MultipleChoiceField(
choices=CircuitStatusChoices,
@@ -110,14 +103,12 @@ class CircuitFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
label=_('Region'),
fetch_trigger='open'
label=_('Region')
)
site_group_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label=_('Site group'),
fetch_trigger='open'
label=_('Site group')
)
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
@@ -126,8 +117,7 @@ class CircuitFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
'region_id': '$region_id',
'site_group_id': '$site_group_id',
},
label=_('Site'),
fetch_trigger='open'
label=_('Site')
)
commit_rate = forms.IntegerField(
required=False,

View File

@@ -22,11 +22,32 @@ CIRCUITTERMINATION_LINK = """
{% endif %}
"""
#
# Table columns
#
class CommitRateColumn(tables.TemplateColumn):
"""
Humanize the commit rate in the column view
"""
template_code = """
{% load helpers %}
{{ record.commit_rate|humanize_speed }}
"""
def __init__(self, *args, **kwargs):
super().__init__(template_code=self.template_code, *args, **kwargs)
def value(self, value):
return str(value) if value else None
#
# Providers
#
class ProviderTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(
@@ -45,7 +66,7 @@ class ProviderTable(BaseTable):
model = Provider
fields = (
'pk', 'id', 'name', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'circuit_count',
'comments', 'tags',
'comments', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count')
@@ -69,7 +90,7 @@ class ProviderNetworkTable(BaseTable):
class Meta(BaseTable.Meta):
model = ProviderNetwork
fields = ('pk', 'id', 'name', 'provider', 'description', 'comments', 'tags')
fields = ('pk', 'id', 'name', 'provider', 'description', 'comments', 'tags', 'created', 'last_updated',)
default_columns = ('pk', 'name', 'provider', 'description')
@@ -92,7 +113,7 @@ class CircuitTypeTable(BaseTable):
class Meta(BaseTable.Meta):
model = CircuitType
fields = ('pk', 'id', 'name', 'circuit_count', 'description', 'slug', 'tags', 'actions')
fields = ('pk', 'id', 'name', 'circuit_count', 'description', 'slug', 'tags', 'actions', 'created', 'last_updated',)
default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug', 'actions')
@@ -119,6 +140,7 @@ class CircuitTable(BaseTable):
template_code=CIRCUITTERMINATION_LINK,
verbose_name='Side Z'
)
commit_rate = CommitRateColumn()
comments = MarkdownColumn()
tags = TagColumn(
url_name='circuits:circuit_list'
@@ -128,7 +150,7 @@ class CircuitTable(BaseTable):
model = Circuit
fields = (
'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date',
'commit_rate', 'description', 'comments', 'tags',
'commit_rate', 'description', 'comments', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description',

View File

@@ -219,7 +219,7 @@ class RackReservationSerializer(PrimaryModelSerializer):
class Meta:
model = RackReservation
fields = [
'id', 'url', 'display', 'rack', 'units', 'created', 'user', 'tenant', 'description', 'tags',
'id', 'url', 'display', 'rack', 'units', 'created', 'last_updated', 'user', 'tenant', 'description', 'tags',
'custom_fields',
]
@@ -762,7 +762,7 @@ class CableSerializer(PrimaryModelSerializer):
fields = [
'id', 'url', 'display', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type',
'termination_b_id', 'termination_b', 'type', 'status', 'tenant', 'label', 'color', 'length', 'length_unit',
'tags', 'custom_fields',
'tags', 'custom_fields', 'created', 'last_updated',
]
def _get_termination(self, obj, side):
@@ -856,7 +856,10 @@ class VirtualChassisSerializer(PrimaryModelSerializer):
class Meta:
model = VirtualChassis
fields = ['id', 'url', 'display', 'name', 'domain', 'master', 'tags', 'custom_fields', 'member_count']
fields = [
'id', 'url', 'display', 'name', 'domain', 'master', 'tags', 'custom_fields', 'member_count',
'created', 'last_updated',
]
#
@@ -875,7 +878,10 @@ class PowerPanelSerializer(PrimaryModelSerializer):
class Meta:
model = PowerPanel
fields = ['id', 'url', 'display', 'site', 'location', 'name', 'tags', 'custom_fields', 'powerfeed_count']
fields = [
'id', 'url', 'display', 'site', 'location', 'name', 'tags', 'custom_fields', 'powerfeed_count',
'created', 'last_updated',
]
class PowerFeedSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):

View File

@@ -15,14 +15,14 @@ from circuits.models import Circuit
from dcim import filtersets
from dcim.models import *
from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet
from ipam.models import Prefix, VLAN, ASN
from ipam.models import Prefix, VLAN
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.exceptions import ServiceUnavailable
from netbox.api.metadata import ContentTypeMetadata
from netbox.api.views import ModelViewSet
from netbox.config import get_config
from utilities.api import get_serializer_for_model
from utilities.utils import count_related, decode_dict
from utilities.utils import count_related
from virtualization.models import VirtualMachine
from . import serializers
from .exceptions import MissingFilterException
@@ -501,7 +501,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
response[method] = {'error': 'Only get_* NAPALM methods are supported'}
continue
try:
response[method] = decode_dict(getattr(d, method)())
response[method] = getattr(d, method)()
except NotImplementedError:
response[method] = {'error': 'Method {} not implemented for NAPALM driver {}'.format(method, driver)}
except Exception as e:

View File

@@ -816,6 +816,10 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_STACKWISE_PLUS = 'cisco-stackwise-plus'
TYPE_FLEXSTACK = 'cisco-flexstack'
TYPE_FLEXSTACK_PLUS = 'cisco-flexstack-plus'
TYPE_STACKWISE80 = 'cisco-stackwise-80'
TYPE_STACKWISE160 = 'cisco-stackwise-160'
TYPE_STACKWISE320 = 'cisco-stackwise-320'
TYPE_STACKWISE480 = 'cisco-stackwise-480'
TYPE_JUNIPER_VCP = 'juniper-vcp'
TYPE_SUMMITSTACK = 'extreme-summitstack'
TYPE_SUMMITSTACK128 = 'extreme-summitstack-128'
@@ -950,6 +954,10 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_STACKWISE_PLUS, 'Cisco StackWise Plus'),
(TYPE_FLEXSTACK, 'Cisco FlexStack'),
(TYPE_FLEXSTACK_PLUS, 'Cisco FlexStack Plus'),
(TYPE_STACKWISE80, 'Cisco StackWise-80'),
(TYPE_STACKWISE160, 'Cisco StackWise-160'),
(TYPE_STACKWISE320, 'Cisco StackWise-320'),
(TYPE_STACKWISE480, 'Cisco StackWise-480'),
(TYPE_JUNIPER_VCP, 'Juniper VCP'),
(TYPE_SUMMITSTACK, 'Extreme SummitStack'),
(TYPE_SUMMITSTACK128, 'Extreme SummitStack-128'),

View File

@@ -27,7 +27,7 @@ class ConnectCableToDeviceForm(TenancyForm, CustomFieldModelForm):
label='Region',
required=False
)
termination_b_site_group = DynamicModelChoiceField(
termination_b_sitegroup = DynamicModelChoiceField(
queryset=SiteGroup.objects.all(),
label='Site group',
required=False
@@ -38,7 +38,7 @@ class ConnectCableToDeviceForm(TenancyForm, CustomFieldModelForm):
required=False,
query_params={
'region_id': '$termination_b_region',
'group_id': '$termination_b_site_group',
'group_id': '$termination_b_sitegroup',
}
)
termination_b_location = DynamicModelChoiceField(
@@ -78,9 +78,9 @@ class ConnectCableToDeviceForm(TenancyForm, CustomFieldModelForm):
class Meta:
model = Cable
fields = [
'termination_b_region', 'termination_b_site', 'termination_b_rack', 'termination_b_device',
'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit',
'tags',
'termination_b_region', 'termination_b_sitegroup', 'termination_b_site', 'termination_b_rack',
'termination_b_device', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color',
'length', 'length_unit', 'tags',
]
widgets = {
'status': StaticSelect,
@@ -182,7 +182,7 @@ class ConnectCableToCircuitTerminationForm(TenancyForm, CustomFieldModelForm):
label='Region',
required=False
)
termination_b_site_group = DynamicModelChoiceField(
termination_b_sitegroup = DynamicModelChoiceField(
queryset=SiteGroup.objects.all(),
label='Site group',
required=False
@@ -193,7 +193,7 @@ class ConnectCableToCircuitTerminationForm(TenancyForm, CustomFieldModelForm):
required=False,
query_params={
'region_id': '$termination_b_region',
'group_id': '$termination_b_site_group',
'group_id': '$termination_b_sitegroup',
}
)
termination_b_circuit = DynamicModelChoiceField(
@@ -219,9 +219,9 @@ class ConnectCableToCircuitTerminationForm(TenancyForm, CustomFieldModelForm):
class Meta(ConnectCableToDeviceForm.Meta):
fields = [
'termination_b_provider', 'termination_b_region', 'termination_b_site', 'termination_b_circuit',
'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit',
'tags',
'termination_b_provider', 'termination_b_region', 'termination_b_sitegroup', 'termination_b_site',
'termination_b_circuit', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color',
'length', 'length_unit', 'tags',
]
def clean_termination_b_id(self):
@@ -235,7 +235,7 @@ class ConnectCableToPowerFeedForm(TenancyForm, CustomFieldModelForm):
label='Region',
required=False
)
termination_b_site_group = DynamicModelChoiceField(
termination_b_sitegroup = DynamicModelChoiceField(
queryset=SiteGroup.objects.all(),
label='Site group',
required=False
@@ -246,7 +246,7 @@ class ConnectCableToPowerFeedForm(TenancyForm, CustomFieldModelForm):
required=False,
query_params={
'region_id': '$termination_b_region',
'group_id': '$termination_b_site_group',
'group_id': '$termination_b_sitegroup',
}
)
termination_b_location = DynamicModelChoiceField(
@@ -281,8 +281,9 @@ class ConnectCableToPowerFeedForm(TenancyForm, CustomFieldModelForm):
class Meta(ConnectCableToDeviceForm.Meta):
fields = [
'termination_b_location', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'tenant_group',
'tenant', 'label', 'color', 'length', 'length_unit', 'tags',
'termination_b_region', 'termination_b_sitegroup', 'termination_b_site', 'termination_b_location',
'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label',
'color', 'length', 'length_unit', 'tags',
]
def clean_termination_b_id(self):

View File

@@ -57,14 +57,12 @@ class DeviceComponentFilterForm(CustomFieldModelFilterForm):
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
label=_('Region'),
fetch_trigger='open'
label=_('Region')
)
site_group_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label=_('Site group'),
fetch_trigger='open'
label=_('Site group')
)
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
@@ -73,8 +71,7 @@ class DeviceComponentFilterForm(CustomFieldModelFilterForm):
'region_id': '$region_id',
'group_id': '$site_group_id',
},
label=_('Site'),
fetch_trigger='open'
label=_('Site')
)
location_id = DynamicModelMultipleChoiceField(
queryset=Location.objects.all(),
@@ -82,14 +79,12 @@ class DeviceComponentFilterForm(CustomFieldModelFilterForm):
query_params={
'site_id': '$site_id',
},
label=_('Location'),
fetch_trigger='open'
label=_('Location')
)
virtual_chassis_id = DynamicModelMultipleChoiceField(
queryset=VirtualChassis.objects.all(),
required=False,
label=_('Virtual Chassis'),
fetch_trigger='open'
label=_('Virtual Chassis')
)
device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
@@ -99,8 +94,7 @@ class DeviceComponentFilterForm(CustomFieldModelFilterForm):
'location_id': '$location_id',
'virtual_chassis_id': '$virtual_chassis_id'
},
label=_('Device'),
fetch_trigger='open'
label=_('Device')
)
@@ -109,8 +103,7 @@ class RegionFilterForm(CustomFieldModelFilterForm):
parent_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
label=_('Parent region'),
fetch_trigger='open'
label=_('Parent region')
)
tag = TagFilterField(model)
@@ -120,8 +113,7 @@ class SiteGroupFilterForm(CustomFieldModelFilterForm):
parent_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label=_('Parent group'),
fetch_trigger='open'
label=_('Parent group')
)
tag = TagFilterField(model)
@@ -142,20 +134,17 @@ class SiteFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
label=_('Region'),
fetch_trigger='open'
label=_('Region')
)
group_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label=_('Site group'),
fetch_trigger='open'
label=_('Site group')
)
asn_id = DynamicModelMultipleChoiceField(
queryset=ASN.objects.all(),
required=False,
label=_('ASNs'),
fetch_trigger='open'
label=_('ASNs')
)
tag = TagFilterField(model)
@@ -163,21 +152,19 @@ class SiteFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
class LocationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
model = Location
field_groups = [
['q'],
['q', 'tag'],
['region_id', 'site_group_id', 'site_id', 'parent_id'],
['tenant_group_id', 'tenant_id'],
]
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
label=_('Region'),
fetch_trigger='open'
label=_('Region')
)
site_group_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label=_('Site group'),
fetch_trigger='open'
label=_('Site group')
)
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
@@ -186,8 +173,7 @@ class LocationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
'region_id': '$region_id',
'group_id': '$site_group_id',
},
label=_('Site'),
fetch_trigger='open'
label=_('Site')
)
parent_id = DynamicModelMultipleChoiceField(
queryset=Location.objects.all(),
@@ -196,8 +182,7 @@ class LocationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
'region_id': '$region_id',
'site_id': '$site_id',
},
label=_('Parent'),
fetch_trigger='open'
label=_('Parent')
)
tag = TagFilterField(model)
@@ -219,8 +204,7 @@ class RackFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
label=_('Region'),
fetch_trigger='open'
label=_('Region')
)
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
@@ -228,8 +212,7 @@ class RackFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
query_params={
'region_id': '$region_id'
},
label=_('Site'),
fetch_trigger='open'
label=_('Site')
)
location_id = DynamicModelMultipleChoiceField(
queryset=Location.objects.all(),
@@ -238,8 +221,7 @@ class RackFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
query_params={
'site_id': '$site_id'
},
label=_('Location'),
fetch_trigger='open'
label=_('Location')
)
status = forms.MultipleChoiceField(
choices=RackStatusChoices,
@@ -260,8 +242,7 @@ class RackFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
queryset=RackRole.objects.all(),
required=False,
null_option='None',
label=_('Role'),
fetch_trigger='open'
label=_('Role')
)
serial = forms.CharField(
required=False
@@ -280,8 +261,7 @@ class RackElevationFilterForm(RackFilterForm):
query_params={
'site_id': '$site_id',
'location_id': '$location_id',
},
fetch_trigger='open'
}
)
@@ -296,8 +276,7 @@ class RackReservationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
label=_('Region'),
fetch_trigger='open'
label=_('Region')
)
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
@@ -305,15 +284,13 @@ class RackReservationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
query_params={
'region_id': '$region_id'
},
label=_('Site'),
fetch_trigger='open'
label=_('Site')
)
location_id = DynamicModelMultipleChoiceField(
queryset=Location.objects.prefetch_related('site'),
required=False,
label=_('Location'),
null_option='None',
fetch_trigger='open'
null_option='None'
)
user_id = DynamicModelMultipleChoiceField(
queryset=User.objects.all(),
@@ -321,8 +298,7 @@ class RackReservationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
label=_('User'),
widget=APISelectMultiple(
api_url='/api/users/users/',
),
fetch_trigger='open'
)
)
tag = TagFilterField(model)
@@ -342,8 +318,7 @@ class DeviceTypeFilterForm(CustomFieldModelFilterForm):
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
required=False,
label=_('Manufacturer'),
fetch_trigger='open'
label=_('Manufacturer')
)
subdevice_role = forms.MultipleChoiceField(
choices=add_blank_choice(SubdeviceRoleChoices),
@@ -410,8 +385,7 @@ class PlatformFilterForm(CustomFieldModelFilterForm):
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
required=False,
label=_('Manufacturer'),
fetch_trigger='open'
label=_('Manufacturer')
)
tag = TagFilterField(model)
@@ -432,14 +406,12 @@ class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFi
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
label=_('Region'),
fetch_trigger='open'
label=_('Region')
)
site_group_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label=_('Site group'),
fetch_trigger='open'
label=_('Site group')
)
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
@@ -448,8 +420,7 @@ class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFi
'region_id': '$region_id',
'group_id': '$site_group_id',
},
label=_('Site'),
fetch_trigger='open'
label=_('Site')
)
location_id = DynamicModelMultipleChoiceField(
queryset=Location.objects.all(),
@@ -458,8 +429,7 @@ class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFi
query_params={
'site_id': '$site_id'
},
label=_('Location'),
fetch_trigger='open'
label=_('Location')
)
rack_id = DynamicModelMultipleChoiceField(
queryset=Rack.objects.all(),
@@ -469,20 +439,17 @@ class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFi
'site_id': '$site_id',
'location_id': '$location_id',
},
label=_('Rack'),
fetch_trigger='open'
label=_('Rack')
)
role_id = DynamicModelMultipleChoiceField(
queryset=DeviceRole.objects.all(),
required=False,
label=_('Role'),
fetch_trigger='open'
label=_('Role')
)
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
required=False,
label=_('Manufacturer'),
fetch_trigger='open'
label=_('Manufacturer')
)
device_type_id = DynamicModelMultipleChoiceField(
queryset=DeviceType.objects.all(),
@@ -490,15 +457,13 @@ class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFi
query_params={
'manufacturer_id': '$manufacturer_id'
},
label=_('Model'),
fetch_trigger='open'
label=_('Model')
)
platform_id = DynamicModelMultipleChoiceField(
queryset=Platform.objects.all(),
required=False,
null_option='None',
label=_('Platform'),
fetch_trigger='open'
label=_('Platform')
)
status = forms.MultipleChoiceField(
choices=DeviceStatusChoices,
@@ -589,14 +554,12 @@ class VirtualChassisFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
label=_('Region'),
fetch_trigger='open'
label=_('Region')
)
site_group_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label=_('Site group'),
fetch_trigger='open'
label=_('Site group')
)
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
@@ -605,8 +568,7 @@ class VirtualChassisFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
'region_id': '$region_id',
'group_id': '$site_group_id',
},
label=_('Site'),
fetch_trigger='open'
label=_('Site')
)
tag = TagFilterField(model)
@@ -616,14 +578,13 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
field_groups = [
['q', 'tag'],
['site_id', 'rack_id', 'device_id'],
['type', 'status', 'color'],
['type', 'status', 'color', 'length', 'length_unit'],
['tenant_group_id', 'tenant_id'],
]
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
label=_('Region'),
fetch_trigger='open'
label=_('Region')
)
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
@@ -631,8 +592,7 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
query_params={
'region_id': '$region_id'
},
label=_('Site'),
fetch_trigger='open'
label=_('Site')
)
rack_id = DynamicModelMultipleChoiceField(
queryset=Rack.objects.all(),
@@ -641,8 +601,17 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
null_option='None',
query_params={
'site_id': '$site_id'
}
)
device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
required=False,
query_params={
'site_id': '$site_id',
'tenant_id': '$tenant_id',
'rack_id': '$rack_id',
},
fetch_trigger='open'
label=_('Device')
)
type = forms.MultipleChoiceField(
choices=add_blank_choice(CableTypeChoices),
@@ -657,16 +626,12 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
color = ColorField(
required=False
)
device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
required=False,
query_params={
'site_id': '$site_id',
'tenant_id': '$tenant_id',
'rack_id': '$rack_id',
},
label=_('Device'),
fetch_trigger='open'
length = forms.IntegerField(
required=False
)
length_unit = forms.ChoiceField(
choices=add_blank_choice(CableLengthUnitChoices),
required=False
)
tag = TagFilterField(model)
@@ -680,14 +645,12 @@ class PowerPanelFilterForm(CustomFieldModelFilterForm):
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
label=_('Region'),
fetch_trigger='open'
label=_('Region')
)
site_group_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label=_('Site group'),
fetch_trigger='open'
label=_('Site group')
)
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
@@ -696,8 +659,7 @@ class PowerPanelFilterForm(CustomFieldModelFilterForm):
'region_id': '$region_id',
'group_id': '$site_group_id',
},
label=_('Site'),
fetch_trigger='open'
label=_('Site')
)
location_id = DynamicModelMultipleChoiceField(
queryset=Location.objects.all(),
@@ -706,8 +668,7 @@ class PowerPanelFilterForm(CustomFieldModelFilterForm):
query_params={
'site_id': '$site_id'
},
label=_('Location'),
fetch_trigger='open'
label=_('Location')
)
tag = TagFilterField(model)
@@ -723,14 +684,12 @@ class PowerFeedFilterForm(CustomFieldModelFilterForm):
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
label=_('Region'),
fetch_trigger='open'
label=_('Region')
)
site_group_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label=_('Site group'),
fetch_trigger='open'
label=_('Site group')
)
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
@@ -738,8 +697,7 @@ class PowerFeedFilterForm(CustomFieldModelFilterForm):
query_params={
'region_id': '$region_id'
},
label=_('Site'),
fetch_trigger='open'
label=_('Site')
)
power_panel_id = DynamicModelMultipleChoiceField(
queryset=PowerPanel.objects.all(),
@@ -748,8 +706,7 @@ class PowerFeedFilterForm(CustomFieldModelFilterForm):
query_params={
'site_id': '$site_id'
},
label=_('Power panel'),
fetch_trigger='open'
label=_('Power panel')
)
rack_id = DynamicModelMultipleChoiceField(
queryset=Rack.objects.all(),
@@ -758,8 +715,7 @@ class PowerFeedFilterForm(CustomFieldModelFilterForm):
query_params={
'site_id': '$site_id'
},
label=_('Rack'),
fetch_trigger='open'
label=_('Rack')
)
status = forms.MultipleChoiceField(
choices=PowerFeedStatusChoices,
@@ -990,8 +946,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
required=False,
label=_('Manufacturer'),
fetch_trigger='open'
label=_('Manufacturer')
)
serial = forms.CharField(
required=False
@@ -1016,8 +971,7 @@ class ConsoleConnectionFilterForm(FilterForm):
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
label=_('Region'),
fetch_trigger='open'
label=_('Region')
)
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
@@ -1025,8 +979,7 @@ class ConsoleConnectionFilterForm(FilterForm):
query_params={
'region_id': '$region_id'
},
label=_('Site'),
fetch_trigger='open'
label=_('Site')
)
device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
@@ -1034,8 +987,7 @@ class ConsoleConnectionFilterForm(FilterForm):
query_params={
'site_id': '$site_id'
},
label=_('Device'),
fetch_trigger='open'
label=_('Device')
)
@@ -1043,8 +995,7 @@ class PowerConnectionFilterForm(FilterForm):
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
label=_('Region'),
fetch_trigger='open'
label=_('Region')
)
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
@@ -1052,8 +1003,7 @@ class PowerConnectionFilterForm(FilterForm):
query_params={
'region_id': '$region_id'
},
label=_('Site'),
fetch_trigger='open'
label=_('Site')
)
device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
@@ -1061,8 +1011,7 @@ class PowerConnectionFilterForm(FilterForm):
query_params={
'site_id': '$site_id'
},
label=_('Device'),
fetch_trigger='open'
label=_('Device')
)
@@ -1070,8 +1019,7 @@ class InterfaceConnectionFilterForm(FilterForm):
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
label=_('Region'),
fetch_trigger='open'
label=_('Region')
)
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
@@ -1079,8 +1027,7 @@ class InterfaceConnectionFilterForm(FilterForm):
query_params={
'region_id': '$region_id'
},
label=_('Site'),
fetch_trigger='open'
label=_('Site')
)
device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
@@ -1088,6 +1035,5 @@ class InterfaceConnectionFilterForm(FilterForm):
query_params={
'site_id': '$site_id'
},
label=_('Device'),
fetch_trigger='open'
label=_('Device')
)

View File

@@ -301,16 +301,14 @@ class RackReservationForm(TenancyForm, CustomFieldModelForm):
required=False,
initial_params={
'sites': '$site'
},
fetch_trigger='open'
}
)
site_group = DynamicModelChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
initial_params={
'sites': '$site'
},
fetch_trigger='open'
}
)
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
@@ -318,24 +316,21 @@ class RackReservationForm(TenancyForm, CustomFieldModelForm):
query_params={
'region_id': '$region',
'group_id': '$site_group',
},
fetch_trigger='open'
}
)
location = DynamicModelChoiceField(
queryset=Location.objects.all(),
required=False,
query_params={
'site_id': '$site'
},
fetch_trigger='open'
}
)
rack = DynamicModelChoiceField(
queryset=Rack.objects.all(),
query_params={
'site_id': '$site',
'location_id': '$location',
},
fetch_trigger='open'
}
)
units = NumericArrayField(
base_field=forms.IntegerField(),
@@ -349,8 +344,7 @@ class RackReservationForm(TenancyForm, CustomFieldModelForm):
)
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False,
fetch_trigger='open'
required=False
)
class Meta:

View File

@@ -5,42 +5,3 @@ from .devices import *
from .power import *
from .racks import *
from .sites import *
__all__ = (
'BaseInterface',
'Cable',
'CablePath',
'LinkTermination',
'ConsolePort',
'ConsolePortTemplate',
'ConsoleServerPort',
'ConsoleServerPortTemplate',
'Device',
'DeviceBay',
'DeviceBayTemplate',
'DeviceRole',
'DeviceType',
'FrontPort',
'FrontPortTemplate',
'Interface',
'InterfaceTemplate',
'InventoryItem',
'Location',
'Manufacturer',
'Platform',
'PowerFeed',
'PowerOutlet',
'PowerOutletTemplate',
'PowerPanel',
'PowerPort',
'PowerPortTemplate',
'Rack',
'RackReservation',
'RackRole',
'RearPort',
'RearPortTemplate',
'Region',
'Site',
'SiteGroup',
'VirtualChassis',
)

View File

@@ -19,7 +19,12 @@ __all__ = (
def get_device_name(device):
return device.name or str(device.device_type)
if device.virtual_chassis:
return f'{device.virtual_chassis.name}:{device.vc_position}'
elif device.name:
return device.name
else:
return str(device.device_type)
class RackElevationSVG:

View File

@@ -56,7 +56,7 @@ class CableTable(BaseTable):
model = Cable
fields = (
'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b',
'status', 'type', 'tenant', 'color', 'length', 'tags',
'status', 'type', 'tenant', 'color', 'length', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b',

View File

@@ -97,7 +97,7 @@ class DeviceRoleTable(BaseTable):
model = DeviceRole
fields = (
'pk', 'id', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'tags',
'actions',
'actions', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'actions')
@@ -130,7 +130,7 @@ class PlatformTable(BaseTable):
model = Platform
fields = (
'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args',
'description', 'tags', 'actions',
'description', 'tags', 'actions', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description', 'actions',
@@ -204,7 +204,8 @@ class DeviceTable(BaseTable):
fields = (
'pk', 'id', 'name', 'status', 'tenant', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial',
'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'airflow', 'primary_ip4',
'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags',
'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags', 'created',
'last_updated',
)
default_columns = (
'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type',
@@ -297,7 +298,7 @@ class ConsolePortTable(DeviceComponentTable, PathEndpointTable):
model = ConsolePort
fields = (
'pk', 'id', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
'link_peer', 'connection', 'tags',
'link_peer', 'connection', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description')
@@ -341,7 +342,7 @@ class ConsoleServerPortTable(DeviceComponentTable, PathEndpointTable):
model = ConsoleServerPort
fields = (
'pk', 'id', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable',
'cable_color', 'link_peer', 'connection', 'tags',
'cable_color', 'link_peer', 'connection', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description')
@@ -386,7 +387,7 @@ class PowerPortTable(DeviceComponentTable, PathEndpointTable):
model = PowerPort
fields = (
'pk', 'id', 'name', 'device', 'label', 'type', 'description', 'mark_connected', 'maximum_draw',
'allocated_draw', 'cable', 'cable_color', 'link_peer', 'connection', 'tags',
'allocated_draw', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description')
@@ -437,7 +438,7 @@ class PowerOutletTable(DeviceComponentTable, PathEndpointTable):
model = PowerOutlet
fields = (
'pk', 'id', 'name', 'device', 'label', 'type', 'description', 'power_port', 'feed_leg', 'mark_connected',
'cable', 'cable_color', 'link_peer', 'connection', 'tags',
'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'type', 'power_port', 'feed_leg', 'description')
@@ -515,7 +516,7 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable
'pk', 'id', 'name', 'device', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn',
'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description',
'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection',
'tags', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans',
'tags', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
@@ -586,7 +587,7 @@ class FrontPortTable(DeviceComponentTable, CableTerminationTable):
model = FrontPort
fields = (
'pk', 'id', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags',
'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
@@ -637,7 +638,7 @@ class RearPortTable(DeviceComponentTable, CableTerminationTable):
model = RearPort
fields = (
'pk', 'id', 'name', 'device', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable',
'cable_color', 'link_peer', 'tags',
'cable_color', 'link_peer', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'type', 'color', 'description')
@@ -689,7 +690,11 @@ class DeviceBayTable(DeviceComponentTable):
class Meta(DeviceComponentTable.Meta):
model = DeviceBay
fields = ('pk', 'id', 'name', 'device', 'label', 'status', 'installed_device', 'description', 'tags')
fields = (
'pk', 'id', 'name', 'device', 'label', 'status', 'installed_device', 'description', 'tags',
'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'status', 'installed_device', 'description')
@@ -736,7 +741,7 @@ class InventoryItemTable(DeviceComponentTable):
model = InventoryItem
fields = (
'pk', 'id', 'name', 'device', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description',
'discovered', 'tags',
'discovered', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag')
@@ -788,5 +793,5 @@ class VirtualChassisTable(BaseTable):
class Meta(BaseTable.Meta):
model = VirtualChassis
fields = ('pk', 'id', 'name', 'domain', 'master', 'member_count', 'tags')
fields = ('pk', 'id', 'name', 'domain', 'master', 'member_count', 'tags', 'created', 'last_updated',)
default_columns = ('pk', 'name', 'domain', 'master', 'member_count')

View File

@@ -50,7 +50,7 @@ class ManufacturerTable(BaseTable):
model = Manufacturer
fields = (
'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug',
'actions',
'actions', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'actions',
@@ -84,7 +84,7 @@ class DeviceTypeTable(BaseTable):
model = DeviceType
fields = (
'pk', 'id', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
'airflow', 'comments', 'instance_count', 'tags',
'airflow', 'comments', 'instance_count', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count',
@@ -111,8 +111,7 @@ class ComponentTemplateTable(BaseTable):
class ConsolePortTemplateTable(ComponentTemplateTable):
actions = ButtonsColumn(
model=ConsolePortTemplate,
buttons=('edit', 'delete'),
return_url_extra='%23tab_consoleports'
buttons=('edit', 'delete')
)
class Meta(ComponentTemplateTable.Meta):
@@ -124,8 +123,7 @@ class ConsolePortTemplateTable(ComponentTemplateTable):
class ConsoleServerPortTemplateTable(ComponentTemplateTable):
actions = ButtonsColumn(
model=ConsoleServerPortTemplate,
buttons=('edit', 'delete'),
return_url_extra='%23tab_consoleserverports'
buttons=('edit', 'delete')
)
class Meta(ComponentTemplateTable.Meta):
@@ -137,8 +135,7 @@ class ConsoleServerPortTemplateTable(ComponentTemplateTable):
class PowerPortTemplateTable(ComponentTemplateTable):
actions = ButtonsColumn(
model=PowerPortTemplate,
buttons=('edit', 'delete'),
return_url_extra='%23tab_powerports'
buttons=('edit', 'delete')
)
class Meta(ComponentTemplateTable.Meta):
@@ -150,8 +147,7 @@ class PowerPortTemplateTable(ComponentTemplateTable):
class PowerOutletTemplateTable(ComponentTemplateTable):
actions = ButtonsColumn(
model=PowerOutletTemplate,
buttons=('edit', 'delete'),
return_url_extra='%23tab_poweroutlets'
buttons=('edit', 'delete')
)
class Meta(ComponentTemplateTable.Meta):
@@ -166,8 +162,7 @@ class InterfaceTemplateTable(ComponentTemplateTable):
)
actions = ButtonsColumn(
model=InterfaceTemplate,
buttons=('edit', 'delete'),
return_url_extra='%23tab_interfaces'
buttons=('edit', 'delete')
)
class Meta(ComponentTemplateTable.Meta):
@@ -183,8 +178,7 @@ class FrontPortTemplateTable(ComponentTemplateTable):
color = ColorColumn()
actions = ButtonsColumn(
model=FrontPortTemplate,
buttons=('edit', 'delete'),
return_url_extra='%23tab_frontports'
buttons=('edit', 'delete')
)
class Meta(ComponentTemplateTable.Meta):
@@ -197,8 +191,7 @@ class RearPortTemplateTable(ComponentTemplateTable):
color = ColorColumn()
actions = ButtonsColumn(
model=RearPortTemplate,
buttons=('edit', 'delete'),
return_url_extra='%23tab_rearports'
buttons=('edit', 'delete')
)
class Meta(ComponentTemplateTable.Meta):
@@ -210,8 +203,7 @@ class RearPortTemplateTable(ComponentTemplateTable):
class DeviceBayTemplateTable(ComponentTemplateTable):
actions = ButtonsColumn(
model=DeviceBayTemplate,
buttons=('edit', 'delete'),
return_url_extra='%23tab_devicebays'
buttons=('edit', 'delete')
)
class Meta(ComponentTemplateTable.Meta):

View File

@@ -33,7 +33,7 @@ class PowerPanelTable(BaseTable):
class Meta(BaseTable.Meta):
model = PowerPanel
fields = ('pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'tags')
fields = ('pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'tags', 'created', 'last_updated',)
default_columns = ('pk', 'name', 'site', 'location', 'powerfeed_count')
@@ -72,7 +72,7 @@ class PowerFeedTable(CableTerminationTable):
fields = (
'pk', 'id', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase',
'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'available_power',
'comments', 'tags',
'comments', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable',

View File

@@ -31,7 +31,10 @@ class RackRoleTable(BaseTable):
class Meta(BaseTable.Meta):
model = RackRole
fields = ('pk', 'id', 'name', 'rack_count', 'color', 'description', 'slug', 'tags', 'actions')
fields = (
'pk', 'id', 'name', 'rack_count', 'color', 'description', 'slug', 'tags', 'actions',
'created', 'last_updated',
)
default_columns = ('pk', 'name', 'rack_count', 'color', 'description', 'actions')
@@ -87,12 +90,13 @@ class RackTable(BaseTable):
class Meta(BaseTable.Meta):
model = Rack
fields = (
'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type',
'width', 'outer_width', 'outer_depth', 'u_height', 'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'tags',
'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag',
'type', 'width', 'outer_width', 'outer_depth', 'u_height', 'comments', 'device_count', 'get_utilization',
'get_power_utilization', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',
'get_utilization', 'get_power_utilization',
'get_utilization',
)
@@ -127,7 +131,7 @@ class RackReservationTable(BaseTable):
model = RackReservation
fields = (
'pk', 'id', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'tags',
'actions',
'actions', 'created', 'last_updated',
)
default_columns = (
'pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description', 'actions',

View File

@@ -36,7 +36,7 @@ class RegionTable(BaseTable):
class Meta(BaseTable.Meta):
model = Region
fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'actions')
fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'actions', 'created', 'last_updated')
default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
@@ -61,7 +61,7 @@ class SiteGroupTable(BaseTable):
class Meta(BaseTable.Meta):
model = SiteGroup
fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'actions')
fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'actions', 'created', 'last_updated')
default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
@@ -98,7 +98,7 @@ class SiteTable(BaseTable):
fields = (
'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn_count', 'time_zone',
'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name',
'contact_phone', 'contact_email', 'comments', 'tags',
'contact_phone', 'contact_email', 'comments', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'description')
@@ -138,6 +138,6 @@ class LocationTable(BaseTable):
model = Location
fields = (
'pk', 'id', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'tags',
'actions',
'actions', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'actions')

View File

@@ -27,13 +27,7 @@ from virtualization.models import VirtualMachine
from . import filtersets, forms, tables
from .choices import DeviceFaceChoices
from .constants import NONCONNECTABLE_IFACE_TYPES
from .models import (
Cable, CablePath, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
InventoryItem, Manufacturer, PathEndpoint, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel,
PowerPort, PowerPortTemplate, Rack, Location, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site,
SiteGroup, VirtualChassis,
)
from .models import *
class DeviceComponentsView(generic.ObjectChildrenView):
@@ -51,10 +45,21 @@ class DeviceComponentsView(generic.ObjectChildrenView):
class DeviceTypeComponentsView(DeviceComponentsView):
queryset = DeviceType.objects.all()
template_name = 'dcim/devicetype/component_templates.html'
viewname = None # Used for return_url resolution
def get_children(self, request, parent):
return self.child_model.objects.restrict(request.user, 'view').filter(device_type=parent)
def get_extra_context(self, request, instance):
if self.viewname:
return_url = reverse(self.viewname, kwargs={'pk': instance.pk})
else:
return_url = instance.get_absolute_url()
return {
'active_tab': f"{self.child_model._meta.verbose_name_plural.replace(' ', '-')}",
'return_url': return_url,
}
class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
"""
@@ -798,48 +803,56 @@ class DeviceTypeConsolePortsView(DeviceTypeComponentsView):
child_model = ConsolePortTemplate
table = tables.ConsolePortTemplateTable
filterset = filtersets.ConsolePortTemplateFilterSet
viewname = 'dcim:devicetype_consoleports'
class DeviceTypeConsoleServerPortsView(DeviceTypeComponentsView):
child_model = ConsoleServerPortTemplate
table = tables.ConsoleServerPortTemplateTable
filterset = filtersets.ConsoleServerPortTemplateFilterSet
viewname = 'dcim:devicetype_consoleserverports'
class DeviceTypePowerPortsView(DeviceTypeComponentsView):
child_model = PowerPortTemplate
table = tables.PowerPortTemplateTable
filterset = filtersets.PowerPortTemplateFilterSet
viewname = 'dcim:devicetype_powerports'
class DeviceTypePowerOutletsView(DeviceTypeComponentsView):
child_model = PowerOutletTemplate
table = tables.PowerOutletTemplateTable
filterset = filtersets.PowerOutletTemplateFilterSet
viewname = 'dcim:devicetype_poweroutlets'
class DeviceTypeInterfacesView(DeviceTypeComponentsView):
child_model = InterfaceTemplate
table = tables.InterfaceTemplateTable
filterset = filtersets.InterfaceTemplateFilterSet
viewname = 'dcim:devicetype_interfaces'
class DeviceTypeFrontPortsView(DeviceTypeComponentsView):
child_model = FrontPortTemplate
table = tables.FrontPortTemplateTable
filterset = filtersets.FrontPortTemplateFilterSet
viewname = 'dcim:devicetype_frontports'
class DeviceTypeRearPortsView(DeviceTypeComponentsView):
child_model = RearPortTemplate
table = tables.RearPortTemplateTable
filterset = filtersets.RearPortTemplateFilterSet
viewname = 'dcim:devicetype_rearports'
class DeviceTypeDeviceBaysView(DeviceTypeComponentsView):
child_model = DeviceBayTemplate
table = tables.DeviceBayTemplateTable
filterset = filtersets.DeviceBayTemplateFilterSet
viewname = 'dcim:devicetype_devicebays'
class DeviceTypeEditView(generic.ObjectEditView):
@@ -2022,8 +2035,9 @@ class DeviceBayPopulateView(generic.ObjectEditView):
device_bay.installed_device = form.cleaned_data['installed_device']
device_bay.save()
messages.success(request, "Added {} to {}.".format(device_bay.installed_device, device_bay))
return_url = self.get_return_url(request)
return redirect('dcim:device', pk=device_bay.device.pk)
return redirect(return_url)
return render(request, 'dcim/devicebay_populate.html', {
'device_bay': device_bay,

View File

@@ -5,10 +5,10 @@ from drf_yasg.utils import swagger_serializer_method
from rest_framework import serializers
from dcim.api.nested_serializers import (
NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedPlatformSerializer,
NestedRackSerializer, NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer,
NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedPlatformSerializer, NestedRegionSerializer,
NestedSiteSerializer, NestedSiteGroupSerializer,
)
from dcim.models import Device, DeviceRole, DeviceType, Platform, Rack, Region, Site, SiteGroup
from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup
from extras.choices import *
from extras.models import *
from extras.utils import FeatureQuery
@@ -61,7 +61,7 @@ class WebhookSerializer(ValidatedModelSerializer):
fields = [
'id', 'url', 'display', 'content_types', 'name', 'type_create', 'type_update', 'type_delete', 'payload_url',
'enabled', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
'conditions', 'ssl_verification', 'ca_file_path',
'conditions', 'ssl_verification', 'ca_file_path', 'created', 'last_updated',
]
@@ -82,7 +82,8 @@ class CustomFieldSerializer(ValidatedModelSerializer):
model = CustomField
fields = [
'id', 'url', 'display', 'content_types', 'type', 'name', 'label', 'description', 'required', 'filter_logic',
'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices',
'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', 'created',
'last_updated',
]
@@ -100,7 +101,7 @@ class CustomLinkSerializer(ValidatedModelSerializer):
model = CustomLink
fields = [
'id', 'url', 'display', 'content_type', 'name', 'link_text', 'link_url', 'weight', 'group_name',
'button_class', 'new_window',
'button_class', 'new_window', 'created', 'last_updated',
]
@@ -118,7 +119,7 @@ class ExportTemplateSerializer(ValidatedModelSerializer):
model = ExportTemplate
fields = [
'id', 'url', 'display', 'content_type', 'name', 'description', 'template_code', 'mime_type',
'file_extension', 'as_attachment',
'file_extension', 'as_attachment', 'created', 'last_updated',
]
@@ -132,7 +133,9 @@ class TagSerializer(ValidatedModelSerializer):
class Meta:
model = Tag
fields = ['id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tagged_items']
fields = [
'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tagged_items', 'created', 'last_updated',
]
#

View File

@@ -3,9 +3,10 @@ from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.forms import SimpleArrayField
from django.utils.safestring import mark_safe
from extras.choices import CustomFieldTypeChoices
from extras.models import *
from extras.utils import FeatureQuery
from utilities.forms import CSVContentTypeField, CSVModelForm, CSVMultipleContentTypeField, SlugField
from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelForm, CSVMultipleContentTypeField, SlugField
__all__ = (
'CustomFieldCSVForm',
@@ -22,6 +23,10 @@ class CustomFieldCSVForm(CSVModelForm):
limit_choices_to=FeatureQuery('custom_fields'),
help_text="One or more assigned object types"
)
type = CSVChoiceField(
choices=CustomFieldTypeChoices,
help_text='Field data type (e.g. text, integer, etc.)'
)
choices = SimpleArrayField(
base_field=forms.CharField(),
required=False,
@@ -32,7 +37,7 @@ class CustomFieldCSVForm(CSVModelForm):
model = CustomField
fields = (
'name', 'label', 'type', 'content_types', 'required', 'description', 'weight', 'filter_logic', 'default',
'choices', 'weight',
'choices', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex',
)

View File

@@ -4,7 +4,7 @@ from django.db.models import Q
from extras.choices import *
from extras.models import *
from utilities.forms import BootstrapMixin, BulkEditForm, CSVModelForm, FilterForm
from utilities.forms import BootstrapMixin, BulkEditBaseForm, CSVModelForm
__all__ = (
'CustomFieldModelCSVForm',
@@ -34,6 +34,9 @@ class CustomFieldsMixin:
raise NotImplementedError(f"{self.__class__.__name__} must specify a model class.")
return ContentType.objects.get_for_model(self.model)
def _get_custom_fields(self, content_type):
return CustomField.objects.filter(content_types=content_type)
def _get_form_field(self, customfield):
return customfield.to_form_field()
@@ -41,10 +44,7 @@ class CustomFieldsMixin:
"""
Append form fields for all CustomFields assigned to this object type.
"""
content_type = self._get_content_type()
# Append form fields; assign initial values if modifying and existing object
for customfield in CustomField.objects.filter(content_types=content_type):
for customfield in self._get_custom_fields(self._get_content_type()):
field_name = f'cf_{customfield.name}'
self.fields[field_name] = self._get_form_field(customfield)
@@ -86,40 +86,37 @@ class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm):
return customfield.to_form_field(for_csv_import=True)
class CustomFieldModelBulkEditForm(BulkEditForm):
class CustomFieldModelBulkEditForm(BootstrapMixin, CustomFieldsMixin, BulkEditBaseForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def _get_form_field(self, customfield):
return customfield.to_form_field(set_initial=False, enforce_required=False)
self.custom_fields = []
self.obj_type = ContentType.objects.get_for_model(self.model)
# Add all applicable CustomFields to the form
custom_fields = CustomField.objects.filter(content_types=self.obj_type)
for cf in custom_fields:
def _append_customfield_fields(self):
"""
Append form fields for all CustomFields assigned to this object type.
"""
for customfield in self._get_custom_fields(self._get_content_type()):
# Annotate non-required custom fields as nullable
if not cf.required:
self.nullable_fields.append(cf.name)
self.fields[cf.name] = cf.to_form_field(set_initial=False, enforce_required=False)
# Annotate this as a custom field
self.custom_fields.append(cf.name)
if not customfield.required:
self.nullable_fields.append(customfield.name)
self.fields[customfield.name] = self._get_form_field(customfield)
# Annotate the field in the list of CustomField form fields
self.custom_fields.append(customfield.name)
class CustomFieldModelFilterForm(FilterForm):
class CustomFieldModelFilterForm(BootstrapMixin, CustomFieldsMixin, forms.Form):
q = forms.CharField(
required=False,
label='Search'
)
def __init__(self, *args, **kwargs):
self.obj_type = ContentType.objects.get_for_model(self.model)
super().__init__(*args, **kwargs)
# Add all applicable CustomFields to the form
self.custom_field_filters = []
custom_fields = CustomField.objects.filter(content_types=self.obj_type).exclude(
def _get_custom_fields(self, content_type):
return CustomField.objects.filter(content_types=content_type).exclude(
Q(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) |
Q(type=CustomFieldTypeChoices.TYPE_JSON)
)
for cf in custom_fields:
field_name = f'cf_{cf.name}'
self.fields[field_name] = cf.to_form_field(set_initial=True, enforce_required=False)
self.custom_field_filters.append(field_name)
def _get_form_field(self, customfield):
return customfield.to_form_field(set_initial=False, enforce_required=False)

View File

@@ -164,69 +164,58 @@ class ConfigContextFilterForm(FilterForm):
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
label=_('Regions'),
fetch_trigger='open'
label=_('Regions')
)
site_group_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label=_('Site groups'),
fetch_trigger='open'
label=_('Site groups')
)
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
required=False,
label=_('Sites'),
fetch_trigger='open'
label=_('Sites')
)
device_type_id = DynamicModelMultipleChoiceField(
queryset=DeviceType.objects.all(),
required=False,
label=_('Device types'),
fetch_trigger='open'
label=_('Device types')
)
role_id = DynamicModelMultipleChoiceField(
queryset=DeviceRole.objects.all(),
required=False,
label=_('Roles'),
fetch_trigger='open'
label=_('Roles')
)
platform_id = DynamicModelMultipleChoiceField(
queryset=Platform.objects.all(),
required=False,
label=_('Platforms'),
fetch_trigger='open'
label=_('Platforms')
)
cluster_group_id = DynamicModelMultipleChoiceField(
queryset=ClusterGroup.objects.all(),
required=False,
label=_('Cluster groups'),
fetch_trigger='open'
label=_('Cluster groups')
)
cluster_id = DynamicModelMultipleChoiceField(
queryset=Cluster.objects.all(),
required=False,
label=_('Clusters'),
fetch_trigger='open'
label=_('Clusters')
)
tenant_group_id = DynamicModelMultipleChoiceField(
queryset=TenantGroup.objects.all(),
required=False,
label=_('Tenant groups'),
fetch_trigger='open'
label=_('Tenant groups')
)
tenant_id = DynamicModelMultipleChoiceField(
queryset=Tenant.objects.all(),
required=False,
label=_('Tenant'),
fetch_trigger='open'
label=_('Tenant')
)
tag = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
to_field_name='slug',
required=False,
label=_('Tags'),
fetch_trigger='open'
label=_('Tags')
)
@@ -263,8 +252,7 @@ class JournalEntryFilterForm(FilterForm):
label=_('User'),
widget=APISelectMultiple(
api_url='/api/users/users/',
),
fetch_trigger='open'
)
)
assigned_object_type_id = DynamicModelMultipleChoiceField(
queryset=ContentType.objects.all(),
@@ -272,8 +260,7 @@ class JournalEntryFilterForm(FilterForm):
label=_('Object Type'),
widget=APISelectMultiple(
api_url='/api/extras/content-types/',
),
fetch_trigger='open'
)
)
kind = forms.ChoiceField(
choices=add_blank_choice(JournalEntryKindChoices),
@@ -310,8 +297,7 @@ class ObjectChangeFilterForm(FilterForm):
label=_('User'),
widget=APISelectMultiple(
api_url='/api/users/users/',
),
fetch_trigger='open'
)
)
changed_object_type_id = DynamicModelMultipleChoiceField(
queryset=ContentType.objects.all(),
@@ -319,6 +305,5 @@ class ObjectChangeFilterForm(FilterForm):
label=_('Object Type'),
widget=APISelectMultiple(
api_url='/api/extras/content-types/',
),
fetch_trigger='open'
)
)

View File

@@ -7,8 +7,8 @@ from extras.models import *
from extras.utils import FeatureQuery
from tenancy.models import Tenant, TenantGroup
from utilities.forms import (
add_blank_choice, BootstrapMixin, CommentField, ContentTypeChoiceField,
ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect,
add_blank_choice, BootstrapMixin, CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField,
DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect,
)
from virtualization.models import Cluster, ClusterGroup
@@ -41,6 +41,10 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
('Values', ('default', 'choices')),
('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')),
)
widgets = {
'type': StaticSelect(),
'filter_logic': StaticSelect(),
}
class CustomLinkForm(BootstrapMixin, forms.ModelForm):
@@ -57,6 +61,7 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm):
('Templates', ('link_text', 'link_url')),
)
widgets = {
'button_class': StaticSelect(),
'link_text': forms.Textarea(attrs={'class': 'font-monospace'}),
'link_url': forms.Textarea(attrs={'class': 'font-monospace'}),
}
@@ -77,7 +82,7 @@ class ExportTemplateForm(BootstrapMixin, forms.ModelForm):
model = ExportTemplate
fields = '__all__'
fieldsets = (
('Custom Link', ('name', 'content_type', 'description')),
('Export Template', ('name', 'content_type', 'description')),
('Template', ('template_code',)),
('Rendering', ('mime_type', 'file_extension', 'as_attachment')),
)
@@ -96,8 +101,7 @@ class WebhookForm(BootstrapMixin, forms.ModelForm):
model = Webhook
fields = '__all__'
fieldsets = (
('Webhook', ('name', 'enabled')),
('Assigned Models', ('content_types',)),
('Webhook', ('name', 'content_types', 'enabled')),
('Events', ('type_create', 'type_update', 'type_delete')),
('HTTP Request', (
'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
@@ -105,7 +109,13 @@ class WebhookForm(BootstrapMixin, forms.ModelForm):
('Conditions', ('conditions',)),
('SSL', ('ssl_verification', 'ca_file_path')),
)
labels = {
'type_create': 'Creations',
'type_update': 'Updates',
'type_delete': 'Deletions',
}
widgets = {
'http_method': StaticSelect(),
'additional_headers': forms.Textarea(attrs={'class': 'font-monospace'}),
'body_template': forms.Textarea(attrs={'class': 'font-monospace'}),
}

View File

@@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0066_customfield_name_validation'),
]
operations = [
migrations.AlterField(
model_name='customfield',
name='validation_maximum',
field=models.IntegerField(blank=True, null=True),
),
migrations.AlterField(
model_name='customfield',
name='validation_minimum',
field=models.IntegerField(blank=True, null=True),
),
]

View File

@@ -16,7 +16,8 @@ from extras.utils import FeatureQuery, extras_features
from netbox.models import ChangeLoggedModel
from utilities import filters
from utilities.forms import (
CSVChoiceField, DatePicker, LaxURLField, StaticSelectMultiple, StaticSelect, add_blank_choice,
CSVChoiceField, CSVMultipleChoiceField, DatePicker, LaxURLField, StaticSelectMultiple, StaticSelect,
add_blank_choice,
)
from utilities.querysets import RestrictedQuerySet
from utilities.validators import validate_regex
@@ -96,13 +97,13 @@ class CustomField(ChangeLoggedModel):
default=100,
help_text='Fields with higher weights appear lower in a form.'
)
validation_minimum = models.PositiveIntegerField(
validation_minimum = models.IntegerField(
blank=True,
null=True,
verbose_name='Minimum value',
help_text='Minimum allowed value (for numeric fields)'
)
validation_maximum = models.PositiveIntegerField(
validation_maximum = models.IntegerField(
blank=True,
null=True,
verbose_name='Maximum value',
@@ -238,7 +239,7 @@ class CustomField(ChangeLoggedModel):
"""
Return a form field suitable for setting a CustomField's value for an object.
set_initial: Set initial date for the field. This should be False when generating a field for bulk editing.
set_initial: Set initial data for the field. This should be False when generating a field for bulk editing.
enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing.
for_csv_import: Return a form field suitable for bulk import of objects in CSV format.
"""
@@ -287,7 +288,7 @@ class CustomField(ChangeLoggedModel):
choices=choices, required=required, initial=initial, widget=StaticSelect()
)
else:
field_class = CSVChoiceField if for_csv_import else forms.MultipleChoiceField
field_class = CSVMultipleChoiceField if for_csv_import else forms.MultipleChoiceField
field = field_class(
choices=choices, required=required, initial=initial, widget=StaticSelectMultiple()
)

View File

@@ -229,6 +229,24 @@ class CustomLink(ChangeLoggedModel):
def get_absolute_url(self):
return reverse('extras:customlink', args=[self.pk])
def render(self, context):
"""
Render the CustomLink given the provided context, and return the text, link, and link_target.
:param context: The context passed to Jinja2
"""
text = render_jinja2(self.link_text, context)
if not text:
return {}
link = render_jinja2(self.link_url, context)
link_target = ' target="_blank"' if self.new_window else ''
return {
'text': text,
'link': link,
'link_target': link_target,
}
@extras_features('webhooks', 'export_templates')
class ExportTemplate(ChangeLoggedModel):

View File

@@ -21,7 +21,7 @@ from extras.models import JobResult
from ipam.formfields import IPAddressFormField, IPNetworkFormField
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
from utilities.exceptions import AbortTransaction
from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField
from utilities.forms import add_blank_choice, DynamicModelChoiceField, DynamicModelMultipleChoiceField
from .context_managers import change_logging
from .forms import ScriptForm
@@ -164,16 +164,22 @@ class ChoiceVar(ScriptVariable):
def __init__(self, choices, *args, **kwargs):
super().__init__(*args, **kwargs)
# Set field choices
self.field_attrs['choices'] = choices
# Set field choices, adding a blank choice to avoid forced selections
self.field_attrs['choices'] = add_blank_choice(choices)
class MultiChoiceVar(ChoiceVar):
class MultiChoiceVar(ScriptVariable):
"""
Like ChoiceVar, but allows for the selection of multiple choices.
"""
form_field = forms.MultipleChoiceField
def __init__(self, choices, *args, **kwargs):
super().__init__(*args, **kwargs)
# Set field choices
self.field_attrs['choices'] = choices
class ObjectVar(ScriptVariable):
"""

View File

@@ -58,7 +58,7 @@ class CustomFieldTable(BaseTable):
model = CustomField
fields = (
'pk', 'id', 'name', 'content_types', 'label', 'type', 'required', 'weight', 'default',
'description', 'filter_logic', 'choices',
'description', 'filter_logic', 'choices', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'content_types', 'label', 'type', 'required', 'description')
@@ -79,7 +79,7 @@ class CustomLinkTable(BaseTable):
model = CustomLink
fields = (
'pk', 'id', 'name', 'content_type', 'link_text', 'link_url', 'weight', 'group_name',
'button_class', 'new_window',
'button_class', 'new_window', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'content_type', 'group_name', 'button_class', 'new_window')
@@ -100,6 +100,7 @@ class ExportTemplateTable(BaseTable):
model = ExportTemplate
fields = (
'pk', 'id', 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment',
'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment',
@@ -134,7 +135,7 @@ class WebhookTable(BaseTable):
model = Webhook
fields = (
'pk', 'id', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete', 'http_method',
'payload_url', 'secret', 'ssl_validation', 'ca_file_path',
'payload_url', 'secret', 'ssl_validation', 'ca_file_path', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete', 'http_method',
@@ -156,7 +157,7 @@ class TagTable(BaseTable):
class Meta(BaseTable.Meta):
model = Tag
fields = ('pk', 'id', 'name', 'items', 'slug', 'color', 'description', 'actions')
fields = ('pk', 'id', 'name', 'items', 'slug', 'color', 'description', 'actions', 'created', 'last_updated',)
default_columns = ('pk', 'name', 'items', 'slug', 'color', 'description', 'actions')
@@ -193,7 +194,7 @@ class ConfigContextTable(BaseTable):
model = ConfigContext
fields = (
'pk', 'id', 'name', 'weight', 'is_active', 'description', 'regions', 'sites', 'roles',
'platforms', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants',
'platforms', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'weight', 'is_active', 'description')

View File

@@ -62,16 +62,14 @@ def custom_links(context, obj):
# Add non-grouped links
else:
try:
text_rendered = render_jinja2(cl.link_text, link_context)
if text_rendered:
link_rendered = render_jinja2(cl.link_url, link_context)
link_target = ' target="_blank"' if cl.new_window else ''
rendered = cl.render(link_context)
if rendered:
template_code += LINK_BUTTON.format(
link_rendered, link_target, cl.button_class, text_rendered
rendered['link'], rendered['link_target'], cl.button_class, rendered['text']
)
except Exception as e:
template_code += '<a class="btn btn-sm btn-outline-dark" disabled="disabled" title="{}">' \
'<i class="mdi mdi-alert"></i> {}</a>\n'.format(e, cl.name)
template_code += f'<a class="btn btn-sm btn-outline-dark" disabled="disabled" title="{e}">' \
f'<i class="mdi mdi-alert"></i> {cl.name}</a>\n'
# Add grouped links to template
for group, links in group_names.items():
@@ -80,17 +78,15 @@ def custom_links(context, obj):
for cl in links:
try:
text_rendered = render_jinja2(cl.link_text, link_context)
if text_rendered:
link_target = ' target="_blank"' if cl.new_window else ''
link_rendered = render_jinja2(cl.link_url, link_context)
rendered = cl.render(link_context)
if rendered:
links_rendered.append(
GROUP_LINK.format(link_rendered, link_target, text_rendered)
GROUP_LINK.format(rendered['link'], rendered['link_target'], rendered['text'])
)
except Exception as e:
links_rendered.append(
'<li><a class="dropdown-item" disabled="disabled" title="{}"><span class="text-muted">'
'<i class="mdi mdi-alert"></i> {}</span></a></li>'.format(e, cl.name)
f'<li><a class="dropdown-item" disabled="disabled" title="{e}"><span class="text-muted">'
f'<i class="mdi mdi-alert"></i> {cl.name}</span></a></li>'
)
if links_rendered:

View File

@@ -25,49 +25,68 @@ class CustomFieldTest(TestCase):
def test_simple_fields(self):
DATA = (
{
'field_type': CustomFieldTypeChoices.TYPE_TEXT,
'field_value': 'Foobar!',
'empty_value': '',
'field': {
'type': CustomFieldTypeChoices.TYPE_TEXT,
},
'value': 'Foobar!',
},
{
'field_type': CustomFieldTypeChoices.TYPE_LONGTEXT,
'field_value': 'Text with **Markdown**',
'empty_value': '',
'field': {
'type': CustomFieldTypeChoices.TYPE_LONGTEXT,
},
'value': 'Text with **Markdown**',
},
{
'field_type': CustomFieldTypeChoices.TYPE_INTEGER,
'field_value': 0,
'empty_value': None,
'field': {
'type': CustomFieldTypeChoices.TYPE_INTEGER,
},
'value': 0,
},
{
'field_type': CustomFieldTypeChoices.TYPE_INTEGER,
'field_value': 42,
'empty_value': None,
'field': {
'type': CustomFieldTypeChoices.TYPE_INTEGER,
'validation_minimum': 1,
'validation_maximum': 100,
},
'value': 42,
},
{
'field_type': CustomFieldTypeChoices.TYPE_BOOLEAN,
'field_value': True,
'empty_value': None,
'field': {
'type': CustomFieldTypeChoices.TYPE_INTEGER,
'validation_minimum': -100,
'validation_maximum': -1,
},
'value': -42,
},
{
'field_type': CustomFieldTypeChoices.TYPE_BOOLEAN,
'field_value': False,
'empty_value': None,
'field': {
'type': CustomFieldTypeChoices.TYPE_BOOLEAN,
},
'value': True,
},
{
'field_type': CustomFieldTypeChoices.TYPE_DATE,
'field_value': '2016-06-23',
'empty_value': None,
'field': {
'type': CustomFieldTypeChoices.TYPE_BOOLEAN,
},
'value': False,
},
{
'field_type': CustomFieldTypeChoices.TYPE_URL,
'field_value': 'http://example.com/',
'empty_value': '',
'field': {
'type': CustomFieldTypeChoices.TYPE_DATE,
},
'value': '2016-06-23',
},
{
'field_type': CustomFieldTypeChoices.TYPE_JSON,
'field_value': '{"foo": 1, "bar": 2}',
'empty_value': 'null',
'field': {
'type': CustomFieldTypeChoices.TYPE_URL,
},
'value': 'http://example.com/',
},
{
'field': {
'type': CustomFieldTypeChoices.TYPE_JSON,
},
'value': '{"foo": 1, "bar": 2}',
},
)
@@ -76,7 +95,7 @@ class CustomFieldTest(TestCase):
for data in DATA:
# Create a custom field
cf = CustomField(type=data['field_type'], name='my_field', required=False)
cf = CustomField(name='my_field', required=False, **data['field'])
cf.save()
cf.content_types.set([obj_type])
@@ -85,12 +104,12 @@ class CustomFieldTest(TestCase):
self.assertIsNone(site.custom_field_data[cf.name])
# Assign a value to the first Site
site.custom_field_data[cf.name] = data['field_value']
site.custom_field_data[cf.name] = data['value']
site.save()
# Retrieve the stored value
site.refresh_from_db()
self.assertEqual(site.custom_field_data[cf.name], data['field_value'])
self.assertEqual(site.custom_field_data[cf.name], data['value'])
# Delete the stored value
site.custom_field_data.pop(cf.name)
@@ -103,13 +122,14 @@ class CustomFieldTest(TestCase):
def test_select_field(self):
obj_type = ContentType.objects.get_for_model(Site)
choices = ['Option A', 'Option B', 'Option C']
# Create a custom field
cf = CustomField(
type=CustomFieldTypeChoices.TYPE_SELECT,
name='my_field',
required=False,
choices=['Option A', 'Option B', 'Option C']
choices=choices
)
cf.save()
cf.content_types.set([obj_type])
@@ -119,12 +139,47 @@ class CustomFieldTest(TestCase):
self.assertIsNone(site.custom_field_data[cf.name])
# Assign a value to the first Site
site.custom_field_data[cf.name] = 'Option A'
site.custom_field_data[cf.name] = choices[0]
site.save()
# Retrieve the stored value
site.refresh_from_db()
self.assertEqual(site.custom_field_data[cf.name], 'Option A')
self.assertEqual(site.custom_field_data[cf.name], choices[0])
# Delete the stored value
site.custom_field_data.pop(cf.name)
site.save()
site.refresh_from_db()
self.assertIsNone(site.custom_field_data.get(cf.name))
# Delete the custom field
cf.delete()
def test_multiselect_field(self):
obj_type = ContentType.objects.get_for_model(Site)
choices = ['Option A', 'Option B', 'Option C']
# Create a custom field
cf = CustomField(
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
name='my_field',
required=False,
choices=choices
)
cf.save()
cf.content_types.set([obj_type])
# Check that the field has a null initial value
site = Site.objects.first()
self.assertIsNone(site.custom_field_data[cf.name])
# Assign a value to the first Site
site.custom_field_data[cf.name] = [choices[0], choices[1]]
site.save()
# Retrieve the stored value
site.refresh_from_db()
self.assertEqual(site.custom_field_data[cf.name], [choices[0], choices[1]])
# Delete the stored value
site.custom_field_data.pop(cf.name)
@@ -578,6 +633,9 @@ class CustomFieldImportTest(TestCase):
CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=[
'Choice A', 'Choice B', 'Choice C',
]),
CustomField(name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choices=[
'Choice A', 'Choice B', 'Choice C',
]),
)
for cf in custom_fields:
cf.save()
@@ -588,19 +646,20 @@ class CustomFieldImportTest(TestCase):
Import a Site in CSV format, including a value for each CustomField.
"""
data = (
('name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_boolean', 'cf_date', 'cf_url', 'cf_json', 'cf_select'),
('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', 'True', '2020-01-01', 'http://example.com/1', '{"foo": 123}', 'Choice A'),
('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', 'False', '2020-01-02', 'http://example.com/2', '{"bar": 456}', 'Choice B'),
('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', ''),
('name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_boolean', 'cf_date', 'cf_url', 'cf_json', 'cf_select', 'cf_multiselect'),
('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', 'True', '2020-01-01', 'http://example.com/1', '{"foo": 123}', 'Choice A', '"Choice A,Choice B"'),
('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', 'False', '2020-01-02', 'http://example.com/2', '{"bar": 456}', 'Choice B', '"Choice B,Choice C"'),
('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', '', ''),
)
csv_data = '\n'.join(','.join(row) for row in data)
response = self.client.post(reverse('dcim:site_import'), {'csv': csv_data})
self.assertEqual(response.status_code, 200)
self.assertEqual(Site.objects.count(), 3)
# Validate data for site 1
site1 = Site.objects.get(name='Site 1')
self.assertEqual(len(site1.custom_field_data), 8)
self.assertEqual(len(site1.custom_field_data), 9)
self.assertEqual(site1.custom_field_data['text'], 'ABC')
self.assertEqual(site1.custom_field_data['longtext'], 'Foo')
self.assertEqual(site1.custom_field_data['integer'], 123)
@@ -609,10 +668,11 @@ class CustomFieldImportTest(TestCase):
self.assertEqual(site1.custom_field_data['url'], 'http://example.com/1')
self.assertEqual(site1.custom_field_data['json'], {"foo": 123})
self.assertEqual(site1.custom_field_data['select'], 'Choice A')
self.assertEqual(site1.custom_field_data['multiselect'], ['Choice A', 'Choice B'])
# Validate data for site 2
site2 = Site.objects.get(name='Site 2')
self.assertEqual(len(site2.custom_field_data), 8)
self.assertEqual(len(site2.custom_field_data), 9)
self.assertEqual(site2.custom_field_data['text'], 'DEF')
self.assertEqual(site2.custom_field_data['longtext'], 'Bar')
self.assertEqual(site2.custom_field_data['integer'], 456)
@@ -621,6 +681,7 @@ class CustomFieldImportTest(TestCase):
self.assertEqual(site2.custom_field_data['url'], 'http://example.com/2')
self.assertEqual(site2.custom_field_data['json'], {"bar": 456})
self.assertEqual(site2.custom_field_data['select'], 'Choice B')
self.assertEqual(site2.custom_field_data['multiselect'], ['Choice B', 'Choice C'])
# No custom field data should be set for site 3
site3 = Site.objects.get(name='Site 3')

View File

@@ -39,10 +39,10 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
cls.csv_data = (
'name,label,type,content_types,weight,filter_logic,choices',
'field4,Field 4,text,dcim.site,100,exact,',
'field5,Field 5,integer,dcim.site,100,exact,',
'field6,Field 6,select,dcim.site,100,exact,"A,B,C"',
'name,label,type,content_types,weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex',
'field4,Field 4,text,dcim.site,100,exact,,,,[a-z]{3}',
'field5,Field 5,integer,dcim.site,100,exact,,1,100,',
'field6,Field 6,select,dcim.site,100,exact,"A,B,C",,,',
)
cls.bulk_edit_data = {

View File

@@ -10,6 +10,7 @@ from rq import Worker
from netbox.views import generic
from utilities.forms import ConfirmationForm
from utilities.htmx import is_htmx
from utilities.tables import paginate_table
from utilities.utils import copy_safe_request, count_related, normalize_querydict, shallow_compare_dict
from utilities.views import ContentTypePermissionRequiredMixin
@@ -471,6 +472,7 @@ class ObjectChangeLogView(View):
class ImageAttachmentEditView(generic.ObjectEditView):
queryset = ImageAttachment.objects.all()
model_form = forms.ImageAttachmentForm
template_name = 'extras/imageattachment_edit.html'
def alter_obj(self, instance, request, args, kwargs):
if not instance.pk:
@@ -693,16 +695,26 @@ class ReportResultView(ContentTypePermissionRequiredMixin, View):
def get(self, request, job_result_pk):
report_content_type = ContentType.objects.get(app_label='extras', model='report')
jobresult = get_object_or_404(JobResult.objects.all(), pk=job_result_pk, obj_type=report_content_type)
result = get_object_or_404(JobResult.objects.all(), pk=job_result_pk, obj_type=report_content_type)
# Retrieve the Report and attach the JobResult to it
module, report_name = jobresult.name.split('.')
module, report_name = result.name.split('.')
report = get_report(module, report_name)
report.result = jobresult
report.result = result
# If this is an HTMX request, return only the result HTML
if is_htmx(request):
response = render(request, 'extras/htmx/report_result.html', {
'report': report,
'result': result,
})
if result.completed:
response.status_code = 286
return response
return render(request, 'extras/report_result.html', {
'report': report,
'result': jobresult,
'result': result,
})
@@ -820,6 +832,16 @@ class ScriptResultView(ContentTypePermissionRequiredMixin, GetScriptMixin, View)
script = self._get_script(result.name)
# If this is an HTMX request, return only the result HTML
if is_htmx(request):
response = render(request, 'extras/htmx/script_result.html', {
'script': script,
'result': result,
})
if result.completed:
response.status_code = 286
return response
return render(request, 'extras/script_result.html', {
'script': script,
'result': result,

View File

@@ -135,6 +135,7 @@ class FHRPGroupProtocolChoices(ChoiceSet):
PROTOCOL_HSRP = 'hsrp'
PROTOCOL_GLBP = 'glbp'
PROTOCOL_CARP = 'carp'
PROTOCOL_OTHER = 'other'
CHOICES = (
(PROTOCOL_VRRP2, 'VRRPv2'),
@@ -142,6 +143,7 @@ class FHRPGroupProtocolChoices(ChoiceSet):
(PROTOCOL_HSRP, 'HSRP'),
(PROTOCOL_GLBP, 'GLBP'),
(PROTOCOL_CARP, 'CARP'),
(PROTOCOL_OTHER, 'Other'),
)

View File

@@ -65,6 +65,7 @@ FHRP_PROTOCOL_ROLE_MAPPINGS = {
FHRPGroupProtocolChoices.PROTOCOL_HSRP: IPAddressRoleChoices.ROLE_HSRP,
FHRPGroupProtocolChoices.PROTOCOL_GLBP: IPAddressRoleChoices.ROLE_GLBP,
FHRPGroupProtocolChoices.PROTOCOL_CARP: IPAddressRoleChoices.ROLE_CARP,
FHRPGroupProtocolChoices.PROTOCOL_OTHER: IPAddressRoleChoices.ROLE_VIP,
}

View File

@@ -302,7 +302,8 @@ class IPAddressBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
)
dns_name = forms.CharField(
max_length=255,
required=False
required=False,
label='DNS name'
)
description = forms.CharField(
max_length=100,

View File

@@ -48,14 +48,12 @@ class VRFFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
import_target_id = DynamicModelMultipleChoiceField(
queryset=RouteTarget.objects.all(),
required=False,
label=_('Import targets'),
fetch_trigger='open'
label=_('Import targets')
)
export_target_id = DynamicModelMultipleChoiceField(
queryset=RouteTarget.objects.all(),
required=False,
label=_('Export targets'),
fetch_trigger='open'
label=_('Export targets')
)
tag = TagFilterField(model)
@@ -70,14 +68,12 @@ class RouteTargetFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
importing_vrf_id = DynamicModelMultipleChoiceField(
queryset=VRF.objects.all(),
required=False,
label=_('Imported by VRF'),
fetch_trigger='open'
label=_('Imported by VRF')
)
exporting_vrf_id = DynamicModelMultipleChoiceField(
queryset=VRF.objects.all(),
required=False,
label=_('Exported by VRF'),
fetch_trigger='open'
label=_('Exported by VRF')
)
tag = TagFilterField(model)
@@ -110,8 +106,7 @@ class AggregateFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
rir_id = DynamicModelMultipleChoiceField(
queryset=RIR.objects.all(),
required=False,
label=_('RIR'),
fetch_trigger='open'
label=_('RIR')
)
tag = TagFilterField(model)
@@ -127,14 +122,12 @@ class ASNFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
rir_id = DynamicModelMultipleChoiceField(
queryset=RIR.objects.all(),
required=False,
label=_('RIR'),
fetch_trigger='open'
label=_('RIR')
)
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
required=False,
label=_('Site'),
fetch_trigger='open'
label=_('Site')
)
@@ -180,14 +173,12 @@ class PrefixFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
queryset=VRF.objects.all(),
required=False,
label=_('Assigned VRF'),
null_option='Global',
fetch_trigger='open'
null_option='Global'
)
present_in_vrf_id = DynamicModelChoiceField(
queryset=VRF.objects.all(),
required=False,
label=_('Present in VRF'),
fetch_trigger='open'
label=_('Present in VRF')
)
status = forms.MultipleChoiceField(
choices=PrefixStatusChoices,
@@ -197,14 +188,12 @@ class PrefixFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
label=_('Region'),
fetch_trigger='open'
label=_('Region')
)
site_group_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label=_('Site group'),
fetch_trigger='open'
label=_('Site group')
)
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
@@ -213,15 +202,13 @@ class PrefixFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
query_params={
'region_id': '$region_id'
},
label=_('Site'),
fetch_trigger='open'
label=_('Site')
)
role_id = DynamicModelMultipleChoiceField(
queryset=Role.objects.all(),
required=False,
null_option='None',
label=_('Role'),
fetch_trigger='open'
label=_('Role')
)
is_pool = forms.NullBooleanField(
required=False,
@@ -257,8 +244,7 @@ class IPRangeFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
queryset=VRF.objects.all(),
required=False,
label=_('Assigned VRF'),
null_option='Global',
fetch_trigger='open'
null_option='Global'
)
status = forms.MultipleChoiceField(
choices=PrefixStatusChoices,
@@ -269,8 +255,7 @@ class IPRangeFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
queryset=Role.objects.all(),
required=False,
null_option='None',
label=_('Role'),
fetch_trigger='open'
label=_('Role')
)
tag = TagFilterField(model)
@@ -308,14 +293,12 @@ class IPAddressFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
queryset=VRF.objects.all(),
required=False,
label=_('Assigned VRF'),
null_option='Global',
fetch_trigger='open'
null_option='Global'
)
present_in_vrf_id = DynamicModelChoiceField(
queryset=VRF.objects.all(),
required=False,
label=_('Present in VRF'),
fetch_trigger='open'
label=_('Present in VRF')
)
status = forms.MultipleChoiceField(
choices=IPAddressStatusChoices,
@@ -376,32 +359,27 @@ class VLANGroupFilterForm(CustomFieldModelFilterForm):
region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
label=_('Region'),
fetch_trigger='open'
label=_('Region')
)
sitegroup = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label=_('Site group'),
fetch_trigger='open'
label=_('Site group')
)
site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
required=False,
label=_('Site'),
fetch_trigger='open'
label=_('Site')
)
location = DynamicModelMultipleChoiceField(
queryset=Location.objects.all(),
required=False,
label=_('Location'),
fetch_trigger='open'
label=_('Location')
)
rack = DynamicModelMultipleChoiceField(
queryset=Rack.objects.all(),
required=False,
label=_('Rack'),
fetch_trigger='open'
label=_('Rack')
)
tag = TagFilterField(model)
@@ -417,14 +395,12 @@ class VLANFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
label=_('Region'),
fetch_trigger='open'
label=_('Region')
)
site_group_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label=_('Site group'),
fetch_trigger='open'
label=_('Site group')
)
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
@@ -433,8 +409,7 @@ class VLANFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
query_params={
'region': '$region'
},
label=_('Site'),
fetch_trigger='open'
label=_('Site')
)
group_id = DynamicModelMultipleChoiceField(
queryset=VLANGroup.objects.all(),
@@ -443,8 +418,7 @@ class VLANFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
query_params={
'region': '$region'
},
label=_('VLAN group'),
fetch_trigger='open'
label=_('VLAN group')
)
status = forms.MultipleChoiceField(
choices=VLANStatusChoices,
@@ -455,8 +429,7 @@ class VLANFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
queryset=Role.objects.all(),
required=False,
null_option='None',
label=_('Role'),
fetch_trigger='open'
label=_('Role')
)
vid = forms.IntegerField(
required=False,

View File

@@ -471,6 +471,8 @@ class IPAddressForm(TenancyForm, CustomFieldModelForm):
})
elif selected_objects:
self.instance.assigned_object = self.cleaned_data[selected_objects[0]]
else:
self.instance.assigned_object = None
# Primary IP assignment is only available if an interface has been assigned.
interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface')
@@ -578,7 +580,7 @@ class FHRPGroupForm(CustomFieldModelForm):
vrf=self.cleaned_data['ip_vrf'],
address=self.cleaned_data['ip_address'],
status=self.cleaned_data['ip_status'],
role=FHRP_PROTOCOL_ROLE_MAPPINGS[self.cleaned_data['protocol']],
role=FHRP_PROTOCOL_ROLE_MAPPINGS.get(self.cleaned_data['protocol'], IPAddressRoleChoices.ROLE_VIP),
assigned_object=instance
)
ipaddress.save()
@@ -590,6 +592,8 @@ class FHRPGroupForm(CustomFieldModelForm):
return instance
def clean(self):
super().clean()
ip_vrf = self.cleaned_data.get('ip_vrf')
ip_address = self.cleaned_data.get('ip_address')
ip_status = self.cleaned_data.get('ip_status')
@@ -626,8 +630,7 @@ class FHRPGroupAssignmentForm(BootstrapMixin, forms.ModelForm):
class VLANGroupForm(CustomFieldModelForm):
scope_type = ContentTypeChoiceField(
queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
required=False,
widget=StaticSelect
required=False
)
region = DynamicModelChoiceField(
queryset=Region.objects.all(),

View File

@@ -32,6 +32,28 @@ __all__ = (
)
class GetAvailablePrefixesMixin:
def get_available_prefixes(self):
"""
Return all available Prefixes within this aggregate as an IPSet.
"""
prefix = netaddr.IPSet(self.prefix)
child_prefixes = netaddr.IPSet([child.prefix for child in self.get_child_prefixes()])
available_prefixes = prefix - child_prefixes
return available_prefixes
def get_first_available_prefix(self):
"""
Return the first available child prefix within the prefix (or None).
"""
available_prefixes = self.get_available_prefixes()
if not available_prefixes:
return None
return available_prefixes.iter_cidrs()[0]
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class RIR(OrganizationalModel):
"""
@@ -103,14 +125,33 @@ class ASN(PrimaryModel):
verbose_name_plural = 'ASNs'
def __str__(self):
return f'AS{self.asn}'
return f'AS{self.asn_with_asdot}'
def get_absolute_url(self):
return reverse('ipam:asn', args=[self.pk])
@property
def asn_asdot(self):
"""
Return ASDOT notation for AS numbers greater than 16 bits.
"""
if self.asn > 65535:
return f'{self.asn // 65536}.{self.asn % 65536}'
return self.asn
@property
def asn_with_asdot(self):
"""
Return both plain and ASDOT notation, where applicable.
"""
if self.asn > 65535:
return f'{self.asn} ({self.asn // 65536}.{self.asn % 65536})'
else:
return self.asn
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Aggregate(PrimaryModel):
class Aggregate(GetAvailablePrefixesMixin, PrimaryModel):
"""
An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize
the hierarchy and track the overall utilization of available address space. Each Aggregate is assigned to a RIR.
@@ -245,7 +286,7 @@ class Role(OrganizationalModel):
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Prefix(PrimaryModel):
class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
"""
A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and
VRFs. A Prefix must be assigned a status and may optionally be assigned a used-define Role. A Prefix can also be
@@ -458,16 +499,6 @@ class Prefix(PrimaryModel):
else:
return IPAddress.objects.filter(address__net_host_contained=str(self.prefix), vrf=self.vrf)
def get_available_prefixes(self):
"""
Return all available Prefixes within this prefix as an IPSet.
"""
prefix = netaddr.IPSet(self.prefix)
child_prefixes = netaddr.IPSet([child.prefix for child in self.get_child_prefixes()])
available_prefixes = prefix - child_prefixes
return available_prefixes
def get_available_ips(self):
"""
Return all available IPs within this prefix as an IPSet.
@@ -494,15 +525,6 @@ class Prefix(PrimaryModel):
return available_ips
def get_first_available_prefix(self):
"""
Return the first available child prefix within the prefix (or None).
"""
available_prefixes = self.get_available_prefixes()
if not available_prefixes:
return None
return available_prefixes.iter_cidrs()[0]
def get_first_available_ip(self):
"""
Return the first available IP within the prefix (or None).

View File

@@ -38,7 +38,7 @@ class FHRPGroupTable(BaseTable):
model = FHRPGroup
fields = (
'pk', 'group_id', 'protocol', 'auth_type', 'auth_key', 'description', 'ip_addresses', 'interface_count',
'tags',
'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'group_id', 'protocol', 'auth_type', 'description', 'ip_addresses', 'interface_count')
@@ -60,7 +60,7 @@ class FHRPGroupAssignmentTable(BaseTable):
)
actions = ButtonsColumn(
model=FHRPGroupAssignment,
buttons=('edit', 'delete', 'foo')
buttons=('edit', 'delete')
)
class Meta(BaseTable.Meta):

View File

@@ -93,7 +93,10 @@ class RIRTable(BaseTable):
class Meta(BaseTable.Meta):
model = RIR
fields = ('pk', 'id', 'name', 'slug', 'is_private', 'aggregate_count', 'description', 'tags', 'actions')
fields = (
'pk', 'id', 'name', 'slug', 'is_private', 'aggregate_count', 'description', 'tags', 'actions', 'created',
'last_updated',
)
default_columns = ('pk', 'name', 'is_private', 'aggregate_count', 'description', 'actions')
@@ -104,8 +107,10 @@ class RIRTable(BaseTable):
class ASNTable(BaseTable):
pk = ToggleColumn()
asn = tables.Column(
accessor=tables.A('asn_asdot'),
linkify=True
)
site_count = LinkedCountColumn(
viewname='dcim:site_list',
url_params={'asn_id': 'pk'},
@@ -115,7 +120,7 @@ class ASNTable(BaseTable):
class Meta(BaseTable.Meta):
model = ASN
fields = ('pk', 'asn', 'rir', 'site_count', 'tenant', 'description', 'actions')
fields = ('pk', 'asn', 'rir', 'site_count', 'tenant', 'description', 'actions', 'created', 'last_updated',)
default_columns = ('pk', 'asn', 'rir', 'site_count', 'sites', 'tenant', 'actions')
@@ -147,7 +152,10 @@ class AggregateTable(BaseTable):
class Meta(BaseTable.Meta):
model = Aggregate
fields = ('pk', 'id', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description', 'tags')
fields = (
'pk', 'id', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description', 'tags',
'created', 'last_updated',
)
default_columns = ('pk', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description')
@@ -177,7 +185,10 @@ class RoleTable(BaseTable):
class Meta(BaseTable.Meta):
model = Role
fields = ('pk', 'id', 'name', 'slug', 'prefix_count', 'vlan_count', 'description', 'weight', 'tags', 'actions')
fields = (
'pk', 'id', 'name', 'slug', 'prefix_count', 'vlan_count', 'description', 'weight', 'tags', 'actions',
'created', 'last_updated',
)
default_columns = ('pk', 'name', 'prefix_count', 'vlan_count', 'description', 'actions')
@@ -264,8 +275,8 @@ class PrefixTable(BaseTable):
class Meta(BaseTable.Meta):
model = Prefix
fields = (
'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan_group',
'vlan', 'role', 'is_pool', 'mark_utilized', 'description', 'tags',
'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site',
'vlan_group', 'vlan', 'role', 'is_pool', 'mark_utilized', 'description', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description',
@@ -306,7 +317,7 @@ class IPRangeTable(BaseTable):
model = IPRange
fields = (
'pk', 'id', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description',
'utilization', 'tags',
'utilization', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description',
@@ -364,7 +375,7 @@ class IPAddressTable(BaseTable):
model = IPAddress
fields = (
'pk', 'id', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'assigned', 'dns_name', 'description',
'tags',
'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned', 'dns_name', 'description',

View File

@@ -31,5 +31,8 @@ class ServiceTable(BaseTable):
class Meta(BaseTable.Meta):
model = Service
fields = ('pk', 'id', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'tags')
fields = (
'pk', 'id', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'tags', 'created',
'last_updated',
)
default_columns = ('pk', 'name', 'parent', 'protocol', 'ports', 'description')

View File

@@ -84,7 +84,10 @@ class VLANGroupTable(BaseTable):
class Meta(BaseTable.Meta):
model = VLANGroup
fields = ('pk', 'id', 'name', 'scope_type', 'scope', 'vlan_count', 'slug', 'description', 'tags', 'actions')
fields = (
'pk', 'id', 'name', 'scope_type', 'scope', 'vlan_count', 'slug', 'description', 'tags', 'actions',
'created', 'last_updated',
)
default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description', 'actions')
@@ -125,7 +128,10 @@ class VLANTable(BaseTable):
class Meta(BaseTable.Meta):
model = VLAN
fields = ('pk', 'id', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description', 'tags')
fields = (
'pk', 'id', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description', 'tags',
'created', 'last_updated',
)
default_columns = ('pk', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description')
row_attrs = {
'class': lambda record: 'success' if not isinstance(record, VLAN) else '',

View File

@@ -47,7 +47,8 @@ class VRFTable(BaseTable):
class Meta(BaseTable.Meta):
model = VRF
fields = (
'pk', 'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'tags',
'pk', 'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets',
'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'rd', 'tenant', 'description')
@@ -68,5 +69,5 @@ class RouteTargetTable(BaseTable):
class Meta(BaseTable.Meta):
model = RouteTarget
fields = ('pk', 'id', 'name', 'tenant', 'description', 'tags')
fields = ('pk', 'id', 'name', 'tenant', 'description', 'tags', 'created', 'last_updated',)
default_columns = ('pk', 'name', 'tenant', 'description')

View File

@@ -1,5 +1,7 @@
import datetime
from django.test import override_settings
from django.urls import reverse
from netaddr import IPNetwork
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
@@ -222,6 +224,21 @@ class AggregateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'description': 'New description',
}
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_aggregate_prefixes(self):
rir = RIR.objects.first()
aggregate = Aggregate.objects.create(prefix=IPNetwork('192.168.0.0/16'), rir=rir)
prefixes = (
Prefix(prefix=IPNetwork('192.168.1.0/24')),
Prefix(prefix=IPNetwork('192.168.2.0/24')),
Prefix(prefix=IPNetwork('192.168.3.0/24')),
)
Prefix.objects.bulk_create(prefixes)
self.assertEqual(aggregate.get_child_prefixes().count(), 3)
url = reverse('ipam:aggregate_prefixes', kwargs={'pk': aggregate.pk})
self.assertHttpStatus(self.client.get(url), 200)
class RoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = Role
@@ -319,6 +336,48 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'description': 'New description',
}
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_prefix_prefixes(self):
prefixes = (
Prefix(prefix=IPNetwork('192.168.0.0/16')),
Prefix(prefix=IPNetwork('192.168.1.0/24')),
Prefix(prefix=IPNetwork('192.168.2.0/24')),
Prefix(prefix=IPNetwork('192.168.3.0/24')),
)
Prefix.objects.bulk_create(prefixes)
self.assertEqual(prefixes[0].get_child_prefixes().count(), 3)
url = reverse('ipam:prefix_prefixes', kwargs={'pk': prefixes[0].pk})
self.assertHttpStatus(self.client.get(url), 200)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_prefix_ipranges(self):
prefix = Prefix.objects.create(prefix=IPNetwork('192.168.0.0/16'))
ip_ranges = (
IPRange(start_address='192.168.0.1/24', end_address='192.168.0.100/24', size=99),
IPRange(start_address='192.168.1.1/24', end_address='192.168.1.100/24', size=99),
IPRange(start_address='192.168.2.1/24', end_address='192.168.2.100/24', size=99),
)
IPRange.objects.bulk_create(ip_ranges)
self.assertEqual(prefix.get_child_ranges().count(), 3)
url = reverse('ipam:prefix_ipranges', kwargs={'pk': prefix.pk})
self.assertHttpStatus(self.client.get(url), 200)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_prefix_ipaddresses(self):
prefix = Prefix.objects.create(prefix=IPNetwork('192.168.0.0/16'))
ip_addresses = (
IPAddress(address=IPNetwork('192.168.0.1/16')),
IPAddress(address=IPNetwork('192.168.0.2/16')),
IPAddress(address=IPNetwork('192.168.0.3/16')),
)
IPAddress.objects.bulk_create(ip_addresses)
self.assertEqual(prefix.get_child_ips().count(), 3)
url = reverse('ipam:prefix_ipaddresses', kwargs={'pk': prefix.pk})
self.assertHttpStatus(self.client.get(url), 200)
class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = IPRange
@@ -377,6 +436,24 @@ class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'description': 'New description',
}
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_iprange_ipaddresses(self):
iprange = IPRange.objects.create(
start_address=IPNetwork('192.168.0.1/24'),
end_address=IPNetwork('192.168.0.100/24'),
size=99
)
ip_addresses = (
IPAddress(address=IPNetwork('192.168.0.1/24')),
IPAddress(address=IPNetwork('192.168.0.2/24')),
IPAddress(address=IPNetwork('192.168.0.3/24')),
)
IPAddress.objects.bulk_create(ip_addresses)
self.assertEqual(iprange.get_child_ips().count(), 3)
url = reverse('ipam:iprange_ipaddresses', kwargs={'pk': iprange.pk})
self.assertHttpStatus(self.client.get(url), 200)
class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = IPAddress

View File

@@ -5,18 +5,18 @@ from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from dcim.filtersets import InterfaceFilterSet
from dcim.models import Device, Interface, Site
from dcim.models import Interface, Site
from dcim.tables import SiteTable
from netbox.views import generic
from utilities.tables import paginate_table
from utilities.utils import count_related
from virtualization.filtersets import VMInterfaceFilterSet
from virtualization.models import VirtualMachine, VMInterface
from virtualization.models import VMInterface
from . import filtersets, forms, tables
from .constants import *
from .models import *
from .models import ASN
from .utils import add_requested_prefixes, add_available_vlans
from .utils import add_requested_prefixes, add_available_ipaddresses, add_available_vlans
#
@@ -299,6 +299,7 @@ class AggregatePrefixesView(generic.ObjectChildrenView):
return {
'bulk_querystring': f'within={instance.prefix}',
'active_tab': 'prefixes',
'first_available_prefix': instance.get_first_available_prefix(),
'show_available': bool(request.GET.get('show_available', 'true') == 'true'),
'show_assigned': bool(request.GET.get('show_assigned', 'true') == 'true'),
}
@@ -418,7 +419,7 @@ class PrefixView(generic.ObjectView):
).filter(
prefix__net_contains=str(instance.prefix)
).prefetch_related(
'site', 'role'
'site', 'role', 'tenant'
)
parent_prefix_table = tables.PrefixTable(
list(parent_prefixes),
@@ -455,7 +456,9 @@ class PrefixPrefixesView(generic.ObjectChildrenView):
template_name = 'ipam/prefix/prefixes.html'
def get_children(self, request, parent):
return parent.get_child_prefixes().restrict(request.user, 'view')
return parent.get_child_prefixes().restrict(request.user, 'view').prefetch_related(
'site', 'vrf', 'vlan', 'role', 'tenant',
)
def prep_table_data(self, request, queryset, parent):
# Determine whether to show assigned prefixes, available prefixes, or both
@@ -482,7 +485,9 @@ class PrefixIPRangesView(generic.ObjectChildrenView):
template_name = 'ipam/prefix/ip_ranges.html'
def get_children(self, request, parent):
return parent.get_child_ranges().restrict(request.user, 'view')
return parent.get_child_ranges().restrict(request.user, 'view').prefetch_related(
'vrf', 'role', 'tenant',
)
def get_extra_context(self, request, instance):
return {
@@ -500,7 +505,14 @@ class PrefixIPAddressesView(generic.ObjectChildrenView):
template_name = 'ipam/prefix/ip_addresses.html'
def get_children(self, request, parent):
return parent.get_child_ips().restrict(request.user, 'view')
return parent.get_child_ips().restrict(request.user, 'view').prefetch_related('vrf', 'tenant')
def prep_table_data(self, request, queryset, parent):
show_available = bool(request.GET.get('show_available', 'true') == 'true')
if show_available:
return add_available_ipaddresses(parent.prefix, queryset, parent.is_pool)
return queryset
def get_extra_context(self, request, instance):
return {
@@ -517,7 +529,6 @@ class PrefixEditView(generic.ObjectEditView):
class PrefixDeleteView(generic.ObjectDeleteView):
queryset = Prefix.objects.all()
template_name = 'ipam/prefix_delete.html'
class PrefixBulkImportView(generic.BulkImportView):
@@ -562,7 +573,9 @@ class IPRangeIPAddressesView(generic.ObjectChildrenView):
template_name = 'ipam/iprange/ip_addresses.html'
def get_children(self, request, parent):
return parent.get_child_ips().restrict(request.user, 'view')
return parent.get_child_ips().restrict(request.user, 'view').prefetch_related(
'vrf', 'role', 'tenant',
)
def get_extra_context(self, request, instance):
return {

View File

@@ -105,7 +105,7 @@ class RemoteUserBackend(_RemoteUserBackend):
return settings.REMOTE_AUTH_AUTO_CREATE_USER
def configure_groups(self, user, remote_groups):
logger = logging.getLogger('netbox.authentication.RemoteUserBackend')
logger = logging.getLogger('netbox.auth.RemoteUserBackend')
# Assign default groups to the user
group_list = []
@@ -141,7 +141,7 @@ class RemoteUserBackend(_RemoteUserBackend):
Return None if ``create_unknown_user`` is ``False`` and a ``User``
object with the given username is not found in the database.
"""
logger = logging.getLogger('netbox.authentication.RemoteUserBackend')
logger = logging.getLogger('netbox.auth.RemoteUserBackend')
logger.debug(
f"trying to authenticate {remote_user} with groups {remote_groups}")
if not remote_user:
@@ -173,7 +173,7 @@ class RemoteUserBackend(_RemoteUserBackend):
return None
def _is_superuser(self, user):
logger = logging.getLogger('netbox.authentication.RemoteUserBackend')
logger = logging.getLogger('netbox.auth.RemoteUserBackend')
superuser_groups = settings.REMOTE_AUTH_SUPERUSER_GROUPS
logger.debug(f"Superuser Groups: {superuser_groups}")
superusers = settings.REMOTE_AUTH_SUPERUSERS
@@ -189,7 +189,7 @@ class RemoteUserBackend(_RemoteUserBackend):
return bool(result)
def _is_staff(self, user):
logger = logging.getLogger('netbox.authentication.RemoteUserBackend')
logger = logging.getLogger('netbox.auth.RemoteUserBackend')
staff_groups = settings.REMOTE_AUTH_STAFF_GROUPS
logger.debug(f"Superuser Groups: {staff_groups}")
staff_users = settings.REMOTE_AUTH_STAFF_USERS
@@ -204,7 +204,7 @@ class RemoteUserBackend(_RemoteUserBackend):
return bool(result)
def configure_user(self, request, user):
logger = logging.getLogger('netbox.authentication.RemoteUserBackend')
logger = logging.getLogger('netbox.auth.RemoteUserBackend')
if not settings.REMOTE_AUTH_GROUP_SYNC_ENABLED:
# Assign default groups to the user
group_list = []

View File

@@ -176,7 +176,7 @@ CONNECTIONS_MENU = Menu(
label='Connections',
items=(
get_model_item('dcim', 'cable', 'Cables', actions=['import']),
get_model_item('wireless', 'wirelesslink', 'Wirelesss Links', actions=['import']),
get_model_item('wireless', 'wirelesslink', 'Wireless Links', actions=['import']),
MenuItem(
link='dcim:interface_connections_list',
link_text='Interface Connections',

View File

@@ -19,7 +19,7 @@ from netbox.config import PARAMS
# Environment setup
#
VERSION = '3.1.2'
VERSION = '3.1.6'
# Hostname
HOSTNAME = platform.node()

View File

@@ -10,6 +10,7 @@ from django.db.models import ManyToManyField, ProtectedError
from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.html import escape
from django.utils.http import is_safe_url
from django.utils.safestring import mark_safe
@@ -430,10 +431,21 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
obj = self.get_object(kwargs)
form = ConfirmationForm(initial=request.GET)
# If this is an HTMX request, return only the rendered deletion form as modal content
if is_htmx(request):
viewname = f'{self.queryset.model._meta.app_label}:{self.queryset.model._meta.model_name}_delete'
form_url = reverse(viewname, kwargs={'pk': obj.pk})
return render(request, 'htmx/delete_form.html', {
'object': obj,
'object_type': self.queryset.model._meta.verbose_name,
'form': form,
'form_url': form_url,
})
return render(request, self.template_name, {
'obj': obj,
'object': obj,
'object_type': self.queryset.model._meta.verbose_name,
'form': form,
'obj_type': self.queryset.model._meta.verbose_name,
'return_url': self.get_return_url(request, obj),
})
@@ -466,9 +478,9 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
logger.debug("Form validation failed")
return render(request, self.template_name, {
'obj': obj,
'object': obj,
'object_type': self.queryset.model._meta.verbose_name,
'form': form,
'obj_type': self.queryset.model._meta.verbose_name,
'return_url': self.get_return_url(request, obj),
})

View File

@@ -40,7 +40,6 @@ async function bundleGraphIQL() {
async function bundleNetBox() {
const entryPoints = {
netbox: 'src/index.ts',
jobs: 'src/jobs.ts',
lldp: 'src/device/lldp.ts',
config: 'src/device/config.ts',
status: 'src/device/status.ts',

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -35,11 +35,6 @@ function handleFormSubmit(event: Event, form: HTMLFormElement): void {
for (const element of form.querySelectorAll<FormControls>('*[name]')) {
if (!element.validity.valid) {
invalids.add(element.name);
// If the field is invalid, but contains the .is-valid class, remove it.
if (element.classList.contains('is-valid')) {
element.classList.remove('is-valid');
}
// If the field is invalid, but doesn't contain the .is-invalid class, add it.
if (!element.classList.contains('is-invalid')) {
element.classList.add('is-invalid');
@@ -49,10 +44,6 @@ function handleFormSubmit(event: Event, form: HTMLFormElement): void {
if (element.classList.contains('is-invalid')) {
element.classList.remove('is-invalid');
}
// If the field is valid, but doesn't contain the .is-valid class, add it.
if (!element.classList.contains('is-valid')) {
element.classList.add('is-valid');
}
}
}

View File

@@ -98,38 +98,6 @@ type APISecret = {
url: string;
};
type JobResultLog = {
message: string;
status: 'success' | 'warning' | 'danger' | 'info';
};
type JobStatus = {
label: string;
value: 'completed' | 'failed' | 'errored' | 'running';
};
type APIJobResult = {
completed: string;
created: string;
data: {
log: JobResultLog[];
output: string;
};
display: string;
id: number;
job_id: string;
name: string;
obj_type: string;
status: JobStatus;
url: string;
user: {
display: string;
username: string;
id: number;
url: string;
};
};
type APIUserConfig = {
tables: { [k: string]: { columns: string[]; available_columns: string[] } };
[k: string]: unknown;

View File

@@ -0,0 +1,23 @@
import { getElements, isTruthy } from './util';
import { initButtons } from './buttons';
function initDepedencies(): void {
for (const init of [initButtons]) {
init();
}
}
/**
* Hook into HTMX's event system to reinitialize specific native event listeners when HTMX swaps
* elements.
*/
export function initHtmx(): void {
for (const element of getElements('[hx-target]')) {
const targetSelector = element.getAttribute('hx-target');
if (isTruthy(targetSelector)) {
for (const target of getElements(targetSelector)) {
target.addEventListener('htmx:afterSettle', initDepedencies);
}
}
}
}

View File

@@ -1,104 +0,0 @@
import { createToast } from './bs';
import { apiGetBase, hasError, getNetboxData } from './util';
let timeout: number = 1000;
interface JobInfo {
url: Nullable<string>;
complete: boolean;
}
/**
* Mimic the behavior of setTimeout() in an async function.
*/
function asyncTimeout(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Job ID & Completion state are only from Django context, which can only be used from the HTML
* template. Hidden elements are present in the template to provide access to these values from
* JavaScript.
*/
function getJobInfo(): JobInfo {
let complete = false;
// Determine the API URL for the job status
const url = getNetboxData('data-job-url');
// Determine the job completion status, if present. If the job is not complete, the value will be
// "None". Otherwise, it will be a stringified date.
const jobComplete = getNetboxData('data-job-complete');
if (typeof jobComplete === 'string' && jobComplete.toLowerCase() !== 'none') {
complete = true;
}
return { url, complete };
}
/**
* Update the job status label element based on the API response.
*/
function updateLabel(status: JobStatus) {
const element = document.querySelector<HTMLSpanElement>('#pending-result-label > span.badge');
if (element !== null) {
let labelClass = 'secondary';
switch (status.value) {
case 'failed' || 'errored':
labelClass = 'danger';
break;
case 'running':
labelClass = 'warning';
break;
case 'completed':
labelClass = 'success';
break;
}
element.setAttribute('class', `badge bg-${labelClass}`);
element.innerText = status.label;
}
}
/**
* Recursively check the job's status.
* @param url API URL for job result
*/
async function checkJobStatus(url: string) {
const res = await apiGetBase<APIJobResult>(url);
if (hasError(res)) {
// If the response is an API error, display an error message and stop checking for job status.
const toast = createToast('danger', 'Error', res.error);
toast.show();
return;
} else {
// Update the job status label.
updateLabel(res.status);
// If the job is complete, reload the page.
if (['completed', 'failed', 'errored'].includes(res.status.value)) {
location.reload();
return;
} else {
// Otherwise, keep checking the job's status, backing off 1 second each time, until a 10
// second interval is reached.
if (timeout < 10000) {
timeout += 1000;
}
await Promise.all([checkJobStatus(url), asyncTimeout(timeout)]);
}
}
}
function initJobs() {
const { url, complete } = getJobInfo();
if (url !== null && !complete) {
// If there is a job ID and it is not completed, check for the job's status.
Promise.resolve(checkJobStatus(url));
}
}
if (document.readyState !== 'loading') {
initJobs();
} else {
document.addEventListener('DOMContentLoaded', initJobs);
}

View File

@@ -12,6 +12,7 @@ import { initInterfaceTable } from './tables';
import { initSideNav } from './sidenav';
import { initRackElevation } from './racks';
import { initLinks } from './links';
import { initHtmx } from './htmx';
function initDocument(): void {
for (const init of [
@@ -29,6 +30,7 @@ function initDocument(): void {
initSideNav,
initRackElevation,
initLinks,
initHtmx,
]) {
init();
}

View File

@@ -251,7 +251,7 @@ export class APISelect {
} else if (collapse !== null) {
this.trigger = 'collapse';
} else {
this.trigger = 'load';
this.trigger = 'open';
}
switch (this.trigger) {

View File

@@ -358,7 +358,7 @@ nav.search {
// Don't overtake dropdowns
z-index: 999;
justify-content: center;
background-color: var(--nbx-body-bg);
background-color: $navbar-light-color;
.search-container {
display: flex;
@@ -452,8 +452,8 @@ main.login-container {
}
.footer {
background-color: $tab-content-bg;
padding: 0;
.nav-link {
padding: 0.5rem;
}
@@ -517,6 +517,10 @@ h6.accordion-item-title {
}
}
.navbar {
border-bottom: 1px solid $border-color;
}
.navbar-brand {
padding-top: 0.75rem;
padding-bottom: 0.75rem;
@@ -554,6 +558,7 @@ div.content-container {
}
div.content {
background-color: $tab-content-bg;
flex: 1;
}
@@ -592,6 +597,10 @@ span.color-label {
box-shadow: $box-shadow-sm;
}
.badge a {
color: inherit;
}
.btn {
white-space: nowrap;
}
@@ -898,6 +907,7 @@ div.card-overlay {
// Tabbed content
.nav-tabs {
background-color: $body-bg;
.nav-link {
&:hover {
// Don't show a bottom-border on a hovered nav link because it overlaps with the .nav-tab border.
@@ -919,14 +929,6 @@ div.card-overlay {
display: flex;
flex-direction: column;
padding: $spacer;
background-color: $tab-content-bg;
border-bottom: 1px solid $nav-tabs-border-color;
// Remove background and border when printing.
@media print {
background-color: var(--nbx-body-bg) !important;
border-bottom: none !important;
}
}
// Override masonry-layout styles when printing.
@@ -965,6 +967,19 @@ div.card-overlay {
max-width: unset;
}
/* Rendered Markdown */
.rendered-markdown table {
width: 100%;
}
.rendered-markdown th {
border-bottom: 2px solid #dddddd;
padding: 8px;
}
.rendered-markdown td {
border-top: 1px solid #dddddd;
padding: 8px;
}
// Preformatted text blocks
td pre {
margin-bottom: 0

View File

@@ -223,11 +223,6 @@
font-weight: $font-weight-bold;
color: var(--nbx-sidenav-parent-color);
&.active {
color: $accordion-button-active-color;
background: $accordion-button-active-bg;
}
&:after {
display: inline-block;
margin-left: auto;
@@ -284,7 +279,7 @@
font-size: $font-size-sm;
color: var(--nbx-sidenav-link-color);
white-space: nowrap;
transition: $transition-100ms-ease-in-out;
transition-duration: 0ms;
&.active {
background-color: var(--nbx-sidebar-link-active-bg);

View File

@@ -33,95 +33,6 @@ $darkest: #171b1d;
@import '../node_modules/bootstrap/scss/variables';
// Make color palette colors available as theme colors.
// For example, you could use `.bg-red-100`, if needed.
$theme-color-addons: (
'darker': $darker,
'darkest': $darkest,
'gray': $gray-400,
'gray-100': $gray-100,
'gray-200': $gray-200,
'gray-300': $gray-300,
'gray-400': $gray-400,
'gray-500': $gray-500,
'gray-600': $gray-600,
'gray-700': $gray-700,
'gray-800': $gray-800,
'gray-900': $gray-900,
'red-100': $red-100,
'red-200': $red-200,
'red-300': $red-300,
'red-400': $red-400,
'red-500': $red-500,
'red-600': $red-600,
'red-700': $red-700,
'red-800': $red-800,
'red-900': $red-900,
'yellow-100': $yellow-100,
'yellow-200': $yellow-200,
'yellow-300': $yellow-300,
'yellow-400': $yellow-400,
'yellow-500': $yellow-500,
'yellow-600': $yellow-600,
'yellow-700': $yellow-700,
'yellow-800': $yellow-800,
'yellow-900': $yellow-900,
'green-100': $green-100,
'green-200': $green-200,
'green-300': $green-300,
'green-400': $green-400,
'green-500': $green-500,
'green-600': $green-600,
'green-700': $green-700,
'green-800': $green-800,
'green-900': $green-900,
'blue-100': $blue-100,
'blue-200': $blue-200,
'blue-300': $blue-300,
'blue-400': $blue-400,
'blue-500': $blue-500,
'blue-600': $blue-600,
'blue-700': $blue-700,
'blue-800': $blue-800,
'blue-900': $blue-900,
'cyan-100': $cyan-100,
'cyan-200': $cyan-200,
'cyan-300': $cyan-300,
'cyan-400': $cyan-400,
'cyan-500': $cyan-500,
'cyan-600': $cyan-600,
'cyan-700': $cyan-700,
'cyan-800': $cyan-800,
'cyan-900': $cyan-900,
'indigo-100': $indigo-100,
'indigo-200': $indigo-200,
'indigo-300': $indigo-300,
'indigo-400': $indigo-400,
'indigo-500': $indigo-500,
'indigo-600': $indigo-600,
'indigo-700': $indigo-700,
'indigo-800': $indigo-800,
'indigo-900': $indigo-900,
'purple-100': $purple-100,
'purple-200': $purple-200,
'purple-300': $purple-300,
'purple-400': $purple-400,
'purple-500': $purple-500,
'purple-600': $purple-600,
'purple-700': $purple-700,
'purple-800': $purple-800,
'purple-900': $purple-900,
'pink-100': $pink-100,
'pink-200': $pink-200,
'pink-300': $pink-300,
'pink-400': $pink-400,
'pink-500': $pink-500,
'pink-600': $pink-600,
'pink-700': $pink-700,
'pink-800': $pink-800,
'pink-900': $pink-900,
);
// This is the same value as the default from Bootstrap, but it needs to be in scope prior to
// importing _variables.scss from Bootstrap.
$btn-close-width: 1em;

View File

@@ -3,6 +3,7 @@
@use 'sass:map';
@import './theme-base';
// Theme colors (BS5 classes)
$primary: $blue-300;
$secondary: $gray-500;
$success: $green-300;
@@ -13,6 +14,7 @@ $light: $gray-300;
$dark: $gray-500;
$theme-colors: (
// BS5 theme colors
'primary': $primary,
'secondary': $secondary,
'success': $success,
@@ -21,18 +23,23 @@ $theme-colors: (
'danger': $danger,
'light': $light,
'dark': $dark,
'red': $red-300,
'yellow': $yellow-300,
'green': $green-300,
// General-purpose palette
'blue': $blue-300,
'cyan': $cyan-300,
'indigo': $indigo-300,
'purple': $purple-300,
'pink': $pink-300,
'red': $red-300,
'orange': $orange-300,
'yellow': $yellow-300,
'green': $green-300,
'teal': $teal-300,
'cyan': $cyan-300,
'gray': $gray-300,
'black': $black,
'white': $white,
);
$theme-colors: map-merge($theme-colors, $theme-color-addons);
// Gradient
$gradient: linear-gradient(180deg, rgba($white, 0.15), rgba($white, 0));
@@ -139,7 +146,7 @@ $nav-tabs-link-active-border-color: $gray-800 $gray-800 $nav-tabs-link-active-bg
$nav-pills-link-active-color: $component-active-color;
$nav-pills-link-active-bg: $component-active-bg;
$navbar-light-color: $gray-500;
$navbar-light-color: $darkest;
$navbar-light-toggler-icon-bg: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'><path stroke='#{$navbar-light-color}' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/></svg>");
$navbar-light-toggler-border-color: $gray-700;

View File

@@ -2,27 +2,47 @@
@import './theme-base.scss';
$input-border-color: $gray-200;
// Theme colors (BS5 classes)
$primary: #337ab7;
$secondary: $gray-600;
$success: $green-500;
$info: #54d6f0;
$warning: $yellow-500;
$danger: $red-500;
$light: $gray-200;
$dark: $gray-800;
$theme-colors: map-merge(
$theme-colors,
(
'primary': #337ab7,
'red': $red-500,
'yellow': $yellow-500,
'green': $green-500,
'blue': $blue-500,
'cyan': $cyan-500,
'indigo': $indigo-500,
'purple': $purple-500,
'pink': $pink-500,
)
$theme-colors: (
// BS5 theme colors
'primary': $primary,
'secondary': $secondary,
'success': $success,
'info': $info,
'warning': $warning,
'danger': $danger,
'light': $light,
'dark': $dark,
// General-purpose palette
'blue': $blue-500,
'indigo': $indigo-500,
'purple': $purple-500,
'pink': $pink-500,
'red': $red-500,
'orange': $orange-500,
'yellow': $yellow-500,
'green': $green-500,
'teal': $teal-500,
'cyan': $cyan-500,
'gray': $gray-500,
'black': $black,
'white': $white,
);
$theme-colors: map-merge($theme-colors, $theme-color-addons);
$light: $gray-200;
$navbar-light-color: $gray-100;
$card-cap-color: $gray-800;
$accordion-bg: transparent;

View File

@@ -5,7 +5,7 @@
--nbx-sidebar-bg: #{$gray-200};
--nbx-sidebar-scroll: #{$gray-500};
--nbx-sidebar-link-hover-bg: #{rgba($gray-600, 0.15)};
--nbx-sidebar-link-active-bg: #{$blue-100};
--nbx-sidebar-link-active-bg: #9cc8f8;
--nbx-sidebar-title-color: #{$text-muted};
--nbx-sidebar-shadow: inset 0px -25px 20px -25px rgba(0, 0, 0, 0.25);
--nbx-breadcrumb-bg: #{$light};

View File

@@ -104,23 +104,23 @@
{# Static resources #}
<link
rel="stylesheet"
href="{% static 'netbox-external.css'%}"
href="{% static 'netbox-external.css'%}?v={{ settings.VERSION }}"
onerror="window.location='{% url 'media_failure' %}?filename=netbox-external.css'"
/>
<link
rel="stylesheet"
href="{% static 'netbox-light.css'%}"
href="{% static 'netbox-light.css'%}?v={{ settings.VERSION }}"
onerror="window.location='{% url 'media_failure' %}?filename=netbox-light.css'"
/>
<link
rel="stylesheet"
href="{% static 'netbox-dark.css'%}"
href="{% static 'netbox-dark.css'%}?v={{ settings.VERSION }}"
onerror="window.location='{% url 'media_failure' %}?filename=netbox-dark.css'"
/>
<link
rel="stylesheet"
media="print"
href="{% static 'netbox-print.css'%}"
href="{% static 'netbox-print.css'%}?v={{ settings.VERSION }}"
onerror="window.location='{% url 'media_failure' %}?filename=netbox-print.css'"
/>
<link rel="icon" type="image/png" href="{% static 'netbox.ico' %}" />
@@ -129,7 +129,7 @@
{# Javascript #}
<script
type="text/javascript"
src="{% static 'netbox.js' %}"
src="{% static 'netbox.js' %}?v={{ settings.VERSION }}"
onerror="window.location='{% url 'media_failure' %}?filename=netbox.js'">
</script>

View File

@@ -1,8 +1,7 @@
{# Base layout for the core NetBox UI w/navbar and page content #}
{% extends 'base/base.html' %}
{% load helpers %}
{% load nav %}
{% load search_options %}
{% load search %}
{% load static %}
{% block layout %}
@@ -21,7 +20,7 @@
</div>
{# Top bar #}
<nav class="navbar navbar-light sticky-top flex-md-nowrap ps-6 p-3 search container-fluid noprint">
<nav class="navbar navbar-light sticky-top flex-md-nowrap p-1 mb-3 search container-fluid noprint">
{# Mobile Navigation #}
<div class="nav-mobile">
@@ -104,6 +103,9 @@
</div>
{% endif %}
{# BS5 pop-up modals #}
{% block modals %}{% endblock %}
{# Page footer #}
<footer class="footer container-fluid">
<div class="row align-items-center justify-content-between mx-0">

View File

@@ -1,4 +1,4 @@
{% load nav %}
{% load navigation %}
{% load static %}
<nav class="sidenav noprint" id="sidenav" data-simplebar>

View File

@@ -1,4 +1,4 @@
{% extends 'utilities/confirmation_form.html' %}
{% extends 'generic/confirmation_form.html' %}
{% block title %}Swap Circuit Terminations{% endblock %}

View File

@@ -10,7 +10,7 @@
</a>
{% endif %}
{% if termination and perms.circuits.change_circuittermination %}
<a href="{% url 'circuits:circuittermination_edit' pk=termination.pk %}" class="btn btn-sm btn-yellow lh-1">
<a href="{% url 'circuits:circuittermination_edit' pk=termination.pk %}" class="btn btn-sm btn-warning lh-1">
<span class="mdi mdi-pencil" aria-hidden="true"></span> Edit
</a>
<a href="{% url 'circuits:circuit_terminations_swap' pk=object.pk %}" class="btn btn-sm btn-primary lh-1">

View File

@@ -41,11 +41,11 @@
</tr>
<tr>
<th scope="row">NOC Contact</th>
<td class="rendered-markdown">{{ object.noc_contact|render_markdown|placeholder }}</td>
<td>{{ object.noc_contact|render_markdown|placeholder }}</td>
</tr>
<tr>
<th scope="row">Admin Contact</th>
<td class="rendered-markdown">{{ object.admin_contact|render_markdown|placeholder }}</td>
<td>{{ object.admin_contact|render_markdown|placeholder }}</td>
</tr>
<tr>
<th scope="row">Circuits</th>

View File

@@ -1,4 +1,4 @@
{% extends 'utilities/confirmation_form.html' %}
{% extends 'generic/confirmation_form.html' %}
{% load helpers %}
{% block title %}Disconnect {{ obj_type_plural|bettertitle }}{% endblock %}

View File

@@ -5,6 +5,14 @@
{% block title %}Connect {{ form.instance.termination_a.device }} {{ form.instance.termination_a }} to {{ termination_b_type|bettertitle }}{% endblock %}
{% block tabs %}
<ul class="nav nav-tabs px-3">
<li class="nav-item" role="presentation">
<a href="#" role="tab" data-bs-toggle="tab" class="nav-link active">Connect Cable</a>
</li>
</ul>
{% endblock %}
{% block content-wrapper %}
<div class="tab-content">
{% with termination_a=form.instance.termination_a %}
@@ -27,6 +35,12 @@
<input class="form-control" value="{{ termination_a.device.site.region }}" disabled />
</div>
</div>
<div class="row mb-3">
<label class="col-sm-3 col-form-label text-lg-end">Site Group</label>
<div class="col">
<input class="form-control" value="{{ termination_a.device.site.group }}" disabled />
</div>
</div>
<div class="row mb-3">
<label class="col-sm-3 col-form-label text-lg-end">Site</label>
<div class="col">
@@ -115,6 +129,9 @@
{% if 'termination_b_region' in form.fields %}
{% render_field form.termination_b_region %}
{% endif %}
{% if 'termination_b_sitegroup' in form.fields %}
{% render_field form.termination_b_sitegroup %}
{% endif %}
{% if 'termination_b_site' in form.fields %}
{% render_field form.termination_b_site %}
{% endif %}

View File

@@ -1,4 +1,4 @@
{% extends 'utilities/confirmation_form.html' %}
{% extends 'generic/confirmation_form.html' %}
{% load form_helpers %}
{% block title %}Delete console port {{ consoleport }}?{% endblock %}

View File

@@ -1,4 +1,4 @@
{% extends 'utilities/confirmation_form.html' %}
{% extends 'generic/confirmation_form.html' %}
{% load form_helpers %}
{% block title %}Delete console server port {{ consoleserverport }}?{% endblock %}

View File

@@ -95,74 +95,74 @@
</a>
</li>
{% with interface_count=object.interfaces_count %}
{% if interface_count %}
{% with tab_name='interfaces' interface_count=object.interfaces_count %}
{% if active_tab == tab_name or interface_count %}
<li role="presentation" class="nav-item">
<a class="nav-link {% if active_tab == 'interfaces' %} active{% endif %}" href="{% url 'dcim:device_interfaces' pk=object.pk %}">Interfaces {% badge interface_count %}</a>
<a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:device_interfaces' pk=object.pk %}">Interfaces {% badge interface_count %}</a>
</li>
{% endif %}
{% endwith %}
{% with frontport_count=object.frontports.count %}
{% if frontport_count %}
{% with tab_name='front-ports' frontport_count=object.frontports.count %}
{% if active_tab == tab_name or frontport_count %}
<li role="presentation" class="nav-item">
<a class="nav-link {% if active_tab == 'front-ports' %} active{% endif %}" href="{% url 'dcim:device_frontports' pk=object.pk %}">Front Ports {% badge frontport_count %}</a>
<a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:device_frontports' pk=object.pk %}">Front Ports {% badge frontport_count %}</a>
</li>
{% endif %}
{% endwith %}
{% with rearport_count=object.rearports.count %}
{% if rearport_count %}
{% with tab_name='rear-ports' rearport_count=object.rearports.count %}
{% if active_tab == tab_name or rearport_count %}
<li role="presentation" class="nav-item">
<a class="nav-link {% if active_tab == 'rear-ports' %} active{% endif %}" href="{% url 'dcim:device_rearports' pk=object.pk %}">Rear Ports {% badge rearport_count %}</a>
<a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:device_rearports' pk=object.pk %}">Rear Ports {% badge rearport_count %}</a>
</li>
{% endif %}
{% endwith %}
{% with consoleport_count=object.consoleports.count %}
{% if consoleport_count %}
{% with tab_name='console-ports' consoleport_count=object.consoleports.count %}
{% if active_tab == tab_name or consoleport_count %}
<li role="presentation" class="nav-item">
<a class="nav-link {% if active_tab == 'console-ports' %} active{% endif %}" href="{% url 'dcim:device_consoleports' pk=object.pk %}">Console Ports {% badge consoleport_count %}</a>
<a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:device_consoleports' pk=object.pk %}">Console Ports {% badge consoleport_count %}</a>
</li>
{% endif %}
{% endwith %}
{% with consoleserverport_count=object.consoleserverports.count %}
{% if consoleserverport_count %}
{% with tab_name='console-server-ports' consoleserverport_count=object.consoleserverports.count %}
{% if active_tab == tab_name or consoleserverport_count %}
<li role="presentation" class="nav-item">
<a class="nav-link {% if active_tab == 'console-server-ports' %} active{% endif %}" href="{% url 'dcim:device_consoleserverports' pk=object.pk %}">Console Server Ports {% badge consoleserverport_count %}</a>
<a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:device_consoleserverports' pk=object.pk %}">Console Server Ports {% badge consoleserverport_count %}</a>
</li>
{% endif %}
{% endwith %}
{% with powerport_count=object.powerports.count %}
{% if powerport_count %}
{% with tab_name='power-ports' powerport_count=object.powerports.count %}
{% if active_tab == tab_name or powerport_count %}
<li role="presentation" class="nav-item">
<a class="nav-link {% if active_tab == 'power-ports' %} active{% endif %}" href="{% url 'dcim:device_powerports' pk=object.pk %}">Power Ports {% badge powerport_count %}</a>
<a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:device_powerports' pk=object.pk %}">Power Ports {% badge powerport_count %}</a>
</li>
{% endif %}
{% endwith %}
{% with poweroutlet_count=object.poweroutlets.count %}
{% if poweroutlet_count %}
{% with tab_name='power-outlets' poweroutlet_count=object.poweroutlets.count %}
{% if active_tab == tab_name or poweroutlet_count %}
<li role="presentation" class="nav-item">
<a class="nav-link {% if active_tab == 'power-outlets' %} active{% endif %}" href="{% url 'dcim:device_poweroutlets' pk=object.pk %}">Power Outlets {% badge poweroutlet_count %}</a>
<a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:device_poweroutlets' pk=object.pk %}">Power Outlets {% badge poweroutlet_count %}</a>
</li>
{% endif %}
{% endwith %}
{% with devicebay_count=object.devicebays.count %}
{% if devicebay_count %}
{% with tab_name='device-bays' devicebay_count=object.devicebays.count %}
{% if active_tab == tab_name or devicebay_count %}
<li role="presentation" class="nav-item">
<a class="nav-link {% if active_tab == 'device-bays' %} active{% endif %}" href="{% url 'dcim:device_devicebays' pk=object.pk %}">Device Bays {% badge devicebay_count %}</a>
<a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:device_devicebays' pk=object.pk %}">Device Bays {% badge devicebay_count %}</a>
</li>
{% endif %}
{% endwith %}
{% with inventoryitem_count=object.inventoryitems.count %}
{% if inventoryitem_count %}
{% with tab_name='inventory-items' inventoryitem_count=object.inventoryitems.count %}
{% if active_tab == tab_name or inventoryitem_count %}
<li role="presentation" class="nav-item">
<a class="nav-link {% if active_tab == 'inventory' %} active{% endif %}" href="{% url 'dcim:device_inventory' pk=object.pk %}">Inventory {% badge inventoryitem_count %}</a>
<a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:device_inventory' pk=object.pk %}">Inventory {% badge inventoryitem_count %}</a>
</li>
{% endif %}
{% endwith %}

View File

@@ -42,5 +42,9 @@
{% endif %}
</div>
</form>
{% table_config_form table %}
{% endblock %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}

View File

@@ -42,5 +42,9 @@
{% endif %}
</div>
</form>
{% table_config_form table %}
{% endblock %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}

View File

@@ -17,27 +17,31 @@
<div class="noprint bulk-buttons">
<div class="bulk-button-group">
{% if perms.dcim.change_devicebay %}
<button type="submit" name="_rename" formaction="{% url 'dcim:devicebay_bulk_rename' %}?return_url={{ object.get_absolute_url }}%23tab_devicebays" class="btn btn-outline-warning btn-sm">
<button type="submit" name="_rename" formaction="{% url 'dcim:devicebay_bulk_rename' %}?return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
</button>
<button type="submit" name="_edit" formaction="{% url 'dcim:devicebay_bulk_edit' %}?device={{ object.pk }}&return_url={{ object.get_absolute_url }}%23tab_devicebays" class="btn btn-warning btn-sm">
<button type="submit" name="_edit" formaction="{% url 'dcim:devicebay_bulk_edit' %}?device={{ object.pk }}&return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-warning btn-sm">
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
</button>
{% endif %}
{% if perms.dcim.delete_devicebay %}
<button type="submit" formaction="{% url 'dcim:devicebay_bulk_delete' %}?return_url={{ object.get_absolute_url }}%23tab_devicebays" class="btn btn-outline-danger btn-sm">
<button type="submit" formaction="{% url 'dcim:devicebay_bulk_delete' %}?return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-outline-danger btn-sm">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete selected
</button>
{% endif %}
</div>
{% if perms.dcim.add_devicebay %}
<div class="bulk-button-group">
<a href="{% url 'dcim:devicebay_add' %}?device={{ object.pk }}&return_url={{ object.get_absolute_url }}%23tab_devicebays" class="btn btn-primary btn-sm">
<a href="{% url 'dcim:devicebay_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add Device Bays
</a>
</div>
{% endif %}
</div>
</form>
{% table_config_form table %}
{% endblock %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}

View File

@@ -42,5 +42,9 @@
{% endif %}
</div>
</form>
{% table_config_form table %}
{% endblock %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}

View File

@@ -77,5 +77,9 @@
{% endif %}
</div>
</form>
{% table_config_form table %}
{% endblock %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}

View File

@@ -39,5 +39,9 @@
{% endif %}
</div>
</form>
{% table_config_form table %}
{% endblock %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}

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