Compare commits

..

88 Commits

Author SHA1 Message Date
Jeff Gehlbach
3f67b5d8cb Merge pull request #16859 from netbox-community/develop
Release v4.0.7
2024-07-09 13:43:51 -04:00
Jeff Gehlbach
596514ce74 Release v4.0.7 2024-07-09 13:27:13 -04:00
transifex-integration[bot]
aafb26662a Updates for project NetBox (#16811)
* Translate django.po in pt

100% reviewed source file: 'django.po'
on 'pt'.

* Translate django.po in fr [Manual Sync]

12% of minimum 1% reviewed source file: 'django.po'
on 'fr'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

* Translate django.po in pt [Manual Sync]

100% reviewed source file: 'django.po'
on 'pt'.

* Translate django.po in de [Manual Sync]

78% of minimum 1% reviewed source file: 'django.po'
on 'de'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

* Translate django.po in tr [Manual Sync]

7% of minimum 1% reviewed source file: 'django.po'
on 'tr'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

* Translate django.po in ru [Manual Sync]

30% of minimum 1% reviewed source file: 'django.po'
on 'ru'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

* Translate django.po in zh [Manual Sync]

16% of minimum 1% reviewed source file: 'django.po'
on 'zh'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

---------

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2024-07-09 13:18:19 -04:00
Jeremy Stretch
4c797bf755 Update dependencies for v4.0.7 2024-07-09 13:05:57 -04:00
Jeremy Stretch
aceed94787 Changelog for #14554, #16721, #16758, #16802, #16808, #16817, #16843 2024-07-09 12:19:39 -04:00
Martin
6a1245c792 Fixes #16758: Create language cookie if required (#16764)
* Fixes #16758: Create language cookie if required

* Align language cookie with session lifetime
2024-07-09 08:51:12 -04:00
Mattias L
96ff796b94 Allowed configuration of Sentry send_default_pii parameter (#16803)
* Allowed configuration of Sentry send_default_pii parameter.

Also changed default value of send_default_pii to False to avoid sending sensitive data to Sentry.

Closes #16802

* Order alphabetically & link to Sentry parameter documentation

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-07-09 08:39:43 -04:00
locklearxd
d8d66581cc potential fix for IKE policy mode issue with version2 2024-07-09 08:35:02 -04:00
Jeremy Stretch
7564f6f538 Fixes #16808: Correct event type of webhooks emitted upon object deletion immediately following a modification 2024-07-09 08:17:46 -04:00
github-actions
f2e3c1a219 Update source translation strings 2024-07-09 05:02:43 +00:00
Arzhel Younsi
22348cdbfc Extend STORAGE_BACKEND config to support Swift (#16319)
* Extend STORAGE_BACKEND config to support Swift

Requires django-storage-swift >= 1.4.0 when used.

Bug: T310717
Change-Id: I67cf439e9152608cbba3a3de4173d54ba5fbddc2

* Update system.md from suggestions

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

* Update settings.py from suggestions

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

* Update system.md from suggestions 2

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

* Remove SWIFT storage from configuration_example.py

* Load swift config as global instead of monkey path

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-07-08 12:04:17 -04:00
Adam Brutsaert
f4532dd4ab Closes #16817: Added 200 & 400 Gbps selections for circuit commit rate 2024-07-04 11:31:10 -04:00
Jeremy Stretch
e02796a64e Fixes #16721: Fix errant API request after deselecting a rack in device edit form 2024-07-04 10:28:52 -04:00
Jeremy Stretch
9ab7960a66 Changelog for #16679, #16716, #16779, #16780, #16791, #16796, #16806, #16807, #16813 2024-07-04 10:00:50 -04:00
Jeremy Stretch
7a88810a23 Fixes #16780: IKE proposal created via REST API should not require authentication_algorithm 2024-07-04 09:32:01 -04:00
Jeremy Stretch
a518579916 Fixes #16796: Allow assignment of VM with no site to a cluster with a site 2024-07-04 09:14:07 -04:00
Jeremy Stretch
e9dd5aa17b Fixes #16806: Fix redirect URL when creating contact assignments with "add another" button 2024-07-04 08:50:22 -04:00
Jeremy Stretch
8026f79cbb Fixes #16813: Fix ordering of bookmarks in dashboard widget when filtering by object type 2024-07-04 08:23:05 -04:00
github-actions
cf38c7724e Update source translation strings 2024-07-04 05:02:06 +00:00
Jeremy Stretch
b18a6b7c59 Fixes #16779: Fix saved filter selection for child object lists (#16789)
* Fixes #16779: Fix saved filter selection for child object lists

* Omit label_suffix
2024-07-03 08:51:30 -04:00
RobertH1993
98748d901b Closes #16716, add NAT IP to device view for OOB IP 2024-07-03 08:48:24 -04:00
Jeremy Stretch
a704708caa Fixes #16679: Avoid overwriting custom JSON fields during bulk edit 2024-07-03 08:42:37 -04:00
Jeremy Stretch
224f157b75 Fixes #16807: Fix layout of VLAN edit form when custom fields are present 2024-07-03 08:31:25 -04:00
Jeremy Stretch
94c2e7582e Closes #16791: Add 200 & 400 Gbps selections for circuit termination port speed 2024-07-03 08:28:12 -04:00
github-actions
4857a87be5 Update source translation strings 2024-07-02 05:02:15 +00:00
Jeremy Stretch
d3d27d8111 Changelog for #16424, #16523, #16654, #16657, #16689, #16714, #16723, #16725, #16735, #16747 2024-07-01 09:40:21 -04:00
Elliott Balsley
e2596587fa fix: allow cloning field value of 0 (#16741)
* fix: allow cloning field value of 0

* Fix evaluation of False value

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-07-01 09:22:09 -04:00
siku4
a12259fae7 fix: add missing parent field to inventory item import form 2024-07-01 09:20:49 -04:00
Peter Eckel
753ba5d3f4 Do not delete all search indexes when reindexing specific models (#16755)
* Do not delete all search indexes when reindexing specific models

* Clear all indexes only if neither --lazy nor a list of models are
  specified for "manage.py reindex"

* Otherwise, clear the index for a model immediately before rebuilding
  it

* Separated clearing from re-indexing the search cache
2024-07-01 09:12:02 -04:00
Jeremy Stretch
b5d8e657ad Fixes #16523: Restore highlighting of current device in virtual chassis members panel 2024-07-01 08:48:01 -04:00
github-actions
67983c6a75 Update source translation strings 2024-07-01 05:02:10 +00:00
prryplatypus
a00ed4b74d Quote VIRTUALENV 2024-06-30 15:21:46 -04:00
Tobias Genannt
a896b14c08 Fixes #16689: Load correct configuration
Loads the the current configuration if no ConfigRevisions are saved to
the database.
2024-06-30 15:20:26 -04:00
Julio-Oliveira-Encora
2c64a52d7d Added default:"0" to total_count in object_list.html 2024-06-30 15:10:38 -04:00
Peter Eckel
96338c002b Updated the documentation section about removing plugins 2024-06-30 15:08:11 -04:00
Julio Oliveira at Encora
00d23a0cff 16725 - The admin section should always come last in the navigation menu (#16762)
* I replaced `append` with `insert` into menu.py to make the admin section appear last in the navigation menu.

* Clean up ordering logic

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-06-30 11:30:39 -04:00
github-actions
c7dcded74f Update source translation strings 2024-06-27 05:02:05 +00:00
Julio Oliveira at Encora
c506f60f12 16424 - Allow filtering of Devices by Cluster and Cluster Group (#16674)
* Allow filtering Devices by Cluster and Cluster Group.

* Allow filtering Devices by Cluster and Cluster Group.

* Added tests for cluster and cluster_groups filterset.

* Add missing filter & complete tests

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-06-26 10:13:32 -04:00
Julio Oliveira at Encora
b241c97e00 Was added to searching support languages other than English for objec… (#16706)
* Was added to searching support languages other than English for object types(s).

* Fix SearchForm field label translation

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-06-26 09:54:15 -04:00
Julio Oliveira at Encora
b605dfcba0 16704 - Define a default help_text for ColorField (#16708)
* Added `help_text` to ColorField.

* Addressed PR comment to remove the redundant help_text from all the forms where ColorField was used.

* Add space before example value

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-06-26 09:14:08 -04:00
Jeff Gehlbach
33004dfab0 Added missing CDN cache clearing step to release checklist in docs 2024-06-25 16:21:32 -04:00
github-actions
65e40603ff Update source translation strings 2024-06-25 05:01:53 +00:00
Jeremy Stretch
7702b0ebb0 PRVB 2024-06-24 15:04:46 -04:00
Jeremy Stretch
b1d1b51304 Merge pull request #16707 from netbox-community/develop
Release v4.0.6
2024-06-24 15:00:57 -04:00
Jeremy Stretch
4ae1a1ffe9 Recompile JS assets 2024-06-24 14:46:15 -04:00
Jeremy Stretch
8107d72961 Release v4.0.6 2024-06-24 14:37:26 -04:00
transifex-integration[bot]
63239d7d9f Updates for project NetBox (#16687)
* Translate django.po in pt

100% reviewed source file: 'django.po'
on 'pt'.

* Translate django.po in pt

100% reviewed source file: 'django.po'
on 'pt'.

* Translate django.po in pt

100% reviewed source file: 'django.po'
on 'pt'.

* Translate django.po in pt

100% reviewed source file: 'django.po'
on 'pt'.

* Translate django.po in pt

100% reviewed source file: 'django.po'
on 'pt'.

* Translate django.po in pt

100% reviewed source file: 'django.po'
on 'pt'.

* Translate django.po in pt

100% reviewed source file: 'django.po'
on 'pt'.

* Translate django.po in pt

100% reviewed source file: 'django.po'
on 'pt'.

* Translate django.po in pt

100% reviewed source file: 'django.po'
on 'pt'.

* Translate django.po in pt

100% reviewed source file: 'django.po'
on 'pt'.

* Translate django.po in pt

100% reviewed source file: 'django.po'
on 'pt'.

* Translate django.po in pt

100% reviewed source file: 'django.po'
on 'pt'.

* Translate django.po in pt

100% reviewed source file: 'django.po'
on 'pt'.

* Translate django.po in pt

100% reviewed source file: 'django.po'
on 'pt'.

* Translate django.po in pt

100% reviewed source file: 'django.po'
on 'pt'.

* Translate django.po in pt

100% reviewed source file: 'django.po'
on 'pt'.

* Translate django.po in pt

100% reviewed source file: 'django.po'
on 'pt'.

* Translate django.po in pt

100% reviewed source file: 'django.po'
on 'pt'.

* Translate django.po in pt

100% reviewed source file: 'django.po'
on 'pt'.

* Translate django.po in pt

100% reviewed source file: 'django.po'
on 'pt'.

* Translate django.po in pt

100% reviewed source file: 'django.po'
on 'pt'.

* Translate django.po in pt

100% reviewed source file: 'django.po'
on 'pt'.

* Translate django.po in pt

100% reviewed source file: 'django.po'
on 'pt'.

* Translate django.po in pt

100% reviewed source file: 'django.po'
on 'pt'.

* Translate django.po in pt

100% reviewed source file: 'django.po'
on 'pt'.

* Translate django.po in pt

100% reviewed source file: 'django.po'
on 'pt'.

* Translate django.po in pt

100% reviewed source file: 'django.po'
on 'pt'.

* Translate django.po in pt

100% reviewed source file: 'django.po'
on 'pt'.

* Translate django.po in pt

100% reviewed source file: 'django.po'
on 'pt'.

* Translate django.po in pt

100% reviewed source file: 'django.po'
on 'pt'.

* Translate django.po in pt

100% reviewed source file: 'django.po'
on 'pt'.

* Translate django.po in pt

100% reviewed source file: 'django.po'
on 'pt'.

* Translate django.po in pt

100% reviewed source file: 'django.po'
on 'pt'.

* Translate django.po in pt

100% reviewed source file: 'django.po'
on 'pt'.

* Translate django.po in pt

100% reviewed source file: 'django.po'
on 'pt'.

* Translate django.po in pt

100% reviewed source file: 'django.po'
on 'pt'.

* Translate django.po in pt

100% reviewed source file: 'django.po'
on 'pt'.

* Translate django.po in fr [Manual Sync]

12% of minimum 1% reviewed source file: 'django.po'
on 'fr'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

* Translate django.po in pt [Manual Sync]

100% reviewed source file: 'django.po'
on 'pt'.

* Translate django.po in ru [Manual Sync]

30% of minimum 1% reviewed source file: 'django.po'
on 'ru'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

* Translate django.po in de [Manual Sync]

75% of minimum 1% reviewed source file: 'django.po'
on 'de'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

* Translate django.po in tr [Manual Sync]

7% of minimum 1% reviewed source file: 'django.po'
on 'tr'.

Sync of partially translated files: 
untranslated content is included with an empty translation 
or source language content depending on file format

---------

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2024-06-24 14:28:24 -04:00
Jeremy Stretch
5f3e147634 Changelog for #15717, #16149, #16252, #16273, #16307, #16702 2024-06-24 12:46:11 -04:00
Jeremy Stretch
bfd023c6a9 Fixes #16702: Fix validation of return_url query parameter 2024-06-24 12:34:35 -04:00
Jeremy Stretch
f4ac23d868 Closes #16700: Audit usage of mark_safe() for consistent escaping 2024-06-24 12:33:54 -04:00
Jeremy Stretch
8b62e40874 Closes #16307: Enable calling log_* methods on Script without a log message 2024-06-24 10:45:33 -04:00
Tobias Genannt
dbcd89c8ed Closes #16273: Add search box to menu on mobile 2024-06-24 10:06:35 -04:00
Jeremy Stretch
00d9a865c0 Closes #16367: Update census URL 2024-06-24 08:17:25 -04:00
Jeremy Stretch
ab3fd0049b Closes #16686: Relete obsolete OpenAPI definitions 2024-06-24 08:16:24 -04:00
github-actions
3e6249387a Update source translation strings 2024-06-22 05:02:11 +00:00
Arthur Hanson
85fd232614 16149 add (optional) obj hyperlink to script list table (#16271)
* 16149 add (optional) obj hyperlink to script list table

* 16149 add (optional) obj hyperlink to script list table

* 16149 review feedback

* 16149 review changes
2024-06-21 10:04:52 -04:00
Arthur Hanson
dda0b0bbd1 16252 only show results count if paginator (#16269)
* 16252 only show results count if paginator

* 16252 hack in table page count

* 16252 review changes
2024-06-21 09:48:41 -04:00
Jeff Gehlbach
3542057839 Remove dead link to project-stats anchor. Fixes #16621 2024-06-21 08:25:48 -04:00
github-actions
cb72b921ae Update source translation strings 2024-06-21 05:02:10 +00:00
Ryan Gillespie
582ede8ed3 Fixes #15717 - Unable to assign a VM in Site to Cluster without Site (#15763)
* Fixes #15717: Allow VM with Site to Cluster without Site

* Fixes #15717: Allow VM with Site to Cluster without Site

* Fixes #15717: Allow VM with Site to Cluster without Site

* Fixes #15717: Allow VM with Site to Cluster without Site

* Fixes #15717: Allow VM with Site to Cluster without Site
2024-06-20 10:59:17 -04:00
Jeff Gehlbach
32e219c70a Interim fix to SECURITY.md: Remove non-working email address 2024-06-20 09:43:23 -04:00
github-actions
7a5e8a80ea Update source translation strings 2024-06-19 05:02:34 +00:00
Jeremy Stretch
9d28af42b2 Update changelog for #15348, #16416, #16444, #16450, #16452, #16460, #16512, #16542 2024-06-18 13:33:05 -04:00
Julio Oliveira at Encora
81292df048 Feature 15348 - Quick Access Saved Filters (#15862)
* Added dropdown for Saved Filters.

* Added dropdown for Saved Filters.

* Added dropdown for Saved Filters.

* Fixed linter issues in savedFiltersSelect.ts

* Fixed linter issues in netbox.ts

* Fixed linter issues in netbox.ts

* Removed the blue tag with the filters when saved filters is selected.

* Adjusts in table_controls_htmx.html to vertical height of the Quick Search match to the dropdown.

* Adjusts in table_controls_htmx.html to vertical height of the Quick Search match to the dropdown.

* Adjusts in table_controls_htmx.html to vertical height of the Quick Search match to the dropdown.

* Minor adjusts in savedFiltersSelect.ts

* Addressed PR comment.

* Addressed PR comment.

* Addressed PR comment.

* Omit saved filters from 'applied filters'; clean up form widget

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-06-18 11:58:54 -04:00
Arthur Hanson
207c91ef6b 16460 remove spaces from telephone dialing 2024-06-18 08:30:40 -04:00
Arthur Hanson
cd9244fd4f 16416 enable dark/light toggle in mobile view (#16635)
* 16416 enable dark/light toggle in mobile view

* 16416 move to inc file
2024-06-18 08:28:18 -04:00
Jeremy Stretch
973bd0ed75 Fixes #16512: Restore a user's preferred language on login (#16628) 2024-06-18 08:17:08 -04:00
github-actions
1eebb98b56 Update source translation strings 2024-06-18 05:02:24 +00:00
Jeremy Stretch
d2a8e52585 Fixes #16444: Disable ordering circuits list by A/Z termination 2024-06-17 12:49:00 -04:00
Jeremy Stretch
b077c664e3 Fixes #16542: Fix bulk form operations when HTMX is enabled 2024-06-17 11:35:49 -04:00
Jeremy Stretch
6f35a2ac2b Fixes #16452: Fix sizing of buttons within object attribute panels 2024-06-17 11:35:10 -04:00
Jeremy Stretch
9559349541 Fixes #16450: Rack unit filter should be case-insensitive 2024-06-17 11:33:17 -04:00
Arthur Hanson
6abad9c20c 16586 add .python-version to gitignore 2024-06-17 08:04:29 -04:00
github-actions
c8aac13cee Update source translation strings 2024-06-15 05:02:20 +00:00
Jeremy Stretch
49971dd7db Changelog for #13925, #14829, #15794, #16143, #16256, #16454 2024-06-14 10:56:03 -04:00
Jeremy Stretch
b2360b62b5 Fixes #13925: Support 'zulu' style timestamps for custom fields 2024-06-14 10:38:09 -04:00
github-actions
a597ad849e Update source translation strings 2024-06-13 05:02:20 +00:00
Jeremy Stretch
83da49cfa3 Update release checklist to include building public docs 2024-06-12 12:28:27 -04:00
Alexander Haase
5353f83710 15794 Make "related objects" dynamic (#15876)
* Closes #15794: Make "related objects" dynamic

Instead of hardcoding relationships between models for the detail view,
they are now dynamically generated.

* Fix related models call

* Remove extra related models hook

Instead of providing a rarely used hook method, additional related
models can now be passed directly to the lookup method.

* Fix relations view for ASNs

ASNs have ManyToMany relationships and therefore can't used automatic
resolving. Explicit relations have been restored as before.

* Add method call keywords for clarification

* Cleanup related models

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-06-12 09:46:41 -04:00
Julio Oliveira at Encora
763d65bed9 Added current time zone to render method in DateTimeColumn (#16323) 2024-06-12 09:23:49 -04:00
github-actions
fbe64cb9a4 Update source translation strings 2024-06-12 05:02:10 +00:00
Julio Oliveira at Encora
d85cf9ee0d 16256 - Allow alphabetical ordering of bookmarks on dashboard (#16426)
* Added alphabetical ordering of bookmarks.

* Addressed PR comments.

* Rename choice constants & fix unrelated typo

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-06-11 09:21:24 -04:00
Jeremy Stretch
eb3d423077 Fixes #16454: Roll back django-debug-toolbar version to avoid DNS looukp bug 2024-06-10 12:56:32 -04:00
github-actions
56b6b1b9d8 Update source translation strings 2024-06-08 05:02:21 +00:00
Jeremy Stretch
e820c145f3 Skip CI for commits that only update translations 2024-06-07 13:50:58 -04:00
Julio Oliveira at Encora
5788b6cb28 Fixes #14829 Simple condition (without and/or) does not work in event rule (#14870) 2024-06-07 07:45:19 -07:00
github-actions
83dc92ed2d Update source translation strings 2024-06-07 05:02:09 +00:00
Jeremy Stretch
c4640534f9 PRVB 2024-06-06 12:02:30 -04:00
105 changed files with 16006 additions and 179950 deletions

View File

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

View File

@@ -5,10 +5,12 @@ on:
paths-ignore:
- 'contrib/**'
- 'docs/**'
- 'netbox/translations/**'
pull_request:
paths-ignore:
- 'contrib/**'
- 'docs/**'
- 'netbox/translations/**'
permissions:
contents: read

1
.gitignore vendored
View File

@@ -28,3 +28,4 @@ netbox.pid
.idea
.coverage
.vscode
.python-version

View File

@@ -17,7 +17,6 @@ NetBox exists to empower network engineers. Since its release in 2016, it has be
<a href="#why-netbox">Why NetBox?</a> |
<a href="#getting-started">Getting Started</a> |
<a href="#get-involved">Get Involved</a> |
<a href="#project-stats">Project Stats</a> |
<a href="#screenshots">Screenshots</a>
</p>

View File

@@ -24,7 +24,7 @@ If you believe you've uncovered a security vulnerability and wish to report it c
Please note that we **DO NOT** accept reports generated by automated tooling which merely suggest that a file or file(s) _may_ be vulnerable under certain conditions, as these are most often innocuous.
If you believe that you've found a vulnerability which meets all of these conditions, please [submit a draft security advisory](https://github.com/netbox-community/netbox/security/advisories/new) on GitHub, or email a brief description of the suspected bug and instructions for reproduction to **security@netbox.dev**. For any security concerns regarding NetBox deployed via Docker, please see the [netbox-docker](https://github.com/netbox-community/netbox-docker) project.
If you believe that you've found a vulnerability which meets all of these conditions, please [submit a draft security advisory](https://github.com/netbox-community/netbox/security/advisories/new) on GitHub. For any security concerns regarding NetBox deployed via Docker, please see the [netbox-docker](https://github.com/netbox-community/netbox-docker) project.
### Bug Bounties

View File

@@ -8,7 +8,9 @@ django-cors-headers
# Runtime UI tool for debugging Django
# https://github.com/jazzband/django-debug-toolbar/blob/main/docs/changes.rst
django-debug-toolbar
# Pinned for DNS looukp bug; see https://github.com/netbox-community/netbox/issues/16454
# and https://github.com/jazzband/django-debug-toolbar/issues/1927
django-debug-toolbar==4.3.0
# Library for writing reusable URL query filters
# https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst
@@ -108,7 +110,7 @@ Pillow
# PostgreSQL database adapter for Python
# https://github.com/psycopg/psycopg/blob/master/docs/news.rst
psycopg[binary,pool]
psycopg[c,pool]
# YAML rendering library
# https://github.com/yaml/pyyaml/blob/master/CHANGES

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -31,6 +31,17 @@ The sampling rate for errors. Must be a value between 0 (disabled) and 1.0 (repo
---
## SENTRY_SEND_DEFAULT_PII
Default: False
Maps to the Sentry SDK's [`send_default_pii`](https://docs.sentry.io/platforms/python/configuration/options/#send-default-pii) parameter. If enabled, certain personally identifiable information (PII) is added.
!!! warning "Sensitive data"
If you enable this option, be aware that sensitive data such as cookies and authentication tokens will be logged.
---
## SENTRY_TAGS
An optional dictionary of tag names and values to apply to Sentry error reports.For example:

View File

@@ -177,7 +177,7 @@ The dotted path to the desired search backend class. `CachedValueSearchBackend`
Default: None (local storage)
The backend storage engine for handling uploaded files (e.g. image attachments). NetBox supports integration with the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) package, which provides backends for several popular file storage services. If not configured, local filesystem storage will be used.
The backend storage engine for handling uploaded files (e.g. image attachments). NetBox supports integration with the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) and [`django-storage-swift`](https://github.com/dennisv/django-storage-swift) packages, which provide backends for several popular file storage services. If not configured, local filesystem storage will be used.
The configuration parameters for the specified storage backend are defined under the `STORAGE_CONFIG` setting.
@@ -187,7 +187,7 @@ The configuration parameters for the specified storage backend are defined under
Default: Empty
A dictionary of configuration parameters for the storage backend configured as `STORAGE_BACKEND`. The specific parameters to be used here are specific to each backend; see the [`django-storages` documentation](https://django-storages.readthedocs.io/en/stable/) for more detail.
A dictionary of configuration parameters for the storage backend configured as `STORAGE_BACKEND`. The specific parameters to be used here are specific to each backend; see the documentation for your selected backend ([`django-storages`](https://django-storages.readthedocs.io/en/stable/) or [`django-storage-swift`](https://github.com/dennisv/django-storage-swift)) for more detail.
If `STORAGE_BACKEND` is not defined, this setting will be ignored.

View File

@@ -138,11 +138,11 @@ These two methods will load data in YAML or JSON format, respectively, from file
The Script object provides a set of convenient functions for recording messages at different severity levels:
* `log_debug(message, object=None)`
* `log_success(message, object=None)`
* `log_info(message, object=None)`
* `log_warning(message, object=None)`
* `log_failure(message, object=None)`
* `log_debug(message=None, obj=None)`
* `log_success(message=None, obj=None)`
* `log_info(message=None, obj=None)`
* `log_warning(message=None, obj=None)`
* `log_failure(message=None, obj=None)`
Log messages are returned to the user upon execution of the script. Markdown rendering is supported for log messages. A message may optionally be associated with a particular object by passing it as the second argument to the logging method.
@@ -152,6 +152,8 @@ A script can define one or more test methods to report on certain conditions. Al
These methods are detected and run automatically when the script is executed, unless its `run()` method has been overridden. (When overriding `run()`, `run_tests()` can be called to run all test methods present in the script.)
Calling any of these logging methods without a message will increment the relevant counter, but will not generate an output line in the script's log.
!!! info
This functionality was ported from [legacy reports](./reports.md) in NetBox v4.0.

View File

@@ -126,3 +126,15 @@ VERSION = 'v3.3.2-dev'
```
Commit this change with the comment "PRVB" (for _post-release version bump_) and push the commit upstream.
### Update the Public Documentation
After a release has been published, the public NetBox documentation needs to be updated. This is accomplished by running two actions on the [netboxlabs-docs](https://github.com/netboxlabs/netboxlabs-docs) repository.
First, run the `build-site` action, by navigating to Actions > build-site > Run workflow. This process compiles the documentation along with an overlay for integration with the documentation portal at <https://netboxlabs.com/docs>. The job should take about two minutes.
Once the documentation files have been compiled, they must be published by running the `deploy-kinsta` action. Select the desired deployment environment (staging or production) and specify `latest` as the deploy tag.
Clear the CDN cache from the [Kinsta](https://my.kinsta.com/) portal. Navigate to _Sites_ / _NetBox Labs_ / _Live_, select _CDN_ in the left-nav, click the _Clear CDN cache_ button, and confirm the clear operation.
Finally, verify that the documentation at <https://netboxlabs.com/docs/netbox/en/stable/> has been updated.

View File

@@ -70,3 +70,19 @@ DROP TABLE
netbox=> DROP TABLE pluginname_bar;
DROP TABLE
```
### Remove the Django Migration Records
After removing the tables created by a plugin, the migrations that created the tables need to be removed from Django's migration history as well. This is necessary to make it possible to reinstall the plugin at a later time. If the migration history were left in place, Django would skip all migrations that were executed in the course of a previous installation, which would cause the plugin to fail after reinstallation.
```no-highlight
netbox=> SELECT * FROM django_migrations WHERE app='pluginname';
id | app | name | applied
-----+------------+------------------------+-------------------------------
492 | pluginname | 0001_initial | 2023-12-21 11:59:59.325995+00
493 | pluginname | 0002_add_foo | 2023-12-21 11:59:59.330026+00
netbox=> DELETE FROM django_migrations WHERE app='pluginname';
```
!!! warning
Exercise extreme caution when altering Django system tables. Users are strongly encouraged to perform a backup of their database immediately before taking these actions.

View File

@@ -1,5 +1,71 @@
# NetBox v4.0
## v4.0.7 (2024-07-09)
### Enhancements
* [#14554](https://github.com/netbox-community/netbox/issues/14554) - Add support for [django-storage-swift](https://github.com/dennisv/django-storage-swift) storage backend
* [#16424](https://github.com/netbox-community/netbox/issues/16424) - Enable filtering of devices by cluster and cluster group
* [#16716](https://github.com/netbox-community/netbox/issues/16716) - Display NAT address (if any) for OOB IP address under device view
* [#16725](https://github.com/netbox-community/netbox/issues/16725) - Always position the admin section last in the navigation menu
* [#16791](https://github.com/netbox-community/netbox/issues/16791) - Add 200 & 400 Gbps selections for circuit termination port speed
* [#16802](https://github.com/netbox-community/netbox/issues/16802) - Introduce `SENTRY_SEND_DEFAULT_PII` configuration parameter and disable PII export by default
* [#16817](https://github.com/netbox-community/netbox/issues/16817) - Add 200 & 400 Gbps selections for circuit commit rate
### Bug Fixes
* [#16523](https://github.com/netbox-community/netbox/issues/16523) - Restore highlighting of current device in virtual chassis members panel
* [#16654](https://github.com/netbox-community/netbox/issues/16654) - Fix parent item assignment for inventory item bulk import
* [#16657](https://github.com/netbox-community/netbox/issues/16657) - Fix translation of object types in global search
* [#16679](https://github.com/netbox-community/netbox/issues/16679) - Avoid overwriting custom JSON fields during bulk edit
* [#16689](https://github.com/netbox-community/netbox/issues/16689) - System configuration view should reflect static parameters when no config revisions exist
* [#16714](https://github.com/netbox-community/netbox/issues/16714) - Fix cloning of device types with 0U height
* [#16721](https://github.com/netbox-community/netbox/issues/16721) - Fix errant API request after deselecting a rack in device edit form
* [#16723](https://github.com/netbox-community/netbox/issues/16723) - Fix escaping of path to virtual environment in `upgrade.sh`
* [#16735](https://github.com/netbox-community/netbox/issues/16735) - Object list "results" tab should show a count of zero when empty
* [#16747](https://github.com/netbox-community/netbox/issues/16747) - Avoid clearing entire search cache when manually reindexing specific apps/models
* [#16758](https://github.com/netbox-community/netbox/issues/16758) - Ensure manually selected lagnuage persists across browser sessions
* [#16779](https://github.com/netbox-community/netbox/issues/16779) - Fix saved filter selection for child object lists
* [#16780](https://github.com/netbox-community/netbox/issues/16780) - IKE proposal created via REST API should not require authentication_algorithm
* [#16796](https://github.com/netbox-community/netbox/issues/16796) - Allow assignment of VM with no site to a cluster with a site
* [#16806](https://github.com/netbox-community/netbox/issues/16806) - Fix redirect URL when creating contact assignments with "add another" button
* [#16807](https://github.com/netbox-community/netbox/issues/16807) - Fix layout of VLAN edit form when custom fields are present
* [#16808](https://github.com/netbox-community/netbox/issues/16808) - Fix event rule triggering in scenario where objects are updated immediately prior to deletion
* [#16813](https://github.com/netbox-community/netbox/issues/16813) - Fix AttributeError exception when filtering bookmarks in dashboard widget by object type
* [#16843](https://github.com/netbox-community/netbox/issues/16843) - Permit creation of IKE policies via REST API without specifying an IKE mode
---
## v4.0.6 (2024-06-24)
### Enhancements
* [#15348](https://github.com/netbox-community/netbox/issues/15348) - Show saved filters alongside quick search on object list views
* [#15794](https://github.com/netbox-community/netbox/issues/15794) - Dynamically populate related objects in UI views
* [#16256](https://github.com/netbox-community/netbox/issues/16256) - Enable alphabetical ordering of bookmarks on dashboard
* [#16307](https://github.com/netbox-community/netbox/issues/16307) - Enable calling `log_*()` methods on Script without passing a message
### Bug Fixes
* [#13925](https://github.com/netbox-community/netbox/issues/13925) - Fix support for "zulu" (UTC) timestamps for custom fields
* [#14829](https://github.com/netbox-community/netbox/issues/14829) - Fix support for simple conditions (without AND/OR) in event rules
* [#15717](https://github.com/netbox-community/netbox/issues/15717) - Allow assigning a device/VM in a site to a cluster with no site assigned
* [#16143](https://github.com/netbox-community/netbox/issues/16143) - Display timestamps in tables in the configured timezone
* [#16149](https://github.com/netbox-community/netbox/issues/16149) - Fix object linking in custom script logs
* [#16252](https://github.com/netbox-community/netbox/issues/16252) - Fix total count in tab at top of rack elevations view
* [#16273](https://github.com/netbox-community/netbox/issues/16273) - Restore global search bar on mobile
* [#16416](https://github.com/netbox-community/netbox/issues/16416) - Retain dark/light mode toggle on mobile view
* [#16444](https://github.com/netbox-community/netbox/issues/16444) - Disable ordering circuits list by A/Z termination
* [#16450](https://github.com/netbox-community/netbox/issues/16450) - Searching for rack unit in form dropdown should be case-insensitive
* [#16452](https://github.com/netbox-community/netbox/issues/16452) - Fix sizing of buttons within object attribute panels
* [#16454](https://github.com/netbox-community/netbox/issues/16454) - Address DNS lookup bug in `django-debug-toolbar
* [#16460](https://github.com/netbox-community/netbox/issues/16460) - Omit spaces from telephone number URLs
* [#16512](https://github.com/netbox-community/netbox/issues/16512) - Restore a user's preferred language (if any) on login
* [#16542](https://github.com/netbox-community/netbox/issues/16542) - Fix bulk form operations when HTMX is enabled
* [#16702](https://github.com/netbox-community/netbox/issues/16702) - Fix validation of `return_url` query parameter
---
## v4.0.5 (2024-06-06)
### Enhancements

View File

@@ -104,10 +104,16 @@ class LoginView(View):
# Ensure the user has a UserConfig defined. (This should normally be handled by
# create_userconfig() on user creation.)
if not hasattr(request.user, 'config'):
config = get_config()
UserConfig(user=request.user, data=config.DEFAULT_USER_PREFERENCES).save()
request.user.config = get_config()
UserConfig(user=request.user, data=request.user.config.DEFAULT_USER_PREFERENCES).save()
return self.redirect_to_next(request, logger)
response = self.redirect_to_next(request, logger)
# Set the user's preferred language (if any)
if language := request.user.config.get('locale.language'):
response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language, max_age=request.session.get_expiry_age())
return response
else:
logger.debug(f"Login form validation failed for username: {form['username'].value()}")
@@ -145,9 +151,10 @@ class LogoutView(View):
logger.info(f"User {username} has logged out")
messages.info(request, "You have logged out.")
# Delete session key cookie (if set) upon logout
# Delete session key & language cookies (if set) upon logout
response = HttpResponseRedirect(resolve_url(settings.LOGOUT_REDIRECT_URL))
response.delete_cookie('session_key')
response.delete_cookie(settings.LANGUAGE_COOKIE_NAME)
return response
@@ -199,7 +206,7 @@ class UserConfigView(LoginRequiredMixin, View):
# Set/clear language cookie
if language := form.cleaned_data['locale.language']:
response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language)
response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language, max_age=request.session.get_expiry_age())
else:
response.delete_cookie(settings.LANGUAGE_COOKIE_NAME)

View File

@@ -38,6 +38,8 @@ class CircuitCommitRateChoices(ChoiceSet):
(25000000, '25 Gbps'),
(40000000, '40 Gbps'),
(100000000, '100 Gbps'),
(200000000, '200 Gbps'),
(400000000, '400 Gbps'),
(1544, 'T1 (1.544 Mbps)'),
(2048, 'E1 (2.048 Mbps)'),
]
@@ -69,6 +71,8 @@ class CircuitTerminationPortSpeedChoices(ChoiceSet):
(25000000, '25 Gbps'),
(40000000, '40 Gbps'),
(100000000, '100 Gbps'),
(200000000, '200 Gbps'),
(400000000, '400 Gbps'),
(1544, 'T1 (1.544 Mbps)'),
(2048, 'E1 (2.048 Mbps)'),
]

View File

@@ -66,9 +66,6 @@ class CircuitTypeImportForm(NetBoxModelImportForm):
class Meta:
model = CircuitType
fields = ('name', 'slug', 'color', 'description', 'tags')
help_texts = {
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
}
class CircuitImportForm(NetBoxModelImportForm):

View File

@@ -63,10 +63,12 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
status = columns.ChoiceFieldColumn()
termination_a = tables.TemplateColumn(
template_code=CIRCUITTERMINATION_LINK,
orderable=False,
verbose_name=_('Side A')
)
termination_z = tables.TemplateColumn(
template_code=CIRCUITTERMINATION_LINK,
orderable=False,
verbose_name=_('Side Z')
)
commit_rate = CommitRateColumn(

View File

@@ -7,7 +7,7 @@ from netbox.views import generic
from tenancy.views import ObjectContactsView
from utilities.forms import ConfirmationForm
from utilities.query import count_related
from utilities.views import register_model_view
from utilities.views import GetRelatedModelsMixin, register_model_view
from . import filtersets, forms, tables
from .models import *
@@ -26,17 +26,12 @@ class ProviderListView(generic.ObjectListView):
@register_model_view(Provider)
class ProviderView(generic.ObjectView):
class ProviderView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Provider.objects.all()
def get_extra_context(self, request, instance):
related_models = (
(ProviderAccount.objects.restrict(request.user, 'view').filter(provider=instance), 'provider_id'),
(Circuit.objects.restrict(request.user, 'view').filter(provider=instance), 'provider_id'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(request, instance),
}
@@ -92,16 +87,12 @@ class ProviderAccountListView(generic.ObjectListView):
@register_model_view(ProviderAccount)
class ProviderAccountView(generic.ObjectView):
class ProviderAccountView(GetRelatedModelsMixin, generic.ObjectView):
queryset = ProviderAccount.objects.all()
def get_extra_context(self, request, instance):
related_models = (
(Circuit.objects.restrict(request.user, 'view').filter(provider_account=instance), 'provider_account_id'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(request, instance),
}
@@ -156,19 +147,21 @@ class ProviderNetworkListView(generic.ObjectListView):
@register_model_view(ProviderNetwork)
class ProviderNetworkView(generic.ObjectView):
class ProviderNetworkView(GetRelatedModelsMixin, generic.ObjectView):
queryset = ProviderNetwork.objects.all()
def get_extra_context(self, request, instance):
related_models = (
(
Circuit.objects.restrict(request.user, 'view').filter(terminations__provider_network=instance),
'provider_network_id',
),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(
request,
instance,
extra=(
(
Circuit.objects.restrict(request.user, 'view').filter(terminations__provider_network=instance),
'provider_network_id',
),
),
),
}
@@ -215,16 +208,12 @@ class CircuitTypeListView(generic.ObjectListView):
@register_model_view(CircuitType)
class CircuitTypeView(generic.ObjectView):
class CircuitTypeView(GetRelatedModelsMixin, generic.ObjectView):
queryset = CircuitType.objects.all()
def get_extra_context(self, request, instance):
related_models = (
(Circuit.objects.restrict(request.user, 'view').filter(type=instance), 'type_id'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(request, instance),
}

View File

@@ -32,7 +32,7 @@ from netbox.views.generic.mixins import TableMixin
from utilities.forms import ConfirmationForm
from utilities.htmx import htmx_partial
from utilities.query import count_related
from utilities.views import ContentTypePermissionRequiredMixin, register_model_view
from utilities.views import ContentTypePermissionRequiredMixin, GetRelatedModelsMixin, register_model_view
from . import filtersets, forms, tables
from .models import *
@@ -51,16 +51,12 @@ class DataSourceListView(generic.ObjectListView):
@register_model_view(DataSource)
class DataSourceView(generic.ObjectView):
class DataSourceView(GetRelatedModelsMixin, generic.ObjectView):
queryset = DataSource.objects.all()
def get_extra_context(self, request, instance):
related_models = (
(DataFile.objects.restrict(request.user, 'view').filter(source=instance), 'source_id'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(request, instance),
}
@@ -559,7 +555,7 @@ class SystemView(UserPassesTestMixin, View):
config = ConfigRevision.objects.get(pk=cache.get('config_version'))
except ConfigRevision.DoesNotExist:
# Fall back to using the active config data if no record is found
config = ConfigRevision(data=get_config().defaults)
config = get_config()
# Raw data export
if 'export' in request.GET:

View File

@@ -219,9 +219,9 @@ class RackViewSet(NetBoxModelViewSet):
)
# Enable filtering rack units by ID
q = data['q']
if q:
elevation = [u for u in elevation if q in str(u['id']) or q in str(u['name'])]
if q := data['q']:
q = q.lower()
elevation = [u for u in elevation if q in str(u['id']) or q in str(u['name']).lower()]
page = self.paginate_queryset(elevation)
if page is not None:

View File

@@ -20,7 +20,7 @@ from utilities.filters import (
ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter,
NumericArrayFilter, TreeNodeMultipleChoiceFilter,
)
from virtualization.models import Cluster
from virtualization.models import Cluster, ClusterGroup
from vpn.models import L2VPN
from wireless.choices import WirelessRoleChoices, WirelessChannelChoices
from wireless.models import WirelessLAN, WirelessLink
@@ -1018,6 +1018,17 @@ class DeviceFilterSet(
queryset=Cluster.objects.all(),
label=_('VM cluster (ID)'),
)
cluster_group = django_filters.ModelMultipleChoiceFilter(
field_name='cluster__group__slug',
queryset=ClusterGroup.objects.all(),
to_field_name='slug',
label=_('Cluster group (slug)'),
)
cluster_group_id = django_filters.ModelMultipleChoiceFilter(
field_name='cluster__group',
queryset=ClusterGroup.objects.all(),
label=_('Cluster group (ID)'),
)
model = django_filters.ModelMultipleChoiceFilter(
field_name='device_type__slug',
queryset=DeviceType.objects.all(),

View File

@@ -174,9 +174,6 @@ class RackRoleImportForm(NetBoxModelImportForm):
class Meta:
model = RackRole
fields = ('name', 'slug', 'color', 'description', 'tags')
help_texts = {
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
}
class RackImportForm(NetBoxModelImportForm):
@@ -384,9 +381,6 @@ class DeviceRoleImportForm(NetBoxModelImportForm):
class Meta:
model = DeviceRole
fields = ('name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags')
help_texts = {
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
}
class PlatformImportForm(NetBoxModelImportForm):
@@ -1052,7 +1046,7 @@ class InventoryItemImportForm(NetBoxModelImportForm):
class Meta:
model = InventoryItem
fields = (
'device', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered',
'device', 'name', 'label', 'role', 'manufacturer', 'parent', 'part_id', 'serial', 'asset_tag', 'discovered',
'description', 'tags', 'component_type', 'component_name',
)
@@ -1104,9 +1098,6 @@ class InventoryItemRoleImportForm(NetBoxModelImportForm):
class Meta:
model = InventoryItemRole
fields = ('name', 'slug', 'color', 'description')
help_texts = {
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
}
#
@@ -1183,9 +1174,6 @@ class CableImportForm(NetBoxModelImportForm):
'side_a_device', 'side_a_type', 'side_a_name', 'side_b_device', 'side_b_type', 'side_b_name', 'type',
'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'comments', 'tags',
]
help_texts = {
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
}
def _clean_side(self, side):
"""

View File

@@ -14,6 +14,7 @@ from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_ch
from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import NumberWithOptions
from virtualization.models import Cluster, ClusterGroup
from vpn.models import L2VPN
from wireless.choices import *
@@ -655,6 +656,7 @@ class DeviceFilterForm(
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports',
name=_('Components')
),
FieldSet('cluster_group_id', 'cluster_id', name=_('Cluster')),
FieldSet(
'has_primary_ip', 'has_oob_ip', 'virtual_chassis_member', 'config_template_id', 'local_context_data',
'has_virtual_device_context',
@@ -821,6 +823,16 @@ class DeviceFilterForm(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
cluster_id = DynamicModelMultipleChoiceField(
queryset=Cluster.objects.all(),
required=False,
label=_('Cluster')
)
cluster_group_id = DynamicModelMultipleChoiceField(
queryset=ClusterGroup.objects.all(),
required=False,
label=_('Cluster group')
)
tag = TagFilterField(model)

View File

@@ -465,7 +465,10 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
label=_('Cluster'),
queryset=Cluster.objects.all(),
required=False,
selector=True
selector=True,
query_params={
'site_id': ['$site', 'null']
},
)
comments = CommentField()
local_context_data = JSONField(

View File

@@ -1,6 +1,7 @@
from django.utils.translation import gettext_lazy as _
import django_tables2 as tables
from django_tables2.utils import Accessor
from django.utils.html import escape
from django.utils.safestring import mark_safe
from dcim.models import Cable
@@ -35,7 +36,7 @@ class CableTerminationsColumn(tables.Column):
def render(self, value):
links = [
f'<a href="{term.get_absolute_url()}">{term}</a>' for term in self._get_terminations(value)
f'<a href="{term.get_absolute_url()}">{escape(term)}</a>' for term in self._get_terminations(value)
]
return mark_safe('<br />'.join(links) or '&mdash;')

View File

@@ -9,7 +9,7 @@ from ipam.models import ASN, IPAddress, RIR, VRF
from netbox.choices import ColorChoices
from tenancy.models import Tenant, TenantGroup
from utilities.testing import ChangeLoggedFilterSetTests, create_test_device
from virtualization.models import Cluster, ClusterType
from virtualization.models import Cluster, ClusterType, ClusterGroup
from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
User = get_user_model()
@@ -1959,10 +1959,16 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks)
cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
cluster_groups = (
ClusterGroup(name='Cluster Group 1', slug='cluster-group-1'),
ClusterGroup(name='Cluster Group 2', slug='cluster-group-2'),
ClusterGroup(name='Cluster Group 3', slug='cluster-group-3'),
)
ClusterGroup.objects.bulk_create(cluster_groups)
clusters = (
Cluster(name='Cluster 1', type=cluster_type),
Cluster(name='Cluster 2', type=cluster_type),
Cluster(name='Cluster 3', type=cluster_type),
Cluster(name='Cluster 1', type=cluster_type, group=cluster_groups[0]),
Cluster(name='Cluster 2', type=cluster_type, group=cluster_groups[1]),
Cluster(name='Cluster 3', type=cluster_type, group=cluster_groups[2]),
)
Cluster.objects.bulk_create(clusters)
@@ -2213,6 +2219,13 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'cluster_id': [clusters[0].pk, clusters[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_cluster_group(self):
cluster_groups = ClusterGroup.objects.all()[:2]
params = {'cluster_group_id': [cluster_groups[0].pk, cluster_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'cluster_group': [cluster_groups[0].slug, cluster_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_model(self):
params = {'model': ['model-1', 'model-2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

View File

@@ -8,6 +8,7 @@ from dcim.models import *
from extras.models import CustomField
from tenancy.models import Tenant
from utilities.data import drange
from virtualization.models import Cluster, ClusterType
class LocationTestCase(TestCase):
@@ -533,6 +534,36 @@ class DeviceTestCase(TestCase):
device2.full_clean()
device2.save()
def test_device_mismatched_site_cluster(self):
cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
Cluster.objects.create(name='Cluster 1', type=cluster_type)
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
)
Site.objects.bulk_create(sites)
clusters = (
Cluster(name='Cluster 1', type=cluster_type, site=sites[0]),
Cluster(name='Cluster 2', type=cluster_type, site=sites[1]),
Cluster(name='Cluster 3', type=cluster_type, site=None),
)
Cluster.objects.bulk_create(clusters)
device_type = DeviceType.objects.first()
device_role = DeviceRole.objects.first()
# Device with site only should pass
Device(name='device1', site=sites[0], device_type=device_type, role=device_role).full_clean()
# Device with site, cluster non-site should pass
Device(name='device1', site=sites[0], device_type=device_type, role=device_role, cluster=clusters[2]).full_clean()
# Device with mismatched site & cluster should fail
with self.assertRaises(ValidationError):
Device(name='device1', site=sites[0], device_type=device_type, role=device_role, cluster=clusters[1]).full_clean()
class CableTestCase(TestCase):

View File

@@ -17,7 +17,7 @@ from jinja2.exceptions import TemplateError
from circuits.models import Circuit, CircuitTermination
from extras.views import ObjectConfigContextView
from ipam.models import ASN, IPAddress, Prefix, VLAN, VLANGroup
from ipam.models import ASN, IPAddress, VLANGroup
from ipam.tables import InterfaceVLANTable
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
from netbox.views import generic
@@ -27,8 +27,11 @@ from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.permissions import get_permission_for_model
from utilities.query import count_related
from utilities.query_functions import CollateAsChar
from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view
from utilities.views import (
GetRelatedModelsMixin, GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view
)
from virtualization.filtersets import VirtualMachineFilterSet
from virtualization.forms import VirtualMachineFilterForm
from virtualization.models import VirtualMachine
from virtualization.tables import VirtualMachineTable
from . import filtersets, forms, tables
@@ -226,19 +229,21 @@ class RegionListView(generic.ObjectListView):
@register_model_view(Region)
class RegionView(generic.ObjectView):
class RegionView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Region.objects.all()
def get_extra_context(self, request, instance):
regions = instance.get_descendants(include_self=True)
related_models = (
(Site.objects.restrict(request.user, 'view').filter(region__in=regions), 'region_id'),
(Location.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
(Rack.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(
request,
regions,
extra=(
(Location.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
(Rack.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
),
),
}
@@ -306,19 +311,21 @@ class SiteGroupListView(generic.ObjectListView):
@register_model_view(SiteGroup)
class SiteGroupView(generic.ObjectView):
class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView):
queryset = SiteGroup.objects.all()
def get_extra_context(self, request, instance):
groups = instance.get_descendants(include_self=True)
related_models = (
(Site.objects.restrict(request.user, 'view').filter(group__in=groups), 'group_id'),
(Location.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
(Rack.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(
request,
groups,
extra=(
(Location.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
(Rack.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
),
),
}
@@ -380,31 +387,25 @@ class SiteListView(generic.ObjectListView):
@register_model_view(Site)
class SiteView(generic.ObjectView):
class SiteView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Site.objects.prefetch_related('tenant__group')
def get_extra_context(self, request, instance):
related_models = (
# DCIM
(Location.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'),
(Rack.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'),
(Device.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'),
# Virtualization
(VirtualMachine.objects.restrict(request.user, 'view').filter(cluster__site=instance), 'site_id'),
# IPAM
(Prefix.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'),
(ASN.objects.restrict(request.user, 'view').filter(sites=instance), 'site_id'),
(VLANGroup.objects.restrict(request.user, 'view').filter(
scope_type=ContentType.objects.get_for_model(Site),
scope_id=instance.pk
), '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'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(
request,
instance,
[CableTermination, CircuitTermination],
(
(VLANGroup.objects.restrict(request.user, 'view').filter(
scope_type=ContentType.objects.get_for_model(Site),
scope_id=instance.pk
), 'site'),
(ASN.objects.restrict(request.user, 'view').filter(sites=instance), 'site_id'),
(Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).distinct(),
'site_id'),
),
),
}
@@ -466,18 +467,13 @@ class LocationListView(generic.ObjectListView):
@register_model_view(Location)
class LocationView(generic.ObjectView):
class LocationView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Location.objects.all()
def get_extra_context(self, request, instance):
locations = instance.get_descendants(include_self=True)
related_models = (
(Rack.objects.restrict(request.user, 'view').filter(location__in=locations), 'location_id'),
(Device.objects.restrict(request.user, 'view').filter(location__in=locations), 'location_id'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(request, locations, [CableTermination]),
}
@@ -541,16 +537,12 @@ class RackRoleListView(generic.ObjectListView):
@register_model_view(RackRole)
class RackRoleView(generic.ObjectView):
class RackRoleView(GetRelatedModelsMixin, generic.ObjectView):
queryset = RackRole.objects.all()
def get_extra_context(self, request, instance):
related_models = (
(Rack.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(request, instance),
}
@@ -655,15 +647,10 @@ class RackElevationListView(generic.ObjectListView):
@register_model_view(Rack)
class RackView(generic.ObjectView):
class RackView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'location', 'role')
def get_extra_context(self, request, instance):
related_models = (
(Device.objects.restrict(request.user, 'view').filter(rack=instance), 'rack_id'),
(PowerFeed.objects.restrict(request.user).filter(rack=instance), 'rack_id'),
)
peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=instance.site)
if instance.location:
@@ -679,7 +666,7 @@ class RackView(generic.ObjectView):
])
return {
'related_models': related_models,
'related_models': self.get_related_models(request, instance, [CableTermination]),
'next_rack': next_rack,
'prev_rack': prev_rack,
'svg_extra': svg_extra,
@@ -693,6 +680,7 @@ class RackRackReservationsView(generic.ObjectChildrenView):
child_model = RackReservation
table = tables.RackReservationTable
filterset = filtersets.RackReservationFilterSet
filterset_form = forms.RackReservationFilterForm
template_name = 'dcim/rack/reservations.html'
tab = ViewTab(
label=_('Reservations'),
@@ -711,6 +699,7 @@ class RackNonRackedView(generic.ObjectChildrenView):
child_model = Device
table = tables.DeviceTable
filterset = filtersets.DeviceFilterSet
filterset_form = forms.DeviceFilterForm
template_name = 'dcim/rack/non_racked_devices.html'
tab = ViewTab(
label=_('Non-Racked Devices'),
@@ -838,19 +827,12 @@ class ManufacturerListView(generic.ObjectListView):
@register_model_view(Manufacturer)
class ManufacturerView(generic.ObjectView):
class ManufacturerView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Manufacturer.objects.all()
def get_extra_context(self, request, instance):
related_models = (
(DeviceType.objects.restrict(request.user, 'view').filter(manufacturer=instance), 'manufacturer_id'),
(ModuleType.objects.restrict(request.user, 'view').filter(manufacturer=instance), 'manufacturer_id'),
(InventoryItem.objects.restrict(request.user, 'view').filter(manufacturer=instance), 'manufacturer_id'),
(Platform.objects.restrict(request.user, 'view').filter(manufacturer=instance), 'manufacturer_id'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(request, instance, [InventoryItemTemplate]),
}
@@ -912,16 +894,16 @@ class DeviceTypeListView(generic.ObjectListView):
@register_model_view(DeviceType)
class DeviceTypeView(generic.ObjectView):
class DeviceTypeView(GetRelatedModelsMixin, generic.ObjectView):
queryset = DeviceType.objects.all()
def get_extra_context(self, request, instance):
related_models = (
(Device.objects.restrict(request.user).filter(device_type=instance), 'device_type_id'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(request, instance, omit=[
ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate,
InventoryItemTemplate, InterfaceTemplate, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate,
RearPortTemplate,
]),
}
@@ -1151,16 +1133,16 @@ class ModuleTypeListView(generic.ObjectListView):
@register_model_view(ModuleType)
class ModuleTypeView(generic.ObjectView):
class ModuleTypeView(GetRelatedModelsMixin, generic.ObjectView):
queryset = ModuleType.objects.all()
def get_extra_context(self, request, instance):
related_models = (
(Module.objects.restrict(request.user).filter(module_type=instance), 'module_type_id'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(request, instance, omit=[
ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate,
InventoryItemTemplate, InterfaceTemplate, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate,
RearPortTemplate,
]),
}
@@ -1711,17 +1693,12 @@ class DeviceRoleListView(generic.ObjectListView):
@register_model_view(DeviceRole)
class DeviceRoleView(generic.ObjectView):
class DeviceRoleView(GetRelatedModelsMixin, generic.ObjectView):
queryset = DeviceRole.objects.all()
def get_extra_context(self, request, instance):
related_models = (
(Device.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'),
(VirtualMachine.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(request, instance),
}
@@ -1775,17 +1752,12 @@ class PlatformListView(generic.ObjectListView):
@register_model_view(Platform)
class PlatformView(generic.ObjectView):
class PlatformView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Platform.objects.all()
def get_extra_context(self, request, instance):
related_models = (
(Device.objects.restrict(request.user, 'view').filter(platform=instance), 'platform_id'),
(VirtualMachine.objects.restrict(request.user, 'view').filter(platform=instance), 'platform_id'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(request, instance),
}
@@ -1866,6 +1838,7 @@ class DeviceConsolePortsView(DeviceComponentsView):
child_model = ConsolePort
table = tables.DeviceConsolePortTable
filterset = filtersets.ConsolePortFilterSet
filterset_form = forms.ConsolePortFilterForm
template_name = 'dcim/device/consoleports.html',
tab = ViewTab(
label=_('Console Ports'),
@@ -1881,6 +1854,7 @@ class DeviceConsoleServerPortsView(DeviceComponentsView):
child_model = ConsoleServerPort
table = tables.DeviceConsoleServerPortTable
filterset = filtersets.ConsoleServerPortFilterSet
filterset_form = forms.ConsoleServerPortFilterForm
template_name = 'dcim/device/consoleserverports.html'
tab = ViewTab(
label=_('Console Server Ports'),
@@ -1896,6 +1870,7 @@ class DevicePowerPortsView(DeviceComponentsView):
child_model = PowerPort
table = tables.DevicePowerPortTable
filterset = filtersets.PowerPortFilterSet
filterset_form = forms.PowerPortFilterForm
template_name = 'dcim/device/powerports.html'
tab = ViewTab(
label=_('Power Ports'),
@@ -1911,6 +1886,7 @@ class DevicePowerOutletsView(DeviceComponentsView):
child_model = PowerOutlet
table = tables.DevicePowerOutletTable
filterset = filtersets.PowerOutletFilterSet
filterset_form = forms.PowerOutletFilterForm
template_name = 'dcim/device/poweroutlets.html'
tab = ViewTab(
label=_('Power Outlets'),
@@ -1926,6 +1902,7 @@ class DeviceInterfacesView(DeviceComponentsView):
child_model = Interface
table = tables.DeviceInterfaceTable
filterset = filtersets.InterfaceFilterSet
filterset_form = forms.InterfaceFilterForm
template_name = 'dcim/device/interfaces.html'
tab = ViewTab(
label=_('Interfaces'),
@@ -1947,6 +1924,7 @@ class DeviceFrontPortsView(DeviceComponentsView):
child_model = FrontPort
table = tables.DeviceFrontPortTable
filterset = filtersets.FrontPortFilterSet
filterset_form = forms.FrontPortFilterForm
template_name = 'dcim/device/frontports.html'
tab = ViewTab(
label=_('Front Ports'),
@@ -1962,6 +1940,7 @@ class DeviceRearPortsView(DeviceComponentsView):
child_model = RearPort
table = tables.DeviceRearPortTable
filterset = filtersets.RearPortFilterSet
filterset_form = forms.RearPortFilterForm
template_name = 'dcim/device/rearports.html'
tab = ViewTab(
label=_('Rear Ports'),
@@ -1977,6 +1956,7 @@ class DeviceModuleBaysView(DeviceComponentsView):
child_model = ModuleBay
table = tables.DeviceModuleBayTable
filterset = filtersets.ModuleBayFilterSet
filterset_form = forms.ModuleBayFilterForm
template_name = 'dcim/device/modulebays.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
@@ -1996,6 +1976,7 @@ class DeviceDeviceBaysView(DeviceComponentsView):
child_model = DeviceBay
table = tables.DeviceDeviceBayTable
filterset = filtersets.DeviceBayFilterSet
filterset_form = forms.DeviceBayFilterForm
template_name = 'dcim/device/devicebays.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
@@ -2015,6 +1996,7 @@ class DeviceInventoryView(DeviceComponentsView):
child_model = InventoryItem
table = tables.DeviceInventoryItemTable
filterset = filtersets.InventoryItemFilterSet
filterset_form = forms.InventoryItemFilterForm
template_name = 'dcim/device/inventory.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
@@ -2093,6 +2075,7 @@ class DeviceVirtualMachinesView(generic.ObjectChildrenView):
child_model = VirtualMachine
table = VirtualMachineTable
filterset = VirtualMachineFilterSet
filterset_form = VirtualMachineFilterForm
tab = ViewTab(
label=_('Virtual Machines'),
badge=lambda obj: VirtualMachine.objects.filter(cluster=obj.cluster, device=obj).count(),
@@ -2157,22 +2140,12 @@ class ModuleListView(generic.ObjectListView):
@register_model_view(Module)
class ModuleView(generic.ObjectView):
class ModuleView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Module.objects.all()
def get_extra_context(self, request, instance):
related_models = (
(Interface.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'),
(ConsolePort.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'),
(ConsoleServerPort.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'),
(PowerPort.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'),
(PowerOutlet.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'),
(FrontPort.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'),
(RearPort.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(request, instance),
}
@@ -2985,6 +2958,7 @@ class InventoryItemChildrenView(generic.ObjectChildrenView):
child_model = InventoryItem
table = tables.InventoryItemTable
filterset = filtersets.InventoryItemFilterSet
filterset_form = forms.InventoryItemFilterForm
tab = ViewTab(
label=_('Children'),
badge=lambda obj: obj.child_items.count(),
@@ -3451,8 +3425,9 @@ class VirtualChassisAddMemberView(ObjectPermissionRequiredMixin, GetReturnURLMix
if membership_form.is_valid():
membership_form.save()
msg = f'Added member <a href="{device.get_absolute_url()}">{escape(device)}</a>'
messages.success(request, mark_safe(msg))
messages.success(request, mark_safe(
f'Added member <a href="{device.get_absolute_url()}">{escape(device)}</a>'
))
if '_addanother' in request.POST:
return redirect(request.get_full_path())
@@ -3552,16 +3527,12 @@ class PowerPanelListView(generic.ObjectListView):
@register_model_view(PowerPanel)
class PowerPanelView(generic.ObjectView):
class PowerPanelView(GetRelatedModelsMixin, generic.ObjectView):
queryset = PowerPanel.objects.all()
def get_extra_context(self, request, instance):
related_models = (
(PowerFeed.objects.restrict(request.user).filter(power_panel=instance), 'power_panel_id'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(request, instance),
}
@@ -3665,16 +3636,18 @@ class VirtualDeviceContextListView(generic.ObjectListView):
@register_model_view(VirtualDeviceContext)
class VirtualDeviceContextView(generic.ObjectView):
class VirtualDeviceContextView(GetRelatedModelsMixin, generic.ObjectView):
queryset = VirtualDeviceContext.objects.all()
def get_extra_context(self, request, instance):
related_models = (
(Interface.objects.restrict(request.user, 'view').filter(vdcs__in=[instance]), 'vdc_id'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(
request,
instance,
extra=(
(Interface.objects.restrict(request.user, 'view').filter(vdcs__in=[instance]), 'vdc_id'),
),
),
}

View File

@@ -117,10 +117,14 @@ class BookmarkOrderingChoices(ChoiceSet):
ORDERING_NEWEST = '-created'
ORDERING_OLDEST = 'created'
ORDERING_ALPHABETICAL_AZ = 'name'
ORDERING_ALPHABETICAL_ZA = '-name'
CHOICES = (
(ORDERING_NEWEST, _('Newest')),
(ORDERING_OLDEST, _('Oldest')),
(ORDERING_ALPHABETICAL_AZ, _('Alphabetical (A-Z)')),
(ORDERING_ALPHABETICAL_ZA, _('Alphabetical (Z-A)')),
)
#

View File

@@ -135,23 +135,23 @@ class ConditionSet:
def __init__(self, ruleset):
if type(ruleset) is not dict:
raise ValueError(_("Ruleset must be a dictionary, not {ruleset}.").format(ruleset=type(ruleset)))
if len(ruleset) != 1:
raise ValueError(_("Ruleset must have exactly one logical operator (found {ruleset})").format(
ruleset=len(ruleset)))
# Determine the logic type
logic = list(ruleset.keys())[0]
if type(logic) is not str or logic.lower() not in (AND, OR):
raise ValueError(_("Invalid logic type: {logic} (must be '{op_and}' or '{op_or}')").format(
logic=logic, op_and=AND, op_or=OR
))
self.logic = logic.lower()
if len(ruleset) == 1:
self.logic = (list(ruleset.keys())[0]).lower()
if self.logic not in (AND, OR):
raise ValueError(_("Invalid logic type: must be 'AND' or 'OR'. Please check documentation."))
# Compile the set of Conditions
self.conditions = [
ConditionSet(rule) if is_ruleset(rule) else Condition(**rule)
for rule in ruleset[self.logic]
]
# Compile the set of Conditions
self.conditions = [
ConditionSet(rule) if is_ruleset(rule) else Condition(**rule)
for rule in ruleset[self.logic]
]
else:
try:
self.logic = None
self.conditions = [Condition(**ruleset)]
except TypeError:
raise ValueError(_("Incorrect key(s) informed. Please check documentation."))
def eval(self, data):
"""

View File

@@ -381,11 +381,17 @@ class BookmarksWidget(DashboardWidget):
if request.user.is_anonymous:
bookmarks = list()
else:
bookmarks = Bookmark.objects.filter(user=request.user).order_by(self.config['order_by'])
bookmarks = Bookmark.objects.filter(user=request.user)
if object_types := self.config.get('object_types'):
models = get_models_from_content_types(object_types)
conent_types = ObjectType.objects.get_for_models(*models).values()
bookmarks = bookmarks.filter(object_type__in=conent_types)
content_types = ObjectType.objects.get_for_models(*models).values()
bookmarks = bookmarks.filter(object_type__in=content_types)
if self.config['order_by'] == BookmarkOrderingChoices.ORDERING_ALPHABETICAL_AZ:
bookmarks = sorted(bookmarks, key=lambda bookmark: bookmark.__str__().lower())
elif self.config['order_by'] == BookmarkOrderingChoices.ORDERING_ALPHABETICAL_ZA:
bookmarks = sorted(bookmarks, key=lambda bookmark: bookmark.__str__().lower(), reverse=True)
else:
bookmarks = bookmarks.order_by(self.config['order_by'])
if max_items := self.config.get('max_items'):
bookmarks = bookmarks[:max_items]

View File

@@ -63,6 +63,9 @@ def enqueue_object(queue, instance, user, request_id, action):
if key in queue:
queue[key]['data'] = serialize_for_event(instance)
queue[key]['snapshots']['postchange'] = get_snapshots(instance, action)['postchange']
# If the object is being deleted, update any prior "update" event to "delete"
if action == ObjectChangeActionChoices.ACTION_DELETE:
queue[key]['event'] = action
else:
queue[key] = {
'content_type': ContentType.objects.get_for_model(instance),

View File

@@ -228,9 +228,6 @@ class TagImportForm(CSVModelForm):
class Meta:
model = Tag
fields = ('name', 'slug', 'color', 'description')
help_texts = {
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
}
class JournalEntryImportForm(NetBoxModelImportForm):

View File

@@ -66,11 +66,16 @@ class Command(BaseCommand):
raise CommandError(_("No indexers found!"))
self.stdout.write(f'Reindexing {len(indexers)} models.')
# Clear all cached values for the specified models (if not being lazy)
# Clear cached values for the specified models (if not being lazy)
if not kwargs['lazy']:
if model_labels:
content_types = [ContentType.objects.get_for_model(model) for model in indexers.keys()]
else:
content_types = None
self.stdout.write('Clearing cached values... ', ending='')
self.stdout.flush()
deleted_count = search_backend.clear()
deleted_count = search_backend.clear(object_types=content_types)
self.stdout.write(f'{deleted_count} entries deleted.')
# Index models

View File

@@ -10,6 +10,7 @@ from django.contrib.postgres.fields import ArrayField
from django.core.validators import RegexValidator, ValidationError
from django.db import models
from django.urls import reverse
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
@@ -489,7 +490,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
# JSON
elif self.type == CustomFieldTypeChoices.TYPE_JSON:
field = JSONField(required=required, initial=json.dumps(initial) if initial else '')
field = JSONField(required=required, initial=json.dumps(initial) if initial else None)
# Object
elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:
@@ -520,7 +521,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
RegexValidator(
regex=self.validation_regex,
message=mark_safe(_("Values must match this regex: <code>{regex}</code>").format(
regex=self.validation_regex
regex=escape(self.validation_regex)
))
)
]
@@ -660,6 +661,10 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
# Validate date & time
elif self.type == CustomFieldTypeChoices.TYPE_DATETIME:
if type(value) is not datetime:
# Work around UTC issue for Python < 3.11; see
# https://docs.python.org/3/library/datetime.html#datetime.datetime.fromisoformat
if type(value) is str and value.endswith('Z'):
value = f'{value[:-1]}+00:00'
try:
datetime.fromisoformat(value)
except ValueError:

View File

@@ -480,19 +480,21 @@ class BaseScript:
# A test method is currently active, so log the message using legacy Report logging
if self._current_test:
# TODO: Use a dataclass for test method logs
self.tests[self._current_test]['log'].append((
timezone.now().isoformat(),
level,
str(obj) if obj else None,
obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
str(message),
))
# Increment the event counter for this level
if level in self.tests[self._current_test]:
self.tests[self._current_test][level] += 1
# Record message (if any) to the report log
if message:
# TODO: Use a dataclass for test method logs
self.tests[self._current_test]['log'].append((
timezone.now().isoformat(),
level,
str(obj) if obj else None,
obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
str(message),
))
elif message:
# Record to the script's log
@@ -500,6 +502,8 @@ class BaseScript:
'time': timezone.now().isoformat(),
'status': level,
'message': str(message),
'obj': str(obj) if obj else None,
'url': obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
})
# Record to the system log
@@ -507,19 +511,19 @@ class BaseScript:
message = f"{obj}: {message}"
self.logger.log(LogLevelChoices.SYSTEM_LEVELS[level], message)
def log_debug(self, message, obj=None):
def log_debug(self, message=None, obj=None):
self._log(message, obj, level=LogLevelChoices.LOG_DEBUG)
def log_success(self, message, obj=None):
def log_success(self, message=None, obj=None):
self._log(message, obj, level=LogLevelChoices.LOG_SUCCESS)
def log_info(self, message, obj=None):
def log_info(self, message=None, obj=None):
self._log(message, obj, level=LogLevelChoices.LOG_INFO)
def log_warning(self, message, obj=None):
def log_warning(self, message=None, obj=None):
self._log(message, obj, level=LogLevelChoices.LOG_WARNING)
def log_failure(self, message, obj=None):
def log_failure(self, message=None, obj=None):
self._log(message, obj, level=LogLevelChoices.LOG_FAILURE)
self.failed = True

View File

@@ -1,6 +1,7 @@
import json
import django_tables2 as tables
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from extras.models import *
@@ -545,6 +546,9 @@ class ScriptResultsTable(BaseTable):
template_code="""{% load log_levels %}{% log_level record.status %}""",
verbose_name=_('Level')
)
object = tables.Column(
verbose_name=_('Object')
)
message = columns.MarkdownColumn(
verbose_name=_('Message')
)
@@ -552,8 +556,17 @@ class ScriptResultsTable(BaseTable):
class Meta(BaseTable.Meta):
empty_text = _(EMPTY_TABLE_TEXT)
fields = (
'index', 'time', 'status', 'message',
'index', 'time', 'status', 'object', 'message',
)
default_columns = (
'index', 'time', 'status', 'object', 'message',
)
def render_object(self, value, record):
return format_html("<a href='{}'>{}</a>", record['url'], value)
def render_url(self, value):
return format_html("<a href='{}'>{}</a>", value, value)
class ReportResultsTable(BaseTable):
@@ -585,3 +598,9 @@ class ReportResultsTable(BaseTable):
fields = (
'index', 'method', 'time', 'status', 'object', 'url', 'message',
)
def render_object(self, value, record):
return format_html("<a href='{}'>{}</a>", record['url'], value)
def render_url(self, value):
return format_html("<a href='{}'>{}</a>", value, value)

View File

@@ -1,4 +1,5 @@
from django import template
from django.utils.html import escape
from django.utils.safestring import mark_safe
from core.models import ObjectType
@@ -59,8 +60,7 @@ def custom_links(context, obj):
# Add non-grouped links
else:
try:
rendered = cl.render(link_context)
if rendered:
if rendered := cl.render(link_context):
template_code += LINK_BUTTON.format(
rendered['link'], rendered['link_target'], cl.button_class, rendered['text']
)
@@ -75,8 +75,7 @@ def custom_links(context, obj):
for cl in links:
try:
rendered = cl.render(link_context)
if rendered:
if rendered := cl.render(link_context):
links_rendered.append(
GROUP_LINK.format(rendered['link'], rendered['link_target'], rendered['text'])
)
@@ -88,7 +87,7 @@ def custom_links(context, obj):
if links_rendered:
template_code += GROUP_BUTTON.format(
links[0].button_class, group, ''.join(links_rendered)
links[0].button_class, escape(group), ''.join(links_rendered)
)
return mark_safe(template_code)

View File

@@ -1,6 +1,12 @@
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from dcim.choices import SiteStatusChoices
from dcim.models import Site
from extras.conditions import Condition, ConditionSet
from extras.events import serialize_for_event
from extras.forms import EventRuleForm
from extras.models import EventRule, Webhook
class ConditionTestCase(TestCase):
@@ -217,3 +223,93 @@ class ConditionSetTest(TestCase):
self.assertTrue(cs.eval({'a': 1, 'b': 2, 'c': 9}))
self.assertFalse(cs.eval({'a': 9, 'b': 2, 'c': 9}))
self.assertFalse(cs.eval({'a': 9, 'b': 9, 'c': 3}))
def test_event_rule_conditions_without_logic_operator(self):
"""
Test evaluation of EventRule conditions without logic operator.
"""
event_rule = EventRule(
name='Event Rule 1',
type_create=True,
type_update=True,
conditions={
'attr': 'status.value',
'value': 'active',
}
)
# Create a Site to evaluate - Status = active
site = Site.objects.create(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_ACTIVE)
data = serialize_for_event(site)
# Evaluate the conditions (status='active')
self.assertTrue(event_rule.eval_conditions(data))
def test_event_rule_conditions_with_logical_operation(self):
"""
Test evaluation of EventRule conditions without logic operator, but with logical operation (in).
"""
event_rule = EventRule(
name='Event Rule 1',
type_create=True,
type_update=True,
conditions={
"attr": "status.value",
"value": ["planned", "staging"],
"op": "in",
}
)
# Create a Site to evaluate - Status = active
site = Site.objects.create(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_ACTIVE)
data = serialize_for_event(site)
# Evaluate the conditions (status in ['planned, 'staging'])
self.assertFalse(event_rule.eval_conditions(data))
def test_event_rule_conditions_with_logical_operation_and_negate(self):
"""
Test evaluation of EventRule with logical operation (in) and negate.
"""
event_rule = EventRule(
name='Event Rule 1',
type_create=True,
type_update=True,
conditions={
"attr": "status.value",
"value": ["planned", "staging"],
"op": "in",
"negate": True,
}
)
# Create a Site to evaluate - Status = active
site = Site.objects.create(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_ACTIVE)
data = serialize_for_event(site)
# Evaluate the conditions (status NOT in ['planned, 'staging'])
self.assertTrue(event_rule.eval_conditions(data))
def test_event_rule_conditions_with_incorrect_key_must_return_false(self):
"""
Test Event Rule with incorrect condition (key "foo" is wrong). Must return false.
"""
ct = ContentType.objects.get(app_label='extras', model='webhook')
site_ct = ContentType.objects.get_for_model(Site)
webhook = Webhook.objects.create(name='Webhook 100', payload_url='http://example.com/?1', http_method='POST')
form = EventRuleForm({
"name": "Event Rule 1",
"type_create": True,
"type_update": True,
"action_object_type": ct.pk,
"action_type": "webhook",
"action_choice": webhook.pk,
"content_types": [site_ct.pk],
"conditions": {
"foo": "status.value",
"value": "active"
}
})
self.assertFalse(form.is_valid())

View File

@@ -390,13 +390,36 @@ class EventRuleTest(APITestCase):
request.id = uuid.uuid4()
request.user = self.user
self.assertEqual(self.queue.count, 0, msg="Unexpected jobs found in queue")
# Test create & update
with event_tracking(request):
site = Site(name='Site 1', slug='site-1')
site.save()
# Save the site a second time
site.description = 'foo'
site.save()
self.assertEqual(self.queue.count, 1, msg="Duplicate jobs found in queue")
job = self.queue.get_jobs()[0]
self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_CREATE)
self.queue.empty()
# Test multiple updates
site = Site.objects.create(name='Site 2', slug='site-2')
with event_tracking(request):
site.description = 'foo'
site.save()
site.description = 'bar'
site.save()
self.assertEqual(self.queue.count, 1, msg="Duplicate jobs found in queue")
job = self.queue.get_jobs()[0]
self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_UPDATE)
self.queue.empty()
# Test update & delete
site = Site.objects.create(name='Site 3', slug='site-3')
with event_tracking(request):
site.description = 'foo'
site.save()
site.delete()
self.assertEqual(self.queue.count, 1, msg="Duplicate jobs found in queue")
job = self.queue.get_jobs()[0]
self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_DELETE)
self.queue.empty()

View File

@@ -1201,6 +1201,8 @@ class ScriptResultView(TableMixin, generic.ObjectView):
'time': log.get('time'),
'status': log.get('status'),
'message': log.get('message'),
'object': log.get('obj'),
'url': log.get('url'),
}
data.append(result)

View File

@@ -7,13 +7,15 @@ from django.utils.translation import gettext as _
from circuits.models import Provider
from dcim.filtersets import InterfaceFilterSet
from dcim.forms import InterfaceFilterForm
from dcim.models import Interface, Site
from netbox.views import generic
from tenancy.views import ObjectContactsView
from utilities.query import count_related
from utilities.tables import get_table_ordering
from utilities.views import ViewTab, register_model_view
from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view
from virtualization.filtersets import VMInterfaceFilterSet
from virtualization.forms import VMInterfaceFilterForm
from virtualization.models import VMInterface
from . import filtersets, forms, tables
from .choices import PrefixStatusChoices
@@ -34,15 +36,10 @@ class VRFListView(generic.ObjectListView):
@register_model_view(VRF)
class VRFView(generic.ObjectView):
class VRFView(GetRelatedModelsMixin, generic.ObjectView):
queryset = VRF.objects.all()
def get_extra_context(self, request, instance):
related_models = (
(Prefix.objects.restrict(request.user, 'view').filter(vrf=instance), 'vrf_id'),
(IPAddress.objects.restrict(request.user, 'view').filter(vrf=instance), 'vrf_id'),
)
import_targets_table = tables.RouteTargetTable(
instance.import_targets.all(),
orderable=False
@@ -53,7 +50,7 @@ class VRFView(generic.ObjectView):
)
return {
'related_models': related_models,
'related_models': self.get_related_models(request, instance, omit=[Interface, VMInterface]),
'import_targets_table': import_targets_table,
'export_targets_table': export_targets_table,
}
@@ -147,16 +144,12 @@ class RIRListView(generic.ObjectListView):
@register_model_view(RIR)
class RIRView(generic.ObjectView):
class RIRView(GetRelatedModelsMixin, generic.ObjectView):
queryset = RIR.objects.all()
def get_extra_context(self, request, instance):
related_models = (
(Aggregate.objects.restrict(request.user, 'view').filter(rir=instance), 'rir_id'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(request, instance),
}
@@ -215,6 +208,7 @@ class ASNRangeASNsView(generic.ObjectChildrenView):
child_model = ASN
table = tables.ASNTable
filterset = filtersets.ASNFilterSet
filterset_form = forms.ASNFilterForm
tab = ViewTab(
label=_('ASNs'),
badge=lambda x: x.get_child_asns().count(),
@@ -273,17 +267,19 @@ class ASNListView(generic.ObjectListView):
@register_model_view(ASN)
class ASNView(generic.ObjectView):
class ASNView(GetRelatedModelsMixin, generic.ObjectView):
queryset = ASN.objects.all()
def get_extra_context(self, request, instance):
related_models = (
(Site.objects.restrict(request.user, 'view').filter(asns__in=[instance]), 'asn_id'),
(Provider.objects.restrict(request.user, 'view').filter(asns__in=[instance]), 'asn_id'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(
request,
instance,
extra=(
(Site.objects.restrict(request.user, 'view').filter(asns__in=[instance]), 'asn_id'),
(Provider.objects.restrict(request.user, 'view').filter(asns__in=[instance]), 'asn_id'),
),
),
}
@@ -344,6 +340,7 @@ class AggregatePrefixesView(generic.ObjectChildrenView):
child_model = Prefix
table = tables.PrefixTable
filterset = filtersets.PrefixFilterSet
filterset_form = forms.PrefixFilterForm
template_name = 'ipam/aggregate/prefixes.html'
tab = ViewTab(
label=_('Prefixes'),
@@ -427,18 +424,12 @@ class RoleListView(generic.ObjectListView):
@register_model_view(Role)
class RoleView(generic.ObjectView):
class RoleView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Role.objects.all()
def get_extra_context(self, request, instance):
related_models = (
(Prefix.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'),
(IPRange.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'),
(VLAN.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(request, instance),
}
@@ -536,6 +527,7 @@ class PrefixPrefixesView(generic.ObjectChildrenView):
child_model = Prefix
table = tables.PrefixTable
filterset = filtersets.PrefixFilterSet
filterset_form = forms.PrefixFilterForm
template_name = 'ipam/prefix/prefixes.html'
tab = ViewTab(
label=_('Child Prefixes'),
@@ -571,6 +563,7 @@ class PrefixIPRangesView(generic.ObjectChildrenView):
child_model = IPRange
table = tables.IPRangeTable
filterset = filtersets.IPRangeFilterSet
filterset_form = forms.IPRangeFilterForm
template_name = 'ipam/prefix/ip_ranges.html'
tab = ViewTab(
label=_('Child Ranges'),
@@ -597,6 +590,7 @@ class PrefixIPAddressesView(generic.ObjectChildrenView):
child_model = IPAddress
table = tables.IPAddressTable
filterset = filtersets.IPAddressFilterSet
filterset_form = forms.IPAddressFilterForm
template_name = 'ipam/prefix/ip_addresses.html'
tab = ViewTab(
label=_('IP Addresses'),
@@ -696,6 +690,7 @@ class IPRangeIPAddressesView(generic.ObjectChildrenView):
child_model = IPAddress
table = tables.IPAddressTable
filterset = filtersets.IPAddressFilterSet
filterset_form = forms.IPRangeFilterForm
template_name = 'ipam/iprange/ip_addresses.html'
tab = ViewTab(
label=_('IP Addresses'),
@@ -898,6 +893,7 @@ class IPAddressRelatedIPsView(generic.ObjectChildrenView):
child_model = IPAddress
table = tables.IPAddressTable
filterset = filtersets.IPAddressFilterSet
filterset_form = forms.IPAddressFilterForm
tab = ViewTab(
label=_('Related IPs'),
badge=lambda x: x.get_related_ips().count(),
@@ -926,16 +922,12 @@ class VLANGroupListView(generic.ObjectListView):
@register_model_view(VLANGroup)
class VLANGroupView(generic.ObjectView):
class VLANGroupView(GetRelatedModelsMixin, generic.ObjectView):
queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
def get_extra_context(self, request, instance):
related_models = (
(VLAN.objects.restrict(request.user, 'view').filter(group=instance), 'group_id'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(request, instance),
}
@@ -974,6 +966,7 @@ class VLANGroupVLANsView(generic.ObjectChildrenView):
child_model = VLAN
table = tables.VLANTable
filterset = filtersets.VLANFilterSet
filterset_form = forms.VLANFilterForm
tab = ViewTab(
label=_('VLANs'),
badge=lambda x: x.get_child_vlans().count(),
@@ -1129,6 +1122,7 @@ class VLANInterfacesView(generic.ObjectChildrenView):
child_model = Interface
table = tables.VLANDevicesTable
filterset = InterfaceFilterSet
filterset_form = InterfaceFilterForm
tab = ViewTab(
label=_('Device Interfaces'),
badge=lambda x: x.get_interfaces().count(),
@@ -1146,6 +1140,7 @@ class VLANVMInterfacesView(generic.ObjectChildrenView):
child_model = VMInterface
table = tables.VLANVirtualMachinesTable
filterset = VMInterfaceFilterSet
filterset_form = VMInterfaceFilterForm
tab = ViewTab(
label=_('VM Interfaces'),
badge=lambda x: x.get_vminterfaces().count(),

View File

@@ -1,7 +1,7 @@
import re
from django import forms
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy as _
from netbox.search import LookupTypes
from netbox.search.backends import search_backend
@@ -36,7 +36,8 @@ class SearchForm(forms.Form):
lookup = forms.ChoiceField(
choices=LOOKUP_CHOICES,
initial=LookupTypes.PARTIAL,
required=False
required=False,
label=_('Lookup')
)
def __init__(self, *args, **kwargs):

View File

@@ -47,6 +47,11 @@ class CoreMiddleware:
with event_tracking(request):
response = self.get_response(request)
# Check if language cookie should be renewed
if request.user.is_authenticated and settings.SESSION_SAVE_EVERY_REQUEST:
if language := request.user.config.get('locale.language'):
response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language, max_age=request.session.get_expiry_age())
# Attach the unique request ID as an HTTP header.
response['X-Request-ID'] = request.id

View File

@@ -462,16 +462,13 @@ MENUS = [
PROVISIONING_MENU,
CUSTOMIZATION_MENU,
OPERATIONS_MENU,
ADMIN_MENU,
]
#
# Add plugin menus
#
# Add top-level plugin menus
for menu in registry['plugins']['menus']:
MENUS.append(menu)
# Add the default "plugins" menu
if registry['plugins']['menu_items']:
# Build the default plugins menu
@@ -485,3 +482,6 @@ if registry['plugins']['menu_items']:
groups=groups
)
MENUS.append(plugins_menu)
# Add the admin menu last
MENUS.append(ADMIN_MENU)

View File

@@ -8,6 +8,7 @@ from django.db.models.fields.related import ForeignKey
from django.db.models.functions import window
from django.db.models.signals import post_delete, post_save
from django.utils.module_loading import import_string
from django.utils.translation import gettext_lazy as _
import netaddr
from netaddr.core import AddrFormatError
@@ -39,7 +40,7 @@ class SearchBackend:
# Organize choices by category
categories = defaultdict(dict)
for label, idx in registry['search'].items():
categories[idx.get_category()][label] = title(idx.model._meta.verbose_name)
categories[idx.get_category()][label] = _(title(idx.model._meta.verbose_name))
# Compile a nested tuple of choices for form rendering
results = (

View File

@@ -25,7 +25,7 @@ from utilities.string import trailing_slash
# Environment setup
#
VERSION = '4.0.5'
VERSION = '4.0.7'
HOSTNAME = platform.node()
# Set the base directory two levels up
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -147,6 +147,7 @@ SECURE_SSL_REDIRECT = getattr(configuration, 'SECURE_SSL_REDIRECT', False)
SENTRY_DSN = getattr(configuration, 'SENTRY_DSN', None)
SENTRY_ENABLED = getattr(configuration, 'SENTRY_ENABLED', False)
SENTRY_SAMPLE_RATE = getattr(configuration, 'SENTRY_SAMPLE_RATE', 1.0)
SENTRY_SEND_DEFAULT_PII = getattr(configuration, 'SENTRY_SEND_DEFAULT_PII', False)
SENTRY_TAGS = getattr(configuration, 'SENTRY_TAGS', {})
SENTRY_TRACES_SAMPLE_RATE = getattr(configuration, 'SENTRY_TRACES_SAMPLE_RATE', 0)
SESSION_COOKIE_NAME = getattr(configuration, 'SESSION_COOKIE_NAME', 'sessionid')
@@ -225,6 +226,23 @@ if STORAGE_BACKEND is not None:
return globals().get(name, default)
storages.utils.setting = _setting
# django-storage-swift
elif STORAGE_BACKEND == 'swift.storage.SwiftStorage':
try:
import swift.utils # type: ignore
except ModuleNotFoundError as e:
if getattr(e, 'name') == 'swift':
raise ImproperlyConfigured(
f"STORAGE_BACKEND is set to {STORAGE_BACKEND} but django-storage-swift is not present. "
"It can be installed by running 'pip install django-storage-swift'."
)
raise e
# Load all SWIFT_* settings from the user configuration
for param, value in STORAGE_CONFIG.items():
if param.startswith('SWIFT_'):
globals()[param] = value
if STORAGE_CONFIG and STORAGE_BACKEND is None:
warnings.warn(
"STORAGE_CONFIG has been set in configuration.py but STORAGE_BACKEND is not defined. STORAGE_CONFIG will be "
@@ -536,7 +554,7 @@ if SENTRY_ENABLED:
release=VERSION,
sample_rate=SENTRY_SAMPLE_RATE,
traces_sample_rate=SENTRY_TRACES_SAMPLE_RATE,
send_default_pii=True,
send_default_pii=SENTRY_SEND_DEFAULT_PII,
http_proxy=HTTP_PROXIES.get('http') if HTTP_PROXIES else None,
https_proxy=HTTP_PROXIES.get('https') if HTTP_PROXIES else None
)
@@ -551,7 +569,7 @@ if SENTRY_ENABLED:
# Calculate a unique deployment ID from the secret key
DEPLOYMENT_ID = hashlib.sha256(SECRET_KEY.encode('utf-8')).hexdigest()[:16]
CENSUS_URL = 'https://census.netbox.dev/api/v1/'
CENSUS_URL = 'https://census.netbox.oss.netboxlabs.com/api/v1/'
CENSUS_PARAMS = {
'version': VERSION,
'python_version': sys.version.split()[0],

View File

@@ -1,3 +1,4 @@
import zoneinfo
from dataclasses import dataclass
from typing import Optional
from urllib.parse import quote
@@ -83,6 +84,8 @@ class DateTimeColumn(tables.Column):
def render(self, value):
if value:
current_tz = zoneinfo.ZoneInfo(settings.TIME_ZONE)
value = value.astimezone(current_tz)
return f"{value.date().isoformat()} {value.time().isoformat(timespec=self.timespec)}"
def value(self, value):
@@ -430,7 +433,7 @@ class LinkedCountColumn(tables.Column):
f'{k}={getattr(record, v) or settings.FILTERS_NULL_CHOICE_VALUE}'
for k, v in self.url_params.items()
])
return mark_safe(f'<a href="{url}">{value}</a>')
return mark_safe(f'<a href="{url}">{escape(value)}</a>')
return value
def value(self, value):

View File

@@ -176,7 +176,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
'model': model,
'table': table,
'actions': actions,
'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None,
'filter_form': self.filterset_form(request.GET) if self.filterset_form else None,
'prerequisite_model': get_prerequisite_model(self.queryset),
**self.get_extra_context(request),
}

View File

@@ -87,12 +87,14 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
child_model: The model class which represents the child objects
table: The django-tables2 Table class used to render the child objects list
filterset: A django-filter FilterSet that is applied to the queryset
filterset_form: The form class used to render filter options
actions: A mapping of supported actions to their required permissions. When adding custom actions, bulk
action names must be prefixed with `bulk_`. (See ActionsMixin.)
"""
child_model = None
table = None
filterset = None
filterset_form = None
template_name = 'generic/object_children.html'
def get_children(self, request, parent):
@@ -152,6 +154,7 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
'base_template': f'{instance._meta.app_label}/{instance._meta.model_name}.html',
'table': table,
'table_config': f'{table.name}_config',
'filter_form': self.filterset_form(request.GET) if self.filterset_form else None,
'actions': actions,
'tab': self.tab,
'return_url': request.get_full_path(),

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

@@ -27,10 +27,10 @@
"bootstrap": "5.3.3",
"clipboard": "2.0.11",
"flatpickr": "4.6.13",
"gridstack": "10.2.0",
"gridstack": "10.3.0",
"htmx.org": "1.9.12",
"query-string": "9.0.0",
"sass": "1.77.4",
"sass": "1.77.6",
"tom-select": "2.3.1",
"typeface-inter": "3.18.1",
"typeface-roboto-mono": "1.1.13"

View File

@@ -0,0 +1,30 @@
import { isTruthy } from '../util';
/**
* Handle saved filter change event.
*
* @param event "change" event for the saved filter select
*/
function handleSavedFilterChange(event: Event): void {
const savedFilter = event.currentTarget as HTMLSelectElement;
let baseUrl = savedFilter.baseURI.split('?')[0];
const preFilter = '?';
const selectedOptions = Array.from(savedFilter.options)
.filter(option => option.selected)
.map(option => `filter_id=${option.value}`)
.join('&');
baseUrl += `${preFilter}${selectedOptions}`;
document.location.href = baseUrl;
}
export function initSavedFilterSelect(): void {
const divResults = document.getElementById('results');
if (isTruthy(divResults)) {
const savedFilterSelect = document.getElementById('id_filter_id');
if (isTruthy(savedFilterSelect)) {
savedFilterSelect.addEventListener('change', handleSavedFilterChange);
}
}
}

View File

@@ -13,6 +13,7 @@ import { initSideNav } from './sidenav';
import { initDashboard } from './dashboard';
import { initRackElevation } from './racks';
import { initHtmx } from './htmx';
import { initSavedFilterSelect } from './forms/savedFiltersSelect';
function initDocument(): void {
for (const init of [
@@ -31,6 +32,7 @@ function initDocument(): void {
initDashboard,
initRackElevation,
initHtmx,
initSavedFilterSelect,
]) {
init();
}

View File

@@ -74,20 +74,25 @@ export class DynamicTomSelect extends TomSelect {
load(value: string) {
const self = this;
const url = self.getRequestUrl(value);
// Automatically clear any cached options. (Only options included
// in the API response should be present.)
self.clearOptions();
addClasses(self.wrapper, self.settings.loadingClass);
self.loading++;
// Populate the null option (if any) if not searching
if (self.nullOption && !value) {
self.addOption(self.nullOption);
}
// Get the API request URL. If none is provided, abort as no request can be made.
const url = self.getRequestUrl(value);
if (!url) {
return;
}
addClasses(self.wrapper, self.settings.loadingClass);
self.loading++;
// Make the API request
fetch(url)
.then(response => response.json())
@@ -129,6 +134,9 @@ export class DynamicTomSelect extends TomSelect {
for (const result of this.api_url.matchAll(new RegExp(`({{${key}}})`, 'g'))) {
if (value) {
url = replaceAll(url, result[1], value.toString());
} else {
// No value is available to replace the token; abort.
return '';
}
}
}

View File

@@ -7,6 +7,7 @@
// Overrides of external libraries
@import 'overrides/bootstrap';
@import 'overrides/tabler';
@import 'overrides/tomselect';
// Transitional styling to ease migration of templates from NetBox v3.x
@import 'transitional/badges';

View File

@@ -0,0 +1,8 @@
.ts-wrapper.multi {
.ts-control {
padding: 7px 7px 3px 7px;
div {
margin: 0 4px 4px 0;
}
}
}

View File

@@ -1754,10 +1754,10 @@ graphql@16.8.1:
resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.8.1.tgz#1930a965bef1170603702acdb68aedd3f3cf6f07"
integrity sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==
gridstack@10.2.0:
version "10.2.0"
resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-10.2.0.tgz#4ba9c7ee69a730851721a9f5cb33dc55026ded1f"
integrity sha512-svKAOq/dfinpvhe/nnxdyZOOEd9qynXiOPHvL96PALE0yWChWp/6lechnqKwud0tL/rRyAfMJ6Hh/z2fS13pBA==
gridstack@10.3.0:
version "10.3.0"
resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-10.3.0.tgz#8fa065f896d0a880c5c54c24d189f3197184488a"
integrity sha512-eGKsmU2TppV4coyDu9IIdIkm4qjgLLdjlEOFwQyQMuSwfOpzSfLdPc8du0HuebGr7CvAIrJxN4lBOmGrWSBg9g==
has-bigints@^1.0.1, has-bigints@^1.0.2:
version "1.0.2"
@@ -2482,10 +2482,10 @@ safe-regex-test@^1.0.3:
es-errors "^1.3.0"
is-regex "^1.1.4"
sass@1.77.4:
version "1.77.4"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.77.4.tgz#92059c7bfc56b827c56eb116778d157ec017a5cd"
integrity sha512-vcF3Ckow6g939GMA4PeU7b2K/9FALXk2KF9J87txdHzXbUF9XRQRwSxcAs/fGaTnJeBFd7UoV22j3lzMLdM0Pw==
sass@1.77.6:
version "1.77.6"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.77.6.tgz#898845c1348078c2e6d1b64f9ee06b3f8bd489e4"
integrity sha512-ByXE1oLD79GVq9Ht1PeHWCPMPB8XHpBuz1r85oByKHjZY6qV6rWnQovQzXJXuQ/XyE1Oj3iPk3lo28uzaRA2/Q==
dependencies:
chokidar ">=3.0.0 <4.0.0"
immutable "^4.0.0"

View File

@@ -35,6 +35,7 @@ Blocks:
{# User menu (mobile view) #}
<div class="navbar-nav flex-row d-lg-none">
{% include 'inc/light_toggle.html' %}
{% include 'inc/user_menu.html' %}
</div>
@@ -52,14 +53,7 @@ Blocks:
<div class="navbar-nav flex-row align-items-center order-md-last">
{# Dark/light mode toggle #}
<div class="d-none d-md-flex">
<button class="btn color-mode-toggle hide-theme-dark" title="{% trans "Enable dark mode" %}" data-bs-toggle="tooltip" data-bs-placement="bottom">
<i class="mdi mdi-lightbulb"></i>
</button>
<button class="btn color-mode-toggle hide-theme-light" title="{% trans "Enable light mode" %}" data-bs-toggle="tooltip" data-bs-placement="bottom">
<i class="mdi mdi-lightbulb-on"></i>
</button>
</div>
{% include 'inc/light_toggle.html' %}
{# User menu #}
{% include 'inc/user_menu.html' %}

View File

@@ -88,7 +88,7 @@
<div class="col col-md-12">
<div class="card">
<h5 class="card-header">{% trans "Current Configuration" %}</h5>
{% include 'core/inc/config_data.html' with config=config.data %}
{% include 'core/inc/config_data.html' %}
</div>
</div>

View File

@@ -28,7 +28,7 @@
</tr>
<tr>
<th scope="row">{% trans "Rack" %}</th>
<td class="d-flex justify-content-between">
<td class="d-flex justify-content-between align-items-start">
{% if object.rack %}
{{ object.rack|linkify }}
<a href="{{ object.rack.get_absolute_url }}?device={{ object.pk }}" class="btn btn-primary btn-sm d-print-none" title="{% trans "Highlight device in rack" %}">
@@ -125,28 +125,30 @@
</div>
</h5>
<table class="table table-hover attr-table">
<tr>
<th>{% trans "Device" %}</th>
<th>{% trans "Position" %}</th>
<th>{% trans "Master" %}</th>
<th>{% trans "Priority" %}</th>
<thead>
<tr class="border-bottom">
<th>{% trans "Device" %}</th>
<th>{% trans "Position" %}</th>
<th>{% trans "Master" %}</th>
<th>{% trans "Priority" %}</th>
</tr>
</thead>
<tbody>
{% for vc_member in vc_members %}
<tr{% if vc_member == object %} class="info"{% endif %}>
<td>
{{ vc_member|linkify }}
</td>
<td>
{% badge vc_member.vc_position show_empty=True %}
</td>
<td>
{% if object.virtual_chassis.master == vc_member %}<i class="mdi mdi-check-bold"></i>{% endif %}
</td>
<td>
{{ vc_member.vc_priority|placeholder }}
</td>
</tr>
<tr{% if vc_member == object %} class="table-primary"{% endif %}>
<td>{{ vc_member|linkify }}</td>
<td>{% badge vc_member.vc_position show_empty=True %}</td>
<td>
{% if object.virtual_chassis.master == vc_member %}
{% checkmark True %}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
<td>{{ vc_member.vc_priority|placeholder }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
@@ -221,6 +223,11 @@
<td>
{% if object.oob_ip %}
<a href="{{ object.oob_ip.get_absolute_url }}" id="oob_ip">{{ object.oob_ip.address.ip }}</a>
{% if object.oob_ip.nat_inside %}
({% trans "NAT for" %} <a href="{{ object.oob_ip.nat_inside.get_absolute_url }}">{{ object.oob_ip.nat_inside.address.ip }}</a>)
{% elif object.oob_ip.nat_outside.exists %}
({% trans "NAT" %}: {% for nat in object.oob_ip.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
{% endif %}
{% copy_content "oob_ip" %}
{% else %}
{{ ''|placeholder }}

View File

@@ -73,7 +73,7 @@
</tr>
<tr>
<th scope="row">{% trans "Physical Address" %}</th>
<td class="d-flex justify-content-between">
<td class="d-flex justify-content-between align-items-start">
{% if object.physical_address %}
<span>{{ object.physical_address|linebreaksbr }}</span>
{% if config.MAPS_URL %}

View File

@@ -44,7 +44,7 @@
{# Object table controls #}
<div class="row mb-3">
<div class="col-auto ms-auto d-print-none">
{% if request.user.is_authenticated %}
{% if request.user.is_authenticated and job.completed %}
<div class="table-configure input-group">
<button type="button" data-bs-toggle="modal" title="{% trans "Configure Table" %}" data-bs-target="#ObjectTable_config"
class="btn">

View File

@@ -48,7 +48,7 @@ Context:
<li class="nav-item" role="presentation">
<a class="nav-link active" id="object-list-tab" data-bs-toggle="tab" data-bs-target="#object-list" type="button" role="tab" aria-controls="edit-form" aria-selected="true">
{% trans "Results" %}
<span class="badge text-bg-secondary total-object-count">{{ table.page.paginator.count }}</span>
<span class="badge text-bg-secondary total-object-count">{% if table.page.paginator.count %}{{ table.page.paginator.count }}{% else %}{{ total_count|default:"0" }}{% endif %}</span>
</a>
</li>
{% if filter_form %}

View File

@@ -0,0 +1,10 @@
{% load i18n %}
<div class="d-flex">
<button class="btn color-mode-toggle hide-theme-dark" title="{% trans "Enable dark mode" %}" data-bs-toggle="tooltip" data-bs-placement="bottom">
<i class="mdi mdi-lightbulb"></i>
</button>
<button class="btn color-mode-toggle hide-theme-light" title="{% trans "Enable light mode" %}" data-bs-toggle="tooltip" data-bs-placement="bottom">
<i class="mdi mdi-lightbulb-on"></i>
</button>
</div>

View File

@@ -1,25 +1,39 @@
{% load helpers %}
{% load i18n %}
<div class="row mb-3">
<div class="row mb-3" id="results">
<div class="col-auto d-print-none">
<div class="input-group input-group-flat me-2 quicksearch" hx-disinherit="hx-select hx-swap">
<input type="search" results="5" name="q" id="quicksearch" class="form-control px-2 py-1" placeholder="Quick search"
hx-get="{{ request.full_path }}" hx-target="#object_list" hx-trigger="keyup changed delay:500ms, search" />
<input type="search" results="5" name="q" id="quicksearch" class="form-control" placeholder="{% trans "Quick search" %}"
hx-get="{{ request.full_path }}" hx-target="#object_list" hx-trigger="keyup changed delay:500ms, search"/>
<span class="input-group-text py-1">
<a href="#" id="quicksearch_clear" class="invisible text-secondary"><i class="mdi mdi-close-circle"></i></a>
</span>
{% block extra_table_controls %}{% endblock %}
{% block extra_table_controls %}{% endblock %}
</div>
</div>
{% if filter_form %}
<div class="col-auto d-print-none">
<div class="input-group">
<div class="input-group-text">
<i class="mdi mdi-filter" title="{% trans "Saved filter" %}"></i>
</div>
{{ filter_form.filter_id }}
</div>
</div>
{% endif %}
<div class="col-auto ms-auto d-print-none">
{% if request.user.is_authenticated and table_modal %}
<div class="table-configure input-group">
<button type="button" data-bs-toggle="modal" title="{% trans "Configure Table" %}" data-bs-target="#{{ table_modal }}"
class="btn">
<button type="button" data-bs-toggle="modal" title="{% trans "Configure Table" %}"
data-bs-target="#{{ table_modal }}"
class="btn">
<i class="mdi mdi-cog"></i> {% trans "Configure Table" %}
</button>
</div>
{% endif %}
</div>
</div>

View File

@@ -53,10 +53,6 @@
{% endwith %}
</div>
<div class="field-group my-5">
{% render_field form.comments %}
</div>
{% if form.custom_fields %}
<div class="field-group my-5">
<div class="row">
@@ -65,4 +61,8 @@
{% render_custom_fields form %}
</div>
{% endif %}
<div class="field-group my-5">
{% render_field form.comments %}
</div>
{% endblock %}

View File

@@ -4,8 +4,7 @@ from django.utils.translation import gettext as _
from netbox.views import generic
from utilities.query import count_related
from utilities.relations import get_related_models
from utilities.views import register_model_view, ViewTab
from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view
from . import filtersets, forms, tables
from .models import *
@@ -14,6 +13,7 @@ class ObjectContactsView(generic.ObjectChildrenView):
child_model = ContactAssignment
table = tables.ContactAssignmentTable
filterset = filtersets.ContactAssignmentFilterSet
filterset_form = forms.ContactAssignmentFilterForm
template_name = 'tenancy/object_contacts.html'
tab = ViewTab(
label=_('Contacts'),
@@ -56,17 +56,14 @@ class TenantGroupListView(generic.ObjectListView):
@register_model_view(TenantGroup)
class TenantGroupView(generic.ObjectView):
class TenantGroupView(GetRelatedModelsMixin, generic.ObjectView):
queryset = TenantGroup.objects.all()
def get_extra_context(self, request, instance):
groups = instance.get_descendants(include_self=True)
related_models = (
(Tenant.objects.restrict(request.user, 'view').filter(group__in=groups), 'group_id'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(request, groups),
}
@@ -123,17 +120,12 @@ class TenantListView(generic.ObjectListView):
@register_model_view(Tenant)
class TenantView(generic.ObjectView):
class TenantView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Tenant.objects.all()
def get_extra_context(self, request, instance):
related_models = [
(model.objects.restrict(request.user, 'view').filter(tenant=instance), f'{field}_id')
for model, field in get_related_models(Tenant)
]
return {
'related_models': related_models,
'related_models': self.get_related_models(request, instance),
}
@@ -189,17 +181,14 @@ class ContactGroupListView(generic.ObjectListView):
@register_model_view(ContactGroup)
class ContactGroupView(generic.ObjectView):
class ContactGroupView(GetRelatedModelsMixin, generic.ObjectView):
queryset = ContactGroup.objects.all()
def get_extra_context(self, request, instance):
groups = instance.get_descendants(include_self=True)
related_models = (
(Contact.objects.restrict(request.user, 'view').filter(group__in=groups), 'group_id'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(request, groups),
}
@@ -256,16 +245,12 @@ class ContactRoleListView(generic.ObjectListView):
@register_model_view(ContactRole)
class ContactRoleView(generic.ObjectView):
class ContactRoleView(GetRelatedModelsMixin, generic.ObjectView):
queryset = ContactRole.objects.all()
def get_extra_context(self, request, instance):
related_models = (
(ContactAssignment.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(request, instance),
}
@@ -380,7 +365,7 @@ class ContactAssignmentEditView(generic.ObjectEditView):
def get_extra_addanother_params(self, request):
return {
'content_type': request.GET.get('content_type'),
'object_type': request.GET.get('object_type'),
'object_id': request.GET.get('object_id'),
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -39,7 +39,7 @@ def handle_protectederror(obj_list, request, e):
if hasattr(dependent, 'get_absolute_url'):
dependent_objects.append(f'<a href="{dependent.get_absolute_url()}">{escape(dependent)}</a>')
else:
dependent_objects.append(str(dependent))
dependent_objects.append(escape(str(dependent)))
err_message += ', '.join(dependent_objects)
messages.error(request, mark_safe(err_message))

View File

@@ -2,6 +2,7 @@ from collections import defaultdict
from django.contrib.contenttypes.fields import GenericForeignKey
from django.db import models
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from utilities.ordering import naturalize
@@ -26,6 +27,7 @@ class ColorField(models.CharField):
def formfield(self, **kwargs):
kwargs['widget'] = ColorSelect
kwargs['help_text'] = mark_safe(_('RGB color in hexadecimal. Example: ') + '<code>00ff00</code>')
return super().formfield(**kwargs)

View File

@@ -55,7 +55,7 @@ def prepare_cloned_fields(instance):
for key, value in attrs.items():
if type(value) in (list, tuple):
params.extend([(key, v) for v in value])
elif value not in (False, None):
elif value is not False and value is not None:
params.append((key, value))
else:
params.append((key, ''))

View File

@@ -29,7 +29,7 @@ def linkify_phone(value):
"""
if value is None:
return None
return f"tel:{value}"
return f"tel:{value.replace(' ', '')}"
def register_table_column(column, name, *tables):

View File

@@ -11,7 +11,7 @@
{% elif customfield.type == 'date' and value %}
{{ value|isodate }}
{% elif customfield.type == 'datetime' and value %}
{{ value|isodate }} {{ value|isodatetime }}
{{ value|isodatetime }}
{% elif customfield.type == 'url' and value %}
<a href="{{ value }}">{{ value|truncatechars:70 }}</a>
{% elif customfield.type == 'json' and value %}

View File

@@ -1,7 +1,23 @@
{% load helpers %}
{% load i18n %}
{% load navigation %}
<ul class="navbar-nav pt-lg-2" {% htmx_boost %}>
<li class="nav-item d-block d-lg-none">
<form action="{% url 'search' %}" method="get" autocomplete="off" novalidate>
<div class="input-group mb-1 mt-2">
<div class="input-group-prepend">
<span class="input-group-text">
<i class="mdi mdi-magnify"></i>
</span>
</div>
<input type="text" name="q" value="" class="form-control" placeholder="{% trans "Search" %}" aria-label="{% trans "Search NetBox" %}">
<div class="input-group-append">
<button type="submit" class="form-control">{% trans "Search" %}</button>
</div>
</div>
</form>
</li>
{% for menu, groups in nav_items %}
<li class="nav-item dropdown">

View File

@@ -58,7 +58,7 @@ def linkify(instance, attr=None):
url = instance.get_absolute_url()
return mark_safe(f'<a href="{url}">{escape(text)}</a>')
except (AttributeError, TypeError):
return text
return escape(text)
@register.filter()

View File

@@ -1,4 +1,5 @@
from django import template
from django.utils.safestring import mark_safe
from extras.choices import CustomFieldTypeChoices
from utilities.querydict import dict_to_querydict
@@ -124,5 +125,5 @@ def formaction(context):
if HTMX navigation is enabled (per the user's preferences).
"""
if context.get('htmx_navigation', False):
return 'hx-push-url="true" hx-post'
return mark_safe('hx-push-url="true" hx-post')
return 'formaction'

View File

@@ -281,6 +281,10 @@ def applied_filters(context, model, form, query_params):
if filter_name not in querydict:
continue
# Skip saved filters, as they're displayed alongside the quick search widget
if filter_name == 'filter_id':
continue
bound_field = form.fields[filter_name].get_bound_field(form, filter_name)
querydict.pop(filter_name)
display_value = ', '.join([str(v) for v in get_selected_values(form, filter_name)])

View File

@@ -1,4 +1,5 @@
from django import template
from django.utils.html import escape
from django.utils.safestring import mark_safe
register = template.Library()
@@ -15,6 +16,6 @@ def nested_tree(obj):
nodes = obj.get_ancestors(include_self=True)
return mark_safe(
' / '.join(
f'<a href="{node.get_absolute_url()}">{node}</a>' for node in nodes
f'<a href="{node.get_absolute_url()}">{escape(node)}</a>' for node in nodes
)
)

View File

@@ -1,15 +1,20 @@
from typing import Iterable
from django.contrib.auth.mixins import AccessMixin
from django.core.exceptions import ImproperlyConfigured
from django.urls import reverse
from django.urls.exceptions import NoReverseMatch
from django.utils.http import url_has_allowed_host_and_scheme
from django.utils.translation import gettext_lazy as _
from netbox.plugins import PluginConfig
from netbox.registry import registry
from utilities.relations import get_related_models
from .permissions import resolve_permission
__all__ = (
'ContentTypePermissionRequiredMixin',
'GetRelatedModelsMixin',
'GetReturnURLMixin',
'ObjectPermissionRequiredMixin',
'ViewTab',
@@ -119,7 +124,7 @@ class GetReturnURLMixin:
# First, see if `return_url` was specified as a query parameter or form data. Use this URL only if it's
# considered safe.
return_url = request.GET.get('return_url') or request.POST.get('return_url')
if return_url and return_url.startswith('/'):
if return_url and url_has_allowed_host_and_scheme(return_url, allowed_hosts=None):
return return_url
# Next, check if the object being modified (if any) has an absolute URL.
@@ -142,6 +147,46 @@ class GetReturnURLMixin:
return reverse('home')
class GetRelatedModelsMixin:
"""
Provides logic for collecting all related models for the currently viewed model.
"""
def get_related_models(self, request, instance, omit=[], extra=[]):
"""
Get related models of the view's `queryset` model without those listed in `omit`. Will be sorted alphabetical.
Args:
request: Current request being processed.
instance: The instance related models should be looked up for. A list of instances can be passed to match
related objects in this list (e.g. to find sites of a region including child regions).
omit: Remove relationships to these models from the result. Needs to be passed, if related models don't
provide a `_list` view.
extra: Add extra models to the list of automatically determined related models. Can be used to add indirect
relationships.
"""
model = self.queryset.model
related = filter(
lambda m: m[0] is not model and m[0] not in omit,
get_related_models(model, False)
)
related_models = [
(
model.objects.restrict(request.user, 'view').filter(**(
{f'{field}__in': instance}
if isinstance(instance, Iterable)
else {field: instance}
)),
f'{field}_id'
)
for model, field in related
]
related_models.extend(extra)
return sorted(related_models, key=lambda x: x[0].model._meta.verbose_name.lower())
class ViewTab:
"""
ViewTabs are used for navigation among multiple object-specific views, such as the changelog or journal for

View File

@@ -178,8 +178,8 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
required=False,
selector=True,
query_params={
'site_id': '$site',
}
'site_id': ['$site', 'null']
},
)
device = DynamicModelChoiceField(
label=_('Device'),

View File

@@ -179,8 +179,8 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co
'cluster': _('A virtual machine must be assigned to a site and/or cluster.')
})
# Validate site for cluster & device
if self.cluster and self.site and self.cluster.site != self.site:
# Validate site for cluster & VM
if self.cluster and self.site and self.cluster.site and self.cluster.site != self.site:
raise ValidationError({
'cluster': _(
'The selected cluster ({cluster}) is not assigned to this site ({site}).'

View File

@@ -63,6 +63,9 @@ class VirtualMachineTestCase(TestCase):
# VM with site only should pass
VirtualMachine(name='vm1', site=sites[0]).full_clean()
# VM with site, cluster non-site should pass
VirtualMachine(name='vm1', site=sites[0], cluster=clusters[2]).full_clean()
# VM with non-site cluster only should pass
VirtualMachine(name='vm1', cluster=clusters[2]).full_clean()

View File

@@ -10,6 +10,7 @@ from django.utils.translation import gettext as _
from jinja2.exceptions import TemplateError
from dcim.filtersets import DeviceFilterSet
from dcim.forms import DeviceFilterForm
from dcim.models import Device
from dcim.tables import DeviceTable
from extras.views import ObjectConfigContextView
@@ -20,7 +21,7 @@ from netbox.views import generic
from tenancy.views import ObjectContactsView
from utilities.query import count_related
from utilities.query_functions import CollateAsChar
from utilities.views import ViewTab, register_model_view
from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view
from . import filtersets, forms, tables
from .models import *
@@ -39,16 +40,12 @@ class ClusterTypeListView(generic.ObjectListView):
@register_model_view(ClusterType)
class ClusterTypeView(generic.ObjectView):
class ClusterTypeView(GetRelatedModelsMixin, generic.ObjectView):
queryset = ClusterType.objects.all()
def get_extra_context(self, request, instance):
related_models = (
(Cluster.objects.restrict(request.user, 'view').filter(type=instance), 'type_id'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(request, instance),
}
@@ -99,16 +96,12 @@ class ClusterGroupListView(generic.ObjectListView):
@register_model_view(ClusterGroup)
class ClusterGroupView(generic.ObjectView):
class ClusterGroupView(GetRelatedModelsMixin, generic.ObjectView):
queryset = ClusterGroup.objects.all()
def get_extra_context(self, request, instance):
related_models = (
(Cluster.objects.restrict(request.user, 'view').filter(group=instance), 'group_id'),
)
return {
'related_models': related_models,
'related_models': self.get_related_models(request, instance),
}
@@ -181,6 +174,7 @@ class ClusterVirtualMachinesView(generic.ObjectChildrenView):
child_model = VirtualMachine
table = tables.VirtualMachineTable
filterset = filtersets.VirtualMachineFilterSet
filterset_form = forms.VirtualMachineFilterForm
tab = ViewTab(
label=_('Virtual Machines'),
badge=lambda obj: obj.virtual_machines.count(),
@@ -198,6 +192,7 @@ class ClusterDevicesView(generic.ObjectChildrenView):
child_model = Device
table = DeviceTable
filterset = DeviceFilterSet
filterset_form = DeviceFilterForm
template_name = 'virtualization/cluster/devices.html'
actions = {
'add': {'add'},
@@ -358,6 +353,7 @@ class VirtualMachineInterfacesView(generic.ObjectChildrenView):
child_model = VMInterface
table = tables.VirtualMachineVMInterfaceTable
filterset = filtersets.VMInterfaceFilterSet
filterset_form = forms.VMInterfaceFilterForm
template_name = 'virtualization/virtualmachine/interfaces.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
@@ -383,6 +379,7 @@ class VirtualMachineVirtualDisksView(generic.ObjectChildrenView):
child_model = VirtualDisk
table = tables.VirtualMachineVirtualDiskTable
filterset = filtersets.VirtualDiskFilterSet
filterset_form = forms.VirtualDiskFilterForm
template_name = 'virtualization/virtualmachine/virtual_disks.html'
tab = ViewTab(
label=_('Virtual Disks'),

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