diff --git a/nautobot_device_lifecycle_mgmt/api/serializers.py b/nautobot_device_lifecycle_mgmt/api/serializers.py index e57abb50..b1acaacc 100644 --- a/nautobot_device_lifecycle_mgmt/api/serializers.py +++ b/nautobot_device_lifecycle_mgmt/api/serializers.py @@ -10,6 +10,7 @@ HardwareLCM, InventoryItemSoftwareValidationResult, ProviderLCM, + SoftwareNotice, ValidatedSoftwareLCM, VulnerabilityLCM, ) @@ -25,6 +26,16 @@ class Meta: fields = "__all__" +class SoftwareNoticeSerializer(NautobotModelSerializer): # pylint: disable=R0901,too-few-public-methods + """SoftwareNotice API serializer.""" + + class Meta: + """Meta attributes.""" + + model = SoftwareNotice + fields = "__all__" + + class ProviderLCMSerializer(NautobotModelSerializer): # pylint: disable=R0901,too-few-public-methods """API serializer.""" diff --git a/nautobot_device_lifecycle_mgmt/api/urls.py b/nautobot_device_lifecycle_mgmt/api/urls.py index 49f3663f..df54f033 100644 --- a/nautobot_device_lifecycle_mgmt/api/urls.py +++ b/nautobot_device_lifecycle_mgmt/api/urls.py @@ -10,6 +10,7 @@ HardwareLCMView, InventoryItemSoftwareValidationResultListViewSet, ProviderLCMView, + SoftwareNoticeView, ValidatedSoftwareLCMViewSet, VulnerabilityLCMViewSet, ) @@ -17,6 +18,7 @@ router = routers.DefaultRouter() router.register("hardware", HardwareLCMView) +router.register("software-notice", SoftwareNoticeView) router.register("contract", ContractLCMView) router.register("provider", ProviderLCMView) router.register("validated-software", ValidatedSoftwareLCMViewSet) diff --git a/nautobot_device_lifecycle_mgmt/api/views.py b/nautobot_device_lifecycle_mgmt/api/views.py index 68008873..c9d3300d 100644 --- a/nautobot_device_lifecycle_mgmt/api/views.py +++ b/nautobot_device_lifecycle_mgmt/api/views.py @@ -10,6 +10,7 @@ HardwareLCMFilterSet, InventoryItemSoftwareValidationResultFilterSet, ProviderLCMFilterSet, + SoftwareNoticeFilterSet, ValidatedSoftwareLCMFilterSet, VulnerabilityLCMFilterSet, ) @@ -21,6 +22,7 @@ HardwareLCM, InventoryItemSoftwareValidationResult, ProviderLCM, + SoftwareNotice, ValidatedSoftwareLCM, VulnerabilityLCM, ) @@ -33,6 +35,7 @@ HardwareLCMSerializer, InventoryItemSoftwareValidationResultSerializer, ProviderLCMSerializer, + SoftwareNoticeSerializer, ValidatedSoftwareLCMSerializer, VulnerabilityLCMSerializer, ) @@ -46,6 +49,14 @@ class HardwareLCMView(NautobotModelViewSet): serializer_class = HardwareLCMSerializer +class SoftwareNoticeView(NautobotModelViewSet): + """CRUD operations set for the Software Notice Lifecycle Management view.""" + + queryset = SoftwareNotice.objects.all() + filterset_class = SoftwareNoticeFilterSet + serializer_class = SoftwareNoticeSerializer + + class ContractLCMView(NautobotModelViewSet): """CRUD operations set for the Contract Lifecycle Management view.""" diff --git a/nautobot_device_lifecycle_mgmt/filters.py b/nautobot_device_lifecycle_mgmt/filters.py index d5e56611..a5c57942 100644 --- a/nautobot_device_lifecycle_mgmt/filters.py +++ b/nautobot_device_lifecycle_mgmt/filters.py @@ -18,6 +18,7 @@ HardwareLCM, InventoryItemSoftwareValidationResult, ProviderLCM, + SoftwareNotice, ValidatedSoftwareLCM, VulnerabilityLCM, ) @@ -136,6 +137,132 @@ def _expired_search(self, queryset, name, value): # pylint: disable=unused-argu return queryset.filter(qs_filter) +class SoftwareNoticeFilterSet(NautobotFilterSet): + """Filter for SoftwareNotice.""" + + q = SearchFilter( + filter_predicates={ + "software_version__version": { + "lookup_expr": "icontains", + "preprocessor": str.strip, + }, + "software_version__alias": { + "lookup_expr": "icontains", + "preprocessor": str.strip, + }, + "device_type__model": { + "lookup_expr": "icontains", + "preprocessor": str.strip, + }, + "device_type__part_number": { + "lookup_expr": "icontains", + "preprocessor": str.strip, + }, + "comments": { + "lookup_expr": "icontains", + "preprocessor": str.strip, + }, + "documentation_url": { + "lookup_expr": "icontains", + "preprocessor": str.strip, + }, + "end_of_sale": { + "lookup_expr": "icontains", + "preprocessor": str.strip, + }, + "end_of_support": { + "lookup_expr": "icontains", + "preprocessor": str.strip, + }, + "end_of_sw_releases": { + "lookup_expr": "icontains", + "preprocessor": str.strip, + }, + "end_of_security_patches": { + "lookup_expr": "icontains", + "preprocessor": str.strip, + }, + } + ) + software_version_id = django_filters.ModelMultipleChoiceFilter( + field_name="software_version", + queryset=SoftwareVersion.objects.all(), + label="Software Version", + ) + software_version = django_filters.ModelMultipleChoiceFilter( + field_name="software_version", queryset=SoftwareVersion.objects.all(), label="Software Version" + ) + software_version_version = django_filters.ModelMultipleChoiceFilter( + field_name="software_version__version", + queryset=SoftwareVersion.objects.all(), + to_field_name="version", + label="Software Version (String)", + ) + + device_type_id = django_filters.ModelMultipleChoiceFilter( + field_name="device_type", + queryset=DeviceType.objects.all(), + label="Device Type", + ) + device_type = django_filters.ModelMultipleChoiceFilter( + field_name="device_type", queryset=DeviceType.objects.all(), label="Device Type" + ) + device_type_model = django_filters.ModelMultipleChoiceFilter( + field_name="device_type__model", + queryset=DeviceType.objects.all(), + to_field_name="model", + label="Device Type (Model)", + ) + + documentation_url = django_filters.CharFilter( + lookup_expr="contains", + ) + + end_of_support = django_filters.DateFilter() + end_of_support__gte = django_filters.DateFilter(field_name="end_of_support", lookup_expr="gte") + end_of_support__lte = django_filters.DateFilter(field_name="end_of_support", lookup_expr="lte") + end_of_support__isnull = django_filters.BooleanFilter(field_name="end_of_support", lookup_expr="isnull") + + end_of_sale = django_filters.DateFilter() + end_of_sale__gte = django_filters.DateFilter(field_name="end_of_sale", lookup_expr="gte") + end_of_sale__lte = django_filters.DateFilter(field_name="end_of_sale", lookup_expr="lte") + end_of_sale__isnull = django_filters.BooleanFilter(field_name="end_of_sale", lookup_expr="isnull") + + end_of_security_patches = django_filters.DateFilter() + end_of_security_patches__gte = django_filters.DateFilter(field_name="end_of_security_patches", lookup_expr="gte") + end_of_security_patches__lte = django_filters.DateFilter(field_name="end_of_security_patches", lookup_expr="lte") + end_of_security_patches__isnull = django_filters.BooleanFilter( + field_name="end_of_security_patches", lookup_expr="isnull" + ) + + end_of_sw_releases = django_filters.DateFilter() + end_of_sw_releases__gte = django_filters.DateFilter(field_name="end_of_sw_releases", lookup_expr="gte") + end_of_sw_releases__lte = django_filters.DateFilter(field_name="end_of_sw_releases", lookup_expr="lte") + end_of_sw_releases__isnull = django_filters.BooleanFilter(field_name="end_of_sw_releases", lookup_expr="isnull") + + expired = django_filters.BooleanFilter(method="_expired_search", label="Support Expired") + + class Meta: + """Meta attributes for filter.""" + + model = SoftwareNotice + + fields = "__all__" + + def _expired_search(self, queryset, name, value): # pylint: disable=unused-argument + """Perform the filtered search.""" + today = datetime.datetime.today().date() + # End of support dates less than today are expired. + # End of support dates greater than or equal to today are not expired. + # If the end of support date is null, the notice will never be expired. + qs_filter = None + if value: + qs_filter = Q(**{"end_of_support__lt": today}) + if not value: + qs_filter = Q(**{"end_of_support__gte": today}) | Q(**{"end_of_support__isnull": True}) + return queryset.filter(qs_filter) + + class ValidatedSoftwareLCMFilterSet(NautobotFilterSet): """Filter for ValidatedSoftwareLCM.""" diff --git a/nautobot_device_lifecycle_mgmt/forms.py b/nautobot_device_lifecycle_mgmt/forms.py index 073b836d..ea8292c0 100644 --- a/nautobot_device_lifecycle_mgmt/forms.py +++ b/nautobot_device_lifecycle_mgmt/forms.py @@ -36,6 +36,7 @@ HardwareLCM, InventoryItemSoftwareValidationResult, ProviderLCM, + SoftwareNotice, ValidatedSoftwareLCM, VulnerabilityLCM, ) @@ -162,6 +163,80 @@ class HardwareLCMFilterForm(NautobotFilterForm): documentation_url = forms.CharField(required=False, label="Documentation URL") +class SoftwareNoticeForm(NautobotModelForm): + """SoftwareNotice Device Lifecycle creation/edit form.""" + + software_version = DynamicModelChoiceField(queryset=SoftwareVersion.objects.all(), required=True) + device_type = DynamicModelChoiceField(queryset=DeviceType.objects.all(), required=False) + + class Meta: + """Meta attributes for the HardwareLCMForm class.""" + + model = SoftwareNotice + fields = "__all__" + + widgets = { + "end_of_sale": DatePicker(), + "end_of_support": DatePicker(), + "end_of_sw_releases": DatePicker(), + "end_of_security_patches": DatePicker(), + } + + +class SoftwareNoticeBulkEditForm(NautobotBulkEditForm): + """SoftwareNotice Device Lifecycle bulk edit form.""" + + pk = forms.ModelMultipleChoiceField(queryset=SoftwareNotice.objects.all(), widget=forms.MultipleHiddenInput) + end_of_sale = forms.DateField(widget=DatePicker(), required=False) + end_of_support = forms.DateField(widget=DatePicker(), required=False) + end_of_sw_releases = forms.DateField(widget=DatePicker(), required=False) + end_of_security_patches = forms.DateField(widget=DatePicker(), required=False) + documentation_url = forms.URLField(required=False) + comments = forms.CharField(required=False) + + class Meta: + """Meta attributes for the SoftwareNoticeBulkEditForm class.""" + + nullable_fields = [ + "end_of_sale", + "end_of_support", + "end_of_sw_releases", + "end_of_security_patches", + "documentation_url", + "comments", + ] + + +class SoftwareNoticeFilterForm(NautobotFilterForm): + """Filter form for filtering SoftwareNotice objects.""" + + model = SoftwareNotice + field_order = [ + "q", + "expired", + "software_version", + "device_type", + "end_of_sale", + "end_of_support", + "end_of_sw_releases", + "end_of_security_patches", + "documentation_url", + ] + q = forms.CharField(required=False, label="Search") + expired = forms.BooleanField( + required=False, + label="Support Expired", + widget=StaticSelect2(choices=BOOLEAN_WITH_BLANK_CHOICES), + ) + software_version = DynamicModelMultipleChoiceField(required=False, queryset=SoftwareVersion.objects.all()) + device_type = DynamicModelMultipleChoiceField(required=False, queryset=DeviceType.objects.all()) + end_of_sale = NullableDateField(required=False, widget=DatePicker(), label="End of sale") + end_of_support = NullableDateField(required=False, widget=DatePicker(), label="End of support") + end_of_sw_releases = NullableDateField(required=False, widget=DatePicker(), label="End of software releases") + end_of_security_patches = NullableDateField(required=False, widget=DatePicker(), label="End of security patches") + documentation_url = forms.CharField(required=False, label="Documentation URL") + + class ValidatedSoftwareLCMForm(NautobotModelForm): """ValidatedSoftwareLCM creation/edit form.""" diff --git a/nautobot_device_lifecycle_mgmt/migrations/0030_softwarenotice.py b/nautobot_device_lifecycle_mgmt/migrations/0030_softwarenotice.py new file mode 100644 index 00000000..39994a3c --- /dev/null +++ b/nautobot_device_lifecycle_mgmt/migrations/0030_softwarenotice.py @@ -0,0 +1,66 @@ +# Generated by Django 4.2.16 on 2025-01-16 17:02 + +import uuid + +import django.core.serializers.json +import django.db.models.deletion +import nautobot.core.models.fields +import nautobot.extras.models.mixins +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("dcim", "0062_module_data_migration"), + ("extras", "0114_computedfield_grouping"), + ("nautobot_device_lifecycle_mgmt", "0029_devicehardwarenoticeresult"), + ] + + operations = [ + migrations.CreateModel( + name="SoftwareNotice", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True + ), + ), + ("created", models.DateTimeField(auto_now_add=True, null=True)), + ("last_updated", models.DateTimeField(auto_now=True, null=True)), + ( + "_custom_field_data", + models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + ("end_of_sale", models.DateField(blank=True, null=True)), + ("end_of_support", models.DateField(blank=True, null=True)), + ("end_of_sw_releases", models.DateField(blank=True, null=True)), + ("end_of_security_patches", models.DateField(blank=True, null=True)), + ("documentation_url", models.URLField(blank=True)), + ("comments", models.TextField(blank=True, default="")), + ( + "device_type", + models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="dcim.devicetype" + ), + ), + ( + "software_version", + models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="dcim.softwareversion" + ), + ), + ("tags", nautobot.core.models.fields.TagsField(through="extras.TaggedItem", to="extras.Tag")), + ], + options={ + "verbose_name": "Software Notice", + "ordering": ("end_of_sale",), + "unique_together": {("software_version", "device_type")}, + }, + bases=( + nautobot.extras.models.mixins.DynamicGroupMixin, + nautobot.extras.models.mixins.NotesMixin, + models.Model, + ), + ), + ] diff --git a/nautobot_device_lifecycle_mgmt/models.py b/nautobot_device_lifecycle_mgmt/models.py index 0cc49dec..f3c5c3a2 100644 --- a/nautobot_device_lifecycle_mgmt/models.py +++ b/nautobot_device_lifecycle_mgmt/models.py @@ -126,6 +126,85 @@ def clean(self): ) +@extras_features( + "custom_fields", + "custom_links", + "custom_validators", + "export_templates", + "graphql", + "relationships", + "webhooks", +) +class SoftwareNotice(PrimaryModel): + """SoftwareNotice model for app.""" + + # Set model columns + software_version = models.ForeignKey( + to="dcim.SoftwareVersion", + on_delete=models.CASCADE, + verbose_name="Software Notice", + blank=True, + null=True, + ) + device_type = models.ForeignKey( + to="dcim.DeviceType", + on_delete=models.CASCADE, + verbose_name="Device Type", + blank=True, + null=True, + ) + end_of_sale = models.DateField(null=True, blank=True, verbose_name="End of Sale") + end_of_support = models.DateField(null=True, blank=True, verbose_name="End of Support") + end_of_sw_releases = models.DateField(null=True, blank=True, verbose_name="End of Software Releases") + end_of_security_patches = models.DateField(null=True, blank=True, verbose_name="End of Security Patches") + documentation_url = models.URLField(blank=True, verbose_name="Documentation URL") + comments = models.TextField(blank=True, default="", verbose_name="Comments") + + class Meta: + """Meta attributes for the SoftwareNotice class.""" + + verbose_name = "Software Notice" + ordering = ("end_of_sale",) + unique_together = ["software_version", "device_type"] + + def __str__(self): + """String representation of SoftwareNotice.""" + if self.device_type: + msg = f"{self.software_version} - {self.device_type}" + else: + msg = f"{self.software_version} - All Device Types" + return msg + + @property + def expired(self): + """ + Return True if the current date is greater than the end of support date. + + If the end of support date has not been provided, return False. + If the current date is less than or equal to the end of support date, return False. + """ + today = datetime.today().date() + if not getattr(self, "end_of_support"): + return False + return today > getattr(self, "end_of_support") + + def save(self, *args, **kwargs): + """Override save to assert a full clean.""" + # Full clean to assert custom validation in clean() for ORM, etc. + super().full_clean() + super().save(*args, **kwargs) + + def clean(self): + """Override clean to do custom validation.""" + super().clean() + + if not self.device_type: + if SoftwareNotice.objects.filter(software_version=self.software_version, device_type__isnull=True).exclude( + pk=self.pk + ): + raise ValidationError("Software Notice with this Software Version and Device Type already exists.") + + class ValidatedSoftwareLCMQuerySet(RestrictedQuerySet): """Queryset for `ValidatedSoftwareLCM` objects.""" diff --git a/nautobot_device_lifecycle_mgmt/navigation.py b/nautobot_device_lifecycle_mgmt/navigation.py index d6c13d58..d0a11f08 100644 --- a/nautobot_device_lifecycle_mgmt/navigation.py +++ b/nautobot_device_lifecycle_mgmt/navigation.py @@ -46,6 +46,31 @@ name="Software Lifecycle", weight=100, items=( + NavMenuItem( + link="plugins:nautobot_device_lifecycle_mgmt:softwarenotice_list", + name="Software Notices", + buttons=( + NavMenuButton( + link="plugins:nautobot_device_lifecycle_mgmt:softwarenotice_add", + title="Add", + icon_class="mdi mdi-plus-thick", + button_class=ButtonColorChoices.GREEN, + permissions=[ + "nautobot_device_lifecycle_mgmt.add_softwarenotice", + ], + ), + NavMenuButton( + link="plugins:nautobot_device_lifecycle_mgmt:softwarenotice_import", + title="Import", + icon_class="mdi mdi-database-import-outline", + button_class=ButtonColorChoices.BLUE, + permissions=["nautobot_device_lifecycle_mgmt.add_softwarenotice"], + ), + ), + permissions=[ + "nautobot_device_lifecycle_mgmt.view_softwarenotice", + ], + ), NavMenuItem( link="plugins:nautobot_device_lifecycle_mgmt:validatedsoftwarelcm_list", name="Validated Software", diff --git a/nautobot_device_lifecycle_mgmt/tables.py b/nautobot_device_lifecycle_mgmt/tables.py index aeb756de..3b197d4d 100644 --- a/nautobot_device_lifecycle_mgmt/tables.py +++ b/nautobot_device_lifecycle_mgmt/tables.py @@ -13,6 +13,7 @@ HardwareLCM, InventoryItemSoftwareValidationResult, ProviderLCM, + SoftwareNotice, ValidatedSoftwareLCM, VulnerabilityLCM, ) @@ -75,6 +76,62 @@ class Meta(BaseTable.Meta): ) +class SoftwareNoticeTable(BaseTable): + """Table for SoftwareNotice list view.""" + + pk = ToggleColumn() + id = tables.LinkColumn( + "plugins:nautobot_device_lifecycle_mgmt:softwarenotice", + text=lambda record: str(record.pk)[:8], + args=[A("pk")], + orderable=False, + verbose_name="Notice ID", + ) + platform = tables.LinkColumn( + "dcim:platform", + text=lambda record: record.software_version.platform.name, + args=[A("software_version.platform.pk")], + orderable=True, + ) + software_version = tables.LinkColumn( + text=lambda record: record.software_version.version, verbose_name="Software Version" + ) + device_type = tables.LinkColumn(verbose_name="Device Type") + + release_date = tables.DateColumn(accessor="software_version.release_date") + + documentation_url = tables.TemplateColumn( + template_code="""{% if record.documentation_url %} + + + + {% else %} + — + {% endif %}""", + verbose_name="Documentation", + ) + actions = ButtonsColumn(SoftwareNotice, buttons=("changelog", "edit", "delete")) + + class Meta(BaseTable.Meta): + """Meta attributes.""" + + model = SoftwareNotice + fields = ( + "pk", + "id", + "platform", + "software_version", + "device_type", + "release_date", + "end_of_sale", + "end_of_sw_releases", + "end_of_security_patches", + "end_of_support", + "documentation_url", + "actions", + ) + + class ValidatedSoftwareLCMTable(BaseTable): """Table for ValidatedSoftwareLCMListView.""" diff --git a/nautobot_device_lifecycle_mgmt/templates/nautobot_device_lifecycle_mgmt/softwarenotice_retrieve.html b/nautobot_device_lifecycle_mgmt/templates/nautobot_device_lifecycle_mgmt/softwarenotice_retrieve.html new file mode 100644 index 00000000..78af15f6 --- /dev/null +++ b/nautobot_device_lifecycle_mgmt/templates/nautobot_device_lifecycle_mgmt/softwarenotice_retrieve.html @@ -0,0 +1,68 @@ +{% extends 'generic/object_detail.html' %} + +{% block masthead %} +
Software Version | +{{ object.software_version }} | +
Device Type | +{% if object.device_type %}{{ object.device_type }}{% else %} — {% endif %} | +
Devices | +{{ device_count }} | +
Release Date | +{% if object.software_version.release_date %} {{ object.software_version.release_date }} {% else %} — {% endif %} | +
End of Sale | +{% if object.end_of_sale %} {{ object.end_of_sale }} {% else %} — {% endif %} | +
End of Software Releases | +{% if object.end_of_sw_releases %} {{ object.end_of_sw_releases }} {% else %} — {% endif %} | +
End of Security Patches | ++ {% if object.end_of_security_patches %} {{ object.end_of_security_patches }} {% else %} — {% endif %} + | +
End of Support | +{% if object.end_of_support %} {{ object.end_of_support }} {% else %} — {% endif %} | +
Documentation URL | ++ {% if object.documentation_url %} + {{ object.documentation_url }} + {% else %} + — + {% endif %} + | +
Comments | +
+ {% if object.comments %}
+ {{ object.comments }}+ {% else %} — {% endif %} + |
+