mirror of
https://github.com/netbox-community/netbox.git
synced 2025-12-29 08:37:46 -06:00
Compare commits
114 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3143f75a38 | ||
|
|
be716a3345 | ||
|
|
8de9f52151 | ||
|
|
0a11fc1221 | ||
|
|
ede576a2ae | ||
|
|
12cf69f7e1 | ||
|
|
2a4ccae113 | ||
|
|
77292050d4 | ||
|
|
e7ef142620 | ||
|
|
07d8476cf5 | ||
|
|
9b9e568446 | ||
|
|
3c5346f60a | ||
|
|
8d547e9906 | ||
|
|
720bd87292 | ||
|
|
8306976b3e | ||
|
|
3bce8e9716 | ||
|
|
9c4f1d5795 | ||
|
|
93fa00b673 | ||
|
|
49a6332d37 | ||
|
|
5c5b9c95aa | ||
|
|
7abcc7acaa | ||
|
|
d0f127e575 | ||
|
|
00b50f9c65 | ||
|
|
46d0e88da3 | ||
|
|
1901f63b4c | ||
|
|
2662bd0ad8 | ||
|
|
27d70b6b51 | ||
|
|
011280b0bf | ||
|
|
4e4a05d3b9 | ||
|
|
7a548e806d | ||
|
|
47962ea732 | ||
|
|
eb4c2e5d7f | ||
|
|
a13bddde58 | ||
|
|
66330418cb | ||
|
|
151943bfbc | ||
|
|
35cbee5107 | ||
|
|
c6473d654d | ||
|
|
096814dc33 | ||
|
|
45b66b174c | ||
|
|
0ec091ffe1 | ||
|
|
f24e7652a8 | ||
|
|
9f58c27fcf | ||
|
|
d3463b596a | ||
|
|
66d5cc47a5 | ||
|
|
9694bacb69 | ||
|
|
fcba2baf42 | ||
|
|
629712142f | ||
|
|
cdecf93f00 | ||
|
|
fe402331f2 | ||
|
|
fcbbb36afc | ||
|
|
2e69037c29 | ||
|
|
1b26afdfbb | ||
|
|
7b517abdb6 | ||
|
|
2445d1896b | ||
|
|
72d1fe0cd7 | ||
|
|
b7e71f9f39 | ||
|
|
f41564b578 | ||
|
|
aa56c020ab | ||
|
|
ba6df87d10 | ||
|
|
5e7fbc4e42 | ||
|
|
f826e15603 | ||
|
|
b7dea5a9f7 | ||
|
|
ddd9f86031 | ||
|
|
1c13a79961 | ||
|
|
03436b729d | ||
|
|
d123664503 | ||
|
|
10917123fd | ||
|
|
b06bed368b | ||
|
|
e13d4ffe60 | ||
|
|
2581a55214 | ||
|
|
aa4b89f751 | ||
|
|
838aaffc4b | ||
|
|
9dfd0e5b40 | ||
|
|
3357c050c4 | ||
|
|
60c5418516 | ||
|
|
48b4695ebe | ||
|
|
737b05d12b | ||
|
|
1d0546b3d1 | ||
|
|
a7a166a9cb | ||
|
|
74e1c08324 | ||
|
|
007de40ada | ||
|
|
e184eb3521 | ||
|
|
e421c15bdd | ||
|
|
469a088874 | ||
|
|
63dbee16cc | ||
|
|
5f3f21215a | ||
|
|
cdd7ed21ee | ||
|
|
255d12309a | ||
|
|
856d14aaa6 | ||
|
|
134cf38a84 | ||
|
|
1a56a5561c | ||
|
|
eb7fbe4b3a | ||
|
|
9d3215e806 | ||
|
|
9e855ac6cd | ||
|
|
a6fde3168b | ||
|
|
939a7bbe50 | ||
|
|
c6d18da2eb | ||
|
|
606f3dacbb | ||
|
|
aa73a7ad02 | ||
|
|
a4687be5e5 | ||
|
|
302f87e108 | ||
|
|
439fa731ba | ||
|
|
c6eb40daa8 | ||
|
|
f15cde0275 | ||
|
|
83427d5585 | ||
|
|
d3f278400e | ||
|
|
295d4f0394 | ||
|
|
8aad11b8d2 | ||
|
|
0a1dd64b94 | ||
|
|
f220b3f128 | ||
|
|
1c0e0fec4c | ||
|
|
5369aef971 | ||
|
|
9f569d4b1b | ||
|
|
604924231a |
@@ -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
|
||||
|
||||
|
||||
58
docs/core-functionality/power.md
Normal file
58
docs/core-functionality/power.md
Normal 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 |
|
||||
+--------+ +--------+
|
||||
```
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -89,7 +89,8 @@ class ProviderBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdi
|
||||
label='Admin contact'
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea()
|
||||
widget=SmallTextarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
#
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
20
netbox/dcim/migrations/0091_interface_type_other.py
Normal file
20
netbox/dcim/migrations/0091_interface_type_other.py
Normal 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
|
||||
),
|
||||
]
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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-台灣')
|
||||
|
||||
@@ -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)
|
||||
|
||||
143
netbox/extras/tests/test_webhooks.py
Normal file
143
netbox/extras/tests/test_webhooks.py
Normal 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)
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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).",
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
21
netbox/ipam/migrations/0034_fix_ipaddress_status_dhcp.py
Normal file
21
netbox/ipam/migrations/0034_fix_ipaddress_status_dhcp.py
Normal 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
|
||||
),
|
||||
]
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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',
|
||||
|
||||
0
netbox/netbox/tests/__init__.py
Normal file
0
netbox/netbox/tests/__init__.py
Normal file
13
netbox/netbox/tests/test_api.py
Normal file
13
netbox/netbox/tests/test_api.py
Normal 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)
|
||||
24
netbox/netbox/tests/test_views.py
Normal file
24
netbox/netbox/tests/test_views.py
Normal 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)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
5
netbox/secrets/constants.py
Normal file
5
netbox/secrets/constants.py
Normal file
@@ -0,0 +1,5 @@
|
||||
#
|
||||
# Secrets
|
||||
#
|
||||
|
||||
SECRET_PLAINTEXT_MAX_LENGTH = 65535
|
||||
@@ -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()
|
||||
|
||||
@@ -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"""
|
||||
|
||||
32
netbox/secrets/tests/test_form.py
Normal file
32
netbox/secrets/tests/test_form.py
Normal 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())
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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' %}
|
||||
|
||||
19
netbox/templates/dcim/inc/cable_form.html
Normal file
19
netbox/templates/dcim/inc/cable_form.html
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
{% render_field form.nat_device %}
|
||||
</div>
|
||||
<div class="tab-pane" id="search">
|
||||
|
||||
{% render_field form.nat_vrf %}
|
||||
</div>
|
||||
</div>
|
||||
{% render_field form.nat_inside %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
50
netbox/utilities/tests/test_choices.py
Normal file
50
netbox/utilities/tests/test_choices.py
Normal 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')
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user