mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-16 04:02:52 -06:00
Merge branch 'feature' into 9102-cabling
This commit is contained in:
commit
25ed3390cb
@ -13,7 +13,7 @@ Each circuit is also assigned one of the following operational statuses:
|
|||||||
* Deprovisioning
|
* Deprovisioning
|
||||||
* Decommissioned
|
* Decommissioned
|
||||||
|
|
||||||
Circuits also have optional fields for annotating their installation date and commit rate, and may be assigned to NetBox tenants.
|
Circuits also have optional fields for annotating their installation and termination dates and commit rate, and may be assigned to NetBox tenants.
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
NetBox currently models only physical circuits: those which have exactly two endpoints. It is common to layer virtualized constructs (_virtual circuits_) such as MPLS or EVPN tunnels on top of these, however NetBox does not yet support virtual circuit modeling.
|
NetBox currently models only physical circuits: those which have exactly two endpoints. It is common to layer virtualized constructs (_virtual circuits_) such as MPLS or EVPN tunnels on top of these, however NetBox does not yet support virtual circuit modeling.
|
||||||
|
@ -11,6 +11,10 @@ Interfaces may be physical or virtual in nature, but only physical interfaces ma
|
|||||||
|
|
||||||
Physical interfaces may be arranged into a link aggregation group (LAG) and associated with a parent LAG (virtual) interface. LAG interfaces can be recursively nested to model bonding of trunk groups. Like all virtual interfaces, LAG interfaces cannot be connected physically.
|
Physical interfaces may be arranged into a link aggregation group (LAG) and associated with a parent LAG (virtual) interface. LAG interfaces can be recursively nested to model bonding of trunk groups. Like all virtual interfaces, LAG interfaces cannot be connected physically.
|
||||||
|
|
||||||
|
### Power over Ethernet (PoE)
|
||||||
|
|
||||||
|
Physical interfaces can be assigned a PoE mode to indicate PoE capability: power supplying equipment (PSE) or powered device (PD). Additionally, a PoE mode may be specified. This can be one of the listed IEEE 802.3 standards, or a passive setting (24 or 48 volts across two or four pairs).
|
||||||
|
|
||||||
### Wireless Interfaces
|
### Wireless Interfaces
|
||||||
|
|
||||||
Wireless interfaces may additionally track the following attributes:
|
Wireless interfaces may additionally track the following attributes:
|
||||||
|
@ -2,5 +2,4 @@
|
|||||||
|
|
||||||
Racks and devices can be grouped by location within a site. A location may represent a floor, room, cage, or similar organizational unit. Locations can be nested to form a hierarchy. For example, you may have floors within a site, and rooms within a floor.
|
Racks and devices can be grouped by location within a site. A location may represent a floor, room, cage, or similar organizational unit. Locations can be nested to form a hierarchy. For example, you may have floors within a site, and rooms within a floor.
|
||||||
|
|
||||||
Each location must have a name that is unique within its parent site and location, if any.
|
Each location must have a name that is unique within its parent site and location, if any, and must be assigned an operational status. (The set of available statuses is configurable.)
|
||||||
|
|
||||||
|
@ -5,9 +5,11 @@ Sometimes it is desirable to associate additional data with a group of devices o
|
|||||||
* Region
|
* Region
|
||||||
* Site group
|
* Site group
|
||||||
* Site
|
* Site
|
||||||
|
* Location (devices only)
|
||||||
* Device type (devices only)
|
* Device type (devices only)
|
||||||
* Role
|
* Role
|
||||||
* Platform
|
* Platform
|
||||||
|
* Cluster type (VMs only)
|
||||||
* Cluster group (VMs only)
|
* Cluster group (VMs only)
|
||||||
* Cluster (VMs only)
|
* Cluster (VMs only)
|
||||||
* Tenant group
|
* Tenant group
|
||||||
|
@ -9,4 +9,4 @@ Each token contains a 160-bit key represented as 40 hexadecimal characters. When
|
|||||||
|
|
||||||
By default, a token can be used to perform all actions via the API that a user would be permitted to do via the web UI. Deselecting the "write enabled" option will restrict API requests made with the token to read operations (e.g. GET) only.
|
By default, a token can be used to perform all actions via the API that a user would be permitted to do via the web UI. Deselecting the "write enabled" option will restrict API requests made with the token to read operations (e.g. GET) only.
|
||||||
|
|
||||||
Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox.
|
Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox. Tokens can also be restricted by IP range: If defined, authentication for API clients connecting from an IP address outside these ranges will fail.
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
# Wireless LANs
|
# Wireless LANs
|
||||||
|
|
||||||
A wireless LAN is a set of interfaces connected via a common wireless channel. Each instance must have an SSID, and may optionally be correlated to a VLAN. Wireless LANs can be arranged into hierarchical groups.
|
A wireless LAN is a set of interfaces connected via a common wireless channel. Each instance must have an SSID, and may optionally be correlated to a VLAN. Wireless LANs can be arranged into hierarchical groups, and each may be associated with a particular tenant.
|
||||||
|
|
||||||
An interface may be attached to multiple wireless LANs, provided they are all operating on the same channel. Only wireless interfaces may be attached to wireless LANs.
|
An interface may be attached to multiple wireless LANs, provided they are all operating on the same channel. Only wireless interfaces may be attached to wireless LANs.
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
# Wireless Links
|
# Wireless Links
|
||||||
|
|
||||||
A wireless link represents a connection between exactly two wireless interfaces. It may optionally be assigned an SSID and a description. It may also have a status assigned to it, similar to the cable model.
|
A wireless link represents a connection between exactly two wireless interfaces. It may optionally be assigned an SSID and a description. It may also have a status assigned to it, similar to the cable model. Each wireless link may also be assigned to a particular tenant.
|
||||||
|
|
||||||
Each wireless link may have authentication attributes associated with it, including:
|
Each wireless link may have authentication attributes associated with it, including:
|
||||||
|
|
||||||
|
@ -49,6 +49,24 @@ class MyModel(NetBoxModel):
|
|||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### The `clone()` Method
|
||||||
|
|
||||||
|
!!! info
|
||||||
|
This method was introduced in NetBox v3.3.
|
||||||
|
|
||||||
|
The `NetBoxModel` class includes a `clone()` method to be used for gathering attriubtes which can be used to create a "cloned" instance. This is used primarily for form initialization, e.g. when using the "clone" button in the NetBox UI. By default, this method will replicate any fields listed in the model's `clone_fields` list, if defined.
|
||||||
|
|
||||||
|
Plugin models can leverage this method by defining `clone_fields` as a list of field names to be replicated, or override this method to replace or extend its content:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MyModel(NetBoxModel):
|
||||||
|
|
||||||
|
def clone(self):
|
||||||
|
attrs = super().clone()
|
||||||
|
attrs['extra-value'] = 123
|
||||||
|
return attrs
|
||||||
|
```
|
||||||
|
|
||||||
### Enabling Features Individually
|
### Enabling Features Individually
|
||||||
|
|
||||||
If you prefer instead to enable only a subset of these features for a plugin model, NetBox provides a discrete "mix-in" class for each feature. You can subclass each of these individually when defining your model. (Your model will also need to inherit from Django's built-in `Model` class.)
|
If you prefer instead to enable only a subset of these features for a plugin model, NetBox provides a discrete "mix-in" class for each feature. You can subclass each of these individually when defining your model. (Your model will also need to inherit from Django's built-in `Model` class.)
|
||||||
|
@ -11,15 +11,30 @@
|
|||||||
|
|
||||||
#### Half-Height Rack Units ([#51](https://github.com/netbox-community/netbox/issues/51))
|
#### Half-Height Rack Units ([#51](https://github.com/netbox-community/netbox/issues/51))
|
||||||
|
|
||||||
|
#### PoE Interface Attributes ([#1099](https://github.com/netbox-community/netbox/issues/1099))
|
||||||
|
|
||||||
|
#### Restrict API Tokens by Client IP ([#8233](https://github.com/netbox-community/netbox/issues/8233))
|
||||||
|
|
||||||
### Enhancements
|
### Enhancements
|
||||||
|
|
||||||
* [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses
|
* [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses
|
||||||
|
* [#4350](https://github.com/netbox-community/netbox/issues/4350) - Illustrate reservations vertically alongside rack elevations
|
||||||
|
* [#4434](https://github.com/netbox-community/netbox/issues/4434) - Enable highlighting devices within rack elevations
|
||||||
* [#5303](https://github.com/netbox-community/netbox/issues/5303) - A virtual machine may be assigned to a site and/or cluster
|
* [#5303](https://github.com/netbox-community/netbox/issues/5303) - A virtual machine may be assigned to a site and/or cluster
|
||||||
|
* [#7120](https://github.com/netbox-community/netbox/issues/7120) - Add `termination_date` field to Circuit
|
||||||
|
* [#7744](https://github.com/netbox-community/netbox/issues/7744) - Add `status` field to Location
|
||||||
* [#8222](https://github.com/netbox-community/netbox/issues/8222) - Enable the assignment of a VM to a specific host device within a cluster
|
* [#8222](https://github.com/netbox-community/netbox/issues/8222) - Enable the assignment of a VM to a specific host device within a cluster
|
||||||
* [#8471](https://github.com/netbox-community/netbox/issues/8471) - Add `status` field to Cluster
|
* [#8471](https://github.com/netbox-community/netbox/issues/8471) - Add `status` field to Cluster
|
||||||
* [#8495](https://github.com/netbox-community/netbox/issues/8495) - Enable custom field grouping
|
* [#8495](https://github.com/netbox-community/netbox/issues/8495) - Enable custom field grouping
|
||||||
* [#8995](https://github.com/netbox-community/netbox/issues/8995) - Enable arbitrary ordering of REST API results
|
* [#8995](https://github.com/netbox-community/netbox/issues/8995) - Enable arbitrary ordering of REST API results
|
||||||
* [#9166](https://github.com/netbox-community/netbox/issues/9166) - Add UI visibility toggle for custom fields
|
* [#9166](https://github.com/netbox-community/netbox/issues/9166) - Add UI visibility toggle for custom fields
|
||||||
|
* [#9177](https://github.com/netbox-community/netbox/issues/9177) - Add tenant assignment for wireless LANs & links
|
||||||
|
* [#9536](https://github.com/netbox-community/netbox/issues/9536) - Track API token usage times
|
||||||
|
* [#9582](https://github.com/netbox-community/netbox/issues/9582) - Enable assigning config contexts based on device location
|
||||||
|
|
||||||
|
### Plugins API
|
||||||
|
|
||||||
|
* [#9414](https://github.com/netbox-community/netbox/issues/9414) - Add `clone()` method to NetBoxModel for copying instance attributes
|
||||||
|
|
||||||
### Other Changes
|
### Other Changes
|
||||||
|
|
||||||
@ -28,20 +43,35 @@
|
|||||||
|
|
||||||
### REST API Changes
|
### REST API Changes
|
||||||
|
|
||||||
|
* circuits.Circuit
|
||||||
|
* Added optional `termination_date` field
|
||||||
* dcim.Device
|
* dcim.Device
|
||||||
* The `position` field has been changed from an integer to a decimal
|
* The `position` field has been changed from an integer to a decimal
|
||||||
* dcim.DeviceType
|
* dcim.DeviceType
|
||||||
* The `u_height` field has been changed from an integer to a decimal
|
* The `u_height` field has been changed from an integer to a decimal
|
||||||
|
* dcim.Interface
|
||||||
|
* Added the optional `poe_mode` and `poe_type` fields
|
||||||
|
* dcim.Location
|
||||||
|
* Added required `status` field (default value: `active`)
|
||||||
* dcim.Rack
|
* dcim.Rack
|
||||||
* The `elevation` endpoint now includes half-height rack units, and utilizes decimal values for the ID and name of each unit
|
* The `elevation` endpoint now includes half-height rack units, and utilizes decimal values for the ID and name of each unit
|
||||||
|
* extras.ConfigContext
|
||||||
|
* Added the `locations` many-to-many field to track the assignment of ConfigContexts to Locations
|
||||||
* extras.CustomField
|
* extras.CustomField
|
||||||
* Added `group_name` and `ui_visibility` fields
|
* Added `group_name` and `ui_visibility` fields
|
||||||
* ipam.IPAddress
|
* ipam.IPAddress
|
||||||
* The `nat_inside` field no longer requires a unique value
|
* The `nat_inside` field no longer requires a unique value
|
||||||
* The `nat_outside` field has changed from a single IP address instance to a list of multiple IP addresses
|
* The `nat_outside` field has changed from a single IP address instance to a list of multiple IP addresses
|
||||||
|
* users.Token
|
||||||
|
* Added the `allowed_ips` array field
|
||||||
|
* Added the read-only `last_used` datetime field
|
||||||
* virtualization.Cluster
|
* virtualization.Cluster
|
||||||
* Added required `status` field (default value: `active`)
|
* Added required `status` field (default value: `active`)
|
||||||
* virtualization.VirtualMachine
|
* virtualization.VirtualMachine
|
||||||
* Added `device` field
|
* Added `device` field
|
||||||
* The `site` field is now directly writable (rather than being inferred from the assigned cluster)
|
* The `site` field is now directly writable (rather than being inferred from the assigned cluster)
|
||||||
* The `cluster` field is now optional. A virtual machine must have a site and/or cluster assigned.
|
* The `cluster` field is now optional. A virtual machine must have a site and/or cluster assigned.
|
||||||
|
wireless.WirelessLAN
|
||||||
|
* Added `tenant` field
|
||||||
|
wireless.WirelessLink
|
||||||
|
* Added `tenant` field
|
||||||
|
@ -29,6 +29,11 @@ $ curl https://netbox/api/dcim/sites/
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
When a token is used to authenticate a request, its `last_updated` time updated to the current time if its last use was recorded more than 60 seconds ago (or was never recorded). This allows users to determine which tokens have been active recently.
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
The "last used" time for tokens will not be updated while maintenance mode is enabled.
|
||||||
|
|
||||||
## Initial Token Provisioning
|
## Initial Token Provisioning
|
||||||
|
|
||||||
Ideally, each user should provision his or her own REST API token(s) via the web UI. However, you may encounter where a token must be created by a user via the REST API itself. NetBox provides a special endpoint to provision tokens using a valid username and password combination.
|
Ideally, each user should provision his or her own REST API token(s) via the web UI. However, you may encounter where a token must be created by a user via the REST API itself. NetBox provides a special endpoint to provision tokens using a valid username and password combination.
|
||||||
|
@ -92,9 +92,9 @@ class CircuitSerializer(NetBoxModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Circuit
|
model = Circuit
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate',
|
'id', 'url', 'display', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date',
|
||||||
'description', 'termination_a', 'termination_z', 'comments', 'tags', 'custom_fields', 'created',
|
'commit_rate', 'description', 'termination_a', 'termination_z', 'comments', 'tags', 'custom_fields',
|
||||||
'last_updated',
|
'created', 'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -183,7 +183,7 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Circuit
|
model = Circuit
|
||||||
fields = ['id', 'cid', 'description', 'install_date', 'commit_rate']
|
fields = ['id', 'cid', 'description', 'install_date', 'termination_date', 'commit_rate']
|
||||||
|
|
||||||
def search(self, queryset, name, value):
|
def search(self, queryset, name, value):
|
||||||
if not value.strip():
|
if not value.strip():
|
||||||
|
@ -7,7 +7,7 @@ from ipam.models import ASN
|
|||||||
from netbox.forms import NetBoxModelBulkEditForm
|
from netbox.forms import NetBoxModelBulkEditForm
|
||||||
from tenancy.models import Tenant
|
from tenancy.models import Tenant
|
||||||
from utilities.forms import (
|
from utilities.forms import (
|
||||||
add_blank_choice, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea,
|
add_blank_choice, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea,
|
||||||
StaticSelect,
|
StaticSelect,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -122,6 +122,14 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
queryset=Tenant.objects.all(),
|
queryset=Tenant.objects.all(),
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
|
install_date = forms.DateField(
|
||||||
|
required=False,
|
||||||
|
widget=DatePicker()
|
||||||
|
)
|
||||||
|
termination_date = forms.DateField(
|
||||||
|
required=False,
|
||||||
|
widget=DatePicker()
|
||||||
|
)
|
||||||
commit_rate = forms.IntegerField(
|
commit_rate = forms.IntegerField(
|
||||||
required=False,
|
required=False,
|
||||||
label='Commit rate (Kbps)'
|
label='Commit rate (Kbps)'
|
||||||
@ -137,7 +145,9 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
|
|
||||||
model = Circuit
|
model = Circuit
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, ('type', 'provider', 'status', 'tenant', 'commit_rate', 'description')),
|
('Circuit', ('provider', 'type', 'status', 'description')),
|
||||||
|
('Service Parameters', ('install_date', 'termination_date', 'commit_rate')),
|
||||||
|
('Tenancy', ('tenant',)),
|
||||||
)
|
)
|
||||||
nullable_fields = (
|
nullable_fields = (
|
||||||
'tenant', 'commit_rate', 'description', 'comments',
|
'tenant', 'commit_rate', 'description', 'comments',
|
||||||
|
@ -72,5 +72,6 @@ class CircuitCSVForm(NetBoxModelCSVForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Circuit
|
model = Circuit
|
||||||
fields = [
|
fields = [
|
||||||
'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
|
'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date', 'commit_rate',
|
||||||
|
'description', 'comments',
|
||||||
]
|
]
|
||||||
|
@ -7,7 +7,7 @@ from dcim.models import Region, Site, SiteGroup
|
|||||||
from ipam.models import ASN
|
from ipam.models import ASN
|
||||||
from netbox.forms import NetBoxModelFilterSetForm
|
from netbox.forms import NetBoxModelFilterSetForm
|
||||||
from tenancy.forms import TenancyFilterForm, ContactModelFilterForm
|
from tenancy.forms import TenancyFilterForm, ContactModelFilterForm
|
||||||
from utilities.forms import DynamicModelMultipleChoiceField, MultipleChoiceField, TagFilterField
|
from utilities.forms import DatePicker, DynamicModelMultipleChoiceField, MultipleChoiceField, TagFilterField
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'CircuitFilterForm',
|
'CircuitFilterForm',
|
||||||
@ -84,7 +84,7 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
|
|||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, ('q', 'tag')),
|
(None, ('q', 'tag')),
|
||||||
('Provider', ('provider_id', 'provider_network_id')),
|
('Provider', ('provider_id', 'provider_network_id')),
|
||||||
('Attributes', ('type_id', 'status', 'commit_rate')),
|
('Attributes', ('type_id', 'status', 'install_date', 'termination_date', 'commit_rate')),
|
||||||
('Location', ('region_id', 'site_group_id', 'site_id')),
|
('Location', ('region_id', 'site_group_id', 'site_id')),
|
||||||
('Tenant', ('tenant_group_id', 'tenant_id')),
|
('Tenant', ('tenant_group_id', 'tenant_id')),
|
||||||
('Contacts', ('contact', 'contact_role', 'contact_group')),
|
('Contacts', ('contact', 'contact_role', 'contact_group')),
|
||||||
@ -130,6 +130,14 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
|
|||||||
},
|
},
|
||||||
label=_('Site')
|
label=_('Site')
|
||||||
)
|
)
|
||||||
|
install_date = forms.DateField(
|
||||||
|
required=False,
|
||||||
|
widget=DatePicker
|
||||||
|
)
|
||||||
|
termination_date = forms.DateField(
|
||||||
|
required=False,
|
||||||
|
widget=DatePicker
|
||||||
|
)
|
||||||
commit_rate = forms.IntegerField(
|
commit_rate = forms.IntegerField(
|
||||||
required=False,
|
required=False,
|
||||||
min_value=0,
|
min_value=0,
|
||||||
|
@ -93,15 +93,16 @@ class CircuitForm(TenancyForm, NetBoxModelForm):
|
|||||||
comments = CommentField()
|
comments = CommentField()
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
('Circuit', ('provider', 'cid', 'type', 'status', 'install_date', 'commit_rate', 'description', 'tags')),
|
('Circuit', ('provider', 'cid', 'type', 'status', 'description', 'tags')),
|
||||||
|
('Service Parameters', ('install_date', 'termination_date', 'commit_rate')),
|
||||||
('Tenancy', ('tenant_group', 'tenant')),
|
('Tenancy', ('tenant_group', 'tenant')),
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Circuit
|
model = Circuit
|
||||||
fields = [
|
fields = [
|
||||||
'cid', 'type', 'provider', 'status', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant',
|
'cid', 'type', 'provider', 'status', 'install_date', 'termination_date', 'commit_rate', 'description',
|
||||||
'comments', 'tags',
|
'tenant_group', 'tenant', 'comments', 'tags',
|
||||||
]
|
]
|
||||||
help_texts = {
|
help_texts = {
|
||||||
'cid': "Unique circuit ID",
|
'cid': "Unique circuit ID",
|
||||||
@ -110,6 +111,7 @@ class CircuitForm(TenancyForm, NetBoxModelForm):
|
|||||||
widgets = {
|
widgets = {
|
||||||
'status': StaticSelect(),
|
'status': StaticSelect(),
|
||||||
'install_date': DatePicker(),
|
'install_date': DatePicker(),
|
||||||
|
'termination_date': DatePicker(),
|
||||||
'commit_rate': SelectSpeedWidget(),
|
'commit_rate': SelectSpeedWidget(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
18
netbox/circuits/migrations/0036_circuit_termination_date.py
Normal file
18
netbox/circuits/migrations/0036_circuit_termination_date.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.0.5 on 2022-06-22 18:51
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('circuits', '0035_provider_asns'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='circuit',
|
||||||
|
name='termination_date',
|
||||||
|
field=models.DateField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
@ -4,7 +4,7 @@ from django.db import migrations, models
|
|||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('circuits', '0035_provider_asns'),
|
('circuits', '0036_circuit_termination_date'),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
@ -4,8 +4,8 @@ from django.db import migrations, models
|
|||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('circuits', '0036_new_cabling_models'),
|
('circuits', '0037_new_cabling_models'),
|
||||||
('dcim', '0158_populate_cable_ends'),
|
('dcim', '0160_populate_cable_ends'),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
@ -78,7 +78,12 @@ class Circuit(NetBoxModel):
|
|||||||
install_date = models.DateField(
|
install_date = models.DateField(
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
verbose_name='Date installed'
|
verbose_name='Installed'
|
||||||
|
)
|
||||||
|
termination_date = models.DateField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name='Terminates'
|
||||||
)
|
)
|
||||||
commit_rate = models.PositiveIntegerField(
|
commit_rate = models.PositiveIntegerField(
|
||||||
blank=True,
|
blank=True,
|
||||||
@ -119,7 +124,7 @@ class Circuit(NetBoxModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
clone_fields = [
|
clone_fields = [
|
||||||
'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description',
|
'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date', 'commit_rate', 'description',
|
||||||
]
|
]
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -70,7 +70,7 @@ class CircuitTable(NetBoxTable):
|
|||||||
model = Circuit
|
model = Circuit
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date',
|
'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date',
|
||||||
'commit_rate', 'description', 'comments', 'contacts', 'tags', 'created', 'last_updated',
|
'termination_date', 'commit_rate', 'description', 'comments', 'contacts', 'tags', 'created', 'last_updated',
|
||||||
)
|
)
|
||||||
default_columns = (
|
default_columns = (
|
||||||
'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description',
|
'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description',
|
||||||
|
@ -208,12 +208,12 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
ProviderNetwork.objects.bulk_create(provider_networks)
|
ProviderNetwork.objects.bulk_create(provider_networks)
|
||||||
|
|
||||||
circuits = (
|
circuits = (
|
||||||
Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 1', install_date='2020-01-01', commit_rate=1000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar1'),
|
Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 1', install_date='2020-01-01', termination_date='2021-01-01', commit_rate=1000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar1'),
|
||||||
Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 2', install_date='2020-01-02', commit_rate=2000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar2'),
|
Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 2', install_date='2020-01-02', termination_date='2021-01-02', commit_rate=2000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar2'),
|
||||||
Circuit(provider=providers[0], tenant=tenants[1], type=circuit_types[0], cid='Test Circuit 3', install_date='2020-01-03', commit_rate=3000, status=CircuitStatusChoices.STATUS_PLANNED),
|
Circuit(provider=providers[0], tenant=tenants[1], type=circuit_types[0], cid='Test Circuit 3', install_date='2020-01-03', termination_date='2021-01-03', commit_rate=3000, status=CircuitStatusChoices.STATUS_PLANNED),
|
||||||
Circuit(provider=providers[1], tenant=tenants[1], type=circuit_types[1], cid='Test Circuit 4', install_date='2020-01-04', commit_rate=4000, status=CircuitStatusChoices.STATUS_PLANNED),
|
Circuit(provider=providers[1], tenant=tenants[1], type=circuit_types[1], cid='Test Circuit 4', install_date='2020-01-04', termination_date='2021-01-04', commit_rate=4000, status=CircuitStatusChoices.STATUS_PLANNED),
|
||||||
Circuit(provider=providers[1], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 5', install_date='2020-01-05', commit_rate=5000, status=CircuitStatusChoices.STATUS_OFFLINE),
|
Circuit(provider=providers[1], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 5', install_date='2020-01-05', termination_date='2021-01-05', commit_rate=5000, status=CircuitStatusChoices.STATUS_OFFLINE),
|
||||||
Circuit(provider=providers[1], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 6', install_date='2020-01-06', commit_rate=6000, status=CircuitStatusChoices.STATUS_OFFLINE),
|
Circuit(provider=providers[1], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 6', install_date='2020-01-06', termination_date='2021-01-06', commit_rate=6000, status=CircuitStatusChoices.STATUS_OFFLINE),
|
||||||
)
|
)
|
||||||
Circuit.objects.bulk_create(circuits)
|
Circuit.objects.bulk_create(circuits)
|
||||||
|
|
||||||
@ -235,6 +235,10 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
params = {'install_date': ['2020-01-01', '2020-01-02']}
|
params = {'install_date': ['2020-01-01', '2020-01-02']}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
|
def test_termination_date(self):
|
||||||
|
params = {'termination_date': ['2021-01-01', '2021-01-02']}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
def test_commit_rate(self):
|
def test_commit_rate(self):
|
||||||
params = {'commit_rate': ['1000', '2000']}
|
params = {'commit_rate': ['1000', '2000']}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
@ -130,6 +130,7 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|||||||
'status': CircuitStatusChoices.STATUS_DECOMMISSIONED,
|
'status': CircuitStatusChoices.STATUS_DECOMMISSIONED,
|
||||||
'tenant': None,
|
'tenant': None,
|
||||||
'install_date': datetime.date(2020, 1, 1),
|
'install_date': datetime.date(2020, 1, 1),
|
||||||
|
'termination_date': datetime.date(2021, 1, 1),
|
||||||
'commit_rate': 1000,
|
'commit_rate': 1000,
|
||||||
'description': 'A new circuit',
|
'description': 'A new circuit',
|
||||||
'comments': 'Some comments',
|
'comments': 'Some comments',
|
||||||
|
@ -196,6 +196,7 @@ class LocationSerializer(NestedGroupModelSerializer):
|
|||||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:location-detail')
|
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:location-detail')
|
||||||
site = NestedSiteSerializer()
|
site = NestedSiteSerializer()
|
||||||
parent = NestedLocationSerializer(required=False, allow_null=True)
|
parent = NestedLocationSerializer(required=False, allow_null=True)
|
||||||
|
status = ChoiceField(choices=LocationStatusChoices, required=False)
|
||||||
tenant = NestedTenantSerializer(required=False, allow_null=True)
|
tenant = NestedTenantSerializer(required=False, allow_null=True)
|
||||||
rack_count = serializers.IntegerField(read_only=True)
|
rack_count = serializers.IntegerField(read_only=True)
|
||||||
device_count = serializers.IntegerField(read_only=True)
|
device_count = serializers.IntegerField(read_only=True)
|
||||||
@ -203,8 +204,8 @@ class LocationSerializer(NestedGroupModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Location
|
model = Location
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'tenant', 'description', 'tags', 'custom_fields',
|
'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'status', 'tenant', 'description', 'tags',
|
||||||
'created', 'last_updated', 'rack_count', 'device_count', '_depth',
|
'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count', '_depth',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -297,7 +298,10 @@ class RackElevationDetailFilterSerializer(serializers.Serializer):
|
|||||||
default=ConfigItem('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT')
|
default=ConfigItem('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT')
|
||||||
)
|
)
|
||||||
legend_width = serializers.IntegerField(
|
legend_width = serializers.IntegerField(
|
||||||
default=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT
|
default=RACK_ELEVATION_DEFAULT_LEGEND_WIDTH
|
||||||
|
)
|
||||||
|
margin_width = serializers.IntegerField(
|
||||||
|
default=RACK_ELEVATION_DEFAULT_MARGIN_WIDTH
|
||||||
)
|
)
|
||||||
exclude = serializers.IntegerField(
|
exclude = serializers.IntegerField(
|
||||||
required=False,
|
required=False,
|
||||||
@ -854,6 +858,8 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn
|
|||||||
duplex = ChoiceField(choices=InterfaceDuplexChoices, required=False, allow_blank=True)
|
duplex = ChoiceField(choices=InterfaceDuplexChoices, required=False, allow_blank=True)
|
||||||
rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_blank=True)
|
rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_blank=True)
|
||||||
rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True)
|
rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True)
|
||||||
|
poe_mode = ChoiceField(choices=InterfacePoEModeChoices, required=False, allow_blank=True)
|
||||||
|
poe_type = ChoiceField(choices=InterfacePoETypeChoices, required=False, allow_blank=True)
|
||||||
untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
|
untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
|
||||||
tagged_vlans = SerializedPKRelatedField(
|
tagged_vlans = SerializedPKRelatedField(
|
||||||
queryset=VLAN.objects.all(),
|
queryset=VLAN.objects.all(),
|
||||||
@ -878,10 +884,10 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn
|
|||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag',
|
'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag',
|
||||||
'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel',
|
'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel',
|
||||||
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'mark_connected',
|
'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan',
|
||||||
'cable', 'wireless_link', 'link_peers', 'link_peers_type', 'wireless_lans', 'vrf', 'connected_endpoints',
|
'tagged_vlans', 'mark_connected', 'cable', 'wireless_link', 'link_peers', 'link_peers_type',
|
||||||
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
|
'wireless_lans', 'vrf', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable',
|
||||||
'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
|
'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
|
||||||
]
|
]
|
||||||
|
|
||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
|
@ -215,6 +215,14 @@ class RackViewSet(NetBoxModelViewSet):
|
|||||||
data = serializer.validated_data
|
data = serializer.validated_data
|
||||||
|
|
||||||
if data['render'] == 'svg':
|
if data['render'] == 'svg':
|
||||||
|
# Determine attributes for highlighting devices (if any)
|
||||||
|
highlight_params = []
|
||||||
|
for param in request.GET.getlist('highlight'):
|
||||||
|
try:
|
||||||
|
highlight_params.append(param.split(':', 1))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
# Render and return the elevation as an SVG drawing with the correct content type
|
# Render and return the elevation as an SVG drawing with the correct content type
|
||||||
drawing = rack.get_elevation_svg(
|
drawing = rack.get_elevation_svg(
|
||||||
face=data['face'],
|
face=data['face'],
|
||||||
@ -223,7 +231,8 @@ class RackViewSet(NetBoxModelViewSet):
|
|||||||
unit_height=data['unit_height'],
|
unit_height=data['unit_height'],
|
||||||
legend_width=data['legend_width'],
|
legend_width=data['legend_width'],
|
||||||
include_images=data['include_images'],
|
include_images=data['include_images'],
|
||||||
base_url=request.build_absolute_uri('/')
|
base_url=request.build_absolute_uri('/'),
|
||||||
|
highlight_params=highlight_params
|
||||||
)
|
)
|
||||||
return HttpResponse(drawing.tostring(), content_type='image/svg+xml')
|
return HttpResponse(drawing.tostring(), content_type='image/svg+xml')
|
||||||
|
|
||||||
|
@ -23,6 +23,28 @@ class SiteStatusChoices(ChoiceSet):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Locations
|
||||||
|
#
|
||||||
|
|
||||||
|
class LocationStatusChoices(ChoiceSet):
|
||||||
|
key = 'Location.status'
|
||||||
|
|
||||||
|
STATUS_PLANNED = 'planned'
|
||||||
|
STATUS_STAGING = 'staging'
|
||||||
|
STATUS_ACTIVE = 'active'
|
||||||
|
STATUS_DECOMMISSIONING = 'decommissioning'
|
||||||
|
STATUS_RETIRED = 'retired'
|
||||||
|
|
||||||
|
CHOICES = [
|
||||||
|
(STATUS_PLANNED, 'Planned', 'cyan'),
|
||||||
|
(STATUS_STAGING, 'Staging', 'blue'),
|
||||||
|
(STATUS_ACTIVE, 'Active', 'green'),
|
||||||
|
(STATUS_DECOMMISSIONING, 'Decommissioning', 'yellow'),
|
||||||
|
(STATUS_RETIRED, 'Retired', 'red'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Racks
|
# Racks
|
||||||
#
|
#
|
||||||
@ -1003,6 +1025,51 @@ class InterfaceModeChoices(ChoiceSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InterfacePoEModeChoices(ChoiceSet):
|
||||||
|
|
||||||
|
MODE_PD = 'pd'
|
||||||
|
MODE_PSE = 'pse'
|
||||||
|
|
||||||
|
CHOICES = (
|
||||||
|
(MODE_PD, 'Powered device (PD)'),
|
||||||
|
(MODE_PSE, 'Power sourcing equipment (PSE)'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InterfacePoETypeChoices(ChoiceSet):
|
||||||
|
|
||||||
|
TYPE_1_8023AF = 'type1-ieee802.3af'
|
||||||
|
TYPE_2_8023AT = 'type2-ieee802.3at'
|
||||||
|
TYPE_3_8023BT = 'type3-ieee802.3bt'
|
||||||
|
TYPE_4_8023BT = 'type4-ieee802.3bt'
|
||||||
|
|
||||||
|
PASSIVE_24V_2PAIR = 'passive-24v-2pair'
|
||||||
|
PASSIVE_24V_4PAIR = 'passive-24v-4pair'
|
||||||
|
PASSIVE_48V_2PAIR = 'passive-48v-2pair'
|
||||||
|
PASSIVE_48V_4PAIR = 'passive-48v-4pair'
|
||||||
|
|
||||||
|
CHOICES = (
|
||||||
|
(
|
||||||
|
'IEEE Standard',
|
||||||
|
(
|
||||||
|
(TYPE_1_8023AF, '802.3af (Type 1)'),
|
||||||
|
(TYPE_2_8023AT, '802.3at (Type 2)'),
|
||||||
|
(TYPE_3_8023BT, '802.3bt (Type 3)'),
|
||||||
|
(TYPE_4_8023BT, '802.3bt (Type 4)'),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Passive',
|
||||||
|
(
|
||||||
|
(PASSIVE_24V_2PAIR, 'Passive 24V (2-pair)'),
|
||||||
|
(PASSIVE_24V_4PAIR, 'Passive 24V (4-pair)'),
|
||||||
|
(PASSIVE_48V_2PAIR, 'Passive 48V (2-pair)'),
|
||||||
|
(PASSIVE_48V_2PAIR, 'Passive 48V (4-pair)'),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# FrontPorts/RearPorts
|
# FrontPorts/RearPorts
|
||||||
#
|
#
|
||||||
|
@ -13,7 +13,8 @@ DEVICETYPE_IMAGE_FORMATS = 'image/bmp,image/gif,image/jpeg,image/png,image/tiff,
|
|||||||
RACK_U_HEIGHT_DEFAULT = 42
|
RACK_U_HEIGHT_DEFAULT = 42
|
||||||
|
|
||||||
RACK_ELEVATION_BORDER_WIDTH = 2
|
RACK_ELEVATION_BORDER_WIDTH = 2
|
||||||
RACK_ELEVATION_LEGEND_WIDTH_DEFAULT = 30
|
RACK_ELEVATION_DEFAULT_LEGEND_WIDTH = 30
|
||||||
|
RACK_ELEVATION_DEFAULT_MARGIN_WIDTH = 15
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
|
@ -217,10 +217,14 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalM
|
|||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
label='Location (slug)',
|
label='Location (slug)',
|
||||||
)
|
)
|
||||||
|
status = django_filters.MultipleChoiceFilter(
|
||||||
|
choices=LocationStatusChoices,
|
||||||
|
null_value=None
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Location
|
model = Location
|
||||||
fields = ['id', 'name', 'slug', 'description']
|
fields = ['id', 'name', 'slug', 'status', 'description']
|
||||||
|
|
||||||
def search(self, queryset, name, value):
|
def search(self, queryset, name, value):
|
||||||
if not value.strip():
|
if not value.strip():
|
||||||
@ -1239,6 +1243,12 @@ class InterfaceFilterSet(
|
|||||||
)
|
)
|
||||||
mac_address = MultiValueMACAddressFilter()
|
mac_address = MultiValueMACAddressFilter()
|
||||||
wwn = MultiValueWWNFilter()
|
wwn = MultiValueWWNFilter()
|
||||||
|
poe_mode = django_filters.MultipleChoiceFilter(
|
||||||
|
choices=InterfacePoEModeChoices
|
||||||
|
)
|
||||||
|
poe_type = django_filters.MultipleChoiceFilter(
|
||||||
|
choices=InterfacePoETypeChoices
|
||||||
|
)
|
||||||
vlan_id = django_filters.CharFilter(
|
vlan_id = django_filters.CharFilter(
|
||||||
method='filter_vlan_id',
|
method='filter_vlan_id',
|
||||||
label='Assigned VLAN'
|
label='Assigned VLAN'
|
||||||
@ -1272,8 +1282,8 @@ class InterfaceFilterSet(
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Interface
|
model = Interface
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'rf_role', 'rf_channel',
|
'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'poe_mode', 'poe_type', 'mode', 'rf_role',
|
||||||
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'cable_end',
|
'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'cable_end',
|
||||||
]
|
]
|
||||||
|
|
||||||
def filter_device(self, queryset, name, value):
|
def filter_device(self, queryset, name, value):
|
||||||
|
@ -72,12 +72,15 @@ class PowerOutletBulkCreateForm(
|
|||||||
|
|
||||||
|
|
||||||
class InterfaceBulkCreateForm(
|
class InterfaceBulkCreateForm(
|
||||||
form_from_model(Interface, ['type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected']),
|
form_from_model(Interface, [
|
||||||
|
'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected', 'poe_mode', 'poe_type',
|
||||||
|
]),
|
||||||
DeviceBulkAddComponentForm
|
DeviceBulkAddComponentForm
|
||||||
):
|
):
|
||||||
model = Interface
|
model = Interface
|
||||||
field_order = (
|
field_order = (
|
||||||
'name_pattern', 'label_pattern', 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'tags',
|
'name_pattern', 'label_pattern', 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'poe_mode',
|
||||||
|
'poe_type', 'mark_connected', 'description', 'tags',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -158,6 +158,12 @@ class LocationBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
'site_id': '$site'
|
'site_id': '$site'
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
status = forms.ChoiceField(
|
||||||
|
choices=add_blank_choice(LocationStatusChoices),
|
||||||
|
required=False,
|
||||||
|
initial='',
|
||||||
|
widget=StaticSelect()
|
||||||
|
)
|
||||||
tenant = DynamicModelChoiceField(
|
tenant = DynamicModelChoiceField(
|
||||||
queryset=Tenant.objects.all(),
|
queryset=Tenant.objects.all(),
|
||||||
required=False
|
required=False
|
||||||
@ -169,7 +175,7 @@ class LocationBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
|
|
||||||
model = Location
|
model = Location
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, ('site', 'parent', 'tenant', 'description')),
|
(None, ('site', 'parent', 'status', 'tenant', 'description')),
|
||||||
)
|
)
|
||||||
nullable_fields = ('parent', 'tenant', 'description')
|
nullable_fields = ('parent', 'tenant', 'description')
|
||||||
|
|
||||||
@ -1063,6 +1069,18 @@ class InterfaceBulkEditForm(
|
|||||||
widget=BulkEditNullBooleanSelect,
|
widget=BulkEditNullBooleanSelect,
|
||||||
label='Management only'
|
label='Management only'
|
||||||
)
|
)
|
||||||
|
poe_mode = forms.ChoiceField(
|
||||||
|
choices=add_blank_choice(InterfacePoEModeChoices),
|
||||||
|
required=False,
|
||||||
|
initial='',
|
||||||
|
widget=StaticSelect()
|
||||||
|
)
|
||||||
|
poe_type = forms.ChoiceField(
|
||||||
|
choices=add_blank_choice(InterfacePoETypeChoices),
|
||||||
|
required=False,
|
||||||
|
initial='',
|
||||||
|
widget=StaticSelect()
|
||||||
|
)
|
||||||
mark_connected = forms.NullBooleanField(
|
mark_connected = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
widget=BulkEditNullBooleanSelect
|
widget=BulkEditNullBooleanSelect
|
||||||
@ -1105,14 +1123,15 @@ class InterfaceBulkEditForm(
|
|||||||
(None, ('module', 'type', 'label', 'speed', 'duplex', 'description')),
|
(None, ('module', 'type', 'label', 'speed', 'duplex', 'description')),
|
||||||
('Addressing', ('vrf', 'mac_address', 'wwn')),
|
('Addressing', ('vrf', 'mac_address', 'wwn')),
|
||||||
('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
|
('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
|
||||||
|
('PoE', ('poe_mode', 'poe_type')),
|
||||||
('Related Interfaces', ('parent', 'bridge', 'lag')),
|
('Related Interfaces', ('parent', 'bridge', 'lag')),
|
||||||
('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')),
|
('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')),
|
||||||
('Wireless', ('rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width')),
|
('Wireless', ('rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width')),
|
||||||
)
|
)
|
||||||
nullable_fields = (
|
nullable_fields = (
|
||||||
'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'description',
|
'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'description',
|
||||||
'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'vlan_group', 'untagged_vlan',
|
'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
|
||||||
'tagged_vlans', 'vrf',
|
'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf',
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
@ -124,6 +124,10 @@ class LocationCSVForm(NetBoxModelCSVForm):
|
|||||||
'invalid_choice': 'Location not found.',
|
'invalid_choice': 'Location not found.',
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
status = CSVChoiceField(
|
||||||
|
choices=LocationStatusChoices,
|
||||||
|
help_text='Operational status'
|
||||||
|
)
|
||||||
tenant = CSVModelChoiceField(
|
tenant = CSVModelChoiceField(
|
||||||
queryset=Tenant.objects.all(),
|
queryset=Tenant.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
@ -133,7 +137,7 @@ class LocationCSVForm(NetBoxModelCSVForm):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Location
|
model = Location
|
||||||
fields = ('site', 'parent', 'name', 'slug', 'tenant', 'description')
|
fields = ('site', 'parent', 'name', 'slug', 'status', 'tenant', 'description')
|
||||||
|
|
||||||
|
|
||||||
class RackRoleCSVForm(NetBoxModelCSVForm):
|
class RackRoleCSVForm(NetBoxModelCSVForm):
|
||||||
@ -622,6 +626,16 @@ class InterfaceCSVForm(NetBoxModelCSVForm):
|
|||||||
choices=InterfaceDuplexChoices,
|
choices=InterfaceDuplexChoices,
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
|
poe_mode = CSVChoiceField(
|
||||||
|
choices=InterfacePoEModeChoices,
|
||||||
|
required=False,
|
||||||
|
help_text='PoE mode'
|
||||||
|
)
|
||||||
|
poe_type = CSVChoiceField(
|
||||||
|
choices=InterfacePoETypeChoices,
|
||||||
|
required=False,
|
||||||
|
help_text='PoE type'
|
||||||
|
)
|
||||||
mode = CSVChoiceField(
|
mode = CSVChoiceField(
|
||||||
choices=InterfaceModeChoices,
|
choices=InterfaceModeChoices,
|
||||||
required=False,
|
required=False,
|
||||||
@ -642,9 +656,9 @@ class InterfaceCSVForm(NetBoxModelCSVForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Interface
|
model = Interface
|
||||||
fields = (
|
fields = (
|
||||||
'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled', 'mark_connected', 'mac_address',
|
'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled',
|
||||||
'wwn', 'mtu', 'mgmt_only', 'description', 'mode', 'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency',
|
'mark_connected', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'mode',
|
||||||
'rf_channel_width', 'tx_power',
|
'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, data=None, *args, **kwargs):
|
def __init__(self, data=None, *args, **kwargs):
|
||||||
|
@ -166,7 +166,7 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelF
|
|||||||
model = Location
|
model = Location
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, ('q', 'tag')),
|
(None, ('q', 'tag')),
|
||||||
('Parent', ('region_id', 'site_group_id', 'site_id', 'parent_id')),
|
('Attributes', ('region_id', 'site_group_id', 'site_id', 'parent_id', 'status')),
|
||||||
('Tenant', ('tenant_group_id', 'tenant_id')),
|
('Tenant', ('tenant_group_id', 'tenant_id')),
|
||||||
('Contacts', ('contact', 'contact_role', 'contact_group')),
|
('Contacts', ('contact', 'contact_role', 'contact_group')),
|
||||||
)
|
)
|
||||||
@ -198,6 +198,10 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelF
|
|||||||
},
|
},
|
||||||
label=_('Parent')
|
label=_('Parent')
|
||||||
)
|
)
|
||||||
|
status = MultipleChoiceField(
|
||||||
|
choices=LocationStatusChoices,
|
||||||
|
required=False
|
||||||
|
)
|
||||||
tag = TagFilterField(model)
|
tag = TagFilterField(model)
|
||||||
|
|
||||||
|
|
||||||
@ -969,6 +973,7 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
|
|||||||
(None, ('q', 'tag')),
|
(None, ('q', 'tag')),
|
||||||
('Attributes', ('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only')),
|
('Attributes', ('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only')),
|
||||||
('Addressing', ('vrf_id', 'mac_address', 'wwn')),
|
('Addressing', ('vrf_id', 'mac_address', 'wwn')),
|
||||||
|
('PoE', ('poe_mode', 'poe_type')),
|
||||||
('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')),
|
('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')),
|
||||||
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')),
|
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')),
|
||||||
)
|
)
|
||||||
@ -1009,6 +1014,14 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
|
|||||||
required=False,
|
required=False,
|
||||||
label='WWN'
|
label='WWN'
|
||||||
)
|
)
|
||||||
|
poe_mode = MultipleChoiceField(
|
||||||
|
choices=InterfacePoEModeChoices,
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
poe_type = MultipleChoiceField(
|
||||||
|
choices=InterfacePoEModeChoices,
|
||||||
|
required=False
|
||||||
|
)
|
||||||
rf_role = MultipleChoiceField(
|
rf_role = MultipleChoiceField(
|
||||||
choices=WirelessRoleChoices,
|
choices=WirelessRoleChoices,
|
||||||
required=False,
|
required=False,
|
||||||
|
@ -194,7 +194,7 @@ class LocationForm(TenancyForm, NetBoxModelForm):
|
|||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
('Location', (
|
('Location', (
|
||||||
'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tags',
|
'region', 'site_group', 'site', 'parent', 'name', 'slug', 'status', 'description', 'tags',
|
||||||
)),
|
)),
|
||||||
('Tenancy', ('tenant_group', 'tenant')),
|
('Tenancy', ('tenant_group', 'tenant')),
|
||||||
)
|
)
|
||||||
@ -202,8 +202,12 @@ class LocationForm(TenancyForm, NetBoxModelForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Location
|
model = Location
|
||||||
fields = (
|
fields = (
|
||||||
'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tenant_group', 'tenant', 'tags',
|
'region', 'site_group', 'site', 'parent', 'name', 'slug', 'status', 'description', 'tenant_group', 'tenant',
|
||||||
|
'tags',
|
||||||
)
|
)
|
||||||
|
widgets = {
|
||||||
|
'status': StaticSelect(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class RackRoleForm(NetBoxModelForm):
|
class RackRoleForm(NetBoxModelForm):
|
||||||
@ -1314,6 +1318,7 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm):
|
|||||||
('Addressing', ('vrf', 'mac_address', 'wwn')),
|
('Addressing', ('vrf', 'mac_address', 'wwn')),
|
||||||
('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
|
('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
|
||||||
('Related Interfaces', ('parent', 'bridge', 'lag')),
|
('Related Interfaces', ('parent', 'bridge', 'lag')),
|
||||||
|
('PoE', ('poe_mode', 'poe_type')),
|
||||||
('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')),
|
('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')),
|
||||||
('Wireless', (
|
('Wireless', (
|
||||||
'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans',
|
'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans',
|
||||||
@ -1324,14 +1329,16 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm):
|
|||||||
model = Interface
|
model = Interface
|
||||||
fields = [
|
fields = [
|
||||||
'device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge', 'lag',
|
'device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge', 'lag',
|
||||||
'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel',
|
'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'poe_mode', 'poe_type', 'mode',
|
||||||
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans', 'untagged_vlan', 'tagged_vlans',
|
'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans',
|
||||||
'vrf', 'tags',
|
'untagged_vlan', 'tagged_vlans', 'vrf', 'tags',
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
'device': forms.HiddenInput(),
|
'device': forms.HiddenInput(),
|
||||||
'type': StaticSelect(),
|
'type': StaticSelect(),
|
||||||
'speed': SelectSpeedWidget(),
|
'speed': SelectSpeedWidget(),
|
||||||
|
'poe_mode': StaticSelect(),
|
||||||
|
'poe_type': StaticSelect(),
|
||||||
'duplex': StaticSelect(),
|
'duplex': StaticSelect(),
|
||||||
'mode': StaticSelect(),
|
'mode': StaticSelect(),
|
||||||
'rf_role': StaticSelect(),
|
'rf_role': StaticSelect(),
|
||||||
|
@ -234,6 +234,12 @@ class InterfaceType(IPAddressesMixin, ComponentObjectType):
|
|||||||
exclude = ('_path',)
|
exclude = ('_path',)
|
||||||
filterset_class = filtersets.InterfaceFilterSet
|
filterset_class = filtersets.InterfaceFilterSet
|
||||||
|
|
||||||
|
def resolve_poe_mode(self, info):
|
||||||
|
return self.poe_mode or None
|
||||||
|
|
||||||
|
def resolve_poe_type(self, info):
|
||||||
|
return self.poe_type or None
|
||||||
|
|
||||||
def resolve_mode(self, info):
|
def resolve_mode(self, info):
|
||||||
return self.mode or None
|
return self.mode or None
|
||||||
|
|
||||||
|
23
netbox/dcim/migrations/0155_interface_poe_mode_type.py
Normal file
23
netbox/dcim/migrations/0155_interface_poe_mode_type.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 4.0.5 on 2022-06-22 00:36
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('dcim', '0154_half_height_rack_units'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='interface',
|
||||||
|
name='poe_mode',
|
||||||
|
field=models.CharField(blank=True, max_length=50),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='interface',
|
||||||
|
name='poe_type',
|
||||||
|
field=models.CharField(blank=True, max_length=50),
|
||||||
|
),
|
||||||
|
]
|
18
netbox/dcim/migrations/0156_location_status.py
Normal file
18
netbox/dcim/migrations/0156_location_status.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.0.5 on 2022-06-22 17:10
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('dcim', '0155_interface_poe_mode_type'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='location',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(default='active', max_length=50),
|
||||||
|
),
|
||||||
|
]
|
@ -6,7 +6,7 @@ class Migration(migrations.Migration):
|
|||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('contenttypes', '0002_remove_content_type_name'),
|
('contenttypes', '0002_remove_content_type_name'),
|
||||||
('dcim', '0154_half_height_rack_units'),
|
('dcim', '0156_location_status'),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
@ -40,7 +40,7 @@ def populate_cable_terminations(apps, schema_editor):
|
|||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('dcim', '0155_new_cabling_models'),
|
('dcim', '0157_new_cabling_models'),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
@ -39,7 +39,7 @@ def populate_cable_paths(apps, schema_editor):
|
|||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('dcim', '0156_populate_cable_terminations'),
|
('dcim', '0158_populate_cable_terminations'),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
@ -30,8 +30,8 @@ def populate_cable_terminations(apps, schema_editor):
|
|||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('circuits', '0036_new_cabling_models'),
|
('circuits', '0037_new_cabling_models'),
|
||||||
('dcim', '0157_populate_cable_paths'),
|
('dcim', '0159_populate_cable_paths'),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
@ -4,7 +4,7 @@ from django.db import migrations
|
|||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('dcim', '0158_populate_cable_ends'),
|
('dcim', '0160_populate_cable_ends'),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
@ -575,6 +575,18 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo
|
|||||||
validators=(MaxValueValidator(127),),
|
validators=(MaxValueValidator(127),),
|
||||||
verbose_name='Transmit power (dBm)'
|
verbose_name='Transmit power (dBm)'
|
||||||
)
|
)
|
||||||
|
poe_mode = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=InterfacePoEModeChoices,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='PoE mode'
|
||||||
|
)
|
||||||
|
poe_type = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=InterfacePoETypeChoices,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='PoE type'
|
||||||
|
)
|
||||||
wireless_link = models.ForeignKey(
|
wireless_link = models.ForeignKey(
|
||||||
to='wireless.WirelessLink',
|
to='wireless.WirelessLink',
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
@ -623,7 +635,7 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo
|
|||||||
related_query_name='+'
|
related_query_name='+'
|
||||||
)
|
)
|
||||||
|
|
||||||
clone_fields = ['device', 'parent', 'bridge', 'lag', 'type', 'mgmt_only']
|
clone_fields = ['device', 'parent', 'bridge', 'lag', 'type', 'mgmt_only', 'poe_mode', 'poe_type']
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ('device', CollateAsChar('_name'))
|
ordering = ('device', CollateAsChar('_name'))
|
||||||
@ -711,6 +723,24 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo
|
|||||||
f"of virtual chassis {self.device.virtual_chassis}."
|
f"of virtual chassis {self.device.virtual_chassis}."
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# PoE validation
|
||||||
|
|
||||||
|
# Only physical interfaces may have a PoE mode/type assigned
|
||||||
|
if self.poe_mode and self.is_virtual:
|
||||||
|
raise ValidationError({
|
||||||
|
'poe_mode': "Virtual interfaces cannot have a PoE mode."
|
||||||
|
})
|
||||||
|
if self.poe_type and self.is_virtual:
|
||||||
|
raise ValidationError({
|
||||||
|
'poe_type': "Virtual interfaces cannot have a PoE type."
|
||||||
|
})
|
||||||
|
|
||||||
|
# An interface with a PoE type set must also specify a mode
|
||||||
|
if self.poe_type and not self.poe_mode:
|
||||||
|
raise ValidationError({
|
||||||
|
'poe_type': "Must specify PoE mode when designating a PoE type."
|
||||||
|
})
|
||||||
|
|
||||||
# Wireless validation
|
# Wireless validation
|
||||||
|
|
||||||
# RF role & channel may only be set for wireless interfaces
|
# RF role & channel may only be set for wireless interfaces
|
||||||
|
@ -367,9 +367,11 @@ class Rack(NetBoxModel):
|
|||||||
user=None,
|
user=None,
|
||||||
unit_width=None,
|
unit_width=None,
|
||||||
unit_height=None,
|
unit_height=None,
|
||||||
legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT,
|
legend_width=RACK_ELEVATION_DEFAULT_LEGEND_WIDTH,
|
||||||
|
margin_width=RACK_ELEVATION_DEFAULT_MARGIN_WIDTH,
|
||||||
include_images=True,
|
include_images=True,
|
||||||
base_url=None
|
base_url=None,
|
||||||
|
highlight_params=None
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Return an SVG of the rack elevation
|
Return an SVG of the rack elevation
|
||||||
@ -381,6 +383,7 @@ class Rack(NetBoxModel):
|
|||||||
:param unit_height: Height of each rack unit for the rendered drawing. Note this is not the total
|
:param unit_height: Height of each rack unit for the rendered drawing. Note this is not the total
|
||||||
height of the elevation
|
height of the elevation
|
||||||
:param legend_width: Width of the unit legend, in pixels
|
:param legend_width: Width of the unit legend, in pixels
|
||||||
|
:param margin_width: Width of the rigth-hand margin, in pixels
|
||||||
:param include_images: Embed front/rear device images where available
|
:param include_images: Embed front/rear device images where available
|
||||||
:param base_url: Base URL for links and images. If none, URLs will be relative.
|
:param base_url: Base URL for links and images. If none, URLs will be relative.
|
||||||
"""
|
"""
|
||||||
@ -389,9 +392,11 @@ class Rack(NetBoxModel):
|
|||||||
unit_width=unit_width,
|
unit_width=unit_width,
|
||||||
unit_height=unit_height,
|
unit_height=unit_height,
|
||||||
legend_width=legend_width,
|
legend_width=legend_width,
|
||||||
|
margin_width=margin_width,
|
||||||
user=user,
|
user=user,
|
||||||
include_images=include_images,
|
include_images=include_images,
|
||||||
base_url=base_url
|
base_url=base_url,
|
||||||
|
highlight_params=highlight_params
|
||||||
)
|
)
|
||||||
|
|
||||||
return elevation.render(face)
|
return elevation.render(face)
|
||||||
|
@ -341,6 +341,11 @@ class Location(NestedGroupModel):
|
|||||||
null=True,
|
null=True,
|
||||||
db_index=True
|
db_index=True
|
||||||
)
|
)
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=LocationStatusChoices,
|
||||||
|
default=LocationStatusChoices.STATUS_ACTIVE
|
||||||
|
)
|
||||||
tenant = models.ForeignKey(
|
tenant = models.ForeignKey(
|
||||||
to='tenancy.Tenant',
|
to='tenancy.Tenant',
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
@ -367,7 +372,7 @@ class Location(NestedGroupModel):
|
|||||||
to='extras.ImageAttachment'
|
to='extras.ImageAttachment'
|
||||||
)
|
)
|
||||||
|
|
||||||
clone_fields = ['site', 'parent', 'tenant', 'description']
|
clone_fields = ['site', 'parent', 'status', 'tenant', 'description']
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['site', 'name']
|
ordering = ['site', 'name']
|
||||||
@ -409,6 +414,9 @@ class Location(NestedGroupModel):
|
|||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse('dcim:location', args=[self.pk])
|
return reverse('dcim:location', args=[self.pk])
|
||||||
|
|
||||||
|
def get_status_color(self):
|
||||||
|
return LocationStatusChoices.colors.get(self.status)
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
super().clean()
|
super().clean()
|
||||||
|
|
||||||
|
@ -7,12 +7,13 @@ from svgwrite.shapes import Rect
|
|||||||
from svgwrite.text import Text
|
from svgwrite.text import Text
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.exceptions import FieldError
|
||||||
|
from django.db.models import Q
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.http import urlencode
|
from django.utils.http import urlencode
|
||||||
|
|
||||||
from netbox.config import get_config
|
from netbox.config import get_config
|
||||||
from utilities.utils import foreground_color
|
from utilities.utils import foreground_color, array_to_ranges
|
||||||
from dcim.choices import DeviceFaceChoices
|
|
||||||
from dcim.constants import RACK_ELEVATION_BORDER_WIDTH
|
from dcim.constants import RACK_ELEVATION_BORDER_WIDTH
|
||||||
|
|
||||||
|
|
||||||
@ -51,12 +52,17 @@ class RackElevationSVG:
|
|||||||
Use this class to render a rack elevation as an SVG image.
|
Use this class to render a rack elevation as an SVG image.
|
||||||
|
|
||||||
:param rack: A NetBox Rack instance
|
:param rack: A NetBox Rack instance
|
||||||
|
:param unit_width: Rendered unit width, in pixels
|
||||||
|
:param unit_height: Rendered unit height, in pixels
|
||||||
|
:param legend_width: Legend width, in pixels (where the unit labels appear)
|
||||||
|
:param margin_width: Margin width, in pixels (where reservations appear)
|
||||||
:param user: User instance. If specified, only devices viewable by this user will be fully displayed.
|
:param user: User instance. If specified, only devices viewable by this user will be fully displayed.
|
||||||
:param include_images: If true, the SVG document will embed front/rear device face images, where available
|
:param include_images: If true, the SVG document will embed front/rear device face images, where available
|
||||||
:param base_url: Base URL for links within the SVG document. If none, links will be relative.
|
:param base_url: Base URL for links within the SVG document. If none, links will be relative.
|
||||||
|
:param highlight_params: Iterable of two-tuples which identifies attributes of devices to highlight
|
||||||
"""
|
"""
|
||||||
def __init__(self, rack, unit_height=None, unit_width=None, legend_width=None, user=None, include_images=True,
|
def __init__(self, rack, unit_height=None, unit_width=None, legend_width=None, margin_width=None, user=None,
|
||||||
base_url=None):
|
include_images=True, base_url=None, highlight_params=None):
|
||||||
self.rack = rack
|
self.rack = rack
|
||||||
self.include_images = include_images
|
self.include_images = include_images
|
||||||
self.base_url = base_url.rstrip('/') if base_url is not None else ''
|
self.base_url = base_url.rstrip('/') if base_url is not None else ''
|
||||||
@ -65,7 +71,8 @@ class RackElevationSVG:
|
|||||||
config = get_config()
|
config = get_config()
|
||||||
self.unit_width = unit_width or config.RACK_ELEVATION_DEFAULT_UNIT_WIDTH
|
self.unit_width = unit_width or config.RACK_ELEVATION_DEFAULT_UNIT_WIDTH
|
||||||
self.unit_height = unit_height or config.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT
|
self.unit_height = unit_height or config.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT
|
||||||
self.legend_width = legend_width or config.RACK_ELEVATION_LEGEND_WIDTH_DEFAULT
|
self.legend_width = legend_width or config.RACK_ELEVATION_DEFAULT_LEGEND_WIDTH
|
||||||
|
self.margin_width = margin_width or config.RACK_ELEVATION_DEFAULT_MARGIN_WIDTH
|
||||||
|
|
||||||
# Determine the subset of devices within this rack that are viewable by the user, if any
|
# Determine the subset of devices within this rack that are viewable by the user, if any
|
||||||
permitted_devices = self.rack.devices
|
permitted_devices = self.rack.devices
|
||||||
@ -73,6 +80,17 @@ class RackElevationSVG:
|
|||||||
permitted_devices = permitted_devices.restrict(user, 'view')
|
permitted_devices = permitted_devices.restrict(user, 'view')
|
||||||
self.permitted_device_ids = permitted_devices.values_list('pk', flat=True)
|
self.permitted_device_ids = permitted_devices.values_list('pk', flat=True)
|
||||||
|
|
||||||
|
# Determine device(s) to highlight within the elevation (if any)
|
||||||
|
self.highlight_devices = []
|
||||||
|
if highlight_params:
|
||||||
|
q = Q()
|
||||||
|
for k, v in highlight_params:
|
||||||
|
q |= Q(**{k: v})
|
||||||
|
try:
|
||||||
|
self.highlight_devices = permitted_devices.filter(q)
|
||||||
|
except FieldError:
|
||||||
|
pass
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _add_gradient(drawing, id_, color):
|
def _add_gradient(drawing, id_, color):
|
||||||
gradient = LinearGradient(
|
gradient = LinearGradient(
|
||||||
@ -91,7 +109,7 @@ class RackElevationSVG:
|
|||||||
drawing.defs.add(gradient)
|
drawing.defs.add(gradient)
|
||||||
|
|
||||||
def _setup_drawing(self):
|
def _setup_drawing(self):
|
||||||
width = self.unit_width + self.legend_width + RACK_ELEVATION_BORDER_WIDTH * 2
|
width = self.unit_width + self.legend_width + self.margin_width + RACK_ELEVATION_BORDER_WIDTH * 2
|
||||||
height = self.unit_height * self.rack.u_height + RACK_ELEVATION_BORDER_WIDTH * 2
|
height = self.unit_height * self.rack.u_height + RACK_ELEVATION_BORDER_WIDTH * 2
|
||||||
drawing = svgwrite.Drawing(size=(width, height))
|
drawing = svgwrite.Drawing(size=(width, height))
|
||||||
|
|
||||||
@ -100,6 +118,7 @@ class RackElevationSVG:
|
|||||||
drawing.defs.add(drawing.style(css_file.read()))
|
drawing.defs.add(drawing.style(css_file.read()))
|
||||||
|
|
||||||
# Add gradients
|
# Add gradients
|
||||||
|
RackElevationSVG._add_gradient(drawing, 'reserved', '#b0b0ff')
|
||||||
RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7')
|
RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7')
|
||||||
RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0')
|
RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0')
|
||||||
|
|
||||||
@ -121,40 +140,44 @@ class RackElevationSVG:
|
|||||||
def _draw_device(self, device, coords, size, color=None, image=None):
|
def _draw_device(self, device, coords, size, color=None, image=None):
|
||||||
name = get_device_name(device)
|
name = get_device_name(device)
|
||||||
description = get_device_description(device)
|
description = get_device_description(device)
|
||||||
|
text_color = f'#{foreground_color(color)}' if color else '#000000'
|
||||||
text_coords = (
|
text_coords = (
|
||||||
coords[0] + size[0] / 2,
|
coords[0] + size[0] / 2,
|
||||||
coords[1] + size[1] / 2
|
coords[1] + size[1] / 2
|
||||||
)
|
)
|
||||||
text_color = f'#{foreground_color(color)}' if color else '#000000'
|
|
||||||
|
# Determine whether highlighting is in use, and if so, whether to shade this device
|
||||||
|
is_shaded = self.highlight_devices and device not in self.highlight_devices
|
||||||
|
css_extra = ' shaded' if is_shaded else ''
|
||||||
|
|
||||||
# Create hyperlink element
|
# Create hyperlink element
|
||||||
link = Hyperlink(
|
link = Hyperlink(href=f'{self.base_url}{device.get_absolute_url()}', target='_blank')
|
||||||
href='{}{}'.format(
|
|
||||||
self.base_url,
|
|
||||||
reverse('dcim:device', kwargs={'pk': device.pk})
|
|
||||||
),
|
|
||||||
target='_blank',
|
|
||||||
)
|
|
||||||
link.set_desc(description)
|
link.set_desc(description)
|
||||||
|
|
||||||
|
# Add rect element to hyperlink
|
||||||
if color:
|
if color:
|
||||||
link.add(Rect(coords, size, style=f'fill: #{color}', class_='slot'))
|
link.add(Rect(coords, size, style=f'fill: #{color}', class_=f'slot{css_extra}'))
|
||||||
else:
|
else:
|
||||||
link.add(Rect(coords, size, class_='slot blocked'))
|
link.add(Rect(coords, size, class_=f'slot blocked{css_extra}'))
|
||||||
link.add(Text(name, insert=text_coords, fill=text_color))
|
link.add(Text(name, insert=text_coords, fill=text_color, class_=f'label{css_extra}'))
|
||||||
|
|
||||||
# Embed device type image if provided
|
# Embed device type image if provided
|
||||||
if self.include_images and image:
|
if self.include_images and image:
|
||||||
image = Image(
|
image = Image(
|
||||||
href='{}{}'.format(self.base_url, image.url),
|
href=f'{self.base_url}{image.url}',
|
||||||
insert=coords,
|
insert=coords,
|
||||||
size=size,
|
size=size,
|
||||||
class_='device-image'
|
class_=f'device-image{css_extra}'
|
||||||
)
|
)
|
||||||
image.fit(scale='slice')
|
image.fit(scale='slice')
|
||||||
link.add(image)
|
link.add(image)
|
||||||
link.add(Text(name, insert=text_coords, stroke='black',
|
link.add(
|
||||||
stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label'))
|
Text(name, insert=text_coords, stroke='black', stroke_width='0.2em', stroke_linejoin='round',
|
||||||
link.add(Text(name, insert=text_coords, fill='white', class_='device-image-label'))
|
class_=f'device-image-label{css_extra}')
|
||||||
|
)
|
||||||
|
link.add(
|
||||||
|
Text(name, insert=text_coords, fill='white', class_=f'device-image-label{css_extra}')
|
||||||
|
)
|
||||||
|
|
||||||
self.drawing.add(link)
|
self.drawing.add(link)
|
||||||
|
|
||||||
@ -198,6 +221,29 @@ class RackElevationSVG:
|
|||||||
Text(str(unit), position_coordinates, class_='unit')
|
Text(str(unit), position_coordinates, class_='unit')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def draw_margin(self):
|
||||||
|
"""
|
||||||
|
Draw any rack reservations in the right-hand margin alongside the rack elevation.
|
||||||
|
"""
|
||||||
|
for reservation in self.rack.reservations.all():
|
||||||
|
for segment in array_to_ranges(reservation.units):
|
||||||
|
u_height = 1 if len(segment) == 1 else segment[1] + 1 - segment[0]
|
||||||
|
coords = self._get_device_coords(segment[0], u_height)
|
||||||
|
coords = (coords[0] + self.unit_width + RACK_ELEVATION_BORDER_WIDTH * 2, coords[1])
|
||||||
|
size = (
|
||||||
|
self.margin_width,
|
||||||
|
u_height * self.unit_height
|
||||||
|
)
|
||||||
|
link = Hyperlink(
|
||||||
|
href='{}{}'.format(self.base_url, reservation.get_absolute_url()),
|
||||||
|
target='_blank'
|
||||||
|
)
|
||||||
|
link.set_desc(f'Reservation #{reservation.pk}: {reservation.description}')
|
||||||
|
link.add(
|
||||||
|
Rect(coords, size, class_='reservation')
|
||||||
|
)
|
||||||
|
self.drawing.add(link)
|
||||||
|
|
||||||
def draw_background(self, face):
|
def draw_background(self, face):
|
||||||
"""
|
"""
|
||||||
Draw the rack unit placeholders which form the "background" of the rack elevation.
|
Draw the rack unit placeholders which form the "background" of the rack elevation.
|
||||||
@ -261,16 +307,12 @@ class RackElevationSVG:
|
|||||||
# Initialize the drawing
|
# Initialize the drawing
|
||||||
self.drawing = self._setup_drawing()
|
self.drawing = self._setup_drawing()
|
||||||
|
|
||||||
# Draw the empty rack & legend
|
# Draw the empty rack, legend, and margin
|
||||||
self.draw_legend()
|
self.draw_legend()
|
||||||
self.draw_background(face)
|
self.draw_background(face)
|
||||||
|
self.draw_margin()
|
||||||
|
|
||||||
# Draw the opposite rack face first, then the near face
|
# Draw the rack face
|
||||||
if face == DeviceFaceChoices.FACE_REAR:
|
|
||||||
opposite_face = DeviceFaceChoices.FACE_FRONT
|
|
||||||
else:
|
|
||||||
opposite_face = DeviceFaceChoices.FACE_REAR
|
|
||||||
# self.draw_face(opposite_face, opposite=True)
|
|
||||||
self.draw_face(face)
|
self.draw_face(face)
|
||||||
|
|
||||||
# Draw the rack border last
|
# Draw the rack border last
|
||||||
|
@ -520,10 +520,10 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
|
|||||||
model = Interface
|
model = Interface
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu',
|
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu',
|
||||||
'speed', 'duplex', 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency',
|
'speed', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel',
|
||||||
'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link',
|
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable',
|
||||||
'wireless_lans', 'link_peer', 'connection', 'tags', 'vrf', 'ip_addresses', 'fhrp_groups', 'untagged_vlan',
|
'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vrf', 'ip_addresses',
|
||||||
'tagged_vlans', 'created', 'last_updated',
|
'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated',
|
||||||
)
|
)
|
||||||
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
|
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
|
||||||
|
|
||||||
|
@ -126,6 +126,7 @@ class LocationTable(NetBoxTable):
|
|||||||
site = tables.Column(
|
site = tables.Column(
|
||||||
linkify=True
|
linkify=True
|
||||||
)
|
)
|
||||||
|
status = columns.ChoiceFieldColumn()
|
||||||
tenant = TenantColumn()
|
tenant = TenantColumn()
|
||||||
rack_count = columns.LinkedCountColumn(
|
rack_count = columns.LinkedCountColumn(
|
||||||
viewname='dcim:rack_list',
|
viewname='dcim:rack_list',
|
||||||
@ -150,7 +151,7 @@ class LocationTable(NetBoxTable):
|
|||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = Location
|
model = Location
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'contacts',
|
'pk', 'id', 'name', 'site', 'status', 'tenant', 'rack_count', 'device_count', 'description', 'slug',
|
||||||
'tags', 'actions', 'created', 'last_updated',
|
'contacts', 'tags', 'actions', 'created', 'last_updated',
|
||||||
)
|
)
|
||||||
default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description')
|
default_columns = ('pk', 'name', 'site', 'status', 'tenant', 'rack_count', 'device_count', 'description')
|
||||||
|
@ -197,13 +197,13 @@ class LocationTest(APIViewTestCases.APIViewTestCase):
|
|||||||
Site.objects.bulk_create(sites)
|
Site.objects.bulk_create(sites)
|
||||||
|
|
||||||
parent_locations = (
|
parent_locations = (
|
||||||
Location.objects.create(site=sites[0], name='Parent Location 1', slug='parent-location-1'),
|
Location.objects.create(site=sites[0], name='Parent Location 1', slug='parent-location-1', status=LocationStatusChoices.STATUS_ACTIVE),
|
||||||
Location.objects.create(site=sites[1], name='Parent Location 2', slug='parent-location-2'),
|
Location.objects.create(site=sites[1], name='Parent Location 2', slug='parent-location-2', status=LocationStatusChoices.STATUS_ACTIVE),
|
||||||
)
|
)
|
||||||
|
|
||||||
Location.objects.create(site=sites[0], name='Location 1', slug='location-1', parent=parent_locations[0])
|
Location.objects.create(site=sites[0], name='Location 1', slug='location-1', parent=parent_locations[0], status=LocationStatusChoices.STATUS_ACTIVE)
|
||||||
Location.objects.create(site=sites[0], name='Location 2', slug='location-2', parent=parent_locations[0])
|
Location.objects.create(site=sites[0], name='Location 2', slug='location-2', parent=parent_locations[0], status=LocationStatusChoices.STATUS_ACTIVE)
|
||||||
Location.objects.create(site=sites[0], name='Location 3', slug='location-3', parent=parent_locations[0])
|
Location.objects.create(site=sites[0], name='Location 3', slug='location-3', parent=parent_locations[0], status=LocationStatusChoices.STATUS_ACTIVE)
|
||||||
|
|
||||||
cls.create_data = [
|
cls.create_data = [
|
||||||
{
|
{
|
||||||
@ -211,18 +211,21 @@ class LocationTest(APIViewTestCases.APIViewTestCase):
|
|||||||
'slug': 'test-location-4',
|
'slug': 'test-location-4',
|
||||||
'site': sites[1].pk,
|
'site': sites[1].pk,
|
||||||
'parent': parent_locations[1].pk,
|
'parent': parent_locations[1].pk,
|
||||||
|
'status': LocationStatusChoices.STATUS_PLANNED,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'Test Location 5',
|
'name': 'Test Location 5',
|
||||||
'slug': 'test-location-5',
|
'slug': 'test-location-5',
|
||||||
'site': sites[1].pk,
|
'site': sites[1].pk,
|
||||||
'parent': parent_locations[1].pk,
|
'parent': parent_locations[1].pk,
|
||||||
|
'status': LocationStatusChoices.STATUS_PLANNED,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'Test Location 6',
|
'name': 'Test Location 6',
|
||||||
'slug': 'test-location-6',
|
'slug': 'test-location-6',
|
||||||
'site': sites[1].pk,
|
'site': sites[1].pk,
|
||||||
'parent': parent_locations[1].pk,
|
'parent': parent_locations[1].pk,
|
||||||
|
'status': LocationStatusChoices.STATUS_PLANNED,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -1507,6 +1510,8 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
|
|||||||
'speed': 1000000,
|
'speed': 1000000,
|
||||||
'duplex': 'full',
|
'duplex': 'full',
|
||||||
'vrf': vrfs[0].pk,
|
'vrf': vrfs[0].pk,
|
||||||
|
'poe_mode': InterfacePoEModeChoices.MODE_PD,
|
||||||
|
'poe_type': InterfacePoETypeChoices.TYPE_1_8023AF,
|
||||||
'tagged_vlans': [vlans[0].pk, vlans[1].pk],
|
'tagged_vlans': [vlans[0].pk, vlans[1].pk],
|
||||||
'untagged_vlan': vlans[2].pk,
|
'untagged_vlan': vlans[2].pk,
|
||||||
},
|
},
|
||||||
|
@ -265,9 +265,9 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
location.save()
|
location.save()
|
||||||
|
|
||||||
locations = (
|
locations = (
|
||||||
Location(name='Location 1', slug='location-1', site=sites[0], parent=parent_locations[0], description='A'),
|
Location(name='Location 1', slug='location-1', site=sites[0], parent=parent_locations[0], status=LocationStatusChoices.STATUS_PLANNED, description='A'),
|
||||||
Location(name='Location 2', slug='location-2', site=sites[1], parent=parent_locations[1], description='B'),
|
Location(name='Location 2', slug='location-2', site=sites[1], parent=parent_locations[1], status=LocationStatusChoices.STATUS_STAGING, description='B'),
|
||||||
Location(name='Location 3', slug='location-3', site=sites[2], parent=parent_locations[2], description='C'),
|
Location(name='Location 3', slug='location-3', site=sites[2], parent=parent_locations[2], status=LocationStatusChoices.STATUS_DECOMMISSIONING, description='C'),
|
||||||
)
|
)
|
||||||
for location in locations:
|
for location in locations:
|
||||||
location.save()
|
location.save()
|
||||||
@ -280,6 +280,10 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
params = {'slug': ['location-1', 'location-2']}
|
params = {'slug': ['location-1', 'location-2']}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
|
def test_status(self):
|
||||||
|
params = {'status': [LocationStatusChoices.STATUS_PLANNED, LocationStatusChoices.STATUS_STAGING]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
def test_description(self):
|
def test_description(self):
|
||||||
params = {'description': ['A', 'B']}
|
params = {'description': ['A', 'B']}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
@ -2540,14 +2544,109 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis, vc_position=2, vc_priority=2)
|
Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis, vc_position=2, vc_priority=2)
|
||||||
|
|
||||||
interfaces = (
|
interfaces = (
|
||||||
Interface(device=devices[0], module=modules[0], name='Interface 1', label='A', type=InterfaceTypeChoices.TYPE_1GE_SFP, enabled=True, mgmt_only=True, mtu=100, mode=InterfaceModeChoices.MODE_ACCESS, mac_address='00-00-00-00-00-01', description='First', vrf=vrfs[0], speed=1000000, duplex='half'),
|
Interface(
|
||||||
Interface(device=devices[1], module=modules[1], name='Interface 2', label='B', type=InterfaceTypeChoices.TYPE_1GE_GBIC, enabled=True, mgmt_only=True, mtu=200, mode=InterfaceModeChoices.MODE_TAGGED, mac_address='00-00-00-00-00-02', description='Second', vrf=vrfs[1], speed=1000000, duplex='full'),
|
device=devices[0],
|
||||||
Interface(device=devices[2], module=modules[2], name='Interface 3', label='C', type=InterfaceTypeChoices.TYPE_1GE_FIXED, enabled=False, mgmt_only=False, mtu=300, mode=InterfaceModeChoices.MODE_TAGGED_ALL, mac_address='00-00-00-00-00-03', description='Third', vrf=vrfs[2], speed=100000, duplex='half'),
|
module=modules[0],
|
||||||
Interface(device=devices[3], name='Interface 4', label='D', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True, tx_power=40, speed=100000, duplex='full'),
|
name='Interface 1',
|
||||||
Interface(device=devices[3], name='Interface 5', label='E', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True, tx_power=40),
|
label='A',
|
||||||
Interface(device=devices[3], name='Interface 6', label='F', type=InterfaceTypeChoices.TYPE_OTHER, enabled=False, mgmt_only=False, tx_power=40),
|
type=InterfaceTypeChoices.TYPE_1GE_SFP,
|
||||||
Interface(device=devices[3], name='Interface 7', type=InterfaceTypeChoices.TYPE_80211AC, rf_role=WirelessRoleChoices.ROLE_AP, rf_channel=WirelessChannelChoices.CHANNEL_24G_1, rf_channel_frequency=2412, rf_channel_width=22),
|
enabled=True,
|
||||||
Interface(device=devices[3], name='Interface 8', type=InterfaceTypeChoices.TYPE_80211AC, rf_role=WirelessRoleChoices.ROLE_STATION, rf_channel=WirelessChannelChoices.CHANNEL_5G_32, rf_channel_frequency=5160, rf_channel_width=20),
|
mgmt_only=True,
|
||||||
|
mtu=100,
|
||||||
|
mode=InterfaceModeChoices.MODE_ACCESS,
|
||||||
|
mac_address='00-00-00-00-00-01',
|
||||||
|
description='First',
|
||||||
|
vrf=vrfs[0],
|
||||||
|
speed=1000000,
|
||||||
|
duplex='half',
|
||||||
|
poe_mode=InterfacePoEModeChoices.MODE_PSE,
|
||||||
|
poe_type=InterfacePoETypeChoices.TYPE_1_8023AF
|
||||||
|
),
|
||||||
|
Interface(
|
||||||
|
device=devices[1],
|
||||||
|
module=modules[1],
|
||||||
|
name='Interface 2',
|
||||||
|
label='B',
|
||||||
|
type=InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||||
|
enabled=True,
|
||||||
|
mgmt_only=True,
|
||||||
|
mtu=200,
|
||||||
|
mode=InterfaceModeChoices.MODE_TAGGED,
|
||||||
|
mac_address='00-00-00-00-00-02',
|
||||||
|
description='Second',
|
||||||
|
vrf=vrfs[1],
|
||||||
|
speed=1000000,
|
||||||
|
duplex='full',
|
||||||
|
poe_mode=InterfacePoEModeChoices.MODE_PD,
|
||||||
|
poe_type=InterfacePoETypeChoices.TYPE_1_8023AF
|
||||||
|
),
|
||||||
|
Interface(
|
||||||
|
device=devices[2],
|
||||||
|
module=modules[2],
|
||||||
|
name='Interface 3',
|
||||||
|
label='C',
|
||||||
|
type=InterfaceTypeChoices.TYPE_1GE_FIXED,
|
||||||
|
enabled=False,
|
||||||
|
mgmt_only=False,
|
||||||
|
mtu=300,
|
||||||
|
mode=InterfaceModeChoices.MODE_TAGGED_ALL,
|
||||||
|
mac_address='00-00-00-00-00-03',
|
||||||
|
description='Third',
|
||||||
|
vrf=vrfs[2],
|
||||||
|
speed=100000,
|
||||||
|
duplex='half',
|
||||||
|
poe_mode=InterfacePoEModeChoices.MODE_PSE,
|
||||||
|
poe_type=InterfacePoETypeChoices.TYPE_2_8023AT
|
||||||
|
),
|
||||||
|
Interface(
|
||||||
|
device=devices[3],
|
||||||
|
name='Interface 4',
|
||||||
|
label='D',
|
||||||
|
type=InterfaceTypeChoices.TYPE_OTHER,
|
||||||
|
enabled=True,
|
||||||
|
mgmt_only=True,
|
||||||
|
tx_power=40,
|
||||||
|
speed=100000,
|
||||||
|
duplex='full',
|
||||||
|
poe_mode=InterfacePoEModeChoices.MODE_PD,
|
||||||
|
poe_type=InterfacePoETypeChoices.TYPE_2_8023AT
|
||||||
|
),
|
||||||
|
Interface(
|
||||||
|
device=devices[3],
|
||||||
|
name='Interface 5',
|
||||||
|
label='E',
|
||||||
|
type=InterfaceTypeChoices.TYPE_OTHER,
|
||||||
|
enabled=True,
|
||||||
|
mgmt_only=True,
|
||||||
|
tx_power=40
|
||||||
|
),
|
||||||
|
Interface(
|
||||||
|
device=devices[3],
|
||||||
|
name='Interface 6',
|
||||||
|
label='F',
|
||||||
|
type=InterfaceTypeChoices.TYPE_OTHER,
|
||||||
|
enabled=False,
|
||||||
|
mgmt_only=False,
|
||||||
|
tx_power=40
|
||||||
|
),
|
||||||
|
Interface(
|
||||||
|
device=devices[3],
|
||||||
|
name='Interface 7',
|
||||||
|
type=InterfaceTypeChoices.TYPE_80211AC,
|
||||||
|
rf_role=WirelessRoleChoices.ROLE_AP,
|
||||||
|
rf_channel=WirelessChannelChoices.CHANNEL_24G_1,
|
||||||
|
rf_channel_frequency=2412,
|
||||||
|
rf_channel_width=22
|
||||||
|
),
|
||||||
|
Interface(
|
||||||
|
device=devices[3],
|
||||||
|
name='Interface 8',
|
||||||
|
type=InterfaceTypeChoices.TYPE_80211AC,
|
||||||
|
rf_role=WirelessRoleChoices.ROLE_STATION,
|
||||||
|
rf_channel=WirelessChannelChoices.CHANNEL_5G_32,
|
||||||
|
rf_channel_frequency=5160,
|
||||||
|
rf_channel_width=20
|
||||||
|
),
|
||||||
)
|
)
|
||||||
Interface.objects.bulk_create(interfaces)
|
Interface.objects.bulk_create(interfaces)
|
||||||
|
|
||||||
@ -2594,6 +2693,14 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
params = {'mgmt_only': 'false'}
|
params = {'mgmt_only': 'false'}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||||
|
|
||||||
|
def test_poe_mode(self):
|
||||||
|
params = {'poe_mode': [InterfacePoEModeChoices.MODE_PD, InterfacePoEModeChoices.MODE_PSE]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||||
|
|
||||||
|
def test_poe_type(self):
|
||||||
|
params = {'poe_type': [InterfacePoETypeChoices.TYPE_1_8023AF, InterfacePoETypeChoices.TYPE_2_8023AT]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||||
|
|
||||||
def test_mode(self):
|
def test_mode(self):
|
||||||
params = {'mode': InterfaceModeChoices.MODE_ACCESS}
|
params = {'mode': InterfaceModeChoices.MODE_ACCESS}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||||
|
@ -175,9 +175,9 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
|||||||
tenant = Tenant.objects.create(name='Tenant 1', slug='tenant-1')
|
tenant = Tenant.objects.create(name='Tenant 1', slug='tenant-1')
|
||||||
|
|
||||||
locations = (
|
locations = (
|
||||||
Location(name='Location 1', slug='location-1', site=site, tenant=tenant),
|
Location(name='Location 1', slug='location-1', site=site, status=LocationStatusChoices.STATUS_ACTIVE, tenant=tenant),
|
||||||
Location(name='Location 2', slug='location-2', site=site, tenant=tenant),
|
Location(name='Location 2', slug='location-2', site=site, status=LocationStatusChoices.STATUS_ACTIVE, tenant=tenant),
|
||||||
Location(name='Location 3', slug='location-3', site=site, tenant=tenant),
|
Location(name='Location 3', slug='location-3', site=site, status=LocationStatusChoices.STATUS_ACTIVE, tenant=tenant),
|
||||||
)
|
)
|
||||||
for location in locations:
|
for location in locations:
|
||||||
location.save()
|
location.save()
|
||||||
@ -188,16 +188,17 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
|||||||
'name': 'Location X',
|
'name': 'Location X',
|
||||||
'slug': 'location-x',
|
'slug': 'location-x',
|
||||||
'site': site.pk,
|
'site': site.pk,
|
||||||
|
'status': LocationStatusChoices.STATUS_PLANNED,
|
||||||
'tenant': tenant.pk,
|
'tenant': tenant.pk,
|
||||||
'description': 'A new location',
|
'description': 'A new location',
|
||||||
'tags': [t.pk for t in tags],
|
'tags': [t.pk for t in tags],
|
||||||
}
|
}
|
||||||
|
|
||||||
cls.csv_data = (
|
cls.csv_data = (
|
||||||
"site,tenant,name,slug,description",
|
"site,tenant,name,slug,status,description",
|
||||||
"Site 1,Tenant 1,Location 4,location-4,Fourth location",
|
"Site 1,Tenant 1,Location 4,location-4,planned,Fourth location",
|
||||||
"Site 1,Tenant 1,Location 5,location-5,Fifth location",
|
"Site 1,Tenant 1,Location 5,location-5,planned,Fifth location",
|
||||||
"Site 1,Tenant 1,Location 6,location-6,Sixth location",
|
"Site 1,Tenant 1,Location 6,location-6,planned,Sixth location",
|
||||||
)
|
)
|
||||||
|
|
||||||
cls.bulk_edit_data = {
|
cls.bulk_edit_data = {
|
||||||
@ -2204,6 +2205,8 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
|||||||
'description': 'A front port',
|
'description': 'A front port',
|
||||||
'mode': InterfaceModeChoices.MODE_TAGGED,
|
'mode': InterfaceModeChoices.MODE_TAGGED,
|
||||||
'tx_power': 10,
|
'tx_power': 10,
|
||||||
|
'poe_mode': InterfacePoEModeChoices.MODE_PSE,
|
||||||
|
'poe_type': InterfacePoETypeChoices.TYPE_1_8023AF,
|
||||||
'untagged_vlan': vlans[0].pk,
|
'untagged_vlan': vlans[0].pk,
|
||||||
'tagged_vlans': [v.pk for v in vlans[1:4]],
|
'tagged_vlans': [v.pk for v in vlans[1:4]],
|
||||||
'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk],
|
'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk],
|
||||||
@ -2225,6 +2228,8 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
|||||||
'duplex': 'half',
|
'duplex': 'half',
|
||||||
'mgmt_only': True,
|
'mgmt_only': True,
|
||||||
'description': 'A front port',
|
'description': 'A front port',
|
||||||
|
'poe_mode': InterfacePoEModeChoices.MODE_PSE,
|
||||||
|
'poe_type': InterfacePoETypeChoices.TYPE_1_8023AF,
|
||||||
'mode': InterfaceModeChoices.MODE_TAGGED,
|
'mode': InterfaceModeChoices.MODE_TAGGED,
|
||||||
'untagged_vlan': vlans[0].pk,
|
'untagged_vlan': vlans[0].pk,
|
||||||
'tagged_vlans': [v.pk for v in vlans[1:4]],
|
'tagged_vlans': [v.pk for v in vlans[1:4]],
|
||||||
@ -2244,6 +2249,8 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
|||||||
'duplex': 'full',
|
'duplex': 'full',
|
||||||
'mgmt_only': True,
|
'mgmt_only': True,
|
||||||
'description': 'New description',
|
'description': 'New description',
|
||||||
|
'poe_mode': InterfacePoEModeChoices.MODE_PD,
|
||||||
|
'poe_type': InterfacePoETypeChoices.TYPE_2_8023AT,
|
||||||
'mode': InterfaceModeChoices.MODE_TAGGED,
|
'mode': InterfaceModeChoices.MODE_TAGGED,
|
||||||
'tx_power': 10,
|
'tx_power': 10,
|
||||||
'untagged_vlan': vlans[0].pk,
|
'untagged_vlan': vlans[0].pk,
|
||||||
@ -2252,10 +2259,10 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
cls.csv_data = (
|
cls.csv_data = (
|
||||||
f"device,name,type,vrf.pk",
|
f"device,name,type,vrf.pk,poe_mode,poe_type",
|
||||||
f"Device 1,Interface 4,1000base-t,{vrfs[0].pk}",
|
f"Device 1,Interface 4,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af",
|
||||||
f"Device 1,Interface 5,1000base-t,{vrfs[0].pk}",
|
f"Device 1,Interface 5,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af",
|
||||||
f"Device 1,Interface 6,1000base-t,{vrfs[0].pk}",
|
f"Device 1,Interface 6,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af",
|
||||||
)
|
)
|
||||||
|
|
||||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||||
|
@ -639,6 +639,11 @@ class RackView(generic.ObjectView):
|
|||||||
|
|
||||||
device_count = Device.objects.restrict(request.user, 'view').filter(rack=instance).count()
|
device_count = Device.objects.restrict(request.user, 'view').filter(rack=instance).count()
|
||||||
|
|
||||||
|
# Determine any additional parameters to pass when embedding the rack elevations
|
||||||
|
svg_extra = '&'.join([
|
||||||
|
f'highlight=id:{pk}' for pk in request.GET.getlist('device')
|
||||||
|
])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'device_count': device_count,
|
'device_count': device_count,
|
||||||
'reservations': reservations,
|
'reservations': reservations,
|
||||||
@ -646,6 +651,7 @@ class RackView(generic.ObjectView):
|
|||||||
'nonracked_devices': nonracked_devices,
|
'nonracked_devices': nonracked_devices,
|
||||||
'next_rack': next_rack,
|
'next_rack': next_rack,
|
||||||
'prev_rack': prev_rack,
|
'prev_rack': prev_rack,
|
||||||
|
'svg_extra': svg_extra,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -5,10 +5,10 @@ from drf_yasg.utils import swagger_serializer_method
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from dcim.api.nested_serializers import (
|
from dcim.api.nested_serializers import (
|
||||||
NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedPlatformSerializer, NestedRegionSerializer,
|
NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedLocationSerializer, NestedPlatformSerializer,
|
||||||
NestedSiteSerializer, NestedSiteGroupSerializer,
|
NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer,
|
||||||
)
|
)
|
||||||
from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup
|
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
|
||||||
from extras.choices import *
|
from extras.choices import *
|
||||||
from extras.models import *
|
from extras.models import *
|
||||||
from extras.utils import FeatureQuery
|
from extras.utils import FeatureQuery
|
||||||
@ -272,6 +272,12 @@ class ConfigContextSerializer(ValidatedModelSerializer):
|
|||||||
required=False,
|
required=False,
|
||||||
many=True
|
many=True
|
||||||
)
|
)
|
||||||
|
locations = SerializedPKRelatedField(
|
||||||
|
queryset=Location.objects.all(),
|
||||||
|
serializer=NestedLocationSerializer,
|
||||||
|
required=False,
|
||||||
|
many=True
|
||||||
|
)
|
||||||
device_types = SerializedPKRelatedField(
|
device_types = SerializedPKRelatedField(
|
||||||
queryset=DeviceType.objects.all(),
|
queryset=DeviceType.objects.all(),
|
||||||
serializer=NestedDeviceTypeSerializer,
|
serializer=NestedDeviceTypeSerializer,
|
||||||
@ -331,8 +337,8 @@ class ConfigContextSerializer(ValidatedModelSerializer):
|
|||||||
model = ConfigContext
|
model = ConfigContext
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display', 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites',
|
'id', 'url', 'display', 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites',
|
||||||
'device_types', 'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups',
|
'locations', 'device_types', 'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters',
|
||||||
'tenants', 'tags', 'data', 'created', 'last_updated',
|
'tenant_groups', 'tenants', 'tags', 'data', 'created', 'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -138,7 +138,7 @@ class JournalEntryViewSet(NetBoxModelViewSet):
|
|||||||
|
|
||||||
class ConfigContextViewSet(NetBoxModelViewSet):
|
class ConfigContextViewSet(NetBoxModelViewSet):
|
||||||
queryset = ConfigContext.objects.prefetch_related(
|
queryset = ConfigContext.objects.prefetch_related(
|
||||||
'regions', 'site_groups', 'sites', 'roles', 'platforms', 'tenant_groups', 'tenants',
|
'regions', 'site_groups', 'sites', 'locations', 'roles', 'platforms', 'tenant_groups', 'tenants',
|
||||||
)
|
)
|
||||||
serializer_class = serializers.ConfigContextSerializer
|
serializer_class = serializers.ConfigContextSerializer
|
||||||
filterset_class = filtersets.ConfigContextFilterSet
|
filterset_class = filtersets.ConfigContextFilterSet
|
||||||
|
@ -3,7 +3,7 @@ from django.contrib.auth.models import User
|
|||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
|
||||||
from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup
|
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
|
||||||
from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
|
from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
|
||||||
from tenancy.models import Tenant, TenantGroup
|
from tenancy.models import Tenant, TenantGroup
|
||||||
from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter
|
from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter
|
||||||
@ -255,6 +255,17 @@ class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
|
|||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
label='Site (slug)',
|
label='Site (slug)',
|
||||||
)
|
)
|
||||||
|
location_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
field_name='locations',
|
||||||
|
queryset=Location.objects.all(),
|
||||||
|
label='Location',
|
||||||
|
)
|
||||||
|
location = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
field_name='locations__slug',
|
||||||
|
queryset=Location.objects.all(),
|
||||||
|
to_field_name='slug',
|
||||||
|
label='Location (slug)',
|
||||||
|
)
|
||||||
device_type_id = django_filters.ModelMultipleChoiceFilter(
|
device_type_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
field_name='device_types',
|
field_name='device_types',
|
||||||
queryset=DeviceType.objects.all(),
|
queryset=DeviceType.objects.all(),
|
||||||
|
@ -3,7 +3,7 @@ from django.contrib.auth.models import User
|
|||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup
|
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
|
||||||
from extras.choices import *
|
from extras.choices import *
|
||||||
from extras.models import *
|
from extras.models import *
|
||||||
from extras.utils import FeatureQuery
|
from extras.utils import FeatureQuery
|
||||||
@ -170,7 +170,7 @@ class TagFilterForm(FilterForm):
|
|||||||
class ConfigContextFilterForm(FilterForm):
|
class ConfigContextFilterForm(FilterForm):
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, ('q', 'tag_id')),
|
(None, ('q', 'tag_id')),
|
||||||
('Location', ('region_id', 'site_group_id', 'site_id')),
|
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')),
|
||||||
('Device', ('device_type_id', 'platform_id', 'role_id')),
|
('Device', ('device_type_id', 'platform_id', 'role_id')),
|
||||||
('Cluster', ('cluster_type_id', 'cluster_group_id', 'cluster_id')),
|
('Cluster', ('cluster_type_id', 'cluster_group_id', 'cluster_id')),
|
||||||
('Tenant', ('tenant_group_id', 'tenant_id'))
|
('Tenant', ('tenant_group_id', 'tenant_id'))
|
||||||
@ -190,6 +190,11 @@ class ConfigContextFilterForm(FilterForm):
|
|||||||
required=False,
|
required=False,
|
||||||
label=_('Sites')
|
label=_('Sites')
|
||||||
)
|
)
|
||||||
|
location_id = DynamicModelMultipleChoiceField(
|
||||||
|
queryset=Location.objects.all(),
|
||||||
|
required=False,
|
||||||
|
label=_('Locations')
|
||||||
|
)
|
||||||
device_type_id = DynamicModelMultipleChoiceField(
|
device_type_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=DeviceType.objects.all(),
|
queryset=DeviceType.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
|
||||||
from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup
|
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
|
||||||
from extras.choices import *
|
from extras.choices import *
|
||||||
from extras.models import *
|
from extras.models import *
|
||||||
from extras.utils import FeatureQuery
|
from extras.utils import FeatureQuery
|
||||||
@ -166,6 +166,10 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
|
|||||||
queryset=Site.objects.all(),
|
queryset=Site.objects.all(),
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
|
locations = DynamicModelMultipleChoiceField(
|
||||||
|
queryset=Location.objects.all(),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
device_types = DynamicModelMultipleChoiceField(
|
device_types = DynamicModelMultipleChoiceField(
|
||||||
queryset=DeviceType.objects.all(),
|
queryset=DeviceType.objects.all(),
|
||||||
required=False
|
required=False
|
||||||
@ -202,15 +206,22 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
|
|||||||
queryset=Tag.objects.all(),
|
queryset=Tag.objects.all(),
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
data = JSONField(
|
data = JSONField()
|
||||||
label=''
|
|
||||||
|
fieldsets = (
|
||||||
|
('Config Context', ('name', 'weight', 'description', 'data', 'is_active')),
|
||||||
|
('Assignment', (
|
||||||
|
'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types',
|
||||||
|
'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags',
|
||||||
|
)),
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ConfigContext
|
model = ConfigContext
|
||||||
fields = (
|
fields = (
|
||||||
'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites', 'roles', 'device_types',
|
'name', 'weight', 'description', 'data', 'is_active', 'regions', 'site_groups', 'sites', 'locations',
|
||||||
'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data',
|
'roles', 'device_types', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups',
|
||||||
|
'tenants', 'tags',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
19
netbox/extras/migrations/0076_configcontext_locations.py
Normal file
19
netbox/extras/migrations/0076_configcontext_locations.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 4.0.5 on 2022-06-22 19:13
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('dcim', '0156_location_status'),
|
||||||
|
('extras', '0075_customfield_ui_visibility'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='configcontext',
|
||||||
|
name='locations',
|
||||||
|
field=models.ManyToManyField(blank=True, related_name='+', to='dcim.location'),
|
||||||
|
),
|
||||||
|
]
|
@ -1,5 +1,3 @@
|
|||||||
from collections import OrderedDict
|
|
||||||
|
|
||||||
from django.core.validators import ValidationError
|
from django.core.validators import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@ -55,6 +53,11 @@ class ConfigContext(WebhooksMixin, ChangeLoggedModel):
|
|||||||
related_name='+',
|
related_name='+',
|
||||||
blank=True
|
blank=True
|
||||||
)
|
)
|
||||||
|
locations = models.ManyToManyField(
|
||||||
|
to='dcim.Location',
|
||||||
|
related_name='+',
|
||||||
|
blank=True
|
||||||
|
)
|
||||||
device_types = models.ManyToManyField(
|
device_types = models.ManyToManyField(
|
||||||
to='dcim.DeviceType',
|
to='dcim.DeviceType',
|
||||||
related_name='+',
|
related_name='+',
|
||||||
@ -138,11 +141,10 @@ class ConfigContextModel(models.Model):
|
|||||||
|
|
||||||
def get_config_context(self):
|
def get_config_context(self):
|
||||||
"""
|
"""
|
||||||
|
Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs.
|
||||||
Return the rendered configuration context for a device or VM.
|
Return the rendered configuration context for a device or VM.
|
||||||
"""
|
"""
|
||||||
|
data = {}
|
||||||
# Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs
|
|
||||||
data = OrderedDict()
|
|
||||||
|
|
||||||
if not hasattr(self, 'config_context_data'):
|
if not hasattr(self, 'config_context_data'):
|
||||||
# The annotation is not available, so we fall back to manually querying for the config context objects
|
# The annotation is not available, so we fall back to manually querying for the config context objects
|
||||||
|
@ -19,8 +19,9 @@ class ConfigContextQuerySet(RestrictedQuerySet):
|
|||||||
# `device_role` for Device; `role` for VirtualMachine
|
# `device_role` for Device; `role` for VirtualMachine
|
||||||
role = getattr(obj, 'device_role', None) or obj.role
|
role = getattr(obj, 'device_role', None) or obj.role
|
||||||
|
|
||||||
# Device type assignment is relevant only for Devices
|
# Device type and location assignment is relevant only for Devices
|
||||||
device_type = getattr(obj, 'device_type', None)
|
device_type = getattr(obj, 'device_type', None)
|
||||||
|
location = getattr(obj, 'location', None)
|
||||||
|
|
||||||
# Get assigned cluster, group, and type (if any)
|
# Get assigned cluster, group, and type (if any)
|
||||||
cluster = getattr(obj, 'cluster', None)
|
cluster = getattr(obj, 'cluster', None)
|
||||||
@ -42,6 +43,7 @@ class ConfigContextQuerySet(RestrictedQuerySet):
|
|||||||
Q(regions__in=regions) | Q(regions=None),
|
Q(regions__in=regions) | Q(regions=None),
|
||||||
Q(site_groups__in=sitegroups) | Q(site_groups=None),
|
Q(site_groups__in=sitegroups) | Q(site_groups=None),
|
||||||
Q(sites=obj.site) | Q(sites=None),
|
Q(sites=obj.site) | Q(sites=None),
|
||||||
|
Q(locations=location) | Q(locations=None),
|
||||||
Q(device_types=device_type) | Q(device_types=None),
|
Q(device_types=device_type) | Q(device_types=None),
|
||||||
Q(roles=role) | Q(roles=None),
|
Q(roles=role) | Q(roles=None),
|
||||||
Q(platforms=obj.platform) | Q(platforms=None),
|
Q(platforms=obj.platform) | Q(platforms=None),
|
||||||
@ -114,6 +116,7 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if self.model._meta.model_name == 'device':
|
if self.model._meta.model_name == 'device':
|
||||||
|
base_query.add((Q(locations=OuterRef('location')) | Q(locations=None)), Q.AND)
|
||||||
base_query.add((Q(device_types=OuterRef('device_type')) | Q(device_types=None)), Q.AND)
|
base_query.add((Q(device_types=OuterRef('device_type')) | Q(device_types=None)), Q.AND)
|
||||||
base_query.add((Q(roles=OuterRef('device_role')) | Q(roles=None)), Q.AND)
|
base_query.add((Q(roles=OuterRef('device_role')) | Q(roles=None)), Q.AND)
|
||||||
base_query.add((Q(sites=OuterRef('site')) | Q(sites=None)), Q.AND)
|
base_query.add((Q(sites=OuterRef('site')) | Q(sites=None)), Q.AND)
|
||||||
|
@ -167,8 +167,9 @@ class ConfigContextTable(NetBoxTable):
|
|||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = ConfigContext
|
model = ConfigContext
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'weight', 'is_active', 'description', 'regions', 'sites', 'roles', 'platforms',
|
'pk', 'id', 'name', 'weight', 'is_active', 'description', 'regions', 'sites', 'locations', 'roles',
|
||||||
'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'created', 'last_updated',
|
'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'created',
|
||||||
|
'last_updated',
|
||||||
)
|
)
|
||||||
default_columns = ('pk', 'name', 'weight', 'is_active', 'description')
|
default_columns = ('pk', 'name', 'weight', 'is_active', 'description')
|
||||||
|
|
||||||
|
@ -6,7 +6,7 @@ from django.contrib.contenttypes.models import ContentType
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from circuits.models import Provider
|
from circuits.models import Provider
|
||||||
from dcim.models import DeviceRole, DeviceType, Manufacturer, Platform, Rack, Region, Site, SiteGroup
|
from dcim.models import DeviceRole, DeviceType, Location, Manufacturer, Platform, Rack, Region, Site, SiteGroup
|
||||||
from extras.choices import JournalEntryKindChoices, ObjectChangeActionChoices
|
from extras.choices import JournalEntryKindChoices, ObjectChangeActionChoices
|
||||||
from extras.filtersets import *
|
from extras.filtersets import *
|
||||||
from extras.models import *
|
from extras.models import *
|
||||||
@ -368,9 +368,9 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
|
|
||||||
regions = (
|
regions = (
|
||||||
Region(name='Test Region 1', slug='test-region-1'),
|
Region(name='Region 1', slug='region-1'),
|
||||||
Region(name='Test Region 2', slug='test-region-2'),
|
Region(name='Region 2', slug='region-2'),
|
||||||
Region(name='Test Region 3', slug='test-region-3'),
|
Region(name='Region 3', slug='region-3'),
|
||||||
)
|
)
|
||||||
for r in regions:
|
for r in regions:
|
||||||
r.save()
|
r.save()
|
||||||
@ -384,12 +384,20 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
site_group.save()
|
site_group.save()
|
||||||
|
|
||||||
sites = (
|
sites = (
|
||||||
Site(name='Test Site 1', slug='test-site-1'),
|
Site(name='Site 1', slug='site-1'),
|
||||||
Site(name='Test Site 2', slug='test-site-2'),
|
Site(name='Site 2', slug='site-2'),
|
||||||
Site(name='Test Site 3', slug='test-site-3'),
|
Site(name='Site 3', slug='site-3'),
|
||||||
)
|
)
|
||||||
Site.objects.bulk_create(sites)
|
Site.objects.bulk_create(sites)
|
||||||
|
|
||||||
|
locations = (
|
||||||
|
Location(name='Location 1', slug='location-1', site=sites[0]),
|
||||||
|
Location(name='Location 2', slug='location-2', site=sites[1]),
|
||||||
|
Location(name='Location 3', slug='location-3', site=sites[2]),
|
||||||
|
)
|
||||||
|
for location in locations:
|
||||||
|
location.save()
|
||||||
|
|
||||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||||
device_types = (
|
device_types = (
|
||||||
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
|
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
|
||||||
@ -460,6 +468,7 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
c.regions.set([regions[i]])
|
c.regions.set([regions[i]])
|
||||||
c.site_groups.set([site_groups[i]])
|
c.site_groups.set([site_groups[i]])
|
||||||
c.sites.set([sites[i]])
|
c.sites.set([sites[i]])
|
||||||
|
c.locations.set([locations[i]])
|
||||||
c.device_types.set([device_types[i]])
|
c.device_types.set([device_types[i]])
|
||||||
c.roles.set([device_roles[i]])
|
c.roles.set([device_roles[i]])
|
||||||
c.platforms.set([platforms[i]])
|
c.platforms.set([platforms[i]])
|
||||||
@ -501,6 +510,13 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
params = {'site': [sites[0].slug, sites[1].slug]}
|
params = {'site': [sites[0].slug, sites[1].slug]}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
|
def test_location(self):
|
||||||
|
locations = Location.objects.all()[:2]
|
||||||
|
params = {'location_id': [locations[0].pk, locations[1].pk]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
params = {'location': [locations[0].slug, locations[1].slug]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
def test_device_type(self):
|
def test_device_type(self):
|
||||||
device_types = DeviceType.objects.all()[:2]
|
device_types = DeviceType.objects.all()[:2]
|
||||||
params = {'device_type_id': [device_types[0].pk, device_types[1].pk]}
|
params = {'device_type_id': [device_types[0].pk, device_types[1].pk]}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Region, Site, SiteGroup
|
from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup
|
||||||
from extras.models import ConfigContext, Tag
|
from extras.models import ConfigContext, Tag
|
||||||
from tenancy.models import Tenant, TenantGroup
|
from tenancy.models import Tenant, TenantGroup
|
||||||
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
||||||
@ -29,7 +29,8 @@ class ConfigContextTest(TestCase):
|
|||||||
self.devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
self.devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||||
self.region = Region.objects.create(name="Region")
|
self.region = Region.objects.create(name="Region")
|
||||||
self.sitegroup = SiteGroup.objects.create(name="Site Group")
|
self.sitegroup = SiteGroup.objects.create(name="Site Group")
|
||||||
self.site = Site.objects.create(name='Site-1', slug='site-1', region=self.region, group=self.sitegroup)
|
self.site = Site.objects.create(name='Site 1', slug='site-1', region=self.region, group=self.sitegroup)
|
||||||
|
self.location = Location.objects.create(name='Location 1', slug='location-1', site=self.site)
|
||||||
self.platform = Platform.objects.create(name="Platform")
|
self.platform = Platform.objects.create(name="Platform")
|
||||||
self.tenantgroup = TenantGroup.objects.create(name="Tenant Group")
|
self.tenantgroup = TenantGroup.objects.create(name="Tenant Group")
|
||||||
self.tenant = Tenant.objects.create(name="Tenant", group=self.tenantgroup)
|
self.tenant = Tenant.objects.create(name="Tenant", group=self.tenantgroup)
|
||||||
@ -40,7 +41,8 @@ class ConfigContextTest(TestCase):
|
|||||||
name='Device 1',
|
name='Device 1',
|
||||||
device_type=self.devicetype,
|
device_type=self.devicetype,
|
||||||
device_role=self.devicerole,
|
device_role=self.devicerole,
|
||||||
site=self.site
|
site=self.site,
|
||||||
|
location=self.location
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_higher_weight_wins(self):
|
def test_higher_weight_wins(self):
|
||||||
@ -144,15 +146,6 @@ class ConfigContextTest(TestCase):
|
|||||||
self.assertEqual(self.device.get_config_context(), annotated_queryset[0].get_config_context())
|
self.assertEqual(self.device.get_config_context(), annotated_queryset[0].get_config_context())
|
||||||
|
|
||||||
def test_annotation_same_as_get_for_object_device_relations(self):
|
def test_annotation_same_as_get_for_object_device_relations(self):
|
||||||
|
|
||||||
site_context = ConfigContext.objects.create(
|
|
||||||
name="site",
|
|
||||||
weight=100,
|
|
||||||
data={
|
|
||||||
"site": 1
|
|
||||||
}
|
|
||||||
)
|
|
||||||
site_context.sites.add(self.site)
|
|
||||||
region_context = ConfigContext.objects.create(
|
region_context = ConfigContext.objects.create(
|
||||||
name="region",
|
name="region",
|
||||||
weight=100,
|
weight=100,
|
||||||
@ -169,6 +162,22 @@ class ConfigContextTest(TestCase):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
sitegroup_context.site_groups.add(self.sitegroup)
|
sitegroup_context.site_groups.add(self.sitegroup)
|
||||||
|
site_context = ConfigContext.objects.create(
|
||||||
|
name="site",
|
||||||
|
weight=100,
|
||||||
|
data={
|
||||||
|
"site": 1
|
||||||
|
}
|
||||||
|
)
|
||||||
|
site_context.sites.add(self.site)
|
||||||
|
location_context = ConfigContext.objects.create(
|
||||||
|
name="location",
|
||||||
|
weight=100,
|
||||||
|
data={
|
||||||
|
"location": 1
|
||||||
|
}
|
||||||
|
)
|
||||||
|
location_context.locations.add(self.location)
|
||||||
platform_context = ConfigContext.objects.create(
|
platform_context = ConfigContext.objects.create(
|
||||||
name="platform",
|
name="platform",
|
||||||
weight=100,
|
weight=100,
|
||||||
@ -205,6 +214,7 @@ class ConfigContextTest(TestCase):
|
|||||||
device = Device.objects.create(
|
device = Device.objects.create(
|
||||||
name="Device 2",
|
name="Device 2",
|
||||||
site=self.site,
|
site=self.site,
|
||||||
|
location=self.location,
|
||||||
tenant=self.tenant,
|
tenant=self.tenant,
|
||||||
platform=self.platform,
|
platform=self.platform,
|
||||||
device_role=self.devicerole,
|
device_role=self.devicerole,
|
||||||
@ -220,13 +230,6 @@ class ConfigContextTest(TestCase):
|
|||||||
cluster_group = ClusterGroup.objects.create(name="Cluster Group")
|
cluster_group = ClusterGroup.objects.create(name="Cluster Group")
|
||||||
cluster = Cluster.objects.create(name="Cluster", group=cluster_group, type=cluster_type)
|
cluster = Cluster.objects.create(name="Cluster", group=cluster_group, type=cluster_type)
|
||||||
|
|
||||||
site_context = ConfigContext.objects.create(
|
|
||||||
name="site",
|
|
||||||
weight=100,
|
|
||||||
data={"site": 1}
|
|
||||||
)
|
|
||||||
site_context.sites.add(self.site)
|
|
||||||
|
|
||||||
region_context = ConfigContext.objects.create(
|
region_context = ConfigContext.objects.create(
|
||||||
name="region",
|
name="region",
|
||||||
weight=100,
|
weight=100,
|
||||||
@ -241,6 +244,13 @@ class ConfigContextTest(TestCase):
|
|||||||
)
|
)
|
||||||
sitegroup_context.site_groups.add(self.sitegroup)
|
sitegroup_context.site_groups.add(self.sitegroup)
|
||||||
|
|
||||||
|
site_context = ConfigContext.objects.create(
|
||||||
|
name="site",
|
||||||
|
weight=100,
|
||||||
|
data={"site": 1}
|
||||||
|
)
|
||||||
|
site_context.sites.add(self.site)
|
||||||
|
|
||||||
platform_context = ConfigContext.objects.create(
|
platform_context = ConfigContext.objects.create(
|
||||||
name="platform",
|
name="platform",
|
||||||
weight=100,
|
weight=100,
|
||||||
|
@ -281,6 +281,7 @@ class ConfigContextView(generic.ObjectView):
|
|||||||
('Regions', instance.regions.all),
|
('Regions', instance.regions.all),
|
||||||
('Site Groups', instance.site_groups.all),
|
('Site Groups', instance.site_groups.all),
|
||||||
('Sites', instance.sites.all),
|
('Sites', instance.sites.all),
|
||||||
|
('Locations', instance.locations.all),
|
||||||
('Device Types', instance.device_types.all),
|
('Device Types', instance.device_types.all),
|
||||||
('Roles', instance.roles.all),
|
('Roles', instance.roles.all),
|
||||||
('Platforms', instance.platforms.all),
|
('Platforms', instance.platforms.all),
|
||||||
@ -311,7 +312,6 @@ class ConfigContextView(generic.ObjectView):
|
|||||||
class ConfigContextEditView(generic.ObjectEditView):
|
class ConfigContextEditView(generic.ObjectEditView):
|
||||||
queryset = ConfigContext.objects.all()
|
queryset = ConfigContext.objects.all()
|
||||||
form = forms.ConfigContextForm
|
form = forms.ConfigContextForm
|
||||||
template_name = 'extras/configcontext_edit.html'
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigContextBulkEditView(generic.BulkEditView):
|
class ConfigContextBulkEditView(generic.BulkEditView):
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
from .fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
|
from .fields import *
|
||||||
from .routers import NetBoxRouter
|
from .routers import NetBoxRouter
|
||||||
from .serializers import BulkOperationSerializer, ValidatedModelSerializer, WritableNestedSerializer
|
from .serializers import BulkOperationSerializer, ValidatedModelSerializer, WritableNestedSerializer
|
||||||
|
|
||||||
@ -7,6 +7,7 @@ __all__ = (
|
|||||||
'BulkOperationSerializer',
|
'BulkOperationSerializer',
|
||||||
'ChoiceField',
|
'ChoiceField',
|
||||||
'ContentTypeField',
|
'ContentTypeField',
|
||||||
|
'IPNetworkSerializer',
|
||||||
'NetBoxRouter',
|
'NetBoxRouter',
|
||||||
'SerializedPKRelatedField',
|
'SerializedPKRelatedField',
|
||||||
'ValidatedModelSerializer',
|
'ValidatedModelSerializer',
|
||||||
|
@ -1,16 +1,42 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.utils import timezone
|
||||||
from rest_framework import authentication, exceptions
|
from rest_framework import authentication, exceptions
|
||||||
from rest_framework.permissions import BasePermission, DjangoObjectPermissions, SAFE_METHODS
|
from rest_framework.permissions import BasePermission, DjangoObjectPermissions, SAFE_METHODS
|
||||||
|
|
||||||
|
from netbox.config import get_config
|
||||||
from users.models import Token
|
from users.models import Token
|
||||||
|
from utilities.request import get_client_ip
|
||||||
|
|
||||||
|
|
||||||
class TokenAuthentication(authentication.TokenAuthentication):
|
class TokenAuthentication(authentication.TokenAuthentication):
|
||||||
"""
|
"""
|
||||||
A custom authentication scheme which enforces Token expiration times.
|
A custom authentication scheme which enforces Token expiration times and source IP restrictions.
|
||||||
"""
|
"""
|
||||||
model = Token
|
model = Token
|
||||||
|
|
||||||
|
def authenticate(self, request):
|
||||||
|
result = super().authenticate(request)
|
||||||
|
|
||||||
|
if result:
|
||||||
|
token = result[1]
|
||||||
|
|
||||||
|
# Enforce source IP restrictions (if any) set on the token
|
||||||
|
if token.allowed_ips:
|
||||||
|
client_ip = get_client_ip(request)
|
||||||
|
if client_ip is None:
|
||||||
|
raise exceptions.AuthenticationFailed(
|
||||||
|
"Client IP address could not be determined for validation. Check that the HTTP server is "
|
||||||
|
"correctly configured to pass the required header(s)."
|
||||||
|
)
|
||||||
|
if not token.validate_client_ip(client_ip):
|
||||||
|
raise exceptions.AuthenticationFailed(
|
||||||
|
f"Source IP {client_ip} is not permitted to authenticate using this token."
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
def authenticate_credentials(self, key):
|
def authenticate_credentials(self, key):
|
||||||
model = self.get_model()
|
model = self.get_model()
|
||||||
try:
|
try:
|
||||||
@ -18,6 +44,16 @@ class TokenAuthentication(authentication.TokenAuthentication):
|
|||||||
except model.DoesNotExist:
|
except model.DoesNotExist:
|
||||||
raise exceptions.AuthenticationFailed("Invalid token")
|
raise exceptions.AuthenticationFailed("Invalid token")
|
||||||
|
|
||||||
|
# Update last used, but only once per minute at most. This reduces write load on the database
|
||||||
|
if not token.last_used or (timezone.now() - token.last_used).total_seconds() > 60:
|
||||||
|
# If maintenance mode is enabled, assume the database is read-only, and disable updating the token's
|
||||||
|
# last_used time upon authentication.
|
||||||
|
if get_config().MAINTENANCE_MODE:
|
||||||
|
logger = logging.getLogger('netbox.auth.login')
|
||||||
|
logger.debug("Maintenance mode enabled: Disabling update of token's last used timestamp")
|
||||||
|
else:
|
||||||
|
Token.objects.filter(pk=token.pk).update(last_used=timezone.now())
|
||||||
|
|
||||||
# Enforce the Token's expiration time, if one has been set.
|
# Enforce the Token's expiration time, if one has been set.
|
||||||
if token.is_expired:
|
if token.is_expired:
|
||||||
raise exceptions.AuthenticationFailed("Token expired")
|
raise exceptions.AuthenticationFailed("Token expired")
|
||||||
|
@ -1,12 +1,18 @@
|
|||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
|
||||||
import pytz
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from django.core.exceptions import ObjectDoesNotExist
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
|
from netaddr import IPNetwork
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import ValidationError
|
||||||
from rest_framework.relations import PrimaryKeyRelatedField, RelatedField
|
from rest_framework.relations import PrimaryKeyRelatedField, RelatedField
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
'ChoiceField',
|
||||||
|
'ContentTypeField',
|
||||||
|
'IPNetworkSerializer',
|
||||||
|
'SerializedPKRelatedField',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ChoiceField(serializers.Field):
|
class ChoiceField(serializers.Field):
|
||||||
"""
|
"""
|
||||||
@ -104,6 +110,17 @@ class ContentTypeField(RelatedField):
|
|||||||
return f"{obj.app_label}.{obj.model}"
|
return f"{obj.app_label}.{obj.model}"
|
||||||
|
|
||||||
|
|
||||||
|
class IPNetworkSerializer(serializers.Serializer):
|
||||||
|
"""
|
||||||
|
Representation of an IP network value (e.g. 192.0.2.0/24).
|
||||||
|
"""
|
||||||
|
def to_representation(self, instance):
|
||||||
|
return str(instance)
|
||||||
|
|
||||||
|
def to_internal_value(self, value):
|
||||||
|
return IPNetwork(value)
|
||||||
|
|
||||||
|
|
||||||
class SerializedPKRelatedField(PrimaryKeyRelatedField):
|
class SerializedPKRelatedField(PrimaryKeyRelatedField):
|
||||||
"""
|
"""
|
||||||
Extends PrimaryKeyRelatedField to return a serialized object on read. This is useful for representing related
|
Extends PrimaryKeyRelatedField to return a serialized object on read. This is useful for representing related
|
||||||
|
@ -2,6 +2,7 @@ from django.core.validators import ValidationError
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from mptt.models import MPTTModel, TreeForeignKey
|
from mptt.models import MPTTModel, TreeForeignKey
|
||||||
|
|
||||||
|
from extras.utils import is_taggable
|
||||||
from utilities.mptt import TreeManager
|
from utilities.mptt import TreeManager
|
||||||
from utilities.querysets import RestrictedQuerySet
|
from utilities.querysets import RestrictedQuerySet
|
||||||
from netbox.models.features import *
|
from netbox.models.features import *
|
||||||
@ -52,6 +53,25 @@ class NetBoxModel(NetBoxFeatureSet, models.Model):
|
|||||||
class Meta:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
|
def clone(self):
|
||||||
|
"""
|
||||||
|
Return a dictionary of attributes suitable for creating a copy of the current instance. This is used for pre-
|
||||||
|
populating an object creation form in the UI.
|
||||||
|
"""
|
||||||
|
attrs = {}
|
||||||
|
|
||||||
|
for field_name in getattr(self, 'clone_fields', []):
|
||||||
|
field = self._meta.get_field(field_name)
|
||||||
|
field_value = field.value_from_object(self)
|
||||||
|
if field_value not in (None, ''):
|
||||||
|
attrs[field_name] = field_value
|
||||||
|
|
||||||
|
# Include tags (if applicable)
|
||||||
|
if is_taggable(self):
|
||||||
|
attrs['tags'] = [tag.pk for tag in self.tags.all()]
|
||||||
|
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
class NestedGroupModel(NetBoxFeatureSet, MPTTModel):
|
class NestedGroupModel(NetBoxFeatureSet, MPTTModel):
|
||||||
"""
|
"""
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import datetime
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import Group, User
|
from django.contrib.auth.models import Group, User
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
@ -8,10 +10,77 @@ from netaddr import IPNetwork
|
|||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from dcim.models import Site
|
from dcim.models import Site
|
||||||
from ipam.choices import PrefixStatusChoices
|
|
||||||
from ipam.models import Prefix
|
from ipam.models import Prefix
|
||||||
from users.models import ObjectPermission, Token
|
from users.models import ObjectPermission, Token
|
||||||
from utilities.testing import TestCase
|
from utilities.testing import TestCase
|
||||||
|
from utilities.testing.api import APITestCase
|
||||||
|
|
||||||
|
|
||||||
|
class TokenAuthenticationTestCase(APITestCase):
|
||||||
|
|
||||||
|
@override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||||
|
def test_token_authentication(self):
|
||||||
|
url = reverse('dcim-api:site-list')
|
||||||
|
|
||||||
|
# Request without a token should return a 403
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
# Valid token should return a 200
|
||||||
|
token = Token.objects.create(user=self.user)
|
||||||
|
response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
# Check that the token's last_used time has been updated
|
||||||
|
token.refresh_from_db()
|
||||||
|
self.assertIsNotNone(token.last_used)
|
||||||
|
|
||||||
|
@override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||||
|
def test_token_expiration(self):
|
||||||
|
url = reverse('dcim-api:site-list')
|
||||||
|
|
||||||
|
# Request without a non-expired token should succeed
|
||||||
|
token = Token.objects.create(user=self.user)
|
||||||
|
response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
# Request with an expired token should fail
|
||||||
|
token.expires = datetime.datetime(2020, 1, 1, tzinfo=datetime.timezone.utc)
|
||||||
|
token.save()
|
||||||
|
response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}')
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
@override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||||
|
def test_token_write_enabled(self):
|
||||||
|
url = reverse('dcim-api:site-list')
|
||||||
|
data = {
|
||||||
|
'name': 'Site 1',
|
||||||
|
'slug': 'site-1',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Request with a write-disabled token should fail
|
||||||
|
token = Token.objects.create(user=self.user, write_enabled=False)
|
||||||
|
response = self.client.post(url, data, format='json', HTTP_AUTHORIZATION=f'Token {token.key}')
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
# Request with a write-enabled token should succeed
|
||||||
|
token.write_enabled = True
|
||||||
|
token.save()
|
||||||
|
response = self.client.post(url, data, format='json', HTTP_AUTHORIZATION=f'Token {token.key}')
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
@override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||||
|
def test_token_allowed_ips(self):
|
||||||
|
url = reverse('dcim-api:site-list')
|
||||||
|
|
||||||
|
# Request from a non-allowed client IP should fail
|
||||||
|
token = Token.objects.create(user=self.user, allowed_ips=['192.0.2.0/24'])
|
||||||
|
response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}', REMOTE_ADDR='127.0.0.1')
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
# Request with an expired token should fail
|
||||||
|
response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}', REMOTE_ADDR='192.0.2.1')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
|
||||||
class ExternalAuthenticationTestCase(TestCase):
|
class ExternalAuthenticationTestCase(TestCase):
|
||||||
|
@ -394,11 +394,11 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
|
|||||||
if '_addanother' in request.POST:
|
if '_addanother' in request.POST:
|
||||||
redirect_url = request.path
|
redirect_url = request.path
|
||||||
|
|
||||||
# If the object has clone_fields, pre-populate a new instance of the form
|
# If cloning is supported, pre-populate a new instance of the form
|
||||||
params = prepare_cloned_fields(obj)
|
params = prepare_cloned_fields(obj)
|
||||||
if 'return_url' in request.GET:
|
|
||||||
params['return_url'] = request.GET.get('return_url')
|
|
||||||
if params:
|
if params:
|
||||||
|
if 'return_url' in request.GET:
|
||||||
|
params['return_url'] = request.GET.get('return_url')
|
||||||
redirect_url += f"?{params.urlencode()}"
|
redirect_url += f"?{params.urlencode()}"
|
||||||
|
|
||||||
return redirect(redirect_url)
|
return redirect(redirect_url)
|
||||||
|
BIN
netbox/project-static/dist/rack_elevation.css
vendored
BIN
netbox/project-static/dist/rack_elevation.css
vendored
Binary file not shown.
@ -48,6 +48,13 @@ svg {
|
|||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rect.shaded, image.shaded {
|
||||||
|
opacity: 25%;
|
||||||
|
}
|
||||||
|
text.shaded {
|
||||||
|
opacity: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
// Rack elevation container.
|
// Rack elevation container.
|
||||||
.rack {
|
.rack {
|
||||||
fill: none;
|
fill: none;
|
||||||
@ -81,17 +88,6 @@ svg {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// When a reserved slot is hovered, use a more readable color for the 'Add Device' text.
|
|
||||||
&.reserved:hover[class] + .add-device {
|
|
||||||
fill: $black;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reserved rack unit background color.
|
|
||||||
&.reserved[class],
|
|
||||||
&.reserved:hover[class] {
|
|
||||||
fill: url(#reserved);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Occupied rack unit background color.
|
// Occupied rack unit background color.
|
||||||
&.occupied[class],
|
&.occupied[class],
|
||||||
&.occupied:hover[class] {
|
&.occupied:hover[class] {
|
||||||
@ -108,4 +104,9 @@ svg {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reservation background color.
|
||||||
|
.reservation[class] {
|
||||||
|
fill: url(#reserved);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -45,6 +45,10 @@
|
|||||||
<th scope="row">Install Date</th>
|
<th scope="row">Install Date</th>
|
||||||
<td>{{ object.install_date|annotated_date|placeholder }}</td>
|
<td>{{ object.install_date|annotated_date|placeholder }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Termination Date</th>
|
||||||
|
<td>{{ object.termination_date|annotated_date|placeholder }}</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Commit Rate</th>
|
<th scope="row">Commit Rate</th>
|
||||||
<td>{{ object.commit_rate|humanize_speed|placeholder }}</td>
|
<td>{{ object.commit_rate|humanize_speed|placeholder }}</td>
|
||||||
|
@ -49,6 +49,11 @@
|
|||||||
<td>
|
<td>
|
||||||
{% if object.rack %}
|
{% if object.rack %}
|
||||||
<a href="{% url 'dcim:rack' pk=object.rack.pk %}">{{ object.rack }}</a>
|
<a href="{% url 'dcim:rack' pk=object.rack.pk %}">{{ object.rack }}</a>
|
||||||
|
<div class="float-end noprint">
|
||||||
|
<a href="{% url 'dcim:rack' pk=object.rack.pk %}?device={{ object.pk }}" class="btn btn-primary btn-sm" title="Highlight device">
|
||||||
|
<i class="mdi mdi-view-day-outline"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ ''|placeholder }}
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
<div style="margin-left: -30px">
|
<div style="margin-left: -30px">
|
||||||
<object data="{% url 'dcim-api:rack-elevation' pk=object.pk %}?face={{face}}&render=svg" class="rack_elevation"></object>
|
<object data="{% url 'dcim-api:rack-elevation' pk=object.pk %}?face={{face}}&render=svg{% if extra_params %}&{{ extra_params }}{% endif %}" class="rack_elevation"></object>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center mt-3">
|
<div class="text-center mt-3">
|
||||||
<a class="btn btn-outline-primary btn-sm" href="{% url 'dcim-api:rack-elevation' pk=object.pk %}?face={{face}}&render=svg">
|
<a class="btn btn-outline-primary btn-sm" href="{% url 'dcim-api:rack-elevation' pk=object.pk %}?face={{face}}&render=svg{% if extra_params %}&{{ extra_params }}{% endif %}">
|
||||||
<i class="mdi mdi-file-download"></i> Download SVG
|
<i class="mdi mdi-file-download"></i> Download SVG
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
@ -69,6 +69,14 @@
|
|||||||
<th scope="row">Description</th>
|
<th scope="row">Description</th>
|
||||||
<td>{{ object.description|placeholder }} </td>
|
<td>{{ object.description|placeholder }} </td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">PoE Mode</th>
|
||||||
|
<td>{{ object.get_poe_mode_display|placeholder }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">PoE Mode</th>
|
||||||
|
<td>{{ object.get_poe_type_display|placeholder }}</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">802.1Q Mode</th>
|
<th scope="row">802.1Q Mode</th>
|
||||||
<td>{{ object.get_mode_display|placeholder }}</td>
|
<td>{{ object.get_mode_display|placeholder }}</td>
|
||||||
|
@ -43,6 +43,10 @@
|
|||||||
<th scope="row">Parent</th>
|
<th scope="row">Parent</th>
|
||||||
<td>{{ object.parent|linkify|placeholder }}</td>
|
<td>{{ object.parent|linkify|placeholder }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Status</th>
|
||||||
|
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Tenant</th>
|
<th scope="row">Tenant</th>
|
||||||
<td>
|
<td>
|
||||||
|
@ -250,13 +250,13 @@
|
|||||||
<div class="col col-md-6 col-sm-6 col-xs-12 text-center">
|
<div class="col col-md-6 col-sm-6 col-xs-12 text-center">
|
||||||
<div style="margin-left: 30px">
|
<div style="margin-left: 30px">
|
||||||
<h4>Front</h4>
|
<h4>Front</h4>
|
||||||
{% include 'dcim/inc/rack_elevation.html' with face='front' %}
|
{% include 'dcim/inc/rack_elevation.html' with face='front' extra_params=svg_extra %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col col-md-6 col-sm-6 col-xs-12 text-center">
|
<div class="col col-md-6 col-sm-6 col-xs-12 text-center">
|
||||||
<div style="margin-left: 30px">
|
<div style="margin-left: 30px">
|
||||||
<h4>Rear</h4>
|
<h4>Rear</h4>
|
||||||
{% include 'dcim/inc/rack_elevation.html' with face='rear' %}
|
{% include 'dcim/inc/rack_elevation.html' with face='rear' extra_params=svg_extra %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,37 +0,0 @@
|
|||||||
{% extends 'generic/object_edit.html' %}
|
|
||||||
{% load form_helpers %}
|
|
||||||
|
|
||||||
{% block form %}
|
|
||||||
<div class="card">
|
|
||||||
<h5 class="card-header">Config Context</h5>
|
|
||||||
<div class="card-body">
|
|
||||||
{% render_field form.name %}
|
|
||||||
{% render_field form.weight %}
|
|
||||||
{% render_field form.description %}
|
|
||||||
{% render_field form.is_active %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<h5 class="card-header">Assignment</h5>
|
|
||||||
<div class="card-body">
|
|
||||||
{% render_field form.regions %}
|
|
||||||
{% render_field form.site_groups %}
|
|
||||||
{% render_field form.sites %}
|
|
||||||
{% render_field form.device_types %}
|
|
||||||
{% render_field form.roles %}
|
|
||||||
{% render_field form.platforms %}
|
|
||||||
{% render_field form.cluster_types %}
|
|
||||||
{% render_field form.cluster_groups %}
|
|
||||||
{% render_field form.clusters %}
|
|
||||||
{% render_field form.tenant_groups %}
|
|
||||||
{% render_field form.tenants %}
|
|
||||||
{% render_field form.tags %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<h5 class="card-header">Data</h5>
|
|
||||||
<div class="card-body">
|
|
||||||
{% render_field form.data %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
@ -59,7 +59,7 @@ Context:
|
|||||||
{# Extra buttons #}
|
{# Extra buttons #}
|
||||||
{% block extra_controls %}{% endblock %}
|
{% block extra_controls %}{% endblock %}
|
||||||
|
|
||||||
{% if object.clone_fields and request.user|can_add:object %}
|
{% if request.user|can_add:object %}
|
||||||
{% clone_button object %}
|
{% clone_button object %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if request.user|can_change:object %}
|
{% if request.user|can_change:object %}
|
||||||
|
@ -61,6 +61,10 @@
|
|||||||
<h2><a href="{% url 'dcim:device_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.device_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.device_count }}</a></h2>
|
<h2><a href="{% url 'dcim:device_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.device_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.device_count }}</a></h2>
|
||||||
<p>Devices</p>
|
<p>Devices</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col col-md-4 text-center">
|
||||||
|
<h2><a href="{% url 'dcim:cable_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.cable_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.cable_count }}</a></h2>
|
||||||
|
<p>Cables</p>
|
||||||
|
</div>
|
||||||
<div class="col col-md-4 text-center">
|
<div class="col col-md-4 text-center">
|
||||||
<h2><a href="{% url 'ipam:vrf_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.vrf_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.vrf_count }}</a></h2>
|
<h2><a href="{% url 'ipam:vrf_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.vrf_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.vrf_count }}</a></h2>
|
||||||
<p>VRFs</p>
|
<p>VRFs</p>
|
||||||
@ -102,8 +106,12 @@
|
|||||||
<p>Clusters</p>
|
<p>Clusters</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="col col-md-4 text-center">
|
<div class="col col-md-4 text-center">
|
||||||
<h2><a href="{% url 'dcim:cable_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.cable_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.cable_count }}</a></h2>
|
<h2><a href="{% url 'wireless:wirelesslan_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.wirelesslan_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.wirelesslan_count }}</a></h2>
|
||||||
<p>Cables</p>
|
<p>Wireless LANs</p>
|
||||||
|
</div>
|
||||||
|
<div class="col col-md-4 text-center">
|
||||||
|
<h2><a href="{% url 'wireless:wirelesslink_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.wirelesslink_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.wirelesslink_count }}</a></h2>
|
||||||
|
<p>Wireless Links</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -22,11 +22,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col col-md-4">
|
<div class="col col-md-3">
|
||||||
<small class="text-muted">Created</small><br />
|
<small class="text-muted">Created</small><br />
|
||||||
{{ token.created|annotated_date }}
|
{{ token.created|annotated_date }}
|
||||||
</div>
|
</div>
|
||||||
<div class="col col-md-4">
|
<div class="col col-md-3">
|
||||||
<small class="text-muted">Expires</small><br />
|
<small class="text-muted">Expires</small><br />
|
||||||
{% if token.expires %}
|
{% if token.expires %}
|
||||||
{{ token.expires|annotated_date }}
|
{{ token.expires|annotated_date }}
|
||||||
@ -34,7 +34,15 @@
|
|||||||
<span>Never</span>
|
<span>Never</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="col col-md-4">
|
<div class="col col-md-3">
|
||||||
|
<small class="text-muted">Last Used</small><br />
|
||||||
|
{% if token.last_used %}
|
||||||
|
{{ token.last_used|annotated_date }}
|
||||||
|
{% else %}
|
||||||
|
<span>Never</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="col col-md-3">
|
||||||
<small class="text-muted">Create/Edit/Delete Operations</small><br />
|
<small class="text-muted">Create/Edit/Delete Operations</small><br />
|
||||||
{% if token.write_enabled %}
|
{% if token.write_enabled %}
|
||||||
<span class="badge bg-success">Enabled</span>
|
<span class="badge bg-success">Enabled</span>
|
||||||
@ -42,7 +50,14 @@
|
|||||||
<span class="badge bg-danger">Disabled</span>
|
<span class="badge bg-danger">Disabled</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="col col-md-3">
|
||||||
|
<small class="text-muted">Allowed Source IPs</small><br />
|
||||||
|
{% if token.allowed_ips %}
|
||||||
|
{{ token.allowed_ips|join:', ' }}
|
||||||
|
{% else %}
|
||||||
|
<span>Any</span>
|
||||||
|
{% endif %}
|
||||||
|
</div> </div>
|
||||||
{% if token.description %}
|
{% if token.description %}
|
||||||
<br /><span>{{ token.description }}</span>
|
<br /><span>{{ token.description }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -6,36 +6,45 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col col-md-6">
|
<div class="col col-md-6">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h5 class="card-header">Wireless LAN</h5>
|
<h5 class="card-header">Wireless LAN</h5>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<table class="table table-hover attr-table">
|
<table class="table table-hover attr-table">
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">SSID</th>
|
<th scope="row">SSID</th>
|
||||||
<td>{{ object.ssid }}</td>
|
<td>{{ object.ssid }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Group</td>
|
<td>Group</td>
|
||||||
<td>{{ object.group|linkify|placeholder }}</td>
|
<td>{{ object.group|linkify|placeholder }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Description</th>
|
<th scope="row">Description</th>
|
||||||
<td>{{ object.description|placeholder }}</td>
|
<td>{{ object.description|placeholder }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">VLAN</th>
|
<th scope="row">VLAN</th>
|
||||||
<td>{{ object.vlan|linkify|placeholder }}</td>
|
<td>{{ object.vlan|linkify|placeholder }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
<tr>
|
||||||
</div>
|
<th scope="row">Tenant</th>
|
||||||
</div>
|
<td>
|
||||||
{% include 'inc/panels/tags.html' %}
|
{% if object.tenant.group %}
|
||||||
{% plugin_left_page object %}
|
{{ object.tenant.group|linkify }} /
|
||||||
|
{% endif %}
|
||||||
|
{{ object.tenant|linkify|placeholder }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col col-md-6">
|
{% include 'inc/panels/tags.html' %}
|
||||||
{% include 'wireless/inc/authentication_attrs.html' %}
|
{% plugin_left_page object %}
|
||||||
{% include 'inc/panels/custom_fields.html' %}
|
</div>
|
||||||
{% plugin_right_page object %}
|
<div class="col col-md-6">
|
||||||
|
{% include 'wireless/inc/authentication_attrs.html' %}
|
||||||
|
{% include 'inc/panels/custom_fields.html' %}
|
||||||
|
{% plugin_right_page object %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
@ -23,6 +23,15 @@
|
|||||||
<th scope="row">SSID</th>
|
<th scope="row">SSID</th>
|
||||||
<td>{{ object.ssid|placeholder }}</td>
|
<td>{{ object.ssid|placeholder }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Tenant</th>
|
||||||
|
<td>
|
||||||
|
{% if object.tenant.group %}
|
||||||
|
{{ object.tenant.group|linkify }} /
|
||||||
|
{% endif %}
|
||||||
|
{{ object.tenant|linkify|placeholder }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Description</th>
|
<th scope="row">Description</th>
|
||||||
<td>{{ object.description|placeholder }}</td>
|
<td>{{ object.description|placeholder }}</td>
|
||||||
|
@ -7,6 +7,7 @@ from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF, ASN
|
|||||||
from netbox.views import generic
|
from netbox.views import generic
|
||||||
from utilities.utils import count_related
|
from utilities.utils import count_related
|
||||||
from virtualization.models import VirtualMachine, Cluster
|
from virtualization.models import VirtualMachine, Cluster
|
||||||
|
from wireless.models import WirelessLAN, WirelessLink
|
||||||
from . import filtersets, forms, tables
|
from . import filtersets, forms, tables
|
||||||
from .models import *
|
from .models import *
|
||||||
|
|
||||||
@ -114,6 +115,8 @@ class TenantView(generic.ObjectView):
|
|||||||
'cluster_count': Cluster.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
|
'cluster_count': Cluster.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
|
||||||
'cable_count': Cable.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
|
'cable_count': Cable.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
|
||||||
'asn_count': ASN.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
|
'asn_count': ASN.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
|
||||||
|
'wirelesslan_count': WirelessLAN.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
|
||||||
|
'wirelesslink_count': WirelessLink.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -58,9 +58,13 @@ class UserAdmin(UserAdmin_):
|
|||||||
class TokenAdmin(admin.ModelAdmin):
|
class TokenAdmin(admin.ModelAdmin):
|
||||||
form = forms.TokenAdminForm
|
form = forms.TokenAdminForm
|
||||||
list_display = [
|
list_display = [
|
||||||
'key', 'user', 'created', 'expires', 'write_enabled', 'description'
|
'key', 'user', 'created', 'expires', 'last_used', 'write_enabled', 'description', 'list_allowed_ips'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def list_allowed_ips(self, obj):
|
||||||
|
return obj.allowed_ips or 'Any'
|
||||||
|
list_allowed_ips.short_description = "Allowed IPs"
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Permissions
|
# Permissions
|
||||||
|
@ -51,7 +51,7 @@ class TokenAdminForm(forms.ModelForm):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
fields = [
|
fields = [
|
||||||
'user', 'key', 'write_enabled', 'expires', 'description'
|
'user', 'key', 'write_enabled', 'expires', 'description', 'allowed_ips'
|
||||||
]
|
]
|
||||||
model = Token
|
model = Token
|
||||||
|
|
||||||
|
@ -2,7 +2,7 @@ from django.contrib.auth.models import Group, User
|
|||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from netbox.api import ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer
|
from netbox.api import ContentTypeField, IPNetworkSerializer, SerializedPKRelatedField, ValidatedModelSerializer
|
||||||
from users.models import ObjectPermission, Token
|
from users.models import ObjectPermission, Token
|
||||||
from .nested_serializers import *
|
from .nested_serializers import *
|
||||||
|
|
||||||
@ -64,10 +64,19 @@ class TokenSerializer(ValidatedModelSerializer):
|
|||||||
url = serializers.HyperlinkedIdentityField(view_name='users-api:token-detail')
|
url = serializers.HyperlinkedIdentityField(view_name='users-api:token-detail')
|
||||||
key = serializers.CharField(min_length=40, max_length=40, allow_blank=True, required=False)
|
key = serializers.CharField(min_length=40, max_length=40, allow_blank=True, required=False)
|
||||||
user = NestedUserSerializer()
|
user = NestedUserSerializer()
|
||||||
|
allowed_ips = serializers.ListField(
|
||||||
|
child=IPNetworkSerializer(),
|
||||||
|
required=False,
|
||||||
|
allow_empty=True,
|
||||||
|
default=[]
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Token
|
model = Token
|
||||||
fields = ('id', 'url', 'display', 'user', 'created', 'expires', 'key', 'write_enabled', 'description')
|
fields = (
|
||||||
|
'id', 'url', 'display', 'user', 'created', 'expires', 'last_used', 'key', 'write_enabled', 'description',
|
||||||
|
'allowed_ips',
|
||||||
|
)
|
||||||
|
|
||||||
def to_internal_value(self, data):
|
def to_internal_value(self, data):
|
||||||
if 'key' not in data:
|
if 'key' not in data:
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm
|
from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm
|
||||||
|
from django.contrib.postgres.forms import SimpleArrayField
|
||||||
from django.utils.html import mark_safe
|
from django.utils.html import mark_safe
|
||||||
|
|
||||||
|
from ipam.formfields import IPNetworkFormField
|
||||||
from netbox.preferences import PREFERENCES
|
from netbox.preferences import PREFERENCES
|
||||||
from utilities.forms import BootstrapMixin, DateTimePicker, StaticSelect
|
from utilities.forms import BootstrapMixin, DateTimePicker, StaticSelect
|
||||||
from utilities.utils import flatten_dict
|
from utilities.utils import flatten_dict
|
||||||
@ -99,11 +101,18 @@ class TokenForm(BootstrapMixin, forms.ModelForm):
|
|||||||
required=False,
|
required=False,
|
||||||
help_text="If no key is provided, one will be generated automatically."
|
help_text="If no key is provided, one will be generated automatically."
|
||||||
)
|
)
|
||||||
|
allowed_ips = SimpleArrayField(
|
||||||
|
base_field=IPNetworkFormField(),
|
||||||
|
required=False,
|
||||||
|
label='Allowed IPs',
|
||||||
|
help_text='Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. '
|
||||||
|
'Ex: "10.1.1.0/24, 192.168.10.16/32, 2001:DB8:1::/64"',
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Token
|
model = Token
|
||||||
fields = [
|
fields = [
|
||||||
'key', 'write_enabled', 'expires', 'description',
|
'key', 'write_enabled', 'expires', 'description', 'allowed_ips',
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
'expires': DateTimePicker(),
|
'expires': DateTimePicker(),
|
||||||
|
23
netbox/users/migrations/0003_token_allowed_ips_last_used.py
Normal file
23
netbox/users/migrations/0003_token_allowed_ips_last_used.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import django.contrib.postgres.fields
|
||||||
|
from django.db import migrations, models
|
||||||
|
import ipam.fields
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('users', '0002_standardize_id_fields'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='token',
|
||||||
|
name='allowed_ips',
|
||||||
|
field=django.contrib.postgres.fields.ArrayField(base_field=ipam.fields.IPNetworkField(), blank=True, null=True, size=None),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='token',
|
||||||
|
name='last_used',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
@ -9,13 +9,14 @@ from django.db import models
|
|||||||
from django.db.models.signals import post_save
|
from django.db.models.signals import post_save
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from netaddr import IPNetwork
|
||||||
|
|
||||||
|
from ipam.fields import IPNetworkField
|
||||||
from netbox.config import get_config
|
from netbox.config import get_config
|
||||||
from utilities.querysets import RestrictedQuerySet
|
from utilities.querysets import RestrictedQuerySet
|
||||||
from utilities.utils import flatten_dict
|
from utilities.utils import flatten_dict
|
||||||
from .constants import *
|
from .constants import *
|
||||||
|
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'ObjectPermission',
|
'ObjectPermission',
|
||||||
'Token',
|
'Token',
|
||||||
@ -203,6 +204,10 @@ class Token(models.Model):
|
|||||||
blank=True,
|
blank=True,
|
||||||
null=True
|
null=True
|
||||||
)
|
)
|
||||||
|
last_used = models.DateTimeField(
|
||||||
|
blank=True,
|
||||||
|
null=True
|
||||||
|
)
|
||||||
key = models.CharField(
|
key = models.CharField(
|
||||||
max_length=40,
|
max_length=40,
|
||||||
unique=True,
|
unique=True,
|
||||||
@ -216,6 +221,14 @@ class Token(models.Model):
|
|||||||
max_length=200,
|
max_length=200,
|
||||||
blank=True
|
blank=True
|
||||||
)
|
)
|
||||||
|
allowed_ips = ArrayField(
|
||||||
|
base_field=IPNetworkField(),
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name='Allowed IPs',
|
||||||
|
help_text='Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. '
|
||||||
|
'Ex: "10.1.1.0/24, 192.168.10.16/32, 2001:DB8:1::/64"',
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
pass
|
pass
|
||||||
@ -240,6 +253,19 @@ class Token(models.Model):
|
|||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def validate_client_ip(self, client_ip):
|
||||||
|
"""
|
||||||
|
Validate the API client IP address against the source IP restrictions (if any) set on the token.
|
||||||
|
"""
|
||||||
|
if not self.allowed_ips:
|
||||||
|
return True
|
||||||
|
|
||||||
|
for ip_network in self.allowed_ips:
|
||||||
|
if client_ip in IPNetwork(ip_network):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Permissions
|
# Permissions
|
||||||
|
27
netbox/utilities/request.py
Normal file
27
netbox/utilities/request.py
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
from netaddr import IPAddress
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
'get_client_ip',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_client_ip(request, additional_headers=()):
|
||||||
|
"""
|
||||||
|
Return the client (source) IP address of the given request.
|
||||||
|
"""
|
||||||
|
HTTP_HEADERS = (
|
||||||
|
'HTTP_X_REAL_IP',
|
||||||
|
'HTTP_X_FORWARDED_FOR',
|
||||||
|
'REMOTE_ADDR',
|
||||||
|
*additional_headers
|
||||||
|
)
|
||||||
|
for header in HTTP_HEADERS:
|
||||||
|
if header in request.META:
|
||||||
|
client_ip = request.META[header].split(',')[0]
|
||||||
|
try:
|
||||||
|
return IPAddress(client_ip)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"Invalid IP address set for {header}: {client_ip}")
|
||||||
|
|
||||||
|
# Could not determine the client IP address from request headers
|
||||||
|
return None
|
@ -282,26 +282,22 @@ def render_jinja2(template_code, context):
|
|||||||
|
|
||||||
def prepare_cloned_fields(instance):
|
def prepare_cloned_fields(instance):
|
||||||
"""
|
"""
|
||||||
Compile an object's `clone_fields` list into a string of URL query parameters. Tags are automatically cloned where
|
Generate a QueryDict comprising attributes from an object's clone() method.
|
||||||
applicable.
|
|
||||||
"""
|
"""
|
||||||
|
# Generate the clone attributes from the instance
|
||||||
|
if not hasattr(instance, 'clone'):
|
||||||
|
return None
|
||||||
|
attrs = instance.clone()
|
||||||
|
|
||||||
|
# Prepare querydict parameters
|
||||||
params = []
|
params = []
|
||||||
for field_name in getattr(instance, 'clone_fields', []):
|
for key, value in attrs.items():
|
||||||
field = instance._meta.get_field(field_name)
|
if type(value) in (list, tuple):
|
||||||
field_value = field.value_from_object(instance)
|
params.extend([(key, v) for v in value])
|
||||||
|
elif value not in (False, None):
|
||||||
# Pass False as null for boolean fields
|
params.append((key, value))
|
||||||
if field_value is False:
|
else:
|
||||||
params.append((field_name, ''))
|
params.append((key, ''))
|
||||||
|
|
||||||
# Omit empty values
|
|
||||||
elif field_value not in (None, ''):
|
|
||||||
params.append((field_name, field_value))
|
|
||||||
|
|
||||||
# Copy tags
|
|
||||||
if is_taggable(instance):
|
|
||||||
for tag in instance.tags.all():
|
|
||||||
params.append(('tags', tag.pk))
|
|
||||||
|
|
||||||
# Return a QueryDict with the parameters
|
# Return a QueryDict with the parameters
|
||||||
return QueryDict('&'.join([f'{k}={v}' for k, v in params]), mutable=True)
|
return QueryDict('&'.join([f'{k}={v}' for k, v in params]), mutable=True)
|
||||||
@ -341,14 +337,34 @@ def flatten_dict(d, prefix='', separator='.'):
|
|||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
def array_to_ranges(array):
|
||||||
|
"""
|
||||||
|
Convert an arbitrary array of integers to a list of consecutive values. Nonconsecutive values are returned as
|
||||||
|
single-item tuples. For example:
|
||||||
|
[0, 1, 2, 10, 14, 15, 16] => [(0, 2), (10,), (14, 16)]"
|
||||||
|
"""
|
||||||
|
group = (
|
||||||
|
list(x) for _, x in groupby(sorted(array), lambda x, c=count(): next(c) - x)
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
(g[0], g[-1])[:len(g)] for g in group
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def array_to_string(array):
|
def array_to_string(array):
|
||||||
"""
|
"""
|
||||||
Generate an efficient, human-friendly string from a set of integers. Intended for use with ArrayField.
|
Generate an efficient, human-friendly string from a set of integers. Intended for use with ArrayField.
|
||||||
For example:
|
For example:
|
||||||
[0, 1, 2, 10, 14, 15, 16] => "0-2, 10, 14-16"
|
[0, 1, 2, 10, 14, 15, 16] => "0-2, 10, 14-16"
|
||||||
"""
|
"""
|
||||||
group = (list(x) for _, x in groupby(sorted(array), lambda x, c=count(): next(c) - x))
|
ret = []
|
||||||
return ', '.join('-'.join(map(str, (g[0], g[-1])[:len(g)])) for g in group)
|
ranges = array_to_ranges(array)
|
||||||
|
for value in ranges:
|
||||||
|
if len(value) == 1:
|
||||||
|
ret.append(str(value[0]))
|
||||||
|
else:
|
||||||
|
ret.append(f'{value[0]}-{value[1]}')
|
||||||
|
return ', '.join(ret)
|
||||||
|
|
||||||
|
|
||||||
def content_type_name(ct):
|
def content_type_name(ct):
|
||||||
|
@ -5,6 +5,7 @@ from dcim.api.serializers import NestedInterfaceSerializer
|
|||||||
from ipam.api.serializers import NestedVLANSerializer
|
from ipam.api.serializers import NestedVLANSerializer
|
||||||
from netbox.api import ChoiceField
|
from netbox.api import ChoiceField
|
||||||
from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer
|
from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer
|
||||||
|
from tenancy.api.nested_serializers import NestedTenantSerializer
|
||||||
from wireless.choices import *
|
from wireless.choices import *
|
||||||
from wireless.models import *
|
from wireless.models import *
|
||||||
from .nested_serializers import *
|
from .nested_serializers import *
|
||||||
@ -33,14 +34,15 @@ class WirelessLANSerializer(NetBoxModelSerializer):
|
|||||||
url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslan-detail')
|
url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslan-detail')
|
||||||
group = NestedWirelessLANGroupSerializer(required=False, allow_null=True)
|
group = NestedWirelessLANGroupSerializer(required=False, allow_null=True)
|
||||||
vlan = NestedVLANSerializer(required=False, allow_null=True)
|
vlan = NestedVLANSerializer(required=False, allow_null=True)
|
||||||
|
tenant = NestedTenantSerializer(required=False, allow_null=True)
|
||||||
auth_type = ChoiceField(choices=WirelessAuthTypeChoices, required=False, allow_blank=True)
|
auth_type = ChoiceField(choices=WirelessAuthTypeChoices, required=False, allow_blank=True)
|
||||||
auth_cipher = ChoiceField(choices=WirelessAuthCipherChoices, required=False, allow_blank=True)
|
auth_cipher = ChoiceField(choices=WirelessAuthCipherChoices, required=False, allow_blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = WirelessLAN
|
model = WirelessLAN
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display', 'ssid', 'description', 'group', 'vlan', 'auth_type', 'auth_cipher', 'auth_psk',
|
'id', 'url', 'display', 'ssid', 'description', 'group', 'vlan', 'tenant', 'auth_type', 'auth_cipher',
|
||||||
'description', 'tags', 'custom_fields', 'created', 'last_updated',
|
'auth_psk', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -49,12 +51,13 @@ class WirelessLinkSerializer(NetBoxModelSerializer):
|
|||||||
status = ChoiceField(choices=LinkStatusChoices, required=False)
|
status = ChoiceField(choices=LinkStatusChoices, required=False)
|
||||||
interface_a = NestedInterfaceSerializer()
|
interface_a = NestedInterfaceSerializer()
|
||||||
interface_b = NestedInterfaceSerializer()
|
interface_b = NestedInterfaceSerializer()
|
||||||
|
tenant = NestedTenantSerializer(required=False, allow_null=True)
|
||||||
auth_type = ChoiceField(choices=WirelessAuthTypeChoices, required=False, allow_blank=True)
|
auth_type = ChoiceField(choices=WirelessAuthTypeChoices, required=False, allow_blank=True)
|
||||||
auth_cipher = ChoiceField(choices=WirelessAuthCipherChoices, required=False, allow_blank=True)
|
auth_cipher = ChoiceField(choices=WirelessAuthCipherChoices, required=False, allow_blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = WirelessLink
|
model = WirelessLink
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display', 'interface_a', 'interface_b', 'ssid', 'status', 'description', 'auth_type',
|
'id', 'url', 'display', 'interface_a', 'interface_b', 'ssid', 'status', 'tenant', 'auth_type',
|
||||||
'auth_cipher', 'auth_psk', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
|
'auth_cipher', 'auth_psk', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||||
]
|
]
|
||||||
|
@ -27,12 +27,12 @@ class WirelessLANGroupViewSet(NetBoxModelViewSet):
|
|||||||
|
|
||||||
|
|
||||||
class WirelessLANViewSet(NetBoxModelViewSet):
|
class WirelessLANViewSet(NetBoxModelViewSet):
|
||||||
queryset = WirelessLAN.objects.prefetch_related('vlan', 'tags')
|
queryset = WirelessLAN.objects.prefetch_related('vlan', 'tenant', 'tags')
|
||||||
serializer_class = serializers.WirelessLANSerializer
|
serializer_class = serializers.WirelessLANSerializer
|
||||||
filterset_class = filtersets.WirelessLANFilterSet
|
filterset_class = filtersets.WirelessLANFilterSet
|
||||||
|
|
||||||
|
|
||||||
class WirelessLinkViewSet(NetBoxModelViewSet):
|
class WirelessLinkViewSet(NetBoxModelViewSet):
|
||||||
queryset = WirelessLink.objects.prefetch_related('interface_a', 'interface_b', 'tags')
|
queryset = WirelessLink.objects.prefetch_related('interface_a', 'interface_b', 'tenant', 'tags')
|
||||||
serializer_class = serializers.WirelessLinkSerializer
|
serializer_class = serializers.WirelessLinkSerializer
|
||||||
filterset_class = filtersets.WirelessLinkFilterSet
|
filterset_class = filtersets.WirelessLinkFilterSet
|
||||||
|
@ -4,6 +4,7 @@ from django.db.models import Q
|
|||||||
from dcim.choices import LinkStatusChoices
|
from dcim.choices import LinkStatusChoices
|
||||||
from ipam.models import VLAN
|
from ipam.models import VLAN
|
||||||
from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
|
from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
|
||||||
|
from tenancy.filtersets import TenancyFilterSet
|
||||||
from utilities.filters import MultiValueNumberFilter, TreeNodeMultipleChoiceFilter
|
from utilities.filters import MultiValueNumberFilter, TreeNodeMultipleChoiceFilter
|
||||||
from .choices import *
|
from .choices import *
|
||||||
from .models import *
|
from .models import *
|
||||||
@ -30,7 +31,7 @@ class WirelessLANGroupFilterSet(OrganizationalModelFilterSet):
|
|||||||
fields = ['id', 'name', 'slug', 'description']
|
fields = ['id', 'name', 'slug', 'description']
|
||||||
|
|
||||||
|
|
||||||
class WirelessLANFilterSet(NetBoxModelFilterSet):
|
class WirelessLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||||
group_id = TreeNodeMultipleChoiceFilter(
|
group_id = TreeNodeMultipleChoiceFilter(
|
||||||
queryset=WirelessLANGroup.objects.all(),
|
queryset=WirelessLANGroup.objects.all(),
|
||||||
field_name='group',
|
field_name='group',
|
||||||
@ -66,7 +67,7 @@ class WirelessLANFilterSet(NetBoxModelFilterSet):
|
|||||||
return queryset.filter(qs_filter)
|
return queryset.filter(qs_filter)
|
||||||
|
|
||||||
|
|
||||||
class WirelessLinkFilterSet(NetBoxModelFilterSet):
|
class WirelessLinkFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||||
interface_a_id = MultiValueNumberFilter()
|
interface_a_id = MultiValueNumberFilter()
|
||||||
interface_b_id = MultiValueNumberFilter()
|
interface_b_id = MultiValueNumberFilter()
|
||||||
status = django_filters.MultipleChoiceFilter(
|
status = django_filters.MultipleChoiceFilter(
|
||||||
|
@ -3,6 +3,7 @@ from django import forms
|
|||||||
from dcim.choices import LinkStatusChoices
|
from dcim.choices import LinkStatusChoices
|
||||||
from ipam.models import VLAN
|
from ipam.models import VLAN
|
||||||
from netbox.forms import NetBoxModelBulkEditForm
|
from netbox.forms import NetBoxModelBulkEditForm
|
||||||
|
from tenancy.models import Tenant
|
||||||
from utilities.forms import add_blank_choice, DynamicModelChoiceField
|
from utilities.forms import add_blank_choice, DynamicModelChoiceField
|
||||||
from wireless.choices import *
|
from wireless.choices import *
|
||||||
from wireless.constants import SSID_MAX_LENGTH
|
from wireless.constants import SSID_MAX_LENGTH
|
||||||
@ -47,6 +48,10 @@ class WirelessLANBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False,
|
required=False,
|
||||||
label='SSID'
|
label='SSID'
|
||||||
)
|
)
|
||||||
|
tenant = DynamicModelChoiceField(
|
||||||
|
queryset=Tenant.objects.all(),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
description = forms.CharField(
|
description = forms.CharField(
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
@ -65,11 +70,11 @@ class WirelessLANBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
|
|
||||||
model = WirelessLAN
|
model = WirelessLAN
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, ('group', 'vlan', 'ssid', 'description')),
|
(None, ('group', 'ssid', 'vlan', 'tenant', 'description')),
|
||||||
('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')),
|
('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')),
|
||||||
)
|
)
|
||||||
nullable_fields = (
|
nullable_fields = (
|
||||||
'ssid', 'group', 'vlan', 'description', 'auth_type', 'auth_cipher', 'auth_psk',
|
'ssid', 'group', 'vlan', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -83,6 +88,10 @@ class WirelessLinkBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
choices=add_blank_choice(LinkStatusChoices),
|
choices=add_blank_choice(LinkStatusChoices),
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
|
tenant = DynamicModelChoiceField(
|
||||||
|
queryset=Tenant.objects.all(),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
description = forms.CharField(
|
description = forms.CharField(
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
@ -101,9 +110,9 @@ class WirelessLinkBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
|
|
||||||
model = WirelessLink
|
model = WirelessLink
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, ('ssid', 'status', 'description')),
|
(None, ('ssid', 'status', 'tenant', 'description')),
|
||||||
('Authentication', ('auth_type', 'auth_cipher', 'auth_psk'))
|
('Authentication', ('auth_type', 'auth_cipher', 'auth_psk'))
|
||||||
)
|
)
|
||||||
nullable_fields = (
|
nullable_fields = (
|
||||||
'ssid', 'description', 'auth_type', 'auth_cipher', 'auth_psk',
|
'ssid', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk',
|
||||||
)
|
)
|
||||||
|
@ -2,6 +2,7 @@ from dcim.choices import LinkStatusChoices
|
|||||||
from dcim.models import Interface
|
from dcim.models import Interface
|
||||||
from ipam.models import VLAN
|
from ipam.models import VLAN
|
||||||
from netbox.forms import NetBoxModelCSVForm
|
from netbox.forms import NetBoxModelCSVForm
|
||||||
|
from tenancy.models import Tenant
|
||||||
from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField
|
from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField
|
||||||
from wireless.choices import *
|
from wireless.choices import *
|
||||||
from wireless.models import *
|
from wireless.models import *
|
||||||
@ -40,6 +41,12 @@ class WirelessLANCSVForm(NetBoxModelCSVForm):
|
|||||||
to_field_name='name',
|
to_field_name='name',
|
||||||
help_text='Bridged VLAN'
|
help_text='Bridged VLAN'
|
||||||
)
|
)
|
||||||
|
tenant = CSVModelChoiceField(
|
||||||
|
queryset=Tenant.objects.all(),
|
||||||
|
required=False,
|
||||||
|
to_field_name='name',
|
||||||
|
help_text='Assigned tenant'
|
||||||
|
)
|
||||||
auth_type = CSVChoiceField(
|
auth_type = CSVChoiceField(
|
||||||
choices=WirelessAuthTypeChoices,
|
choices=WirelessAuthTypeChoices,
|
||||||
required=False,
|
required=False,
|
||||||
@ -53,7 +60,7 @@ class WirelessLANCSVForm(NetBoxModelCSVForm):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = WirelessLAN
|
model = WirelessLAN
|
||||||
fields = ('ssid', 'group', 'description', 'vlan', 'auth_type', 'auth_cipher', 'auth_psk')
|
fields = ('ssid', 'group', 'vlan', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk')
|
||||||
|
|
||||||
|
|
||||||
class WirelessLinkCSVForm(NetBoxModelCSVForm):
|
class WirelessLinkCSVForm(NetBoxModelCSVForm):
|
||||||
@ -67,6 +74,12 @@ class WirelessLinkCSVForm(NetBoxModelCSVForm):
|
|||||||
interface_b = CSVModelChoiceField(
|
interface_b = CSVModelChoiceField(
|
||||||
queryset=Interface.objects.all()
|
queryset=Interface.objects.all()
|
||||||
)
|
)
|
||||||
|
tenant = CSVModelChoiceField(
|
||||||
|
queryset=Tenant.objects.all(),
|
||||||
|
required=False,
|
||||||
|
to_field_name='name',
|
||||||
|
help_text='Assigned tenant'
|
||||||
|
)
|
||||||
auth_type = CSVChoiceField(
|
auth_type = CSVChoiceField(
|
||||||
choices=WirelessAuthTypeChoices,
|
choices=WirelessAuthTypeChoices,
|
||||||
required=False,
|
required=False,
|
||||||
@ -80,4 +93,6 @@ class WirelessLinkCSVForm(NetBoxModelCSVForm):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = WirelessLink
|
model = WirelessLink
|
||||||
fields = ('interface_a', 'interface_b', 'ssid', 'description', 'auth_type', 'auth_cipher', 'auth_psk')
|
fields = (
|
||||||
|
'interface_a', 'interface_b', 'ssid', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk',
|
||||||
|
)
|
||||||
|
@ -3,6 +3,7 @@ from django.utils.translation import gettext as _
|
|||||||
|
|
||||||
from dcim.choices import LinkStatusChoices
|
from dcim.choices import LinkStatusChoices
|
||||||
from netbox.forms import NetBoxModelFilterSetForm
|
from netbox.forms import NetBoxModelFilterSetForm
|
||||||
|
from tenancy.forms import TenancyFilterForm
|
||||||
from utilities.forms import add_blank_choice, DynamicModelMultipleChoiceField, StaticSelect, TagFilterField
|
from utilities.forms import add_blank_choice, DynamicModelMultipleChoiceField, StaticSelect, TagFilterField
|
||||||
from wireless.choices import *
|
from wireless.choices import *
|
||||||
from wireless.models import *
|
from wireless.models import *
|
||||||
@ -24,11 +25,12 @@ class WirelessLANGroupFilterForm(NetBoxModelFilterSetForm):
|
|||||||
tag = TagFilterField(model)
|
tag = TagFilterField(model)
|
||||||
|
|
||||||
|
|
||||||
class WirelessLANFilterForm(NetBoxModelFilterSetForm):
|
class WirelessLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||||
model = WirelessLAN
|
model = WirelessLAN
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, ('q', 'tag')),
|
(None, ('q', 'tag')),
|
||||||
('Attributes', ('ssid', 'group_id',)),
|
('Attributes', ('ssid', 'group_id',)),
|
||||||
|
('Tenant', ('tenant_group_id', 'tenant_id')),
|
||||||
('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')),
|
('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')),
|
||||||
)
|
)
|
||||||
ssid = forms.CharField(
|
ssid = forms.CharField(
|
||||||
@ -57,8 +59,14 @@ class WirelessLANFilterForm(NetBoxModelFilterSetForm):
|
|||||||
tag = TagFilterField(model)
|
tag = TagFilterField(model)
|
||||||
|
|
||||||
|
|
||||||
class WirelessLinkFilterForm(NetBoxModelFilterSetForm):
|
class WirelessLinkFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||||
model = WirelessLink
|
model = WirelessLink
|
||||||
|
fieldsets = (
|
||||||
|
(None, ('q', 'tag')),
|
||||||
|
('Attributes', ('ssid', 'status',)),
|
||||||
|
('Tenant', ('tenant_group_id', 'tenant_id')),
|
||||||
|
('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')),
|
||||||
|
)
|
||||||
ssid = forms.CharField(
|
ssid = forms.CharField(
|
||||||
required=False,
|
required=False,
|
||||||
label='SSID'
|
label='SSID'
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
from dcim.models import Device, Interface, Location, Region, Site, SiteGroup
|
from dcim.models import Device, Interface, Location, Region, Site, SiteGroup
|
||||||
from ipam.models import VLAN, VLANGroup
|
from ipam.models import VLAN, VLANGroup
|
||||||
from netbox.forms import NetBoxModelForm
|
from netbox.forms import NetBoxModelForm
|
||||||
|
from tenancy.forms import TenancyForm
|
||||||
from utilities.forms import DynamicModelChoiceField, SlugField, StaticSelect
|
from utilities.forms import DynamicModelChoiceField, SlugField, StaticSelect
|
||||||
from wireless.models import *
|
from wireless.models import *
|
||||||
|
|
||||||
@ -25,7 +26,7 @@ class WirelessLANGroupForm(NetBoxModelForm):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class WirelessLANForm(NetBoxModelForm):
|
class WirelessLANForm(TenancyForm, NetBoxModelForm):
|
||||||
group = DynamicModelChoiceField(
|
group = DynamicModelChoiceField(
|
||||||
queryset=WirelessLANGroup.objects.all(),
|
queryset=WirelessLANGroup.objects.all(),
|
||||||
required=False
|
required=False
|
||||||
@ -79,14 +80,15 @@ class WirelessLANForm(NetBoxModelForm):
|
|||||||
fieldsets = (
|
fieldsets = (
|
||||||
('Wireless LAN', ('ssid', 'group', 'description', 'tags')),
|
('Wireless LAN', ('ssid', 'group', 'description', 'tags')),
|
||||||
('VLAN', ('region', 'site_group', 'site', 'vlan_group', 'vlan',)),
|
('VLAN', ('region', 'site_group', 'site', 'vlan_group', 'vlan',)),
|
||||||
|
('Tenancy', ('tenant_group', 'tenant')),
|
||||||
('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')),
|
('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')),
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = WirelessLAN
|
model = WirelessLAN
|
||||||
fields = [
|
fields = [
|
||||||
'ssid', 'group', 'description', 'region', 'site_group', 'site', 'vlan_group', 'vlan', 'auth_type',
|
'ssid', 'group', 'description', 'region', 'site_group', 'site', 'vlan_group', 'vlan', 'tenant_group',
|
||||||
'auth_cipher', 'auth_psk', 'tags',
|
'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'tags',
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
'auth_type': StaticSelect,
|
'auth_type': StaticSelect,
|
||||||
@ -94,7 +96,7 @@ class WirelessLANForm(NetBoxModelForm):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class WirelessLinkForm(NetBoxModelForm):
|
class WirelessLinkForm(TenancyForm, NetBoxModelForm):
|
||||||
site_a = DynamicModelChoiceField(
|
site_a = DynamicModelChoiceField(
|
||||||
queryset=Site.objects.all(),
|
queryset=Site.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
@ -180,6 +182,7 @@ class WirelessLinkForm(NetBoxModelForm):
|
|||||||
('Side A', ('site_a', 'location_a', 'device_a', 'interface_a')),
|
('Side A', ('site_a', 'location_a', 'device_a', 'interface_a')),
|
||||||
('Side B', ('site_b', 'location_b', 'device_b', 'interface_b')),
|
('Side B', ('site_b', 'location_b', 'device_b', 'interface_b')),
|
||||||
('Link', ('status', 'ssid', 'description', 'tags')),
|
('Link', ('status', 'ssid', 'description', 'tags')),
|
||||||
|
('Tenancy', ('tenant_group', 'tenant')),
|
||||||
('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')),
|
('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -187,7 +190,7 @@ class WirelessLinkForm(NetBoxModelForm):
|
|||||||
model = WirelessLink
|
model = WirelessLink
|
||||||
fields = [
|
fields = [
|
||||||
'site_a', 'location_a', 'device_a', 'interface_a', 'site_b', 'location_b', 'device_b', 'interface_b',
|
'site_a', 'location_a', 'device_a', 'interface_a', 'site_b', 'location_b', 'device_b', 'interface_b',
|
||||||
'status', 'ssid', 'description', 'auth_type', 'auth_cipher', 'auth_psk', 'tags',
|
'status', 'ssid', 'tenant_group', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk', 'tags',
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
'status': StaticSelect,
|
'status': StaticSelect,
|
||||||
|
25
netbox/wireless/migrations/0004_wireless_tenancy.py
Normal file
25
netbox/wireless/migrations/0004_wireless_tenancy.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# Generated by Django 4.0.5 on 2022-06-27 13:44
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('tenancy', '0007_contact_link'),
|
||||||
|
('wireless', '0003_created_datetimefield'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='wirelesslan',
|
||||||
|
name='tenant',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='wireless_lans', to='tenancy.tenant'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='wirelesslink',
|
||||||
|
name='tenant',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='wireless_links', to='tenancy.tenant'),
|
||||||
|
),
|
||||||
|
]
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user