Compare commits

...

144 Commits

Author SHA1 Message Date
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
Jeremy Stretch
b15ecf7649 Merge pull request #8123 from netbox-community/develop
Release v3.1.2
2021-12-20 16:04:41 -05:00
jeremystretch
df4f80e773 Release v3.1.2 2021-12-20 15:48:28 -05:00
jeremystretch
b8b485af4d Changelog & PEP8 cleanup for #7999 2021-12-20 14:17:52 -05:00
Jeremy Stretch
892d6b55ec Merge pull request #8000 from joni1993/more-channels
feat: add 6GHz & 60Ghz channels
2021-12-20 14:16:12 -05:00
Jeremy Stretch
4a3bc8d365 Merge pull request #8111 from bonktree/opaque-icon
templates: add an opaque icon for mobile home screens
2021-12-20 13:58:25 -05:00
jeremystretch
e12da72615 Fixes #8101: Preserve return URL when using "create and add another" button 2021-12-20 13:41:22 -05:00
jeremystretch
f95e510060 Fixes #8102: Raise validation error when attempting to assign an IP address to multiple objects 2021-12-20 13:09:28 -05:00
Daniel Sheppard
82932ae7a5 Fixes #8102 - Add validation around assigned objects 2021-12-20 11:07:44 -06:00
jeremystretch
14fc37a8b8 Closes #7661: Remove forced styling of custom banners 2021-12-19 15:33:48 -05:00
Arseny Maslennikov
7b23856cc8 templates: add an opaque icon for mobile home screens
The netbox_touch-icon-180.png icon was produced by rendering
netbox_icon.svg into a 160x160 square, centered in a 180x180 PNG filled
by the background colour of #212529.

In other words, it is a screenshot of the following HTML element:
```html
  <div style="width: 180px;height: 180px;background-color: #212529;">
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 320" style="padding: 10px;">
    <g fill="#9cc8f8" stroke="#9cc8f8">
      <circle cx="37" cy="284" r="23"></circle>
      <circle cx="101" cy="37" r="23"></circle>
      <circle cx="101" cy="220" r="23"></circle>
      <circle cx="284" cy="220" r="23"></circle>
      <rect x="93" y="37" width="16" height="180"></rect>
      <rect x="101" y="212" width="180" height="16"></rect>
      <rect x="93" y="212" width="16" height="90" transform="rotate(45 101 220)"></rect>
    </g>
    <g fill="#1685fc" stroke="#1685fc">
      <circle cx="284" cy="37" r="23"></circle>
      <circle cx="37" cy="101" r="23"></circle>
      <circle cx="220" cy="101" r="23"></circle>
      <circle cx="220" cy="284" r="23"></circle>
      <rect x="37" y="93" width="180" height="16"></rect>
      <rect x="212" y="101" width="16" height="180"></rect>
      <rect x="212" y="93" width="16" height="90" transform="rotate(225 220 101)"></rect>
    </g>
  </svg>
  </div>
```
2021-12-19 01:32:15 +03:00
jeremystretch
85f9690377 Closes #8083: Removed "related devices" panel from device view 2021-12-18 14:30:28 -05:00
jeremystretch
4723500c5f Fixes #8092: Rack elevations should not include device asset tags 2021-12-18 14:26:32 -05:00
jeremystretch
2db82a73a5 #8096: Include only first assigned IP in FHRPGroup string representation 2021-12-18 14:19:57 -05:00
jeremystretch
b00eeb86ea Fixes #8096: Fix DataError during change logging of objects with very long string representations 2021-12-18 14:16:37 -05:00
jeremystretch
628e186846 Closes #8108: Improve breadcrumb links for device/VM components 2021-12-18 14:02:01 -05:00
jeremystretch
cf4a55bc2f Closes #8107: Correct template name 2021-12-18 13:52:39 -05:00
Christian Jonak
cab07c7c4b fix: non 20Mhz-wide channel centers 2021-12-16 19:28:39 +01:00
jeremystretch
7735a539e9 Fixes #8088: Improve legibility of text in labels with light-colored backgrounds 2021-12-16 12:44:18 -05:00
Christian Jonak
68eb6fc3c1 fix: use center freq instead of beginning of freq range for 6Ghz 2021-12-16 18:14:56 +01:00
jeremystretch
fd785fc9a5 Move speed select dropdown menu to widget template 2021-12-16 08:41:43 -05:00
jeremystretch
8d06908353 Bulk component add view should use tabs 2021-12-15 16:57:30 -05:00
jeremystretch
806706ca1d Refresh development documentation 2021-12-15 16:31:06 -05:00
jeremystretch
044e203eab Standardize button colors 2021-12-15 12:16:50 -05:00
jeremystretch
fcc7207b67 Restore actions column under VM interfaces table 2021-12-15 12:11:20 -05:00
jeremystretch
8dbd3f332b Closes #8081: Allow creating services directly from navigation menu 2021-12-15 11:55:27 -05:00
jeremystretch
f43ec7c05d Add "add IP range" button to prefix IP ranges view 2021-12-15 11:03:38 -05:00
jeremystretch
ff9dde54e3 Ensure consistent placement of table paginator 2021-12-15 10:34:20 -05:00
jeremystretch
3699f16848 Show per-page selector only when results are present 2021-12-15 09:46:59 -05:00
jeremystretch
fee2ac2ebd Changelog for #8057 2021-12-15 09:36:52 -05:00
Jeremy Stretch
57d3bfcfc9 Merge pull request #8073 from netbox-community/8057-htmx-tables
Closes #8057: Dynamic object tables using HTMX
2021-12-15 09:16:41 -05:00
jeremystretch
b92e34556f Fixes #8077: Fix exception when attaching image to location, circuit, or power panel 2021-12-15 08:45:17 -05:00
jeremystretch
b6ff55309e Fixes #8078: Add missing wireless models to lsmodels() in nbshell 2021-12-15 08:38:19 -05:00
jeremystretch
305d88ebda Fixes #8079: Fix validation of LLDP neighbors when connected device has an asset tag 2021-12-15 08:36:03 -05:00
jeremystretch
cdc73d4f56 Closes #8080: Link to NAT IPs for device/VM primary IPs 2021-12-15 08:35:01 -05:00
jeremystretch
0e50c964d5 Remove obsolete pagination TS/CSS 2021-12-14 21:00:48 -05:00
jeremystretch
863fb9aa47 Sync HTMX and non-HTMX paginator styles 2021-12-14 20:53:24 -05:00
jeremystretch
298fb00a3e Remove obsolete "quick find" TS 2021-12-14 20:04:49 -05:00
jeremystretch
d1e8c06d36 Fixes #8074: Ordering VMs by name should reference naturalized value 2021-12-14 17:03:03 -05:00
jeremystretch
8ed79d5973 Remove obsolete templates 2021-12-14 16:44:03 -05:00
jeremystretch
85b10b59e4 Introduce child prefixes view for aggregates 2021-12-14 16:38:25 -05:00
jeremystretch
9a53c22833 Serve HTMX JS locally 2021-12-14 15:55:40 -05:00
jeremystretch
c981b5cba0 Add prep_table_data() method to ObjectChildrenView 2021-12-14 15:42:28 -05:00
jeremystretch
4ffa823ab8 Enable HTMX for all ObjectChildrenViews 2021-12-14 15:31:42 -05:00
Jeremy Stretch
001c7e4b18 Merge pull request #8070 from netbox-community/8069-generic-children-view
Closes #8069: Generic children view
2021-12-14 14:30:41 -05:00
jeremystretch
402136dc8f Merge branch '8069-generic-children-view' into 8057-htmx-tables 2021-12-14 14:21:08 -05:00
jeremystretch
59ee30f056 Update cluster VM/device views to use ObjectChildrenView 2021-12-14 14:08:44 -05:00
jeremystretch
c795068a78 Update VLAN member interface views to use ObjectChildrenView 2021-12-14 14:03:44 -05:00
jeremystretch
5ce080779b Update IPRange IP addresses view to use ObjectChildrenView 2021-12-14 13:55:09 -05:00
jeremystretch
8d3b296eed Update device/VM component views to use ObjectChildrenView 2021-12-14 13:47:40 -05:00
jeremystretch
cfdb985d00 Update prefix children views to use ObjectChildrenView 2021-12-14 13:33:53 -05:00
jeremystretch
af6f0db284 Introduce ObjectChildrenView 2021-12-14 13:33:36 -05:00
jeremystretch
491eac184e Enable HTMX for connections lists 2021-12-14 11:53:16 -05:00
jeremystretch
414d33eb26 Refactor HTMX table template 2021-12-14 11:41:39 -05:00
jeremystretch
6dd6094088 Push HTMX URL to browser location 2021-12-14 08:25:17 -05:00
rizlas
2ec64a2ea2 Get_Environment from napalm should not need any decoding 2021-12-14 10:17:00 +01:00
jeremystretch
5c34a75032 Enable HTMX for quick table search 2021-12-13 20:15:03 -05:00
jeremystretch
91f33d3289 #8057: Enable dynamic tables for object list views 2021-12-13 16:51:59 -05:00
jeremystretch
c50dc1eb35 Standardize usage of table template 2021-12-13 15:36:51 -05:00
jeremystretch
dc1331e736 Fixes #7674: Fix inadvertent application of device type context to virtual machines 2021-12-13 13:42:59 -05:00
jeremystretch
afc866eee4 #7665: Refactored add_requested_prefixes(); removed button icons 2021-12-13 12:15:43 -05:00
jeremystretch
b6d93b7c5b Changelog for #7665 2021-12-13 12:10:03 -05:00
Jeremy Stretch
5d6158dd64 Merge pull request #7826 from WillIrvine/develop
Add filter for optionally including assigned prefixes
2021-12-13 12:04:38 -05:00
jeremystretch
e9549ab0bd PRVB 2021-12-13 09:16:55 -05:00
Christian Jonak-Möchel
cc50e22928 feat: add 6GHz & 60Ghz channels 2021-12-07 15:14:17 +01:00
William Irvine
13414dcd25 pep8 compliance... 2021-12-07 10:13:54 +13:00
William Irvine
aebfccfd4b Merge branch 'develop' into develop 2021-12-07 10:06:35 +13:00
Will Irvine
ca07a88674 fix spelling... 2021-12-02 10:47:19 +13:00
Will Irvine
dcfd332cbf Moved filtering logic to utils, adjusted show buttons 2021-12-01 19:24:44 +13: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
Will Irvine
80048bfa2b Make the same changes for aggregate views as these use the same adjusted functions 2021-11-13 16:42:38 +13:00
Will Irvine
641a9bc6c5 pep8 compliance 2021-11-13 15:26:07 +13:00
Will Irvine
0edf9b17f6 Closes #7665 add new boolen for filtering assigned prefixes, adjust current filter for avaliabile prefixes to only return avaliable 2021-11-13 13:27:49 +13:00
241 changed files with 3549 additions and 3128 deletions

View File

@@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v3.1.1
placeholder: v3.1.4
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.1
placeholder: v3.1.4
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

@@ -6,9 +6,9 @@ Models within each app are stored in either `models.py` or within a submodule un
Each model should define, at a minimum:
* A `Meta` class specifying a deterministic ordering (if ordered by fields other than the primary ID)
* A `__str__()` method returning a user-friendly string representation of the instance
* A `get_absolute_url()` method returning an instance's direct URL (using `reverse()`)
* A `Meta` class specifying a deterministic ordering (if ordered by fields other than the primary ID)
## 2. Define field choices
@@ -16,9 +16,9 @@ If the model has one or more fields with static choices, define those choices in
## 3. Generate database migrations
Once your model definition is complete, generate database migrations by running `manage.py -n $NAME --no-header`. Always specify a short unique name when generating migrations.
Once your model definition is complete, generate database migrations by running `manage.py makemigrations -n $NAME --no-header`. Always specify a short unique name when generating migrations.
!!! info
!!! info "Configuration Required"
Set `DEVELOPER = True` in your NetBox configuration to enable the creation of new migrations.
## 4. Add all standard views
@@ -37,25 +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.
Every model FilterSet should define a `q` filter to support general search queries.
## 7. Create the table
## 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
For NetBox releases prior to v3.0, add the relevant link(s) to the navigation menu template. For later releases, add the relevant items in `netbox/netbox/navigation_menu.py`.
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:
@@ -64,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 (v3.0+)
## 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:
@@ -78,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

@@ -4,16 +4,16 @@ Below is a list of tasks to consider when adding a new field to a core model.
## 1. Generate and run database migrations
Django migrations are used to express changes to the database schema. In most cases, Django can generate these automatically, however very complex changes may require manual intervention. Always remember to specify a short but descriptive name when generating a new migration.
[Django migrations](https://docs.djangoproject.com/en/stable/topics/migrations/) are used to express changes to the database schema. In most cases, Django can generate these automatically, however very complex changes may require manual intervention. Always remember to specify a short but descriptive name when generating a new migration.
```
./manage.py makemigrations <app> -n <name>
./manage.py migrate
```
Where possible, try to merge related changes into a single migration. For example, if three new fields are being added to different models within an app, these can be expressed in the same migration. You can merge a new migration with an existing one by combining their `operations` lists.
Where possible, try to merge related changes into a single migration. For example, if three new fields are being added to different models within an app, these can be expressed in a single migration. You can merge a newly generated migration with an existing one by combining their `operations` lists.
!!! note
!!! warning "Do not alter existing migrations"
Migrations can only be merged within a release. Once a new release has been published, its migrations cannot be altered (other than for the purpose of correcting a bug).
## 2. Add validation logic to `clean()`
@@ -24,7 +24,6 @@ If the new field introduces additional validation requirements (beyond what's in
class Foo(models.Model):
def clean(self):
super().clean()
# Custom validation goes here
@@ -40,9 +39,9 @@ If you're adding a relational field (e.g. `ForeignKey`) and intend to include th
Extend the model's API serializer in `<app>.api.serializers` to include the new field. In most cases, it will not be necessary to also extend the nested serializer, which produces a minimal representation of the model.
## 5. Add field to forms
## 5. Add fields to forms
Extend any forms to include the new field as appropriate. Common forms include:
Extend any forms to include the new field(s) as appropriate. These are found under the `forms/` directory within each app. Common forms include:
* **Credit/edit** - Manipulating a single object
* **Bulk edit** - Performing a change on many objects at once
@@ -51,11 +50,11 @@ Extend any forms to include the new field as appropriate. Common forms include:
## 6. Extend object filter set
If the new field should be filterable, add it to the `FilterSet` for the model. If the field should be searchable, remember to reference it in the FilterSet's `search()` method.
If the new field should be filterable, add it to the `FilterSet` for the model. If the field should be searchable, remember to query it in the FilterSet's `search()` method.
## 7. Add column to object table
If the new field will be included in the object list view, add a column to the model's table. For simple fields, adding the field name to `Meta.fields` will be sufficient. More complex fields may require declaring a custom column.
If the new field will be included in the object list view, add a column to the model's table. For simple fields, adding the field name to `Meta.fields` will be sufficient. More complex fields may require declaring a custom column. Also add the field name to `default_columns` if the column should be present in the table by default.
## 8. Update the UI templates

View File

@@ -35,6 +35,8 @@ The NetBox project utilizes three persistent git branches to track work:
Typically, you'll base pull requests off of the `develop` branch, or off of `feature` if you're working on a new major release. **Never** merge pull requests into the `master` branch, which receives merged only from the `develop` branch.
For example, assume that the current NetBox release is v3.1.1. Work applied to the `develop` branch will appear in v3.1.2, and work done under the `feature` branch will be included in the next minor release (v3.2.0).
### Enable Pre-Commit Hooks
NetBox ships with a [git pre-commit hook](https://githooks.com/) script that automatically checks for style compliance and missing database migrations prior to committing changes. This helps avoid erroneous commits that result in CI test failures. You are encouraged to enable it by creating a link to `scripts/git-hooks/pre-commit`:
@@ -46,7 +48,7 @@ $ ln -s ../../scripts/git-hooks/pre-commit
### Create a Python Virtual Environment
A [virtual environment](https://docs.python.org/3/tutorial/venv.html) is like a container for a set of Python packages. They allow you to build environments suited to specific projects without interfering with system packages or other projects. When installed per the documentation, NetBox uses a virtual environment in production.
A [virtual environment](https://docs.python.org/3/tutorial/venv.html) (or "venv" for short) is like a container for a set of Python packages. These allow you to build environments suited to specific projects without interfering with system packages or other projects. When installed per the documentation, NetBox uses a virtual environment in production.
Create a virtual environment using the `venv` Python module:
@@ -57,8 +59,8 @@ $ python3 -m venv ~/.venv/netbox
This will create a directory named `.venv/netbox/` in your home directory, which houses a virtual copy of the Python executable and its related libraries and tooling. When running NetBox for development, it will be run using the Python binary at `~/.venv/netbox/bin/python`.
!!! info
Keeping virtual environments in `~/.venv/` is a common convention but entirely optional: Virtual environments can be created wherever you please.
!!! info "Where to Create Your Virtual Environments"
Keeping virtual environments in `~/.venv/` is a common convention but entirely optional: Virtual environments can be created almost wherever you please.
Once created, activate the virtual environment:
@@ -94,7 +96,7 @@ Within the `netbox/netbox/` directory, copy `configuration.example.py` to `confi
### Start the Development Server
Django provides a lightweight, auto-updating HTTP/WSGI server for development use. NetBox extends this slightly to automatically import models and other utilities. Run the NetBox development server with the `nbshell` management command:
Django provides a lightweight, auto-updating HTTP/WSGI server for development use. It is started with the `runserver` management command:
```no-highlight
$ python netbox/manage.py runserver
@@ -109,9 +111,12 @@ Quit the server with CONTROL-C.
This ensures that your development environment is now complete and operational. Any changes you make to the code base will be automatically adapted by the development server.
!!! 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.
## Running Tests
Throughout the course of development, it's a good idea to occasionally run NetBox's test suite to catch any potential errors. Tests are run using the `test` management command:
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.
```no-highlight
$ python netbox/manage.py test
@@ -123,9 +128,15 @@ In cases where you haven't made any changes to the database (which is most of th
$ python netbox/manage.py test --keepdb
```
You can also limit the command to running only a specific subset of tests. For example, to run only IPAM and DCIM view tests:
```no-highlight
$ python netbox/manage.py test dcim.tests.test_views ipam.tests.test_views
```
## Submitting Pull Requests
Once you're happy with your work and have verified that all tests pass, commit your changes and push it upstream to your fork. Always provide descriptive (but not excessively verbose) commit messages. When working on a specific issue, be sure to reference it.
Once you're happy with your work and have verified that all tests pass, commit your changes and push it upstream to your fork. Always provide descriptive (but not excessively verbose) commit messages. When working on a specific issue, be sure to prefix your commit message with the word "Fixes" or "Closes" and the issue number (with a hash mark). This tells GitHub to automatically close the referenced issue once the commit has been merged.
```no-highlight
$ git commit -m "Closes #1234: Add IPv5 support"
@@ -136,5 +147,5 @@ Once your fork has the new commit, submit a [pull request](https://github.com/ne
Once submitted, a maintainer will review your pull request and either merge it or request changes. If changes are needed, you can make them via new commits to your fork: The pull request will update automatically.
!!! note
Remember, pull requests are entertained only for **accepted** issues. If an issue you want to work on hasn't been approved by a maintainer yet, it's best to avoid risking your time and effort on a change that might not be accepted.
!!! note "Remember to Open an Issue First"
Remember, pull requests are permitted only for **accepted** issues. If an issue you want to work on hasn't been approved by a maintainer yet, it's best to avoid risking your time and effort on a change that might not be accepted. (The one exception to this is trivial changes to the documentation or other non-critical resources.)

View File

@@ -1,25 +1,25 @@
# NetBox Development
NetBox is maintained as a [GitHub project](https://github.com/netbox-community/netbox) under the Apache 2 license. Users are encouraged to submit GitHub issues for feature requests and bug reports, however we are very selective about pull requests. Please see the `CONTRIBUTING` guide for more direction on contributing to NetBox.
NetBox is maintained as a [GitHub project](https://github.com/netbox-community/netbox) under the Apache 2 license. Users are encouraged to submit GitHub issues for feature requests and bug reports, however we are very selective about pull requests. Each pull request must be preceded by an **approved** issue. Please see the `CONTRIBUTING` guide for more direction on contributing to NetBox.
## Communication
There are several official forums for communication among the developers and community members:
* [GitHub issues](https://github.com/netbox-community/netbox/issues) - All feature requests, bug reports, and other substantial changes to the code base **must** be documented in an issue.
* [GitHub issues](https://github.com/netbox-community/netbox/issues) - All feature requests, bug reports, and other substantial changes to the code base **must** be documented in a GitHub issue.
* [GitHub Discussions](https://github.com/netbox-community/netbox/discussions) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting an issue.
* [#netbox on NetDev Community Slack](https://netdev.chat/) - Good for quick chats. Avoid any discussion that might need to be referenced later on, as the chat history is not retained long.
* [Google Group](https://groups.google.com/g/netbox-discuss) - Legacy mailing list; slowly being phased out in favor of GitHub discussions.
## Governance
NetBox follows the [benevolent dictator](http://oss-watch.ac.uk/resources/benevolentdictatorgovernancemodel) model of governance, with [Jeremy Stretch](https://github.com/jeremystretch) ultimately responsible for all changes to the code base. While community contributions are welcomed and encouraged, the lead maintainer's primary role is to ensure the project's long-term maintainability and continued focus on its primary functions (in other words, avoid scope creep).
NetBox follows the [benevolent dictator](http://oss-watch.ac.uk/resources/benevolentdictatorgovernancemodel) model of governance, with [Jeremy Stretch](https://github.com/jeremystretch) ultimately responsible for all changes to the code base. While community contributions are welcomed and encouraged, the lead maintainer's primary role is to ensure the project's long-term maintainability and continued focus on its primary functions.
## Project Structure
All development of the current NetBox release occurs in the `develop` branch; releases are packaged from the `master` branch. The `master` branch should _always_ represent the current stable release in its entirety, such that installing NetBox by either downloading a packaged release or cloning the `master` branch provides the same code base.
All development of the current NetBox release occurs in the `develop` branch; releases are packaged from the `master` branch. The `master` branch should _always_ represent the current stable release in its entirety, such that installing NetBox by either downloading a packaged release or cloning the `master` branch provides the same code base. Only pull requests representing new releases should be merged into `master`.
NetBox components are arranged into functional subsections called _apps_ (a carryover from Django vernacular). Each app holds the models, views, and templates relevant to a particular function:
NetBox components are arranged into Django apps. Each app holds the models, views, and other resources relevant to a particular function:
* `circuits`: Communications circuits and providers (not to be confused with power circuits)
* `dcim`: Datacenter infrastructure management (sites, racks, and devices)
@@ -29,3 +29,6 @@ NetBox components are arranged into functional subsections called _apps_ (a carr
* `users`: Authentication and user preferences
* `utilities`: Resources which are not user-facing (extendable classes, etc.)
* `virtualization`: Virtual machines and clusters
* `wireless`: Wireless links and LANs
All core functionality is stored within the `netbox/` subdirectory. HTML templates are stored in a common `templates/` directory, with model- and view-specific templates arranged by app. Documentation is kept in the `docs/` root directory.

View File

@@ -17,12 +17,12 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/
* Nesting - These models can be nested recursively to create a hierarchy
| Type | Change Logging | Webhooks | Custom Fields | Export Templates | Tags | Journaling | Nesting |
| ------------------ | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- |
| ------------------ | ---------------- | ---------------- |------------------| ---------------- | ---------------- | ---------------- | ---------------- |
| Primary | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | |
| Organizational | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | | |
| Nested Group | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | | :material-check: |
| Component | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | | |
| Component Template | :material-check: | :material-check: | :material-check: | | | | |
| Component Template | :material-check: | :material-check: | | | | | |
## Models Index
@@ -44,6 +44,7 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/
* [ipam.ASN](../models/ipam/asn.md)
* [ipam.FHRPGroup](../models/ipam/fhrpgroup.md)
* [ipam.IPAddress](../models/ipam/ipaddress.md)
* [ipam.IPRange](../models/ipam/iprange.md)
* [ipam.Prefix](../models/ipam/prefix.md)
* [ipam.RouteTarget](../models/ipam/routetarget.md)
* [ipam.Service](../models/ipam/service.md)

View File

@@ -1,6 +1,6 @@
# Style Guide
NetBox generally follows the [Django style guide](https://docs.djangoproject.com/en/stable/internals/contributing/writing-code/coding-style/), which is itself based on [PEP 8](https://www.python.org/dev/peps/pep-0008/). [Pycodestyle](https://github.com/pycqa/pycodestyle) is used to validate code formatting, ignoring certain violations. See `scripts/cibuild.sh`.
NetBox generally follows the [Django style guide](https://docs.djangoproject.com/en/stable/internals/contributing/writing-code/coding-style/), which is itself based on [PEP 8](https://www.python.org/dev/peps/pep-0008/). [Pycodestyle](https://github.com/pycqa/pycodestyle) is used to validate code formatting, ignoring certain violations. See `scripts/cibuild.sh` for details.
## PEP 8 Exceptions
@@ -30,7 +30,7 @@ pycodestyle --ignore=W504,E501 netbox/
## Introducing New Dependencies
The introduction of a new dependency is best avoided unless it is absolutely necessary. For small features, it's generally preferable to replicate functionality within the NetBox code base rather than to introduce reliance on an external project. This reduces both the burden of tracking new releases and our exposure to outside bugs and attacks.
The introduction of a new dependency is best avoided unless it is absolutely necessary. For small features, it's generally preferable to replicate functionality within the NetBox code base rather than to introduce reliance on an external project. This reduces both the burden of tracking new releases and our exposure to outside bugs and supply chain attacks.
If there's a strong case for introducing a new dependency, it must meet the following criteria:
@@ -43,7 +43,7 @@ When adding a new dependency, a short description of the package and the URL of
## General Guidance
* When in doubt, remain consistent: It is better to be consistently incorrect than inconsistently correct. If you notice in the course of unrelated work a pattern that should be corrected, continue to follow the pattern for now and open a bug so that the entire code base can be evaluated at a later point.
* When in doubt, remain consistent: It is better to be consistently incorrect than inconsistently correct. If you notice in the course of unrelated work a pattern that should be corrected, continue to follow the pattern for now and submit a separate bug report so that the entire code base can be evaluated at a later point.
* Prioritize readability over concision. Python is a very flexible language that typically offers several options for expressing a given piece of logic, but some may be more friendly to the reader than others. (List comprehensions are particularly vulnerable to over-optimization.) Always remain considerate of the future reader who may need to interpret your code without the benefit of the context within which you are writing it.

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,77 @@
# NetBox v3.1
## 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
* [#7661](https://github.com/netbox-community/netbox/issues/7661) - Remove forced styling of custom banners
* [#7665](https://github.com/netbox-community/netbox/issues/7665) - Add toggle to show only available child prefixes
* [#7999](https://github.com/netbox-community/netbox/issues/7999) - Add 6 GHz and 60 GHz wireless channels
* [#8057](https://github.com/netbox-community/netbox/issues/8057) - Dynamic object tables using HTMX
* [#8080](https://github.com/netbox-community/netbox/issues/8080) - Link to NAT IPs for device/VM primary IPs
* [#8081](https://github.com/netbox-community/netbox/issues/8081) - Allow creating services directly from navigation menu
* [#8083](https://github.com/netbox-community/netbox/issues/8083) - Removed "related devices" panel from device view
* [#8108](https://github.com/netbox-community/netbox/issues/8108) - Improve breadcrumb links for device/VM components
### Bug Fixes
* [#7674](https://github.com/netbox-community/netbox/issues/7674) - Fix inadvertent application of device type context to virtual machines
* [#8074](https://github.com/netbox-community/netbox/issues/8074) - Ordering VMs by name should reference naturalized value
* [#8077](https://github.com/netbox-community/netbox/issues/8077) - Fix exception when attaching image to location, circuit, or power panel
* [#8078](https://github.com/netbox-community/netbox/issues/8078) - Add missing wireless models to `lsmodels()` in `nbshell`
* [#8079](https://github.com/netbox-community/netbox/issues/8079) - Fix validation of LLDP neighbors when connected device has an asset tag
* [#8088](https://github.com/netbox-community/netbox/issues/8088) - Improve legibility of text in labels with light-colored backgrounds
* [#8092](https://github.com/netbox-community/netbox/issues/8092) - Rack elevations should not include device asset tags
* [#8096](https://github.com/netbox-community/netbox/issues/8096) - Fix DataError during change logging of objects with very long string representations
* [#8101](https://github.com/netbox-community/netbox/issues/8101) - Preserve return URL when using "create and add another" button
* [#8102](https://github.com/netbox-community/netbox/issues/8102) - Raise validation error when attempting to assign an IP address to multiple objects
---
## v3.1.1 (2021-12-13)
### 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

@@ -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

@@ -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

@@ -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)
@@ -170,14 +159,12 @@ class LocationFilterForm(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(),
@@ -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)
@@ -622,8 +584,7 @@ class CableFilterForm(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(),
@@ -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,7 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
null_option='None',
query_params={
'site_id': '$site_id'
},
fetch_trigger='open'
}
)
type = forms.MultipleChoiceField(
choices=add_blank_choice(CableTypeChoices),
@@ -665,8 +624,7 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
'tenant_id': '$tenant_id',
'rack_id': '$rack_id',
},
label=_('Device'),
fetch_trigger='open'
label=_('Device')
)
tag = TagFilterField(model)
@@ -680,14 +638,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 +652,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 +661,7 @@ class PowerPanelFilterForm(CustomFieldModelFilterForm):
query_params={
'site_id': '$site_id'
},
label=_('Location'),
fetch_trigger='open'
label=_('Location')
)
tag = TagFilterField(model)
@@ -723,14 +677,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 +690,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 +699,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 +708,7 @@ class PowerFeedFilterForm(CustomFieldModelFilterForm):
query_params={
'site_id': '$site_id'
},
label=_('Rack'),
fetch_trigger='open'
label=_('Rack')
)
status = forms.MultipleChoiceField(
choices=PowerFeedStatusChoices,
@@ -990,8 +939,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 +964,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 +972,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 +980,7 @@ class ConsoleConnectionFilterForm(FilterForm):
query_params={
'site_id': '$site_id'
},
label=_('Device'),
fetch_trigger='open'
label=_('Device')
)
@@ -1043,8 +988,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 +996,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 +1004,7 @@ class PowerConnectionFilterForm(FilterForm):
query_params={
'site_id': '$site_id'
},
label=_('Device'),
fetch_trigger='open'
label=_('Device')
)
@@ -1070,8 +1012,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 +1020,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 +1028,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

@@ -18,6 +18,10 @@ __all__ = (
)
def get_device_name(device):
return device.name or str(device.device_type)
class RackElevationSVG:
"""
Use this class to render a rack elevation as an SVG image.
@@ -85,7 +89,7 @@ class RackElevationSVG:
return drawing
def _draw_device_front(self, drawing, device, start, end, text):
name = str(device)
name = get_device_name(device)
if device.devicebay_count:
name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count)
@@ -120,7 +124,7 @@ class RackElevationSVG:
rect = drawing.rect(start, end, class_="slot blocked")
rect.set_desc(self._get_device_description(device))
drawing.add(rect)
drawing.add(drawing.text(str(device), insert=text))
drawing.add(drawing.text(get_device_name(device), insert=text))
# Embed rear device type image if one exists
if self.include_images and device.device_type.rear_image:
@@ -132,9 +136,9 @@ class RackElevationSVG:
)
image.fit(scale='slice')
drawing.add(image)
drawing.add(drawing.text(str(device), insert=text, stroke='black',
drawing.add(drawing.text(get_device_name(device), insert=text, stroke='black',
stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label'))
drawing.add(drawing.text(str(device), insert=text, fill='white', class_='device-image-label'))
drawing.add(drawing.text(get_device_name(device), insert=text, fill='white', class_='device-image-label'))
@staticmethod
def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_, reservation):

View File

@@ -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

@@ -92,7 +92,7 @@ class RackTable(BaseTable):
)
default_columns = (
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',
'get_utilization', 'get_power_utilization',
'get_utilization',
)

View File

@@ -1,7 +1,6 @@
from django.urls import path
from extras.views import ObjectChangeLogView, ObjectJournalView
from ipam.views import ServiceEditView
from utilities.views import SlugRedirectView
from . import views
from .models import *
@@ -233,7 +232,6 @@ urlpatterns = [
path('devices/<int:pk>/status/', views.DeviceStatusView.as_view(), name='device_status'),
path('devices/<int:pk>/lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
path('devices/<int:pk>/config/', views.DeviceConfigView.as_view(), name='device_config'),
path('devices/<int:device>/services/assign/', ServiceEditView.as_view(), name='device_service_assign'),
# Console ports
path('console-ports/', views.ConsolePortListView.as_view(), name='consoleport_list'),

View File

@@ -27,44 +27,38 @@ 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.ObjectView):
class DeviceComponentsView(generic.ObjectChildrenView):
queryset = Device.objects.all()
model = None
table = None
def get_components(self, request, instance):
return self.model.objects.restrict(request.user, 'view').filter(device=instance)
def get_children(self, request, parent):
return self.child_model.objects.restrict(request.user, 'view').filter(device=parent)
def get_extra_context(self, request, instance):
components = self.get_components(request, instance)
table = self.table(data=components, user=request.user)
change_perm = f'{self.model._meta.app_label}.change_{self.model._meta.model_name}'
delete_perm = f'{self.model._meta.app_label}.delete_{self.model._meta.model_name}'
if request.user.has_perm(change_perm) or request.user.has_perm(delete_perm):
table.columns.show('pk')
paginate_table(table, request)
return {
'table': table,
'active_tab': f"{self.model._meta.verbose_name_plural.replace(' ', '-')}",
'active_tab': f"{self.child_model._meta.verbose_name_plural.replace(' ', '-')}",
}
class DeviceTypeComponentsView(DeviceComponentsView):
queryset = DeviceType.objects.all()
template_name = 'dcim/devicetype/component_templates.html'
viewname = None # Used for return_url resolution
def get_components(self, request, instance):
return self.model.objects.restrict(request.user, 'view').filter(device_type=instance)
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):
@@ -806,43 +800,59 @@ class DeviceTypeView(generic.ObjectView):
class DeviceTypeConsolePortsView(DeviceTypeComponentsView):
model = ConsolePortTemplate
child_model = ConsolePortTemplate
table = tables.ConsolePortTemplateTable
filterset = filtersets.ConsolePortTemplateFilterSet
viewname = 'dcim:devicetype_consoleports'
class DeviceTypeConsoleServerPortsView(DeviceTypeComponentsView):
model = ConsoleServerPortTemplate
child_model = ConsoleServerPortTemplate
table = tables.ConsoleServerPortTemplateTable
filterset = filtersets.ConsoleServerPortTemplateFilterSet
viewname = 'dcim:devicetype_consoleserverports'
class DeviceTypePowerPortsView(DeviceTypeComponentsView):
model = PowerPortTemplate
child_model = PowerPortTemplate
table = tables.PowerPortTemplateTable
filterset = filtersets.PowerPortTemplateFilterSet
viewname = 'dcim:devicetype_powerports'
class DeviceTypePowerOutletsView(DeviceTypeComponentsView):
model = PowerOutletTemplate
child_model = PowerOutletTemplate
table = tables.PowerOutletTemplateTable
filterset = filtersets.PowerOutletTemplateFilterSet
viewname = 'dcim:devicetype_poweroutlets'
class DeviceTypeInterfacesView(DeviceTypeComponentsView):
model = InterfaceTemplate
child_model = InterfaceTemplate
table = tables.InterfaceTemplateTable
filterset = filtersets.InterfaceTemplateFilterSet
viewname = 'dcim:devicetype_interfaces'
class DeviceTypeFrontPortsView(DeviceTypeComponentsView):
model = FrontPortTemplate
child_model = FrontPortTemplate
table = tables.FrontPortTemplateTable
filterset = filtersets.FrontPortTemplateFilterSet
viewname = 'dcim:devicetype_frontports'
class DeviceTypeRearPortsView(DeviceTypeComponentsView):
model = RearPortTemplate
child_model = RearPortTemplate
table = tables.RearPortTemplateTable
filterset = filtersets.RearPortTemplateFilterSet
viewname = 'dcim:devicetype_rearports'
class DeviceTypeDeviceBaysView(DeviceTypeComponentsView):
model = DeviceBayTemplate
child_model = DeviceBayTemplate
table = tables.DeviceBayTemplateTable
filterset = filtersets.DeviceBayTemplateFilterSet
viewname = 'dcim:devicetype_devicebays'
class DeviceTypeEditView(generic.ObjectEditView):
@@ -1319,80 +1329,79 @@ class DeviceView(generic.ObjectView):
# Services
services = Service.objects.restrict(request.user, 'view').filter(device=instance)
# Find up to ten devices in the same site with the same functional role for quick reference.
related_devices = Device.objects.restrict(request.user, 'view').filter(
site=instance.site, device_role=instance.device_role
).exclude(
pk=instance.pk
).prefetch_related(
'rack', 'device_type__manufacturer'
)[:10]
return {
'services': services,
'vc_members': vc_members,
'related_devices': related_devices,
'active_tab': 'device',
}
class DeviceConsolePortsView(DeviceComponentsView):
model = ConsolePort
child_model = ConsolePort
table = tables.DeviceConsolePortTable
filterset = filtersets.ConsolePortFilterSet
template_name = 'dcim/device/consoleports.html'
class DeviceConsoleServerPortsView(DeviceComponentsView):
model = ConsoleServerPort
child_model = ConsoleServerPort
table = tables.DeviceConsoleServerPortTable
filterset = filtersets.ConsoleServerPortFilterSet
template_name = 'dcim/device/consoleserverports.html'
class DevicePowerPortsView(DeviceComponentsView):
model = PowerPort
child_model = PowerPort
table = tables.DevicePowerPortTable
filterset = filtersets.PowerPortFilterSet
template_name = 'dcim/device/powerports.html'
class DevicePowerOutletsView(DeviceComponentsView):
model = PowerOutlet
child_model = PowerOutlet
table = tables.DevicePowerOutletTable
filterset = filtersets.PowerOutletFilterSet
template_name = 'dcim/device/poweroutlets.html'
class DeviceInterfacesView(DeviceComponentsView):
model = Interface
child_model = Interface
table = tables.DeviceInterfaceTable
filterset = filtersets.InterfaceFilterSet
template_name = 'dcim/device/interfaces.html'
def get_components(self, request, instance):
return instance.vc_interfaces().restrict(request.user, 'view').prefetch_related(
def get_children(self, request, parent):
return parent.vc_interfaces().restrict(request.user, 'view').prefetch_related(
Prefetch('ip_addresses', queryset=IPAddress.objects.restrict(request.user)),
Prefetch('member_interfaces', queryset=Interface.objects.restrict(request.user))
)
class DeviceFrontPortsView(DeviceComponentsView):
model = FrontPort
child_model = FrontPort
table = tables.DeviceFrontPortTable
filterset = filtersets.FrontPortFilterSet
template_name = 'dcim/device/frontports.html'
class DeviceRearPortsView(DeviceComponentsView):
model = RearPort
child_model = RearPort
table = tables.DeviceRearPortTable
filterset = filtersets.RearPortFilterSet
template_name = 'dcim/device/rearports.html'
class DeviceDeviceBaysView(DeviceComponentsView):
model = DeviceBay
child_model = DeviceBay
table = tables.DeviceDeviceBayTable
filterset = filtersets.DeviceBayFilterSet
template_name = 'dcim/device/devicebays.html'
class DeviceInventoryView(DeviceComponentsView):
model = InventoryItem
child_model = InventoryItem
table = tables.DeviceInventoryItemTable
filterset = filtersets.InventoryItemFilterSet
template_name = 'dcim/device/inventory.html'

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
@@ -170,17 +170,7 @@ class ImageAttachmentSerializer(ValidatedModelSerializer):
@swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_parent(self, obj):
# Static mapping of models to their nested serializers
if isinstance(obj.parent, Device):
serializer = NestedDeviceSerializer
elif isinstance(obj.parent, Rack):
serializer = NestedRackSerializer
elif isinstance(obj.parent, Site):
serializer = NestedSiteSerializer
else:
raise Exception("Unexpected type of parent object for ImageAttachment")
serializer = get_serializer_for_model(obj.parent, prefix='Nested')
return serializer(obj.parent, context={'request': self.context['request']}).data

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

@@ -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

@@ -9,7 +9,7 @@ from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.core.management.base import BaseCommand
APPS = ['circuits', 'dcim', 'extras', 'ipam', 'tenancy', 'users', 'virtualization']
APPS = ('circuits', 'dcim', 'extras', 'ipam', 'tenancy', 'users', 'virtualization', 'wireless')
BANNER_TEXT = """### NetBox interactive shell ({node})
### Python {python} | Django {django} | NetBox {netbox}

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

@@ -96,13 +96,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',

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

@@ -22,7 +22,7 @@ class ConfigContextQuerySet(RestrictedQuerySet):
# Device type assignment is relevant only for Devices
device_type = getattr(obj, 'device_type', None)
# Cluster assignment is relevant only for VirtualMachines
# Get assigned Cluster and ClusterGroup, if any
cluster = getattr(obj, 'cluster', None)
cluster_group = getattr(cluster, 'group', None)
@@ -67,11 +67,8 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
Includes a method which appends an annotation of aggregated config context JSON data objects. This is
implemented as a subquery which performs all the joins necessary to filter relevant config context objects.
This offers a substantial performance gain over ConfigContextQuerySet.get_for_object() when dealing with
multiple objects.
This allows the annotation to be entirely optional.
multiple objects. This allows the annotation to be entirely optional.
"""
def annotate_config_context_data(self):
"""
Attach the subquery annotation to the base queryset
@@ -123,6 +120,7 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
elif self.model._meta.model_name == 'virtualmachine':
base_query.add((Q(roles=OuterRef('role')) | Q(roles=None)), Q.AND)
base_query.add((Q(sites=OuterRef('cluster__site')) | Q(sites=None)), Q.AND)
base_query.add(Q(device_types=None), Q.AND)
region_field = 'cluster__site__region'
sitegroup_field = 'cluster__site__group'

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)

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

@@ -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

@@ -462,12 +462,17 @@ class IPAddressForm(TenancyForm, CustomFieldModelForm):
super().clean()
# Handle object assignment
if self.cleaned_data['interface']:
self.instance.assigned_object = self.cleaned_data['interface']
elif self.cleaned_data['vminterface']:
self.instance.assigned_object = self.cleaned_data['vminterface']
elif self.cleaned_data['fhrpgroup']:
self.instance.assigned_object = self.cleaned_data['fhrpgroup']
selected_objects = [
field for field in ('interface', 'vminterface', 'fhrpgroup') if self.cleaned_data[field]
]
if len(selected_objects) > 1:
raise forms.ValidationError({
selected_objects[1]: "An IP address can only be assigned to a single object."
})
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')
@@ -809,6 +814,14 @@ class VLANForm(TenancyForm, CustomFieldModelForm):
class ServiceForm(CustomFieldModelForm):
device = DynamicModelChoiceField(
queryset=Device.objects.all(),
required=False
)
virtual_machine = DynamicModelChoiceField(
queryset=VirtualMachine.objects.all(),
required=False
)
ports = NumericArrayField(
base_field=forms.IntegerField(
min_value=SERVICE_PORT_MIN,
@@ -816,6 +829,15 @@ class ServiceForm(CustomFieldModelForm):
),
help_text="Comma-separated list of one or more port numbers. A range may be specified using a hyphen."
)
ipaddresses = DynamicModelMultipleChoiceField(
queryset=IPAddress.objects.all(),
required=False,
label='IP Addresses',
query_params={
'device_id': '$device',
'virtual_machine_id': '$virtual_machine',
}
)
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
@@ -824,7 +846,7 @@ class ServiceForm(CustomFieldModelForm):
class Meta:
model = Service
fields = [
'name', 'protocol', 'ports', 'ipaddresses', 'description', 'tags',
'device', 'virtual_machine', 'name', 'protocol', 'ports', 'ipaddresses', 'description', 'tags',
]
help_texts = {
'ipaddresses': "IP address assignment is optional. If no IPs are selected, the service is assumed to be "
@@ -834,18 +856,3 @@ class ServiceForm(CustomFieldModelForm):
'protocol': StaticSelect(),
'ipaddresses': StaticSelectMultiple(),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Limit IP address choices to those assigned to interfaces of the parent device/VM
if self.instance.device:
self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
interface__in=self.instance.device.vc_interfaces().values_list('id', flat=True)
)
elif self.instance.virtual_machine:
self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
vminterface__in=self.instance.virtual_machine.interfaces.values_list('id', flat=True)
)
else:
self.fields['ipaddresses'].choices = []

View File

@@ -58,13 +58,11 @@ class FHRPGroup(PrimaryModel):
def __str__(self):
name = f'{self.get_protocol_display()}: {self.group_id}'
# Append the list of assigned IP addresses to serve as an additional identifier
# Append the first assigned IP addresses (if any) to serve as an additional identifier
if self.pk:
ip_addresses = [
str(ip.address) for ip in self.ip_addresses.all()
]
if ip_addresses:
return f"{name} ({', '.join(ip_addresses)})"
ip_address = self.ip_addresses.first()
if ip_address:
return f"{name} ({ip_address})"
return name

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):
"""
@@ -110,7 +132,7 @@ class ASN(PrimaryModel):
@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.
@@ -195,6 +217,12 @@ class Aggregate(PrimaryModel):
return self.prefix.version
return None
def get_child_prefixes(self):
"""
Return all Prefixes within this Aggregate
"""
return Prefix.objects.filter(prefix__net_contained=str(self.prefix))
def get_utilization(self):
"""
Determine the prefix utilization of the aggregate and return it as a percentage.
@@ -239,7 +267,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
@@ -452,16 +480,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.
@@ -488,15 +506,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

@@ -562,18 +562,7 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
# TODO: Update base class to PrimaryObjectViewTestCase
# Blocked by absence of standard creation view
class ServiceTestCase(
ViewTestCases.GetObjectViewTestCase,
ViewTestCases.GetObjectChangelogViewTestCase,
ViewTestCases.EditObjectViewTestCase,
ViewTestCases.DeleteObjectViewTestCase,
ViewTestCases.ListObjectsViewTestCase,
ViewTestCases.BulkImportObjectsViewTestCase,
ViewTestCases.BulkEditObjectsViewTestCase,
ViewTestCases.BulkDeleteObjectsViewTestCase
):
class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Service
@classmethod

View File

@@ -61,6 +61,7 @@ urlpatterns = [
path('aggregates/edit/', views.AggregateBulkEditView.as_view(), name='aggregate_bulk_edit'),
path('aggregates/delete/', views.AggregateBulkDeleteView.as_view(), name='aggregate_bulk_delete'),
path('aggregates/<int:pk>/', views.AggregateView.as_view(), name='aggregate'),
path('aggregates/<int:pk>/prefixes/', views.AggregatePrefixesView.as_view(), name='aggregate_prefixes'),
path('aggregates/<int:pk>/edit/', views.AggregateEditView.as_view(), name='aggregate_edit'),
path('aggregates/<int:pk>/delete/', views.AggregateDeleteView.as_view(), name='aggregate_delete'),
path('aggregates/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='aggregate_changelog', kwargs={'model': Aggregate}),
@@ -163,6 +164,7 @@ urlpatterns = [
# Services
path('services/', views.ServiceListView.as_view(), name='service_list'),
path('services/add/', views.ServiceEditView.as_view(), name='service_add'),
path('services/import/', views.ServiceBulkImportView.as_view(), name='service_import'),
path('services/edit/', views.ServiceBulkEditView.as_view(), name='service_bulk_edit'),
path('services/delete/', views.ServiceBulkDeleteView.as_view(), name='service_bulk_delete'),

View File

@@ -4,20 +4,34 @@ from .constants import *
from .models import Prefix, VLAN
def add_available_prefixes(parent, prefix_list):
def add_requested_prefixes(parent, prefix_list, show_available=True, show_assigned=True):
"""
Create fake Prefix objects for all unallocated space within a prefix.
Return a list of requested prefixes using show_available, show_assigned filters. If available prefixes are
requested, create fake Prefix objects for all unallocated space within a prefix.
:param parent: Parent Prefix instance
:param prefix_list: Child prefixes list
:param show_available: Include available prefixes.
:param show_assigned: Show assigned prefixes.
"""
child_prefixes = []
# Find all unallocated space
available_prefixes = netaddr.IPSet(parent) ^ netaddr.IPSet([p.prefix for p in prefix_list])
available_prefixes = [Prefix(prefix=p, status=None) for p in available_prefixes.iter_cidrs()]
# Add available prefixes to the table if requested
if prefix_list and show_available:
# Concatenate and sort complete list of children
prefix_list = list(prefix_list) + available_prefixes
prefix_list.sort(key=lambda p: p.prefix)
# Find all unallocated space, add fake Prefix objects to child_prefixes.
available_prefixes = netaddr.IPSet(parent) ^ netaddr.IPSet([p.prefix for p in prefix_list])
available_prefixes = [Prefix(prefix=p, status=None) for p in available_prefixes.iter_cidrs()]
child_prefixes = child_prefixes + available_prefixes
return prefix_list
# Add assigned prefixes to the table if requested
if prefix_list and show_assigned:
child_prefixes = child_prefixes + list(prefix_list)
# Sort child prefixes after additions
child_prefixes.sort(key=lambda p: p.prefix)
return child_prefixes
def add_available_ipaddresses(prefix, ipaddress_list, is_pool=False):

View File

@@ -1,21 +1,22 @@
from django.contrib.contenttypes.models import ContentType
from django.db.models import Prefetch
from django.db.models.expressions import RawSQL
from django.http import Http404
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from dcim.models import Device, Interface, Site
from dcim.filtersets import InterfaceFilterSet
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.models import VirtualMachine, VMInterface
from virtualization.filtersets import VMInterfaceFilterSet
from virtualization.models import VMInterface
from . import filtersets, forms, tables
from .constants import *
from .models import *
from .models import ASN
from .utils import add_available_ipaddresses, add_available_prefixes, add_available_vlans
from .utils import add_requested_prefixes, add_available_ipaddresses, add_available_vlans
#
@@ -274,37 +275,33 @@ class AggregateListView(generic.ObjectListView):
class AggregateView(generic.ObjectView):
queryset = Aggregate.objects.all()
class AggregatePrefixesView(generic.ObjectChildrenView):
queryset = Aggregate.objects.all()
child_model = Prefix
table = tables.PrefixTable
filterset = filtersets.PrefixFilterSet
template_name = 'ipam/aggregate/prefixes.html'
def get_children(self, request, parent):
return Prefix.objects.restrict(request.user, 'view').filter(
prefix__net_contained_or_equal=str(parent.prefix)
).prefetch_related('site', 'role', 'tenant', 'vlan')
def prep_table_data(self, request, queryset, parent):
# Determine whether to show assigned prefixes, available prefixes, or both
show_available = bool(request.GET.get('show_available', 'true') == 'true')
show_assigned = bool(request.GET.get('show_assigned', 'true') == 'true')
return add_requested_prefixes(parent.prefix, queryset, show_available, show_assigned)
def get_extra_context(self, request, instance):
# Find all child prefixes contained by this aggregate
child_prefixes = Prefix.objects.restrict(request.user, 'view').filter(
prefix__net_contained_or_equal=str(instance.prefix)
).prefetch_related(
'site', 'role'
).order_by(
'prefix'
)
# Add available prefixes to the table if requested
if request.GET.get('show_available', 'true') == 'true':
child_prefixes = add_available_prefixes(instance.prefix, child_prefixes)
prefix_table = tables.PrefixTable(child_prefixes, exclude=('utilization',))
if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
prefix_table.columns.show('pk')
paginate_table(prefix_table, request)
# Compile permissions list for rendering the object table
permissions = {
'add': request.user.has_perm('ipam.add_prefix'),
'change': request.user.has_perm('ipam.change_prefix'),
'delete': request.user.has_perm('ipam.delete_prefix'),
}
return {
'prefix_table': prefix_table,
'permissions': permissions,
'bulk_querystring': f'within={instance.prefix}',
'show_available': request.GET.get('show_available', 'true') == 'true',
'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'),
}
@@ -422,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),
@@ -451,104 +448,79 @@ class PrefixView(generic.ObjectView):
}
class PrefixPrefixesView(generic.ObjectView):
class PrefixPrefixesView(generic.ObjectChildrenView):
queryset = Prefix.objects.all()
child_model = Prefix
table = tables.PrefixTable
filterset = filtersets.PrefixFilterSet
template_name = 'ipam/prefix/prefixes.html'
def get_extra_context(self, request, instance):
# Child prefixes table
child_prefixes = instance.get_child_prefixes().restrict(request.user, 'view').prefetch_related(
'site', 'vlan', 'role',
def get_children(self, request, parent):
return parent.get_child_prefixes().restrict(request.user, 'view').prefetch_related(
'site', 'vrf', 'vlan', 'role', 'tenant',
)
# Add available prefixes to the table if requested
if child_prefixes and request.GET.get('show_available', 'true') == 'true':
child_prefixes = add_available_prefixes(instance.prefix, child_prefixes)
def prep_table_data(self, request, queryset, parent):
# Determine whether to show assigned prefixes, available prefixes, or both
show_available = bool(request.GET.get('show_available', 'true') == 'true')
show_assigned = bool(request.GET.get('show_assigned', 'true') == 'true')
table = tables.PrefixTable(child_prefixes, user=request.user, exclude=('utilization',))
if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
table.columns.show('pk')
paginate_table(table, request)
bulk_querystring = 'vrf_id={}&within={}'.format(instance.vrf.pk if instance.vrf else '0', instance.prefix)
# Compile permissions list for rendering the object table
permissions = {
'change': request.user.has_perm('ipam.change_prefix'),
'delete': request.user.has_perm('ipam.delete_prefix'),
}
return add_requested_prefixes(parent.prefix, queryset, show_available, show_assigned)
def get_extra_context(self, request, instance):
return {
'table': table,
'permissions': permissions,
'bulk_querystring': bulk_querystring,
'bulk_querystring': f"vrf_id={instance.vrf.pk if instance.vrf else '0'}&within={instance.prefix}",
'active_tab': 'prefixes',
'first_available_prefix': instance.get_first_available_prefix(),
'show_available': request.GET.get('show_available', 'true') == 'true',
'show_available': bool(request.GET.get('show_available', 'true') == 'true'),
'show_assigned': bool(request.GET.get('show_assigned', 'true') == 'true'),
}
class PrefixIPRangesView(generic.ObjectView):
class PrefixIPRangesView(generic.ObjectChildrenView):
queryset = Prefix.objects.all()
child_model = IPRange
table = tables.IPRangeTable
filterset = filtersets.IPRangeFilterSet
template_name = 'ipam/prefix/ip_ranges.html'
def get_children(self, request, parent):
return parent.get_child_ranges().restrict(request.user, 'view').prefetch_related(
'vrf', 'role', 'tenant',
)
def get_extra_context(self, request, instance):
# Find all IPRanges belonging to this Prefix
ip_ranges = instance.get_child_ranges().restrict(request.user, 'view').prefetch_related('vrf')
table = tables.IPRangeTable(ip_ranges, user=request.user)
if request.user.has_perm('ipam.change_iprange') or request.user.has_perm('ipam.delete_iprange'):
table.columns.show('pk')
paginate_table(table, request)
bulk_querystring = 'vrf_id={}&parent={}'.format(instance.vrf.pk if instance.vrf else '0', instance.prefix)
# Compile permissions list for rendering the object table
permissions = {
'change': request.user.has_perm('ipam.change_iprange'),
'delete': request.user.has_perm('ipam.delete_iprange'),
}
return {
'table': table,
'permissions': permissions,
'bulk_querystring': bulk_querystring,
'bulk_querystring': f"vrf_id={instance.vrf.pk if instance.vrf else '0'}&parent={instance.prefix}",
'active_tab': 'ip-ranges',
'first_available_ip': instance.get_first_available_ip(),
}
class PrefixIPAddressesView(generic.ObjectView):
class PrefixIPAddressesView(generic.ObjectChildrenView):
queryset = Prefix.objects.all()
child_model = IPAddress
table = tables.IPAddressTable
filterset = filtersets.IPAddressFilterSet
template_name = 'ipam/prefix/ip_addresses.html'
def get_children(self, request, parent):
return parent.get_child_ips().restrict(request.user, 'view').prefetch_related(
'vrf', 'role', '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):
# Find all IPAddresses belonging to this Prefix
ipaddresses = instance.get_child_ips().restrict(request.user, 'view').prefetch_related('vrf')
# Add available IP addresses to the table if requested
if request.GET.get('show_available', 'true') == 'true':
ipaddresses = add_available_ipaddresses(instance.prefix, ipaddresses, instance.is_pool)
table = tables.IPAddressTable(ipaddresses, user=request.user)
if request.user.has_perm('ipam.change_ipaddress') or request.user.has_perm('ipam.delete_ipaddress'):
table.columns.show('pk')
paginate_table(table, request)
bulk_querystring = 'vrf_id={}&parent={}'.format(instance.vrf.pk if instance.vrf else '0', instance.prefix)
# Compile permissions list for rendering the object table
permissions = {
'change': request.user.has_perm('ipam.change_ipaddress'),
'delete': request.user.has_perm('ipam.delete_ipaddress'),
}
return {
'table': table,
'permissions': permissions,
'bulk_querystring': bulk_querystring,
'bulk_querystring': f"vrf_id={instance.vrf.pk if instance.vrf else '0'}&parent={instance.prefix}",
'active_tab': 'ip-addresses',
'first_available_ip': instance.get_first_available_ip(),
'show_available': request.GET.get('show_available', 'true') == 'true',
}
@@ -596,35 +568,21 @@ class IPRangeView(generic.ObjectView):
queryset = IPRange.objects.all()
class IPRangeIPAddressesView(generic.ObjectView):
class IPRangeIPAddressesView(generic.ObjectChildrenView):
queryset = IPRange.objects.all()
child_model = IPAddress
table = tables.IPAddressTable
filterset = filtersets.IPAddressFilterSet
template_name = 'ipam/iprange/ip_addresses.html'
def get_children(self, request, parent):
return parent.get_child_ips().restrict(request.user, 'view').prefetch_related(
'vrf', 'role', 'tenant',
)
def get_extra_context(self, request, instance):
# Find all IPAddresses within this range
ipaddresses = instance.get_child_ips().restrict(request.user, 'view').prefetch_related('vrf')
# Add available IP addresses to the table if requested
# if request.GET.get('show_available', 'true') == 'true':
# ipaddresses = add_available_ipaddresses(instance.prefix, ipaddresses, instance.is_pool)
ip_table = tables.IPAddressTable(ipaddresses)
if request.user.has_perm('ipam.change_ipaddress') or request.user.has_perm('ipam.delete_ipaddress'):
ip_table.columns.show('pk')
paginate_table(ip_table, request)
# Compile permissions list for rendering the object table
permissions = {
'add': request.user.has_perm('ipam.add_ipaddress'),
'change': request.user.has_perm('ipam.change_ipaddress'),
'delete': request.user.has_perm('ipam.delete_ipaddress'),
}
return {
'ip_table': ip_table,
'permissions': permissions,
'active_tab': 'ip-addresses',
'show_available': request.GET.get('show_available', 'true') == 'true',
}
@@ -1012,32 +970,34 @@ class VLANView(generic.ObjectView):
}
class VLANInterfacesView(generic.ObjectView):
class VLANInterfacesView(generic.ObjectChildrenView):
queryset = VLAN.objects.all()
child_model = Interface
table = tables.VLANDevicesTable
filterset = InterfaceFilterSet
template_name = 'ipam/vlan/interfaces.html'
def get_extra_context(self, request, instance):
interfaces = instance.get_interfaces().prefetch_related('device')
members_table = tables.VLANDevicesTable(interfaces)
paginate_table(members_table, request)
def get_children(self, request, parent):
return parent.get_interfaces().restrict(request.user, 'view')
def get_extra_context(self, request, instance):
return {
'members_table': members_table,
'active_tab': 'interfaces',
}
class VLANVMInterfacesView(generic.ObjectView):
class VLANVMInterfacesView(generic.ObjectChildrenView):
queryset = VLAN.objects.all()
child_model = VMInterface
table = tables.VLANVirtualMachinesTable
filterset = VMInterfaceFilterSet
template_name = 'ipam/vlan/vminterfaces.html'
def get_extra_context(self, request, instance):
interfaces = instance.get_vminterfaces().prefetch_related('virtual_machine')
members_table = tables.VLANVirtualMachinesTable(interfaces)
paginate_table(members_table, request)
def get_children(self, request, parent):
return parent.get_vminterfaces().restrict(request.user, 'view')
def get_extra_context(self, request, instance):
return {
'members_table': members_table,
'active_tab': 'vminterfaces',
}
@@ -1092,19 +1052,6 @@ class ServiceEditView(generic.ObjectEditView):
model_form = forms.ServiceForm
template_name = 'ipam/service_edit.html'
def alter_obj(self, obj, request, url_args, url_kwargs):
if 'device' in url_kwargs:
obj.device = get_object_or_404(
Device.objects.restrict(request.user),
pk=url_kwargs['device']
)
elif 'virtualmachine' in url_kwargs:
obj.virtual_machine = get_object_or_404(
VirtualMachine.objects.restrict(request.user),
pk=url_kwargs['virtualmachine']
)
return obj
class ServiceBulkImportView(generic.BulkImportView):
queryset = Service.objects.all()

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

@@ -62,7 +62,7 @@ class ChangeLoggingMixin(models.Model):
objectchange = ObjectChange(
changed_object=self,
related_object=related_object,
object_repr=str(self),
object_repr=str(self)[:200],
action=action
)
if hasattr(self, '_prechange_snapshot'):

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',
@@ -260,7 +260,7 @@ IPAM_MENU = Menu(
label='Other',
items=(
get_model_item('ipam', 'fhrpgroup', 'FHRP Groups'),
get_model_item('ipam', 'service', 'Services', actions=['import']),
get_model_item('ipam', 'service', 'Services'),
),
),
),

View File

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

View File

@@ -23,6 +23,7 @@ from utilities.exceptions import AbortTransaction, PermissionsViolation
from utilities.forms import (
BootstrapMixin, BulkRenameForm, ConfirmationForm, CSVDataField, CSVFileField, ImportForm, restrict_form_fields,
)
from utilities.htmx import is_htmx
from utilities.permissions import get_permission_for_model
from utilities.tables import paginate_table
from utilities.utils import normalize_querydict, prepare_cloned_fields
@@ -72,6 +73,75 @@ class ObjectView(ObjectPermissionRequiredMixin, View):
})
class ObjectChildrenView(ObjectView):
"""
Display a table of child objects associated with the parent object.
queryset: The base queryset for retrieving the *parent* object
table: Table class used to render child objects list
template_name: Name of the template to use
"""
queryset = None
child_model = None
table = None
filterset = None
template_name = None
def get_children(self, request, parent):
"""
Return a QuerySet of child objects.
request: The current request
parent: The parent object
"""
raise NotImplementedError(f'{self.__class__.__name__} must implement get_children()')
def prep_table_data(self, request, queryset, parent):
"""
Provides a hook for subclassed views to modify data before initializing the table.
:param request: The current request
:param queryset: The filtered queryset of child objects
:param parent: The parent object
"""
return queryset
def get(self, request, *args, **kwargs):
"""
GET handler for rendering child objects.
"""
instance = get_object_or_404(self.queryset, **kwargs)
child_objects = self.get_children(request, instance)
if self.filterset:
child_objects = self.filterset(request.GET, child_objects).qs
permissions = {}
for action in ('change', 'delete'):
perm_name = get_permission_for_model(self.child_model, action)
permissions[action] = request.user.has_perm(perm_name)
table = self.table(self.prep_table_data(request, child_objects, instance), user=request.user)
# Determine whether to display bulk action checkboxes
if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']):
table.columns.show('pk')
paginate_table(table, request)
# If this is an HTMX request, return only the rendered table HTML
if is_htmx(request):
return render(request, 'htmx/table.html', {
'object': instance,
'table': table,
})
return render(request, self.get_template_name(), {
'object': instance,
'table': table,
'permissions': permissions,
**self.get_extra_context(request, instance),
})
class ObjectListView(ObjectPermissionRequiredMixin, View):
"""
List a series of objects.
@@ -185,6 +255,12 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
table = self.get_table(request, permissions)
paginate_table(table, request)
# If this is an HTMX request, return only the rendered table HTML
if is_htmx(request):
return render(request, 'htmx/table.html', {
'table': table,
})
context = {
'content_type': content_type,
'table': table,
@@ -295,8 +371,11 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
redirect_url = request.path
# If the object has clone_fields, pre-populate a new instance of the form
if hasattr(obj, 'clone_fields'):
redirect_url += f"?{prepare_cloned_fields(obj)}"
params = prepare_cloned_fields(obj)
if 'return_url' in request.GET:
params['return_url'] = request.GET.get('return_url')
if params:
redirect_url += f"?{params.urlencode()}"
return redirect(redirect_url)
@@ -1229,7 +1308,7 @@ class BulkComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin,
if not selected_objects:
messages.warning(request, "No {} were selected.".format(self.parent_model._meta.verbose_name_plural))
return redirect(self.get_return_url(request))
table = self.table(selected_objects)
table = self.table(selected_objects, orderable=False)
if '_create' in request.POST:
form = self.form(request.POST)

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -30,6 +30,7 @@
"cookie": "^0.4.1",
"dayjs": "^1.10.4",
"flatpickr": "4.6.3",
"htmx.org": "^1.6.1",
"just-debounce-it": "^1.4.0",
"masonry-layout": "^4.2.2",
"query-string": "^6.14.1",

View File

@@ -1,7 +1,6 @@
import { initConnectionToggle } from './connectionToggle';
import { initDepthToggle } from './depthToggle';
import { initMoveButtons } from './moveOptions';
import { initPerPage } from './pagination';
import { initPreferenceUpdate } from './preferences';
import { initReslug } from './reslug';
import { initSelectAll } from './selectAll';
@@ -13,7 +12,6 @@ export function initButtons(): void {
initReslug,
initSelectAll,
initPreferenceUpdate,
initPerPage,
initMoveButtons,
]) {
func();

View File

@@ -1,14 +0,0 @@
import { getElements } from '../util';
function handlePerPageSelect(event: Event): void {
const select = event.currentTarget as HTMLSelectElement;
if (select.form !== null) {
select.form.submit();
}
}
export function initPerPage(): void {
for (const element of getElements<HTMLSelectElement>('select.per-page')) {
element.addEventListener('change', handlePerPageSelect);
}
}

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,4 +1,5 @@
import '@popperjs/core';
import 'bootstrap';
import 'htmx.org';
import 'simplebar';
import './netbox';

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

@@ -1,5 +1,4 @@
import debounce from 'just-debounce-it';
import { getElements, getRowValues, findFirstAdjacent, isTruthy } from './util';
import { getElements, findFirstAdjacent, isTruthy } from './util';
/**
* Change the display value and hidden input values of the search filter based on dropdown
@@ -41,109 +40,8 @@ function initSearchBar(): void {
}
}
/**
* Initialize Interface Table Filter Elements.
*/
function initInterfaceFilter(): void {
for (const input of getElements<HTMLInputElement>('input.interface-filter')) {
const table = findFirstAdjacent<HTMLTableElement>(input, 'table');
const rows = Array.from(
table?.querySelectorAll<HTMLTableRowElement>('tbody > tr') ?? [],
).filter(r => r !== null);
/**
* Filter on-page table by input text.
*/
function handleInput(event: Event): void {
const target = event.target as HTMLInputElement;
// Create a regex pattern from the input search text to match against.
const filter = new RegExp(target.value.toLowerCase().trim());
// Each row represents an interface and its attributes.
for (const row of rows) {
// Find the row's checkbox and deselect it, so that it is not accidentally included in form
// submissions.
const checkBox = row.querySelector<HTMLInputElement>('input[type="checkbox"][name="pk"]');
if (checkBox !== null) {
checkBox.checked = false;
}
// The data-name attribute's value contains the interface name.
const name = row.getAttribute('data-name');
if (typeof name === 'string') {
if (filter.test(name.toLowerCase().trim())) {
// If this row matches the search pattern, but is already hidden, unhide it.
if (row.classList.contains('d-none')) {
row.classList.remove('d-none');
}
} else {
// If this row doesn't match the search pattern, hide it.
row.classList.add('d-none');
}
}
}
}
input.addEventListener('keyup', debounce(handleInput, 300));
}
}
function initTableFilter(): void {
for (const input of getElements<HTMLInputElement>('input.object-filter')) {
// Find the first adjacent table element.
const table = findFirstAdjacent<HTMLTableElement>(input, 'table');
// Build a valid array of <tr/> elements that are children of the adjacent table.
const rows = Array.from(
table?.querySelectorAll<HTMLTableRowElement>('tbody > tr') ?? [],
).filter(r => r !== null);
/**
* Filter table rows by matched input text.
* @param event
*/
function handleInput(event: Event): void {
const target = event.target as HTMLInputElement;
// Create a regex pattern from the input search text to match against.
const filter = new RegExp(target.value.toLowerCase().trim());
// List of which rows which match the query
const matchedRows: Array<HTMLTableRowElement> = [];
for (const row of rows) {
// Find the row's checkbox and deselect it, so that it is not accidentally included in form
// submissions.
const checkBox = row.querySelector<HTMLInputElement>('input[type="checkbox"][name="pk"]');
if (checkBox !== null) {
checkBox.checked = false;
}
// Iterate through each row's cell values
for (const value of getRowValues(row)) {
if (filter.test(value.toLowerCase())) {
// If this row matches the search pattern, add it to the list.
matchedRows.push(row);
break;
}
}
}
// Iterate the rows again to set visibility.
// This results in a single reflow instead of one for each row.
for (const row of rows) {
if (matchedRows.indexOf(row) >= 0) {
row.classList.remove('d-none');
} else {
row.classList.add('d-none');
}
}
}
input.addEventListener('keyup', debounce(handleInput, 300));
}
}
export function initSearch(): void {
for (const func of [initSearchBar, initTableFilter, initInterfaceFilter]) {
for (const func of [initSearchBar]) {
func();
}
}

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

@@ -737,10 +737,6 @@ nav.breadcrumb-container {
}
}
div.paginator > form > div.input-group {
width: fit-content;
}
label.required {
font-weight: $font-weight-bold;
@@ -900,14 +896,6 @@ div.card-overlay {
}
}
// Right-align the paginator element.
.paginator {
display: flex;
flex-direction: column;
align-items: flex-end;
padding: $spacer 0;
}
// Tabbed content
.nav-tabs {
.nav-link {
@@ -977,6 +965,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

@@ -8,6 +8,7 @@ $theme-colors: map-merge(
$theme-colors,
(
'primary': #337ab7,
'info': #54d6f0,
'red': $red-500,
'yellow': $yellow-500,
'green': $green-500,

View File

@@ -1688,6 +1688,11 @@ hosted-git-info@^2.1.4, hosted-git-info@^2.8.9:
resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9"
integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==
htmx.org@^1.6.1:
version "1.6.1"
resolved "https://registry.yarnpkg.com/htmx.org/-/htmx.org-1.6.1.tgz#6f0d59a93fa61cbaa15316c134a2f179045a5778"
integrity sha512-i+1k5ee2eFWaZbomjckyrDjUpa3FMDZWufatUSBmmsjXVksn89nsXvr1KLGIdAajiz+ZSL7TE4U/QaZVd2U2sA==
ignore@^4.0.6:
version "4.0.6"
resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc"

View File

@@ -104,31 +104,32 @@
{# 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' %}" />
<link rel="apple-touch-icon" type="image/png" href="{% static 'netbox_touch-icon-180.png' %}" />
{# 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 border-bottom noprint">
{# Mobile Navigation #}
<div class="nav-mobile">
@@ -59,7 +58,7 @@
</nav>
{% if config.BANNER_TOP %}
<div class="alert alert-info text-center mx-3" role="alert">
<div class="text-center mx-3">
{{ config.BANNER_TOP|safe }}
</div>
{% endif %}
@@ -99,7 +98,7 @@
</div>
{% if config.BANNER_BOTTOM %}
<div class="alert alert-info text-center mx-3" role="alert">
<div class="text-center mx-3">
{{ config.BANNER_BOTTOM|safe }}
</div>
{% endif %}

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

@@ -1,6 +1,15 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load render_table from django_tables2 %}
{% block extra_controls %}
{% if perms.circuits.add_circuit %}
<a href="{% url 'circuits:circuit_add' %}?type={{ object.pk }}" class="btn btn-sm btn-primary">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add Circuit
</a>
{% endif %}
{% endblock extra_controls %}
{% block content %}
<div class="row mb-3">
@@ -39,22 +48,13 @@
<div class="row mb-3">
<div class="col col-md-12">
<div class="card">
<h5 class="card-header">
Circuits
</h5>
<div class="card-body">
{% include 'inc/table.html' with table=circuits_table %}
<h5 class="card-header">Circuits</h5>
<div class="card-body table-responsive">
{% render_table circuits_table 'inc/table.html' %}
{% include 'inc/paginator.html' with paginator=circuits_table.paginator page=circuits_table.page %}
</div>
{% if perms.circuits.add_circuit %}
<div class="card-footer text-end noprint">
<a href="{% url 'circuits:circuit_add' %}?type={{ object.pk }}" class="btn btn-sm btn-primary">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add Circuit
</a>
</div>
{% endif %}
</div>
{% include 'inc/paginator.html' with paginator=circuits_table.paginator page=circuits_table.page %}
{% plugin_full_width_page object %}
</div>
{% plugin_full_width_page object %}
</div>
</div>
{% 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

@@ -2,9 +2,18 @@
{% load static %}
{% load helpers %}
{% load plugins %}
{% load render_table from django_tables2 %}
{% block extra_controls %}
{% if perms.circuits.add_circuit %}
<a href="{% url 'circuits:circuit_add' %}?provider={{ object.pk }}" class="btn btn-sm btn-primary">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add circuit
</a>
{% endif %}
{% endblock extra_controls %}
{% block content %}
<div class="row">
<div class="row mb-3">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">
@@ -32,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>
@@ -56,28 +65,17 @@
{% include 'inc/panels/contacts.html' %}
{% plugin_right_page object %}
</div>
<div class="col col-md-12">
<div class="card">
<h5 class="card-header">
Circuits
</h5>
<div class="card-body">
{% include 'inc/table.html' with table=circuits_table %}
</div>
{% if perms.circuits.add_circuit %}
<div class="card-footer text-end noprint">
<a href="{% url 'circuits:circuit_add' %}?provider={{ object.pk }}" class="btn btn-sm btn-primary">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add circuit
</a>
</div>
{% endif %}
</div>
{% include 'inc/paginator.html' with paginator=circuits_table.paginator page=circuits_table.page %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
<div class="row mb-3">
<div class="col col-md-12">
<div class="card">
<h5 class="card-header">Circuits</h5>
<div class="card-body table-responsive">
{% render_table circuits_table 'inc/table.html' %}
{% include 'inc/paginator.html' with paginator=circuits_table.paginator page=circuits_table.page %}
</div>
</div>
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}

View File

@@ -2,6 +2,7 @@
{% load static %}
{% load helpers %}
{% load plugins %}
{% load render_table from django_tables2 %}
{% block breadcrumbs %}
{{ block.super }}
@@ -9,7 +10,7 @@
{% endblock %}
{% block content %}
<div class="row">
<div class="row mb-3">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">
@@ -43,22 +44,16 @@
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
<div class="card">
<h5 class="card-header">
Circuits
</h5>
<div class="card-body">
{% include 'inc/table.html' with table=circuits_table %}
</div>
</div>
<div class="row mb-3">
<div class="col col-md-12">
<div class="card">
<h5 class="card-header">Circuits</h5>
<div class="card-body table-responsive">
{% render_table circuits_table 'inc/table.html' %}
{% include 'inc/paginator.html' with paginator=circuits_table.paginator page=circuits_table.page %}
</div>
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}

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

@@ -8,19 +8,14 @@
{% block content-wrapper %}
<div class="tab-content">
{# Conncetions list #}
{# Connections list #}
<div class="tab-pane show active" id="object-list" role="tabpanel" aria-labelledby="object-list-tab">
{% include 'inc/table_controls.html' %}
{% include 'inc/table_controls_htmx.html' %}
<div class="card">
<div class="card-body">
<div class="table-responsive">
{% render_table table 'inc/table.html' %}
</div>
<div class="card-body" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
</div>
{# Filter form #}

View File

@@ -1,7 +1,14 @@
{% extends 'dcim/device_component.html' %}
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% block breadcrumbs %}
{{ block.super }}
<li class="breadcrumb-item">
<a href="{% url 'dcim:device_consoleports' pk=object.device.pk %}">{{ object.device }}</a>
</li>
{% endblock %}
{% block content %}
<div class="row">
<div class="col col-md-6">

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,7 +1,14 @@
{% extends 'dcim/device_component.html' %}
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% block breadcrumbs %}
{{ block.super }}
<li class="breadcrumb-item">
<a href="{% url 'dcim:device_consoleserverports' pk=object.device.pk %}">{{ object.device }}</a>
</li>
{% endblock %}
{% block content %}
<div class="row">
<div class="col col-md-6">

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

@@ -148,6 +148,12 @@
</div>
</div>
{% endif %}
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/comments.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">
Management
@@ -179,31 +185,31 @@
<tr>
<th scope="row">Primary IPv4</th>
<td>
{% if object.primary_ip4 %}
<a href="{% url 'ipam:ipaddress' pk=object.primary_ip4.pk %}">{{ object.primary_ip4.address.ip }}</a>
{% if object.primary_ip4.nat_inside %}
<span>(NAT for {{ object.primary_ip4.nat_inside.address.ip }})</span>
{% elif object.primary_ip4.nat_outside %}
<span>(NAT: {{ object.primary_ip4.nat_outside.address.ip }})</span>
{% endif %}
{% else %}
<span class="text-muted">&mdash;</span>
{% if object.primary_ip4 %}
<a href="{% url 'ipam:ipaddress' pk=object.primary_ip4.pk %}">{{ object.primary_ip4.address.ip }}</a>
{% if object.primary_ip4.nat_inside %}
(NAT for <a href="{{ object.primary_ip4.nat_inside.get_absolute_url }})">{{ object.primary_ip4.nat_inside.address.ip }}</a>)
{% elif object.primary_ip4.nat_outside %}
(NAT: <a href="{{ object.primary_ip4.nat_outside.get_absolute_url }}">{{ object.primary_ip4.nat_outside.address.ip }}</a>)
{% endif %}
{% else %}
<span class="text-muted">&mdash;</span>
{% endif %}
</td>
</tr>
<tr>
<th scope="row">Primary IPv6</th>
<td>
{% if object.primary_ip6 %}
<a href="{% url 'ipam:ipaddress' pk=object.primary_ip6.pk %}">{{ object.primary_ip6.address.ip }}</a>
{% if object.primary_ip6.nat_inside %}
<span>(NAT for {{ object.primary_ip6.nat_inside.address.ip }})</span>
{% elif object.primary_ip6.nat_outside %}
<span>(NAT: {{ object.primary_ip6.nat_outside.address.ip }})</span>
{% endif %}
{% else %}
<span class="text-muted">&mdash;</span>
{% if object.primary_ip6 %}
<a href="{% url 'ipam:ipaddress' pk=object.primary_ip6.pk %}">{{ object.primary_ip6.address.ip }}</a>
{% if object.primary_ip6.nat_inside %}
(NAT for <a href="{{ object.primary_ip6.nat_inside.get_absolute_url }}">{{ object.primary_ip6.nat_inside.address.ip }}</a>)
{% elif object.primary_ip6.nat_outside %}
(NAT: <a href="{{ object.primary_ip6.nat_outside.get_absolute_url }}">{{ object.primary_ip6.nat_outside.address.ip }}</a>)
{% endif %}
{% else %}
<span class="text-muted">&mdash;</span>
{% endif %}
</td>
</tr>
{% if object.cluster %}
@@ -220,12 +226,6 @@
</table>
</div>
</div>
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/comments.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
{% if object.powerports.exists and object.poweroutlets.exists %}
<div class="card">
<h5 class="card-header">
@@ -290,7 +290,7 @@
</div>
{% if perms.ipam.add_service %}
<div class="card-footer text-end noprint">
<a href="{% url 'dcim:device_service_assign' device=object.pk %}" class="btn btn-sm btn-primary">
<a href="{% url 'ipam:service_add' %}?device={{ object.pk }}" class="btn btn-sm btn-primary">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> Assign Service
</a>
</div>
@@ -298,39 +298,6 @@
</div>
{% include 'inc/panels/contacts.html' %}
{% include 'inc/panels/image_attachments.html' %}
<div class="card noprint">
<h5 class="card-header">
Related Devices
</h5>
<div class="card-body">
{% if related_devices %}
<table class="table table-hover">
<tr>
<th>Device</th>
<th>Rack</th>
<th>Type</th>
</tr>
{% for rd in related_devices %}
<tr>
<td>
<a href="{% url 'dcim:device' pk=rd.pk %}">{{ rd }}</a>
</td>
<td>
{% if rd.rack %}
<a href="{% url 'dcim:rack' pk=rd.rack.pk %}">{{ rd.rack }}</a>
{% else %}
<span class="text-muted">&mdash;</span>
{% endif %}
</td>
<td>{{ rd.device_type }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<div class="text-muted">None</div>
{% endif %}
</div>
</div>
{% plugin_right_page object %}
</div>
</div>

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

@@ -6,8 +6,14 @@
{% block content %}
<form method="post">
{% csrf_token %}
{% include 'inc/table_controls.html' with table_modal="DeviceConsolePortTable_config" %}
{% render_table table 'inc/table.html' %}
{% include 'inc/table_controls_htmx.html' with table_modal="DeviceConsolePortTable_config" %}
<div class="card">
<div class="card-body" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>
<div class="noprint bulk-buttons">
<div class="bulk-button-group">
{% if perms.dcim.change_consoleport %}
@@ -36,6 +42,5 @@
{% endif %}
</div>
</form>
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
{% table_config_form table %}
{% endblock %}

View File

@@ -6,8 +6,14 @@
{% block content %}
<form method="post">
{% csrf_token %}
{% include 'inc/table_controls.html' with table_modal="DeviceConsoleServerPortTable_config" %}
{% render_table table 'inc/table.html' %}
{% include 'inc/table_controls_htmx.html' with table_modal="DeviceConsoleServerPortTable_config" %}
<div class="card">
<div class="card-body" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>
<div class="noprint bulk-buttons">
<div class="bulk-button-group">
{% if perms.dcim.change_consoleserverport %}
@@ -36,6 +42,5 @@
{% endif %}
</div>
</form>
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
{% table_config_form table %}
{% endblock %}

View File

@@ -6,33 +6,38 @@
{% block content %}
<form method="post">
{% csrf_token %}
{% include 'inc/table_controls.html' with table_modal="DeviceDeviceBayTable_config" %}
{% render_table table 'inc/table.html' %}
{% include 'inc/table_controls_htmx.html' with table_modal="DeviceDeviceBayTable_config" %}
<div class="card">
<div class="card-body" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>
<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>
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
{% table_config_form table %}
{% endblock %}

View File

@@ -6,8 +6,14 @@
{% block content %}
<form method="post">
{% csrf_token %}
{% include 'inc/table_controls.html' with table_modal="DeviceFrontPortTable_config" %}
{% render_table table 'inc/table.html' %}
{% include 'inc/table_controls_htmx.html' with table_modal="DeviceFrontPortTable_config" %}
<div class="card">
<div class="card-body" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>
<div class="noprint bulk-buttons">
<div class="bulk-button-group">
{% if perms.dcim.change_frontport %}
@@ -36,6 +42,5 @@
{% endif %}
</div>
</form>
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
{% table_config_form table %}
{% endblock %}

View File

@@ -9,7 +9,15 @@
<div class="row mb-3 justify-content-between">
<div class="col col-12 col-lg-4 my-3 my-lg-0 d-flex noprint table-controls">
<div class="input-group input-group-sm">
<input type="text" class="form-control interface-filter" placeholder="Filter" title="Filter text (regular expressions supported)" />
<input
type="text"
name="q"
class="form-control"
placeholder="Quick search"
hx-get="{{ request.full_path }}"
hx-target="#object_list"
hx-trigger="keyup changed delay:500ms"
/>
</div>
</div>
<div class="col col-md-3 mb-0 d-flex noprint table-controls">
@@ -34,7 +42,13 @@
</div>
</div>
</div>
{% render_table table 'inc/table.html' %}
<div class="card">
<div class="card-body" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>
<div class="noprint bulk-buttons">
<div class="bulk-button-group">
{% if perms.dcim.change_interface %}
@@ -63,6 +77,5 @@
{% endif %}
</div>
</form>
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
{% table_config_form table %}
{% endblock %}

View File

@@ -6,8 +6,14 @@
{% block content %}
<form method="post">
{% csrf_token %}
{% include 'inc/table_controls.html' with table_modal="DeviceInventoryItemTable_config" %}
{% render_table table 'inc/table.html' %}
{% include 'inc/table_controls_htmx.html' with table_modal="DeviceInventoryItemTable_config" %}
<div class="card">
<div class="card-body" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>
<div class="noprint bulk-buttons">
<div class="bulk-button-group">
{% if perms.dcim.change_inventoryitem %}
@@ -33,6 +39,5 @@
{% endif %}
</div>
</form>
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
{% table_config_form table %}
{% endblock %}

View File

@@ -31,12 +31,12 @@
<tbody>
{% for iface in interfaces %}
<tr id="{{ iface.name }}">
<td class="font-monospace">{{ iface }}</td>
<td>{{ iface }}</td>
{% if iface.connected_endpoint.device %}
<td class="configured_device" data="{{ iface.connected_endpoint.device }}" data-chassis="{{ iface.connected_endpoint.device.virtual_chassis.name }}">
<td class="configured_device" data="{{ iface.connected_endpoint.device.name }}" data-chassis="{{ iface.connected_endpoint.device.virtual_chassis.name }}">
<a href="{% url 'dcim:device' pk=iface.connected_endpoint.device.pk %}">{{ iface.connected_endpoint.device }}</a>
</td>
<td class="configured_interface" data="{{ iface.connected_endpoint }}">
<td class="configured_interface" data="{{ iface.connected_endpoint.name }}">
<span title="{{ iface.connected_endpoint.get_type_display }}">{{ iface.connected_endpoint }}</span>
</td>
{% elif iface.connected_endpoint.circuit %}

View File

@@ -6,8 +6,14 @@
{% block content %}
<form method="post">
{% csrf_token %}
{% include 'inc/table_controls.html' with table_modal="DevicePowerOutletTable_config" %}
{% render_table table 'inc/table.html' %}
{% include 'inc/table_controls_htmx.html' with table_modal="DevicePowerOutletTable_config" %}
<div class="card">
<div class="card-body" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>
<div class="noprint bulk-buttons">
<div class="bulk-button-group">
{% if perms.dcim.change_powerport %}
@@ -36,6 +42,5 @@
{% endif %}
</div>
</form>
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
{% table_config_form table %}
{% endblock %}

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