Compare commits

...

114 Commits

Author SHA1 Message Date
Jeremy Stretch
3143f75a38 Merge pull request #4035 from netbox-community/develop
Release v2.7.3
2020-01-28 16:39:09 -05:00
Jeremy Stretch
be716a3345 Release v2.7.3 2020-01-28 16:33:55 -05:00
Jeremy Stretch
8de9f52151 Fixes #4033: Restore missing comments field label of various bulk edit forms 2020-01-28 16:09:10 -05:00
Jeremy Stretch
0a11fc1221 Fixes #4030: Fix exception when setting interfaces to tagged mode in bulk 2020-01-28 14:19:29 -05:00
Jeremy Stretch
ede576a2ae Changelog for #4022 2020-01-28 13:55:44 -05:00
Jeremy Stretch
12cf69f7e1 Merge pull request #4022 from hSaria/4010-interface-ip-filter
Fixes #4010: Fixes IP addresses table when filtering interfaces
2020-01-28 13:54:21 -05:00
Jeremy Stretch
2a4ccae113 Merge pull request #4031 from kobayashi/3978-add-vrf-filter
Fixes #3978: VRF filtering for NAT IP search
2020-01-28 13:46:54 -05:00
Jeremy Stretch
77292050d4 Changelog for #4025 2020-01-28 13:38:03 -05:00
Jeremy Stretch
e7ef142620 Merge pull request #4026 from hSaria/4025-cable-status-class
Fixes #4025: Cable status class
2020-01-28 13:34:46 -05:00
Jeremy Stretch
07d8476cf5 Merge pull request #4032 from netbox-community/4027-ipaddress-migration
Fixes #4027: Repair schema migration for IP addresses with DHCP status
2020-01-28 13:32:17 -05:00
Jeremy Stretch
9b9e568446 Fixes #4027: Repair schema migration for #3569 to convert IP addresses with DHCP status 2020-01-28 12:49:00 -05:00
kobayashi
3c5346f60a Fixes #3978: VRF filtering for NAT IP search 2020-01-28 10:22:28 -05:00
Jeremy Stretch
8d547e9906 Fixes #4028: Correct URL patterns to match Unicode characters in tag slugs 2020-01-28 09:47:33 -05:00
Saria Hajjar
720bd87292 Fixed interface mark connected/planned buttons 2020-01-27 22:56:25 +00:00
Saria Hajjar
8306976b3e Removed erroneous double-space 2020-01-27 22:49:36 +00:00
Saria Hajjar
3bce8e9716 Fixes #4025: Cable status class 2020-01-27 22:44:38 +00:00
Jeremy Stretch
9c4f1d5795 Changelog for #3338 2020-01-27 17:24:00 -05:00
Jeremy Stretch
93fa00b673 #3338: Prefetch termination devices to avoid extra database queries 2020-01-27 17:22:31 -05:00
Jeremy Stretch
49a6332d37 Merge pull request #4012 from hSaria/3338-api-circuit-term
Fixes #3338: Added termination A and Z to the circuit
2020-01-27 17:14:40 -05:00
Saria Hajjar
5c5b9c95aa Interface selector restricted to only interface 2020-01-27 22:07:42 +00:00
Jeremy Stretch
7abcc7acaa Merge pull request #3993 from hSaria/3935-swagger-default-info
Fixes #3935: Swagger DEFAULT_INFO
2020-01-27 16:58:03 -05:00
Saria Hajjar
d0f127e575 Fixes #3338: Added termination A and Z to the circuit 2020-01-27 21:53:10 +00:00
Jeremy Stretch
00b50f9c65 Remove obsolete constants 2020-01-27 12:34:52 -05:00
Saria Hajjar
46d0e88da3 Fixes #4010: Fixes IP addresses table when filtering interfaces 2020-01-27 15:49:15 +00:00
Jeremy Stretch
1901f63b4c Update changelog 2020-01-27 09:45:18 -05:00
Jeremy Stretch
2662bd0ad8 Merge pull request #4017 from hSaria/4016-duplicate-tenant-field
Fixes #4016: Removed duplicate tenant field for cluster edit form
2020-01-27 09:36:36 -05:00
Jeremy Stretch
27d70b6b51 Merge pull request #4021 from hellerve/veit/fix-4019
SVG Elevation: Add borders on the rear of devices as well
2020-01-27 09:32:53 -05:00
hellerve
011280b0bf dcim: add borders on the rear of devices as well 2020-01-27 13:13:07 +01:00
Saria Hajjar
4e4a05d3b9 Fixes #4016: Removed duplicate tenant field for cluster edit form 2020-01-26 12:52:18 +00:00
Jeremy Stretch
7a548e806d Merge pull request #4009 from netbox-community/4006-remove-fixtures
Closes #4006: remove test fixtures
2020-01-24 16:36:41 -05:00
Jeremy Stretch
47962ea732 Adapt form tests to work without fixture data 2020-01-24 16:30:43 -05:00
Jeremy Stretch
eb4c2e5d7f Remove obsolete fixtures files 2020-01-24 16:29:23 -05:00
Jeremy Stretch
a13bddde58 Refactor prefix and IP mask length choice generation to reference constants 2020-01-24 15:50:45 -05:00
Jeremy Stretch
66330418cb Remove obsolete IP_FAMILY_CHOICES constant 2020-01-24 15:40:03 -05:00
Jeremy Stretch
151943bfbc Merge pull request #4007 from netbox-community/3880-use-constants
Closes #3880: Define constants for arbitrary values
2020-01-24 15:29:38 -05:00
Jeremy Stretch
35cbee5107 Fixes #4008: Toggle rack elevation face using front/rear strings 2020-01-24 15:28:15 -05:00
Jeremy Stretch
c6473d654d Add explanatory text for constants 2020-01-24 15:03:38 -05:00
Jeremy Stretch
096814dc33 #3880: Define constants for arbitrary values 2020-01-24 14:42:57 -05:00
Jeremy Stretch
45b66b174c Merge pull request #3955 from kobayashi/3950-not-retain-device-type
Fixes: #3950 "Create and Add Another" does retain device type
2020-01-24 13:49:46 -05:00
Jeremy Stretch
0ec091ffe1 Merge branch 'develop' into 3950-not-retain-device-type 2020-01-24 13:49:30 -05:00
Jeremy Stretch
f24e7652a8 Add changelog for #3982 2020-01-24 12:10:38 -05:00
Jeremy Stretch
9f58c27fcf Merge pull request #4002 from hellerve/veit/fix-3982
Read reserved tooltip on rack elevations
2020-01-24 12:09:39 -05:00
Jeremy Stretch
d3463b596a Closes #4005: Include timezone context in webhook timestamps 2020-01-24 12:00:24 -05:00
kobayashi
66d5cc47a5 Fixes #3950: Cloned Device Form does not retain device type 2020-01-24 03:30:24 -05:00
kobayashi
9694bacb69 3950 not retain device type 2020-01-24 03:13:50 -05:00
hellerve
fcba2baf42 dcim: fix #3982 by readding reserved tooltip 2020-01-24 08:45:55 +01:00
Jeremy Stretch
629712142f Fixes #3999: Do not filter child results by null if non-required parent fields are blank 2020-01-23 17:11:45 -05:00
Jeremy Stretch
cdecf93f00 Add tests for ChoiceSet 2020-01-23 16:19:34 -05:00
Jeremy Stretch
fe402331f2 Handle grouped choices when returning ChoiceSet values 2020-01-23 16:16:52 -05:00
Jeremy Stretch
fcbbb36afc Add tests for home and search views 2020-01-23 15:41:09 -05:00
Jeremy Stretch
2e69037c29 Closes #3952: Add test for webhooks_worker; introduce generate_signature() 2020-01-23 15:05:27 -05:00
Saria Hajjar
1b26afdfbb Fixes #3935: Swagger DEFAULT_INFO 2020-01-23 14:26:04 +00:00
Jeremy Stretch
7b517abdb6 Fixes #3989: Correct HTTP content type assignment for webhooks 2020-01-22 20:33:57 -05:00
Jeremy Stretch
2445d1896b Merge pull request #3988 from netbox-community/3509-ipaddress-script-vars
Closes #3509: Add IP address vars for custom scripts
2020-01-22 17:56:00 -05:00
Jeremy Stretch
72d1fe0cd7 Changelog for #3509 2020-01-22 17:49:03 -05:00
Jeremy Stretch
b7e71f9f39 Add tests for IP address vars 2020-01-22 17:48:03 -05:00
Jeremy Stretch
f41564b578 Introduce IPAddressVar and IPAddressWithMaskVar 2020-01-22 17:16:40 -05:00
Jeremy Stretch
aa56c020ab Move prefix_validator() to ipam.validators 2020-01-22 16:33:34 -05:00
Jeremy Stretch
ba6df87d10 Move min/max prefix length validators to ipam.validators 2020-01-22 16:26:06 -05:00
Jeremy Stretch
5e7fbc4e42 Merge pull request #3987 from netbox-community/3310-cableform-initial-data
Closes #3310: Pre-select site/rack for B side when creating a new cable
2020-01-22 16:14:17 -05:00
Jeremy Stretch
f826e15603 Closes #3310: Pre-select site/rack for B side when creating a new cable 2020-01-22 16:07:09 -05:00
Jeremy Stretch
b7dea5a9f7 Fixes #3983: Permit the creation of multiple unnamed devices 2020-01-22 09:26:49 -05:00
Jeremy Stretch
ddd9f86031 Add tests for rack elevation API endpoint 2020-01-21 17:36:38 -05:00
Jeremy Stretch
1c13a79961 Suppress extraneous test output 2020-01-21 17:23:50 -05:00
Jeremy Stretch
03436b729d Add test for device graphs API endpoint 2020-01-21 17:11:26 -05:00
Jeremy Stretch
d123664503 Add tests for front/rear port API endpoints 2020-01-21 17:00:30 -05:00
Jeremy Stretch
10917123fd Add tests for cable tracing endpoints 2020-01-21 16:24:03 -05:00
Jeremy Stretch
b06bed368b Post-release version bump 2020-01-21 15:13:49 -05:00
Jeremy Stretch
e13d4ffe60 Merge pull request #3980 from netbox-community/develop
Release v2.7.2
2020-01-21 15:12:00 -05:00
Jeremy Stretch
2581a55214 Release v2.7.2 2020-01-21 15:04:09 -05:00
Jeremy Stretch
aa4b89f751 Changelog for #3965 2020-01-21 13:56:25 -05:00
Jeremy Stretch
838aaffc4b Merge pull request #3971 from hellerve/veit/fix-3965
Display occupied rack units correctly
2020-01-21 13:53:21 -05:00
Jeremy Stretch
9dfd0e5b40 Merge pull request #3957 from kobayashi/3923-validate-key-format
Fixes: #3923 validate key format
2020-01-21 13:27:35 -05:00
Jeremy Stretch
3357c050c4 Merge pull request #3959 from hSaria/3135-document-power
Fixes #3135: Documented power modelling
2020-01-21 13:15:57 -05:00
Jeremy Stretch
60c5418516 Add tests for device component filtering by region/site 2020-01-21 12:28:22 -05:00
Jeremy Stretch
48b4695ebe Fixes #3966: Fix filtering of device components by region/site 2020-01-21 12:27:52 -05:00
Jeremy Stretch
737b05d12b Changelog for #3964 2020-01-21 11:41:44 -05:00
Jeremy Stretch
1d0546b3d1 Merge pull request #3972 from hellerve/veit/fix-3964
Display borders around devices in rack elevations
2020-01-21 11:40:20 -05:00
Jeremy Stretch
a7a166a9cb Merge branch 'develop' into veit/fix-3964 2020-01-21 11:39:45 -05:00
Jeremy Stretch
74e1c08324 Changelog for #3963 2020-01-21 11:35:05 -05:00
Jeremy Stretch
007de40ada Merge pull request #3973 from hellerve/veit/fix-3963
dcim: fix tooltips in svg rack display
2020-01-21 11:33:14 -05:00
hellerve
e184eb3521 dcim: make pep happy 2020-01-21 17:01:48 +01:00
hellerve
e421c15bdd dcim: merge elevations as necessary 2020-01-21 16:56:06 +01:00
hellerve
469a088874 dcim: fix tooltips in svg rack display 2020-01-21 16:23:59 +01:00
Jeremy Stretch
63dbee16cc Changelog for #3962 2020-01-21 10:11:27 -05:00
hellerve
5f3f21215a dcim: fix #3964 by moving away from properties to inline styles 2020-01-21 16:06:15 +01:00
Jeremy Stretch
cdd7ed21ee Merge pull request #3970 from hellerve/veit/fix-3962
Display device correctly in SVG
2020-01-21 10:05:37 -05:00
hellerve
255d12309a dcim: fix #3965 by adding an option to get_rack_units 2020-01-21 15:50:38 +01:00
Jeremy Stretch
856d14aaa6 Merge pull request #3969 from kobayashi/3960-legacy-device-status
Fixes: #3960 legacy device status
2020-01-21 09:47:16 -05:00
Jeremy Stretch
134cf38a84 Merge branch 'develop' into 3960-legacy-device-status 2020-01-21 09:47:07 -05:00
Jeremy Stretch
1a56a5561c Add systemd migration doc to pages list 2020-01-21 09:41:55 -05:00
hellerve
eb7fbe4b3a dcim: fix #3962 by moving away from device.name 2020-01-21 15:33:17 +01:00
Jeremy Stretch
9d3215e806 Fixes #3967: Resolve migration of "other" interface type 2020-01-21 09:32:51 -05:00
kobayashi
9e855ac6cd 3960 legacy device status 2020-01-21 00:30:47 -05:00
Saria Hajjar
a6fde3168b Minor corrections 2020-01-20 11:37:51 +00:00
Saria Hajjar
939a7bbe50 Fixes #3135: Documented power modelling 2020-01-19 15:43:31 +00:00
kobayashi
c6d18da2eb 3923 validate key format 2020-01-19 02:19:03 -05:00
Jeremy Stretch
606f3dacbb Fixes #3721: Allow Unicode characters in tag slugs 2020-01-17 17:25:46 -05:00
Jeremy Stretch
aa73a7ad02 Closes #3954: Add device_bays filter for devices and device types 2020-01-17 16:39:31 -05:00
Jeremy Stretch
a4687be5e5 Closes #3842: Add 802.11ax interface type 2020-01-17 16:20:11 -05:00
Jeremy Stretch
302f87e108 Fixes #3937: Suppress warning messages in tests for requests expected to yield a 4XX response 2020-01-17 14:53:33 -05:00
Jeremy Stretch
439fa731ba Fixes #3953: Fix validation error when creating child devices 2020-01-17 14:22:58 -05:00
Jeremy Stretch
c6eb40daa8 #3951: Add tests for webhook queuing 2020-01-17 12:39:14 -05:00
Jeremy Stretch
f15cde0275 Fixes #3951: Fix exception in webhook worker due to missing constant 2020-01-17 11:28:50 -05:00
Jeremy Stretch
83427d5585 Closes #3949: Add tests for IPAM model methods 2020-01-17 11:15:05 -05:00
Jeremy Stretch
d3f278400e Post-release version bump 2020-01-16 23:47:38 -05:00
Jeremy Stretch
295d4f0394 Merge pull request #3946 from netbox-community/develop
Release v2.7.1
2020-01-16 23:46:40 -05:00
Jeremy Stretch
8aad11b8d2 Release v2.7.1 2020-01-16 23:43:32 -05:00
Jeremy Stretch
0a1dd64b94 Fixes #3943: Prevent rack elevation links from opening new tabs/windows 2020-01-16 23:41:52 -05:00
Jeremy Stretch
f220b3f128 Merge pull request #3942 from hSaria/3941-ip-assign-exception
Fixes #3941: AttributeError when searching on IP assign
2020-01-16 21:43:15 -05:00
Jeremy Stretch
1c0e0fec4c Merge branch 'develop' into 3941-ip-assign-exception 2020-01-16 21:42:27 -05:00
Jeremy Stretch
5369aef971 Fixes #3944: Fix AttributeError exception when viewing prefixes list 2020-01-16 21:39:46 -05:00
Saria Hajjar
9f569d4b1b Fixes #3941: AttributeError when searching on IP assign 2020-01-16 23:03:16 +00:00
Jeremy Stretch
604924231a Post-release version bump 2020-01-16 14:47:55 -05:00
78 changed files with 2051 additions and 6496 deletions

View File

@@ -124,7 +124,7 @@ Arbitrary text of any length. Renders as multi-line text input field.
Stored a numeric integer. Options include:
* `min_value:` - Minimum value
* `min_value` - Minimum value
* `max_value` - Maximum value
### BooleanVar
@@ -158,9 +158,20 @@ A NetBox object. The list of available objects is defined by the queryset parame
An uploaded file. Note that uploaded files are present in memory only for the duration of the script's execution: They will not be save for future use.
### IPAddressVar
An IPv4 or IPv6 address, without a mask. Returns a `netaddr.IPAddress` object.
### IPAddressWithMaskVar
An IPv4 or IPv6 address with a mask. Returns a `netaddr.IPNetwork` object which includes the mask.
### IPNetworkVar
An IPv4 or IPv6 network with a mask.
An IPv4 or IPv6 network with a mask. Returns a `netaddr.IPNetwork` object. Two attributes are available to validate the provided mask:
* `min_prefix_length` - Minimum length of the mask (default: none)
* `max_prefix_length` - Maximum length of the mask (default: none)
### Default Options

View File

@@ -0,0 +1,58 @@
# Power Panel
A power panel represents the distribution board where power circuits and their circuit breakers terminate on. If you have multiple power panels in your data center, you should model them as such in NetBox to assist you in determining the redundancy of your power allocation.
# Power Feed
A power feed identifies the power outlet/drop that goes to a rack and is terminated to a power panel. Power feeds have a supply type (AC/DC), voltage, amperage, and phase type (single/three).
Power feeds are optionally assigned to a rack. In addition, a power port and only one can connect to a power feed; in the context of a PDU, the power feed is analogous to the power outlet that a PDU's power port/inlet connects to.
!!! info
The power usage of a rack is calculated when a power feed (or multiple) is assigned to that rack and connected to a power port.
# Power Outlet
Power outlets represent the ports on a PDU that supply power to other devices. Power outlets are downstream-facing towards power ports. A power outlet can be associated with a power port on the same device and a feed leg (i.e. in a case of a three-phase supply). This indicates which power port supplies power to a power outlet.
# Power Port
A power port is the inlet of a device where it draws its power. Power ports are upstream-facing towards power outlets. Alternatively, a power port can connect to a power feed as mentioned in the power feed section to indicate the power source of a PDU's inlet.
!!! info
If the draw of a power port is left empty, it will be dynamically calculated based on the power outlets associated with that power port. This is usually the case on the power ports of devices that supply power, like a PDU.
# Example
Below is a simple diagram demonstrating how power is modelled in NetBox.
!!! note
The power feeds are connected to the same power panel for illustrative purposes; usually, you would have such feeds diversely connected to panels to avoid the single point of failure.
```
+---------------+
| Power panel 1 |
+---------------+
| |
| |
+--------------+ +--------------+
| Power feed 1 | | Power feed 2 |
+--------------+ +--------------+
| |
| |
| | <-- Power ports
+---------+ +---------+
| PDU 1 | | PDU 2 |
+---------+ +---------+
| \ / | <-- Power outlets
| \ / |
| \ / |
| X |
| / \ |
| / \ |
| / \ | <-- Power ports
+--------+ +--------+
| Server | | Router |
+--------+ +--------+
```

View File

@@ -24,6 +24,20 @@ Each user within NetBox can associate his or her account with an RSA public key.
User keys may be created by users individually, however they are of no use until they have been activated by a user who already possesses an active user key.
## Supported Key Format
Public key formats supported
- PKCS#1 RSAPublicKey* (PEM header: BEGIN RSA PUBLIC KEY)
- X.509 SubjectPublicKeyInfo** (PEM header: BEGIN PUBLIC KEY)
- **OpenSSH line format is not supported.**
Private key formats supported (unencrypted)
- PKCS#1 RSAPrivateKey** (PEM header: BEGIN RSA PRIVATE KEY)
- PKCS#8 PrivateKeyInfo* (PEM header: BEGIN PRIVATE KEY)
## Creating the First User Key
When NetBox is first installed, it contains no encryption keys. Before it can store secrets, a user (typically the superuser) must create a user key. This can be done by navigating to Profile > User Key.

View File

@@ -1,3 +1,66 @@
# v2.7.3 (2020-01-28)
## Enhancements
* [#3310](https://github.com/netbox-community/netbox/issues/3310) - Pre-select site/rack for B side when creating a new cable
* [#3338](https://github.com/netbox-community/netbox/issues/3338) - Include circuit terminations in API representation of circuits
* [#3509](https://github.com/netbox-community/netbox/issues/3509) - Add IP address variables for custom scripts
* [#3978](https://github.com/netbox-community/netbox/issues/3978) - Add VRF filtering to search NAT IP
* [#4005](https://github.com/netbox-community/netbox/issues/4005) - Include timezone context in webhook timestamps
## Bug Fixes
* [#3950](https://github.com/netbox-community/netbox/issues/3950) - Automatically select parent manufacturer when specifying initial device type during device creation
* [#3982](https://github.com/netbox-community/netbox/issues/3982) - Restore tooltip for reservations on rack elevations
* [#3983](https://github.com/netbox-community/netbox/issues/3983) - Permit the creation of multiple unnamed devices
* [#3989](https://github.com/netbox-community/netbox/issues/3989) - Correct HTTP content type assignment for webhooks
* [#3999](https://github.com/netbox-community/netbox/issues/3999) - Do not filter child results by null if non-required parent fields are blank
* [#4008](https://github.com/netbox-community/netbox/issues/4008) - Toggle rack elevation face using front/rear strings
* [#4017](https://github.com/netbox-community/netbox/issues/4017) - Remove redundant tenant field from cluster form
* [#4019](https://github.com/netbox-community/netbox/issues/4019) - Restore border around background devices in rack elevations
* [#4022](https://github.com/netbox-community/netbox/issues/4022) - Fix display of assigned IPs when filtering device interfaces
* [#4025](https://github.com/netbox-community/netbox/issues/4025) - Correct display of cable status (various places)
* [#4027](https://github.com/netbox-community/netbox/issues/4027) - Repair schema migration for #3569 to convert IP addresses with DHCP status
* [#4028](https://github.com/netbox-community/netbox/issues/4028) - Correct URL patterns to match Unicode characters in tag slugs
* [#4030](https://github.com/netbox-community/netbox/issues/4030) - Fix exception when setting interfaces to tagged mode in bulk
* [#4033](https://github.com/netbox-community/netbox/issues/4033) - Restore missing comments field label of various bulk edit forms
---
# v2.7.2 (2020-01-21)
## Enhancements
* [#3135](https://github.com/netbox-community/netbox/issues/3135) - Documented power modelling
* [#3842](https://github.com/netbox-community/netbox/issues/3842) - Add 802.11ax interface type
* [#3954](https://github.com/netbox-community/netbox/issues/3954) - Add `device_bays` filter for devices and device types
## Bug Fixes
* [#3721](https://github.com/netbox-community/netbox/issues/3721) - Allow Unicode characters in tag slugs
* [#3923](https://github.com/netbox-community/netbox/issues/3923) - Indicate validation failure when using SSH-style RSA keys
* [#3951](https://github.com/netbox-community/netbox/issues/3951) - Fix exception in webhook worker due to missing constant
* [#3953](https://github.com/netbox-community/netbox/issues/3953) - Fix validation error when creating child devices
* [#3960](https://github.com/netbox-community/netbox/issues/3960) - Fix legacy device status choice
* [#3962](https://github.com/netbox-community/netbox/issues/3962) - Fix display of unnamed devices in rack elevations
* [#3963](https://github.com/netbox-community/netbox/issues/3963) - Restore tooltip for devices in rack elevations
* [#3964](https://github.com/netbox-community/netbox/issues/3964) - Show borders around devices in rack elevations
* [#3965](https://github.com/netbox-community/netbox/issues/3965) - Indicate the presence of "background" devices in rack elevations
* [#3966](https://github.com/netbox-community/netbox/issues/3966) - Fix filtering of device components by region/site
* [#3967](https://github.com/netbox-community/netbox/issues/3967) - Resolve migration of "other" interface type
---
# v2.7.1 (2020-01-16)
## Bug Fixes
* [#3941](https://github.com/netbox-community/netbox/issues/3941) - Fixed exception when attempting to assign IP to interface
* [#3943](https://github.com/netbox-community/netbox/issues/3943) - Prevent rack elevation links from opening new tabs/windows
* [#3944](https://github.com/netbox-community/netbox/issues/3944) - Fix AttributeError exception when viewing prefixes list
---
# v2.7.0 (2020-01-16)
**Note:** This release completely removes the topology map feature ([#2745](https://github.com/netbox-community/netbox/issues/2745)).
@@ -172,7 +235,7 @@ REDIS = {
'SSL': False,
}
}
```
```
Note that the `CACHE_DATABASE` parameter has been removed and the connection settings have been duplicated for both
`webhooks` and `caching`. This allows the user to make use of separate Redis instances if desired. It is fine to use the

View File

@@ -12,6 +12,7 @@ pages:
- 4. LDAP (Optional): 'installation/4-ldap.md'
- Upgrading NetBox: 'installation/upgrading.md'
- Migrating to Python3: 'installation/migrating-to-python3.md'
- Migrating to systemd: 'installation/migrating-to-systemd.md'
- Configuration:
- Configuring NetBox: 'configuration/index.md'
- Required Settings: 'configuration/required-settings.md'
@@ -24,6 +25,7 @@ pages:
- Virtual Machines: 'core-functionality/virtual-machines.md'
- Services: 'core-functionality/services.md'
- Circuits: 'core-functionality/circuits.md'
- Power: 'core-functionality/power.md'
- Secrets: 'core-functionality/secrets.md'
- Tenancy: 'core-functionality/tenancy.md'
- Additional Features:

View File

@@ -3,11 +3,11 @@ from taggit_serializer.serializers import TaggitSerializer, TagListSerializerFie
from circuits.choices import CircuitStatusChoices
from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer
from dcim.api.nested_serializers import NestedCableSerializer, NestedInterfaceSerializer, NestedSiteSerializer
from dcim.api.serializers import ConnectedEndpointSerializer
from extras.api.customfields import CustomFieldModelSerializer
from tenancy.api.nested_serializers import NestedTenantSerializer
from utilities.api import ChoiceField, ValidatedModelSerializer
from utilities.api import ChoiceField, ValidatedModelSerializer, WritableNestedSerializer
from .nested_serializers import *
@@ -39,18 +39,30 @@ class CircuitTypeSerializer(ValidatedModelSerializer):
fields = ['id', 'name', 'slug', 'description', 'circuit_count']
class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
site = NestedSiteSerializer()
connected_endpoint = NestedInterfaceSerializer()
class Meta:
model = CircuitTermination
fields = ['id', 'url', 'site', 'connected_endpoint', 'port_speed', 'upstream_speed', 'xconnect_id']
class CircuitSerializer(TaggitSerializer, CustomFieldModelSerializer):
provider = NestedProviderSerializer()
status = ChoiceField(choices=CircuitStatusChoices, required=False)
type = NestedCircuitTypeSerializer()
tenant = NestedTenantSerializer(required=False, allow_null=True)
termination_a = CircuitCircuitTerminationSerializer(read_only=True)
termination_z = CircuitCircuitTerminationSerializer(read_only=True)
tags = TagListSerializerField(required=False)
class Meta:
model = Circuit
fields = [
'id', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description',
'comments', 'tags', 'custom_fields', 'created', 'last_updated',
'termination_a', 'termination_z', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
]

View File

@@ -62,7 +62,9 @@ class CircuitTypeViewSet(ModelViewSet):
#
class CircuitViewSet(CustomFieldModelViewSet):
queryset = Circuit.objects.prefetch_related('type', 'tenant', 'provider').prefetch_related('tags')
queryset = Circuit.objects.prefetch_related(
'type', 'tenant', 'provider', 'terminations__site', 'terminations__connected_endpoint__device'
).prefetch_related('tags')
serializer_class = serializers.CircuitSerializer
filterset_class = filters.CircuitFilterSet

View File

@@ -89,7 +89,8 @@ class ProviderBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdi
label='Admin contact'
)
comments = CommentField(
widget=SmallTextarea()
widget=SmallTextarea,
label='Comments'
)
class Meta:

View File

@@ -545,6 +545,7 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_80211N = 'ieee802.11n'
TYPE_80211AC = 'ieee802.11ac'
TYPE_80211AD = 'ieee802.11ad'
TYPE_80211AX = 'ieee802.11ax'
# Cellular
TYPE_GSM = 'gsm'
@@ -650,6 +651,7 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_80211N, 'IEEE 802.11n'),
(TYPE_80211AC, 'IEEE 802.11ac'),
(TYPE_80211AD, 'IEEE 802.11ad'),
(TYPE_80211AX, 'IEEE 802.11ax'),
)
),
(
@@ -800,6 +802,7 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_SUMMITSTACK128: 5310,
TYPE_SUMMITSTACK256: 5320,
TYPE_SUMMITSTACK512: 5330,
TYPE_OTHER: 32767,
}

View File

@@ -4,17 +4,30 @@ from .choices import InterfaceTypeChoices
#
# Rack elevation rendering
# Racks
#
RACK_U_HEIGHT_DEFAULT = 42
RACK_ELEVATION_UNIT_WIDTH_DEFAULT = 230
RACK_ELEVATION_UNIT_HEIGHT_DEFAULT = 20
#
# Interface type groups
# RearPorts
#
REARPORT_POSITIONS_MIN = 1
REARPORT_POSITIONS_MAX = 64
#
# Interfaces
#
INTERFACE_MTU_MIN = 1
INTERFACE_MTU_MAX = 32767 # Max value of a signed 16-bit integer
VIRTUAL_IFACE_TYPES = [
InterfaceTypeChoices.TYPE_VIRTUAL,
InterfaceTypeChoices.TYPE_LAG,
@@ -31,6 +44,17 @@ WIRELESS_IFACE_TYPES = [
NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
#
# PowerFeeds
#
POWERFEED_VOLTAGE_DEFAULT = 120
POWERFEED_AMPERAGE_DEFAULT = 20
POWERFEED_MAX_UTILIZATION_DEFAULT = 80 # Percentage
#
# Cabling and connections
#

View File

@@ -1,6 +1,5 @@
import django_filters
from django.contrib.auth.models import User
from django.db.models import Q
from extras.filters import CustomFieldFilterSet, LocalConfigContextFilterSet, CreatedUpdatedFilterSet
from tenancy.filters import TenancyFilterSet
@@ -356,6 +355,10 @@ class DeviceTypeFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet):
method='_pass_through_ports',
label='Has pass-through ports',
)
device_bays = django_filters.BooleanFilter(
method='_device_bays',
label='Has device bays',
)
tag = TagFilter()
class Meta:
@@ -395,6 +398,9 @@ class DeviceTypeFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet):
rearport_templates__isnull=value
)
def _device_bays(self, queryset, name, value):
return queryset.exclude(device_bay_templates__isnull=value)
class DeviceTypeComponentFilterSet(NameSlugSearchFilterSet):
devicetype_id = django_filters.ModelMultipleChoiceFilter(
@@ -623,6 +629,10 @@ class DeviceFilterSet(LocalConfigContextFilterSet, TenancyFilterSet, CustomField
method='_pass_through_ports',
label='Has pass-through ports',
)
device_bays = django_filters.BooleanFilter(
method='_device_bays',
label='Has device bays',
)
tag = TagFilter()
class Meta:
@@ -676,21 +686,25 @@ class DeviceFilterSet(LocalConfigContextFilterSet, TenancyFilterSet, CustomField
rearports__isnull=value
)
def _device_bays(self, queryset, name, value):
return queryset.exclude(device_bays__isnull=value)
class DeviceComponentFilterSet(django_filters.FilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
region_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__site__region',
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='device__site__region__in',
label='Region (ID)',
)
region = django_filters.ModelMultipleChoiceFilter(
field_name='device__site__region__in',
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
label='Region name (slug)',
field_name='device__site__region__in',
to_field_name='slug',
label='Region (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__site',
@@ -700,6 +714,7 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
site = django_filters.ModelMultipleChoiceFilter(
field_name='device__site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site name (slug)',
)
device_id = django_filters.ModelMultipleChoiceFilter(
@@ -787,35 +802,13 @@ class PowerOutletFilterSet(DeviceComponentFilterSet):
fields = ['id', 'name', 'feed_leg', 'description', 'connection_status']
class InterfaceFilterSet(django_filters.FilterSet):
"""
Not using DeviceComponentFilterSet for Interfaces because we need to check for VirtualChassis membership.
"""
class InterfaceFilterSet(DeviceComponentFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
region_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__site__region',
queryset=Region.objects.all(),
label='Region (ID)',
)
region = django_filters.ModelMultipleChoiceFilter(
field_name='device__site__region__in',
queryset=Region.objects.all(),
label='Region name (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__site',
queryset=Site.objects.all(),
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
field_name='device__site__slug',
to_field_name='slug',
queryset=Site.objects.all(),
label='Site name (slug)',
)
# Override device and device_id filters from DeviceComponentFilterSet to match against any peer virtual chassis
# members
device = MultiValueCharFilter(
method='filter_device',
field_name='name',
@@ -859,14 +852,6 @@ class InterfaceFilterSet(django_filters.FilterSet):
model = Interface
fields = ['id', 'name', 'connection_status', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'description']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(description__icontains=value)
).distinct()
def filter_device(self, queryset, name, value):
try:
devices = Device.objects.filter(**{'{}__in'.format(name): value})

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,6 @@ from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.forms.array import SimpleArrayField
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import Q
from mptt.forms import TreeNodeChoiceField
from netaddr import EUI
from netaddr.core import AddrFormatError
@@ -66,21 +65,25 @@ class DeviceComponentFilterForm(BootstrapMixin, forms.Form):
required=False,
label='Search'
)
region = TreeNodeChoiceField(
region = FilterChoiceField(
queryset=Region.objects.all(),
required=False,
widget=APISelect(
api_url="/api/dcim/regions/"
)
)
site = forms.ModelChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
required=False,
help_text='Name of parent site',
error_messages={
'invalid_choice': 'Site not found.',
}
widget=APISelectMultiple(
api_url='/api/dcim/regions/',
value_field='slug',
filter_for={
'site': 'region'
}
)
)
site = FilterChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
widget=APISelectMultiple(
api_url="/api/dcim/sites/",
value_field="slug"
)
)
@@ -673,7 +676,8 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
widget=StaticSelect2()
)
comments = CommentField(
widget=SmallTextarea
widget=SmallTextarea,
label='Comments'
)
class Meta:
@@ -1297,8 +1301,8 @@ class RearPortTemplateCreateForm(ComponentForm):
widget=StaticSelect2(),
)
positions = forms.IntegerField(
min_value=1,
max_value=64,
min_value=REARPORT_POSITIONS_MIN,
max_value=REARPORT_POSITIONS_MAX,
initial=1,
help_text='The number of front ports which may be mapped to each rear port'
)
@@ -1638,6 +1642,16 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
if instance and instance.cluster is not None:
kwargs['initial']['cluster_group'] = instance.cluster.group
if 'device_type' in kwargs['initial'] and 'manufacturer' not in kwargs['initial']:
device_type_id = kwargs['initial']['device_type']
manufacturer_id = DeviceType.objects.filter(pk=device_type_id).values_list('manufacturer__pk', flat=True).first()
kwargs['initial']['manufacturer'] = manufacturer_id
if 'cluster' in kwargs['initial'] and 'cluster_group' not in kwargs['initial']:
cluster_id = kwargs['initial']['cluster']
cluster_group_id = Cluster.objects.filter(pk=cluster_id).values_list('group__pk', flat=True).first()
kwargs['initial']['cluster_group'] = cluster_group_id
super().__init__(*args, **kwargs)
if self.instance.pk:
@@ -2119,8 +2133,8 @@ class DeviceBulkAddInterfaceForm(DeviceBulkAddComponentForm):
)
mtu = forms.IntegerField(
required=False,
min_value=1,
max_value=32767,
min_value=INTERFACE_MTU_MIN,
max_value=INTERFACE_MTU_MAX,
label='MTU'
)
mgmt_only = forms.BooleanField(
@@ -2606,8 +2620,8 @@ class InterfaceCreateForm(InterfaceCommonForm, ComponentForm, forms.Form):
)
mtu = forms.IntegerField(
required=False,
min_value=1,
max_value=32767,
min_value=INTERFACE_MTU_MIN,
max_value=INTERFACE_MTU_MAX,
label='MTU'
)
mac_address = forms.CharField(
@@ -2735,7 +2749,7 @@ class InterfaceCSVForm(forms.ModelForm):
return self.cleaned_data['enabled']
class InterfaceBulkEditForm(InterfaceCommonForm, BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Interface.objects.all(),
widget=forms.MultipleHiddenInput()
@@ -2761,8 +2775,8 @@ class InterfaceBulkEditForm(InterfaceCommonForm, BootstrapMixin, AddRemoveTagsFo
)
mtu = forms.IntegerField(
required=False,
min_value=1,
max_value=32767,
min_value=INTERFACE_MTU_MIN,
max_value=INTERFACE_MTU_MAX,
label='MTU'
)
mgmt_only = forms.NullBooleanField(
@@ -2816,6 +2830,18 @@ class InterfaceBulkEditForm(InterfaceCommonForm, BootstrapMixin, AddRemoveTagsFo
else:
self.fields['lag'].choices = []
def clean(self):
# Untagged interfaces cannot be assigned tagged VLANs
if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and self.cleaned_data['tagged_vlans']:
raise forms.ValidationError({
'mode': "An access interface cannot have tagged VLANs assigned."
})
# Remove all tagged VLAN assignments from "tagged all" interfaces
elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED_ALL:
self.cleaned_data['tagged_vlans'] = []
class InterfaceBulkRenameForm(BulkRenameForm):
pk = forms.ModelMultipleChoiceField(
@@ -3041,8 +3067,8 @@ class RearPortCreateForm(ComponentForm):
widget=StaticSelect2(),
)
positions = forms.IntegerField(
min_value=1,
max_value=64,
min_value=REARPORT_POSITIONS_MIN,
max_value=REARPORT_POSITIONS_MAX,
initial=1,
help_text='The number of front ports which may be mapped to each rear port'
)
@@ -3164,6 +3190,11 @@ class ConnectCableToDeviceForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFo
'termination_b_site', 'termination_b_rack', 'termination_b_device', 'termination_b_id', 'type', 'status',
'label', 'color', 'length', 'length_unit',
]
widgets = {
'status': StaticSelect2,
'type': StaticSelect2,
'length_unit': StaticSelect2,
}
class ConnectCableToConsolePortForm(ConnectCableToDeviceForm):
@@ -3359,6 +3390,11 @@ class CableForm(BootstrapMixin, forms.ModelForm):
fields = [
'type', 'status', 'label', 'color', 'length', 'length_unit',
]
widgets = {
'status': StaticSelect2,
'type': StaticSelect2,
'length_unit': StaticSelect2,
}
class CableCSVForm(forms.ModelForm):
@@ -3509,7 +3545,7 @@ class CableBulkEditForm(BootstrapMixin, BulkEditForm):
required=False
)
color = forms.CharField(
max_length=6,
max_length=6, # RGB color code
required=False,
widget=ColorSelect()
)
@@ -3588,7 +3624,7 @@ class CableFilterForm(BootstrapMixin, forms.Form):
widget=StaticSelect2()
)
color = forms.CharField(
max_length=6,
max_length=6, # RGB color code
required=False,
widget=ColorSelect()
)
@@ -4383,8 +4419,9 @@ class PowerFeedBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd
max_utilization = forms.IntegerField(
required=False
)
comments = forms.CharField(
required=False
comments = CommentField(
widget=SmallTextarea,
label='Comments'
)
class Meta:

View File

@@ -0,0 +1,20 @@
from django.db import migrations
def interface_type_to_slug(apps, schema_editor):
Interface = apps.get_model('dcim', 'Interface')
Interface.objects.filter(type=32767).update(type='other')
class Migration(migrations.Migration):
dependencies = [
('dcim', '0090_cable_termination_models'),
]
operations = [
# Missed type "other" in the initial migration (see #3967)
migrations.RunPython(
code=interface_type_to_slug
),
]

View File

@@ -390,26 +390,44 @@ class RackElevationHelperMixin:
color = device.device_role.color
link = drawing.add(
drawing.a(
reverse('dcim:device', kwargs={'pk': device.pk}), fill='black'
href=reverse('dcim:device', kwargs={'pk': device.pk}),
target='_top',
fill='black'
)
)
link.add(drawing.rect(start, end, fill='#{}'.format(color)))
link.set_desc('{}{} ({}U) {} {}'.format(
device.device_role, device.device_type.display_name,
device.device_type.u_height, device.asset_tag or '', device.serial or ''
))
link.add(drawing.rect(start, end, style='fill: #{}'.format(color), class_='slot'))
hex_color = '#{}'.format(foreground_color(color))
link.add(drawing.text(device.name, insert=text, fill=hex_color))
link.add(drawing.text(str(device), insert=text, fill=hex_color))
@staticmethod
def _draw_device_rear(drawing, device, start, end, text):
drawing.add(drawing.rect(start, end, class_="blocked"))
drawing.add(drawing.text(device.name, insert=text))
rect = drawing.rect(start, end, class_="slot blocked")
rect.set_desc('{}{} ({}U) {} {}'.format(
device.device_role, device.device_type.display_name,
device.device_type.u_height, device.asset_tag or '', device.serial or ''
))
drawing.add(rect)
drawing.add(drawing.text(str(device), insert=text))
@staticmethod
def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_):
def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_, reservation):
link = drawing.add(
drawing.a('{}?{}'.format(
reverse('dcim:device_add'),
urlencode({'rack': rack.pk, 'site': rack.site.pk, 'face': face_id, 'position': id_})
))
drawing.a(
href='{}?{}'.format(
reverse('dcim:device_add'),
urlencode({'rack': rack.pk, 'site': rack.site.pk, 'face': face_id, 'position': id_})
),
target='_top'
)
)
if reservation:
link.set_desc('{}{} · {}'.format(
reservation.description, reservation.user, reservation.created
))
link.add(drawing.rect(start, end, class_=class_))
link.add(drawing.text("add device", insert=text, class_='add-device'))
@@ -439,12 +457,13 @@ class RackElevationHelperMixin:
else:
# Draw shallow devices, reservations, or empty units
class_ = 'slot'
reservation = reserved_units.get(unit["id"])
if device:
class_ += ' occupied'
if unit["id"] in reserved_units:
if reservation:
class_ += ' reserved'
self._draw_empty(
drawing, self, start_cordinates, end_cordinates, text_cordinates, unit["id"], face, class_
drawing, self, start_cordinates, end_cordinates, text_cordinates, unit["id"], face, class_, reservation
)
unit_cursor += height
@@ -454,7 +473,27 @@ class RackElevationHelperMixin:
return drawing
def get_elevation_svg(self, face=DeviceFaceChoices.FACE_FRONT, unit_width=230, unit_height=20):
def merge_elevations(self, face):
elevation = self.get_rack_units(face=face, expand_devices=False)
other_face = DeviceFaceChoices.FACE_FRONT if face == DeviceFaceChoices.FACE_REAR else DeviceFaceChoices.FACE_REAR
other = self.get_rack_units(face=other_face)
unit_cursor = 0
for u in elevation:
o = other[unit_cursor]
if not u['device'] and o['device']:
u['device'] = o['device']
u['height'] = 1
unit_cursor += u.get('height', 1)
return elevation
def get_elevation_svg(
self,
face=DeviceFaceChoices.FACE_FRONT,
unit_width=RACK_ELEVATION_UNIT_WIDTH_DEFAULT,
unit_height=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT
):
"""
Return an SVG of the rack elevation
@@ -463,8 +502,8 @@ class RackElevationHelperMixin:
:param unit_height: Height of each rack unit for the rendered drawing. Note this is not the total
height of the elevation
"""
elevation = self.get_rack_units(face=face, expand_devices=False)
reserved_units = self.get_reserved_units().keys()
elevation = self.merge_elevations(face)
reserved_units = self.get_reserved_units()
return self._draw_elevations(elevation, reserved_units, face, unit_width, unit_height)
@@ -540,7 +579,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin):
help_text='Rail-to-rail width'
)
u_height = models.PositiveSmallIntegerField(
default=42,
default=RACK_U_HEIGHT_DEFAULT,
verbose_name='Height (U)',
validators=[MinValueValidator(1), MaxValueValidator(100)]
)
@@ -1416,10 +1455,11 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
# Check for a duplicate name on a device assigned to the same Site and no Tenant. This is necessary
# because Django does not consider two NULL fields to be equal, and thus will not trigger a violation
# of the uniqueness constraint without manual intervention.
if self.tenant is None and Device.objects.exclude(pk=self.pk).filter(name=self.name, tenant__isnull=True):
raise ValidationError({
'name': 'A device with this name already exists.'
})
if self.name and self.tenant is None:
if Device.objects.exclude(pk=self.pk).filter(name=self.name, tenant__isnull=True):
raise ValidationError({
'name': 'A device with this name already exists.'
})
super().validate_unique(exclude)
@@ -1459,7 +1499,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
try:
# Child devices cannot be assigned to a rack face/unit
if self.device_type.is_child_device and self.face is not None:
if self.device_type.is_child_device and self.face:
raise ValidationError({
'face': "Child device types cannot be assigned to a rack face. This is an attribute of the "
"parent device."
@@ -1829,15 +1869,15 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
)
voltage = models.PositiveSmallIntegerField(
validators=[MinValueValidator(1)],
default=120
default=POWERFEED_VOLTAGE_DEFAULT
)
amperage = models.PositiveSmallIntegerField(
validators=[MinValueValidator(1)],
default=20
default=POWERFEED_AMPERAGE_DEFAULT
)
max_utilization = models.PositiveSmallIntegerField(
validators=[MinValueValidator(1), MaxValueValidator(100)],
default=80,
default=POWERFEED_MAX_UTILIZATION_DEFAULT,
help_text="Maximum permissible draw (percentage)"
)
available_power = models.PositiveIntegerField(

View File

@@ -4,6 +4,7 @@ from netaddr import IPNetwork
from rest_framework import status
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
from dcim.api import serializers
from dcim.choices import *
from dcim.constants import *
from dcim.models import (
@@ -595,6 +596,21 @@ class RackTest(APITestCase):
self.assertEqual(response.data['count'], 42)
def test_get_rack_elevation(self):
url = reverse('dcim-api:rack-elevation', kwargs={'pk': self.rack1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 42)
def test_get_rack_elevation_svg(self):
url = '{}?render=svg'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': self.rack1.pk}))
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.get('Content-Type'), 'image/svg+xml')
def test_list_racks(self):
url = reverse('dcim-api:rack-list')
@@ -1900,6 +1916,31 @@ class DeviceTest(APITestCase):
self.assertEqual(response.data['device_role']['id'], self.devicerole1.pk)
self.assertEqual(response.data['cluster']['id'], self.cluster1.pk)
def test_get_device_graphs(self):
device_ct = ContentType.objects.get_for_model(Device)
self.graph1 = Graph.objects.create(
type=device_ct,
name='Test Graph 1',
source='http://example.com/graphs.py?device={{ obj.name }}&foo=1'
)
self.graph2 = Graph.objects.create(
type=device_ct,
name='Test Graph 2',
source='http://example.com/graphs.py?device={{ obj.name }}&foo=2'
)
self.graph3 = Graph.objects.create(
type=device_ct,
name='Test Graph 3',
source='http://example.com/graphs.py?device={{ obj.name }}&foo=3'
)
url = reverse('dcim-api:device-graphs', kwargs={'pk': self.device1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(len(response.data), 3)
self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?device=Test Device 1&foo=1')
def test_list_devices(self):
url = reverse('dcim-api:device-list')
@@ -2134,6 +2175,31 @@ class ConsolePortTest(APITestCase):
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(ConsolePort.objects.count(), 2)
def test_trace_consoleport(self):
peer_device = Device.objects.create(
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first(),
name='Peer Device'
)
console_server_port = ConsoleServerPort.objects.create(
device=peer_device,
name='Console Server Port 1'
)
cable = Cable(termination_a=self.consoleport1, termination_b=console_server_port, label='Cable 1')
cable.save()
url = reverse('dcim-api:consoleport-trace', kwargs={'pk': self.consoleport1.pk})
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
segment1 = response.data[0]
self.assertEqual(segment1[0]['name'], self.consoleport1.name)
self.assertEqual(segment1[1]['label'], cable.label)
self.assertEqual(segment1[2]['name'], console_server_port.name)
class ConsoleServerPortTest(APITestCase):
@@ -2245,6 +2311,31 @@ class ConsoleServerPortTest(APITestCase):
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(ConsoleServerPort.objects.count(), 2)
def test_trace_consoleserverport(self):
peer_device = Device.objects.create(
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first(),
name='Peer Device'
)
console_port = ConsolePort.objects.create(
device=peer_device,
name='Console Port 1'
)
cable = Cable(termination_a=self.consoleserverport1, termination_b=console_port, label='Cable 1')
cable.save()
url = reverse('dcim-api:consoleserverport-trace', kwargs={'pk': self.consoleserverport1.pk})
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
segment1 = response.data[0]
self.assertEqual(segment1[0]['name'], self.consoleserverport1.name)
self.assertEqual(segment1[1]['label'], cable.label)
self.assertEqual(segment1[2]['name'], console_port.name)
class PowerPortTest(APITestCase):
@@ -2358,6 +2449,31 @@ class PowerPortTest(APITestCase):
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(PowerPort.objects.count(), 2)
def test_trace_powerport(self):
peer_device = Device.objects.create(
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first(),
name='Peer Device'
)
power_outlet = PowerOutlet.objects.create(
device=peer_device,
name='Power Outlet 1'
)
cable = Cable(termination_a=self.powerport1, termination_b=power_outlet, label='Cable 1')
cable.save()
url = reverse('dcim-api:powerport-trace', kwargs={'pk': self.powerport1.pk})
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
segment1 = response.data[0]
self.assertEqual(segment1[0]['name'], self.powerport1.name)
self.assertEqual(segment1[1]['label'], cable.label)
self.assertEqual(segment1[2]['name'], power_outlet.name)
class PowerOutletTest(APITestCase):
@@ -2469,6 +2585,31 @@ class PowerOutletTest(APITestCase):
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(PowerOutlet.objects.count(), 2)
def test_trace_poweroutlet(self):
peer_device = Device.objects.create(
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first(),
name='Peer Device'
)
power_port = PowerPort.objects.create(
device=peer_device,
name='Power Port 1'
)
cable = Cable(termination_a=self.poweroutlet1, termination_b=power_port, label='Cable 1')
cable.save()
url = reverse('dcim-api:poweroutlet-trace', kwargs={'pk': self.poweroutlet1.pk})
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
segment1 = response.data[0]
self.assertEqual(segment1[0]['name'], self.poweroutlet1.name)
self.assertEqual(segment1[1]['label'], cable.label)
self.assertEqual(segment1[2]['name'], power_port.name)
class InterfaceTest(APITestCase):
@@ -2673,6 +2814,262 @@ class InterfaceTest(APITestCase):
self.assertEqual(Interface.objects.count(), 2)
class FrontPortTest(APITestCase):
def setUp(self):
super().setUp()
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
devicetype = DeviceType.objects.create(
manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
)
devicerole = DeviceRole.objects.create(
name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
)
self.device = Device.objects.create(
device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site
)
rear_ports = RearPort.objects.bulk_create((
RearPort(device=self.device, name='Rear Port 1', type=PortTypeChoices.TYPE_8P8C),
RearPort(device=self.device, name='Rear Port 2', type=PortTypeChoices.TYPE_8P8C),
RearPort(device=self.device, name='Rear Port 3', type=PortTypeChoices.TYPE_8P8C),
RearPort(device=self.device, name='Rear Port 4', type=PortTypeChoices.TYPE_8P8C),
RearPort(device=self.device, name='Rear Port 5', type=PortTypeChoices.TYPE_8P8C),
RearPort(device=self.device, name='Rear Port 6', type=PortTypeChoices.TYPE_8P8C),
))
self.frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[0])
self.frontport3 = FrontPort.objects.create(device=self.device, name='Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[1])
self.frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 3', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[2])
def test_get_frontport(self):
url = reverse('dcim-api:frontport-detail', kwargs={'pk': self.frontport1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.frontport1.name)
def test_list_frontports(self):
url = reverse('dcim-api:frontport-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
def test_list_frontports_brief(self):
url = reverse('dcim-api:frontport-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['cable', 'device', 'id', 'name', 'url']
)
def test_create_frontport(self):
rear_port = RearPort.objects.get(name='Rear Port 4')
data = {
'device': self.device.pk,
'name': 'Front Port 4',
'type': PortTypeChoices.TYPE_8P8C,
'rear_port': rear_port.pk,
'rear_port_position': 1,
}
url = reverse('dcim-api:frontport-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(FrontPort.objects.count(), 4)
frontport4 = FrontPort.objects.get(pk=response.data['id'])
self.assertEqual(frontport4.device_id, data['device'])
self.assertEqual(frontport4.name, data['name'])
def test_create_frontport_bulk(self):
rear_ports = RearPort.objects.filter(frontports__isnull=True)
data = [
{
'device': self.device.pk,
'name': 'Front Port 4',
'type': PortTypeChoices.TYPE_8P8C,
'rear_port': rear_ports[0].pk,
'rear_port_position': 1,
},
{
'device': self.device.pk,
'name': 'Front Port 5',
'type': PortTypeChoices.TYPE_8P8C,
'rear_port': rear_ports[1].pk,
'rear_port_position': 1,
},
{
'device': self.device.pk,
'name': 'Front Port 6',
'type': PortTypeChoices.TYPE_8P8C,
'rear_port': rear_ports[2].pk,
'rear_port_position': 1,
},
]
url = reverse('dcim-api:frontport-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(FrontPort.objects.count(), 6)
self.assertEqual(response.data[0]['name'], data[0]['name'])
self.assertEqual(response.data[1]['name'], data[1]['name'])
self.assertEqual(response.data[2]['name'], data[2]['name'])
def test_update_frontport(self):
rear_port = RearPort.objects.get(name='Rear Port 4')
data = {
'device': self.device.pk,
'name': 'Front Port X',
'type': PortTypeChoices.TYPE_110_PUNCH,
'rear_port': rear_port.pk,
'rear_port_position': 1,
}
url = reverse('dcim-api:frontport-detail', kwargs={'pk': self.frontport1.pk})
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(FrontPort.objects.count(), 3)
frontport1 = FrontPort.objects.get(pk=response.data['id'])
self.assertEqual(frontport1.name, data['name'])
self.assertEqual(frontport1.type, data['type'])
self.assertEqual(frontport1.rear_port, rear_port)
def test_delete_frontport(self):
url = reverse('dcim-api:frontport-detail', kwargs={'pk': self.frontport1.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(FrontPort.objects.count(), 2)
class RearPortTest(APITestCase):
def setUp(self):
super().setUp()
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
devicetype = DeviceType.objects.create(
manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
)
devicerole = DeviceRole.objects.create(
name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
)
self.device = Device.objects.create(
device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site
)
self.rearport1 = RearPort.objects.create(device=self.device, type=PortTypeChoices.TYPE_8P8C, name='Rear Port 1')
self.rearport3 = RearPort.objects.create(device=self.device, type=PortTypeChoices.TYPE_8P8C, name='Rear Port 2')
self.rearport1 = RearPort.objects.create(device=self.device, type=PortTypeChoices.TYPE_8P8C, name='Rear Port 3')
def test_get_rearport(self):
url = reverse('dcim-api:rearport-detail', kwargs={'pk': self.rearport1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.rearport1.name)
def test_list_rearports(self):
url = reverse('dcim-api:rearport-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
def test_list_rearports_brief(self):
url = reverse('dcim-api:rearport-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['cable', 'device', 'id', 'name', 'url']
)
def test_create_rearport(self):
data = {
'device': self.device.pk,
'name': 'Front Port 4',
'type': PortTypeChoices.TYPE_8P8C,
}
url = reverse('dcim-api:rearport-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(RearPort.objects.count(), 4)
rearport4 = RearPort.objects.get(pk=response.data['id'])
self.assertEqual(rearport4.device_id, data['device'])
self.assertEqual(rearport4.name, data['name'])
def test_create_rearport_bulk(self):
data = [
{
'device': self.device.pk,
'name': 'Rear Port 4',
'type': PortTypeChoices.TYPE_8P8C,
},
{
'device': self.device.pk,
'name': 'Rear Port 5',
'type': PortTypeChoices.TYPE_8P8C,
},
{
'device': self.device.pk,
'name': 'Rear Port 6',
'type': PortTypeChoices.TYPE_8P8C,
},
]
url = reverse('dcim-api:rearport-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(RearPort.objects.count(), 6)
self.assertEqual(response.data[0]['name'], data[0]['name'])
self.assertEqual(response.data[1]['name'], data[1]['name'])
self.assertEqual(response.data[2]['name'], data[2]['name'])
def test_update_rearport(self):
data = {
'device': self.device.pk,
'name': 'Front Port X',
'type': PortTypeChoices.TYPE_110_PUNCH
}
url = reverse('dcim-api:rearport-detail', kwargs={'pk': self.rearport1.pk})
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(RearPort.objects.count(), 3)
rearport1 = RearPort.objects.get(pk=response.data['id'])
self.assertEqual(rearport1.name, data['name'])
self.assertEqual(rearport1.type, data['type'])
def test_delete_rearport(self):
url = reverse('dcim-api:rearport-detail', kwargs={'pk': self.rearport1.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(RearPort.objects.count(), 2)
class DeviceBayTest(APITestCase):
def setUp(self):

View File

@@ -595,12 +595,11 @@ class DeviceTypeTestCase(TestCase):
params = {'pass_through_ports': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
# TODO: Add device_bay filter
# def test_device_bays(self):
# params = {'device_bays': 'true'}
# self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
# params = {'device_bays': 'false'}
# self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_device_bays(self):
params = {'device_bays': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'device_bays': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
class ConsolePortTemplateTestCase(TestCase):
@@ -1322,12 +1321,11 @@ class DeviceTestCase(TestCase):
params = {'pass_through_ports': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
# TODO: Add device_bay filter
# def test_device_bays(self):
# params = {'device_bays': 'true'}
# self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
# params = {'device_bays': 'false'}
# self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_device_bays(self):
params = {'device_bays': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'device_bays': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_local_context_data(self):
params = {'local_context_data': 'true'}
@@ -1343,16 +1341,28 @@ class ConsolePortTestCase(TestCase):
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Site 1', slug='site1')
regions = (
Region(name='Region 1', slug='region-1'),
Region(name='Region 2', slug='region-2'),
Region(name='Region 3', slug='region-3'),
)
for region in regions:
region.save()
sites = Site.objects.bulk_create((
Site(name='Site 1', slug='site-1', region=regions[0]),
Site(name='Site 2', slug='site-2', region=regions[1]),
Site(name='Site 3', slug='site-3', region=regions[2]),
Site(name='Site X', slug='site-x'),
))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=site),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=site),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=site),
Device(name=None, device_type=device_type, device_role=device_role, site=site), # For cable connections
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2]),
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
)
Device.objects.bulk_create(devices)
@@ -1392,6 +1402,20 @@ class ConsolePortTestCase(TestCase):
params = {'connection_status': 'True'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_region(self):
regions = Region.objects.all()[:2]
params = {'region_id': [regions[0].pk, regions[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'region': [regions[0].slug, regions[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'site': [sites[0].slug, sites[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_device(self):
devices = Device.objects.all()[:2]
params = {'device_id': [devices[0].pk, devices[1].pk]}
@@ -1413,16 +1437,28 @@ class ConsoleServerPortTestCase(TestCase):
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Site 1', slug='site1')
regions = (
Region(name='Region 1', slug='region-1'),
Region(name='Region 2', slug='region-2'),
Region(name='Region 3', slug='region-3'),
)
for region in regions:
region.save()
sites = Site.objects.bulk_create((
Site(name='Site 1', slug='site-1', region=regions[0]),
Site(name='Site 2', slug='site-2', region=regions[1]),
Site(name='Site 3', slug='site-3', region=regions[2]),
Site(name='Site X', slug='site-x'),
))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=site),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=site),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=site),
Device(name=None, device_type=device_type, device_role=device_role, site=site), # For cable connections
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2]),
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
)
Device.objects.bulk_create(devices)
@@ -1462,6 +1498,20 @@ class ConsoleServerPortTestCase(TestCase):
params = {'connection_status': 'True'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_region(self):
regions = Region.objects.all()[:2]
params = {'region_id': [regions[0].pk, regions[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'region': [regions[0].slug, regions[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'site': [sites[0].slug, sites[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_device(self):
devices = Device.objects.all()[:2]
params = {'device_id': [devices[0].pk, devices[1].pk]}
@@ -1483,16 +1533,28 @@ class PowerPortTestCase(TestCase):
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Site 1', slug='site1')
regions = (
Region(name='Region 1', slug='region-1'),
Region(name='Region 2', slug='region-2'),
Region(name='Region 3', slug='region-3'),
)
for region in regions:
region.save()
sites = Site.objects.bulk_create((
Site(name='Site 1', slug='site-1', region=regions[0]),
Site(name='Site 2', slug='site-2', region=regions[1]),
Site(name='Site 3', slug='site-3', region=regions[2]),
Site(name='Site X', slug='site-x'),
))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=site),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=site),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=site),
Device(name=None, device_type=device_type, device_role=device_role, site=site), # For cable connections
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2]),
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
)
Device.objects.bulk_create(devices)
@@ -1540,6 +1602,20 @@ class PowerPortTestCase(TestCase):
params = {'connection_status': 'True'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_region(self):
regions = Region.objects.all()[:2]
params = {'region_id': [regions[0].pk, regions[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'region': [regions[0].slug, regions[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'site': [sites[0].slug, sites[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_device(self):
devices = Device.objects.all()[:2]
params = {'device_id': [devices[0].pk, devices[1].pk]}
@@ -1561,16 +1637,28 @@ class PowerOutletTestCase(TestCase):
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Site 1', slug='site1')
regions = (
Region(name='Region 1', slug='region-1'),
Region(name='Region 2', slug='region-2'),
Region(name='Region 3', slug='region-3'),
)
for region in regions:
region.save()
sites = Site.objects.bulk_create((
Site(name='Site 1', slug='site-1', region=regions[0]),
Site(name='Site 2', slug='site-2', region=regions[1]),
Site(name='Site 3', slug='site-3', region=regions[2]),
Site(name='Site X', slug='site-x'),
))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=site),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=site),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=site),
Device(name=None, device_type=device_type, device_role=device_role, site=site), # For cable connections
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2]),
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
)
Device.objects.bulk_create(devices)
@@ -1615,6 +1703,20 @@ class PowerOutletTestCase(TestCase):
params = {'connection_status': 'True'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_region(self):
regions = Region.objects.all()[:2]
params = {'region_id': [regions[0].pk, regions[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'region': [regions[0].slug, regions[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'site': [sites[0].slug, sites[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_device(self):
devices = Device.objects.all()[:2]
params = {'device_id': [devices[0].pk, devices[1].pk]}
@@ -1636,16 +1738,28 @@ class InterfaceTestCase(TestCase):
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Site 1', slug='site1')
regions = (
Region(name='Region 1', slug='region-1'),
Region(name='Region 2', slug='region-2'),
Region(name='Region 3', slug='region-3'),
)
for region in regions:
region.save()
sites = Site.objects.bulk_create((
Site(name='Site 1', slug='site-1', region=regions[0]),
Site(name='Site 2', slug='site-2', region=regions[1]),
Site(name='Site 3', slug='site-3', region=regions[2]),
Site(name='Site X', slug='site-x'),
))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=site),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=site),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=site),
Device(name=None, device_type=device_type, device_role=device_role, site=site), # For cable connections
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2]),
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
)
Device.objects.bulk_create(devices)
@@ -1702,6 +1816,20 @@ class InterfaceTestCase(TestCase):
params = {'description': ['First', 'Second']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_region(self):
regions = Region.objects.all()[:2]
params = {'region_id': [regions[0].pk, regions[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'region': [regions[0].slug, regions[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'site': [sites[0].slug, sites[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_device(self):
devices = Device.objects.all()[:2]
params = {'device_id': [devices[0].pk, devices[1].pk]}
@@ -1737,16 +1865,28 @@ class FrontPortTestCase(TestCase):
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Site 1', slug='site1')
regions = (
Region(name='Region 1', slug='region-1'),
Region(name='Region 2', slug='region-2'),
Region(name='Region 3', slug='region-3'),
)
for region in regions:
region.save()
sites = Site.objects.bulk_create((
Site(name='Site 1', slug='site-1', region=regions[0]),
Site(name='Site 2', slug='site-2', region=regions[1]),
Site(name='Site 3', slug='site-3', region=regions[2]),
Site(name='Site X', slug='site-x'),
))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=site),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=site),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=site),
Device(name=None, device_type=device_type, device_role=device_role, site=site), # For cable connections
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2]),
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
)
Device.objects.bulk_create(devices)
@@ -1793,6 +1933,20 @@ class FrontPortTestCase(TestCase):
params = {'description': ['First', 'Second']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_region(self):
regions = Region.objects.all()[:2]
params = {'region_id': [regions[0].pk, regions[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'region': [regions[0].slug, regions[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'site': [sites[0].slug, sites[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_device(self):
devices = Device.objects.all()[:2]
params = {'device_id': [devices[0].pk, devices[1].pk]}
@@ -1814,16 +1968,28 @@ class RearPortTestCase(TestCase):
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Site 1', slug='site1')
regions = (
Region(name='Region 1', slug='region-1'),
Region(name='Region 2', slug='region-2'),
Region(name='Region 3', slug='region-3'),
)
for region in regions:
region.save()
sites = Site.objects.bulk_create((
Site(name='Site 1', slug='site-1', region=regions[0]),
Site(name='Site 2', slug='site-2', region=regions[1]),
Site(name='Site 3', slug='site-3', region=regions[2]),
Site(name='Site X', slug='site-x'),
))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=site),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=site),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=site),
Device(name=None, device_type=device_type, device_role=device_role, site=site), # For cable connections
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2]),
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
)
Device.objects.bulk_create(devices)
@@ -1864,6 +2030,20 @@ class RearPortTestCase(TestCase):
params = {'description': ['First', 'Second']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_region(self):
regions = Region.objects.all()[:2]
params = {'region_id': [regions[0].pk, regions[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'region': [regions[0].slug, regions[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'site': [sites[0].slug, sites[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_device(self):
devices = Device.objects.all()[:2]
params = {'device_id': [devices[0].pk, devices[1].pk]}
@@ -1885,15 +2065,27 @@ class DeviceBayTestCase(TestCase):
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Site 1', slug='site1')
regions = (
Region(name='Region 1', slug='region-1'),
Region(name='Region 2', slug='region-2'),
Region(name='Region 3', slug='region-3'),
)
for region in regions:
region.save()
sites = Site.objects.bulk_create((
Site(name='Site 1', slug='site-1', region=regions[0]),
Site(name='Site 2', slug='site-2', region=regions[1]),
Site(name='Site 3', slug='site-3', region=regions[2]),
Site(name='Site X', slug='site-x'),
))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=site),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=site),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=site),
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2]),
)
Device.objects.bulk_create(devices)
@@ -1917,6 +2109,20 @@ class DeviceBayTestCase(TestCase):
params = {'description': ['First', 'Second']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_region(self):
regions = Region.objects.all()[:2]
params = {'region_id': [regions[0].pk, regions[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'region': [regions[0].slug, regions[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'site': [sites[0].slug, sites[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_device(self):
devices = Device.objects.all()[:2]
params = {'device_id': [devices[0].pk, devices[1].pk]}

View File

@@ -2,6 +2,7 @@ from django.test import TestCase
from dcim.forms import *
from dcim.models import *
from virtualization.models import Cluster, ClusterGroup, ClusterType
def get_id(model, slug):
@@ -10,71 +11,108 @@ def get_id(model, slug):
class DeviceTestCase(TestCase):
fixtures = ['dcim', 'ipam']
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Site 1', slug='site-1')
rack = Rack.objects.create(name='Rack 1', site=site)
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1', u_height=1
)
device_role = DeviceRole.objects.create(
name='Device Role 1', slug='device-role-1', color='ff0000'
)
Platform.objects.create(name='Platform 1', slug='platform-1')
Device.objects.create(
name='Device 1', device_type=device_type, device_role=device_role, site=site, rack=rack, position=1
)
cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
cluster_group = ClusterGroup.objects.create(name='Cluster Group 1', slug='cluster-group-1')
Cluster.objects.create(name='Cluster 1', type=cluster_type, group=cluster_group)
def test_racked_device(self):
test = DeviceForm(data={
'name': 'test',
'device_role': get_id(DeviceRole, 'leaf-switch'),
form = DeviceForm(data={
'name': 'New Device',
'device_role': DeviceRole.objects.first().pk,
'tenant': None,
'manufacturer': get_id(Manufacturer, 'juniper'),
'device_type': get_id(DeviceType, 'qfx5100-48s'),
'site': get_id(Site, 'test1'),
'rack': '1',
'manufacturer': Manufacturer.objects.first().pk,
'device_type': DeviceType.objects.first().pk,
'site': Site.objects.first().pk,
'rack': Rack.objects.first().pk,
'face': DeviceFaceChoices.FACE_FRONT,
'position': 41,
'platform': get_id(Platform, 'juniper-junos'),
'position': 2,
'platform': Platform.objects.first().pk,
'status': DeviceStatusChoices.STATUS_ACTIVE,
})
self.assertTrue(test.is_valid(), test.fields['position'].choices)
self.assertTrue(test.save())
self.assertTrue(form.is_valid())
self.assertTrue(form.save())
def test_racked_device_occupied(self):
test = DeviceForm(data={
form = DeviceForm(data={
'name': 'test',
'device_role': get_id(DeviceRole, 'leaf-switch'),
'device_role': DeviceRole.objects.first().pk,
'tenant': None,
'manufacturer': get_id(Manufacturer, 'juniper'),
'device_type': get_id(DeviceType, 'qfx5100-48s'),
'site': get_id(Site, 'test1'),
'rack': '1',
'manufacturer': Manufacturer.objects.first().pk,
'device_type': DeviceType.objects.first().pk,
'site': Site.objects.first().pk,
'rack': Rack.objects.first().pk,
'face': DeviceFaceChoices.FACE_FRONT,
'position': 1,
'platform': get_id(Platform, 'juniper-junos'),
'platform': Platform.objects.first().pk,
'status': DeviceStatusChoices.STATUS_ACTIVE,
})
self.assertFalse(test.is_valid())
self.assertFalse(form.is_valid())
self.assertIn('position', form.errors)
def test_non_racked_device(self):
test = DeviceForm(data={
'name': 'test',
'device_role': get_id(DeviceRole, 'pdu'),
form = DeviceForm(data={
'name': 'New Device',
'device_role': DeviceRole.objects.first().pk,
'tenant': None,
'manufacturer': get_id(Manufacturer, 'servertech'),
'device_type': get_id(DeviceType, 'cwg-24vym415c9'),
'site': get_id(Site, 'test1'),
'rack': '1',
'face': '',
'manufacturer': Manufacturer.objects.first().pk,
'device_type': DeviceType.objects.first().pk,
'site': Site.objects.first().pk,
'rack': None,
'face': None,
'position': None,
'platform': None,
'platform': Platform.objects.first().pk,
'status': DeviceStatusChoices.STATUS_ACTIVE,
})
self.assertTrue(test.is_valid())
self.assertTrue(test.save())
self.assertTrue(form.is_valid())
self.assertTrue(form.save())
def test_non_racked_device_with_face(self):
test = DeviceForm(data={
'name': 'test',
'device_role': get_id(DeviceRole, 'pdu'),
def test_non_racked_device_with_face_position(self):
form = DeviceForm(data={
'name': 'New Device',
'device_role': DeviceRole.objects.first().pk,
'tenant': None,
'manufacturer': get_id(Manufacturer, 'servertech'),
'device_type': get_id(DeviceType, 'cwg-24vym415c9'),
'site': get_id(Site, 'test1'),
'rack': '1',
'manufacturer': Manufacturer.objects.first().pk,
'device_type': DeviceType.objects.first().pk,
'site': Site.objects.first().pk,
'rack': None,
'face': DeviceFaceChoices.FACE_REAR,
'position': None,
'position': 10,
'platform': None,
'status': DeviceStatusChoices.STATUS_ACTIVE,
})
self.assertTrue(test.is_valid())
self.assertTrue(test.save())
self.assertFalse(form.is_valid())
self.assertIn('face', form.errors)
self.assertIn('position', form.errors)
def test_initial_data_population(self):
device_type = DeviceType.objects.first()
cluster = Cluster.objects.first()
test = DeviceForm(initial={
'device_type': device_type.pk,
'device_role': DeviceRole.objects.first().pk,
'status': DeviceStatusChoices.STATUS_ACTIVE,
'site': Site.objects.first().pk,
'cluster': cluster.pk,
})
# Check that the initial value for the manufacturer is set automatically when assigning the device type
self.assertEqual(test.initial['manufacturer'], device_type.manufacturer.pk)
# Check that the initial value for the cluster group is set automatically when assigning the cluster
self.assertEqual(test.initial['cluster_group'], cluster.group.pk)

View File

@@ -285,7 +285,28 @@ class DeviceTestCase(TestCase):
name='Device Bay 1'
)
def test_device_duplicate_name_per_site(self):
def test_multiple_unnamed_devices(self):
device1 = Device(
site=self.site,
device_type=self.device_type,
device_role=self.device_role,
name=''
)
device1.save()
device2 = Device(
site=device1.site,
device_type=device1.device_type,
device_role=device1.device_role,
name=''
)
device2.full_clean()
device2.save()
self.assertEqual(Device.objects.filter(name='').count(), 2)
def test_device_duplicate_names(self):
device1 = Device(
site=self.site,

View File

@@ -30,6 +30,7 @@ from utilities.views import (
)
from virtualization.models import VirtualMachine
from . import filters, forms, tables
from .choices import DeviceFaceChoices
from .models import (
Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
@@ -376,16 +377,15 @@ class RackElevationListView(PermissionRequiredMixin, View):
page = paginator.page(paginator.num_pages)
# Determine rack face
if request.GET.get('face') == '1':
face_id = 1
else:
face_id = 0
rack_face = request.GET.get('face', DeviceFaceChoices.FACE_FRONT)
if rack_face not in DeviceFaceChoices.values():
rack_face = DeviceFaceChoices.FACE_FRONT
return render(request, 'dcim/rack_elevation_list.html', {
'paginator': paginator,
'page': page,
'total_count': total_count,
'face_id': face_id,
'rack_face': rack_face,
'filter_form': forms.RackElevationFilterForm(request.GET),
})
@@ -1945,6 +1945,12 @@ class CableCreateView(PermissionRequiredMixin, GetReturnURLMixin, View):
# Parse initial data manually to avoid setting field values as lists
initial_data = {k: request.GET[k] for k in request.GET}
# Set initial site and rack based on side A termination (if not already set)
if 'termination_b_site' not in initial_data:
initial_data['termination_b_site'] = getattr(self.obj.termination_a.parent, 'site', None)
if 'termination_b_rack' not in initial_data:
initial_data['termination_b_rack'] = getattr(self.obj.termination_a.parent, 'rack', None)
form = self.form_class(instance=self.obj, initial=initial_data)
return render(request, self.template_name, {

View File

@@ -1,35 +0,0 @@
[
{
"model": "extras.graph",
"pk": 1,
"fields": {
"type": 300,
"weight": 1000,
"name": "Site Test Graph",
"source": "http://localhost/na.png",
"link": ""
}
},
{
"model": "extras.graph",
"pk": 2,
"fields": {
"type": 200,
"weight": 1000,
"name": "Provider Test Graph",
"source": "http://localhost/provider_graph.png",
"link": ""
}
},
{
"model": "extras.graph",
"pk": 3,
"fields": {
"type": 100,
"weight": 1000,
"name": "Interface Test Graph",
"source": "http://localhost/interface_graph.png",
"link": ""
}
}
]

View File

@@ -10,6 +10,7 @@ from django.db import models
from django.http import HttpResponse
from django.template import Template, Context
from django.urls import reverse
from django.utils.text import slugify
from taggit.models import TagBase, GenericTaggedItemBase
from utilities.fields import ColorField
@@ -952,6 +953,13 @@ class Tag(TagBase, ChangeLoggedModel):
def get_absolute_url(self):
return reverse('extras:tag', args=[self.slug])
def slugify(self, tag, i=None):
# Allow Unicode in Tag slugs (avoids empty slugs for Tags with all-Unicode names)
slug = slugify(tag, allow_unicode=True)
if i is not None:
slug += "_%d" % i
return slug
class TaggedItem(GenericTaggedItemBase):
tag = models.ForeignKey(

View File

@@ -14,10 +14,10 @@ from django.db import transaction
from mptt.forms import TreeNodeChoiceField, TreeNodeMultipleChoiceField
from mptt.models import MPTTModel
from ipam.formfields import IPFormField
from utilities.exceptions import AbortTransaction
from utilities.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator
from ipam.formfields import IPAddressFormField, IPNetworkFormField
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
from .constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_SUCCESS, LOG_WARNING
from utilities.exceptions import AbortTransaction
from .forms import ScriptForm
from .signals import purge_changelog
@@ -27,6 +27,8 @@ __all__ = [
'ChoiceVar',
'FileVar',
'IntegerVar',
'IPAddressVar',
'IPAddressWithMaskVar',
'IPNetworkVar',
'MultiObjectVar',
'ObjectVar',
@@ -48,15 +50,19 @@ class ScriptVariable:
def __init__(self, label='', description='', default=None, required=True):
# Default field attributes
self.field_attrs = {
'help_text': description,
'required': required
}
# Initialize field attributes
if not hasattr(self, 'field_attrs'):
self.field_attrs = {}
if description:
self.field_attrs['help_text'] = description
if label:
self.field_attrs['label'] = label
if default:
self.field_attrs['initial'] = default
if required:
self.field_attrs['required'] = True
if 'validators' not in self.field_attrs:
self.field_attrs['validators'] = []
def as_field(self):
"""
@@ -196,17 +202,32 @@ class FileVar(ScriptVariable):
form_field = forms.FileField
class IPAddressVar(ScriptVariable):
"""
An IPv4 or IPv6 address without a mask.
"""
form_field = IPAddressFormField
class IPAddressWithMaskVar(ScriptVariable):
"""
An IPv4 or IPv6 address with a mask.
"""
form_field = IPNetworkFormField
class IPNetworkVar(ScriptVariable):
"""
An IPv4 or IPv6 prefix.
"""
form_field = IPFormField
form_field = IPNetworkFormField
field_attrs = {
'validators': [prefix_validator]
}
def __init__(self, min_prefix_length=None, max_prefix_length=None, *args, **kwargs):
super().__init__(*args, **kwargs)
self.field_attrs['validators'] = list()
# Optional minimum/maximum prefix lengths
if min_prefix_length is not None:
self.field_attrs['validators'].append(

View File

@@ -3,7 +3,7 @@ from django.test import TestCase
from dcim.models import Site
from extras.choices import TemplateLanguageChoices
from extras.models import Graph
from extras.models import Graph, Tag
class GraphTest(TestCase):
@@ -44,3 +44,12 @@ class GraphTest(TestCase):
self.assertEqual(graph.embed_url(self.site), RENDERED_TEXT)
self.assertEqual(graph.embed_link(self.site), RENDERED_TEXT)
class TagTest(TestCase):
def test_create_tag_unicode(self):
tag = Tag(name='Testing Unicode: 台灣')
tag.save()
self.assertEqual(tag.slug, 'testing-unicode-台灣')

View File

@@ -1,6 +1,6 @@
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from netaddr import IPNetwork
from netaddr import IPAddress, IPNetwork
from dcim.models import DeviceRole
from extras.scripts import *
@@ -186,6 +186,54 @@ class ScriptVariablesTest(TestCase):
self.assertTrue(form.is_valid())
self.assertEqual(form.cleaned_data['var1'], testfile)
def test_ipaddressvar(self):
class TestScript(Script):
var1 = IPAddressVar()
# Validate IP network enforcement
data = {'var1': '1.2.3'}
form = TestScript().as_form(data, None)
self.assertFalse(form.is_valid())
self.assertIn('var1', form.errors)
# Validate IP mask exclusion
data = {'var1': '192.0.2.0/24'}
form = TestScript().as_form(data, None)
self.assertFalse(form.is_valid())
self.assertIn('var1', form.errors)
# Validate valid data
data = {'var1': '192.0.2.1'}
form = TestScript().as_form(data, None)
self.assertTrue(form.is_valid())
self.assertEqual(form.cleaned_data['var1'], IPAddress(data['var1']))
def test_ipaddresswithmaskvar(self):
class TestScript(Script):
var1 = IPAddressWithMaskVar()
# Validate IP network enforcement
data = {'var1': '1.2.3'}
form = TestScript().as_form(data, None)
self.assertFalse(form.is_valid())
self.assertIn('var1', form.errors)
# Validate IP mask requirement
data = {'var1': '192.0.2.0'}
form = TestScript().as_form(data, None)
self.assertFalse(form.is_valid())
self.assertIn('var1', form.errors)
# Validate valid data
data = {'var1': '192.0.2.0/24'}
form = TestScript().as_form(data, None)
self.assertTrue(form.is_valid())
self.assertEqual(form.cleaned_data['var1'], IPNetwork(data['var1']))
def test_ipnetworkvar(self):
class TestScript(Script):
@@ -198,6 +246,12 @@ class ScriptVariablesTest(TestCase):
self.assertFalse(form.is_valid())
self.assertIn('var1', form.errors)
# Validate host IP check
data = {'var1': '192.0.2.1/24'}
form = TestScript().as_form(data, None)
self.assertFalse(form.is_valid())
self.assertIn('var1', form.errors)
# Validate valid data
data = {'var1': '192.0.2.0/24'}
form = TestScript().as_form(data, None)

View File

@@ -0,0 +1,143 @@
import json
import uuid
from unittest.mock import patch
import django_rq
from django.contrib.contenttypes.models import ContentType
from django.http import HttpResponse
from django.urls import reverse
from requests import Session
from rest_framework import status
from dcim.models import Site
from extras.choices import ObjectChangeActionChoices
from extras.models import Webhook
from extras.webhooks import enqueue_webhooks, generate_signature
from extras.webhooks_worker import process_webhook
from utilities.testing import APITestCase
class WebhookTest(APITestCase):
def setUp(self):
super().setUp()
self.queue = django_rq.get_queue('default')
self.queue.empty() # Begin each test with an empty queue
@classmethod
def setUpTestData(cls):
site_ct = ContentType.objects.get_for_model(Site)
DUMMY_URL = "http://localhost/"
DUMMY_SECRET = "LOOKATMEIMASECRETSTRING"
webhooks = Webhook.objects.bulk_create((
Webhook(name='Site Create Webhook', type_create=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET, additional_headers={'X-Foo': 'Bar'}),
Webhook(name='Site Update Webhook', type_update=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET),
Webhook(name='Site Delete Webhook', type_delete=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET),
))
for webhook in webhooks:
webhook.obj_type.set([site_ct])
def test_enqueue_webhook_create(self):
# Create an object via the REST API
data = {
'name': 'Test Site',
'slug': 'test-site',
}
url = reverse('dcim-api:site-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Site.objects.count(), 1)
# Verify that a job was queued for the object creation webhook
self.assertEqual(self.queue.count, 1)
job = self.queue.jobs[0]
self.assertEqual(job.args[0], Webhook.objects.get(type_create=True))
self.assertEqual(job.args[1]['id'], response.data['id'])
self.assertEqual(job.args[2], 'site')
self.assertEqual(job.args[3], ObjectChangeActionChoices.ACTION_CREATE)
def test_enqueue_webhook_update(self):
site = Site.objects.create(name='Site 1', slug='site-1')
# Update an object via the REST API
data = {
'comments': 'Updated the site',
}
url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
# Verify that a job was queued for the object update webhook
self.assertEqual(self.queue.count, 1)
job = self.queue.jobs[0]
self.assertEqual(job.args[0], Webhook.objects.get(type_update=True))
self.assertEqual(job.args[1]['id'], site.pk)
self.assertEqual(job.args[2], 'site')
self.assertEqual(job.args[3], ObjectChangeActionChoices.ACTION_UPDATE)
def test_enqueue_webhook_delete(self):
site = Site.objects.create(name='Site 1', slug='site-1')
# Delete an object via the REST API
url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
# Verify that a job was queued for the object update webhook
self.assertEqual(self.queue.count, 1)
job = self.queue.jobs[0]
self.assertEqual(job.args[0], Webhook.objects.get(type_delete=True))
self.assertEqual(job.args[1]['id'], site.pk)
self.assertEqual(job.args[2], 'site')
self.assertEqual(job.args[3], ObjectChangeActionChoices.ACTION_DELETE)
def test_webhooks_worker(self):
request_id = uuid.uuid4()
def dummy_send(_, request):
"""
A dummy implementation of Session.send() to be used for testing.
Always returns a 200 HTTP response.
"""
webhook = Webhook.objects.get(type_create=True)
signature = generate_signature(request.body, webhook.secret)
# Validate the outgoing request headers
self.assertEqual(request.headers['Content-Type'], webhook.http_content_type)
self.assertEqual(request.headers['X-Hook-Signature'], signature)
self.assertEqual(request.headers['X-Foo'], 'Bar')
# Validate the outgoing request body
body = json.loads(request.body)
self.assertEqual(body['event'], 'created')
self.assertEqual(body['timestamp'], job.args[4])
self.assertEqual(body['model'], 'site')
self.assertEqual(body['username'], 'testuser')
self.assertEqual(body['request_id'], str(request_id))
self.assertEqual(body['data']['name'], 'Site 1')
return HttpResponse()
# Enqueue a webhook for processing
site = Site.objects.create(name='Site 1', slug='site-1')
enqueue_webhooks(
instance=site,
user=self.user,
request_id=request_id,
action=ObjectChangeActionChoices.ACTION_CREATE
)
# Retrieve the job from queue
job = self.queue.jobs[0]
# Patch the Session object with our dummy_send() method, then process the webhook for sending
with patch.object(Session, 'send', dummy_send) as mock_send:
process_webhook(*job.args)

View File

@@ -11,10 +11,10 @@ urlpatterns = [
path(r'tags/', views.TagListView.as_view(), name='tag_list'),
path(r'tags/edit/', views.TagBulkEditView.as_view(), name='tag_bulk_edit'),
path(r'tags/delete/', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'),
path(r'tags/<slug:slug>/', views.TagView.as_view(), name='tag'),
path(r'tags/<slug:slug>/edit/', views.TagEditView.as_view(), name='tag_edit'),
path(r'tags/<slug:slug>/delete/', views.TagDeleteView.as_view(), name='tag_delete'),
path(r'tags/<slug:slug>/changelog/', views.ObjectChangeLogView.as_view(), name='tag_changelog', kwargs={'model': Tag}),
path(r'tags/<str:slug>/', views.TagView.as_view(), name='tag'),
path(r'tags/<str:slug>/edit/', views.TagEditView.as_view(), name='tag_edit'),
path(r'tags/<str:slug>/delete/', views.TagDeleteView.as_view(), name='tag_delete'),
path(r'tags/<str:slug>/changelog/', views.ObjectChangeLogView.as_view(), name='tag_changelog', kwargs={'model': Tag}),
# Config contexts
path(r'config-contexts/', views.ConfigContextListView.as_view(), name='configcontext_list'),

View File

@@ -1,6 +1,9 @@
import datetime
import hashlib
import hmac
from django.contrib.contenttypes.models import ContentType
from django.utils import timezone
from extras.models import Webhook
from utilities.api import get_serializer_for_model
@@ -8,6 +11,18 @@ from .choices import *
from .constants import *
def generate_signature(request_body, secret):
"""
Return a cryptographic signature that can be used to verify the authenticity of webhook data.
"""
hmac_prep = hmac.new(
key=secret.encode('utf8'),
msg=request_body.encode('utf8'),
digestmod=hashlib.sha512
)
return hmac_prep.hexdigest()
def enqueue_webhooks(instance, user, request_id, action):
"""
Find Webhook(s) assigned to this instance + action and enqueue them
@@ -48,7 +63,7 @@ def enqueue_webhooks(instance, user, request_id, action):
serializer.data,
instance._meta.model_name,
action,
str(datetime.datetime.now()),
str(timezone.now()),
user.username,
request_id
)

View File

@@ -1,13 +1,11 @@
import hashlib
import hmac
import json
import requests
from django_rq import job
from rest_framework.utils.encoders import JSONEncoder
from .choices import ObjectChangeActionChoices
from .constants import *
from .choices import ObjectChangeActionChoices, WebhookContentTypeChoices
from .webhooks import generate_signature
@job('default')
@@ -24,7 +22,7 @@ def process_webhook(webhook, data, model_name, event, timestamp, username, reque
'data': data
}
headers = {
'Content-Type': webhook.get_http_content_type_display(),
'Content-Type': webhook.http_content_type,
}
if webhook.additional_headers:
headers.update(webhook.additional_headers)
@@ -35,21 +33,16 @@ def process_webhook(webhook, data, model_name, event, timestamp, username, reque
'headers': headers
}
if webhook.http_content_type == WEBHOOK_CT_JSON:
if webhook.http_content_type == WebhookContentTypeChoices.CONTENTTYPE_JSON:
params.update({'data': json.dumps(payload, cls=JSONEncoder)})
elif webhook.http_content_type == WEBHOOK_CT_X_WWW_FORM_ENCODED:
elif webhook.http_content_type == WebhookContentTypeChoices.CONTENTTYPE_FORMDATA:
params.update({'data': payload})
prepared_request = requests.Request(**params).prepare()
if webhook.secret != '':
# Sign the request with a hash of the secret key and its content.
hmac_prep = hmac.new(
key=webhook.secret.encode('utf8'),
msg=prepared_request.body.encode('utf8'),
digestmod=hashlib.sha512
)
prepared_request.headers['X-Hook-Signature'] = hmac_prep.hexdigest()
prepared_request.headers['X-Hook-Signature'] = generate_signature(prepared_request.body, webhook.secret)
with requests.Session() as session:
session.verify = webhook.ssl_verification
@@ -57,9 +50,11 @@ def process_webhook(webhook, data, model_name, event, timestamp, username, reque
session.verify = webhook.ca_file_path
response = session.send(prepared_request)
if response.status_code >= 200 and response.status_code <= 299:
if 200 <= response.status_code <= 299:
return 'Status {} returned, webhook successfully processed.'.format(response.status_code)
else:
raise requests.exceptions.RequestException(
"Status {} returned with content '{}', webhook FAILED to process.".format(response.status_code, response.content)
"Status {} returned with content '{}', webhook FAILED to process.".format(
response.status_code, response.content
)
)

View File

@@ -4,10 +4,34 @@ from .choices import IPAddressRoleChoices
BGP_ASN_MIN = 1
BGP_ASN_MAX = 2**32 - 1
#
# IP addresses
# VRFs
#
# Per RFC 4364 section 4.2, a route distinguisher may be encoded as one of the following:
# * Type 0 (16-bit AS number : 32-bit integer)
# * Type 1 (32-bit IPv4 address : 16-bit integer)
# * Type 2 (32-bit AS number : 16-bit integer)
# 21 characters are sufficient to convey the longest possible string value (255.255.255.255:65535)
VRF_RD_MAX_LENGTH = 21
#
# Prefixes
#
PREFIX_LENGTH_MIN = 1
PREFIX_LENGTH_MAX = 127 # IPv6
#
# IPAddresses
#
IPADDRESS_MASK_LENGTH_MIN = 1
IPADDRESS_MASK_LENGTH_MAX = 128 # IPv6
IPADDRESS_ROLES_NONUNIQUE = (
# IPAddress roles which are exempt from unique address enforcement
IPAddressRoleChoices.ROLE_ANYCAST,
@@ -17,3 +41,21 @@ IPADDRESS_ROLES_NONUNIQUE = (
IPAddressRoleChoices.ROLE_GLBP,
IPAddressRoleChoices.ROLE_CARP,
)
#
# VLANs
#
# 12-bit VLAN ID (values 0 and 4095 are reserved)
VLAN_VID_MIN = 1
VLAN_VID_MAX = 4094
#
# Services
#
# 16-bit port number
SERVICE_PORT_MIN = 1
SERVICE_PORT_MAX = 65535

View File

@@ -1,14 +1,9 @@
from django.core.exceptions import ValidationError
from django.db import models
from netaddr import AddrFormatError, IPNetwork, IPAddress
from netaddr import AddrFormatError, IPNetwork
from . import lookups
from .formfields import IPFormField
def prefix_validator(prefix):
if prefix.ip != prefix.cidr.ip:
raise ValidationError("{} is not a valid prefix. Did you mean {}?".format(prefix, prefix.cidr))
from . import lookups, validators
from .formfields import IPNetworkFormField
class BaseIPField(models.Field):
@@ -23,11 +18,9 @@ class BaseIPField(models.Field):
if not value:
return value
try:
if '/' in str(value):
return IPNetwork(value)
else:
return IPAddress(value)
except AddrFormatError as e:
# Always return a netaddr.IPNetwork object. (netaddr.IPAddress does not provide a mask.)
return IPNetwork(value)
except AddrFormatError:
raise ValidationError("Invalid IP address format: {}".format(value))
except (TypeError, ValueError) as e:
raise ValidationError(e)
@@ -40,7 +33,7 @@ class BaseIPField(models.Field):
return str(self.to_python(value))
def form_class(self):
return IPFormField
return IPNetworkFormField
def formfield(self, **kwargs):
defaults = {'form_class': self.form_class()}
@@ -53,7 +46,7 @@ class IPNetworkField(BaseIPField):
IP prefix (network and mask)
"""
description = "PostgreSQL CIDR field"
default_validators = [prefix_validator]
default_validators = [validators.prefix_validator]
def db_type(self, connection):
return 'cidr'

View File

@@ -1,329 +0,0 @@
[
{
"model": "ipam.rir",
"pk": 1,
"fields": {
"name": "RFC1918",
"slug": "rfc1918"
}
},
{
"model": "ipam.aggregate",
"pk": 1,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"family": 4,
"prefix": "10.0.0.0/8",
"rir": 1,
"date_added": null,
"description": ""
}
},
{
"model": "ipam.role",
"pk": 1,
"fields": {
"name": "Lab Network",
"slug": "lab-network",
"weight": 1000
}
},
{
"model": "ipam.prefix",
"pk": 1,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"family": 4,
"prefix": "10.1.1.0/24",
"site": 1,
"vrf": null,
"vlan": null,
"status": "active",
"role": 1,
"description": ""
}
},
{
"model": "ipam.prefix",
"pk": 2,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"family": 4,
"prefix": "10.0.255.0/24",
"site": 1,
"vrf": null,
"vlan": null,
"status": "active",
"role": 1,
"description": ""
}
},
{
"model": "ipam.ipaddress",
"pk": 1,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"family": 4,
"address": "10.0.255.1/32",
"vrf": null,
"interface_id": 3,
"nat_inside": null,
"description": ""
}
},
{
"model": "ipam.ipaddress",
"pk": 2,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"family": 4,
"address": "169.254.254.1/31",
"vrf": null,
"interface_id": 4,
"nat_inside": null,
"description": ""
}
},
{
"model": "ipam.ipaddress",
"pk": 3,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"family": 4,
"address": "10.0.255.2/32",
"vrf": null,
"interface_id": 185,
"nat_inside": null,
"description": ""
}
},
{
"model": "ipam.ipaddress",
"pk": 4,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"family": 4,
"address": "169.254.1.1/31",
"vrf": null,
"interface_id": 213,
"nat_inside": null,
"description": ""
}
},
{
"model": "ipam.ipaddress",
"pk": 5,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"family": 4,
"address": "10.0.254.1/24",
"vrf": null,
"interface_id": 12,
"nat_inside": null,
"description": ""
}
},
{
"model": "ipam.ipaddress",
"pk": 8,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"family": 4,
"address": "10.15.21.1/31",
"vrf": null,
"interface_id": 218,
"nat_inside": null,
"description": ""
}
},
{
"model": "ipam.ipaddress",
"pk": 9,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"family": 4,
"address": "10.15.21.2/31",
"vrf": null,
"interface_id": 9,
"nat_inside": null,
"description": ""
}
},
{
"model": "ipam.ipaddress",
"pk": 10,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"family": 4,
"address": "10.15.22.1/31",
"vrf": null,
"interface_id": 8,
"nat_inside": null,
"description": ""
}
},
{
"model": "ipam.ipaddress",
"pk": 11,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"family": 4,
"address": "10.15.20.1/31",
"vrf": null,
"interface_id": 7,
"nat_inside": null,
"description": ""
}
},
{
"model": "ipam.ipaddress",
"pk": 12,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"family": 4,
"address": "10.16.20.1/31",
"vrf": null,
"interface_id": 216,
"nat_inside": null,
"description": ""
}
},
{
"model": "ipam.ipaddress",
"pk": 13,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"family": 4,
"address": "10.15.22.2/31",
"vrf": null,
"interface_id": 206,
"nat_inside": null,
"description": ""
}
},
{
"model": "ipam.ipaddress",
"pk": 14,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"family": 4,
"address": "10.16.22.1/31",
"vrf": null,
"interface_id": 217,
"nat_inside": null,
"description": ""
}
},
{
"model": "ipam.ipaddress",
"pk": 15,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"family": 4,
"address": "10.16.22.2/31",
"vrf": null,
"interface_id": 205,
"nat_inside": null,
"description": ""
}
},
{
"model": "ipam.ipaddress",
"pk": 16,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"family": 4,
"address": "10.16.20.2/31",
"vrf": null,
"interface_id": 211,
"nat_inside": null,
"description": ""
}
},
{
"model": "ipam.ipaddress",
"pk": 17,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"family": 4,
"address": "10.15.22.2/31",
"vrf": null,
"interface_id": 212,
"nat_inside": null,
"description": ""
}
},
{
"model": "ipam.ipaddress",
"pk": 19,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"family": 4,
"address": "10.0.254.2/32",
"vrf": null,
"interface_id": 188,
"nat_inside": null,
"description": ""
}
},
{
"model": "ipam.ipaddress",
"pk": 20,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"family": 4,
"address": "169.254.1.1/31",
"vrf": null,
"interface_id": 200,
"nat_inside": null,
"description": ""
}
},
{
"model": "ipam.ipaddress",
"pk": 21,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"family": 4,
"address": "169.254.1.2/31",
"vrf": null,
"interface_id": 194,
"nat_inside": null,
"description": ""
}
},
{
"model": "ipam.vlan",
"pk": 1,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"site": 1,
"vid": 999,
"name": "TEST",
"status": "active",
"role": 1
}
}
]

View File

@@ -1,13 +1,44 @@
from django import forms
from django.core.exceptions import ValidationError
from netaddr import IPNetwork, AddrFormatError
from django.core.validators import validate_ipv4_address, validate_ipv6_address
from netaddr import IPAddress, IPNetwork, AddrFormatError
#
# Form fields
#
class IPFormField(forms.Field):
class IPAddressFormField(forms.Field):
default_error_messages = {
'invalid': "Enter a valid IPv4 or IPv6 address (without a mask).",
}
def to_python(self, value):
if not value:
return None
if isinstance(value, IPAddress):
return value
# netaddr is a bit too liberal with what it accepts as a valid IP address. For example, '1.2.3' will become
# IPAddress('1.2.0.3'). Here, we employ Django's built-in IPv4 and IPv6 address validators as a sanity check.
try:
validate_ipv4_address(value)
except ValidationError:
try:
validate_ipv6_address(value)
except ValidationError:
raise ValidationError("Invalid IPv4/IPv6 address format: {}".format(value))
try:
return IPAddress(value)
except ValueError:
raise ValidationError('This field requires an IP address without a mask.')
except AddrFormatError:
raise ValidationError("Please specify a valid IPv4 or IPv6 address.")
class IPNetworkFormField(forms.Field):
default_error_messages = {
'invalid': "Enter a valid IPv4 or IPv6 address (with CIDR mask).",
}

View File

@@ -13,17 +13,18 @@ from utilities.forms import (
SlugField, StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES
)
from virtualization.models import VirtualMachine
from .constants import *
from .choices import *
from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
IP_FAMILY_CHOICES = [
('', 'All'),
(4, 'IPv4'),
(6, 'IPv6'),
]
PREFIX_MASK_LENGTH_CHOICES = add_blank_choice([(i, i) for i in range(1, 128)])
IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([(i, i) for i in range(1, 129)])
PREFIX_MASK_LENGTH_CHOICES = add_blank_choice([
(i, i) for i in range(PREFIX_LENGTH_MIN, PREFIX_LENGTH_MAX + 1)
])
IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([
(i, i) for i in range(IPADDRESS_MASK_LENGTH_MIN, IPADDRESS_MASK_LENGTH_MAX + 1)
])
#
@@ -218,7 +219,7 @@ class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm):
)
family = forms.ChoiceField(
required=False,
choices=IP_FAMILY_CHOICES,
choices=add_blank_choice(IPAddressFamilyChoices),
label='Address family',
widget=StaticSelect2()
)
@@ -450,8 +451,8 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
)
)
prefix_length = forms.IntegerField(
min_value=1,
max_value=127,
min_value=PREFIX_LENGTH_MIN,
max_value=PREFIX_LENGTH_MAX,
required=False
)
tenant = forms.ModelChoiceField(
@@ -510,7 +511,7 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
)
family = forms.ChoiceField(
required=False,
choices=IP_FAMILY_CHOICES,
choices=add_blank_choice(IPAddressFamilyChoices),
label='Address family',
widget=StaticSelect2()
)
@@ -634,6 +635,17 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
}
)
)
nat_vrf = forms.ModelChoiceField(
queryset=VRF.objects.all(),
required=False,
label='VRF',
widget=APISelect(
api_url="/api/ipam/vrfs/",
filter_for={
'nat_inside': 'vrf_id'
}
)
)
nat_inside = ChainedModelChoiceField(
queryset=IPAddress.objects.all(),
chains=(
@@ -896,8 +908,8 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd
)
)
mask_length = forms.IntegerField(
min_value=1,
max_value=128,
min_value=IPADDRESS_MASK_LENGTH_MIN,
max_value=IPADDRESS_MASK_LENGTH_MAX,
required=False
)
tenant = forms.ModelChoiceField(
@@ -969,7 +981,7 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo
)
family = forms.ChoiceField(
required=False,
choices=IP_FAMILY_CHOICES,
choices=add_blank_choice(IPAddressFamilyChoices),
label='Address family',
widget=StaticSelect2()
)
@@ -1300,8 +1312,8 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
class ServiceForm(BootstrapMixin, CustomFieldForm):
port = forms.IntegerField(
min_value=1,
max_value=65535
min_value=SERVICE_PORT_MIN,
max_value=SERVICE_PORT_MAX
)
tags = TagField(
required=False

View File

@@ -103,6 +103,10 @@ class NetHost(Lookup):
class NetIn(Lookup):
lookup_name = 'net_in'
def get_prep_lookup(self):
# Don't cast the query value to a netaddr object, since it may or may not include a mask.
return self.rhs
def as_sql(self, qn, connection):
lhs, lhs_params = self.process_lhs(qn, connection)
rhs, rhs_params = self.process_rhs(qn, connection)

View File

@@ -2,10 +2,10 @@ from django.db import migrations, models
IPADDRESS_STATUS_CHOICES = (
(0, 'container'),
(1, 'active'),
(2, 'reserved'),
(3, 'deprecated'),
(5, 'dhcp'),
)
IPADDRESS_ROLE_CHOICES = (

View File

@@ -0,0 +1,21 @@
from django.db import migrations
def ipaddress_status_dhcp_to_slug(apps, schema_editor):
IPAddress = apps.get_model('ipam', 'IPAddress')
IPAddress.objects.filter(status='5').update(status='dhcp')
class Migration(migrations.Migration):
dependencies = [
('ipam', '0033_deterministic_ordering'),
]
operations = [
# Fixes a missed integer substitution from #3569; see bug #4027. The original migration has also been fixed,
# so this can be omitted when squashing in the future.
migrations.RunPython(
code=ipaddress_status_dhcp_to_slug
),
]

View File

@@ -14,7 +14,7 @@ from utilities.models import ChangeLoggedModel
from utilities.utils import serialize_object
from virtualization.models import VirtualMachine
from .choices import *
from .constants import IPADDRESS_ROLES_NONUNIQUE
from .constants import *
from .fields import IPNetworkField, IPAddressField
from .managers import IPAddressManager
from .querysets import PrefixQuerySet
@@ -44,7 +44,7 @@ class VRF(ChangeLoggedModel, CustomFieldModel):
max_length=50
)
rd = models.CharField(
max_length=21,
max_length=VRF_RD_MAX_LENGTH,
unique=True,
blank=True,
null=True,
@@ -1006,7 +1006,7 @@ class Service(ChangeLoggedModel, CustomFieldModel):
choices=ServiceProtocolChoices
)
port = models.PositiveIntegerField(
validators=[MinValueValidator(1), MaxValueValidator(65535)],
validators=[MinValueValidator(SERVICE_PORT_MIN), MaxValueValidator(SERVICE_PORT_MAX)],
verbose_name='Port number'
)
ipaddresses = models.ManyToManyField(

View File

@@ -7,7 +7,7 @@ from rest_framework import status
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
from ipam.choices import *
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
from utilities.testing import APITestCase, choices_to_dict
from utilities.testing import APITestCase, choices_to_dict, disable_warnings
class AppTest(APITestCase):
@@ -1007,7 +1007,8 @@ class VLANTest(APITestCase):
self.prefix1.save()
url = reverse('ipam-api:vlan-detail', kwargs={'pk': self.vlan1.pk})
response = self.client.delete(url, **self.header)
with disable_warnings('django.request'):
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_409_CONFLICT)

View File

@@ -2,12 +2,199 @@ import netaddr
from django.core.exceptions import ValidationError
from django.test import TestCase, override_settings
from ipam.choices import IPAddressRoleChoices
from ipam.models import IPAddress, Prefix, VRF
from ipam.choices import IPAddressRoleChoices, PrefixStatusChoices
from ipam.models import Aggregate, IPAddress, Prefix, RIR, VLAN, VLANGroup, VRF
class TestAggregate(TestCase):
def test_get_utilization(self):
rir = RIR.objects.create(name='RIR 1', slug='rir-1')
aggregate = Aggregate(prefix=netaddr.IPNetwork('10.0.0.0/8'), rir=rir)
aggregate.save()
# 25% utilization
Prefix.objects.bulk_create((
Prefix(family=4, prefix=netaddr.IPNetwork('10.0.0.0/12')),
Prefix(family=4, prefix=netaddr.IPNetwork('10.16.0.0/12')),
Prefix(family=4, prefix=netaddr.IPNetwork('10.32.0.0/12')),
Prefix(family=4, prefix=netaddr.IPNetwork('10.48.0.0/12')),
))
self.assertEqual(aggregate.get_utilization(), 25)
# 50% utilization
Prefix.objects.bulk_create((
Prefix(family=4, prefix=netaddr.IPNetwork('10.64.0.0/10')),
))
self.assertEqual(aggregate.get_utilization(), 50)
# 100% utilization
Prefix.objects.bulk_create((
Prefix(family=4, prefix=netaddr.IPNetwork('10.128.0.0/9')),
))
self.assertEqual(aggregate.get_utilization(), 100)
class TestPrefix(TestCase):
def test_get_duplicates(self):
prefixes = Prefix.objects.bulk_create((
Prefix(family=4, prefix=netaddr.IPNetwork('192.0.2.0/24')),
Prefix(family=4, prefix=netaddr.IPNetwork('192.0.2.0/24')),
Prefix(family=4, prefix=netaddr.IPNetwork('192.0.2.0/24')),
))
duplicate_prefix_pks = [p.pk for p in prefixes[0].get_duplicates()]
self.assertSetEqual(set(duplicate_prefix_pks), {prefixes[1].pk, prefixes[2].pk})
def test_get_child_prefixes(self):
vrfs = VRF.objects.bulk_create((
VRF(name='VRF 1'),
VRF(name='VRF 2'),
VRF(name='VRF 3'),
))
prefixes = Prefix.objects.bulk_create((
Prefix(family=4, prefix=netaddr.IPNetwork('10.0.0.0/16'), status=PrefixStatusChoices.STATUS_CONTAINER),
Prefix(family=4, prefix=netaddr.IPNetwork('10.0.0.0/24'), vrf=None),
Prefix(family=4, prefix=netaddr.IPNetwork('10.0.1.0/24'), vrf=vrfs[0]),
Prefix(family=4, prefix=netaddr.IPNetwork('10.0.2.0/24'), vrf=vrfs[1]),
Prefix(family=4, prefix=netaddr.IPNetwork('10.0.3.0/24'), vrf=vrfs[2]),
))
child_prefix_pks = {p.pk for p in prefixes[0].get_child_prefixes()}
# Global container should return all children
self.assertSetEqual(child_prefix_pks, {prefixes[1].pk, prefixes[2].pk, prefixes[3].pk, prefixes[4].pk})
prefixes[0].vrf = vrfs[0]
prefixes[0].save()
child_prefix_pks = {p.pk for p in prefixes[0].get_child_prefixes()}
# VRF container is limited to its own VRF
self.assertSetEqual(child_prefix_pks, {prefixes[2].pk})
def test_get_child_ips(self):
vrfs = VRF.objects.bulk_create((
VRF(name='VRF 1'),
VRF(name='VRF 2'),
VRF(name='VRF 3'),
))
parent_prefix = Prefix.objects.create(
family=4, prefix=netaddr.IPNetwork('10.0.0.0/16'), status=PrefixStatusChoices.STATUS_CONTAINER
)
ips = IPAddress.objects.bulk_create((
IPAddress(family=4, address=netaddr.IPNetwork('10.0.0.1/24'), vrf=None),
IPAddress(family=4, address=netaddr.IPNetwork('10.0.1.1/24'), vrf=vrfs[0]),
IPAddress(family=4, address=netaddr.IPNetwork('10.0.2.1/24'), vrf=vrfs[1]),
IPAddress(family=4, address=netaddr.IPNetwork('10.0.3.1/24'), vrf=vrfs[2]),
))
child_ip_pks = {p.pk for p in parent_prefix.get_child_ips()}
# Global container should return all children
self.assertSetEqual(child_ip_pks, {ips[0].pk, ips[1].pk, ips[2].pk, ips[3].pk})
parent_prefix.vrf = vrfs[0]
parent_prefix.save()
child_ip_pks = {p.pk for p in parent_prefix.get_child_ips()}
# VRF container is limited to its own VRF
self.assertSetEqual(child_ip_pks, {ips[1].pk})
def test_get_available_prefixes(self):
prefixes = Prefix.objects.bulk_create((
Prefix(family=4, prefix=netaddr.IPNetwork('10.0.0.0/16')), # Parent prefix
Prefix(family=4, prefix=netaddr.IPNetwork('10.0.0.0/20')),
Prefix(family=4, prefix=netaddr.IPNetwork('10.0.32.0/20')),
Prefix(family=4, prefix=netaddr.IPNetwork('10.0.128.0/18')),
))
missing_prefixes = netaddr.IPSet([
netaddr.IPNetwork('10.0.16.0/20'),
netaddr.IPNetwork('10.0.48.0/20'),
netaddr.IPNetwork('10.0.64.0/18'),
netaddr.IPNetwork('10.0.192.0/18'),
])
available_prefixes = prefixes[0].get_available_prefixes()
self.assertEqual(available_prefixes, missing_prefixes)
def test_get_available_ips(self):
parent_prefix = Prefix.objects.create(family=4, prefix=netaddr.IPNetwork('10.0.0.0/28'))
IPAddress.objects.bulk_create((
IPAddress(family=4, address=netaddr.IPNetwork('10.0.0.1/26')),
IPAddress(family=4, address=netaddr.IPNetwork('10.0.0.3/26')),
IPAddress(family=4, address=netaddr.IPNetwork('10.0.0.5/26')),
IPAddress(family=4, address=netaddr.IPNetwork('10.0.0.7/26')),
IPAddress(family=4, address=netaddr.IPNetwork('10.0.0.9/26')),
IPAddress(family=4, address=netaddr.IPNetwork('10.0.0.11/26')),
IPAddress(family=4, address=netaddr.IPNetwork('10.0.0.13/26')),
))
missing_ips = netaddr.IPSet([
'10.0.0.2/32',
'10.0.0.4/32',
'10.0.0.6/32',
'10.0.0.8/32',
'10.0.0.10/32',
'10.0.0.12/32',
'10.0.0.14/32',
])
available_ips = parent_prefix.get_available_ips()
self.assertEqual(available_ips, missing_ips)
def test_get_first_available_prefix(self):
prefixes = Prefix.objects.bulk_create((
Prefix(family=4, prefix=netaddr.IPNetwork('10.0.0.0/16')), # Parent prefix
Prefix(family=4, prefix=netaddr.IPNetwork('10.0.0.0/24')),
Prefix(family=4, prefix=netaddr.IPNetwork('10.0.1.0/24')),
Prefix(family=4, prefix=netaddr.IPNetwork('10.0.2.0/24')),
))
self.assertEqual(prefixes[0].get_first_available_prefix(), netaddr.IPNetwork('10.0.3.0/24'))
Prefix.objects.create(family=4, prefix=netaddr.IPNetwork('10.0.3.0/24'))
self.assertEqual(prefixes[0].get_first_available_prefix(), netaddr.IPNetwork('10.0.4.0/22'))
def test_get_first_available_ip(self):
parent_prefix = Prefix.objects.create(family=4, prefix=netaddr.IPNetwork('10.0.0.0/24'))
IPAddress.objects.bulk_create((
IPAddress(family=4, address=netaddr.IPNetwork('10.0.0.1/24')),
IPAddress(family=4, address=netaddr.IPNetwork('10.0.0.2/24')),
IPAddress(family=4, address=netaddr.IPNetwork('10.0.0.3/24')),
))
self.assertEqual(parent_prefix.get_first_available_ip(), '10.0.0.4/24')
IPAddress.objects.create(family=4, address=netaddr.IPNetwork('10.0.0.4/24'))
self.assertEqual(parent_prefix.get_first_available_ip(), '10.0.0.5/24')
def test_get_utilization(self):
# Container Prefix
prefix = Prefix.objects.create(
family=4,
prefix=netaddr.IPNetwork('10.0.0.0/24'),
status=PrefixStatusChoices.STATUS_CONTAINER
)
Prefix.objects.bulk_create((
Prefix(family=4, prefix=netaddr.IPNetwork('10.0.0.0/26')),
Prefix(family=4, prefix=netaddr.IPNetwork('10.0.0.128/26')),
))
self.assertEqual(prefix.get_utilization(), 50)
# Non-container Prefix
prefix.status = PrefixStatusChoices.STATUS_ACTIVE
prefix.save()
IPAddress.objects.bulk_create(
# Create 32 IPAddresses within the Prefix
[IPAddress(family=4, address=netaddr.IPNetwork('10.0.0.{}/24'.format(i))) for i in range(1, 33)]
)
self.assertEqual(prefix.get_utilization(), 12) # ~= 12%
#
# Uniqueness enforcement tests
#
@override_settings(ENFORCE_GLOBAL_UNIQUE=False)
def test_duplicate_global(self):
Prefix.objects.create(prefix=netaddr.IPNetwork('192.0.2.0/24'))
@@ -35,6 +222,20 @@ class TestPrefix(TestCase):
class TestIPAddress(TestCase):
def test_get_duplicates(self):
ips = IPAddress.objects.bulk_create((
IPAddress(family=4, address=netaddr.IPNetwork('192.0.2.1/24')),
IPAddress(family=4, address=netaddr.IPNetwork('192.0.2.1/24')),
IPAddress(family=4, address=netaddr.IPNetwork('192.0.2.1/24')),
))
duplicate_ip_pks = [p.pk for p in ips[0].get_duplicates()]
self.assertSetEqual(set(duplicate_ip_pks), {ips[1].pk, ips[2].pk})
#
# Uniqueness enforcement tests
#
@override_settings(ENFORCE_GLOBAL_UNIQUE=False)
def test_duplicate_global(self):
IPAddress.objects.create(address=netaddr.IPNetwork('192.0.2.1/24'))
@@ -63,3 +264,22 @@ class TestIPAddress(TestCase):
def test_duplicate_nonunique_role(self):
IPAddress.objects.create(address=netaddr.IPNetwork('192.0.2.1/24'), role=IPAddressRoleChoices.ROLE_VIP)
IPAddress.objects.create(address=netaddr.IPNetwork('192.0.2.1/24'), role=IPAddressRoleChoices.ROLE_VIP)
class TestVLANGroup(TestCase):
def test_get_next_available_vid(self):
vlangroup = VLANGroup.objects.create(name='VLAN Group 1', slug='vlan-group-1')
VLAN.objects.bulk_create((
VLAN(name='VLAN 1', vid=1, group=vlangroup),
VLAN(name='VLAN 2', vid=2, group=vlangroup),
VLAN(name='VLAN 3', vid=3, group=vlangroup),
VLAN(name='VLAN 5', vid=5, group=vlangroup),
))
self.assertEqual(vlangroup.get_next_available_vid(), 4)
VLAN.objects.bulk_create((
VLAN(name='VLAN 4', vid=4, group=vlangroup),
))
self.assertEqual(vlangroup.get_next_available_vid(), 6)

View File

@@ -1,4 +1,26 @@
from django.core.validators import RegexValidator
from django.core.exceptions import ValidationError
from django.core.validators import BaseValidator, RegexValidator
def prefix_validator(prefix):
if prefix.ip != prefix.cidr.ip:
raise ValidationError("{} is not a valid prefix. Did you mean {}?".format(prefix, prefix.cidr))
class MaxPrefixLengthValidator(BaseValidator):
message = 'The prefix length must be less than or equal to %(limit_value)s.'
code = 'max_prefix_length'
def compare(self, a, b):
return a.prefixlen > b
class MinPrefixLengthValidator(BaseValidator):
message = 'The prefix length must be greater than or equal to %(limit_value)s.'
code = 'min_prefix_length'
def compare(self, a, b):
return a.prefixlen < b
DNSValidator = RegexValidator(

View File

@@ -15,6 +15,7 @@ from utilities.views import (
from virtualization.models import VirtualMachine
from . import filters, forms, tables
from .choices import *
from .constants import *
from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
@@ -86,23 +87,20 @@ def add_available_vlans(vlan_group, vlans):
"""
Create fake records for all gaps between used VLANs
"""
MIN_VLAN = 1
MAX_VLAN = 4094
if not vlans:
return [{'vid': MIN_VLAN, 'available': MAX_VLAN - MIN_VLAN + 1}]
return [{'vid': VLAN_VID_MIN, 'available': VLAN_VID_MAX - VLAN_VID_MIN + 1}]
prev_vid = MAX_VLAN
prev_vid = VLAN_VID_MAX
new_vlans = []
for vlan in vlans:
if vlan.vid - prev_vid > 1:
new_vlans.append({'vid': prev_vid + 1, 'available': vlan.vid - prev_vid - 1})
prev_vid = vlan.vid
if vlans[0].vid > MIN_VLAN:
new_vlans.append({'vid': MIN_VLAN, 'available': vlans[0].vid - MIN_VLAN})
if prev_vid < MAX_VLAN:
new_vlans.append({'vid': prev_vid + 1, 'available': MAX_VLAN - prev_vid})
if vlans[0].vid > VLAN_VID_MIN:
new_vlans.append({'vid': VLAN_VID_MIN, 'available': vlans[0].vid - VLAN_VID_MIN})
if prev_vid < VLAN_VID_MAX:
new_vlans.append({'vid': prev_vid + 1, 'available': VLAN_VID_MAX - prev_vid})
vlans = list(vlans) + new_vlans
vlans.sort(key=lambda v: v.vid if type(v) == VLAN else v['vid'])
@@ -760,7 +758,7 @@ class IPAddressAssignView(PermissionRequiredMixin, View):
'vrf', 'tenant', 'interface__device', 'interface__virtual_machine'
)
# Limit to 100 results
addresses = filters.IPAddressFilter(request.POST, addresses).qs[:100]
addresses = filters.IPAddressFilterSet(request.POST, addresses).qs[:100]
table = tables.IPAddressAssignTable(addresses)
return render(request, 'ipam/ipaddress_assign.html', {

View File

@@ -12,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured
# Environment setup
#
VERSION = '2.7.0'
VERSION = '2.7.3'
# Hostname
HOSTNAME = platform.node()
@@ -503,6 +503,7 @@ SWAGGER_SETTINGS = {
'utilities.custom_inspectors.IdInFilterInspector',
'drf_yasg.inspectors.CoreAPICompatInspector',
],
'DEFAULT_INFO': 'netbox.urls.openapi_info',
'DEFAULT_MODEL_DEPTH': 1,
'DEFAULT_PAGINATOR_INSPECTORS': [
'utilities.custom_inspectors.NullablePaginatorInspector',

View File

View File

@@ -0,0 +1,13 @@
from django.urls import reverse
from utilities.testing import APITestCase
class AppTest(APITestCase):
def test_root(self):
url = reverse('api-root')
response = self.client.get('{}?format=api'.format(url), **self.header)
self.assertEqual(response.status_code, 200)

View File

@@ -0,0 +1,24 @@
import urllib.parse
from django.test import TestCase
from django.urls import reverse
class HomeViewTestCase(TestCase):
def test_home(self):
url = reverse('home')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_search(self):
url = reverse('search')
params = {
'q': 'foo',
}
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
self.assertEqual(response.status_code, 200)

View File

@@ -9,14 +9,16 @@ from netbox.views import APIRootView, HomeView, SearchView
from users.views import LoginView, LogoutView
from .admin import admin_site
openapi_info = openapi.Info(
title="NetBox API",
default_version='v2',
description="API to access NetBox",
terms_of_service="https://github.com/netbox-community/netbox",
license=openapi.License(name="Apache v2 License"),
)
schema_view = get_schema_view(
openapi.Info(
title="NetBox API",
default_version='v2',
description="API to access NetBox",
terms_of_service="https://github.com/netbox-community/netbox",
license=openapi.License(name="Apache v2 License"),
),
openapi_info,
validators=['flex', 'ssv'],
public=True,
)

View File

@@ -158,14 +158,17 @@ $(document).ready(function() {
filter_for_elements.each(function(index, filter_for_element) {
var param_name = $(filter_for_element).attr(attr_name);
var is_required = $(filter_for_element).attr("required");
var is_nullable = $(filter_for_element).attr("nullable");
var is_visible = $(filter_for_element).is(":visible");
var value = $(filter_for_element).val();
if (param_name && is_visible && value) {
parameters[param_name] = value;
} else if (param_name && is_visible && is_nullable) {
parameters[param_name] = "null";
if (param_name && is_visible) {
if (value) {
parameters[param_name] = value;
} else if (is_required && is_nullable) {
parameters[param_name] = "null";
}
}
});

View File

@@ -2,9 +2,9 @@
$('button.toggle-ips').click(function() {
var selected = $(this).attr('selected');
if (selected) {
$('#interfaces_table tr.ipaddresses').hide();
$('#interfaces_table tr.interface:visible + tr.ipaddresses').hide();
} else {
$('#interfaces_table tr.ipaddresses').show();
$('#interfaces_table tr.interface:visible + tr.ipaddresses').show();
}
$(this).attr('selected', !selected);
$(this).children('span').toggleClass('glyphicon-check glyphicon-unchecked');
@@ -14,17 +14,22 @@ $('button.toggle-ips').click(function() {
// Inteface filtering
$('input.interface-filter').on('input', function() {
var filter = new RegExp(this.value);
var interface;
for (interface of $(this).closest('div.panel').find('tbody > tr')) {
for (interface of $('#interfaces_table > tbody > tr.interface')) {
// Slice off 'interface_' at the start of the ID
if (filter && filter.test(interface.id.slice(10))) {
if (filter.test(interface.id.slice(10))) {
// Match the toggle in case the filter now matches the interface
$(interface).find('input:checkbox[name=pk]').prop('checked', $('input.toggle').prop('checked'));
$(interface).show();
if ($('button.toggle-ips').attr('selected')) {
$(interface).next('tr.ipaddresses').show();
}
} else {
// Uncheck to prevent actions from including it when it doesn't match
$(interface).find('input:checkbox[name=pk]').prop('checked', false);
$(interface).hide();
$(interface).next('tr.ipaddresses').hide();
}
}
});

View File

@@ -0,0 +1,5 @@
#
# Secrets
#
SECRET_PLAINTEXT_MAX_LENGTH = 65535

View File

@@ -9,6 +9,7 @@ from utilities.forms import (
APISelect, APISelectMultiple, BootstrapMixin, FilterChoiceField, FlexibleModelChoiceField, SlugField,
StaticSelect2Multiple
)
from .constants import *
from .models import Secret, SecretRole, UserKey
@@ -16,6 +17,8 @@ def validate_rsa_key(key, is_secret=True):
"""
Validate the format and type of an RSA key.
"""
if key.startswith('ssh-rsa '):
raise forms.ValidationError("OpenSSH line format is not supported. Please ensure that your public is in PEM (base64) format.")
try:
key = RSA.importKey(key)
except ValueError:
@@ -67,7 +70,7 @@ class SecretRoleCSVForm(forms.ModelForm):
class SecretForm(BootstrapMixin, CustomFieldForm):
plaintext = forms.CharField(
max_length=65535,
max_length=SECRET_PLAINTEXT_MAX_LENGTH,
required=False,
label='Plaintext',
widget=forms.PasswordInput(
@@ -77,7 +80,7 @@ class SecretForm(BootstrapMixin, CustomFieldForm):
)
)
plaintext2 = forms.CharField(
max_length=65535,
max_length=SECRET_PLAINTEXT_MAX_LENGTH,
required=False,
label='Plaintext (verify)',
widget=forms.PasswordInput()

View File

@@ -36,3 +36,5 @@ GY2b4PKuSTcsYjbg8adOGzFL9RXLI1X4PHNCzD/Y1vdM3jJXv+luk3TU+JIbzJeN
5ZEEz+sIdlMPCAACaZAY/t9Kd/LxHr0o4K/6gqkZIukxFCK6sN53gibAXfaKc4xl
qQIDAQAB
-----END PUBLIC KEY-----"""
SSH_PUBLIC_KEY = """ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCy2yMGnuvmM5CnFG8CsohfUYobXU7+pz/RJtvUUnARAY11Ybc3cn0tvzn4aPxclX8+514n6R7jJCZuVGJXXapqZDq2l+PLmgLhyBJxE9qq7rbp4EAJiUP0inDyf8qFzSKT7Rm8cjHvY3v2GI32JUXuWACA23t5YPUqVglkjfdVX8VHJh6fMQrQ4O3CKKh2x0S82UHH7SaYH0HqOknPgyRQ+ZQorUU25IpzJPesk29nN3DYqfY+VQsKJOLglWvoapaZiu+wK/7ovXqYXNuhfAwlkjbCRKjwix1kZjtDS44US1//BCaT7AeuwMpFLI44v/VajoxTfE0h74Mxl48mNt7Qme4lbXxH8yMa6HNfDp4vjnxPE1CWuSrFo4G+HI1rc22qSmw9e67qIGRbcI7/cIFpeBvnfCCgWrqWZ6ZzdAZJCnu7/aWn00+VG+54GFmJ+3R2xhWcu+Uzn+o1aWROtUuzq0qR6zdXME3A0Oud2uQrQAiAGFdWpfvcOEbD+tlPNDk= test"""

View File

@@ -0,0 +1,32 @@
from django.test import TestCase
from secrets.forms import UserKeyForm
from secrets.models import UserKey
from utilities.testing import create_test_user
from .constants import PUBLIC_KEY, SSH_PUBLIC_KEY
class UserKeyFormTestCase(TestCase):
def setUp(self):
user = create_test_user(
permissions=[
'secrets.view_secretrole',
'secrets.add_secretrole',
]
)
self.userkey = UserKey(user=user)
def test_upload_rsakey(self):
form = UserKeyForm(
data={'public_key': PUBLIC_KEY},
instance=self.userkey,
)
self.assertTrue(form.is_valid())
self.assertTrue(form.save())
def test_upload_sshkey(self):
form = UserKeyForm(
data={'public_key': SSH_PUBLIC_KEY},
instance=self.userkey,
)
self.assertFalse(form.is_valid())

View File

@@ -144,25 +144,8 @@
</div>
</div>
<div class="row">
<div class="col-md-4 col-md-offset-4">
<div class="panel panel-default">
<div class="panel-heading"><strong>Cable</strong></div>
<div class="panel-body">
{% render_field form.status %}
{% render_field form.type %}
{% render_field form.label %}
{% render_field form.color %}
<div class="form-group">
<label class="col-md-3 control-label" for="id_length">{{ form.length.label }}</label>
<div class="col-md-6">
{{ form.length }}
</div>
<div class="col-md-3">
{{ form.length_unit }}
</div>
</div>
</div>
</div>
<div class="col-md-6 col-md-offset-3">
{% include 'dcim/inc/cable_form.html' %}
</div>
</div>
<div class="form-group">

View File

@@ -1,23 +1,5 @@
{% extends 'utilities/obj_edit.html' %}
{% load form_helpers %}
{% block form %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Cable</strong></div>
<div class="panel-body">
{% render_field form.type %}
{% render_field form.status %}
{% render_field form.label %}
{% render_field form.color %}
<div class="form-group">
<label class="col-md-3 control-label" for="id_length">{{ form.length.label }}</label>
<div class="col-md-6">
{{ form.length }}
</div>
<div class="col-md-3">
{{ form.length_unit }}
</div>
</div>
</div>
</div>
{% include 'dcim/inc/cable_form.html' %}
{% endblock %}

View File

@@ -32,7 +32,7 @@
{% if cable.label %}<code>{{ cable.label }}</code>{% else %}Cable #{{ cable.pk }}{% endif %}
</a>
</h4>
<p><span class="label label-{% if cable.status %}success{% else %}info{% endif %}">{{ cable.get_status_display }}</span></p>
<p><span class="label label-{{ cable.get_status_class }}">{{ cable.get_status_display }}</span></p>
<p>{{ cable.get_type_display|default:"" }}</p>
{% if cable.length %}{{ cable.length }} {{ cable.get_length_unit_display }}{% endif %}
{% if cable.color %}

View File

@@ -84,7 +84,7 @@
</a>
</li>
{% if perms.dcim.napalm_read %}
{% if device.status != 1 %}
{% if device.status != 'active' %}
{% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='Device must be in active status' %}
{% elif not device.platform %}
{% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='No platform assigned to this device' %}

View File

@@ -0,0 +1,19 @@
{% load form_helpers %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Cable</strong></div>
<div class="panel-body">
{% render_field form.status %}
{% render_field form.type %}
{% render_field form.label %}
{% render_field form.color %}
<div class="form-group">
<label class="col-md-3 control-label" for="id_length">{{ form.length.label }}</label>
<div class="col-md-5">
{{ form.length }}
</div>
<div class="col-md-4">
{{ form.length_unit }}
</div>
</div>
</div>
</div>

View File

@@ -1,5 +1,5 @@
{% if perms.dcim.change_cable %}
{% if cable.status %}
{% if cable.status == 'connected' %}
<a href="#" class="btn btn-warning btn-xs cable-toggle connected" title="Mark planned" data="{{ cable.pk }}">
<i class="glyphicon glyphicon-ban-circle" aria-hidden="true"></i>
</a>

View File

@@ -1,4 +1,4 @@
<tr class="consoleport{% if cp.cable.status %} success{% elif cp.cable %} info{% endif %}">
<tr class="consoleport{% if cp.cable %} {{ cp.cable.get_status_class }}{% endif %}">
{# Name #}
<td>

View File

@@ -1,6 +1,6 @@
{% load helpers %}
<tr class="consoleserverport{% if csp.cable.status %} success{% elif csp.cable %} info{% endif %}">
<tr class="consoleserverport{% if csp.cable %} {{ csp.cable.get_status_class }}{% endif %}">
{# Checkbox #}
{% if perms.dcim.change_consoleserverport or perms.dcim.delete_consoleserverport %}

View File

@@ -1,5 +1,5 @@
{% load helpers %}
<tr class="frontport{% if frontport.cable.status %} success{% elif frontport.cable %} info{% endif %}">
<tr class="frontport{% if frontport.cable %} {{ frontport.cable.get_status_class }}{% endif %}">
{# Checkbox #}
{% if perms.dcim.change_frontport or perms.dcim.delete_frontport %}

View File

@@ -1,5 +1,5 @@
{% load helpers %}
<tr class="interface{% if not iface.enabled %} danger{% elif iface.cable.status %} success{% elif iface.cable %} info{% elif iface.is_virtual %} warning{% endif %}" id="interface_{{ iface.name }}">
<tr class="interface{% if not iface.enabled %} danger{% elif iface.cable %} {{ iface.cable.get_status_class }}{% elif iface.is_virtual %} warning{% endif %}" id="interface_{{ iface.name }}">
{# Checkbox #}
{% if perms.dcim.change_interface or perms.dcim.delete_interface %}

View File

@@ -1,6 +1,6 @@
{% load helpers %}
<tr class="poweroutlet{% if po.cable.status %} success{% elif po.cable %} info{% endif %}">
<tr class="poweroutlet{% if po.cable %} {{ po.cable.get_status_class }}{% endif %}">
{# Checkbox #}
{% if perms.dcim.change_poweroutlet or perms.dcim.delete_poweroutlet %}

View File

@@ -1,4 +1,4 @@
<tr class="powerport{% if pp.cable.status %} success{% elif pp.cable %} info{% endif %}">
<tr class="powerport{% if pp.cable %} {{ pp.cable.get_status_class }}{% endif %}">
{# Name #}
<td>

View File

@@ -1,5 +1,5 @@
{% load helpers %}
<tr class="rearport{% if rearport.cable.status %} success{% elif rearport.cable %} info{% endif %}">
<tr class="rearport{% if rearport.cable %} {{ rearport.cable.get_status_class }}{% endif %}">
{# Checkbox #}
{% if perms.dcim.change_rearport or perms.dcim.delete_rearport %}

View File

@@ -3,8 +3,8 @@
{% block content %}
<div class="btn-group pull-right noprint" role="group">
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face=0 %}" class="btn btn-default{% if request.GET.face != '1' %} active{% endif %}">Front</a>
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face=1 %}" class="btn btn-default{% if request.GET.face == '1' %} active{% endif %}">Rear</a>
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='front' %}" class="btn btn-default{% if rack_face == 'front' %} active{% endif %}">Front</a>
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='rear' %}" class="btn btn-default{% if rack_face == 'rear' %} active{% endif %}">Rear</a>
</div>
<h1>{% block title %}Rack Elevations{% endblock %}</h1>
<div class="row">
@@ -17,11 +17,7 @@
<strong><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name|truncatechars:"25" }}</a></strong>
<p><small class="text-muted">{{ rack.facility_id|truncatechars:"30" }}</small></p>
</div>
{% if face_id %}
{% include 'dcim/inc/rack_elevation.html' with face='rear' %}
{% else %}
{% include 'dcim/inc/rack_elevation.html' with face='front' %}
{% endif %}
{% include 'dcim/inc/rack_elevation.html' with face=rack_face %}
<div class="clearfix"></div>
<div class="rack_header">
<strong><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name|truncatechars:"25" }}</a></strong>

View File

@@ -61,7 +61,7 @@
{% render_field form.nat_device %}
</div>
<div class="tab-pane" id="search">
&nbsp;
{% render_field form.nat_vrf %}
</div>
</div>
{% render_field form.nat_inside %}

View File

@@ -8,7 +8,6 @@
{% render_field form.name %}
{% render_field form.type %}
{% render_field form.group %}
{% render_field form.tenant %}
{% render_field form.site %}
</div>
</div>

View File

@@ -13,7 +13,6 @@ from rest_framework.response import Response
from rest_framework.serializers import Field, ModelSerializer, ValidationError
from rest_framework.viewsets import ModelViewSet as _ModelViewSet, ViewSet
from utilities.choices import ChoiceSet
from .utils import dict_to_filter_params, dynamic_import

View File

@@ -18,7 +18,7 @@ class ChoiceSet(metaclass=ChoiceSetMeta):
@classmethod
def values(cls):
return [c[0] for c in cls.CHOICES]
return [c[0] for c in unpack_grouped_choices(cls.CHOICES)]
@classmethod
def as_dict(cls):

View File

@@ -7,9 +7,6 @@ from django.urls import reverse
from .views import server_error
BASE_PATH = getattr(settings, 'BASE_PATH', False)
LOGIN_REQUIRED = getattr(settings, 'LOGIN_REQUIRED', False)
class LoginRequiredMiddleware(object):
"""
@@ -19,7 +16,7 @@ class LoginRequiredMiddleware(object):
self.get_response = get_response
def __call__(self, request):
if LOGIN_REQUIRED and not request.user.is_authenticated:
if settings.LOGIN_REQUIRED and not request.user.is_authenticated:
# Redirect unauthenticated requests to the login page. API requests are exempt from redirection as the API
# performs its own authentication. Also metrics can be read without login.
api_path = reverse('api-root')

View File

@@ -1,3 +1,6 @@
import logging
from contextlib import contextmanager
from django.contrib.auth.models import Permission, User
from rest_framework.test import APITestCase as _APITestCase
@@ -62,3 +65,15 @@ def choices_to_dict(choices_list):
return {
choice['value']: choice['label'] for choice in choices_list
}
@contextmanager
def disable_warnings(logger_name):
"""
Temporarily suppress expected warning messages to keep the test output clean.
"""
logger = logging.getLogger(logger_name)
current_level = logger.level
logger.setLevel(logging.ERROR)
yield
logger.setLevel(current_level)

View File

@@ -9,7 +9,7 @@ from dcim.models import Region, Site
from extras.choices import CustomFieldTypeChoices
from extras.models import CustomField
from ipam.models import VLAN
from utilities.testing import APITestCase
from utilities.testing import APITestCase, disable_warnings
class WritableNestedSerializerTest(APITestCase):
@@ -50,7 +50,8 @@ class WritableNestedSerializerTest(APITestCase):
}
url = reverse('ipam-api:vlan-list')
response = self.client.post(url, data, format='json', **self.header)
with disable_warnings('django.request'):
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
self.assertEqual(VLAN.objects.count(), 0)
@@ -85,7 +86,8 @@ class WritableNestedSerializerTest(APITestCase):
}
url = reverse('ipam-api:vlan-list')
response = self.client.post(url, data, format='json', **self.header)
with disable_warnings('django.request'):
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
self.assertEqual(VLAN.objects.count(), 0)
@@ -104,7 +106,8 @@ class WritableNestedSerializerTest(APITestCase):
}
url = reverse('ipam-api:vlan-list')
response = self.client.post(url, data, format='json', **self.header)
with disable_warnings('django.request'):
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
self.assertEqual(VLAN.objects.count(), 0)
@@ -119,7 +122,8 @@ class WritableNestedSerializerTest(APITestCase):
}
url = reverse('ipam-api:vlan-list')
response = self.client.post(url, data, format='json', **self.header)
with disable_warnings('django.request'):
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
self.assertEqual(VLAN.objects.count(), 0)

View File

@@ -0,0 +1,50 @@
from django.test import TestCase
from utilities.choices import ChoiceSet
class ExampleChoices(ChoiceSet):
CHOICE_A = 'a'
CHOICE_B = 'b'
CHOICE_C = 'c'
CHOICE_1 = 1
CHOICE_2 = 2
CHOICE_3 = 3
CHOICES = (
('Letters', (
(CHOICE_A, 'A'),
(CHOICE_B, 'B'),
(CHOICE_C, 'C'),
)),
('Digits', (
(CHOICE_1, 'One'),
(CHOICE_2, 'Two'),
(CHOICE_3, 'Three'),
)),
)
LEGACY_MAP = {
CHOICE_A: 101,
CHOICE_B: 102,
CHOICE_C: 103,
CHOICE_1: 201,
CHOICE_2: 202,
CHOICE_3: 203,
}
class ChoiceSetTestCase(TestCase):
def test_values(self):
self.assertListEqual(ExampleChoices.values(), ['a', 'b', 'c', 1, 2, 3])
def test_as_dict(self):
self.assertEqual(ExampleChoices.as_dict(), {
'a': 'A', 'b': 'B', 'c': 'C', 1: 'One', 2: 'Two', 3: 'Three'
})
def test_slug_to_id(self):
self.assertEqual(ExampleChoices.slug_to_id('a'), 101)
def test_id_to_slug(self):
self.assertEqual(ExampleChoices.id_to_slug(101), 'a')

View File

@@ -1,6 +1,6 @@
import re
from django.core.validators import _lazy_re_compile, BaseValidator, URLValidator
from django.core.validators import _lazy_re_compile, URLValidator
class EnhancedURLValidator(URLValidator):
@@ -26,19 +26,3 @@ class EnhancedURLValidator(URLValidator):
r'(?:[/?#][^\s]*)?' # Path
r'\Z', re.IGNORECASE)
schemes = AnyURLScheme()
class MaxPrefixLengthValidator(BaseValidator):
message = 'The prefix length must be less than or equal to %(limit_value)s.'
code = 'max_prefix_length'
def compare(self, a, b):
return a.prefixlen > b
class MinPrefixLengthValidator(BaseValidator):
message = 'The prefix length must be greater than or equal to %(limit_value)s.'
code = 'min_prefix_length'
def compare(self, a, b):
return a.prefixlen < b

View File

@@ -3,6 +3,7 @@ from django.core.exceptions import ValidationError
from taggit.forms import TagField
from dcim.choices import InterfaceModeChoices
from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN
from dcim.forms import INTERFACE_MODE_HELP_TEXT
from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site
from extras.forms import AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm
@@ -170,7 +171,8 @@ class ClusterBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit
)
)
comments = CommentField(
widget=SmallTextarea()
widget=SmallTextarea,
label='Comments'
)
class Meta:
@@ -534,7 +536,8 @@ class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldB
label='Disk (GB)'
)
comments = CommentField(
widget=SmallTextarea()
widget=SmallTextarea,
label='Comments'
)
class Meta:
@@ -745,8 +748,8 @@ class InterfaceCreateForm(ComponentForm):
)
mtu = forms.IntegerField(
required=False,
min_value=1,
max_value=32767,
min_value=INTERFACE_MTU_MIN,
max_value=INTERFACE_MTU_MAX,
label='MTU'
)
mac_address = forms.CharField(
@@ -834,8 +837,8 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
)
mtu = forms.IntegerField(
required=False,
min_value=1,
max_value=32767,
min_value=INTERFACE_MTU_MIN,
max_value=INTERFACE_MTU_MAX,
label='MTU'
)
description = forms.CharField(
@@ -931,8 +934,8 @@ class VirtualMachineBulkAddInterfaceForm(VirtualMachineBulkAddComponentForm):
)
mtu = forms.IntegerField(
required=False,
min_value=1,
max_value=32767,
min_value=INTERFACE_MTU_MIN,
max_value=INTERFACE_MTU_MAX,
label='MTU'
)
description = forms.CharField(

View File

@@ -5,7 +5,7 @@ from rest_framework import status
from dcim.choices import InterfaceModeChoices
from dcim.models import Interface
from ipam.models import IPAddress, VLAN
from utilities.testing import APITestCase, choices_to_dict
from utilities.testing import APITestCase, choices_to_dict, disable_warnings
from virtualization.choices import *
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@@ -417,7 +417,8 @@ class VirtualMachineTest(APITestCase):
}
url = reverse('virtualization-api:virtualmachine-list')
response = self.client.post(url, data, format='json', **self.header)
with disable_warnings('django.request'):
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
self.assertEqual(VirtualMachine.objects.count(), 4)