Merge pull request #22634 from netbox-community/22205-eol

#22205 - Add EOL to DeviceType, ModuleType
This commit is contained in:
bctiemann 2026-07-09 14:50:14 -04:00 committed by GitHub
commit 517804758f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 152 additions and 22 deletions

View File

@ -61,6 +61,10 @@ The default direction in which airflow circulates within the device chassis. Thi
The numeric weight of the device, including a unit designation (e.g. 10 kilograms or 20 pounds).
### End of Life
The date after which this device type is no longer supported by its manufacturer. This can be used to identify devices approaching or past their support horizon to aid in hardware lifecycle planning.
### Front & Rear Images
Users can upload illustrations of the device's front and rear panels. If present, these will be used to render the device in [rack](./rack.md) elevation diagrams.

View File

@ -75,6 +75,10 @@ The numeric weight of the module, including a unit designation (e.g. 3 kilograms
The direction in which air circulates through the device chassis for cooling.
### End of Life
The date after which this module type is no longer supported by its manufacturer. This can be used to identify modules approaching or past their support horizon to aid in hardware lifecycle planning.
### Profile
The assigned [profile](./moduletypeprofile.md) for the type of module. Profiles can be used to classify module types by function (e.g. power supply, hard disk, etc.), and they support the addition of user-configurable attributes on module types. The assignment of a module type to a profile is optional.

View File

@ -53,7 +53,8 @@ class DeviceTypeSerializer(PrimaryModelSerializer):
fields = [
'id', 'url', 'display_url', 'display', 'manufacturer', 'default_platform', 'model', 'slug', 'part_number',
'u_height', 'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'weight',
'weight_unit', 'front_image', 'rear_image', 'description', 'owner', 'comments', 'tags', 'custom_fields',
'weight_unit', 'end_of_life', 'front_image', 'rear_image', 'description', 'owner',
'comments', 'tags', 'custom_fields',
'created', 'last_updated', 'device_count', 'console_port_template_count',
'console_server_port_template_count', 'power_port_template_count', 'power_outlet_template_count',
'interface_template_count', 'front_port_template_count', 'rear_port_template_count',
@ -115,7 +116,8 @@ class ModuleTypeSerializer(PrimaryModelSerializer):
model = ModuleType
fields = [
'id', 'url', 'display_url', 'display', 'profile', 'manufacturer', 'model', 'part_number', 'airflow',
'weight', 'weight_unit', 'description', 'attributes', 'owner', 'comments', 'tags', 'custom_fields',
'weight', 'weight_unit', 'end_of_life', 'description', 'attributes', 'owner', 'comments',
'tags', 'custom_fields',
'created', 'last_updated', 'module_count', 'console_port_template_count',
'console_server_port_template_count', 'power_port_template_count', 'power_outlet_template_count',
'interface_template_count', 'front_port_template_count', 'rear_port_template_count',

View File

@ -727,7 +727,7 @@ class DeviceTypeFilterSet(PrimaryModelFilterSet):
model = DeviceType
fields = (
'id', 'model', 'slug', 'part_number', 'u_height', 'exclude_from_utilization', 'is_full_depth',
'subdevice_role', 'airflow', 'weight', 'weight_unit', 'description',
'subdevice_role', 'airflow', 'weight', 'weight_unit', 'end_of_life', 'description',
# Counters
'console_port_template_count',
@ -870,7 +870,7 @@ class ModuleTypeFilterSet(AttributeFiltersMixin, PrimaryModelFilterSet):
class Meta:
model = ModuleType
fields = (
'id', 'model', 'part_number', 'airflow', 'weight', 'weight_unit', 'description',
'id', 'model', 'part_number', 'airflow', 'weight', 'weight_unit', 'end_of_life', 'description',
# Counters
'console_port_template_count',

View File

@ -29,7 +29,7 @@ from utilities.forms.fields import (
PositiveBigIntegerField,
)
from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups
from utilities.forms.widgets import BulkEditNullBooleanSelect, NumberWithOptions
from utilities.forms.widgets import BulkEditNullBooleanSelect, DatePicker, NumberWithOptions
from virtualization.models import Cluster
from wireless.choices import WirelessRoleChoices
from wireless.models import WirelessLAN, WirelessLANGroup
@ -550,6 +550,11 @@ class DeviceTypeBulkEditForm(PrimaryModelBulkEditForm):
required=False,
initial=''
)
end_of_life = forms.DateField(
label=_('End of life'),
required=False,
widget=DatePicker()
)
model = DeviceType
fieldsets = (
@ -558,8 +563,9 @@ class DeviceTypeBulkEditForm(PrimaryModelBulkEditForm):
'airflow', 'description', name=_('Device Type')
),
FieldSet('weight', 'weight_unit', name=_('Weight')),
FieldSet('end_of_life', name=_('Lifecycle')),
)
nullable_fields = ('part_number', 'airflow', 'weight', 'weight_unit', 'description', 'comments')
nullable_fields = ('part_number', 'airflow', 'weight', 'weight_unit', 'end_of_life', 'description', 'comments')
class ModuleTypeProfileBulkEditForm(PrimaryModelBulkEditForm):
@ -606,6 +612,11 @@ class ModuleTypeBulkEditForm(PrimaryModelBulkEditForm):
required=False,
initial=''
)
end_of_life = forms.DateField(
label=_('End of life'),
required=False,
widget=DatePicker()
)
model = ModuleType
fieldsets = (
@ -615,8 +626,9 @@ class ModuleTypeBulkEditForm(PrimaryModelBulkEditForm):
InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')),
name=_('Chassis')
),
FieldSet('end_of_life', name=_('Lifecycle')),
)
nullable_fields = ('part_number', 'weight', 'weight_unit', 'profile', 'description', 'comments')
nullable_fields = ('part_number', 'weight', 'weight_unit', 'profile', 'end_of_life', 'description', 'comments')
class DeviceRoleBulkEditForm(NestedGroupModelBulkEditForm):

View File

@ -457,8 +457,8 @@ class DeviceTypeImportForm(PrimaryModelImportForm):
model = DeviceType
fields = [
'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'exclude_from_utilization',
'is_full_depth', 'subdevice_role', 'airflow', 'description', 'weight', 'weight_unit', 'owner', 'comments',
'tags',
'is_full_depth', 'subdevice_role', 'airflow', 'description', 'weight', 'weight_unit', 'end_of_life',
'owner', 'comments', 'tags',
]
@ -509,8 +509,8 @@ class ModuleTypeImportForm(PrimaryModelImportForm):
class Meta:
model = ModuleType
fields = [
'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit', 'profile',
'attribute_data', 'owner', 'comments', 'tags',
'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit', 'end_of_life',
'profile', 'attribute_data', 'owner', 'comments', 'tags',
]
def clean(self):

View File

@ -21,7 +21,7 @@ from users.models import User
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, PositiveBigIntegerField, TagFilterField
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import NumberWithOptions
from utilities.forms.widgets import DatePicker, NumberWithOptions
from virtualization.models import Cluster, ClusterGroup, VirtualMachine
from vpn.models import L2VPN
from wireless.choices import *
@ -569,6 +569,7 @@ class DeviceTypeFilterForm(PrimaryModelFilterSetForm):
'pass_through_ports', 'device_bays', 'module_bays', 'inventory_items', name=_('Components')
),
FieldSet('weight', 'weight_unit', name=_('Weight')),
FieldSet('end_of_life__gte', 'end_of_life__lte', name=_('Lifecycle')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
selector_fields = ('filter_id', 'q', 'manufacturer_id')
@ -688,6 +689,16 @@ class DeviceTypeFilterForm(PrimaryModelFilterSetForm):
choices=add_blank_choice(WeightUnitChoices),
required=False
)
end_of_life__gte = forms.DateField(
label=_('End of life after'),
required=False,
widget=DatePicker()
)
end_of_life__lte = forms.DateField(
label=_('End of life before'),
required=False,
widget=DatePicker()
)
class ModuleTypeProfileFilterForm(PrimaryModelFilterSetForm):
@ -713,6 +724,7 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
'pass_through_ports', 'module_bays', name=_('Components')
),
FieldSet('weight', 'weight_unit', name=_('Weight')),
FieldSet('end_of_life__gte', 'end_of_life__lte', name=_('Lifecycle')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
)
selector_fields = ('filter_id', 'q', 'manufacturer_id')
@ -800,6 +812,16 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
choices=add_blank_choice(WeightUnitChoices),
required=False
)
end_of_life__gte = forms.DateField(
label=_('End of life after'),
required=False,
widget=DatePicker()
)
end_of_life__lte = forms.DateField(
label=_('End of life before'),
required=False,
widget=DatePicker()
)
class DeviceRoleFilterForm(NestedGroupModelFilterSetForm):

View File

@ -32,6 +32,7 @@ from utilities.forms.widgets import (
APISelect,
ClearableFileInput,
ClearableSelect,
DatePicker,
HTMXSelect,
NumberWithOptions,
SelectWithPK,
@ -510,6 +511,7 @@ class DeviceTypeForm(PrimaryModelForm):
'u_height', 'exclude_from_utilization', 'is_full_depth', 'part_number', 'subdevice_role', 'airflow',
'weight', 'weight_unit', name=_('Chassis')
),
FieldSet('end_of_life', name=_('Lifecycle')),
FieldSet('front_image', 'rear_image', name=_('Images')),
)
@ -517,8 +519,8 @@ class DeviceTypeForm(PrimaryModelForm):
model = DeviceType
fields = [
'manufacturer', 'model', 'slug', 'default_platform', 'part_number', 'u_height', 'exclude_from_utilization',
'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image',
'description', 'owner', 'comments', 'tags',
'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit', 'end_of_life', 'front_image',
'rear_image', 'description', 'owner', 'comments', 'tags',
]
widgets = {
'front_image': ClearableFileInput(attrs={
@ -527,6 +529,7 @@ class DeviceTypeForm(PrimaryModelForm):
'rear_image': ClearableFileInput(attrs={
'accept': DEVICETYPE_IMAGE_FORMATS
}),
'end_of_life': DatePicker(),
}
@ -575,6 +578,7 @@ class ModuleTypeForm(PrimaryModelForm):
return [
FieldSet('manufacturer', 'model', 'part_number', 'description', 'tags', name=_('Module Type')),
FieldSet('airflow', 'weight', 'weight_unit', name=_('Hardware')),
FieldSet('end_of_life', name=_('Lifecycle')),
FieldSet('profile', *self.attr_fields, name=_('Profile & Attributes'), html_id='profile-attributes')
]
@ -582,8 +586,11 @@ class ModuleTypeForm(PrimaryModelForm):
model = ModuleType
fields = [
'profile', 'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit',
'owner', 'comments', 'tags',
'end_of_life', 'owner', 'comments', 'tags',
]
widgets = {
'end_of_life': DatePicker(),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

View File

@ -4,7 +4,13 @@ import strawberry
import strawberry_django
from django.db.models import Q
from strawberry.scalars import ID
from strawberry_django import BaseFilterLookup, ComparisonFilterLookup, FilterLookup, StrFilterLookup
from strawberry_django import (
BaseFilterLookup,
ComparisonFilterLookup,
DateFilterLookup,
FilterLookup,
StrFilterLookup,
)
from dcim import models
from dcim.constants import *
@ -391,6 +397,7 @@ class DeviceTypeFilter(ImageAttachmentFilterMixin, WeightFilterMixin, PrimaryMod
airflow: BaseFilterLookup[Annotated['DeviceAirflowEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
end_of_life: DateFilterLookup | None = strawberry_django.filter_field()
front_image: Annotated['ImageAttachmentFilter', strawberry.lazy('extras.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@ -781,6 +788,7 @@ class ModuleTypeFilter(ImageAttachmentFilterMixin, WeightFilterMixin, PrimaryMod
airflow: BaseFilterLookup[Annotated['ModuleAirflowEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
end_of_life: DateFilterLookup | None = strawberry_django.filter_field()
consoleporttemplates: Annotated['ConsolePortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='console_port_templates')
)

View File

@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("dcim", "0241_consolidate_unique_constraints"),
]
operations = [
migrations.AddField(
model_name="devicetype",
name="end_of_life",
field=models.DateField(blank=True, null=True),
),
migrations.AddField(
model_name="moduletype",
name="end_of_life",
field=models.DateField(blank=True, null=True),
),
]

View File

@ -136,6 +136,12 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
blank=True,
null=True
)
end_of_life = models.DateField(
verbose_name=_('end of life'),
blank=True,
null=True,
help_text=_('The date after which this device type is no longer supported by the manufacturer')
)
front_image = models.ImageField(
upload_to='devicetype-images',
blank=True
@ -245,6 +251,7 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
'airflow': self.airflow,
'weight': float(self.weight) if self.weight is not None else None,
'weight_unit': self.weight_unit,
'end_of_life': self.end_of_life.isoformat() if self.end_of_life else None,
'comments': self.comments,
}

View File

@ -91,6 +91,12 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
blank=True,
null=True
)
end_of_life = models.DateField(
verbose_name=_('end of life'),
blank=True,
null=True,
help_text=_('The date after which this module type is no longer supported by the manufacturer')
)
attribute_data = models.JSONField(
blank=True,
null=True,
@ -199,6 +205,7 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
'weight': float(self.weight) if self.weight is not None else None,
'weight_unit': self.weight_unit,
'airflow': self.airflow,
'end_of_life': self.end_of_life.isoformat() if self.end_of_life else None,
'attribute_data': self.attribute_data,
'comments': self.comments,
}

View File

@ -151,7 +151,7 @@ class DeviceTypeTable(PrimaryModelTable):
model = models.DeviceType
fields = (
'pk', 'id', 'model', 'manufacturer', 'default_platform', 'slug', 'part_number', 'u_height',
'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'weight',
'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'end_of_life',
'description', 'comments', 'device_count', 'tags', 'created', 'last_updated',
)
default_columns = (

View File

@ -71,8 +71,8 @@ class ModuleTypeTable(PrimaryModelTable):
class Meta(PrimaryModelTable.Meta):
model = ModuleType
fields = (
'pk', 'id', 'model', 'profile', 'manufacturer', 'part_number', 'airflow', 'weight', 'description',
'attributes', 'module_count', 'comments', 'tags', 'created', 'last_updated',
'pk', 'id', 'model', 'profile', 'manufacturer', 'part_number', 'airflow', 'weight', 'end_of_life',
'description', 'attributes', 'module_count', 'comments', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'model', 'profile', 'manufacturer', 'part_number', 'module_count',

View File

@ -941,6 +941,7 @@ class DeviceTypeTestCase(APIViewTestCases.APIViewTestCase):
brief_fields = ['description', 'device_count', 'display', 'id', 'manufacturer', 'model', 'slug', 'url']
bulk_update_data = {
'part_number': 'ABC123',
'end_of_life': '2030-01-01',
}
user_permissions = ('dcim.view_manufacturer', )
@ -972,6 +973,7 @@ class DeviceTypeTestCase(APIViewTestCases.APIViewTestCase):
'model': 'Device Type 5',
'slug': 'device-type-5',
'u_height': 0.5,
'end_of_life': '2035-06-30',
},
{
'manufacturer': manufacturers[1].pk,
@ -987,6 +989,7 @@ class ModuleTypeTestCase(APIViewTestCases.APIViewTestCase):
brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'module_count', 'profile', 'url']
bulk_update_data = {
'part_number': 'ABC123',
'end_of_life': '2030-01-01',
}
user_permissions = ('dcim.view_manufacturer', )
@ -1014,6 +1017,7 @@ class ModuleTypeTestCase(APIViewTestCases.APIViewTestCase):
{
'manufacturer': manufacturers[1].pk,
'model': 'Module Type 5',
'end_of_life': '2035-06-30',
},
{
'manufacturer': manufacturers[1].pk,

View File

@ -1376,6 +1376,7 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
rear_image='rear.png',
weight=10,
weight_unit=WeightUnitChoices.UNIT_POUND,
end_of_life='2030-01-01',
description='foobar1'
),
DeviceType(
@ -1390,6 +1391,7 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR,
weight=20,
weight_unit=WeightUnitChoices.UNIT_POUND,
end_of_life='2035-06-30',
description='foobar2'
),
DeviceType(
@ -1471,6 +1473,14 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'part_number': ['Part Number 1', 'Part Number 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_end_of_life(self):
params = {'end_of_life': ['2030-01-01', '2035-06-30']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'end_of_life__gte': ['2031-01-01']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'end_of_life__lte': ['2031-01-01']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@ -1630,6 +1640,7 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
weight_unit=WeightUnitChoices.UNIT_POUND,
description='foobar1',
airflow=ModuleAirflowChoices.FRONT_TO_REAR,
end_of_life='2030-01-01',
profile=module_type_profiles[0],
attribute_data={
'string': 'string1',
@ -1646,6 +1657,7 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
weight_unit=WeightUnitChoices.UNIT_POUND,
description='foobar2',
airflow=ModuleAirflowChoices.REAR_TO_FRONT,
end_of_life='2035-06-30',
profile=module_type_profiles[1],
attribute_data={
'string': 'string2',
@ -1728,6 +1740,14 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_end_of_life(self):
params = {'end_of_life': ['2030-01-01', '2035-06-30']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'end_of_life__gte': ['2031-01-01']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'end_of_life__lte': ['2031-01-01']}
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]}

View File

@ -1,4 +1,5 @@
import csv
import datetime
import json
from decimal import Decimal
from io import StringIO
@ -719,6 +720,7 @@ class DeviceTypeTestCase(
'u_height': 2,
'is_full_depth': True,
'subdevice_role': None,
'end_of_life': datetime.date(2035, 6, 30),
'comments': 'Some comments',
'tags': [t.pk for t in tags],
}
@ -728,6 +730,7 @@ class DeviceTypeTestCase(
'default_platform': platforms[1].pk,
'u_height': 3,
'is_full_depth': False,
'end_of_life': datetime.date(2030, 1, 1),
}
def test_devicetype_consoleports(self):
@ -1269,6 +1272,7 @@ class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'manufacturer': manufacturers[1].pk,
'model': 'Device Type X',
'part_number': '123ABC',
'end_of_life': datetime.date(2035, 6, 30),
'comments': 'Some comments',
'tags': [t.pk for t in tags],
}
@ -1276,11 +1280,12 @@ class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
cls.bulk_edit_data = {
'manufacturer': manufacturers[1].pk,
'part_number': '456DEF',
'end_of_life': datetime.date(2030, 1, 1),
}
cls.csv_data = (
"manufacturer,model,part_number,comments,profile",
f"Manufacturer 1,fan0,generic-fan,,{fan_module_type_profile.name}"
"manufacturer,model,part_number,end_of_life,comments,profile",
f"Manufacturer 1,fan0,generic-fan,2035-06-30,,{fan_module_type_profile.name}"
)
cls.csv_update_data = (

View File

@ -158,6 +158,7 @@ class DeviceTypePanel(panels.ObjectAttributesPanel):
weight = attrs.WeightAttr('weight')
subdevice_role = attrs.ChoiceAttr('subdevice_role', label=_('Parent/child'))
airflow = attrs.ChoiceAttr('airflow')
end_of_life = attrs.DateTimeAttr('end_of_life', spec='date')
front_image = attrs.ImageAttr('front_image')
rear_image = attrs.ImageAttr('rear_image')
@ -185,6 +186,7 @@ class ModuleTypePanel(panels.ObjectAttributesPanel):
description = attrs.TextAttr('description')
airflow = attrs.ChoiceAttr('airflow')
weight = attrs.WeightAttr('weight')
end_of_life = attrs.DateTimeAttr('end_of_life', spec='date')
class PlatformPanel(panels.NestedGroupObjectPanel):

View File

@ -6,7 +6,7 @@ from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField, RangeField
from django.core.exceptions import FieldDoesNotExist
from django.db import transaction
from django.db.models import JSONField, ManyToManyField, ManyToManyRel
from django.db.models import DateField, DateTimeField, JSONField, ManyToManyField, ManyToManyRel
from django.forms.models import model_to_dict
from django.test import Client
from django.test import TestCase as _TestCase
@ -214,6 +214,11 @@ class ModelTestCase(TestCase):
elif type(value) is IPNetwork:
model_dict[key] = str(value)
# Convert date values to ISO 8601 strings (as rendered by the REST API). DateTimeField
# subclasses DateField, so exclude it here to preserve existing datetime handling.
elif isinstance(field, DateField) and not isinstance(field, DateTimeField) and value is not None:
model_dict[key] = value.isoformat()
# Normalize arrays of numeric ranges (e.g. VLAN IDs or port ranges).
# DB uses canonical half-open [lo, hi) via NumericRange; API uses inclusive [lo, hi].
# Convert to inclusive pairs for stable API comparisons.