From 3f077df77f1a1830dd7a5502b9d4aff559db12ab Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 7 Jul 2026 10:26:38 -0700 Subject: [PATCH 1/3] #22205 - Add EOL to DeviceType, ModuleType --- docs/models/dcim/devicetype.md | 4 ++++ docs/models/dcim/moduletype.md | 4 ++++ netbox/dcim/api/serializers_/devicetypes.py | 6 +++-- netbox/dcim/filtersets.py | 4 ++-- netbox/dcim/forms/bulk_edit.py | 18 +++++++++++--- netbox/dcim/forms/bulk_import.py | 8 +++---- netbox/dcim/forms/filtersets.py | 24 ++++++++++++++++++- netbox/dcim/forms/model_forms.py | 13 +++++++--- netbox/dcim/graphql/filters.py | 10 +++++++- .../0242_add_devicetype_end_of_life.py | 21 ++++++++++++++++ netbox/dcim/models/devices.py | 9 ++++++- netbox/dcim/models/modules.py | 9 ++++++- netbox/dcim/tables/devicetypes.py | 2 +- netbox/dcim/tables/modules.py | 4 ++-- netbox/dcim/tests/test_api.py | 4 ++++ netbox/dcim/tests/test_filtersets.py | 20 ++++++++++++++++ netbox/dcim/tests/test_views.py | 9 +++++-- netbox/dcim/ui/panels.py | 2 ++ 18 files changed, 148 insertions(+), 23 deletions(-) create mode 100644 netbox/dcim/migrations/0242_add_devicetype_end_of_life.py diff --git a/docs/models/dcim/devicetype.md b/docs/models/dcim/devicetype.md index 4f75aff09..b2222e78a 100644 --- a/docs/models/dcim/devicetype.md +++ b/docs/models/dcim/devicetype.md @@ -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. diff --git a/docs/models/dcim/moduletype.md b/docs/models/dcim/moduletype.md index e2e42620d..8c9604ecc 100644 --- a/docs/models/dcim/moduletype.md +++ b/docs/models/dcim/moduletype.md @@ -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. diff --git a/netbox/dcim/api/serializers_/devicetypes.py b/netbox/dcim/api/serializers_/devicetypes.py index 1b3087229..fb2e13806 100644 --- a/netbox/dcim/api/serializers_/devicetypes.py +++ b/netbox/dcim/api/serializers_/devicetypes.py @@ -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', diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 9ebd2ad5b..5a6b277de 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -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', diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 8710b448b..411b0a780 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -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): diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index f756672fe..b7937745c 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -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): diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 0cb86a268..7d2efb1dc 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -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): diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 9d7ceae05..e48a1382c 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -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) diff --git a/netbox/dcim/graphql/filters.py b/netbox/dcim/graphql/filters.py index 09ba07acd..58f1b9bf5 100644 --- a/netbox/dcim/graphql/filters.py +++ b/netbox/dcim/graphql/filters.py @@ -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') ) diff --git a/netbox/dcim/migrations/0242_add_devicetype_end_of_life.py b/netbox/dcim/migrations/0242_add_devicetype_end_of_life.py new file mode 100644 index 000000000..fac6a449d --- /dev/null +++ b/netbox/dcim/migrations/0242_add_devicetype_end_of_life.py @@ -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), + ), + ] diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 702b091a3..31c7b1a1f 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -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 @@ -193,7 +199,7 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): clone_fields = ( 'manufacturer', 'default_platform', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', - 'weight_unit', + 'weight_unit', 'end_of_life', ) prerequisite_models = ( 'dcim.Manufacturer', @@ -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, } diff --git a/netbox/dcim/models/modules.py b/netbox/dcim/models/modules.py index 3c2043970..4d6407e6b 100644 --- a/netbox/dcim/models/modules.py +++ b/netbox/dcim/models/modules.py @@ -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, @@ -135,7 +141,7 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): to_field='module_type' ) - clone_fields = ('profile', 'manufacturer', 'weight', 'weight_unit', 'airflow') + clone_fields = ('profile', 'manufacturer', 'weight', 'weight_unit', 'airflow', 'end_of_life') prerequisite_models = ( 'dcim.Manufacturer', ) @@ -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, } diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index f952c2d4a..e46f51f41 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -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 = ( diff --git a/netbox/dcim/tables/modules.py b/netbox/dcim/tables/modules.py index 67959e92e..9fa7a7d44 100644 --- a/netbox/dcim/tables/modules.py +++ b/netbox/dcim/tables/modules.py @@ -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', diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 0fc6fae3e..37e67a5e3 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -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, diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 007445670..9adc9fc78 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -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]} diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index dc32c7bd9..d718f6ea3 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -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 = ( diff --git a/netbox/dcim/ui/panels.py b/netbox/dcim/ui/panels.py index bd2adc7f6..228875875 100644 --- a/netbox/dcim/ui/panels.py +++ b/netbox/dcim/ui/panels.py @@ -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): From 6fece9cfc2917088a7c55611826781642a6d9053 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 7 Jul 2026 16:42:39 -0700 Subject: [PATCH 2/3] fix --- netbox/utilities/testing/base.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/netbox/utilities/testing/base.py b/netbox/utilities/testing/base.py index 924988cbc..23dac73c8 100644 --- a/netbox/utilities/testing/base.py +++ b/netbox/utilities/testing/base.py @@ -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, 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,10 @@ 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) + elif type(field) is DateField 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. From f9b5df87c52bf319c1c51de75b4577d5ab77cb56 Mon Sep 17 00:00:00 2001 From: Arthur Date: Wed, 8 Jul 2026 10:45:34 -0700 Subject: [PATCH 3/3] fix --- netbox/dcim/models/devices.py | 2 +- netbox/dcim/models/modules.py | 2 +- netbox/utilities/testing/base.py | 7 ++++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 31c7b1a1f..6e090e33d 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -199,7 +199,7 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): clone_fields = ( 'manufacturer', 'default_platform', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', - 'weight_unit', 'end_of_life', + 'weight_unit', ) prerequisite_models = ( 'dcim.Manufacturer', diff --git a/netbox/dcim/models/modules.py b/netbox/dcim/models/modules.py index 4d6407e6b..69aaa482e 100644 --- a/netbox/dcim/models/modules.py +++ b/netbox/dcim/models/modules.py @@ -141,7 +141,7 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): to_field='module_type' ) - clone_fields = ('profile', 'manufacturer', 'weight', 'weight_unit', 'airflow', 'end_of_life') + clone_fields = ('profile', 'manufacturer', 'weight', 'weight_unit', 'airflow') prerequisite_models = ( 'dcim.Manufacturer', ) diff --git a/netbox/utilities/testing/base.py b/netbox/utilities/testing/base.py index 23dac73c8..4cf6b841b 100644 --- a/netbox/utilities/testing/base.py +++ b/netbox/utilities/testing/base.py @@ -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 DateField, 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,8 +214,9 @@ 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) - elif type(field) is DateField and value is not None: + # 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).