Merge branch 'feature' into 14731-plugins-catalog

This commit is contained in:
Jeremy Stretch 2024-07-16 12:22:08 -04:00
commit dbcc29ebc7
60 changed files with 1953 additions and 463 deletions

View File

@ -56,6 +56,10 @@ A site typically represents a building within a region and/or site group. Each s
A location can be any logical subdivision within a building, such as a floor or room. Like regions and site groups, locations can be nested into a self-recursive hierarchy for maximum flexibility. And like sites, each location has an operational status assigned to it.
## Rack Types
A rack type represents a unique specification of a rack which exists in the real world. Each rack type can be setup with weight, height, and unit ordering. New racks of this type can then be created in NetBox, and any associated specifications will be automatically replicated from the device type.
## Racks
Finally, NetBox models each equipment rack as a discrete object within a site and location. These are physical objects into which devices are installed. Each rack can be assigned an operational status, type, facility ID, and other attributes related to inventory tracking. Each rack also must define a height (in rack units) and width, and may optionally specify its physical dimensions.

View File

@ -20,6 +20,10 @@ The [location](./location.md) within a site where the rack has been installed (o
The rack's name or identifier. Must be unique to the rack's location, if assigned.
### Rack Type
The [physical type](./racktype.md) of this rack. The rack type defines physical attributes such as height and weight.
### Status
Operational status.
@ -43,44 +47,5 @@ The unique physical serial number assigned to this rack.
A unique, locally-administered label used to identify hardware resources.
### Type
A rack can be designated as one of the following types:
* 2-post frame
* 4-post frame
* 4-post cabinet
* Wall-mounted frame
* Wall-mounted cabinet
### Width
The canonical distance between the two vertical rails on a face. (This is typically 19 inches, however other standard widths exist.)
### Height
The height of the rack, measured in units.
### Starting Unit
The number of the numerically lowest unit in the rack. This value defaults to one, but may be higher in certain situations. For example, you may want to model only a select range of units within a shared physical rack (e.g. U13 through U24).
### Outer Dimensions
The external width and depth of the rack can be tracked to aid in floorplan calculations. These measurements must be designated in either millimeters or inches.
### Mounting Depth
The maximum depth of a mounted device that the rack can accommodate, in millimeters. For four-post frames or cabinets, this is the horizontal distance between the front and rear vertical rails. (Note that this measurement does _not_ include space between the rails and the cabinet doors.)
### Weight
The numeric weight of the rack, including a unit designation (e.g. 10 kilograms or 20 pounds).
### Maximum Weight
The maximum total weight capacity for all installed devices, inclusive of the rack itself.
### Descending Units
If selected, the rack's elevation will display unit 1 at the top of the rack. (Most racks use ascending numbering, with unit 1 assigned to the bottommost position.)
!!! note
Some additional fields pertaining to physical attributes such as height and weight can also be defined on each rack, but should generally be defined instead on the [rack type](./racktype.md).

View File

@ -0,0 +1,57 @@
# Rack Types
A rack type defines the physical characteristics of a particular model of [rack](./rack.md).
## Fields
### Manufacturer
The [manufacturer](./manufacturer.md) which produces this type of rack.
### Name
The unique name of the rack type.
### Form Factor
A rack can be designated as one of the following form factors:
* 2-post frame
* 4-post frame
* 4-post cabinet
* Wall-mounted frame
* Wall-mounted cabinet
### Width
The canonical distance between the two vertical rails on a face. (This is typically 19 inches, however other standard widths exist.)
### Height
The height of the rack, measured in units.
### Starting Unit
The number of the numerically lowest unit in the rack. This value defaults to one, but may be higher in certain situations. For example, you may want to model only a select range of units within a shared physical rack (e.g. U13 through U24).
### Outer Dimensions
The external width and depth of the rack can be tracked to aid in floorplan calculations. These measurements must be designated in either millimeters or inches.
### Mounting Depth
The maximum depth of a mounted device that the rack can accommodate, in millimeters. For four-post frames or cabinets, this is the horizontal distance between the front and rear vertical rails. (Note that this measurement does _not_ include space between the rails and the cabinet doors.)
### Weight
The numeric weight of the rack, including a unit designation (e.g. 10 kilograms or 20 pounds).
### Maximum Weight
The maximum total weight capacity for all installed devices, inclusive of the rack itself.
### Descending Units
If selected, the rack's elevation will display unit 1 at the top of the rack. (Most racks use ascending numbering, with unit 1 assigned to the bottommost position.)

View File

@ -14,9 +14,9 @@ A unique human-friendly name.
A unique URL-friendly identifier. (This value can be used for filtering.)
### Minimum & Maximum VLAN IDs
### VLAN ID Ranges
A minimum and maximum child VLAN ID must be set for each group. (These default to 1 and 4094 respectively.) VLANs created within a group must have a VID that falls between these values (inclusive).
The set of VLAN IDs which are encompassed by the group. By default, this will be the entire range of valid IEEE 802.1Q VLAN IDs (1 to 4094, inclusive). VLANs created within a group must have a VID that falls within one of these ranges. Ranges may not overlap.
### Scope

View File

@ -206,6 +206,7 @@ nav:
- Rack: 'models/dcim/rack.md'
- RackReservation: 'models/dcim/rackreservation.md'
- RackRole: 'models/dcim/rackrole.md'
- RackType: 'models/dcim/racktype.md'
- RearPort: 'models/dcim/rearport.md'
- RearPortTemplate: 'models/dcim/rearporttemplate.md'
- Region: 'models/dcim/region.md'

View File

@ -3,12 +3,13 @@ from rest_framework import serializers
from dcim.choices import *
from dcim.constants import *
from dcim.models import Rack, RackReservation, RackRole
from dcim.models import Rack, RackReservation, RackRole, RackType
from netbox.api.fields import ChoiceField, RelatedObjectCountField
from netbox.api.serializers import NetBoxModelSerializer
from netbox.config import ConfigItem
from tenancy.api.serializers_.tenants import TenantSerializer
from users.api.serializers_.users import UserSerializer
from .manufacturers import ManufacturerSerializer
from .sites import LocationSerializer, SiteSerializer
__all__ = (
@ -16,6 +17,7 @@ __all__ = (
'RackReservationSerializer',
'RackRoleSerializer',
'RackSerializer',
'RackTypeSerializer',
)
@ -33,18 +35,84 @@ class RackRoleSerializer(NetBoxModelSerializer):
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count')
class RackSerializer(NetBoxModelSerializer):
site = SiteSerializer(nested=True)
location = LocationSerializer(nested=True, required=False, allow_null=True, default=None)
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
status = ChoiceField(choices=RackStatusChoices, required=False)
role = RackRoleSerializer(nested=True, required=False, allow_null=True)
type = ChoiceField(choices=RackTypeChoices, allow_blank=True, required=False, allow_null=True)
facility_id = serializers.CharField(max_length=50, allow_blank=True, allow_null=True, label=_('Facility ID'),
default=None)
width = ChoiceField(choices=RackWidthChoices, required=False)
outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False, allow_null=True)
weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True)
class RackBaseSerializer(NetBoxModelSerializer):
form_factor = ChoiceField(
choices=RackFormFactorChoices,
allow_blank=True,
required=False,
allow_null=True
)
width = ChoiceField(
choices=RackWidthChoices,
required=False
)
outer_unit = ChoiceField(
choices=RackDimensionUnitChoices,
allow_blank=True,
required=False,
allow_null=True
)
weight_unit = ChoiceField(
choices=WeightUnitChoices,
allow_blank=True,
required=False,
allow_null=True
)
class RackTypeSerializer(RackBaseSerializer):
manufacturer = ManufacturerSerializer(
nested=True
)
class Meta:
model = RackType
fields = [
'id', 'url', 'display_url', 'display', 'manufacturer', 'name', 'slug', 'description', 'form_factor',
'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'weight',
'max_weight', 'weight_unit', 'mounting_depth', 'description', 'comments', 'tags', 'custom_fields',
'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'manufacturer', 'name', 'slug', 'description')
class RackSerializer(RackBaseSerializer):
site = SiteSerializer(
nested=True
)
location = LocationSerializer(
nested=True,
required=False,
allow_null=True,
default=None
)
tenant = TenantSerializer(
nested=True,
required=False,
allow_null=True
)
status = ChoiceField(
choices=RackStatusChoices,
required=False
)
role = RackRoleSerializer(
nested=True,
required=False,
allow_null=True
)
facility_id = serializers.CharField(
max_length=50,
allow_blank=True,
allow_null=True,
label=_('Facility ID'),
default=None
)
rack_type = RackTypeSerializer(
nested=True,
required=False,
allow_null=True,
default=None
)
# Related object counts
device_count = RelatedObjectCountField('devices')
@ -54,9 +122,10 @@ class RackSerializer(NetBoxModelSerializer):
model = Rack
fields = [
'id', 'url', 'display_url', 'display', 'name', 'facility_id', 'site', 'location', 'tenant', 'status',
'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'starting_unit', 'weight', 'max_weight',
'weight_unit', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'description',
'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count',
'role', 'serial', 'asset_tag', 'rack_type', 'form_factor', 'width', 'u_height', 'starting_unit', 'weight',
'max_weight', 'weight_unit', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth',
'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
'powerfeed_count',
]
brief_fields = ('id', 'url', 'display', 'name', 'description', 'device_count')

View File

@ -12,6 +12,7 @@ router.register('sites', views.SiteViewSet)
# Racks
router.register('locations', views.LocationViewSet)
router.register('rack-types', views.RackTypeViewSet)
router.register('rack-roles', views.RackRoleViewSet)
router.register('racks', views.RackViewSet)
router.register('rack-reservations', views.RackReservationViewSet)

View File

@ -161,6 +161,16 @@ class RackRoleViewSet(NetBoxModelViewSet):
filterset_class = filtersets.RackRoleFilterSet
#
# Rack Types
#
class RackTypeViewSet(NetBoxModelViewSet):
queryset = RackType.objects.all()
serializer_class = serializers.RackTypeSerializer
filterset_class = filtersets.RackTypeFilterSet
#
# Racks
#

View File

@ -51,7 +51,7 @@ class LocationStatusChoices(ChoiceSet):
# Racks
#
class RackTypeChoices(ChoiceSet):
class RackFormFactorChoices(ChoiceSet):
TYPE_2POST = '2-post-frame'
TYPE_4POST = '4-post-frame'

View File

@ -69,6 +69,7 @@ __all__ = (
'RackFilterSet',
'RackReservationFilterSet',
'RackRoleFilterSet',
'RackTypeFilterSet',
'RearPortFilterSet',
'RearPortTemplateFilterSet',
'RegionFilterSet',
@ -289,6 +290,41 @@ class RackRoleFilterSet(OrganizationalModelFilterSet):
fields = ('id', 'name', 'slug', 'color', 'description')
class RackTypeFilterSet(NetBoxModelFilterSet):
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
queryset=Manufacturer.objects.all(),
label=_('Manufacturer (ID)'),
)
manufacturer = django_filters.ModelMultipleChoiceFilter(
field_name='manufacturer__slug',
queryset=Manufacturer.objects.all(),
to_field_name='slug',
label=_('Manufacturer (slug)'),
)
form_factor = django_filters.MultipleChoiceFilter(
choices=RackFormFactorChoices
)
width = django_filters.MultipleChoiceFilter(
choices=RackWidthChoices
)
class Meta:
model = RackType
fields = (
'id', 'name', 'slug', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit',
'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description',
)
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(description__icontains=value) |
Q(comments__icontains=value)
)
class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
@ -339,12 +375,22 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe
to_field_name='slug',
label=_('Location (slug)'),
)
rack_type = django_filters.ModelMultipleChoiceFilter(
field_name='rack_type__slug',
queryset=RackType.objects.all(),
to_field_name='slug',
label=_('Rack type (slug)'),
)
rack_type_id = django_filters.ModelMultipleChoiceFilter(
queryset=RackType.objects.all(),
label=_('Rack type (ID)'),
)
status = django_filters.MultipleChoiceFilter(
choices=RackStatusChoices,
null_value=None
)
type = django_filters.MultipleChoiceFilter(
choices=RackTypeChoices
form_factor = django_filters.MultipleChoiceFilter(
choices=RackFormFactorChoices
)
width = django_filters.MultipleChoiceFilter(
choices=RackWidthChoices

View File

@ -13,7 +13,7 @@ from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant
from utilities.forms import BulkEditForm, add_blank_choice, form_from_model
from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
from utilities.forms.rendering import FieldSet
from utilities.forms.rendering import FieldSet, InlineFields
from utilities.forms.widgets import BulkEditNullBooleanSelect, NumberWithOptions
from wireless.models import WirelessLAN, WirelessLANGroup
from wireless.choices import WirelessRoleChoices
@ -52,6 +52,7 @@ __all__ = (
'RackBulkEditForm',
'RackReservationBulkEditForm',
'RackRoleBulkEditForm',
'RackTypeBulkEditForm',
'RearPortBulkEditForm',
'RearPortTemplateBulkEditForm',
'RegionBulkEditForm',
@ -218,6 +219,97 @@ class RackRoleBulkEditForm(NetBoxModelBulkEditForm):
nullable_fields = ('color', 'description')
class RackTypeBulkEditForm(NetBoxModelBulkEditForm):
manufacturer = DynamicModelChoiceField(
label=_('Manufacturer'),
queryset=Manufacturer.objects.all(),
required=False
)
form_factor = forms.ChoiceField(
label=_('Form factor'),
choices=add_blank_choice(RackFormFactorChoices),
required=False
)
width = forms.ChoiceField(
label=_('Width'),
choices=add_blank_choice(RackWidthChoices),
required=False
)
u_height = forms.IntegerField(
required=False,
label=_('Height (U)')
)
starting_unit = forms.IntegerField(
required=False,
min_value=1
)
desc_units = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect,
label=_('Descending units')
)
outer_width = forms.IntegerField(
label=_('Outer width'),
required=False,
min_value=1
)
outer_depth = forms.IntegerField(
label=_('Outer depth'),
required=False,
min_value=1
)
outer_unit = forms.ChoiceField(
label=_('Outer unit'),
choices=add_blank_choice(RackDimensionUnitChoices),
required=False
)
mounting_depth = forms.IntegerField(
label=_('Mounting depth'),
required=False,
min_value=1
)
weight = forms.DecimalField(
label=_('Weight'),
min_value=0,
required=False
)
max_weight = forms.IntegerField(
label=_('Max weight'),
min_value=0,
required=False
)
weight_unit = forms.ChoiceField(
label=_('Weight unit'),
choices=add_blank_choice(WeightUnitChoices),
required=False,
initial=''
)
description = forms.CharField(
label=_('Description'),
max_length=200,
required=False
)
comments = CommentField()
model = RackType
fieldsets = (
FieldSet('manufacturer', 'description', 'form_factor', name=_('Rack Type')),
FieldSet(
'width',
'u_height',
InlineFields('outer_width', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')),
InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')),
'mounting_depth',
name=_('Dimensions')
),
FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
)
nullable_fields = (
'outer_width', 'outer_depth', 'outer_unit', 'weight',
'max_weight', 'weight_unit', 'description', 'comments',
)
class RackBulkEditForm(NetBoxModelBulkEditForm):
region = DynamicModelChoiceField(
label=_('Region'),
@ -278,9 +370,9 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
max_length=50,
required=False
)
type = forms.ChoiceField(
label=_('Type'),
choices=add_blank_choice(RackTypeChoices),
form_factor = forms.ChoiceField(
label=_('Form factor'),
choices=add_blank_choice(RackFormFactorChoices),
required=False
)
width = forms.ChoiceField(
@ -345,8 +437,8 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
FieldSet('status', 'role', 'tenant', 'serial', 'asset_tag', 'description', name=_('Rack')),
FieldSet('region', 'site_group', 'site', 'location', name=_('Location')),
FieldSet(
'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth',
name=_('Hardware')
'form_factor', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit',
'mounting_depth', name=_('Hardware')
),
FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
)

View File

@ -45,6 +45,7 @@ __all__ = (
'RackImportForm',
'RackReservationImportForm',
'RackRoleImportForm',
'RackTypeImportForm',
'RearPortImportForm',
'RegionImportForm',
'SiteImportForm',
@ -176,6 +177,54 @@ class RackRoleImportForm(NetBoxModelImportForm):
fields = ('name', 'slug', 'color', 'description', 'tags')
class RackTypeImportForm(NetBoxModelImportForm):
manufacturer = forms.ModelChoiceField(
label=_('Manufacturer'),
queryset=Manufacturer.objects.all(),
to_field_name='name',
help_text=_('The manufacturer of this rack type')
)
form_factor = CSVChoiceField(
label=_('Type'),
choices=RackFormFactorChoices,
required=False,
help_text=_('Form factor')
)
starting_unit = forms.IntegerField(
required=False,
min_value=1,
help_text=_('The lowest-numbered position in the rack')
)
width = forms.ChoiceField(
label=_('Width'),
choices=RackWidthChoices,
help_text=_('Rail-to-rail width (in inches)')
)
outer_unit = CSVChoiceField(
label=_('Outer unit'),
choices=RackDimensionUnitChoices,
required=False,
help_text=_('Unit for outer dimensions')
)
weight_unit = CSVChoiceField(
label=_('Weight unit'),
choices=WeightUnitChoices,
required=False,
help_text=_('Unit for rack weights')
)
class Meta:
model = RackType
fields = (
'manufacturer', 'name', 'slug', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units',
'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit',
'description', 'comments', 'tags',
)
def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs)
class RackImportForm(NetBoxModelImportForm):
site = CSVModelChoiceField(
label=_('Site'),
@ -207,11 +256,11 @@ class RackImportForm(NetBoxModelImportForm):
to_field_name='name',
help_text=_('Name of assigned role')
)
type = CSVChoiceField(
form_factor = CSVChoiceField(
label=_('Type'),
choices=RackTypeChoices,
choices=RackFormFactorChoices,
required=False,
help_text=_('Rack type')
help_text=_('Form factor')
)
width = forms.ChoiceField(
label=_('Width'),
@ -234,7 +283,7 @@ class RackImportForm(NetBoxModelImportForm):
class Meta:
model = Rack
fields = (
'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag',
'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'form_factor', 'serial', 'asset_tag',
'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight',
'max_weight', 'weight_unit', 'description', 'comments', 'tags',
)

View File

@ -48,6 +48,7 @@ __all__ = (
'RackElevationFilterForm',
'RackReservationFilterForm',
'RackRoleFilterForm',
'RackTypeFilterForm',
'RearPortFilterForm',
'RegionFilterForm',
'SiteFilterForm',
@ -240,16 +241,77 @@ class RackRoleFilterForm(NetBoxModelFilterSetForm):
tag = TagFilterField(model)
class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
class RackBaseFilterForm(NetBoxModelFilterSetForm):
form_factor = forms.MultipleChoiceField(
label=_('Form factor'),
choices=RackFormFactorChoices,
required=False
)
width = forms.MultipleChoiceField(
label=_('Width'),
choices=RackWidthChoices,
required=False
)
u_height = forms.IntegerField(
required=False,
min_value=1
)
starting_unit = forms.IntegerField(
required=False,
min_value=1
)
desc_units = forms.NullBooleanField(
required=False,
label=_('Descending units'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
weight = forms.DecimalField(
label=_('Weight'),
required=False,
min_value=1
)
max_weight = forms.IntegerField(
label=_('Max weight'),
required=False,
min_value=1
)
weight_unit = forms.ChoiceField(
label=_('Weight unit'),
choices=add_blank_choice(WeightUnitChoices),
required=False
)
class RackTypeFilterForm(RackBaseFilterForm):
model = RackType
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('form_factor', 'width', 'u_height', name=_('Rack Type')),
FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
)
selector_fields = ('filter_id', 'q', 'manufacturer_id')
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
required=False,
label=_('Manufacturer')
)
tag = TagFilterField(model)
class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, RackBaseFilterForm):
model = Rack
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
FieldSet('status', 'role_id', name=_('Function')),
FieldSet('type', 'width', 'serial', 'asset_tag', name=_('Hardware')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
FieldSet('status', 'role_id', 'serial', 'asset_tag', name=_('Rack')),
FieldSet('form_factor', 'width', 'u_height', name=_('Rack Type')),
FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
)
selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'location_id')
region_id = DynamicModelMultipleChoiceField(
@ -284,16 +346,6 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
choices=RackStatusChoices,
required=False
)
type = forms.MultipleChoiceField(
label=_('Type'),
choices=RackTypeChoices,
required=False
)
width = forms.MultipleChoiceField(
label=_('Width'),
choices=RackWidthChoices,
required=False
)
role_id = DynamicModelMultipleChoiceField(
queryset=RackRole.objects.all(),
required=False,
@ -309,21 +361,6 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
required=False
)
tag = TagFilterField(model)
weight = forms.DecimalField(
label=_('Weight'),
required=False,
min_value=1
)
max_weight = forms.IntegerField(
label=_('Max weight'),
required=False,
min_value=1
)
weight_unit = forms.ChoiceField(
label=_('Weight unit'),
choices=add_blank_choice(WeightUnitChoices),
required=False
)
class RackElevationFilterForm(RackFilterForm):

View File

@ -11,7 +11,7 @@ from extras.models import ConfigTemplate
from ipam.models import ASN, IPAddress, VLAN, VLANGroup, VRF
from netbox.forms import NetBoxModelForm
from tenancy.forms import TenancyForm
from utilities.forms import add_blank_choice
from utilities.forms import add_blank_choice, get_field_value
from utilities.forms.fields import (
CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SlugField,
)
@ -57,6 +57,7 @@ __all__ = (
'RackForm',
'RackReservationForm',
'RackRoleForm',
'RackTypeForm',
'RearPortForm',
'RearPortTemplateForm',
'RegionForm',
@ -201,6 +202,34 @@ class RackRoleForm(NetBoxModelForm):
]
class RackTypeForm(NetBoxModelForm):
manufacturer = DynamicModelChoiceField(
label=_('Manufacturer'),
queryset=Manufacturer.objects.all()
)
comments = CommentField()
slug = SlugField()
fieldsets = (
FieldSet('manufacturer', 'name', 'slug', 'description', 'form_factor', 'tags', name=_('Rack Type')),
FieldSet(
'width', 'u_height',
InlineFields('outer_width', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')),
InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')),
'mounting_depth', name=_('Dimensions')
),
FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
)
class Meta:
model = RackType
fields = [
'manufacturer', 'name', 'slug', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units',
'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit',
'description', 'comments', 'tags',
]
class RackForm(TenancyForm, NetBoxModelForm):
site = DynamicModelChoiceField(
label=_('Site'),
@ -220,28 +249,54 @@ class RackForm(TenancyForm, NetBoxModelForm):
queryset=RackRole.objects.all(),
required=False
)
rack_type = DynamicModelChoiceField(
label=_('Rack Type'),
queryset=RackType.objects.all(),
required=False,
help_text=_("Select a pre-defined rack type, or set physical characteristics below.")
)
comments = CommentField()
fieldsets = (
FieldSet('site', 'location', 'name', 'status', 'role', 'description', 'tags', name=_('Rack')),
FieldSet('site', 'location', 'name', 'status', 'role', 'rack_type', 'description', 'tags', name=_('Rack')),
FieldSet('facility_id', 'serial', 'asset_tag', name=_('Inventory Control')),
FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
FieldSet(
'type', 'width', 'starting_unit', 'u_height',
InlineFields('outer_width', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')),
InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')),
'mounting_depth', 'desc_units', name=_('Dimensions')
),
)
class Meta:
model = Rack
fields = [
'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial',
'asset_tag', 'type', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth',
'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags',
'asset_tag', 'rack_type', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width',
'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description',
'comments', 'tags',
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Mimic HTMXSelect()
self.fields['rack_type'].widget.attrs.update({
'hx-get': '.',
'hx-include': '#form_fields',
'hx-target': '#form_fields',
})
# Omit RackType-defined fields if rack_type is set
if get_field_value(self, 'rack_type'):
for field_name in Rack.RACKTYPE_FIELDS:
del self.fields[field_name]
else:
self.fieldsets = (
*self.fieldsets,
FieldSet(
'form_factor', 'width', 'starting_unit', 'u_height',
InlineFields('outer_width', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')),
InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')),
'mounting_depth', 'desc_units', name=_('Dimensions')
),
)
class RackReservationForm(TenancyForm, NetBoxModelForm):
rack = DynamicModelChoiceField(

View File

@ -38,6 +38,7 @@ __all__ = (
'RackFilter',
'RackReservationFilter',
'RackRoleFilter',
'RackTypeFilter',
'RearPortFilter',
'RearPortTemplateFilter',
'RegionFilter',
@ -234,6 +235,12 @@ class PowerPortTemplateFilter(BaseFilterMixin):
pass
@strawberry_django.filter(models.RackType, lookups=True)
@autotype_decorator(filtersets.RackTypeFilterSet)
class RackTypeFilter(BaseFilterMixin):
pass
@strawberry_django.filter(models.Rack, lookups=True)
@autotype_decorator(filtersets.RackFilterSet)
class RackFilter(BaseFilterMixin):

View File

@ -159,6 +159,11 @@ class DCIMQuery:
return models.PowerPortTemplate.objects.get(id=id)
power_port_template_list: List[PowerPortTemplateType] = strawberry_django.field()
@strawberry.field
def rack_type(self, id: int) -> RackTypeType:
return models.RackType.objects.get(id=id)
rack_type_list: List[RackTypeType] = strawberry_django.field()
@strawberry.field
def rack(self, id: int) -> RackType:
return models.Rack.objects.get(id=id)

View File

@ -50,6 +50,7 @@ __all__ = (
'RackType',
'RackReservationType',
'RackRoleType',
'RackTypeType',
'RearPortType',
'RearPortTemplateType',
'RegionType',
@ -606,6 +607,16 @@ class PowerPortTemplateType(ModularComponentTemplateType):
poweroutlet_templates: List[Annotated["PowerOutletTemplateType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.type(
models.RackType,
fields='__all__',
filters=RackTypeFilter
)
class RackTypeType(NetBoxObjectType):
_name: str
manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
@strawberry_django.type(
models.Rack,
fields='__all__',
@ -618,6 +629,7 @@ class RackType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObje
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
role: Annotated["RackRoleType", strawberry.lazy('dcim.graphql.types')] | None
rack_type: Annotated["RackTypeType", strawberry.lazy('dcim.graphql.types')] | None
reservations: List[Annotated["RackReservationType", strawberry.lazy('dcim.graphql.types')]]
devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]]
powerfeeds: List[Annotated["PowerFeedType", strawberry.lazy('dcim.graphql.types')]]

View File

@ -0,0 +1,93 @@
import django.core.validators
import django.db.models.deletion
import taggit.managers
from django.db import migrations, models
import utilities.fields
import utilities.json
import utilities.ordering
class Migration(migrations.Migration):
dependencies = [
('extras', '0117_customfield_uniqueness'),
('dcim', '0187_alter_device_vc_position'),
]
operations = [
migrations.CreateModel(
name='RackType',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(
blank=True,
default=dict,
encoder=utilities.json.CustomFieldJSONEncoder
)),
('description', models.CharField(blank=True, max_length=200)),
('comments', models.TextField(blank=True)),
('weight', models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True)),
('weight_unit', models.CharField(blank=True, max_length=50)),
('_abs_weight', models.PositiveBigIntegerField(blank=True, null=True)),
('manufacturer', models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name='rack_types',
to='dcim.manufacturer'
)),
('name', models.CharField(max_length=100)),
('_name', utilities.fields.NaturalOrderingField(
'name',
blank=True,
max_length=100,
naturalize_function=utilities.ordering.naturalize
),
),
('slug', models.SlugField(max_length=100, unique=True)),
('form_factor', models.CharField(blank=True, max_length=50)),
('width', models.PositiveSmallIntegerField(default=19)),
('u_height', models.PositiveSmallIntegerField(
default=42,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(100),
]
)),
('starting_unit', models.PositiveSmallIntegerField(
default=1,
validators=[django.core.validators.MinValueValidator(1)]
)),
('desc_units', models.BooleanField(default=False)),
('outer_width', models.PositiveSmallIntegerField(blank=True, null=True)),
('outer_depth', models.PositiveSmallIntegerField(blank=True, null=True)),
('outer_unit', models.CharField(blank=True, max_length=50)),
('max_weight', models.PositiveIntegerField(blank=True, null=True)),
('_abs_max_weight', models.PositiveBigIntegerField(blank=True, null=True)),
('mounting_depth', models.PositiveSmallIntegerField(blank=True, null=True)),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
],
options={
'verbose_name': 'racktype',
'verbose_name_plural': 'racktypes',
'ordering': ('_name', 'pk'),
},
),
migrations.RenameField(
model_name='rack',
old_name='type',
new_name='form_factor',
),
migrations.AddField(
model_name='rack',
name='rack_type',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name='racks',
to='dcim.racktype',
),
),
]

View File

@ -29,9 +29,181 @@ __all__ = (
'Rack',
'RackReservation',
'RackRole',
'RackType',
)
#
# Rack Types
#
class RackBase(WeightMixin, PrimaryModel):
"""
Base class for RackType & Rack. Holds
"""
form_factor = models.CharField(
choices=RackFormFactorChoices,
max_length=50,
blank=True,
verbose_name=_('form factor')
)
width = models.PositiveSmallIntegerField(
choices=RackWidthChoices,
default=RackWidthChoices.WIDTH_19IN,
verbose_name=_('width'),
help_text=_('Rail-to-rail width')
)
# Numbering
u_height = models.PositiveSmallIntegerField(
default=RACK_U_HEIGHT_DEFAULT,
verbose_name=_('height (U)'),
validators=[MinValueValidator(1), MaxValueValidator(RACK_U_HEIGHT_MAX)],
help_text=_('Height in rack units')
)
starting_unit = models.PositiveSmallIntegerField(
default=RACK_STARTING_UNIT_DEFAULT,
verbose_name=_('starting unit'),
validators=[MinValueValidator(1)],
help_text=_('Starting unit for rack')
)
desc_units = models.BooleanField(
default=False,
verbose_name=_('descending units'),
help_text=_('Units are numbered top-to-bottom')
)
# Dimensions
outer_width = models.PositiveSmallIntegerField(
verbose_name=_('outer width'),
blank=True,
null=True,
help_text=_('Outer dimension of rack (width)')
)
outer_depth = models.PositiveSmallIntegerField(
verbose_name=_('outer depth'),
blank=True,
null=True,
help_text=_('Outer dimension of rack (depth)')
)
outer_unit = models.CharField(
verbose_name=_('outer unit'),
max_length=50,
choices=RackDimensionUnitChoices,
blank=True
)
mounting_depth = models.PositiveSmallIntegerField(
verbose_name=_('mounting depth'),
blank=True,
null=True,
help_text=(_(
'Maximum depth of a mounted device, in millimeters. For four-post racks, this is the distance between the '
'front and rear rails.'
))
)
# Weight
# WeightMixin provides weight, weight_unit, and _abs_weight
max_weight = models.PositiveIntegerField(
verbose_name=_('max weight'),
blank=True,
null=True,
help_text=_('Maximum load capacity for the rack')
)
# Stores the normalized max weight (in grams) for database ordering
_abs_max_weight = models.PositiveBigIntegerField(
blank=True,
null=True
)
class Meta:
abstract = True
class RackType(RackBase):
"""
Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
Each Rack is assigned to a Site and (optionally) a Location.
"""
manufacturer = models.ForeignKey(
to='dcim.Manufacturer',
on_delete=models.PROTECT,
related_name='rack_types'
)
name = models.CharField(
verbose_name=_('name'),
max_length=100
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
slug = models.SlugField(
verbose_name=_('slug'),
max_length=100,
unique=True
)
clone_fields = (
'manufacturer', 'form_factor', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit',
'mounting_depth', 'weight', 'max_weight', 'weight_unit',
)
prerequisite_models = (
'dcim.Manufacturer',
)
class Meta:
ordering = ('_name', 'pk') # (site, location, name) may be non-unique
verbose_name = _('rack type')
verbose_name_plural = _('rack types')
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('dcim:racktype', args=[self.pk])
def clean(self):
super().clean()
# Validate outer dimensions and unit
if (self.outer_width is not None or self.outer_depth is not None) and not self.outer_unit:
raise ValidationError(_("Must specify a unit when setting an outer width/depth"))
# Validate max_weight and weight_unit
if self.max_weight and not self.weight_unit:
raise ValidationError(_("Must specify a unit when setting a maximum weight"))
def save(self, *args, **kwargs):
# Store the given max weight (if any) in grams for use in database ordering
if self.max_weight and self.weight_unit:
self._abs_max_weight = to_grams(self.max_weight, self.weight_unit)
else:
self._abs_max_weight = None
# Clear unit if outer width & depth are not set
if self.outer_width is None and self.outer_depth is None:
self.outer_unit = ''
super().save(*args, **kwargs)
# Update all Racks associated with this RackType
for rack in self.racks.all():
rack.snapshot()
rack.copy_racktype_attrs()
rack.save()
@property
def units(self):
"""
Return a list of unit numbers, top to bottom.
"""
if self.desc_units:
return drange(decimal.Decimal(self.starting_unit), self.u_height + self.starting_unit, 0.5)
return drange(self.u_height + decimal.Decimal(0.5) + self.starting_unit - 1, 0.5 + self.starting_unit - 1, -0.5)
#
# Racks
#
@ -54,11 +226,24 @@ class RackRole(OrganizationalModel):
return reverse('dcim:rackrole', args=[self.pk])
class Rack(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, WeightMixin):
class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase):
"""
Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
Each Rack is assigned to a Site and (optionally) a Location.
"""
# Fields which cannot be set locally if a RackType is assigned
RACKTYPE_FIELDS = [
'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit',
'mounting_depth', 'weight', 'weight_unit', 'max_weight'
]
rack_type = models.ForeignKey(
to='dcim.RackType',
on_delete=models.PROTECT,
related_name='racks',
blank=True,
null=True,
)
name = models.CharField(
verbose_name=_('name'),
max_length=100
@ -121,73 +306,6 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, WeightMixin):
verbose_name=_('asset tag'),
help_text=_('A unique tag used to identify this rack')
)
type = models.CharField(
choices=RackTypeChoices,
max_length=50,
blank=True,
verbose_name=_('type')
)
width = models.PositiveSmallIntegerField(
choices=RackWidthChoices,
default=RackWidthChoices.WIDTH_19IN,
verbose_name=_('width'),
help_text=_('Rail-to-rail width')
)
u_height = models.PositiveSmallIntegerField(
default=RACK_U_HEIGHT_DEFAULT,
verbose_name=_('height (U)'),
validators=[MinValueValidator(1), MaxValueValidator(RACK_U_HEIGHT_MAX)],
help_text=_('Height in rack units')
)
starting_unit = models.PositiveSmallIntegerField(
default=RACK_STARTING_UNIT_DEFAULT,
verbose_name=_('starting unit'),
validators=[MinValueValidator(1),],
help_text=_('Starting unit for rack')
)
desc_units = models.BooleanField(
default=False,
verbose_name=_('descending units'),
help_text=_('Units are numbered top-to-bottom')
)
outer_width = models.PositiveSmallIntegerField(
verbose_name=_('outer width'),
blank=True,
null=True,
help_text=_('Outer dimension of rack (width)')
)
outer_depth = models.PositiveSmallIntegerField(
verbose_name=_('outer depth'),
blank=True,
null=True,
help_text=_('Outer dimension of rack (depth)')
)
outer_unit = models.CharField(
verbose_name=_('outer unit'),
max_length=50,
choices=RackDimensionUnitChoices,
blank=True,
)
max_weight = models.PositiveIntegerField(
verbose_name=_('max weight'),
blank=True,
null=True,
help_text=_('Maximum load capacity for the rack')
)
# Stores the normalized max weight (in grams) for database ordering
_abs_max_weight = models.PositiveBigIntegerField(
blank=True,
null=True
)
mounting_depth = models.PositiveSmallIntegerField(
verbose_name=_('mounting depth'),
blank=True,
null=True,
help_text=(
_('Maximum depth of a mounted device, in millimeters. For four-post racks, this is the '
'distance between the front and rear rails.')
)
)
# Generic relations
vlan_groups = GenericRelation(
@ -198,7 +316,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, WeightMixin):
)
clone_fields = (
'site', 'location', 'tenant', 'status', 'role', 'type', 'width', 'u_height', 'desc_units', 'outer_width',
'site', 'location', 'tenant', 'status', 'role', 'form_factor', 'width', 'u_height', 'desc_units', 'outer_width',
'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit',
)
prerequisite_models = (
@ -271,6 +389,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, WeightMixin):
})
def save(self, *args, **kwargs):
self.copy_racktype_attrs()
# Store the given max weight (if any) in grams for use in database ordering
if self.max_weight and self.weight_unit:
@ -284,6 +403,14 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, WeightMixin):
super().save(*args, **kwargs)
def copy_racktype_attrs(self):
"""
Copy physical attributes from the assigned RackType (if any).
"""
if self.rack_type:
for field_name in self.RACKTYPE_FIELDS:
setattr(self, field_name, getattr(self.rack_type, field_name))
@property
def units(self):
"""

View File

@ -242,6 +242,17 @@ class PowerPortIndex(SearchIndex):
display_attrs = ('device', 'label', 'type', 'description')
@register_search
class RackTypeIndex(SearchIndex):
model = models.RackType
fields = (
('name', 100),
('description', 500),
('comments', 5000),
)
display_attrs = ('type', 'description')
@register_search
class RackIndex(SearchIndex):
model = models.Rack

View File

@ -2,7 +2,7 @@ from django.utils.translation import gettext_lazy as _
import django_tables2 as tables
from django_tables2.utils import Accessor
from dcim.models import Rack, RackReservation, RackRole
from dcim.models import Rack, RackReservation, RackRole, RackType
from netbox.tables import NetBoxTable, columns
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
from .template_code import WEIGHT
@ -11,6 +11,7 @@ __all__ = (
'RackTable',
'RackReservationTable',
'RackRoleTable',
'RackTypeTable',
)
@ -44,6 +45,61 @@ class RackRoleTable(NetBoxTable):
default_columns = ('pk', 'name', 'rack_count', 'color', 'description')
#
# Rack Types
#
class RackTypeTable(NetBoxTable):
name = tables.Column(
verbose_name=_('Name'),
order_by=('_name',),
linkify=True
)
manufacturer = tables.Column(
verbose_name=_('Manufacturer'),
linkify=True
)
u_height = tables.TemplateColumn(
template_code="{{ value }}U",
verbose_name=_('Height')
)
outer_width = tables.TemplateColumn(
template_code="{{ record.outer_width }} {{ record.outer_unit }}",
verbose_name=_('Outer Width')
)
outer_depth = tables.TemplateColumn(
template_code="{{ record.outer_depth }} {{ record.outer_unit }}",
verbose_name=_('Outer Depth')
)
weight = columns.TemplateColumn(
verbose_name=_('Weight'),
template_code=WEIGHT,
order_by=('_abs_weight', 'weight_unit')
)
max_weight = columns.TemplateColumn(
verbose_name=_('Max Weight'),
template_code=WEIGHT,
order_by=('_abs_max_weight', 'weight_unit')
)
comments = columns.MarkdownColumn(
verbose_name=_('Comments'),
)
tags = columns.TagColumn(
url_name='dcim:rack_list'
)
class Meta(NetBoxTable.Meta):
model = RackType
fields = (
'pk', 'id', 'name', 'manufacturer', 'form_factor', 'u_height', 'starting_unit', 'width', 'outer_width',
'outer_depth', 'mounting_depth', 'weight', 'max_weight', 'description', 'comments', 'tags', 'created',
'last_updated',
)
default_columns = (
'pk', 'name', 'manufacturer', 'type', 'u_height', 'description',
)
#
# Racks
#
@ -114,9 +170,9 @@ class RackTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
model = Rack
fields = (
'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'tenant_group', 'role', 'serial',
'asset_tag', 'type', 'u_height', 'starting_unit', 'width', 'outer_width', 'outer_depth', 'mounting_depth',
'weight', 'max_weight', 'comments', 'device_count', 'get_utilization', 'get_power_utilization',
'description', 'contacts', 'tags', 'created', 'last_updated',
'asset_tag', 'form_factor', 'u_height', 'starting_unit', 'width', 'outer_width', 'outer_depth',
'mounting_depth', 'weight', 'max_weight', 'comments', 'device_count', 'get_utilization',
'get_power_utilization', 'description', 'contacts', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',

View File

@ -274,6 +274,47 @@ class RackRoleTest(APIViewTestCases.APIViewTestCase):
RackRole.objects.bulk_create(rack_roles)
class RackTypeTest(APIViewTestCases.APIViewTestCase):
model = RackType
brief_fields = ['description', 'display', 'id', 'manufacturer', 'name', 'slug', 'url']
bulk_update_data = {
'description': 'new description',
}
@classmethod
def setUpTestData(cls):
manufacturers = (
Manufacturer(name='Manufacturer 1', slug='manufacturer-1'),
Manufacturer(name='Manufacturer 2', slug='manufacturer-2'),
)
Manufacturer.objects.bulk_create(manufacturers)
rack_types = (
RackType(manufacturer=manufacturers[0], name='Rack Type 1', slug='rack-type-1'),
RackType(manufacturer=manufacturers[0], name='Rack Type 2', slug='rack-type-2'),
RackType(manufacturer=manufacturers[0], name='Rack Type 3', slug='rack-type-3'),
)
RackType.objects.bulk_create(rack_types)
cls.create_data = [
{
'manufacturer': manufacturers[1].pk,
'name': 'Rack Type 4',
'slug': 'rack-type-4',
},
{
'manufacturer': manufacturers[1].pk,
'name': 'Rack Type 5',
'slug': 'rack-type-5',
},
{
'manufacturer': manufacturers[1].pk,
'name': 'Rack Type 6',
'slug': 'rack-type-6',
},
]
class RackTest(APIViewTestCases.APIViewTestCase):
model = Rack
brief_fields = ['description', 'device_count', 'display', 'id', 'name', 'url']

View File

@ -468,6 +468,152 @@ class RackRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class RackTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = RackType.objects.all()
filterset = RackTypeFilterSet
@classmethod
def setUpTestData(cls):
manufacturers = (
Manufacturer(name='Manufacturer 1', slug='manufacturer-1'),
Manufacturer(name='Manufacturer 2', slug='manufacturer-2'),
Manufacturer(name='Manufacturer 3', slug='manufacturer-3'),
)
Manufacturer.objects.bulk_create(manufacturers)
racks = (
RackType(
manufacturer=manufacturers[0],
name='RackType 1',
slug='rack-type-1',
form_factor=RackFormFactorChoices.TYPE_2POST,
width=RackWidthChoices.WIDTH_19IN,
u_height=42,
starting_unit=1,
desc_units=False,
outer_width=100,
outer_depth=100,
outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER,
mounting_depth=100,
weight=10,
max_weight=1000,
weight_unit=WeightUnitChoices.UNIT_POUND,
description='foobar1'
),
RackType(
manufacturer=manufacturers[1],
name='RackType 2',
slug='rack-type-2',
form_factor=RackFormFactorChoices.TYPE_4POST,
width=RackWidthChoices.WIDTH_21IN,
u_height=43,
starting_unit=2,
desc_units=False,
outer_width=200,
outer_depth=200,
outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER,
mounting_depth=200,
weight=20,
max_weight=2000,
weight_unit=WeightUnitChoices.UNIT_POUND,
description='foobar2'
),
RackType(
manufacturer=manufacturers[2],
name='RackType 3',
slug='rack-type-3',
form_factor=RackFormFactorChoices.TYPE_CABINET,
width=RackWidthChoices.WIDTH_23IN,
u_height=44,
starting_unit=3,
desc_units=True,
outer_width=300,
outer_depth=300,
outer_unit=RackDimensionUnitChoices.UNIT_INCH,
mounting_depth=300,
weight=30,
max_weight=3000,
weight_unit=WeightUnitChoices.UNIT_KILOGRAM,
description='foobar3'
),
)
RackType.objects.bulk_create(racks)
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_manufacturer(self):
manufacturers = Manufacturer.objects.all()[:2]
params = {'manufacturer_id': [manufacturers[0].pk, manufacturers[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'manufacturer': [manufacturers[0].slug, manufacturers[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_name(self):
params = {'name': ['RackType 1', 'RackType 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_slug(self):
params = {'slug': ['rack-type-1', 'rack-type-2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_form_factor(self):
params = {'form_factor': [RackFormFactorChoices.TYPE_2POST, RackFormFactorChoices.TYPE_4POST]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_width(self):
params = {'width': [RackWidthChoices.WIDTH_19IN, RackWidthChoices.WIDTH_21IN]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_u_height(self):
params = {'u_height': [42, 43]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_starting_unit(self):
params = {'starting_unit': [1, 2]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_desc_units(self):
params = {'desc_units': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'desc_units': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_outer_width(self):
params = {'outer_width': [100, 200]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_outer_depth(self):
params = {'outer_depth': [100, 200]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_outer_unit(self):
self.assertEqual(RackType.objects.filter(outer_unit__isnull=False).count(), 3)
params = {'outer_unit': RackDimensionUnitChoices.UNIT_MILLIMETER}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_mounting_depth(self):
params = {'mounting_depth': [100, 200]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_weight(self):
params = {'weight': [10, 20]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_max_weight(self):
params = {'max_weight': [1000, 2000]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_weight_unit(self):
params = {'weight_unit': WeightUnitChoices.UNIT_POUND}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = Rack.objects.all()
filterset = RackFilterSet
@ -540,7 +686,7 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
role=rack_roles[0],
serial='ABC',
asset_tag='1001',
type=RackTypeChoices.TYPE_2POST,
form_factor=RackFormFactorChoices.TYPE_2POST,
width=RackWidthChoices.WIDTH_19IN,
u_height=42,
desc_units=False,
@ -562,7 +708,7 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
role=rack_roles[1],
serial='DEF',
asset_tag='1002',
type=RackTypeChoices.TYPE_4POST,
form_factor=RackFormFactorChoices.TYPE_4POST,
width=RackWidthChoices.WIDTH_21IN,
u_height=43,
desc_units=False,
@ -584,7 +730,7 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
role=rack_roles[2],
serial='GHI',
asset_tag='1003',
type=RackTypeChoices.TYPE_CABINET,
form_factor=RackFormFactorChoices.TYPE_CABINET,
width=RackWidthChoices.WIDTH_23IN,
u_height=44,
desc_units=True,
@ -619,8 +765,8 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_type(self):
params = {'type': [RackTypeChoices.TYPE_2POST, RackTypeChoices.TYPE_4POST]}
def test_form_factor(self):
params = {'form_factor': [RackFormFactorChoices.TYPE_2POST, RackFormFactorChoices.TYPE_4POST]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_width(self):

View File

@ -74,6 +74,61 @@ class LocationTestCase(TestCase):
self.assertEqual(PowerPanel.objects.get(pk=powerpanel1.pk).site, site_b)
class RackTypeTestCase(TestCase):
@classmethod
def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
RackType.objects.create(
manufacturer=manufacturer,
name='RackType 1',
slug='rack-type-1',
width=11,
u_height=22,
starting_unit=3,
desc_units=True,
outer_width=444,
outer_depth=5,
outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER,
weight=66,
weight_unit=WeightUnitChoices.UNIT_POUND,
max_weight=7777,
mounting_depth=8,
)
def test_rack_creation(self):
rack_type = RackType.objects.first()
sites = (
Site(name='Site 1', slug='site-1'),
)
Site.objects.bulk_create(sites)
locations = (
Location(name='Location 1', slug='location-1', site=sites[0]),
)
for location in locations:
location.save()
rack = Rack.objects.create(
name='Rack 1',
facility_id='A101',
site=sites[0],
location=locations[0],
rack_type=rack_type
)
self.assertEqual(rack.width, rack_type.width)
self.assertEqual(rack.u_height, rack_type.u_height)
self.assertEqual(rack.starting_unit, rack_type.starting_unit)
self.assertEqual(rack.desc_units, rack_type.desc_units)
self.assertEqual(rack.outer_width, rack_type.outer_width)
self.assertEqual(rack.outer_depth, rack_type.outer_depth)
self.assertEqual(rack.outer_unit, rack_type.outer_unit)
self.assertEqual(rack.weight, rack_type.weight)
self.assertEqual(rack.weight_unit, rack_type.weight_unit)
self.assertEqual(rack.max_weight, rack_type.max_weight)
self.assertEqual(rack.mounting_depth, rack_type.mounting_depth)
class RackTestCase(TestCase):
@classmethod

View File

@ -336,6 +336,75 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
class RackTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = RackType
@classmethod
def setUpTestData(cls):
manufacturers = (
Manufacturer(name='Manufacturer 1', slug='manufacturer-1'),
Manufacturer(name='Manufacturer 2', slug='manufacturer-2'),
)
Manufacturer.objects.bulk_create(manufacturers)
rack_types = (
RackType(manufacturer=manufacturers[0], name='RackType 1', slug='rack-type-1',),
RackType(manufacturer=manufacturers[0], name='RackType 2', slug='rack-type-2',),
RackType(manufacturer=manufacturers[0], name='RackType 3', slug='rack-type-3',),
)
RackType.objects.bulk_create(rack_types)
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'manufacturer': manufacturers[1].pk,
'name': 'RackType X',
'slug': 'rack-type-x',
'type': RackFormFactorChoices.TYPE_CABINET,
'width': RackWidthChoices.WIDTH_19IN,
'u_height': 48,
'desc_units': False,
'outer_width': 500,
'outer_depth': 500,
'outer_unit': RackDimensionUnitChoices.UNIT_MILLIMETER,
'starting_unit': 1,
'weight': 100,
'max_weight': 2000,
'weight_unit': WeightUnitChoices.UNIT_POUND,
'comments': 'Some comments',
'tags': [t.pk for t in tags],
}
cls.csv_data = (
"manufacturer,name,slug,width,u_height,weight,max_weight,weight_unit",
"Manufacturer 1,RackType 4,rack-type-4,19,42,100,2000,kg",
"Manufacturer 1,RackType 5,rack-type-5,19,42,100,2000,kg",
"Manufacturer 1,RackType 6,rack-type-6,19,42,100,2000,kg",
)
cls.csv_update_data = (
"id,name",
f"{rack_types[0].pk},RackType 7",
f"{rack_types[1].pk},RackType 8",
f"{rack_types[2].pk},RackType 9",
)
cls.bulk_edit_data = {
'manufacturer': manufacturers[1].pk,
'type': RackFormFactorChoices.TYPE_4POST,
'width': RackWidthChoices.WIDTH_23IN,
'u_height': 49,
'desc_units': True,
'outer_width': 30,
'outer_depth': 30,
'outer_unit': RackDimensionUnitChoices.UNIT_INCH,
'weight': 200,
'max_weight': 4000,
'weight_unit': WeightUnitChoices.UNIT_POUND,
'comments': 'New comments',
}
class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Rack
@ -380,7 +449,7 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'role': rackroles[1].pk,
'serial': '123456',
'asset_tag': 'ABCDEF',
'type': RackTypeChoices.TYPE_CABINET,
'form_factor': RackFormFactorChoices.TYPE_CABINET,
'width': RackWidthChoices.WIDTH_19IN,
'u_height': 48,
'desc_units': False,
@ -416,7 +485,7 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'status': RackStatusChoices.STATUS_DEPRECATED,
'role': rackroles[1].pk,
'serial': '654321',
'type': RackTypeChoices.TYPE_4POST,
'form_factor': RackFormFactorChoices.TYPE_4POST,
'width': RackWidthChoices.WIDTH_23IN,
'u_height': 49,
'desc_units': True,

View File

@ -63,6 +63,14 @@ urlpatterns = [
path('racks/delete/', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'),
path('racks/<int:pk>/', include(get_model_urls('dcim', 'rack'))),
# Rack Types
path('rack-types/', views.RackTypeListView.as_view(), name='racktype_list'),
path('rack-types/add/', views.RackTypeEditView.as_view(), name='racktype_add'),
path('rack-types/import/', views.RackTypeBulkImportView.as_view(), name='racktype_import'),
path('rack-types/edit/', views.RackTypeBulkEditView.as_view(), name='racktype_bulk_edit'),
path('rack-types/delete/', views.RackTypeBulkDeleteView.as_view(), name='racktype_bulk_delete'),
path('rack-types/<int:pk>/', include(get_model_urls('dcim', 'racktype'))),
# Manufacturers
path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'),
path('manufacturers/add/', views.ManufacturerEditView.as_view(), name='manufacturer_add'),

View File

@ -579,6 +579,56 @@ class RackRoleBulkDeleteView(generic.BulkDeleteView):
table = tables.RackRoleTable
#
# RackTypes
#
class RackTypeListView(generic.ObjectListView):
queryset = RackType.objects.all()
filterset = filtersets.RackTypeFilterSet
filterset_form = forms.RackTypeFilterForm
table = tables.RackTypeTable
@register_model_view(RackType)
class RackTypeView(GetRelatedModelsMixin, generic.ObjectView):
queryset = RackType.objects.all()
def get_extra_context(self, request, instance):
return {
'related_models': self.get_related_models(request, instance),
}
@register_model_view(RackType, 'edit')
class RackTypeEditView(generic.ObjectEditView):
queryset = RackType.objects.all()
form = forms.RackTypeForm
@register_model_view(RackType, 'delete')
class RackTypeDeleteView(generic.ObjectDeleteView):
queryset = RackType.objects.all()
class RackTypeBulkImportView(generic.BulkImportView):
queryset = RackType.objects.all()
model_form = forms.RackTypeImportForm
class RackTypeBulkEditView(generic.BulkEditView):
queryset = RackType.objects.all()
filterset = filtersets.RackTypeFilterSet
table = tables.RackTypeTable
form = forms.RackTypeBulkEditForm
class RackTypeBulkDeleteView(generic.BulkDeleteView):
queryset = RackType.objects.all()
filterset = filtersets.RackTypeFilterSet
table = tables.RackTypeTable
#
# Racks
#

View File

@ -21,6 +21,7 @@ from netbox.models import ChangeLoggedModel
from netbox.models.features import CloningMixin, ExportTemplatesMixin
from netbox.search import FieldTypes
from utilities import filters
from utilities.datetime import datetime_from_timestamp
from utilities.forms.fields import (
CSVChoiceField, CSVModelChoiceField, CSVModelMultipleChoiceField, CSVMultipleChoiceField, DynamicChoiceField,
DynamicModelChoiceField, DynamicModelMultipleChoiceField, DynamicMultipleChoiceField, JSONField, LaxURLField,
@ -672,12 +673,8 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
# Validate date & time
elif self.type == CustomFieldTypeChoices.TYPE_DATETIME:
if type(value) is not datetime:
# Work around UTC issue for Python < 3.11; see
# https://docs.python.org/3/library/datetime.html#datetime.datetime.fromisoformat
if type(value) is str and value.endswith('Z'):
value = f'{value[:-1]}+00:00'
try:
datetime.fromisoformat(value)
datetime_from_timestamp(value)
except ValueError:
raise ValidationError(
_("Date and time values must be in ISO 8601 format (YYYY-MM-DD HH:MM:SS).")

View File

@ -1184,6 +1184,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
'rack',
'rackreservation',
'rackrole',
'racktype',
'rearport',
'region',
'rir',

View File

@ -6,7 +6,7 @@ from dcim.api.serializers_.sites import SiteSerializer
from ipam.choices import *
from ipam.constants import VLANGROUP_SCOPE_TYPES
from ipam.models import VLAN, VLANGroup
from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField
from netbox.api.fields import ChoiceField, ContentTypeField, IntegerRangeSerializer, RelatedObjectCountField
from netbox.api.serializers import NetBoxModelSerializer
from tenancy.api.serializers_.tenants import TenantSerializer
from utilities.api import get_serializer_for_model
@ -32,6 +32,7 @@ class VLANGroupSerializer(NetBoxModelSerializer):
)
scope_id = serializers.IntegerField(allow_null=True, required=False, default=None)
scope = serializers.SerializerMethodField(read_only=True)
vid_ranges = IntegerRangeSerializer(many=True, required=False)
utilization = serializers.CharField(read_only=True)
# Related object counts
@ -40,8 +41,8 @@ class VLANGroupSerializer(NetBoxModelSerializer):
class Meta:
model = VLANGroup
fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'min_vid',
'max_vid', 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count', 'utilization'
'id', 'url', 'display_url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'vid_ranges',
'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count', 'utilization'
]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'vlan_count')
validators = []

View File

@ -911,10 +911,13 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet):
cluster = django_filters.NumberFilter(
method='filter_scope'
)
contains_vid = django_filters.NumberFilter(
method='filter_contains_vid'
)
class Meta:
model = VLANGroup
fields = ('id', 'name', 'slug', 'min_vid', 'max_vid', 'description', 'scope_id')
fields = ('id', 'name', 'slug', 'description', 'scope_id')
def search(self, queryset, name, value):
if not value.strip():
@ -932,6 +935,21 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet):
scope_id=value
)
def filter_contains_vid(self, queryset, name, value):
"""
Return all VLANGroups which contain the given VLAN ID.
"""
table_name = VLANGroup._meta.db_table
# TODO: See if this can be optimized without compromising queryset integrity
# Expand VLAN ID ranges to query by integer
groups = VLANGroup.objects.raw(
f'SELECT id FROM {table_name}, unnest(vid_ranges) vid_range WHERE %s <@ vid_range',
params=(value,)
)
return queryset.filter(
pk__in=[g.id for g in groups]
)
class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
region_id = TreeNodeMultipleChoiceFilter(

View File

@ -12,6 +12,7 @@ from tenancy.models import Tenant
from utilities.forms import add_blank_choice
from utilities.forms.fields import (
CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField,
NumericRangeArrayField,
)
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import BulkEditNullBooleanSelect
@ -408,18 +409,6 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm):
class VLANGroupBulkEditForm(NetBoxModelBulkEditForm):
min_vid = forms.IntegerField(
min_value=VLAN_VID_MIN,
max_value=VLAN_VID_MAX,
required=False,
label=_('Minimum child VLAN VID')
)
max_vid = forms.IntegerField(
min_value=VLAN_VID_MIN,
max_value=VLAN_VID_MAX,
required=False,
label=_('Maximum child VLAN VID')
)
description = forms.CharField(
label=_('Description'),
max_length=200,
@ -483,10 +472,14 @@ class VLANGroupBulkEditForm(NetBoxModelBulkEditForm):
'group_id': '$clustergroup',
}
)
vid_ranges = NumericRangeArrayField(
label=_('VLAN ID ranges'),
required=False
)
model = VLANGroup
fieldsets = (
FieldSet('site', 'min_vid', 'max_vid', 'description'),
FieldSet('site', 'vid_ranges', 'description'),
FieldSet(
'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster', name=_('Scope')
),

View File

@ -9,7 +9,8 @@ from ipam.models import *
from netbox.forms import NetBoxModelImportForm
from tenancy.models import Tenant
from utilities.forms.fields import (
CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, SlugField
CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, SlugField,
NumericRangeArrayField,
)
from virtualization.models import VirtualMachine, VMInterface
@ -411,22 +412,13 @@ class VLANGroupImportForm(NetBoxModelImportForm):
required=False,
label=_('Scope type (app & model)')
)
min_vid = forms.IntegerField(
min_value=VLAN_VID_MIN,
max_value=VLAN_VID_MAX,
required=False,
label=_('Minimum child VLAN VID (default: {minimum})').format(minimum=VLAN_VID_MIN)
)
max_vid = forms.IntegerField(
min_value=VLAN_VID_MIN,
max_value=VLAN_VID_MAX,
required=False,
label=_('Maximum child VLAN VID (default: {maximum})').format(maximum=VLAN_VID_MIN)
vid_ranges = NumericRangeArrayField(
required=False
)
class Meta:
model = VLANGroup
fields = ('name', 'slug', 'scope_type', 'scope_id', 'min_vid', 'max_vid', 'description', 'tags')
fields = ('name', 'slug', 'scope_type', 'scope_id', 'vid_ranges', 'description', 'tags')
labels = {
'scope_id': 'Scope ID',
}

View File

@ -413,7 +413,7 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm):
FieldSet('q', 'filter_id', 'tag'),
FieldSet('region', 'sitegroup', 'site', 'location', 'rack', name=_('Location')),
FieldSet('cluster_group', 'cluster', name=_('Cluster')),
FieldSet('min_vid', 'max_vid', name=_('VLAN ID')),
FieldSet('contains_vid', name=_('VLANs')),
)
model = VLANGroup
region = DynamicModelMultipleChoiceField(
@ -441,18 +441,6 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm):
required=False,
label=_('Rack')
)
min_vid = forms.IntegerField(
required=False,
min_value=VLAN_VID_MIN,
max_value=VLAN_VID_MAX,
label=_('Minimum VID')
)
max_vid = forms.IntegerField(
required=False,
min_value=VLAN_VID_MIN,
max_value=VLAN_VID_MAX,
label=_('Maximum VID')
)
cluster = DynamicModelMultipleChoiceField(
queryset=Cluster.objects.all(),
required=False,
@ -463,6 +451,11 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm):
required=False,
label=_('Cluster group')
)
contains_vid = forms.IntegerField(
min_value=0,
required=False,
label=_('Contains VLAN ID')
)
tag = TagFilterField(model)

View File

@ -1,5 +1,6 @@
from django import forms
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.forms import IntegerRangeField, SimpleArrayField
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
@ -14,7 +15,7 @@ from utilities.exceptions import PermissionsViolation
from utilities.forms import add_blank_choice
from utilities.forms.fields import (
CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField,
SlugField,
NumericRangeArrayField, SlugField
)
from utilities.forms.rendering import FieldSet, InlineFields, ObjectAttribute, TabbedGroups
from utilities.forms.widgets import DatePicker
@ -632,10 +633,13 @@ class VLANGroupForm(NetBoxModelForm):
}
)
slug = SlugField()
vid_ranges = NumericRangeArrayField(
label=_('VLAN IDs')
)
fieldsets = (
FieldSet('name', 'slug', 'description', 'tags', name=_('VLAN Group')),
FieldSet('min_vid', 'max_vid', name=_('Child VLANs')),
FieldSet('vid_ranges', name=_('Child VLANs')),
FieldSet(
'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster',
name=_('Scope')
@ -646,7 +650,7 @@ class VLANGroupForm(NetBoxModelForm):
model = VLANGroup
fields = [
'name', 'slug', 'description', 'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack',
'clustergroup', 'cluster', 'min_vid', 'max_vid', 'tags',
'clustergroup', 'cluster', 'vid_ranges', 'tags',
]
def __init__(self, *args, **kwargs):

View File

@ -251,6 +251,7 @@ class VLANType(NetBoxObjectType):
class VLANGroupType(OrganizationalObjectType):
vlans: List[VLANType]
vid_ranges: List[str]
@strawberry_django.field
def scope(self) -> Annotated[Union[

View File

@ -0,0 +1,55 @@
import django.contrib.postgres.fields
import django.contrib.postgres.fields.ranges
from django.db import migrations, models
from django.db.backends.postgresql.psycopg_any import NumericRange
import ipam.models.vlans
def set_vid_ranges(apps, schema_editor):
"""
Convert the min_vid & max_vid fields to a range in the new vid_ranges ArrayField.
"""
VLANGroup = apps.get_model('ipam', 'VLANGroup')
for group in VLANGroup.objects.all():
group.vid_ranges = [
NumericRange(group.min_vid, group.max_vid, bounds='[]')
]
group._total_vlan_ids = group.max_vid - group.min_vid + 1
group.save()
class Migration(migrations.Migration):
dependencies = [
('ipam', '0069_gfk_indexes'),
]
operations = [
migrations.AddField(
model_name='vlangroup',
name='vid_ranges',
field=django.contrib.postgres.fields.ArrayField(
base_field=django.contrib.postgres.fields.ranges.IntegerRangeField(),
default=ipam.models.vlans.default_vid_ranges,
size=None
),
),
migrations.AddField(
model_name='vlangroup',
name='_total_vlan_ids',
field=models.PositiveBigIntegerField(default=4094),
),
migrations.RunPython(
code=set_vid_ranges,
reverse_code=migrations.RunPython.noop
),
migrations.RemoveField(
model_name='vlangroup',
name='max_vid',
),
migrations.RemoveField(
model_name='vlangroup',
name='min_vid',
),
]

View File

@ -1,7 +1,9 @@
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.postgres.fields import ArrayField, IntegerRangeField
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.backends.postgresql.psycopg_any import NumericRange
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
@ -10,6 +12,7 @@ from ipam.choices import *
from ipam.constants import *
from ipam.querysets import VLANQuerySet, VLANGroupQuerySet
from netbox.models import OrganizationalModel, PrimaryModel
from utilities.data import check_ranges_overlap, ranges_to_string
from virtualization.models import VMInterface
__all__ = (
@ -18,9 +21,16 @@ __all__ = (
)
def default_vid_ranges():
return [
NumericRange(VLAN_VID_MIN, VLAN_VID_MAX, bounds='[]')
]
class VLANGroup(OrganizationalModel):
"""
A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique.
A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique. Each group must
define one or more ranges of valid VLAN IDs, and may be assigned a specific scope.
"""
name = models.CharField(
verbose_name=_('name'),
@ -45,23 +55,13 @@ class VLANGroup(OrganizationalModel):
ct_field='scope_type',
fk_field='scope_id'
)
min_vid = models.PositiveSmallIntegerField(
verbose_name=_('minimum VLAN ID'),
default=VLAN_VID_MIN,
validators=(
MinValueValidator(VLAN_VID_MIN),
MaxValueValidator(VLAN_VID_MAX)
),
help_text=_('Lowest permissible ID of a child VLAN')
vid_ranges = ArrayField(
IntegerRangeField(),
verbose_name=_('VLAN ID ranges'),
default=default_vid_ranges
)
max_vid = models.PositiveSmallIntegerField(
verbose_name=_('maximum VLAN ID'),
default=VLAN_VID_MAX,
validators=(
MinValueValidator(VLAN_VID_MIN),
MaxValueValidator(VLAN_VID_MAX)
),
help_text=_('Highest permissible ID of a child VLAN')
_total_vlan_ids = models.PositiveBigIntegerField(
default=VLAN_VID_MAX - VLAN_VID_MIN + 1
)
objects = VLANGroupQuerySet.as_manager()
@ -96,17 +96,33 @@ class VLANGroup(OrganizationalModel):
if self.scope_id and not self.scope_type:
raise ValidationError(_("Cannot set scope_id without scope_type."))
# Validate min/max child VID limits
if self.max_vid < self.min_vid:
raise ValidationError({
'max_vid': _("Maximum child VID must be greater than or equal to minimum child VID")
})
# Validate VID ranges
if self.vid_ranges and check_ranges_overlap(self.vid_ranges):
raise ValidationError({'vid_ranges': _("Ranges cannot overlap.")})
for vid_range in self.vid_ranges:
if vid_range.lower >= vid_range.upper:
raise ValidationError({
'vid_ranges': _(
"Maximum child VID must be greater than or equal to minimum child VID ({value})"
).format(value=vid_range)
})
def save(self, *args, **kwargs):
self._total_vlan_ids = 0
for vid_range in self.vid_ranges:
self._total_vlan_ids += vid_range.upper - vid_range.lower + 1
super().save(*args, **kwargs)
def get_available_vids(self):
"""
Return all available VLANs within this group.
"""
available_vlans = {vid for vid in range(self.min_vid, self.max_vid + 1)}
available_vlans = set()
for vlan_range in self.vid_ranges:
available_vlans = available_vlans.union({
vid for vid in range(vlan_range.lower, vlan_range.upper)
})
available_vlans -= set(VLAN.objects.filter(group=self).values_list('vid', flat=True))
return sorted(available_vlans)
@ -126,6 +142,10 @@ class VLANGroup(OrganizationalModel):
"""
return VLAN.objects.filter(group=self).order_by('vid')
@property
def vid_ranges_list(self):
return ranges_to_string(self.vid_ranges)
class VLAN(PrimaryModel):
"""
@ -231,13 +251,14 @@ class VLAN(PrimaryModel):
).format(group=self.group, scope=self.group.scope, site=self.site)
)
# Validate group min/max VIDs
if self.group and not self.group.min_vid <= self.vid <= self.group.max_vid:
raise ValidationError({
'vid': _(
"VID must be between {minimum} and {maximum} for VLANs in group {group}"
).format(minimum=self.group.min_vid, maximum=self.group.max_vid, group=self.group)
})
# Check that the VLAN ID is permitted in the assigned group (if any)
if self.group:
if not any([self.vid in r for r in self.group.vid_ranges]):
raise ValidationError({
'vid': _(
"VID must be in ranges {ranges} for VLANs in group {group}"
).format(ranges=ranges_to_string(self.group.vid_ranges), group=self.group)
})
def get_status_color(self):
return VLANStatusChoices.colors.get(self.status)

View File

@ -9,6 +9,7 @@ from utilities.querysets import RestrictedQuerySet
__all__ = (
'ASNRangeQuerySet',
'PrefixQuerySet',
'VLANGroupQuerySet',
'VLANQuerySet',
)
@ -63,7 +64,7 @@ class VLANGroupQuerySet(RestrictedQuerySet):
return self.annotate(
vlan_count=count_related(VLAN, 'group'),
utilization=Round(F('vlan_count') / (F('max_vid') - F('min_vid') + 1.0) * 100, 2)
utilization=Round(F('vlan_count') * 100 / F('_total_vlan_ids'), 2)
)

View File

@ -154,9 +154,8 @@ class VLANGroupIndex(SearchIndex):
('name', 100),
('slug', 110),
('description', 500),
('max_vid', 2000),
)
display_attrs = ('scope_type', 'min_vid', 'max_vid', 'description')
display_attrs = ('scope_type', 'description')
@register_search

View File

@ -72,6 +72,10 @@ class VLANGroupTable(NetBoxTable):
linkify=True,
orderable=False
)
vid_ranges_list = tables.Column(
verbose_name=_('VID Ranges'),
orderable=False
)
vlan_count = columns.LinkedCountColumn(
viewname='ipam:vlan_list',
url_params={'group_id': 'pk'},
@ -91,7 +95,7 @@ class VLANGroupTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = VLANGroup
fields = (
'pk', 'id', 'name', 'scope_type', 'scope', 'min_vid', 'max_vid', 'vlan_count', 'slug', 'description',
'pk', 'id', 'name', 'scope_type', 'scope', 'vid_ranges_list', 'vlan_count', 'slug', 'description',
'tags', 'created', 'last_updated', 'actions', 'utilization',
)
default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'utilization', 'description')

View File

@ -8,6 +8,7 @@ from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer,
from ipam.choices import *
from ipam.models import *
from tenancy.models import Tenant
from utilities.data import string_to_ranges
from utilities.testing import APITestCase, APIViewTestCases, create_test_device, disable_warnings
@ -882,8 +883,7 @@ class VLANGroupTest(APIViewTestCases.APIViewTestCase):
vlangroup = VLANGroup.objects.create(
name='VLAN Group X',
slug='vlan-group-x',
min_vid=MIN_VID,
max_vid=MAX_VID
vid_ranges=string_to_ranges(f"{MIN_VID}-{MAX_VID}")
)
# Create a set of VLANs within the group

View File

@ -1,4 +1,5 @@
from django.contrib.contenttypes.models import ContentType
from django.db.backends.postgresql.psycopg_any import NumericRange
from django.test import TestCase
from netaddr import IPNetwork
@ -1465,6 +1466,7 @@ class FHRPGroupAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests):
class VLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = VLANGroup.objects.all()
filterset = VLANGroupFilterSet
ignore_fields = ('vid_ranges',)
@classmethod
def setUpTestData(cls):
@ -1494,14 +1496,55 @@ class VLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
cluster.save()
vlan_groups = (
VLANGroup(name='VLAN Group 1', slug='vlan-group-1', scope=region, description='foobar1'),
VLANGroup(name='VLAN Group 2', slug='vlan-group-2', scope=sitegroup, description='foobar2'),
VLANGroup(name='VLAN Group 3', slug='vlan-group-3', scope=site, description='foobar3'),
VLANGroup(name='VLAN Group 4', slug='vlan-group-4', scope=location),
VLANGroup(name='VLAN Group 5', slug='vlan-group-5', scope=rack),
VLANGroup(name='VLAN Group 6', slug='vlan-group-6', scope=clustergroup),
VLANGroup(name='VLAN Group 7', slug='vlan-group-7', scope=cluster),
VLANGroup(name='VLAN Group 8', slug='vlan-group-8'),
VLANGroup(
name='VLAN Group 1',
slug='vlan-group-1',
vid_ranges=[NumericRange(1, 11), NumericRange(100, 200)],
scope=region,
description='foobar1'
),
VLANGroup(
name='VLAN Group 2',
slug='vlan-group-2',
vid_ranges=[NumericRange(1, 11), NumericRange(200, 300)],
scope=sitegroup,
description='foobar2'
),
VLANGroup(
name='VLAN Group 3',
slug='vlan-group-3',
vid_ranges=[NumericRange(1, 11), NumericRange(300, 400)],
scope=site,
description='foobar3'
),
VLANGroup(
name='VLAN Group 4',
slug='vlan-group-4',
vid_ranges=[NumericRange(1, 11), NumericRange(400, 500)],
scope=location
),
VLANGroup(
name='VLAN Group 5',
slug='vlan-group-5',
vid_ranges=[NumericRange(1, 11), NumericRange(500, 600)],
scope=rack
),
VLANGroup(
name='VLAN Group 6',
slug='vlan-group-6',
vid_ranges=[NumericRange(1, 11), NumericRange(600, 700)],
scope=clustergroup
),
VLANGroup(
name='VLAN Group 7',
slug='vlan-group-7',
vid_ranges=[NumericRange(1, 11), NumericRange(700, 800)],
scope=cluster
),
VLANGroup(
name='VLAN Group 8',
slug='vlan-group-8'
),
)
VLANGroup.objects.bulk_create(vlan_groups)
@ -1521,6 +1564,12 @@ class VLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_contains_vid(self):
params = {'contains_vid': 123}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'contains_vid': 1}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
def test_region(self):
params = {'region': Region.objects.first().pk}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)

View File

@ -1,6 +1,7 @@
from django.core.exceptions import ValidationError
from django.test import TestCase, override_settings
from netaddr import IPNetwork, IPSet
from utilities.data import string_to_ranges
from ipam.choices import *
from ipam.models import *
@ -509,8 +510,7 @@ class TestVLANGroup(TestCase):
vlangroup = VLANGroup.objects.create(
name='VLAN Group 1',
slug='vlan-group-1',
min_vid=100,
max_vid=199
vid_ranges=string_to_ranges('100-199'),
)
VLAN.objects.bulk_create((
VLAN(name='VLAN 100', vid=100, group=vlangroup),
@ -533,3 +533,13 @@ class TestVLANGroup(TestCase):
VLAN.objects.create(name='VLAN 104', vid=104, group=vlangroup)
self.assertEqual(vlangroup.get_next_available_vid(), 105)
def test_vid_validation(self):
vlangroup = VLANGroup.objects.first()
vlan = VLAN(vid=1, name='VLAN 1', group=vlangroup)
with self.assertRaises(ValidationError):
vlan.full_clean()
vlan = VLAN(vid=109, name='VLAN 109', group=vlangroup)
vlan.full_clean()

View File

@ -764,9 +764,8 @@ class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
cls.form_data = {
'name': 'VLAN Group X',
'slug': 'vlan-group-x',
'min_vid': 1,
'max_vid': 4094,
'description': 'A new VLAN group',
'vid_ranges': '100-199,300-399',
'tags': [t.pk for t in tags],
}

View File

@ -90,12 +90,12 @@ def add_available_ipaddresses(prefix, ipaddress_list, is_pool=False):
return output
def add_available_vlans(vlans, vlan_group=None):
def available_vlans_from_range(vlans, vlan_group, vlan_range):
"""
Create fake records for all gaps between used VLANs
"""
min_vid = vlan_group.min_vid if vlan_group else VLAN_VID_MIN
max_vid = vlan_group.max_vid if vlan_group else VLAN_VID_MAX
min_vid = int(vlan_range.lower) if vlan_range else VLAN_VID_MIN
max_vid = int(vlan_range.upper) if vlan_range else VLAN_VID_MAX
if not vlans:
return [{
@ -128,6 +128,17 @@ def add_available_vlans(vlans, vlan_group=None):
'available': max_vid - prev_vid,
})
return new_vlans
def add_available_vlans(vlans, vlan_group):
"""
Create fake records for all gaps between used VLANs
"""
new_vlans = []
for vlan_range in vlan_group.vid_ranges:
new_vlans.extend(available_vlans_from_range(vlans, vlan_group, vlan_range))
vlans = list(vlans) + new_vlans
vlans.sort(key=lambda v: v.vid if type(v) is VLAN else v['vid'])

View File

@ -915,7 +915,7 @@ class IPAddressContactsView(ObjectContactsView):
#
class VLANGroupListView(generic.ObjectListView):
queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
queryset = VLANGroup.objects.annotate_utilization()
filterset = filtersets.VLANGroupFilterSet
filterset_form = forms.VLANGroupFilterForm
table = tables.VLANGroupTable
@ -923,7 +923,7 @@ class VLANGroupListView(generic.ObjectListView):
@register_model_view(VLANGroup)
class VLANGroupView(GetRelatedModelsMixin, generic.ObjectView):
queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
queryset = VLANGroup.objects.annotate_utilization()
def get_extra_context(self, request, instance):
return {

View File

@ -1,4 +1,5 @@
from django.core.exceptions import ObjectDoesNotExist
from django.db.backends.postgresql.psycopg_any import NumericRange
from django.utils.translation import gettext as _
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
@ -11,6 +12,7 @@ __all__ = (
'ChoiceField',
'ContentTypeField',
'IPNetworkSerializer',
'IntegerRangeSerializer',
'RelatedObjectCountField',
'SerializedPKRelatedField',
)
@ -154,3 +156,19 @@ class RelatedObjectCountField(serializers.ReadOnlyField):
self.relation = relation
super().__init__(**kwargs)
class IntegerRangeSerializer(serializers.Serializer):
"""
Represents a range of integers.
"""
def to_internal_value(self, data):
if not isinstance(data, (list, tuple)) or len(data) != 2:
raise ValidationError(_("Ranges must be specified in the form (lower, upper)."))
if type(data[0]) is not int or type(data[1]) is not int:
raise ValidationError(_("Range boundaries must be defined as integers."))
return NumericRange(data[0], data[1], bounds='[]')
def to_representation(self, instance):
return instance.lower, instance.upper - 1

View File

@ -24,6 +24,7 @@ ORGANIZATION_MENU = Menu(
label=_('Racks'),
items=(
get_model_item('dcim', 'rack', _('Racks')),
get_model_item('dcim', 'racktype', _('Rack Types')),
get_model_item('dcim', 'rackrole', _('Rack Roles')),
get_model_item('dcim', 'rackreservation', _('Reservations')),
MenuItem(

View File

@ -0,0 +1,48 @@
{% load i18n %}
<div class="card">
<h5 class="card-header">{% trans "Dimensions" %}</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Form factor" %}</th>
<td>{{ object.get_form_factor_display|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Width" %}</th>
<td>{{ object.get_width_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "Height" %}</th>
<td>{{ object.u_height }}U</td>
</tr>
<tr>
<th scope="row">{% trans "Outer Width" %}</th>
<td>
{% if object.outer_width %}
{{ object.outer_width }} {{ object.get_outer_unit_display }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Outer Depth" %}</th>
<td>
{% if object.outer_depth %}
{{ object.outer_depth }} {{ object.get_outer_unit_display }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Mounting Depth" %}</th>
<td>
{% if object.mounting_depth %}
{{ object.mounting_depth }} {% trans "Millimeters" %}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
</table>
</div>

View File

@ -0,0 +1,14 @@
{% load i18n %}
<div class="card">
<h5 class="card-header">{% trans "Numbering" %}</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Starting Unit" %}</th>
<td>{{ object.starting_unit }}</td>
</tr>
<tr>
<th scope="row">{% trans "Descending Units" %}</th>
<td>{% checkmark object.desc_units %}</td>
</tr>
</table>
</div>

View File

@ -9,157 +9,107 @@
{% block content %}
<div class="row">
<div class="col col-12 col-xl-5">
<div class="card">
<h5 class="card-header">{% trans "Rack" %}</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Region" %}</th>
<td>
{% nested_tree object.site.region %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Site" %}</th>
<td>{{ object.site|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Location" %}</th>
<td>{% nested_tree object.location %}</td>
</tr>
<tr>
<th scope="row">{% trans "Facility ID" %}</th>
<td>{{ object.facility_id|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Tenant" %}</th>
<td>
{% if object.tenant.group %}
{{ object.tenant.group|linkify }} /
{% endif %}
{{ object.tenant|linkify|placeholder }}
</td>
</tr>
<tr>
<th scope="row">{% trans "Status" %}</th>
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
</tr>
<tr>
<th scope="row">{% trans "Role" %}</th>
<td>{{ object.role|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Serial Number" %}</th>
<td class="font-monospace">{{ object.serial|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Asset Tag" %}</th>
<td class="font-monospace">{{ object.asset_tag|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Space Utilization" %}</th>
<td>{% utilization_graph object.get_utilization %}</td>
</tr>
<tr>
<th scope="row">{% trans "Power Utilization" %}</th>
<td>{% utilization_graph object.get_power_utilization %}</td>
</tr>
</table>
</div>
<div class="card">
<h5 class="card-header">{% trans "Dimensions" %}</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Type" %}</th>
<td>
{% if object.type %}
{{ object.get_type_display }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Width" %}</th>
<td>{{ object.get_width_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "Height" %}</th>
<td>{{ object.u_height }}U ({% if object.desc_units %}{% trans "descending" %}{% else %}{% trans "ascending" %}{% endif %})</td>
</tr>
<tr>
<th scope="row">{% trans "Starting Unit" %}</th>
<td>
{{ object.starting_unit }}
</td>
</tr>
<tr>
<th scope="row">{% trans "Outer Width" %}</th>
<td>
{% if object.outer_width %}
{{ object.outer_width }} {{ object.get_outer_unit_display }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Outer Depth" %}</th>
<td>
{% if object.outer_depth %}
{{ object.outer_depth }} {{ object.get_outer_unit_display }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Mounting Depth" %}</th>
<td>
{% if object.mounting_depth %}
{{ object.mounting_depth }} {% trans "Millimeters" %}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Rack Weight" %}</th>
<td>
{% if object.weight %}
{{ object.weight|floatformat }} {{ object.get_weight_unit_display }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Maximum Weight" %}</th>
<td>
{% if object.max_weight %}
{{ object.max_weight }} {{ object.get_weight_unit_display }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Total Weight" %}</th>
<td>
{{ object.total_weight|floatformat }} {% trans "Kilograms" %}
({{ object.total_weight|kg_to_pounds|floatformat }} {% trans "Pounds" %})
</td>
</tr>
</table>
</div>
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/comments.html' %}
{% include 'inc/panels/image_attachments.html' %}
{% plugin_left_page object %}
<div class="card">
<h5 class="card-header">{% trans "Rack" %}</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Region" %}</th>
<td>{% nested_tree object.site.region %}</td>
</tr>
<tr>
<th scope="row">{% trans "Site" %}</th>
<td>{{ object.site|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Location" %}</th>
<td>{% nested_tree object.location %}</td>
</tr>
<tr>
<th scope="row">{% trans "Facility ID" %}</th>
<td>{{ object.facility_id|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Tenant" %}</th>
<td>
{% if object.tenant.group %}
{{ object.tenant.group|linkify }} /
{% endif %}
{{ object.tenant|linkify|placeholder }}
</td>
</tr>
<tr>
<th scope="row">{% trans "Status" %}</th>
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
</tr>
<tr>
<th scope="row">{% trans "Rack Type" %}</th>
<td>{{ object.rack_type|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Role" %}</th>
<td>{{ object.role|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Serial Number" %}</th>
<td class="font-monospace">{{ object.serial|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Asset Tag" %}</th>
<td class="font-monospace">{{ object.asset_tag|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Space Utilization" %}</th>
<td>{% utilization_graph object.get_utilization %}</td>
</tr>
<tr>
<th scope="row">{% trans "Power Utilization" %}</th>
<td>{% utilization_graph object.get_power_utilization %}</td>
</tr>
</table>
</div>
{% include 'dcim/inc/panels/racktype_dimensions.html' %}
{% include 'dcim/inc/panels/racktype_numbering.html' %}
<div class="card">
<h5 class="card-header">{% trans "Weight" %}</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Rack Weight" %}</th>
<td>
{% if object.weight %}
{{ object.weight|floatformat }} {{ object.get_weight_unit_display }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Maximum Weight" %}</th>
<td>
{% if object.max_weight %}
{{ object.max_weight }} {{ object.get_weight_unit_display }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Total Weight" %}</th>
<td>
{{ object.total_weight|floatformat }} {% trans "Kilograms" %}
({{ object.total_weight|kg_to_pounds|floatformat }} {% trans "Pounds" %})
</td>
</tr>
</table>
</div>
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/comments.html' %}
{% include 'inc/panels/image_attachments.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-12 col-xl-7">
<div class="text-end mb-4">
@ -170,26 +120,26 @@
</select>
</div>
<div class="row" style="margin-bottom: 20px">
<div class="col col-md-6 col-sm-6 col-xs-12 text-center">
<div style="margin-left: 30px">
<h4>{% trans "Front" %}</h4>
{% include 'dcim/inc/rack_elevation.html' with face='front' extra_params=svg_extra %}
</div>
<div class="col col-md-6 col-sm-6 col-xs-12 text-center">
<div style="margin-left: 30px">
<h4>{% trans "Front" %}</h4>
{% include 'dcim/inc/rack_elevation.html' with face='front' extra_params=svg_extra %}
</div>
<div class="col col-md-6 col-sm-6 col-xs-12 text-center">
<div style="margin-left: 30px">
<h4>{% trans "Rear" %}</h4>
{% include 'dcim/inc/rack_elevation.html' with face='rear' extra_params=svg_extra %}
</div>
</div>
<div class="col col-md-6 col-sm-6 col-xs-12 text-center">
<div style="margin-left: 30px">
<h4>{% trans "Rear" %}</h4>
{% include 'dcim/inc/rack_elevation.html' with face='rear' extra_params=svg_extra %}
</div>
</div>
</div>
{% include 'inc/panels/related_objects.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,71 @@
{% extends 'generic/object.html' %}
{% load buttons %}
{% load helpers %}
{% load static %}
{% load plugins %}
{% load i18n %}
{% load mptt %}
{% block content %}
<div class="row">
<div class="col col-6">
<div class="card">
<h5 class="card-header">{% trans "Rack Type" %}</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Manufacturer" %}</th>
<td>{{ object.manufacturer|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
</table>
</div>
{% include 'dcim/inc/panels/racktype_dimensions.html' %}
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/comments.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-6">
{% include 'dcim/inc/panels/racktype_numbering.html' %}
<div class="card">
<h5 class="card-header">{% trans "Weight" %}</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Rack Weight" %}</th>
<td>
{% if object.weight %}
{{ object.weight|floatformat }} {{ object.get_weight_unit_display }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Maximum Weight" %}</th>
<td>
{% if object.max_weight %}
{{ object.max_weight }} {{ object.get_weight_unit_display }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
</table>
</div>
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/related_objects.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}

View File

@ -39,8 +39,8 @@
<td>{{ object.scope|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Permitted VIDs" %}</th>
<td>{{ object.min_vid }} - {{ object.max_vid }}</td>
<th scope="row">{% trans "VLAN IDs" %}</th>
<td>{{ object.vid_ranges_list }}</td>
</tr>
<tr>
<th scope="row">Utilization</th>

View File

@ -1,13 +1,17 @@
import decimal
from django.db.backends.postgresql.psycopg_any import NumericRange
from itertools import count, groupby
__all__ = (
'array_to_ranges',
'array_to_string',
'check_ranges_overlap',
'deepmerge',
'drange',
'flatten_dict',
'ranges_to_string',
'shallow_compare_dict',
'string_to_ranges',
)
@ -113,3 +117,52 @@ def drange(start, end, step=decimal.Decimal(1)):
while start > end:
yield start
start += step
def check_ranges_overlap(ranges):
"""
Check for overlap in an iterable of NumericRanges.
"""
ranges.sort(key=lambda x: x.lower)
for i in range(1, len(ranges)):
prev_range = ranges[i - 1]
prev_upper = prev_range.upper if prev_range.upper_inc else prev_range.upper - 1
lower = ranges[i].lower if ranges[i].lower_inc else ranges[i].lower + 1
if prev_upper >= lower:
return True
return False
def ranges_to_string(ranges):
"""
Generate a human-friendly string from a set of ranges. Intended for use with ArrayField. For example:
[[1, 100)], [200, 300)] => "1-99,200-299"
"""
if not ranges:
return ''
output = []
for r in ranges:
lower = r.lower if r.lower_inc else r.lower + 1
upper = r.upper if r.upper_inc else r.upper - 1
output.append(f'{lower}-{upper}')
return ','.join(output)
def string_to_ranges(value):
"""
Given a string in the format "1-100, 200-300" return an list of NumericRanges. Intended for use with ArrayField.
For example:
"1-99,200-299" => [NumericRange(1, 100), NumericRange(200, 300)]
"""
if not value:
return None
value.replace(' ', '') # Remove whitespace
values = []
for dash_range in value.split(','):
if '-' not in dash_range:
return None
lower, upper = dash_range.split('-')
values.append(NumericRange(int(lower), int(upper), bounds='[]'))
return values

View File

@ -1,7 +1,10 @@
import datetime
from django.utils import timezone
from django.utils.timezone import localtime
__all__ = (
'datetime_from_timestamp',
'local_now',
)
@ -11,3 +14,15 @@ def local_now():
Return the current date & time in the system timezone.
"""
return localtime(timezone.now())
def datetime_from_timestamp(value):
"""
Convert an ISO 8601 or RFC 3339 timestamp to a datetime object.
"""
# Work around UTC issue for Python < 3.11; see
# https://docs.python.org/3/library/datetime.html#datetime.datetime.fromisoformat
# TODO: Remove this once Python 3.10 is no longer supported
if type(value) is str and value.endswith('Z'):
value = f'{value[:-1]}+00:00'
return datetime.datetime.fromisoformat(value)

View File

@ -1,11 +1,14 @@
from django import forms
from django.contrib.postgres.forms import SimpleArrayField
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from utilities.data import ranges_to_string, string_to_ranges
from ..utils import parse_numeric_range
__all__ = (
'NumericArrayField',
'NumericRangeArrayField',
)
@ -24,3 +27,31 @@ class NumericArrayField(SimpleArrayField):
if isinstance(value, str):
value = ','.join([str(n) for n in parse_numeric_range(value)])
return super().to_python(value)
class NumericRangeArrayField(forms.CharField):
"""
A field which allows for array of numeric ranges:
Example: 1-5,7-20,30-50
"""
def __init__(self, *args, help_text='', **kwargs):
if not help_text:
help_text = mark_safe(
_("Specify one or more numeric ranges separated by commas. Example: " + "<code>1-5,20-30</code>")
)
super().__init__(*args, help_text=help_text, **kwargs)
def clean(self, value):
if value and not self.to_python(value):
raise forms.ValidationError(
_("Invalid ranges ({value}). Must be a range of integers in ascending order.").format(value=value)
)
return super().clean(value)
def prepare_value(self, value):
if isinstance(value, str):
return value
return ranges_to_string(value)
def to_python(self, value):
return string_to_ranges(value)

View File

@ -6,6 +6,8 @@ from typing import Union
from django.core.exceptions import ImproperlyConfigured
from utilities.datetime import datetime_from_timestamp
RELEASE_PATH = 'release.yaml'
LOCAL_RELEASE_PATH = 'local/release.yaml'
@ -52,6 +54,6 @@ def load_release_data():
# Convert the published date to a date object
if 'published' in data:
data['published'] = datetime.date.fromisoformat(data['published'])
data['published'] = datetime_from_timestamp(data['published'])
return ReleaseInfo(**data)

View File

@ -2,7 +2,7 @@ import json
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField
from django.contrib.postgres.fields import ArrayField, RangeField
from django.core.exceptions import FieldDoesNotExist
from django.db.models import ManyToManyField, ManyToManyRel, JSONField
from django.forms.models import model_to_dict
@ -12,6 +12,7 @@ from taggit.managers import TaggableManager
from core.models import ObjectType
from users.models import ObjectPermission
from utilities.data import ranges_to_string
from utilities.object_types import object_type_identifier
from utilities.permissions import resolve_permission_type
from .utils import DUMMY_CF_DATA, extract_form_failures
@ -139,6 +140,9 @@ class ModelTestCase(TestCase):
if type(field.base_field) is ArrayField:
# Handle nested arrays (e.g. choice sets)
model_dict[key] = '\n'.join([f'{k},{v}' for k, v in value])
elif issubclass(type(field.base_field), RangeField):
# Handle arrays of numeric ranges (e.g. VLANGroup VLAN ID ranges)
model_dict[key] = ranges_to_string(value)
else:
model_dict[key] = ','.join([str(v) for v in value])

View File

@ -0,0 +1,68 @@
from django.db.backends.postgresql.psycopg_any import NumericRange
from django.test import TestCase
from utilities.data import check_ranges_overlap, ranges_to_string, string_to_ranges
class RangeFunctionsTestCase(TestCase):
def test_check_ranges_overlap(self):
# Non-overlapping ranges
self.assertFalse(
check_ranges_overlap([
NumericRange(9, 19, bounds='(]'), # 10-19
NumericRange(19, 30, bounds='(]'), # 20-29
])
)
self.assertFalse(
check_ranges_overlap([
NumericRange(10, 19, bounds='[]'), # 10-19
NumericRange(20, 29, bounds='[]'), # 20-29
])
)
self.assertFalse(
check_ranges_overlap([
NumericRange(10, 20, bounds='[)'), # 10-19
NumericRange(20, 30, bounds='[)'), # 20-29
])
)
# Overlapping ranges
self.assertTrue(
check_ranges_overlap([
NumericRange(9, 20, bounds='(]'), # 10-20
NumericRange(19, 30, bounds='(]'), # 20-30
])
)
self.assertTrue(
check_ranges_overlap([
NumericRange(10, 20, bounds='[]'), # 10-20
NumericRange(20, 30, bounds='[]'), # 20-30
])
)
self.assertTrue(
check_ranges_overlap([
NumericRange(10, 21, bounds='[)'), # 10-20
NumericRange(20, 31, bounds='[)'), # 10-30
])
)
def test_ranges_to_string(self):
self.assertEqual(
ranges_to_string([
NumericRange(10, 20), # 10-19
NumericRange(30, 40), # 30-39
NumericRange(100, 200), # 100-199
]),
'10-19,30-39,100-199'
)
def test_string_to_ranges(self):
self.assertEqual(
string_to_ranges('10-19, 30-39, 100-199'),
[
NumericRange(10, 19, bounds='[]'), # 10-19
NumericRange(30, 39, bounds='[]'), # 30-39
NumericRange(100, 199, bounds='[]'), # 100-199
]
)