Compare commits

..

83 Commits

Author SHA1 Message Date
jeremystretch
f11a6f0135 Release v3.3-beta2 2022-08-03 14:41:50 -04:00
Jeremy Stretch
44850feaf8 Merge pull request #9904 from netbox-community/9888-site_device_filters
Fixes: #9888 - Add filter and columns for device and site
2022-08-03 13:31:58 -04:00
jeremystretch
367bf25618 Fixes #9778: Fix exception during cable deletion after deleting a connected termination 2022-08-03 12:46:16 -04:00
jeremystretch
b9678c7c0e Closes #9853: Show full object in cable A/B termination lists 2022-08-03 10:50:35 -04:00
Daniel Sheppard
37c4f1a7d3 Fix up a few minor mistakes. Add tests. 2022-08-02 13:38:17 -05:00
jeremystretch
d3a567a2f5 Fixes #9788: Ensure denormalized fields on CableTermination are kept in sync with related objects 2022-08-02 13:56:52 -04:00
Daniel Sheppard
5b3ef04550 #9888 - Add filter and columns for device and site 2022-08-02 12:56:10 -05:00
jeremystretch
e96620260a Closes #9903: Implement a mechanism for automatically updating denormalized fields 2022-08-02 13:49:34 -04:00
jeremystretch
29a611c729 Closes #9896: Discontinue arbitrary use of OrderedDict 2022-08-01 16:51:44 -04:00
jeremystretch
562769fb89 Merge branch 'develop' into feature 2022-08-01 14:42:09 -04:00
Jeremy Stretch
4591237bfd Merge pull request #9886 from hagbarddenstore/issue_9699
Remove deprecated usage of prefetch_related
2022-08-01 13:46:38 -04:00
jeremystretch
262a0cf397 Fixes #9789: Fix rendering of cable traces ending at provider networks 2022-08-01 13:29:39 -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
Kim Johansson
6904666e2a Remove deprecated usage of prefetch_related
Fixes #9699
2022-07-30 01:18:30 +02: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
04fb0bd51c Closes #9858: ChangeLoggedModelFilterSet cleanup 2022-07-28 15:41:10 -04:00
jeremystretch
2c43c8d077 Closes #9793: Add PoE attributes to interface templates 2022-07-28 15:20:25 -04:00
jeremystretch
c5fb7b72f0 Closes #9391: Remove 500-character limit for custom link text & URL fields 2022-07-28 14:36:20 -04: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
jeremystretch
a6be8dccf5 Fixes #9847: Respect desc_units when ordering rack units 2022-07-26 15:45:47 -04:00
jeremystretch
466931d2fb Fixes #9829: Arrange custom fields by group when editing objects 2022-07-26 12:41:51 -04:00
jeremystretch
d442f8fd60 Fixes #9843: Fix rendering of custom field values (regression from #9647) 2022-07-26 11:09:51 -04:00
jeremystretch
7631722f97 Merge branch 'temp' into feature 2022-07-26 08:36:50 -04:00
Daniel Sheppard
6d30c07dd0 Update changelog for #9844 2022-07-26 07:29:18 -05:00
Daniel Sheppard
6f7289f932 Fixes #9844 - Add dedicated device_vlan form field 2022-07-26 07:22:21 -05:00
jeremystretch
2583abc39d Fix null cable termination representation 2022-07-25 11:34:16 -04:00
jeremystretch
12476036cd Changelog for #9794, #9818 2022-07-25 09:55:20 -04:00
Jeremy Stretch
91070f823a Merge pull request #9819 from jaylik/jaylik-9818
Fixes #9818: Typo fix in CableForm
2022-07-25 09:52:42 -04:00
Jeremy Stretch
8df4966a2b Merge pull request #9796 from mzbroch/mzb-9102,4
Fixes 9794: Typo fix in REARPORT_BUTTONS template code
2022-07-25 09:50:27 -04:00
Gabor SOMOGYVARI
451a0067c7 Closes #9825: Add Contacts to VM table view 2022-07-22 10:42:20 +02:00
Juho Ylikorpi
e2580ea469 fixes #9818 2022-07-21 16:54:23 +03:00
jeremystretch
abf11fbcb8 Merge branch 'develop' into feature 2022-07-20 12:20:33 -04: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
Marek Zbroch
e92b7f8bb9 Typo fix in REARPORT_BUTTONS template code 2022-07-20 13:02:10 +02: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
Jeremy Stretch
abfa6a325a Merge pull request #9748 from mzbroch/mzb-9102
Fixes #9768: Typo fix in CableEditView
2022-07-19 10:05:14 -04:00
jeremystretch
0e18292e41 Add summary release notes for v3.3 2022-07-18 13:09:45 -04:00
jeremystretch
6d53788ea2 Changelog for #9765 2022-07-18 13:05:12 -04:00
Jeremy Stretch
fae9874dde Merge pull request #9766 from kkthxbye-code/fix-9765
Fixes #9765 - Fix "Total segment" count on trace view
2022-07-18 13:04:01 -04:00
jeremystretch
b8da66bb55 Fixes #9733: Handle split paths during trace when fanning out to front ports with differing cables 2022-07-18 11:51:59 -04:00
kkthxbye-code
8a2276e791 Use segment_count for segment count on trace view 2022-07-18 16:17:16 +02:00
Jeremy Stretch
4bdef80554 Merge pull request #9752 from mzbroch/mzb-9102#2
Fixes #9730 - Fix CableForm Validation for #9102
2022-07-18 10:03:48 -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
Marek Zbroch
58b191b439 Fix CableForm Validation for #9102 2022-07-17 09:28:58 +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
Marek Zbroch
f385a5fd5e Typo fix in CableEditView 2022-07-16 09:42:01 +02: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
jeremystretch
68b87dd668 Fixes #9729: Fix ordering of content type creation to ensure compatability with demo data 2022-07-15 13:27:20 -04:00
Henry
6da171a699 Corrected typo for description of 'snapshots' 2022-07-15 10:52:37 -04:00
jeremystretch
024e7d8651 Fixes #9728: Fix validation when assigning a virtual machine to a device 2022-07-15 10:19:56 -04:00
jeremystretch
e8dd952aa5 Fix migration progress output 2022-07-15 10:07:35 -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
112 changed files with 1442 additions and 1060 deletions

View File

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

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

@@ -1,3 +1,3 @@
## Interface Templates
A template for a network interface that will be created on all instantiations of the parent device type. Each interface may be assigned a physical or virtual type, and may be designated as "management-only."
A template for a network interface that will be created on all instantiations of the parent device type. Each interface may be assigned a physical or virtual type, and may be designated as "management-only." Power over Ethernet (PoE) mode and type may also be assigned to interface templates.

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

@@ -10,6 +10,17 @@ Minor releases are published in April, August, and December of each calendar yea
This page contains a history of all major and minor releases since NetBox v2.0. For more detail on a specific patch release, please see the release notes page for that specific minor release.
#### [Version 3.3](./version-3.3.md) (August 2022)
* Multi-object Cable Terminations ([#9102](https://github.com/netbox-community/netbox/issues/9102))
* L2VPN Modeling ([#8157](https://github.com/netbox-community/netbox/issues/8157))
* PoE Interface Attributes ([#1099](https://github.com/netbox-community/netbox/issues/1099))
* Half-Height Rack Units ([#51](https://github.com/netbox-community/netbox/issues/51))
* Restrict API Tokens by Client IP ([#8233](https://github.com/netbox-community/netbox/issues/8233))
* Reference User in Permission Constraints ([#9074](https://github.com/netbox-community/netbox/issues/9074))
* Custom Field Grouping ([#8495](https://github.com/netbox-community/netbox/issues/8495))
* Toggle Custom Field Visibility ([#9166](https://github.com/netbox-community/netbox/issues/9166))
#### [Version 3.2](./version-3.2.md) (April 2022)
* Plugins Framework Extensions ([#8333](https://github.com/netbox-community/netbox/issues/8333))

View File

@@ -1,6 +1,44 @@
# NetBox v3.2
## v3.2.7 (FUTURE)
## v3.2.8 (FUTURE)
### 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
### Bug Fixes
* [#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
---
## 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
---

View File

@@ -1,6 +1,6 @@
# NetBox v3.3
## v3.3-beta1 (2022-07-14)
## v3.3-beta2 (2022-08-03)
### Breaking Changes
@@ -93,9 +93,27 @@ Custom field UI visibility has no impact on API operation.
* [#8995](https://github.com/netbox-community/netbox/issues/8995) - Enable arbitrary ordering of REST API results
* [#9070](https://github.com/netbox-community/netbox/issues/9070) - Hide navigation menu items based on user permissions
* [#9177](https://github.com/netbox-community/netbox/issues/9177) - Add tenant assignment for wireless LANs & links
* [#9391](https://github.com/netbox-community/netbox/issues/9391) - Remove 500-character limit for custom link text & URL fields
* [#9536](https://github.com/netbox-community/netbox/issues/9536) - Track API token usage times
* [#9582](https://github.com/netbox-community/netbox/issues/9582) - Enable assigning config contexts based on device location
### Bug Fixes (from Beta1)
* [#9728](https://github.com/netbox-community/netbox/issues/9728) - Fix validation when assigning a virtual machine to a device
* [#9729](https://github.com/netbox-community/netbox/issues/9729) - Fix ordering of content type creation to ensure compatability with demo data
* [#9730](https://github.com/netbox-community/netbox/issues/9730) - Fix validation error when creating a new cable via UI form
* [#9733](https://github.com/netbox-community/netbox/issues/9733) - Handle split paths during trace when fanning out to front ports with differing cables
* [#9765](https://github.com/netbox-community/netbox/issues/9765) - Report correct segment count under cable trace UI view
* [#9778](https://github.com/netbox-community/netbox/issues/9778) - Fix exception during cable deletion after deleting a connected termination
* [#9788](https://github.com/netbox-community/netbox/issues/9788) - Ensure denormalized fields on CableTermination are kept in sync with related objects
* [#9789](https://github.com/netbox-community/netbox/issues/9789) - Fix rendering of cable traces ending at provider networks
* [#9794](https://github.com/netbox-community/netbox/issues/9794) - Fix link to connect a rear port to a circuit termination
* [#9818](https://github.com/netbox-community/netbox/issues/9818) - Fix circuit side selection when connecting a cable to a circuit termination
* [#9829](https://github.com/netbox-community/netbox/issues/9829) - Arrange custom fields by group when editing objects
* [#9843](https://github.com/netbox-community/netbox/issues/9843) - Fix rendering of custom field values (regression from #9647)
* [#9844](https://github.com/netbox-community/netbox/issues/9844) - Fix interface api request when creating/editing L2VPN termination
* [#9847](https://github.com/netbox-community/netbox/issues/9847) - Respect `desc_units` when ordering rack units
### Plugins API
* [#9075](https://github.com/netbox-community/netbox/issues/9075) - Introduce `AbortRequest` exception for cleanly interrupting object mutations
@@ -108,6 +126,7 @@ Custom field UI visibility has no impact on API operation.
* [#9261](https://github.com/netbox-community/netbox/issues/9261) - `NetBoxTable` no longer automatically clears pre-existing calls to `prefetch_related()` on its queryset
* [#9434](https://github.com/netbox-community/netbox/issues/9434) - Enabled `django-rich` test runner for more user-friendly output
* [#9903](https://github.com/netbox-community/netbox/issues/9903) - Implement a mechanism for automatically updating denormalized fields
### REST API Changes
@@ -162,6 +181,8 @@ Custom field UI visibility has no impact on API operation.
* `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable`
* Added the optional `poe_mode` and `poe_type` fields
* Added the `l2vpn_termination` read-only field
* dcim.InterfaceTemplate
* Added the optional `poe_mode` and `poe_type` fields
* dcim.Location
* Added required `status` field (default value: `active`)
* dcim.PowerOutlet

View File

@@ -30,7 +30,8 @@ class ProviderView(generic.ObjectView):
circuits = Circuit.objects.restrict(request.user, 'view').filter(
provider=instance
).prefetch_related(
'type', 'tenant', 'tenant__group', 'terminations__site'
'tenant__group', 'termination_a__site', 'termination_z__site',
'termination_a__provider_network', 'termination_z__provider_network',
)
circuits_table = tables.CircuitTable(circuits, user=request.user, exclude=('provider',))
circuits_table.configure(request)
@@ -91,7 +92,8 @@ class ProviderNetworkView(generic.ObjectView):
Q(termination_a__provider_network=instance.pk) |
Q(termination_z__provider_network=instance.pk)
).prefetch_related(
'type', 'tenant', 'tenant__group', 'terminations__site'
'tenant__group', 'termination_a__site', 'termination_z__site',
'termination_a__provider_network', 'termination_z__provider_network',
)
circuits_table = tables.CircuitTable(circuits, user=request.user)
circuits_table.configure(request)
@@ -192,7 +194,8 @@ class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
class CircuitListView(generic.ObjectListView):
queryset = Circuit.objects.prefetch_related(
'provider', 'type', 'tenant', 'tenant__group', 'termination_a', 'termination_z'
'tenant__group', 'termination_a__site', 'termination_z__site',
'termination_a__provider_network', 'termination_z__provider_network',
)
filterset = filtersets.CircuitFilterSet
filterset_form = forms.CircuitFilterForm
@@ -220,7 +223,8 @@ class CircuitBulkImportView(generic.BulkImportView):
class CircuitBulkEditView(generic.BulkEditView):
queryset = Circuit.objects.prefetch_related(
'provider', 'type', 'tenant', 'terminations'
'termination_a__site', 'termination_z__site',
'termination_a__provider_network', 'termination_z__provider_network',
)
filterset = filtersets.CircuitFilterSet
table = tables.CircuitTable
@@ -229,7 +233,8 @@ class CircuitBulkEditView(generic.BulkEditView):
class CircuitBulkDeleteView(generic.BulkDeleteView):
queryset = Circuit.objects.prefetch_related(
'provider', 'type', 'tenant', 'terminations'
'termination_a__site', 'termination_z__site',
'termination_a__provider_network', 'termination_z__provider_network',
)
filterset = filtersets.CircuitFilterSet
table = tables.CircuitTable

View File

@@ -19,6 +19,7 @@ from netbox.api.serializers import (
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
@@ -57,7 +58,7 @@ class CabledObjectSerializer(serializers.ModelSerializer):
return []
# Return serialized peer termination objects
serializer = get_serializer_for_model(obj.link_peers[0], prefix='Nested')
serializer = get_serializer_for_model(obj.link_peers[0], prefix=NESTED_SERIALIZER_PREFIX)
context = {'request': self.context['request']}
return serializer(obj.link_peers, context=context, many=True).data
@@ -84,7 +85,7 @@ class ConnectedEndpointsSerializer(serializers.ModelSerializer):
Return the appropriate serializer for the type of connected object.
"""
if endpoints := obj.connected_endpoints:
serializer = get_serializer_for_model(endpoints[0], prefix='Nested')
serializer = get_serializer_for_model(endpoints[0], prefix=NESTED_SERIALIZER_PREFIX)
context = {'request': self.context['request']}
return serializer(endpoints, many=True, context=context).data
@@ -468,12 +469,22 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer):
default=None
)
type = ChoiceField(choices=InterfaceTypeChoices)
poe_mode = ChoiceField(
choices=InterfacePoEModeChoices,
required=False,
allow_blank=True
)
poe_type = ChoiceField(
choices=InterfacePoETypeChoices,
required=False,
allow_blank=True
)
class Meta:
model = InterfaceTemplate
fields = [
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description',
'created', 'last_updated',
'poe_mode', 'poe_type', 'created', 'last_updated',
]
@@ -572,7 +583,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
@@ -968,7 +979,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
@@ -1037,7 +1048,7 @@ class CableTerminationSerializer(NetBoxModelSerializer):
@swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_termination(self, obj):
serializer = get_serializer_for_model(obj.termination, prefix='Nested')
serializer = get_serializer_for_model(obj.termination, prefix=NESTED_SERIALIZER_PREFIX)
context = {'request': self.context['request']}
return serializer(obj.termination, context=context).data
@@ -1053,7 +1064,7 @@ class CablePathSerializer(serializers.ModelSerializer):
def get_path(self, obj):
ret = []
for nodes in obj.path_objects:
serializer = get_serializer_for_model(nodes[0], prefix='Nested')
serializer = get_serializer_for_model(nodes[0], prefix=NESTED_SERIALIZER_PREFIX)
context = {'request': self.context['request']}
ret.append(serializer(nodes, context=context, many=True).data)
return ret

View File

@@ -1,5 +1,4 @@
import socket
from collections import OrderedDict
from django.http import Http404, HttpResponse, HttpResponseForbidden
from django.shortcuts import get_object_or_404
@@ -24,6 +23,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
@@ -63,20 +63,20 @@ class PathEndpointMixin(object):
return HttpResponse(drawing.render().tostring(), content_type='image/svg+xml')
# Serialize path objects, iterating over each three-tuple in the path
for near_end, cable, far_end in obj.trace():
if near_end is not None:
serializer_a = get_serializer_for_model(near_end[0], prefix='Nested')
near_end = serializer_a(near_end, many=True, context={'request': request}).data
for near_ends, cable, far_ends in obj.trace():
if near_ends:
serializer_a = get_serializer_for_model(near_ends[0], prefix=NESTED_SERIALIZER_PREFIX)
near_ends = serializer_a(near_ends, many=True, context={'request': request}).data
else:
# Path is split; stop here
break
if cable is not None:
if cable:
cable = serializers.TracedCableSerializer(cable[0], context={'request': request}).data
if far_end is not None:
serializer_b = get_serializer_for_model(far_end[0], prefix='Nested')
far_end = serializer_b(far_end, many=True, context={'request': request}).data
if far_ends:
serializer_b = get_serializer_for_model(far_ends[0], prefix=NESTED_SERIALIZER_PREFIX)
far_ends = serializer_b(far_ends, many=True, context={'request': request}).data
path.append((near_end, cable, far_end))
path.append((near_ends, cable, far_ends))
return Response(path)
@@ -483,7 +483,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet):
return HttpResponseForbidden()
napalm_methods = request.GET.getlist('method')
response = OrderedDict([(m, None) for m in napalm_methods])
response = {m: None for m in napalm_methods}
config = get_config()
username = config.NAPALM_USERNAME

View File

@@ -1,10 +1,26 @@
from django.apps import AppConfig
from netbox import denormalized
class DCIMConfig(AppConfig):
name = "dcim"
verbose_name = "DCIM"
def ready(self):
import dcim.signals
from .models import CableTermination
# Register denormalized fields
denormalized.register(CableTermination, '_device', {
'_rack': 'rack',
'_location': 'location',
'_site': 'site',
})
denormalized.register(CableTermination, '_rack', {
'_location': 'location',
'_site': 'site',
})
denormalized.register(CableTermination, '_location', {
'_site': 'site',
})

View File

@@ -312,7 +312,7 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe
to_field_name='slug',
label='Role (slug)',
)
serial = django_filters.CharFilter(
serial = MultiValueCharFilter(
lookup_expr='iexact'
)
@@ -652,6 +652,12 @@ class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
choices=InterfaceTypeChoices,
null_value=None
)
poe_mode = django_filters.MultipleChoiceFilter(
choices=InterfacePoEModeChoices
)
poe_type = django_filters.MultipleChoiceFilter(
choices=InterfacePoETypeChoices
)
class Meta:
model = InterfaceTemplate
@@ -1007,10 +1013,13 @@ class ModuleFilterSet(NetBoxModelFilterSet):
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():
@@ -1411,7 +1420,7 @@ class InventoryItemFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet):
)
component_type = ContentTypeFilter()
component_id = MultiValueNumberFilter()
serial = django_filters.CharFilter(
serial = MultiValueCharFilter(
lookup_expr='iexact'
)

View File

@@ -818,8 +818,22 @@ class InterfaceTemplateBulkEditForm(BulkEditForm):
description = forms.CharField(
required=False
)
poe_mode = forms.ChoiceField(
choices=add_blank_choice(InterfacePoEModeChoices),
required=False,
initial='',
widget=StaticSelect(),
label='PoE mode'
)
poe_type = forms.ChoiceField(
choices=add_blank_choice(InterfacePoETypeChoices),
required=False,
initial='',
widget=StaticSelect(),
label='PoE type'
)
nullable_fields = ('label', 'description')
nullable_fields = ('label', 'description', 'poe_mode', 'poe_type')
class FrontPortTemplateBulkEditForm(BulkEditForm):

View File

@@ -138,7 +138,7 @@ def get_cable_form(a_type, b_type):
label='Side',
disabled_indicator='_occupied',
query_params={
'circuit_id': f'termination_{cable_end}_circuit',
'circuit_id': f'$termination_{cable_end}_circuit',
}
)
@@ -160,12 +160,11 @@ def get_cable_form(a_type, b_type):
self.initial['a_terminations'] = self.instance.a_terminations
self.initial['b_terminations'] = self.instance.b_terminations
def save(self, *args, **kwargs):
def clean(self):
super().clean()
# Set the A/B terminations on the Cable instance
self.instance.a_terminations = self.cleaned_data['a_terminations']
self.instance.b_terminations = self.cleaned_data['b_terminations']
return super().save(*args, **kwargs)
return _CableForm

View File

@@ -291,7 +291,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(
@@ -299,25 +299,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,
@@ -998,8 +1011,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,
@@ -1027,11 +1040,13 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
)
poe_mode = MultipleChoiceField(
choices=InterfacePoEModeChoices,
required=False
required=False,
label='PoE mode'
)
poe_type = MultipleChoiceField(
choices=InterfacePoEModeChoices,
required=False
required=False,
label='PoE type'
)
rf_role = MultipleChoiceField(
choices=WirelessRoleChoices,

View File

@@ -325,7 +325,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')),
)
@@ -1052,12 +1052,14 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = InterfaceTemplate
fields = [
'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description',
'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description', 'poe_mode', 'poe_type',
]
widgets = {
'device_type': forms.HiddenInput(),
'module_type': forms.HiddenInput(),
'type': StaticSelect(),
'poe_mode': StaticSelect(),
'poe_type': StaticSelect(),
}

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

@@ -1,6 +1,6 @@
from django import forms
from dcim.choices import InterfaceTypeChoices, PortTypeChoices
from dcim.choices import InterfacePoEModeChoices, InterfacePoETypeChoices, InterfaceTypeChoices, PortTypeChoices
from dcim.models import *
from utilities.forms import BootstrapMixin
@@ -112,11 +112,21 @@ class InterfaceTemplateImportForm(ComponentTemplateImportForm):
type = forms.ChoiceField(
choices=InterfaceTypeChoices.CHOICES
)
poe_mode = forms.ChoiceField(
choices=InterfacePoEModeChoices,
required=False,
label='PoE mode'
)
poe_type = forms.ChoiceField(
choices=InterfacePoETypeChoices,
required=False,
label='PoE type'
)
class Meta:
model = InterfaceTemplate
fields = [
'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description',
'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description', 'poe_mode', 'poe_type',
]

View File

@@ -258,6 +258,12 @@ class InterfaceTemplateType(ComponentTemplateObjectType):
fields = '__all__'
filterset_class = filtersets.InterfaceTemplateFilterSet
def resolve_poe_mode(self, info):
return self.poe_mode or None
def resolve_poe_type(self, info):
return self.poe_type or None
class InventoryItemType(ComponentObjectType):

View File

@@ -20,4 +20,14 @@ class Migration(migrations.Migration):
name='poe_type',
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='interfacetemplate',
name='poe_mode',
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='interfacetemplate',
name='poe_type',
field=models.CharField(blank=True, max_length=50),
),
]

View File

@@ -64,6 +64,8 @@ def populate_cable_terminations(apps, schema_editor):
# Output progress occasionally
if 'test' not in sys.argv and not i % 100:
progress = float(i) * 100 / cable_count
if i == 100:
print('')
sys.stdout.write(f"\r Updated {i}/{cable_count} cables ({progress:.2f}%)")
sys.stdout.flush()

View File

@@ -3,7 +3,6 @@ from django.db import migrations
def populate_cable_terminations(apps, schema_editor):
Cable = apps.get_model('dcim', 'Cable')
ContentType = apps.get_model('contenttypes', 'ContentType')
cable_termination_models = (
apps.get_model('dcim', 'ConsolePort'),
@@ -18,12 +17,17 @@ def populate_cable_terminations(apps, schema_editor):
)
for model in cable_termination_models:
ct = ContentType.objects.get_for_model(model)
model.objects.filter(
id__in=Cable.objects.filter(termination_a_type=ct).values_list('termination_a_id', flat=True)
id__in=Cable.objects.filter(
termination_a_type__app_label=model._meta.app_label,
termination_a_type__model=model._meta.model_name
).values_list('termination_a_id', flat=True)
).update(cable_end='A')
model.objects.filter(
id__in=Cable.objects.filter(termination_b_type=ct).values_list('termination_b_id', flat=True)
id__in=Cable.objects.filter(
termination_b_type__app_label=model._meta.app_label,
termination_b_type__model=model._meta.model_name
).values_list('termination_b_id', flat=True)
).update(cable_end='B')

View File

@@ -431,11 +431,7 @@ class CablePath(models.Model):
"""
Return the list of originating objects.
"""
if hasattr(self, '_path_objects'):
return self.path_objects[0]
return [
path_node_to_object(node) for node in self.path[0]
]
return self.path_objects[0]
@property
def destinations(self):
@@ -444,11 +440,7 @@ class CablePath(models.Model):
"""
if not self.is_complete:
return []
if hasattr(self, '_path_objects'):
return self.path_objects[-1]
return [
path_node_to_object(node) for node in self.path[-1]
]
return self.path_objects[-1]
@property
def segment_count(self):
@@ -463,6 +455,13 @@ class CablePath(models.Model):
"""
from circuits.models import CircuitTermination
if not terminations:
return None
# Ensure all originating terminations are attached to the same link
if len(terminations) > 1:
assert all(t.link == terminations[0].link for t in terminations[1:])
path = []
position_stack = []
is_complete = False
@@ -474,6 +473,12 @@ class CablePath(models.Model):
# Terminations must all be of the same type
assert all(isinstance(t, type(terminations[0])) for t in terminations[1:])
# Check for a split path (e.g. rear port fanning out to multiple front ports with
# different cables attached)
if len(set(t.link for t in terminations)) > 1:
is_split = True
break
# Step 1: Record the near-end termination object(s)
path.append([
object_to_path_node(t) for t in terminations
@@ -481,7 +486,6 @@ class CablePath(models.Model):
# Step 2: Determine the attached link (Cable or WirelessLink), if any
link = terminations[0].link
assert all(t.link == link for t in terminations[1:])
if link is None and len(path) == 1:
# If this is the start of the path and no link exists, return None
return None
@@ -520,6 +524,9 @@ class CablePath(models.Model):
])
# Step 6: Determine the "next hop" terminations, if applicable
if not remote_terminations:
break
if isinstance(remote_terminations[0], FrontPort):
# Follow FrontPorts to their corresponding RearPorts
rear_ports = RearPort.objects.filter(
@@ -631,7 +638,11 @@ class CablePath(models.Model):
nodes = []
for node in step:
ct_id, object_id = decompile_path_node(node)
nodes.append(prefetched[ct_id][object_id])
try:
nodes.append(prefetched[ct_id][object_id])
except KeyError:
# Ignore stale (deleted) object IDs
pass
path.append(nodes)
return path

View File

@@ -1,6 +1,6 @@
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from mptt.models import MPTTModel, TreeForeignKey
@@ -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):
"""
@@ -318,6 +357,18 @@ class InterfaceTemplate(ModularComponentTemplateModel):
default=False,
verbose_name='Management only'
)
poe_mode = models.CharField(
max_length=50,
choices=InterfacePoEModeChoices,
blank=True,
verbose_name='PoE mode'
)
poe_type = models.CharField(
max_length=50,
choices=InterfacePoETypeChoices,
blank=True,
verbose_name='PoE type'
)
component_model = Interface
@@ -334,9 +385,22 @@ class InterfaceTemplate(ModularComponentTemplateModel):
label=self.resolve_label(kwargs.get('module')),
type=self.type,
mgmt_only=self.mgmt_only,
poe_mode=self.poe_mode,
poe_type=self.poe_type,
**kwargs
)
def to_yaml(self):
return {
'name': self.name,
'type': self.type,
'mgmt_only': self.mgmt_only,
'label': self.label,
'description': self.description,
'poe_mode': self.poe_mode,
'poe_type': self.poe_type,
}
class FrontPortTemplate(ModularComponentTemplateModel):
"""
@@ -410,6 +474,16 @@ class FrontPortTemplate(ModularComponentTemplateModel):
**kwargs
)
def to_yaml(self):
return {
'name': self.name,
'type': self.type,
'rear_port': self.rear_port.name,
'rear_port_position': self.rear_port_position,
'label': self.label,
'description': self.description,
}
class RearPortTemplate(ModularComponentTemplateModel):
"""
@@ -449,6 +523,15 @@ class RearPortTemplate(ModularComponentTemplateModel):
**kwargs
)
def to_yaml(self):
return {
'name': self.name,
'type': self.type,
'positions': self.positions,
'label': self.label,
'description': self.description,
}
class ModuleBayTemplate(ComponentTemplateModel):
"""
@@ -474,6 +557,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 +589,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

@@ -212,10 +212,13 @@ class PathEndpoint(models.Model):
break
path.extend(origin._path.path_objects)
while (len(path)) % 3:
# Pad to ensure we have complete three-tuples (e.g. for paths that end at a non-connected FrontPort)
# by inserting empty entries immediately prior to the path's destination node(s)
path.append([])
# If the path ends at a non-connected pass-through port, pad out the link and far-end terminations
if len(path) % 3 == 1:
path.extend(([], []))
# If the path ends at a site or provider network, inject a null "link" to render an attachment
elif len(path) % 3 == 2:
path.insert(-1, [])
# Check for a bridged relationship to continue the trace
destinations = origin._path.destinations

View File

@@ -1,5 +1,4 @@
import decimal
from collections import OrderedDict
import yaml
from django.contrib.contenttypes.fields import GenericRelation
@@ -164,115 +163,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', float(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': float(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)
@@ -404,91 +342,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

@@ -244,10 +244,9 @@ class Rack(NetBoxModel):
"""
Return a list of unit numbers, top to bottom.
"""
max_position = self.u_height + decimal.Decimal(0.5)
if self.desc_units:
drange(0.5, max_position, 0.5)
return drange(max_position, 0.5, -0.5)
return drange(decimal.Decimal(1.0), self.u_height + 1, 0.5)
return drange(self.u_height + decimal.Decimal(0.5), 0.5, -0.5)
def get_status_color(self):
return RackStatusChoices.colors.get(self.status)

View File

@@ -116,7 +116,10 @@ def retrace_cable_paths(instance, **kwargs):
@receiver(post_delete, sender=CableTermination)
def nullify_connected_endpoints(instance, **kwargs):
"""
Disassociate the Cable from the termination object.
Disassociate the Cable from the termination object, and retrace any affected CablePaths.
"""
model = instance.termination_type.model_class()
model.objects.filter(pk=instance.termination_id).update(cable=None, cable_end='')
for cablepath in CablePath.objects.filter(_nodes__contains=instance.cable):
cablepath.retrace()

View File

@@ -362,21 +362,26 @@ class CableTraceSVG:
terminations = self.draw_terminations(far_ends)
for term in terminations:
self.draw_fanout(term, cable)
else:
elif far_ends:
self.draw_terminations(far_ends)
else:
# Link is not connected to anything
break
# Far end parent
parent_objects = set(end.parent_object for end in far_ends)
self.draw_parent_objects(parent_objects)
# Render a far-end object not connected via a link (e.g. a ProviderNetwork or Site associated with
# a CircuitTermination)
elif far_ends:
# Attachment
attachment = self.draw_attachment()
self.connectors.append(attachment)
# ProviderNetwork
self.draw_parent_objects(set(end.parent_object for end in far_ends))
# Object
self.draw_parent_objects(far_ends)
# Determine drawing size
self.drawing = svgwrite.Drawing(

View File

@@ -163,8 +163,9 @@ class RackElevationSVG:
# Embed device type image if provided
if self.include_images and image:
url = f'{self.base_url}{image.url}' if image.url.startswith('/') else image.url
image = Image(
href=f'{self.base_url}{image.url}',
href=url,
insert=coords,
size=size,
class_=f'device-image{css_extra}'

View File

@@ -172,7 +172,7 @@ class InterfaceTemplateTable(ComponentTemplateTable):
class Meta(ComponentTemplateTable.Meta):
model = InterfaceTemplate
fields = ('pk', 'name', 'label', 'mgmt_only', 'type', 'description', 'actions')
fields = ('pk', 'name', 'label', 'mgmt_only', 'type', 'description', 'poe_mode', 'poe_type', 'actions')
empty_text = "None"

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

@@ -109,6 +109,10 @@ class RackReservationTable(TenancyColumnsMixin, NetBoxTable):
accessor=Accessor('rack__site'),
linkify=True
)
location = tables.Column(
accessor=Accessor('rack__location'),
linkify=True
)
rack = tables.Column(
linkify=True
)
@@ -123,7 +127,7 @@ class RackReservationTable(TenancyColumnsMixin, NetBoxTable):
class Meta(NetBoxTable.Meta):
model = RackReservation
fields = (
'pk', 'id', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'tenant_group', '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

@@ -343,7 +343,7 @@ REARPORT_BUTTONS = """
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ record.pk }}&b_terminations_type=dcim.consoleport&return_url={% url 'dcim:device_rearports' pk=object.pk %}">Console Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ record.pk }}&b_terminations_type=dcim.frontport&return_url={% url 'dcim:device_rearports' pk=object.pk %}">Front Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ record.pk }}&b_terminations_type=dcim.rearport&return_url={% url 'dcim:device_rearports' pk=object.pk %}">Rear Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ record.pk }}&b_terminations_type=circuits.circuitterminations&return_url={% url 'dcim:device_rearports' pk=object.pk %}">Circuit Termination</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ record.pk }}&b_terminations_type=circuits.circuittermination&return_url={% url 'dcim:device_rearports' pk=object.pk %}">Circuit Termination</a></li>
</ul>
</span>
{% else %}

View File

@@ -498,10 +498,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]
@@ -1089,8 +1089,8 @@ class InterfaceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
DeviceType.objects.bulk_create(device_types)
InterfaceTemplate.objects.bulk_create((
InterfaceTemplate(device_type=device_types[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED, mgmt_only=True),
InterfaceTemplate(device_type=device_types[1], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_GBIC, mgmt_only=False),
InterfaceTemplate(device_type=device_types[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED, mgmt_only=True, poe_mode=InterfacePoEModeChoices.MODE_PD, poe_type=InterfacePoETypeChoices.TYPE_1_8023AF),
InterfaceTemplate(device_type=device_types[1], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_GBIC, mgmt_only=False, poe_mode=InterfacePoEModeChoices.MODE_PSE, poe_type=InterfacePoETypeChoices.TYPE_2_8023AT),
InterfaceTemplate(device_type=device_types[2], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_SFP, mgmt_only=False),
))
@@ -1113,6 +1113,14 @@ class InterfaceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'mgmt_only': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_poe_mode(self):
params = {'poe_mode': [InterfacePoEModeChoices.MODE_PD, InterfacePoEModeChoices.MODE_PSE]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_poe_type(self):
params = {'poe_type': [InterfacePoETypeChoices.TYPE_1_8023AF, InterfacePoETypeChoices.TYPE_2_8023AT]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class FrontPortTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = FrontPortTemplate.objects.all()
@@ -1864,7 +1872,9 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
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):
@@ -3520,10 +3530,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

@@ -163,8 +163,8 @@ class RackTestCase(TestCase):
}
self.assertEqual(rack1_inventory_front[10.0]['device'], device1)
self.assertEqual(rack1_inventory_front[10.5]['device'], device1)
del(rack1_inventory_front[10.0])
del(rack1_inventory_front[10.5])
del rack1_inventory_front[10.0]
del rack1_inventory_front[10.5]
for u in rack1_inventory_front.values():
self.assertIsNone(u['device'])
@@ -174,8 +174,8 @@ class RackTestCase(TestCase):
}
self.assertEqual(rack1_inventory_rear[10.0]['device'], device1)
self.assertEqual(rack1_inventory_rear[10.5]['device'], device1)
del(rack1_inventory_rear[10.0])
del(rack1_inventory_rear[10.5])
del rack1_inventory_rear[10.0]
del rack1_inventory_rear[10.5]
for u in rack1_inventory_rear.values():
self.assertIsNone(u['device'])

View File

@@ -24,11 +24,12 @@ def object_to_path_node(obj):
def path_node_to_object(repr):
"""
Given the string representation of a path node, return the corresponding instance.
Given the string representation of a path node, return the corresponding instance. If the object no longer
exists, return None.
"""
ct_id, object_id = decompile_path_node(repr)
ct = ContentType.objects.get_for_id(ct_id)
return ct.model_class().objects.get(pk=object_id)
return ct.model_class().objects.filter(pk=object_id).first()
def create_cablepath(terminations):

View File

@@ -1,5 +1,3 @@
from collections import OrderedDict
from django.contrib import messages
from django.contrib.contenttypes.models import ContentType
from django.core.paginator import EmptyPage, PageNotAnInteger
@@ -324,7 +322,7 @@ class SiteListView(generic.ObjectListView):
class SiteView(generic.ObjectView):
queryset = Site.objects.prefetch_related('region', 'tenant__group')
queryset = Site.objects.prefetch_related('tenant__group')
def get_extra_context(self, request, instance):
stats = {
@@ -359,7 +357,7 @@ class SiteView(generic.ObjectView):
site=instance,
position__isnull=True,
parent_bay__isnull=True
).prefetch_related('device_type__manufacturer')
).prefetch_related('device_type__manufacturer', 'parent_bay', 'device_role')
asns = ASN.objects.restrict(request.user, 'view').filter(sites=instance)
asn_count = asns.count()
@@ -391,14 +389,14 @@ class SiteBulkImportView(generic.BulkImportView):
class SiteBulkEditView(generic.BulkEditView):
queryset = Site.objects.prefetch_related('region', 'tenant')
queryset = Site.objects.all()
filterset = filtersets.SiteFilterSet
table = tables.SiteTable
form = forms.SiteBulkEditForm
class SiteBulkDeleteView(generic.BulkDeleteView):
queryset = Site.objects.prefetch_related('region', 'tenant')
queryset = Site.objects.all()
filterset = filtersets.SiteFilterSet
table = tables.SiteTable
@@ -454,7 +452,7 @@ class LocationView(generic.ObjectView):
location=instance,
position__isnull=True,
parent_bay__isnull=True
).prefetch_related('device_type__manufacturer')
).prefetch_related('device_type__manufacturer', 'parent_bay', 'device_role')
return {
'rack_count': rack_count,
@@ -572,7 +570,7 @@ class RackRoleBulkDeleteView(generic.BulkDeleteView):
#
class RackListView(generic.ObjectListView):
queryset = Rack.objects.prefetch_related('devices__device_type').annotate(
queryset = Rack.objects.annotate(
device_count=count_related(Device, 'rack')
)
filterset = filtersets.RackFilterSet
@@ -631,7 +629,7 @@ class RackView(generic.ObjectView):
rack=instance,
position__isnull=True,
parent_bay__isnull=True
).prefetch_related('device_type__manufacturer')
).prefetch_related('device_type__manufacturer', 'parent_bay', 'device_role')
peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=instance.site)
@@ -682,14 +680,14 @@ class RackBulkImportView(generic.BulkImportView):
class RackBulkEditView(generic.BulkEditView):
queryset = Rack.objects.prefetch_related('site', 'location', 'tenant', 'role')
queryset = Rack.objects.all()
filterset = filtersets.RackFilterSet
table = tables.RackTable
form = forms.RackBulkEditForm
class RackBulkDeleteView(generic.BulkDeleteView):
queryset = Rack.objects.prefetch_related('site', 'location', 'tenant', 'role')
queryset = Rack.objects.all()
filterset = filtersets.RackFilterSet
table = tables.RackTable
@@ -706,7 +704,7 @@ class RackReservationListView(generic.ObjectListView):
class RackReservationView(generic.ObjectView):
queryset = RackReservation.objects.prefetch_related('rack')
queryset = RackReservation.objects.all()
class RackReservationEditView(generic.ObjectEditView):
@@ -742,14 +740,14 @@ class RackReservationImportView(generic.BulkImportView):
class RackReservationBulkEditView(generic.BulkEditView):
queryset = RackReservation.objects.prefetch_related('rack', 'user')
queryset = RackReservation.objects.all()
filterset = filtersets.RackReservationFilterSet
table = tables.RackReservationTable
form = forms.RackReservationBulkEditForm
class RackReservationBulkDeleteView(generic.BulkDeleteView):
queryset = RackReservation.objects.prefetch_related('rack', 'user')
queryset = RackReservation.objects.all()
filterset = filtersets.RackReservationFilterSet
table = tables.RackReservationTable
@@ -831,7 +829,7 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView):
#
class DeviceTypeListView(generic.ObjectListView):
queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
queryset = DeviceType.objects.annotate(
instance_count=count_related(Device, 'device_type')
)
filterset = filtersets.DeviceTypeFilterSet
@@ -840,7 +838,7 @@ class DeviceTypeListView(generic.ObjectListView):
class DeviceTypeView(generic.ObjectView):
queryset = DeviceType.objects.prefetch_related('manufacturer')
queryset = DeviceType.objects.all()
def get_extra_context(self, request, instance):
instance_count = Device.objects.restrict(request.user).filter(device_type=instance).count()
@@ -945,18 +943,18 @@ class DeviceTypeImportView(generic.ObjectImportView):
]
queryset = DeviceType.objects.all()
model_form = forms.DeviceTypeImportForm
related_object_forms = OrderedDict((
('console-ports', forms.ConsolePortTemplateImportForm),
('console-server-ports', forms.ConsoleServerPortTemplateImportForm),
('power-ports', forms.PowerPortTemplateImportForm),
('power-outlets', forms.PowerOutletTemplateImportForm),
('interfaces', forms.InterfaceTemplateImportForm),
('rear-ports', forms.RearPortTemplateImportForm),
('front-ports', forms.FrontPortTemplateImportForm),
('module-bays', forms.ModuleBayTemplateImportForm),
('device-bays', forms.DeviceBayTemplateImportForm),
('inventory-items', forms.InventoryItemTemplateImportForm),
))
related_object_forms = {
'console-ports': forms.ConsolePortTemplateImportForm,
'console-server-ports': forms.ConsoleServerPortTemplateImportForm,
'power-ports': forms.PowerPortTemplateImportForm,
'power-outlets': forms.PowerOutletTemplateImportForm,
'interfaces': forms.InterfaceTemplateImportForm,
'rear-ports': forms.RearPortTemplateImportForm,
'front-ports': forms.FrontPortTemplateImportForm,
'module-bays': forms.ModuleBayTemplateImportForm,
'device-bays': forms.DeviceBayTemplateImportForm,
'inventory-items': forms.InventoryItemTemplateImportForm,
}
def prep_related_object_data(self, parent, data):
data.update({'device_type': parent})
@@ -964,7 +962,7 @@ class DeviceTypeImportView(generic.ObjectImportView):
class DeviceTypeBulkEditView(generic.BulkEditView):
queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
queryset = DeviceType.objects.annotate(
instance_count=count_related(Device, 'device_type')
)
filterset = filtersets.DeviceTypeFilterSet
@@ -973,7 +971,7 @@ class DeviceTypeBulkEditView(generic.BulkEditView):
class DeviceTypeBulkDeleteView(generic.BulkDeleteView):
queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
queryset = DeviceType.objects.annotate(
instance_count=count_related(Device, 'device_type')
)
filterset = filtersets.DeviceTypeFilterSet
@@ -985,7 +983,7 @@ class DeviceTypeBulkDeleteView(generic.BulkDeleteView):
#
class ModuleTypeListView(generic.ObjectListView):
queryset = ModuleType.objects.prefetch_related('manufacturer').annotate(
queryset = ModuleType.objects.annotate(
instance_count=count_related(Module, 'module_type')
)
filterset = filtersets.ModuleTypeFilterSet
@@ -994,7 +992,7 @@ class ModuleTypeListView(generic.ObjectListView):
class ModuleTypeView(generic.ObjectView):
queryset = ModuleType.objects.prefetch_related('manufacturer')
queryset = ModuleType.objects.all()
def get_extra_context(self, request, instance):
instance_count = Module.objects.restrict(request.user).filter(module_type=instance).count()
@@ -1075,15 +1073,15 @@ class ModuleTypeImportView(generic.ObjectImportView):
]
queryset = ModuleType.objects.all()
model_form = forms.ModuleTypeImportForm
related_object_forms = OrderedDict((
('console-ports', forms.ConsolePortTemplateImportForm),
('console-server-ports', forms.ConsoleServerPortTemplateImportForm),
('power-ports', forms.PowerPortTemplateImportForm),
('power-outlets', forms.PowerOutletTemplateImportForm),
('interfaces', forms.InterfaceTemplateImportForm),
('rear-ports', forms.RearPortTemplateImportForm),
('front-ports', forms.FrontPortTemplateImportForm),
))
related_object_forms = {
'console-ports': forms.ConsolePortTemplateImportForm,
'console-server-ports': forms.ConsoleServerPortTemplateImportForm,
'power-ports': forms.PowerPortTemplateImportForm,
'power-outlets': forms.PowerOutletTemplateImportForm,
'interfaces': forms.InterfaceTemplateImportForm,
'rear-ports': forms.RearPortTemplateImportForm,
'front-ports': forms.FrontPortTemplateImportForm,
}
def prep_related_object_data(self, parent, data):
data.update({'module_type': parent})
@@ -1091,7 +1089,7 @@ class ModuleTypeImportView(generic.ObjectImportView):
class ModuleTypeBulkEditView(generic.BulkEditView):
queryset = ModuleType.objects.prefetch_related('manufacturer').annotate(
queryset = ModuleType.objects.annotate(
instance_count=count_related(Module, 'module_type')
)
filterset = filtersets.ModuleTypeFilterSet
@@ -1100,7 +1098,7 @@ class ModuleTypeBulkEditView(generic.BulkEditView):
class ModuleTypeBulkDeleteView(generic.BulkDeleteView):
queryset = ModuleType.objects.prefetch_related('manufacturer').annotate(
queryset = ModuleType.objects.annotate(
instance_count=count_related(Module, 'module_type')
)
filterset = filtersets.ModuleTypeFilterSet
@@ -1611,9 +1609,7 @@ class DeviceListView(generic.ObjectListView):
class DeviceView(generic.ObjectView):
queryset = Device.objects.prefetch_related(
'site__region', 'location', 'rack', 'tenant__group', 'device_role', 'platform', 'primary_ip4', 'primary_ip6'
)
queryset = Device.objects.all()
def get_extra_context(self, request, instance):
# VirtualChassis members
@@ -1790,14 +1786,14 @@ class ChildDeviceBulkImportView(generic.BulkImportView):
class DeviceBulkEditView(generic.BulkEditView):
queryset = Device.objects.prefetch_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer')
queryset = Device.objects.prefetch_related('device_type__manufacturer')
filterset = filtersets.DeviceFilterSet
table = tables.DeviceTable
form = forms.DeviceBulkEditForm
class DeviceBulkDeleteView(generic.BulkDeleteView):
queryset = Device.objects.prefetch_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer')
queryset = Device.objects.prefetch_related('device_type__manufacturer')
filterset = filtersets.DeviceFilterSet
table = tables.DeviceTable
@@ -1807,7 +1803,7 @@ class DeviceBulkDeleteView(generic.BulkDeleteView):
#
class ModuleListView(generic.ObjectListView):
queryset = Module.objects.prefetch_related('device', 'module_type__manufacturer')
queryset = Module.objects.prefetch_related('module_type__manufacturer')
filterset = filtersets.ModuleFilterSet
filterset_form = forms.ModuleFilterForm
table = tables.ModuleTable
@@ -1833,14 +1829,14 @@ class ModuleBulkImportView(generic.BulkImportView):
class ModuleBulkEditView(generic.BulkEditView):
queryset = Module.objects.prefetch_related('device', 'module_type__manufacturer')
queryset = Module.objects.prefetch_related('module_type__manufacturer')
filterset = filtersets.ModuleFilterSet
table = tables.ModuleTable
form = forms.ModuleBulkEditForm
class ModuleBulkDeleteView(generic.BulkDeleteView):
queryset = Module.objects.prefetch_related('device', 'module_type__manufacturer')
queryset = Module.objects.prefetch_related('module_type__manufacturer')
filterset = filtersets.ModuleFilterSet
table = tables.ModuleTable
@@ -2566,7 +2562,7 @@ class InventoryItemBulkImportView(generic.BulkImportView):
class InventoryItemBulkEditView(generic.BulkEditView):
queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer', 'role')
queryset = InventoryItem.objects.all()
filterset = filtersets.InventoryItemFilterSet
table = tables.InventoryItemTable
form = forms.InventoryItemBulkEditForm
@@ -2577,7 +2573,7 @@ class InventoryItemBulkRenameView(generic.BulkRenameView):
class InventoryItemBulkDeleteView(generic.BulkDeleteView):
queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer', 'role')
queryset = InventoryItem.objects.all()
table = tables.InventoryItemTable
template_name = 'dcim/inventoryitem_bulk_delete.html'
@@ -2850,7 +2846,7 @@ class CableEditView(generic.ObjectEditView):
termination_a = obj.terminations.filter(cable_end='A').first()
a_type = termination_a.termination._meta.model if termination_a else None
termination_b = obj.terminations.filter(cable_end='B').first()
b_type = termination_b.termination._meta.model if termination_a else None
b_type = termination_b.termination._meta.model if termination_b else None
self.form = forms.get_cable_form(a_type, b_type)
return obj
@@ -2867,14 +2863,20 @@ class CableBulkImportView(generic.BulkImportView):
class CableBulkEditView(generic.BulkEditView):
queryset = Cable.objects.prefetch_related('terminations')
queryset = Cable.objects.prefetch_related(
'terminations__termination', 'terminations___device', 'terminations___rack', 'terminations___location',
'terminations___site',
)
filterset = filtersets.CableFilterSet
table = tables.CableTable
form = forms.CableBulkEditForm
class CableBulkDeleteView(generic.BulkDeleteView):
queryset = Cable.objects.prefetch_related('terminations')
queryset = Cable.objects.prefetch_related(
'terminations__termination', 'terminations___device', 'terminations___rack', 'terminations___location',
'terminations___site',
)
filterset = filtersets.CableFilterSet
table = tables.CableTable
@@ -2930,7 +2932,7 @@ class InterfaceConnectionsListView(generic.ObjectListView):
#
class VirtualChassisListView(generic.ObjectListView):
queryset = VirtualChassis.objects.prefetch_related('master').annotate(
queryset = VirtualChassis.objects.annotate(
member_count=count_related(Device, 'virtual_chassis')
)
table = tables.VirtualChassisTable
@@ -3158,9 +3160,7 @@ class VirtualChassisBulkDeleteView(generic.BulkDeleteView):
#
class PowerPanelListView(generic.ObjectListView):
queryset = PowerPanel.objects.prefetch_related(
'site', 'location'
).annotate(
queryset = PowerPanel.objects.annotate(
powerfeed_count=count_related(PowerFeed, 'power_panel')
)
filterset = filtersets.PowerPanelFilterSet
@@ -3169,10 +3169,10 @@ class PowerPanelListView(generic.ObjectListView):
class PowerPanelView(generic.ObjectView):
queryset = PowerPanel.objects.prefetch_related('site', 'location')
queryset = PowerPanel.objects.all()
def get_extra_context(self, request, instance):
power_feeds = PowerFeed.objects.restrict(request.user).filter(power_panel=instance).prefetch_related('rack')
power_feeds = PowerFeed.objects.restrict(request.user).filter(power_panel=instance)
powerfeed_table = tables.PowerFeedTable(
data=power_feeds,
orderable=False
@@ -3202,16 +3202,14 @@ class PowerPanelBulkImportView(generic.BulkImportView):
class PowerPanelBulkEditView(generic.BulkEditView):
queryset = PowerPanel.objects.prefetch_related('site', 'location')
queryset = PowerPanel.objects.all()
filterset = filtersets.PowerPanelFilterSet
table = tables.PowerPanelTable
form = forms.PowerPanelBulkEditForm
class PowerPanelBulkDeleteView(generic.BulkDeleteView):
queryset = PowerPanel.objects.prefetch_related(
'site', 'location'
).annotate(
queryset = PowerPanel.objects.annotate(
powerfeed_count=count_related(PowerFeed, 'power_panel')
)
filterset = filtersets.PowerPanelFilterSet
@@ -3230,7 +3228,7 @@ class PowerFeedListView(generic.ObjectListView):
class PowerFeedView(generic.ObjectView):
queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack')
queryset = PowerFeed.objects.all()
class PowerFeedEditView(generic.ObjectEditView):
@@ -3249,7 +3247,7 @@ class PowerFeedBulkImportView(generic.BulkImportView):
class PowerFeedBulkEditView(generic.BulkEditView):
queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack')
queryset = PowerFeed.objects.all()
filterset = filtersets.PowerFeedFilterSet
table = tables.PowerFeedTable
form = forms.PowerFeedBulkEditForm
@@ -3260,6 +3258,6 @@ class PowerFeedBulkDisconnectView(BulkDisconnectView):
class PowerFeedBulkDeleteView(generic.BulkDeleteView):
queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack')
queryset = PowerFeed.objects.all()
filterset = filtersets.PowerFeedFilterSet
table = tables.PowerFeedTable

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.exceptions import SerializerNotFound
from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
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
@@ -193,7 +194,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
@@ -243,7 +244,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
@@ -469,7 +470,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

@@ -19,6 +19,7 @@ class CustomFieldsMixin:
def __init__(self, *args, **kwargs):
self.custom_fields = {}
self.custom_field_groups = {}
super().__init__(*args, **kwargs)
@@ -58,3 +59,6 @@ class CustomFieldsMixin:
# Annotate the field in the list of CustomField form fields
self.custom_fields[field_name] = customfield
if customfield.group_name not in self.custom_field_groups:
self.custom_field_groups[customfield.group_name] = []
self.custom_field_groups[customfield.group_name].append(field_name)

View File

@@ -136,6 +136,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

@@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0076_tag_slug_unicode'),
]
operations = [
migrations.AlterField(
model_name='customlink',
name='link_text',
field=models.TextField(),
),
migrations.AlterField(
model_name='customlink',
name='link_url',
field=models.TextField(),
),
]

View File

@@ -181,7 +181,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):

View File

@@ -204,12 +204,10 @@ class CustomLink(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
enabled = models.BooleanField(
default=True
)
link_text = models.CharField(
max_length=500,
link_text = models.TextField(
help_text="Jinja2 template code for link text"
)
link_url = models.CharField(
max_length=500,
link_url = models.TextField(
verbose_name='Link URL',
help_text="Jinja2 template code for link URL"
)

View File

@@ -28,3 +28,4 @@ registry = Registry()
registry['model_features'] = {
feature: collections.defaultdict(set) for feature in EXTRAS_FEATURES
}
registry['denormalized_fields'] = collections.defaultdict(list)

View File

@@ -3,7 +3,6 @@ import inspect
import logging
import pkgutil
import traceback
from collections import OrderedDict
from django.conf import settings
from django.utils import timezone
@@ -114,7 +113,7 @@ class Report(object):
def __init__(self):
self._results = OrderedDict()
self._results = {}
self.active_test = None
self.failed = False
@@ -125,13 +124,13 @@ class Report(object):
for method in dir(self):
if method.startswith('test_') and callable(getattr(self, method)):
test_methods.append(method)
self._results[method] = OrderedDict([
('success', 0),
('info', 0),
('warning', 0),
('failure', 0),
('log', []),
])
self._results[method] = {
'success': 0,
'info': 0,
'warning': 0,
'failure': 0,
'log': [],
}
if not test_methods:
raise Exception("A report must contain at least one test method.")
self.test_methods = test_methods

View File

@@ -6,7 +6,6 @@ import pkgutil
import sys
import traceback
import threading
from collections import OrderedDict
import yaml
from django import forms
@@ -496,7 +495,7 @@ def get_scripts(use_names=False):
Return a dict of dicts mapping all scripts to their modules. Set use_names to True to use each module's human-
defined name in place of the actual module name.
"""
scripts = OrderedDict()
scripts = {}
# Iterate through all modules within the scripts path. These are the user-created files in which reports are
# defined.
for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]):
@@ -510,7 +509,7 @@ def get_scripts(use_names=False):
if use_names and hasattr(module, 'name'):
module_name = module.name
module_scripts = OrderedDict()
module_scripts = {}
script_order = getattr(module, "script_order", ())
ordered_scripts = [cls for cls in script_order if is_script(cls)]
unordered_scripts = [cls for _, cls in inspect.getmembers(module, is_script) if cls not in script_order]

View File

@@ -1,5 +1,3 @@
from collections import OrderedDict
from django import template
from django.contrib.contenttypes.models import ContentType
from django.utils.safestring import mark_safe
@@ -50,7 +48,7 @@ def custom_links(context, obj):
'perms': context['perms'], # django.contrib.auth.context_processors.auth
}
template_code = ''
group_names = OrderedDict()
group_names = {}
for cl in custom_links:

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

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

View File

@@ -492,14 +492,14 @@ class JournalEntryDeleteView(generic.ObjectDeleteView):
class JournalEntryBulkEditView(generic.BulkEditView):
queryset = JournalEntry.objects.prefetch_related('created_by')
queryset = JournalEntry.objects.all()
filterset = filtersets.JournalEntryFilterSet
table = tables.JournalEntryTable
form = forms.JournalEntryBulkEditForm
class JournalEntryBulkDeleteView(generic.BulkDeleteView):
queryset = JournalEntry.objects.prefetch_related('created_by')
queryset = JournalEntry.objects.all()
filterset = filtersets.JournalEntryFilterSet
table = tables.JournalEntryTable

View File

@@ -1,5 +1,3 @@
from collections import OrderedDict
from django.contrib.contenttypes.models import ContentType
from drf_yasg.utils import swagger_serializer_method
from rest_framework import serializers
@@ -10,6 +8,7 @@ from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS, VLANGROUP_SCOPE_TYPES
from ipam.models import *
from netbox.api.fields 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
@@ -148,7 +147,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
@@ -194,7 +193,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
@@ -226,13 +225,13 @@ class AvailableVLANSerializer(serializers.Serializer):
group = NestedVLANGroupSerializer(read_only=True)
def to_representation(self, instance):
return OrderedDict([
('vid', instance),
('group', NestedVLANGroupSerializer(
return {
'vid': instance,
'group': NestedVLANGroupSerializer(
self.context['group'],
context={'request': self.context['request']}
).data),
])
).data,
}
class CreateAvailableVLANSerializer(NetBoxModelSerializer):
@@ -317,11 +316,11 @@ class AvailablePrefixSerializer(serializers.Serializer):
vrf = NestedVRFSerializer(self.context['vrf'], context={'request': self.context['request']}).data
else:
vrf = None
return OrderedDict([
('family', instance.version),
('prefix', str(instance)),
('vrf', vrf),
])
return {
'family': instance.version,
'prefix': str(instance),
'vrf': vrf,
}
#
@@ -378,7 +377,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
@@ -396,11 +395,11 @@ class AvailableIPSerializer(serializers.Serializer):
vrf = NestedVRFSerializer(self.context['vrf'], context={'request': self.context['request']}).data
else:
vrf = None
return OrderedDict([
('family', self.context['parent'].family),
('address', f"{instance}/{self.context['parent'].mask_length}"),
('vrf', vrf),
])
return {
'family': self.context['parent'].family,
'address': f"{instance}/{self.context['parent'].mask_length}",
'vrf': vrf,
}
#
@@ -485,6 +484,6 @@ class L2VPNTerminationSerializer(NetBoxModelSerializer):
@swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_assigned_object(self, instance):
serializer = get_serializer_for_model(instance.assigned_object, prefix='Nested')
serializer = get_serializer_for_model(instance.assigned_object, prefix=NESTED_SERIALIZER_PREFIX)
context = {'request': self.context['request']}
return serializer(instance.assigned_object, context=context).data

View File

@@ -980,21 +980,65 @@ class L2VPNTerminationFilterSet(NetBoxModelFilterSet):
to_field_name='slug',
label='L2VPN (slug)',
)
device = MultiValueCharFilter(
method='filter_device',
field_name='name',
region = MultiValueCharFilter(
method='filter_region',
field_name='slug',
label='Region (slug)',
)
region_id = MultiValueNumberFilter(
method='filter_region',
field_name='pk',
label='Region (ID)',
)
site = MultiValueCharFilter(
method='filter_site',
field_name='slug',
label='Site (slug)',
)
site_id = MultiValueNumberFilter(
method='filter_site',
field_name='pk',
label='Site (ID)',
)
device = django_filters.ModelMultipleChoiceFilter(
field_name='interface__device__name',
queryset=Device.objects.all(),
to_field_name='name',
label='Device (name)',
)
device_id = MultiValueNumberFilter(
method='filter_device',
field_name='pk',
device_id = django_filters.ModelMultipleChoiceFilter(
field_name='interface__device',
queryset=Device.objects.all(),
label='Device (ID)',
)
virtual_machine = django_filters.ModelMultipleChoiceFilter(
field_name='vminterface__virtual_machine__name',
queryset=VirtualMachine.objects.all(),
to_field_name='name',
label='Virtual machine (name)',
)
virtual_machine_id = django_filters.ModelMultipleChoiceFilter(
field_name='vminterface__virtual_machine',
queryset=VirtualMachine.objects.all(),
label='Virtual machine (ID)',
)
interface = django_filters.ModelMultipleChoiceFilter(
field_name='interface__name',
queryset=Interface.objects.all(),
to_field_name='name',
label='Interface (name)',
)
interface_id = django_filters.ModelMultipleChoiceFilter(
field_name='interface',
queryset=Interface.objects.all(),
label='Interface (ID)',
)
vminterface = django_filters.ModelMultipleChoiceFilter(
field_name='vminterface__name',
queryset=VMInterface.objects.all(),
to_field_name='name',
label='VM interface (name)',
)
vminterface_id = django_filters.ModelMultipleChoiceFilter(
field_name='vminterface',
queryset=VMInterface.objects.all(),
@@ -1027,13 +1071,22 @@ class L2VPNTerminationFilterSet(NetBoxModelFilterSet):
qs_filter = Q(l2vpn__name__icontains=value)
return queryset.filter(qs_filter)
def filter_device(self, queryset, name, value):
devices = Device.objects.filter(**{'{}__in'.format(name): value})
if not devices.exists():
return queryset.none()
interface_ids = []
for device in devices:
interface_ids.extend(device.vc_interfaces().values_list('id', flat=True))
return queryset.filter(
interface__in=interface_ids
def filter_site(self, queryset, name, value):
qs = queryset.filter(
Q(
Q(**{'vlan__site__{}__in'.format(name): value}) |
Q(**{'interface__device__site__{}__in'.format(name): value}) |
Q(**{'vminterface__virtual_machine__site__{}__in'.format(name): value})
)
)
return qs
def filter_region(self, queryset, name, value):
qs = queryset.filter(
Q(
Q(**{'vlan__site__region__{}__in'.format(name): value}) |
Q(**{'interface__device__site__region__{}__in'.format(name): value}) |
Q(**{'vminterface__virtual_machine__site__region__{}__in'.format(name): value})
)
)
return qs

View File

@@ -11,7 +11,7 @@ from netbox.forms import NetBoxModelFilterSetForm
from tenancy.forms import TenancyFilterForm
from utilities.forms import (
add_blank_choice, ContentTypeMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
MultipleChoiceField, StaticSelect, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
MultipleChoiceField, StaticSelect, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, APISelectMultiple,
)
from virtualization.models import VirtualMachine
@@ -508,7 +508,8 @@ class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
class L2VPNTerminationFilterForm(NetBoxModelFilterSetForm):
model = L2VPNTermination
fieldsets = (
(None, ('l2vpn_id', 'assigned_object_type_id')),
(None, ('l2vpn_id', )),
('Assigned Object', ('assigned_object_type_id', 'region_id', 'site_id', 'device_id', 'virtual_machine_id', 'vlan_id')),
)
l2vpn_id = DynamicModelChoiceField(
queryset=L2VPN.objects.all(),
@@ -516,7 +517,49 @@ class L2VPNTerminationFilterForm(NetBoxModelFilterSetForm):
label='L2VPN'
)
assigned_object_type_id = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(),
queryset=ContentType.objects.filter(L2VPN_ASSIGNMENT_MODELS),
required=False,
label='Object type'
label=_('Assigned Object Type'),
limit_choices_to=L2VPN_ASSIGNMENT_MODELS
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
label=_('Region')
)
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
required=False,
null_option='None',
query_params={
'region_id': '$region_id'
},
label=_('Site')
)
device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
required=False,
null_option='None',
query_params={
'site_id': '$site_id'
},
label=_('Device')
)
vlan_id = DynamicModelMultipleChoiceField(
queryset=VLAN.objects.all(),
required=False,
null_option='None',
query_params={
'site_id': '$site_id'
},
label=_('VLAN')
)
virtual_machine_id = DynamicModelMultipleChoiceField(
queryset=VirtualMachine.objects.all(),
required=False,
null_option='None',
query_params={
'site_id': '$site_id'
},
label=_('Virtual Machine')
)

View File

@@ -851,7 +851,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']:
@@ -906,8 +906,9 @@ class L2VPNTerminationForm(NetBoxModelForm):
label='L2VPN',
fetch_trigger='open'
)
device = DynamicModelChoiceField(
device_vlan = DynamicModelChoiceField(
queryset=Device.objects.all(),
label="Available on Device",
required=False,
query_params={}
)
@@ -915,10 +916,15 @@ class L2VPNTerminationForm(NetBoxModelForm):
queryset=VLAN.objects.all(),
required=False,
query_params={
'available_on_device': '$device'
'available_on_device': '$device_vlan'
},
label='VLAN'
)
device = DynamicModelChoiceField(
queryset=Device.objects.all(),
required=False,
query_params={}
)
interface = DynamicModelChoiceField(
queryset=Interface.objects.all(),
required=False,

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

@@ -113,3 +113,18 @@ class L2VPNTermination(NetBoxModel):
f'{l2vpn_type} L2VPNs cannot have more than two terminations; found {terminations_count} already '
f'defined.'
)
@property
def assigned_object_parent(self):
obj_type = ContentType.objects.get_for_model(self.assigned_object)
if obj_type.model == 'vminterface':
return self.assigned_object.virtual_machine
elif obj_type.model == 'interface':
return self.assigned_object.device
elif obj_type.model == 'vminterface':
return self.assigned_object.virtual_machine
return None
@property
def assigned_object_site(self):
return self.assigned_object_parent.site

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

@@ -369,6 +369,11 @@ class IPAddressTable(TenancyColumnsMixin, 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,
@@ -381,7 +386,7 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable):
class Meta(NetBoxTable.Meta):
model = IPAddress
fields = (
'pk', 'id', 'address', 'vrf', 'status', 'role', 'tenant', 'tenant_group', '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

@@ -53,8 +53,17 @@ class L2VPNTerminationTable(NetBoxTable):
linkify=True,
orderable=False
)
assigned_object_parent = tables.Column(
linkify=True,
orderable=False
)
assigned_object_site = tables.Column(
linkify=True,
orderable=False
)
class Meta(NetBoxTable.Meta):
model = L2VPNTermination
fields = ('pk', 'l2vpn', 'assigned_object_type', 'assigned_object', 'actions')
fields = ('pk', 'l2vpn', 'assigned_object_type', 'assigned_object', 'assigned_object_parent',
'assigned_object_site', 'actions')
default_columns = ('pk', 'l2vpn', 'assigned_object_type', 'assigned_object', 'actions')

View File

@@ -1600,3 +1600,24 @@ class L2VPNTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'vlan': ['VLAN 1', 'VLAN 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site(self):
site = Site.objects.all().first()
params = {'site_id': [site.pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
params = {'site': ['site-1']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_device(self):
device = Device.objects.all().first()
params = {'device_id': [device.pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
params = {'device': ['Device 1']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_virtual_machine(self):
virtual_machine = VirtualMachine.objects.all().first()
params = {'virtual_machine_id': [virtual_machine.pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
params = {'virtual_machine': ['Virtual Machine 1']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)

View File

@@ -40,11 +40,11 @@ class VRFView(generic.ObjectView):
ipaddress_count = IPAddress.objects.restrict(request.user, 'view').filter(vrf=instance).count()
import_targets_table = tables.RouteTargetTable(
instance.import_targets.prefetch_related('tenant'),
instance.import_targets.all(),
orderable=False
)
export_targets_table = tables.RouteTargetTable(
instance.export_targets.prefetch_related('tenant'),
instance.export_targets.all(),
orderable=False
)
@@ -72,14 +72,14 @@ class VRFBulkImportView(generic.BulkImportView):
class VRFBulkEditView(generic.BulkEditView):
queryset = VRF.objects.prefetch_related('tenant')
queryset = VRF.objects.all()
filterset = filtersets.VRFFilterSet
table = tables.VRFTable
form = forms.VRFBulkEditForm
class VRFBulkDeleteView(generic.BulkDeleteView):
queryset = VRF.objects.prefetch_related('tenant')
queryset = VRF.objects.all()
filterset = filtersets.VRFFilterSet
table = tables.VRFTable
@@ -100,11 +100,11 @@ class RouteTargetView(generic.ObjectView):
def get_extra_context(self, request, instance):
importing_vrfs_table = tables.VRFTable(
instance.importing_vrfs.prefetch_related('tenant'),
instance.importing_vrfs.all(),
orderable=False
)
exporting_vrfs_table = tables.VRFTable(
instance.exporting_vrfs.prefetch_related('tenant'),
instance.exporting_vrfs.all(),
orderable=False
)
@@ -130,14 +130,14 @@ class RouteTargetBulkImportView(generic.BulkImportView):
class RouteTargetBulkEditView(generic.BulkEditView):
queryset = RouteTarget.objects.prefetch_related('tenant')
queryset = RouteTarget.objects.all()
filterset = filtersets.RouteTargetFilterSet
table = tables.RouteTargetTable
form = forms.RouteTargetBulkEditForm
class RouteTargetBulkDeleteView(generic.BulkDeleteView):
queryset = RouteTarget.objects.prefetch_related('tenant')
queryset = RouteTarget.objects.all()
filterset = filtersets.RouteTargetFilterSet
table = tables.RouteTargetTable
@@ -334,14 +334,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
@@ -417,7 +421,7 @@ class PrefixListView(generic.ObjectListView):
class PrefixView(generic.ObjectView):
queryset = Prefix.objects.prefetch_related('vrf', 'site__region', 'tenant__group', 'vlan__group', 'role')
queryset = Prefix.objects.all()
def get_extra_context(self, request, instance):
try:
@@ -433,7 +437,7 @@ class PrefixView(generic.ObjectView):
).filter(
prefix__net_contains=str(instance.prefix)
).prefetch_related(
'site', 'role', 'tenant'
'site', 'role', 'tenant', 'vlan',
)
parent_prefix_table = tables.PrefixTable(
list(parent_prefixes),
@@ -447,7 +451,7 @@ class PrefixView(generic.ObjectView):
).exclude(
pk=instance.pk
).prefetch_related(
'site', 'role'
'site', 'role', 'tenant', 'vlan',
)
duplicate_prefix_table = tables.PrefixTable(
list(duplicate_prefixes),
@@ -500,7 +504,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', 'tenant__group',
'tenant__group',
)
def get_extra_context(self, request, instance):
@@ -519,7 +523,7 @@ class PrefixIPAddressesView(generic.ObjectChildrenView):
template_name = 'ipam/prefix/ip_addresses.html'
def get_children(self, request, parent):
return parent.get_child_ips().restrict(request.user, 'view').prefetch_related('vrf', 'tenant')
return parent.get_child_ips().restrict(request.user, 'view').prefetch_related('vrf', 'tenant', 'tenant__group')
def prep_table_data(self, request, queryset, parent):
show_available = bool(request.GET.get('show_available', 'true') == 'true')
@@ -552,14 +556,14 @@ class PrefixBulkImportView(generic.BulkImportView):
class PrefixBulkEditView(generic.BulkEditView):
queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
queryset = Prefix.objects.prefetch_related('vrf__tenant')
filterset = filtersets.PrefixFilterSet
table = tables.PrefixTable
form = forms.PrefixBulkEditForm
class PrefixBulkDeleteView(generic.BulkDeleteView):
queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
queryset = Prefix.objects.prefetch_related('vrf__tenant')
filterset = filtersets.PrefixFilterSet
table = tables.PrefixTable
@@ -611,14 +615,14 @@ class IPRangeBulkImportView(generic.BulkImportView):
class IPRangeBulkEditView(generic.BulkEditView):
queryset = IPRange.objects.prefetch_related('vrf', 'tenant')
queryset = IPRange.objects.all()
filterset = filtersets.IPRangeFilterSet
table = tables.IPRangeTable
form = forms.IPRangeBulkEditForm
class IPRangeBulkDeleteView(generic.BulkDeleteView):
queryset = IPRange.objects.prefetch_related('vrf', 'tenant')
queryset = IPRange.objects.all()
filterset = filtersets.IPRangeFilterSet
table = tables.IPRangeTable
@@ -789,14 +793,14 @@ class IPAddressBulkImportView(generic.BulkImportView):
class IPAddressBulkEditView(generic.BulkEditView):
queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant')
queryset = IPAddress.objects.prefetch_related('vrf__tenant')
filterset = filtersets.IPAddressFilterSet
table = tables.IPAddressTable
form = forms.IPAddressBulkEditForm
class IPAddressBulkDeleteView(generic.BulkDeleteView):
queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant')
queryset = IPAddress.objects.prefetch_related('vrf__tenant')
filterset = filtersets.IPAddressFilterSet
table = tables.IPAddressTable
@@ -819,7 +823,8 @@ class VLANGroupView(generic.ObjectView):
def get_extra_context(self, request, instance):
vlans = VLAN.objects.restrict(request.user, 'view').filter(group=instance).prefetch_related(
Prefetch('prefixes', queryset=Prefix.objects.restrict(request.user))
Prefetch('prefixes', queryset=Prefix.objects.restrict(request.user)),
'tenant', 'site', 'role',
).order_by('vid')
vlans_count = vlans.count()
vlans = add_available_vlans(vlans, vlan_group=instance)
@@ -894,7 +899,7 @@ class FHRPGroupView(generic.ObjectView):
def get_extra_context(self, request, instance):
# Get assigned IP addresses
ipaddress_table = tables.AssignedIPAddressesTable(
data=instance.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'),
data=instance.ip_addresses.restrict(request.user, 'view'),
orderable=False
)
@@ -984,11 +989,11 @@ class VLANListView(generic.ObjectListView):
class VLANView(generic.ObjectView):
queryset = VLAN.objects.prefetch_related('site__region', 'tenant__group', 'role')
queryset = VLAN.objects.all()
def get_extra_context(self, request, instance):
prefixes = Prefix.objects.restrict(request.user, 'view').filter(vlan=instance).prefetch_related(
'vrf', 'site', 'role'
'vrf', 'site', 'role', 'tenant'
)
prefix_table = tables.PrefixTable(list(prefixes), exclude=('vlan', 'utilization'), orderable=False)
@@ -1046,14 +1051,14 @@ class VLANBulkImportView(generic.BulkImportView):
class VLANBulkEditView(generic.BulkEditView):
queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role')
queryset = VLAN.objects.all()
filterset = filtersets.VLANFilterSet
table = tables.VLANTable
form = forms.VLANBulkEditForm
class VLANBulkDeleteView(generic.BulkDeleteView):
queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role')
queryset = VLAN.objects.all()
filterset = filtersets.VLANFilterSet
table = tables.VLANTable
@@ -1106,14 +1111,14 @@ class ServiceTemplateBulkDeleteView(generic.BulkDeleteView):
#
class ServiceListView(generic.ObjectListView):
queryset = Service.objects.all()
queryset = Service.objects.prefetch_related('device', 'virtual_machine')
filterset = filtersets.ServiceFilterSet
filterset_form = forms.ServiceFilterForm
table = tables.ServiceTable
class ServiceView(generic.ObjectView):
queryset = Service.objects.prefetch_related('ipaddresses')
queryset = Service.objects.all()
class ServiceCreateView(generic.ObjectEditView):
@@ -1123,7 +1128,7 @@ class ServiceCreateView(generic.ObjectEditView):
class ServiceEditView(generic.ObjectEditView):
queryset = Service.objects.prefetch_related('ipaddresses')
queryset = Service.objects.all()
form = forms.ServiceForm
template_name = 'ipam/service_edit.html'

View File

@@ -1,5 +1,3 @@
from collections import OrderedDict
from django.core.exceptions import ObjectDoesNotExist
from netaddr import IPNetwork
from rest_framework import serializers
@@ -48,10 +46,10 @@ class ChoiceField(serializers.Field):
def to_representation(self, obj):
if obj == '':
return None
return OrderedDict([
('value', obj),
('label', self._choices[obj])
])
return {
'value': obj,
'label': self._choices[obj],
}
def to_internal_value(self, data):
if data == '':

View File

@@ -1,7 +1,10 @@
from django.contrib.contenttypes.models import ContentType
from drf_yasg.utils import swagger_serializer_method
from rest_framework import serializers
from netbox.api.fields import ContentTypeField
from netbox.constants import NESTED_SERIALIZER_PREFIX
from utilities.api import get_serializer_for_model
from utilities.utils import content_type_identifier
__all__ = (
@@ -17,6 +20,7 @@ class GenericObjectSerializer(serializers.Serializer):
queryset=ContentType.objects.all()
)
object_id = serializers.IntegerField()
object = serializers.SerializerMethodField(read_only=True)
def to_internal_value(self, data):
data = super().to_internal_value(data)
@@ -25,7 +29,17 @@ class GenericObjectSerializer(serializers.Serializer):
def to_representation(self, instance):
ct = ContentType.objects.get_for_model(instance)
return {
data = {
'object_type': content_type_identifier(ct),
'object_id': instance.pk,
}
if 'request' in self.context:
data['object'] = self.get_object(instance)
return data
@swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_object(self, obj):
serializer = get_serializer_for_model(obj, prefix=NESTED_SERIALIZER_PREFIX)
# context = {'request': self.context['request']}
return serializer(obj, context=self.context).data

View File

@@ -1,5 +1,4 @@
import platform
from collections import OrderedDict
from django import __version__ as DJANGO_VERSION
from django.apps import apps
@@ -26,18 +25,18 @@ class APIRootView(APIView):
def get(self, request, format=None):
return Response(OrderedDict((
('circuits', reverse('circuits-api:api-root', request=request, format=format)),
('dcim', reverse('dcim-api:api-root', request=request, format=format)),
('extras', reverse('extras-api:api-root', request=request, format=format)),
('ipam', reverse('ipam-api:api-root', request=request, format=format)),
('plugins', reverse('plugins-api:api-root', request=request, format=format)),
('status', reverse('api-status', request=request, format=format)),
('tenancy', reverse('tenancy-api:api-root', request=request, format=format)),
('users', reverse('users-api:api-root', request=request, format=format)),
('virtualization', reverse('virtualization-api:api-root', request=request, format=format)),
('wireless', reverse('wireless-api:api-root', request=request, format=format)),
)))
return Response({
'circuits': reverse('circuits-api:api-root', request=request, format=format),
'dcim': reverse('dcim-api:api-root', request=request, format=format),
'extras': reverse('extras-api:api-root', request=request, format=format),
'ipam': reverse('ipam-api:api-root', request=request, format=format),
'plugins': reverse('plugins-api:api-root', request=request, format=format),
'status': reverse('api-status', request=request, format=format),
'tenancy': reverse('tenancy-api:api-root', request=request, format=format),
'users': reverse('users-api:api-root', request=request, format=format),
'virtualization': reverse('virtualization-api:api-root', request=request, format=format),
'wireless': reverse('wireless-api:api-root', request=request, format=format),
})
class StatusView(APIView):

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 utilities.exceptions import AbortRequest
from .mixins import *
@@ -61,7 +62,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

@@ -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', '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 = OrderedDict(
(
('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 = OrderedDict(
(
('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 = 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', 'tenant__group', '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

@@ -0,0 +1,54 @@
import logging
from django.db.models.signals import post_save
from django.dispatch import receiver
from extras.registry import registry
logger = logging.getLogger('netbox.denormalized')
def register(model, field_name, mappings):
"""
Register a denormalized model field to ensure that it is kept up-to-date with the related object.
Args:
model: The class being updated
field_name: The name of the field related to the triggering instance
mappings: Dictionary mapping of local to remote fields
"""
logger.debug(f'Registering denormalized field {model}.{field_name}')
field = model._meta.get_field(field_name)
rel_model = field.related_model
registry['denormalized_fields'][rel_model].append(
(model, field_name, mappings)
)
@receiver(post_save)
def update_denormalized_fields(sender, instance, created, raw, **kwargs):
"""
Check if the sender has denormalized fields registered, and update them as necessary.
"""
# Skip for new objects or those being populated from raw data
if created or raw:
return
# Look up any denormalized fields referencing this model from the application registry
for model, field_name, mappings in registry['denormalized_fields'].get(sender, []):
logger.debug(f'Updating denormalized values for {model}.{field_name}')
filter_params = {
field_name: instance.pk,
}
update_params = {
# Map the denormalized field names to the instance's values
denorm: getattr(instance, origin) for denorm, origin in mappings.items()
}
# TODO: Improve efficiency here by placing conditions on the query?
# Update all the denormalized fields with the triggering object's new values
count = model.objects.filter(**filter_params).update(**update_params)
logger.debug(f'Updated {count} rows')

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
@@ -197,24 +197,11 @@ class BaseFilterSet(django_filters.FilterSet):
class ChangeLoggedModelFilterSet(BaseFilterSet):
created = django_filters.DateTimeFilter()
created__gte = django_filters.DateTimeFilter(
field_name='created',
lookup_expr='gte'
)
created__lte = django_filters.DateTimeFilter(
field_name='created',
lookup_expr='lte'
)
last_updated = django_filters.DateTimeFilter()
last_updated__gte = django_filters.DateTimeFilter(
field_name='last_updated',
lookup_expr='gte'
)
last_updated__lte = django_filters.DateTimeFilter(
field_name='last_updated',
lookup_expr='lte'
)
"""
Base FilterSet for ChangeLoggedModel classes.
"""
created = filters.MultiValueDateTimeFilter()
last_updated = filters.MultiValueDateTimeFilter()
class NetBoxModelFilterSet(ChangeLoggedModelFilterSet):

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

@@ -94,30 +94,19 @@ class NetBoxModelBulkEditForm(BootstrapMixin, CustomFieldsMixin, forms.Form):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['pk'].queryset = self.model.objects.all()
self._extend_nullable_fields()
def _get_form_field(self, customfield):
return customfield.to_form_field(set_initial=False, enforce_required=False)
def _append_customfield_fields(self):
"""
Append form fields for all CustomFields assigned to this object type.
"""
nullable_custom_fields = []
for customfield in self._get_custom_fields(self._get_content_type()):
field_name = f'cf_{customfield.name}'
self.fields[field_name] = self._get_form_field(customfield)
# Record non-required custom fields as nullable
if not customfield.required:
nullable_custom_fields.append(field_name)
# Annotate the field in the list of CustomField form fields
self.custom_fields[field_name] = customfield
# Annotate nullable custom fields (if any) on the form instance
if nullable_custom_fields:
self.nullable_fields = (*self.nullable_fields, *nullable_custom_fields)
def _extend_nullable_fields(self):
nullable_custom_fields = [
name for name, customfield in self.custom_fields.items() if not customfield.required
]
self.nullable_fields = (*self.nullable_fields, *nullable_custom_fields)
class NetBoxModelFilterSetForm(BootstrapMixin, CustomFieldsMixin, forms.Form):

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.3-beta1'
VERSION = '3.3-beta2'
# Hostname
HOSTNAME = platform.node()
@@ -478,13 +478,6 @@ if SENTRY_ENABLED:
# Django social auth
#
# Load all SOCIAL_AUTH_* settings from the user configuration
for param in dir(configuration):
if param.startswith('SOCIAL_AUTH_'):
globals()[param] = getattr(configuration, param)
SOCIAL_AUTH_JSONFIELD_ENABLED = True
SOCIAL_AUTH_PIPELINE = (
'social_core.pipeline.social_auth.social_details',
'social_core.pipeline.social_auth.social_uid',
@@ -498,6 +491,14 @@ SOCIAL_AUTH_PIPELINE = (
'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
#
# Django Prometheus

View File

@@ -21,8 +21,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

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

@@ -411,7 +411,6 @@ export class APISelect {
} finally {
this.setOptionStyles();
this.enable();
this.slim.slim.search.input.focus();
this.base.dispatchEvent(this.loadEvent);
}
}

View File

@@ -56,17 +56,3 @@
{% render_custom_fields form %}
</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

@@ -18,43 +18,41 @@
</div>
</div>
<div class="trace-end">
{% with traced_path=path.origin.trace %}
{% if path.is_split %}
<h3 class="text-danger">Path split!</h3>
<p>Select a node below to continue:</p>
<ul class="text-start">
{% for next_node in path.get_split_nodes %}
{% if next_node.cable %}
<li>
<a href="{% url 'dcim:frontport_trace' pk=next_node.pk %}">{{ next_node }}</a>
(Cable {{ next_node.cable|linkify }})
</li>
{% else %}
<li class="text-muted">{{ next_node }}</li>
{% endif %}
{% endfor %}
</ul>
{% else %}
<h3 class="text-center text-success">Trace Completed</h3>
<table class="table">
<tr>
<th scope="row">Total segments</th>
<td>{{ traced_path|length }}</td>
</tr>
<tr>
<th scope="row">Total length</th>
<td>
{% if total_length %}
{{ total_length|floatformat:"-2" }}{% if not is_definitive %}+{% endif %} Meters /
{{ total_length|meters_to_feet|floatformat:"-2" }} Feet
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
</table>
{% endif %}
{% endwith %}
{% if path.is_split %}
<h3 class="text-danger">Path split!</h3>
<p>Select a node below to continue:</p>
<ul class="text-start">
{% for next_node in path.get_split_nodes %}
{% if next_node.cable %}
<li>
<a href="{% url 'dcim:frontport_trace' pk=next_node.pk %}">{{ next_node }}</a>
(Cable {{ next_node.cable|linkify }})
</li>
{% else %}
<li class="text-muted">{{ next_node }}</li>
{% endif %}
{% endfor %}
</ul>
{% else %}
<h3 class="text-center text-success">Trace Completed</h3>
<table class="table">
<tr>
<th scope="row">Total segments</th>
<td>{{ path.segment_count }}</td>
</tr>
<tr>
<th scope="row">Total length</th>
<td>
{% if total_length %}
{{ total_length|floatformat:"-2" }}{% if not is_definitive %}+{% endif %} Meters /
{{ total_length|meters_to_feet|floatformat:"-2" }} Feet
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
</table>
{% endif %}
</div>
{% else %}
<h3 class="text-center text-muted my-3">

View File

@@ -1,58 +1,62 @@
{% load helpers %}
<table class="table table-hover panel-body attr-table">
{% if terminations.0.device %}
{# Device component #}
<tr>
<td>Site</td>
<td>{{ terminations.0.device.site|linkify }}</td>
</tr>
<tr>
<td>Rack</td>
<td>{{ terminations.0.device.rack|linkify|placeholder }}</td>
</tr>
<tr>
<td>Device</td>
<td>{{ terminations.0.device|linkify }}</td>
</tr>
<tr>
<td>{{ terminations.0|meta:"verbose_name"|capfirst }}</td>
<td>
{% for term in terminations %}
{{ term|linkify }}{% if not forloop.last %},{% endif %}
{% endfor %}
</td>
</tr>
{% elif terminations.0.power_panel %}
{# Power feed #}
<tr>
<td>Site</td>
<td>{{ terminations.0.power_panel.site|linkify }}</td>
</tr>
<tr>
<td>Power Panel</td>
<td>{{ terminations.0.power_panel|linkify }}</td>
</tr>
<tr>
<td>{{ terminations.0|meta:"verbose_name"|capfirst }}</td>
<td>
{% for term in terminations %}
{{ term|linkify }}{% if not forloop.last %},{% endif %}
{% endfor %}
</td>
</tr>
{% else %}
{# Circuit termination #}
<tr>
<td>Provider</td>
<td>{{ terminations.0.circuit.provider|linkify }}</td>
</tr>
<tr>
<td>Circuit</td>
<td>
{% for term in terminations %}
{{ term.circuit|linkify }} ({{ term }}){% if not forloop.last %},{% endif %}
{% endfor %}
</td>
</tr>
{% endif %}
</table>
{% if terminations.0 %}
<table class="table table-hover panel-body attr-table">
{% if terminations.0.device %}
{# Device component #}
<tr>
<td>Site</td>
<td>{{ terminations.0.device.site|linkify }}</td>
</tr>
<tr>
<td>Rack</td>
<td>{{ terminations.0.device.rack|linkify|placeholder }}</td>
</tr>
<tr>
<td>Device</td>
<td>{{ terminations.0.device|linkify }}</td>
</tr>
<tr>
<td>{{ terminations.0|meta:"verbose_name"|capfirst }}</td>
<td>
{% for term in terminations %}
{{ term|linkify }}{% if not forloop.last %},{% endif %}
{% endfor %}
</td>
</tr>
{% elif terminations.0.power_panel %}
{# Power feed #}
<tr>
<td>Site</td>
<td>{{ terminations.0.power_panel.site|linkify }}</td>
</tr>
<tr>
<td>Power Panel</td>
<td>{{ terminations.0.power_panel|linkify }}</td>
</tr>
<tr>
<td>{{ terminations.0|meta:"verbose_name"|capfirst }}</td>
<td>
{% for term in terminations %}
{{ term|linkify }}{% if not forloop.last %},{% endif %}
{% endfor %}
</td>
</tr>
{% elif terminations.0.circuit %}
{# Circuit termination #}
<tr>
<td>Provider</td>
<td>{{ terminations.0.circuit.provider|linkify }}</td>
</tr>
<tr>
<td>Circuit</td>
<td>
{% for term in terminations %}
{{ term.circuit|linkify }} ({{ term }}){% if not forloop.last %},{% endif %}
{% endfor %}
</td>
</tr>
{% endif %}
</table>
{% else %}
<span class="text-muted">No termination</span>
{% endif %}

View File

@@ -219,7 +219,7 @@
<tr>
<th scope="row">Path Status</th>
<td>
{% if object.path.is_active %}
{% if object.path.is_complete and object.path.is_active %}
<span class="badge bg-success">Reachable</span>
{% else %}
<span class="badge bg-danger">Not Reachable</span>

View File

@@ -99,14 +99,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

@@ -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

@@ -7,7 +7,7 @@
<div class="card-body">
{% for group_name, fields in custom_fields.items %}
{% if group_name %}
<h6><strong>{{ group_name }}</strong></h6>
<h6>{{ group_name }}</h6>
{% endif %}
<table class="table table-hover attr-table">
{% for field, value in fields.items %}

View File

@@ -32,7 +32,7 @@
<div class="row mb-3">
<div class="tab-content p-0 border-0">
<div class="tab-pane {% if not form.initial.interface or form.initial.vminterface %}active{% endif %}" id="vlan" role="tabpanel" aria-labeled-by="vlan_tab">
{% render_field form.device %}
{% render_field form.device_vlan %}
{% render_field form.vlan %}
</div>
<div class="tab-pane {% if form.initial.interface %}active{% endif %}" id="interface" role="tabpanel" aria-labeled-by="interface_tab">

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.fields 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

@@ -95,7 +95,7 @@ class TenantListView(generic.ObjectListView):
class TenantView(generic.ObjectView):
queryset = Tenant.objects.prefetch_related('group')
queryset = Tenant.objects.all()
def get_extra_context(self, request, instance):
stats = {
@@ -140,14 +140,14 @@ class TenantBulkImportView(generic.BulkImportView):
class TenantBulkEditView(generic.BulkEditView):
queryset = Tenant.objects.prefetch_related('group')
queryset = Tenant.objects.all()
filterset = filtersets.TenantFilterSet
table = tables.TenantTable
form = forms.TenantBulkEditForm
class TenantBulkDeleteView(generic.BulkDeleteView):
queryset = Tenant.objects.prefetch_related('group')
queryset = Tenant.objects.all()
filterset = filtersets.TenantFilterSet
table = tables.TenantTable
@@ -337,14 +337,14 @@ class ContactBulkImportView(generic.BulkImportView):
class ContactBulkEditView(generic.BulkEditView):
queryset = Contact.objects.prefetch_related('group')
queryset = Contact.objects.all()
filterset = filtersets.ContactFilterSet
table = tables.ContactTable
form = forms.ContactBulkEditForm
class ContactBulkDeleteView(generic.BulkDeleteView):
queryset = Contact.objects.prefetch_related('group')
queryset = Contact.objects.all()
filterset = filtersets.ContactFilterSet
table = tables.ContactTable

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