Compare commits

..

142 Commits

Author SHA1 Message Date
Jeremy Stretch
55e9685d30 Merge pull request #7317 from netbox-community/develop
Release v3.0.3
2021-09-20 13:08:32 -04:00
jeremystretch
d2dcc51430 Changelog cleanup 2021-09-20 12:55:04 -04:00
jeremystretch
214c1d5a50 Release v3.0.3 2021-09-20 12:44:44 -04:00
jeremystretch
38be0b4976 Changelog for #7228 2021-09-20 10:23:29 -04:00
Jeremy Stretch
b86847c57e Merge pull request #7307 from royreznik/fix-7228
Fixes #7228: Round °F temperature to one decimal place
2021-09-20 10:21:43 -04:00
jeremystretch
8ba5d03280 Fixes #7301: Fix exception when deleting a large number of child prefixes 2021-09-20 10:02:03 -04:00
jeremystretch
879ffd648b Fix 'select all' widget display 2021-09-20 09:02:28 -04:00
royreznik
030c573037 Fixes #7228: Round °F temperature to one decimal place 2021-09-20 02:44:40 -07:00
jeremystretch
713e79c1a9 Fixes #7298: Restore missing object names from applied object list filters 2021-09-17 16:55:32 -04:00
Jeremy Stretch
383cdb5340 Merge pull request #7297 from netbox-community/7295-tables-cleanup
Closes #7295: General cleanup of table classes
2021-09-17 15:56:26 -04:00
jeremystretch
7b3f6f1c67 Clean up table classes 2021-09-17 15:37:19 -04:00
jeremystretch
9cb29f48a0 Use exclude() when instantiating tables to omit columns 2021-09-17 14:25:02 -04:00
jeremystretch
5e29679968 Merge 'detail' tables into primaries for each model 2021-09-17 13:55:32 -04:00
jeremystretch
84f3ab90df Changelog for #7273 2021-09-17 12:11:08 -04:00
Jeremy Stretch
34db2eb611 Merge pull request #7285 from netbox-community/7273-apiselect-sorting
Fixes #7273: Disable automatic sorting of select options fetched via API
2021-09-17 12:09:56 -04:00
jeremystretch
16d8981a3f Closes #7284: Include comments field in table/export for all appropriate models 2021-09-17 12:04:22 -04:00
jeremystretch
e67c965180 Closes #7087: Add local_context_data filter for virtual machines list 2021-09-17 10:30:31 -04:00
jeremystretch
b0abfee35b Fixes #7282: Fix KeyError exception when INSECURE_SKIP_TLS_VERIFY is true 2021-09-17 10:16:06 -04:00
jeremystretch
1e8ee5e59b Correct changelog for #7210; revert static asset change 2021-09-16 16:14:20 -04:00
jeremystretch
cc0830bf28 Closes #7087: Add search/filter forms for all organizational models 2021-09-16 16:04:46 -04:00
jeremystretch
e3e005e327 Fixes #7266: Tweak font color for form field placeholder text 2021-09-16 14:41:01 -04:00
jeremystretch
42afd80e82 Changelog for #5775 2021-09-16 14:28:17 -04:00
jeremystretch
0b2862be54 Changelog for #5775 2021-09-16 14:03:28 -04:00
Jeremy Stretch
8d703ffb36 Merge pull request #6730 from MaxRink/remote_groups
Remote groups via HTTP Headers
2021-09-16 14:02:22 -04:00
jeremystretch
6f24a938d9 Fixes #7273: Disable automatic sorting of select options fetched via API 2021-09-16 11:08:05 -04:00
jeremystretch
574b57eadb Treat compiled JS/CSS as binary when diffing 2021-09-16 11:03:55 -04:00
jeremystretch
aa05097fca Fixes #7279: Fix exception when tracing cable with no associated path 2021-09-16 08:56:26 -04:00
jeremystretch
de58f53f9f Closes #7208: Add navigation breadcrumbs for custom scripts & reports 2021-09-15 15:14:24 -04:00
jeremystretch
e738ff2fa7 Closes #6988: Order tenants alphabetically without regard to group assignment 2021-09-15 14:56:20 -04:00
jeremystretch
25f501fb12 Remove obsolete responsive_table.html template 2021-09-15 12:41:21 -04:00
jeremystretch
baf045aed6 Clean up documentation build warnings 2021-09-15 11:46:58 -04:00
jeremystretch
e813dda275 Remove default_app_config (deprecated in Django 3.2) 2021-09-15 11:18:47 -04:00
Jeremy Stretch
ca131f12db Merge pull request #7272 from cimnine/Fix7271
Remove secrets from the feature list
2021-09-15 07:56:28 -04:00
cimnine
ca72b07947 Remove secrets from the feature list 2021-09-15 11:00:51 +02:00
jeremystretch
13d8957cf1 Changelog for #7032 2021-09-14 17:05:07 -04:00
Jeremy Stretch
ca11b74c8e Merge pull request #7052 from tb-killa/#7032
Close #7032
2021-09-14 16:59:16 -04:00
jeremystretch
2ba6a6fc45 Closes #6387: Add xDSL interface type 2021-09-14 16:56:47 -04:00
jeremystretch
a6e79a1d61 Add virtualenv instructions to plugin development docs 2021-09-13 10:58:03 -04:00
jeremystretch
1f4263aa6d Fixes #7253: Remove obsolete reference to queryset cache invalidation 2021-09-13 09:36:01 -04:00
jeremystretch
147a4cbfb0 Closes #7239: Redirect global search to filtered object list when an object type is selected 2021-09-13 09:33:08 -04:00
jeremystretch
ab0a2abc54 Fixes #7248: Fix global search results section links 2021-09-13 09:21:57 -04:00
Jeremy Stretch
57abbf1058 Merge pull request #7245 from dvaccarosenna/issue/7195
Fixes #7195 update base template in plugin dev doc
2021-09-10 11:34:49 -04:00
Daniel Vaccaro-Senna
2a95e1bf71 Fixes #7195 update base template in plugin dev doc 2021-09-10 16:03:46 +01:00
jeremystretch
bd957612c6 Fixes #7167: Ensure consistent font size when using monospace formatting 2021-09-09 16:05:46 -04:00
jeremystretch
908e6a7a38 Update NetBox installation video 2021-09-09 14:46:31 -04:00
jeremystretch
4493c31216 Fixes #7227: Correct placeholder value for webhook CA file path 2021-09-09 09:10:02 -04:00
jeremystretch
7a813349f3 Correct example GraphQL queries 2021-09-09 09:07:28 -04:00
jeremystretch
ad7b8a9ac8 Fixes #7226: Exempt GraphQL API requests from CSRF inspection 2021-09-09 09:06:45 -04:00
jeremystretch
a226f06b1b PRVB 2021-09-08 16:47:04 -04:00
Jeremy Stretch
b55c85b2af Merge pull request #7220 from netbox-community/develop
Release v3.0.2
2021-09-08 16:45:05 -04:00
jeremystretch
0d1d14bcd6 Release v3.0.2 2021-09-08 16:32:37 -04:00
Jeremy Stretch
8c1a01d5ab Merge pull request #7218 from netbox-community/7162-base-path-bug2
Fixes #7162: Decouple base path rendering from API request logic
2021-09-08 16:19:49 -04:00
jeremystretch
cf8fdacfa3 Refactor connection toggle to use API URLs 2021-09-08 14:25:14 -04:00
jeremystretch
2c1745ce28 Refactor checkJobStatus() to use API URLs provided via context 2021-09-08 14:17:27 -04:00
jeremystretch
950ce94653 Refactor ApiRequest to take only full URLs; update TableConfigForm 2021-09-08 13:59:25 -04:00
thatmattlove
851f8a1585 Fixes #7191: Access SlimSelect's internal options when getting current options so selection state is maintained 2021-09-08 09:54:38 -07:00
jeremystretch
d40d1638af Fixes #7179: Prevent obscuring "connect" pop-up for interfaces under device view 2021-09-08 11:27:14 -04:00
Jeremy Stretch
26ceeb61ef Merge pull request #7206 from netbox-community/7205-applied-filters
Handle `null_option` in `applied_filters` template tag
2021-09-08 11:22:54 -04:00
Jeremy Stretch
a39a9c9b56 Merge branch 'develop' into 7205-applied-filters 2021-09-08 11:12:29 -04:00
jeremystretch
45988b9818 Minor cleanup for get_selected_values() 2021-09-08 11:11:52 -04:00
Jeremy Stretch
7234b3bbf8 Merge pull request #7204 from netbox-community/7202-verify-static-assets
Verify integrity of bundled assets in CI
2021-09-08 10:23:51 -04:00
Jeremy Stretch
513ecd7e26 Merge branch 'develop' into 7202-verify-static-assets 2021-09-08 10:13:07 -04:00
jeremystretch
e12314ba60 Fix test user permissions for API pagination tests 2021-09-08 09:57:53 -04:00
jeremystretch
9226302742 Fixes #7209: Allow unlimited API results when MAX_PAGE_SIZE is disabled 2021-09-08 09:38:23 -04:00
jeremystretch
0e8c6ee522 Changelog for #7189 2021-09-08 08:33:30 -04:00
thatmattlove
a9c1c8968e Return cleaned null_option value as None in dynamic multi-select field 2021-09-07 18:43:36 -07:00
thatmattlove
6a15c2ae86 Remove invalid (for Python <3.9) type annotation 2021-09-07 18:37:55 -07:00
thatmattlove
752de0d9c0 Fixes #7205: Handle null_option when getting selected form values in applied_filters template tag 2021-09-07 18:30:45 -07:00
thatmattlove
49617a595d #7205: Handle null_option in dynamic multi-select choices field 2021-09-07 18:28:49 -07:00
thatmattlove
2a293d5d02 Fixes #7188: Re-add missing support for null_option on API select 2021-09-07 18:19:32 -07:00
thatmattlove
9d99ede024 Fixes #7202: Verify integrity of bundled assets in CI 2021-09-07 16:20:36 -07:00
thatmattlove
4a13ee6f40 Fixes #7176: Reset query parameters on APISelect when deep copied 2021-09-07 14:13:53 -07:00
Jeremy Stretch
2ba840c72c Merge pull request #7200 from pierrechev/develop
Enable the alternate connection factory for Redis Sentinel
2021-09-07 16:58:11 -04:00
jeremystretch
46cd55151d Use shallow git clone for production installations 2021-09-07 16:35:48 -04:00
jeremystretch
4d9691c8e5 Extend feature request template to request more detail 2021-09-07 16:25:10 -04:00
jeremystretch
f1687ef53d Remove obsolete entries from .gitignore 2021-09-07 16:21:01 -04:00
Daniel Sheppard
2fb55374b9 Fixes #7193 - Fixes issue with viewing child prefixes when prefix (flat) column is selected and there are available prefixes. 2021-09-07 14:53:12 -05:00
Pierre Chevallereau
312246fec2 Enable the alternate connection factory (https://github.com/jazzband/django-redis#use-the-sentinel-connection-factory) 2021-09-07 16:07:25 +02:00
jeremystretch
27c0e6dd5e Fixes #7164: Fix styling of "decommissioned" label for circuits 2021-09-03 13:52:48 -04:00
jeremystretch
0d7986e082 Fixes #7169: Fix CSV import file upload 2021-09-03 13:46:38 -04:00
Jeremy Stretch
94300b221e Merge pull request #7160 from netbox-community/7149-secrets-changelog
Fixes #7149: Delete all changelog records referencing the old secrets app
2021-09-03 12:51:13 -04:00
jeremystretch
a1110b07de Fixes #7153: Allow clearing of assigned device type images 2021-09-02 16:48:54 -04:00
jeremystretch
a3069239e9 Fixes #7159: Remove NAPALM link 2021-09-02 15:55:41 -04:00
jeremystretch
69f083428d Fixes #7149: Delete all changelog records referencing the old secrets app 2021-09-02 15:27:05 -04:00
thatmattlove
113358f2de Fixes #7148: Handle array values when constructing API URLs 2021-09-02 08:59:26 -07:00
thatmattlove
caa2813d0d Fix regression from ddff193 causing invalid selections 2021-09-02 07:56:08 -07:00
Jeremy Stretch
481046c8b8 Merge pull request #7133 from slowy07/minor-changes
fix: typo spelling grammar
2021-09-02 09:48:58 -04:00
slowy07
83f70dc28c fix: typo spelling grammar
Signed-off-by: slowy07 <slowy.arfy@gmail.com>
2021-09-02 12:01:43 +07:00
thatmattlove
8ede7a9297 Update changelog for #7131 2021-09-01 17:05:50 -07:00
thatmattlove
ddff193786 #7123: Handle empty_option on API Select 2021-09-01 17:02:43 -07:00
thatmattlove
774dff07ee Fixes #7131: Only execute scope selector field visibility logic on specified views 2021-09-01 15:27:37 -07:00
thatmattlove
4b14b31853 Use url_name instead of request.path for view-based styles 2021-09-01 15:22:38 -07:00
jeremystretch
b0addfbe13 PRVB 2021-09-01 15:22:03 -04:00
Jeremy Stretch
593874b45f Merge pull request #7130 from netbox-community/develop
Release v3.0.1
2021-09-01 15:10:17 -04:00
jeremystretch
b207f28402 Release v3.0.1 2021-09-01 14:53:57 -04:00
thatmattlove
7bdde47473 Fixes #7124: Fix duplicate static query param values in API Select 2021-09-01 11:48:13 -07:00
thatmattlove
a2eb0d80d2 #7084: Fix issue where hidden VLAN form fields were incorrectly included in the form submission 2021-09-01 11:41:35 -07:00
jeremystretch
6f94198934 #7123: Remove "Global" placeholder for null VRF field 2021-09-01 13:52:32 -04:00
jeremystretch
707e51d855 #7084: Catch ValueErrors when initializing dynamic form fields 2021-09-01 13:33:41 -04:00
jeremystretch
528df76747 #7082: Handle stale content types 2021-09-01 12:55:25 -04:00
jeremystretch
662c896480 #7113: Add bulk actions under child prefixes view; general cleanup 2021-09-01 11:06:50 -04:00
Jeremy Stretch
29eb2383d6 Merge pull request #7115 from sdktr/7113-fix-prefix-iprange-bulkedit
Fixes #7113: iprange bulk options within Prefix view
2021-09-01 10:56:11 -04:00
Jeremy Stretch
9772c5705f Merge branch 'develop' into 7113-fix-prefix-iprange-bulkedit 2021-09-01 10:55:52 -04:00
jeremystretch
d2fe59ae8f Fixes #7109: Ensure human readability of exceptions raised during REST API requests 2021-09-01 10:43:12 -04:00
Max Rink
d5e5cdda23 Add Remote Group Support to the RemoteUserAuth Backend and Middleware
fix incorrect assumption about when to run the group sync

Add documentation for new Settings

format to autopep8 compliance

add first set of basic testcases

format test to comply with pep8

rename SEPERATOR to SEPARATOR

remove accidentally carried over parameter
2021-09-01 16:34:14 +02:00
jeremystretch
f63dcb1f08 #7091: Simplify access to BASE_PATH variable 2021-09-01 09:34:33 -04:00
Stefan de Kooter
6f66b27507 Changelog for #7113 2021-09-01 11:00:30 +02:00
Stefan de Kooter
909d127c27 Fixes #7113: Correct links to IPRanges bulk actions within Prefix view 2021-09-01 10:25:37 +02:00
Stefan de Kooter
20ef18f98f Fixes #7113: Add permissions to IPRanges bulk actions within Prefix view 2021-09-01 10:24:35 +02:00
thatmattlove
a33e47780b Remove legacy script tags from templates 2021-09-01 00:27:10 -07:00
thatmattlove
691c66d2f5 Fixes #7107: Fix missing search button and search results in IP Address assignment "Assign IP" tab 2021-09-01 00:11:48 -07:00
thatmattlove
14d87a3584 Fixes #7041: Properly format JSON config object returned from a NAPALM device 2021-09-01 00:03:53 -07:00
thatmattlove
d743dc160a Fixes #7080: Re-add missing image preview element 2021-08-31 17:05:02 -07:00
thatmattlove
2b263b054c Fixes #7106: Fix incorrect "Map It" button URL on a site's Physical Address field 2021-08-31 16:27:02 -07:00
thatmattlove
b95e8350d2 Fixes #7092: Fix missing object permissions on Prefix IP Addresses view 2021-08-31 16:03:22 -07:00
thatmattlove
5235866d05 Changelog for #7081, #7091 2021-08-31 15:23:12 -07:00
thatmattlove
093a86bc38 Fixes #7081: Properly handle pre-selected values even when they're outside of pagination limits 2021-08-31 15:13:53 -07:00
thatmattlove
5b87232f59 #7081: Fix APISelect loading of paginated data 2021-08-31 15:13:53 -07:00
thatmattlove
679bbd3e76 Fixes #7091: Ensure API requests from the UI are aware of BASE_PATH 2021-08-31 15:13:53 -07:00
Jeremy Stretch
515b6bf71a Merge pull request #7105 from sdktr/7090-fix-cablebulkedit-length-field
Fix #7090: cable bulk edit form - allow decimal input on length field
2021-08-31 16:12:04 -04:00
Stefan de Kooter
9c389d9dcb Changelog #7090 fix whitespace 2021-08-31 22:01:15 +02:00
Stefan de Kooter
f1e4273a23 Changelog for #7090 2021-08-31 21:24:07 +02:00
Stefan de Kooter
4618cc2b22 Merge branch 'develop' of github.com:netbox-community/netbox into 7090-fix-cablebulkedit-length-field 2021-08-31 21:18:35 +02:00
Stefan de Kooter
1909f0c733 Fix #7090: Cable Bulk Edit, length field should be decimal 2021-08-31 21:17:50 +02:00
Jeremy Stretch
840ea36f70 Merge pull request #7103 from candlerb/candlerb/7102
Redirect users on error to the GitHub discussion forum
2021-08-31 15:08:08 -04:00
jeremystretch
a8cdb3895b Fixes #7093: Multi-select custom field filters should employ exact match 2021-08-31 15:03:39 -04:00
Brian Candler
349733c6dd Redirect users on error to the GitHub discussion forum
Fixes #7102
2021-08-31 19:51:53 +01:00
jeremystretch
1c09ffdd1f Fixes #7101: Enforce MAX_PAGE_SIZE for table and REST API pagination 2021-08-31 13:52:04 -04:00
Daniel Sheppard
c4c6fa6042 Fix misplacement of method for #7089 2021-08-31 11:41:50 -05:00
Daniel Sheppard
86da6c6c14 Fixes #7089 - Adds Q filter to ContentTypeFilterSet 2021-08-31 11:31:40 -05:00
jeremystretch
7b7b01a26b Changelog for #7075 2021-08-31 11:44:51 -04:00
jeremystretch
415313ac2f Fixes #7082: Avoid exception when referencing invalid content type in table 2021-08-31 11:43:44 -04:00
jeremystretch
7db2b9d091 Fixes #7072: Fix table configuration under prefix child object views 2021-08-31 11:15:41 -04:00
jeremystretch
8036d1e5a5 Fixes #7078: Restore styling on server error page 2021-08-31 09:50:24 -04:00
jeremystretch
65c9339687 Fixes #7083: Correct labeling for VM memory attribute 2021-08-31 09:44:59 -04:00
jeremystretch
3090981335 Fixes #7084: Fix KeyError exception when editing access VLAN on an interface 2021-08-31 09:44:59 -04:00
jeremystretch
4f36885c5e Fixes #7096: Home links should honor BASE_PATH configuration 2021-08-31 09:44:59 -04:00
thatmattlove
db2993035d Fixes #7075: Wrap label selectors in quotes to ensure IDs with spaces are properly selected 2021-08-30 17:48:33 -07:00
jeremystretch
bf05bc2986 #7070: Fix filterset test 2021-08-30 17:22:48 -04:00
jeremystretch
88b230f0e4 Fixes #7071: Fix exception when removing a primary IP from a device/VM 2021-08-30 16:55:31 -04:00
jeremystretch
deb53d771d Fixes #7070: Fix exception when filtering by prefix max length in UI 2021-08-30 16:51:07 -04:00
Oliver
b44ec35ade Close #7032
Add URM connector to the list of available types for front and rear ports.

There are URM-P2, URM-P4 and URM-P8 connectors available.
2021-08-27 20:27:26 +02:00
161 changed files with 2224 additions and 1098 deletions

8
.gitattributes vendored
View File

@@ -1,5 +1,5 @@
*.sh text eol=lf
# Treat minified or packed JS/CSS files as binary, as they're not meant to be human-readable
*.min.* binary
*.map binary
*.pack.js binary
# Treat compiled JS/CSS files as binary, as they're not meant to be human-readable
netbox/project-static/dist/*.css binary
netbox/project-static/dist/*.js binary
netbox/project-static/dist/*.js.map binary

View File

@@ -17,7 +17,7 @@ body:
What version of NetBox are you currently running? (If you don't have access to the most
recent NetBox release, consider testing on our [demo instance](https://demo.netbox.dev/)
before opening a bug report to see if your issue has already been addressed.)
placeholder: v3.0.0
placeholder: v3.0.3
validations:
required: true
- type: dropdown

View File

@@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v3.0.0
placeholder: v3.0.3
validations:
required: true
- type: dropdown
@@ -30,8 +30,10 @@ body:
attributes:
label: Proposed functionality
description: >
Describe in detail the new feature or behavior you'd like to propose. Include any specific
changes to work flows, data models, or the user interface.
Describe in detail the new feature or behavior you are proposing. Include any specific changes
to work flows, data models, and/or the user interface. The more detail you provide here, the
greater chance your proposal has of being discussed. Feature requests which don't include an
actionable implementation plan will be rejected.
validations:
required: true
- type: textarea

View File

@@ -58,6 +58,9 @@ jobs:
- name: Check UI ESLint, TypeScript, and Prettier Compliance
run: yarn --cwd netbox/project-static validate
- name: Validate Static Asset Integrity
run: scripts/verify-bundles.sh
- name: Run tests
run: coverage run --source="netbox/" netbox/manage.py test netbox/

5
.gitignore vendored
View File

@@ -1,16 +1,13 @@
*.pyc
*.swp
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
/netbox/project-static/.cache
/netbox/project-static/node_modules
/netbox/project-static/docs/*
!/netbox/project-static/docs/.info
/netbox/netbox/configuration.py
/netbox/netbox/ldap_config.py
/netbox/project-static/.cache
/netbox/project-static/node_modules
/netbox/reports/*
!/netbox/reports/__init__.py
/netbox/scripts/*

View File

@@ -26,4 +26,4 @@ For the exhaustive list of exposed metrics, visit the `/metrics` endpoint on you
When deploying NetBox in a multiprocess manner (e.g. running multiple Gunicorn workers) the Prometheus client library requires the use of a shared directory to collect metrics from all worker processes. To configure this, first create or designate a local directory to which the worker processes have read and write access, and then configure your WSGI service (e.g. Gunicorn) to define this path as the `prometheus_multiproc_dir` environment variable.
!!! warning
If having accurate long-term metrics in a multiprocess environment is crucial to your deployment, it's recommended you use the `uwsgi` library instead of `gunicorn`. The issue lies in the way `gunicorn` tracks worker processes (vs `uwsgi`) which helps manage the metrics files created by the above configurations. If you're using NetBox with gunicorn in a containerized enviroment following the one-process-per-container methodology, then you will likely not need to change to `uwsgi`. More details can be found in [issue #3779](https://github.com/netbox-community/netbox/issues/3779#issuecomment-590547562).
If having accurate long-term metrics in a multiprocess environment is crucial to your deployment, it's recommended you use the `uwsgi` library instead of `gunicorn`. The issue lies in the way `gunicorn` tracks worker processes (vs `uwsgi`) which helps manage the metrics files created by the above configurations. If you're using NetBox with gunicorn in a containerized environment following the one-process-per-container methodology, then you will likely not need to change to `uwsgi`. More details can be found in [issue #3779](https://github.com/netbox-community/netbox/issues/3779#issuecomment-590547562).

View File

@@ -2,7 +2,7 @@
NetBox v2.9 introduced a new object-based permissions framework, which replace's Django's built-in permissions model. Object-based permissions enable an administrator to grant users or groups the ability to perform an action on arbitrary subsets of objects in NetBox, rather than all objects of a certain type. For example, it is possible to grant a user permission to view only sites within a particular region, or to modify only VLANs with a numeric ID within a certain range.
{!docs/models/users/objectpermission.md!}
{!models/users/objectpermission.md!}
### Example Constraint Definitions

View File

@@ -71,14 +71,3 @@ To extract the saved archive into a new installation, run the following from the
```no-highlight
tar -xf netbox_media.tar.gz
```
---
## Cache Invalidation
If you are migrating your instance of NetBox to a different machine, be sure to first invalidate the cache on the original instance by issuing the `invalidate all` management command (within the Python virtual environment):
```no-highlight
# source /opt/netbox/venv/bin/activate
(venv) # python3 manage.py invalidate all
```

View File

@@ -490,6 +490,14 @@ NetBox can be configured to support remote user authentication by inferring user
---
## REMOTE_AUTH_GROUP_SYNC_ENABLED
Default: `False`
NetBox can be configured to sync remote user groups by inferring user authentication from an HTTP header set by the HTTP reverse proxy (e.g. nginx or Apache). Set this to `True` to enable this functionality. (Local authentication will still take effect as a fallback.) (Requires `REMOTE_AUTH_ENABLED`.)
---
## REMOTE_AUTH_HEADER
Default: `'HTTP_REMOTE_USER'`
@@ -498,6 +506,54 @@ When remote user authentication is in use, this is the name of the HTTP header w
---
## REMOTE_AUTH_GROUP_HEADER
Default: `'HTTP_REMOTE_USER_GROUP'`
When remote user authentication is in use, this is the name of the HTTP header which informs NetBox of the currently authenticated user. For example, to use the request header `X-Remote-User-Groups` it needs to be set to `HTTP_X_REMOTE_USER_GROUPS`. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
---
## REMOTE_AUTH_SUPERUSER_GROUPS
Default: `[]` (Empty list)
The list of groups that promote an remote User to Superuser on Login. If group isn't present on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
---
## REMOTE_AUTH_SUPERUSERS
Default: `[]` (Empty list)
The list of users that get promoted to Superuser on Login. If user isn't present in list on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
---
## REMOTE_AUTH_STAFF_GROUPS
Default: `[]` (Empty list)
The list of groups that promote an remote User to Staff on Login. If group isn't present on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
---
## REMOTE_AUTH_STAFF_USERS
Default: `[]` (Empty list)
The list of users that get promoted to Staff on Login. If user isn't present in list on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
---
## REMOTE_AUTH_GROUP_SEPARATOR
Default: `|` (Pipe)
The Seperator upon which `REMOTE_AUTH_GROUP_HEADER` gets split into individual Groups. This needs to be coordinated with your authentication Proxy. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
---
## RELEASE_CHECK_URL
Default: None (disabled)

View File

@@ -1,10 +1,10 @@
# Circuits
{!docs/models/circuits/provider.md!}
{!docs/models/circuits/providernetwork.md!}
{!models/circuits/provider.md!}
{!models/circuits/providernetwork.md!}
---
{!docs/models/circuits/circuit.md!}
{!docs/models/circuits/circuittype.md!}
{!docs/models/circuits/circuittermination.md!}
{!models/circuits/circuit.md!}
{!models/circuits/circuittype.md!}
{!models/circuits/circuittermination.md!}

View File

@@ -1,7 +1,7 @@
# Device Types
{!docs/models/dcim/devicetype.md!}
{!docs/models/dcim/manufacturer.md!}
{!models/dcim/devicetype.md!}
{!models/dcim/manufacturer.md!}
---
@@ -30,11 +30,11 @@ Once component templates have been created, every new device that you create as
!!! note
Assignment of components from templates occurs only at the time of device creation. If you modify the templates of a device type, it will not affect devices which have already been created. However, you always have the option of adding, modifying, or deleting components on existing devices.
{!docs/models/dcim/consoleporttemplate.md!}
{!docs/models/dcim/consoleserverporttemplate.md!}
{!docs/models/dcim/powerporttemplate.md!}
{!docs/models/dcim/poweroutlettemplate.md!}
{!docs/models/dcim/interfacetemplate.md!}
{!docs/models/dcim/frontporttemplate.md!}
{!docs/models/dcim/rearporttemplate.md!}
{!docs/models/dcim/devicebaytemplate.md!}
{!models/dcim/consoleporttemplate.md!}
{!models/dcim/consoleserverporttemplate.md!}
{!models/dcim/powerporttemplate.md!}
{!models/dcim/poweroutlettemplate.md!}
{!models/dcim/interfacetemplate.md!}
{!models/dcim/frontporttemplate.md!}
{!models/dcim/rearporttemplate.md!}
{!models/dcim/devicebaytemplate.md!}

View File

@@ -1,8 +1,8 @@
# Devices and Cabling
{!docs/models/dcim/device.md!}
{!docs/models/dcim/devicerole.md!}
{!docs/models/dcim/platform.md!}
{!models/dcim/device.md!}
{!models/dcim/devicerole.md!}
{!models/dcim/platform.md!}
---
@@ -10,20 +10,20 @@
Device components represent discrete objects within a device which are used to terminate cables, house child devices, or track resources.
{!docs/models/dcim/consoleport.md!}
{!docs/models/dcim/consoleserverport.md!}
{!docs/models/dcim/powerport.md!}
{!docs/models/dcim/poweroutlet.md!}
{!docs/models/dcim/interface.md!}
{!docs/models/dcim/frontport.md!}
{!docs/models/dcim/rearport.md!}
{!docs/models/dcim/devicebay.md!}
{!docs/models/dcim/inventoryitem.md!}
{!models/dcim/consoleport.md!}
{!models/dcim/consoleserverport.md!}
{!models/dcim/powerport.md!}
{!models/dcim/poweroutlet.md!}
{!models/dcim/interface.md!}
{!models/dcim/frontport.md!}
{!models/dcim/rearport.md!}
{!models/dcim/devicebay.md!}
{!models/dcim/inventoryitem.md!}
---
{!docs/models/dcim/virtualchassis.md!}
{!models/dcim/virtualchassis.md!}
---
{!docs/models/dcim/cable.md!}
{!models/dcim/cable.md!}

View File

@@ -1,19 +1,19 @@
# IP Address Management
{!docs/models/ipam/aggregate.md!}
{!docs/models/ipam/rir.md!}
{!models/ipam/aggregate.md!}
{!models/ipam/rir.md!}
---
{!docs/models/ipam/prefix.md!}
{!docs/models/ipam/role.md!}
{!models/ipam/prefix.md!}
{!models/ipam/role.md!}
---
{!docs/models/ipam/iprange.md!}
{!docs/models/ipam/ipaddress.md!}
{!models/ipam/iprange.md!}
{!models/ipam/ipaddress.md!}
---
{!docs/models/ipam/vrf.md!}
{!docs/models/ipam/routetarget.md!}
{!models/ipam/vrf.md!}
{!models/ipam/routetarget.md!}

View File

@@ -1,8 +1,8 @@
# Power Tracking
{!docs/models/dcim/powerpanel.md!}
{!docs/models/dcim/powerfeed.md!}
{!models/dcim/powerpanel.md!}
{!models/dcim/powerfeed.md!}
# Example Power Topology
![Power distribution model](../../media/power_distribution.png)
![Power distribution model](/media/power_distribution.png)

View File

@@ -1,3 +1,3 @@
# Service Mapping
{!docs/models/ipam/service.md!}
{!models/ipam/service.md!}

View File

@@ -1,12 +1,12 @@
# Sites and Racks
{!docs/models/dcim/region.md!}
{!docs/models/dcim/sitegroup.md!}
{!docs/models/dcim/site.md!}
{!docs/models/dcim/location.md!}
{!models/dcim/region.md!}
{!models/dcim/sitegroup.md!}
{!models/dcim/site.md!}
{!models/dcim/location.md!}
---
{!docs/models/dcim/rack.md!}
{!docs/models/dcim/rackrole.md!}
{!docs/models/dcim/rackreservation.md!}
{!models/dcim/rack.md!}
{!models/dcim/rackrole.md!}
{!models/dcim/rackreservation.md!}

View File

@@ -1,4 +1,4 @@
# Tenancy Assignment
{!docs/models/tenancy/tenant.md!}
{!docs/models/tenancy/tenantgroup.md!}
{!models/tenancy/tenant.md!}
{!models/tenancy/tenantgroup.md!}

View File

@@ -1,10 +1,10 @@
# Virtualization
{!docs/models/virtualization/cluster.md!}
{!docs/models/virtualization/clustertype.md!}
{!docs/models/virtualization/clustergroup.md!}
{!models/virtualization/cluster.md!}
{!models/virtualization/clustertype.md!}
{!models/virtualization/clustergroup.md!}
---
{!docs/models/virtualization/virtualmachine.md!}
{!docs/models/virtualization/vminterface.md!}
{!models/virtualization/virtualmachine.md!}
{!models/virtualization/vminterface.md!}

View File

@@ -1,4 +1,4 @@
# VLAN Management
{!docs/models/ipam/vlan.md!}
{!docs/models/ipam/vlangroup.md!}
{!models/ipam/vlan.md!}
{!models/ipam/vlangroup.md!}

View File

@@ -226,7 +226,7 @@ An IPv4 or IPv6 network with a mask. Returns a `netaddr.IPNetwork` object. Two a
!!! note
To run a custom script, a user must be assigned the `extras.run_script` permission. This is achieved by assigning the user (or group) a permission on the Script object and specifying the `run` action in the admin UI as shown below.
![Adding the run action to a permission](../../media/admin_ui_run_permission.png)
![Adding the run action to a permission](/media/admin_ui_run_permission.png)
### Via the Web UI

View File

@@ -104,7 +104,7 @@ Once you have created a report, it will appear in the reports list. Initially, r
!!! note
To run a report, a user must be assigned the `extras.run_report` permission. This is achieved by assigning the user (or group) a permission on the Report object and specifying the `run` action in the admin UI as shown below.
![Adding the run action to a permission](../../media/admin_ui_run_permission.png)
![Adding the run action to a permission](/media/admin_ui_run_permission.png)
### Via the Web UI

View File

@@ -34,11 +34,11 @@ class Foo(models.Model):
## 3. Update relevant querysets
If you're adding a relational field (e.g. `ForeignKey`) and intend to include the data when retreiving a list of objects, be sure to include the field using `prefetch_related()` as appropriate. This will optimize the view and avoid extraneous database queries.
If you're adding a relational field (e.g. `ForeignKey`) and intend to include the data when retrieving a list of objects, be sure to include the field using `prefetch_related()` as appropriate. This will optimize the view and avoid extraneous database queries.
## 4. Update API serializer
Extend the model's API serializer in `<app>.api.serializers` to include the new field. In most cases, it will not be necessary to also extend the nested serializer, which produces a minimal represenation of the model.
Extend the model's API serializer in `<app>.api.serializers` to include the new field. In most cases, it will not be necessary to also extend the nested serializer, which produces a minimal representation of the model.
## 5. Add field to forms

View File

@@ -11,7 +11,7 @@ curl -H "Authorization: Token $TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
http://netbox/graphql/ \
--data '{"query": "query {circuits(status:\"active\" {cid provider {name}}}"}'
--data '{"query": "query {circuit_list(status:\"active\") {cid provider {name}}}"}'
```
The response will include the requested data formatted as JSON:
@@ -45,7 +45,7 @@ NetBox provides both a singular and plural query field for each object type:
* `$OBJECT`: Returns a single object. Must specify the object's unique ID as `(id: 123)`.
* `$OBJECT_list`: Returns a list of objects, optionally filtered by given parameters.
For example, query `device(id:123)` to fetch a specific device (identified by its unique ID), and query `device_list` (with an optional set of fitlers) to fetch all devices.
For example, query `device(id:123)` to fetch a specific device (identified by its unique ID), and query `device_list` (with an optional set of filters) to fetch all devices.
For more detail on constructing GraphQL queries, see the [Graphene documentation](https://docs.graphene-python.org/en/latest/).
@@ -54,7 +54,7 @@ For more detail on constructing GraphQL queries, see the [Graphene documentation
The GraphQL API employs the same filtering logic as the UI and REST API. Filters can be specified as key-value pairs within parentheses immediately following the query name. For example, the following will return only sites within the North Carolina region with a status of active:
```
{"query": "query {sites(region:\"north-carolina\", status:\"active\") {name}}"}
{"query": "query {site_list(region:\"north-carolina\", status:\"active\") {name}}"}
```
## Authentication

View File

@@ -10,7 +10,6 @@ NetBox is an infrastructure resource modeling (IRM) application designed to empo
* **Connections** - Network, console, and power connections among devices
* **Virtualization** - Virtual machines and clusters
* **Data circuits** - Long-haul communications circuits and providers
* **Secrets** - Encrypted storage of sensitive credentials
## What NetBox Is Not

View File

@@ -70,19 +70,22 @@ If `git` is not already installed, install it:
Next, clone the **master** branch of the NetBox GitHub repository into the current directory. (This branch always holds the current stable release.)
```no-highlight
sudo git clone -b master https://github.com/netbox-community/netbox.git .
sudo git clone -b master --depth 1 https://github.com/netbox-community/netbox.git .
```
!!! note
The `git clone` command above utilizes a "shallow clone" to retrieve only the most recent commit. If you need to download the entire history, omit the `--depth 1` argument.
The `git clone` command should generate output similar to the following:
```
Cloning into '.'...
remote: Counting objects: 1994, done.
remote: Compressing objects: 100% (150/150), done.
remote: Total 1994 (delta 80), reused 0 (delta 0), pack-reused 1842
Receiving objects: 100% (1994/1994), 472.36 KiB | 0 bytes/s, done.
Resolving deltas: 100% (1495/1495), done.
Checking connectivity... done.
remote: Enumerating objects: 996, done.
remote: Counting objects: 100% (996/996), done.
remote: Compressing objects: 100% (935/935), done.
remote: Total 996 (delta 148), reused 386 (delta 34), pack-reused 0
Receiving objects: 100% (996/996), 4.26 MiB | 9.81 MiB/s, done.
Resolving deltas: 100% (148/148), done.
```
!!! note

View File

@@ -14,7 +14,7 @@ While the provided configuration should suffice for most initial installations,
## systemd Setup
We'll use systemd to control both gunicorn and NetBox's background worker process. First, copy `contrib/netbox.service` and `contrib/netbox-rq.service` to the `/etc/systemd/system/` directory and reload the systemd dameon:
We'll use systemd to control both gunicorn and NetBox's background worker process. First, copy `contrib/netbox.service` and `contrib/netbox-rq.service` to the `/etc/systemd/system/` directory and reload the systemd daemon:
```no-highlight
sudo cp -v /opt/netbox/contrib/*.service /etc/systemd/system/

View File

@@ -13,7 +13,7 @@ The following sections detail how to set up a new instance of NetBox:
The video below demonstrates the installation of NetBox v2.10.3 on Ubuntu 20.04 for your reference.
<iframe width="560" height="315" src="https://www.youtube.com/embed/dFANGlxXEng" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
<iframe width="560" height="315" src="https://www.youtube.com/embed/7Fpd2-q9_28" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
## Requirements

View File

@@ -25,7 +25,7 @@ A cable may be traced from either of its endpoints by clicking the "trace" butto
In the example below, three individual cables comprise a path between devices A and D:
![Cable path](../../media/models/dcim_cable_trace.png)
![Cable path](/media/models/dcim_cable_trace.png)
Traced from Interface 1 on Device A, NetBox will show the following path:

View File

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

View File

@@ -17,12 +17,12 @@ However, keep in mind that each piece of functionality is entirely optional. For
## Initial Setup
## Plugin Structure
### Plugin Structure
Although the specific structure of a plugin is largely left to the discretion of its authors, a typical NetBox plugin looks something like this:
```no-highlight
plugin_name/
project-name/
- plugin_name/
- templates/
- plugin_name/
@@ -38,13 +38,13 @@ plugin_name/
- setup.py
```
The top level is the project root. Immediately within the root should exist several items:
The top level is the project root, which can have any name that you like. Immediately within the root should exist several items:
* `setup.py` - This is a standard installation script used to install the plugin package within the Python environment.
* `README` - A brief introduction to your plugin, how to install and configure it, where to find help, and any other pertinent information. It is recommended to write README files using a markup language such as Markdown.
* The plugin source directory, with the same name as your plugin.
* The plugin source directory, with the same name as your plugin. This must be a valid Python package name (e.g. no spaces or hyphens).
The plugin source directory contains all of the actual Python code and other resources used by your plugin. Its structure is left to the author's discretion, however it is recommended to follow best practices as outlined in the [Django documentation](https://docs.djangoproject.com/en/stable/intro/reusable-apps/). At a minimum, this directory **must** contain an `__init__.py` file containing an instance of NetBox's `PluginConfig` class.
The plugin source directory contains all the actual Python code and other resources used by your plugin. Its structure is left to the author's discretion, however it is recommended to follow best practices as outlined in the [Django documentation](https://docs.djangoproject.com/en/stable/intro/reusable-apps/). At a minimum, this directory **must** contain an `__init__.py` file containing an instance of NetBox's `PluginConfig` class.
### Create setup.py
@@ -118,6 +118,21 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i
All required settings must be configured by the user. If a configuration parameter is listed in both `required_settings` and `default_settings`, the default setting will be ignored.
### Create a Virtual Environment
It is strongly recommended to create a Python [virtual environment](https://docs.python.org/3/tutorial/venv.html) specific to your plugin. This will afford you complete control over the installed versions of all dependencies and avoid conflicting with any system packages. This environment can live wherever you'd like, however it should be excluded from revision control. (A popular convention is to keep all virtual environments in the user's home directory, e.g. `~/.virtualenvs/`.)
```shell
python3 -m venv /path/to/my/venv
```
You can make NetBox available within this environment by creating a path file pointing to its location. This will add NetBox to the Python path upon activation. (Be sure to adjust the command below to specify your actual virtual environment path, Python version, and NetBox installation.)
```shell
cd $VENV/lib/python3.7/site-packages/
echo /opt/netbox/netbox > netbox.pth
```
### Install the Plugin for Development
To ease development, it is recommended to go ahead and install the plugin at this point using setuptools' `develop` mode. This will create symbolic links within your Python environment to the plugin development directory. Call `setup.py` from the plugin's root directory with the `develop` argument (instead of `install`):
@@ -218,7 +233,7 @@ NetBox provides a base template to ensure a consistent user experience, which pl
For more information on how template blocks work, consult the [Django documentation](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#block).
```jinja2
{% extends 'base.html' %}
{% extends 'base/layout.html' %}
{% block content %}
{% with config=settings.PLUGINS_CONFIG.netbox_animal_sounds %}

View File

@@ -1,5 +1,86 @@
# NetBox v3.0
## v3.0.3 (2021-09-20)
### Enhancements
* [#5775](https://github.com/netbox-community/netbox/issues/5775) - Enable synchronization of groups for remote authentication backend
* [#6387](https://github.com/netbox-community/netbox/issues/6387) - Add xDSL interface type
* [#6988](https://github.com/netbox-community/netbox/issues/6988) - Order tenants alphabetically without regard to group assignment
* [#7032](https://github.com/netbox-community/netbox/issues/7032) - Add URM port types
* [#7087](https://github.com/netbox-community/netbox/issues/7087) - Add `local_context_data` filter for virtual machines list
* [#7208](https://github.com/netbox-community/netbox/issues/7208) - Add navigation breadcrumbs for custom scripts & reports
* [#7210](https://github.com/netbox-community/netbox/issues/7210) - Add search/filter forms for all organizational models
* [#7239](https://github.com/netbox-community/netbox/issues/7239) - Redirect global search to filtered object list when an object type is selected
* [#7284](https://github.com/netbox-community/netbox/issues/7284) - Include comments field in table/export for all appropriate models
### Bug Fixes
* [#7167](https://github.com/netbox-community/netbox/issues/7167) - Ensure consistent font size when using monospace formatting
* [#7226](https://github.com/netbox-community/netbox/issues/7226) - Exempt GraphQL API requests from CSRF inspection
* [#7228](https://github.com/netbox-community/netbox/issues/7228) - Improve temperature conversions under device status
* [#7248](https://github.com/netbox-community/netbox/issues/7248) - Fix global search results section links
* [#7266](https://github.com/netbox-community/netbox/issues/7266) - Tweak font color for form field placeholder text
* [#7273](https://github.com/netbox-community/netbox/issues/7273) - Fix natural ordering of device components in UI form fields
* [#7279](https://github.com/netbox-community/netbox/issues/7279) - Fix exception when tracing cable with no associated path
* [#7282](https://github.com/netbox-community/netbox/issues/7282) - Fix KeyError exception when `INSECURE_SKIP_TLS_VERIFY` is true
* [#7298](https://github.com/netbox-community/netbox/issues/7298) - Restore missing object names from applied object list filters
* [#7301](https://github.com/netbox-community/netbox/issues/7301) - Fix exception when deleting a large number of child prefixes
---
## v3.0.2 (2021-09-08)
### Bug Fixes
* [#7131](https://github.com/netbox-community/netbox/issues/7131) - Fix issue where Site fields were hidden when editing a VLAN group
* [#7148](https://github.com/netbox-community/netbox/issues/7148) - Fix issue where static query parameters with multiple values were not queried properly
* [#7153](https://github.com/netbox-community/netbox/issues/7153) - Allow clearing of assigned device type images
* [#7162](https://github.com/netbox-community/netbox/issues/7162) - Ensure consistent treatment of `BASE_PATH` for UI-driven API requests
* [#7164](https://github.com/netbox-community/netbox/issues/7164) - Fix styling of "decommissioned" label for circuits
* [#7169](https://github.com/netbox-community/netbox/issues/7169) - Fix CSV import file upload
* [#7176](https://github.com/netbox-community/netbox/issues/7176) - Fix issue where query parameters were duplicated across different forms of the same type
* [#7179](https://github.com/netbox-community/netbox/issues/7179) - Prevent obscuring "connect" pop-up for interfaces under device view
* [#7188](https://github.com/netbox-community/netbox/issues/7188) - Fix issue where select fields with `null_option` did not render or send the null option
* [#7189](https://github.com/netbox-community/netbox/issues/7189) - Set connection factory for django-redis when Sentinel is in use
* [#7191](https://github.com/netbox-community/netbox/issues/7191) - Fix issue where API-backed multi-select elements cleared selected options when adding new options
* [#7193](https://github.com/netbox-community/netbox/issues/7193) - Fix prefix (flat) template issue when viewing child prefixes with prefixes available
* [#7205](https://github.com/netbox-community/netbox/issues/7205) - Fix issue where selected fields with `null_option` set were not added to applied filters
* [#7209](https://github.com/netbox-community/netbox/issues/7209) - Allow unlimited API results when `MAX_PAGE_SIZE` is disabled
---
## v3.0.1 (2021-09-01)
### Bug Fixes
* [#7041](https://github.com/netbox-community/netbox/issues/7041) - Properly format JSON config object returned from a NAPALM device
* [#7070](https://github.com/netbox-community/netbox/issues/7070) - Fix exception when filtering by prefix max length in UI
* [#7071](https://github.com/netbox-community/netbox/issues/7071) - Fix exception when removing a primary IP from a device/VM
* [#7072](https://github.com/netbox-community/netbox/issues/7072) - Fix table configuration under prefix child object views
* [#7075](https://github.com/netbox-community/netbox/issues/7075) - Fix UI bug when a custom field has a space in the name
* [#7080](https://github.com/netbox-community/netbox/issues/7080) - Fix missing image previews
* [#7081](https://github.com/netbox-community/netbox/issues/7081) - Fix UI bug that did not properly request and handle paginated data
* [#7082](https://github.com/netbox-community/netbox/issues/7082) - Avoid exception when referencing invalid content type in table
* [#7083](https://github.com/netbox-community/netbox/issues/7083) - Correct labeling for VM memory attribute
* [#7084](https://github.com/netbox-community/netbox/issues/7084) - Fix KeyError exception when editing access VLAN on an interface
* [#7084](https://github.com/netbox-community/netbox/issues/7084) - Fix issue where hidden VLAN form fields were incorrectly included in the form submission
* [#7089](https://github.com/netbox-community/netbox/issues/7089) - Fix filtering of change log by content type
* [#7090](https://github.com/netbox-community/netbox/issues/7090) - Allow decimal input on length field when bulk editing cables
* [#7091](https://github.com/netbox-community/netbox/issues/7091) - Ensure API requests from the UI are aware of `BASE_PATH`
* [#7092](https://github.com/netbox-community/netbox/issues/7092) - Fix missing bulk edit buttons on Prefix IP Addresses table
* [#7093](https://github.com/netbox-community/netbox/issues/7093) - Multi-select custom field filters should employ exact match
* [#7096](https://github.com/netbox-community/netbox/issues/7096) - Home links should honor `BASE_PATH` configuration
* [#7101](https://github.com/netbox-community/netbox/issues/7101) - Enforce `MAX_PAGE_SIZE` for table and REST API pagination
* [#7106](https://github.com/netbox-community/netbox/issues/7106) - Fix incorrect "Map It" button URL on a site's physical address field
* [#7107](https://github.com/netbox-community/netbox/issues/7107) - Fix missing search button and search results in IP address assignment "Assign IP" tab
* [#7109](https://github.com/netbox-community/netbox/issues/7109) - Ensure human readability of exceptions raised during REST API requests
* [#7113](https://github.com/netbox-community/netbox/issues/7113) - Show bulk edit/delete actions for prefix child objects
* [#7123](https://github.com/netbox-community/netbox/issues/7123) - Remove "Global" placeholder for null VRF field
* [#7124](https://github.com/netbox-community/netbox/issues/7124) - Fix duplicate static query param values in API Select
---
## v3.0.0 (2021-08-30)
!!! warning "Existing Deployments Must Upgrade from v2.11"

View File

@@ -2,7 +2,7 @@
The NetBox REST API primarily employs token-based authentication. For convenience, cookie-based authentication can also be used when navigating the browsable API.
{!docs/models/users/token.md!}
{!models/users/token.md!}
## Authenticating to the API

View File

@@ -3,9 +3,6 @@ site_dir: netbox/project-static/docs
site_url: https://netbox.readthedocs.io/
repo_name: netbox-community/netbox
repo_url: https://github.com/netbox-community/netbox
python:
install:
- requirements: docs/requirements.txt
theme:
name: material
icon:
@@ -24,13 +21,14 @@ extra:
- icon: fontawesome/brands/github
link: https://github.com/netbox-community/netbox
- icon: fontawesome/brands/slack
link: https://slack.netbox.dev
link: https://netdev.chat/
extra_css:
- extra.css
markdown_extensions:
- admonition
- attr_list
- markdown_include.include:
base_path: 'docs/'
headingOffset: 1
- pymdownx.emoji:
emoji_index: !!python/name:materialx.emoji.twemoji

View File

@@ -1 +0,0 @@
default_app_config = 'circuits.apps.CircuitsConfig'

View File

@@ -29,7 +29,7 @@ class CircuitStatusChoices(ChoiceSet):
STATUS_PLANNED: 'info',
STATUS_PROVISIONING: 'primary',
STATUS_OFFLINE: 'danger',
STATUS_DECOMMISSIONED: 'default',
STATUS_DECOMMISSIONED: 'secondary',
}

View File

@@ -266,6 +266,18 @@ class CircuitTypeCSVForm(CustomFieldModelCSVForm):
}
class CircuitTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
model = CircuitType
field_groups = [
['q'],
]
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
#
# Circuits
#

View File

@@ -2,10 +2,18 @@ import django_tables2 as tables
from django_tables2.utils import Accessor
from tenancy.tables import TenantColumn
from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, TagColumn, ToggleColumn
from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, MarkdownColumn, TagColumn, ToggleColumn
from .models import *
__all__ = (
'CircuitTable',
'CircuitTypeTable',
'ProviderTable',
'ProviderNetworkTable',
)
CIRCUITTERMINATION_LINK = """
{% if value.site %}
<a href="{{ value.site.get_absolute_url }}">{{ value.site }}</a>
@@ -28,6 +36,7 @@ class ProviderTable(BaseTable):
accessor=Accessor('count_circuits'),
verbose_name='Circuits'
)
comments = MarkdownColumn()
tags = TagColumn(
url_name='circuits:provider_list'
)
@@ -35,7 +44,8 @@ class ProviderTable(BaseTable):
class Meta(BaseTable.Meta):
model = Provider
fields = (
'pk', 'name', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'circuit_count', 'tags',
'pk', 'name', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'circuit_count', 'comments',
'tags',
)
default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count')
@@ -52,13 +62,14 @@ class ProviderNetworkTable(BaseTable):
provider = tables.Column(
linkify=True
)
comments = MarkdownColumn()
tags = TagColumn(
url_name='circuits:providernetwork_list'
)
class Meta(BaseTable.Meta):
model = ProviderNetwork
fields = ('pk', 'name', 'provider', 'description', 'tags')
fields = ('pk', 'name', 'provider', 'description', 'comments', 'tags')
default_columns = ('pk', 'name', 'provider', 'description')
@@ -105,6 +116,7 @@ class CircuitTable(BaseTable):
template_code=CIRCUITTERMINATION_LINK,
verbose_name='Side Z'
)
comments = MarkdownColumn()
tags = TagColumn(
url_name='circuits:circuit_list'
)
@@ -113,7 +125,7 @@ class CircuitTable(BaseTable):
model = Circuit
fields = (
'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date',
'commit_rate', 'description', 'tags',
'commit_rate', 'description', 'comments', 'tags',
)
default_columns = (
'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description',

View File

@@ -34,9 +34,7 @@ class ProviderView(generic.ObjectView):
).prefetch_related(
'type', 'tenant', 'terminations__site'
)
circuits_table = tables.CircuitTable(circuits)
circuits_table.columns.hide('provider')
circuits_table = tables.CircuitTable(circuits, exclude=('provider',))
paginate_table(circuits_table, request)
return {
@@ -97,10 +95,7 @@ class ProviderNetworkView(generic.ObjectView):
).prefetch_related(
'type', 'tenant', 'terminations__site'
)
circuits_table = tables.CircuitTable(circuits)
circuits_table.columns.hide('termination_a')
circuits_table.columns.hide('termination_z')
paginate_table(circuits_table, request)
return {
@@ -144,6 +139,8 @@ class CircuitTypeListView(generic.ObjectListView):
queryset = CircuitType.objects.annotate(
circuit_count=count_related(Circuit, 'type')
)
filterset = filtersets.CircuitTypeFilterSet
filterset_form = forms.CircuitTypeFilterForm
table = tables.CircuitTypeTable
@@ -151,12 +148,8 @@ class CircuitTypeView(generic.ObjectView):
queryset = CircuitType.objects.all()
def get_extra_context(self, request, instance):
circuits = Circuit.objects.restrict(request.user, 'view').filter(
type=instance
)
circuits_table = tables.CircuitTable(circuits)
circuits_table.columns.hide('type')
circuits = Circuit.objects.restrict(request.user, 'view').filter(type=instance)
circuits_table = tables.CircuitTable(circuits, exclude=('type',))
paginate_table(circuits_table, request)
return {

View File

@@ -1 +0,0 @@
default_app_config = 'dcim.apps.DCIMConfig'

View File

@@ -22,7 +22,7 @@ from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.exceptions import ServiceUnavailable
from netbox.api.metadata import ContentTypeMetadata
from utilities.api import get_serializer_for_model
from utilities.utils import count_related
from utilities.utils import count_related, decode_dict
from virtualization.models import VirtualMachine
from . import serializers
from .exceptions import MissingFilterException
@@ -498,7 +498,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
response[method] = {'error': 'Only get_* NAPALM methods are supported'}
continue
try:
response[method] = getattr(d, method)()
response[method] = decode_dict(getattr(d, method)())
except NotImplementedError:
response[method] = {'error': 'Method {} not implemented for NAPALM driver {}'.format(method, driver)}
except Exception as e:

View File

@@ -761,6 +761,9 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_T3 = 't3'
TYPE_E3 = 'e3'
# ATM/DSL
TYPE_XDSL = 'xdsl'
# Stacking
TYPE_STACKWISE = 'cisco-stackwise'
TYPE_STACKWISE_PLUS = 'cisco-stackwise-plus'
@@ -885,6 +888,12 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_E3, 'E3 (34 Mbps)'),
)
),
(
'ATM',
(
(TYPE_XDSL, 'xDSL'),
)
),
(
'Stacking',
(
@@ -958,6 +967,9 @@ class PortTypeChoices(ChoiceSet):
TYPE_SPLICE = 'splice'
TYPE_CS = 'cs'
TYPE_SN = 'sn'
TYPE_URM_P2 = 'urm-p2'
TYPE_URM_P4 = 'urm-p4'
TYPE_URM_P8 = 'urm-p8'
CHOICES = (
(
@@ -998,6 +1010,9 @@ class PortTypeChoices(ChoiceSet):
(TYPE_ST, 'ST'),
(TYPE_CS, 'CS'),
(TYPE_SN, 'SN'),
(TYPE_URM_P2, 'URM-P2'),
(TYPE_URM_P4, 'URM-P4'),
(TYPE_URM_P8, 'URM-P8'),
(TYPE_SPLICE, 'Splice'),
)
)

View File

@@ -23,10 +23,10 @@ from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
ColorField, CommentField, CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField,
DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField,
NumericArrayField, SelectWithPK, SmallTextarea, SlugField, StaticSelect, StaticSelectMultiple, TagFilterField,
BOOLEAN_WITH_BLANK_CHOICES,
ClearableFileInput, ColorField, CommentField, CSVChoiceField, CSVContentTypeField, CSVModelChoiceField,
CSVTypedChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model,
JSONField, NumericArrayField, SelectWithPK, SmallTextarea, SlugField, StaticSelect, StaticSelectMultiple,
TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
)
from virtualization.models import Cluster, ClusterGroup
from .choices import *
@@ -129,7 +129,7 @@ class InterfaceCommonForm(forms.Form):
super().clean()
parent_field = 'device' if 'device' in self.cleaned_data else 'virtual_machine'
tagged_vlans = self.cleaned_data['tagged_vlans']
tagged_vlans = self.cleaned_data.get('tagged_vlans')
# Untagged interfaces cannot be assigned tagged VLANs
if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and tagged_vlans:
@@ -142,7 +142,7 @@ class InterfaceCommonForm(forms.Form):
self.cleaned_data['tagged_vlans'] = []
# Validate tagged VLANs; must be a global VLAN or in the same site
elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED:
elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED and tagged_vlans:
valid_sites = [None, self.cleaned_data[parent_field].site]
invalid_vlans = [str(v) for v in tagged_vlans if v.site not in valid_sites]
@@ -696,6 +696,18 @@ class RackRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
nullable_fields = ['color', 'description']
class RackRoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
model = RackRole
field_groups = [
['q'],
]
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
#
# Racks
#
@@ -1240,6 +1252,18 @@ class ManufacturerBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
nullable_fields = ['description']
class ManufacturerFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
model = Manufacturer
field_groups = [
['q'],
]
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
#
# Device types
#
@@ -1271,10 +1295,10 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm):
)
widgets = {
'subdevice_role': StaticSelect(),
'front_image': forms.ClearableFileInput(attrs={
'front_image': ClearableFileInput(attrs={
'accept': DEVICETYPE_IMAGE_FORMATS
}),
'rear_image': forms.ClearableFileInput(attrs={
'rear_image': ClearableFileInput(attrs={
'accept': DEVICETYPE_IMAGE_FORMATS
})
}
@@ -2076,6 +2100,18 @@ class DeviceRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
nullable_fields = ['color', 'description']
class DeviceRoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
model = DeviceRole
field_groups = [
['q'],
]
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
#
# Platforms
#
@@ -2202,9 +2238,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
api_url='/api/dcim/racks/{{rack}}/elevation/',
attrs={
'disabled-indicator': 'device',
'data-query-param-face': "[\"$face\"]",
# The UI will not sort this element's options.
'pre-sorted': ''
'data-query-param-face': "[\"$face\"]"
}
)
)
@@ -4586,8 +4620,8 @@ class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkE
color = ColorField(
required=False
)
length = forms.IntegerField(
min_value=1,
length = forms.DecimalField(
min_value=0,
required=False
)
length_unit = forms.ChoiceField(

View File

@@ -9,7 +9,7 @@ from dcim.models import (
from tenancy.tables import TenantColumn
from utilities.tables import (
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn,
TagColumn, ToggleColumn,
MarkdownColumn, TagColumn, ToggleColumn,
)
from .template_code import (
CABLETERMINATION, CONSOLEPORT_BUTTONS, CONSOLESERVERPORT_BUTTONS, DEVICE_LINK, DEVICEBAY_BUTTONS, DEVICEBAY_STATUS,
@@ -18,6 +18,7 @@ from .template_code import (
)
__all__ = (
'BaseInterfaceTable',
'ConsolePortTable',
'ConsoleServerPortTable',
'DeviceBayTable',
@@ -187,6 +188,7 @@ class DeviceTable(BaseTable):
vc_priority = tables.Column(
verbose_name='VC Priority'
)
comments = MarkdownColumn()
tags = TagColumn(
url_name='dcim:device_list'
)
@@ -196,7 +198,7 @@ class DeviceTable(BaseTable):
fields = (
'pk', 'name', 'status', 'tenant', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial',
'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'primary_ip4', 'primary_ip6',
'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'tags',
'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags',
)
default_columns = (
'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type',

View File

@@ -5,7 +5,7 @@ from dcim.models import (
Manufacturer, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
)
from utilities.tables import (
BaseTable, BooleanColumn, ButtonsColumn, ColorColumn, LinkedCountColumn, TagColumn, ToggleColumn,
BaseTable, BooleanColumn, ButtonsColumn, ColorColumn, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn,
)
__all__ = (
@@ -68,6 +68,7 @@ class DeviceTypeTable(BaseTable):
url_params={'device_type_id': 'pk'},
verbose_name='Instances'
)
comments = MarkdownColumn()
tags = TagColumn(
url_name='dcim:devicetype_list'
)
@@ -76,7 +77,7 @@ class DeviceTypeTable(BaseTable):
model = DeviceType
fields = (
'pk', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
'instance_count', 'tags',
'comments', 'instance_count', 'tags',
)
default_columns = (
'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count',

View File

@@ -1,7 +1,7 @@
import django_tables2 as tables
from dcim.models import PowerFeed, PowerPanel
from utilities.tables import BaseTable, ChoiceFieldColumn, LinkedCountColumn, TagColumn, ToggleColumn
from utilities.tables import BaseTable, ChoiceFieldColumn, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn
from .devices import CableTerminationTable
__all__ = (
@@ -62,6 +62,7 @@ class PowerFeedTable(CableTerminationTable):
available_power = tables.Column(
verbose_name='Available power (VA)'
)
comments = MarkdownColumn()
tags = TagColumn(
url_name='dcim:powerfeed_list'
)
@@ -71,7 +72,7 @@ class PowerFeedTable(CableTerminationTable):
fields = (
'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase',
'max_utilization', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'available_power',
'tags',
'comments', 'tags',
)
default_columns = (
'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable',

View File

@@ -4,13 +4,12 @@ from django_tables2.utils import Accessor
from dcim.models import Rack, RackReservation, RackRole
from tenancy.tables import TenantColumn
from utilities.tables import (
BaseTable, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, TagColumn,
ToggleColumn, UtilizationColumn,
BaseTable, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn,
TagColumn, ToggleColumn, UtilizationColumn,
)
__all__ = (
'RackTable',
'RackDetailTable',
'RackReservationTable',
'RackRoleTable',
)
@@ -56,17 +55,7 @@ class RackTable(BaseTable):
template_code="{{ record.u_height }}U",
verbose_name='Height'
)
class Meta(BaseTable.Meta):
model = Rack
fields = (
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type',
'width', 'u_height',
)
default_columns = ('pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height')
class RackDetailTable(RackTable):
comments = MarkdownColumn()
device_count = LinkedCountColumn(
viewname='dcim:device_list',
url_params={'rack_id': 'pk'},
@@ -84,10 +73,11 @@ class RackDetailTable(RackTable):
url_name='dcim:rack_list'
)
class Meta(RackTable.Meta):
class Meta(BaseTable.Meta):
model = Rack
fields = (
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type',
'width', 'u_height', 'device_count', 'get_utilization', 'get_power_utilization', 'tags',
'width', 'u_height', 'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'tags',
)
default_columns = (
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',

View File

@@ -3,7 +3,7 @@ import django_tables2 as tables
from dcim.models import Location, Region, Site, SiteGroup
from tenancy.tables import TenantColumn
from utilities.tables import (
BaseTable, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, MPTTColumn, TagColumn, ToggleColumn,
BaseTable, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, MarkdownColumn, MPTTColumn, TagColumn, ToggleColumn,
)
from .template_code import LOCATION_ELEVATIONS
@@ -76,6 +76,7 @@ class SiteTable(BaseTable):
linkify=True
)
tenant = TenantColumn()
comments = MarkdownColumn()
tags = TagColumn(
url_name='dcim:site_list'
)
@@ -85,7 +86,7 @@ class SiteTable(BaseTable):
fields = (
'pk', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn', 'time_zone', 'description',
'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone',
'contact_email', 'tags',
'contact_email', 'comments', 'tags',
)
default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'asn', 'description')

View File

@@ -131,8 +131,7 @@ class RegionView(generic.ObjectView):
sites = Site.objects.restrict(request.user, 'view').filter(
region=instance
)
sites_table = tables.SiteTable(sites)
sites_table.columns.hide('region')
sites_table = tables.SiteTable(sites, exclude=('region',))
paginate_table(sites_table, request)
return {
@@ -216,8 +215,7 @@ class SiteGroupView(generic.ObjectView):
sites = Site.objects.restrict(request.user, 'view').filter(
group=instance
)
sites_table = tables.SiteTable(sites)
sites_table.columns.hide('group')
sites_table = tables.SiteTable(sites, exclude=('group',))
paginate_table(sites_table, request)
return {
@@ -440,6 +438,8 @@ class RackRoleListView(generic.ObjectListView):
queryset = RackRole.objects.annotate(
rack_count=count_related(Rack, 'role')
)
filterset = filtersets.RackRoleFilterSet
filterset_form = forms.RackRoleFilterForm
table = tables.RackRoleTable
@@ -451,8 +451,7 @@ class RackRoleView(generic.ObjectView):
role=instance
)
racks_table = tables.RackTable(racks)
racks_table.columns.hide('role')
racks_table = tables.RackTable(racks, exclude=('role', 'get_utilization', 'get_power_utilization'))
paginate_table(racks_table, request)
return {
@@ -503,7 +502,7 @@ class RackListView(generic.ObjectListView):
)
filterset = filtersets.RackFilterSet
filterset_form = forms.RackFilterForm
table = tables.RackDetailTable
table = tables.RackTable
class RackElevationListView(generic.ObjectListView):
@@ -684,6 +683,8 @@ class ManufacturerListView(generic.ObjectListView):
inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
platform_count=count_related(Platform, 'manufacturer')
)
filterset = filtersets.ManufacturerFilterSet
filterset_form = forms.ManufacturerFilterForm
table = tables.ManufacturerTable
@@ -700,8 +701,7 @@ class ManufacturerView(generic.ObjectView):
manufacturer=instance
)
devicetypes_table = tables.DeviceTypeTable(devicetypes)
devicetypes_table.columns.hide('manufacturer')
devicetypes_table = tables.DeviceTypeTable(devicetypes, exclude=('manufacturer',))
paginate_table(devicetypes_table, request)
return {
@@ -1149,6 +1149,8 @@ class DeviceRoleListView(generic.ObjectListView):
device_count=count_related(Device, 'device_role'),
vm_count=count_related(VirtualMachine, 'role')
)
filterset = filtersets.DeviceRoleFilterSet
filterset_form = forms.DeviceRoleFilterForm
table = tables.DeviceRoleTable
@@ -1159,9 +1161,7 @@ class DeviceRoleView(generic.ObjectView):
devices = Device.objects.restrict(request.user, 'view').filter(
device_role=instance
)
devices_table = tables.DeviceTable(devices)
devices_table.columns.hide('device_role')
devices_table = tables.DeviceTable(devices, exclude=('device_role',))
paginate_table(devices_table, request)
return {
@@ -1225,9 +1225,7 @@ class PlatformView(generic.ObjectView):
devices = Device.objects.restrict(request.user, 'view').filter(
platform=instance
)
devices_table = tables.DeviceTable(devices)
devices_table.columns.hide('platform')
devices_table = tables.DeviceTable(devices, exclude=('platform',))
paginate_table(devices_table, request)
return {
@@ -1872,9 +1870,9 @@ class InterfaceView(generic.ObjectView):
child_interfaces = Interface.objects.restrict(request.user, 'view').filter(parent=instance)
child_interfaces_tables = tables.InterfaceTable(
child_interfaces,
exclude=('device', 'parent'),
orderable=False
)
child_interfaces_tables.columns.hide('device')
# Get assigned VLANs and annotate whether each is tagged or untagged
vlans = []
@@ -2411,6 +2409,12 @@ class PathTraceView(generic.ObjectView):
else:
path = related_paths.first()
# No paths found
if path is None:
return {
'path': None
}
# Get the total length of the cable and whether the length is definitive (fully defined)
total_length, is_definitive = path.get_total_length() if path else (None, False)

View File

@@ -1 +0,0 @@
default_app_config = 'extras.apps.ExtrasConfig'

View File

@@ -14,6 +14,7 @@ EXACT_FILTER_TYPES = (
CustomFieldTypeChoices.TYPE_DATE,
CustomFieldTypeChoices.TYPE_INTEGER,
CustomFieldTypeChoices.TYPE_SELECT,
CustomFieldTypeChoices.TYPE_MULTISELECT,
)
@@ -35,7 +36,9 @@ class CustomFieldFilter(django_filters.Filter):
self.field_name = f'custom_field_data__{self.field_name}'
if custom_field.type not in EXACT_FILTER_TYPES:
if custom_field.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
self.lookup_expr = 'has_key'
elif custom_field.type not in EXACT_FILTER_TYPES:
if custom_field.filter_logic == CustomFieldFilterLogicChoices.FILTER_LOOSE:
self.lookup_expr = 'icontains'

View File

@@ -367,7 +367,19 @@ class JobResultFilterSet(BaseFilterSet):
#
class ContentTypeFilterSet(django_filters.FilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
class Meta:
model = ContentType
fields = ['id', 'app_label', 'model']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(app_label__icontains=value) |
Q(model__icontains=value)
)

View File

@@ -0,0 +1,26 @@
from django.db import migrations
def clear_secrets_changelog(apps, schema_editor):
"""
Delete all ObjectChange records referencing a model within the old secrets app (pre-v3.0).
"""
ContentType = apps.get_model('contenttypes', 'ContentType')
ObjectChange = apps.get_model('extras', 'ObjectChange')
content_type_ids = ContentType.objects.filter(app_label='secrets').values_list('id', flat=True)
ObjectChange.objects.filter(changed_object_type__in=content_type_ids).delete()
class Migration(migrations.Migration):
dependencies = [
('extras', '0061_extras_change_logging'),
]
operations = [
migrations.RunPython(
code=clear_secrets_changelog,
reverse_code=migrations.RunPython.noop
),
]

View File

@@ -3,10 +3,23 @@ from django.conf import settings
from utilities.tables import (
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ContentTypeColumn, ContentTypesColumn,
ToggleColumn,
MarkdownColumn, ToggleColumn,
)
from .models import *
__all__ = (
'ConfigContextTable',
'CustomFieldTable',
'CustomLinkTable',
'ExportTemplateTable',
'JournalEntryTable',
'ObjectChangeTable',
'ObjectJournalTable',
'TaggedItemTable',
'TagTable',
'WebhookTable',
)
CONFIGCONTEXT_ACTIONS = """
{% if perms.extras.change_configcontext %}
<a href="{% url 'extras:configcontext_edit' pk=record.pk %}" class="btn btn-sm btn-warning"><i class="mdi mdi-pencil" aria-hidden="true"></i></a>
@@ -232,6 +245,7 @@ class JournalEntryTable(ObjectJournalTable):
orderable=False,
verbose_name='Object'
)
comments = MarkdownColumn()
class Meta(BaseTable.Meta):
model = JournalEntry

View File

@@ -681,7 +681,12 @@ class CustomFieldFilterTest(TestCase):
cf.content_types.set([obj_type])
# Selection filtering
cf = CustomField(name='cf8', type=CustomFieldTypeChoices.TYPE_URL, choices=['Foo', 'Bar', 'Baz'])
cf = CustomField(name='cf8', type=CustomFieldTypeChoices.TYPE_SELECT, choices=['Foo', 'Bar', 'Baz'])
cf.save()
cf.content_types.set([obj_type])
# Multiselect filtering
cf = CustomField(name='cf9', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choices=['A', 'AA', 'B', 'C'])
cf.save()
cf.content_types.set([obj_type])
@@ -695,6 +700,7 @@ class CustomFieldFilterTest(TestCase):
'cf6': 'http://foo.example.com/',
'cf7': 'http://foo.example.com/',
'cf8': 'Foo',
'cf9': ['A', 'B'],
}),
Site(name='Site 2', slug='site-2', custom_field_data={
'cf1': 200,
@@ -705,9 +711,9 @@ class CustomFieldFilterTest(TestCase):
'cf6': 'http://bar.example.com/',
'cf7': 'http://bar.example.com/',
'cf8': 'Bar',
'cf9': ['AA', 'B'],
}),
Site(name='Site 3', slug='site-3', custom_field_data={
}),
Site(name='Site 3', slug='site-3'),
])
def test_filter_integer(self):
@@ -730,3 +736,10 @@ class CustomFieldFilterTest(TestCase):
def test_filter_select(self):
self.assertEqual(self.filterset({'cf_cf8': 'Foo'}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf8': 'Bar'}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf8': 'Baz'}, self.queryset).qs.count(), 0)
def test_filter_multiselect(self):
self.assertEqual(self.filterset({'cf_cf9': 'A'}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf9': 'B'}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf9': 'C'}, self.queryset).qs.count(), 0)

View File

@@ -1 +0,0 @@
default_app_config = 'ipam.apps.IPAMConfig'

View File

@@ -216,7 +216,7 @@ class PrefixFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
children = MultiValueNumberFilter(
field_name='_children'
)
mask_length = django_filters.NumberFilter(
mask_length = MultiValueNumberFilter(
field_name='prefix',
lookup_expr='net_mask_length'
)

View File

@@ -256,7 +256,17 @@ class RIRBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
nullable_fields = ['is_private', 'description']
class RIRFilterForm(BootstrapMixin, forms.Form):
class RIRFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
model = RIR
field_groups = [
['q'],
['is_private'],
]
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
is_private = forms.NullBooleanField(
required=False,
label=_('Private'),
@@ -413,6 +423,18 @@ class RoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
nullable_fields = ['description']
class RoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
model = Role
field_groups = [
['q'],
]
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
#
# Prefixes
#
@@ -491,11 +513,6 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
'status': StaticSelect(),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['vrf'].empty_label = 'Global'
class PrefixCSVForm(CustomFieldModelCSVForm):
vrf = CSVModelChoiceField(
@@ -658,11 +675,11 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilter
label=_('Address family'),
widget=StaticSelect()
)
mask_length = forms.ChoiceField(
mask_length = forms.MultipleChoiceField(
required=False,
choices=PREFIX_MASK_LENGTH_CHOICES,
label=_('Mask length'),
widget=StaticSelect()
widget=StaticSelectMultiple()
)
vrf_id = DynamicModelMultipleChoiceField(
queryset=VRF.objects.all(),
@@ -760,11 +777,6 @@ class IPRangeForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
'status': StaticSelect(),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['vrf'].empty_label = 'Global'
class IPRangeCSVForm(CustomFieldModelCSVForm):
vrf = CSVModelChoiceField(
@@ -1026,8 +1038,6 @@ class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
super().__init__(*args, **kwargs)
self.fields['vrf'].empty_label = 'Global'
# Initialize primary_for_parent if IP address is already assigned
if self.instance.pk and self.instance.assigned_object:
parent = self.instance.assigned_object.parent_object
@@ -1102,10 +1112,6 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
'role': StaticSelect(),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['vrf'].empty_label = 'Global'
class IPAddressCSVForm(CustomFieldModelCSVForm):
vrf = CSVModelChoiceField(
@@ -1256,8 +1262,7 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form):
vrf_id = DynamicModelChoiceField(
queryset=VRF.objects.all(),
required=False,
label='VRF',
empty_label='Global'
label='VRF'
)
q = forms.CharField(
required=False,
@@ -1477,11 +1482,12 @@ class VLANGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
nullable_fields = ['site', 'description']
class VLANGroupFilterForm(BootstrapMixin, forms.Form):
class VLANGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
field_groups = [
['q'],
['region', 'sitegroup', 'site', 'location', 'rack']
]
model = VLANGroup
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),

View File

@@ -825,9 +825,9 @@ class IPAddress(PrimaryModel):
if self.pk:
for cls, attr in ((Device, 'device'), (VirtualMachine, 'virtual_machine')):
parent = cls.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
if parent and getattr(self.assigned_object, attr) != parent:
if parent and getattr(self.assigned_object, attr, None) != parent:
# Check for a NAT relationship
if not self.nat_inside or getattr(self.nat_inside.assigned_object, attr) != parent:
if not self.nat_inside or getattr(self.nat_inside.assigned_object, attr, None) != parent:
raise ValidationError({
'interface': f"IP address is primary for {cls._meta.model_name} {parent} but "
f"not assigned to it!"

View File

@@ -0,0 +1,4 @@
from .ip import *
from .services import *
from .vlans import *
from .vrfs import *

View File

@@ -2,14 +2,23 @@ import django_tables2 as tables
from django.utils.safestring import mark_safe
from django_tables2.utils import Accessor
from dcim.models import Interface
from tenancy.tables import TenantColumn
from utilities.tables import (
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ContentTypeColumn, LinkedCountColumn, TagColumn,
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, TagColumn,
ToggleColumn, UtilizationColumn,
)
from virtualization.models import VMInterface
from .models import *
from ipam.models import *
__all__ = (
'AggregateTable',
'InterfaceIPAddressTable',
'IPAddressAssignTable',
'IPAddressTable',
'IPRangeTable',
'PrefixTable',
'RIRTable',
'RoleTable',
)
AVAILABLE_LABEL = mark_safe('<span class="badge bg-success">Available</span>')
@@ -25,6 +34,15 @@ PREFIX_LINK = """
<a href="{% if record.pk %}{% url 'ipam:prefix' pk=record.pk %}{% else %}{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.site %}&site={{ object.site.pk }}{% endif %}{% if object.tenant %}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}{% endif %}{% endif %}">{{ record.prefix }}</a>
"""
PREFIXFLAT_LINK = """
{% load helpers %}
{% if record.pk %}
<a href="{% url 'ipam:prefix' pk=record.pk %}">{{ record.prefix }}</a>
{% else %}
&mdash;
{% endif %}
"""
PREFIX_ROLE_LINK = """
{% if record.role %}
<a href="{% url 'ipam:prefix_list' %}?role={{ record.role.slug }}">{{ record.role }}</a>
@@ -57,114 +75,6 @@ VRF_LINK = """
{% endif %}
"""
VRF_TARGETS = """
{% for rt in value.all %}
<a href="{{ rt.get_absolute_url }}">{{ rt }}</a>{% if not forloop.last %}<br />{% endif %}
{% empty %}
&mdash;
{% endfor %}
"""
VLAN_LINK = """
{% if record.pk %}
<a href="{{ record.get_absolute_url }}">{{ record.vid }}</a>
{% elif perms.ipam.add_vlan %}
<a href="{% url 'ipam:vlan_add' %}?vid={{ record.vid }}{% if record.vlan_group %}&group={{ record.vlan_group.pk }}{% endif %}" class="btn btn-sm btn-success">{{ record.available }} VLAN{{ record.available|pluralize }} available</a>
{% else %}
{{ record.available }} VLAN{{ record.available|pluralize }} available
{% endif %}
"""
VLAN_PREFIXES = """
{% for prefix in record.prefixes.all %}
<a href="{% url 'ipam:prefix' pk=prefix.pk %}">{{ prefix }}</a>{% if not forloop.last %}<br />{% endif %}
{% empty %}
&mdash;
{% endfor %}
"""
VLAN_ROLE_LINK = """
{% if record.role %}
<a href="{% url 'ipam:vlan_list' %}?role={{ record.role.slug }}">{{ record.role }}</a>
{% else %}
&mdash;
{% endif %}
"""
VLANGROUP_ADD_VLAN = """
{% with next_vid=record.get_next_available_vid %}
{% if next_vid and perms.ipam.add_vlan %}
<a href="{% url 'ipam:vlan_add' %}?group={{ record.pk }}&vid={{ next_vid }}" title="Add VLAN" class="btn btn-sm btn-success">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
</a>
{% endif %}
{% endwith %}
"""
VLAN_MEMBER_TAGGED = """
{% if record.untagged_vlan_id == object.pk %}
<span class="text-danger"><i class="mdi mdi-close-thick"></i></span>
{% else %}
<span class="text-success"><i class="mdi mdi-check-bold"></i></span>
{% endif %}
"""
#
# VRFs
#
class VRFTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(
linkify=True
)
rd = tables.Column(
verbose_name='RD'
)
tenant = TenantColumn()
enforce_unique = BooleanColumn(
verbose_name='Unique'
)
import_targets = tables.TemplateColumn(
template_code=VRF_TARGETS,
orderable=False
)
export_targets = tables.TemplateColumn(
template_code=VRF_TARGETS,
orderable=False
)
tags = TagColumn(
url_name='ipam:vrf_list'
)
class Meta(BaseTable.Meta):
model = VRF
fields = (
'pk', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'tags',
)
default_columns = ('pk', 'name', 'rd', 'tenant', 'description')
#
# Route targets
#
class RouteTargetTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(
linkify=True
)
tenant = TenantColumn()
tags = TagColumn(
url_name='ipam:vrf_list'
)
class Meta(BaseTable.Meta):
model = RouteTarget
fields = ('pk', 'name', 'tenant', 'description', 'tags')
default_columns = ('pk', 'name', 'tenant', 'description')
#
# RIRs
@@ -206,13 +116,6 @@ class AggregateTable(BaseTable):
format="Y-m-d",
verbose_name='Added'
)
class Meta(BaseTable.Meta):
model = Aggregate
fields = ('pk', 'prefix', 'rir', 'tenant', 'date_added', 'description')
class AggregateDetailTable(AggregateTable):
child_count = tables.Column(
verbose_name='Prefixes'
)
@@ -224,7 +127,8 @@ class AggregateDetailTable(AggregateTable):
url_name='ipam:aggregate_list'
)
class Meta(AggregateTable.Meta):
class Meta(BaseTable.Meta):
model = Aggregate
fields = ('pk', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description', 'tags')
default_columns = ('pk', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description')
@@ -281,10 +185,10 @@ class PrefixTable(BaseTable):
template_code=PREFIX_LINK,
attrs={'td': {'class': 'text-nowrap'}}
)
prefix_flat = tables.Column(
accessor=Accessor('prefix'),
linkify=True,
verbose_name='Prefix (Flat)'
prefix_flat = tables.TemplateColumn(
template_code=PREFIXFLAT_LINK,
attrs={'td': {'class': 'text-nowrap'}},
verbose_name='Prefix (Flat)',
)
depth = tables.Column(
accessor=Accessor('_depth'),
@@ -323,20 +227,6 @@ class PrefixTable(BaseTable):
mark_utilized = BooleanColumn(
verbose_name='Marked Utilized'
)
class Meta(BaseTable.Meta):
model = Prefix
fields = (
'pk', 'prefix', 'prefix_flat', 'status', 'depth', 'children', 'vrf', 'tenant', 'site', 'vlan', 'role',
'is_pool', 'mark_utilized', 'description',
)
default_columns = ('pk', 'prefix', 'status', 'vrf', 'tenant', 'site', 'vlan', 'role', 'description')
row_attrs = {
'class': lambda record: 'success' if not record.pk else '',
}
class PrefixDetailTable(PrefixTable):
utilization = PrefixUtilizationColumn(
accessor='get_utilization',
orderable=False
@@ -345,7 +235,8 @@ class PrefixDetailTable(PrefixTable):
url_name='ipam:prefix_list'
)
class Meta(PrefixTable.Meta):
class Meta(BaseTable.Meta):
model = Prefix
fields = (
'pk', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role',
'is_pool', 'mark_utilized', 'description', 'tags',
@@ -353,6 +244,9 @@ class PrefixDetailTable(PrefixTable):
default_columns = (
'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description',
)
row_attrs = {
'class': lambda record: 'success' if not record.pk else '',
}
#
@@ -418,25 +312,11 @@ class IPAddressTable(BaseTable):
orderable=False,
verbose_name='Device/VM'
)
class Meta(BaseTable.Meta):
model = IPAddress
fields = (
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned_object', 'assigned_object_parent', 'dns_name',
'description',
)
row_attrs = {
'class': lambda record: 'success' if not isinstance(record, IPAddress) else '',
}
class IPAddressDetailTable(IPAddressTable):
nat_inside = tables.Column(
linkify=True,
orderable=False,
verbose_name='NAT (Inside)'
)
tenant = TenantColumn()
assigned = BooleanColumn(
accessor='assigned_object_id',
verbose_name='Assigned'
@@ -445,14 +325,18 @@ class IPAddressDetailTable(IPAddressTable):
url_name='ipam:ipaddress_list'
)
class Meta(IPAddressTable.Meta):
class Meta(BaseTable.Meta):
model = IPAddress
fields = (
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'assigned', 'dns_name',
'description', 'tags',
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'assigned', 'dns_name', 'description',
'tags',
)
default_columns = (
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned', 'dns_name', 'description',
)
row_attrs = {
'class': lambda record: 'success' if not isinstance(record, IPAddress) else '',
}
class IPAddressAssignTable(BaseTable):
@@ -492,173 +376,3 @@ class InterfaceIPAddressTable(BaseTable):
class Meta(BaseTable.Meta):
model = IPAddress
fields = ('address', 'vrf', 'status', 'role', 'tenant', 'description')
#
# VLAN groups
#
class VLANGroupTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(linkify=True)
scope_type = ContentTypeColumn()
scope = tables.Column(
linkify=True,
orderable=False
)
vlan_count = LinkedCountColumn(
viewname='ipam:vlan_list',
url_params={'group_id': 'pk'},
verbose_name='VLANs'
)
actions = ButtonsColumn(
model=VLANGroup,
prepend_template=VLANGROUP_ADD_VLAN
)
class Meta(BaseTable.Meta):
model = VLANGroup
fields = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'slug', 'description', 'actions')
default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description', 'actions')
#
# VLANs
#
class VLANTable(BaseTable):
pk = ToggleColumn()
vid = tables.TemplateColumn(
template_code=VLAN_LINK,
verbose_name='ID'
)
site = tables.Column(
linkify=True
)
group = tables.Column(
linkify=True
)
tenant = TenantColumn()
status = ChoiceFieldColumn(
default=AVAILABLE_LABEL
)
role = tables.TemplateColumn(
template_code=VLAN_ROLE_LINK
)
class Meta(BaseTable.Meta):
model = VLAN
fields = ('pk', 'vid', 'name', 'site', 'group', 'tenant', 'status', 'role', 'description')
row_attrs = {
'class': lambda record: 'success' if not isinstance(record, VLAN) else '',
}
class VLANDetailTable(VLANTable):
prefixes = tables.TemplateColumn(
template_code=VLAN_PREFIXES,
orderable=False,
verbose_name='Prefixes'
)
tenant = TenantColumn()
tags = TagColumn(
url_name='ipam:vlan_list'
)
class Meta(VLANTable.Meta):
fields = ('pk', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description', 'tags')
default_columns = ('pk', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description')
class VLANMembersTable(BaseTable):
"""
Base table for Interface and VMInterface assignments
"""
name = tables.Column(
linkify=True,
verbose_name='Interface'
)
tagged = tables.TemplateColumn(
template_code=VLAN_MEMBER_TAGGED,
orderable=False
)
class VLANDevicesTable(VLANMembersTable):
device = tables.Column(
linkify=True
)
actions = ButtonsColumn(Interface, buttons=['edit'])
class Meta(BaseTable.Meta):
model = Interface
fields = ('device', 'name', 'tagged', 'actions')
class VLANVirtualMachinesTable(VLANMembersTable):
virtual_machine = tables.Column(
linkify=True
)
actions = ButtonsColumn(VMInterface, buttons=['edit'])
class Meta(BaseTable.Meta):
model = VMInterface
fields = ('virtual_machine', 'name', 'tagged', 'actions')
class InterfaceVLANTable(BaseTable):
"""
List VLANs assigned to a specific Interface.
"""
vid = tables.Column(
linkify=True,
verbose_name='ID'
)
tagged = BooleanColumn()
site = tables.Column(
linkify=True
)
group = tables.Column(
accessor=Accessor('group__name'),
verbose_name='Group'
)
tenant = TenantColumn()
status = ChoiceFieldColumn()
role = tables.TemplateColumn(
template_code=VLAN_ROLE_LINK
)
class Meta(BaseTable.Meta):
model = VLAN
fields = ('vid', 'tagged', 'site', 'group', 'name', 'tenant', 'status', 'role', 'description')
def __init__(self, interface, *args, **kwargs):
self.interface = interface
super().__init__(*args, **kwargs)
#
# Services
#
class ServiceTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(
linkify=True
)
parent = tables.Column(
linkify=True,
order_by=('device', 'virtual_machine')
)
ports = tables.TemplateColumn(
template_code='{{ record.port_list }}',
verbose_name='Ports'
)
tags = TagColumn(
url_name='ipam:service_list'
)
class Meta(BaseTable.Meta):
model = Service
fields = ('pk', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'tags')
default_columns = ('pk', 'name', 'parent', 'protocol', 'ports', 'description')

View File

@@ -0,0 +1,35 @@
import django_tables2 as tables
from utilities.tables import BaseTable, TagColumn, ToggleColumn
from ipam.models import *
__all__ = (
'ServiceTable',
)
#
# Services
#
class ServiceTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(
linkify=True
)
parent = tables.Column(
linkify=True,
order_by=('device', 'virtual_machine')
)
ports = tables.TemplateColumn(
template_code='{{ record.port_list }}',
verbose_name='Ports'
)
tags = TagColumn(
url_name='ipam:service_list'
)
class Meta(BaseTable.Meta):
model = Service
fields = ('pk', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'tags')
default_columns = ('pk', 'name', 'parent', 'protocol', 'ports', 'description')

203
netbox/ipam/tables/vlans.py Normal file
View File

@@ -0,0 +1,203 @@
import django_tables2 as tables
from django.utils.safestring import mark_safe
from django_tables2.utils import Accessor
from dcim.models import Interface
from tenancy.tables import TenantColumn
from utilities.tables import (
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ContentTypeColumn, LinkedCountColumn, TagColumn,
ToggleColumn,
)
from virtualization.models import VMInterface
from ipam.models import *
__all__ = (
'InterfaceVLANTable',
'VLANDevicesTable',
'VLANGroupTable',
'VLANMembersTable',
'VLANTable',
'VLANVirtualMachinesTable',
)
AVAILABLE_LABEL = mark_safe('<span class="badge bg-success">Available</span>')
VLAN_LINK = """
{% if record.pk %}
<a href="{{ record.get_absolute_url }}">{{ record.vid }}</a>
{% elif perms.ipam.add_vlan %}
<a href="{% url 'ipam:vlan_add' %}?vid={{ record.vid }}{% if record.vlan_group %}&group={{ record.vlan_group.pk }}{% endif %}" class="btn btn-sm btn-success">{{ record.available }} VLAN{{ record.available|pluralize }} available</a>
{% else %}
{{ record.available }} VLAN{{ record.available|pluralize }} available
{% endif %}
"""
VLAN_PREFIXES = """
{% for prefix in record.prefixes.all %}
<a href="{% url 'ipam:prefix' pk=prefix.pk %}">{{ prefix }}</a>{% if not forloop.last %}<br />{% endif %}
{% empty %}
&mdash;
{% endfor %}
"""
VLAN_ROLE_LINK = """
{% if record.role %}
<a href="{% url 'ipam:vlan_list' %}?role={{ record.role.slug }}">{{ record.role }}</a>
{% else %}
&mdash;
{% endif %}
"""
VLANGROUP_ADD_VLAN = """
{% with next_vid=record.get_next_available_vid %}
{% if next_vid and perms.ipam.add_vlan %}
<a href="{% url 'ipam:vlan_add' %}?group={{ record.pk }}&vid={{ next_vid }}" title="Add VLAN" class="btn btn-sm btn-success">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
</a>
{% endif %}
{% endwith %}
"""
VLAN_MEMBER_TAGGED = """
{% if record.untagged_vlan_id == object.pk %}
<span class="text-danger"><i class="mdi mdi-close-thick"></i></span>
{% else %}
<span class="text-success"><i class="mdi mdi-check-bold"></i></span>
{% endif %}
"""
#
# VLAN groups
#
class VLANGroupTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(linkify=True)
scope_type = ContentTypeColumn()
scope = tables.Column(
linkify=True,
orderable=False
)
vlan_count = LinkedCountColumn(
viewname='ipam:vlan_list',
url_params={'group_id': 'pk'},
verbose_name='VLANs'
)
actions = ButtonsColumn(
model=VLANGroup,
prepend_template=VLANGROUP_ADD_VLAN
)
class Meta(BaseTable.Meta):
model = VLANGroup
fields = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'slug', 'description', 'actions')
default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description', 'actions')
#
# VLANs
#
class VLANTable(BaseTable):
pk = ToggleColumn()
vid = tables.TemplateColumn(
template_code=VLAN_LINK,
verbose_name='ID'
)
site = tables.Column(
linkify=True
)
group = tables.Column(
linkify=True
)
tenant = TenantColumn()
status = ChoiceFieldColumn(
default=AVAILABLE_LABEL
)
role = tables.TemplateColumn(
template_code=VLAN_ROLE_LINK
)
prefixes = tables.TemplateColumn(
template_code=VLAN_PREFIXES,
orderable=False,
verbose_name='Prefixes'
)
tags = TagColumn(
url_name='ipam:vlan_list'
)
class Meta(BaseTable.Meta):
model = VLAN
fields = ('pk', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description', 'tags')
default_columns = ('pk', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description')
row_attrs = {
'class': lambda record: 'success' if not isinstance(record, VLAN) else '',
}
class VLANMembersTable(BaseTable):
"""
Base table for Interface and VMInterface assignments
"""
name = tables.Column(
linkify=True,
verbose_name='Interface'
)
tagged = tables.TemplateColumn(
template_code=VLAN_MEMBER_TAGGED,
orderable=False
)
class VLANDevicesTable(VLANMembersTable):
device = tables.Column(
linkify=True
)
actions = ButtonsColumn(Interface, buttons=['edit'])
class Meta(BaseTable.Meta):
model = Interface
fields = ('device', 'name', 'tagged', 'actions')
class VLANVirtualMachinesTable(VLANMembersTable):
virtual_machine = tables.Column(
linkify=True
)
actions = ButtonsColumn(VMInterface, buttons=['edit'])
class Meta(BaseTable.Meta):
model = VMInterface
fields = ('virtual_machine', 'name', 'tagged', 'actions')
class InterfaceVLANTable(BaseTable):
"""
List VLANs assigned to a specific Interface.
"""
vid = tables.Column(
linkify=True,
verbose_name='ID'
)
tagged = BooleanColumn()
site = tables.Column(
linkify=True
)
group = tables.Column(
accessor=Accessor('group__name'),
verbose_name='Group'
)
tenant = TenantColumn()
status = ChoiceFieldColumn()
role = tables.TemplateColumn(
template_code=VLAN_ROLE_LINK
)
class Meta(BaseTable.Meta):
model = VLAN
fields = ('vid', 'tagged', 'site', 'group', 'name', 'tenant', 'status', 'role', 'description')
def __init__(self, interface, *args, **kwargs):
self.interface = interface
super().__init__(*args, **kwargs)

View File

@@ -0,0 +1,74 @@
import django_tables2 as tables
from tenancy.tables import TenantColumn
from utilities.tables import BaseTable, BooleanColumn, TagColumn, ToggleColumn
from ipam.models import *
__all__ = (
'RouteTargetTable',
'VRFTable',
)
VRF_TARGETS = """
{% for rt in value.all %}
<a href="{{ rt.get_absolute_url }}">{{ rt }}</a>{% if not forloop.last %}<br />{% endif %}
{% empty %}
&mdash;
{% endfor %}
"""
#
# VRFs
#
class VRFTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(
linkify=True
)
rd = tables.Column(
verbose_name='RD'
)
tenant = TenantColumn()
enforce_unique = BooleanColumn(
verbose_name='Unique'
)
import_targets = tables.TemplateColumn(
template_code=VRF_TARGETS,
orderable=False
)
export_targets = tables.TemplateColumn(
template_code=VRF_TARGETS,
orderable=False
)
tags = TagColumn(
url_name='ipam:vrf_list'
)
class Meta(BaseTable.Meta):
model = VRF
fields = (
'pk', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'tags',
)
default_columns = ('pk', 'name', 'rd', 'tenant', 'description')
#
# Route targets
#
class RouteTargetTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(
linkify=True
)
tenant = TenantColumn()
tags = TagColumn(
url_name='ipam:vrf_list'
)
class Meta(BaseTable.Meta):
model = RouteTarget
fields = ('pk', 'name', 'tenant', 'description', 'tags')
default_columns = ('pk', 'name', 'tenant', 'description')

View File

@@ -451,7 +451,7 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_mask_length(self):
params = {'mask_length': '24'}
params = {'mask_length': ['24']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_vrf(self):

View File

@@ -155,9 +155,7 @@ class RIRView(generic.ObjectView):
aggregates = Aggregate.objects.restrict(request.user, 'view').filter(
rir=instance
)
aggregates_table = tables.AggregateTable(aggregates)
aggregates_table.columns.hide('rir')
aggregates_table = tables.AggregateTable(aggregates, exclude=('rir', 'utilization'))
paginate_table(aggregates_table, request)
return {
@@ -207,7 +205,7 @@ class AggregateListView(generic.ObjectListView):
)
filterset = filtersets.AggregateFilterSet
filterset_form = forms.AggregateFilterForm
table = tables.AggregateDetailTable
table = tables.AggregateTable
class AggregateView(generic.ObjectView):
@@ -227,7 +225,7 @@ class AggregateView(generic.ObjectView):
if request.GET.get('show_available', 'true') == 'true':
child_prefixes = add_available_prefixes(instance.prefix, child_prefixes)
prefix_table = tables.PrefixDetailTable(child_prefixes)
prefix_table = tables.PrefixTable(child_prefixes, exclude=('utilization',))
if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
prefix_table.columns.show('pk')
paginate_table(prefix_table, request)
@@ -283,6 +281,8 @@ class RoleListView(generic.ObjectListView):
prefix_count=count_related(Prefix, 'role'),
vlan_count=count_related(VLAN, 'role')
)
filterset = filtersets.RoleFilterSet
filterset_form = forms.RoleFilterForm
table = tables.RoleTable
@@ -294,8 +294,7 @@ class RoleView(generic.ObjectView):
role=instance
)
prefixes_table = tables.PrefixTable(prefixes)
prefixes_table.columns.hide('role')
prefixes_table = tables.PrefixTable(prefixes, exclude=('role', 'utilization'))
paginate_table(prefixes_table, request)
return {
@@ -338,7 +337,7 @@ class PrefixListView(generic.ObjectListView):
queryset = Prefix.objects.all()
filterset = filtersets.PrefixFilterSet
filterset_form = forms.PrefixFilterForm
table = tables.PrefixDetailTable
table = tables.PrefixTable
template_name = 'ipam/prefix_list.html'
@@ -361,8 +360,11 @@ class PrefixView(generic.ObjectView):
).prefetch_related(
'site', 'role'
)
parent_prefix_table = tables.PrefixTable(list(parent_prefixes), orderable=False)
parent_prefix_table.exclude = ('vrf',)
parent_prefix_table = tables.PrefixTable(
list(parent_prefixes),
exclude=('vrf', 'utilization'),
orderable=False
)
# Duplicate prefixes table
duplicate_prefixes = Prefix.objects.restrict(request.user, 'view').filter(
@@ -372,8 +374,11 @@ class PrefixView(generic.ObjectView):
).prefetch_related(
'site', 'role'
)
duplicate_prefix_table = tables.PrefixTable(list(duplicate_prefixes), orderable=False)
duplicate_prefix_table.exclude = ('vrf',)
duplicate_prefix_table = tables.PrefixTable(
list(duplicate_prefixes),
exclude=('vrf', 'utilization'),
orderable=False
)
return {
'aggregate': aggregate,
@@ -396,20 +401,26 @@ class PrefixPrefixesView(generic.ObjectView):
if child_prefixes and request.GET.get('show_available', 'true') == 'true':
child_prefixes = add_available_prefixes(instance.prefix, child_prefixes)
table = tables.PrefixDetailTable(child_prefixes, user=request.user)
table = tables.PrefixTable(child_prefixes, user=request.user, exclude=('utilization',))
if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
table.columns.show('pk')
paginate_table(table, request)
bulk_querystring = 'vrf_id={}&within={}'.format(instance.vrf.pk if instance.vrf else '0', instance.prefix)
# Compile permissions list for rendering the object table
permissions = {
'change': request.user.has_perm('ipam.change_prefix'),
'delete': request.user.has_perm('ipam.delete_prefix'),
}
return {
'first_available_prefix': instance.get_first_available_prefix(),
'table': table,
'permissions': permissions,
'bulk_querystring': bulk_querystring,
'active_tab': 'prefixes',
'first_available_prefix': instance.get_first_available_prefix(),
'show_available': request.GET.get('show_available', 'true') == 'true',
'table_config_form': TableConfigForm(table=table),
}
@@ -421,15 +432,22 @@ class PrefixIPRangesView(generic.ObjectView):
# Find all IPRanges belonging to this Prefix
ip_ranges = instance.get_child_ranges().restrict(request.user, 'view').prefetch_related('vrf')
table = tables.IPRangeTable(ip_ranges)
table = tables.IPRangeTable(ip_ranges, user=request.user)
if request.user.has_perm('ipam.change_iprange') or request.user.has_perm('ipam.delete_iprange'):
table.columns.show('pk')
paginate_table(table, request)
bulk_querystring = 'vrf_id={}&parent={}'.format(instance.vrf.pk if instance.vrf else '0', instance.prefix)
# Compile permissions list for rendering the object table
permissions = {
'change': request.user.has_perm('ipam.change_iprange'),
'delete': request.user.has_perm('ipam.delete_iprange'),
}
return {
'table': table,
'permissions': permissions,
'bulk_querystring': bulk_querystring,
'active_tab': 'ip-ranges',
}
@@ -449,18 +467,25 @@ class PrefixIPAddressesView(generic.ObjectView):
if request.GET.get('show_available', 'true') == 'true':
ipaddresses = add_available_ipaddresses(instance.prefix, ipaddresses, instance.is_pool)
table = tables.IPAddressTable(ipaddresses)
table = tables.IPAddressTable(ipaddresses, user=request.user)
if request.user.has_perm('ipam.change_ipaddress') or request.user.has_perm('ipam.delete_ipaddress'):
table.columns.show('pk')
paginate_table(table, request)
bulk_querystring = 'vrf_id={}&parent={}'.format(instance.vrf.pk if instance.vrf else '0', instance.prefix)
# Compile permissions list for rendering the object table
permissions = {
'change': request.user.has_perm('ipam.change_ipaddress'),
'delete': request.user.has_perm('ipam.delete_ipaddress'),
}
return {
'first_available_ip': instance.get_first_available_ip(),
'table': table,
'permissions': permissions,
'bulk_querystring': bulk_querystring,
'active_tab': 'ip-addresses',
'first_available_ip': instance.get_first_available_ip(),
'show_available': request.GET.get('show_available', 'true') == 'true',
}
@@ -579,7 +604,7 @@ class IPAddressListView(generic.ObjectListView):
queryset = IPAddress.objects.all()
filterset = filtersets.IPAddressFilterSet
filterset_form = forms.IPAddressFilterForm
table = tables.IPAddressDetailTable
table = tables.IPAddressTable
class IPAddressView(generic.ObjectView):
@@ -593,8 +618,11 @@ class IPAddressView(generic.ObjectView):
).prefetch_related(
'site', 'role'
)
parent_prefixes_table = tables.PrefixTable(list(parent_prefixes), orderable=False)
parent_prefixes_table.exclude = ('vrf',)
parent_prefixes_table = tables.PrefixTable(
list(parent_prefixes),
exclude=('vrf', 'utilization'),
orderable=False
)
# Duplicate IPs table
duplicate_ips = IPAddress.objects.restrict(request.user, 'view').filter(
@@ -745,11 +773,9 @@ class VLANGroupView(generic.ObjectView):
vlans_count = vlans.count()
vlans = add_available_vlans(vlans, vlan_group=instance)
vlans_table = tables.VLANDetailTable(vlans)
vlans_table = tables.VLANTable(vlans, exclude=('site', 'group', 'prefixes'))
if request.user.has_perm('ipam.change_vlan') or request.user.has_perm('ipam.delete_vlan'):
vlans_table.columns.show('pk')
vlans_table.columns.hide('site')
vlans_table.columns.hide('group')
paginate_table(vlans_table, request)
# Compile permissions list for rendering the object table
@@ -806,7 +832,7 @@ class VLANListView(generic.ObjectListView):
queryset = VLAN.objects.all()
filterset = filtersets.VLANFilterSet
filterset_form = forms.VLANFilterForm
table = tables.VLANDetailTable
table = tables.VLANTable
class VLANView(generic.ObjectView):
@@ -816,8 +842,7 @@ class VLANView(generic.ObjectView):
prefixes = Prefix.objects.restrict(request.user, 'view').filter(vlan=instance).prefetch_related(
'vrf', 'site', 'role'
)
prefix_table = tables.PrefixTable(list(prefixes), orderable=False)
prefix_table.exclude = ('vlan',)
prefix_table = tables.PrefixTable(list(prefixes), exclude=('vlan', 'utilization'), orderable=False)
return {
'prefix_table': prefix_table,

View File

@@ -34,7 +34,6 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
return list(queryset[self.offset:])
def get_limit(self, request):
if self.limit_query_param:
try:
limit = int(request.query_params[self.limit_query_param])

View File

@@ -2,14 +2,17 @@ import logging
from collections import defaultdict
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend, RemoteUserBackend as _RemoteUserBackend
from django.contrib.auth.models import Group
from django.contrib.auth.models import Group, AnonymousUser
from django.core.exceptions import ImproperlyConfigured
from django.db.models import Q
from users.models import ObjectPermission
from utilities.permissions import permission_is_exempt, resolve_permission, resolve_permission_ct
UserModel = get_user_model()
class ObjectPermissionMixin():
@@ -101,38 +104,145 @@ class RemoteUserBackend(_RemoteUserBackend):
def create_unknown_user(self):
return settings.REMOTE_AUTH_AUTO_CREATE_USER
def configure_user(self, request, user):
def configure_groups(self, user, remote_groups):
logger = logging.getLogger('netbox.authentication.RemoteUserBackend')
# Assign default groups to the user
group_list = []
for name in settings.REMOTE_AUTH_DEFAULT_GROUPS:
for name in remote_groups:
try:
group_list.append(Group.objects.get(name=name))
except Group.DoesNotExist:
logging.error(f"Could not assign group {name} to remotely-authenticated user {user}: Group not found")
if group_list:
user.groups.add(*group_list)
logger.debug(f"Assigned groups to remotely-authenticated user {user}: {group_list}")
# Assign default object permissions to the user
permissions_list = []
for permission_name, constraints in settings.REMOTE_AUTH_DEFAULT_PERMISSIONS.items():
try:
object_type, action = resolve_permission_ct(permission_name)
# TODO: Merge multiple actions into a single ObjectPermission per content type
obj_perm = ObjectPermission(actions=[action], constraints=constraints)
obj_perm.save()
obj_perm.users.add(user)
obj_perm.object_types.add(object_type)
permissions_list.append(permission_name)
except ValueError:
logging.error(
f"Invalid permission name: '{permission_name}'. Permissions must be in the form "
"<app>.<action>_<model>. (Example: dcim.add_site)"
)
if permissions_list:
logger.debug(f"Assigned permissions to remotely-authenticated user {user}: {permissions_list}")
f"Could not assign group {name} to remotely-authenticated user {user}: Group not found")
if group_list:
user.groups.set(group_list)
logger.debug(
f"Assigned groups to remotely-authenticated user {user}: {group_list}")
else:
user.groups.clear()
logger.debug(f"Stripping user {user} from Groups")
user.is_superuser = self._is_superuser(user)
logger.debug(f"User {user} is Superuser: {user.is_superuser}")
logger.debug(
f"User {user} should be Superuser: {self._is_superuser(user)}")
user.is_staff = self._is_staff(user)
logger.debug(f"User {user} is Staff: {user.is_staff}")
logger.debug(f"User {user} should be Staff: {self._is_staff(user)}")
user.save()
return user
def authenticate(self, request, remote_user, remote_groups=None):
"""
The username passed as ``remote_user`` is considered trusted. Return
the ``User`` object with the given username. Create a new ``User``
object if ``create_unknown_user`` is ``True``.
Return None if ``create_unknown_user`` is ``False`` and a ``User``
object with the given username is not found in the database.
"""
logger = logging.getLogger('netbox.authentication.RemoteUserBackend')
logger.debug(
f"trying to authenticate {remote_user} with groups {remote_groups}")
if not remote_user:
return
user = None
username = self.clean_username(remote_user)
# Note that this could be accomplished in one try-except clause, but
# instead we use get_or_create when creating unknown users since it has
# built-in safeguards for multiple threads.
if self.create_unknown_user:
user, created = UserModel._default_manager.get_or_create(**{
UserModel.USERNAME_FIELD: username
})
if created:
user = self.configure_user(request, user)
else:
try:
user = UserModel._default_manager.get_by_natural_key(username)
except UserModel.DoesNotExist:
pass
if self.user_can_authenticate(user):
if settings.REMOTE_AUTH_GROUP_SYNC_ENABLED:
if user is not None and not isinstance(user, AnonymousUser):
return self.configure_groups(user, remote_groups)
else:
return user
else:
return None
def _is_superuser(self, user):
logger = logging.getLogger('netbox.authentication.RemoteUserBackend')
superuser_groups = settings.REMOTE_AUTH_SUPERUSER_GROUPS
logger.debug(f"Superuser Groups: {superuser_groups}")
superusers = settings.REMOTE_AUTH_SUPERUSERS
logger.debug(f"Superuser Users: {superusers}")
user_groups = set()
for g in user.groups.all():
user_groups.add(g.name)
logger.debug(f"User {user.username} is in Groups:{user_groups}")
result = user.username in superusers or (
set(user_groups) & set(superuser_groups))
logger.debug(f"User {user.username} in Superuser Users :{result}")
return bool(result)
def _is_staff(self, user):
logger = logging.getLogger('netbox.authentication.RemoteUserBackend')
staff_groups = settings.REMOTE_AUTH_STAFF_GROUPS
logger.debug(f"Superuser Groups: {staff_groups}")
staff_users = settings.REMOTE_AUTH_STAFF_USERS
logger.debug(f"Staff Users :{staff_users}")
user_groups = set()
for g in user.groups.all():
user_groups.add(g.name)
logger.debug(f"User {user.username} is in Groups:{user_groups}")
result = user.username in staff_users or (
set(user_groups) & set(staff_groups))
logger.debug(f"User {user.username} in Staff Users :{result}")
return bool(result)
def configure_user(self, request, user):
logger = logging.getLogger('netbox.authentication.RemoteUserBackend')
if not settings.REMOTE_AUTH_GROUP_SYNC_ENABLED:
# Assign default groups to the user
group_list = []
for name in settings.REMOTE_AUTH_DEFAULT_GROUPS:
try:
group_list.append(Group.objects.get(name=name))
except Group.DoesNotExist:
logging.error(
f"Could not assign group {name} to remotely-authenticated user {user}: Group not found")
if group_list:
user.groups.add(*group_list)
logger.debug(
f"Assigned groups to remotely-authenticated user {user}: {group_list}")
# Assign default object permissions to the user
permissions_list = []
for permission_name, constraints in settings.REMOTE_AUTH_DEFAULT_PERMISSIONS.items():
try:
object_type, action = resolve_permission_ct(
permission_name)
# TODO: Merge multiple actions into a single ObjectPermission per content type
obj_perm = ObjectPermission(
actions=[action], constraints=constraints)
obj_perm.save()
obj_perm.users.add(user)
obj_perm.object_types.add(object_type)
permissions_list.append(permission_name)
except ValueError:
logging.error(
f"Invalid permission name: '{permission_name}'. Permissions must be in the form "
"<app>.<action>_<model>. (Example: dcim.add_site)"
)
if permissions_list:
logger.debug(
f"Assigned permissions to remotely-authenticated user {user}: {permissions_list}")
else:
logger.debug(
f"Skipped initial assignment of permissions and groups to remotely-authenticated user {user} as Group sync is enabled")
return user

View File

@@ -21,7 +21,7 @@ from tenancy.tables import TenantTable
from utilities.utils import count_related
from virtualization.filtersets import ClusterFilterSet, VirtualMachineFilterSet
from virtualization.models import Cluster, VirtualMachine
from virtualization.tables import ClusterTable, VirtualMachineDetailTable
from virtualization.tables import ClusterTable, VirtualMachineTable
SEARCH_MAX_RESULTS = 15
SEARCH_TYPES = OrderedDict((
@@ -130,7 +130,7 @@ SEARCH_TYPES = OrderedDict((
'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
),
'filterset': VirtualMachineFilterSet,
'table': VirtualMachineDetailTable,
'table': VirtualMachineTable,
'url': 'virtualization:virtualmachine_list',
}),
# IPAM

View File

@@ -1,8 +1,11 @@
import uuid
from urllib import parse
import logging
from django.conf import settings
from django.contrib.auth.middleware import RemoteUserMiddleware as RemoteUserMiddleware_
from django.contrib import auth
from django.core.exceptions import ImproperlyConfigured
from django.db import ProgrammingError
from django.http import Http404, HttpResponseRedirect
from django.urls import reverse
@@ -16,6 +19,7 @@ class LoginRequiredMiddleware(object):
"""
If LOGIN_REQUIRED is True, redirect all non-authenticated users to the login page.
"""
def __init__(self, get_response):
self.get_response = get_response
@@ -49,12 +53,65 @@ class RemoteUserMiddleware(RemoteUserMiddleware_):
return settings.REMOTE_AUTH_HEADER
def process_request(self, request):
logger = logging.getLogger(
'netbox.authentication.RemoteUserMiddleware')
# Bypass middleware if remote authentication is not enabled
if not settings.REMOTE_AUTH_ENABLED:
return
# AuthenticationMiddleware is required so that request.user exists.
if not hasattr(request, 'user'):
raise ImproperlyConfigured(
"The Django remote user auth middleware requires the"
" authentication middleware to be installed. Edit your"
" MIDDLEWARE setting to insert"
" 'django.contrib.auth.middleware.AuthenticationMiddleware'"
" before the RemoteUserMiddleware class.")
try:
username = request.META[self.header]
except KeyError:
# If specified header doesn't exist then remove any existing
# authenticated remote-user, or return (leaving request.user set to
# AnonymousUser by the AuthenticationMiddleware).
if self.force_logout_if_no_header and request.user.is_authenticated:
self._remove_invalid_user(request)
return
# If the user is already authenticated and that user is the user we are
# getting passed in the headers, then the correct user is already
# persisted in the session and we don't need to continue.
if request.user.is_authenticated:
if request.user.get_username() == self.clean_username(username, request):
return
else:
# An authenticated user is associated with the request, but
# it does not match the authorized user in the header.
self._remove_invalid_user(request)
return super().process_request(request)
# We are seeing this user for the first time in this session, attempt
# to authenticate the user.
if settings.REMOTE_AUTH_GROUP_SYNC_ENABLED:
logger.debug("Trying to sync Groups")
user = auth.authenticate(
request, remote_user=username, remote_groups=self._get_groups(request))
else:
user = auth.authenticate(request, remote_user=username)
if user:
# User is valid. Set request.user and persist user in the session
# by logging the user in.
request.user = user
auth.login(request, user)
def _get_groups(self, request):
logger = logging.getLogger(
'netbox.authentication.RemoteUserMiddleware')
groups_string = request.META.get(
settings.REMOTE_AUTH_GROUP_HEADER, None)
if groups_string:
groups = groups_string.split(settings.REMOTE_AUTH_GROUP_SEPARATOR)
else:
groups = []
logger.debug(f"Groups are {groups}")
return groups
class ObjectChangeMiddleware(object):
@@ -71,6 +128,7 @@ class ObjectChangeMiddleware(object):
have been created. Conversely, deletions are acted upon immediately, so that the serialized representation of the
object is recorded before it (and any related objects) are actually deleted from the database.
"""
def __init__(self, get_response):
self.get_response = get_response
@@ -90,6 +148,7 @@ class APIVersionMiddleware(object):
"""
If the request is for an API endpoint, include the API version as a response header.
"""
def __init__(self, get_response):
self.get_response = get_response
@@ -105,6 +164,7 @@ class ExceptionHandlingMiddleware(object):
Intercept certain exceptions which are likely indicative of installation issues and provide helpful instructions
to the user.
"""
def __init__(self, get_response):
self.get_response = get_response
@@ -113,6 +173,10 @@ class ExceptionHandlingMiddleware(object):
def process_exception(self, request, exception):
# Handle exceptions that occur from REST API requests
if is_api_request(request):
return rest_api_server_error(request)
# Don't catch exceptions when in debug mode
if settings.DEBUG:
return
@@ -121,10 +185,6 @@ class ExceptionHandlingMiddleware(object):
if isinstance(exception, Http404):
return
# Handle exceptions that occur from REST API requests
if is_api_request(request):
return rest_api_server_error(request)
# Determine the type of exception. If it's a common issue, return a custom error page with instructions.
custom_template = None
if isinstance(exception, ProgrammingError):

View File

@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
# Environment setup
#
VERSION = '3.0.0'
VERSION = '3.0.3'
# Hostname
HOSTNAME = platform.node()
@@ -120,6 +120,13 @@ REMOTE_AUTH_DEFAULT_GROUPS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_GROUPS'
REMOTE_AUTH_DEFAULT_PERMISSIONS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_PERMISSIONS', {})
REMOTE_AUTH_ENABLED = getattr(configuration, 'REMOTE_AUTH_ENABLED', False)
REMOTE_AUTH_HEADER = getattr(configuration, 'REMOTE_AUTH_HEADER', 'HTTP_REMOTE_USER')
REMOTE_AUTH_GROUP_HEADER = getattr(configuration, 'REMOTE_AUTH_GROUP_HEADER', 'HTTP_REMOTE_USER_GROUP')
REMOTE_AUTH_GROUP_SYNC_ENABLED = getattr(configuration, 'REMOTE_AUTH_GROUP_SYNC_ENABLED', False)
REMOTE_AUTH_SUPERUSER_GROUPS = getattr(configuration, 'REMOTE_AUTH_SUPERUSER_GROUPS', [])
REMOTE_AUTH_SUPERUSERS = getattr(configuration, 'REMOTE_AUTH_SUPERUSERS', [])
REMOTE_AUTH_STAFF_GROUPS = getattr(configuration, 'REMOTE_AUTH_STAFF_GROUPS', [])
REMOTE_AUTH_STAFF_USERS = getattr(configuration, 'REMOTE_AUTH_STAFF_USERS', [])
REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATOR', '|')
RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None)
REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
@@ -250,10 +257,12 @@ CACHES = {
}
}
if CACHING_REDIS_SENTINELS:
DJANGO_REDIS_CONNECTION_FACTORY = 'django_redis.pool.SentinelConnectionFactory'
CACHES['default']['LOCATION'] = f'{CACHING_REDIS_PROTO}://{CACHING_REDIS_SENTINEL_SERVICE}/{CACHING_REDIS_DATABASE}'
CACHES['default']['OPTIONS']['CLIENT_CLASS'] = 'django_redis.client.SentinelClient'
CACHES['default']['OPTIONS']['SENTINELS'] = CACHING_REDIS_SENTINELS
if CACHING_REDIS_SKIP_TLS_VERIFY:
CACHES['default']['OPTIONS'].setdefault('CONNECTION_POOL_KWARGS', {})
CACHES['default']['OPTIONS']['CONNECTION_POOL_KWARGS']['ssl_cert_reqs'] = False
@@ -560,6 +569,10 @@ RQ_QUEUES = {
#
# Pagination
if MAX_PAGE_SIZE and PAGINATE_COUNT > MAX_PAGE_SIZE:
raise ImproperlyConfigured(
f"PAGINATE_COUNT ({PAGINATE_COUNT}) must be less than or equal to MAX_PAGE_SIZE ({MAX_PAGE_SIZE}), if set."
)
PER_PAGE_DEFAULTS = [
25, 50, 100, 250, 500, 1000
]

View File

@@ -58,7 +58,8 @@ class ExternalAuthenticationTestCase(TestCase):
response = self.client.get(reverse('home'), follow=True, **headers)
self.assertEqual(response.status_code, 200)
self.assertEqual(int(self.client.session.get('_auth_user_id')), self.user.pk, msg='Authentication failed')
self.assertEqual(int(self.client.session.get(
'_auth_user_id')), self.user.pk, msg='Authentication failed')
@override_settings(
REMOTE_AUTH_ENABLED=True,
@@ -78,7 +79,8 @@ class ExternalAuthenticationTestCase(TestCase):
response = self.client.get(reverse('home'), follow=True, **headers)
self.assertEqual(response.status_code, 200)
self.assertEqual(int(self.client.session.get('_auth_user_id')), self.user.pk, msg='Authentication failed')
self.assertEqual(int(self.client.session.get(
'_auth_user_id')), self.user.pk, msg='Authentication failed')
@override_settings(
REMOTE_AUTH_ENABLED=True,
@@ -102,7 +104,8 @@ class ExternalAuthenticationTestCase(TestCase):
# Local user should have been automatically created
new_user = User.objects.get(username='remoteuser2')
self.assertEqual(int(self.client.session.get('_auth_user_id')), new_user.pk, msg='Authentication failed')
self.assertEqual(int(self.client.session.get(
'_auth_user_id')), new_user.pk, msg='Authentication failed')
@override_settings(
REMOTE_AUTH_ENABLED=True,
@@ -121,7 +124,8 @@ class ExternalAuthenticationTestCase(TestCase):
self.assertTrue(settings.REMOTE_AUTH_ENABLED)
self.assertTrue(settings.REMOTE_AUTH_AUTO_CREATE_USER)
self.assertEqual(settings.REMOTE_AUTH_HEADER, 'HTTP_REMOTE_USER')
self.assertEqual(settings.REMOTE_AUTH_DEFAULT_GROUPS, ['Group 1', 'Group 2'])
self.assertEqual(settings.REMOTE_AUTH_DEFAULT_GROUPS,
['Group 1', 'Group 2'])
# Create required groups
groups = (
@@ -135,7 +139,8 @@ class ExternalAuthenticationTestCase(TestCase):
self.assertEqual(response.status_code, 200)
new_user = User.objects.get(username='remoteuser2')
self.assertEqual(int(self.client.session.get('_auth_user_id')), new_user.pk, msg='Authentication failed')
self.assertEqual(int(self.client.session.get(
'_auth_user_id')), new_user.pk, msg='Authentication failed')
self.assertListEqual(
[groups[0], groups[1]],
list(new_user.groups.all())
@@ -144,7 +149,8 @@ class ExternalAuthenticationTestCase(TestCase):
@override_settings(
REMOTE_AUTH_ENABLED=True,
REMOTE_AUTH_AUTO_CREATE_USER=True,
REMOTE_AUTH_DEFAULT_PERMISSIONS={'dcim.add_site': None, 'dcim.change_site': None},
REMOTE_AUTH_DEFAULT_PERMISSIONS={
'dcim.add_site': None, 'dcim.change_site': None},
LOGIN_REQUIRED=True
)
def test_remote_auth_default_permissions(self):
@@ -158,14 +164,102 @@ class ExternalAuthenticationTestCase(TestCase):
self.assertTrue(settings.REMOTE_AUTH_ENABLED)
self.assertTrue(settings.REMOTE_AUTH_AUTO_CREATE_USER)
self.assertEqual(settings.REMOTE_AUTH_HEADER, 'HTTP_REMOTE_USER')
self.assertEqual(settings.REMOTE_AUTH_DEFAULT_PERMISSIONS, {'dcim.add_site': None, 'dcim.change_site': None})
self.assertEqual(settings.REMOTE_AUTH_DEFAULT_PERMISSIONS, {
'dcim.add_site': None, 'dcim.change_site': None})
response = self.client.get(reverse('home'), follow=True, **headers)
self.assertEqual(response.status_code, 200)
new_user = User.objects.get(username='remoteuser2')
self.assertEqual(int(self.client.session.get('_auth_user_id')), new_user.pk, msg='Authentication failed')
self.assertTrue(new_user.has_perms(['dcim.add_site', 'dcim.change_site']))
self.assertEqual(int(self.client.session.get(
'_auth_user_id')), new_user.pk, msg='Authentication failed')
self.assertTrue(new_user.has_perms(
['dcim.add_site', 'dcim.change_site']))
@override_settings(
REMOTE_AUTH_ENABLED=True,
REMOTE_AUTH_AUTO_CREATE_USER=True,
REMOTE_AUTH_GROUP_SYNC_ENABLED=True,
LOGIN_REQUIRED=True
)
def test_remote_auth_remote_groups_default(self):
"""
Test enabling remote authentication with group sync enabled with the default configuration.
"""
headers = {
'HTTP_REMOTE_USER': 'remoteuser2',
'HTTP_REMOTE_USER_GROUP': 'Group 1|Group 2',
}
self.assertTrue(settings.REMOTE_AUTH_ENABLED)
self.assertTrue(settings.REMOTE_AUTH_AUTO_CREATE_USER)
self.assertTrue(settings.REMOTE_AUTH_GROUP_SYNC_ENABLED)
self.assertEqual(settings.REMOTE_AUTH_HEADER, 'HTTP_REMOTE_USER')
self.assertEqual(settings.REMOTE_AUTH_GROUP_HEADER,
'HTTP_REMOTE_USER_GROUP')
self.assertEqual(settings.REMOTE_AUTH_GROUP_SEPARATOR, '|')
# Create required groups
groups = (
Group(name='Group 1'),
Group(name='Group 2'),
Group(name='Group 3'),
)
Group.objects.bulk_create(groups)
response = self.client.get(reverse('home'), follow=True, **headers)
self.assertEqual(response.status_code, 200)
new_user = User.objects.get(username='remoteuser2')
self.assertEqual(int(self.client.session.get(
'_auth_user_id')), new_user.pk, msg='Authentication failed')
self.assertListEqual(
[groups[0], groups[1]],
list(new_user.groups.all())
)
@override_settings(
REMOTE_AUTH_ENABLED=True,
REMOTE_AUTH_AUTO_CREATE_USER=True,
REMOTE_AUTH_GROUP_SYNC_ENABLED=True,
REMOTE_AUTH_HEADER='HTTP_FOO',
REMOTE_AUTH_GROUP_HEADER='HTTP_BAR',
LOGIN_REQUIRED=True
)
def test_remote_auth_remote_groups_custom_header(self):
"""
Test enabling remote authentication with group sync enabled with the default configuration.
"""
headers = {
'HTTP_FOO': 'remoteuser2',
'HTTP_BAR': 'Group 1|Group 2',
}
self.assertTrue(settings.REMOTE_AUTH_ENABLED)
self.assertTrue(settings.REMOTE_AUTH_AUTO_CREATE_USER)
self.assertTrue(settings.REMOTE_AUTH_GROUP_SYNC_ENABLED)
self.assertEqual(settings.REMOTE_AUTH_HEADER, 'HTTP_FOO')
self.assertEqual(settings.REMOTE_AUTH_GROUP_HEADER, 'HTTP_BAR')
self.assertEqual(settings.REMOTE_AUTH_GROUP_SEPARATOR, '|')
# Create required groups
groups = (
Group(name='Group 1'),
Group(name='Group 2'),
Group(name='Group 3'),
)
Group.objects.bulk_create(groups)
response = self.client.get(reverse('home'), follow=True, **headers)
self.assertEqual(response.status_code, 200)
new_user = User.objects.get(username='remoteuser2')
self.assertEqual(int(self.client.session.get(
'_auth_user_id')), new_user.pk, msg='Authentication failed')
self.assertListEqual(
[groups[0], groups[1]],
list(new_user.groups.all())
)
class ObjectPermissionAPIViewTestCase(TestCase):
@@ -206,7 +300,8 @@ class ObjectPermissionAPIViewTestCase(TestCase):
def test_get_object(self):
# Attempt to retrieve object without permission
url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk})
url = reverse('ipam-api:prefix-detail',
kwargs={'pk': self.prefixes[0].pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.status_code, 403)
@@ -221,12 +316,14 @@ class ObjectPermissionAPIViewTestCase(TestCase):
obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix))
# Retrieve permitted object
url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk})
url = reverse('ipam-api:prefix-detail',
kwargs={'pk': self.prefixes[0].pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.status_code, 200)
# Attempt to retrieve non-permitted object
url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[3].pk})
url = reverse('ipam-api:prefix-detail',
kwargs={'pk': self.prefixes[3].pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.status_code, 404)
@@ -292,7 +389,8 @@ class ObjectPermissionAPIViewTestCase(TestCase):
# Attempt to edit an object without permission
data = {'site': self.sites[0].pk}
url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk})
url = reverse('ipam-api:prefix-detail',
kwargs={'pk': self.prefixes[0].pk})
response = self.client.patch(url, data, format='json', **self.header)
self.assertEqual(response.status_code, 403)
@@ -308,19 +406,22 @@ class ObjectPermissionAPIViewTestCase(TestCase):
# Attempt to edit a non-permitted object
data = {'site': self.sites[0].pk}
url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[3].pk})
url = reverse('ipam-api:prefix-detail',
kwargs={'pk': self.prefixes[3].pk})
response = self.client.patch(url, data, format='json', **self.header)
self.assertEqual(response.status_code, 404)
# Edit a permitted object
data['status'] = 'reserved'
url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk})
url = reverse('ipam-api:prefix-detail',
kwargs={'pk': self.prefixes[0].pk})
response = self.client.patch(url, data, format='json', **self.header)
self.assertEqual(response.status_code, 200)
# Attempt to modify a permitted object to a non-permitted object
data['site'] = self.sites[1].pk
url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk})
url = reverse('ipam-api:prefix-detail',
kwargs={'pk': self.prefixes[0].pk})
response = self.client.patch(url, data, format='json', **self.header)
self.assertEqual(response.status_code, 403)
@@ -328,7 +429,8 @@ class ObjectPermissionAPIViewTestCase(TestCase):
def test_delete_object(self):
# Attempt to delete an object without permission
url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk})
url = reverse('ipam-api:prefix-detail',
kwargs={'pk': self.prefixes[0].pk})
response = self.client.delete(url, format='json', **self.header)
self.assertEqual(response.status_code, 403)
@@ -343,11 +445,13 @@ class ObjectPermissionAPIViewTestCase(TestCase):
obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix))
# Attempt to delete a non-permitted object
url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[3].pk})
url = reverse('ipam-api:prefix-detail',
kwargs={'pk': self.prefixes[3].pk})
response = self.client.delete(url, format='json', **self.header)
self.assertEqual(response.status_code, 404)
# Delete a permitted object
url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk})
url = reverse('ipam-api:prefix-detail',
kwargs={'pk': self.prefixes[0].pk})
response = self.client.delete(url, format='json', **self.header)
self.assertEqual(response.status_code, 204)

View File

@@ -1,6 +1,7 @@
from django.conf import settings
from django.conf.urls import include
from django.urls import path, re_path
from django.views.decorators.csrf import csrf_exempt
from django.views.static import serve
from drf_yasg import openapi
from drf_yasg.views import get_schema_view
@@ -63,7 +64,7 @@ _patterns = [
re_path(r'^api/swagger(?P<format>.json|.yaml)$', schema_view.without_ui(), name='schema_swagger'),
# GraphQL
path('graphql/', GraphQLView.as_view(graphiql=True, schema=schema), name='graphql'),
path('graphql/', csrf_exempt(GraphQLView.as_view(graphiql=True, schema=schema)), name='graphql'),
# Serving static media in Django to pipe it through LoginRequiredMiddleware
path('media/<path:path>', serve, {'document_root': settings.MEDIA_ROOT}),

View File

@@ -26,7 +26,6 @@ from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF
from netbox.constants import SEARCH_MAX_RESULTS, SEARCH_TYPES
from netbox.forms import SearchForm
from tenancy.models import Tenant
from utilities.tables import paginate_table
from virtualization.models import Cluster, VirtualMachine
@@ -154,26 +153,18 @@ class HomeView(View):
class SearchView(View):
def get(self, request):
# No query
if 'q' not in request.GET:
return render(request, 'search.html', {
'form': SearchForm(),
})
form = SearchForm(request.GET)
results = []
if form.is_valid():
# If an object type has been specified, redirect to the dedicated view for it
if form.cleaned_data['obj_type']:
# Searching for a single type of object
obj_types = [form.cleaned_data['obj_type']]
else:
# Searching all object types
obj_types = SEARCH_TYPES.keys()
object_type = form.cleaned_data['obj_type']
url = reverse(SEARCH_TYPES[object_type]['url'])
return redirect(f"{url}?q={form.cleaned_data['q']}")
for obj_type in obj_types:
for obj_type in SEARCH_TYPES.keys():
queryset = SEARCH_TYPES[obj_type]['queryset'].restrict(request.user, 'view')
filterset = SEARCH_TYPES[obj_type]['filterset']

View File

@@ -21,8 +21,7 @@ from extras.signals import clear_webhooks
from utilities.error_handlers import handle_protectederror
from utilities.exceptions import AbortTransaction, PermissionsViolation
from utilities.forms import (
BootstrapMixin, BulkRenameForm, ConfirmationForm, CSVDataField, CSVFileField, ImportForm, TableConfigForm,
restrict_form_fields,
BootstrapMixin, BulkRenameForm, ConfirmationForm, CSVDataField, CSVFileField, ImportForm, restrict_form_fields,
)
from utilities.permissions import get_permission_for_model
from utilities.tables import paginate_table
@@ -181,7 +180,6 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
'table': table,
'permissions': permissions,
'action_buttons': self.action_buttons,
'table_config_form': TableConfigForm(table=table),
'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None,
}
context.update(self.extra_context())
@@ -1012,10 +1010,10 @@ class BulkDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
# Are we deleting *all* objects in the queryset or just a selected subset?
if request.POST.get('_all'):
qs = model.objects.all()
if self.filterset is not None:
pk_list = [obj.pk for obj in self.filterset(request.GET, model.objects.only('pk')).qs]
else:
pk_list = model.objects.values_list('pk', flat=True)
qs = self.filterset(request.GET, qs).qs
pk_list = qs.only('pk').values_list('pk', flat=True)
else:
pk_list = [int(pk) for pk in request.POST.getlist('pk')]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,6 @@
import { Collapse, Modal, Tab, Toast, Tooltip } from 'bootstrap';
import { Collapse, Modal, Popover, Tab, Toast, Tooltip } from 'bootstrap';
import Masonry from 'masonry-layout';
import { getElements } from './util';
import { createElement, getElements } from './util';
type ToastLevel = 'danger' | 'warning' | 'success' | 'info';
@@ -8,6 +8,7 @@ type ToastLevel = 'danger' | 'warning' | 'success' | 'info';
// plugins).
window.Collapse = Collapse;
window.Modal = Modal;
window.Popover = Popover;
window.Toast = Toast;
window.Tooltip = Tooltip;
@@ -156,13 +157,48 @@ function initSidebarAccordions(): void {
}
}
/**
* Initialize image preview popover, which shows a preview of an image from an image link with the
* `.image-preview` class.
*/
function initImagePreview(): void {
for (const element of getElements<HTMLAnchorElement>('a.image-preview')) {
// Generate a max-width that's a quarter of the screen's width (note - the actual element
// width will be slightly larger due to the popover body's padding).
const maxWidth = `${Math.round(window.innerWidth / 4)}px`;
// Create an image element that uses the linked image as its `src`.
const image = createElement('img', { src: element.href });
image.style.maxWidth = maxWidth;
// Create a container for the image.
const content = createElement('div', null, null, [image]);
// Initialize the Bootstrap Popper instance.
new Popover(element, {
// Attach this custom class to the popover so that it styling can be controlled via CSS.
customClass: 'image-preview-popover',
trigger: 'hover',
html: true,
content,
});
}
}
/**
* Enable any defined Bootstrap Tooltips.
*
* @see https://getbootstrap.com/docs/5.0/components/tooltips
*/
export function initBootstrap(): void {
for (const func of [initTooltips, initModals, initMasonry, initTabs, initSidebarAccordions]) {
for (const func of [
initTooltips,
initModals,
initMasonry,
initTabs,
initImagePreview,
initSidebarAccordions,
]) {
func();
}
}

View File

@@ -8,12 +8,12 @@ import { isTruthy, apiPatch, hasError, getElements } from '../util';
* @param element Connection Toggle Button Element
*/
function toggleConnection(element: HTMLButtonElement): void {
const id = element.getAttribute('data');
const url = element.getAttribute('data-url');
const connected = element.classList.contains('connected');
const status = connected ? 'planned' : 'connected';
if (isTruthy(id)) {
apiPatch(`/api/dcim/cables/${id}/`, { status }).then(res => {
if (isTruthy(url)) {
apiPatch(url, { status }).then(res => {
if (hasError(res)) {
// If the API responds with an error, show it to the user.
createToast('danger', 'Error', res.error).show();

View File

@@ -13,18 +13,26 @@ function initConfig(): void {
.then(data => {
if (hasError(data)) {
createToast('danger', 'Error Fetching Device Config', data.error).show();
console.error(data.error);
return;
} else if (hasError<Required<DeviceConfig['get_config']>>(data.get_config)) {
createToast('danger', 'Error Fetching Device Config', data.get_config.error).show();
console.error(data.get_config.error);
return;
} else {
const configTypes = [
'running',
'startup',
'candidate',
] as (keyof DeviceConfig['get_config'])[];
const configTypes = ['running', 'startup', 'candidate'] as DeviceConfigType[];
for (const configType of configTypes) {
const element = document.getElementById(`${configType}_config`);
if (element !== null) {
element.innerHTML = data.get_config[configType];
const config = data.get_config[configType];
if (typeof config === 'string') {
// If the returned config is a string, set the element innerHTML as-is.
element.innerHTML = config;
} else {
// If the returned config is an object (dict), convert it to JSON.
element.innerHTML = JSON.stringify(data.get_config[configType], null, 2);
}
}
}
}

View File

@@ -1,8 +1,21 @@
import { getElements, toggleVisibility } from '../util';
type ShowHideMap = {
default: { hide: string[]; show: string[] };
[k: string]: { hide: string[]; show: string[] };
/**
* Name of view to which this map should apply.
*
* @example vlangroup_edit
*/
[view: string]: {
/**
* Default layout.
*/
default: { hide: string[]; show: string[] };
/**
* Field name to layout mapping.
*/
[fieldName: string]: { hide: string[]; show: string[] };
};
};
/**
@@ -14,45 +27,47 @@ type ShowHideMap = {
* showHideMap.region.show should be shown.
*/
const showHideMap: ShowHideMap = {
region: {
hide: ['id_sitegroup', 'id_site', 'id_location', 'id_rack', 'id_clustergroup', 'id_cluster'],
show: ['id_region'],
},
'site group': {
hide: ['id_region', 'id_site', 'id_location', 'id_rack', 'id_clustergroup', 'id_cluster'],
show: ['id_sitegroup'],
},
site: {
hide: ['id_location', 'id_rack', 'id_clustergroup', 'id_cluster'],
show: ['id_region', 'id_sitegroup', 'id_site'],
},
location: {
hide: ['id_rack', 'id_clustergroup', 'id_cluster'],
show: ['id_region', 'id_sitegroup', 'id_site', 'id_location'],
},
rack: {
hide: ['id_clustergroup', 'id_cluster'],
show: ['id_region', 'id_sitegroup', 'id_site', 'id_location', 'id_rack'],
},
'cluster group': {
hide: ['id_region', 'id_sitegroup', 'id_site', 'id_location', 'id_rack', 'id_cluster'],
show: ['id_clustergroup'],
},
cluster: {
hide: ['id_region', 'id_sitegroup', 'id_site', 'id_location', 'id_rack'],
show: ['id_clustergroup', 'id_cluster'],
},
default: {
hide: [
'id_region',
'id_sitegroup',
'id_site',
'id_location',
'id_rack',
'id_clustergroup',
'id_cluster',
],
show: [],
vlangroup_edit: {
region: {
hide: ['id_sitegroup', 'id_site', 'id_location', 'id_rack', 'id_clustergroup', 'id_cluster'],
show: ['id_region'],
},
'site group': {
hide: ['id_region', 'id_site', 'id_location', 'id_rack', 'id_clustergroup', 'id_cluster'],
show: ['id_sitegroup'],
},
site: {
hide: ['id_location', 'id_rack', 'id_clustergroup', 'id_cluster'],
show: ['id_region', 'id_sitegroup', 'id_site'],
},
location: {
hide: ['id_rack', 'id_clustergroup', 'id_cluster'],
show: ['id_region', 'id_sitegroup', 'id_site', 'id_location'],
},
rack: {
hide: ['id_clustergroup', 'id_cluster'],
show: ['id_region', 'id_sitegroup', 'id_site', 'id_location', 'id_rack'],
},
'cluster group': {
hide: ['id_region', 'id_sitegroup', 'id_site', 'id_location', 'id_rack', 'id_cluster'],
show: ['id_clustergroup'],
},
cluster: {
hide: ['id_region', 'id_sitegroup', 'id_site', 'id_location', 'id_rack'],
show: ['id_clustergroup', 'id_cluster'],
},
default: {
hide: [
'id_region',
'id_sitegroup',
'id_site',
'id_location',
'id_rack',
'id_clustergroup',
'id_cluster',
],
show: [],
},
},
};
/**
@@ -76,11 +91,11 @@ function toggleParentVisibility(query: string, action: 'show' | 'hide') {
/**
* Handle changes to the Scope Type field.
*/
function handleScopeChange(element: HTMLSelectElement) {
function handleScopeChange<P extends keyof ShowHideMap>(view: P, element: HTMLSelectElement) {
// Scope type's innerText looks something like `DCIM > region`.
const scopeType = element.options[element.selectedIndex].innerText.toLowerCase();
for (const [scope, fields] of Object.entries(showHideMap)) {
for (const [scope, fields] of Object.entries(showHideMap[view])) {
// If the scope type ends with the specified scope, toggle its field visibility according to
// the show/hide values.
if (scopeType.endsWith(scope)) {
@@ -94,7 +109,7 @@ function handleScopeChange(element: HTMLSelectElement) {
break;
} else {
// Otherwise, hide all fields.
for (const field of showHideMap.default.hide) {
for (const field of showHideMap[view].default.hide) {
toggleParentVisibility(`#${field}`, 'hide');
}
}
@@ -105,8 +120,12 @@ function handleScopeChange(element: HTMLSelectElement) {
* Initialize scope type select event listeners.
*/
export function initScopeSelector(): void {
for (const element of getElements<HTMLSelectElement>('#id_scope_type')) {
handleScopeChange(element);
element.addEventListener('change', () => handleScopeChange(element));
for (const view of Object.keys(showHideMap)) {
for (const element of getElements<HTMLSelectElement>(
`html[data-netbox-url-name="${view}"] #id_scope_type`,
)) {
handleScopeChange(view, element);
element.addEventListener('change', () => handleScopeChange(view, element));
}
}
}

View File

@@ -1,4 +1,4 @@
import { all, getElement, resetSelect, toggleVisibility } from '../util';
import { all, getElement, resetSelect, toggleVisibility as _toggleVisibility } from '../util';
/**
* Get a select element's containing `.row` element.
@@ -14,6 +14,38 @@ function fieldContainer(element: Nullable<HTMLSelectElement>): Nullable<HTMLElem
return null;
}
/**
* Toggle visibility of the select element's container and disable the select element itself.
*
* @param element Select element.
* @param action 'show' or 'hide'
*/
function toggleVisibility<E extends Nullable<HTMLSelectElement>>(
element: E,
action: 'show' | 'hide',
): void {
// Find the select element's containing element.
const parent = fieldContainer(element);
if (element !== null && parent !== null) {
// Toggle container visibility to visually remove it from the form.
_toggleVisibility(parent, action);
// Create a new event so that the APISelect instance properly handles the enable/disable
// action.
const event = new Event(`netbox.select.disabled.${element.name}`);
switch (action) {
case 'hide':
// Disable the native select element and dispatch the event APISelect is listening for.
element.disabled = true;
element.dispatchEvent(event);
break;
case 'show':
// Enable the native select element and dispatch the event APISelect is listening for.
element.disabled = false;
element.dispatchEvent(event);
}
}
}
/**
* Toggle element visibility when the mode field does not have a value.
*/
@@ -29,7 +61,7 @@ function handleModeNone(): void {
resetSelect(untaggedVlan);
resetSelect(taggedVlans);
for (const element of elements) {
toggleVisibility(fieldContainer(element), 'hide');
toggleVisibility(element, 'hide');
}
}
}
@@ -46,9 +78,9 @@ function handleModeAccess(): void {
if (all(elements)) {
const [taggedVlans, untaggedVlan, vlanGroup] = elements;
resetSelect(taggedVlans);
toggleVisibility(fieldContainer(vlanGroup), 'show');
toggleVisibility(fieldContainer(untaggedVlan), 'show');
toggleVisibility(fieldContainer(taggedVlans), 'hide');
toggleVisibility(vlanGroup, 'show');
toggleVisibility(untaggedVlan, 'show');
toggleVisibility(taggedVlans, 'hide');
}
}
@@ -63,9 +95,9 @@ function handleModeTagged(): void {
];
if (all(elements)) {
const [taggedVlans, untaggedVlan, vlanGroup] = elements;
toggleVisibility(fieldContainer(taggedVlans), 'show');
toggleVisibility(fieldContainer(vlanGroup), 'show');
toggleVisibility(fieldContainer(untaggedVlan), 'show');
toggleVisibility(taggedVlans, 'show');
toggleVisibility(vlanGroup, 'show');
toggleVisibility(untaggedVlan, 'show');
}
}
@@ -81,9 +113,9 @@ function handleModeTaggedAll(): void {
if (all(elements)) {
const [taggedVlans, untaggedVlan, vlanGroup] = elements;
resetSelect(taggedVlans);
toggleVisibility(fieldContainer(vlanGroup), 'show');
toggleVisibility(fieldContainer(untaggedVlan), 'show');
toggleVisibility(fieldContainer(taggedVlans), 'hide');
toggleVisibility(vlanGroup, 'show');
toggleVisibility(untaggedVlan, 'show');
toggleVisibility(taggedVlans, 'hide');
}
}

View File

@@ -17,6 +17,11 @@ interface Window {
*/
Modal: typeof import('bootstrap').Modal;
/**
* Bootstrap Popover Instance.
*/
Popover: typeof import('bootstrap').Popover;
/**
* Bootstrap Toast Instance.
*/
@@ -147,12 +152,15 @@ type LLDPNeighborDetail = {
type DeviceConfig = {
get_config: {
candidate: string;
running: string;
startup: string;
candidate: string | Record<string, unknown>;
running: string | Record<string, unknown>;
startup: string | Record<string, unknown>;
error?: string;
};
};
type DeviceConfigType = Exclude<keyof DeviceConfig['get_config'], 'error'>;
type DeviceEnvironment = {
cpu?: {
[core: string]: { '%usage': number };

View File

@@ -4,7 +4,7 @@ import { apiGetBase, hasError, getNetboxData } from './util';
let timeout: number = 1000;
interface JobInfo {
id: Nullable<string>;
url: Nullable<string>;
complete: boolean;
}
@@ -23,15 +23,16 @@ function asyncTimeout(ms: number) {
function getJobInfo(): JobInfo {
let complete = false;
const id = getNetboxData('data-job-id');
const jobComplete = getNetboxData('data-job-complete');
// Determine the API URL for the job status
const url = getNetboxData('data-job-url');
// Determine the job completion status, if present. If the job is not complete, the value will be
// "None". Otherwise, it will be a stringified date.
const jobComplete = getNetboxData('data-job-complete');
if (typeof jobComplete === 'string' && jobComplete.toLowerCase() !== 'none') {
complete = true;
}
return { id, complete };
return { url, complete };
}
/**
@@ -59,10 +60,10 @@ function updateLabel(status: JobStatus) {
/**
* Recursively check the job's status.
* @param id Job ID
* @param url API URL for job result
*/
async function checkJobStatus(id: string) {
const res = await apiGetBase<APIJobResult>(`/api/extras/job-results/${id}/`);
async function checkJobStatus(url: string) {
const res = await apiGetBase<APIJobResult>(url);
if (hasError(res)) {
// If the response is an API error, display an error message and stop checking for job status.
const toast = createToast('danger', 'Error', res.error);
@@ -82,17 +83,17 @@ async function checkJobStatus(id: string) {
if (timeout < 10000) {
timeout += 1000;
}
await Promise.all([checkJobStatus(id), asyncTimeout(timeout)]);
await Promise.all([checkJobStatus(url), asyncTimeout(timeout)]);
}
}
}
function initJobs() {
const { id, complete } = getJobInfo();
const { url, complete } = getJobInfo();
if (id !== null && !complete) {
if (url !== null && !complete) {
// If there is a job ID and it is not completed, check for the job's status.
Promise.resolve(checkJobStatus(id));
Promise.resolve(checkJobStatus(url));
}
}

View File

@@ -5,7 +5,7 @@ import SlimSelect from 'slim-select';
import { createToast } from '../../bs';
import { hasUrl, hasExclusions, isTrigger } from '../util';
import { DynamicParamsMap } from './dynamicParams';
import { isStaticParams } from './types';
import { isStaticParams, isOption } from './types';
import {
hasMore,
isTruthy,
@@ -23,7 +23,7 @@ import type { Option } from 'slim-select/dist/data';
import type { Trigger, PathFilter, ApplyMethod, QueryFilter } from './types';
// Empty placeholder option.
const PLACEHOLDER = {
const EMPTY_PLACEHOLDER = {
value: '',
text: '',
placeholder: true,
@@ -52,6 +52,18 @@ export class APISelect {
*/
public readonly placeholder: string;
/**
* Empty/placeholder option. Display text is optionally overridden via the `data-empty-option`
* attribute.
*/
public readonly emptyOption: Option;
/**
* Null option. When `data-null-option` attribute is a string, the value is used to created an
* option of type `{text: '<value from data-null-option>': 'null'}`.
*/
public readonly nullOption: Nullable<Option> = null;
/**
* Event that will initiate the API call to NetBox to load option data. By default, the trigger
* is `'load'`, so data will be fetched when the element renders on the page.
@@ -137,18 +149,6 @@ export class APISelect {
*/
private more: Nullable<string> = null;
/**
* This element's options come from the server pre-sorted and should not be sorted client-side.
* Determined by the existence of the `pre-sorted` attribute on the base `<select/>` element, or
* by existence of specific fields such as `_depth`.
*/
private preSorted: boolean = false;
/**
* This instance's available options.
*/
private _options: Option[] = [PLACEHOLDER];
/**
* Array of options values which should be considered disabled or static.
*/
@@ -164,10 +164,6 @@ export class APISelect {
this.base = base;
this.name = base.name;
if (base.getAttribute('pre-sorted') !== null) {
this.preSorted = true;
}
if (hasUrl(base)) {
const url = base.getAttribute('data-url') as string;
this.url = url;
@@ -181,6 +177,24 @@ export class APISelect {
this.disabledOptions = this.getDisabledOptions();
this.disabledAttributes = this.getDisabledAttributes();
const emptyOption = base.getAttribute('data-empty-option');
if (isTruthy(emptyOption)) {
this.emptyOption = {
text: emptyOption,
value: '',
};
} else {
this.emptyOption = EMPTY_PLACEHOLDER;
}
const nullOption = base.getAttribute('data-null-option');
if (isTruthy(nullOption)) {
this.nullOption = {
text: nullOption,
value: 'null',
};
}
this.slim = new SlimSelect({
select: this.base,
allowDeselect: true,
@@ -265,38 +279,32 @@ export class APISelect {
* This instance's available options.
*/
private get options(): Option[] {
return this._options;
return this.slim.data.data.filter(isOption);
}
/**
* Sort incoming options by label and apply the new options to both the SlimSelect instance and
* this manager's state. If the `preSorted` attribute exists on the base `<select/>` element,
* the options will *not* be sorted.
* Apply new options to both the SlimSelect instance and this manager's state.
*/
private set options(optionsIn: Option[]) {
let newOptions = optionsIn;
if (!this.preSorted) {
newOptions = optionsIn.sort((a, b) => (a.text.toLowerCase() > b.text.toLowerCase() ? 1 : -1));
// Ensure null option is present, if it exists.
if (this.nullOption !== null) {
newOptions = [this.nullOption, ...newOptions];
}
// Deduplicate options each time they're set.
let deduplicated = uniqueByProperty(newOptions, 'value');
const deduplicated = uniqueByProperty(newOptions, 'value');
// Determine if the new options have a placeholder.
const hasPlaceholder = typeof deduplicated.find(o => o.value === '') !== 'undefined';
// Get the placeholder index (note: if there is no placeholder, the index will be `-1`).
const placeholderIdx = deduplicated.findIndex(o => o.value === '');
if (hasPlaceholder && placeholderIdx < 0) {
// If there is a placeholder but it is not the first element (due to sorting or other merge
// issues), remove it from the options array and place it in front.
deduplicated.splice(placeholderIdx);
deduplicated = [PLACEHOLDER, ...deduplicated];
if (hasPlaceholder && placeholderIdx >= 0) {
// If there is an existing placeholder, replace it.
deduplicated[placeholderIdx] = this.emptyOption;
} else {
// If there is not a placeholder, add one to the front.
deduplicated.unshift(this.emptyOption);
}
if (!hasPlaceholder) {
// If there is no placeholder, add one to the front of the array.
deduplicated = [PLACEHOLDER, ...deduplicated];
}
this._options = deduplicated;
this.slim.setData(deduplicated);
}
@@ -304,7 +312,7 @@ export class APISelect {
* Remove all options and reset back to the generic placeholder.
*/
private resetOptions(): void {
this.options = [PLACEHOLDER];
this.options = [this.emptyOption];
}
/**
@@ -320,6 +328,7 @@ export class APISelect {
this.slim.slim.multiSelected.container.setAttribute('disabled', '');
}
}
this.slim.disable();
}
/**
@@ -335,6 +344,7 @@ export class APISelect {
this.slim.slim.multiSelected.container.removeAttribute('disabled');
}
}
this.slim.enable();
}
/**
@@ -346,7 +356,12 @@ export class APISelect {
const fetcher = debounce((event: Event) => this.handleSearch(event), 300, false);
// Query the API when the input value changes or a value is pasted.
this.slim.slim.search.input.addEventListener('keyup', event => fetcher(event));
this.slim.slim.search.input.addEventListener('keyup', event => {
// Only search when necessary keys are pressed.
if (!event.key.match(/^(Arrow|Enter|Tab).*/)) {
return fetcher(event);
}
});
this.slim.slim.search.input.addEventListener('paste', event => fetcher(event));
// Watch every scroll event to determine if the scroll position is at bottom.
@@ -357,6 +372,11 @@ export class APISelect {
this.fetchOptions(this.more, 'merge'),
);
// When the base select element is disabled or enabled, properly disable/enable this instance.
this.base.addEventListener(`netbox.select.disabled.${this.name}`, event =>
this.handleDisableEnable(event),
);
// Create a unique iterator of all possible form fields which, when changed, should cause this
// element to update its API query.
// const dependencies = new Set([...this.filterParams.keys(), ...this.pathValues.keys()]);
@@ -389,6 +409,19 @@ export class APISelect {
}
}
/**
* Get all options from the native select element that are already selected and do not contain
* placeholder values.
*/
private getPreselectedOptions(): HTMLOptionElement[] {
return Array.from(this.base.options)
.filter(option => option.selected)
.filter(option => {
if (option.value === '---------' || option.innerText === '---------') return false;
return true;
});
}
/**
* Process a valid API response and add results to this instance's options.
*
@@ -398,24 +431,27 @@ export class APISelect {
data: APIAnswer<APIObjectBase>,
action: ApplyMethod = 'merge',
): Promise<void> {
// Get all non-placeholder (empty) options' values. If any exist, it means we're editing an
// existing object. When we fetch options from the API later, we can set any of the options
// contained in this array to `selected`.
const selectOptions = Array.from(this.base.options)
.filter(option => option.selected)
.map(option => option.getAttribute('value'))
.filter(isTruthy);
// Get all already-selected options.
const preSelected = this.getPreselectedOptions();
// Get the values of all already-selected options.
const selectedValues = preSelected.map(option => option.getAttribute('value')).filter(isTruthy);
// Build SlimSelect options from all already-selected options.
const preSelectedOptions = preSelected.map(option => ({
value: option.value,
text: option.innerText,
selected: true,
disabled: false,
})) as Option[];
let options = [] as Option[];
for (const result of data.results) {
let text = result.display;
if (typeof result._depth === 'number') {
if (typeof result._depth === 'number' && result._depth > 0) {
// If the object has a `_depth` property, indent its display text.
if (!this.preSorted) {
this.preSorted = true;
}
text = `<span class="depth">${'─'.repeat(result._depth)}&nbsp;</span>${text}`;
}
const data = {} as Record<string, string>;
@@ -441,12 +477,12 @@ export class APISelect {
}
// Set option to disabled if it is contained within the disabled array.
if (selectOptions.some(option => this.disabledOptions.includes(option))) {
if (selectedValues.some(option => this.disabledOptions.includes(option))) {
disabled = true;
}
// Set pre-selected options.
if (selectOptions.includes(value)) {
if (selectedValues.includes(value)) {
selected = true;
// If an option is selected, it can't be disabled. Otherwise, it won't be submitted with
// the rest of the form, resulting in that field's value being deleting from the object.
@@ -469,7 +505,8 @@ export class APISelect {
this.options = [...this.options, ...options];
break;
case 'replace':
this.options = options;
this.options = [...preSelectedOptions, ...options];
break;
}
if (hasMore(data)) {
@@ -507,7 +544,7 @@ export class APISelect {
*/
private async getOptions(action: ApplyMethod = 'merge'): Promise<void> {
if (this.queryUrl.includes(`{{`)) {
this.options = [PLACEHOLDER];
this.resetOptions();
return;
}
await this.fetchOptions(this.queryUrl, action);
@@ -558,6 +595,23 @@ export class APISelect {
Promise.all([this.loadData()]);
}
/**
* Event handler to be dispatched when the base select element is disabled or enabled. When that
* occurs, run the instance's `disable()` or `enable()` methods to synchronize UI state with
* desired action.
*
* @param event Dispatched event matching pattern `netbox.select.disabled.<name>`
*/
private handleDisableEnable(event: Event): void {
const target = event.target as HTMLSelectElement;
if (target.disabled === true) {
this.disable();
} else if (target.disabled === false) {
this.enable();
}
}
/**
* When the API returns an error, show it to the user and reset this element's available options.
*
@@ -715,7 +769,7 @@ export class APISelect {
private getPlaceholder(): string {
let placeholder = this.name;
if (this.base.id) {
const label = document.querySelector(`label[for=${this.base.id}]`) as HTMLLabelElement;
const label = document.querySelector(`label[for="${this.base.id}"]`) as HTMLLabelElement;
// Set the placeholder text to the label value, if it exists.
if (label !== null) {
placeholder = `Select ${label.innerText.trim()}`;

View File

@@ -1,4 +1,5 @@
import type { Stringifiable } from 'query-string';
import type { Option, Optgroup } from 'slim-select/dist/data';
/**
* Map of string keys to primitive array values accepted by `query-string`. Keys are used as
@@ -187,3 +188,12 @@ export function isStaticParams(value: unknown): value is DataStaticParam[] {
}
return false;
}
/**
* Type guard to determine if a SlimSelect `dataObject` is an `Option`.
*
* @param data Option or Option Group
*/
export function isOption(data: Option | Optgroup): data is Option {
return !('options' in data);
}

View File

@@ -4,7 +4,7 @@ import { getElements } from '../util';
export function initStaticSelect(): void {
for (const select of getElements<HTMLSelectElement>('.netbox-static-select')) {
if (select !== null) {
const label = document.querySelector(`label[for=${select.id}]`) as HTMLLabelElement;
const label = document.querySelector(`label[for="${select.id}"]`) as HTMLLabelElement;
let placeholder;
if (label !== null) {

View File

@@ -53,8 +53,8 @@ function removeColumns(event: Event): void {
/**
* Submit form configuration to the NetBox API.
*/
async function submitFormConfig(formConfig: Dict<Dict>): Promise<APIResponse<APIUserConfig>> {
return await apiPatch<APIUserConfig>('/api/users/config/', formConfig);
async function submitFormConfig(url: string, formConfig: Dict<Dict>): Promise<APIResponse<APIUserConfig>> {
return await apiPatch<APIUserConfig>(url, formConfig);
}
/**
@@ -66,6 +66,18 @@ function handleSubmit(event: Event): void {
const element = event.currentTarget as HTMLFormElement;
// Get the API URL for submitting the form
const url = element.getAttribute('data-url');
if (url == null) {
const toast = createToast(
'danger',
'Error Updating Table Configuration',
'No API path defined for configuration form.'
);
toast.show();
return;
}
// Get all the selected options from any select element in the form.
const options = getSelectedOptions(element);
@@ -83,7 +95,7 @@ function handleSubmit(event: Event): void {
const data = path.reduceRight<Dict<Dict>>((value, key) => ({ [key]: value }), formData);
// Submit the resulting object to the API to update the user's preferences for this table.
submitFormConfig(data).then(res => {
submitFormConfig(url, data).then(res => {
if (hasError(res)) {
const toast = createToast('danger', 'Error Updating Table Configuration', res.error);
toast.show();

View File

@@ -11,14 +11,16 @@ type InferredProps<
// Element name.
T extends keyof HTMLElementTagNameMap,
// Element type.
E extends HTMLElementTagNameMap[T] = HTMLElementTagNameMap[T]
E extends HTMLElementTagNameMap[T] = HTMLElementTagNameMap[T],
> = Partial<Record<keyof E, E[keyof E]>>;
export function isApiError(data: Record<string, unknown>): data is APIError {
return 'error' in data && 'exception' in data;
}
export function hasError(data: Record<string, unknown>): data is ErrorBase {
export function hasError<E extends ErrorBase = ErrorBase>(
data: Record<string, unknown>,
): data is E {
return 'error' in data;
}
@@ -94,7 +96,7 @@ export function isElement(obj: Element | null | undefined): obj is Element {
/**
* Retrieve the CSRF token from cookie storage.
*/
export function getCsrfToken(): string {
function getCsrfToken(): string {
const { csrftoken: csrfToken } = Cookie.parse(document.cookie);
if (typeof csrfToken === 'undefined') {
throw new Error('Invalid or missing CSRF token');
@@ -367,8 +369,13 @@ export function createElement<
// Element props.
P extends InferredProps<T>,
// Child element type.
C extends HTMLElement = HTMLElement
>(tag: T, properties: P | null, classes: string[], children: C[] = []): HTMLElementTagNameMap[T] {
C extends HTMLElement = HTMLElement,
>(
tag: T,
properties: P | null,
classes: Nullable<string[]> = null,
children: C[] = [],
): HTMLElementTagNameMap[T] {
// Create the base element.
const element = document.createElement<T>(tag);
@@ -384,7 +391,9 @@ export function createElement<
}
// Add each CSS class to the element's class list.
element.classList.add(...classes);
if (classes !== null && classes.length > 0) {
element.classList.add(...classes);
}
for (const child of children) {
// Add each child element to the base element.
@@ -400,7 +409,7 @@ export function createElement<
* @returns Degrees in Fahrenheit.
*/
export function cToF(celsius: number): number {
return celsius * (9 / 5) + 32;
return Math.round((celsius * (9 / 5) + 32 + Number.EPSILON) * 10) / 10;
}
/**

View File

@@ -956,6 +956,11 @@ div.card-overlay {
}
}
// Remove the max-width from image preview popovers as this is controlled on the image element.
.popover.image-preview-popover {
max-width: unset;
}
#django-messages {
position: fixed;
right: $spacer;
@@ -966,7 +971,7 @@ div.card-overlay {
// Page-specific styles.
html {
// Shade the home page content background-color.
&[data-netbox-path='/'] {
&[data-netbox-url-name='home'] {
.content-container,
.search {
background-color: $gray-100 !important;
@@ -980,7 +985,7 @@ html {
}
// Don't show the django-messages toasts on the login screen in favor of the alert component.
&[data-netbox-path*='/login'] {
&[data-netbox-url-name='login'] {
#django-messages {
display: none;
}

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