Browse Source

#22205 - Add EOL to DeviceType, ModuleType

Arthur 1 month ago
parent
commit
3f077df77f

+ 4 - 0
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.

+ 4 - 0
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.

+ 4 - 2
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',

+ 2 - 2
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',

+ 15 - 3
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):

+ 4 - 4
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):

+ 23 - 1
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):

+ 10 - 3
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)

+ 9 - 1
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')
     )

+ 21 - 0
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),
+        ),
+    ]

+ 8 - 1
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,
         }
 

+ 8 - 1
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,
         }

+ 1 - 1
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 = (

+ 2 - 2
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',

+ 4 - 0
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,

+ 20 - 0
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]}

+ 7 - 2
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 = (

+ 2 - 0
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):