Przeglądaj źródła

#22205 - Add EOL to DeviceType, ModuleType

Arthur 1 miesiąc temu
rodzic
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).
 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
 ### 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.
 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.
 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
 ### 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.
 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 = [
         fields = [
             'id', 'url', 'display_url', 'display', 'manufacturer', 'default_platform', 'model', 'slug', 'part_number',
             'id', 'url', 'display_url', 'display', 'manufacturer', 'default_platform', 'model', 'slug', 'part_number',
             'u_height', 'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'weight',
             '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',
             'created', 'last_updated', 'device_count', 'console_port_template_count',
             'console_server_port_template_count', 'power_port_template_count', 'power_outlet_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',
             'interface_template_count', 'front_port_template_count', 'rear_port_template_count',
@@ -115,7 +116,8 @@ class ModuleTypeSerializer(PrimaryModelSerializer):
         model = ModuleType
         model = ModuleType
         fields = [
         fields = [
             'id', 'url', 'display_url', 'display', 'profile', 'manufacturer', 'model', 'part_number', 'airflow',
             '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',
             'created', 'last_updated', 'module_count', 'console_port_template_count',
             'console_server_port_template_count', 'power_port_template_count', 'power_outlet_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',
             '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
         model = DeviceType
         fields = (
         fields = (
             'id', 'model', 'slug', 'part_number', 'u_height', 'exclude_from_utilization', 'is_full_depth',
             '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
             # Counters
             'console_port_template_count',
             'console_port_template_count',
@@ -870,7 +870,7 @@ class ModuleTypeFilterSet(AttributeFiltersMixin, PrimaryModelFilterSet):
     class Meta:
     class Meta:
         model = ModuleType
         model = ModuleType
         fields = (
         fields = (
-            'id', 'model', 'part_number', 'airflow', 'weight', 'weight_unit', 'description',
+            'id', 'model', 'part_number', 'airflow', 'weight', 'weight_unit', 'end_of_life', 'description',
 
 
             # Counters
             # Counters
             'console_port_template_count',
             'console_port_template_count',

+ 15 - 3
netbox/dcim/forms/bulk_edit.py

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

+ 4 - 4
netbox/dcim/forms/bulk_import.py

@@ -457,8 +457,8 @@ class DeviceTypeImportForm(PrimaryModelImportForm):
         model = DeviceType
         model = DeviceType
         fields = [
         fields = [
             'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'exclude_from_utilization',
             '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:
     class Meta:
         model = ModuleType
         model = ModuleType
         fields = [
         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):
     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 import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
 from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, PositiveBigIntegerField, TagFilterField
 from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, PositiveBigIntegerField, TagFilterField
 from utilities.forms.rendering import FieldSet
 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 virtualization.models import Cluster, ClusterGroup, VirtualMachine
 from vpn.models import L2VPN
 from vpn.models import L2VPN
 from wireless.choices import *
 from wireless.choices import *
@@ -569,6 +569,7 @@ class DeviceTypeFilterForm(PrimaryModelFilterSetForm):
             'pass_through_ports', 'device_bays', 'module_bays', 'inventory_items', name=_('Components')
             'pass_through_ports', 'device_bays', 'module_bays', 'inventory_items', name=_('Components')
         ),
         ),
         FieldSet('weight', 'weight_unit', name=_('Weight')),
         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')),
         FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     selector_fields = ('filter_id', 'q', 'manufacturer_id')
     selector_fields = ('filter_id', 'q', 'manufacturer_id')
@@ -688,6 +689,16 @@ class DeviceTypeFilterForm(PrimaryModelFilterSetForm):
         choices=add_blank_choice(WeightUnitChoices),
         choices=add_blank_choice(WeightUnitChoices),
         required=False
         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):
 class ModuleTypeProfileFilterForm(PrimaryModelFilterSetForm):
@@ -713,6 +724,7 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
             'pass_through_ports', 'module_bays', name=_('Components')
             'pass_through_ports', 'module_bays', name=_('Components')
         ),
         ),
         FieldSet('weight', 'weight_unit', name=_('Weight')),
         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')),
         FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
     selector_fields = ('filter_id', 'q', 'manufacturer_id')
     selector_fields = ('filter_id', 'q', 'manufacturer_id')
@@ -800,6 +812,16 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
         choices=add_blank_choice(WeightUnitChoices),
         choices=add_blank_choice(WeightUnitChoices),
         required=False
         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):
 class DeviceRoleFilterForm(NestedGroupModelFilterSetForm):

+ 10 - 3
netbox/dcim/forms/model_forms.py

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

+ 9 - 1
netbox/dcim/graphql/filters.py

@@ -4,7 +4,13 @@ import strawberry
 import strawberry_django
 import strawberry_django
 from django.db.models import Q
 from django.db.models import Q
 from strawberry.scalars import ID
 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 import models
 from dcim.constants import *
 from dcim.constants import *
@@ -391,6 +397,7 @@ class DeviceTypeFilter(ImageAttachmentFilterMixin, WeightFilterMixin, PrimaryMod
     airflow: BaseFilterLookup[Annotated['DeviceAirflowEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
     airflow: BaseFilterLookup[Annotated['DeviceAirflowEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
+    end_of_life: DateFilterLookup | None = strawberry_django.filter_field()
     front_image: Annotated['ImageAttachmentFilter', strawberry.lazy('extras.graphql.filters')] | None = (
     front_image: Annotated['ImageAttachmentFilter', strawberry.lazy('extras.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -781,6 +788,7 @@ class ModuleTypeFilter(ImageAttachmentFilterMixin, WeightFilterMixin, PrimaryMod
     airflow: BaseFilterLookup[Annotated['ModuleAirflowEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
     airflow: BaseFilterLookup[Annotated['ModuleAirflowEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
+    end_of_life: DateFilterLookup | None = strawberry_django.filter_field()
     consoleporttemplates: Annotated['ConsolePortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
     consoleporttemplates: Annotated['ConsolePortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field(name='console_port_templates')
         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,
         blank=True,
         null=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(
     front_image = models.ImageField(
         upload_to='devicetype-images',
         upload_to='devicetype-images',
         blank=True
         blank=True
@@ -193,7 +199,7 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
 
 
     clone_fields = (
     clone_fields = (
         'manufacturer', 'default_platform', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight',
         'manufacturer', 'default_platform', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight',
-        'weight_unit',
+        'weight_unit', 'end_of_life',
     )
     )
     prerequisite_models = (
     prerequisite_models = (
         'dcim.Manufacturer',
         'dcim.Manufacturer',
@@ -245,6 +251,7 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
             'airflow': self.airflow,
             'airflow': self.airflow,
             'weight': float(self.weight) if self.weight is not None else None,
             'weight': float(self.weight) if self.weight is not None else None,
             'weight_unit': self.weight_unit,
             'weight_unit': self.weight_unit,
+            'end_of_life': self.end_of_life.isoformat() if self.end_of_life else None,
             'comments': self.comments,
             'comments': self.comments,
         }
         }
 
 

+ 8 - 1
netbox/dcim/models/modules.py

@@ -91,6 +91,12 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
         blank=True,
         blank=True,
         null=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(
     attribute_data = models.JSONField(
         blank=True,
         blank=True,
         null=True,
         null=True,
@@ -135,7 +141,7 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
         to_field='module_type'
         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 = (
     prerequisite_models = (
         'dcim.Manufacturer',
         'dcim.Manufacturer',
     )
     )
@@ -199,6 +205,7 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
             'weight': float(self.weight) if self.weight is not None else None,
             'weight': float(self.weight) if self.weight is not None else None,
             'weight_unit': self.weight_unit,
             'weight_unit': self.weight_unit,
             'airflow': self.airflow,
             'airflow': self.airflow,
+            'end_of_life': self.end_of_life.isoformat() if self.end_of_life else None,
             'attribute_data': self.attribute_data,
             'attribute_data': self.attribute_data,
             'comments': self.comments,
             'comments': self.comments,
         }
         }

+ 1 - 1
netbox/dcim/tables/devicetypes.py

@@ -151,7 +151,7 @@ class DeviceTypeTable(PrimaryModelTable):
         model = models.DeviceType
         model = models.DeviceType
         fields = (
         fields = (
             'pk', 'id', 'model', 'manufacturer', 'default_platform', 'slug', 'part_number', 'u_height',
             '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',
             'description', 'comments', 'device_count', 'tags', 'created', 'last_updated',
         )
         )
         default_columns = (
         default_columns = (

+ 2 - 2
netbox/dcim/tables/modules.py

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

+ 20 - 0
netbox/dcim/tests/test_filtersets.py

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

+ 7 - 2
netbox/dcim/tests/test_views.py

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

+ 2 - 0
netbox/dcim/ui/panels.py

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