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

View File

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

View File

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

View File

@@ -1,7 +1,20 @@
name: CI name: CI
on: [push, pull_request]
on:
push:
paths-ignore:
- 'contrib/**'
- 'docs/**'
- 'netbox/translations/**'
pull_request:
paths-ignore:
- 'contrib/**'
- 'docs/**'
- 'netbox/translations/**'
permissions: permissions:
contents: read contents: read
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -34,12 +47,12 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4 uses: actions/setup-python@v5
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
@@ -47,7 +60,7 @@ jobs:
run: npm install -g yarn run: npm install -g yarn
- name: Setup Node.js with Yarn Caching - name: Setup Node.js with Yarn Caching
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: yarn 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: workflow_dispatch:
permissions: permissions:
actions: write
issues: write issues: write
pull-requests: write pull-requests: write
@@ -16,18 +17,19 @@ jobs:
steps: steps:
- uses: actions/stale@v9 - uses: actions/stale@v9
with: with:
# General parameters
operations-per-run: 100
remove-stale-when-updated: false
# Issue parameters
close-issue-message: > close-issue-message: >
This issue has been automatically closed due to lack of activity. In an 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 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 core maintainers may elect to reopen this issue at a later date if deemed
necessary. necessary.
close-pr-message: > days-before-issue-stale: 90
This PR has been automatically closed due to lack of activity. days-before-issue-close: 30
days-before-stale: 90 exempt-issue-labels: 'status: accepted,status: backlog,status: blocked'
days-before-close: 30
exempt-issue-labels: 'status: accepted,status: blocked,status: needs milestone'
operations-per-run: 100
remove-stale-when-updated: false
stale-issue-label: 'pending closure' stale-issue-label: 'pending closure'
stale-issue-message: > stale-issue-message: >
This issue has been automatically marked as stale because it has not had 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 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 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). 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-label: 'pending closure'
stale-pr-message: > stale-pr-message: >
This PR has been automatically marked as stale because it has not had 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/ /venv/
/*.sh /*.sh
local_requirements.txt local_requirements.txt
local_settings.py
!upgrade.sh !upgrade.sh
fabfile.py fabfile.py
gunicorn.py gunicorn.py
uwsgi.ini
netbox.log netbox.log
netbox.pid netbox.pid
.DS_Store .DS_Store
.idea .idea
.coverage .coverage
.vscode .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/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/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://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> <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> <p></p>
</div> </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="#why-netbox">Why NetBox?</a> |
<a href="#getting-started">Getting Started</a> | <a href="#getting-started">Getting Started</a> |
<a href="#get-involved">Get Involved</a> | <a href="#get-involved">Get Involved</a> |
<a href="#project-stats">Project Stats</a> |
<a href="#screenshots">Screenshots</a> <a href="#screenshots">Screenshots</a>
</p> </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. * 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! * [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 ## Screenshots
<p align="center"> <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. 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 ### Bug Bounties

View File

@@ -8,7 +8,9 @@ django-cors-headers
# Runtime UI tool for debugging Django # Runtime UI tool for debugging Django
# https://github.com/jazzband/django-debug-toolbar/blob/main/docs/changes.rst # 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 # Library for writing reusable URL query filters
# https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst # https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst
@@ -108,7 +110,7 @@ Pillow
# PostgreSQL database adapter for Python # PostgreSQL database adapter for Python
# https://github.com/psycopg/psycopg/blob/master/docs/news.rst # https://github.com/psycopg/psycopg/blob/master/docs/news.rst
psycopg[binary,pool] psycopg[c,pool]
# YAML rendering library # YAML rendering library
# https://github.com/yaml/pyyaml/blob/master/CHANGES # https://github.com/yaml/pyyaml/blob/master/CHANGES
@@ -131,7 +133,7 @@ social-auth-app-django
strawberry-graphql strawberry-graphql
# Strawberry GraphQL Django extension # 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 strawberry-graphql-django
# SVG image rendering (used for rack elevations) # SVG image rendering (used for rack elevations)

View File

@@ -179,6 +179,9 @@
"usb-micro-ab", "usb-micro-ab",
"usb-3-b", "usb-3-b",
"usb-3-micro-b", "usb-3-micro-b",
"molex-micro-fit-1x2",
"molex-micro-fit-2x2",
"molex-micro-fit-2x4",
"dc-terminal", "dc-terminal",
"saf-d-grid", "saf-d-grid",
"neutrik-powercon-20", "neutrik-powercon-20",
@@ -281,6 +284,9 @@
"usb-a", "usb-a",
"usb-micro-b", "usb-micro-b",
"usb-c", "usb-c",
"molex-micro-fit-1x2",
"molex-micro-fit-2x2",
"molex-micro-fit-2x4",
"dc-terminal", "dc-terminal",
"hdot-cx", "hdot-cx",
"saf-d-grid", "saf-d-grid",
@@ -317,6 +323,7 @@
"100base-tx", "100base-tx",
"100base-t1", "100base-t1",
"1000base-t", "1000base-t",
"1000base-tx",
"2.5gbase-t", "2.5gbase-t",
"5gbase-t", "5gbase-t",
"10gbase-t", "10gbase-t",
@@ -375,6 +382,8 @@
"gsm", "gsm",
"cdma", "cdma",
"lte", "lte",
"4g",
"5g",
"sonet-oc3", "sonet-oc3",
"sonet-oc12", "sonet-oc12",
"sonet-oc48", "sonet-oc48",
@@ -408,12 +417,15 @@
"e3", "e3",
"xdsl", "xdsl",
"docsis", "docsis",
"bpon",
"epon",
"10g-epon",
"gpon", "gpon",
"xg-pon", "xg-pon",
"xgs-pon", "xgs-pon",
"ng-pon2", "ng-pon2",
"epon", "25g-pon",
"10g-epon", "50g-pon",
"cisco-stackwise", "cisco-stackwise",
"cisco-stackwise-plus", "cisco-stackwise-plus",
"cisco-flexstack", "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 %} {% block site_meta %}
{{ super() }} {{ super() }}
{# Disable search indexing unless we're building for ReadTheDocs #} {# Disable search indexing unless we're building for public consumption #}
{% if not config.extra.readthedocs %} {% if not config.extra.build_public %}
<meta name="robots" content="noindex"> <meta name="robots" content="noindex">
{% endif %} {% endif %}
{% endblock %} {% 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 ## SENTRY_TAGS
An optional dictionary of tag names and values to apply to Sentry error reports.For example: 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 !!! warning
It is highly recommended to keep the task and cache databases separate. Using the same database number on the 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. 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 ### Using Redis Sentinel
If you are using [Redis Sentinel](https://redis.io/topics/sentinel) for high-availability purposes, there is minimal 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 ## 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) 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. 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 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. 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 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). 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) 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
Script attributes are defined under a class named `Meta` within the script. These are optional, but encouraged. 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: The Script object provides a set of convenient functions for recording messages at different severity levels:
* `log_debug(message, object=None)` * `log_debug(message=None, obj=None)`
* `log_success(message, object=None)` * `log_success(message=None, obj=None)`
* `log_info(message, object=None)` * `log_info(message=None, obj=None)`
* `log_warning(message, object=None)` * `log_warning(message=None, obj=None)`
* `log_failure(message, object=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. 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.) 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 !!! info
This functionality was ported from [legacy reports](./reports.md) in NetBox v4.0. 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 ### 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.) 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.
![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
```
### Update Version and Changelog ### 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. 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. 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 ## 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 ```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 ## 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 import forms
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from dcim.models import Site from dcim.models import Site
from netbox.forms import NetBoxModelImportForm from netbox.forms import NetBoxModelBulkEditForm
from utilities.forms import CommentField, DynamicModelChoiceField from utilities.forms import CommentField, DynamicModelChoiceField
from utilities.forms.rendering import FieldSet from utilities.forms.rendering import FieldSet
from .models import MyModel, MyModelStatusChoices from .models import MyModel, MyModelStatusChoices
class MyModelEditForm(NetBoxModelImportForm): class MyModelBulkEditForm(NetBoxModelBulkEditForm):
name = forms.CharField( name = forms.CharField(
required=False required=False
) )

View File

@@ -70,3 +70,19 @@ DROP TABLE
netbox=> DROP TABLE pluginname_bar; netbox=> DROP TABLE pluginname_bar;
DROP TABLE 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 # 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) ## v4.0.1 (2024-05-09)
### Enhancements ### Enhancements

View File

@@ -42,7 +42,7 @@ plugins:
show_root_toc_entry: false show_root_toc_entry: false
show_source: false show_source: false
extra: extra:
readthedocs: !ENV READTHEDOCS build_public: !ENV BUILD_PUBLIC
social: social:
- icon: fontawesome/brands/github - icon: fontawesome/brands/github
link: https://github.com/netbox-community/netbox 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 # Ensure the user has a UserConfig defined. (This should normally be handled by
# create_userconfig() on user creation.) # create_userconfig() on user creation.)
if not hasattr(request.user, 'config'): if not hasattr(request.user, 'config'):
config = get_config() request.user.config = get_config()
UserConfig(user=request.user, data=config.DEFAULT_USER_PREFERENCES).save() 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: else:
logger.debug(f"Login form validation failed for username: {form['username'].value()}") 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") logger.info(f"User {username} has logged out")
messages.info(request, "You have 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 = HttpResponseRedirect(resolve_url(settings.LOGOUT_REDIRECT_URL))
response.delete_cookie('session_key') response.delete_cookie('session_key')
response.delete_cookie(settings.LANGUAGE_COOKIE_NAME)
return response return response
@@ -199,7 +206,7 @@ class UserConfigView(LoginRequiredMixin, View):
# Set/clear language cookie # Set/clear language cookie
if language := form.cleaned_data['locale.language']: 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: else:
response.delete_cookie(settings.LANGUAGE_COOKIE_NAME) response.delete_cookie(settings.LANGUAGE_COOKIE_NAME)

View File

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

View File

@@ -275,6 +275,17 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
queryset=ProviderNetwork.objects.all(), queryset=ProviderNetwork.objects.all(),
label=_('ProviderNetwork (ID)'), 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: class Meta:
model = CircuitTermination model = CircuitTermination

View File

@@ -3,16 +3,18 @@ from django.utils.translation import gettext_lazy as _
from circuits.choices import CircuitCommitRateChoices, CircuitStatusChoices from circuits.choices import CircuitCommitRateChoices, CircuitStatusChoices
from circuits.models import * from circuits.models import *
from dcim.models import Site
from ipam.models import ASN from ipam.models import ASN
from netbox.forms import NetBoxModelBulkEditForm from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import add_blank_choice from utilities.forms import add_blank_choice
from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
from utilities.forms.rendering import FieldSet from utilities.forms.rendering import FieldSet, TabbedGroups
from utilities.forms.widgets import DatePicker, NumberWithOptions from utilities.forms.widgets import BulkEditNullBooleanSelect, DatePicker, NumberWithOptions
__all__ = ( __all__ = (
'CircuitBulkEditForm', 'CircuitBulkEditForm',
'CircuitTerminationBulkEditForm',
'CircuitTypeBulkEditForm', 'CircuitTypeBulkEditForm',
'ProviderBulkEditForm', 'ProviderBulkEditForm',
'ProviderAccountBulkEditForm', 'ProviderAccountBulkEditForm',
@@ -172,3 +174,48 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
nullable_fields = ( nullable_fields = (
'tenant', 'commit_rate', 'description', 'comments', '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 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.safestring import mark_safe
from django.utils.translation import gettext_lazy as _ 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 netbox.forms import NetBoxModelImportForm
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugField from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugField
@@ -12,6 +12,7 @@ from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugFiel
__all__ = ( __all__ = (
'CircuitImportForm', 'CircuitImportForm',
'CircuitTerminationImportForm', 'CircuitTerminationImportForm',
'CircuitTerminationImportRelatedForm',
'CircuitTypeImportForm', 'CircuitTypeImportForm',
'ProviderImportForm', 'ProviderImportForm',
'ProviderAccountImportForm', 'ProviderAccountImportForm',
@@ -65,9 +66,6 @@ class CircuitTypeImportForm(NetBoxModelImportForm):
class Meta: class Meta:
model = CircuitType model = CircuitType
fields = ('name', 'slug', 'color', 'description', 'tags') fields = ('name', 'slug', 'color', 'description', 'tags')
help_texts = {
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
}
class CircuitImportForm(NetBoxModelImportForm): 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( site = CSVModelChoiceField(
label=_('Site'), label=_('Site'),
queryset=Site.objects.all(), queryset=Site.objects.all(),
@@ -125,9 +132,21 @@ class CircuitTerminationImportForm(forms.ModelForm):
required=False required=False
) )
class CircuitTerminationImportRelatedForm(BaseCircuitTerminationImportForm):
class Meta: class Meta:
model = CircuitTermination model = CircuitTermination
fields = [ fields = [
'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id', '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 import forms
from django.utils.translation import gettext as _ 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 circuits.models import *
from dcim.models import Region, Site, SiteGroup from dcim.models import Region, Site, SiteGroup
from ipam.models import ASN from ipam.models import ASN
@@ -13,6 +13,7 @@ from utilities.forms.widgets import DatePicker, NumberWithOptions
__all__ = ( __all__ = (
'CircuitFilterForm', 'CircuitFilterForm',
'CircuitTerminationFilterForm',
'CircuitTypeFilterForm', 'CircuitTypeFilterForm',
'ProviderFilterForm', 'ProviderFilterForm',
'ProviderAccountFilterForm', 'ProviderAccountFilterForm',
@@ -186,3 +187,46 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
) )
) )
tag = TagFilterField(model) 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}' return f'{self.circuit}: Termination {self.term_side}'
def get_absolute_url(self): def get_absolute_url(self):
return self.circuit.get_absolute_url() return reverse('circuits:circuittermination', args=[self.pk])
def clean(self): def clean(self):
super().clean() super().clean()

View File

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

View File

@@ -10,6 +10,7 @@ from .columns import CommitRateColumn
__all__ = ( __all__ = (
'CircuitTable', 'CircuitTable',
'CircuitTerminationTable',
'CircuitTypeTable', 'CircuitTypeTable',
) )
@@ -62,10 +63,12 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
status = columns.ChoiceFieldColumn() status = columns.ChoiceFieldColumn()
termination_a = tables.TemplateColumn( termination_a = tables.TemplateColumn(
template_code=CIRCUITTERMINATION_LINK, template_code=CIRCUITTERMINATION_LINK,
orderable=False,
verbose_name=_('Side A') verbose_name=_('Side A')
) )
termination_z = tables.TemplateColumn( termination_z = tables.TemplateColumn(
template_code=CIRCUITTERMINATION_LINK, template_code=CIRCUITTERMINATION_LINK,
orderable=False,
verbose_name=_('Side Z') verbose_name=_('Side Z')
) )
commit_rate = CommitRateColumn( commit_rate = CommitRateColumn(
@@ -88,3 +91,31 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
default_columns = ( default_columns = (
'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description', '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 = ( providers = (
Provider(name='Provider 1', slug='provider-1'), 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.objects.bulk_create(providers)
provider_networks = ( provider_networks = (
ProviderNetwork(name='Provider Network 1', provider=providers[0]), ProviderNetwork(name='Provider Network 1', provider=providers[0]),
ProviderNetwork(name='Provider Network 2', provider=providers[0]), ProviderNetwork(name='Provider Network 2', provider=providers[1]),
ProviderNetwork(name='Provider Network 3', provider=providers[0]), ProviderNetwork(name='Provider Network 3', provider=providers[2]),
) )
ProviderNetwork.objects.bulk_create(provider_networks) ProviderNetwork.objects.bulk_create(provider_networks)
circuits = ( circuits = (
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 1'), 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[1], type=circuit_types[0], cid='Circuit 2'),
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 3'), 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 4'),
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 5'), Circuit(provider=providers[1], type=circuit_types[0], cid='Circuit 5'),
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 6'), Circuit(provider=providers[2], type=circuit_types[0], cid='Circuit 6'),
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 7'), Circuit(provider=providers[2], type=circuit_types[0], cid='Circuit 7'),
) )
Circuit.objects.bulk_create(circuits) Circuit.objects.bulk_create(circuits)
@@ -413,10 +415,17 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_circuit_id(self): 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]} params = {'circuit_id': [circuits[0].pk, circuits[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) 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): def test_site(self):
sites = Site.objects.all()[:2] sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]} 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.choices import *
from circuits.models import * from circuits.models import *
from core.models import ObjectType
from dcim.models import Cable, Interface, Site from dcim.models import Cable, Interface, Site
from ipam.models import ASN, RIR 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 from utilities.testing import ViewTestCases, create_tags, create_test_device
@@ -115,6 +118,7 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
Site.objects.create(name='Site 1', slug='site-1')
providers = ( providers = (
Provider(name='Provider 1', slug='provider-1'), Provider(name='Provider 1', slug='provider-1'),
@@ -184,6 +188,51 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'comments': 'New comments', '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): class ProviderAccountTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = ProviderAccount model = ProviderAccount
@@ -287,10 +336,7 @@ class ProviderNetworkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
} }
class CircuitTerminationTestCase( class CircuitTerminationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
ViewTestCases.EditObjectViewTestCase,
ViewTestCases.DeleteObjectViewTestCase,
):
model = CircuitTermination model = CircuitTermination
@classmethod @classmethod
@@ -327,6 +373,24 @@ class CircuitTerminationTestCase(
'description': 'New description', '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=['*']) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_trace(self): def test_trace(self):
device = create_test_device('Device 1') device = create_test_device('Device 1')

View File

@@ -48,7 +48,11 @@ urlpatterns = [
path('circuits/<int:pk>/', include(get_model_urls('circuits', 'circuit'))), path('circuits/<int:pk>/', include(get_model_urls('circuits', 'circuit'))),
# Circuit terminations # 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/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'))), 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 tenancy.views import ObjectContactsView
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.query import count_related 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 . import filtersets, forms, tables
from .models import * from .models import *
@@ -26,17 +26,12 @@ class ProviderListView(generic.ObjectListView):
@register_model_view(Provider) @register_model_view(Provider)
class ProviderView(generic.ObjectView): class ProviderView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Provider.objects.all() queryset = Provider.objects.all()
def get_extra_context(self, request, instance): 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 { 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) @register_model_view(ProviderAccount)
class ProviderAccountView(generic.ObjectView): class ProviderAccountView(GetRelatedModelsMixin, generic.ObjectView):
queryset = ProviderAccount.objects.all() queryset = ProviderAccount.objects.all()
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
related_models = (
(Circuit.objects.restrict(request.user, 'view').filter(provider_account=instance), 'provider_account_id'),
)
return { 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) @register_model_view(ProviderNetwork)
class ProviderNetworkView(generic.ObjectView): class ProviderNetworkView(GetRelatedModelsMixin, generic.ObjectView):
queryset = ProviderNetwork.objects.all() queryset = ProviderNetwork.objects.all()
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
related_models = (
(
Circuit.objects.restrict(request.user, 'view').filter(terminations__provider_network=instance),
'provider_network_id',
),
)
return { 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) @register_model_view(CircuitType)
class CircuitTypeView(generic.ObjectView): class CircuitTypeView(GetRelatedModelsMixin, generic.ObjectView):
queryset = CircuitType.objects.all() queryset = CircuitType.objects.all()
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
related_models = (
(Circuit.objects.restrict(request.user, 'view').filter(type=instance), 'type_id'),
)
return { return {
'related_models': related_models, 'related_models': self.get_related_models(request, instance),
} }
@@ -298,7 +287,7 @@ class CircuitBulkImportView(generic.BulkImportView):
'circuits.add_circuittermination', 'circuits.add_circuittermination',
] ]
related_object_forms = { related_object_forms = {
'terminations': forms.CircuitTerminationImportForm, 'terminations': forms.CircuitTerminationImportRelatedForm,
} }
def prep_related_object_data(self, parent, data): def prep_related_object_data(self, parent, data):
@@ -408,6 +397,18 @@ class CircuitContactsView(ObjectContactsView):
# Circuit terminations # 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') @register_model_view(CircuitTermination, 'edit')
class CircuitTerminationEditView(generic.ObjectEditView): class CircuitTerminationEditView(generic.ObjectEditView):
queryset = CircuitTermination.objects.all() queryset = CircuitTermination.objects.all()
@@ -419,5 +420,23 @@ class CircuitTerminationDeleteView(generic.ObjectDeleteView):
queryset = CircuitTermination.objects.all() 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 # Trace view
register_model_view(CircuitTermination, 'trace', kwargs={'model': CircuitTermination})(PathTraceView) register_model_view(CircuitTermination, 'trace', kwargs={'model': CircuitTermination})(PathTraceView)

View File

@@ -255,3 +255,14 @@ class NetBoxAutoSchema(AutoSchema):
if '{id}' in self.path: if '{id}' in self.path:
return f"{self.method.capitalize()} a {model_name} object." return f"{self.method.capitalize()} a {model_name} object."
return f"{self.method.capitalize()} a list of {model_name} objects." 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.forms import ConfirmationForm
from utilities.htmx import htmx_partial from utilities.htmx import htmx_partial
from utilities.query import count_related 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 . import filtersets, forms, tables
from .models import * from .models import *
@@ -51,16 +51,12 @@ class DataSourceListView(generic.ObjectListView):
@register_model_view(DataSource) @register_model_view(DataSource)
class DataSourceView(generic.ObjectView): class DataSourceView(GetRelatedModelsMixin, generic.ObjectView):
queryset = DataSource.objects.all() queryset = DataSource.objects.all()
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
related_models = (
(DataFile.objects.restrict(request.user, 'view').filter(source=instance), 'source_id'),
)
return { 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: for param in PARAMS:
params.append(( params.append((
param.name, 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) candidate_config.data.get(param.name, None)
)) ))
@@ -559,7 +555,7 @@ class SystemView(UserPassesTestMixin, View):
config = ConfigRevision.objects.get(pk=cache.get('config_version')) config = ConfigRevision.objects.get(pk=cache.get('config_version'))
except ConfigRevision.DoesNotExist: except ConfigRevision.DoesNotExist:
# Fall back to using the active config data if no record is found # 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 # Raw data export
if 'export' in request.GET: if 'export' in request.GET:

View File

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

View File

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

View File

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

View File

@@ -20,7 +20,7 @@ from utilities.filters import (
ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter, ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter,
NumericArrayFilter, TreeNodeMultipleChoiceFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter,
) )
from virtualization.models import Cluster from virtualization.models import Cluster, ClusterGroup
from vpn.models import L2VPN from vpn.models import L2VPN
from wireless.choices import WirelessRoleChoices, WirelessChannelChoices from wireless.choices import WirelessRoleChoices, WirelessChannelChoices
from wireless.models import WirelessLAN, WirelessLink from wireless.models import WirelessLAN, WirelessLink
@@ -1018,6 +1018,17 @@ class DeviceFilterSet(
queryset=Cluster.objects.all(), queryset=Cluster.objects.all(),
label=_('VM cluster (ID)'), 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( model = django_filters.ModelMultipleChoiceFilter(
field_name='device_type__slug', field_name='device_type__slug',
queryset=DeviceType.objects.all(), queryset=DeviceType.objects.all(),
@@ -1100,6 +1111,10 @@ class DeviceFilterSet(
queryset=IPAddress.objects.all(), queryset=IPAddress.objects.all(),
label=_('OOB IP (ID)'), label=_('OOB IP (ID)'),
) )
has_virtual_device_context = django_filters.BooleanFilter(
method='_has_virtual_device_context',
label=_('Has virtual device context'),
)
class Meta: class Meta:
model = Device model = Device
@@ -1176,6 +1191,12 @@ class DeviceFilterSet(
def _device_bays(self, queryset, name, value): def _device_bays(self, queryset, name, value):
return queryset.exclude(devicebays__isnull=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): class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet, PrimaryIPFilterSet):
device_id = django_filters.ModelMultipleChoiceFilter( device_id = django_filters.ModelMultipleChoiceFilter(

View File

@@ -174,9 +174,6 @@ class RackRoleImportForm(NetBoxModelImportForm):
class Meta: class Meta:
model = RackRole model = RackRole
fields = ('name', 'slug', 'color', 'description', 'tags') fields = ('name', 'slug', 'color', 'description', 'tags')
help_texts = {
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
}
class RackImportForm(NetBoxModelImportForm): class RackImportForm(NetBoxModelImportForm):
@@ -384,9 +381,6 @@ class DeviceRoleImportForm(NetBoxModelImportForm):
class Meta: class Meta:
model = DeviceRole model = DeviceRole
fields = ('name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags') 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): class PlatformImportForm(NetBoxModelImportForm):
@@ -1052,7 +1046,7 @@ class InventoryItemImportForm(NetBoxModelImportForm):
class Meta: class Meta:
model = InventoryItem model = InventoryItem
fields = ( 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', 'description', 'tags', 'component_type', 'component_name',
) )
@@ -1104,9 +1098,6 @@ class InventoryItemRoleImportForm(NetBoxModelImportForm):
class Meta: class Meta:
model = InventoryItemRole model = InventoryItemRole
fields = ('name', 'slug', 'color', 'description') 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', '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', '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): 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.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField
from utilities.forms.rendering import FieldSet from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import NumberWithOptions from utilities.forms.widgets import NumberWithOptions
from virtualization.models import Cluster, ClusterGroup
from vpn.models import L2VPN from vpn.models import L2VPN
from wireless.choices import * from wireless.choices import *
@@ -655,8 +656,10 @@ class DeviceFilterForm(
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports', 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports',
name=_('Components') name=_('Components')
), ),
FieldSet('cluster_group_id', 'cluster_id', name=_('Cluster')),
FieldSet( FieldSet(
'has_primary_ip', 'has_oob_ip', 'virtual_chassis_member', 'config_template_id', 'local_context_data', 'has_primary_ip', 'has_oob_ip', 'virtual_chassis_member', 'config_template_id', 'local_context_data',
'has_virtual_device_context',
name=_('Miscellaneous') name=_('Miscellaneous')
) )
) )
@@ -813,6 +816,23 @@ class DeviceFilterForm(
choices=BOOLEAN_WITH_BLANK_CHOICES 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) tag = TagFilterField(model)

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import django_tables2 as tables import django_tables2 as tables
from django_tables2.utils import Accessor from django_tables2.utils import Accessor
from django.utils.html import escape
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from dcim.models import Cable from dcim.models import Cable
@@ -35,7 +36,7 @@ class CableTerminationsColumn(tables.Column):
def render(self, value): def render(self, value):
links = [ 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;') 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 # Device roles
# #
@@ -313,6 +305,10 @@ class ModularDeviceComponentTable(DeviceComponentTable):
verbose_name=_('Module'), verbose_name=_('Module'),
linkify=True linkify=True
) )
inventory_items = columns.ManyToManyColumn(
linkify_item=True,
verbose_name=_('Inventory Items'),
)
class CableTerminationTable(NetBoxTable): class CableTerminationTable(NetBoxTable):
@@ -335,6 +331,14 @@ class CableTerminationTable(NetBoxTable):
verbose_name=_('Mark Connected'), 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): def value_link_peer(self, value):
return ', '.join([ return ', '.join([
f"{termination.parent_object} > {termination}" for termination in value f"{termination.parent_object} > {termination}" for termination in value
@@ -366,7 +370,7 @@ class ConsolePortTable(ModularDeviceComponentTable, PathEndpointTable):
model = models.ConsolePort model = models.ConsolePort
fields = ( fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'speed', 'description', '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') default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description')
@@ -382,16 +386,13 @@ class DeviceConsolePortTable(ConsolePortTable):
extra_buttons=CONSOLEPORT_BUTTONS extra_buttons=CONSOLEPORT_BUTTONS
) )
class Meta(DeviceComponentTable.Meta): class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
model = models.ConsolePort model = models.ConsolePort
fields = ( fields = (
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected', 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected',
'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions' 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions'
) )
default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection') default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection')
row_attrs = {
'class': get_cabletermination_row_class
}
class ConsoleServerPortTable(ModularDeviceComponentTable, PathEndpointTable): class ConsoleServerPortTable(ModularDeviceComponentTable, PathEndpointTable):
@@ -410,7 +411,7 @@ class ConsoleServerPortTable(ModularDeviceComponentTable, PathEndpointTable):
model = models.ConsoleServerPort model = models.ConsoleServerPort
fields = ( fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'speed', 'description', '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') default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description')
@@ -427,16 +428,13 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable):
extra_buttons=CONSOLESERVERPORT_BUTTONS extra_buttons=CONSOLESERVERPORT_BUTTONS
) )
class Meta(DeviceComponentTable.Meta): class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
model = models.ConsoleServerPort model = models.ConsoleServerPort
fields = ( fields = (
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected', 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected',
'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions',
) )
default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection') default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection')
row_attrs = {
'class': get_cabletermination_row_class
}
class PowerPortTable(ModularDeviceComponentTable, PathEndpointTable): class PowerPortTable(ModularDeviceComponentTable, PathEndpointTable):
@@ -461,8 +459,8 @@ class PowerPortTable(ModularDeviceComponentTable, PathEndpointTable):
model = models.PowerPort model = models.PowerPort
fields = ( fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'mark_connected', 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'mark_connected',
'maximum_draw', 'allocated_draw', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', 'maximum_draw', 'allocated_draw', 'cable', 'cable_color', 'link_peer', 'connection', 'inventory_items',
'last_updated', 'tags', 'created', 'last_updated',
) )
default_columns = ('pk', 'name', 'device', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description') default_columns = ('pk', 'name', 'device', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description')
@@ -479,7 +477,7 @@ class DevicePowerPortTable(PowerPortTable):
extra_buttons=POWERPORT_BUTTONS extra_buttons=POWERPORT_BUTTONS
) )
class Meta(DeviceComponentTable.Meta): class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
model = models.PowerPort model = models.PowerPort
fields = ( fields = (
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'maximum_draw', 'allocated_draw', 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'maximum_draw', 'allocated_draw',
@@ -488,9 +486,6 @@ class DevicePowerPortTable(PowerPortTable):
default_columns = ( default_columns = (
'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'connection', 'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'connection',
) )
row_attrs = {
'class': get_cabletermination_row_class
}
class PowerOutletTable(ModularDeviceComponentTable, PathEndpointTable): class PowerOutletTable(ModularDeviceComponentTable, PathEndpointTable):
@@ -513,8 +508,8 @@ class PowerOutletTable(ModularDeviceComponentTable, PathEndpointTable):
model = models.PowerOutlet model = models.PowerOutlet
fields = ( fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'power_port', 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'power_port',
'feed_leg', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', 'feed_leg', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'inventory_items',
'last_updated', 'tags', 'created', 'last_updated',
) )
default_columns = ('pk', 'name', 'device', 'label', 'type', 'power_port', 'feed_leg', 'description') default_columns = ('pk', 'name', 'device', 'label', 'type', 'power_port', 'feed_leg', 'description')
@@ -530,7 +525,7 @@ class DevicePowerOutletTable(PowerOutletTable):
extra_buttons=POWEROUTLET_BUTTONS extra_buttons=POWEROUTLET_BUTTONS
) )
class Meta(DeviceComponentTable.Meta): class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
model = models.PowerOutlet model = models.PowerOutlet
fields = ( fields = (
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'power_port', 'feed_leg', 'description', 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'power_port', 'feed_leg', 'description',
@@ -539,9 +534,6 @@ class DevicePowerOutletTable(PowerOutletTable):
default_columns = ( default_columns = (
'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'connection', 'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'connection',
) )
row_attrs = {
'class': get_cabletermination_row_class
}
class BaseInterfaceTable(NetBoxTable): class BaseInterfaceTable(NetBoxTable):
@@ -618,10 +610,6 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
verbose_name=_('VRF'), verbose_name=_('VRF'),
linkify=True linkify=True
) )
inventory_items = columns.ManyToManyColumn(
linkify_item=True,
verbose_name=_('Inventory Items'),
)
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='dcim:interface_list' url_name='dcim:interface_list'
) )
@@ -713,8 +701,8 @@ class FrontPortTable(ModularDeviceComponentTable, CableTerminationTable):
model = models.FrontPort model = models.FrontPort
fields = ( fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'rear_port', 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'rear_port',
'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', 'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer',
'created', 'last_updated', 'inventory_items', 'tags', 'created', 'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', 'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
@@ -733,7 +721,7 @@ class DeviceFrontPortTable(FrontPortTable):
extra_buttons=FRONTPORT_BUTTONS extra_buttons=FRONTPORT_BUTTONS
) )
class Meta(DeviceComponentTable.Meta): class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
model = models.FrontPort model = models.FrontPort
fields = ( fields = (
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'rear_port', 'rear_port_position', 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'rear_port', 'rear_port_position',
@@ -742,9 +730,6 @@ class DeviceFrontPortTable(FrontPortTable):
default_columns = ( default_columns = (
'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'link_peer', 'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'link_peer',
) )
row_attrs = {
'class': get_cabletermination_row_class
}
class RearPortTable(ModularDeviceComponentTable, CableTerminationTable): class RearPortTable(ModularDeviceComponentTable, CableTerminationTable):
@@ -766,7 +751,7 @@ class RearPortTable(ModularDeviceComponentTable, CableTerminationTable):
model = models.RearPort model = models.RearPort
fields = ( fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'positions', 'description', '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') default_columns = ('pk', 'name', 'device', 'label', 'type', 'color', 'description')
@@ -783,7 +768,7 @@ class DeviceRearPortTable(RearPortTable):
extra_buttons=REARPORT_BUTTONS extra_buttons=REARPORT_BUTTONS
) )
class Meta(DeviceComponentTable.Meta): class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
model = models.RearPort model = models.RearPort
fields = ( fields = (
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'positions', 'description', 'mark_connected', 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'positions', 'description', 'mark_connected',
@@ -792,9 +777,6 @@ class DeviceRearPortTable(RearPortTable):
default_columns = ( default_columns = (
'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'link_peer', 'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'link_peer',
) )
row_attrs = {
'class': get_cabletermination_row_class
}
class DeviceBayTable(DeviceComponentTable): class DeviceBayTable(DeviceComponentTable):

View File

@@ -9,7 +9,7 @@ from ipam.models import ASN, IPAddress, RIR, VRF
from netbox.choices import ColorChoices from netbox.choices import ColorChoices
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from utilities.testing import ChangeLoggedFilterSetTests, create_test_device 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 from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
User = get_user_model() User = get_user_model()
@@ -1959,10 +1959,16 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks) Rack.objects.bulk_create(racks)
cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') 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 = ( clusters = (
Cluster(name='Cluster 1', type=cluster_type), Cluster(name='Cluster 1', type=cluster_type, group=cluster_groups[0]),
Cluster(name='Cluster 2', type=cluster_type), Cluster(name='Cluster 2', type=cluster_type, group=cluster_groups[1]),
Cluster(name='Cluster 3', type=cluster_type), Cluster(name='Cluster 3', type=cluster_type, group=cluster_groups[2]),
) )
Cluster.objects.bulk_create(clusters) 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[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) 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): def test_q(self):
params = {'q': 'foobar1'} params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) 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]} params = {'cluster_id': [clusters[0].pk, clusters[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) 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): def test_model(self):
params = {'model': ['model-1', 'model-2']} params = {'model': ['model-1', 'model-2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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]} params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) 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): class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = Module.objects.all() queryset = Module.objects.all()

View File

@@ -8,6 +8,7 @@ from dcim.models import *
from extras.models import CustomField from extras.models import CustomField
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.data import drange from utilities.data import drange
from virtualization.models import Cluster, ClusterType
class LocationTestCase(TestCase): class LocationTestCase(TestCase):
@@ -533,6 +534,36 @@ class DeviceTestCase(TestCase):
device2.full_clean() device2.full_clean()
device2.save() 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): class CableTestCase(TestCase):

View File

@@ -17,7 +17,7 @@ from jinja2.exceptions import TemplateError
from circuits.models import Circuit, CircuitTermination from circuits.models import Circuit, CircuitTermination
from extras.views import ObjectConfigContextView 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 ipam.tables import InterfaceVLANTable
from netbox.constants import DEFAULT_ACTION_PERMISSIONS from netbox.constants import DEFAULT_ACTION_PERMISSIONS
from netbox.views import generic 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.permissions import get_permission_for_model
from utilities.query import count_related from utilities.query import count_related
from utilities.query_functions import CollateAsChar 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.filtersets import VirtualMachineFilterSet
from virtualization.forms import VirtualMachineFilterForm
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
from virtualization.tables import VirtualMachineTable from virtualization.tables import VirtualMachineTable
from . import filtersets, forms, tables from . import filtersets, forms, tables
@@ -226,19 +229,21 @@ class RegionListView(generic.ObjectListView):
@register_model_view(Region) @register_model_view(Region)
class RegionView(generic.ObjectView): class RegionView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Region.objects.all() queryset = Region.objects.all()
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
regions = instance.get_descendants(include_self=True) 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 { 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) @register_model_view(SiteGroup)
class SiteGroupView(generic.ObjectView): class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView):
queryset = SiteGroup.objects.all() queryset = SiteGroup.objects.all()
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
groups = instance.get_descendants(include_self=True) 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 { 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) @register_model_view(Site)
class SiteView(generic.ObjectView): class SiteView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Site.objects.prefetch_related('tenant__group') queryset = Site.objects.prefetch_related('tenant__group')
def get_extra_context(self, request, instance): 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 { 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) @register_model_view(Location)
class LocationView(generic.ObjectView): class LocationView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Location.objects.all() queryset = Location.objects.all()
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
locations = instance.get_descendants(include_self=True) 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 { 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) @register_model_view(RackRole)
class RackRoleView(generic.ObjectView): class RackRoleView(GetRelatedModelsMixin, generic.ObjectView):
queryset = RackRole.objects.all() queryset = RackRole.objects.all()
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
related_models = (
(Rack.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'),
)
return { 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) @register_model_view(Rack)
class RackView(generic.ObjectView): class RackView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'location', 'role') queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'location', 'role')
def get_extra_context(self, request, instance): 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) peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=instance.site)
if instance.location: if instance.location:
@@ -679,7 +666,7 @@ class RackView(generic.ObjectView):
]) ])
return { return {
'related_models': related_models, 'related_models': self.get_related_models(request, instance, [CableTermination]),
'next_rack': next_rack, 'next_rack': next_rack,
'prev_rack': prev_rack, 'prev_rack': prev_rack,
'svg_extra': svg_extra, 'svg_extra': svg_extra,
@@ -693,6 +680,7 @@ class RackRackReservationsView(generic.ObjectChildrenView):
child_model = RackReservation child_model = RackReservation
table = tables.RackReservationTable table = tables.RackReservationTable
filterset = filtersets.RackReservationFilterSet filterset = filtersets.RackReservationFilterSet
filterset_form = forms.RackReservationFilterForm
template_name = 'dcim/rack/reservations.html' template_name = 'dcim/rack/reservations.html'
tab = ViewTab( tab = ViewTab(
label=_('Reservations'), label=_('Reservations'),
@@ -711,6 +699,7 @@ class RackNonRackedView(generic.ObjectChildrenView):
child_model = Device child_model = Device
table = tables.DeviceTable table = tables.DeviceTable
filterset = filtersets.DeviceFilterSet filterset = filtersets.DeviceFilterSet
filterset_form = forms.DeviceFilterForm
template_name = 'dcim/rack/non_racked_devices.html' template_name = 'dcim/rack/non_racked_devices.html'
tab = ViewTab( tab = ViewTab(
label=_('Non-Racked Devices'), label=_('Non-Racked Devices'),
@@ -838,19 +827,12 @@ class ManufacturerListView(generic.ObjectListView):
@register_model_view(Manufacturer) @register_model_view(Manufacturer)
class ManufacturerView(generic.ObjectView): class ManufacturerView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Manufacturer.objects.all() queryset = Manufacturer.objects.all()
def get_extra_context(self, request, instance): 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 { 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) @register_model_view(DeviceType)
class DeviceTypeView(generic.ObjectView): class DeviceTypeView(GetRelatedModelsMixin, generic.ObjectView):
queryset = DeviceType.objects.all() queryset = DeviceType.objects.all()
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
related_models = (
(Device.objects.restrict(request.user).filter(device_type=instance), 'device_type_id'),
)
return { 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) @register_model_view(ModuleType)
class ModuleTypeView(generic.ObjectView): class ModuleTypeView(GetRelatedModelsMixin, generic.ObjectView):
queryset = ModuleType.objects.all() queryset = ModuleType.objects.all()
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
related_models = (
(Module.objects.restrict(request.user).filter(module_type=instance), 'module_type_id'),
)
return { 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) @register_model_view(DeviceRole)
class DeviceRoleView(generic.ObjectView): class DeviceRoleView(GetRelatedModelsMixin, generic.ObjectView):
queryset = DeviceRole.objects.all() queryset = DeviceRole.objects.all()
def get_extra_context(self, request, instance): 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 { 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) @register_model_view(Platform)
class PlatformView(generic.ObjectView): class PlatformView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Platform.objects.all() queryset = Platform.objects.all()
def get_extra_context(self, request, instance): 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 { return {
'related_models': related_models, 'related_models': self.get_related_models(request, instance),
} }
@@ -1866,6 +1838,7 @@ class DeviceConsolePortsView(DeviceComponentsView):
child_model = ConsolePort child_model = ConsolePort
table = tables.DeviceConsolePortTable table = tables.DeviceConsolePortTable
filterset = filtersets.ConsolePortFilterSet filterset = filtersets.ConsolePortFilterSet
filterset_form = forms.ConsolePortFilterForm
template_name = 'dcim/device/consoleports.html', template_name = 'dcim/device/consoleports.html',
tab = ViewTab( tab = ViewTab(
label=_('Console Ports'), label=_('Console Ports'),
@@ -1881,6 +1854,7 @@ class DeviceConsoleServerPortsView(DeviceComponentsView):
child_model = ConsoleServerPort child_model = ConsoleServerPort
table = tables.DeviceConsoleServerPortTable table = tables.DeviceConsoleServerPortTable
filterset = filtersets.ConsoleServerPortFilterSet filterset = filtersets.ConsoleServerPortFilterSet
filterset_form = forms.ConsoleServerPortFilterForm
template_name = 'dcim/device/consoleserverports.html' template_name = 'dcim/device/consoleserverports.html'
tab = ViewTab( tab = ViewTab(
label=_('Console Server Ports'), label=_('Console Server Ports'),
@@ -1896,6 +1870,7 @@ class DevicePowerPortsView(DeviceComponentsView):
child_model = PowerPort child_model = PowerPort
table = tables.DevicePowerPortTable table = tables.DevicePowerPortTable
filterset = filtersets.PowerPortFilterSet filterset = filtersets.PowerPortFilterSet
filterset_form = forms.PowerPortFilterForm
template_name = 'dcim/device/powerports.html' template_name = 'dcim/device/powerports.html'
tab = ViewTab( tab = ViewTab(
label=_('Power Ports'), label=_('Power Ports'),
@@ -1911,6 +1886,7 @@ class DevicePowerOutletsView(DeviceComponentsView):
child_model = PowerOutlet child_model = PowerOutlet
table = tables.DevicePowerOutletTable table = tables.DevicePowerOutletTable
filterset = filtersets.PowerOutletFilterSet filterset = filtersets.PowerOutletFilterSet
filterset_form = forms.PowerOutletFilterForm
template_name = 'dcim/device/poweroutlets.html' template_name = 'dcim/device/poweroutlets.html'
tab = ViewTab( tab = ViewTab(
label=_('Power Outlets'), label=_('Power Outlets'),
@@ -1926,6 +1902,7 @@ class DeviceInterfacesView(DeviceComponentsView):
child_model = Interface child_model = Interface
table = tables.DeviceInterfaceTable table = tables.DeviceInterfaceTable
filterset = filtersets.InterfaceFilterSet filterset = filtersets.InterfaceFilterSet
filterset_form = forms.InterfaceFilterForm
template_name = 'dcim/device/interfaces.html' template_name = 'dcim/device/interfaces.html'
tab = ViewTab( tab = ViewTab(
label=_('Interfaces'), label=_('Interfaces'),
@@ -1947,6 +1924,7 @@ class DeviceFrontPortsView(DeviceComponentsView):
child_model = FrontPort child_model = FrontPort
table = tables.DeviceFrontPortTable table = tables.DeviceFrontPortTable
filterset = filtersets.FrontPortFilterSet filterset = filtersets.FrontPortFilterSet
filterset_form = forms.FrontPortFilterForm
template_name = 'dcim/device/frontports.html' template_name = 'dcim/device/frontports.html'
tab = ViewTab( tab = ViewTab(
label=_('Front Ports'), label=_('Front Ports'),
@@ -1962,6 +1940,7 @@ class DeviceRearPortsView(DeviceComponentsView):
child_model = RearPort child_model = RearPort
table = tables.DeviceRearPortTable table = tables.DeviceRearPortTable
filterset = filtersets.RearPortFilterSet filterset = filtersets.RearPortFilterSet
filterset_form = forms.RearPortFilterForm
template_name = 'dcim/device/rearports.html' template_name = 'dcim/device/rearports.html'
tab = ViewTab( tab = ViewTab(
label=_('Rear Ports'), label=_('Rear Ports'),
@@ -1977,6 +1956,7 @@ class DeviceModuleBaysView(DeviceComponentsView):
child_model = ModuleBay child_model = ModuleBay
table = tables.DeviceModuleBayTable table = tables.DeviceModuleBayTable
filterset = filtersets.ModuleBayFilterSet filterset = filtersets.ModuleBayFilterSet
filterset_form = forms.ModuleBayFilterForm
template_name = 'dcim/device/modulebays.html' template_name = 'dcim/device/modulebays.html'
actions = { actions = {
**DEFAULT_ACTION_PERMISSIONS, **DEFAULT_ACTION_PERMISSIONS,
@@ -1996,6 +1976,7 @@ class DeviceDeviceBaysView(DeviceComponentsView):
child_model = DeviceBay child_model = DeviceBay
table = tables.DeviceDeviceBayTable table = tables.DeviceDeviceBayTable
filterset = filtersets.DeviceBayFilterSet filterset = filtersets.DeviceBayFilterSet
filterset_form = forms.DeviceBayFilterForm
template_name = 'dcim/device/devicebays.html' template_name = 'dcim/device/devicebays.html'
actions = { actions = {
**DEFAULT_ACTION_PERMISSIONS, **DEFAULT_ACTION_PERMISSIONS,
@@ -2015,6 +1996,7 @@ class DeviceInventoryView(DeviceComponentsView):
child_model = InventoryItem child_model = InventoryItem
table = tables.DeviceInventoryItemTable table = tables.DeviceInventoryItemTable
filterset = filtersets.InventoryItemFilterSet filterset = filtersets.InventoryItemFilterSet
filterset_form = forms.InventoryItemFilterForm
template_name = 'dcim/device/inventory.html' template_name = 'dcim/device/inventory.html'
actions = { actions = {
**DEFAULT_ACTION_PERMISSIONS, **DEFAULT_ACTION_PERMISSIONS,
@@ -2093,7 +2075,7 @@ class DeviceVirtualMachinesView(generic.ObjectChildrenView):
child_model = VirtualMachine child_model = VirtualMachine
table = VirtualMachineTable table = VirtualMachineTable
filterset = VirtualMachineFilterSet filterset = VirtualMachineFilterSet
template_name = 'generic/object_children.html' filterset_form = VirtualMachineFilterForm
tab = ViewTab( tab = ViewTab(
label=_('Virtual Machines'), label=_('Virtual Machines'),
badge=lambda obj: VirtualMachine.objects.filter(cluster=obj.cluster, device=obj).count(), badge=lambda obj: VirtualMachine.objects.filter(cluster=obj.cluster, device=obj).count(),
@@ -2158,22 +2140,12 @@ class ModuleListView(generic.ObjectListView):
@register_model_view(Module) @register_model_view(Module)
class ModuleView(generic.ObjectView): class ModuleView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Module.objects.all() queryset = Module.objects.all()
def get_extra_context(self, request, instance): 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 { return {
'related_models': related_models, 'related_models': self.get_related_models(request, instance),
} }
@@ -2986,7 +2958,7 @@ class InventoryItemChildrenView(generic.ObjectChildrenView):
child_model = InventoryItem child_model = InventoryItem
table = tables.InventoryItemTable table = tables.InventoryItemTable
filterset = filtersets.InventoryItemFilterSet filterset = filtersets.InventoryItemFilterSet
template_name = 'generic/object_children.html' filterset_form = forms.InventoryItemFilterForm
tab = ViewTab( tab = ViewTab(
label=_('Children'), label=_('Children'),
badge=lambda obj: obj.child_items.count(), badge=lambda obj: obj.child_items.count(),
@@ -3453,8 +3425,9 @@ class VirtualChassisAddMemberView(ObjectPermissionRequiredMixin, GetReturnURLMix
if membership_form.is_valid(): if membership_form.is_valid():
membership_form.save() membership_form.save()
msg = f'Added member <a href="{device.get_absolute_url()}">{escape(device)}</a>' messages.success(request, mark_safe(
messages.success(request, mark_safe(msg)) f'Added member <a href="{device.get_absolute_url()}">{escape(device)}</a>'
))
if '_addanother' in request.POST: if '_addanother' in request.POST:
return redirect(request.get_full_path()) return redirect(request.get_full_path())
@@ -3554,16 +3527,12 @@ class PowerPanelListView(generic.ObjectListView):
@register_model_view(PowerPanel) @register_model_view(PowerPanel)
class PowerPanelView(generic.ObjectView): class PowerPanelView(GetRelatedModelsMixin, generic.ObjectView):
queryset = PowerPanel.objects.all() queryset = PowerPanel.objects.all()
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
related_models = (
(PowerFeed.objects.restrict(request.user).filter(power_panel=instance), 'power_panel_id'),
)
return { 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) @register_model_view(VirtualDeviceContext)
class VirtualDeviceContextView(generic.ObjectView): class VirtualDeviceContextView(GetRelatedModelsMixin, generic.ObjectView):
queryset = VirtualDeviceContext.objects.all() queryset = VirtualDeviceContext.objects.all()
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
related_models = (
(Interface.objects.restrict(request.user, 'view').filter(vdcs__in=[instance]), 'vdc_id'),
)
return { 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( changed_object = serializers.SerializerMethodField(
read_only=True 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: class Meta:
model = ObjectChange model = ObjectChange

View File

@@ -43,7 +43,7 @@ class JournalEntrySerializer(NetBoxModelSerializer):
def validate(self, data): def validate(self, data):
# Validate that the parent object exists # 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: try:
data['assigned_object_type'].get_object_for_this_type(id=data['assigned_object_id']) data['assigned_object_type'].get_object_for_this_type(id=data['assigned_object_id'])
except ObjectDoesNotExist: except ObjectDoesNotExist:
@@ -51,10 +51,7 @@ class JournalEntrySerializer(NetBoxModelSerializer):
f"Invalid assigned_object: {data['assigned_object_type']} ID {data['assigned_object_id']}" f"Invalid assigned_object: {data['assigned_object_type']} ID {data['assigned_object_id']}"
) )
# Enforce model validation return super().validate(data)
super().validate(data)
return data
@extend_schema_field(serializers.JSONField(allow_null=True)) @extend_schema_field(serializers.JSONField(allow_null=True))
def get_assigned_object(self, instance): 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.shortcuts import get_object_or_404
from django_rq.queues import get_connection from django_rq.queues import get_connection
from rest_framework import status from rest_framework import status
@@ -215,21 +216,32 @@ class ScriptViewSet(ModelViewSet):
_ignore_model_permissions = True _ignore_model_permissions = True
lookup_value_regex = '[^/]+' # Allow dots 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): 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}) serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
return Response(serializer.data) return Response(serializer.data)
def post(self, request, pk): 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'): if not request.user.has_perm('extras.run_script'):
raise PermissionDenied("This user does not have permission to run scripts.") 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( input_serializer = serializers.ScriptInputSerializer(
data=request.data, data=request.data,
context={'script': script} context={'script': script}
@@ -240,9 +252,9 @@ class ScriptViewSet(ModelViewSet):
raise RQWorkerNotRunningException() raise RQWorkerNotRunningException()
if input_serializer.is_valid(): if input_serializer.is_valid():
script.result = Job.enqueue( Job.enqueue(
run_script, run_script,
instance=script.module, instance=script,
name=script.python_class.class_name, name=script.python_class.class_name,
user=request.user, user=request.user,
data=input_serializer.data['data'], data=input_serializer.data['data'],

View File

@@ -117,10 +117,14 @@ class BookmarkOrderingChoices(ChoiceSet):
ORDERING_NEWEST = '-created' ORDERING_NEWEST = '-created'
ORDERING_OLDEST = 'created' ORDERING_OLDEST = 'created'
ORDERING_ALPHABETICAL_AZ = 'name'
ORDERING_ALPHABETICAL_ZA = '-name'
CHOICES = ( CHOICES = (
(ORDERING_NEWEST, _('Newest')), (ORDERING_NEWEST, _('Newest')),
(ORDERING_OLDEST, _('Oldest')), (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): def __init__(self, ruleset):
if type(ruleset) is not dict: if type(ruleset) is not dict:
raise ValueError(_("Ruleset must be a dictionary, not {ruleset}.").format(ruleset=type(ruleset))) 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 if len(ruleset) == 1:
logic = list(ruleset.keys())[0] self.logic = (list(ruleset.keys())[0]).lower()
if type(logic) is not str or logic.lower() not in (AND, OR): if self.logic not in (AND, OR):
raise ValueError(_("Invalid logic type: {logic} (must be '{op_and}' or '{op_or}')").format( raise ValueError(_("Invalid logic type: must be 'AND' or 'OR'. Please check documentation."))
logic=logic, op_and=AND, op_or=OR
))
self.logic = logic.lower()
# Compile the set of Conditions # Compile the set of Conditions
self.conditions = [ self.conditions = [
ConditionSet(rule) if is_ruleset(rule) else Condition(**rule) ConditionSet(rule) if is_ruleset(rule) else Condition(**rule)
for rule in ruleset[self.logic] 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): def eval(self, data):
""" """

View File

@@ -13,13 +13,14 @@ def event_tracking(request):
:param request: WSGIRequest object with a unique `id` set :param request: WSGIRequest object with a unique `id` set
""" """
current_request.set(request) current_request.set(request)
events_queue.set([]) events_queue.set({})
yield yield
# Flush queued webhooks to RQ # Flush queued webhooks to RQ
flush_events(events_queue.get()) if events := list(events_queue.get().values()):
flush_events(events)
# Clear context vars # Clear context vars
current_request.set(None) 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 {} parameters = self.config.get('url_params') or {}
if page_size := self.config.get('page_size'): if page_size := self.config.get('page_size'):
parameters['per_page'] = page_size parameters['per_page'] = page_size
parameters['embedded'] = True
if parameters: if parameters:
try: try:
@@ -380,11 +381,17 @@ class BookmarksWidget(DashboardWidget):
if request.user.is_anonymous: if request.user.is_anonymous:
bookmarks = list() bookmarks = list()
else: 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'): if object_types := self.config.get('object_types'):
models = get_models_from_content_types(object_types) models = get_models_from_content_types(object_types)
conent_types = ObjectType.objects.get_for_models(*models).values() content_types = ObjectType.objects.get_for_models(*models).values()
bookmarks = bookmarks.filter(object_type__in=conent_types) 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'): if max_items := self.config.get('max_items'):
bookmarks = bookmarks[: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, []): if model_name not in registry['model_features']['event_rules'].get(app_label, []):
return return
queue.append({ assert instance.pk is not None
'content_type': ContentType.objects.get_for_model(instance), key = f'{app_label}.{model_name}:{instance.pk}'
'object_id': instance.pk, if key in queue:
'event': action, queue[key]['data'] = serialize_for_event(instance)
'data': serialize_for_event(instance), queue[key]['snapshots']['postchange'] = get_snapshots(instance, action)['postchange']
'snapshots': get_snapshots(instance, action), # If the object is being deleted, update any prior "update" event to "delete"
'username': user.username, if action == ObjectChangeActionChoices.ACTION_DELETE:
'request_id': request_id 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): 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: for name in settings.EVENTS_PIPELINE:
try: try:
func = import_string(name) func = import_string(name)
func(queue) func(events)
except Exception as e: except Exception as e:
logger.error(_("Cannot import events pipeline {name} error: {error}").format(name=name, error=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: class Meta:
model = Tag model = Tag
fields = ('name', 'slug', 'color', 'description') fields = ('name', 'slug', 'color', 'description')
help_texts = {
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
}
class JournalEntryImportForm(NetBoxModelImportForm): class JournalEntryImportForm(NetBoxModelImportForm):

View File

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

View File

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

View File

@@ -66,11 +66,16 @@ class Command(BaseCommand):
raise CommandError(_("No indexers found!")) raise CommandError(_("No indexers found!"))
self.stdout.write(f'Reindexing {len(indexers)} models.') 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 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.write('Clearing cached values... ', ending='')
self.stdout.flush() 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.') self.stdout.write(f'{deleted_count} entries deleted.')
# Index models # Index models

View File

@@ -1,12 +1,17 @@
from functools import cached_property
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from mptt.models import MPTTModel
from core.models import ObjectType from core.models import ObjectType
from extras.choices import * from extras.choices import *
from netbox.models.features import ChangeLoggingMixin
from utilities.data import shallow_compare_dict
from ..querysets import ObjectChangeQuerySet from ..querysets import ObjectChangeQuerySet
__all__ = ( __all__ = (
@@ -136,6 +141,71 @@ class ObjectChange(models.Model):
def get_action_color(self): def get_action_color(self):
return ObjectChangeActionChoices.colors.get(self.action) return ObjectChangeActionChoices.colors.get(self.action)
@property @cached_property
def has_changes(self): def has_changes(self):
return self.prechange_data != self.postchange_data 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.core.validators import RegexValidator, ValidationError
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.html import escape
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -489,7 +490,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
# JSON # JSON
elif self.type == CustomFieldTypeChoices.TYPE_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 # Object
elif self.type == CustomFieldTypeChoices.TYPE_OBJECT: elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:
@@ -520,7 +521,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
RegexValidator( RegexValidator(
regex=self.validation_regex, regex=self.validation_regex,
message=mark_safe(_("Values must match this regex: <code>{regex}</code>").format( 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 # Validate date & time
elif self.type == CustomFieldTypeChoices.TYPE_DATETIME: elif self.type == CustomFieldTypeChoices.TYPE_DATETIME:
if type(value) is not 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: try:
datetime.fromisoformat(value) datetime.fromisoformat(value)
except ValueError: except ValueError:

View File

@@ -96,6 +96,7 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
Proxy model for script module files. Proxy model for script module files.
""" """
objects = ScriptModuleManager() objects = ScriptModuleManager()
error = None
event_rules = GenericRelation( event_rules = GenericRelation(
to='extras.EventRule', to='extras.EventRule',
@@ -126,6 +127,7 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
try: try:
module = self.get_module() module = self.get_module()
except Exception as e: except Exception as e:
self.error = e
logger.debug(f"Failed to load script: {self.python_name} error: {e}") logger.debug(f"Failed to load script: {self.python_name} error: {e}")
module = None 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.contrib.contenttypes.fields import GenericForeignKey
from django.db import models, transaction from django.db import models, transaction
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from mptt.models import MPTTModel
from extras.choices import ChangeActionChoices from extras.choices import ChangeActionChoices
from netbox.models import ChangeLoggedModel from netbox.models import ChangeLoggedModel
@@ -124,6 +125,11 @@ class StagedChange(CustomValidationMixin, EventRulesMixin, models.Model):
instance = self.model.objects.get(pk=self.object_id) instance = self.model.objects.get(pk=self.object_id)
logger.info(f'Deleting {self.model._meta.verbose_name} {instance}') logger.info(f'Deleting {self.model._meta.verbose_name} {instance}')
instance.delete() instance.delete()
# Rebuild the MPTT tree where applicable
if issubclass(self.model, MPTTModel):
self.model.objects.rebuild()
apply.alters_data = True apply.alters_data = True
def get_action_color(self): 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 # A test method is currently active, so log the message using legacy Report logging
if self._current_test: 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 # Increment the event counter for this level
if level in self.tests[self._current_test]: if level in self.tests[self._current_test]:
self.tests[self._current_test][level] += 1 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: elif message:
# Record to the script's log # Record to the script's log
@@ -500,6 +502,8 @@ class BaseScript:
'time': timezone.now().isoformat(), 'time': timezone.now().isoformat(),
'status': level, 'status': level,
'message': str(message), '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 # Record to the system log
@@ -507,19 +511,19 @@ class BaseScript:
message = f"{obj}: {message}" message = f"{obj}: {message}"
self.logger.log(LogLevelChoices.SYSTEM_LEVELS[level], 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) 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) 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) 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) 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._log(message, obj, level=LogLevelChoices.LOG_FAILURE)
self.failed = True self.failed = True

View File

@@ -55,18 +55,6 @@ def run_validators(instance, validators):
clear_events = Signal() 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)) @receiver((post_save, m2m_changed))
def handle_changed_object(sender, instance, **kwargs): def handle_changed_object(sender, instance, **kwargs):
""" """
@@ -112,14 +100,13 @@ def handle_changed_object(sender, instance, **kwargs):
objectchange.request_id = request.id objectchange.request_id = request.id
objectchange.save() 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() queue = events_queue.get()
if m2m_changed and queue and is_same_object(instance, queue[-1], request.id): enqueue_object(queue, instance, request.user, request.id, action)
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)
events_queue.set(queue) events_queue.set(queue)
# Increment metric counters # Increment metric counters
@@ -179,7 +166,7 @@ def handle_deleted_object(sender, instance, **kwargs):
obj.snapshot() # Ensure the change record includes the "before" state obj.snapshot() # Ensure the change record includes the "before" state
getattr(obj, related_field_name).remove(instance) getattr(obj, related_field_name).remove(instance)
# Enqueue webhooks # Enqueue the object for event processing
queue = events_queue.get() queue = events_queue.get()
enqueue_object(queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE) enqueue_object(queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE)
events_queue.set(queue) events_queue.set(queue)
@@ -195,7 +182,7 @@ def clear_events_queue(sender, **kwargs):
""" """
logger = logging.getLogger('events') logger = logging.getLogger('events')
logger.info(f"Clearing {len(events_queue.get())} queued events ({sender})") 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 json
import django_tables2 as tables 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 django.utils.translation import gettext_lazy as _
from extras.models import * from extras.models import *
from netbox.constants import EMPTY_TABLE_TEXT
from netbox.tables import BaseTable, NetBoxTable, columns from netbox.tables import BaseTable, NetBoxTable, columns
from .template_code import * from .template_code import *
@@ -545,15 +546,27 @@ class ScriptResultsTable(BaseTable):
template_code="""{% load log_levels %}{% log_level record.status %}""", template_code="""{% load log_levels %}{% log_level record.status %}""",
verbose_name=_('Level') verbose_name=_('Level')
) )
object = tables.Column(
verbose_name=_('Object')
)
message = columns.MarkdownColumn( message = columns.MarkdownColumn(
verbose_name=_('Message') verbose_name=_('Message')
) )
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
empty_text = _('No results found') empty_text = _(EMPTY_TABLE_TEXT)
fields = ( 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): class ReportResultsTable(BaseTable):
@@ -581,7 +594,13 @@ class ReportResultsTable(BaseTable):
) )
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
empty_text = _('No results found') empty_text = _(EMPTY_TABLE_TEXT)
fields = ( fields = (
'index', 'method', 'time', 'status', 'object', 'url', 'message', '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 import template
from django.utils.html import escape
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from core.models import ObjectType from core.models import ObjectType
@@ -59,8 +60,7 @@ def custom_links(context, obj):
# Add non-grouped links # Add non-grouped links
else: else:
try: try:
rendered = cl.render(link_context) if rendered := cl.render(link_context):
if rendered:
template_code += LINK_BUTTON.format( template_code += LINK_BUTTON.format(
rendered['link'], rendered['link_target'], cl.button_class, rendered['text'] rendered['link'], rendered['link_target'], cl.button_class, rendered['text']
) )
@@ -75,8 +75,7 @@ def custom_links(context, obj):
for cl in links: for cl in links:
try: try:
rendered = cl.render(link_context) if rendered := cl.render(link_context):
if rendered:
links_rendered.append( links_rendered.append(
GROUP_LINK.format(rendered['link'], rendered['link_target'], rendered['text']) GROUP_LINK.format(rendered['link'], rendered['link_target'], rendered['text'])
) )
@@ -88,7 +87,7 @@ def custom_links(context, obj):
if links_rendered: if links_rendered:
template_code += GROUP_BUTTON.format( 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) 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['custom_fields']['cf2'], form_data['cf_cf2'])
self.assertEqual(oc.postchange_data['tags'], ['Tag 1', 'Tag 2']) 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): def test_update_object(self):
site = Site(name='Site 1', slug='site-1') site = Site(name='Site 1', slug='site-1')
site.save() 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['custom_fields']['cf2'], form_data['cf_cf2'])
self.assertEqual(oc.postchange_data['tags'], ['Tag 3']) 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): def test_delete_object(self):
site = Site( site = Site(
name='Site 1', name='Site 1',
@@ -142,6 +152,10 @@ class ChangeLogViewTest(ModelViewTestCase):
self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2']) self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
self.assertEqual(oc.postchange_data, None) 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): def test_bulk_update_objects(self):
sites = ( sites = (
Site(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_ACTIVE), 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['custom_fields'], data['custom_fields'])
self.assertEqual(oc.postchange_data['tags'], ['Tag 1', 'Tag 2']) 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): def test_update_object(self):
site = Site(name='Site 1', slug='site-1') site = Site(name='Site 1', slug='site-1')
site.save() site.save()
@@ -370,6 +388,12 @@ class ChangeLogAPITest(APITestCase):
self.assertEqual(oc.postchange_data['custom_fields'], data['custom_fields']) self.assertEqual(oc.postchange_data['custom_fields'], data['custom_fields'])
self.assertEqual(oc.postchange_data['tags'], ['Tag 3']) 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): def test_delete_object(self):
site = Site( site = Site(
name='Site 1', name='Site 1',
@@ -398,6 +422,10 @@ class ChangeLogAPITest(APITestCase):
self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2']) self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
self.assertEqual(oc.postchange_data, None) 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): def test_bulk_create_objects(self):
data = ( data = (
{ {

View File

@@ -1,6 +1,12 @@
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase from django.test import TestCase
from dcim.choices import SiteStatusChoices
from dcim.models import Site
from extras.conditions import Condition, ConditionSet 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): class ConditionTestCase(TestCase):
@@ -217,3 +223,93 @@ class ConditionSetTest(TestCase):
self.assertTrue(cs.eval({'a': 1, 'b': 2, 'c': 9})) 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': 2, 'c': 9}))
self.assertFalse(cs.eval({'a': 9, 'b': 9, 'c': 3})) 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 import django_rq
from django.http import HttpResponse from django.http import HttpResponse
from django.test import RequestFactory
from django.urls import reverse from django.urls import reverse
from requests import Session from requests import Session
from rest_framework import status from rest_framework import status
@@ -12,6 +13,7 @@ from core.models import ObjectType
from dcim.choices import SiteStatusChoices from dcim.choices import SiteStatusChoices
from dcim.models import Site from dcim.models import Site
from extras.choices import EventRuleActionChoices, ObjectChangeActionChoices 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.events import enqueue_object, flush_events, serialize_for_event
from extras.models import EventRule, Tag, Webhook from extras.models import EventRule, Tag, Webhook
from extras.webhooks import generate_signature, send_webhook from extras.webhooks import generate_signature, send_webhook
@@ -360,7 +362,7 @@ class EventRuleTest(APITestCase):
return HttpResponse() return HttpResponse()
# Enqueue a webhook for processing # Enqueue a webhook for processing
webhooks_queue = [] webhooks_queue = {}
site = Site.objects.create(name='Site 1', slug='site-1') site = Site.objects.create(name='Site 1', slug='site-1')
enqueue_object( enqueue_object(
webhooks_queue, webhooks_queue,
@@ -369,7 +371,7 @@ class EventRuleTest(APITestCase):
request_id=request_id, request_id=request_id,
action=ObjectChangeActionChoices.ACTION_CREATE action=ObjectChangeActionChoices.ACTION_CREATE
) )
flush_events(webhooks_queue) flush_events(list(webhooks_queue.values()))
# Retrieve the job from queue # Retrieve the job from queue
job = self.queue.jobs[0] 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 # 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: with patch.object(Session, 'send', dummy_send) as mock_send:
send_webhook(**job.kwargs) 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: if not instance.prechange_data and instance.action in ['update', 'delete'] and prev_change:
non_atomic_change = True non_atomic_change = True
prechange_data = prev_change.postchange_data prechange_data = prev_change.postchange_data_clean
else: else:
non_atomic_change = False non_atomic_change = False
prechange_data = instance.prechange_data prechange_data = instance.prechange_data_clean
if prechange_data and instance.postchange_data: if prechange_data and instance.postchange_data:
diff_added = shallow_compare_dict( diff_added = shallow_compare_dict(
prechange_data or dict(), prechange_data or dict(),
instance.postchange_data or dict(), instance.postchange_data_clean or dict(),
exclude=['last_updated'], exclude=['last_updated'],
) )
diff_removed = { diff_removed = {
@@ -1052,12 +1052,27 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
}) })
class ScriptView(generic.ObjectView): class BaseScriptView(generic.ObjectView):
queryset = Script.objects.all() 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): def get(self, request, **kwargs):
script = self.get_object(**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)) form = script_class.as_form(initial=normalize_querydict(request.GET))
return render(request, 'extras/script.html', { return render(request, 'extras/script.html', {
@@ -1069,11 +1084,16 @@ class ScriptView(generic.ObjectView):
def post(self, request, **kwargs): def post(self, request, **kwargs):
script = self.get_object(**kwargs) script = self.get_object(**kwargs)
script_class = script.python_class()
if not request.user.has_perm('extras.run_script', obj=script): if not request.user.has_perm('extras.run_script', obj=script):
return HttpResponseForbidden() 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) form = script_class.as_form(request.POST, request.FILES)
# Allow execution only if RQ worker process is running # 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() queryset = Script.objects.all()
def get(self, request, **kwargs): def get(self, request, **kwargs):
script = self.get_object(**kwargs) script = self.get_object(**kwargs)
script_class = self._get_script_class(script)
return render(request, 'extras/script/source.html', { return render(request, 'extras/script/source.html', {
'script': script, 'script': script,
'script_class': script.python_class(), 'script_class': script_class,
'job_count': script.jobs.count(), 'job_count': script.jobs.count(),
'tab': 'source', 'tab': 'source',
}) })
class ScriptJobsView(generic.ObjectView): class ScriptJobsView(BaseScriptView):
queryset = Script.objects.all() queryset = Script.objects.all()
def get(self, request, **kwargs): def get(self, request, **kwargs):
@@ -1180,6 +1201,8 @@ class ScriptResultView(TableMixin, generic.ObjectView):
'time': log.get('time'), 'time': log.get('time'),
'status': log.get('status'), 'status': log.get('status'),
'message': log.get('message'), 'message': log.get('message'),
'object': log.get('obj'),
'url': log.get('url'),
} }
data.append(result) 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 import BOOLEAN_WITH_BLANK_CHOICES, add_blank_choice
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField
from utilities.forms.rendering import FieldSet from utilities.forms.rendering import FieldSet
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine, ClusterGroup, Cluster
from vpn.models import L2VPN from vpn.models import L2VPN
__all__ = ( __all__ = (
@@ -168,6 +168,7 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
'within_include', 'family', 'status', 'role_id', 'mask_length', 'is_pool', 'mark_utilized', 'within_include', 'family', 'status', 'role_id', 'mask_length', 'is_pool', 'mark_utilized',
name=_('Addressing') name=_('Addressing')
), ),
FieldSet('vlan_id', name=_('VLAN Assignment')),
FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')), FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
@@ -249,6 +250,12 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
) )
) )
vlan_id = DynamicModelMultipleChoiceField(
queryset=VLAN.objects.all(),
required=False,
label=_('VLAN'),
)
tag = TagFilterField(model) tag = TagFilterField(model)
@@ -405,6 +412,7 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm):
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('region', 'sitegroup', 'site', 'location', 'rack', name=_('Location')), FieldSet('region', 'sitegroup', 'site', 'location', 'rack', name=_('Location')),
FieldSet('cluster_group', 'cluster', name=_('Cluster')),
FieldSet('min_vid', 'max_vid', name=_('VLAN ID')), FieldSet('min_vid', 'max_vid', name=_('VLAN ID')),
) )
model = VLANGroup model = VLANGroup
@@ -445,6 +453,17 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm):
max_value=VLAN_VID_MAX, max_value=VLAN_VID_MAX,
label=_('Maximum VID') 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) tag = TagFilterField(model)

View File

@@ -355,6 +355,15 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
): ):
self.initial['primary_for_parent'] = True 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 # Disable object assignment fields if the IP address is designated as primary
if self.initial.get('primary_for_parent'): if self.initial.get('primary_for_parent'):
self.fields['interface'].disabled = True self.fields['interface'].disabled = True

View File

@@ -18,6 +18,7 @@ from ipam.querysets import PrefixQuerySet
from ipam.validators import DNSValidator from ipam.validators import DNSValidator
from netbox.config import get_config from netbox.config import get_config
from netbox.models import OrganizationalModel, PrimaryModel from netbox.models import OrganizationalModel, PrimaryModel
from netbox.models.features import ContactsMixin
__all__ = ( __all__ = (
'Aggregate', 'Aggregate',
@@ -74,7 +75,7 @@ class RIR(OrganizationalModel):
return reverse('ipam:rir', args=[self.pk]) 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 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. 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]) 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 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 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) return min(utilization, 100)
class IPRange(PrimaryModel): class IPRange(ContactsMixin, PrimaryModel):
""" """
A range of IP addresses, defined by start and end addresses. 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) 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 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 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.choices import *
from ipam.constants import * from ipam.constants import *
from netbox.models import PrimaryModel from netbox.models import PrimaryModel
from netbox.models.features import ContactsMixin
from utilities.data import array_to_string from utilities.data import array_to_string
__all__ = ( __all__ = (
@@ -62,7 +63,7 @@ class ServiceTemplate(ServiceBase, PrimaryModel):
return reverse('ipam:servicetemplate', args=[self.pk]) 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 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. 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 = { bulk_update_data = {
'description': 'New description', 'description': 'New description',
} }
graphql_filter = {
'address': {'lookup': 'i_exact', 'value': '192.168.0.1/24'},
}
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):

View File

@@ -7,12 +7,15 @@ from django.utils.translation import gettext as _
from circuits.models import Provider from circuits.models import Provider
from dcim.filtersets import InterfaceFilterSet from dcim.filtersets import InterfaceFilterSet
from dcim.forms import InterfaceFilterForm
from dcim.models import Interface, Site from dcim.models import Interface, Site
from netbox.views import generic from netbox.views import generic
from tenancy.views import ObjectContactsView
from utilities.query import count_related from utilities.query import count_related
from utilities.tables import get_table_ordering 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.filtersets import VMInterfaceFilterSet
from virtualization.forms import VMInterfaceFilterForm
from virtualization.models import VMInterface from virtualization.models import VMInterface
from . import filtersets, forms, tables from . import filtersets, forms, tables
from .choices import PrefixStatusChoices from .choices import PrefixStatusChoices
@@ -33,15 +36,10 @@ class VRFListView(generic.ObjectListView):
@register_model_view(VRF) @register_model_view(VRF)
class VRFView(generic.ObjectView): class VRFView(GetRelatedModelsMixin, generic.ObjectView):
queryset = VRF.objects.all() queryset = VRF.objects.all()
def get_extra_context(self, request, instance): 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( import_targets_table = tables.RouteTargetTable(
instance.import_targets.all(), instance.import_targets.all(),
orderable=False orderable=False
@@ -52,7 +50,7 @@ class VRFView(generic.ObjectView):
) )
return { return {
'related_models': related_models, 'related_models': self.get_related_models(request, instance, omit=[Interface, VMInterface]),
'import_targets_table': import_targets_table, 'import_targets_table': import_targets_table,
'export_targets_table': export_targets_table, 'export_targets_table': export_targets_table,
} }
@@ -146,16 +144,12 @@ class RIRListView(generic.ObjectListView):
@register_model_view(RIR) @register_model_view(RIR)
class RIRView(generic.ObjectView): class RIRView(GetRelatedModelsMixin, generic.ObjectView):
queryset = RIR.objects.all() queryset = RIR.objects.all()
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
related_models = (
(Aggregate.objects.restrict(request.user, 'view').filter(rir=instance), 'rir_id'),
)
return { return {
'related_models': related_models, 'related_models': self.get_related_models(request, instance),
} }
@@ -214,7 +208,7 @@ class ASNRangeASNsView(generic.ObjectChildrenView):
child_model = ASN child_model = ASN
table = tables.ASNTable table = tables.ASNTable
filterset = filtersets.ASNFilterSet filterset = filtersets.ASNFilterSet
template_name = 'generic/object_children.html' filterset_form = forms.ASNFilterForm
tab = ViewTab( tab = ViewTab(
label=_('ASNs'), label=_('ASNs'),
badge=lambda x: x.get_child_asns().count(), badge=lambda x: x.get_child_asns().count(),
@@ -273,17 +267,19 @@ class ASNListView(generic.ObjectListView):
@register_model_view(ASN) @register_model_view(ASN)
class ASNView(generic.ObjectView): class ASNView(GetRelatedModelsMixin, generic.ObjectView):
queryset = ASN.objects.all() queryset = ASN.objects.all()
def get_extra_context(self, request, instance): 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 { 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 child_model = Prefix
table = tables.PrefixTable table = tables.PrefixTable
filterset = filtersets.PrefixFilterSet filterset = filtersets.PrefixFilterSet
filterset_form = forms.PrefixFilterForm
template_name = 'ipam/aggregate/prefixes.html' template_name = 'ipam/aggregate/prefixes.html'
tab = ViewTab( tab = ViewTab(
label=_('Prefixes'), label=_('Prefixes'),
@@ -406,6 +403,11 @@ class AggregateBulkDeleteView(generic.BulkDeleteView):
table = tables.AggregateTable table = tables.AggregateTable
@register_model_view(Aggregate, 'contacts')
class AggregateContactsView(ObjectContactsView):
queryset = Aggregate.objects.all()
# #
# Prefix/VLAN roles # Prefix/VLAN roles
# #
@@ -422,18 +424,12 @@ class RoleListView(generic.ObjectListView):
@register_model_view(Role) @register_model_view(Role)
class RoleView(generic.ObjectView): class RoleView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Role.objects.all() queryset = Role.objects.all()
def get_extra_context(self, request, instance): 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 { return {
'related_models': related_models, 'related_models': self.get_related_models(request, instance),
} }
@@ -531,6 +527,7 @@ class PrefixPrefixesView(generic.ObjectChildrenView):
child_model = Prefix child_model = Prefix
table = tables.PrefixTable table = tables.PrefixTable
filterset = filtersets.PrefixFilterSet filterset = filtersets.PrefixFilterSet
filterset_form = forms.PrefixFilterForm
template_name = 'ipam/prefix/prefixes.html' template_name = 'ipam/prefix/prefixes.html'
tab = ViewTab( tab = ViewTab(
label=_('Child Prefixes'), label=_('Child Prefixes'),
@@ -566,6 +563,7 @@ class PrefixIPRangesView(generic.ObjectChildrenView):
child_model = IPRange child_model = IPRange
table = tables.IPRangeTable table = tables.IPRangeTable
filterset = filtersets.IPRangeFilterSet filterset = filtersets.IPRangeFilterSet
filterset_form = forms.IPRangeFilterForm
template_name = 'ipam/prefix/ip_ranges.html' template_name = 'ipam/prefix/ip_ranges.html'
tab = ViewTab( tab = ViewTab(
label=_('Child Ranges'), label=_('Child Ranges'),
@@ -592,6 +590,7 @@ class PrefixIPAddressesView(generic.ObjectChildrenView):
child_model = IPAddress child_model = IPAddress
table = tables.IPAddressTable table = tables.IPAddressTable
filterset = filtersets.IPAddressFilterSet filterset = filtersets.IPAddressFilterSet
filterset_form = forms.IPAddressFilterForm
template_name = 'ipam/prefix/ip_addresses.html' template_name = 'ipam/prefix/ip_addresses.html'
tab = ViewTab( tab = ViewTab(
label=_('IP Addresses'), label=_('IP Addresses'),
@@ -644,6 +643,11 @@ class PrefixBulkDeleteView(generic.BulkDeleteView):
table = tables.PrefixTable table = tables.PrefixTable
@register_model_view(Prefix, 'contacts')
class PrefixContactsView(ObjectContactsView):
queryset = Prefix.objects.all()
# #
# IP Ranges # IP Ranges
# #
@@ -686,6 +690,7 @@ class IPRangeIPAddressesView(generic.ObjectChildrenView):
child_model = IPAddress child_model = IPAddress
table = tables.IPAddressTable table = tables.IPAddressTable
filterset = filtersets.IPAddressFilterSet filterset = filtersets.IPAddressFilterSet
filterset_form = forms.IPRangeFilterForm
template_name = 'ipam/iprange/ip_addresses.html' template_name = 'ipam/iprange/ip_addresses.html'
tab = ViewTab( tab = ViewTab(
label=_('IP Addresses'), label=_('IP Addresses'),
@@ -727,6 +732,11 @@ class IPRangeBulkDeleteView(generic.BulkDeleteView):
table = tables.IPRangeTable table = tables.IPRangeTable
@register_model_view(IPRange, 'contacts')
class IPRangeContactsView(ObjectContactsView):
queryset = IPRange.objects.all()
# #
# IP addresses # IP addresses
# #
@@ -883,7 +893,7 @@ class IPAddressRelatedIPsView(generic.ObjectChildrenView):
child_model = IPAddress child_model = IPAddress
table = tables.IPAddressTable table = tables.IPAddressTable
filterset = filtersets.IPAddressFilterSet filterset = filtersets.IPAddressFilterSet
template_name = 'generic/object_children.html' filterset_form = forms.IPAddressFilterForm
tab = ViewTab( tab = ViewTab(
label=_('Related IPs'), label=_('Related IPs'),
badge=lambda x: x.get_related_ips().count(), 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') return parent.get_related_ips().restrict(request.user, 'view')
@register_model_view(IPAddress, 'contacts')
class IPAddressContactsView(ObjectContactsView):
queryset = IPAddress.objects.all()
# #
# VLAN groups # VLAN groups
# #
@@ -907,16 +922,12 @@ class VLANGroupListView(generic.ObjectListView):
@register_model_view(VLANGroup) @register_model_view(VLANGroup)
class VLANGroupView(generic.ObjectView): class VLANGroupView(GetRelatedModelsMixin, generic.ObjectView):
queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags') queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
related_models = (
(VLAN.objects.restrict(request.user, 'view').filter(group=instance), 'group_id'),
)
return { return {
'related_models': related_models, 'related_models': self.get_related_models(request, instance),
} }
@@ -955,7 +966,7 @@ class VLANGroupVLANsView(generic.ObjectChildrenView):
child_model = VLAN child_model = VLAN
table = tables.VLANTable table = tables.VLANTable
filterset = filtersets.VLANFilterSet filterset = filtersets.VLANFilterSet
template_name = 'generic/object_children.html' filterset_form = forms.VLANFilterForm
tab = ViewTab( tab = ViewTab(
label=_('VLANs'), label=_('VLANs'),
badge=lambda x: x.get_child_vlans().count(), badge=lambda x: x.get_child_vlans().count(),
@@ -1111,7 +1122,7 @@ class VLANInterfacesView(generic.ObjectChildrenView):
child_model = Interface child_model = Interface
table = tables.VLANDevicesTable table = tables.VLANDevicesTable
filterset = InterfaceFilterSet filterset = InterfaceFilterSet
template_name = 'generic/object_children.html' filterset_form = InterfaceFilterForm
tab = ViewTab( tab = ViewTab(
label=_('Device Interfaces'), label=_('Device Interfaces'),
badge=lambda x: x.get_interfaces().count(), badge=lambda x: x.get_interfaces().count(),
@@ -1129,7 +1140,7 @@ class VLANVMInterfacesView(generic.ObjectChildrenView):
child_model = VMInterface child_model = VMInterface
table = tables.VLANVirtualMachinesTable table = tables.VLANVirtualMachinesTable
filterset = VMInterfaceFilterSet filterset = VMInterfaceFilterSet
template_name = 'generic/object_children.html' filterset_form = VMInterfaceFilterForm
tab = ViewTab( tab = ViewTab(
label=_('VM Interfaces'), label=_('VM Interfaces'),
badge=lambda x: x.get_vminterfaces().count(), badge=lambda x: x.get_vminterfaces().count(),
@@ -1264,3 +1275,8 @@ class ServiceBulkDeleteView(generic.BulkDeleteView):
queryset = Service.objects.prefetch_related('device', 'virtual_machine') queryset = Service.objects.prefetch_related('device', 'virtual_machine')
filterset = filtersets.ServiceFilterSet filterset = filtersets.ServiceFilterSet
table = tables.ServiceTable 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. # authenticated to NetBox indefinitely.
LOGIN_PERSISTENCE = False LOGIN_PERSISTENCE = False
# Setting this to True will permit only authenticated users to access any part of NetBox. By default, anonymous users # Setting this to False will permit unauthenticated users to access most areas of NetBox (but not make any changes).
# are permitted to access most data in NetBox but not make any changes. LOGIN_REQUIRED = True
LOGIN_REQUIRED = False
# The length of time (in seconds) for which a user will remain logged into the web UI before being prompted to # 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]) # re-authenticate. (Default: 1209600 [14 days])

View File

@@ -41,3 +41,6 @@ DEFAULT_ACTION_PERMISSIONS = {
# General-purpose tokens # General-purpose tokens
CENSOR_TOKEN = '********' CENSOR_TOKEN = '********'
CENSOR_TOKEN_CHANGED = '***CHANGED***' 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) 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 import re
from django import forms 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 import LookupTypes
from netbox.search.backends import search_backend from netbox.search.backends import search_backend
@@ -36,7 +36,8 @@ class SearchForm(forms.Form):
lookup = forms.ChoiceField( lookup = forms.ChoiceField(
choices=LOOKUP_CHOICES, choices=LOOKUP_CHOICES,
initial=LookupTypes.PARTIAL, initial=LookupTypes.PARTIAL,
required=False required=False,
label=_('Lookup')
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):

View File

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

View File

@@ -47,6 +47,11 @@ class CoreMiddleware:
with event_tracking(request): with event_tracking(request):
response = self.get_response(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. # Attach the unique request ID as an HTTP header.
response['X-Request-ID'] = request.id response['X-Request-ID'] = request.id

View File

@@ -258,6 +258,7 @@ CIRCUITS_MENU = Menu(
items=( items=(
get_model_item('circuits', 'circuit', _('Circuits')), get_model_item('circuits', 'circuit', _('Circuits')),
get_model_item('circuits', 'circuittype', _('Circuit Types')), get_model_item('circuits', 'circuittype', _('Circuit Types')),
get_model_item('circuits', 'circuittermination', _('Circuit Terminations')),
), ),
), ),
MenuGroup( MenuGroup(
@@ -372,19 +373,19 @@ ADMIN_MENU = Menu(
link=f'users:user_list', link=f'users:user_list',
link_text=_('Users'), link_text=_('Users'),
auth_required=True, auth_required=True,
permissions=[f'auth.view_user'], permissions=[f'users.view_user'],
buttons=( buttons=(
MenuItemButton( MenuItemButton(
link=f'users:user_add', link=f'users:user_add',
title='Add', title='Add',
icon_class='mdi mdi-plus-thick', icon_class='mdi mdi-plus-thick',
permissions=[f'auth.add_user'] permissions=[f'users.add_user']
), ),
MenuItemButton( MenuItemButton(
link=f'users:user_import', link=f'users:user_import',
title='Import', title='Import',
icon_class='mdi mdi-upload', 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=f'users:group_list',
link_text=_('Groups'), link_text=_('Groups'),
auth_required=True, auth_required=True,
permissions=[f'auth.view_group'], permissions=[f'users.view_group'],
buttons=( buttons=(
MenuItemButton( MenuItemButton(
link=f'users:group_add', link=f'users:group_add',
title='Add', title='Add',
icon_class='mdi mdi-plus-thick', icon_class='mdi mdi-plus-thick',
permissions=[f'auth.add_group'] permissions=[f'users.add_group']
), ),
MenuItemButton( MenuItemButton(
link=f'users:group_import', link=f'users:group_import',
title='Import', title='Import',
icon_class='mdi mdi-upload', icon_class='mdi mdi-upload',
permissions=[f'auth.add_group'] permissions=[f'users.add_group']
) )
) )
), ),
@@ -461,16 +462,13 @@ MENUS = [
PROVISIONING_MENU, PROVISIONING_MENU,
CUSTOMIZATION_MENU, CUSTOMIZATION_MENU,
OPERATIONS_MENU, OPERATIONS_MENU,
ADMIN_MENU,
] ]
# # Add top-level plugin menus
# Add plugin menus
#
for menu in registry['plugins']['menus']: for menu in registry['plugins']['menus']:
MENUS.append(menu) MENUS.append(menu)
# Add the default "plugins" menu
if registry['plugins']['menu_items']: if registry['plugins']['menu_items']:
# Build the default plugins menu # Build the default plugins menu
@@ -484,3 +482,6 @@ if registry['plugins']['menu_items']:
groups=groups groups=groups
) )
MENUS.append(plugins_menu) 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) min_version = version.parse(cls.min_version)
if current_version < min_version: if current_version < min_version:
raise ImproperlyConfigured( 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: if cls.max_version is not None:
max_version = version.parse(cls.max_version) max_version = version.parse(cls.max_version)
if current_version > max_version: if current_version > max_version:
raise ImproperlyConfigured( 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 # Verify required configuration settings

View File

@@ -23,7 +23,7 @@ PREFERENCES = {
), ),
description=_('Enable dynamic UI navigation'), description=_('Enable dynamic UI navigation'),
default=False, default=False,
experimental=True warning=_('Experimental feature')
), ),
'locale.language': UserPreference( 'locale.language': UserPreference(
label=_('Language'), label=_('Language'),
@@ -31,7 +31,12 @@ PREFERENCES = {
('', _('Auto')), ('', _('Auto')),
*settings.LANGUAGES, *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( 'pagination.per_page': UserPreference(
label=_('Page length'), 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.functions import window
from django.db.models.signals import post_delete, post_save from django.db.models.signals import post_delete, post_save
from django.utils.module_loading import import_string from django.utils.module_loading import import_string
from django.utils.translation import gettext_lazy as _
import netaddr import netaddr
from netaddr.core import AddrFormatError from netaddr.core import AddrFormatError
@@ -39,7 +40,7 @@ class SearchBackend:
# Organize choices by category # Organize choices by category
categories = defaultdict(dict) categories = defaultdict(dict)
for label, idx in registry['search'].items(): 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 # Compile a nested tuple of choices for form rendering
results = ( results = (

View File

@@ -25,7 +25,7 @@ from utilities.string import trailing_slash
# Environment setup # Environment setup
# #
VERSION = '4.0.1' VERSION = '4.0.7'
HOSTNAME = platform.node() HOSTNAME = platform.node()
# Set the base directory two levels up # Set the base directory two levels up
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 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 LANGUAGE_COOKIE_PATH = CSRF_COOKIE_PATH
LOGGING = getattr(configuration, 'LOGGING', {}) LOGGING = getattr(configuration, 'LOGGING', {})
LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False) 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) LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None)
LOGOUT_REDIRECT_URL = getattr(configuration, 'LOGOUT_REDIRECT_URL', 'home') LOGOUT_REDIRECT_URL = getattr(configuration, 'LOGOUT_REDIRECT_URL', 'home')
MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(BASE_DIR, 'media')).rstrip('/') 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_DSN = getattr(configuration, 'SENTRY_DSN', None)
SENTRY_ENABLED = getattr(configuration, 'SENTRY_ENABLED', False) SENTRY_ENABLED = getattr(configuration, 'SENTRY_ENABLED', False)
SENTRY_SAMPLE_RATE = getattr(configuration, 'SENTRY_SAMPLE_RATE', 1.0) 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_TAGS = getattr(configuration, 'SENTRY_TAGS', {})
SENTRY_TRACES_SAMPLE_RATE = getattr(configuration, 'SENTRY_TRACES_SAMPLE_RATE', 0) SENTRY_TRACES_SAMPLE_RATE = getattr(configuration, 'SENTRY_TRACES_SAMPLE_RATE', 0)
SESSION_COOKIE_NAME = getattr(configuration, 'SESSION_COOKIE_NAME', 'sessionid') 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_BACKEND = getattr(configuration, 'STORAGE_BACKEND', None)
STORAGE_CONFIG = getattr(configuration, 'STORAGE_CONFIG', {}) STORAGE_CONFIG = getattr(configuration, 'STORAGE_CONFIG', {})
TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC') 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 # Load any dynamic configuration parameters which have been hard-coded in the configuration file
for param in CONFIG_PARAMS: for param in CONFIG_PARAMS:
@@ -224,6 +226,23 @@ if STORAGE_BACKEND is not None:
return globals().get(name, default) return globals().get(name, default)
storages.utils.setting = _setting 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: if STORAGE_CONFIG and STORAGE_BACKEND is None:
warnings.warn( warnings.warn(
"STORAGE_CONFIG has been set in configuration.py but STORAGE_BACKEND is not defined. STORAGE_CONFIG will be " "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 = REDIS['tasks']
TASKS_REDIS_HOST = TASKS_REDIS.get('HOST', 'localhost') TASKS_REDIS_HOST = TASKS_REDIS.get('HOST', 'localhost')
TASKS_REDIS_PORT = TASKS_REDIS.get('PORT', 6379) TASKS_REDIS_PORT = TASKS_REDIS.get('PORT', 6379)
TASKS_REDIS_URL = TASKS_REDIS.get('URL')
TASKS_REDIS_SENTINELS = TASKS_REDIS.get('SENTINELS', []) TASKS_REDIS_SENTINELS = TASKS_REDIS.get('SENTINELS', [])
TASKS_REDIS_USING_SENTINEL = all([ TASKS_REDIS_USING_SENTINEL = all([
isinstance(TASKS_REDIS_SENTINELS, (list, tuple)), 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_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_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_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 # Configure Django's default cache to use Redis
CACHES = { CACHES = {
@@ -366,6 +386,8 @@ INSTALLED_APPS = [
'drf_spectacular', 'drf_spectacular',
'drf_spectacular_sidecar', 'drf_spectacular_sidecar',
] ]
if not DEBUG:
INSTALLED_APPS.remove('debug_toolbar')
if not DJANGO_ADMIN_ENABLED: if not DJANGO_ADMIN_ENABLED:
INSTALLED_APPS.remove('django.contrib.admin') INSTALLED_APPS.remove('django.contrib.admin')
@@ -445,6 +467,9 @@ LOGIN_REDIRECT_URL = f'/{BASE_PATH}'
# Use timezone-aware datetime objects # Use timezone-aware datetime objects
USE_TZ = True USE_TZ = True
# Toggle language translation support
USE_I18N = TRANSLATION_ENABLED
# WSGI # WSGI
WSGI_APPLICATION = 'netbox.wsgi.application' WSGI_APPLICATION = 'netbox.wsgi.application'
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
@@ -529,7 +554,7 @@ if SENTRY_ENABLED:
release=VERSION, release=VERSION,
sample_rate=SENTRY_SAMPLE_RATE, sample_rate=SENTRY_SAMPLE_RATE,
traces_sample_rate=SENTRY_TRACES_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, http_proxy=HTTP_PROXIES.get('http') if HTTP_PROXIES else None,
https_proxy=HTTP_PROXIES.get('https') 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 # Calculate a unique deployment ID from the secret key
DEPLOYMENT_ID = hashlib.sha256(SECRET_KEY.encode('utf-8')).hexdigest()[:16] 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 = { CENSUS_PARAMS = {
'version': VERSION, 'version': VERSION,
'python_version': sys.version.split()[0], 'python_version': sys.version.split()[0],
@@ -674,6 +699,12 @@ if TASKS_REDIS_USING_SENTINEL:
'socket_connect_timeout': TASKS_REDIS_SENTINEL_TIMEOUT '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: else:
RQ_PARAMS = { RQ_PARAMS = {
'HOST': TASKS_REDIS_HOST, 'HOST': TASKS_REDIS_HOST,
@@ -708,6 +739,7 @@ RQ_QUEUES.update({
# Supported translation languages # Supported translation languages
LANGUAGES = ( LANGUAGES = (
('de', _('German')),
('en', _('English')), ('en', _('English')),
('es', _('Spanish')), ('es', _('Spanish')),
('fr', _('French')), ('fr', _('French')),
@@ -715,6 +747,8 @@ LANGUAGES = (
('pt', _('Portuguese')), ('pt', _('Portuguese')),
('ru', _('Russian')), ('ru', _('Russian')),
('tr', _('Turkish')), ('tr', _('Turkish')),
('uk', _('Ukrainian')),
('zh', _('Chinese')),
) )
LOCALE_PATHS = ( LOCALE_PATHS = (
BASE_DIR + '/translations', BASE_DIR + '/translations',
@@ -801,3 +835,10 @@ for plugin_name in PLUGINS:
RQ_QUEUES.update({ RQ_QUEUES.update({
f"{plugin_name}.{queue}": RQ_PARAMS for queue in plugin_config.queues 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 dataclasses import dataclass
from typing import Optional from typing import Optional
from urllib.parse import quote from urllib.parse import quote
@@ -83,6 +84,8 @@ class DateTimeColumn(tables.Column):
def render(self, value): def render(self, value):
if 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)}" return f"{value.date().isoformat()} {value.time().isoformat(timespec=self.timespec)}"
def value(self, value): def value(self, value):
@@ -430,7 +433,7 @@ class LinkedCountColumn(tables.Column):
f'{k}={getattr(record, v) or settings.FILTERS_NULL_CHOICE_VALUE}' f'{k}={getattr(record, v) or settings.FILTERS_NULL_CHOICE_VALUE}'
for k, v in self.url_params.items() 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 return value
def value(self, value): def value(self, value):

View File

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

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