Compare commits

..

91 Commits

Author SHA1 Message Date
Jeremy Stretch
c9b79ca579 Merge pull request #12681 from netbox-community/develop
Release v3.5.2
2023-05-22 16:54:03 -04:00
jeremystretch
fbc7811f56 Release v3.5.2 2023-05-22 16:24:30 -04:00
Austin de Coup-Crank
005e3fd692 Closes #9068: validate addresses assigned to interfaces (#12618)
* Begin logic

* Closes #9068: Disallow assigning bcast/networks to interfaces

* Allow net IDs in /31, /32, /127, /128

* linting error

* Implement requested changes

* Condensed the "if" logic a bit

---------

Co-authored-by: Austin de Coup-Crank <decoupca@moodys.com>
Co-authored-by: jeremystretch <jstretch@netboxlabs.com>
2023-05-22 16:16:17 -04:00
Dillon Henschen
078893e034 Closes #11619: Include VLANs with a null site in query during bulk interface edit for Devices > DEVICE COMPONENTS > Interfaces (#12659)
* Closes #11619: Allow VLANs without a site during multi-port edits

This commit allows users to be able to select VLANs without a site assignment
during bulk interfaces edits under Devices > DEVICE COMPONENTS > Interfaces.

Prior to this commit, only VLANs that were assigned the same site as the device
were available for selection.

* Replace 'null' with FILTERS_NULL_CHOICE_VALUE constant

---------

Co-authored-by: jeremystretch <jstretch@netboxlabs.com>
2023-05-22 15:37:31 -04:00
Daniel W. Anner
80fc8db514 Adding interface type 200gbase-x-qsfpdd 2023-05-22 13:11:29 -04:00
jeremystretch
fa3bedb947 Fixes #12642: Fix bulk tenant assignment via cluster import form 2023-05-22 13:07:40 -04:00
jeremystretch
c8d9a3b4eb Changelog for #12327, #12548, #12594, #12605, #12629 2023-05-18 14:34:13 -04:00
Austin de Coup-Crank
311dce0b5f Closes #12605: Add LX.5 port type 2023-05-18 14:25:51 -04:00
neope
23b21246f0 Adding CDFP and CFP8 400GE connectors (#12646)
* Adding CDFP and CFP8 400GE connectors

* Update choices.py

typo on CFP8
2023-05-18 14:21:32 -04:00
Arthur
92c49669f9 12548 add prefetch_related for l2vpn and vdcs to interface api 2023-05-18 14:18:55 -04:00
Abhimanyu Saharan
2204735e9f Adds rq retry options (#12588)
* adds rq retry options #12327

* Clean up docs; disable retries of failed jobs by default

* Pass a Retry object only if RQ_RETRY_MAX is non-zero

---------

Co-authored-by: jeremystretch <jstretch@netboxlabs.com>
2023-05-16 14:10:44 -04:00
Abhimanyu Saharan
0df6a5793a Adds maintenance exempt paths (#12592)
* adds maintenance exempt paths #11233

* adds maintenance exempt paths #11233

* Rename method & remove login/logout from exempt paths

---------

Co-authored-by: jeremystretch <jstretch@netboxlabs.com>
2023-05-16 11:35:21 -04:00
Arthur
eeb15ab5d1 12594 add config context to object count / list widget 2023-05-16 10:52:23 -04:00
jeremystretch
d5be59ef67 Update README 2023-05-16 08:39:05 -04:00
kkthxbye
0ad88e2431 Changed docs to reflect the new URL for the dynamic API documentation 2023-05-16 08:19:57 +02:00
jeremystretch
c65b2a080f Changelog for #11017, #12468 2023-05-15 09:13:11 -04:00
Devon Mar
0f44f7eb20 Use .font-monospace instead of .text-monospace 2023-05-15 08:28:59 -04:00
Austin de Coup-Crank
e40e9cb406 Closes #11017: increase maximum power draw (#12587)
* Convert power draw/max draw to PositiveIntegerField

* Closes #11017: Increase maximum power draw

* Rename migration file for clarity

---------

Co-authored-by: jeremystretch <jstretch@netboxlabs.com>
2023-05-12 16:10:12 -04:00
Arthur Hanson
21f4761335 12468 disallow double underscores in custom field names (#12523)
* 12468 disallow double underscores in custom field names

* 12468 disallow double underscores in custom field names

* 12468 review changes

* 12468 correct migration

* 12468 use inverse match

* 12468 use inverse match

* Add test for invalid custom field names

---------

Co-authored-by: jeremystretch <jstretch@netboxlabs.com>
2023-05-12 16:08:57 -04:00
jeremystretch
39fd64b2ef Fixes #12570: Disable ordering of synchronized object tables by the synced attribute 2023-05-12 11:08:32 -04:00
jeremystretch
567285d36a Changelog for #7671, #10686, #11233, #11559, #12554 2023-05-12 11:00:33 -04:00
jeremystretch
ff874a24dd #7671: Document REMOTE_AUTH_AUTO_CREATE_GROUPS config parameter 2023-05-12 10:56:36 -04:00
Abhimanyu Saharan
9b80ec22ba Adds db read-only middleware (#12490)
* adds db read-only middleware #11233

* fixed attribute error

* replaces getattr with get_config
2023-05-12 10:50:51 -04:00
Jon Schewe
cc0c985fec Feature/remote group autocreate (#12394)
* Add REMOTE_AUTH_AUTOCREATE_GROUPS

When REMOTE_AUTH_AUTOCREATE_GROUPS is True, Netbox will create groups
referenced in the REMOTE_AUTH_GROUP_HEADER that don't exist in the
database.

Closes #7671

* Fix naming of parameter

Apply the fix requested by kkthxbye-code in https://github.com/netbox-community/netbox/pull/8603

---------

Co-authored-by: Lars Kellogg-Stedman <lars@oddbit.com>
2023-05-12 10:35:09 -04:00
Abhimanyu Saharan
4eb5e90ccc Adds contact tabs (#12460)
* adds contact tabs #11599

* fixed lint issues

* changes as per review

* changes as per review

* replaces generic object template with base template
2023-05-12 10:26:26 -04:00
Abhimanyu Saharan
e71a98499f Adds BANNER_MAINTENANCE config (#12555)
* adds BANNER_MAINTENANCE config #12554

* changes as per review

* lint fix

* Fix admin form field widget

---------

Co-authored-by: jeremystretch <jstretch@netboxlabs.com>
2023-05-12 09:54:59 -04:00
Daniel Sheppard
011a936a56 Fixes #10686 - Import cables using VC master device (#12551)
* Allow importing cables against master device for subordinate device interfaces

* Add tests
2023-05-12 09:07:51 -04:00
Abhimanyu Saharan
556beeee6c Updates doc for reports and scripts permission (#12565)
* updates doc for script permission #12557

* updates doc for reports permission #12557
2023-05-12 09:04:56 -04:00
jeremystretch
b7f028fba3 Fixes #12550: Fix rear port selection widget under front port creation form 2023-05-10 10:44:01 -04:00
jeremystretch
2d0ac213c7 Changelog for #11670, #11900, #12131, #12233, #12286, #12323 2023-05-10 09:39:25 -04:00
Abhimanyu Saharan
6b19f15a7b Moves related ips to a tab (#12502)
* moves related ips to a tab #12233

* Refactor IP address templates to use a base template

---------

Co-authored-by: jeremystretch <jstretch@netboxlabs.com>
2023-05-09 12:49:13 -04:00
Abhimanyu Saharan
57156f0e94 Adds stroke to the reservation (#12506)
* adds stroke to the reservation #11900

* fixed right side border

* Tweak reserved stroke style & add constants for colors

---------

Co-authored-by: jeremystretch <jstretch@netboxlabs.com>
2023-05-09 10:21:23 -04:00
Abhimanyu Saharan
4e49f4a434 Adds tooltip on custom field (#12505)
* adds tooltip on custom field #12131

* adds description field check

* fixed field name

* updated code to match the panel

* added escape filter on description
2023-05-09 10:20:02 -04:00
Dillon Henschen
c55c14ea4c Closes #11670: Add ability to optionally import DeviceType and ModuleType weight (#12512)
* 11670: Add optional weight to DeviceType import

This is 1 of 2 commits to address issue #11670

To maintain consistency, the import design of the DeviceType weight follows the
same pattern used for importing weight and weight units in DCIM Racks.

* Closes #11670: Add weight to ModuleType import

This is commit 2 of 2 to address and close #11670.

To maintain consistency, the import design of the ModuleType weight follows the
same pattern used for importing weight and weight units in DCIM Racks.

* Merge tests; misc cleanup

---------

Co-authored-by: jeremystretch <jstretch@netboxlabs.com>
2023-05-09 09:59:42 -04:00
Abhimanyu Saharan
e1b7a3aeb6 Replaced device type weight with device total weight (#12522)
* replaced device type weight with device total weight #12286

* replaced device type weight with device total weight #12286

* Update netbox/templates/dcim/device.html

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-05-09 09:35:00 -04:00
Abhimanyu Saharan
2b2c559a37 updates ldap doc for centos #12447 2023-05-09 08:32:22 -04:00
Abhimanyu Saharan
1af3ba9496 Adds full_clean in examples (#12527)
* adds full_clean in examples #11689

* removes extra info
2023-05-09 08:31:50 -04:00
Abhimanyu Saharan
cb6852bf7a adds CXP (100GE) #12323 2023-05-09 08:28:58 -04:00
Abhimanyu Saharan
259d0e96f2 Adds dimensions card to device view (#12509)
* adds dimensions card to device view #12286

* Update netbox/templates/dcim/device.html

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-05-08 08:56:28 -04:00
jeremystretch
9eeca06115 #12498: Annotate option to set MAPS_URL=None 2023-05-05 15:26:22 -04:00
jeremystretch
da781b8d28 Changelog for #12223, #12498 2023-05-05 15:25:08 -04:00
Abhimanyu Saharan
896b19eaa3 adds parent device and bay position to table #12223 2023-05-05 15:23:28 -04:00
Abhimanyu Saharan
12bef7623c disables map button when MAP_URL is none #12498 2023-05-05 15:04:36 -04:00
jeremystretch
e96cfadd22 PRVB 2023-05-05 12:58:09 -04:00
Jeremy Stretch
5f184f2435 Merge pull request #12507 from netbox-community/develop
Release v3.5.1
2023-05-05 12:50:30 -04:00
jeremystretch
56a4d0333e Formatting fix 2023-05-05 12:31:12 -04:00
jeremystretch
6794742213 Release v3.5.1 2023-05-05 12:29:10 -04:00
jeremystretch
a29a07ed26 Fixes #12463: Fix the association of completed jobs with reports & scripts in the REST API 2023-05-05 10:17:13 -04:00
jeremystretch
42c80f69e6 Changelog for #11715, #11801, #11932, #12122, #12245, #12416, #12476, #12483, #12496 2023-05-05 10:01:54 -04:00
Abhimanyu Saharan
ca0e7be637 Adds bulk import for journal entry (#12485)
* adds bulk import for journal entry #12122

* lint fix

* Add kind as CSVChoiceField on JournalEntryImportForm

---------

Co-authored-by: jeremystretch <jstretch@netboxlabs.com>
2023-05-05 09:57:09 -04:00
Abhimanyu Saharan
42346702a1 Adds image attachment list view (#12487)
* adds image attachment list view #11932

* fixed typo

* Update netbox/extras/tables/tables.py

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>

* Update netbox/extras/forms/filtersets.py

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>

* changes as per review

* Disable ordering by size (not stored in database)

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-05-05 09:48:13 -04:00
Arthur Hanson
9909213c0d 12416 warning for missing script file (#12456)
* 12416 warning for missing script file

* 12416 widen exception catching for internal script error

* Update netbox/extras/models/scripts.py

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>

* 12416 update from review feedback

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-05-05 09:37:38 -04:00
Daniel Sheppard
7a38f601de Fixes: #11715 - Fix Parent Prefix table display (#12448)
* Fixes: #11715 - Fix Parent Prefix table display of global vrf prefixes that are **not** containers.

* Combine AND into a single Q object

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-05-05 09:35:05 -04:00
Abhimanyu Saharan
abdcfdecf5 Adds description to elevation device tooltip (#12488)
* adds description to elevation device tooltip #11801

* changes as per review

* changes as per review

* Rearrange attrs, add headings, and update docstring

---------

Co-authored-by: jeremystretch <jstretch@netboxlabs.com>
2023-05-05 09:09:21 -04:00
Luke Anderson
9d62174e1e Fix #12245 - Add WLAN to InterfaceBulkEditForm 2023-05-05 08:41:40 -04:00
Patrick Hurrelmann
a96b76a8d1 Fixes #12496: Remove account field from provider template 2023-05-05 08:27:29 -04:00
Tobias Genannt
ab69faab87 Fix #12483: Using mutable dulwich configuration 2023-05-04 16:56:45 -04:00
Arthur
f3826e6235 fix runscript command 2023-05-04 16:18:44 -04:00
jeremystretch
3eba65b5c2 Changelog for #10757, #11652, #11791, #11190, #12363, #12464 2023-05-04 14:14:21 -04:00
Arthur Hanson
683ef30af4 12363 update paragraph spacing on journal table (#12435)
* 12363 update paragraph spacing on journal table

* 12363 make css rule generic

* 12363 change p tag to only effect last-child

* 12363 change p table spacing to .5em

* 12363 move comment
2023-05-04 14:05:32 -04:00
Daniel Sheppard
46914d9479 Fixes: #10757 - Change IP interface assignment to use new selector (#12452)
* Fixes: #10757 - Change interface assignment to use new selector.  Perform the same change to the NAT assignment as well.

* Remove nat_vrf from form and remove query_params that are not required anymore
2023-05-04 13:59:28 -04:00
Abhimanyu Saharan
ea8a0135ad Adds module status to module bay table (#12455)
* adds module status to module bay table #11652

* removed the extra line

* updated field name
2023-05-04 13:46:31 -04:00
Abhimanyu Saharan
25142e037a Adds housekeeping systemd files (#12457)
* adds housekeeping systemd files #11190

* Clean up Markdown

---------

Co-authored-by: jeremystretch <jstretch@netboxlabs.com>
2023-05-04 13:43:40 -04:00
Abhimanyu Saharan
93b912c2da Adds ENGINE to database config (#12458)
* adds ENGINE to database config #11791

* fixed lint issues

* updated doc
2023-05-04 13:36:52 -04:00
Tobias Genannt
4df517e4da Fix #12464: Only set username, password for HTTP/s (#12482) 2023-05-04 13:34:04 -04:00
jeremystretch
2c756873aa Fix broken links 2023-05-04 08:43:17 -04:00
jeremystretch
01fa6e28cd Changelog for #10759, #11422, #11504, #12433 2023-05-03 09:32:48 -04:00
Arthur Hanson
5036020dc0 12433 update object list widget to correctly parameterize urls (#12434)
* 12433 update object list widget to correctly parameterize urls

* Update netbox/extras/dashboard/widgets.py

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>

* 12433 fix indent

* Correct per_page query parameter

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-05-03 09:20:53 -04:00
Arthur
78ec3a6411 11504 add rack filter to elevation 2023-05-03 08:40:43 -04:00
Arthur
24650d9118 11422 add power-panel-name to quick search of power feeds 2023-05-03 08:36:12 -04:00
Austin de Coup-Crank
b14a514b47 Closes 10759: Enable markdown support for custom field descriptions (#12344)
* enable markdown in custom field descriptions

* Closes #10759: Enable markdown support for custom field descriptions

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-05-02 16:47:22 -04:00
jeremystretch
7aa8434575 Changelog for #12367, #12410, #12436 2023-05-02 16:40:56 -04:00
Arthur
fbcf4c268b 12436 remove add button from contactassignment list view 2023-05-02 16:35:23 -04:00
Abhimanyu Saharan
a566a56a64 fixes RelatedObjectDoesNotExist #12367 2023-05-02 09:48:16 -04:00
jeremystretch
cc3b95bdb0 Fixes #12400: Validate default values for object and multi-object custom fields 2023-05-02 09:35:25 -04:00
jeremystretch
2099cd0fdc #12415: Bump django-rq to v2.8.0 2023-05-02 09:35:25 -04:00
Jop Zinkweg
3bdbf67b8f Update examples to reflect obj rename to object
#8684 missed some references to ```obj```
2023-05-02 08:31:33 -04:00
jeremystretch
20f0464824 Fix OpanAPI schema base path 2023-05-01 17:01:51 -04:00
jeremystretch
1952707702 Fixes #12401: Support the creation of front ports without a pre-populated device ID 2023-05-01 16:51:10 -04:00
jeremystretch
9319cffb1c Fixes #12384: Add a three-second timeout for RSS reader widget 2023-05-01 16:19:39 -04:00
jeremystretch
261f5e4995 Fixes #12415: Pin rq to v1.13.0 to fix ImportError exception from django-rq 2023-05-01 14:38:51 -04:00
jeremystretch
8cede0daf8 Changelog for #12380, #12395, #12396, #12405, #12412 2023-05-01 14:19:56 -04:00
jeremystretch
139ef7ef4c Fixes #12395: Fix "create & add another" action for objects with custom fields 2023-05-01 14:17:50 -04:00
jeremystretch
47f3023401 Fixes #12412: Device/VM interface MAC addresses can be nullified via REST API 2023-05-01 13:06:10 -04:00
Jeremy Stretch
a0f0b29432 Merge pull request #12382 from kkthxbye-code/12380-widget-fix-missing-ct
Fixes #12380 - Add ObjectChange as a valid option for ObjectListWidget and ObjectCountsWidget
2023-05-01 10:17:11 -04:00
Jeremy Stretch
f86f4f9257 Merge pull request #12406 from kkthxbye-code/12405-fix-related-vlangroup
Fixes #12405 - Fix the filtervar being set to site_id instead of site in SiteView related_models
2023-05-01 10:14:29 -04:00
Jeremy Stretch
3fc7c0edc7 Merge pull request #12407 from kkthxbye-code/12396-fix-provideraccount
Fixes #12396 - Mark provider_account as not required in API and bulk import
2023-05-01 10:12:10 -04:00
kkthxbye-code
528fb21a7e Mark provider_account as not required in API and bulk import 2023-05-01 07:44:15 +02:00
kkthxbye-code
6206d226ae Fix the filtervar being set to site_id instead of site in SiteView related_models 2023-05-01 07:15:00 +02:00
kkthxbye-code
aabaeec1d7 Add ObjectChange as a valid option for ObjectListWidget and ObjectCountsWidget 2023-04-28 12:17:39 +02:00
jeremystretch
25dc7e234d PRVB 2023-04-27 14:52:07 -04:00
110 changed files with 1183 additions and 531 deletions

View File

@@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v3.5.0
placeholder: v3.5.2
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.5.0
placeholder: v3.5.2
validations:
required: true
- type: dropdown

View File

@@ -1,11 +1,13 @@
<div align="center">
<strong>The :ballot_box_with_check: <a href="https://forms.gle/zUHrrPo7K34yKaqC9">2023 NetBox Community Survey</a> is now open!</strong>
<p>Please take a few minutes to tell us about your NetBox deployment.</p>
<img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
The premiere source of truth powering network automation
<p>The premiere source of truth powering network automation</p>
<img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master" alt="CI status" />
<p></p>
</div>
![Master branch build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master)
NetBox is the leading solution for modeling and documenting modern networks. By
combining the traditional disciplines of IP address management (IPAM) and
datacenter infrastructure management (DCIM) with powerful APIs and extensions,

View File

@@ -0,0 +1,17 @@
[Unit]
Description=NetBox Housekeeping Service
Documentation=https://docs.netbox.dev/
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=netbox
Group=netbox
WorkingDirectory=/opt/netbox
ExecStart=/opt/netbox/venv/bin/python /opt/netbox/netbox/manage.py housekeeping
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,13 @@
[Unit]
Description=NetBox Housekeeping Timer
Documentation=https://docs.netbox.dev/
After=network-online.target
Wants=network-online.target
[Timer]
OnCalendar=daily
AccuracySec=1h
Persistent=true
[Install]
WantedBy=multi-user.target

View File

@@ -7,7 +7,13 @@ NetBox includes a `housekeeping` management command that should be run nightly.
* Deleting job result records older than the configured [retention time](../configuration/miscellaneous.md#job_retention)
* Check for new NetBox releases (if [`RELEASE_CHECK_URL`](../configuration/miscellaneous.md#release_check_url) is set)
This command can be invoked directly, or by using the shell script provided at `/opt/netbox/contrib/netbox-housekeeping.sh`. This script can be linked from your cron scheduler's daily jobs directory (e.g. `/etc/cron.daily`) or referenced directly within the cron configuration file.
This command can be invoked directly, or by using the shell script provided at `/opt/netbox/contrib/netbox-housekeeping.sh`.
## Scheduling
### Using Cron
This script can be linked from your cron scheduler's daily jobs directory (e.g. `/etc/cron.daily`) or referenced directly within the cron configuration file.
```shell
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
@@ -16,4 +22,28 @@ sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-hou
!!! note
On Debian-based systems, be sure to omit the `.sh` file extension when linking to the script from within a cron directory. Otherwise, the task may not run.
The `housekeeping` command can also be run manually at any time: Running the command outside scheduled execution times will not interfere with its operation.
### Using Systemd
First, create symbolic links for the systemd service and timer files. Link the existing service and timer files from the `/opt/netbox/contrib/` directory to the `/etc/systemd/system/` directory:
```bash
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.service /etc/systemd/system/netbox-housekeeping.service
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.timer /etc/systemd/system/netbox-housekeeping.timer
```
Then, reload the systemd configuration and enable the timer to start automatically at boot:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now netbox-housekeeping.timer
```
Check the status of your timer by running:
```bash
sudo systemctl list-timers --all
```
This command will show a list of all timers, including your `netbox-housekeeping.timer`. Make sure the timer is active and properly scheduled.
That's it! Your NetBox housekeeping service is now configured to run daily using systemd.

View File

@@ -153,15 +153,10 @@ New objects can be created by instantiating the desired model, defining values f
```
>>> lab1 = Site.objects.get(pk=7)
>>> myvlan = VLAN(vid=123, name='MyNewVLAN', site=lab1)
>>> myvlan.full_clean()
>>> myvlan.save()
```
Alternatively, the above can be performed as a single operation. (Note, however, that `save()` does _not_ return the new instance for reuse.)
```
>>> VLAN(vid=123, name='MyNewVLAN', site=Site.objects.get(pk=7)).save()
```
To modify an existing object, we retrieve it, update the desired field(s), and call `save()` again.
```
@@ -169,6 +164,7 @@ To modify an existing object, we retrieve it, update the desired field(s), and c
>>> vlan.name
'MyNewVLAN'
>>> vlan.name = 'BetterName'
>>> vlan.full_clean()
>>> vlan.save()
>>> VLAN.objects.get(pk=1280).name
'BetterName'

View File

@@ -29,6 +29,17 @@ This defines custom content to be displayed on the login page above the login fo
---
## BANNER_MAINTENANCE
!!! tip "Dynamic Configuration Parameter"
!!! note
This parameter was added in NetBox v3.5.
This adds a banner to the top of every page when maintenance mode is enabled. HTML is allowed.
---
## BANNER_TOP
!!! tip "Dynamic Configuration Parameter"
@@ -129,7 +140,7 @@ Setting this to True will display a "maintenance mode" banner at the top of ever
Default: `https://maps.google.com/?q=` (Google Maps)
This specifies the URL to use when presenting a map of a physical location by street address or GPS coordinates. The URL must accept either a free-form street address or a comma-separated pair of numeric coordinates appended to it.
This specifies the URL to use when presenting a map of a physical location by street address or GPS coordinates. The URL must accept either a free-form street address or a comma-separated pair of numeric coordinates appended to it. Set this to `None` to disable the "map it" button within the UI.
---
@@ -193,3 +204,25 @@ This parameter defines the URL of the repository that will be checked for new Ne
Default: `300`
The maximum execution time of a background task (such as running a custom script), in seconds.
---
## RQ_RETRY_INTERVAL
!!! note
This parameter was added in NetBox v3.5.
Default: `60`
This parameter controls how frequently a failed job is retried, up to the maximum number of times specified by `RQ_RETRY_MAX`. This must be either an integer specifying the number of seconds to wait between successive attempts, or a list of such values. For example, `[60, 300, 3600]` will retry the task after 1 minute, 5 minutes, and 1 hour.
---
## RQ_RETRY_MAX
!!! note
This parameter was added in NetBox v3.5.
Default: `0` (retries disabled)
The maximum number of times a background task will be retried before being marked as failed.

View File

@@ -4,6 +4,14 @@ The configuration parameters listed here control remote authentication for NetBo
---
## REMOTE_AUTH_AUTO_CREATE_GROUPS
Default: `False`
If true, NetBox will automatically create groups specified in the `REMOTE_AUTH_GROUP_HEADER` header if they don't already exist. (Requires `REMOTE_AUTH_ENABLED`.)
---
## REMOTE_AUTH_AUTO_CREATE_USER
Default: `False`

View File

@@ -33,11 +33,13 @@ NetBox requires access to a PostgreSQL 11 or later database service to store dat
* `HOST` - Name or IP address of the database server (use `localhost` if running locally)
* `PORT` - TCP port of the PostgreSQL service; leave blank for default port (TCP/5432)
* `CONN_MAX_AGE` - Lifetime of a [persistent database connection](https://docs.djangoproject.com/en/stable/ref/databases/#persistent-connections), in seconds (300 is the default)
* `ENGINE` - The database backend to use; must be a PostgreSQL-compatible backend (e.g. `django.db.backends.postgresql`)
Example:
```python
DATABASE = {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'netbox', # Database name
'USER': 'netbox', # PostgreSQL username
'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password
@@ -50,6 +52,9 @@ DATABASE = {
!!! note
NetBox supports all PostgreSQL database options supported by the underlying Django framework. For a complete list of available parameters, please see [the Django documentation](https://docs.djangoproject.com/en/stable/ref/settings/#databases).
!!! warning
Make sure to use a PostgreSQL-compatible backend for the ENGINE setting. If you don't specify an ENGINE, the default will be django.db.backends.postgresql.
---
## REDIS

View File

@@ -2,12 +2,12 @@
Custom links allow users to display arbitrary hyperlinks to external content within NetBox object views. These are helpful for cross-referencing related records in systems outside NetBox. For example, you might create a custom link on the device view which links to the current device in a Network Monitoring System (NMS).
Custom links are created by navigating to Customization > Custom Links. Each link is associated with a particular NetBox object type (site, device, prefix, etc.) and will be displayed on relevant views. Each link has display text and a URL, and data from the NetBox item being viewed can be included in the link using [Jinja2 template code](https://jinja2docs.readthedocs.io/en/stable/) through the variable `obj`, and custom fields through `obj.cf`.
Custom links are created by navigating to Customization > Custom Links. Each link is associated with a particular NetBox object type (site, device, prefix, etc.) and will be displayed on relevant views. Each link has display text and a URL, and data from the NetBox item being viewed can be included in the link using [Jinja2 template code](https://jinja2docs.readthedocs.io/en/stable/) through the variable `object`, and custom fields through `object.cf`.
For example, you might define a link like this:
* Text: `View NMS`
* URL: `https://nms.example.com/nodes/?name={{ obj.name }}`
* URL: `https://nms.example.com/nodes/?name={{ object.name }}`
When viewing a device named Router4, this link would render as:
@@ -43,7 +43,7 @@ Only links which render with non-empty text are included on the page. You can em
For example, if you only want to display a link for active devices, you could set the link text to
```jinja2
{% if obj.status == 'active' %}View NMS{% endif %}
{% if object.status == 'active' %}View NMS{% endif %}
```
The link will not appear when viewing a device with any status other than "active."
@@ -51,7 +51,7 @@ The link will not appear when viewing a device with any status other than "activ
As another example, if you wanted to show only devices belonging to a certain manufacturer, you could do something like this:
```jinja2
{% if obj.device_type.manufacturer.name == 'Cisco' %}View NMS{% endif %}
{% if object.device_type.manufacturer.name == 'Cisco' %}View NMS{% endif %}
```
The link will only appear when viewing a device with a manufacturer name of "Cisco."

View File

@@ -378,6 +378,7 @@ class NewBranchScript(Script):
slug=slugify(data['site_name']),
status=SiteStatusChoices.STATUS_PLANNED
)
site.full_clean()
site.save()
self.log_success(f"Created new site: {site}")
@@ -391,6 +392,7 @@ class NewBranchScript(Script):
status=DeviceStatusChoices.STATUS_PLANNED,
device_role=switch_role
)
switch.full_clean()
switch.save()
self.log_success(f"Created new switch: {switch}")

View File

@@ -32,7 +32,7 @@ These are considered the "core" application models which are used to model netwo
* [circuits.Circuit](../models/circuits/circuit.md)
* [circuits.Provider](../models/circuits/provider.md)
* [circuits.ProviderAccount](../models/circuits/provideracount.md)
* [circuits.ProviderAccount](../models/circuits/provideraccount.md)
* [circuits.ProviderNetwork](../models/circuits/providernetwork.md)
* [core.DataSource](../models/core/datasource.md)
* [dcim.Cable](../models/dcim/cable.md)

View File

@@ -100,6 +100,8 @@ Create a system user account named `netbox`. We'll configure the WSGI and HTTP s
```
sudo adduser --system --group netbox
sudo chown --recursive netbox /opt/netbox/netbox/media/
sudo chown --recursive netbox /opt/netbox/netbox/reports/
sudo chown --recursive netbox /opt/netbox/netbox/scripts/
```
=== "CentOS"
@@ -108,6 +110,8 @@ Create a system user account named `netbox`. We'll configure the WSGI and HTTP s
sudo groupadd --system netbox
sudo adduser --system -g netbox netbox
sudo chown --recursive netbox /opt/netbox/netbox/media/
sudo chown --recursive netbox /opt/netbox/netbox/reports/
sudo chown --recursive netbox /opt/netbox/netbox/scripts/
```
## Configuration

View File

@@ -15,7 +15,7 @@ sudo apt install -y libldap2-dev libsasl2-dev libssl-dev
On CentOS:
```no-highlight
sudo yum install -y openldap-devel
sudo yum install -y openldap-devel python3-devel
```
### Install django-auth-ldap

View File

@@ -63,7 +63,7 @@ Each attribute of the IP address is expressed as an attribute of the JSON object
## Interactive Documentation
Comprehensive, interactive documentation of all REST API endpoints is available on a running NetBox instance at `/api/docs/`. This interface provides a convenient sandbox for researching and experimenting with specific endpoints and request types. The API itself can also be explored using a web browser by navigating to its root at `/api/`.
Comprehensive, interactive documentation of all REST API endpoints is available on a running NetBox instance at `/api/schema/swagger-ui/`. This interface provides a convenient sandbox for researching and experimenting with specific endpoints and request types. The API itself can also be explored using a web browser by navigating to its root at `/api/`.
## Endpoint Hierarchy

View File

@@ -4,8 +4,6 @@ A platform defines the type of software running on a [device](./device.md) or [v
Platforms may optionally be limited by [manufacturer](./manufacturer.md): If a platform is assigned to a particular manufacturer, it can only be assigned to devices with a type belonging to that manufacturer.
The platform model is also used to indicate which [NAPALM driver](../../integrations/napalm.md) (if any) and any associated arguments NetBox should use when connecting to a remote device. The name of the driver along with optional parameters are stored with the platform.
The assignment of platforms to devices is an optional feature, and may be disregarded if not desired.
## Fields

View File

@@ -1,5 +1,83 @@
# NetBox v3.5
## v3.5.2 (2023-05-22)
### Enhancements
* [#7671](https://github.com/netbox-community/netbox/issues/7671) - Introduce `REMOTE_AUTH_AUTO_CREATE_GROUPS` config parameter to enable the automatic creation of new groups when remote authentication is in use
* [#9068](https://github.com/netbox-community/netbox/issues/9068) - Disallow the assignment of network/broadcast IP addresses to interfaces
* [#11017](https://github.com/netbox-community/netbox/issues/11017) - Increase the maximum values for allocated and maximum power draws
* [#11233](https://github.com/netbox-community/netbox/issues/11233) - Intercept and cleanly report errors upon attempted database writes when maintenance mode is enabled
* [#11599](https://github.com/netbox-community/netbox/issues/11599) - Move contacts panels to separate tabs under object views
* [#11670](https://github.com/netbox-community/netbox/issues/11670) - Enable setting device type & module type weight via bulk import
* [#11900](https://github.com/netbox-community/netbox/issues/11900) - Add an outline to the reservation markers on rack elevations
* [#12131](https://github.com/netbox-community/netbox/issues/12131) - Show custom field description as an icon tooltip under object views
* [#12223](https://github.com/netbox-community/netbox/issues/12223) - Add columns for parent device bay and position to devices list
* [#12233](https://github.com/netbox-community/netbox/issues/12233) - Move related IP addresses table to a separate tab
* [#12286](https://github.com/netbox-community/netbox/issues/12286) - Show height and total weight under device view
* [#12323](https://github.com/netbox-community/netbox/issues/12323) - Add 100GE CXP interface type
* [#12327](https://github.com/netbox-community/netbox/issues/12327) - Introduce the ability to automatically retry failed background jobs
* [#12498](https://github.com/netbox-community/netbox/issues/12498) - Hide map button if `MAPS_URL` is empty
* [#12548](https://github.com/netbox-community/netbox/issues/12548) - Optimize REST API performance when retrieving interfaces with L2VPN assignments
* [#12554](https://github.com/netbox-community/netbox/issues/12554) - Allow customization or disabling of the maintenance mode banner
* [#12605](https://github.com/netbox-community/netbox/issues/12605) - Add LX.5 port types
* [#12629](https://github.com/netbox-community/netbox/issues/12629) - Add 400GE CDFP and CFP8 interface types
* [#12678](https://github.com/netbox-community/netbox/issues/12678) - Add 200GE QSFP-DD interface type
### Bug Fixes
* [#10686](https://github.com/netbox-community/netbox/issues/10686) - Enable specifying termination object by virtual chassis master when importing cables
* [#11619](https://github.com/netbox-community/netbox/issues/11619) - Enable assigning VLANs without a site to interfaces during bulk edit
* [#12468](https://github.com/netbox-community/netbox/issues/12468) - Custom field names should not permit double underscores
* [#12550](https://github.com/netbox-community/netbox/issues/12550) - Fix rear port selection widget under front port creation form
* [#12570](https://github.com/netbox-community/netbox/issues/12570) - Disable ordering of synchronized object tables by the "synced" attribute
* [#12594](https://github.com/netbox-community/netbox/issues/12594) - Enable selecting config context as object type in object counts dashboard widget
* [#12642](https://github.com/netbox-community/netbox/issues/12642) - Fix bulk tenant assignment via cluster import form
---
## v3.5.1 (2023-05-05)
### Enhancements
* [#10759](https://github.com/netbox-community/netbox/issues/10759) - Support Markdown rendering for custom field descriptions
* [#11190](https://github.com/netbox-community/netbox/issues/11190) - Including systemd service & timer configurations for housekeeping tasks
* [#11422](https://github.com/netbox-community/netbox/issues/11422) - Match on power panel name when searching for power feeds
* [#11504](https://github.com/netbox-community/netbox/issues/11504) - Add filter to select individual racks under rack elevations view
* [#11652](https://github.com/netbox-community/netbox/issues/11652) - Add a module status column to module bay tables
* [#11791](https://github.com/netbox-community/netbox/issues/11791) - Enable configuration of custom database backend via `ENGINE` parameter
* [#11801](https://github.com/netbox-community/netbox/issues/11801) - Include device description within rack elevation tooltip
* [#11932](https://github.com/netbox-community/netbox/issues/11932) - Introduce a list view for image attachments, orderable by date and other attributes
* [#12122](https://github.com/netbox-community/netbox/issues/12122) - Enable bulk import oj journal entries
* [#12245](https://github.com/netbox-community/netbox/issues/12245) - Enable the assignment of wireless LANs to interfaces under bulk edit
### Bug Fixes
* [#10757](https://github.com/netbox-community/netbox/issues/10757) - Simplify IP address interface and NAT IP assignment form fields to avoid confusion
* [#11715](https://github.com/netbox-community/netbox/issues/11715) - Prefix within a VRF should list global prefixes as parents only if they are containers
* [#12363](https://github.com/netbox-community/netbox/issues/12363) - Fix whitespace for paragraph elements in Markdown-rendered table columns
* [#12367](https://github.com/netbox-community/netbox/issues/12367) - Fix `RelatedObjectDoesNotExist` exception under certain conditions (regression from #11550)
* [#12380](https://github.com/netbox-community/netbox/issues/12380) - Allow selecting object change as model under object list widget configuration
* [#12384](https://github.com/netbox-community/netbox/issues/12384) - Add a three-second timeout for RSS reader widget
* [#12395](https://github.com/netbox-community/netbox/issues/12395) - Fix "create & add another" action for objects with custom fields
* [#12396](https://github.com/netbox-community/netbox/issues/12396) - Provider account should not be a required field in REST API serializer
* [#12400](https://github.com/netbox-community/netbox/issues/12400) - Validate default values for object and multi-object custom fields
* [#12401](https://github.com/netbox-community/netbox/issues/12401) - Support the creation of front ports without a pre-populated device ID
* [#12405](https://github.com/netbox-community/netbox/issues/12405) - Fix filtering for VLAN groups displayed under site view
* [#12410](https://github.com/netbox-community/netbox/issues/12410) - Fix base path for OpenAPI schema (fixes Swagger UI requests)
* [#12416](https://github.com/netbox-community/netbox/issues/12416) - Fix `FileNotFoundError` exception when a managed script file is missing from disk
* [#12412](https://github.com/netbox-community/netbox/issues/12412) - Device/VM interface MAC addresses can be nullified via REST API
* [#12415](https://github.com/netbox-community/netbox/issues/12415) - Fix `ImportError` exception when running RQ worker
* [#12433](https://github.com/netbox-community/netbox/issues/12433) - Correct the application of URL query parameters for object list dashboard widgets
* [#12436](https://github.com/netbox-community/netbox/issues/12436) - Remove extraneous "add" button from contact assignments list
* [#12463](https://github.com/netbox-community/netbox/issues/12463) - Fix the association of completed jobs with reports & scripts in the REST API
* [#12464](https://github.com/netbox-community/netbox/issues/12464) - Apply credentials for git data source only when connecting via HTTP/S
* [#12476](https://github.com/netbox-community/netbox/issues/12476) - Fix `TypeError` exception when running the `runscript` management command
* [#12483](https://github.com/netbox-community/netbox/issues/12483) - Fix git remote data syncing when with HTTP proxies defined
* [#12496](https://github.com/netbox-community/netbox/issues/12496) - Remove obsolete account field from provider UI view
---
## v3.5.0 (2023-04-27)
### Breaking Changes

View File

@@ -106,7 +106,7 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
class CircuitSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail')
provider = NestedProviderSerializer()
provider_account = NestedProviderAccountSerializer()
provider_account = NestedProviderAccountSerializer(required=False, allow_null=True)
status = ChoiceField(choices=CircuitStatusChoices, required=False)
type = NestedCircuitTypeSerializer()
tenant = NestedTenantSerializer(required=False, allow_null=True)

View File

@@ -74,7 +74,8 @@ class CircuitImportForm(NetBoxModelImportForm):
provider_account = CSVModelChoiceField(
queryset=ProviderAccount.objects.all(),
to_field_name='name',
help_text=_('Assigned provider account')
help_text=_('Assigned provider account'),
required=False
)
type = CSVModelChoiceField(
queryset=CircuitType.objects.all(),

View File

@@ -1,10 +1,10 @@
from django.contrib import messages
from django.db import transaction
from django.db.models import Q
from django.shortcuts import get_object_or_404, redirect, render
from dcim.views import PathTraceView
from netbox.views import generic
from tenancy.views import ObjectContactsView
from utilities.forms import ConfirmationForm
from utilities.utils import count_related
from utilities.views import register_model_view
@@ -73,6 +73,11 @@ class ProviderBulkDeleteView(generic.BulkDeleteView):
table = tables.ProviderTable
@register_model_view(Provider, 'contacts')
class ProviderContactsView(ObjectContactsView):
queryset = Provider.objects.all()
#
# ProviderAccounts
#
@@ -134,6 +139,11 @@ class ProviderAccountBulkDeleteView(generic.BulkDeleteView):
table = tables.ProviderAccountTable
@register_model_view(ProviderAccount, 'contacts')
class ProviderAccountContactsView(ObjectContactsView):
queryset = ProviderAccount.objects.all()
#
# Provider networks
#
@@ -389,6 +399,11 @@ class CircuitSwapTerminations(generic.ObjectEditView):
})
@register_model_view(Circuit, 'contacts')
class CircuitContactsView(ObjectContactsView):
queryset = Circuit.objects.all()
#
# Circuit terminations
#

View File

@@ -1,23 +1,12 @@
import re
import typing
from drf_spectacular.extensions import (
OpenApiSerializerFieldExtension,
OpenApiViewExtension,
)
from drf_spectacular.extensions import OpenApiSerializerFieldExtension
from drf_spectacular.openapi import AutoSchema
from drf_spectacular.plumbing import (
ComponentRegistry,
ResolvedComponent,
build_basic_type,
build_choice_field,
build_media_type_object,
build_object_type,
get_doc,
is_serializer,
build_basic_type, build_choice_field, build_media_type_object, build_object_type, get_doc,
)
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema
from rest_framework.relations import ManyRelatedField
from netbox.api.fields import ChoiceField, SerializedPKRelatedField

View File

@@ -12,7 +12,7 @@ from django import forms
from django.conf import settings
from django.utils.translation import gettext as _
from dulwich import porcelain
from dulwich.config import StackedConfig
from dulwich.config import ConfigDict
from netbox.registry import registry
from .choices import DataSourceTypeChoices
@@ -31,6 +31,7 @@ def register_backend(name):
"""
Decorator for registering a DataBackend class.
"""
def _wrapper(cls):
registry['data_backends'][name] = cls
return cls
@@ -56,7 +57,6 @@ class DataBackend:
@register_backend(DataSourceTypeChoices.LOCAL)
class LocalBackend(DataBackend):
@contextmanager
def fetch(self):
logger.debug(f"Data source type is local; skipping fetch")
@@ -71,12 +71,14 @@ class GitBackend(DataBackend):
'username': forms.CharField(
required=False,
label=_('Username'),
widget=forms.TextInput(attrs={'class': 'form-control'})
widget=forms.TextInput(attrs={'class': 'form-control'}),
help_text=_("Only used for cloning with HTTP / HTTPS"),
),
'password': forms.CharField(
required=False,
label=_('Password'),
widget=forms.TextInput(attrs={'class': 'form-control'})
widget=forms.TextInput(attrs={'class': 'form-control'}),
help_text=_("Only used for cloning with HTTP / HTTPS"),
),
'branch': forms.CharField(
required=False,
@@ -89,10 +91,22 @@ class GitBackend(DataBackend):
def fetch(self):
local_path = tempfile.TemporaryDirectory()
username = self.params.get('username')
password = self.params.get('password')
branch = self.params.get('branch')
config = StackedConfig.default()
config = ConfigDict()
clone_args = {
"branch": self.params.get('branch'),
"config": config,
"depth": 1,
"errstream": porcelain.NoneStream(),
"quiet": True,
}
if self.url_scheme in ('http', 'https'):
clone_args.update(
{
"username": self.params.get('username'),
"password": self.params.get('password'),
}
)
if settings.HTTP_PROXIES and self.url_scheme in ('http', 'https'):
if proxy := settings.HTTP_PROXIES.get(self.url_scheme):
@@ -100,10 +114,7 @@ class GitBackend(DataBackend):
logger.debug(f"Cloning git repo: {self.url}")
try:
porcelain.clone(
self.url, local_path.name, depth=1, branch=branch, username=username, password=password,
config=config, quiet=True, errstream=porcelain.NoneStream()
)
porcelain.clone(self.url, local_path.name, **clone_args)
except BaseException as e:
raise SyncError(f"Fetching remote data failed ({type(e).__name__}): {e}")

View File

@@ -16,7 +16,7 @@ from extras.utils import FeatureQuery
from netbox.config import get_config
from netbox.constants import RQ_QUEUE_DEFAULT
from utilities.querysets import RestrictedQuerySet
from utilities.rqworker import get_queue_for_model
from utilities.rqworker import get_queue_for_model, get_rq_retry
__all__ = (
'Job',
@@ -219,5 +219,6 @@ class Job(models.Model):
event=event,
data=self.data,
timestamp=str(timezone.now()),
username=self.user.username
username=self.user.username,
retry=get_rq_retry()
)

View File

@@ -904,7 +904,11 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
)
count_ipaddresses = serializers.IntegerField(read_only=True)
count_fhrp_groups = serializers.IntegerField(read_only=True)
mac_address = serializers.CharField(required=False, default=None)
mac_address = serializers.CharField(
required=False,
default=None,
allow_null=True
)
wwn = serializers.CharField(required=False, default=None)
class Meta:

View File

@@ -493,7 +493,8 @@ class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet):
class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
queryset = Interface.objects.prefetch_related(
'device', 'module__module_bay', 'parent', 'bridge', 'lag', '_path', 'cable__terminations', 'wireless_lans',
'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', 'fhrp_group_assignments', 'tags'
'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', 'fhrp_group_assignments', 'tags', 'l2vpn_terminations',
'vdcs',
)
serializer_class = serializers.InterfaceSerializer
filterset_class = filtersets.InterfaceFilterSet

View File

@@ -807,12 +807,16 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_100GE_CFP = '100gbase-x-cfp'
TYPE_100GE_CFP2 = '100gbase-x-cfp2'
TYPE_100GE_CFP4 = '100gbase-x-cfp4'
TYPE_100GE_CXP = '100gbase-x-cxp'
TYPE_100GE_CPAK = '100gbase-x-cpak'
TYPE_100GE_QSFP28 = '100gbase-x-qsfp28'
TYPE_200GE_CFP2 = '200gbase-x-cfp2'
TYPE_200GE_QSFP56 = '200gbase-x-qsfp56'
TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd'
TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd'
TYPE_400GE_OSFP = '400gbase-x-osfp'
TYPE_400GE_CDFP = '400gbase-x-cdfp'
TYPE_400GE_CFP8 = '400gbase-x-cfp8'
TYPE_800GE_QSFP_DD = '800gbase-x-qsfpdd'
TYPE_800GE_OSFP = '800gbase-x-osfp'
@@ -952,11 +956,15 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_100GE_CFP2, 'CFP2 (100GE)'),
(TYPE_200GE_CFP2, 'CFP2 (200GE)'),
(TYPE_100GE_CFP4, 'CFP4 (100GE)'),
(TYPE_100GE_CXP, 'CXP (100GE)'),
(TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'),
(TYPE_100GE_QSFP28, 'QSFP28 (100GE)'),
(TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
(TYPE_200GE_QSFP_DD, 'QSFP-DD (200GE)'),
(TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'),
(TYPE_400GE_OSFP, 'OSFP (400GE)'),
(TYPE_400GE_CDFP, 'CDFP (400GE)'),
(TYPE_400GE_CFP8, 'CPF8 (400GE)'),
(TYPE_800GE_QSFP_DD, 'QSFP-DD (800GE)'),
(TYPE_800GE_OSFP, 'OSFP (800GE)'),
)
@@ -1221,6 +1229,10 @@ class PortTypeChoices(ChoiceSet):
TYPE_LSH_PC = 'lsh-pc'
TYPE_LSH_UPC = 'lsh-upc'
TYPE_LSH_APC = 'lsh-apc'
TYPE_LX5 = 'lx5'
TYPE_LX5_PC = 'lx5-pc'
TYPE_LX5_UPC = 'lx5-upc'
TYPE_LX5_APC = 'lx5-apc'
TYPE_SPLICE = 'splice'
TYPE_CS = 'cs'
TYPE_SN = 'sn'
@@ -1267,6 +1279,10 @@ class PortTypeChoices(ChoiceSet):
(TYPE_LSH_PC, 'LSH/PC'),
(TYPE_LSH_UPC, 'LSH/UPC'),
(TYPE_LSH_APC, 'LSH/APC'),
(TYPE_LX5, 'LX.5'),
(TYPE_LX5_PC, 'LX.5/PC'),
(TYPE_LX5_UPC, 'LX.5/UPC'),
(TYPE_LX5_APC, 'LX.5/APC'),
(TYPE_MPO, 'MPO'),
(TYPE_MTRJ, 'MTRJ'),
(TYPE_SC, 'SC'),

View File

@@ -1900,6 +1900,7 @@ class PowerFeedFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet, PathEndpoi
return queryset
qs_filter = (
Q(name__icontains=value) |
Q(power_panel__name__icontains=value) |
Q(comments__icontains=value)
)
return queryset.filter(qs_filter)

View File

@@ -1,4 +1,5 @@
from django import forms
from django.conf import settings
from django.contrib.auth.models import User
from django.utils.translation import gettext as _
from timezone_field import TimeZoneFormField
@@ -13,6 +14,7 @@ from tenancy.models import Tenant
from utilities.forms import BulkEditForm, add_blank_choice, form_from_model
from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
from utilities.forms.widgets import BulkEditNullBooleanSelect, NumberWithOptions
from wireless.models import WirelessLAN, WirelessLANGroup
__all__ = (
'CableBulkEditForm',
@@ -1139,7 +1141,7 @@ class InterfaceBulkEditForm(
form_from_model(Interface, [
'label', 'type', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'mgmt_only',
'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width',
'tx_power',
'tx_power', 'wireless_lans'
]),
ComponentBulkEditForm
):
@@ -1229,6 +1231,19 @@ class InterfaceBulkEditForm(
required=False,
label=_('VRF')
)
wireless_lan_group = DynamicModelChoiceField(
queryset=WirelessLANGroup.objects.all(),
required=False,
label=_('Wireless LAN group')
)
wireless_lans = DynamicModelMultipleChoiceField(
queryset=WirelessLAN.objects.all(),
required=False,
label=_('Wireless LANs'),
query_params={
'group_id': '$wireless_lan_group',
}
)
model = Interface
fieldsets = (
@@ -1238,12 +1253,14 @@ class InterfaceBulkEditForm(
('PoE', ('poe_mode', 'poe_type')),
('Related Interfaces', ('parent', 'bridge', 'lag')),
('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')),
('Wireless', ('rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width')),
('Wireless', (
'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans',
)),
)
nullable_fields = (
'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'vdcs', 'mtu', 'description',
'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf',
'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf', 'wireless_lans'
)
def __init__(self, *args, **kwargs):
@@ -1276,8 +1293,13 @@ class InterfaceBulkEditForm(
break
if site is not None:
self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk)
self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk)
# Query for VLANs assigned to the same site and VLANs with no site assigned (null).
self.fields['untagged_vlan'].widget.add_query_param(
'site_id', [site.pk, settings.FILTERS_NULL_CHOICE_VALUE]
)
self.fields['tagged_vlans'].widget.add_query_param(
'site_id', [site.pk, settings.FILTERS_NULL_CHOICE_VALUE]
)
self.fields['parent'].choices = ()
self.fields['parent'].widget.attrs['disabled'] = True

View File

@@ -292,12 +292,21 @@ class DeviceTypeImportForm(NetBoxModelImportForm):
required=False,
help_text=_('The default platform for devices of this type (optional)')
)
weight = forms.DecimalField(
required=False,
help_text=_('Device weight'),
)
weight_unit = CSVChoiceField(
choices=WeightUnitChoices,
required=False,
help_text=_('Unit for device weight')
)
class Meta:
model = DeviceType
fields = [
'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
'subdevice_role', 'airflow', 'description', 'comments',
'subdevice_role', 'airflow', 'description', 'weight', 'weight_unit', 'comments',
]
@@ -306,10 +315,19 @@ class ModuleTypeImportForm(NetBoxModelImportForm):
queryset=Manufacturer.objects.all(),
to_field_name='name'
)
weight = forms.DecimalField(
required=False,
help_text=_('Module weight'),
)
weight_unit = CSVChoiceField(
choices=WeightUnitChoices,
required=False,
help_text=_('Unit for module weight')
)
class Meta:
model = ModuleType
fields = ['manufacturer', 'model', 'part_number', 'description', 'comments']
fields = ['manufacturer', 'model', 'part_number', 'description', 'weight', 'weight_unit', 'comments']
class DeviceRoleImportForm(NetBoxModelImportForm):
@@ -1060,7 +1078,11 @@ class CableImportForm(NetBoxModelImportForm):
model = content_type.model_class()
try:
termination_object = model.objects.get(device=device, name=name)
if device.virtual_chassis and device.virtual_chassis.master == device and \
model.objects.filter(device=device, name=name).count() == 0:
termination_object = model.objects.get(device__in=device.virtual_chassis.members.all(), name=name)
else:
termination_object = model.objects.get(device=device, name=name)
if termination_object.cable is not None:
raise forms.ValidationError(f"Side {side.upper()}: {device} {termination_object} is already connected")
except ObjectDoesNotExist:

View File

@@ -298,6 +298,15 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
class RackElevationFilterForm(RackFilterForm):
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'id')),
('Function', ('status', 'role_id')),
('Hardware', ('type', 'width', 'serial', 'asset_tag')),
('Tenant', ('tenant_group_id', 'tenant_id')),
('Contacts', ('contact', 'contact_role', 'contact_group')),
('Weight', ('weight', 'max_weight', 'weight_unit')),
)
id = DynamicModelMultipleChoiceField(
queryset=Rack.objects.all(),
label=_('Rack'),

View File

@@ -1214,7 +1214,7 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
installed_device = forms.ModelChoiceField(
queryset=Device.objects.all(),
label=_('Child Device'),
help_text=_("Child devices must first be created and assigned to the site/rack of the parent device.")
help_text=_("Child devices must first be created and assigned to the site and rack of the parent device.")
)
def __init__(self, device_bay, *args, **kwargs):

View File

@@ -4,6 +4,7 @@ from django.utils.translation import gettext as _
from dcim.models import *
from netbox.forms import NetBoxModelForm
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField
from utilities.forms.widgets import APISelect
from . import model_forms
__all__ = (
@@ -225,10 +226,23 @@ class InterfaceCreateForm(ComponentCreateForm, model_forms.InterfaceForm):
class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):
device = DynamicModelChoiceField(
queryset=Device.objects.all(),
selector=True,
widget=APISelect(
# TODO: Clean up the application of HTMXSelect attributes
attrs={
'hx-get': '.',
'hx-include': f'#form_fields',
'hx-target': f'#form_fields',
}
)
)
rear_port = forms.MultipleChoiceField(
choices=[],
label=_('Rear ports'),
help_text=_('Select one rear port assignment for each front port being created.'),
widget=forms.SelectMultiple(attrs={'size': 6})
)
# Override fieldsets from FrontPortForm to omit rear_port_position
@@ -244,9 +258,10 @@ class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
device = Device.objects.get(
pk=self.initial.get('device') or self.data.get('device')
)
if device_id := self.data.get('device') or self.initial.get('device'):
device = Device.objects.get(pk=device_id)
else:
return
# Determine which rear port positions are occupied. These will be excluded from the list of available
# mappings.

View File

@@ -0,0 +1,42 @@
# Generated by Django 4.1.9 on 2023-05-12 18:46
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0171_cabletermination_change_logging'),
]
operations = [
migrations.AlterField(
model_name='powerport',
name='allocated_draw',
field=models.PositiveIntegerField(
blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]
),
),
migrations.AlterField(
model_name='powerport',
name='maximum_draw',
field=models.PositiveIntegerField(
blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]
),
),
migrations.AlterField(
model_name='powerporttemplate',
name='allocated_draw',
field=models.PositiveIntegerField(
blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]
),
),
migrations.AlterField(
model_name='powerporttemplate',
name='maximum_draw',
field=models.PositiveIntegerField(
blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]
),
),
]

View File

@@ -232,13 +232,13 @@ class PowerPortTemplate(ModularComponentTemplateModel):
choices=PowerPortTypeChoices,
blank=True
)
maximum_draw = models.PositiveSmallIntegerField(
maximum_draw = models.PositiveIntegerField(
blank=True,
null=True,
validators=[MinValueValidator(1)],
help_text=_("Maximum power draw (watts)")
)
allocated_draw = models.PositiveSmallIntegerField(
allocated_draw = models.PositiveIntegerField(
blank=True,
null=True,
validators=[MinValueValidator(1)],

View File

@@ -329,13 +329,13 @@ class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
blank=True,
help_text=_('Physical port type')
)
maximum_draw = models.PositiveSmallIntegerField(
maximum_draw = models.PositiveIntegerField(
blank=True,
null=True,
validators=[MinValueValidator(1)],
help_text=_("Maximum power draw (watts)")
)
allocated_draw = models.PositiveSmallIntegerField(
allocated_draw = models.PositiveIntegerField(
blank=True,
null=True,
validators=[MinValueValidator(1)],

View File

@@ -184,6 +184,8 @@ class DeviceType(PrimaryModel, WeightMixin):
'subdevice_role': self.subdevice_role,
'airflow': self.airflow,
'comments': self.comments,
'weight': float(self.weight) if self.weight is not None else None,
'weight_unit': self.weight_unit,
}
# Component templates
@@ -361,6 +363,8 @@ class ModuleType(PrimaryModel, WeightMixin):
'model': self.model,
'part_number': self.part_number,
'comments': self.comments,
'weight': float(self.weight) if self.weight is not None else None,
'weight_unit': self.weight_unit,
}
# Component templates

View File

@@ -22,6 +22,11 @@ __all__ = (
'RackElevationSVG',
)
GRADIENT_RESERVED = '#b0b0ff'
GRADIENT_OCCUPIED = '#d7d7d7'
GRADIENT_BLOCKED = '#ffc0c0'
STROKE_RESERVED = '#4d4dff'
def get_device_name(device):
if device.virtual_chassis:
@@ -37,15 +42,28 @@ def get_device_name(device):
def get_device_description(device):
return '{} ({}) — {} {} ({}U) {} {}'.format(
device.name,
device.device_role,
device.device_type.manufacturer.name,
device.device_type.model,
floatformat(device.device_type.u_height),
device.asset_tag or '',
device.serial or ''
)
"""
Return a description for a device to be rendered in the rack elevation in the following format
Name: <name>
Role: <device_role>
Device Type: <manufacturer> <model> (<u_height>)
Asset tag: <asset_tag> (if defined)
Serial: <serial> (if defined)
Description: <description> (if defined)
"""
description = f'Name: {device.name}'
description += f'\nRole: {device.device_role}'
u_height = f'{floatformat(device.device_type.u_height)}U'
description += f'\nDevice Type: {device.device_type.manufacturer.name} {device.device_type.model} ({u_height})'
if device.asset_tag:
description += f'\nAsset tag: {device.asset_tag}'
if device.serial:
description += f'\nSerial: {device.serial}'
if device.description:
description += f'\nDescription: {device.description}'
return description
class RackElevationSVG:
@@ -119,9 +137,9 @@ class RackElevationSVG:
drawing.defs.add(drawing.style(css_file.read()))
# Add gradients
RackElevationSVG._add_gradient(drawing, 'reserved', '#b0b0ff')
RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7')
RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0')
RackElevationSVG._add_gradient(drawing, 'reserved', GRADIENT_RESERVED)
RackElevationSVG._add_gradient(drawing, 'occupied', GRADIENT_OCCUPIED)
RackElevationSVG._add_gradient(drawing, 'blocked', GRADIENT_BLOCKED)
return drawing
@@ -233,13 +251,13 @@ class RackElevationSVG:
coords = self._get_device_coords(segment[0], u_height)
coords = (coords[0] + self.unit_width + RACK_ELEVATION_BORDER_WIDTH * 2, coords[1])
size = (
self.margin_width,
self.margin_width - 3,
u_height * self.unit_height
)
link = Hyperlink(href=f'{self.base_url}{reservation.get_absolute_url()}', target='_parent')
link.set_desc(f'Reservation #{reservation.pk}: {reservation.description}')
link.add(
Rect(coords, size, class_='reservation')
Rect(coords, size, class_='reservation', stroke=STROKE_RESERVED, stroke_width=2)
)
self.drawing.add(link)

View File

@@ -39,6 +39,10 @@ __all__ = (
'VirtualDeviceContextTable'
)
MODULEBAY_STATUS = """
{% badge record.installed_module.get_status_display bg_color=record.installed_module.get_status_color %}
"""
def get_cabletermination_row_class(record):
if record.mark_connected:
@@ -212,6 +216,16 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
config_template = tables.Column(
linkify=True
)
parent_device = tables.Column(
verbose_name='Parent Device',
linkify=True,
accessor='parent_bay__device'
)
device_bay_position = tables.Column(
verbose_name='Position (Device Bay)',
accessor='parent_bay',
linkify=True
)
comments = columns.MarkdownColumn()
tags = columns.TagColumn(
url_name='dcim:device_list'
@@ -221,9 +235,10 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
model = models.Device
fields = (
'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type',
'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'position', 'face',
'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position',
'vc_priority', 'description', 'config_template', 'comments', 'contacts', 'tags', 'created', 'last_updated',
'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'parent_device',
'device_bay_position', 'position', 'face', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster',
'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template', 'comments', 'contacts',
'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type',
@@ -781,14 +796,17 @@ class ModuleBayTable(DeviceComponentTable):
tags = columns.TagColumn(
url_name='dcim:modulebay_list'
)
module_status = columns.TemplateColumn(
template_code=MODULEBAY_STATUS
)
class Meta(DeviceComponentTable.Meta):
model = models.ModuleBay
fields = (
'pk', 'id', 'name', 'device', 'label', 'position', 'installed_module', 'module_serial', 'module_asset_tag',
'description', 'tags',
'pk', 'id', 'name', 'device', 'label', 'position', 'installed_module', 'module_status', 'module_serial',
'module_asset_tag', 'description', 'tags',
)
default_columns = ('pk', 'name', 'device', 'label', 'installed_module', 'description')
default_columns = ('pk', 'name', 'device', 'label', 'installed_module', 'module_status', 'description')
class DeviceModuleBayTable(ModuleBayTable):
@@ -799,10 +817,10 @@ class DeviceModuleBayTable(ModuleBayTable):
class Meta(DeviceComponentTable.Meta):
model = models.ModuleBay
fields = (
'pk', 'id', 'name', 'label', 'position', 'installed_module', 'module_serial', 'module_asset_tag',
'pk', 'id', 'name', 'label', 'position', 'installed_module', 'module_status', 'module_serial', 'module_asset_tag',
'description', 'tags', 'actions',
)
default_columns = ('pk', 'name', 'label', 'installed_module', 'description')
default_columns = ('pk', 'name', 'label', 'installed_module', 'module_status', 'description')
class InventoryItemTable(DeviceComponentTable):

View File

@@ -681,11 +681,15 @@ class DeviceTypeTestCase(
"""
IMPORT_DATA = """
manufacturer: Generic
default_platform: Platform
model: TEST-1000
slug: test-1000
default_platform: Platform
u_height: 2
is_full_depth: false
airflow: front-to-rear
subdevice_role: parent
weight: 10
weight_unit: kg
comments: Test comment
console-ports:
- name: Console Port 1
@@ -794,8 +798,16 @@ inventory-items:
self.assertHttpStatus(response, 200)
device_type = DeviceType.objects.get(model='TEST-1000')
self.assertEqual(device_type.comments, 'Test comment')
self.assertEqual(device_type.manufacturer.pk, manufacturer.pk)
self.assertEqual(device_type.default_platform.pk, platform.pk)
self.assertEqual(device_type.slug, 'test-1000')
self.assertEqual(device_type.u_height, 2)
self.assertFalse(device_type.is_full_depth)
self.assertEqual(device_type.airflow, DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR)
self.assertEqual(device_type.subdevice_role, SubdeviceRoleChoices.ROLE_PARENT)
self.assertEqual(device_type.weight, 10)
self.assertEqual(device_type.weight_unit, WeightUnitChoices.UNIT_KILOGRAM)
self.assertEqual(device_type.comments, 'Test comment')
# Verify all of the components were created
self.assertEqual(device_type.consoleporttemplates.count(), 3)
@@ -1019,6 +1031,8 @@ class ModuleTypeTestCase(
IMPORT_DATA = """
manufacturer: Generic
model: TEST-1000
weight: 10
weight_unit: lb
comments: Test comment
console-ports:
- name: Console Port 1
@@ -1082,7 +1096,8 @@ front-ports:
"""
# Create the manufacturer
Manufacturer(name='Generic', slug='generic').save()
manufacturer = Manufacturer(name='Generic', slug='generic')
manufacturer.save()
# Add all required permissions to the test user
self.add_permissions(
@@ -1105,6 +1120,9 @@ front-ports:
self.assertHttpStatus(response, 200)
module_type = ModuleType.objects.get(model='TEST-1000')
self.assertEqual(module_type.manufacturer.pk, manufacturer.pk)
self.assertEqual(module_type.weight, 10)
self.assertEqual(module_type.weight_unit, WeightUnitChoices.UNIT_POUND)
self.assertEqual(module_type.comments, 'Test comment')
# Verify all the components were created
@@ -2889,6 +2907,7 @@ class CableTestCase(
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
devicetype = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer)
devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
vc = VirtualChassis.objects.create(name='Virtual Chassis')
devices = (
Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole),
@@ -2898,6 +2917,10 @@ class CableTestCase(
)
Device.objects.bulk_create(devices)
vc.members.set((devices[0], devices[1], devices[2]))
vc.master = devices[0]
vc.save()
interfaces = (
Interface(device=devices[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[0], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
@@ -2911,6 +2934,10 @@ class CableTestCase(
Interface(device=devices[3], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[3], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[3], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[1], name='Device 2 Interface', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[2], name='Device 3 Interface', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[3], name='Interface 4', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[3], name='Interface 5', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
)
Interface.objects.bulk_create(interfaces)
@@ -2943,6 +2970,8 @@ class CableTestCase(
"Device 3,dcim.interface,Interface 1,Device 4,dcim.interface,Interface 1",
"Device 3,dcim.interface,Interface 2,Device 4,dcim.interface,Interface 2",
"Device 3,dcim.interface,Interface 3,Device 4,dcim.interface,Interface 3",
"Device 1,dcim.interface,Device 2 Interface,Device 4,dcim.interface,Interface 4",
"Device 1,dcim.interface,Device 3 Interface,Device 4,dcim.interface,Interface 5",
)
cls.csv_update_data = (

View File

@@ -20,6 +20,7 @@ from extras.views import ObjectConfigContextView
from ipam.models import ASN, IPAddress, Prefix, VLAN, VLANGroup
from ipam.tables import InterfaceVLANTable
from netbox.views import generic
from tenancy.views import ObjectContactsView
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.permissions import get_permission_for_model
@@ -267,6 +268,11 @@ class RegionBulkDeleteView(generic.BulkDeleteView):
table = tables.RegionTable
@register_model_view(Region, 'contacts')
class RegionContactsView(ObjectContactsView):
queryset = Region.objects.all()
#
# Site groups
#
@@ -342,6 +348,11 @@ class SiteGroupBulkDeleteView(generic.BulkDeleteView):
table = tables.SiteGroupTable
@register_model_view(SiteGroup, 'contacts')
class SiteGroupContactsView(ObjectContactsView):
queryset = SiteGroup.objects.all()
#
# Sites
#
@@ -371,7 +382,7 @@ class SiteView(generic.ObjectView):
(VLANGroup.objects.restrict(request.user, 'view').filter(
scope_type=ContentType.objects.get_for_model(Site),
scope_id=instance.pk
), 'site_id'),
), 'site'),
(VLAN.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'),
# Circuits
(Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).distinct(), 'site_id'),
@@ -435,6 +446,11 @@ class SiteBulkDeleteView(generic.BulkDeleteView):
table = tables.SiteTable
@register_model_view(Site, 'contacts')
class SiteContactsView(ObjectContactsView):
queryset = Site.objects.all()
#
# Locations
#
@@ -523,6 +539,11 @@ class LocationBulkDeleteView(generic.BulkDeleteView):
table = tables.LocationTable
@register_model_view(Location, 'contacts')
class LocationContactsView(ObjectContactsView):
queryset = Location.objects.all()
#
# Rack roles
#
@@ -740,6 +761,11 @@ class RackBulkDeleteView(generic.BulkDeleteView):
table = tables.RackTable
@register_model_view(Rack, 'contacts')
class RackContactsView(ObjectContactsView):
queryset = Rack.objects.all()
#
# Rack reservations
#
@@ -874,6 +900,11 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView):
table = tables.ManufacturerTable
@register_model_view(Manufacturer, 'contacts')
class ManufacturerContactsView(ObjectContactsView):
queryset = Manufacturer.objects.all()
#
# Device types
#
@@ -2088,6 +2119,11 @@ class DeviceBulkRenameView(generic.BulkRenameView):
table = tables.DeviceTable
@register_model_view(Device, 'contacts')
class DeviceContactsView(ObjectContactsView):
queryset = Device.objects.all()
#
# Modules
#
@@ -3469,6 +3505,11 @@ class PowerPanelBulkDeleteView(generic.BulkDeleteView):
table = tables.PowerPanelTable
@register_model_view(PowerPanel, 'contacts')
class PowerPanelContactsView(ObjectContactsView):
queryset = PowerPanel.objects.all()
#
# Power feeds
#

View File

@@ -25,7 +25,7 @@ class ConfigRevisionAdmin(admin.ModelAdmin):
'fields': ('ALLOWED_URL_SCHEMES',),
}),
('Banners', {
'fields': ('BANNER_LOGIN', 'BANNER_TOP', 'BANNER_BOTTOM'),
'fields': ('BANNER_LOGIN', 'BANNER_MAINTENANCE', 'BANNER_TOP', 'BANNER_BOTTOM'),
'classes': ('monospace',),
}),
('Pagination', {

View File

@@ -187,11 +187,10 @@ class ReportViewSet(ViewSet):
"""
Compile all reports and their related results (if any). Result data is deferred in the list view.
"""
report_content_type = ContentType.objects.get(app_label='extras', model='report')
results = {
r.name: r
for r in Job.objects.filter(
object_type=report_content_type,
job.name: job
for job in Job.objects.filter(
object_type=ContentType.objects.get(app_label='extras', model='reportmodule'),
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
).order_by('name', '-created').distinct('name').defer('data')
}
@@ -202,7 +201,7 @@ class ReportViewSet(ViewSet):
# Attach Job objects to each report (if any)
for report in report_list:
report.result = results.get(report.full_name, None)
report.result = results.get(report.name, None)
serializer = serializers.ReportSerializer(report_list, many=True, context={
'request': request,
@@ -290,12 +289,10 @@ class ScriptViewSet(ViewSet):
return module, script
def list(self, request):
script_content_type = ContentType.objects.get(app_label='extras', model='script')
results = {
r.name: r
for r in Job.objects.filter(
object_type=script_content_type,
job.name: job
for job in Job.objects.filter(
object_type=ContentType.objects.get(app_label='extras', model='scriptmodule'),
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
).order_by('name', '-created').distinct('name').defer('data')
}
@@ -306,7 +303,7 @@ class ScriptViewSet(ViewSet):
# Attach Job objects to each script (if any)
for script in script_list:
script.result = results.get(script.full_name, None)
script.result = results.get(script.name, None)
serializer = serializers.ScriptSerializer(script_list, many=True, context={'request': request})

View File

@@ -4,10 +4,12 @@ from hashlib import sha256
from urllib.parse import urlencode
import feedparser
import requests
from django import forms
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache
from django.db.models import Q
from django.template.loader import render_to_string
from django.urls import NoReverseMatch, reverse
from django.utils.translation import gettext as _
@@ -33,7 +35,8 @@ def get_content_type_labels():
return [
(content_type_identifier(ct), content_type_name(ct))
for ct in ContentType.objects.filter(
FeatureQuery('export_templates').get_query()
FeatureQuery('export_templates').get_query() | Q(app_label='extras', model='objectchange') |
Q(app_label='extras', model='configcontext')
).order_by('app_label', 'model')
]
@@ -227,7 +230,11 @@ class ObjectListWidget(DashboardWidget):
htmx_url = reverse(viewname)
except NoReverseMatch:
htmx_url = None
if parameters := self.config.get('url_params'):
parameters = self.config.get('url_params') or {}
if page_size := self.config.get('page_size'):
parameters['per_page'] = page_size
if parameters:
try:
htmx_url = f'{htmx_url}?{urlencode(parameters, doseq=True)}'
except ValueError:
@@ -236,7 +243,6 @@ class ObjectListWidget(DashboardWidget):
'viewname': viewname,
'has_permission': has_permission,
'htmx_url': htmx_url,
'page_size': self.config.get('page_size'),
})
@@ -268,12 +274,9 @@ class RSSFeedWidget(DashboardWidget):
)
def render(self, request):
url = self.config['feed_url']
feed = self.get_feed()
return render_to_string(self.template_name, {
'url': url,
'feed': feed,
'url': self.config['feed_url'],
**self.get_feed()
})
@cached_property
@@ -285,17 +288,33 @@ class RSSFeedWidget(DashboardWidget):
def get_feed(self):
# Fetch RSS content from cache if available
if feed_content := cache.get(self.cache_key):
feed = feedparser.FeedParserDict(feed_content)
else:
feed = feedparser.parse(
self.config['feed_url'],
request_headers={'User-Agent': f'NetBox/{settings.VERSION}'}
)
if not feed.bozo:
# Cap number of entries
max_entries = self.config.get('max_entries')
feed['entries'] = feed['entries'][:max_entries]
# Cache the feed content
cache.set(self.cache_key, dict(feed), self.config.get('cache_timeout'))
return {
'feed': feedparser.FeedParserDict(feed_content),
}
return feed
# Fetch feed content from remote server
try:
response = requests.get(
url=self.config['feed_url'],
headers={'User-Agent': f'NetBox/{settings.VERSION}'},
proxies=settings.HTTP_PROXIES,
timeout=3
)
response.raise_for_status()
except requests.exceptions.RequestException as e:
return {
'error': e,
}
# Parse feed content
feed = feedparser.parse(response.content)
if not feed.bozo:
# Cap number of entries
max_entries = self.config.get('max_entries')
feed['entries'] = feed['entries'][:max_entries]
# Cache the feed content
cache.set(self.cache_key, dict(feed), self.config.get('cache_timeout'))
return {
'feed': feed,
}

View File

@@ -4,9 +4,10 @@ from django.contrib.postgres.forms import SimpleArrayField
from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _
from extras.choices import CustomFieldVisibilityChoices, CustomFieldTypeChoices
from extras.choices import CustomFieldVisibilityChoices, CustomFieldTypeChoices, JournalEntryKindChoices
from extras.models import *
from extras.utils import FeatureQuery
from netbox.forms import NetBoxModelImportForm
from utilities.forms import CSVModelForm
from utilities.forms.fields import CSVChoiceField, CSVContentTypeField, CSVMultipleContentTypeField, SlugField
@@ -15,6 +16,7 @@ __all__ = (
'CustomFieldImportForm',
'CustomLinkImportForm',
'ExportTemplateImportForm',
'JournalEntryImportForm',
'SavedFilterImportForm',
'TagImportForm',
'WebhookImportForm',
@@ -132,3 +134,20 @@ class TagImportForm(CSVModelForm):
help_texts = {
'color': mark_safe(_('RGB color in hexadecimal (e.g. <code>00ff00</code>)')),
}
class JournalEntryImportForm(NetBoxModelImportForm):
assigned_object_type = CSVContentTypeField(
queryset=ContentType.objects.all(),
label=_('Assigned object type'),
)
kind = CSVChoiceField(
choices=JournalEntryKindChoices,
help_text=_('The classification of entry')
)
class Meta:
model = JournalEntry
fields = (
'assigned_object_type', 'assigned_object_id', 'created_by', 'kind', 'comments', 'tags'
)

View File

@@ -11,7 +11,7 @@ from extras.utils import FeatureQuery
from netbox.forms.base import NetBoxModelFilterSetForm
from tenancy.models import Tenant, TenantGroup
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
from utilities.forms.fields import ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField
from utilities.forms.fields import ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField
from utilities.forms.widgets import APISelectMultiple, DateTimePicker
from virtualization.models import Cluster, ClusterGroup, ClusterType
from .mixins import SavedFiltersMixin
@@ -22,6 +22,7 @@ __all__ = (
'CustomFieldFilterForm',
'CustomLinkFilterForm',
'ExportTemplateFilterForm',
'ImageAttachmentFilterForm',
'JournalEntryFilterForm',
'LocalConfigContextFilterForm',
'ObjectChangeFilterForm',
@@ -137,6 +138,20 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
)
class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = (
(None, ('q', 'filter_id')),
('Attributes', ('content_type_id', 'name',)),
)
content_type_id = ContentTypeChoiceField(
queryset=ContentType.objects.filter(FeatureQuery('custom_fields').get_query()),
required=False
)
name = forms.CharField(
required=False
)
class SavedFilterFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = (
(None, ('q', 'filter_id')),

View File

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

View File

@@ -13,6 +13,22 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='customfield',
name='name',
field=models.CharField(max_length=50, unique=True, validators=[django.core.validators.RegexValidator(flags=re.RegexFlag['IGNORECASE'], message='Only alphanumeric characters and underscores are allowed.', regex='^[a-z0-9_]+$')]),
field=models.CharField(
max_length=50,
unique=True,
validators=[
django.core.validators.RegexValidator(
flags=re.RegexFlag['IGNORECASE'],
message='Only alphanumeric characters and underscores are allowed.',
regex='^[a-z0-9_]+$',
),
django.core.validators.RegexValidator(
flags=re.RegexFlag['IGNORECASE'],
inverse_match=True,
message='Double underscores are not permitted in custom field names.',
regex=r'__',
),
],
),
),
]

View File

@@ -85,6 +85,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
message="Only alphanumeric characters and underscores are allowed.",
flags=re.IGNORECASE
),
RegexValidator(
regex=r'__',
message="Double underscores are not permitted in custom field names.",
flags=re.IGNORECASE,
inverse_match=True
),
)
)
label = models.CharField(
@@ -606,5 +612,18 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
f"Invalid choice(s) ({', '.join(value)}). Available choices are: {', '.join(self.choices)}"
)
# Validate selected object
elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:
if type(value) is not int:
raise ValidationError(f"Value must be an object ID, not {type(value).__name__}")
# Validate selected objects
elif self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
if type(value) is not list:
raise ValidationError(f"Value must be a list of object IDs, not {type(value).__name__}")
for id in value:
if type(id) is not int:
raise ValidationError(f"Found invalid object ID: {id}")
elif self.required:
raise ValidationError("Required field cannot be empty.")

View File

@@ -1,4 +1,5 @@
import inspect
import logging
from functools import cached_property
from django.db import models
@@ -16,6 +17,8 @@ __all__ = (
'ScriptModule',
)
logger = logging.getLogger('netbox.data_backends')
class Script(WebhooksMixin, models.Model):
"""
@@ -53,7 +56,12 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
# For child objects in submodules use the full import path w/o the root module as the name
return cls.full_name.split(".", maxsplit=1)[1]
module = self.get_module()
try:
module = self.get_module()
except Exception as e:
logger.debug(f"Failed to load script: {self.python_name} error: {e}")
module = None
scripts = {}
ordered = getattr(module, 'script_order', [])

View File

@@ -13,6 +13,7 @@ __all__ = (
'CustomFieldTable',
'CustomLinkTable',
'ExportTemplateTable',
'ImageAttachmentTable',
'JournalEntryTable',
'ObjectChangeTable',
'SavedFilterTable',
@@ -29,6 +30,7 @@ class CustomFieldTable(NetBoxTable):
content_types = columns.ContentTypesColumn()
required = columns.BooleanColumn()
ui_visibility = columns.ChoiceFieldColumn(verbose_name="UI visibility")
description = columns.MarkdownColumn()
is_cloneable = columns.BooleanColumn()
class Meta(NetBoxTable.Meta):
@@ -71,6 +73,7 @@ class ExportTemplateTable(NetBoxTable):
linkify=True
)
is_synced = columns.BooleanColumn(
orderable=False,
verbose_name='Synced'
)
@@ -85,6 +88,28 @@ class ExportTemplateTable(NetBoxTable):
)
class ImageAttachmentTable(NetBoxTable):
id = tables.Column(
linkify=False
)
content_type = columns.ContentTypeColumn()
parent = tables.Column(
linkify=True
)
size = tables.Column(
orderable=False,
verbose_name='Size (bytes)'
)
class Meta(NetBoxTable.Meta):
model = ImageAttachment
fields = (
'pk', 'content_type', 'parent', 'image', 'name', 'image_height', 'image_width', 'size', 'created',
'last_updated',
)
default_columns = ('content_type', 'parent', 'image', 'name', 'size', 'created')
class SavedFilterTable(NetBoxTable):
name = tables.Column(
linkify=True
@@ -194,6 +219,7 @@ class ConfigContextTable(NetBoxTable):
verbose_name='Active'
)
is_synced = columns.BooleanColumn(
orderable=False,
verbose_name='Synced'
)
@@ -218,6 +244,7 @@ class ConfigTemplateTable(NetBoxTable):
linkify=True
)
is_synced = columns.BooleanColumn(
orderable=False,
verbose_name='Synced'
)
tags = columns.TagColumn(

View File

@@ -29,6 +29,17 @@ class CustomFieldTest(TestCase):
cls.object_type = ContentType.objects.get_for_model(Site)
def test_invalid_name(self):
"""
Try creating a CustomField with an invalid name.
"""
with self.assertRaises(ValidationError):
# Invalid character
CustomField(name='?', type=CustomFieldTypeChoices.TYPE_TEXT).full_clean()
with self.assertRaises(ValidationError):
# Double underscores not permitted
CustomField(name='foo__bar', type=CustomFieldTypeChoices.TYPE_TEXT).full_clean()
def test_text_field(self):
value = 'Foobar!'

View File

@@ -73,6 +73,7 @@ urlpatterns = [
path('config-templates/<int:pk>/', include(get_model_urls('extras', 'configtemplate'))),
# Image attachments
path('image-attachments/', views.ImageAttachmentListView.as_view(), name='imageattachment_list'),
path('image-attachments/add/', views.ImageAttachmentEditView.as_view(), name='imageattachment_add'),
path('image-attachments/<int:pk>/', include(get_model_urls('extras', 'imageattachment'))),
@@ -81,6 +82,7 @@ urlpatterns = [
path('journal-entries/add/', views.JournalEntryEditView.as_view(), name='journalentry_add'),
path('journal-entries/edit/', views.JournalEntryBulkEditView.as_view(), name='journalentry_bulk_edit'),
path('journal-entries/delete/', views.JournalEntryBulkDeleteView.as_view(), name='journalentry_bulk_delete'),
path('journal-entries/import/', views.JournalEntryBulkImportView.as_view(), name='journalentry_import'),
path('journal-entries/<int:pk>/', include(get_model_urls('extras', 'journalentry'))),
# Change logging

View File

@@ -577,6 +577,14 @@ class ObjectChangeView(generic.ObjectView):
# Image attachments
#
class ImageAttachmentListView(generic.ObjectListView):
queryset = ImageAttachment.objects.all()
filterset = filtersets.ImageAttachmentFilterSet
filterset_form = forms.ImageAttachmentFilterForm
table = tables.ImageAttachmentTable
actions = ('export',)
@register_model_view(ImageAttachment, 'edit')
class ImageAttachmentEditView(generic.ObjectEditView):
queryset = ImageAttachment.objects.all()
@@ -617,7 +625,7 @@ class JournalEntryListView(generic.ObjectListView):
filterset = filtersets.JournalEntryFilterSet
filterset_form = forms.JournalEntryFilterForm
table = tables.JournalEntryTable
actions = ('export', 'bulk_edit', 'bulk_delete')
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
@register_model_view(JournalEntry)
@@ -666,6 +674,11 @@ class JournalEntryBulkDeleteView(generic.BulkDeleteView):
table = tables.JournalEntryTable
class JournalEntryBulkImportView(generic.BulkImportView):
queryset = JournalEntry.objects.all()
model_form = forms.JournalEntryImportForm
#
# Dashboard & widgets
#
@@ -1033,7 +1046,6 @@ class ScriptView(ContentTypePermissionRequiredMixin, View):
return 'extras.view_script'
def get(self, request, module, name):
print(module)
module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__startswith=module)
script = module.scripts[name]()
form = script.as_form(initial=normalize_querydict(request.GET))

View File

@@ -9,6 +9,7 @@ from netbox.config import get_config
from netbox.constants import RQ_QUEUE_DEFAULT
from netbox.registry import registry
from utilities.api import get_serializer_for_model
from utilities.rqworker import get_rq_retry
from utilities.utils import serialize_object
from .choices import *
from .models import Webhook
@@ -116,5 +117,6 @@ def flush_webhooks(queue):
snapshots=data['snapshots'],
timestamp=str(timezone.now()),
username=data['username'],
request_id=data['request_id']
request_id=data['request_id'],
retry=get_rq_retry()
)

View File

@@ -262,38 +262,21 @@ class IPRangeForm(TenancyForm, NetBoxModelForm):
class IPAddressForm(TenancyForm, NetBoxModelForm):
device = DynamicModelChoiceField(
queryset=Device.objects.all(),
required=False,
initial_params={
'interfaces': '$interface'
}
)
interface = DynamicModelChoiceField(
queryset=Interface.objects.all(),
required=False,
query_params={
'device_id': '$device'
}
)
virtual_machine = DynamicModelChoiceField(
queryset=VirtualMachine.objects.all(),
required=False,
initial_params={
'interfaces': '$vminterface'
}
selector=True,
)
vminterface = DynamicModelChoiceField(
queryset=VMInterface.objects.all(),
required=False,
selector=True,
label=_('Interface'),
query_params={
'virtual_machine_id': '$virtual_machine'
}
)
fhrpgroup = DynamicModelChoiceField(
queryset=FHRPGroup.objects.all(),
required=False,
selector=True,
label=_('FHRP Group')
)
vrf = DynamicModelChoiceField(
@@ -301,33 +284,11 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
required=False,
label=_('VRF')
)
nat_device = DynamicModelChoiceField(
queryset=Device.objects.all(),
required=False,
selector=True,
label=_('Device')
)
nat_virtual_machine = DynamicModelChoiceField(
queryset=VirtualMachine.objects.all(),
required=False,
selector=True,
label=_('Virtual Machine')
)
nat_vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(),
required=False,
selector=True,
label=_('VRF')
)
nat_inside = DynamicModelChoiceField(
queryset=IPAddress.objects.all(),
required=False,
selector=True,
label=_('IP Address'),
query_params={
'device_id': '$nat_device',
'virtual_machine_id': '$nat_virtual_machine',
'vrf_id': '$nat_vrf',
}
)
primary_for_parent = forms.BooleanField(
required=False,
@@ -338,8 +299,8 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
class Meta:
model = IPAddress
fields = [
'address', 'vrf', 'status', 'role', 'dns_name', 'primary_for_parent', 'nat_device', 'nat_virtual_machine',
'nat_vrf', 'nat_inside', 'tenant_group', 'tenant', 'description', 'comments', 'tags',
'address', 'vrf', 'status', 'role', 'dns_name', 'primary_for_parent', 'nat_inside', 'tenant_group',
'tenant', 'description', 'comments', 'tags',
]
def __init__(self, *args, **kwargs):
@@ -354,17 +315,6 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
initial['vminterface'] = instance.assigned_object
elif type(instance.assigned_object) is FHRPGroup:
initial['fhrpgroup'] = instance.assigned_object
if instance.nat_inside:
nat_inside_parent = instance.nat_inside.assigned_object
if type(nat_inside_parent) is Interface:
initial['nat_site'] = nat_inside_parent.device.site.pk
if nat_inside_parent.device.rack:
initial['nat_rack'] = nat_inside_parent.device.rack.pk
initial['nat_device'] = nat_inside_parent.device.pk
elif type(nat_inside_parent) is VMInterface:
if cluster := nat_inside_parent.virtual_machine.cluster:
initial['nat_cluster'] = cluster.pk
initial['nat_virtual_machine'] = nat_inside_parent.virtual_machine.pk
kwargs['initial'] = initial
super().__init__(*args, **kwargs)
@@ -401,6 +351,18 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
'primary_for_parent', "Only IP addresses assigned to an interface can be designated as primary IPs."
)
# Do not allow assigning a network ID or broadcast address to an interface.
if interface and (address := self.cleaned_data.get('address')):
if address.ip == address.network:
msg = f"{address} is a network ID, which may not be assigned to an interface."
if address.version == 4 and address.prefixlen not in (31, 32):
raise ValidationError(msg)
if address.version == 6 and address.prefixlen not in (127, 128):
raise ValidationError(msg)
if address.ip == address.broadcast:
msg = f"{address} is a broadcast address, which may not be assigned to an interface."
raise ValidationError(msg)
def save(self, *args, **kwargs):
ipaddress = super().save(*args, **kwargs)

View File

@@ -783,6 +783,14 @@ class IPAddress(PrimaryModel):
if available_ips:
return next(iter(available_ips))
def get_related_ips(self):
"""
Return all IPAddresses belonging to the same VRF.
"""
return IPAddress.objects.exclude(address=str(self.address)).filter(
vrf=self.vrf, address__net_contained_or_equal=str(self.address)
)
def clean(self):
super().clean()

View File

@@ -9,11 +9,13 @@ from circuits.models import Provider
from dcim.filtersets import InterfaceFilterSet
from dcim.models import Interface, Site
from netbox.views import generic
from tenancy.views import ObjectContactsView
from utilities.utils import count_related
from utilities.views import ViewTab, register_model_view
from virtualization.filtersets import VMInterfaceFilterSet
from virtualization.models import VMInterface
from . import filtersets, forms, tables
from .choices import PrefixStatusChoices
from .constants import *
from .models import *
from .tables.l2vpn import L2VPNTable, L2VPNTerminationTable
@@ -495,7 +497,7 @@ class PrefixView(generic.ObjectView):
# Parent prefixes table
parent_prefixes = Prefix.objects.restrict(request.user, 'view').filter(
Q(vrf=instance.vrf) | Q(vrf__isnull=True)
Q(vrf=instance.vrf) | Q(vrf__isnull=True, status=PrefixStatusChoices.STATUS_CONTAINER)
).filter(
prefix__net_contains=str(instance.prefix)
).prefetch_related(
@@ -754,19 +756,9 @@ class IPAddressView(generic.ObjectView):
# Limit to a maximum of 10 duplicates displayed here
duplicate_ips_table = tables.IPAddressTable(duplicate_ips[:10], orderable=False)
# Related IP table
related_ips = IPAddress.objects.restrict(request.user, 'view').exclude(
address=str(instance.address)
).filter(
vrf=instance.vrf, address__net_contained_or_equal=str(instance.address)
)
related_ips_table = tables.IPAddressTable(related_ips, orderable=False)
related_ips_table.configure(request)
return {
'parent_prefixes_table': parent_prefixes_table,
'duplicate_ips_table': duplicate_ips_table,
'related_ips_table': related_ips_table,
}
@@ -871,6 +863,24 @@ class IPAddressBulkDeleteView(generic.BulkDeleteView):
table = tables.IPAddressTable
@register_model_view(IPAddress, 'related_ips', path='related-ip-addresses')
class IPAddressRelatedIPsView(generic.ObjectChildrenView):
queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant')
child_model = IPAddress
table = tables.IPAddressTable
filterset = filtersets.IPAddressFilterSet
template_name = 'ipam/ipaddress/ip_addresses.html'
tab = ViewTab(
label=_('Related IPs'),
badge=lambda x: x.get_related_ips().count(),
weight=500,
hide_if_empty=True,
)
def get_children(self, request, parent):
return parent.get_related_ips().restrict(request.user, 'view')
#
# VLAN groups
#
@@ -1291,6 +1301,11 @@ class L2VPNBulkDeleteView(generic.BulkDeleteView):
table = tables.L2VPNTable
@register_model_view(L2VPN, 'contacts')
class L2VPNContactsView(ObjectContactsView):
queryset = L2VPN.objects.all()
#
# L2VPN terminations
#

View File

@@ -14,35 +14,13 @@ __all__ = (
class CustomFieldModelSerializer(serializers.Serializer):
"""
Introduces support for custom field assignment. Adds `custom_fields` serialization and ensures
that custom field data is populated upon initialization.
Introduces support for custom field assignment and representation.
"""
custom_fields = CustomFieldsDataField(
source='custom_field_data',
default=CreateOnlyDefault(CustomFieldDefaultValues())
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance is not None:
# Retrieve the set of CustomFields which apply to this type of object
content_type = ContentType.objects.get_for_model(self.Meta.model)
fields = CustomField.objects.filter(content_types=content_type)
# Populate custom field values for each instance from database
if type(self.instance) in (list, tuple):
for obj in self.instance:
self._populate_custom_fields(obj, fields)
else:
self._populate_custom_fields(self.instance, fields)
def _populate_custom_fields(self, instance, custom_fields):
instance.custom_fields = {}
for field in custom_fields:
instance.custom_fields[field.name] = instance.cf.get(field.name)
class TaggableModelSerializer(serializers.Serializer):
"""

View File

@@ -156,8 +156,11 @@ class RemoteUserBackend(_RemoteUserBackend):
try:
group_list.append(Group.objects.get(name=name))
except Group.DoesNotExist:
logging.error(
f"Could not assign group {name} to remotely-authenticated user {user}: Group not found")
if settings.REMOTE_AUTH_AUTO_CREATE_GROUPS:
group_list.append(Group.objects.create(name=name))
else:
logging.error(
f"Could not assign group {name} to remotely-authenticated user {user}: Group not found")
if group_list:
user.groups.set(group_list)
logger.debug(

View File

@@ -28,6 +28,17 @@ PARAMS = (
),
},
),
ConfigParam(
name='BANNER_MAINTENANCE',
label=_('Maintenance banner'),
default='NetBox is currently in maintenance mode. Functionality may be limited.',
description=_('Additional content to display when in maintenance mode'),
field_kwargs={
'widget': forms.Textarea(
attrs={'class': 'vLargeTextField'}
),
},
),
ConfigParam(
name='BANNER_TOP',
label=_('Top banner'),

View File

@@ -13,6 +13,7 @@ ALLOWED_HOSTS = []
# PostgreSQL database configuration. See the Django documentation for a complete list of available parameters:
# https://docs.djangoproject.com/en/stable/ref/settings/#databases
DATABASE = {
'ENGINE': 'django.db.backends.postgresql', # Database engine
'NAME': 'netbox', # Database name
'USER': '', # PostgreSQL username
'PASSWORD': '', # PostgreSQL password

View File

@@ -3,19 +3,21 @@ import uuid
from urllib import parse
from django.conf import settings
from django.contrib import auth
from django.contrib import auth, messages
from django.contrib.auth.middleware import RemoteUserMiddleware as RemoteUserMiddleware_
from django.core.exceptions import ImproperlyConfigured
from django.db import ProgrammingError
from django.db import connection, ProgrammingError
from django.db.utils import InternalError
from django.http import Http404, HttpResponseRedirect
from extras.context_managers import change_logging
from netbox.config import clear_config
from netbox.config import clear_config, get_config
from netbox.views import handler_500
from utilities.api import is_api_request, rest_api_server_error
__all__ = (
'CoreMiddleware',
'MaintenanceModeMiddleware',
'RemoteUserMiddleware',
)
@@ -166,3 +168,47 @@ class RemoteUserMiddleware(RemoteUserMiddleware_):
groups = []
logger.debug(f"Groups are {groups}")
return groups
class MaintenanceModeMiddleware:
"""
Middleware that checks if the application is in maintenance mode
and restricts write-related operations to the database.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if get_config().MAINTENANCE_MODE:
self._set_session_type(
allow_write=request.path_info.startswith(settings.MAINTENANCE_EXEMPT_PATHS)
)
return self.get_response(request)
@staticmethod
def _set_session_type(allow_write):
"""
Prevent any write-related database operations.
Args:
allow_write (bool): If True, write operations will be permitted.
"""
with connection.cursor() as cursor:
mode = 'READ WRITE' if allow_write else 'READ ONLY'
cursor.execute(f'SET SESSION CHARACTERISTICS AS TRANSACTION {mode};')
def process_exception(self, request, exception):
"""
Prevent any write-related database operations if an exception is raised.
"""
if isinstance(exception, InternalError):
error_message = 'NetBox is currently operating in maintenance mode and is unable to perform write ' \
'operations. Please try again later.'
if is_api_request(request):
return rest_api_server_error(request, error=error_message)
messages.error(request, error_message)
return HttpResponseRedirect(request.path_info)

View File

@@ -67,8 +67,8 @@ class NetBoxModel(CloningMixin, NetBoxFeatureSet, models.Model):
for field in self._meta.get_fields():
if isinstance(field, GenericForeignKey):
ct_value = getattr(self, field.ct_field)
fk_value = getattr(self, field.fk_field)
ct_value = getattr(self, field.ct_field, None)
fk_value = getattr(self, field.fk_field, None)
if ct_value is None and fk_value is not None:
raise ValidationError({

View File

@@ -292,6 +292,7 @@ CUSTOMIZATION_MENU = Menu(
get_model_item('extras', 'exporttemplate', _('Export Templates')),
get_model_item('extras', 'savedfilter', _('Saved Filters')),
get_model_item('extras', 'tag', 'Tags'),
get_model_item('extras', 'imageattachment', _('Image Attachments'), actions=()),
),
),
MenuGroup(
@@ -336,7 +337,7 @@ OPERATIONS_MENU = Menu(
MenuGroup(
label=_('Logging'),
items=(
get_model_item('extras', 'journalentry', _('Journal Entries'), actions=[]),
get_model_item('extras', 'journalentry', _('Journal Entries'), actions=['import']),
get_model_item('extras', 'objectchange', _('Change Log'), actions=[]),
),
),

View File

@@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
# Environment setup
#
VERSION = '3.5.0'
VERSION = '3.5.2'
# Hostname
HOSTNAME = platform.node()
@@ -122,6 +122,7 @@ PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {})
QUEUE_MAPPINGS = getattr(configuration, 'QUEUE_MAPPINGS', {})
RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None)
REMOTE_AUTH_AUTO_CREATE_USER = getattr(configuration, 'REMOTE_AUTH_AUTO_CREATE_USER', False)
REMOTE_AUTH_AUTO_CREATE_GROUPS = getattr(configuration, 'REMOTE_AUTH_AUTO_CREATE_GROUPS', False)
REMOTE_AUTH_BACKEND = getattr(configuration, 'REMOTE_AUTH_BACKEND', 'netbox.authentication.RemoteUserBackend')
REMOTE_AUTH_DEFAULT_GROUPS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_GROUPS', [])
REMOTE_AUTH_DEFAULT_PERMISSIONS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_PERMISSIONS', {})
@@ -139,6 +140,8 @@ REMOTE_AUTH_STAFF_USERS = getattr(configuration, 'REMOTE_AUTH_STAFF_USERS', [])
REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATOR', '|')
REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
RQ_RETRY_INTERVAL = getattr(configuration, 'RQ_RETRY_INTERVAL', 60)
RQ_RETRY_MAX = getattr(configuration, 'RQ_RETRY_MAX', 0)
SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
SEARCH_BACKEND = getattr(configuration, 'SEARCH_BACKEND', 'netbox.search.backends.CachedValueSearchBackend')
SECURE_SSL_REDIRECT = getattr(configuration, 'SECURE_SSL_REDIRECT', False)
@@ -182,15 +185,16 @@ if RELEASE_CHECK_URL:
# Database
#
# Only PostgreSQL is supported
if METRICS_ENABLED:
DATABASE.update({
'ENGINE': 'django_prometheus.db.backends.postgresql'
})
else:
DATABASE.update({
'ENGINE': 'django.db.backends.postgresql'
})
if 'ENGINE' not in DATABASE:
# Only PostgreSQL is supported
if METRICS_ENABLED:
DATABASE.update({
'ENGINE': 'django_prometheus.db.backends.postgresql'
})
else:
DATABASE.update({
'ENGINE': 'django.db.backends.postgresql'
})
DATABASES = {
'default': DATABASE,
@@ -381,6 +385,7 @@ MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'netbox.middleware.RemoteUserMiddleware',
'netbox.middleware.CoreMiddleware',
'netbox.middleware.MaintenanceModeMiddleware',
'django_prometheus.middleware.PrometheusAfterMiddleware',
]
@@ -475,6 +480,11 @@ AUTH_EXEMPT_PATHS = (
f'/{BASE_PATH}metrics',
)
# All URLs starting with a string listed here are exempt from maintenance mode enforcement
MAINTENANCE_EXEMPT_PATHS = (
f'/{BASE_PATH}admin/',
)
SERIALIZATION_MODULES = {
'json': 'utilities.serializers.json',
}
@@ -616,13 +626,15 @@ REST_FRAMEWORK = {
#
SPECTACULAR_SETTINGS = {
'TITLE': 'NetBox API',
'DESCRIPTION': 'API to access NetBox',
'TITLE': 'NetBox REST API',
'LICENSE': {'name': 'Apache v2 License'},
'VERSION': VERSION,
'COMPONENT_SPLIT_REQUEST': True,
'REDOC_DIST': 'SIDECAR',
'SERVERS': [{'url': f'/{BASE_PATH}api'}],
'SERVERS': [{
'url': BASE_PATH,
'description': 'NetBox',
}],
'SWAGGER_UI_DIST': 'SIDECAR',
'SWAGGER_UI_FAVICON_HREF': 'SIDECAR',
'POSTPROCESSING_HOOKS': [],

View File

@@ -310,6 +310,50 @@ class ExternalAuthenticationTestCase(TestCase):
list(new_user.groups.all())
)
@override_settings(
REMOTE_AUTH_ENABLED=True,
REMOTE_AUTH_AUTO_CREATE_USER=True,
REMOTE_AUTH_GROUP_SYNC_ENABLED=True,
REMOTE_AUTH_AUTO_CREATE_GROUPS=True,
LOGIN_REQUIRED=True,
)
def test_remote_auth_remote_groups_autocreate(self):
"""
Test enabling remote authentication with group sync and autocreate
enabled with the default configuration.
"""
headers = {
"HTTP_REMOTE_USER": "remoteuser2",
"HTTP_REMOTE_USER_GROUP": "Group 1|Group 2",
}
self.assertTrue(settings.REMOTE_AUTH_ENABLED)
self.assertTrue(settings.REMOTE_AUTH_AUTO_CREATE_USER)
self.assertTrue(settings.REMOTE_AUTH_AUTO_CREATE_GROUPS)
self.assertTrue(settings.REMOTE_AUTH_GROUP_SYNC_ENABLED)
self.assertEqual(settings.REMOTE_AUTH_HEADER, "HTTP_REMOTE_USER")
self.assertEqual(settings.REMOTE_AUTH_GROUP_HEADER, "HTTP_REMOTE_USER_GROUP")
self.assertEqual(settings.REMOTE_AUTH_GROUP_SEPARATOR, "|")
groups = (
Group(name="Group 1"),
Group(name="Group 2"),
)
response = self.client.get(reverse("home"), follow=True, **headers)
self.assertEqual(response.status_code, 200)
new_user = User.objects.get(username="remoteuser2")
self.assertEqual(
int(self.client.session.get("_auth_user_id")),
new_user.pk,
msg="Authentication failed",
)
self.assertListEqual(
[group.name for group in groups],
[group.name for group in list(new_user.groups.all())],
)
@override_settings(
REMOTE_AUTH_ENABLED=True,
REMOTE_AUTH_AUTO_CREATE_USER=True,

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -231,6 +231,10 @@ table {
p {
// Remove spacing from paragraph elements within tables.
margin-bottom: 0.5em;
}
p:last-child {
margin-bottom: 0;
}
}

View File

@@ -77,10 +77,10 @@ Blocks:
</div>
{% endif %}
{% if config.MAINTENANCE_MODE %}
{% if config.MAINTENANCE_MODE and config.BANNER_MAINTENANCE %}
<div class="alert alert-warning text-center mx-3" role="alert">
<h5><i class="mdi mdi-alert"></i> Maintenance Mode</h5>
NetBox is currently in maintenance mode. Functionality may be limited.
{{ config.BANNER_MAINTENANCE|escape }}
</div>
{% endif %}

View File

@@ -70,7 +70,6 @@
<div class="col col-md-6">
{% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %}
{% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %}
{% include 'inc/panels/contacts.html' %}
{% include 'inc/panels/image_attachments.html' %}
{% plugin_right_page object %}
</div>

View File

@@ -132,9 +132,16 @@
</tr>
{% for field, value in fields.items %}
<tr>
<td>
<span title="{{ field.description|escape }}">{{ field }}</span>
</td>
<th scope="row">{{ field }}
{% if field.description %}
<i
class="mdi mdi-information text-primary"
data-bs-toggle="tooltip"
data-bs-placement="right"
title="{{ field.description|escape }}"
></i>
{% endif %}
</th>
<td>
{% customfield_value field value %}
</td>

View File

@@ -29,17 +29,6 @@
{% endfor %}
</td>
</tr>
<tr>
<th scope="row">
Account <i
class="mdi mdi-alert-box text-warning"
data-bs-toggle="tooltip"
data-bs-placement="right"
title="This field has been deprecated, and will be removed in NetBox v3.5."
></i>
</th>
<td>{{ object.account|placeholder }}</td>
</tr>
<tr>
<th scope="row">Description</th>
<td>{{ object.description|placeholder }}</td>
@@ -54,7 +43,6 @@
<div class="col col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/contacts.html' %}
{% plugin_right_page object %}
</div>
</div>

View File

@@ -38,7 +38,6 @@
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/comments.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/contacts.html' %}
{% plugin_right_page object %}
</div>
<div class="col col-md-12">

View File

@@ -298,8 +298,30 @@
</div>
{% endif %}
</div>
{% include 'inc/panels/contacts.html' %}
{% include 'inc/panels/image_attachments.html' %}
<div class="card">
<h5 class="card-header">Dimensions</h5>
<div class="card-body table-responsive">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Height</th>
<td>
{{ object.device_type.u_height }}U
</td>
</tr>
<tr>
<th scope="row">Weight</th>
<td>
{% if object.total_weight %}
{{ object.total_weight|floatformat }} Kilograms
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
</table>
</div>
</div>
{% if object.rack and object.position %}
<div class="row" style="margin-bottom: 20px">
<div class="col col-md-6 col-sm-6 col-xs-12 text-center">

View File

@@ -123,11 +123,11 @@
<table class="table table-hover">
<tr>
<th scope="row">MAC Address</th>
<td><span class="text-monospace">{{ object.mac_address|placeholder }}</span></td>
<td><span class="font-monospace">{{ object.mac_address|placeholder }}</span></td>
</tr>
<tr>
<th scope="row">WWN</th>
<td><span class="text-monospace">{{ object.wwn|placeholder }}</span></td>
<td><span class="font-monospace">{{ object.wwn|placeholder }}</span></td>
</tr>
<tr>
<th scope="row">VRF</th>

View File

@@ -65,7 +65,6 @@
</div>
<div class="col col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/contacts.html' %}
{% include 'dcim/inc/nonracked_devices.html' %}
{% include 'inc/panels/image_attachments.html' %}
{% plugin_right_page object %}

View File

@@ -51,7 +51,6 @@
<div class="col col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/contacts.html' %}
{% plugin_right_page object %}
</div>
</div>

View File

@@ -40,7 +40,6 @@
<div class="col col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/contacts.html' %}
{% include 'inc/panels/image_attachments.html' %}
{% plugin_right_page object %}
</div>

View File

@@ -191,7 +191,6 @@
</div>
{% include 'inc/panels/related_objects.html' %}
{% include 'dcim/inc/nonracked_devices.html' %}
{% include 'inc/panels/contacts.html' %}
{% plugin_right_page object %}
</div>
</div>

View File

@@ -46,7 +46,6 @@
</div>
<div class="col col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/contacts.html' %}
{% plugin_right_page object %}
</div>
</div>

View File

@@ -87,11 +87,13 @@
<th scope="row">Physical Address</th>
<td class="position-relative">
{% if object.physical_address %}
<div class="position-absolute top-50 end-0 translate-middle-y noprint">
<a href="{{ config.MAPS_URL }}{{ object.physical_address|urlencode }}" target="_blank" class="btn btn-primary btn-sm">
<i class="mdi mdi-map-marker"></i> Map
</a>
</div>
{% if config.MAPS_URL %}
<div class="position-absolute top-50 end-0 translate-middle-y noprint">
<a href="{{ config.MAPS_URL }}{{ object.physical_address|urlencode }}" target="_blank" class="btn btn-primary btn-sm">
<i class="mdi mdi-map-marker"></i> Map
</a>
</div>
{% endif %}
<span>{{ object.physical_address|linebreaksbr }}</span>
{% else %}
{{ ''|placeholder }}
@@ -106,11 +108,13 @@
<th scope="row">GPS Coordinates</th>
<td class="position-relative">
{% if object.latitude and object.longitude %}
<div class="position-absolute top-50 end-0 translate-middle-y noprint">
<a href="{{ config.MAPS_URL }}{{ object.latitude }},{{ object.longitude }}" target="_blank" class="btn btn-primary btn-sm">
<i class="mdi mdi-map-marker"></i> Map It
</a>
</div>
{% if config.MAPS_URL %}
<div class="position-absolute top-50 end-0 translate-middle-y noprint">
<a href="{{ config.MAPS_URL }}{{ object.latitude }},{{ object.longitude }}" target="_blank" class="btn btn-primary btn-sm">
<i class="mdi mdi-map-marker"></i> Map It
</a>
</div>
{% endif %}
<span>{{ object.latitude }}, {{ object.longitude }}</span>
{% else %}
{{ ''|placeholder }}
@@ -127,7 +131,6 @@
</div>
<div class="col col-md-6">
{% include 'inc/panels/related_objects.html' with filter_name='site_id' %}
{% include 'inc/panels/contacts.html' %}
<div class="card">
<h5 class="card-header">Locations</h5>
<div class='card-body'>

View File

@@ -42,7 +42,6 @@
</div>
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/contacts.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">

View File

@@ -32,7 +32,7 @@
</tr>
<tr>
<th scope="row">Description</th>
<td>{{ object.description|placeholder }}</td>
<td>{{ object.description|markdown|placeholder }}</td>
</tr>
<tr>
<th scope="row">Required</th>

View File

@@ -1,5 +1,5 @@
{% if htmx_url and has_permission %}
<div class="htmx-container" hx-get="{{ htmx_url }}{% if page_size %}?per_page={{ page_size }}{% endif %}" hx-trigger="load"></div>
<div class="htmx-container" hx-get="{{ htmx_url }}" hx-trigger="load"></div>
{% elif htmx_url %}
<div class="text-muted text-center">
<i class="mdi mdi-lock-outline"></i> No permission to view this content.

View File

@@ -1,4 +1,4 @@
{% if not feed.bozo %}
{% if feed and not feed.bozo %}
<div class="list-group list-group-flush">
{% for entry in feed.entries %}
<div class="list-group-item px-1">
@@ -16,7 +16,9 @@
<span class="text-danger">
<i class="mdi mdi-alert"></i> There was a problem fetching the RSS feed:
</span>
<pre class="m-2">
Response status: {{ feed.status }}
Error: {{ feed.bozo_exception|escape }}</pre>
{% if feed %}
{{ feed.bozo_exception|escape }} (HTTP {{ feed.status }})
{% else %}
{{ error }}
{% endif %}
{% endif %}

View File

@@ -37,43 +37,49 @@
</h5>
<div class="card-body">
{% include 'inc/sync_warning.html' with object=module %}
<table class="table table-hover table-headings reports">
<thead>
<tr>
<th width="250">Name</th>
<th>Description</th>
<th>Last Run</th>
<th class="text-end">Status</th>
</tr>
</thead>
<tbody>
{% with jobs=module.get_latest_jobs %}
{% for script_name, script_class in module.scripts.items %}
<tr>
<td>
<a href="{% url 'extras:script' module=module.python_name name=script_name %}" name="script.{{ script_name }}">{{ script_class.name }}</a>
</td>
<td>
{{ script_class.Meta.description|markdown|placeholder }}
</td>
{% with last_result=jobs|get_key:script_class.name %}
{% if last_result %}
<td>
<a href="{% url 'extras:script_result' job_pk=last_result.pk %}">{{ last_result.created|annotated_date }}</a>
</td>
<td class="text-end">
{% badge last_result.get_status_display last_result.get_status_color %}
</td>
{% else %}
<td class="text-muted">Never</td>
<td class="text-end">{{ ''|placeholder }}</td>
{% endif %}
{% endwith %}
</tr>
{% endfor %}
{% endwith %}
</tbody>
</table>
{% if not module.scripts %}
<div class="alert alert-warning d-flex align-items-center" role="alert">
<i class="mdi mdi-alert"></i>&nbsp; Script file at: {{module.full_path}} could not be loaded.
</div>
{% else %}
<table class="table table-hover table-headings reports">
<thead>
<tr>
<th width="250">Name</th>
<th>Description</th>
<th>Last Run</th>
<th class="text-end">Status</th>
</tr>
</thead>
<tbody>
{% with jobs=module.get_latest_jobs %}
{% for script_name, script_class in module.scripts.items %}
<tr>
<td>
<a href="{% url 'extras:script' module=module.python_name name=script_name %}" name="script.{{ script_name }}">{{ script_class.name }}</a>
</td>
<td>
{{ script_class.Meta.description|markdown|placeholder }}
</td>
{% with last_result=jobs|get_key:script_class.name %}
{% if last_result %}
<td>
<a href="{% url 'extras:script_result' job_pk=last_result.pk %}">{{ last_result.created|annotated_date }}</a>
</td>
<td class="text-end">
{% badge last_result.get_status_display last_result.get_status_color %}
</td>
{% else %}
<td class="text-muted">Never</td>
<td class="text-end">{{ ''|placeholder }}</td>
{% endif %}
{% endwith %}
</tr>
{% endfor %}
{% endwith %}
</tbody>
</table>
{% endif %}
</div>
</div>
{% empty %}

View File

@@ -1,63 +0,0 @@
{% load helpers %}
<div class="card">
<h5 class="card-header">Contacts</h5>
<div class="card-body">
{% with contacts=object.contacts.all %}
{% if contacts.exists %}
<table class="table table-hover">
<tr>
<th>Name</th>
<th>Role</th>
<th>Priority</th>
<th>Phone</th>
<th>Email</th>
<th></th>
</tr>
{% for contact in contacts %}
<tr>
<td>{{ contact.contact|linkify }}</td>
<td>{{ contact.role|placeholder }}</td>
<td>{{ contact.get_priority_display|placeholder }}</td>
<td>
{% if contact.contact.phone %}
<a href="tel:{{ contact.contact.phone }}">{{ contact.contact.phone }}</a>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
<td>
{% if contact.contact.email %}
<a href="mailto:{{ contact.contact.email }}">{{ contact.contact.email }}</a>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
<td class="text-end noprint">
{% if perms.tenancy.change_contactassignment %}
<a href="{% url 'tenancy:contactassignment_edit' pk=contact.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-warning btn-sm lh-1" title="Edit">
<i class="mdi mdi-pencil" aria-hidden="true"></i>
</a>
{% endif %}
{% if perms.tenancy.delete_contactassignment %}
<a href="{% url 'tenancy:contactassignment_delete' pk=contact.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-danger btn-sm lh-1" title="Delete">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i>
</a>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
{% else %}
<div class="text-muted">None</div>
{% endif %}
{% endwith %}
</div>
{% if perms.tenancy.add_contactassignment %}
<div class="card-footer text-end noprint">
<a href="{% url 'tenancy:contactassignment_add' %}?content_type={{ object|content_type_id }}&object_id={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add a contact
</a>
</div>
{% endif %}
</div>

View File

@@ -12,8 +12,15 @@
<table class="table table-hover attr-table">
{% for field, value in fields.items %}
<tr>
<th scope="row">
<span title="{{ field.description|escape }}">{{ field }}</span>
<th scope="row">{{ field }}
{% if field.description %}
<i
class="mdi mdi-information text-primary"
data-bs-toggle="tooltip"
data-bs-placement="right"
title="{{ field.description|escape }}"
></i>
{% endif %}
</th>
<td>
{% customfield_value field value %}

View File

@@ -4,44 +4,9 @@
<h5 class="card-header">
Images
</h5>
<div class="card-body">
{% with images=object.images.all %}
{% if images.exists %}
<table class="table table-hover">
<tr>
<th>Name</th>
<th>Size</th>
<th>Created</th>
<th></th>
</tr>
{% for attachment in images %}
<tr{% if not attachment.size %} class="table-danger"{% endif %}>
<td>
<i class="mdi mdi-file-image-outline"></i>
<a class="image-preview" href="{{ attachment.image.url }}" target="_blank">{{ attachment }}</a>
</td>
<td>{{ attachment.size|filesizeformat }}</td>
<td>{{ attachment.created|annotated_date }}</td>
<td class="text-end noprint">
{% if perms.extras.change_imageattachment %}
<a href="{% url 'extras:imageattachment_edit' pk=attachment.pk %}" class="btn btn-warning btn-sm lh-1" title="Edit Image">
<i class="mdi mdi-pencil" aria-hidden="true"></i>
</a>
{% endif %}
{% if perms.extras.delete_imageattachment %}
<a href="{% url 'extras:imageattachment_delete' pk=attachment.pk %}" class="btn btn-danger btn-sm lh-1" title="Delete Image">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i>
</a>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
{% else %}
<div class="text-muted">None</div>
{% endif %}
{% endwith %}
</div>
<div class="card-body htmx-container table-responsive"
hx-get="{% url 'extras:imageattachment_list' %}?content_type_id={{ object|content_type_id }}&object_id={{ object.pk }}"
hx-trigger="load"></div>
{% if perms.extras.add_imageattachment %}
<div class="card-footer text-end noprint">
<a href="{% url 'extras:imageattachment_add' %}?content_type={{ object|content_type_id }}&object_id={{ object.pk }}" class="btn btn-primary btn-sm">

View File

@@ -3,13 +3,6 @@
{% load plugins %}
{% load render_table from django_tables2 %}
{% block breadcrumbs %}
{{ block.super }}
{% if object.vrf %}
<li class="breadcrumb-item"><a href="{% url 'ipam:ipaddress_list' %}?vrf_id={{ object.vrf.pk }}">{{ object.vrf }}</a></li>
{% endif %}
{% endblock %}
{% block content %}
<div class="row">
<div class="col col-md-4">
@@ -116,7 +109,6 @@
{% if duplicate_ips_table.rows %}
{% include 'inc/panel_table.html' with table=duplicate_ips_table heading='Duplicate IPs' panel_class='danger' %}
{% endif %}
{% include 'inc/panel_table.html' with table=related_ips_table heading='Related IPs' %}
<div class="card">
<h5 class="card-header">Services</h5>
<div class="card-body htmx-container table-responsive"

View File

@@ -0,0 +1,8 @@
{% extends 'generic/object.html' %}
{% block breadcrumbs %}
{{ block.super }}
{% if object.vrf %}
<li class="breadcrumb-item"><a href="{% url 'ipam:ipaddress_list' %}?vrf_id={{ object.vrf.pk }}">{{ object.vrf }}</a></li>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,19 @@
{% extends 'ipam/ipaddress/base.html' %}
{% load helpers %}
{% block content %}
{% include 'inc/table_controls_htmx.html' with table_modal="IPAddressTable_config" %}
<form method="post">
{% csrf_token %}
<div class="card">
<div class="card-body" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>
</form>
{% endblock content %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}

View File

@@ -56,11 +56,9 @@
</div>
<div class="tab-content p-0 border-0">
<div class="tab-pane {% if not form.initial.vminterface and not form.initial.fhrpgroup %}active{% endif %}" id="device" role="tabpanel" aria-labeled-by="device_tab">
{% render_field form.device %}
{% render_field form.interface %}
</div>
<div class="tab-pane {% if form.initial.vminterface %}active{% endif %}" id="vm" role="tabpanel" aria-labeled-by="vm_tab">
{% render_field form.virtual_machine %}
{% render_field form.vminterface %}
</div>
<div class="tab-pane {% if form.initial.fhrpgroup %}active{% endif %}" id="fhrpgroup" role="tabpanel" aria-labeled-by="fhrpgroup_tab">
@@ -75,60 +73,6 @@
<h5 class="offset-sm-3">NAT IP (Inside)</h5>
</div>
<div class="row mb-2">
<div class="offset-sm-3">
<ul class="nav nav-pills" role="tablist">
<li class="nav-item" role="presentation">
<button
role="tab"
type="button"
id="device_tab"
data-bs-toggle="tab"
class="nav-link active"
data-bs-target="#by_device"
aria-controls="by_device"
>
By Device
</button>
</li>
<li class="nav-item" role="presentation">
<button
role="tab"
type="button"
id="vm_tab"
data-bs-toggle="tab"
class="nav-link"
data-bs-target="#by_vm"
aria-controls="by_vm"
>
By VM
</button>
</li>
<li class="nav-item" role="presentation">
<button
role="tab"
type="button"
id="vrf_tab"
data-bs-toggle="tab"
class="nav-link"
data-bs-target="#by_vrf"
aria-controls="by_vrf"
>
By IP
</button>
</li>
</ul>
</div>
</div>
<div class="tab-content p-0 border-0">
<div class="tab-pane active" id="by_device" aria-labelledby="device_tab" role="tabpanel">
{% render_field form.nat_device %}
</div>
<div class="tab-pane" id="by_vm" aria-labelledby="vm_tab" role="tabpanel">
{% render_field form.nat_virtual_machine %}
</div>
<div class="tab-pane" id="by_vrf" aria-labelledby="vrf_tab" role="tabpanel">
{% render_field form.nat_vrf %}
</div>
{% render_field form.nat_inside %}
</div>
</div>

View File

@@ -37,7 +37,6 @@
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
{% include 'inc/panels/contacts.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/comments.html' %}
{% plugin_right_page object %}

View File

@@ -0,0 +1,27 @@
{% extends base_template %}
{% load helpers %}
{% block extra_controls %}
{% if perms.tenancy.add_contactassignment %}
<a href="{% url 'tenancy:contactassignment_add' %}?content_type={{ object|content_type_id }}&object_id={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add a contact
</a>
{% endif %}
{% endblock %}
{% block content %}
{% include 'inc/table_controls_htmx.html' with table_modal="ContactTable_config" %}
<form method="post">
{% csrf_token %}
<div class="card">
<div class="card-body" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>
</form>
{% endblock content %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}

View File

@@ -30,7 +30,6 @@
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/comments.html' %}
{% include 'inc/panels/contacts.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-5">

View File

@@ -84,7 +84,6 @@
</div>
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/contacts.html' %}
{% plugin_right_page object %}
</div>
</div>

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