Merge branch 'feature' into 9102-cabling

This commit is contained in:
jeremystretch 2022-06-27 12:12:34 -04:00
commit 25ed3390cb
106 changed files with 1384 additions and 352 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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',
] ]

View File

@ -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():

View File

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

View File

@ -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',
] ]

View File

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

View File

@ -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(),
} }

View 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),
),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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',
) )

View File

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

View File

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

View File

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

View File

@ -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(),

View File

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

View 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),
),
]

View 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),
),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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=['*'])

View File

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

View File

@ -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',
] ]

View File

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

View File

@ -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(),

View File

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

View File

@ -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',
) )

View 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'),
),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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):
""" """

View File

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

View File

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

Binary file not shown.

View File

@ -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);
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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(),

View 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),
),
]

View File

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

View 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

View File

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

View File

@ -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',
] ]

View File

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

View File

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

View File

@ -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',
) )

View File

@ -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',
)

View File

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

View File

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

View 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