Compare commits

...

104 Commits

Author SHA1 Message Date
Jeremy Stretch
f1877c0c5f Merge pull request #9955 from netbox-community/develop
Release v3.2.8
2022-08-08 15:32:38 -04:00
jeremystretch
ce7fb8ab17 Release v3.2.8 2022-08-08 15:17:36 -04:00
jeremystretch
caca074161 Fixes #9950: Prevent redirection to arbitrary URLs via 'next' parameter on login URL 2022-08-08 14:21:42 -04:00
jeremystretch
8721ad987c Fixes #9952: Prevent InvalidMove when attempting to assign a nested child object as parent 2022-08-08 12:22:22 -04:00
jeremystretch
876251c1cf Fixes #9948: Fix TypeError exception when requesting API tokens list as non-authenticated user 2022-08-08 12:22:01 -04:00
jeremystretch
36ac83a319 Fixes #9949: Fix KeyError exception resulting from invalid API token provisioning request 2022-08-08 11:43:27 -04:00
jeremystretch
90317adae7 Clean up usages of mark_safe() 2022-08-08 10:47:07 -04:00
jeremystretch
135543683d Changelog for #9919 2022-08-08 10:24:49 -04:00
Jeremy Stretch
38350a1023 Merge pull request #9940 from osamu-kj/develop
Fixes #9919: XSS Bypass in custom fields displayed in tables
2022-08-08 10:10:11 -04:00
jeremystretch
0e1947bc4b PEP8 fix 2022-08-08 09:58:58 -04:00
Osamu-kj
7141fc8eb0 Custom fields - removed the debug lines 2022-08-06 17:17:43 +02:00
Osamu-kj
db38ed4f19 Fixed the XSS protection code inside custom fields 2022-08-06 15:10:31 +02:00
Osamu-kj
f874e9932d Added HTML Sanitization to the custom fields 2022-08-04 18:52:25 +02:00
jeremystretch
a2e84dd279 Changelog for #9827, #9906 2022-08-03 15:22:51 -04:00
Jeremy Stretch
a397ce234a Merge pull request #9850 from sleepinggenius2/issue_9827
Adds patterned_fields support for bulk component creation
2022-08-03 15:22:16 -04:00
Jeremy Stretch
3694e5e846 Merge pull request #9911 from oasys/9906-support-color-on-frontrearport-import-export
Fixes #9906 import/export front/rearport color field for module- and device-types
2022-08-03 14:58:02 -04:00
Jason Lavoie
c6e25f068d import/export color field on front- and rear-ports for module-types and device-types
Closes: #9906

- Adds `color` field to front and rearport template import forms
- Adds `color` field to `to_yaml` export for front and rearport
  templates
2022-08-03 09:22:06 -04:00
jeremystretch
ff3fcb8134 #9871: Tweak display of utilization graph value 2022-08-01 12:38:12 -04:00
jeremystretch
d4d73674fc Fixes #9871: Fix utilization graph value alignments 2022-08-01 11:54:39 -04:00
jeremystretch
984d15d7fb Closes #9893: Move YAML serialization logic for component templates under the individual models 2022-08-01 11:39:07 -04:00
jeremystretch
efa449faff Closes #9882: Add manufacturer column to modules table 2022-08-01 10:36:53 -04:00
jeremystretch
3af989763e Closes #9883: Linkify location column in power panels table 2022-08-01 10:32:05 -04:00
jeremystretch
9646f88384 Fixes #9885: Fix child prefix counts when editing/deleting aggregates in bulk 2022-08-01 10:28:21 -04:00
jeremystretch
1bbf5d214b Closes #9881: Increase granularity in utilization graph values 2022-08-01 10:23:18 -04:00
jeremystretch
8a075bcff9 Fixes #9884: Prevent querying assigned VRF on prefix object init 2022-08-01 09:47:18 -04:00
jeremystretch
9fe5f09742 Fixes #9891: Ensure consistent ordering for tags during object serialization 2022-08-01 09:32:52 -04:00
jeremystretch
84f2225f42 PEP8 cleanup 2022-08-01 09:16:58 -04:00
jeremystretch
728ad51624 Changelog & cleanup for #9637 2022-08-01 09:12:15 -04:00
atownson
5ab03b7e92 Closes #9637: Add existing fields to the Rack Reservation user interface pages (#9870)
* Closes netbox-community#9637

Added site_group to RackReservationFrom class fieldsets
Added location to RackReservationTable class
2022-08-01 09:01:18 -04:00
Daniel Sheppard
890efa5400 Fixes #9062 - Add/edit {module} substitution to help text for component template name 2022-07-29 11:55:26 -05:00
jeremystretch
07620db027 Changelog for #9762 2022-07-28 12:45:27 -04:00
Jeremy Stretch
f8a3ffae4e Merge pull request #9868 from atownson/issue_9762
Closes #9762: Add nat_outside to the IPAddress table
2022-07-28 12:43:13 -04:00
atownson
62d1510c55 Closes netbox-community#9762
Added nat_outside to IPAddressTable class
2022-07-28 10:23:04 -05:00
jeremystretch
498b655cb7 Changelog and cleanup for #9825 2022-07-27 16:50:31 -04:00
Jeremy Stretch
fa94d9c82c Merge pull request #9826 from viroge/develop
Closes #9825: Add Contacts to VM table view
2022-07-27 16:48:08 -04:00
jeremystretch
6cee12b153 Fix formatting of webhook conditions form field 2022-07-27 15:40:25 -04:00
sleepinggenius2
bbf4b906e4 Adds patterned_fields support for bulk components 2022-07-26 17:16:03 -04:00
Gabor SOMOGYVARI
451a0067c7 Closes #9825: Add Contacts to VM table view 2022-07-22 10:42:20 +02:00
jeremystretch
383918d83b PRVB 2022-07-20 11:15:02 -04:00
Jeremy Stretch
f8cbd322ba Merge pull request #9801 from netbox-community/develop
Release v3.2.7
2022-07-20 11:13:00 -04:00
jeremystretch
9835d6b2df Release NetBox v3.2.7 2022-07-20 10:57:11 -04:00
jeremystretch
17e00ac040 Fixes #9705: Support filter expressions for the serial field on racks, devices, and inventory items 2022-07-20 10:39:36 -04:00
jeremystretch
1c9db2d9f8 Fixes #9499: Fix filtered bulk deletion of VM Interfaces 2022-07-19 16:21:32 -04:00
jeremystretch
44586743ea Fixes #9437: Standardize form submission buttons and behavior when using enter key 2022-07-19 14:21:20 -04:00
jeremystretch
802d9d2b6e Fixes #9749: Retain original slug values when modifying object names 2022-07-19 13:01:51 -04:00
jeremystretch
a7a20ad2ea Fixes #9775: Fix exception when viewing a report with no descripton 2022-07-19 13:01:21 -04:00
Jeremy Stretch
124ff23e3d Merge pull request #9750 from smuth4/fix/broken-image-urls
Fixes #9634: Respect image URLs which are already fully formed
2022-07-19 12:46:32 -04:00
jeremystretch
1a028f77d4 Changelog for #9754 2022-07-18 09:16:43 -04:00
Jeremy Stretch
7603468abc Merge pull request #9757 from kkthxbye-code/fix-9754-2
Fixes #9754 -Revert #9735 & #9696
2022-07-18 08:40:45 -04:00
kkthxbye-code
b854cefb57 Revert #9735 & #9696 2022-07-17 17:33:47 +02:00
Stephen Muth
3d475e5afa Fixes #9634: Respect image URLs which are already fully formed
For local storage, URLs will always be relative, but some custom storage backends
such as S3 may return absolute ones.
2022-07-16 11:46:25 -04:00
jeremystretch
250265c3d9 Fixes #9746: Permit filtering interfaces by arbitrary speed value in UI 2022-07-15 15:40:55 -04:00
jeremystretch
e07dd3ddcb Define NESTED_SERIALIZER_PREFIX constant 2022-07-15 15:31:42 -04:00
jeremystretch
68f53aaa87 Closes #9745: Add wireless LANs and links to global search 2022-07-15 15:28:00 -04:00
Jeremy Stretch
5fda5cc08c Merge pull request #9742 from henryriveraCS/develop
Corrected typo for description of 'snapshots'
2022-07-15 14:53:15 -04:00
Henry
6da171a699 Corrected typo for description of 'snapshots' 2022-07-15 10:52:37 -04:00
jeremystretch
fe2fae5b86 Closes #9741: Check for UserConfig instance during user login 2022-07-15 09:42:12 -04:00
jeremystretch
5b5160ca6f Fixes #9715: Fix SOCIAL_AUTH_PIPELINE config parameter not taking effect 2022-07-15 08:34:30 -04:00
Jeremy Stretch
b9dd654e7a Merge pull request #9735 from kkthxbye-code/fix-9734
Fixes #9734 & #9713 - Only set focus on select field search boxes if the select is open
2022-07-15 08:14:46 -04:00
kkthxbye-code
b0df24e6d1 UI: Only set focus on select field search boxes if the select is open 2022-07-15 08:51:05 +02:00
jeremystretch
57397570c0 Drop extraneous/invalid prefetches 2022-07-11 12:45:53 -04:00
jeremystretch
e106d7ac3a PRVB 2022-07-11 12:11:50 -04:00
Jeremy Stretch
b72793a85a Merge pull request #9706 from netbox-community/develop
Release v3.2.6
2022-07-11 12:09:21 -04:00
jeremystretch
68f24755aa Release v3.2.6 2022-07-11 11:41:28 -04:00
jeremystretch
5a4467a4a8 Fixes #9687: Don't restrict custom text field lengths when entering via UI form 2022-07-11 11:12:06 -04:00
jeremystretch
7c109ffd8c Fixes #9697: Fix device role link under device view 2022-07-11 11:02:18 -04:00
jeremystretch
6415661b61 Remove extraneous argument to GenericRelation 2022-07-11 10:33:14 -04:00
jeremystretch
ed7f42a803 Fixes #9704: Include last_updated field on JournalEntry REST API serializer 2022-07-11 10:28:37 -04:00
jeremystretch
e2af716a81 #9686: Add default accessor to TenantGroupColumn 2022-07-11 10:03:45 -04:00
jeremystretch
d3f91ce0a6 Changelog for #9632, #9686 2022-07-11 09:57:12 -04:00
Jeremy Stretch
dde005366a Merge pull request #9700 from PieterL75/issue9656_journalviewlayout
Fixes: #9656 Re-order journal list and form
2022-07-11 09:54:48 -04:00
Jeremy Stretch
85cab8d9b0 Merge pull request #9696 from kkthxbye-code/fix-9632
Fixes #9632 - Focus on select field after populating data
2022-07-11 09:51:45 -04:00
Jeremy Stretch
a49d3d2ddc Merge pull request #9695 from hagbarddenstore/issue_9686
Add Tenant Group column to tables #9686
2022-07-11 09:50:42 -04:00
kkthxbye-code
93c30c94b3 Focus on select field after populating data 2022-07-10 19:16:16 +02:00
Kim Johansson
1539769c08 Prefetch Tenant Group on user configurable tables
Prefetch the Tenant Group in views which allows its table to be configured
by the user. This decreases the amount of database queries that are required
to fetch the data.
2022-07-10 17:17:56 +02:00
Kim Johansson
c7ece43a18 Remove Tenant Group from child tables
Removes the Tenant Group column from tables which aren't configurable
by the user.
2022-07-10 17:16:12 +02:00
Kim Johansson
69a22ffe5e Prefetch Tenant Group in global search
Configure the prefetch to also include the Tenant Group, avoids additional
database queries when the Tenant Group column is to be rendered.

NOTE: If no personalisation of the global search tables should be done,
this commit can be reverted.
2022-07-10 15:38:21 +02:00
Kim Johansson
e6bfde1397 Replace TenantColumn with new TenancyColumnsMixin
Replaces all usages of the TenantColumn with the new TenancyColumnsMixin.

This enables the user to add a column for Tenant Group on all tables which
also has a column for Tenant.
2022-07-10 15:13:48 +02:00
Kim Johansson
bd60d46b82 Table mixin for Tenancy columns
A mixin to add the Tenant and Tenant Group columns to a table.
2022-07-10 15:08:55 +02:00
Kim Johansson
3c2a55a521 Add TenantGroupColumn to display Tenant Group on tables
Works the same as the existing TenantColumn, but displats the Tenant Group of
the Tenant.

Views should prefetch the Tenants Group for this to be efficient in large
tables.
2022-07-10 11:58:45 +02:00
jeremystretch
a40ab9ffb1 Fixes #9657: Fix filtering for custom fields and webhooks in the UI 2022-07-08 14:59:16 -04:00
jeremystretch
55b3e4eeb3 Fixes #9682: Fix bulk assignment of ASNs to sites 2022-07-08 14:16:42 -04:00
Pieter Lambrecht
13f854c91f Re-order journal list and form 2022-07-06 14:10:10 +02:00
jeremystretch
29f629156a Update NOTICE file 2022-07-01 11:36:34 -04:00
jeremystretch
8e200a9cb4 #9403: Add labels to device VC fields 2022-06-28 16:24:56 -04:00
jeremystretch
ccb7e96d8a Changelog for #8854, #9403, #9540 2022-06-28 16:22:38 -04:00
Jeremy Stretch
f75ddeb721 Merge pull request #9622 from cpund/9403-branch
Closes #9403: Add support for setting a virtual chassis on a device in the GUI
2022-06-28 16:21:22 -04:00
Jeremy Stretch
221ddc6d0f Merge pull request #9600 from huntabyte/issue-8854
Fixes #8854: Remote auth default groups added to new remote auth users
2022-06-28 15:29:23 -04:00
Jeremy Stretch
31c752bf3a Merge pull request #9607 from huntabyte/feature-9540
Closes #9540: Filter IP addresses by assigned Device/VM
2022-06-28 15:08:37 -04:00
Hunter Johnston
2077378ae1 Closes #9540: Filter IP addresses by assigned Device/VM 2022-06-25 15:41:31 -04:00
Hunter Johnston
9b91c2a886 syntax: Removed dev imports 2022-06-23 23:29:14 -04:00
Hunter Johnston
d8b40056b5 Fixes #8854: Remote auth default groups added to new remote auth users 2022-06-23 20:54:26 -04:00
jeremystretch
4315c4697c Ignore default field values which reference ConfigItems when calculating migrations 2022-06-23 17:44:19 -04:00
jeremystretch
b77013c859 Docs & cleanup for #7702 2022-06-23 17:26:20 -04:00
Jeremy Stretch
f7de2611c1 Merge pull request #9576 from huntabyte/feature-7702
Closes #7702: Add Power feed defaults to user configurations
2022-06-23 17:00:06 -04:00
Hunter Johnston
c330282919 Fix syntax error 2022-06-23 14:56:24 -04:00
Hunter Johnston
db807ab4a6 Closes #7702: Add power feed defaults to user configurations 2022-06-23 14:16:09 -04:00
jeremystretch
d55e3c352a Changelog for #9396, #9575, #9597 2022-06-23 14:14:02 -04:00
Hunter Johnston
afec53cea3 Fixes #9575: Add exception handling to services (#9586) 2022-06-23 14:11:59 -04:00
Hunter Johnston
6cb8b9110e Closes #9396: Query modules by module bay & display installed_modules for module_bay REST API endpoint (#9574)
* Closes #9396 - Added ability to query modules by module bay & installed_modules for module bay REST API endpoint

* Closes #9396 - Added ability to query modules by module bay & installed_modules for module bay REST API endpoint

* Closes #9396 - Added ability to query modules by module bay & installed_modules for module bay REST API endpoint
2022-06-23 13:28:36 -04:00
Hunter Johnston
52178f78d1 Closes #7702: Add Powerfeed Defaults to User Configurations 2022-06-21 12:58:41 -04:00
jeremystretch
575e2c443b PRVB 2022-06-20 11:38:49 -04:00
Craig Pund
6876c9878e added field definitions for device form 2022-06-13 17:06:08 -04:00
Craig Pund
29a5fb041f add fields for virtual chassis to device_edit form 2022-06-13 17:04:25 -04:00
98 changed files with 1128 additions and 854 deletions

View File

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

6
NOTICE
View File

@@ -1 +1,7 @@
Copyrighted and licensed under Apache License 2.0 by DigitalOcean, LLC.
This project contains code developed expressly for NetBox, and its reuse in
other projects may introduce issues affecting performance, data integrity,
and security.
For more information, please see https://github.com/netbox-community/netbox.

View File

@@ -1,6 +1,10 @@
# HTML sanitizer
# https://github.com/mozilla/bleach
bleach
# The Python web framework on which NetBox is built
# https://github.com/django/django
Django
Django<4.1
# Django middleware which permits cross-domain API requests
# https://github.com/OttoYiu/django-cors-headers
@@ -126,7 +130,3 @@ tablib
# Timezone data (required by django-timezone-field on Python 3.9+)
# https://github.com/python/tzdata
tzdata
# HTML sanitizer
# https://github.com/mozilla/bleach
bleach

View File

@@ -34,4 +34,4 @@ REMOTE_AUTH_BACKEND = 'social_core.backends.google.GoogleOAuth2'
NetBox supports single sign-on authentication via the [python-social-auth](https://github.com/python-social-auth) library. To enable SSO, specify the path to the desired authentication backend within the `social_core` Python package. Please see the complete list of [supported authentication backends](https://github.com/python-social-auth/social-core/tree/master/social_core/backends) for the available options.
Most remote authentication backends require some additional configuration through settings prefixed with `SOCIAL_AUTH_`. These will be automatically imported from NetBox's `configuration.py` file. Additionally, the [authentication pipeline](https://python-social-auth.readthedocs.io/en/latest/pipeline.html) can be customized via the `SOCIAL_AUTH_PIPELINE` parameter.
Most remote authentication backends require some additional configuration through settings prefixed with `SOCIAL_AUTH_`. These will be automatically imported from NetBox's `configuration.py` file. Additionally, the [authentication pipeline](https://python-social-auth.readthedocs.io/en/latest/pipeline.html) can be customized via the `SOCIAL_AUTH_PIPELINE` parameter. (NetBox's default pipeline is defined in `netbox/settings.py` for your reference.)

View File

@@ -43,18 +43,6 @@ changes in the database indefinitely.
---
## JOBRESULT_RETENTION
Default: 90
The number of days to retain job results (scripts and reports). Set this to `0` to retain
job results in the database indefinitely.
!!! warning
If enabling indefinite job results retention, it is recommended to periodically delete old entries. Otherwise, the database may eventually exceed capacity.
---
## CUSTOM_VALIDATORS
This is a mapping of models to [custom validators](../customization/custom-validation.md) that have been defined locally to enforce custom validation logic. An example is provided below:
@@ -110,6 +98,18 @@ Setting this to False will disable the GraphQL API.
---
## JOBRESULT_RETENTION
Default: 90
The number of days to retain job results (scripts and reports). Set this to `0` to retain
job results in the database indefinitely.
!!! warning
If enabling indefinite job results retention, it is recommended to periodically delete old entries. Otherwise, the database may eventually exceed capacity.
---
## MAINTENANCE_MODE
Default: False
@@ -185,6 +185,30 @@ The default maximum number of objects to display per page within each list of ob
---
## POWERFEED_DEFAULT_AMPERAGE
Default: 15
The default value for the `amperage` field when creating new power feeds.
---
## POWERFEED_DEFAULT_MAX_UTILIZATION
Default: 80
The default value (percentage) for the `max_utilization` field when creating new power feeds.
---
## POWERFEED_DEFAULT_VOLTAGE
Default: 120
The default value for the `voltage` field when creating new power feeds.
---
## PREFER_IPV4
Default: False

View File

@@ -10,7 +10,7 @@ Within the database, custom fields are stored as JSON data directly alongside ea
Custom fields may be created by navigating to Customization > Custom Fields. NetBox supports six types of custom field:
* Text: Free-form text (up to 255 characters)
* Text: Free-form text (intended for single-line use)
* Long text: Free-form of any length; supports Markdown rendering
* Integer: A whole number (positive or negative)
* Boolean: True or false

View File

@@ -43,7 +43,7 @@ The following data is available as context for Jinja2 templates:
* `username` - The name of the user account associated with the change.
* `request_id` - The unique request ID. This may be used to correlate multiple changes associated with a single request.
* `data` - A detailed representation of the object in its current state. This is typically equivalent to the model's representation in NetBox's REST API.
* `snapshots` - Minimal "snapshots" of the object state both before and after the change was made; provided ass a dictionary with keys named `prechange` and `postchange`. These are not as extensive as the fully serialized representation, but contain enough information to convey what has changed.
* `snapshots` - Minimal "snapshots" of the object state both before and after the change was made; provided as a dictionary with keys named `prechange` and `postchange`. These are not as extensive as the fully serialized representation, but contain enough information to convey what has changed.
### Default Request Body

View File

@@ -1,5 +1,77 @@
# NetBox v3.2
## v3.2.8 (2022-08-08)
### Enhancements
* [#9062](https://github.com/netbox-community/netbox/issues/9062) - Add/edit {module} substitution to help text for component template name
* [#9637](https://github.com/netbox-community/netbox/issues/9637) - Add site group field to rack reservation form
* [#9762](https://github.com/netbox-community/netbox/issues/9762) - Add `nat_outside` column to the IPAddress table
* [#9825](https://github.com/netbox-community/netbox/issues/9825) - Add contacts column to virtual machines table
* [#9881](https://github.com/netbox-community/netbox/issues/9881) - Increase granularity in utilization graph values
* [#9882](https://github.com/netbox-community/netbox/issues/9882) - Add manufacturer column to modules table
* [#9883](https://github.com/netbox-community/netbox/issues/9883) - Linkify location column in power panels table
* [#9906](https://github.com/netbox-community/netbox/issues/9906) - Include `color` attribute in front & rear port YAML import/export
### Bug Fixes
* [#9827](https://github.com/netbox-community/netbox/issues/9827) - Fix assignment of module bay position during bulk creation
* [#9871](https://github.com/netbox-community/netbox/issues/9871) - Fix utilization graph value alignments
* [#9884](https://github.com/netbox-community/netbox/issues/9884) - Prevent querying assigned VRF on prefix object init
* [#9885](https://github.com/netbox-community/netbox/issues/9885) - Fix child prefix counts when editing/deleting aggregates in bulk
* [#9891](https://github.com/netbox-community/netbox/issues/9891) - Ensure consistent ordering for tags during object serialization
* [#9919](https://github.com/netbox-community/netbox/issues/9919) - Fix potential XSS avenue via linked objects in tables
* [#9948](https://github.com/netbox-community/netbox/issues/9948) - Fix TypeError exception when requesting API tokens list as non-authenticated user
* [#9949](https://github.com/netbox-community/netbox/issues/9949) - Fix KeyError exception resulting from invalid API token provisioning request
* [#9950](https://github.com/netbox-community/netbox/issues/9950) - Prevent redirection to arbitrary URLs via `next` parameter on login URL
* [#9952](https://github.com/netbox-community/netbox/issues/9952) - Prevent InvalidMove when attempting to assign a nested child object as parent
---
## v3.2.7 (2022-07-20)
### Enhancements
* [#9705](https://github.com/netbox-community/netbox/issues/9705) - Support filter expressions for the `serial` field on racks, devices, and inventory items
* [#9741](https://github.com/netbox-community/netbox/issues/9741) - Check for UserConfig instance during user login
* [#9745](https://github.com/netbox-community/netbox/issues/9745) - Add wireless LANs and links to global search
### Bug Fixes
* [#9437](https://github.com/netbox-community/netbox/issues/9437) - Standardize form submission buttons and behavior when using enter key
* [#9499](https://github.com/netbox-community/netbox/issues/9499) - Fix filtered bulk deletion of VM Interfaces
* [#9634](https://github.com/netbox-community/netbox/issues/9634) - Fix image URLs in rack elevations when using external storage
* [#9715](https://github.com/netbox-community/netbox/issues/9715) - Fix `SOCIAL_AUTH_PIPELINE` config parameter not taking effect
* [#9754](https://github.com/netbox-community/netbox/issues/9754) - Fix regression introduced by #9632
* [#9746](https://github.com/netbox-community/netbox/issues/9746) - Permit filtering interfaces by arbitrary speed value in UI
* [#9749](https://github.com/netbox-community/netbox/issues/9749) - Retain original slug values when modifying object names
* [#9775](https://github.com/netbox-community/netbox/issues/9775) - Fix exception when viewing a report with no description
---
## v3.2.6 (2022-07-11)
### Enhancements
* [#7702](https://github.com/netbox-community/netbox/issues/7702) - Enable dynamic configuration for default powerfeed attributes
* [#9396](https://github.com/netbox-community/netbox/issues/9396) - Allow filtering modules by bay ID
* [#9403](https://github.com/netbox-community/netbox/issues/9403) - Enable modifying virtual chassis properties when creating/editing a device
* [#9540](https://github.com/netbox-community/netbox/issues/9540) - Add filters for assigned device & VM to IP addresses list
* [#9686](https://github.com/netbox-community/netbox/issues/9686) - Add tenant group column for all object tables with tenant assignments
### Bug Fixes
* [#8854](https://github.com/netbox-community/netbox/issues/8854) - Fix `REMOTE_AUTH_DEFAULT_GROUPS` for social-auth backends
* [#9575](https://github.com/netbox-community/netbox/issues/9575) - Fix AttributeError exception for FHRP group with an IP address assigned
* [#9597](https://github.com/netbox-community/netbox/issues/9597) - Include `installed_module` in module bay REST API serializer
* [#9632](https://github.com/netbox-community/netbox/issues/9632) - Automatically focus on search box when expanding dropdowns
* [#9657](https://github.com/netbox-community/netbox/issues/9657) - Fix filtering for custom fields and webhooks in the UI
* [#9682](https://github.com/netbox-community/netbox/issues/9682) - Fix bulk assignment of ASNs to sites
* [#9687](https://github.com/netbox-community/netbox/issues/9687) - Don't restrict custom text field lengths when entering via UI form
* [#9704](https://github.com/netbox-community/netbox/issues/9704) - Include `last_updated` field on JournalEntry REST API serializer
---
## v3.2.5 (2022-06-20)
### Enhancements
@@ -25,7 +97,7 @@
* [#9484](https://github.com/netbox-community/netbox/issues/9484) - Include services listening on "all IPs" under IP address view
* [#9486](https://github.com/netbox-community/netbox/issues/9486) - Fix redirect URL when adding device components from the module view
* [#9495](https://github.com/netbox-community/netbox/issues/9495) - Correct link to contacts in contact groups table column
* [#9503](https://github.com/netbox-community/netbox/issues/9503) - Hyperlinks in ack elevation SVGs must always use absolute URLs
* [#9503](https://github.com/netbox-community/netbox/issues/9503) - Hyperlinks in rack elevation SVGs must always use absolute URLs
* [#9512](https://github.com/netbox-community/netbox/issues/9512) - Fix duplicate site results when searching by ASN
* [#9524](https://github.com/netbox-community/netbox/issues/9524) - Correct order of VLAN fields under VM interface creation form
* [#9537](https://github.com/netbox-community/netbox/issues/9537) - Ensure consistent use of placeholder tag throughout UI

View File

@@ -2,7 +2,7 @@ import django_tables2 as tables
from circuits.models import *
from netbox.tables import NetBoxTable, columns
from tenancy.tables import TenantColumn
from tenancy.tables import TenancyColumnsMixin
from .columns import CommitRateColumn
__all__ = (
@@ -39,7 +39,7 @@ class CircuitTypeTable(NetBoxTable):
default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug')
class CircuitTable(NetBoxTable):
class CircuitTable(TenancyColumnsMixin, NetBoxTable):
cid = tables.Column(
linkify=True,
verbose_name='Circuit ID'
@@ -48,7 +48,6 @@ class CircuitTable(NetBoxTable):
linkify=True
)
status = columns.ChoiceFieldColumn()
tenant = TenantColumn()
termination_a = tables.TemplateColumn(
template_code=CIRCUITTERMINATION_LINK,
verbose_name='Side A'
@@ -69,7 +68,7 @@ class CircuitTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Circuit
fields = (
'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date',
'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'tenant_group', 'termination_a', 'termination_z', 'install_date',
'commit_rate', 'description', 'comments', 'contacts', 'tags', 'created', 'last_updated',
)
default_columns = (

View File

@@ -30,7 +30,7 @@ class ProviderView(generic.ObjectView):
circuits = Circuit.objects.restrict(request.user, 'view').filter(
provider=instance
).prefetch_related(
'type', 'tenant', 'terminations__site'
'type', 'tenant', 'tenant__group', 'terminations__site'
)
circuits_table = tables.CircuitTable(circuits, user=request.user, exclude=('provider',))
circuits_table.configure(request)
@@ -91,7 +91,7 @@ class ProviderNetworkView(generic.ObjectView):
Q(termination_a__provider_network=instance.pk) |
Q(termination_z__provider_network=instance.pk)
).prefetch_related(
'type', 'tenant', 'terminations__site'
'type', 'tenant', 'tenant__group', 'terminations__site'
)
circuits_table = tables.CircuitTable(circuits, user=request.user)
circuits_table.configure(request)
@@ -192,7 +192,7 @@ class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
class CircuitListView(generic.ObjectListView):
queryset = Circuit.objects.prefetch_related(
'provider', 'type', 'tenant', 'termination_a', 'termination_z'
'provider', 'type', 'tenant', 'tenant__group', 'termination_a', 'termination_z'
)
filterset = filtersets.CircuitFilterSet
filterset_form = forms.CircuitFilterForm

View File

@@ -5,6 +5,7 @@ from netbox.api.serializers import BaseModelSerializer, WritableNestedSerializer
__all__ = [
'ComponentNestedModuleSerializer',
'ModuleBayNestedModuleSerializer',
'NestedCableSerializer',
'NestedConsolePortSerializer',
'NestedConsolePortTemplateSerializer',
@@ -281,6 +282,14 @@ class ModuleNestedModuleBaySerializer(WritableNestedSerializer):
fields = ['id', 'url', 'display', 'name']
class ModuleBayNestedModuleSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail')
class Meta:
model = models.Module
fields = ['id', 'url', 'display', 'serial']
class ComponentNestedModuleSerializer(WritableNestedSerializer):
"""
Used by device component serializers.

View File

@@ -15,6 +15,7 @@ from netbox.api.serializers import (
NestedGroupModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer, WritableNestedSerializer,
)
from netbox.config import ConfigItem
from netbox.constants import NESTED_SERIALIZER_PREFIX
from tenancy.api.nested_serializers import NestedTenantSerializer
from users.api.nested_serializers import NestedUserSerializer
from utilities.api import get_serializer_for_model
@@ -41,7 +42,7 @@ class LinkTerminationSerializer(serializers.ModelSerializer):
Return the appropriate serializer for the link termination model.
"""
if obj._link_peer is not None:
serializer = get_serializer_for_model(obj._link_peer, prefix='Nested')
serializer = get_serializer_for_model(obj._link_peer, prefix=NESTED_SERIALIZER_PREFIX)
context = {'request': self.context['request']}
return serializer(obj._link_peer, context=context).data
return None
@@ -67,7 +68,7 @@ class ConnectedEndpointSerializer(serializers.ModelSerializer):
Return the appropriate serializer for the type of connected object.
"""
if obj._path is not None and obj._path.destination is not None:
serializer = get_serializer_for_model(obj._path.destination, prefix='Nested')
serializer = get_serializer_for_model(obj._path.destination, prefix=NESTED_SERIALIZER_PREFIX)
context = {'request': self.context['request']}
return serializer(obj._path.destination, context=context).data
return None
@@ -543,7 +544,7 @@ class InventoryItemTemplateSerializer(ValidatedModelSerializer):
def get_component(self, obj):
if obj.component is None:
return None
serializer = get_serializer_for_model(obj.component, prefix='Nested')
serializer = get_serializer_for_model(obj.component, prefix=NESTED_SERIALIZER_PREFIX)
context = {'request': self.context['request']}
return serializer(obj.component, context=context).data
@@ -886,12 +887,12 @@ class FrontPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer):
class ModuleBaySerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail')
device = NestedDeviceSerializer()
# installed_module = NestedModuleSerializer(required=False, allow_null=True)
installed_module = ModuleBayNestedModuleSerializer(required=False, allow_null=True)
class Meta:
model = ModuleBay
fields = [
'id', 'url', 'display', 'device', 'name', 'label', 'position', 'description', 'tags', 'custom_fields',
'id', 'url', 'display', 'device', 'name', 'installed_module', 'label', 'position', 'description', 'tags', 'custom_fields',
'created', 'last_updated',
]
@@ -935,7 +936,7 @@ class InventoryItemSerializer(NetBoxModelSerializer):
def get_component(self, obj):
if obj.component is None:
return None
serializer = get_serializer_for_model(obj.component, prefix='Nested')
serializer = get_serializer_for_model(obj.component, prefix=NESTED_SERIALIZER_PREFIX)
context = {'request': self.context['request']}
return serializer(obj.component, context=context).data
@@ -991,7 +992,7 @@ class CableSerializer(NetBoxModelSerializer):
termination = getattr(obj, 'termination_{}'.format(side.lower()))
if termination is None:
return None
serializer = get_serializer_for_model(termination, prefix='Nested')
serializer = get_serializer_for_model(termination, prefix=NESTED_SERIALIZER_PREFIX)
context = {'request': self.context['request']}
data = serializer(termination, context=context).data
@@ -1037,7 +1038,7 @@ class CablePathSerializer(serializers.ModelSerializer):
"""
Return the appropriate serializer for the origin.
"""
serializer = get_serializer_for_model(obj.origin, prefix='Nested')
serializer = get_serializer_for_model(obj.origin, prefix=NESTED_SERIALIZER_PREFIX)
context = {'request': self.context['request']}
return serializer(obj.origin, context=context).data
@@ -1047,7 +1048,7 @@ class CablePathSerializer(serializers.ModelSerializer):
Return the appropriate serializer for the destination, if any.
"""
if obj.destination_id is not None:
serializer = get_serializer_for_model(obj.destination, prefix='Nested')
serializer = get_serializer_for_model(obj.destination, prefix=NESTED_SERIALIZER_PREFIX)
context = {'request': self.context['request']}
return serializer(obj.destination, context=context).data
return None
@@ -1056,7 +1057,7 @@ class CablePathSerializer(serializers.ModelSerializer):
def get_path(self, obj):
ret = []
for node in obj.get_path():
serializer = get_serializer_for_model(node, prefix='Nested')
serializer = get_serializer_for_model(node, prefix=NESTED_SERIALIZER_PREFIX)
context = {'request': self.context['request']}
ret.append(serializer(node, context=context).data)
return ret

View File

@@ -22,6 +22,7 @@ from netbox.api.metadata import ContentTypeMetadata
from netbox.api.pagination import StripCountAnnotationsPaginator
from netbox.api.viewsets import NetBoxModelViewSet
from netbox.config import get_config
from netbox.constants import NESTED_SERIALIZER_PREFIX
from utilities.api import get_serializer_for_model
from utilities.utils import count_related
from virtualization.models import VirtualMachine
@@ -69,14 +70,14 @@ class PathEndpointMixin(object):
break
# Serialize each object
serializer_a = get_serializer_for_model(near_end, prefix='Nested')
serializer_a = get_serializer_for_model(near_end, prefix=NESTED_SERIALIZER_PREFIX)
x = serializer_a(near_end, context={'request': request}).data
if cable is not None:
y = serializers.TracedCableSerializer(cable, context={'request': request}).data
else:
y = None
if far_end is not None:
serializer_b = get_serializer_for_model(far_end, prefix='Nested')
serializer_b = get_serializer_for_model(far_end, prefix=NESTED_SERIALIZER_PREFIX)
z = serializer_b(far_end, context={'request': request}).data
else:
z = None
@@ -611,7 +612,7 @@ class RearPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
class ModuleBayViewSet(NetBoxModelViewSet):
queryset = ModuleBay.objects.prefetch_related('tags')
queryset = ModuleBay.objects.prefetch_related('tags', 'installed_module')
serializer_class = serializers.ModuleBaySerializer
filterset_class = filtersets.ModuleBayFilterSet
brief_prefetch_fields = ['device']

View File

@@ -49,15 +49,6 @@ WIRELESS_IFACE_TYPES = [
NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
#
# Power feeds
#
POWERFEED_VOLTAGE_DEFAULT = 120
POWERFEED_AMPERAGE_DEFAULT = 20
POWERFEED_MAX_UTILIZATION_DEFAULT = 80 # Percentage
#
# Device components
#

View File

@@ -307,7 +307,7 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe
to_field_name='slug',
label='Role (slug)',
)
serial = django_filters.CharFilter(
serial = MultiValueCharFilter(
lookup_expr='iexact'
)
@@ -992,14 +992,23 @@ class ModuleFilterSet(NetBoxModelFilterSet):
to_field_name='model',
label='Module type (model)',
)
module_bay_id = django_filters.ModelMultipleChoiceFilter(
field_name='module_bay',
queryset=ModuleBay.objects.all(),
to_field_name='id',
label='Module Bay (ID)'
)
device_id = django_filters.ModelMultipleChoiceFilter(
queryset=Device.objects.all(),
label='Device (ID)',
)
serial = MultiValueCharFilter(
lookup_expr='iexact'
)
class Meta:
model = Module
fields = ['id', 'serial', 'asset_tag']
fields = ['id', 'asset_tag']
def search(self, queryset, name, value):
if not value.strip():
@@ -1394,7 +1403,7 @@ class InventoryItemFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet):
)
component_type = ContentTypeFilter()
component_id = MultiValueNumberFilter()
serial = django_filters.CharFilter(
serial = MultiValueCharFilter(
lookup_expr='iexact'
)

View File

@@ -287,7 +287,7 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
fieldsets = (
(None, ('q', 'tag')),
('User', ('user_id',)),
('Rack', ('region_id', 'site_group_id', 'site_id', 'location_id')),
('Rack', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Tenant', ('tenant_group_id', 'tenant_id')),
)
region_id = DynamicModelMultipleChoiceField(
@@ -295,25 +295,38 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
required=False,
label=_('Region')
)
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
required=False,
query_params={
'region_id': '$region_id'
},
label=_('Site')
)
site_group_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label=_('Site group')
)
location_id = DynamicModelMultipleChoiceField(
queryset=Location.objects.prefetch_related('site'),
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
required=False,
query_params={
'region_id': '$region_id',
'group_id': '$site_group_id',
},
label=_('Site')
)
location_id = DynamicModelMultipleChoiceField(
queryset=Location.objects.all(),
required=False,
query_params={
'site_id': '$site_id',
},
label=_('Location'),
null_option='None'
)
rack_id = DynamicModelMultipleChoiceField(
queryset=Rack.objects.all(),
required=False,
query_params={
'site_id': '$site_id',
'location_id': '$location_id',
},
label=_('Rack')
)
user_id = DynamicModelMultipleChoiceField(
queryset=User.objects.all(),
required=False,
@@ -982,8 +995,8 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
)
speed = forms.IntegerField(
required=False,
label='Select Speed',
widget=SelectSpeedWidget(attrs={'readonly': None})
label='Speed',
widget=SelectSpeedWidget()
)
duplex = MultipleChoiceField(
choices=InterfaceDuplexChoices,

View File

@@ -321,7 +321,7 @@ class RackReservationForm(TenancyForm, NetBoxModelForm):
)
fieldsets = (
('Reservation', ('region', 'site', 'location', 'rack', 'units', 'user', 'description', 'tags')),
('Reservation', ('region', 'site_group', 'site', 'location', 'rack', 'units', 'user', 'description', 'tags')),
('Tenancy', ('tenant_group', 'tenant')),
)
@@ -521,13 +521,28 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
required=False,
label=''
)
virtual_chassis = DynamicModelChoiceField(
queryset=VirtualChassis.objects.all(),
required=False
)
vc_position = forms.IntegerField(
required=False,
label='Position',
help_text="The position in the virtual chassis this device is identified by"
)
vc_priority = forms.IntegerField(
required=False,
label='Priority',
help_text="The priority of the device in the virtual chassis"
)
class Meta:
model = Device
fields = [
'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'rack',
'location', 'position', 'face', 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6',
'cluster_group', 'cluster', 'tenant_group', 'tenant', 'comments', 'tags', 'local_context_data'
'cluster_group', 'cluster', 'tenant_group', 'tenant', 'virtual_chassis', 'vc_position', 'vc_priority',
'comments', 'tags', 'local_context_data'
]
help_texts = {
'device_role': "The function this device serves",

View File

@@ -64,6 +64,14 @@ class ModularComponentTemplateCreateForm(ComponentCreateForm):
"""
Creation form for component templates that can be assigned to either a DeviceType *or* a ModuleType.
"""
name_pattern = ExpandableNameField(
label='Name',
help_text="""
Alphanumeric ranges are supported for bulk creation. Mixed cases and types within a single range
are not supported. Example: <code>[ge,xe]-0/0/[0-9]</code>. {module} is accepted as a substitution for
the module bay position.
"""
)
device_type = DynamicModelChoiceField(
queryset=DeviceType.objects.all(),
required=False

View File

@@ -146,7 +146,7 @@ class FrontPortTemplateImportForm(ComponentTemplateImportForm):
class Meta:
model = FrontPortTemplate
fields = [
'device_type', 'module_type', 'name', 'type', 'rear_port', 'rear_port_position', 'label', 'description',
'device_type', 'module_type', 'name', 'type', 'color', 'rear_port', 'rear_port_position', 'label', 'description',
]
@@ -158,7 +158,7 @@ class RearPortTemplateImportForm(ComponentTemplateImportForm):
class Meta:
model = RearPortTemplate
fields = [
'device_type', 'module_type', 'name', 'type', 'positions', 'label', 'description',
'device_type', 'module_type', 'name', 'type', 'color', 'positions', 'label', 'description',
]

View File

@@ -386,9 +386,9 @@ class Migration(migrations.Migration):
('type', models.CharField(default='primary', max_length=50)),
('supply', models.CharField(default='ac', max_length=50)),
('phase', models.CharField(default='single-phase', max_length=50)),
('voltage', models.SmallIntegerField(default=120, validators=[utilities.validators.ExclusionValidator([0])])),
('amperage', models.PositiveSmallIntegerField(default=20, validators=[django.core.validators.MinValueValidator(1)])),
('max_utilization', models.PositiveSmallIntegerField(default=80, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)])),
('voltage', models.SmallIntegerField(validators=[utilities.validators.ExclusionValidator([0])])),
('amperage', models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1)])),
('max_utilization', models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)])),
('available_power', models.PositiveIntegerField(default=0, editable=False)),
('comments', models.TextField(blank=True)),
],

View File

@@ -39,7 +39,10 @@ class ComponentTemplateModel(WebhooksMixin, ChangeLoggedModel):
related_name='%(class)ss'
)
name = models.CharField(
max_length=64
max_length=64,
help_text="""
{module} is accepted as a substitution for the module bay position when attached to a module type.
"""
)
_name = NaturalOrderingField(
target_field='name',
@@ -157,6 +160,14 @@ class ConsolePortTemplate(ModularComponentTemplateModel):
**kwargs
)
def to_yaml(self):
return {
'name': self.name,
'type': self.type,
'label': self.label,
'description': self.description,
}
class ConsoleServerPortTemplate(ModularComponentTemplateModel):
"""
@@ -185,6 +196,14 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel):
**kwargs
)
def to_yaml(self):
return {
'name': self.name,
'type': self.type,
'label': self.label,
'description': self.description,
}
class PowerPortTemplate(ModularComponentTemplateModel):
"""
@@ -236,6 +255,16 @@ class PowerPortTemplate(ModularComponentTemplateModel):
'allocated_draw': f"Allocated draw cannot exceed the maximum draw ({self.maximum_draw}W)."
})
def to_yaml(self):
return {
'name': self.name,
'type': self.type,
'maximum_draw': self.maximum_draw,
'allocated_draw': self.allocated_draw,
'label': self.label,
'description': self.description,
}
class PowerOutletTemplate(ModularComponentTemplateModel):
"""
@@ -298,6 +327,16 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
**kwargs
)
def to_yaml(self):
return {
'name': self.name,
'type': self.type,
'power_port': self.power_port.name if self.power_port else None,
'feed_leg': self.feed_leg,
'label': self.label,
'description': self.description,
}
class InterfaceTemplate(ModularComponentTemplateModel):
"""
@@ -337,6 +376,15 @@ class InterfaceTemplate(ModularComponentTemplateModel):
**kwargs
)
def to_yaml(self):
return {
'name': self.name,
'type': self.type,
'mgmt_only': self.mgmt_only,
'label': self.label,
'description': self.description,
}
class FrontPortTemplate(ModularComponentTemplateModel):
"""
@@ -410,6 +458,17 @@ class FrontPortTemplate(ModularComponentTemplateModel):
**kwargs
)
def to_yaml(self):
return {
'name': self.name,
'type': self.type,
'color': self.color,
'rear_port': self.rear_port.name,
'rear_port_position': self.rear_port_position,
'label': self.label,
'description': self.description,
}
class RearPortTemplate(ModularComponentTemplateModel):
"""
@@ -449,6 +508,16 @@ class RearPortTemplate(ModularComponentTemplateModel):
**kwargs
)
def to_yaml(self):
return {
'name': self.name,
'type': self.type,
'color': self.color,
'positions': self.positions,
'label': self.label,
'description': self.description,
}
class ModuleBayTemplate(ComponentTemplateModel):
"""
@@ -474,6 +543,14 @@ class ModuleBayTemplate(ComponentTemplateModel):
position=self.position
)
def to_yaml(self):
return {
'name': self.name,
'label': self.label,
'position': self.position,
'description': self.description,
}
class DeviceBayTemplate(ComponentTemplateModel):
"""
@@ -498,6 +575,13 @@ class DeviceBayTemplate(ComponentTemplateModel):
f"Subdevice role of device type ({self.device_type}) must be set to \"parent\" to allow device bays."
)
def to_yaml(self):
return {
'name': self.name,
'label': self.label,
'description': self.description,
}
class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
"""

View File

@@ -95,8 +95,7 @@ class ModularComponentModel(ComponentModel):
inventory_items = GenericRelation(
to='dcim.InventoryItem',
content_type_field='component_type',
object_id_field='component_id',
related_name='%(class)ss',
object_id_field='component_id'
)
class Meta:

View File

@@ -1,5 +1,3 @@
from collections import OrderedDict
import yaml
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
@@ -161,115 +159,54 @@ class DeviceType(NetBoxModel):
return reverse('dcim:devicetype', args=[self.pk])
def to_yaml(self):
data = OrderedDict((
('manufacturer', self.manufacturer.name),
('model', self.model),
('slug', self.slug),
('part_number', self.part_number),
('u_height', self.u_height),
('is_full_depth', self.is_full_depth),
('subdevice_role', self.subdevice_role),
('airflow', self.airflow),
('comments', self.comments),
))
data = {
'manufacturer': self.manufacturer.name,
'model': self.model,
'slug': self.slug,
'part_number': self.part_number,
'u_height': self.u_height,
'is_full_depth': self.is_full_depth,
'subdevice_role': self.subdevice_role,
'airflow': self.airflow,
'comments': self.comments,
}
# Component templates
if self.consoleporttemplates.exists():
data['console-ports'] = [
{
'name': c.name,
'type': c.type,
'label': c.label,
'description': c.description,
}
for c in self.consoleporttemplates.all()
c.to_yaml() for c in self.consoleporttemplates.all()
]
if self.consoleserverporttemplates.exists():
data['console-server-ports'] = [
{
'name': c.name,
'type': c.type,
'label': c.label,
'description': c.description,
}
for c in self.consoleserverporttemplates.all()
c.to_yaml() for c in self.consoleserverporttemplates.all()
]
if self.powerporttemplates.exists():
data['power-ports'] = [
{
'name': c.name,
'type': c.type,
'maximum_draw': c.maximum_draw,
'allocated_draw': c.allocated_draw,
'label': c.label,
'description': c.description,
}
for c in self.powerporttemplates.all()
c.to_yaml() for c in self.powerporttemplates.all()
]
if self.poweroutlettemplates.exists():
data['power-outlets'] = [
{
'name': c.name,
'type': c.type,
'power_port': c.power_port.name if c.power_port else None,
'feed_leg': c.feed_leg,
'label': c.label,
'description': c.description,
}
for c in self.poweroutlettemplates.all()
c.to_yaml() for c in self.poweroutlettemplates.all()
]
if self.interfacetemplates.exists():
data['interfaces'] = [
{
'name': c.name,
'type': c.type,
'mgmt_only': c.mgmt_only,
'label': c.label,
'description': c.description,
}
for c in self.interfacetemplates.all()
c.to_yaml() for c in self.interfacetemplates.all()
]
if self.frontporttemplates.exists():
data['front-ports'] = [
{
'name': c.name,
'type': c.type,
'rear_port': c.rear_port.name,
'rear_port_position': c.rear_port_position,
'label': c.label,
'description': c.description,
}
for c in self.frontporttemplates.all()
c.to_yaml() for c in self.frontporttemplates.all()
]
if self.rearporttemplates.exists():
data['rear-ports'] = [
{
'name': c.name,
'type': c.type,
'positions': c.positions,
'label': c.label,
'description': c.description,
}
for c in self.rearporttemplates.all()
c.to_yaml() for c in self.rearporttemplates.all()
]
if self.modulebaytemplates.exists():
data['module-bays'] = [
{
'name': c.name,
'label': c.label,
'position': c.position,
'description': c.description,
}
for c in self.modulebaytemplates.all()
c.to_yaml() for c in self.modulebaytemplates.all()
]
if self.devicebaytemplates.exists():
data['device-bays'] = [
{
'name': c.name,
'label': c.label,
'description': c.description,
}
for c in self.devicebaytemplates.all()
c.to_yaml() for c in self.devicebaytemplates.all()
]
return yaml.dump(dict(data), sort_keys=False)
@@ -395,91 +332,41 @@ class ModuleType(NetBoxModel):
return reverse('dcim:moduletype', args=[self.pk])
def to_yaml(self):
data = OrderedDict((
('manufacturer', self.manufacturer.name),
('model', self.model),
('part_number', self.part_number),
('comments', self.comments),
))
data = {
'manufacturer': self.manufacturer.name,
'model': self.model,
'part_number': self.part_number,
'comments': self.comments,
}
# Component templates
if self.consoleporttemplates.exists():
data['console-ports'] = [
{
'name': c.name,
'type': c.type,
'label': c.label,
'description': c.description,
}
for c in self.consoleporttemplates.all()
c.to_yaml() for c in self.consoleporttemplates.all()
]
if self.consoleserverporttemplates.exists():
data['console-server-ports'] = [
{
'name': c.name,
'type': c.type,
'label': c.label,
'description': c.description,
}
for c in self.consoleserverporttemplates.all()
c.to_yaml() for c in self.consoleserverporttemplates.all()
]
if self.powerporttemplates.exists():
data['power-ports'] = [
{
'name': c.name,
'type': c.type,
'maximum_draw': c.maximum_draw,
'allocated_draw': c.allocated_draw,
'label': c.label,
'description': c.description,
}
for c in self.powerporttemplates.all()
c.to_yaml() for c in self.powerporttemplates.all()
]
if self.poweroutlettemplates.exists():
data['power-outlets'] = [
{
'name': c.name,
'type': c.type,
'power_port': c.power_port.name if c.power_port else None,
'feed_leg': c.feed_leg,
'label': c.label,
'description': c.description,
}
for c in self.poweroutlettemplates.all()
c.to_yaml() for c in self.poweroutlettemplates.all()
]
if self.interfacetemplates.exists():
data['interfaces'] = [
{
'name': c.name,
'type': c.type,
'mgmt_only': c.mgmt_only,
'label': c.label,
'description': c.description,
}
for c in self.interfacetemplates.all()
c.to_yaml() for c in self.interfacetemplates.all()
]
if self.frontporttemplates.exists():
data['front-ports'] = [
{
'name': c.name,
'type': c.type,
'rear_port': c.rear_port.name,
'rear_port_position': c.rear_port_position,
'label': c.label,
'description': c.description,
}
for c in self.frontporttemplates.all()
c.to_yaml() for c in self.frontporttemplates.all()
]
if self.rearporttemplates.exists():
data['rear-ports'] = [
{
'name': c.name,
'type': c.type,
'positions': c.positions,
'label': c.label,
'description': c.description,
}
for c in self.rearporttemplates.all()
c.to_yaml() for c in self.rearporttemplates.all()
]
return yaml.dump(dict(data), sort_keys=False)

View File

@@ -6,6 +6,7 @@ from django.urls import reverse
from dcim.choices import *
from dcim.constants import *
from netbox.config import ConfigItem
from netbox.models import NetBoxModel
from utilities.validators import ExclusionValidator
from .device_components import LinkTermination, PathEndpoint
@@ -105,16 +106,16 @@ class PowerFeed(NetBoxModel, PathEndpoint, LinkTermination):
default=PowerFeedPhaseChoices.PHASE_SINGLE
)
voltage = models.SmallIntegerField(
default=POWERFEED_VOLTAGE_DEFAULT,
default=ConfigItem('POWERFEED_DEFAULT_VOLTAGE'),
validators=[ExclusionValidator([0])]
)
amperage = models.PositiveSmallIntegerField(
validators=[MinValueValidator(1)],
default=POWERFEED_AMPERAGE_DEFAULT
default=ConfigItem('POWERFEED_DEFAULT_AMPERAGE')
)
max_utilization = models.PositiveSmallIntegerField(
validators=[MinValueValidator(1), MaxValueValidator(100)],
default=POWERFEED_MAX_UTILIZATION_DEFAULT,
default=ConfigItem('POWERFEED_DEFAULT_MAX_UTILIZATION'),
help_text="Maximum permissible draw (percentage)"
)
available_power = models.PositiveIntegerField(

View File

@@ -113,8 +113,12 @@ class RackElevationSVG:
# Embed front device type image if one exists
if self.include_images and device.device_type.front_image:
url = device.device_type.front_image.url
# Convert any relative URLs to absolute
if url.startswith('/'):
url = '{}{}'.format(self.base_url, url)
image = drawing.image(
href='{}{}'.format(self.base_url, device.device_type.front_image.url),
href=url,
insert=start,
size=end,
class_='device-image'
@@ -139,8 +143,12 @@ class RackElevationSVG:
# Embed rear device type image if one exists
if self.include_images and device.device_type.rear_image:
url = device.device_type.rear_image.url
# Convert any relative URLs to absolute
if url.startswith('/'):
url = '{}{}'.format(self.base_url, url)
image = drawing.image(
href='{}{}'.format(self.base_url, device.device_type.rear_image.url),
href=url,
insert=start,
size=end,
class_='device-image'

View File

@@ -3,7 +3,7 @@ from django_tables2.utils import Accessor
from dcim.models import Cable
from netbox.tables import NetBoxTable, columns
from tenancy.tables import TenantColumn
from tenancy.tables import TenancyColumnsMixin
from .template_code import CABLE_LENGTH, CABLE_TERMINATION_PARENT
__all__ = (
@@ -15,7 +15,7 @@ __all__ = (
# Cables
#
class CableTable(NetBoxTable):
class CableTable(TenancyColumnsMixin, NetBoxTable):
termination_a_parent = tables.TemplateColumn(
template_code=CABLE_TERMINATION_PARENT,
accessor=Accessor('termination_a'),
@@ -53,7 +53,6 @@ class CableTable(NetBoxTable):
verbose_name='Termination B'
)
status = columns.ChoiceFieldColumn()
tenant = TenantColumn()
length = columns.TemplateColumn(
template_code=CABLE_LENGTH,
order_by=('_abs_length', 'length_unit')
@@ -67,7 +66,7 @@ class CableTable(NetBoxTable):
model = Cable
fields = (
'pk', 'id', 'label', 'termination_a_parent', 'rack_a', 'termination_a', 'termination_b_parent', 'rack_b', 'termination_b',
'status', 'type', 'tenant', 'color', 'length', 'tags', 'created', 'last_updated',
'status', 'type', 'tenant', 'tenant_group', 'color', 'length', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b',

View File

@@ -6,7 +6,7 @@ from dcim.models import (
InventoryItemRole, ModuleBay, Platform, PowerOutlet, PowerPort, RearPort, VirtualChassis,
)
from netbox.tables import NetBoxTable, columns
from tenancy.tables import TenantColumn
from tenancy.tables import TenancyColumnsMixin
from .template_code import *
__all__ = (
@@ -137,13 +137,12 @@ class PlatformTable(NetBoxTable):
# Devices
#
class DeviceTable(NetBoxTable):
class DeviceTable(TenancyColumnsMixin, NetBoxTable):
name = tables.TemplateColumn(
order_by=('_name',),
template_code=DEVICE_LINK
)
status = columns.ChoiceFieldColumn()
tenant = TenantColumn()
site = tables.Column(
linkify=True
)
@@ -200,7 +199,7 @@ class DeviceTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Device
fields = (
'pk', 'id', 'name', 'status', 'tenant', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial',
'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial',
'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'airflow', 'primary_ip4',
'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'contacts', 'tags',
'created', 'last_updated',
@@ -211,12 +210,11 @@ class DeviceTable(NetBoxTable):
)
class DeviceImportTable(NetBoxTable):
class DeviceImportTable(TenancyColumnsMixin, NetBoxTable):
name = tables.TemplateColumn(
template_code=DEVICE_LINK
)
status = columns.ChoiceFieldColumn()
tenant = TenantColumn()
site = tables.Column(
linkify=True
)
@@ -232,7 +230,7 @@ class DeviceImportTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Device
fields = ('id', 'name', 'status', 'tenant', 'site', 'rack', 'position', 'device_role', 'device_type')
fields = ('id', 'name', 'status', 'tenant', 'tenant_group', 'site', 'rack', 'position', 'device_role', 'device_type')
empty_text = False

View File

@@ -14,6 +14,9 @@ class ModuleTypeTable(NetBoxTable):
linkify=True,
verbose_name='Module Type'
)
manufacturer = tables.Column(
linkify=True
)
instance_count = columns.LinkedCountColumn(
viewname='dcim:module_list',
url_params={'module_type_id': 'pk'},
@@ -41,6 +44,10 @@ class ModuleTable(NetBoxTable):
module_bay = tables.Column(
linkify=True
)
manufacturer = tables.Column(
accessor=tables.A('module_type__manufacturer'),
linkify=True
)
module_type = tables.Column(
linkify=True
)
@@ -52,8 +59,9 @@ class ModuleTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Module
fields = (
'pk', 'id', 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'comments', 'tags',
'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', 'comments',
'tags',
)
default_columns = (
'pk', 'id', 'device', 'module_bay', 'module_type', 'serial', 'asset_tag',
'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag',
)

View File

@@ -21,6 +21,9 @@ class PowerPanelTable(NetBoxTable):
site = tables.Column(
linkify=True
)
location = tables.Column(
linkify=True
)
powerfeed_count = columns.LinkedCountColumn(
viewname='dcim:powerfeed_list',
url_params={'power_panel_id': 'pk'},
@@ -35,7 +38,9 @@ class PowerPanelTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = PowerPanel
fields = ('pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'contacts', 'tags', 'created', 'last_updated',)
fields = (
'pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'contacts', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'site', 'location', 'powerfeed_count')

View File

@@ -3,7 +3,7 @@ from django_tables2.utils import Accessor
from dcim.models import Rack, RackReservation, RackRole
from netbox.tables import NetBoxTable, columns
from tenancy.tables import TenantColumn
from tenancy.tables import TenancyColumnsMixin
__all__ = (
'RackTable',
@@ -37,7 +37,7 @@ class RackRoleTable(NetBoxTable):
# Racks
#
class RackTable(NetBoxTable):
class RackTable(TenancyColumnsMixin, NetBoxTable):
name = tables.Column(
order_by=('_name',),
linkify=True
@@ -48,7 +48,6 @@ class RackTable(NetBoxTable):
site = tables.Column(
linkify=True
)
tenant = TenantColumn()
status = columns.ChoiceFieldColumn()
role = columns.ColoredLabelColumn()
u_height = tables.TemplateColumn(
@@ -87,7 +86,7 @@ class RackTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Rack
fields = (
'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag',
'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'tenant_group', 'role', 'serial', 'asset_tag',
'type', 'width', 'outer_width', 'outer_depth', 'u_height', 'comments', 'device_count', 'get_utilization',
'get_power_utilization', 'contacts', 'tags', 'created', 'last_updated',
)
@@ -101,7 +100,7 @@ class RackTable(NetBoxTable):
# Rack reservations
#
class RackReservationTable(NetBoxTable):
class RackReservationTable(TenancyColumnsMixin, NetBoxTable):
reservation = tables.Column(
accessor='pk',
linkify=True
@@ -110,7 +109,10 @@ class RackReservationTable(NetBoxTable):
accessor=Accessor('rack__site'),
linkify=True
)
tenant = TenantColumn()
location = tables.Column(
accessor=Accessor('rack__location'),
linkify=True
)
rack = tables.Column(
linkify=True
)
@@ -125,7 +127,7 @@ class RackReservationTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = RackReservation
fields = (
'pk', 'id', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'tags',
'pk', 'id', 'reservation', 'site', 'location', 'rack', 'unit_list', 'user', 'created', 'tenant', 'tenant_group', 'description', 'tags',
'actions', 'created', 'last_updated',
)
default_columns = ('pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description')

View File

@@ -2,7 +2,7 @@ import django_tables2 as tables
from dcim.models import Location, Region, Site, SiteGroup
from netbox.tables import NetBoxTable, columns
from tenancy.tables import TenantColumn
from tenancy.tables import TenancyColumnsMixin
from .template_code import LOCATION_BUTTONS
__all__ = (
@@ -75,7 +75,7 @@ class SiteGroupTable(NetBoxTable):
# Sites
#
class SiteTable(NetBoxTable):
class SiteTable(TenancyColumnsMixin, NetBoxTable):
name = tables.Column(
linkify=True
)
@@ -96,7 +96,6 @@ class SiteTable(NetBoxTable):
url_params={'site_id': 'pk'},
verbose_name='ASN Count'
)
tenant = TenantColumn()
comments = columns.MarkdownColumn()
contacts = columns.ManyToManyColumn(
linkify_item=True
@@ -108,7 +107,7 @@ class SiteTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Site
fields = (
'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asns', 'asn_count',
'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'tenant_group', 'asns', 'asn_count',
'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments',
'contacts', 'tags', 'created', 'last_updated', 'actions',
)
@@ -119,14 +118,13 @@ class SiteTable(NetBoxTable):
# Locations
#
class LocationTable(NetBoxTable):
class LocationTable(TenancyColumnsMixin, NetBoxTable):
name = columns.MPTTColumn(
linkify=True
)
site = tables.Column(
linkify=True
)
tenant = TenantColumn()
rack_count = columns.LinkedCountColumn(
viewname='dcim:rack_list',
url_params={'location_id': 'pk'},
@@ -150,7 +148,7 @@ class LocationTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Location
fields = (
'pk', 'id', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'contacts',
'pk', 'id', 'name', 'site', 'tenant', 'tenant_group', 'rack_count', 'device_count', 'description', 'slug', 'contacts',
'tags', 'actions', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description')

View File

@@ -494,10 +494,10 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_serial(self):
params = {'serial': 'ABC'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'serial': 'abc'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'serial': ['ABC', 'DEF']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'serial': ['abc', 'def']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_tenant(self):
tenants = Tenant.objects.all()[:2]
@@ -1849,13 +1849,20 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'module_type': [module_types[0].model, module_types[1].model]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
def test_module_bay(self):
module_bays = ModuleBay.objects.all()[:2]
params = {'module_bay_id': [module_bays[0].pk, module_bays[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_device(self):
device_types = Device.objects.all()[:2]
params = {'device_id': [device_types[0].pk, device_types[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
def test_serial(self):
params = {'asset_tag': ['A', 'B']}
params = {'serial': ['A', 'B']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'serial': ['a', 'b']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_asset_tag(self):
@@ -3408,10 +3415,10 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_serial(self):
params = {'serial': 'ABC'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'serial': 'abc'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'serial': ['ABC', 'DEF']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'serial': ['abc', 'def']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_component_type(self):
params = {'component_type': 'dcim.interface'}

View File

@@ -194,14 +194,14 @@ class RackTestCase(TestCase):
# Validate inventory (front face)
rack1_inventory_front = self.rack.get_rack_units(face=DeviceFaceChoices.FACE_FRONT)
self.assertEqual(rack1_inventory_front[-10]['device'], device1)
del(rack1_inventory_front[-10])
del rack1_inventory_front[-10]
for u in rack1_inventory_front:
self.assertIsNone(u['device'])
# Validate inventory (rear face)
rack1_inventory_rear = self.rack.get_rack_units(face=DeviceFaceChoices.FACE_REAR)
self.assertEqual(rack1_inventory_rear[-10]['device'], device1)
del(rack1_inventory_rear[-10])
del rack1_inventory_rear[-10]
for u in rack1_inventory_rear:
self.assertIsNone(u['device'])

View File

@@ -560,9 +560,7 @@ class RackRoleBulkDeleteView(generic.BulkDeleteView):
#
class RackListView(generic.ObjectListView):
queryset = Rack.objects.prefetch_related(
'site', 'location', 'tenant', 'role', 'devices__device_type'
).annotate(
queryset = Rack.objects.prefetch_related('devices__device_type').annotate(
device_count=count_related(Device, 'rack')
)
filterset = filtersets.RackFilterSet
@@ -2709,6 +2707,7 @@ class DeviceBulkAddModuleBayView(generic.BulkComponentCreateView):
filterset = filtersets.DeviceFilterSet
table = tables.DeviceTable
default_return_url = 'dcim:device_list'
patterned_fields = ('name', 'label', 'position')
class DeviceBulkAddDeviceBayView(generic.BulkComponentCreateView):
@@ -3084,7 +3083,7 @@ class VirtualChassisAddMemberView(ObjectPermissionRequiredMixin, GetReturnURLMix
if membership_form.is_valid():
membership_form.save()
msg = 'Added member <a href="{}">{}</a>'.format(device.get_absolute_url(), escape(device))
msg = f'Added member <a href="{device.get_absolute_url()}">{escape(device)}</a>'
messages.success(request, mark_safe(msg))
if '_addanother' in request.POST:
@@ -3129,8 +3128,7 @@ class VirtualChassisRemoveMemberView(ObjectPermissionRequiredMixin, GetReturnURL
# Protect master device from being removed
virtual_chassis = VirtualChassis.objects.filter(master=device).first()
if virtual_chassis is not None:
msg = 'Unable to remove master device {} from the virtual chassis.'.format(escape(device))
messages.error(request, mark_safe(msg))
messages.error(request, f'Unable to remove master device {device} from the virtual chassis.')
return redirect(device.get_absolute_url())
if form.is_valid():

View File

@@ -15,6 +15,9 @@ class ConfigRevisionAdmin(admin.ModelAdmin):
('Rack Elevations', {
'fields': ('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH'),
}),
('Power', {
'fields': ('POWERFEED_DEFAULT_VOLTAGE', 'POWERFEED_DEFAULT_AMPERAGE', 'POWERFEED_DEFAULT_MAX_UTILIZATION')
}),
('IPAM', {
'fields': ('ENFORCE_GLOBAL_UNIQUE', 'PREFER_IPV4'),
}),

View File

@@ -3,6 +3,7 @@ from rest_framework.fields import Field
from extras.choices import CustomFieldTypeChoices
from extras.models import CustomField
from netbox.constants import NESTED_SERIALIZER_PREFIX
#
@@ -51,10 +52,10 @@ class CustomFieldsDataField(Field):
for cf in self._get_custom_fields():
value = cf.deserialize(obj.get(cf.name))
if value is not None and cf.type == CustomFieldTypeChoices.TYPE_OBJECT:
serializer = get_serializer_for_model(cf.object_type.model_class(), prefix='Nested')
serializer = get_serializer_for_model(cf.object_type.model_class(), prefix=NESTED_SERIALIZER_PREFIX)
value = serializer(value, context=self.parent.context).data
elif value is not None and cf.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
serializer = get_serializer_for_model(cf.object_type.model_class(), prefix='Nested')
serializer = get_serializer_for_model(cf.object_type.model_class(), prefix=NESTED_SERIALIZER_PREFIX)
value = serializer(value, many=True, context=self.parent.context).data
data[cf.name] = value

View File

@@ -15,6 +15,7 @@ from extras.utils import FeatureQuery
from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
from netbox.api.exceptions import SerializerNotFound
from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer
from netbox.constants import NESTED_SERIALIZER_PREFIX
from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer
from tenancy.models import Tenant, TenantGroup
from users.api.nested_serializers import NestedUserSerializer
@@ -192,7 +193,7 @@ class ImageAttachmentSerializer(ValidatedModelSerializer):
@swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_parent(self, obj):
serializer = get_serializer_for_model(obj.parent, prefix='Nested')
serializer = get_serializer_for_model(obj.parent, prefix=NESTED_SERIALIZER_PREFIX)
return serializer(obj.parent, context={'request': self.context['request']}).data
@@ -221,7 +222,7 @@ class JournalEntrySerializer(NetBoxModelSerializer):
model = JournalEntry
fields = [
'id', 'url', 'display', 'assigned_object_type', 'assigned_object_id', 'assigned_object', 'created',
'created_by', 'kind', 'comments', 'tags', 'custom_fields',
'created_by', 'kind', 'comments', 'tags', 'custom_fields', 'last_updated',
]
def validate(self, data):
@@ -242,7 +243,7 @@ class JournalEntrySerializer(NetBoxModelSerializer):
@swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_assigned_object(self, instance):
serializer = get_serializer_for_model(instance.assigned_object_type.model_class(), prefix='Nested')
serializer = get_serializer_for_model(instance.assigned_object_type.model_class(), prefix=NESTED_SERIALIZER_PREFIX)
context = {'request': self.context['request']}
return serializer(instance.assigned_object, context=context).data
@@ -462,7 +463,7 @@ class ObjectChangeSerializer(BaseModelSerializer):
return None
try:
serializer = get_serializer_for_model(obj.changed_object, prefix='Nested')
serializer = get_serializer_for_model(obj.changed_object, prefix=NESTED_SERIALIZER_PREFIX)
except SerializerNotFound:
return obj.object_repr
context = {

View File

@@ -32,6 +32,9 @@ class WebhookFilterSet(BaseFilterSet):
method='search',
label='Search',
)
content_type_id = MultiValueNumberFilter(
field_name='content_types__id'
)
content_types = ContentTypeFilter()
http_method = django_filters.MultipleChoiceFilter(
choices=WebhookHttpMethodChoices
@@ -40,8 +43,8 @@ class WebhookFilterSet(BaseFilterSet):
class Meta:
model = Webhook
fields = [
'id', 'content_types', 'name', 'type_create', 'type_update', 'type_delete', 'payload_url', 'enabled',
'http_method', 'http_content_type', 'secret', 'ssl_verification', 'ca_file_path',
'id', 'name', 'type_create', 'type_update', 'type_delete', 'payload_url', 'enabled', 'http_method',
'http_content_type', 'secret', 'ssl_verification', 'ca_file_path',
]
def search(self, queryset, name, value):
@@ -58,11 +61,17 @@ class CustomFieldFilterSet(BaseFilterSet):
method='search',
label='Search',
)
type = django_filters.MultipleChoiceFilter(
choices=CustomFieldTypeChoices
)
content_type_id = MultiValueNumberFilter(
field_name='content_types__id'
)
content_types = ContentTypeFilter()
class Meta:
model = CustomField
fields = ['id', 'content_types', 'name', 'required', 'filter_logic', 'weight', 'description']
fields = ['id', 'name', 'required', 'filter_logic', 'weight', 'description']
def search(self, queryset, name, value):
if not value.strip():

View File

@@ -32,12 +32,13 @@ __all__ = (
class CustomFieldFilterForm(FilterForm):
fieldsets = (
(None, ('q',)),
('Attributes', ('type', 'content_types', 'weight', 'required')),
('Attributes', ('type', 'content_type_id', 'weight', 'required')),
)
content_types = ContentTypeMultipleChoiceField(
content_type_id = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields'),
required=False
required=False,
label='Object type'
)
type = MultipleChoiceField(
choices=CustomFieldTypeChoices,
@@ -110,13 +111,14 @@ class ExportTemplateFilterForm(FilterForm):
class WebhookFilterForm(FilterForm):
fieldsets = (
(None, ('q',)),
('Attributes', ('content_types', 'http_method', 'enabled')),
('Attributes', ('content_type_id', 'http_method', 'enabled')),
('Events', ('type_create', 'type_update', 'type_delete')),
)
content_types = ContentTypeMultipleChoiceField(
content_type_id = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('webhooks'),
required=False
required=False,
label='Object type'
)
http_method = MultipleChoiceField(
choices=WebhookHttpMethodChoices,

View File

@@ -133,6 +133,7 @@ class WebhookForm(BootstrapMixin, forms.ModelForm):
'http_method': StaticSelect(),
'additional_headers': forms.Textarea(attrs={'class': 'font-monospace'}),
'body_template': forms.Textarea(attrs={'class': 'font-monospace'}),
'conditions': forms.Textarea(attrs={'class': 'font-monospace'}),
}

View File

@@ -169,7 +169,7 @@ class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
model = ct.model_class()
instances = model.objects.filter(**{f'custom_field_data__{self.name}__isnull': False})
for instance in instances:
del(instance.custom_field_data[self.name])
del instance.custom_field_data[self.name]
model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
def rename_object_data(self, old_name, new_name):
@@ -365,13 +365,8 @@ class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
# Text
else:
if self.type == CustomFieldTypeChoices.TYPE_LONGTEXT:
max_length = None
widget = forms.Textarea
else:
max_length = 255
widget = None
field = forms.CharField(max_length=max_length, required=required, initial=initial, widget=widget)
widget = forms.Textarea if self.type == CustomFieldTypeChoices.TYPE_LONGTEXT else None
field = forms.CharField(required=required, initial=initial, widget=widget)
if self.validation_regex:
field.validators = [
RegexValidator(

View File

@@ -992,7 +992,7 @@ class CustomFieldModelTest(TestCase):
with self.assertRaises(ValidationError):
site.clean()
del(site.cf['bar'])
del site.cf['bar']
site.clean()
def test_missing_required_field(self):

View File

@@ -7,7 +7,9 @@ from django.test import TestCase
from circuits.models import Provider
from dcim.models import DeviceRole, DeviceType, Manufacturer, Platform, Rack, Region, Site, SiteGroup
from extras.choices import JournalEntryKindChoices, ObjectChangeActionChoices
from extras.choices import (
CustomFieldTypeChoices, CustomFieldFilterLogicChoices, JournalEntryKindChoices, ObjectChangeActionChoices,
)
from extras.filtersets import *
from extras.models import *
from ipam.models import IPAddress
@@ -16,6 +18,65 @@ from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests, cr
from virtualization.models import Cluster, ClusterGroup, ClusterType
class CustomFieldTestCase(TestCase, BaseFilterSetTests):
queryset = CustomField.objects.all()
filterset = CustomFieldFilterSet
@classmethod
def setUpTestData(cls):
content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device'])
custom_fields = (
CustomField(
name='Custom Field 1',
type=CustomFieldTypeChoices.TYPE_TEXT,
required=True,
weight=100,
filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE
),
CustomField(
name='Custom Field 2',
type=CustomFieldTypeChoices.TYPE_INTEGER,
required=False,
weight=200,
filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT
),
CustomField(
name='Custom Field 3',
type=CustomFieldTypeChoices.TYPE_BOOLEAN,
required=False,
weight=300,
filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED
),
)
CustomField.objects.bulk_create(custom_fields)
custom_fields[0].content_types.add(content_types[0])
custom_fields[1].content_types.add(content_types[1])
custom_fields[2].content_types.add(content_types[2])
def test_name(self):
params = {'name': ['Custom Field 1', 'Custom Field 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_content_types(self):
params = {'content_types': 'dcim.site'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'content_type_id': [ContentType.objects.get_for_model(Site).pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_required(self):
params = {'required': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_weight(self):
params = {'weight': [100, 200]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_filter_logic(self):
params = {'filter_logic': CustomFieldFilterLogicChoices.FILTER_LOOSE}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
class WebhookTestCase(TestCase, BaseFilterSetTests):
queryset = Webhook.objects.all()
filterset = WebhookFilterSet
@@ -62,6 +123,8 @@ class WebhookTestCase(TestCase, BaseFilterSetTests):
def test_content_types(self):
params = {'content_types': 'dcim.site'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'content_type_id': [ContentType.objects.get_for_model(Site).pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_type_create(self):
params = {'type_create': True}

View File

@@ -30,4 +30,4 @@ class RegistryTest(TestCase):
reg['foo'] = 123
with self.assertRaises(TypeError):
del(reg['foo'])
del reg['foo']

View File

@@ -10,6 +10,7 @@ from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS, VLANGROUP_SCOPE_TYPES
from ipam.models import *
from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
from netbox.api.serializers import NetBoxModelSerializer
from netbox.constants import NESTED_SERIALIZER_PREFIX
from tenancy.api.nested_serializers import NestedTenantSerializer
from utilities.api import get_serializer_for_model
from virtualization.api.nested_serializers import NestedVirtualMachineSerializer
@@ -145,7 +146,7 @@ class FHRPGroupAssignmentSerializer(NetBoxModelSerializer):
def get_interface(self, obj):
if obj.interface is None:
return None
serializer = get_serializer_for_model(obj.interface, prefix='Nested')
serializer = get_serializer_for_model(obj.interface, prefix=NESTED_SERIALIZER_PREFIX)
context = {'request': self.context['request']}
return serializer(obj.interface, context=context).data
@@ -191,7 +192,7 @@ class VLANGroupSerializer(NetBoxModelSerializer):
def get_scope(self, obj):
if obj.scope_id is None:
return None
serializer = get_serializer_for_model(obj.scope, prefix='Nested')
serializer = get_serializer_for_model(obj.scope, prefix=NESTED_SERIALIZER_PREFIX)
context = {'request': self.context['request']}
return serializer(obj.scope, context=context).data
@@ -375,7 +376,7 @@ class IPAddressSerializer(NetBoxModelSerializer):
def get_assigned_object(self, obj):
if obj.assigned_object is None:
return None
serializer = get_serializer_for_model(obj.assigned_object, prefix='Nested')
serializer = get_serializer_for_model(obj.assigned_object, prefix=NESTED_SERIALIZER_PREFIX)
context = {'request': self.context['request']}
return serializer(obj.assigned_object, context=context).data

View File

@@ -1,7 +1,8 @@
from django import forms
from django.utils.translation import gettext as _
from dcim.models import Location, Rack, Region, Site, SiteGroup
from dcim.models import Location, Rack, Region, Site, SiteGroup, Device
from virtualization.models import VirtualMachine
from ipam.choices import *
from ipam.constants import *
from ipam.models import *
@@ -265,6 +266,7 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
('Attributes', ('parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface')),
('VRF', ('vrf_id', 'present_in_vrf_id')),
('Tenant', ('tenant_group_id', 'tenant_id')),
('Device/VM', ('device_id', 'virtual_machine_id')),
)
parent = forms.CharField(
required=False,
@@ -298,6 +300,16 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
required=False,
label=_('Present in VRF')
)
device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
required=False,
label=_('Assigned Device'),
)
virtual_machine_id = DynamicModelMultipleChoiceField(
queryset=VirtualMachine.objects.all(),
required=False,
label=_('Assigned VM'),
)
status = MultipleChoiceField(
choices=IPAddressStatusChoices,
required=False

View File

@@ -848,7 +848,7 @@ class ServiceCreateForm(ServiceForm):
# Fields which may be populated from a ServiceTemplate are not required
for field in ('name', 'protocol', 'ports'):
self.fields[field].required = False
del(self.fields[field].widget.attrs['required'])
del self.fields[field].widget.attrs['required']
def clean(self):
if self.cleaned_data['service_template']:

View File

@@ -373,7 +373,7 @@ class Prefix(GetAvailablePrefixesMixin, NetBoxModel):
# Cache the original prefix and VRF so we can check if they have changed on post_save
self._prefix = self.prefix
self._vrf = self.vrf
self._vrf_id = self.vrf_id
def __str__(self):
return str(self.prefix)

View File

@@ -30,14 +30,14 @@ def update_children_depth(prefix):
def handle_prefix_saved(instance, created, **kwargs):
# Prefix has changed (or new instance has been created)
if created or instance.vrf != instance._vrf or instance.prefix != instance._prefix:
if created or instance.vrf_id != instance._vrf_id or instance.prefix != instance._prefix:
update_parents_children(instance)
update_children_depth(instance)
# If this is not a new prefix, clean up parent/children of previous prefix
if not created:
old_prefix = Prefix(vrf=instance._vrf, prefix=instance._prefix)
old_prefix = Prefix(vrf_id=instance._vrf_id, prefix=instance._prefix)
update_parents_children(old_prefix)
update_children_depth(old_prefix)

View File

@@ -4,7 +4,7 @@ from django_tables2.utils import Accessor
from ipam.models import *
from netbox.tables import NetBoxTable, columns
from tenancy.tables import TenantColumn
from tenancy.tables import TenancyColumnsMixin, TenantColumn
__all__ = (
'AggregateTable',
@@ -99,7 +99,7 @@ class RIRTable(NetBoxTable):
# ASNs
#
class ASNTable(NetBoxTable):
class ASNTable(TenancyColumnsMixin, NetBoxTable):
asn = tables.Column(
linkify=True
)
@@ -122,7 +122,6 @@ class ASNTable(NetBoxTable):
linkify_item=True,
verbose_name='Sites'
)
tenant = TenantColumn()
tags = columns.TagColumn(
url_name='ipam:asn_list'
)
@@ -130,7 +129,7 @@ class ASNTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = ASN
fields = (
'pk', 'asn', 'asn_asdot', 'rir', 'site_count', 'provider_count', 'tenant', 'description', 'sites', 'tags',
'pk', 'asn', 'asn_asdot', 'rir', 'site_count', 'provider_count', 'tenant', 'tenant_group', 'description', 'sites', 'tags',
'created', 'last_updated', 'actions',
)
default_columns = ('pk', 'asn', 'rir', 'site_count', 'provider_count', 'sites', 'description', 'tenant')
@@ -140,12 +139,11 @@ class ASNTable(NetBoxTable):
# Aggregates
#
class AggregateTable(NetBoxTable):
class AggregateTable(TenancyColumnsMixin, NetBoxTable):
prefix = tables.Column(
linkify=True,
verbose_name='Aggregate'
)
tenant = TenantColumn()
date_added = tables.DateColumn(
format="Y-m-d",
verbose_name='Added'
@@ -164,7 +162,7 @@ class AggregateTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Aggregate
fields = (
'pk', 'id', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description', 'tags',
'pk', 'id', 'prefix', 'rir', 'tenant', 'tenant_group', 'child_count', 'utilization', 'date_added', 'description', 'tags',
'created', 'last_updated',
)
default_columns = ('pk', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description')
@@ -225,7 +223,7 @@ class PrefixUtilizationColumn(columns.UtilizationColumn):
"""
class PrefixTable(NetBoxTable):
class PrefixTable(TenancyColumnsMixin, NetBoxTable):
prefix = columns.TemplateColumn(
template_code=PREFIX_LINK,
export_raw=True,
@@ -256,7 +254,6 @@ class PrefixTable(NetBoxTable):
template_code=VRF_LINK,
verbose_name='VRF'
)
tenant = TenantColumn()
site = tables.Column(
linkify=True
)
@@ -289,7 +286,7 @@ class PrefixTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Prefix
fields = (
'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site',
'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'tenant_group', 'site',
'vlan_group', 'vlan', 'role', 'is_pool', 'mark_utilized', 'description', 'tags', 'created', 'last_updated',
)
default_columns = (
@@ -303,7 +300,7 @@ class PrefixTable(NetBoxTable):
#
# IP ranges
#
class IPRangeTable(NetBoxTable):
class IPRangeTable(TenancyColumnsMixin, NetBoxTable):
start_address = tables.Column(
linkify=True
)
@@ -317,7 +314,6 @@ class IPRangeTable(NetBoxTable):
role = tables.Column(
linkify=True
)
tenant = TenantColumn()
utilization = columns.UtilizationColumn(
accessor='utilization',
orderable=False
@@ -329,7 +325,7 @@ class IPRangeTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = IPRange
fields = (
'pk', 'id', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description',
'pk', 'id', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'tenant_group', 'description',
'utilization', 'tags', 'created', 'last_updated',
)
default_columns = (
@@ -344,7 +340,7 @@ class IPRangeTable(NetBoxTable):
# IPAddresses
#
class IPAddressTable(NetBoxTable):
class IPAddressTable(TenancyColumnsMixin, NetBoxTable):
address = tables.TemplateColumn(
template_code=IPADDRESS_LINK,
verbose_name='IP Address'
@@ -357,7 +353,6 @@ class IPAddressTable(NetBoxTable):
default=AVAILABLE_LABEL
)
role = columns.ChoiceFieldColumn()
tenant = TenantColumn()
assigned_object = tables.Column(
linkify=True,
orderable=False,
@@ -374,6 +369,11 @@ class IPAddressTable(NetBoxTable):
orderable=False,
verbose_name='NAT (Inside)'
)
nat_outside = tables.Column(
linkify=True,
orderable=False,
verbose_name='NAT (Outside)'
)
assigned = columns.BooleanColumn(
accessor='assigned_object_id',
linkify=True,
@@ -386,7 +386,7 @@ class IPAddressTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = IPAddress
fields = (
'pk', 'id', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'assigned', 'dns_name', 'description',
'pk', 'id', 'address', 'vrf', 'status', 'role', 'tenant', 'tenant_group', 'nat_inside', 'nat_outside', 'assigned', 'dns_name', 'description',
'tags', 'created', 'last_updated',
)
default_columns = (

View File

@@ -5,7 +5,7 @@ from django_tables2.utils import Accessor
from dcim.models import Interface
from ipam.models import *
from netbox.tables import NetBoxTable, columns
from tenancy.tables import TenantColumn
from tenancy.tables import TenancyColumnsMixin, TenantColumn
from virtualization.models import VMInterface
__all__ = (
@@ -90,7 +90,7 @@ class VLANGroupTable(NetBoxTable):
# VLANs
#
class VLANTable(NetBoxTable):
class VLANTable(TenancyColumnsMixin, NetBoxTable):
vid = tables.TemplateColumn(
template_code=VLAN_LINK,
verbose_name='VID'
@@ -104,7 +104,6 @@ class VLANTable(NetBoxTable):
group = tables.Column(
linkify=True
)
tenant = TenantColumn()
status = columns.ChoiceFieldColumn(
default=AVAILABLE_LABEL
)
@@ -123,7 +122,7 @@ class VLANTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = VLAN
fields = (
'pk', 'id', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description', 'tags',
'pk', 'id', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'tenant_group', 'status', 'role', 'description', 'tags',
'created', 'last_updated',
)
default_columns = ('pk', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description')

View File

@@ -2,7 +2,7 @@ import django_tables2 as tables
from ipam.models import *
from netbox.tables import NetBoxTable, columns
from tenancy.tables import TenantColumn
from tenancy.tables import TenancyColumnsMixin
__all__ = (
'RouteTargetTable',
@@ -20,14 +20,13 @@ VRF_TARGETS = """
# VRFs
#
class VRFTable(NetBoxTable):
class VRFTable(TenancyColumnsMixin, NetBoxTable):
name = tables.Column(
linkify=True
)
rd = tables.Column(
verbose_name='RD'
)
tenant = TenantColumn()
enforce_unique = columns.BooleanColumn(
verbose_name='Unique'
)
@@ -46,7 +45,7 @@ class VRFTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = VRF
fields = (
'pk', 'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets',
'pk', 'id', 'name', 'rd', 'tenant', 'tenant_group', 'enforce_unique', 'description', 'import_targets', 'export_targets',
'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'rd', 'tenant', 'description')
@@ -56,16 +55,15 @@ class VRFTable(NetBoxTable):
# Route targets
#
class RouteTargetTable(NetBoxTable):
class RouteTargetTable(TenancyColumnsMixin, NetBoxTable):
name = tables.Column(
linkify=True
)
tenant = TenantColumn()
tags = columns.TagColumn(
url_name='ipam:vrf_list'
)
class Meta(NetBoxTable.Meta):
model = RouteTarget
fields = ('pk', 'id', 'name', 'tenant', 'description', 'tags', 'created', 'last_updated',)
fields = ('pk', 'id', 'name', 'tenant', 'tenant_group', 'description', 'tags', 'created', 'last_updated',)
default_columns = ('pk', 'name', 'tenant', 'description')

View File

@@ -298,7 +298,7 @@ class AggregatePrefixesView(generic.ObjectChildrenView):
def get_children(self, request, parent):
return Prefix.objects.restrict(request.user, 'view').filter(
prefix__net_contained_or_equal=str(parent.prefix)
).prefetch_related('site', 'role', 'tenant', 'vlan')
).prefetch_related('site', 'role', 'tenant', 'tenant__group', 'vlan')
def prep_table_data(self, request, queryset, parent):
# Determine whether to show assigned prefixes, available prefixes, or both
@@ -333,14 +333,18 @@ class AggregateBulkImportView(generic.BulkImportView):
class AggregateBulkEditView(generic.BulkEditView):
queryset = Aggregate.objects.prefetch_related('rir')
queryset = Aggregate.objects.annotate(
child_count=RawSQL('SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix', ())
)
filterset = filtersets.AggregateFilterSet
table = tables.AggregateTable
form = forms.AggregateBulkEditForm
class AggregateBulkDeleteView(generic.BulkDeleteView):
queryset = Aggregate.objects.prefetch_related('rir')
queryset = Aggregate.objects.annotate(
child_count=RawSQL('SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix', ())
)
filterset = filtersets.AggregateFilterSet
table = tables.AggregateTable
@@ -470,7 +474,7 @@ class PrefixPrefixesView(generic.ObjectChildrenView):
def get_children(self, request, parent):
return parent.get_child_prefixes().restrict(request.user, 'view').prefetch_related(
'site', 'vrf', 'vlan', 'role', 'tenant',
'site', 'vrf', 'vlan', 'role', 'tenant', 'tenant__group'
)
def prep_table_data(self, request, queryset, parent):
@@ -499,7 +503,7 @@ class PrefixIPRangesView(generic.ObjectChildrenView):
def get_children(self, request, parent):
return parent.get_child_ranges().restrict(request.user, 'view').prefetch_related(
'vrf', 'role', 'tenant',
'vrf', 'role', 'tenant', 'tenant__group',
)
def get_extra_context(self, request, instance):
@@ -586,9 +590,7 @@ class IPRangeIPAddressesView(generic.ObjectChildrenView):
template_name = 'ipam/iprange/ip_addresses.html'
def get_children(self, request, parent):
return parent.get_child_ips().restrict(request.user, 'view').prefetch_related(
'vrf', 'role', 'tenant',
)
return parent.get_child_ips().restrict(request.user, 'view')
def get_extra_context(self, request, instance):
return {
@@ -680,13 +682,16 @@ class IPAddressView(generic.ObjectView):
service_filter = Q(ipaddresses=instance)
# Find services listening on all IPs on the assigned device/vm
if instance.assigned_object and instance.assigned_object.parent_object:
parent_object = instance.assigned_object.parent_object
try:
if instance.assigned_object and instance.assigned_object.parent_object:
parent_object = instance.assigned_object.parent_object
if isinstance(parent_object, VirtualMachine):
service_filter |= (Q(virtual_machine=parent_object) & Q(ipaddresses=None))
elif isinstance(parent_object, Device):
service_filter |= (Q(device=parent_object) & Q(ipaddresses=None))
if isinstance(parent_object, VirtualMachine):
service_filter |= (Q(virtual_machine=parent_object) & Q(ipaddresses=None))
elif isinstance(parent_object, Device):
service_filter |= (Q(device=parent_object) & Q(ipaddresses=None))
except AttributeError:
pass
services = Service.objects.restrict(request.user, 'view').filter(service_filter)

View File

@@ -10,6 +10,7 @@ from rest_framework.viewsets import ModelViewSet
from extras.models import ExportTemplate
from netbox.api.exceptions import SerializerNotFound
from netbox.constants import NESTED_SERIALIZER_PREFIX
from utilities.api import get_serializer_for_model
from .mixins import *
@@ -60,7 +61,7 @@ class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectVali
if self.brief:
logger.debug("Request is for 'brief' format; initializing nested serializer")
try:
serializer = get_serializer_for_model(self.queryset.model, prefix='Nested')
serializer = get_serializer_for_model(self.queryset.model, prefix=NESTED_SERIALIZER_PREFIX)
logger.debug(f"Using serializer {serializer}")
return serializer
except SerializerNotFound:

View File

@@ -348,3 +348,26 @@ class LDAPBackend:
ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
return obj
# Custom Social Auth Pipeline Handlers
def user_default_groups_handler(backend, user, response, *args, **kwargs):
"""
Custom pipeline handler which adds remote auth users to the default group specified in the
configuration file.
"""
logger = logging.getLogger('netbox.auth.user_default_groups_handler')
if settings.REMOTE_AUTH_DEFAULT_GROUPS:
# 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)
else:
user.groups.clear()
logger.debug(f"Stripping user {user} from Groups")

View File

@@ -82,6 +82,31 @@ PARAMS = (
field=forms.IntegerField
),
# Power
ConfigParam(
name='POWERFEED_DEFAULT_VOLTAGE',
label='Powerfeed voltage',
default=120,
description="Default voltage for powerfeeds",
field=forms.IntegerField
),
ConfigParam(
name='POWERFEED_DEFAULT_AMPERAGE',
label='Powerfeed amperage',
default=15,
description="Default amperage for powerfeeds",
field=forms.IntegerField
),
ConfigParam(
name='POWERFEED_DEFAULT_MAX_UTILIZATION',
label='Powerfeed max utilization',
default=80,
description="Default max utilization for powerfeeds",
field=forms.IntegerField
),
# Security
ConfigParam(
name='ALLOWED_URL_SCHEMES',

View File

@@ -1,256 +1,5 @@
from collections import OrderedDict
from typing import Dict
import circuits.filtersets
import circuits.tables
import dcim.filtersets
import dcim.tables
import ipam.filtersets
import ipam.tables
import tenancy.filtersets
import tenancy.tables
import virtualization.filtersets
import virtualization.tables
from circuits.models import Circuit, ProviderNetwork, Provider
from dcim.models import (
Cable, Device, DeviceType, Location, Module, ModuleType, PowerFeed, Rack, RackReservation, Site, VirtualChassis,
)
from ipam.models import Aggregate, ASN, IPAddress, Prefix, Service, VLAN, VRF
from tenancy.models import Contact, Tenant, ContactAssignment
from utilities.utils import count_related
from virtualization.models import Cluster, VirtualMachine
# Prefix for nested serializers
NESTED_SERIALIZER_PREFIX = 'Nested'
# Max results per object type
SEARCH_MAX_RESULTS = 15
CIRCUIT_TYPES = OrderedDict(
(
('provider', {
'queryset': Provider.objects.annotate(
count_circuits=count_related(Circuit, 'provider')
),
'filterset': circuits.filtersets.ProviderFilterSet,
'table': circuits.tables.ProviderTable,
'url': 'circuits:provider_list',
}),
('circuit', {
'queryset': Circuit.objects.prefetch_related(
'type', 'provider', 'tenant', 'terminations__site'
),
'filterset': circuits.filtersets.CircuitFilterSet,
'table': circuits.tables.CircuitTable,
'url': 'circuits:circuit_list',
}),
('providernetwork', {
'queryset': ProviderNetwork.objects.prefetch_related('provider'),
'filterset': circuits.filtersets.ProviderNetworkFilterSet,
'table': circuits.tables.ProviderNetworkTable,
'url': 'circuits:providernetwork_list',
}),
)
)
DCIM_TYPES = OrderedDict(
(
('site', {
'queryset': Site.objects.prefetch_related('region', 'tenant'),
'filterset': dcim.filtersets.SiteFilterSet,
'table': dcim.tables.SiteTable,
'url': 'dcim:site_list',
}),
('rack', {
'queryset': Rack.objects.prefetch_related('site', 'location', 'tenant', 'role').annotate(
device_count=count_related(Device, 'rack')
),
'filterset': dcim.filtersets.RackFilterSet,
'table': dcim.tables.RackTable,
'url': 'dcim:rack_list',
}),
('rackreservation', {
'queryset': RackReservation.objects.prefetch_related('site', 'rack', 'user'),
'filterset': dcim.filtersets.RackReservationFilterSet,
'table': dcim.tables.RackReservationTable,
'url': 'dcim:rackreservation_list',
}),
('location', {
'queryset': Location.objects.add_related_count(
Location.objects.add_related_count(
Location.objects.all(),
Device,
'location',
'device_count',
cumulative=True
),
Rack,
'location',
'rack_count',
cumulative=True
).prefetch_related('site'),
'filterset': dcim.filtersets.LocationFilterSet,
'table': dcim.tables.LocationTable,
'url': 'dcim:location_list',
}),
('devicetype', {
'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate(
instance_count=count_related(Device, 'device_type')
),
'filterset': dcim.filtersets.DeviceTypeFilterSet,
'table': dcim.tables.DeviceTypeTable,
'url': 'dcim:devicetype_list',
}),
('device', {
'queryset': Device.objects.prefetch_related(
'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6',
),
'filterset': dcim.filtersets.DeviceFilterSet,
'table': dcim.tables.DeviceTable,
'url': 'dcim:device_list',
}),
('moduletype', {
'queryset': ModuleType.objects.prefetch_related('manufacturer').annotate(
instance_count=count_related(Module, 'module_type')
),
'filterset': dcim.filtersets.ModuleTypeFilterSet,
'table': dcim.tables.ModuleTypeTable,
'url': 'dcim:moduletype_list',
}),
('module', {
'queryset': Module.objects.prefetch_related(
'module_type__manufacturer', 'device', 'module_bay',
),
'filterset': dcim.filtersets.ModuleFilterSet,
'table': dcim.tables.ModuleTable,
'url': 'dcim:module_list',
}),
('virtualchassis', {
'queryset': VirtualChassis.objects.prefetch_related('master').annotate(
member_count=count_related(Device, 'virtual_chassis')
),
'filterset': dcim.filtersets.VirtualChassisFilterSet,
'table': dcim.tables.VirtualChassisTable,
'url': 'dcim:virtualchassis_list',
}),
('cable', {
'queryset': Cable.objects.all(),
'filterset': dcim.filtersets.CableFilterSet,
'table': dcim.tables.CableTable,
'url': 'dcim:cable_list',
}),
('powerfeed', {
'queryset': PowerFeed.objects.all(),
'filterset': dcim.filtersets.PowerFeedFilterSet,
'table': dcim.tables.PowerFeedTable,
'url': 'dcim:powerfeed_list',
}),
)
)
IPAM_TYPES = OrderedDict(
(
('vrf', {
'queryset': VRF.objects.prefetch_related('tenant'),
'filterset': ipam.filtersets.VRFFilterSet,
'table': ipam.tables.VRFTable,
'url': 'ipam:vrf_list',
}),
('aggregate', {
'queryset': Aggregate.objects.prefetch_related('rir'),
'filterset': ipam.filtersets.AggregateFilterSet,
'table': ipam.tables.AggregateTable,
'url': 'ipam:aggregate_list',
}),
('prefix', {
'queryset': Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role'),
'filterset': ipam.filtersets.PrefixFilterSet,
'table': ipam.tables.PrefixTable,
'url': 'ipam:prefix_list',
}),
('ipaddress', {
'queryset': IPAddress.objects.prefetch_related('vrf__tenant', 'tenant'),
'filterset': ipam.filtersets.IPAddressFilterSet,
'table': ipam.tables.IPAddressTable,
'url': 'ipam:ipaddress_list',
}),
('vlan', {
'queryset': VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role'),
'filterset': ipam.filtersets.VLANFilterSet,
'table': ipam.tables.VLANTable,
'url': 'ipam:vlan_list',
}),
('asn', {
'queryset': ASN.objects.prefetch_related('rir', 'tenant'),
'filterset': ipam.filtersets.ASNFilterSet,
'table': ipam.tables.ASNTable,
'url': 'ipam:asn_list',
}),
('service', {
'queryset': Service.objects.prefetch_related('device', 'virtual_machine'),
'filterset': ipam.filtersets.ServiceFilterSet,
'table': ipam.tables.ServiceTable,
'url': 'ipam:service_list',
}),
)
)
TENANCY_TYPES = OrderedDict(
(
('tenant', {
'queryset': Tenant.objects.prefetch_related('group'),
'filterset': tenancy.filtersets.TenantFilterSet,
'table': tenancy.tables.TenantTable,
'url': 'tenancy:tenant_list',
}),
('contact', {
'queryset': Contact.objects.prefetch_related('group', 'assignments').annotate(
assignment_count=count_related(ContactAssignment, 'contact')),
'filterset': tenancy.filtersets.ContactFilterSet,
'table': tenancy.tables.ContactTable,
'url': 'tenancy:contact_list',
}),
)
)
VIRTUALIZATION_TYPES = OrderedDict(
(
('cluster', {
'queryset': Cluster.objects.prefetch_related('type', 'group').annotate(
device_count=count_related(Device, 'cluster'),
vm_count=count_related(VirtualMachine, 'cluster')
),
'filterset': virtualization.filtersets.ClusterFilterSet,
'table': virtualization.tables.ClusterTable,
'url': 'virtualization:cluster_list',
}),
('virtualmachine', {
'queryset': VirtualMachine.objects.prefetch_related(
'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
),
'filterset': virtualization.filtersets.VirtualMachineFilterSet,
'table': virtualization.tables.VirtualMachineTable,
'url': 'virtualization:virtualmachine_list',
}),
)
)
SEARCH_TYPE_HIERARCHY = OrderedDict(
(
("Circuits", CIRCUIT_TYPES),
("DCIM", DCIM_TYPES),
("IPAM", IPAM_TYPES),
("Tenancy", TENANCY_TYPES),
("Virtualization", VIRTUALIZATION_TYPES),
)
)
def build_search_types() -> Dict[str, Dict]:
result = dict()
for app_types in SEARCH_TYPE_HIERARCHY.values():
for name, items in app_types.items():
result[name] = items
return result
SEARCH_TYPES = build_search_types()

View File

@@ -125,7 +125,7 @@ class BaseFilterSet(django_filters.FilterSet):
return {}
# Skip nonstandard lookup expressions
if existing_filter.method is not None or existing_filter.lookup_expr not in ['exact', 'in']:
if existing_filter.method is not None or existing_filter.lookup_expr not in ['exact', 'iexact', 'in']:
return {}
# Choose the lookup expression map based on the filter type

View File

@@ -1,6 +1,6 @@
from django import forms
from netbox.constants import SEARCH_TYPE_HIERARCHY
from netbox.search import SEARCH_TYPE_HIERARCHY
from utilities.forms import BootstrapMixin
from .base import *

View File

@@ -89,9 +89,9 @@ class NestedGroupModel(NetBoxFeatureSet, MPTTModel):
super().clean()
# An MPTT model cannot be its own parent
if self.pk and self.parent_id == self.pk:
if self.pk and self.parent and self.parent in self.get_descendants(include_self=True):
raise ValidationError({
"parent": "Cannot assign self as parent."
"parent": f"Cannot assign self or child {self._meta.verbose_name} as parent."
})

261
netbox/netbox/search.py Normal file
View File

@@ -0,0 +1,261 @@
import circuits.filtersets
import circuits.tables
import dcim.filtersets
import dcim.tables
import ipam.filtersets
import ipam.tables
import tenancy.filtersets
import tenancy.tables
import virtualization.filtersets
import wireless.tables
import wireless.filtersets
import virtualization.tables
from circuits.models import Circuit, ProviderNetwork, Provider
from dcim.models import (
Cable, Device, DeviceType, Interface, Location, Module, ModuleType, PowerFeed, Rack, RackReservation, Site,
VirtualChassis,
)
from ipam.models import Aggregate, ASN, IPAddress, Prefix, Service, VLAN, VRF
from tenancy.models import Contact, Tenant, ContactAssignment
from utilities.utils import count_related
from wireless.models import WirelessLAN, WirelessLink
from virtualization.models import Cluster, VirtualMachine
CIRCUIT_TYPES = {
'provider': {
'queryset': Provider.objects.annotate(
count_circuits=count_related(Circuit, 'provider')
),
'filterset': circuits.filtersets.ProviderFilterSet,
'table': circuits.tables.ProviderTable,
'url': 'circuits:provider_list',
},
'circuit': {
'queryset': Circuit.objects.prefetch_related(
'type', 'provider', 'tenant', 'tenant__group', 'terminations__site'
),
'filterset': circuits.filtersets.CircuitFilterSet,
'table': circuits.tables.CircuitTable,
'url': 'circuits:circuit_list',
},
'providernetwork': {
'queryset': ProviderNetwork.objects.prefetch_related('provider'),
'filterset': circuits.filtersets.ProviderNetworkFilterSet,
'table': circuits.tables.ProviderNetworkTable,
'url': 'circuits:providernetwork_list',
},
}
DCIM_TYPES = {
'site': {
'queryset': Site.objects.prefetch_related('region', 'tenant', 'tenant__group'),
'filterset': dcim.filtersets.SiteFilterSet,
'table': dcim.tables.SiteTable,
'url': 'dcim:site_list',
},
'rack': {
'queryset': Rack.objects.prefetch_related('site', 'location', 'tenant', 'tenant__group', 'role').annotate(
device_count=count_related(Device, 'rack')
),
'filterset': dcim.filtersets.RackFilterSet,
'table': dcim.tables.RackTable,
'url': 'dcim:rack_list',
},
'rackreservation': {
'queryset': RackReservation.objects.prefetch_related('site', 'rack', 'user'),
'filterset': dcim.filtersets.RackReservationFilterSet,
'table': dcim.tables.RackReservationTable,
'url': 'dcim:rackreservation_list',
},
'location': {
'queryset': Location.objects.add_related_count(
Location.objects.add_related_count(
Location.objects.all(),
Device,
'location',
'device_count',
cumulative=True
),
Rack,
'location',
'rack_count',
cumulative=True
).prefetch_related('site'),
'filterset': dcim.filtersets.LocationFilterSet,
'table': dcim.tables.LocationTable,
'url': 'dcim:location_list',
},
'devicetype': {
'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate(
instance_count=count_related(Device, 'device_type')
),
'filterset': dcim.filtersets.DeviceTypeFilterSet,
'table': dcim.tables.DeviceTypeTable,
'url': 'dcim:devicetype_list',
},
'device': {
'queryset': Device.objects.prefetch_related(
'device_type__manufacturer', 'device_role', 'tenant', 'tenant__group', 'site', 'rack', 'primary_ip4',
'primary_ip6',
),
'filterset': dcim.filtersets.DeviceFilterSet,
'table': dcim.tables.DeviceTable,
'url': 'dcim:device_list',
},
'moduletype': {
'queryset': ModuleType.objects.prefetch_related('manufacturer').annotate(
instance_count=count_related(Module, 'module_type')
),
'filterset': dcim.filtersets.ModuleTypeFilterSet,
'table': dcim.tables.ModuleTypeTable,
'url': 'dcim:moduletype_list',
},
'module': {
'queryset': Module.objects.prefetch_related(
'module_type__manufacturer', 'device', 'module_bay',
),
'filterset': dcim.filtersets.ModuleFilterSet,
'table': dcim.tables.ModuleTable,
'url': 'dcim:module_list',
},
'virtualchassis': {
'queryset': VirtualChassis.objects.prefetch_related('master').annotate(
member_count=count_related(Device, 'virtual_chassis')
),
'filterset': dcim.filtersets.VirtualChassisFilterSet,
'table': dcim.tables.VirtualChassisTable,
'url': 'dcim:virtualchassis_list',
},
'cable': {
'queryset': Cable.objects.all(),
'filterset': dcim.filtersets.CableFilterSet,
'table': dcim.tables.CableTable,
'url': 'dcim:cable_list',
},
'powerfeed': {
'queryset': PowerFeed.objects.all(),
'filterset': dcim.filtersets.PowerFeedFilterSet,
'table': dcim.tables.PowerFeedTable,
'url': 'dcim:powerfeed_list',
},
}
IPAM_TYPES = {
'vrf': {
'queryset': VRF.objects.prefetch_related('tenant', 'tenant__group'),
'filterset': ipam.filtersets.VRFFilterSet,
'table': ipam.tables.VRFTable,
'url': 'ipam:vrf_list',
},
'aggregate': {
'queryset': Aggregate.objects.prefetch_related('rir'),
'filterset': ipam.filtersets.AggregateFilterSet,
'table': ipam.tables.AggregateTable,
'url': 'ipam:aggregate_list',
},
'prefix': {
'queryset': Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'tenant__group', 'vlan', 'role'),
'filterset': ipam.filtersets.PrefixFilterSet,
'table': ipam.tables.PrefixTable,
'url': 'ipam:prefix_list',
},
'ipaddress': {
'queryset': IPAddress.objects.prefetch_related('vrf__tenant', 'tenant', 'tenant__group'),
'filterset': ipam.filtersets.IPAddressFilterSet,
'table': ipam.tables.IPAddressTable,
'url': 'ipam:ipaddress_list',
},
'vlan': {
'queryset': VLAN.objects.prefetch_related('site', 'group', 'tenant', 'tenant__group', 'role'),
'filterset': ipam.filtersets.VLANFilterSet,
'table': ipam.tables.VLANTable,
'url': 'ipam:vlan_list',
},
'asn': {
'queryset': ASN.objects.prefetch_related('rir', 'tenant', 'tenant__group'),
'filterset': ipam.filtersets.ASNFilterSet,
'table': ipam.tables.ASNTable,
'url': 'ipam:asn_list',
},
'service': {
'queryset': Service.objects.prefetch_related('device', 'virtual_machine'),
'filterset': ipam.filtersets.ServiceFilterSet,
'table': ipam.tables.ServiceTable,
'url': 'ipam:service_list',
},
}
TENANCY_TYPES = {
'tenant': {
'queryset': Tenant.objects.prefetch_related('group'),
'filterset': tenancy.filtersets.TenantFilterSet,
'table': tenancy.tables.TenantTable,
'url': 'tenancy:tenant_list',
},
'contact': {
'queryset': Contact.objects.prefetch_related('group', 'assignments').annotate(
assignment_count=count_related(ContactAssignment, 'contact')),
'filterset': tenancy.filtersets.ContactFilterSet,
'table': tenancy.tables.ContactTable,
'url': 'tenancy:contact_list',
},
}
VIRTUALIZATION_TYPES = {
'cluster': {
'queryset': Cluster.objects.prefetch_related('type', 'group').annotate(
device_count=count_related(Device, 'cluster'),
vm_count=count_related(VirtualMachine, 'cluster')
),
'filterset': virtualization.filtersets.ClusterFilterSet,
'table': virtualization.tables.ClusterTable,
'url': 'virtualization:cluster_list',
},
'virtualmachine': {
'queryset': VirtualMachine.objects.prefetch_related(
'cluster', 'tenant', 'tenant__group', 'platform', 'primary_ip4', 'primary_ip6',
),
'filterset': virtualization.filtersets.VirtualMachineFilterSet,
'table': virtualization.tables.VirtualMachineTable,
'url': 'virtualization:virtualmachine_list',
},
}
WIRELESS_TYPES = {
'wirelesslan': {
'queryset': WirelessLAN.objects.prefetch_related('group', 'vlan').annotate(
interface_count=count_related(Interface, 'wireless_lans')
),
'filterset': wireless.filtersets.WirelessLANFilterSet,
'table': wireless.tables.WirelessLANTable,
'url': 'wireless:wirelesslan_list',
},
'wirelesslink': {
'queryset': WirelessLink.objects.prefetch_related('interface_a__device', 'interface_b__device'),
'filterset': wireless.filtersets.WirelessLinkFilterSet,
'table': wireless.tables.WirelessLinkTable,
'url': 'wireless:wirelesslink_list',
},
}
SEARCH_TYPE_HIERARCHY = {
'Circuits': CIRCUIT_TYPES,
'DCIM': DCIM_TYPES,
'IPAM': IPAM_TYPES,
'Tenancy': TENANCY_TYPES,
'Virtualization': VIRTUALIZATION_TYPES,
'Wireless': WIRELESS_TYPES,
}
def build_search_types():
result = dict()
for app_types in SEARCH_TYPE_HIERARCHY.values():
for name, items in app_types.items():
result[name] = items
return result
SEARCH_TYPES = build_search_types()

View File

@@ -29,7 +29,7 @@ django.utils.encoding.force_text = force_str
# Environment setup
#
VERSION = '3.2.5'
VERSION = '3.2.8'
# Hostname
HOSTNAME = platform.node()
@@ -476,11 +476,25 @@ if SENTRY_ENABLED:
# Django social auth
#
SOCIAL_AUTH_PIPELINE = (
'social_core.pipeline.social_auth.social_details',
'social_core.pipeline.social_auth.social_uid',
'social_core.pipeline.social_auth.social_user',
'social_core.pipeline.user.get_username',
'social_core.pipeline.social_auth.associate_by_email',
'social_core.pipeline.user.create_user',
'social_core.pipeline.social_auth.associate_user',
'netbox.authentication.user_default_groups_handler',
'social_core.pipeline.social_auth.load_extra_data',
'social_core.pipeline.user.user_details',
)
# Load all SOCIAL_AUTH_* settings from the user configuration
for param in dir(configuration):
if param.startswith('SOCIAL_AUTH_'):
globals()[param] = getattr(configuration, param)
# Force usage of PostgreSQL's JSONB field for extra data
SOCIAL_AUTH_JSONFIELD_ENABLED = True

View File

@@ -7,6 +7,7 @@ from django.contrib.auth.models import AnonymousUser
from django.db.models import DateField, DateTimeField
from django.template import Context, Template
from django.urls import reverse
from django.utils.html import escape
from django.utils.formats import date_format
from django.utils.safestring import mark_safe
from django_tables2.columns import library
@@ -428,8 +429,8 @@ class CustomFieldColumn(tables.Column):
@staticmethod
def _likify_item(item):
if hasattr(item, 'get_absolute_url'):
return f'<a href="{item.get_absolute_url()}">{item}</a>'
return item
return f'<a href="{item.get_absolute_url()}">{escape(item)}</a>'
return escape(item)
def render(self, value):
if self.customfield.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value is True:
@@ -437,13 +438,13 @@ class CustomFieldColumn(tables.Column):
if self.customfield.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value is False:
return mark_safe('<i class="mdi mdi-close-thick text-danger"></i>')
if self.customfield.type == CustomFieldTypeChoices.TYPE_URL:
return mark_safe(f'<a href="{value}">{value}</a>')
return mark_safe(f'<a href="{escape(value)}">{escape(value)}</a>')
if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
return ', '.join(v for v in value)
if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
return mark_safe(', '.join([
return mark_safe(', '.join(
self._likify_item(obj) for obj in self.customfield.deserialize(value)
]))
))
if value is not None:
obj = self.customfield.deserialize(value)
return mark_safe(self._likify_item(obj))

View File

@@ -22,8 +22,9 @@ from dcim.models import (
from extras.models import ObjectChange
from extras.tables import ObjectChangeTable
from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF
from netbox.constants import SEARCH_MAX_RESULTS, SEARCH_TYPES
from netbox.constants import SEARCH_MAX_RESULTS
from netbox.forms import SearchForm
from netbox.search import SEARCH_TYPES
from tenancy.models import Tenant
from virtualization.models import Cluster, VirtualMachine
from wireless.models import WirelessLAN, WirelessLink

View File

@@ -8,6 +8,7 @@ from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldDoesNotExist, ValidationError
from django.db import transaction, IntegrityError
from django.db.models import ManyToManyField, ProtectedError
from django.db.models.fields.reverse_related import ManyToManyRel
from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
@@ -484,7 +485,7 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
setattr(obj, name, None if model_field.null else '')
# ManyToManyFields
elif isinstance(model_field, ManyToManyField):
elif isinstance(model_field, (ManyToManyField, ManyToManyRel)):
if form.cleaned_data[name]:
getattr(obj, name).set(form.cleaned_data[name])
# Normal fields
@@ -794,6 +795,7 @@ class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView):
model_form = None
filterset = None
table = None
patterned_fields = ('name', 'label')
def get_required_permission(self):
return f'dcim.add_{self.queryset.model._meta.model_name}'
@@ -829,16 +831,16 @@ class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView):
for obj in data['pk']:
names = data['name_pattern']
labels = data['label_pattern'] if 'label_pattern' in data else None
for i, name in enumerate(names):
label = labels[i] if labels else None
pattern_count = len(data[f'{self.patterned_fields[0]}_pattern'])
for i in range(pattern_count):
component_data = {
self.parent_field: obj.pk,
'name': name,
'label': label
self.parent_field: obj.pk
}
for field_name in self.patterned_fields:
if data.get(f'{field_name}_pattern'):
component_data[field_name] = data[f'{field_name}_pattern'][i]
component_data.update(data)
component_form = self.model_form(component_data)
if component_form.is_valid():

View File

@@ -386,10 +386,10 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
)
logger.info(f"{msg} {obj} (PK: {obj.pk})")
if hasattr(obj, 'get_absolute_url'):
msg = '{} <a href="{}">{}</a>'.format(msg, obj.get_absolute_url(), escape(obj))
msg = mark_safe(f'{msg} <a href="{obj.get_absolute_url()}">{escape(obj)}</a>')
else:
msg = '{} {}'.format(msg, escape(obj))
messages.success(request, mark_safe(msg))
msg = f'{msg} {obj}'
messages.success(request, msg)
if '_addanother' in request.POST:
redirect_url = request.path

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -38,7 +38,9 @@ export function initReslug(): void {
slugLength = Number(slugLengthAttr);
}
sourceField.addEventListener('blur', () => {
slugField.value = slugify(sourceField.value, slugLength);
if (!slugField.value) {
slugField.value = slugify(sourceField.value, slugLength);
}
});
slugButton.addEventListener('click', () => {
slugField.value = slugify(sourceField.value, slugLength);

View File

@@ -1,32 +1,4 @@
import { getElements, scrollTo, isTruthy } from '../util';
/**
* When editing an object, it is sometimes desirable to customize the form action *without*
* overriding the form's `submit` event. For example, the 'Save & Continue' button. We don't want
* to use the `formaction` attribute on that element because it will be included on the form even
* if the button isn't clicked.
*
* @example
* ```html
* <button type="button" return-url="/special-url/">
* Save & Continue
* </button>
* ```
*
* @param event Click event.
*/
function handleSubmitWithReturnUrl(event: MouseEvent): void {
const element = event.target as HTMLElement;
if (element.tagName === 'BUTTON') {
const button = element as HTMLButtonElement;
const action = button.getAttribute('return-url');
const form = button.form;
if (form !== null && isTruthy(action)) {
form.action = action;
form.submit();
}
}
}
import { getElements, scrollTo } from '../util';
function handleFormSubmit(event: Event, form: HTMLFormElement): void {
// Track the names of each invalid field.
@@ -57,15 +29,6 @@ function handleFormSubmit(event: Event, form: HTMLFormElement): void {
}
}
/**
* Attach event listeners to form buttons with the `return-url` attribute present.
*/
function initReturnUrlSubmitButtons(): void {
for (const button of getElements<HTMLButtonElement>('button[return-url]')) {
button.addEventListener('click', handleSubmitWithReturnUrl);
}
}
/**
* Attach an event listener to each form's submitter (button[type=submit]). When called, the
* callback checks the validity of each form field and adds the appropriate Bootstrap CSS class
@@ -82,5 +45,4 @@ export function initFormElements(): void {
submitter.addEventListener('click', (event: Event) => handleFormSubmit(event, form));
}
}
initReturnUrlSubmitButtons();
}

View File

@@ -48,17 +48,3 @@
{% render_field form.description %}
</div>
{% endblock %}
{# Override buttons block, 'Create & Add Another'/'_addanother' is not needed on a circuit. #}
{% block buttons %}
<a class="btn btn-outline-danger" href="{{ return_url }}">Cancel</a>
{% if object.pk %}
<button type="submit" name="_update" class="btn btn-primary">
Save
</button>
{% else %}
<button type="submit" name="_create" class="btn btn-primary">
Create
</button>
{% endif %}
{% endblock buttons %}

View File

@@ -46,13 +46,7 @@
</tr>
<tr>
<th scope="row">Rack</th>
<td>
{% if object.rack %}
<a href="{% url 'dcim:rack' pk=object.rack.pk %}">{{ object.rack }}</a>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
<td>{{ object.rack|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">Position</th>
@@ -161,9 +155,7 @@
</tr>
<tr>
<th scope="row">Role</th>
<td>
<a href="{% url 'dcim:device_list' %}?role={{ object.device_role.slug }}">{{ object.device_role }}</a>
</td>
<td>{{ object.device_role|linkify }}</td>
</tr>
<tr>
<th scope="row">Platform</th>
@@ -173,7 +165,7 @@
<th scope="row">Primary IPv4</th>
<td>
{% if object.primary_ip4 %}
<a href="{% url 'ipam:ipaddress' pk=object.primary_ip4.pk %}">{{ object.primary_ip4.address.ip }}</a>
<a href="{{ object.primary_ip4.get_absolute_url }}">{{ object.primary_ip4.address.ip }}</a>
{% if object.primary_ip4.nat_inside %}
(NAT for {{ object.primary_ip4.nat_inside.address.ip|linkify }})
{% elif object.primary_ip4.nat_outside %}
@@ -188,7 +180,7 @@
<th scope="row">Primary IPv6</th>
<td>
{% if object.primary_ip6 %}
<a href="{% url 'ipam:ipaddress' pk=object.primary_ip6.pk %}">{{ object.primary_ip6.address.ip }}</a>
<a href="{{ object.primary_ip6.get_absolute_url }}">{{ object.primary_ip6.address.ip }}</a>
{% if object.primary_ip6.nat_inside %}
(NAT for {{ object.primary_ip6.nat_inside.address.ip|linkify }})
{% elif object.primary_ip6.nat_outside %}

View File

@@ -86,6 +86,15 @@
{% render_field form.tenant %}
</div>
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Virtual Chassis</h5>
</div>
{% render_field form.virtual_chassis %}
{% render_field form.vc_position %}
{% render_field form.vc_priority %}
</div>
{% if form.custom_fields %}
<div class="field-group my-5">
<div class="row mb-2">

View File

@@ -91,14 +91,3 @@
</div>
{% endif %}
{% endblock %}
{% block buttons %}
<a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
{% if object.pk %}
<button type="button" return-url="?return_url={% url 'dcim:interface_edit' pk=object.pk %}" class="btn btn-outline-primary">Save & Continue Editing</button>
<button type="submit" name="_update" class="btn btn-primary">Save</button>
{% else %}
<button type="submit" name="_addanother" class="btn btn-outline-primary">Create & Add Another</button>
<button type="submit" name="_create" class="btn btn-primary">Create</button>
{% endif %}
{% endblock %}

View File

@@ -5,25 +5,29 @@
{% render_errors form %}
{% block content %}
{% if perms.extras.add_journalentry %}
<form action="{% url 'extras:journalentry_add' %}" method="post" enctype="multipart/form-data">
<div class="container">
<div class="field-group">
<h4>New Journal Entry</h4>
{% csrf_token %}
{% render_form form %}
</div>
<div class="col col-md-12 text-end my-3">
<a href="{{ object.get_absolute_url }}" class="btn btn-outline-danger">Cancel</a>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</div>
</form>
{% endif %}
<div class="card">
<div class="card-body table-responsive">
{% render_table table 'inc/table.html' %}
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
</div>
</div>
{% if perms.extras.add_journalentry %}
<div class="card">
<div class="card-body table-responsive">
<h4 class="card-header">New Journal Entry</h4>
<form action="{% url 'extras:journalentry_add' %}" method="post" enctype="multipart/form-data">
<div class="container">
<div class="field-group">
{% csrf_token %}
{% render_form form %}
</div>
<div class="col col-md-12 text-end my-3">
<a href="{{ object.get_absolute_url }}" class="btn btn-outline-danger">Cancel</a>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</div>
</form>
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -36,8 +36,8 @@ Context:
{{ field }}
{% endfor %}
<div class="text-end">
<a href="{{ return_url }}" class="btn btn-outline-dark">Cancel</a>
<button type="submit" name="_confirm" class="btn btn-danger">Delete {{ table.rows|length }} {{ model|meta:"verbose_name_plural" }}</button>
<a href="{{ return_url }}" class="btn btn-outline-dark">Cancel</a>
</div>
</form>
</div>

View File

@@ -118,8 +118,8 @@ Context:
</div>
<div class="text-end">
<a href="{{ return_url }}" class="btn btn-sm btn-outline-danger">Cancel</a>
<button type="submit" name="_apply" class="btn btn-sm btn-primary">Apply</button>
<a href="{{ return_url }}" class="btn btn-sm btn-outline-danger">Cancel</a>
</div>
</div>
</div>

View File

@@ -44,12 +44,12 @@ Context:
</div>
</div>
<div class="form-group">
<div class="col col-md-12 text-end">
{% if return_url %}
<a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
{% endif %}
<button type="submit" class="btn btn-primary">Submit</button>
</div>
<div class="col col-md-12 text-end">
<button type="submit" class="btn btn-primary">Submit</button>
{% if return_url %}
<a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
{% endif %}
</div>
</div>
</form>
{% if fields %}

View File

@@ -23,8 +23,8 @@
{{ field }}
{% endfor %}
<div class="text-center">
<a href="{{ return_url }}" class="btn btn-outline-dark">Cancel</a>
<button type="submit" name="_confirm" class="btn btn-danger">Delete these {{ table.rows|length }} {{ obj_type_plural }}</button>
<a href="{{ return_url }}" class="btn btn-outline-dark">Cancel</a>
</div>
</form>
</div>

View File

@@ -34,11 +34,11 @@
</div>
</div>
<div class="col col-md-12 my-3 text-end">
<a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
<button type="submit" name="_preview" class="btn btn-primary">Preview</button>
{% if '_preview' in request.POST and not form.errors %}
<button type="submit" name="_apply" class="btn btn-primary">Apply</button>
{% endif %}
<a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
</div>
</form>
</div>

View File

@@ -2,33 +2,24 @@
{% load form_helpers %}
{% block content %}
<div class="row mt-5">
<div class="col col-md-6 offset-md-3">
<form action="" method="post" class="form">
{% csrf_token %}
{% for field in form.hidden_fields %}
{{ field }}
{% endfor %}
<div class="card border-danger">
<h5 class="card-header">{% block confirmation_title %}{% endblock %}</h5>
<div class="card-body">
{% block message %}<p>Are you sure?</p>{% endblock %}
</div>
<div class="card-footer text-end">
<a href="{{ return_url }}" class="btn btn-outline-dark">Cancel</a>
<button type="submit" name="_confirm" class="btn btn-{{ button_class|default:"danger" }}">Confirm</button>
</div>
</div>
</form>
<div class="row mt-5">
<div class="col col-md-6 offset-md-3">
<form action="" method="post" class="form">
{% csrf_token %}
{% for field in form.hidden_fields %}
{{ field }}
{% endfor %}
<div class="card border-danger">
<h5 class="card-header">{% block confirmation_title %}{% endblock %}</h5>
<div class="card-body">
{% block message %}<p>Are you sure?</p>{% endblock %}
</div>
<div class="card-footer text-end">
<button type="submit" name="_confirm" class="btn btn-{{ button_class|default:"danger" }}">Confirm</button>
<a href="{{ return_url }}" class="btn btn-outline-dark">Cancel</a>
</div>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@@ -94,19 +94,19 @@ Context:
<div class="text-end my-3">
{% block buttons %}
<a class="btn btn-outline-danger" href="{{ return_url }}">Cancel</a>
{% if object.pk %}
<button type="submit" name="_update" class="btn btn-primary">
Save
</button>
{% else %}
<button type="submit" name="_addanother" class="btn btn-outline-primary">
Create & Add Another
</button>
<button type="submit" name="_create" class="btn btn-primary">
Create
</button>
<button type="submit" name="_addanother" class="btn btn-outline-primary">
Create & Add Another
</button>
{% endif %}
<a class="btn btn-outline-danger" href="{{ return_url }}">Cancel</a>
{% endblock buttons %}
</div>
</form>

View File

@@ -5,19 +5,19 @@
{% block title %}{{ obj_type|bettertitle }} Import{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col col-md-12 col-xl-8 offset-xl-2">
<form action="" method="post" class="form form-horizontal">
{% csrf_token %}
{% render_form form %}
<div class="col col-md-12 text-end">
{% if return_url %}
<a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
{% endif %}
<button type="submit" name="_addanother" class="btn btn-outline-primary">Submit & Import Another</button>
<button type="submit" name="_create" class="btn btn-primary">Submit</button>
</div>
</form>
</div>
</div>
<div class="row mb-3">
<div class="col col-md-12 col-xl-8 offset-xl-2">
<form action="" method="post" class="form form-horizontal">
{% csrf_token %}
{% render_form form %}
<div class="col col-md-12 text-end">
<button type="submit" name="_create" class="btn btn-primary">Submit</button>
<button type="submit" name="_addanother" class="btn btn-outline-primary">Submit & Import Another</button>
{% if return_url %}
<a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
{% endif %}
</div>
</form>
</div>
</div>
{% endblock content %}

View File

@@ -55,14 +55,3 @@
</div>
{% endif %}
{% endblock %}
{% block buttons %}
<a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
{% if object.pk %}
<button type="button" return-url="?return_url={% url 'virtualization:vminterface_edit' pk=object.pk %}" class="btn btn-outline-primary">Save & Continue Editing</button>
<button type="submit" name="_update" class="btn btn-primary">Save</button>
{% else %}
<button type="submit" name="_addanother" class="btn btn-outline-primary">Create & Add Another</button>
<button type="submit" name="_create" class="btn btn-primary">Create</button>
{% endif %}
{% endblock %}

View File

@@ -4,6 +4,7 @@ from rest_framework import serializers
from netbox.api import ChoiceField, ContentTypeField
from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer
from netbox.constants import NESTED_SERIALIZER_PREFIX
from tenancy.choices import ContactPriorityChoices
from tenancy.models import *
from utilities.api import get_serializer_for_model
@@ -108,6 +109,6 @@ class ContactAssignmentSerializer(NetBoxModelSerializer):
@swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_object(self, instance):
serializer = get_serializer_for_model(instance.content_type.model_class(), prefix='Nested')
serializer = get_serializer_for_model(instance.content_type.model_class(), prefix=NESTED_SERIALIZER_PREFIX)
context = {'request': self.context['request']}
return serializer(instance.object, context=context).data

View File

@@ -2,6 +2,8 @@ import django_tables2 as tables
__all__ = (
'TenantColumn',
'TenantGroupColumn',
'TenancyColumnsMixin',
)
@@ -24,3 +26,32 @@ class TenantColumn(tables.TemplateColumn):
def value(self, value):
return str(value) if value else None
class TenantGroupColumn(tables.TemplateColumn):
"""
Include the tenant group description.
"""
template_code = """
{% if record.tenant and record.tenant.group %}
<a href="{{ record.tenant.group.get_absolute_url }}" title="{{ record.tenant.group.description }}">{{ record.tenant.group }}</a>
{% elif record.vrf.tenant and record.vrf.tenant.group %}
<a href="{{ record.vrf.tenant.group.get_absolute_url }}" title="{{ record.vrf.tenant.group.description }}">{{ record.vrf.tenant.group }}</a>*
{% else %}
&mdash;
{% endif %}
"""
def __init__(self, accessor=tables.A('tenant__group'), *args, **kwargs):
if 'verbose_name' not in kwargs:
kwargs['verbose_name'] = 'Tenant Group'
super().__init__(template_code=self.template_code, accessor=accessor, *args, **kwargs)
def value(self, value):
return str(value) if value else None
class TenancyColumnsMixin(tables.Table):
tenant_group = TenantGroupColumn()
tenant = TenantColumn()

View File

@@ -58,6 +58,8 @@ class TokenViewSet(NetBoxModelViewSet):
# Workaround for schema generation (drf_yasg)
if getattr(self, 'swagger_fake_view', False):
return queryset.none()
if not self.request.user.is_authenticated:
return queryset.none()
if self.request.user.is_superuser:
return queryset
return queryset.filter(user=self.request.user)
@@ -74,11 +76,11 @@ class TokenProvisionView(APIView):
serializer.is_valid()
# Authenticate the user account based on the provided credentials
user = authenticate(
request=request,
username=serializer.data['username'],
password=serializer.data['password']
)
username = serializer.data.get('username')
password = serializer.data.get('password')
if not username or not password:
raise AuthenticationFailed("Username and password must be provided to provision a token.")
user = authenticate(request=request, username=username, password=password)
if user is None:
raise AuthenticationFailed("Invalid username/password")

View File

@@ -10,6 +10,7 @@ from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.utils.http import url_has_allowed_host_and_scheme
from django.views.decorators.debug import sensitive_post_parameters
from django.views.generic import View
from social_core.backends.utils import load_backends
@@ -20,7 +21,7 @@ from netbox.authentication import get_auth_backend_display
from netbox.config import get_config
from utilities.forms import ConfirmationForm
from .forms import LoginForm, PasswordChangeForm, TokenForm, UserConfigForm
from .models import Token
from .models import Token, UserConfig
#
@@ -69,7 +70,13 @@ class LoginView(View):
# Authenticate user
auth_login(request, form.get_user())
logger.info(f"User {request.user} successfully authenticated")
messages.info(request, "Logged in as {}.".format(request.user))
messages.info(request, f"Logged in as {request.user}.")
# Ensure the user has a UserConfig defined. (This should normally be handled by
# create_userconfig() on user creation.)
if not hasattr(request.user, 'config'):
config = get_config()
UserConfig(user=request.user, data=config.DEFAULT_USER_PREFERENCES).save()
return self.redirect_to_next(request, logger)
@@ -85,7 +92,7 @@ class LoginView(View):
data = request.POST if request.method == "POST" else request.GET
redirect_url = data.get('next', settings.LOGIN_REDIRECT_URL)
if redirect_url and redirect_url.startswith('/'):
if redirect_url and url_has_allowed_host_and_scheme(redirect_url, allowed_hosts=None):
logger.debug(f"Redirecting user to {redirect_url}")
else:
if redirect_url:

View File

@@ -1,6 +1,8 @@
from django.db import models
from timezone_field import TimeZoneField
from netbox.config import ConfigItem
SKIP_FIELDS = (
TimeZoneField,
@@ -26,4 +28,9 @@ def custom_deconstruct(field):
for attr in EXEMPT_ATTRS:
kwargs.pop(attr, None)
# Ignore any field defaults which reference a ConfigItem
kwargs = {
k: v for k, v in kwargs.items() if not isinstance(v, ConfigItem)
}
return name, path, args, kwargs

View File

@@ -1,21 +1,15 @@
{% if utilization == 0 %}
<div class="progress align-items-center justify-content-center">
<span class="w-100 text-center">{{ utilization }}%</span>
<div class="progress">
<div
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="{{ utilization }}"
class="progress-bar {{ bar_class }}"
style="width: {{ utilization }}%;"
>
{% if utilization >= 35 %}{{ utilization|floatformat:1 }}%{% endif %}
</div>
{% else %}
<div class="progress">
<div
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="{{ utilization }}"
class="progress-bar {{ bar_class }}"
style="width: {{ utilization }}%;"
>
{% if utilization >= 25 %}{{ utilization|floatformat:0 }}%{% endif %}
</div>
{% if utilization < 25 %}
<span class="ps-1">{{ utilization|floatformat:0 }}%</span>
{% endif %}
</div>
{% endif %}
{% if utilization < 35 %}
<span class="ps-1">{{ utilization|floatformat:1 }}%</span>
{% endif %}
</div>

View File

@@ -86,8 +86,8 @@ def placeholder(value):
"""
if value not in ('', None):
return value
placeholder = '<span class="text-muted">&mdash;</span>'
return mark_safe(placeholder)
return mark_safe('<span class="text-muted">&mdash;</span>')
@register.filter()
@@ -144,6 +144,8 @@ def render_markdown(value):
{{ md_source_text|markdown }}
"""
if not value:
return ''
# Render Markdown
html = markdown(value, extensions=['def_list', 'fenced_code', 'tables', StrikethroughExtension()])

View File

@@ -109,9 +109,7 @@ def annotated_date(date_value):
long_ts = date(date_value, 'DATETIME_FORMAT')
short_ts = date(date_value, 'SHORT_DATETIME_FORMAT')
span = f'<span title="{long_ts}">{short_ts}</span>'
return mark_safe(span)
return mark_safe(f'<span title="{long_ts}">{short_ts}</span>')
@register.simple_tag

View File

@@ -148,7 +148,7 @@ def serialize_object(obj, extra=None):
# Include any tags. Check for tags cached on the instance; fall back to using the manager.
if is_taggable(obj):
tags = getattr(obj, '_tags', None) or obj.tags.all()
data['tags'] = [tag.name for tag in tags]
data['tags'] = sorted([tag.name for tag in tags])
# Append any extra data
if extra is not None:

View File

@@ -1,6 +1,7 @@
import django_tables2 as tables
from netbox.tables import NetBoxTable, columns
from tenancy.tables import TenancyColumnsMixin
from virtualization.models import Cluster, ClusterGroup, ClusterType
__all__ = (
@@ -56,7 +57,7 @@ class ClusterGroupTable(NetBoxTable):
default_columns = ('pk', 'name', 'cluster_count', 'description')
class ClusterTable(NetBoxTable):
class ClusterTable(TenancyColumnsMixin, NetBoxTable):
name = tables.Column(
linkify=True
)
@@ -66,9 +67,6 @@ class ClusterTable(NetBoxTable):
group = tables.Column(
linkify=True
)
tenant = tables.Column(
linkify=True
)
site = tables.Column(
linkify=True
)
@@ -93,7 +91,7 @@ class ClusterTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Cluster
fields = (
'pk', 'id', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'device_count', 'vm_count', 'contacts',
'pk', 'id', 'name', 'type', 'group', 'tenant', 'tenant_group', 'site', 'comments', 'device_count', 'vm_count', 'contacts',
'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'type', 'group', 'tenant', 'site', 'device_count', 'vm_count')

View File

@@ -2,7 +2,7 @@ import django_tables2 as tables
from dcim.tables.devices import BaseInterfaceTable
from netbox.tables import NetBoxTable, columns
from tenancy.tables import TenantColumn
from tenancy.tables import TenancyColumnsMixin
from virtualization.models import VirtualMachine, VMInterface
__all__ = (
@@ -24,7 +24,7 @@ VMINTERFACE_BUTTONS = """
# Virtual machines
#
class VirtualMachineTable(NetBoxTable):
class VirtualMachineTable(TenancyColumnsMixin, NetBoxTable):
name = tables.Column(
order_by=('_name',),
linkify=True
@@ -34,7 +34,6 @@ class VirtualMachineTable(NetBoxTable):
linkify=True
)
role = columns.ColoredLabelColumn()
tenant = TenantColumn()
comments = columns.MarkdownColumn()
primary_ip4 = tables.Column(
linkify=True,
@@ -49,6 +48,9 @@ class VirtualMachineTable(NetBoxTable):
order_by=('primary_ip4', 'primary_ip6'),
verbose_name='IP Address'
)
contacts = columns.ManyToManyColumn(
linkify_item=True
)
tags = columns.TagColumn(
url_name='virtualization:virtualmachine_list'
)
@@ -56,8 +58,9 @@ class VirtualMachineTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = VirtualMachine
fields = (
'pk', 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk',
'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'tags', 'created', 'last_updated',
'pk', 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'tenant_group', 'platform', 'vcpus', 'memory',
'disk', 'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'contacts', 'tags', 'created',
'last_updated',
)
default_columns = (
'pk', 'name', 'status', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip',
@@ -78,9 +81,6 @@ class VMInterfaceTable(BaseInterfaceTable):
vrf = tables.Column(
linkify=True
)
contacts = columns.ManyToManyColumn(
linkify_item=True
)
tags = columns.TagColumn(
url_name='virtualization:vminterface_list'
)
@@ -89,8 +89,7 @@ class VMInterfaceTable(BaseInterfaceTable):
model = VMInterface
fields = (
'pk', 'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags',
'vrf', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'contacts', 'created',
'last_updated',
'vrf', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'description')

View File

@@ -471,6 +471,7 @@ class VMInterfaceBulkImportView(generic.BulkImportView):
class VMInterfaceBulkEditView(generic.BulkEditView):
queryset = VMInterface.objects.all()
filterset = filtersets.VMInterfaceFilterSet
table = tables.VMInterfaceTable
form = forms.VMInterfaceBulkEditForm
@@ -482,6 +483,7 @@ class VMInterfaceBulkRenameView(generic.BulkRenameView):
class VMInterfaceBulkDeleteView(generic.BulkDeleteView):
queryset = VMInterface.objects.all()
filterset = filtersets.VMInterfaceFilterSet
table = tables.VMInterfaceTable

View File

@@ -1,7 +1,7 @@
bleach==5.0.0
Django==4.0.5
bleach==5.0.1
Django==4.0.7
django-cors-headers==3.13.0
django-debug-toolbar==3.4.0
django-debug-toolbar==3.5.0
django-filter==22.1
django-graphiql-debug-toolbar==0.2.0
django-mptt==0.13.4
@@ -13,22 +13,22 @@ django-tables2==2.4.1
django-taggit==2.1.0
django-timezone-field==5.0
djangorestframework==3.13.1
drf-yasg[validation]==1.20.0
drf-yasg[validation]==1.21.3
graphene-django==2.15.0
gunicorn==20.1.0
Jinja2==3.1.2
Markdown==3.3.7
markdown-include==0.6.0
mkdocs-material==8.3.6
Markdown==3.4.1
markdown-include==0.7.0
mkdocs-material==8.3.9
mkdocstrings[python-legacy]==0.19.0
netaddr==0.8.0
Pillow==9.1.1
Pillow==9.2.0
psycopg2-binary==2.9.3
PyYAML==6.0
sentry-sdk==1.5.12
sentry-sdk==1.9.2
social-auth-app-django==5.0.0
social-auth-core==4.3.0
svgwrite==1.4.2
svgwrite==1.4.3
tablib==3.2.1
tzdata==2022.1