Compare commits

..

193 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
Jeremy Stretch
e68b83907b Merge pull request #16432 from netbox-community/develop
Release v4.0.5
2024-06-06 11:59:00 -04:00
Jeremy Stretch
2682f03a6b Re-bundle static assets 2024-06-06 11:42:47 -04:00
Jeremy Stretch
2304df84d5 Merge branch 'master' into develop 2024-06-06 11:36:08 -04:00
Jeremy Stretch
5530556626 Merge pull request #16429 from netbox-community/develop
Release v4.0.5
2024-06-06 11:31:54 -04:00
Jeremy Stretch
e4d240ace2 Release v4.0.5 2024-06-06 10:55:30 -04:00
transifex-integration[bot]
58f22eec37 Updates for project NetBox (#16346)
* Translate django.po in de [Manual Sync]

74% 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 de [Manual Sync]

74% 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 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 pt [Manual Sync]

2% of minimum 1% reviewed source file: 'django.po'
on 'pt'.

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 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 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-06 10:27:06 -04:00
Julio Oliveira at Encora
7e1b3d0b54 15873 - Make Cluster resource counters more readable (#15900)
* Created "convert_byte_size" method to convert the memory and disk size according to unit informed.
Changed "get_extra_context" method from "ClusterView" to use the method above and convert all the disks and memories from VMs to normalize the units.

* Changed decimal size for memory_sum and disk_sum

* Added test for convert_byte_size.

* Fixed

* Addressed PR comments.
Changed humanize_megabytes in helpers.py

* Addressed PR comments.
Changed humanize_megabytes in helpers.py

* Linter issues for helpers.py

* Changed humanize_megabytes

* Changed humanize_megabytes

* Changed humanize_megabytes

* Added the title to display the value in MB when mouseover.

* Addressed PR comment.

* Addressed PR comment.

* Rewrite sizing logic

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-06-06 09:37:29 -04:00
Julio Oliveira at Encora
3acf3b51ee Fixes: #14567 - Export current view of IP Addresses (#15659)
* Added javascript and htmx to change the url.

* Added javascript and htmx to change the url

* Addressed PR comments

* Added Netbox.js and netbox.js.map

* Addressed PR comments

* Addressed PR comments

* Addressed PR comments

* Addressed PR comments

* Addressed PR comments

* Addressed PR comments

* Addressed PR comments

* Addressed PR comments

* Linter Issues

* Fix assets issue

* Fix assets issue

* Addressed PR comment.
It was added clearLinkParams to clear button.

* Added passive:true to search.ts

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-06-06 09:35:27 -04:00
Arthur Hanson
8f87c72eaa 16050 Show script python_class name and description (#16185)
* 16050 Show script python_class name and description

* 16050 change to use Meta.description

* 16050 change to use Meta.description

* 16050 remove module name customization from docs
2024-06-06 09:05:59 -04:00
Louis Jarasius
18b43408ec Fixes #16274: Dark mode highlight color (#16355)
* Increase ::selection background-color aplha

* Improve comment for override

* Add compiled CSS

* Only override on dark theme
2024-06-06 08:44:32 -04:00
Julio Oliveira at Encora
b10fb67ce9 Fixed error when the active Config is deleted and rest only one to restore. (#16408) 2024-06-05 12:23:36 -07:00
Jeremy Stretch
c27cb6f153 Fix styling of object jobs table 2024-06-05 09:02:05 -04:00
github-actions
81f0a40505 Update source translation strings 2024-06-05 05:02:18 +00:00
Jeremy Stretch
4242546270 Fixes #16376: Log changes on terminating objects when attaching a cable 2024-06-04 14:37:33 -04:00
Julio Oliveira at Encora
87109f5539 16315 - Cant filter changelog by object type (no results found) (#16324)
* Replaced "api=/api/extras/content-types/" with "/api/extras/object-types/" for JournalEntryFilterForm and ObjectChangeFilterForm.

* Addressed PR comment.

* Correct feature classifications

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-06-04 09:37:08 -04:00
Daniel Sheppard
8ab9afb8db Fixes: #16083 - Add font-variant-ligatures setting to disable ligatur… (#16383)
* Fixes: #16083 - Add font-variant-ligatures setting to disable ligatures on chromium

* Fix comment

* Disable ligatures on input fields

* Condense rules & apply to all elements

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-06-04 09:02:38 -04:00
Jamie (Bear) Murphy
7be003f5a0 Allow plugins to extend objectchange view (#16371)
* allow plugins to extend objectchangeview with panels

* replace tabs with spaces

* Update netbox/templates/extras/objectchange.html

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

* Eliminate excessive vertical margin

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-06-04 08:49:08 -04:00
github-actions
291e0665d0 Update source translation strings 2024-06-04 05:02:13 +00:00
Arthur Hanson
8e48e939aa 16261 fix graphql lookup for MultiValueCharFilter fields (#16354)
* 16261 fix graphql lookup for MultiValueCharFilter fields

* 16261 fix graphql lookup for MultiValueCharFilter fields

* 16261 fixup test

* 16261 fixup test

* Omit redundant assignment

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-06-03 10:24:01 -04:00
Daniel Sheppard
fdad59c8cc Fixes: #16039 - Fix row highlighting on device components and VM interfaces (#16044)
* Fix row highlighting

* Minor fix for VMInterfaces

* Move duplicated dicts into inheritable meta class

* Add CableTerminationTable.Meta class for inheritance of the row_attrs to each descendant Meta class.
2024-06-03 08:47:53 -04:00
Jeremy Stretch
24d02cb381 Fixes #15194: Prevent enqueuing duplicate events for an object 2024-06-03 08:34:26 -04:00
Jeremy Stretch
602754439a Update workflows to use most recent release of each action 2024-06-03 08:01:50 -04:00
github-actions
e18e6cf756 Update source translation strings 2024-06-01 05:02:24 +00:00
Jeremy Stretch
0dde0b506e Fixes #16312: Fix object list navigation for dashboard widgets 2024-05-31 13:16:41 -04:00
Jeremy Stretch
26a856f57c Changelog for #13422, #14810, #15489, #16202, #16286, #16290 2024-05-31 10:29:53 -04:00
Jeremy Stretch
e095ec6860 Fixes #13422: Rebuild MPTT trees for applicable models when merging staged changes 2024-05-31 10:07:07 -04:00
Jeremy Stretch
05c69f84e6 Enable scheduled runs 2024-05-30 10:43:54 -04:00
github-actions
05d3224c33 Update source translation strings 2024-05-30 14:23:18 +00:00
Jeremy Stretch
4ad74587e5 Fix action permissions 2024-05-30 10:21:02 -04:00
Jeremy Stretch
153341c1b7 Install gettext 2024-05-30 10:14:43 -04:00
Jeremy Stretch
f5aa34bb37 Add GitHub action to run makemessages 2024-05-30 09:56:56 -04:00
Jeremy Stretch
a3c4984623 Skip CI if changes are limited to non-code paths 2024-05-30 08:37:24 -04:00
Jeremy Stretch
67165a9f91 Remove abhi1693 from issue triage rotation 2024-05-29 11:37:25 -04:00
Arthur Hanson
4d924a9041 16202 fix mapit button for internationalized decimal seperator (#16270)
* 16202 fix mapit button for internationalized decimal seperator

* 16202 revert untranslate

* 16202 revert untranslate
2024-05-29 10:22:59 -04:00
Jeremy Stretch
a094719d23 Closes #16290: Capture entire object in changelog data 2024-05-29 09:34:22 -04:00
Jeremy Stretch
418389c577 Update translations workflow documentation 2024-05-29 09:14:02 -04:00
Markku Leiniö
f1bf4c8758 Closes #16297: Add uwsgi.ini in .gitignore 2024-05-28 12:12:33 -04:00
Arthur
0bfb9777be 14810 add contacts to service 2024-05-28 09:44:41 -04:00
Arthur
360f3bc01b 16284 fix plugin forms doc 2024-05-28 09:07:32 -04:00
Arthur
8a91252d51 16286 fix provider account search 2024-05-28 09:06:34 -04:00
Julio-Oliveira-Encora
eb3adc050d Added 1000-Base-TX to the choices.py 2024-05-28 09:01:15 -04:00
Jeremy Stretch
103c08c2d2 Update exempt issue labels for stale action 2024-05-22 15:39:24 -04:00
Jeremy Stretch
806ff646e2 PRVB 2024-05-22 14:57:39 -04:00
Jeremy Stretch
3f345cdbee Merge pull request #16247 from netbox-community/develop
Release v4.0.3
2024-05-22 14:56:03 -04:00
Jeremy Stretch
99b8f589cf Release v4.0.3 2024-05-22 14:10:00 -04:00
transifex-integration[bot]
ec510d865f Updates for file netbox/translations/en/LC_MESSAGES/django.po (#16243)
* Translate django.po in es

100% translated source file: 'django.po'
on 'es'.

* Translate django.po in pt

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

* Translate django.po in ja

100% translated source file: 'django.po'
on 'ja'.

* Translate django.po in de

100% translated source file: 'django.po'
on 'de'.

* Translate django.po in uk

100% translated source file: 'django.po'
on 'uk'.

* Translate django.po in ru

100% translated source file: 'django.po'
on 'ru'.

* Translate django.po in fr

100% translated source file: 'django.po'
on 'fr'.

* Translate django.po in tr

100% translated source file: 'django.po'
on 'tr'.

* Translate django.po in zh

100% translated source file: 'django.po'
on 'zh'.

---------

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2024-05-22 13:54:49 -04:00
Jeremy Stretch
cd3dea7ca9 Update origin strings for translation 2024-05-22 13:42:22 -04:00
Arthur Hanson
753c4021eb 14948 add has_virtual_device_contexts filter to device (#16209)
* 14948 add has_virtual_device_cnotexts filter to device

* 14948 make singular

* 14948 add test
2024-05-22 11:51:15 -04:00
Arthur Hanson
8e4466812d 16145 Use module.ScriptName to call Script API instead of PK (#16170)
* 16145 script api use module.script name instead of pk

* 16145 fix test

* 16145 allow both pk and script name

* 16145 update doc string

* Simplify retrieval logic

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-05-22 10:42:36 -04:00
Jeremy Stretch
83d3de276b Fixes #16232: Fix inclusion of bulk action checkboxes on dynamic tables 2024-05-22 10:35:19 -04:00
Jeremy Stretch
97f8f94ebb Changelog for #13764, #14653, #15082, #15603, #15962, #16164, #16173, #16228 2024-05-21 16:53:17 -04:00
Rémi NICOLE
60f5dd7b51 Support Redis Unix sockets (#16227)
* Fixes #15962: support Redis Unix sockets

* Clean up language & remove obsolete note

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-05-21 16:51:28 -04:00
Arthur Hanson
5b83d7040f 14653 Add Inventory Item column to all Device components tables (#16210)
* 14653 Add Inventory Item column to all Device components tables

* 14653 add inventory_items to base class
2024-05-21 16:40:35 -04:00
Jeremy Stretch
a3b34c7a78 Fixes #16228: Fix permissions enforcement for GraphQL queries of users & groups 2024-05-21 16:38:37 -04:00
Jeremy Stretch
902c61bf47 Rename environment variable controlling public docs build 2024-05-21 15:22:40 -04:00
Jeremy Stretch
09c1228712 Fixes #16216: Fix validation of JournalEntry when referenced by a custom field 2024-05-21 10:59:10 -04:00
Jeremy Stretch
02755d43d5 Define separate stale & close timers for PRs 2024-05-21 10:52:23 -04:00
Jeremy Stretch
44771d1221 Fixes #16139: Ensure input buttons use standard font family 2024-05-21 10:25:34 -04:00
Arthur Hanson
88461f9d7a 14250 add BPON to interface types (#16208)
* 14250 add BPON to interface types

* 14250 remove huwai specific from PON

* Reorder choices & fix typo

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-05-21 10:08:54 -04:00
Julio Oliveira at Encora
ade6d2e11b 16117 - Allow filtering by VLAN in Prefixes (#16204)
* Updated clean method on DynamicModelMultipleChoiceField to return the name.

* Updated VLAN section name
2024-05-21 10:07:58 -04:00
Julio Oliveira at Encora
b0520b9e60 Fixes #15603 - Added 5G to Cellular choices in dcim/choices.py. (#15677)
* Added 5G to Cellular choices in dcim/choices.py.

* Added 4G for Cellular choices.
2024-05-21 10:02:09 -04:00
Julio-Oliveira-Encora
85ca750ad7 Changed "clean_extra_choices" in "CustomFieldChoiceSetForm" to strip the space for value and label. 2024-05-21 09:59:24 -04:00
Arthur
17799df72e 13764 Add contacts to IP views 2024-05-21 09:06:49 -04:00
Jeremy Stretch
233b9029e1 Remove start date restriction from stale check for incomplete issues 2024-05-20 11:36:35 -04:00
devon-mar
5e92dac4ac Fix pagination when pagination.per_page is "" 2024-05-20 10:29:24 -04:00
Julio-Oliveira-Encora
6c51b89502 Updated clean method on DynamicModelMultipleChoiceField to return the name. 2024-05-20 08:37:31 -04:00
Jeremy Stretch
558a9beda2 Changelog for #12984, #13293, #14953, #14982, #15353, #15496, #16138 2024-05-17 16:23:02 -04:00
arcticash
9751ce6cb3 Moving the Molex connectors into their own category for better UX - expansion on #12984 2024-05-17 16:18:32 -04:00
arcticash
270a1da601 Adding Molex Micro-Fit connectors to power outlet choices to fix #12984 2024-05-17 16:18:32 -04:00
arcticash
46d12fbe2e Adding Molex Micro-Fit connectors to power plug choices to fix #12984 2024-05-17 16:18:32 -04:00
Sami Tahri
79b9ef7d0c fix: SerializedPKRelatedField schema now use nested serializer or response 2024-05-17 16:16:21 -04:00
Arthur Hanson
97a37576fc 14953 fix serializers when using add_related_count (#16158)
* 14953 fix serializers when using add_related_count

* 14953 update comments

* Set default=0 for annotated count fields

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-05-17 15:50:39 -04:00
Arthur Hanson
b2d2a23c26 15496 Add circuit termination to menu and associated forms (#15980)
* 15496 base changes

* 15496 detail view template

* 15496 tweaks

* 15496 bulk views

* 15496 filterset

* 15496 optimize qs

* 15496 bulk edit

* 15496 bulk import

* 15496 update tests

* Update netbox/templates/circuits/circuittermination.html

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

* 15496 review changes

* 15496 template include

* 15496 expand filters

* 15496 split import form

* 15496 split import form

* 15496 add test for circuit bulk import with termiantions

* Add test for provider filters

* Rename provider column

* Fix test

* Misc cleanup

* Fix test

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-05-17 15:30:10 -04:00
Arthur Hanson
d060b380c9 16138 fix user/group permissions (#16152)
* 16138 change view perms

* 16138 add migration of group perms

* 16138 update users and groups in perm selection
2024-05-17 15:07:19 -04:00
Arthur Hanson
58da5c1252 15353 add better script error message (#15441)
* 15353 add better script error message

* Simplify _get_script_class() & add docstring

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-05-17 14:54:30 -04:00
Jeremy Stretch
4b2f26a800 Correct label name 2024-05-16 14:08:28 -04:00
Jeremy Stretch
cfe010007f Enable stale bot for incomplete issues 2024-05-16 14:04:37 -04:00
Jeremy Stretch
755513a148 #16127: Ignore local_settings.py 2024-05-15 17:05:39 -04:00
Jeremy Stretch
d78a86afac Drop Repography stats from README (malfunctioning) 2024-05-15 17:02:01 -04:00
Jeremy Stretch
dba36fafa7 Enable translation support for Chinese, German, and Ukrainian 2024-05-15 16:36:30 -04:00
transifex-integration[bot]
b666b10f14 Updates for file netbox/translations/en/LC_MESSAGES/django.po (#16151)
* Translate django.po in ja

100% translated source file: 'django.po'
on 'ja'.

* Translate django.po in uk

100% translated source file: 'django.po'
on 'uk'.

* Translate django.po in de

100% translated source file: 'django.po'
on 'de'.

* Translate django.po in zh

100% translated source file: 'django.po'
on 'zh'.

---------

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2024-05-15 16:28:52 -04:00
Jeremy Stretch
0b7804c01c Fixes #13293: Limit interface selector for IP address to current device/VM 2024-05-14 14:48:47 -04:00
Jeremy Stretch
69545fd82d PRVB 2024-05-14 11:26:19 -04:00
Jeremy Stretch
cca1b0a897 Merge pull request #16132 from netbox-community/develop
Release v4.0.2
2024-05-14 11:20:56 -04:00
Jeremy Stretch
70c0aec53a Release v4.0.2 2024-05-14 11:02:17 -04:00
Jeremy Stretch
beb9b96395 Changelog for #16096, #16107, #16123, #16124, #16127 2024-05-14 10:35:00 -04:00
Jeremy Stretch
e5ab48e3c5 Fixes #16123: Fix custom script execution via REST API 2024-05-14 10:31:55 -04:00
Jeremy Stretch
c95dd0b4d1 Update translations 2024-05-14 09:30:28 -04:00
Jeremy Stretch
34f8bf7caf Update source strings for translations 2024-05-14 09:22:27 -04:00
Anton
1feb3742e2 add ENABLE_TRANSLATION setting to optionally turn translation off (#16096)
* add USE_I18N setting

* change setting name to ENABLE_TRANSLATION

* raise a warning in the UI when translation is disabled

* Misc cleanup

* Rename to TRANSLATION_ENABLED for consistency with other settings

---------

Co-authored-by: Anton Myasnikov <anton.myasnikov@nordigy.ru>
Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-05-14 09:21:00 -04:00
Jeremy Stretch
829bae6b29 Fixes #16124: Fix GraphQL API support for querying virtual machine interfaces 2024-05-14 09:15:57 -04:00
Jeremy Stretch
fcc8eccb6c Closes #16127: Enable loading local settings 2024-05-14 09:14:40 -04:00
Jeremy Stretch
c117218332 Fix permissions for stalebot (see actions/stale #1131) 2024-05-14 08:20:31 -04:00
Jeremy Stretch
b8a8db09ed Closes #16107: Set LOGIN_REQUIRED to True by default (#16122)
* Closes #16107: Set LOGIN_REQUIRED to True by default

* Update tests
2024-05-14 07:53:19 -04:00
Jeremy Stretch
b67eda403a Changelog for #15119, #16077, #16078, #16090, #16101 2024-05-13 19:15:40 -04:00
Arthur Hanson
b291aa4312 16078 make GraphQL NumberFilter optional (#16115)
* 16078 make GraphQL NumberFilter optional

* 16078 add tests for graphql filtering

* 16078 add tests for graphql filtering

* 16078 add tests for graphql filtering
2024-05-13 19:01:30 -04:00
Jeremy Stretch
e6ccea0168 Refactor & expand search view tests 2024-05-13 18:56:44 -04:00
Jeremy Stretch
a20ccfee7e Update queryset resolution methods for compatibility with Django 5.0 2024-05-13 18:56:44 -04:00
Jeremy Stretch
c7850b586b Fixes #16101: Fix initial loading of pagination widget for dynamic object tables 2024-05-13 18:55:13 -04:00
Jeremy Stretch
e0f138dea2 Closes #16070: Set default template for ObjectChildrenView 2024-05-13 15:21:52 -04:00
Arthur
5be14b0ee2 16110 fix typo 2024-05-13 15:20:33 -04:00
Julio-Oliveira-Encora
dffd52d6b0 Added Cluster category and cluster, cluster_group for VLAN Group filters. 2024-05-13 15:16:01 -04:00
Markku Leiniö
4b91e79d1e Closes #16090: Show NetBox version if plugin validation fails (#16094)
* Closes #16090: Show NetBox version if plugin validation fails

* Shorten error message

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-05-13 09:37:40 -04:00
Arthur
111cbe5b7c 16077 fix display of config revision 2024-05-13 09:33:30 -04:00
Jeremy Stretch
4a64a3f6e0 PRVB 2024-05-09 16:03:13 -04:00
200 changed files with 80005 additions and 194683 deletions

View File

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

View File

@@ -12,10 +12,10 @@ jobs:
auto-assign:
runs-on: ubuntu-latest
steps:
- uses: pozil/auto-assign-issue@v1
- uses: pozil/auto-assign-issue@v2
if: "contains(github.event.issue.labels.*.name, 'status: needs triage')"
with:
# Weighted assignments
assignees: arthanson:3, jeffgdotorg:3, jeremystretch:3, abhi1693, DanSheps
assignees: arthanson:3, jeffgdotorg:3, jeremystretch:3, DanSheps
numOfAssignee: 1
abortIfPreviousAssignees: true

View File

@@ -1,7 +1,20 @@
name: CI
on: [push, pull_request]
on:
push:
paths-ignore:
- 'contrib/**'
- 'docs/**'
- 'netbox/translations/**'
pull_request:
paths-ignore:
- 'contrib/**'
- 'docs/**'
- 'netbox/translations/**'
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
@@ -34,12 +47,12 @@ jobs:
uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
@@ -47,7 +60,7 @@ jobs:
run: npm install -g yarn
- name: Setup Node.js with Yarn Caching
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: yarn

View File

@@ -0,0 +1,32 @@
# close-stale-issues (https://github.com/marketplace/actions/close-stale-issues)
name: Close incomplete issues
on:
schedule:
- cron: '15 4 * * *'
workflow_dispatch:
permissions:
actions: write
issues: write
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
with:
close-issue-message: >
This issue is being closed as no further information has been provided. If
you would like to revisit this topic, please first modify your original post
to include all the requested detail, and then ask that the issue be reopened.
days-before-stale: 7
days-before-close: 7
only-issue-labels: 'status: revisions needed'
operations-per-run: 100
remove-stale-when-updated: false
stale-issue-label: 'pending closure'
stale-issue-message: >
This is a reminder that additional information is needed in order to further
triage this issue. If the requested details are not provided, the issue will
soon be closed automatically.

View File

@@ -7,6 +7,7 @@ on:
workflow_dispatch:
permissions:
actions: write
issues: write
pull-requests: write
@@ -16,18 +17,19 @@ jobs:
steps:
- uses: actions/stale@v9
with:
# General parameters
operations-per-run: 100
remove-stale-when-updated: false
# Issue parameters
close-issue-message: >
This issue has been automatically closed due to lack of activity. In an
effort to reduce noise, please do not comment any further. Note that the
core maintainers may elect to reopen this issue at a later date if deemed
necessary.
close-pr-message: >
This PR has been automatically closed due to lack of activity.
days-before-stale: 90
days-before-close: 30
exempt-issue-labels: 'status: accepted,status: blocked,status: needs milestone'
operations-per-run: 100
remove-stale-when-updated: false
days-before-issue-stale: 90
days-before-issue-close: 30
exempt-issue-labels: 'status: accepted,status: backlog,status: blocked'
stale-issue-label: 'pending closure'
stale-issue-message: >
This issue has been automatically marked as stale because it has not had
@@ -37,6 +39,12 @@ jobs:
process by "bumping" the issue; doing so will result in its immediate closure
and you may be barred from participating in any future discussions. Please see
our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md).
# Pull request parameters
close-pr-message: >
This PR has been automatically closed due to lack of activity.
days-before-pr-stale: 15
days-before-pr-close: 15
stale-pr-label: 'pending closure'
stale-pr-message: >
This PR has been automatically marked as stale because it has not had

View File

@@ -0,0 +1,45 @@
name: Update translation strings
on:
schedule:
- cron: '0 5 * * *'
workflow_dispatch:
permissions:
contents: write
env:
LOCALE: "en"
jobs:
makemessages:
runs-on: ubuntu-latest
env:
NETBOX_CONFIGURATION: netbox.configuration_testing
steps:
- name: Check out repo
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: 3.11
- name: Install system dependencies
run: sudo apt install -y gettext
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run makemessages
run: python netbox/manage.py makemessages -l ${{ env.LOCALE }}
- name: Commit changes
uses: EndBug/add-and-commit@v9
with:
add: 'netbox/translations/'
default_author: github_actions
message: 'Update source translation strings'

3
.gitignore vendored
View File

@@ -17,12 +17,15 @@ yarn-error.log*
/venv/
/*.sh
local_requirements.txt
local_settings.py
!upgrade.sh
fabfile.py
gunicorn.py
uwsgi.ini
netbox.log
netbox.pid
.DS_Store
.idea
.coverage
.vscode
.python-version

View File

@@ -5,7 +5,7 @@
<a href="https://github.com/netbox-community/netbox/blob/master/LICENSE.txt"><img src="https://img.shields.io/badge/license-Apache_2.0-blue.svg" alt="License" /></a>
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://img.shields.io/github/contributors/netbox-community/netbox?color=blue" alt="Contributors" /></a>
<a href="https://github.com/netbox-community/netbox/stargazers"><img src="https://img.shields.io/github/stars/netbox-community/netbox?style=flat" alt="GitHub stars" /></a>
<a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-7-blue" alt="Languages supported" /></a>
<a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-10-blue" alt="Languages supported" /></a>
<a href="https://github.com/netbox-community/netbox/actions/workflows/ci.yml"><img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master" alt="CI status" /></a>
<p></p>
</div>
@@ -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>
@@ -95,16 +94,6 @@ NetBox automatically logs the creation, modification, and deletion of all manage
* Contributions from the community are encouraged and appreciated! Check out our [contributing guide](CONTRIBUTING.md) to get started.
* [Share your idea](https://plugin-ideas.netbox.dev/) for a new plugin, or [learn how to build one](https://github.com/netbox-community/netbox-plugin-tutorial) yourself!
## Project Stats
<p align="center">
<a href="https://github.com/netbox-community/netbox/commits"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_timeline.svg" alt="Timeline graph"></a>
<a href="https://github.com/netbox-community/netbox/issues"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_issues.svg" alt="Issues graph"></a>
<a href="https://github.com/netbox-community/netbox/pulls"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_prs.svg" alt="Pull requests graph"></a>
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_users.svg" alt="Top contributors"></a>
<br />Stats via <a href="https://repography.com">Repography</a>
</p>
## Screenshots
<p align="center">

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
@@ -131,7 +133,7 @@ social-auth-app-django
strawberry-graphql
# Strawberry GraphQL Django extension
# https://github.com/strawberry-graphql/strawberry-django/blob/main/CHANGELOG.md
# https://github.com/strawberry-graphql/strawberry-django/releases
strawberry-graphql-django
# SVG image rendering (used for rack elevations)

View File

@@ -179,6 +179,9 @@
"usb-micro-ab",
"usb-3-b",
"usb-3-micro-b",
"molex-micro-fit-1x2",
"molex-micro-fit-2x2",
"molex-micro-fit-2x4",
"dc-terminal",
"saf-d-grid",
"neutrik-powercon-20",
@@ -281,6 +284,9 @@
"usb-a",
"usb-micro-b",
"usb-c",
"molex-micro-fit-1x2",
"molex-micro-fit-2x2",
"molex-micro-fit-2x4",
"dc-terminal",
"hdot-cx",
"saf-d-grid",
@@ -317,6 +323,7 @@
"100base-tx",
"100base-t1",
"1000base-t",
"1000base-tx",
"2.5gbase-t",
"5gbase-t",
"10gbase-t",
@@ -375,6 +382,8 @@
"gsm",
"cdma",
"lte",
"4g",
"5g",
"sonet-oc3",
"sonet-oc12",
"sonet-oc48",
@@ -408,12 +417,15 @@
"e3",
"xdsl",
"docsis",
"bpon",
"epon",
"10g-epon",
"gpon",
"xg-pon",
"xgs-pon",
"ng-pon2",
"epon",
"10g-epon",
"25g-pon",
"50g-pon",
"cisco-stackwise",
"cisco-stackwise-plus",
"cisco-flexstack",

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -2,8 +2,8 @@
{% block site_meta %}
{{ super() }}
{# Disable search indexing unless we're building for ReadTheDocs #}
{% if not config.extra.readthedocs %}
{# Disable search indexing unless we're building for public consumption #}
{% if not config.extra.build_public %}
<meta name="robots" content="noindex">
{% endif %}
{% endblock %}

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

@@ -94,15 +94,25 @@ REDIS = {
}
```
!!! note
If you are upgrading from a NetBox release older than v2.7.0, please note that the Redis connection configuration
settings have changed. Manual modification to bring the `REDIS` section inline with the above specification is
necessary
!!! warning
It is highly recommended to keep the task and cache databases separate. Using the same database number on the
same Redis instance for both may result in queued background tasks being lost during cache flushing events.
### UNIX Socket Support
Redis may alternatively be configured by specifying a complete URL instead of individual components. This approach supports the use of a UNIX socket connection. For example:
```python
REDIS = {
'tasks': {
'URL': 'unix:///run/redis-netbox/redis.sock?db=0'
},
'caching': {
'URL': 'unix:///run/redis-netbox/redis.sock?db=1'
},
}
```
### Using Redis Sentinel
If you are using [Redis Sentinel](https://redis.io/topics/sentinel) for high-availability purposes, there is minimal

View File

@@ -159,9 +159,12 @@ Note that enabling this setting causes NetBox to update a user's session in the
## LOGIN_REQUIRED
Default: False
Default: True
Setting this to True will permit only authenticated users to access any part of NetBox. By default, anonymous users are permitted to access most data in NetBox but not make any changes.
When enabled, only authenticated users are permitted to access any part of NetBox. Disabling this will allow unauthenticated users to access most areas of NetBox (but not make any changes).
!!! info "Changed in NetBox v4.0.2"
Prior to NetBox v4.0.2, this setting was disabled by default.
---

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.
@@ -198,3 +198,11 @@ If `STORAGE_BACKEND` is not defined, this setting will be ignored.
Default: UTC
The time zone NetBox will use when dealing with dates and times. It is recommended to use UTC time unless you have a specific need to use a local time zone. Please see the [list of available time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones).
---
## TRANSLATION_ENABLED
Default: True
Enables language translation for the user interface. (This parameter maps to Django's [USE_I18N](https://docs.djangoproject.com/en/stable/ref/settings/#std-setting-USE_I18N) setting.)

View File

@@ -65,12 +65,6 @@ class AnotherCustomScript(Script):
script_order = (MyCustomScript, AnotherCustomScript)
```
## Module Attributes
### `name`
You can define `name` within a script module (the Python file which contains one or more scripts) to set the module name. If `name` is not defined, the module's file name will be used.
## Script Attributes
Script attributes are defined under a class named `Meta` within the script. These are optional, but encouraged.
@@ -144,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.
@@ -158,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

@@ -86,15 +86,7 @@ This will automatically update the schema file at `contrib/generated_schema.json
### Update & Compile Translations
Log into [Transifex](https://app.transifex.com/netbox-community/netbox/dashboard/) to download the updated string maps. Download the resource (portable object, or `.po`) file for each language and save them to `netbox/translations/$lang/LC_MESSAGES/django.po`, overwriting the current files. (Be sure to click the **Download for use** link.)
![Transifex download](../media/development/transifex_download.png)
Once the resource files for all languages have been updated, compile the machine object (`.mo`) files using the `compilemessages` management command:
```nohighlight
./manage.py compilemessages
```
Updated language translations should be pulled from [Transifex](https://app.transifex.com/netbox-community/netbox/dashboard/) and re-compiled for each new release. Follow the documented process for [updating translated strings](./translations.md#updating-translated-strings) to do this.
### Update Version and Changelog
@@ -134,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

@@ -6,17 +6,38 @@ All language translations in NetBox are generated from the source file found at
Reviewers log into Transifex and navigate to their designated language(s) to translate strings. The initial translation for most strings will be machine-generated via the AWS Translate service. Human reviewers are responsible for reviewing these translations and making corrections where necessary.
Immediately prior to each NetBox release, the translation maps for all completed languages will be downloaded from Transifex, compiled, and checked into the NetBox code base by a maintainer.
## Updating Translation Sources
To update the English `.po` file from which all translations are derived, use the `makemessages` management command:
To update the English `.po` file from which all translations are derived, use the `makemessages` management command (ignoring the `project-static/` directory):
```nohighlight
./manage.py makemessages -l en
./manage.py makemessages -l en -i "project-static/*"
```
Then, commit the change and push to the `develop` branch on GitHub. After some time, any new strings will appear for translation on Transifex automatically.
Then, commit the change and push to the `develop` branch on GitHub. Any new strings will appear for translation on Transifex automatically.
## Updating Translated Strings
Typically, translated strings need to be updated only as part of the NetBox [release process](./release-checklist.md).
To update translated strings, start by initiating a sync from Transifex. From the Transifex dashboard, navigate to Settings > Integrations > GitHub > Manage, and click the **Manual Sync** button at top right.
![Transifex manual sync](../media/development/transifex_sync.png)
Enter a threshold percentage of 1 (to ensure all translations are captured) and select the `develop` branch, then click **Sync**. This will initiate a pull request to GitHub to update any newly modified translation (`.po`) files.
!!! tip
The new PR should appear within a few minutes. If it does not, check that there are in fact new translations to be added.
![Transifex pull request](../media/development/transifex_pull_request.png)
Once the PR has been merged, the updated strings need to be compiled into new `.mo` files so they can be used by the application. Update the `develop` branch locally to pull in the changes from the Transifex PR, then run Django's [`compilemessages`](https://docs.djangoproject.com/en/stable/ref/django-admin/#django-admin-compilemessages) management command:
```nohighlight
./manage.py compilemessages
```
Once any new `.mo` files have been generated, they need to be committed and pushed back up to GitHub. (Again, this is typically done as part of publishing a new NetBox release.)
## Proposing New Languages

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@@ -89,13 +89,13 @@ This form facilitates editing multiple objects in bulk. Unlike a model form, thi
from django import forms
from django.utils.translation import gettext_lazy as _
from dcim.models import Site
from netbox.forms import NetBoxModelImportForm
from netbox.forms import NetBoxModelBulkEditForm
from utilities.forms import CommentField, DynamicModelChoiceField
from utilities.forms.rendering import FieldSet
from .models import MyModel, MyModelStatusChoices
class MyModelEditForm(NetBoxModelImportForm):
class MyModelBulkEditForm(NetBoxModelBulkEditForm):
name = forms.CharField(
required=False
)

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,154 @@
# 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
* [#14810](https://github.com/netbox-community/netbox/issues/14810) - Enable contact assignment for services
* [#15489](https://github.com/netbox-community/netbox/issues/15489) - Add 1000Base-TX interface type
* [#15873](https://github.com/netbox-community/netbox/issues/15873) - Improve readability of allocates resource numbers for clusters
* [#16290](https://github.com/netbox-community/netbox/issues/16290) - Capture entire object in changelog data (but continue to display only non-internal attributes)
* [#16353](https://github.com/netbox-community/netbox/issues/16353) - Enable plugins to extend object change view with custom content
### Bug Fixes
* [#13422](https://github.com/netbox-community/netbox/issues/13422) - Rebuild MPTT trees for applicable models after merging staged changes
* [#14567](https://github.com/netbox-community/netbox/issues/14567) - Apply active quicksearch value when exporting "current view" from object list
* [#15194](https://github.com/netbox-community/netbox/issues/15194) - Avoid enqueuing duplicate event triggers for a modified object
* [#16039](https://github.com/netbox-community/netbox/issues/16039) - Fix row highlighting for front & rear port connections under device view
* [#16050](https://github.com/netbox-community/netbox/issues/16050) - Fix display of names & descriptions defined for custom scripts
* [#16083](https://github.com/netbox-community/netbox/issues/16083) - Disable font ligatures to avoid peculiarities in rendered text
* [#16202](https://github.com/netbox-community/netbox/issues/16202) - Fix site map button URL for certain localizations
* [#16261](https://github.com/netbox-community/netbox/issues/16261) - Fix GraphQL filtering for certain multi-value filters
* [#16286](https://github.com/netbox-community/netbox/issues/16286) - Fix global search support for provider accounts
* [#16312](https://github.com/netbox-community/netbox/issues/16312) - Fix object list navigation for dashboard widgets
* [#16315](https://github.com/netbox-community/netbox/issues/16315) - Fix filtering change log & journal entries by object type in UI
* [#16376](https://github.com/netbox-community/netbox/issues/16376) - Update change log for the terminating object (e.g. interface) when attaching a cable
* [#16400](https://github.com/netbox-community/netbox/issues/16400) - Fix AttributeError when attempting to restore a previous configuration revision after deleting the current one
---
## v4.0.3 (2024-05-22)
### Enhancements
* [#12984](https://github.com/netbox-community/netbox/issues/12984) - Add Molex Micro-Fit power port & outlet types
* [#13764](https://github.com/netbox-community/netbox/issues/13764) - Enable contact assignments for aggregates, prefixes, IP ranges, and IP addresses
* [#14639](https://github.com/netbox-community/netbox/issues/14639) - Add Ukrainian translation support
* [#14653](https://github.com/netbox-community/netbox/issues/14653) - Add an inventory items table column for all device components
* [#14686](https://github.com/netbox-community/netbox/issues/14686) - Add German translation support
* [#14855](https://github.com/netbox-community/netbox/issues/14855) - Add Chinese translation support
* [#14948](https://github.com/netbox-community/netbox/issues/14948) - Introduce the `has_virtual_device_context` filter for devices
* [#15353](https://github.com/netbox-community/netbox/issues/15353) - Improve error reporting when custom scripts fail to load
* [#15496](https://github.com/netbox-community/netbox/issues/15496) - Implement dedicated views for management of circuit terminations
* [#15603](https://github.com/netbox-community/netbox/issues/15603) - Add 4G & 5G cellular interface types
* [#15962](https://github.com/netbox-community/netbox/issues/15962) - Enable UNIX socket connections for Redis
### Bug Fixes
* [#13293](https://github.com/netbox-community/netbox/issues/13293) - Limit interface selector for IP address to current device/VM
* [#14953](https://github.com/netbox-community/netbox/issues/14953) - Ensure annotated count fields are present in REST API response data when creating new objects
* [#14982](https://github.com/netbox-community/netbox/issues/14982) - Fix OpenAPI schema definition for SerializedPKRelatedFields
* [#15082](https://github.com/netbox-community/netbox/issues/15082) - Strip whitespace from choice values & labels when creating a custom field choice set
* [#16138](https://github.com/netbox-community/netbox/issues/16138) - Fix support for referencing users & groups in object permissions
* [#16145](https://github.com/netbox-community/netbox/issues/16145) - Restore ability to reference custom scripts via module & name in REST API
* [#16164](https://github.com/netbox-community/netbox/issues/16164) - Correct display of selected values in UI when filtering object list by a null value
* [#16173](https://github.com/netbox-community/netbox/issues/16173) - Fix TypeError exception when viewing object list with no pagination preference defined
* [#16228](https://github.com/netbox-community/netbox/issues/16228) - Fix permissions enforcement for GraphQL queries of users & groups
* [#16232](https://github.com/netbox-community/netbox/issues/16232) - Preserve bulk action checkboxes on dynamic tables when using pagination
* [#16240](https://github.com/netbox-community/netbox/issues/16240) - Fixed NoReverseMatch exception when adding circuit terminations to an object counts dashboard widget
---
## v4.0.2 (2024-05-14)
!!! warning "Important"
This release includes an important security fix, and is a strongly recommended update for all users. More details will follow.
### Enhancements
* [#15119](https://github.com/netbox-community/netbox/issues/15119) - Add cluster & cluster group UI filter fields for VLAN groups
* [#16090](https://github.com/netbox-community/netbox/issues/16090) - Include current NetBox version when an unsupported plugin is detected
* [#16096](https://github.com/netbox-community/netbox/issues/16096) - Introduce the `ENABLE_TRANSLATION` configuration parameter
* [#16107](https://github.com/netbox-community/netbox/issues/16107) - Change the default value for `LOGIN_REQUIRED` to True
* [#16127](https://github.com/netbox-community/netbox/issues/16127) - Add integration point for unsupported settings
### Bug Fixes
* [#16077](https://github.com/netbox-community/netbox/issues/16077) - Fix display of parameter values when viewing configuration revisions
* [#16078](https://github.com/netbox-community/netbox/issues/16078) - Fix integer filters mistakenly marked as required for GraphQL API
* [#16101](https://github.com/netbox-community/netbox/issues/16101) - Fix initial loading of pagination widget for dynamic object tables
* [#16123](https://github.com/netbox-community/netbox/issues/16123) - Fix custom script execution via REST API
* [#16124](https://github.com/netbox-community/netbox/issues/16124) - Fix GraphQL API support for querying virtual machine interfaces
---
## v4.0.1 (2024-05-09)
### Enhancements

View File

@@ -42,7 +42,7 @@ plugins:
show_root_toc_entry: false
show_source: false
extra:
readthedocs: !ENV READTHEDOCS
build_public: !ENV BUILD_PUBLIC
social:
- icon: fontawesome/brands/github
link: https://github.com/netbox-community/netbox

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

@@ -275,6 +275,17 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
queryset=ProviderNetwork.objects.all(),
label=_('ProviderNetwork (ID)'),
)
provider_id = django_filters.ModelMultipleChoiceFilter(
field_name='circuit__provider_id',
queryset=Provider.objects.all(),
label=_('Provider (ID)'),
)
provider = django_filters.ModelMultipleChoiceFilter(
field_name='circuit__provider__slug',
queryset=Provider.objects.all(),
to_field_name='slug',
label=_('Provider (slug)'),
)
class Meta:
model = CircuitTermination

View File

@@ -3,16 +3,18 @@ from django.utils.translation import gettext_lazy as _
from circuits.choices import CircuitCommitRateChoices, CircuitStatusChoices
from circuits.models import *
from dcim.models import Site
from ipam.models import ASN
from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant
from utilities.forms import add_blank_choice
from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import DatePicker, NumberWithOptions
from utilities.forms.rendering import FieldSet, TabbedGroups
from utilities.forms.widgets import BulkEditNullBooleanSelect, DatePicker, NumberWithOptions
__all__ = (
'CircuitBulkEditForm',
'CircuitTerminationBulkEditForm',
'CircuitTypeBulkEditForm',
'ProviderBulkEditForm',
'ProviderAccountBulkEditForm',
@@ -172,3 +174,48 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
nullable_fields = (
'tenant', 'commit_rate', 'description', 'comments',
)
class CircuitTerminationBulkEditForm(NetBoxModelBulkEditForm):
description = forms.CharField(
label=_('Description'),
max_length=200,
required=False
)
site = DynamicModelChoiceField(
label=_('Site'),
queryset=Site.objects.all(),
required=False
)
provider_network = DynamicModelChoiceField(
label=_('Provider Network'),
queryset=ProviderNetwork.objects.all(),
required=False
)
port_speed = forms.IntegerField(
required=False,
label=_('Port speed (Kbps)'),
)
upstream_speed = forms.IntegerField(
required=False,
label=_('Upstream speed (Kbps)'),
)
mark_connected = forms.NullBooleanField(
label=_('Mark connected'),
required=False,
widget=BulkEditNullBooleanSelect
)
model = CircuitTermination
fieldsets = (
FieldSet(
'description',
TabbedGroups(
FieldSet('site', name=_('Site')),
FieldSet('provider_network', name=_('Provider Network')),
),
'mark_connected', name=_('Circuit Termination')
),
FieldSet('port_speed', 'upstream_speed', name=_('Termination Details')),
)
nullable_fields = ('description')

View File

@@ -1,10 +1,10 @@
from django import forms
from circuits.choices import CircuitStatusChoices
from circuits.models import *
from dcim.models import Site
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from circuits.choices import *
from circuits.models import *
from dcim.models import Site
from netbox.forms import NetBoxModelImportForm
from tenancy.models import Tenant
from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugField
@@ -12,6 +12,7 @@ from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugFiel
__all__ = (
'CircuitImportForm',
'CircuitTerminationImportForm',
'CircuitTerminationImportRelatedForm',
'CircuitTypeImportForm',
'ProviderImportForm',
'ProviderAccountImportForm',
@@ -65,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):
@@ -111,7 +109,16 @@ class CircuitImportForm(NetBoxModelImportForm):
]
class CircuitTerminationImportForm(forms.ModelForm):
class BaseCircuitTerminationImportForm(forms.ModelForm):
circuit = CSVModelChoiceField(
label=_('Circuit'),
queryset=Circuit.objects.all(),
to_field_name='cid',
)
term_side = CSVChoiceField(
label=_('Termination'),
choices=CircuitTerminationSideChoices,
)
site = CSVModelChoiceField(
label=_('Site'),
queryset=Site.objects.all(),
@@ -125,9 +132,21 @@ class CircuitTerminationImportForm(forms.ModelForm):
required=False
)
class CircuitTerminationImportRelatedForm(BaseCircuitTerminationImportForm):
class Meta:
model = CircuitTermination
fields = [
'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id',
'pp_info', 'description',
'pp_info', 'description'
]
class CircuitTerminationImportForm(NetBoxModelImportForm, BaseCircuitTerminationImportForm):
class Meta:
model = CircuitTermination
fields = [
'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id',
'pp_info', 'description', 'tags'
]

View File

@@ -1,7 +1,7 @@
from django import forms
from django.utils.translation import gettext as _
from circuits.choices import CircuitCommitRateChoices, CircuitStatusChoices
from circuits.choices import CircuitCommitRateChoices, CircuitStatusChoices, CircuitTerminationSideChoices
from circuits.models import *
from dcim.models import Region, Site, SiteGroup
from ipam.models import ASN
@@ -13,6 +13,7 @@ from utilities.forms.widgets import DatePicker, NumberWithOptions
__all__ = (
'CircuitFilterForm',
'CircuitTerminationFilterForm',
'CircuitTypeFilterForm',
'ProviderFilterForm',
'ProviderAccountFilterForm',
@@ -186,3 +187,46 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
)
)
tag = TagFilterField(model)
class CircuitTerminationFilterForm(NetBoxModelFilterSetForm):
model = CircuitTermination
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('circuit_id', 'term_side', name=_('Circuit')),
FieldSet('provider_id', 'provider_network_id', name=_('Provider')),
FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
)
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
required=False,
query_params={
'region_id': '$region_id',
'site_group_id': '$site_group_id',
},
label=_('Site')
)
circuit_id = DynamicModelMultipleChoiceField(
queryset=Circuit.objects.all(),
required=False,
label=_('Circuit')
)
term_side = forms.MultipleChoiceField(
label=_('Term Side'),
choices=CircuitTerminationSideChoices,
required=False
)
provider_network_id = DynamicModelMultipleChoiceField(
queryset=ProviderNetwork.objects.all(),
required=False,
query_params={
'provider_id': '$provider_id'
},
label=_('Provider network')
)
provider_id = DynamicModelMultipleChoiceField(
queryset=Provider.objects.all(),
required=False,
label=_('Provider')
)
tag = TagFilterField(model)

View File

@@ -227,7 +227,7 @@ class CircuitTermination(
return f'{self.circuit}: Termination {self.term_side}'
def get_absolute_url(self):
return self.circuit.get_absolute_url()
return reverse('circuits:circuittermination', args=[self.pk])
def clean(self):
super().clean()

View File

@@ -48,6 +48,7 @@ class ProviderIndex(SearchIndex):
display_attrs = ('description',)
@register_search
class ProviderAccountIndex(SearchIndex):
model = models.ProviderAccount
fields = (

View File

@@ -10,6 +10,7 @@ from .columns import CommitRateColumn
__all__ = (
'CircuitTable',
'CircuitTerminationTable',
'CircuitTypeTable',
)
@@ -62,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(
@@ -88,3 +91,31 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
default_columns = (
'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description',
)
class CircuitTerminationTable(NetBoxTable):
circuit = tables.Column(
verbose_name=_('Circuit'),
linkify=True
)
provider = tables.Column(
verbose_name=_('Provider'),
linkify=True,
accessor='circuit.provider'
)
site = tables.Column(
verbose_name=_('Site'),
linkify=True
)
provider_network = tables.Column(
verbose_name=_('Provider Network'),
linkify=True
)
class Meta(NetBoxTable.Meta):
model = CircuitTermination
fields = (
'pk', 'id', 'circuit', 'provider', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed',
'xconnect_id', 'pp_info', 'description', 'created', 'last_updated', 'actions',
)
default_columns = ('pk', 'id', 'circuit', 'provider', 'term_side', 'description')

View File

@@ -351,24 +351,26 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
providers = (
Provider(name='Provider 1', slug='provider-1'),
Provider(name='Provider 2', slug='provider-2'),
Provider(name='Provider 3', slug='provider-3'),
)
Provider.objects.bulk_create(providers)
provider_networks = (
ProviderNetwork(name='Provider Network 1', provider=providers[0]),
ProviderNetwork(name='Provider Network 2', provider=providers[0]),
ProviderNetwork(name='Provider Network 3', provider=providers[0]),
ProviderNetwork(name='Provider Network 2', provider=providers[1]),
ProviderNetwork(name='Provider Network 3', provider=providers[2]),
)
ProviderNetwork.objects.bulk_create(provider_networks)
circuits = (
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 1'),
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 2'),
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 3'),
Circuit(provider=providers[1], type=circuit_types[0], cid='Circuit 2'),
Circuit(provider=providers[2], type=circuit_types[0], cid='Circuit 3'),
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 4'),
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 5'),
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 6'),
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 7'),
Circuit(provider=providers[1], type=circuit_types[0], cid='Circuit 5'),
Circuit(provider=providers[2], type=circuit_types[0], cid='Circuit 6'),
Circuit(provider=providers[2], type=circuit_types[0], cid='Circuit 7'),
)
Circuit.objects.bulk_create(circuits)
@@ -413,10 +415,17 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_circuit_id(self):
circuits = Circuit.objects.all()[:2]
circuits = Circuit.objects.filter(cid__in=['Circuit 1', 'Circuit 2'])
params = {'circuit_id': [circuits[0].pk, circuits[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_provider(self):
providers = Provider.objects.all()[:2]
params = {'provider_id': [providers[0].pk, providers[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
params = {'provider': [providers[0].slug, providers[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}

View File

@@ -5,8 +5,11 @@ from django.urls import reverse
from circuits.choices import *
from circuits.models import *
from core.models import ObjectType
from dcim.models import Cable, Interface, Site
from ipam.models import ASN, RIR
from netbox.choices import ImportFormatChoices
from users.models import ObjectPermission
from utilities.testing import ViewTestCases, create_tags, create_test_device
@@ -115,6 +118,7 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@classmethod
def setUpTestData(cls):
Site.objects.create(name='Site 1', slug='site-1')
providers = (
Provider(name='Provider 1', slug='provider-1'),
@@ -184,6 +188,51 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'comments': 'New comments',
}
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
def test_bulk_import_objects_with_terminations(self):
json_data = """
[
{
"cid": "Circuit 7",
"provider": "Provider 1",
"type": "Circuit Type 1",
"status": "active",
"description": "Testing Import",
"terminations": [
{
"term_side": "A",
"site": "Site 1"
},
{
"term_side": "Z",
"site": "Site 1"
}
]
}
]
"""
initial_count = self._get_queryset().count()
data = {
'data': json_data,
'format': ImportFormatChoices.JSON,
}
# Assign model-level permission
obj_perm = ObjectPermission(
name='Test permission',
actions=['add']
)
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
# Try GET with model-level permission
self.assertHttpStatus(self.client.get(self._get_url('import')), 200)
# Test POST with permission
self.assertHttpStatus(self.client.post(self._get_url('import'), data), 302)
self.assertEqual(self._get_queryset().count(), initial_count + 1)
class ProviderAccountTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = ProviderAccount
@@ -287,10 +336,7 @@ class ProviderNetworkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
class CircuitTerminationTestCase(
ViewTestCases.EditObjectViewTestCase,
ViewTestCases.DeleteObjectViewTestCase,
):
class CircuitTerminationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = CircuitTermination
@classmethod
@@ -327,6 +373,24 @@ class CircuitTerminationTestCase(
'description': 'New description',
}
cls.csv_data = (
"circuit,term_side,site,description",
"Circuit 3,A,Site 1,Foo",
"Circuit 3,Z,Site 1,Bar",
)
cls.csv_update_data = (
"id,port_speed,description",
f"{circuit_terminations[0].pk},100,New description7",
f"{circuit_terminations[1].pk},200,New description8",
f"{circuit_terminations[2].pk},300,New description9",
)
cls.bulk_edit_data = {
'port_speed': 400,
'description': 'New description',
}
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_trace(self):
device = create_test_device('Device 1')

View File

@@ -48,7 +48,11 @@ urlpatterns = [
path('circuits/<int:pk>/', include(get_model_urls('circuits', 'circuit'))),
# Circuit terminations
path('circuit-terminations/', views.CircuitTerminationListView.as_view(), name='circuittermination_list'),
path('circuit-terminations/add/', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'),
path('circuit-terminations/import/', views.CircuitTerminationBulkImportView.as_view(), name='circuittermination_import'),
path('circuit-terminations/edit/', views.CircuitTerminationBulkEditView.as_view(), name='circuittermination_bulk_edit'),
path('circuit-terminations/delete/', views.CircuitTerminationBulkDeleteView.as_view(), name='circuittermination_bulk_delete'),
path('circuit-terminations/<int:pk>/', include(get_model_urls('circuits', 'circuittermination'))),
]

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),
}
@@ -298,7 +287,7 @@ class CircuitBulkImportView(generic.BulkImportView):
'circuits.add_circuittermination',
]
related_object_forms = {
'terminations': forms.CircuitTerminationImportForm,
'terminations': forms.CircuitTerminationImportRelatedForm,
}
def prep_related_object_data(self, parent, data):
@@ -408,6 +397,18 @@ class CircuitContactsView(ObjectContactsView):
# Circuit terminations
#
class CircuitTerminationListView(generic.ObjectListView):
queryset = CircuitTermination.objects.all()
filterset = filtersets.CircuitTerminationFilterSet
filterset_form = forms.CircuitTerminationFilterForm
table = tables.CircuitTerminationTable
@register_model_view(CircuitTermination)
class CircuitTerminationView(generic.ObjectView):
queryset = CircuitTermination.objects.all()
@register_model_view(CircuitTermination, 'edit')
class CircuitTerminationEditView(generic.ObjectEditView):
queryset = CircuitTermination.objects.all()
@@ -419,5 +420,23 @@ class CircuitTerminationDeleteView(generic.ObjectDeleteView):
queryset = CircuitTermination.objects.all()
class CircuitTerminationBulkImportView(generic.BulkImportView):
queryset = CircuitTermination.objects.all()
model_form = forms.CircuitTerminationImportForm
class CircuitTerminationBulkEditView(generic.BulkEditView):
queryset = CircuitTermination.objects.all()
filterset = filtersets.CircuitTerminationFilterSet
table = tables.CircuitTerminationTable
form = forms.CircuitTerminationBulkEditForm
class CircuitTerminationBulkDeleteView(generic.BulkDeleteView):
queryset = CircuitTermination.objects.all()
filterset = filtersets.CircuitTerminationFilterSet
table = tables.CircuitTerminationTable
# Trace view
register_model_view(CircuitTermination, 'trace', kwargs={'model': CircuitTermination})(PathTraceView)

View File

@@ -255,3 +255,14 @@ class NetBoxAutoSchema(AutoSchema):
if '{id}' in self.path:
return f"{self.method.capitalize()} a {model_name} object."
return f"{self.method.capitalize()} a list of {model_name} objects."
class FixSerializedPKRelatedField(OpenApiSerializerFieldExtension):
target_class = 'netbox.api.fields.SerializedPKRelatedField'
def map_serializer_field(self, auto_schema, direction):
if direction == "response":
component = auto_schema.resolve_serializer(self.target.serializer, direction)
return component.ref if component else None
else:
return build_basic_type(OpenApiTypes.INT)

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),
}
@@ -224,7 +220,7 @@ class ConfigRevisionRestoreView(ContentTypePermissionRequiredMixin, View):
for param in PARAMS:
params.append((
param.name,
current_config.data.get(param.name, None),
current_config.data.get(param.name, None) if current_config else None,
candidate_config.data.get(param.name, None)
))
@@ -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

@@ -21,7 +21,7 @@ __all__ = (
class RegionSerializer(NestedGroupModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail')
parent = NestedRegionSerializer(required=False, allow_null=True, default=None)
site_count = serializers.IntegerField(read_only=True)
site_count = serializers.IntegerField(read_only=True, default=0)
class Meta:
model = Region
@@ -35,7 +35,7 @@ class RegionSerializer(NestedGroupModelSerializer):
class SiteGroupSerializer(NestedGroupModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:sitegroup-detail')
parent = NestedSiteGroupSerializer(required=False, allow_null=True, default=None)
site_count = serializers.IntegerField(read_only=True)
site_count = serializers.IntegerField(read_only=True, default=0)
class Meta:
model = SiteGroup
@@ -86,8 +86,8 @@ class LocationSerializer(NestedGroupModelSerializer):
parent = NestedLocationSerializer(required=False, allow_null=True, default=None)
status = ChoiceField(choices=LocationStatusChoices, required=False)
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
rack_count = serializers.IntegerField(read_only=True)
device_count = serializers.IntegerField(read_only=True)
rack_count = serializers.IntegerField(read_only=True, default=0)
device_count = serializers.IntegerField(read_only=True, default=0)
class Meta:
model = Location

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

@@ -399,6 +399,10 @@ class PowerPortTypeChoices(ChoiceSet):
TYPE_USB_MICRO_AB = 'usb-micro-ab'
TYPE_USB_3_B = 'usb-3-b'
TYPE_USB_3_MICROB = 'usb-3-micro-b'
# Molex
TYPE_MOLEX_MICRO_FIT_1X2 = 'molex-micro-fit-1x2'
TYPE_MOLEX_MICRO_FIT_2X2 = 'molex-micro-fit-2x2'
TYPE_MOLEX_MICRO_FIT_2X4 = 'molex-micro-fit-2x4'
# Direct current (DC)
TYPE_DC = 'dc-terminal'
# Proprietary
@@ -520,6 +524,11 @@ class PowerPortTypeChoices(ChoiceSet):
(TYPE_USB_3_B, 'USB 3.0 Type B'),
(TYPE_USB_3_MICROB, 'USB 3.0 Micro B'),
)),
('Molex', (
(TYPE_MOLEX_MICRO_FIT_1X2, 'Molex Micro-Fit 1x2'),
(TYPE_MOLEX_MICRO_FIT_2X2, 'Molex Micro-Fit 2x2'),
(TYPE_MOLEX_MICRO_FIT_2X4, 'Molex Micro-Fit 2x4'),
)),
('DC', (
(TYPE_DC, 'DC Terminal'),
)),
@@ -635,6 +644,10 @@ class PowerOutletTypeChoices(ChoiceSet):
TYPE_USB_A = 'usb-a'
TYPE_USB_MICROB = 'usb-micro-b'
TYPE_USB_C = 'usb-c'
# Molex
TYPE_MOLEX_MICRO_FIT_1X2 = 'molex-micro-fit-1x2'
TYPE_MOLEX_MICRO_FIT_2X2 = 'molex-micro-fit-2x2'
TYPE_MOLEX_MICRO_FIT_2X4 = 'molex-micro-fit-2x4'
# Direct current (DC)
TYPE_DC = 'dc-terminal'
# Proprietary
@@ -749,6 +762,11 @@ class PowerOutletTypeChoices(ChoiceSet):
(TYPE_USB_MICROB, 'USB Micro B'),
(TYPE_USB_C, 'USB Type C'),
)),
('Molex', (
(TYPE_MOLEX_MICRO_FIT_1X2, 'Molex Micro-Fit 1x2'),
(TYPE_MOLEX_MICRO_FIT_2X2, 'Molex Micro-Fit 2x2'),
(TYPE_MOLEX_MICRO_FIT_2X4, 'Molex Micro-Fit 2x4'),
)),
('DC', (
(TYPE_DC, 'DC Terminal'),
)),
@@ -810,6 +828,7 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_100ME_FIXED = '100base-tx'
TYPE_100ME_T1 = '100base-t1'
TYPE_1GE_FIXED = '1000base-t'
TYPE_1GE_TX_FIXED = '1000base-tx'
TYPE_1GE_GBIC = '1000base-x-gbic'
TYPE_1GE_SFP = '1000base-x-sfp'
TYPE_2GE_FIXED = '2.5gbase-t'
@@ -874,6 +893,8 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_GSM = 'gsm'
TYPE_CDMA = 'cdma'
TYPE_LTE = 'lte'
TYPE_4G = '4g'
TYPE_5G = '5g'
# SONET
TYPE_SONET_OC3 = 'sonet-oc3'
@@ -921,12 +942,15 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_DOCSIS = 'docsis'
# PON
TYPE_BPON = 'bpon'
TYPE_EPON = 'epon'
TYPE_10G_EPON = '10g-epon'
TYPE_GPON = 'gpon'
TYPE_XG_PON = 'xg-pon'
TYPE_XGS_PON = 'xgs-pon'
TYPE_NG_PON2 = 'ng-pon2'
TYPE_EPON = 'epon'
TYPE_10G_EPON = '10g-epon'
TYPE_25G_PON = '25g-pon'
TYPE_50G_PON = '50g-pon'
# Stacking
TYPE_STACKWISE = 'cisco-stackwise'
@@ -964,6 +988,7 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_100ME_FIXED, '100BASE-TX (10/100ME)'),
(TYPE_100ME_T1, '100BASE-T1 (10/100ME Single Pair)'),
(TYPE_1GE_FIXED, '1000BASE-T (1GE)'),
(TYPE_1GE_TX_FIXED, '1000BASE-TX (1GE)'),
(TYPE_2GE_FIXED, '2.5GBASE-T (2.5GE)'),
(TYPE_5GE_FIXED, '5GBASE-T (5GE)'),
(TYPE_10GE_FIXED, '10GBASE-T (10GE)'),
@@ -1042,6 +1067,8 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_GSM, 'GSM'),
(TYPE_CDMA, 'CDMA'),
(TYPE_LTE, 'LTE'),
(TYPE_4G, '4G'),
(TYPE_5G, '5G'),
)
),
(
@@ -1110,12 +1137,15 @@ class InterfaceTypeChoices(ChoiceSet):
(
'PON',
(
(TYPE_GPON, 'GPON (2.5 Gbps / 1.25 Gps)'),
(TYPE_BPON, 'BPON (622 Mbps / 155 Mbps)'),
(TYPE_EPON, 'EPON (1 Gbps)'),
(TYPE_10G_EPON, '10G-EPON (10 Gbps)'),
(TYPE_GPON, 'GPON (2.5 Gbps / 1.25 Gbps)'),
(TYPE_XG_PON, 'XG-PON (10 Gbps / 2.5 Gbps)'),
(TYPE_XGS_PON, 'XGS-PON (10 Gbps)'),
(TYPE_NG_PON2, 'NG-PON2 (TWDM-PON) (4x10 Gbps)'),
(TYPE_EPON, 'EPON (1 Gbps)'),
(TYPE_10G_EPON, '10G-EPON (10 Gbps)'),
(TYPE_25G_PON, '25G-PON (25 Gbps)'),
(TYPE_50G_PON, '50G-PON (50 Gbps)'),
)
),
(

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(),
@@ -1100,6 +1111,10 @@ class DeviceFilterSet(
queryset=IPAddress.objects.all(),
label=_('OOB IP (ID)'),
)
has_virtual_device_context = django_filters.BooleanFilter(
method='_has_virtual_device_context',
label=_('Has virtual device context'),
)
class Meta:
model = Device
@@ -1176,6 +1191,12 @@ class DeviceFilterSet(
def _device_bays(self, queryset, name, value):
return queryset.exclude(devicebays__isnull=value)
def _has_virtual_device_context(self, queryset, name, value):
params = Q(vdcs__isnull=False)
if value:
return queryset.filter(params).distinct()
return queryset.exclude(params)
class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet, PrimaryIPFilterSet):
device_id = django_filters.ModelMultipleChoiceFilter(

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,8 +656,10 @@ 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',
name=_('Miscellaneous')
)
)
@@ -813,6 +816,23 @@ class DeviceFilterForm(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
has_virtual_device_context = forms.NullBooleanField(
required=False,
label=_('Has virtual device contexts'),
widget=forms.Select(
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

@@ -355,11 +355,11 @@ class CableTermination(ChangeLoggedModel):
super().save(*args, **kwargs)
# Set the cable on the terminating object
termination_model = self.termination._meta.model
termination_model.objects.filter(pk=self.termination_id).update(
cable=self.cable,
cable_end=self.cable_end
)
termination = self.termination._meta.model.objects.get(pk=self.termination_id)
termination.snapshot()
termination.cable = self.cable
termination.cable_end = self.cable_end
termination.save()
def delete(self, *args, **kwargs):

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

@@ -43,14 +43,6 @@ MODULEBAY_STATUS = """
"""
def get_cabletermination_row_class(record):
if record.mark_connected:
return 'success'
elif record.cable:
return record.cable.get_status_color()
return ''
#
# Device roles
#
@@ -313,6 +305,10 @@ class ModularDeviceComponentTable(DeviceComponentTable):
verbose_name=_('Module'),
linkify=True
)
inventory_items = columns.ManyToManyColumn(
linkify_item=True,
verbose_name=_('Inventory Items'),
)
class CableTerminationTable(NetBoxTable):
@@ -335,6 +331,14 @@ class CableTerminationTable(NetBoxTable):
verbose_name=_('Mark Connected'),
)
class Meta:
row_attrs = {
'data-name': lambda record: record.name,
'data-mark-connected': lambda record: "true" if record.mark_connected else "false",
'data-cable-status': lambda record: record.cable.status if record.cable else "",
'data-type': lambda record: record.type
}
def value_link_peer(self, value):
return ', '.join([
f"{termination.parent_object} > {termination}" for termination in value
@@ -366,7 +370,7 @@ class ConsolePortTable(ModularDeviceComponentTable, PathEndpointTable):
model = models.ConsolePort
fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'speed', 'description',
'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', 'last_updated',
'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'inventory_items', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description')
@@ -382,16 +386,13 @@ class DeviceConsolePortTable(ConsolePortTable):
extra_buttons=CONSOLEPORT_BUTTONS
)
class Meta(DeviceComponentTable.Meta):
class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
model = models.ConsolePort
fields = (
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected',
'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions'
)
default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection')
row_attrs = {
'class': get_cabletermination_row_class
}
class ConsoleServerPortTable(ModularDeviceComponentTable, PathEndpointTable):
@@ -410,7 +411,7 @@ class ConsoleServerPortTable(ModularDeviceComponentTable, PathEndpointTable):
model = models.ConsoleServerPort
fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'speed', 'description',
'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', 'last_updated',
'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'inventory_items', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description')
@@ -427,16 +428,13 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable):
extra_buttons=CONSOLESERVERPORT_BUTTONS
)
class Meta(DeviceComponentTable.Meta):
class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
model = models.ConsoleServerPort
fields = (
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected',
'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions',
)
default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection')
row_attrs = {
'class': get_cabletermination_row_class
}
class PowerPortTable(ModularDeviceComponentTable, PathEndpointTable):
@@ -461,8 +459,8 @@ class PowerPortTable(ModularDeviceComponentTable, PathEndpointTable):
model = models.PowerPort
fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'mark_connected',
'maximum_draw', 'allocated_draw', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created',
'last_updated',
'maximum_draw', 'allocated_draw', 'cable', 'cable_color', 'link_peer', 'connection', 'inventory_items',
'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description')
@@ -479,7 +477,7 @@ class DevicePowerPortTable(PowerPortTable):
extra_buttons=POWERPORT_BUTTONS
)
class Meta(DeviceComponentTable.Meta):
class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
model = models.PowerPort
fields = (
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'maximum_draw', 'allocated_draw',
@@ -488,9 +486,6 @@ class DevicePowerPortTable(PowerPortTable):
default_columns = (
'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'connection',
)
row_attrs = {
'class': get_cabletermination_row_class
}
class PowerOutletTable(ModularDeviceComponentTable, PathEndpointTable):
@@ -513,8 +508,8 @@ class PowerOutletTable(ModularDeviceComponentTable, PathEndpointTable):
model = models.PowerOutlet
fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'power_port',
'feed_leg', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created',
'last_updated',
'feed_leg', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'inventory_items',
'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'type', 'power_port', 'feed_leg', 'description')
@@ -530,7 +525,7 @@ class DevicePowerOutletTable(PowerOutletTable):
extra_buttons=POWEROUTLET_BUTTONS
)
class Meta(DeviceComponentTable.Meta):
class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
model = models.PowerOutlet
fields = (
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'power_port', 'feed_leg', 'description',
@@ -539,9 +534,6 @@ class DevicePowerOutletTable(PowerOutletTable):
default_columns = (
'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'connection',
)
row_attrs = {
'class': get_cabletermination_row_class
}
class BaseInterfaceTable(NetBoxTable):
@@ -618,10 +610,6 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
verbose_name=_('VRF'),
linkify=True
)
inventory_items = columns.ManyToManyColumn(
linkify_item=True,
verbose_name=_('Inventory Items'),
)
tags = columns.TagColumn(
url_name='dcim:interface_list'
)
@@ -713,8 +701,8 @@ class FrontPortTable(ModularDeviceComponentTable, CableTerminationTable):
model = models.FrontPort
fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'rear_port',
'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags',
'created', 'last_updated',
'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer',
'inventory_items', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
@@ -733,7 +721,7 @@ class DeviceFrontPortTable(FrontPortTable):
extra_buttons=FRONTPORT_BUTTONS
)
class Meta(DeviceComponentTable.Meta):
class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
model = models.FrontPort
fields = (
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'rear_port', 'rear_port_position',
@@ -742,9 +730,6 @@ class DeviceFrontPortTable(FrontPortTable):
default_columns = (
'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'link_peer',
)
row_attrs = {
'class': get_cabletermination_row_class
}
class RearPortTable(ModularDeviceComponentTable, CableTerminationTable):
@@ -766,7 +751,7 @@ class RearPortTable(ModularDeviceComponentTable, CableTerminationTable):
model = models.RearPort
fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'positions', 'description',
'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', 'created', 'last_updated',
'mark_connected', 'cable', 'cable_color', 'link_peer', 'inventory_items', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'type', 'color', 'description')
@@ -783,7 +768,7 @@ class DeviceRearPortTable(RearPortTable):
extra_buttons=REARPORT_BUTTONS
)
class Meta(DeviceComponentTable.Meta):
class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
model = models.RearPort
fields = (
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'positions', 'description', 'mark_connected',
@@ -792,9 +777,6 @@ class DeviceRearPortTable(RearPortTable):
default_columns = (
'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'link_peer',
)
row_attrs = {
'class': get_cabletermination_row_class
}
class DeviceBayTable(DeviceComponentTable):

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)
@@ -2103,6 +2109,9 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
Device.objects.filter(pk=devices[0].pk).update(virtual_chassis=virtual_chassis, vc_position=1, vc_priority=1)
Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis, vc_position=2, vc_priority=2)
# VirtualDeviceContext assignment for filtering
VirtualDeviceContext.objects.create(device=devices[0], name="VDC 1", identifier=1, status='active')
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -2210,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)
@@ -2336,6 +2352,12 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_has_virtual_device_context(self):
params = {'has_virtual_device_context': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'has_virtual_device_context': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = Module.objects.all()

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,7 +2075,7 @@ class DeviceVirtualMachinesView(generic.ObjectChildrenView):
child_model = VirtualMachine
table = VirtualMachineTable
filterset = VirtualMachineFilterSet
template_name = 'generic/object_children.html'
filterset_form = VirtualMachineFilterForm
tab = ViewTab(
label=_('Virtual Machines'),
badge=lambda obj: VirtualMachine.objects.filter(cluster=obj.cluster, device=obj).count(),
@@ -2158,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),
}
@@ -2986,7 +2958,7 @@ class InventoryItemChildrenView(generic.ObjectChildrenView):
child_model = InventoryItem
table = tables.InventoryItemTable
filterset = filtersets.InventoryItemFilterSet
template_name = 'generic/object_children.html'
filterset_form = forms.InventoryItemFilterForm
tab = ViewTab(
label=_('Children'),
badge=lambda obj: obj.child_items.count(),
@@ -3453,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())
@@ -3554,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),
}
@@ -3667,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

@@ -30,6 +30,16 @@ class ObjectChangeSerializer(BaseModelSerializer):
changed_object = serializers.SerializerMethodField(
read_only=True
)
prechange_data = serializers.JSONField(
source='prechange_data_clean',
read_only=True,
allow_null=True
)
postchange_data = serializers.JSONField(
source='postchange_data_clean',
read_only=True,
allow_null=True
)
class Meta:
model = ObjectChange

View File

@@ -43,7 +43,7 @@ class JournalEntrySerializer(NetBoxModelSerializer):
def validate(self, data):
# Validate that the parent object exists
if 'assigned_object_type' in data and 'assigned_object_id' in data:
if not self.nested and 'assigned_object_type' in data and 'assigned_object_id' in data:
try:
data['assigned_object_type'].get_object_for_this_type(id=data['assigned_object_id'])
except ObjectDoesNotExist:
@@ -51,10 +51,7 @@ class JournalEntrySerializer(NetBoxModelSerializer):
f"Invalid assigned_object: {data['assigned_object_type']} ID {data['assigned_object_id']}"
)
# Enforce model validation
super().validate(data)
return data
return super().validate(data)
@extend_schema_field(serializers.JSONField(allow_null=True))
def get_assigned_object(self, instance):

View File

@@ -1,3 +1,4 @@
from django.http import Http404
from django.shortcuts import get_object_or_404
from django_rq.queues import get_connection
from rest_framework import status
@@ -215,21 +216,32 @@ class ScriptViewSet(ModelViewSet):
_ignore_model_permissions = True
lookup_value_regex = '[^/]+' # Allow dots
def _get_script(self, pk):
# If pk is numeric, retrieve script by ID
if pk.isnumeric():
return get_object_or_404(self.queryset, pk=pk)
# Default to retrieval by module & name
try:
module_name, script_name = pk.split('.', maxsplit=1)
except ValueError:
raise Http404
return get_object_or_404(self.queryset, module__file_path=f'{module_name}.py', name=script_name)
def retrieve(self, request, pk):
script = get_object_or_404(self.queryset, pk=pk)
script = self._get_script(pk)
serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
return Response(serializer.data)
def post(self, request, pk):
"""
Run a Script identified by the id and return the pending Job as the result
Run a Script identified by its numeric PK or module & name and return the pending Job as the result
"""
if not request.user.has_perm('extras.run_script'):
raise PermissionDenied("This user does not have permission to run scripts.")
script = get_object_or_404(self.queryset, pk=pk)
script = self._get_script(pk)
input_serializer = serializers.ScriptInputSerializer(
data=request.data,
context={'script': script}
@@ -240,9 +252,9 @@ class ScriptViewSet(ModelViewSet):
raise RQWorkerNotRunningException()
if input_serializer.is_valid():
script.result = Job.enqueue(
Job.enqueue(
run_script,
instance=script.module,
instance=script,
name=script.python_class.class_name,
user=request.user,
data=input_serializer.data['data'],

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

@@ -13,13 +13,14 @@ def event_tracking(request):
:param request: WSGIRequest object with a unique `id` set
"""
current_request.set(request)
events_queue.set([])
events_queue.set({})
yield
# Flush queued webhooks to RQ
flush_events(events_queue.get())
if events := list(events_queue.get().values()):
flush_events(events)
# Clear context vars
current_request.set(None)
events_queue.set([])
events_queue.set({})

View File

@@ -265,6 +265,7 @@ class ObjectListWidget(DashboardWidget):
parameters = self.config.get('url_params') or {}
if page_size := self.config.get('page_size'):
parameters['per_page'] = page_size
parameters['embedded'] = True
if parameters:
try:
@@ -380,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

@@ -58,15 +58,24 @@ def enqueue_object(queue, instance, user, request_id, action):
if model_name not in registry['model_features']['event_rules'].get(app_label, []):
return
queue.append({
'content_type': ContentType.objects.get_for_model(instance),
'object_id': instance.pk,
'event': action,
'data': serialize_for_event(instance),
'snapshots': get_snapshots(instance, action),
'username': user.username,
'request_id': request_id
})
assert instance.pk is not None
key = f'{app_label}.{model_name}:{instance.pk}'
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),
'object_id': instance.pk,
'event': action,
'data': serialize_for_event(instance),
'snapshots': get_snapshots(instance, action),
'username': user.username,
'request_id': request_id
}
def process_event_rules(event_rules, model_name, event, data, username=None, snapshots=None, request_id=None):
@@ -163,14 +172,14 @@ def process_event_queue(events):
)
def flush_events(queue):
def flush_events(events):
"""
Flush a list of object representation to RQ for webhook processing.
Flush a list of object representations to RQ for event processing.
"""
if queue:
if events:
for name in settings.EVENTS_PIPELINE:
try:
func = import_string(name)
func(queue)
func(events)
except Exception as e:
logger.error(_("Cannot import events pipeline {name} error: {error}").format(name=name, error=e))

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

@@ -464,13 +464,10 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm):
required=False,
label=_('User')
)
assigned_object_type_id = DynamicModelMultipleChoiceField(
queryset=ObjectType.objects.all(),
assigned_object_type_id = ContentTypeMultipleChoiceField(
queryset=ObjectType.objects.with_feature('journaling'),
required=False,
label=_('Object Type'),
widget=APISelectMultiple(
api_url='/api/extras/content-types/',
)
)
kind = forms.ChoiceField(
label=_('Kind'),
@@ -507,11 +504,8 @@ class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):
required=False,
label=_('User')
)
changed_object_type_id = DynamicModelMultipleChoiceField(
queryset=ObjectType.objects.all(),
changed_object_type_id = ContentTypeMultipleChoiceField(
queryset=ObjectType.objects.with_feature('change_logging'),
required=False,
label=_('Object Type'),
widget=APISelectMultiple(
api_url='/api/extras/content-types/',
)
)

View File

@@ -122,7 +122,7 @@ class CustomFieldChoiceSetForm(forms.ModelForm):
label = label.replace('\\:', ':')
except ValueError:
value, label = line, line
data.append((value, label))
data.append((value.strip(), label.strip()))
return data

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

@@ -1,12 +1,17 @@
from functools import cached_property
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from mptt.models import MPTTModel
from core.models import ObjectType
from extras.choices import *
from netbox.models.features import ChangeLoggingMixin
from utilities.data import shallow_compare_dict
from ..querysets import ObjectChangeQuerySet
__all__ = (
@@ -136,6 +141,71 @@ class ObjectChange(models.Model):
def get_action_color(self):
return ObjectChangeActionChoices.colors.get(self.action)
@property
@cached_property
def has_changes(self):
return self.prechange_data != self.postchange_data
@cached_property
def diff_exclude_fields(self):
"""
Return a set of attributes which should be ignored when calculating a diff
between the pre- and post-change data. (For instance, it would not make
sense to compare the "last updated" times as these are expected to differ.)
"""
model = self.changed_object_type.model_class()
attrs = set()
# Exclude auto-populated change tracking fields
if issubclass(model, ChangeLoggingMixin):
attrs.update({'created', 'last_updated'})
# Exclude MPTT-internal fields
if issubclass(model, MPTTModel):
attrs.update({'level', 'lft', 'rght', 'tree_id'})
return attrs
def get_clean_data(self, prefix):
"""
Return only the pre-/post-change attributes which are relevant for calculating a diff.
"""
ret = {}
change_data = getattr(self, f'{prefix}_data') or {}
for k, v in change_data.items():
if k not in self.diff_exclude_fields and not k.startswith('_'):
ret[k] = v
return ret
@cached_property
def prechange_data_clean(self):
return self.get_clean_data('prechange')
@cached_property
def postchange_data_clean(self):
return self.get_clean_data('postchange')
def diff(self):
"""
Return a dictionary of pre- and post-change values for attributes which have changed.
"""
prechange_data = self.prechange_data_clean
postchange_data = self.postchange_data_clean
# Determine which attributes have changed
if self.action == ObjectChangeActionChoices.ACTION_CREATE:
changed_attrs = sorted(postchange_data.keys())
elif self.action == ObjectChangeActionChoices.ACTION_DELETE:
changed_attrs = sorted(prechange_data.keys())
else:
# TODO: Support deep (recursive) comparison
changed_data = shallow_compare_dict(prechange_data, postchange_data)
changed_attrs = sorted(changed_data.keys())
return {
'pre': {
k: prechange_data.get(k) for k in changed_attrs
},
'post': {
k: postchange_data.get(k) for k in changed_attrs
},
}

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

@@ -96,6 +96,7 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
Proxy model for script module files.
"""
objects = ScriptModuleManager()
error = None
event_rules = GenericRelation(
to='extras.EventRule',
@@ -126,6 +127,7 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
try:
module = self.get_module()
except Exception as e:
self.error = e
logger.debug(f"Failed to load script: {self.python_name} error: {e}")
module = None

View File

@@ -4,6 +4,7 @@ from django.contrib.auth import get_user_model
from django.contrib.contenttypes.fields import GenericForeignKey
from django.db import models, transaction
from django.utils.translation import gettext_lazy as _
from mptt.models import MPTTModel
from extras.choices import ChangeActionChoices
from netbox.models import ChangeLoggedModel
@@ -124,6 +125,11 @@ class StagedChange(CustomValidationMixin, EventRulesMixin, models.Model):
instance = self.model.objects.get(pk=self.object_id)
logger.info(f'Deleting {self.model._meta.verbose_name} {instance}')
instance.delete()
# Rebuild the MPTT tree where applicable
if issubclass(self.model, MPTTModel):
self.model.objects.rebuild()
apply.alters_data = True
def get_action_color(self):

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

@@ -55,18 +55,6 @@ def run_validators(instance, validators):
clear_events = Signal()
def is_same_object(instance, webhook_data, request_id):
"""
Compare the given instance to the most recent queued webhook object, returning True
if they match. This check is used to avoid creating duplicate webhook entries.
"""
return (
ContentType.objects.get_for_model(instance) == webhook_data['content_type'] and
instance.pk == webhook_data['object_id'] and
request_id == webhook_data['request_id']
)
@receiver((post_save, m2m_changed))
def handle_changed_object(sender, instance, **kwargs):
"""
@@ -112,14 +100,13 @@ def handle_changed_object(sender, instance, **kwargs):
objectchange.request_id = request.id
objectchange.save()
# If this is an M2M change, update the previously queued webhook (from post_save)
# Ensure that we're working with fresh M2M assignments
if m2m_changed:
instance.refresh_from_db()
# Enqueue the object for event processing
queue = events_queue.get()
if m2m_changed and queue and is_same_object(instance, queue[-1], request.id):
instance.refresh_from_db() # Ensure that we're working with fresh M2M assignments
queue[-1]['data'] = serialize_for_event(instance)
queue[-1]['snapshots']['postchange'] = get_snapshots(instance, action)['postchange']
else:
enqueue_object(queue, instance, request.user, request.id, action)
enqueue_object(queue, instance, request.user, request.id, action)
events_queue.set(queue)
# Increment metric counters
@@ -179,7 +166,7 @@ def handle_deleted_object(sender, instance, **kwargs):
obj.snapshot() # Ensure the change record includes the "before" state
getattr(obj, related_field_name).remove(instance)
# Enqueue webhooks
# Enqueue the object for event processing
queue = events_queue.get()
enqueue_object(queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE)
events_queue.set(queue)
@@ -195,7 +182,7 @@ def clear_events_queue(sender, **kwargs):
"""
logger = logging.getLogger('events')
logger.info(f"Clearing {len(events_queue.get())} queued events ({sender})")
events_queue.set([])
events_queue.set({})
#

View File

@@ -1,10 +1,11 @@
import json
import django_tables2 as tables
from django.conf import settings
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from extras.models import *
from netbox.constants import EMPTY_TABLE_TEXT
from netbox.tables import BaseTable, NetBoxTable, columns
from .template_code import *
@@ -545,15 +546,27 @@ 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')
)
class Meta(BaseTable.Meta):
empty_text = _('No results found')
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):
@@ -581,7 +594,13 @@ class ReportResultsTable(BaseTable):
)
class Meta(BaseTable.Meta):
empty_text = _('No results found')
empty_text = _(EMPTY_TABLE_TEXT)
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

@@ -75,6 +75,10 @@ class ChangeLogViewTest(ModelViewTestCase):
self.assertEqual(oc.postchange_data['custom_fields']['cf2'], form_data['cf_cf2'])
self.assertEqual(oc.postchange_data['tags'], ['Tag 1', 'Tag 2'])
# Check that private attributes were included in raw data but not display data
self.assertIn('_name', oc.postchange_data)
self.assertNotIn('_name', oc.postchange_data_clean)
def test_update_object(self):
site = Site(name='Site 1', slug='site-1')
site.save()
@@ -112,6 +116,12 @@ class ChangeLogViewTest(ModelViewTestCase):
self.assertEqual(oc.postchange_data['custom_fields']['cf2'], form_data['cf_cf2'])
self.assertEqual(oc.postchange_data['tags'], ['Tag 3'])
# Check that private attributes were included in raw data but not display data
self.assertIn('_name', oc.prechange_data)
self.assertNotIn('_name', oc.prechange_data_clean)
self.assertIn('_name', oc.postchange_data)
self.assertNotIn('_name', oc.postchange_data_clean)
def test_delete_object(self):
site = Site(
name='Site 1',
@@ -142,6 +152,10 @@ class ChangeLogViewTest(ModelViewTestCase):
self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
self.assertEqual(oc.postchange_data, None)
# Check that private attributes were included in raw data but not display data
self.assertIn('_name', oc.prechange_data)
self.assertNotIn('_name', oc.prechange_data_clean)
def test_bulk_update_objects(self):
sites = (
Site(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_ACTIVE),
@@ -338,6 +352,10 @@ class ChangeLogAPITest(APITestCase):
self.assertEqual(oc.postchange_data['custom_fields'], data['custom_fields'])
self.assertEqual(oc.postchange_data['tags'], ['Tag 1', 'Tag 2'])
# Check that private attributes were included in raw data but not display data
self.assertIn('_name', oc.postchange_data)
self.assertNotIn('_name', oc.postchange_data_clean)
def test_update_object(self):
site = Site(name='Site 1', slug='site-1')
site.save()
@@ -370,6 +388,12 @@ class ChangeLogAPITest(APITestCase):
self.assertEqual(oc.postchange_data['custom_fields'], data['custom_fields'])
self.assertEqual(oc.postchange_data['tags'], ['Tag 3'])
# Check that private attributes were included in raw data but not display data
self.assertIn('_name', oc.prechange_data)
self.assertNotIn('_name', oc.prechange_data_clean)
self.assertIn('_name', oc.postchange_data)
self.assertNotIn('_name', oc.postchange_data_clean)
def test_delete_object(self):
site = Site(
name='Site 1',
@@ -398,6 +422,10 @@ class ChangeLogAPITest(APITestCase):
self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
self.assertEqual(oc.postchange_data, None)
# Check that private attributes were included in raw data but not display data
self.assertIn('_name', oc.prechange_data)
self.assertNotIn('_name', oc.prechange_data_clean)
def test_bulk_create_objects(self):
data = (
{

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

@@ -4,6 +4,7 @@ from unittest.mock import patch
import django_rq
from django.http import HttpResponse
from django.test import RequestFactory
from django.urls import reverse
from requests import Session
from rest_framework import status
@@ -12,6 +13,7 @@ from core.models import ObjectType
from dcim.choices import SiteStatusChoices
from dcim.models import Site
from extras.choices import EventRuleActionChoices, ObjectChangeActionChoices
from extras.context_managers import event_tracking
from extras.events import enqueue_object, flush_events, serialize_for_event
from extras.models import EventRule, Tag, Webhook
from extras.webhooks import generate_signature, send_webhook
@@ -360,7 +362,7 @@ class EventRuleTest(APITestCase):
return HttpResponse()
# Enqueue a webhook for processing
webhooks_queue = []
webhooks_queue = {}
site = Site.objects.create(name='Site 1', slug='site-1')
enqueue_object(
webhooks_queue,
@@ -369,7 +371,7 @@ class EventRuleTest(APITestCase):
request_id=request_id,
action=ObjectChangeActionChoices.ACTION_CREATE
)
flush_events(webhooks_queue)
flush_events(list(webhooks_queue.values()))
# Retrieve the job from queue
job = self.queue.jobs[0]
@@ -377,3 +379,47 @@ class EventRuleTest(APITestCase):
# Patch the Session object with our dummy_send() method, then process the webhook for sending
with patch.object(Session, 'send', dummy_send) as mock_send:
send_webhook(**job.kwargs)
def test_duplicate_triggers(self):
"""
Test for erroneous duplicate event triggers resulting from saving an object multiple times
within the span of a single request.
"""
url = reverse('dcim:site_add')
request = RequestFactory().get(url)
request.id = uuid.uuid4()
request.user = self.user
# Test create & update
with event_tracking(request):
site = Site(name='Site 1', slug='site-1')
site.save()
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

@@ -723,15 +723,15 @@ class ObjectChangeView(generic.ObjectView):
if not instance.prechange_data and instance.action in ['update', 'delete'] and prev_change:
non_atomic_change = True
prechange_data = prev_change.postchange_data
prechange_data = prev_change.postchange_data_clean
else:
non_atomic_change = False
prechange_data = instance.prechange_data
prechange_data = instance.prechange_data_clean
if prechange_data and instance.postchange_data:
diff_added = shallow_compare_dict(
prechange_data or dict(),
instance.postchange_data or dict(),
instance.postchange_data_clean or dict(),
exclude=['last_updated'],
)
diff_removed = {
@@ -1052,12 +1052,27 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
})
class ScriptView(generic.ObjectView):
class BaseScriptView(generic.ObjectView):
queryset = Script.objects.all()
def _get_script_class(self, script):
"""
Return an instance of the Script's Python class
"""
if script_class := script.python_class:
return script_class()
class ScriptView(BaseScriptView):
def get(self, request, **kwargs):
script = self.get_object(**kwargs)
script_class = script.python_class()
script_class = self._get_script_class(script)
if not script_class:
return render(request, 'extras/script.html', {
'script': script,
})
form = script_class.as_form(initial=normalize_querydict(request.GET))
return render(request, 'extras/script.html', {
@@ -1069,11 +1084,16 @@ class ScriptView(generic.ObjectView):
def post(self, request, **kwargs):
script = self.get_object(**kwargs)
script_class = script.python_class()
if not request.user.has_perm('extras.run_script', obj=script):
return HttpResponseForbidden()
script_class = self._get_script_class(script)
if not script_class:
return render(request, 'extras/script.html', {
'script': script,
})
form = script_class.as_form(request.POST, request.FILES)
# Allow execution only if RQ worker process is running
@@ -1103,21 +1123,22 @@ class ScriptView(generic.ObjectView):
})
class ScriptSourceView(generic.ObjectView):
class ScriptSourceView(BaseScriptView):
queryset = Script.objects.all()
def get(self, request, **kwargs):
script = self.get_object(**kwargs)
script_class = self._get_script_class(script)
return render(request, 'extras/script/source.html', {
'script': script,
'script_class': script.python_class(),
'script_class': script_class,
'job_count': script.jobs.count(),
'tab': 'source',
})
class ScriptJobsView(generic.ObjectView):
class ScriptJobsView(BaseScriptView):
queryset = Script.objects.all()
def get(self, request, **kwargs):
@@ -1180,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

@@ -10,7 +10,7 @@ from tenancy.forms import TenancyFilterForm
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, add_blank_choice
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField
from utilities.forms.rendering import FieldSet
from virtualization.models import VirtualMachine
from virtualization.models import VirtualMachine, ClusterGroup, Cluster
from vpn.models import L2VPN
__all__ = (
@@ -168,6 +168,7 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
'within_include', 'family', 'status', 'role_id', 'mask_length', 'is_pool', 'mark_utilized',
name=_('Addressing')
),
FieldSet('vlan_id', name=_('VLAN Assignment')),
FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
@@ -249,6 +250,12 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
vlan_id = DynamicModelMultipleChoiceField(
queryset=VLAN.objects.all(),
required=False,
label=_('VLAN'),
)
tag = TagFilterField(model)
@@ -405,6 +412,7 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm):
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('region', 'sitegroup', 'site', 'location', 'rack', name=_('Location')),
FieldSet('cluster_group', 'cluster', name=_('Cluster')),
FieldSet('min_vid', 'max_vid', name=_('VLAN ID')),
)
model = VLANGroup
@@ -445,6 +453,17 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm):
max_value=VLAN_VID_MAX,
label=_('Maximum VID')
)
cluster = DynamicModelMultipleChoiceField(
queryset=Cluster.objects.all(),
required=False,
label=_('Cluster')
)
cluster_group = DynamicModelMultipleChoiceField(
queryset=ClusterGroup.objects.all(),
required=False,
label=_('Cluster group')
)
tag = TagFilterField(model)

View File

@@ -355,6 +355,15 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
):
self.initial['primary_for_parent'] = True
if type(instance.assigned_object) is Interface:
self.fields['interface'].widget.add_query_params({
'device_id': instance.assigned_object.device.pk,
})
elif type(instance.assigned_object) is VMInterface:
self.fields['vminterface'].widget.add_query_params({
'virtual_machine_id': instance.assigned_object.virtual_machine.pk,
})
# Disable object assignment fields if the IP address is designated as primary
if self.initial.get('primary_for_parent'):
self.fields['interface'].disabled = True

View File

@@ -18,6 +18,7 @@ from ipam.querysets import PrefixQuerySet
from ipam.validators import DNSValidator
from netbox.config import get_config
from netbox.models import OrganizationalModel, PrimaryModel
from netbox.models.features import ContactsMixin
__all__ = (
'Aggregate',
@@ -74,7 +75,7 @@ class RIR(OrganizationalModel):
return reverse('ipam:rir', args=[self.pk])
class Aggregate(GetAvailablePrefixesMixin, PrimaryModel):
class Aggregate(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel):
"""
An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize
the hierarchy and track the overall utilization of available address space. Each Aggregate is assigned to a RIR.
@@ -206,7 +207,7 @@ class Role(OrganizationalModel):
return reverse('ipam:role', args=[self.pk])
class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
class Prefix(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel):
"""
A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and
VRFs. A Prefix must be assigned a status and may optionally be assigned a used-define Role. A Prefix can also be
@@ -486,7 +487,7 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
return min(utilization, 100)
class IPRange(PrimaryModel):
class IPRange(ContactsMixin, PrimaryModel):
"""
A range of IP addresses, defined by start and end addresses.
"""
@@ -695,7 +696,7 @@ class IPRange(PrimaryModel):
return min(float(child_count) / self.size * 100, 100)
class IPAddress(PrimaryModel):
class IPAddress(ContactsMixin, PrimaryModel):
"""
An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is
configured in the real world. (Typically, only loopback interfaces are configured with /32 or /128 masks.) Like

View File

@@ -8,6 +8,7 @@ from django.utils.translation import gettext_lazy as _
from ipam.choices import *
from ipam.constants import *
from netbox.models import PrimaryModel
from netbox.models.features import ContactsMixin
from utilities.data import array_to_string
__all__ = (
@@ -62,7 +63,7 @@ class ServiceTemplate(ServiceBase, PrimaryModel):
return reverse('ipam:servicetemplate', args=[self.pk])
class Service(ServiceBase, PrimaryModel):
class Service(ContactsMixin, ServiceBase, PrimaryModel):
"""
A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may
optionally be tied to one or more specific IPAddresses belonging to its parent.

View File

@@ -648,6 +648,9 @@ class IPAddressTest(APIViewTestCases.APIViewTestCase):
bulk_update_data = {
'description': 'New description',
}
graphql_filter = {
'address': {'lookup': 'i_exact', 'value': '192.168.0.1/24'},
}
@classmethod
def setUpTestData(cls):

View File

@@ -7,12 +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
@@ -33,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
@@ -52,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,
}
@@ -146,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),
}
@@ -214,7 +208,7 @@ class ASNRangeASNsView(generic.ObjectChildrenView):
child_model = ASN
table = tables.ASNTable
filterset = filtersets.ASNFilterSet
template_name = 'generic/object_children.html'
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'),
@@ -406,6 +403,11 @@ class AggregateBulkDeleteView(generic.BulkDeleteView):
table = tables.AggregateTable
@register_model_view(Aggregate, 'contacts')
class AggregateContactsView(ObjectContactsView):
queryset = Aggregate.objects.all()
#
# Prefix/VLAN roles
#
@@ -422,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),
}
@@ -531,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'),
@@ -566,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'),
@@ -592,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'),
@@ -644,6 +643,11 @@ class PrefixBulkDeleteView(generic.BulkDeleteView):
table = tables.PrefixTable
@register_model_view(Prefix, 'contacts')
class PrefixContactsView(ObjectContactsView):
queryset = Prefix.objects.all()
#
# IP Ranges
#
@@ -686,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'),
@@ -727,6 +732,11 @@ class IPRangeBulkDeleteView(generic.BulkDeleteView):
table = tables.IPRangeTable
@register_model_view(IPRange, 'contacts')
class IPRangeContactsView(ObjectContactsView):
queryset = IPRange.objects.all()
#
# IP addresses
#
@@ -883,7 +893,7 @@ class IPAddressRelatedIPsView(generic.ObjectChildrenView):
child_model = IPAddress
table = tables.IPAddressTable
filterset = filtersets.IPAddressFilterSet
template_name = 'generic/object_children.html'
filterset_form = forms.IPAddressFilterForm
tab = ViewTab(
label=_('Related IPs'),
badge=lambda x: x.get_related_ips().count(),
@@ -895,6 +905,11 @@ class IPAddressRelatedIPsView(generic.ObjectChildrenView):
return parent.get_related_ips().restrict(request.user, 'view')
@register_model_view(IPAddress, 'contacts')
class IPAddressContactsView(ObjectContactsView):
queryset = IPAddress.objects.all()
#
# VLAN groups
#
@@ -907,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),
}
@@ -955,7 +966,7 @@ class VLANGroupVLANsView(generic.ObjectChildrenView):
child_model = VLAN
table = tables.VLANTable
filterset = filtersets.VLANFilterSet
template_name = 'generic/object_children.html'
filterset_form = forms.VLANFilterForm
tab = ViewTab(
label=_('VLANs'),
badge=lambda x: x.get_child_vlans().count(),
@@ -1111,7 +1122,7 @@ class VLANInterfacesView(generic.ObjectChildrenView):
child_model = Interface
table = tables.VLANDevicesTable
filterset = InterfaceFilterSet
template_name = 'generic/object_children.html'
filterset_form = InterfaceFilterForm
tab = ViewTab(
label=_('Device Interfaces'),
badge=lambda x: x.get_interfaces().count(),
@@ -1129,7 +1140,7 @@ class VLANVMInterfacesView(generic.ObjectChildrenView):
child_model = VMInterface
table = tables.VLANVirtualMachinesTable
filterset = VMInterfaceFilterSet
template_name = 'generic/object_children.html'
filterset_form = VMInterfaceFilterForm
tab = ViewTab(
label=_('VM Interfaces'),
badge=lambda x: x.get_vminterfaces().count(),
@@ -1264,3 +1275,8 @@ class ServiceBulkDeleteView(generic.BulkDeleteView):
queryset = Service.objects.prefetch_related('device', 'virtual_machine')
filterset = filtersets.ServiceFilterSet
table = tables.ServiceTable
@register_model_view(Service, 'contacts')
class ServiceContactsView(ObjectContactsView):
queryset = Service.objects.all()

View File

@@ -157,9 +157,8 @@ LOGGING = {}
# authenticated to NetBox indefinitely.
LOGIN_PERSISTENCE = False
# Setting this to True will permit only authenticated users to access any part of NetBox. By default, anonymous users
# are permitted to access most data in NetBox but not make any changes.
LOGIN_REQUIRED = False
# Setting this to False will permit unauthenticated users to access most areas of NetBox (but not make any changes).
LOGIN_REQUIRED = True
# The length of time (in seconds) for which a user will remain logged into the web UI before being prompted to
# re-authenticate. (Default: 1209600 [14 days])

View File

@@ -41,3 +41,6 @@ DEFAULT_ACTION_PERMISSIONS = {
# General-purpose tokens
CENSOR_TOKEN = '********'
CENSOR_TOKEN_CHANGED = '***CHANGED***'
# Placeholder text for empty tables
EMPTY_TABLE_TEXT = 'No results found'

View File

@@ -7,4 +7,4 @@ __all__ = (
current_request = ContextVar('current_request', default=None)
events_queue = ContextVar('events_queue', default=[])
events_queue = ContextVar('events_queue', default=dict())

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

@@ -23,8 +23,9 @@ def map_strawberry_type(field):
elif isinstance(field, MultiValueArrayFilter):
pass
elif isinstance(field, MultiValueCharFilter):
should_create_function = True
attr_type = List[str] | None
# Note: Need to use the legacy FilterLookup from filters, not from
# strawberry_django.FilterLookup as we currently have USE_DEPRECATED_FILTERS
attr_type = strawberry_django.filters.FilterLookup[str] | None
elif isinstance(field, MultiValueDateFilter):
attr_type = auto
elif isinstance(field, MultiValueDateTimeFilter):
@@ -87,7 +88,7 @@ def map_strawberry_type(field):
pass
elif issubclass(type(field), django_filters.NumberFilter):
should_create_function = True
attr_type = int
attr_type = int | None
elif issubclass(type(field), django_filters.ModelMultipleChoiceFilter):
should_create_function = True
attr_type = List[str] | None

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

@@ -258,6 +258,7 @@ CIRCUITS_MENU = Menu(
items=(
get_model_item('circuits', 'circuit', _('Circuits')),
get_model_item('circuits', 'circuittype', _('Circuit Types')),
get_model_item('circuits', 'circuittermination', _('Circuit Terminations')),
),
),
MenuGroup(
@@ -372,19 +373,19 @@ ADMIN_MENU = Menu(
link=f'users:user_list',
link_text=_('Users'),
auth_required=True,
permissions=[f'auth.view_user'],
permissions=[f'users.view_user'],
buttons=(
MenuItemButton(
link=f'users:user_add',
title='Add',
icon_class='mdi mdi-plus-thick',
permissions=[f'auth.add_user']
permissions=[f'users.add_user']
),
MenuItemButton(
link=f'users:user_import',
title='Import',
icon_class='mdi mdi-upload',
permissions=[f'auth.add_user']
permissions=[f'users.add_user']
)
)
),
@@ -392,19 +393,19 @@ ADMIN_MENU = Menu(
link=f'users:group_list',
link_text=_('Groups'),
auth_required=True,
permissions=[f'auth.view_group'],
permissions=[f'users.view_group'],
buttons=(
MenuItemButton(
link=f'users:group_add',
title='Add',
icon_class='mdi mdi-plus-thick',
permissions=[f'auth.add_group']
permissions=[f'users.add_group']
),
MenuItemButton(
link=f'users:group_import',
title='Import',
icon_class='mdi mdi-upload',
permissions=[f'auth.add_group']
permissions=[f'users.add_group']
)
)
),
@@ -461,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
@@ -484,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

@@ -138,13 +138,15 @@ class PluginConfig(AppConfig):
min_version = version.parse(cls.min_version)
if current_version < min_version:
raise ImproperlyConfigured(
f"Plugin {cls.__module__} requires NetBox minimum version {cls.min_version}."
f"Plugin {cls.__module__} requires NetBox minimum version {cls.min_version} (current: "
f"{netbox_version})."
)
if cls.max_version is not None:
max_version = version.parse(cls.max_version)
if current_version > max_version:
raise ImproperlyConfigured(
f"Plugin {cls.__module__} requires NetBox maximum version {cls.max_version}."
f"Plugin {cls.__module__} requires NetBox maximum version {cls.max_version} (current: "
f"{netbox_version})."
)
# Verify required configuration settings

View File

@@ -23,7 +23,7 @@ PREFERENCES = {
),
description=_('Enable dynamic UI navigation'),
default=False,
experimental=True
warning=_('Experimental feature')
),
'locale.language': UserPreference(
label=_('Language'),
@@ -31,7 +31,12 @@ PREFERENCES = {
('', _('Auto')),
*settings.LANGUAGES,
),
description=_('Forces UI translation to the specified language.')
description=_('Forces UI translation to the specified language'),
warning=(
_("Support for translation has been disabled locally")
if not settings.TRANSLATION_ENABLED
else ''
)
),
'pagination.per_page': UserPreference(
label=_('Page length'),

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.1'
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__)))
@@ -105,7 +105,7 @@ LANGUAGE_CODE = getattr(configuration, 'DEFAULT_LANGUAGE', 'en-us')
LANGUAGE_COOKIE_PATH = CSRF_COOKIE_PATH
LOGGING = getattr(configuration, 'LOGGING', {})
LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False)
LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', True)
LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None)
LOGOUT_REDIRECT_URL = getattr(configuration, 'LOGOUT_REDIRECT_URL', 'home')
MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(BASE_DIR, 'media')).rstrip('/')
@@ -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')
@@ -156,6 +157,7 @@ SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None)
STORAGE_BACKEND = getattr(configuration, 'STORAGE_BACKEND', None)
STORAGE_CONFIG = getattr(configuration, 'STORAGE_CONFIG', {})
TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC')
TRANSLATION_ENABLED = getattr(configuration, 'TRANSLATION_ENABLED', True)
# Load any dynamic configuration parameters which have been hard-coded in the configuration file
for param in CONFIG_PARAMS:
@@ -224,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 "
@@ -241,6 +260,7 @@ if 'tasks' not in REDIS:
TASKS_REDIS = REDIS['tasks']
TASKS_REDIS_HOST = TASKS_REDIS.get('HOST', 'localhost')
TASKS_REDIS_PORT = TASKS_REDIS.get('PORT', 6379)
TASKS_REDIS_URL = TASKS_REDIS.get('URL')
TASKS_REDIS_SENTINELS = TASKS_REDIS.get('SENTINELS', [])
TASKS_REDIS_USING_SENTINEL = all([
isinstance(TASKS_REDIS_SENTINELS, (list, tuple)),
@@ -269,7 +289,7 @@ CACHING_REDIS_SENTINEL_SERVICE = REDIS['caching'].get('SENTINEL_SERVICE', 'defau
CACHING_REDIS_PROTO = 'rediss' if REDIS['caching'].get('SSL', False) else 'redis'
CACHING_REDIS_SKIP_TLS_VERIFY = REDIS['caching'].get('INSECURE_SKIP_TLS_VERIFY', False)
CACHING_REDIS_CA_CERT_PATH = REDIS['caching'].get('CA_CERT_PATH', False)
CACHING_REDIS_URL = f'{CACHING_REDIS_PROTO}://{CACHING_REDIS_USERNAME_HOST}:{CACHING_REDIS_PORT}/{CACHING_REDIS_DATABASE}'
CACHING_REDIS_URL = REDIS['caching'].get('URL', f'{CACHING_REDIS_PROTO}://{CACHING_REDIS_USERNAME_HOST}:{CACHING_REDIS_PORT}/{CACHING_REDIS_DATABASE}')
# Configure Django's default cache to use Redis
CACHES = {
@@ -366,6 +386,8 @@ INSTALLED_APPS = [
'drf_spectacular',
'drf_spectacular_sidecar',
]
if not DEBUG:
INSTALLED_APPS.remove('debug_toolbar')
if not DJANGO_ADMIN_ENABLED:
INSTALLED_APPS.remove('django.contrib.admin')
@@ -445,6 +467,9 @@ LOGIN_REDIRECT_URL = f'/{BASE_PATH}'
# Use timezone-aware datetime objects
USE_TZ = True
# Toggle language translation support
USE_I18N = TRANSLATION_ENABLED
# WSGI
WSGI_APPLICATION = 'netbox.wsgi.application'
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
@@ -529,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
)
@@ -544,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],
@@ -674,6 +699,12 @@ if TASKS_REDIS_USING_SENTINEL:
'socket_connect_timeout': TASKS_REDIS_SENTINEL_TIMEOUT
},
}
elif TASKS_REDIS_URL:
RQ_PARAMS = {
'URL': TASKS_REDIS_URL,
'SSL': TASKS_REDIS_SSL,
'SSL_CERT_REQS': None if TASKS_REDIS_SKIP_TLS_VERIFY else 'required',
}
else:
RQ_PARAMS = {
'HOST': TASKS_REDIS_HOST,
@@ -708,6 +739,7 @@ RQ_QUEUES.update({
# Supported translation languages
LANGUAGES = (
('de', _('German')),
('en', _('English')),
('es', _('Spanish')),
('fr', _('French')),
@@ -715,6 +747,8 @@ LANGUAGES = (
('pt', _('Portuguese')),
('ru', _('Russian')),
('tr', _('Turkish')),
('uk', _('Ukrainian')),
('zh', _('Chinese')),
)
LOCALE_PATHS = (
BASE_DIR + '/translations',
@@ -801,3 +835,10 @@ for plugin_name in PLUGINS:
RQ_QUEUES.update({
f"{plugin_name}.{queue}": RQ_PARAMS for queue in plugin_config.queues
})
# UNSUPPORTED FUNCTIONALITY: Import any local overrides.
try:
from .local_settings import *
_UNSUPPORTED_SETTINGS = True
except ImportError:
pass

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

@@ -1,4 +1,5 @@
from copy import deepcopy
from functools import cached_property
import django_tables2 as tables
from django.contrib.auth.models import AnonymousUser
@@ -14,6 +15,7 @@ from django_tables2.data import TableQuerysetData
from core.models import ObjectType
from extras.choices import *
from extras.models import CustomField, CustomLink
from netbox.constants import EMPTY_TABLE_TEXT
from netbox.registry import registry
from netbox.tables import columns
from utilities.paginator import EnhancedPaginator, get_paginate_count
@@ -188,6 +190,7 @@ class NetBoxTable(BaseTable):
actions = columns.ActionsColumn()
exempt_columns = ('pk', 'actions')
embedded = False
class Meta(BaseTable.Meta):
pass
@@ -217,12 +220,12 @@ class NetBoxTable(BaseTable):
super().__init__(*args, extra_columns=extra_columns, **kwargs)
@property
@cached_property
def htmx_url(self):
"""
Return the base HTML request URL for embedded tables.
"""
if getattr(self, 'embedded', False):
if self.embedded:
viewname = get_viewname(self._meta.model, action='list')
try:
return reverse(viewname)
@@ -258,7 +261,7 @@ class SearchTable(tables.Table):
attrs = {
'class': 'table table-hover object-list',
}
empty_text = _('No results found')
empty_text = _(EMPTY_TABLE_TEXT)
def __init__(self, data, highlight=None, **kwargs):
self.highlight = highlight

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