From ede881d2c37c0b10fc08c93d36e6c5b41922b289 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Mon, 11 May 2026 22:50:10 -0500 Subject: [PATCH 1/9] Closes: #157 - Modernize Lifecycle Plugin to take advantage of new NetBox UI elements and functions --- netbox_lifecycle/filtersets/contract.py | 40 +- netbox_lifecycle/filtersets/hardware.py | 3 + netbox_lifecycle/filtersets/license.py | 8 + netbox_lifecycle/models/contract.py | 4 +- netbox_lifecycle/navigation.py | 103 +++- netbox_lifecycle/template_content.py | 332 +++++++----- .../netbox_lifecycle/generic/base.html | 1 - .../netbox_lifecycle/hardwarelifecycle.html | 78 --- .../netbox_lifecycle/htmx/contract_list.html | 23 - .../htmx/device_contracts.html | 130 ----- .../htmx/device_licenses.html | 47 -- .../htmx/virtualmachine_contracts.html | 130 ----- .../htmx/virtualmachine_licenses.html | 47 -- .../inc/contract_card_placeholder.html | 12 - .../inc/hardware_lifecycle_info.html | 43 -- .../inc/license_card_placeholder.html | 12 - .../inc/support_contract_info.html | 45 -- .../templates/netbox_lifecycle/license.html | 46 -- .../netbox_lifecycle/license/assignments.html | 49 -- .../netbox_lifecycle/licenseassignment.html | 61 --- .../netbox_lifecycle/supportcontract.html | 78 --- .../supportcontract/assignments.html | 49 -- .../supportcontractassignment.html | 74 --- .../netbox_lifecycle/supportsku.html | 46 -- .../templates/netbox_lifecycle/vendor.html | 42 -- netbox_lifecycle/templatetags/__init__.py | 0 .../templatetags/netbox_lifecycle_filters.py | 38 -- netbox_lifecycle/tests/test_htmx_views.py | 511 ------------------ netbox_lifecycle/tests/test_templatetags.py | 39 -- netbox_lifecycle/ui/__init__.py | 18 + netbox_lifecycle/ui/attributes/__init__.py | 3 + netbox_lifecycle/ui/attributes/datetime.py | 35 ++ netbox_lifecycle/ui/panels/__init__.py | 18 + netbox_lifecycle/ui/panels/contract.py | 86 +++ netbox_lifecycle/ui/panels/hardware.py | 32 ++ netbox_lifecycle/ui/panels/license.py | 42 ++ netbox_lifecycle/urls.py | 293 +--------- netbox_lifecycle/views/__init__.py | 1 - netbox_lifecycle/views/contract.py | 126 +++-- netbox_lifecycle/views/hardware.py | 43 +- netbox_lifecycle/views/htmx.py | 192 ------- netbox_lifecycle/views/license.py | 63 ++- ruff.toml | 4 +- 43 files changed, 772 insertions(+), 2275 deletions(-) delete mode 100644 netbox_lifecycle/templates/netbox_lifecycle/generic/base.html delete mode 100644 netbox_lifecycle/templates/netbox_lifecycle/hardwarelifecycle.html delete mode 100644 netbox_lifecycle/templates/netbox_lifecycle/htmx/contract_list.html delete mode 100644 netbox_lifecycle/templates/netbox_lifecycle/htmx/device_contracts.html delete mode 100644 netbox_lifecycle/templates/netbox_lifecycle/htmx/device_licenses.html delete mode 100644 netbox_lifecycle/templates/netbox_lifecycle/htmx/virtualmachine_contracts.html delete mode 100644 netbox_lifecycle/templates/netbox_lifecycle/htmx/virtualmachine_licenses.html delete mode 100644 netbox_lifecycle/templates/netbox_lifecycle/inc/contract_card_placeholder.html delete mode 100644 netbox_lifecycle/templates/netbox_lifecycle/inc/hardware_lifecycle_info.html delete mode 100644 netbox_lifecycle/templates/netbox_lifecycle/inc/license_card_placeholder.html delete mode 100644 netbox_lifecycle/templates/netbox_lifecycle/inc/support_contract_info.html delete mode 100644 netbox_lifecycle/templates/netbox_lifecycle/license.html delete mode 100644 netbox_lifecycle/templates/netbox_lifecycle/license/assignments.html delete mode 100644 netbox_lifecycle/templates/netbox_lifecycle/licenseassignment.html delete mode 100644 netbox_lifecycle/templates/netbox_lifecycle/supportcontract.html delete mode 100644 netbox_lifecycle/templates/netbox_lifecycle/supportcontract/assignments.html delete mode 100644 netbox_lifecycle/templates/netbox_lifecycle/supportcontractassignment.html delete mode 100644 netbox_lifecycle/templates/netbox_lifecycle/supportsku.html delete mode 100644 netbox_lifecycle/templates/netbox_lifecycle/vendor.html delete mode 100644 netbox_lifecycle/templatetags/__init__.py delete mode 100644 netbox_lifecycle/templatetags/netbox_lifecycle_filters.py delete mode 100644 netbox_lifecycle/tests/test_htmx_views.py delete mode 100644 netbox_lifecycle/tests/test_templatetags.py create mode 100644 netbox_lifecycle/ui/__init__.py create mode 100644 netbox_lifecycle/ui/attributes/__init__.py create mode 100644 netbox_lifecycle/ui/attributes/datetime.py create mode 100644 netbox_lifecycle/ui/panels/__init__.py create mode 100644 netbox_lifecycle/ui/panels/contract.py create mode 100644 netbox_lifecycle/ui/panels/hardware.py create mode 100644 netbox_lifecycle/ui/panels/license.py delete mode 100644 netbox_lifecycle/views/htmx.py diff --git a/netbox_lifecycle/filtersets/contract.py b/netbox_lifecycle/filtersets/contract.py index 6c6f9e8..653be04 100644 --- a/netbox_lifecycle/filtersets/contract.py +++ b/netbox_lifecycle/filtersets/contract.py @@ -1,8 +1,11 @@ import django_filters -from dcim.models import Device, Manufacturer, Module +from django.utils import timezone + +from dcim.models import Device, Manufacturer, Module, DeviceType from django.db.models import Q from django.utils.translation import gettext as _ from netbox.filtersets import NetBoxModelFilterSet +from utilities.filtersets import register_filterset from virtualization.models import VirtualMachine from netbox_lifecycle.models import ( @@ -21,6 +24,7 @@ ) +@register_filterset class VendorFilterSet(NetBoxModelFilterSet): class Meta: @@ -38,6 +42,7 @@ def search(self, queryset, name, value): return queryset.filter(qs_filter).distinct() +@register_filterset class SupportSKUFilterSet(NetBoxModelFilterSet): manufacturer_id = django_filters.ModelMultipleChoiceFilter( field_name='manufacturer', @@ -56,7 +61,7 @@ class Meta: fields = ( 'id', 'q', - 'sku', + 'manufacturer_id', ) def search(self, queryset, name, value): @@ -66,6 +71,7 @@ def search(self, queryset, name, value): return queryset.filter(qs_filter).distinct() +@register_filterset class SupportContractFilterSet(NetBoxModelFilterSet): vendor_id = django_filters.ModelMultipleChoiceFilter( field_name='vendor', @@ -85,6 +91,7 @@ class Meta: 'id', 'q', 'contract_id', + 'vendor_id', ) def search(self, queryset, name, value): @@ -94,6 +101,7 @@ def search(self, queryset, name, value): return queryset.filter(qs_filter).distinct() +@register_filterset class SupportContractAssignmentFilterSet(NetBoxModelFilterSet): contract_id = django_filters.ModelMultipleChoiceFilter( field_name='contract', @@ -117,6 +125,17 @@ class SupportContractAssignmentFilterSet(NetBoxModelFilterSet): to_field_name='sku', label=_('SKU'), ) + device_type_id = django_filters.ModelMultipleChoiceFilter( + field_name='device__device_type', + queryset=DeviceType.objects.all(), + label=_('Device Type (ID)'), + ) + device_type = django_filters.ModelMultipleChoiceFilter( + field_name='device__device_type__model', + queryset=DeviceType.objects.all(), + to_field_name='model', + label=_('Device Type (model)'), + ) device_id = django_filters.ModelMultipleChoiceFilter( field_name='device', queryset=Device.objects.all(), @@ -167,12 +186,24 @@ class SupportContractAssignmentFilterSet(NetBoxModelFilterSet): to_field_name='status', label=_('Device Status'), ) + expired = django_filters.BooleanFilter( + method='filter_expired', + label=_('Expired'), + ) class Meta: model = SupportContractAssignment fields = ( 'id', 'q', + 'contract_id', + 'sku_id', + 'device_type_id', + 'device_id', + 'module_id', + 'virtual_machine_id', + 'license_id', + 'device_status', ) def search(self, queryset, name, value): @@ -191,3 +222,8 @@ def search(self, queryset, name, value): | Q(license__license__name__icontains=value) ) return queryset.filter(qs_filter).distinct() + + def filter_expired(self, queryset, name, value): + if value: + return queryset.filter(end__lt=timezone.now()) + return queryset.exclude(end__lt=timezone.now()) diff --git a/netbox_lifecycle/filtersets/hardware.py b/netbox_lifecycle/filtersets/hardware.py index 169c1da..226893f 100644 --- a/netbox_lifecycle/filtersets/hardware.py +++ b/netbox_lifecycle/filtersets/hardware.py @@ -9,7 +9,10 @@ __all__ = ('HardwareLifecycleFilterSet',) +from utilities.filtersets import register_filterset + +@register_filterset class HardwareLifecycleFilterSet(NetBoxModelFilterSet): assigned_object_type_id = django_filters.ModelMultipleChoiceFilter( queryset=ContentType.objects.all() diff --git a/netbox_lifecycle/filtersets/license.py b/netbox_lifecycle/filtersets/license.py index 633a876..9f7e36b 100644 --- a/netbox_lifecycle/filtersets/license.py +++ b/netbox_lifecycle/filtersets/license.py @@ -3,6 +3,7 @@ from django.db.models import Q from django.utils.translation import gettext as _ from netbox.filtersets import NetBoxModelFilterSet +from utilities.filtersets import register_filterset from virtualization.models import VirtualMachine from netbox_lifecycle.models import License, LicenseAssignment, Vendor @@ -13,6 +14,7 @@ ) +@register_filterset class LicenseFilterSet(NetBoxModelFilterSet): manufacturer_id = django_filters.ModelMultipleChoiceFilter( field_name='manufacturer', @@ -32,6 +34,7 @@ class Meta: 'id', 'q', 'name', + 'manufacturer_id', ) def search(self, queryset, name, value): @@ -41,6 +44,7 @@ def search(self, queryset, name, value): return queryset.filter(qs_filter).distinct() +@register_filterset class LicenseAssignmentFilterSet(NetBoxModelFilterSet): license_id = django_filters.ModelMultipleChoiceFilter( field_name='license', @@ -92,6 +96,10 @@ class Meta: fields = ( 'id', 'q', + 'license_id', + 'vendor_id', + 'device_id', + 'virtual_machine_id', ) def search(self, queryset, name, value): diff --git a/netbox_lifecycle/models/contract.py b/netbox_lifecycle/models/contract.py index 1467e87..b90409a 100644 --- a/netbox_lifecycle/models/contract.py +++ b/netbox_lifecycle/models/contract.py @@ -51,13 +51,15 @@ class SupportSKU(PrimaryModel): on_delete=models.CASCADE, related_name='skus', ) - sku = models.CharField(max_length=100) + sku = models.CharField(verbose_name=_('SKU'), max_length=100) clone_fields = ('manufacturer',) prerequisite_models = ('dcim.Manufacturer',) class Meta: ordering = ['manufacturer', 'sku'] + verbose_name = 'Support SKU' + verbose_name_plural = 'Support SKUs' constraints = ( models.UniqueConstraint( 'manufacturer', diff --git a/netbox_lifecycle/navigation.py b/netbox_lifecycle/navigation.py index d63565d..7181f3f 100644 --- a/netbox_lifecycle/navigation.py +++ b/netbox_lifecycle/navigation.py @@ -1,40 +1,141 @@ -from netbox.plugins import PluginMenu, PluginMenuItem +from netbox.plugins import PluginMenu, PluginMenuItem, PluginMenuButton + +COL_ADD = 'mdi mdi-plus' +COL_IMPORT = 'mdi mdi-upload' lifecycle = PluginMenuItem( link='plugins:netbox_lifecycle:hardwarelifecycle_list', link_text='Hardware Lifecycle', permissions=['netbox_lifecycle.view_hardwarelifecycle'], + buttons=( + PluginMenuButton( + link='plugins:netbox_lifecycle:hardwarelifecycle_add', + title='Add', + icon_class=COL_ADD, + permissions=['netbox_lifecycle.add_hardwarelifecycle'], + ), + PluginMenuButton( + link='plugins:netbox_lifecycle:hardwarelifecycle_bulk_import', + title='Import', + icon_class=COL_IMPORT, + permissions=['netbox_lifecycle.add_hardwarelifecycle'], + ), + ), ) vendors = PluginMenuItem( link='plugins:netbox_lifecycle:vendor_list', link_text='Vendors', permissions=['netbox_lifecycle.view_vendor'], + buttons=( + PluginMenuButton( + link='plugins:netbox_lifecycle:vendor_add', + title='Add', + icon_class=COL_ADD, + permissions=['netbox_lifecycle.add_vendor'], + ), + PluginMenuButton( + link='plugins:netbox_lifecycle:vendor_bulk_import', + title='Import', + icon_class=COL_IMPORT, + permissions=['netbox_lifecycle.add_vendor'], + ), + ), ) skus = PluginMenuItem( link='plugins:netbox_lifecycle:supportsku_list', link_text='Support SKUs', permissions=['netbox_lifecycle.view_supportsku'], + buttons=( + PluginMenuButton( + link='plugins:netbox_lifecycle:supportsku_add', + title='Add', + icon_class=COL_ADD, + permissions=['netbox_lifecycle.add_supportsku'], + ), + PluginMenuButton( + link='plugins:netbox_lifecycle:supportsku_bulk_import', + title='Import', + icon_class=COL_IMPORT, + permissions=['netbox_lifecycle.add_supportsku'], + ), + ), ) contracts = PluginMenuItem( link='plugins:netbox_lifecycle:supportcontract_list', link_text='Support Contracts', permissions=['netbox_lifecycle.view_supportcontract'], + buttons=( + PluginMenuButton( + link='plugins:netbox_lifecycle:supportcontract_add', + title='Add', + icon_class=COL_ADD, + permissions=['netbox_lifecycle.add_supportcontract'], + ), + PluginMenuButton( + link='plugins:netbox_lifecycle:supportcontract_bulk_import', + title='Import', + icon_class=COL_IMPORT, + permissions=['netbox_lifecycle.add_supportcontract'], + ), + ), ) contract_assignments = PluginMenuItem( link='plugins:netbox_lifecycle:supportcontractassignment_list', link_text='Support Assignments', permissions=['netbox_lifecycle.view_supportcontractassignment'], + buttons=( + PluginMenuButton( + link='plugins:netbox_lifecycle:supportcontractassignment_add', + title='Add', + icon_class=COL_ADD, + permissions=['netbox_lifecycle.add_supportcontractassignment'], + ), + PluginMenuButton( + link='plugins:netbox_lifecycle:supportcontractassignment_bulk_import', + title='Import', + icon_class=COL_IMPORT, + permissions=['netbox_lifecycle.add_supportcontractassignment'], + ), + ), ) licenses = PluginMenuItem( link='plugins:netbox_lifecycle:license_list', link_text='Licenses', permissions=['netbox_lifecycle.view_license'], + buttons=( + PluginMenuButton( + link='plugins:netbox_lifecycle:license_add', + title='Add', + icon_class=COL_ADD, + permissions=['netbox_lifecycle.add_license'], + ), + PluginMenuButton( + link='plugins:netbox_lifecycle:license_bulk_import', + title='Import', + icon_class=COL_IMPORT, + permissions=['netbox_lifecycle.add_license'], + ), + ), ) license_assignments = PluginMenuItem( link='plugins:netbox_lifecycle:licenseassignment_list', link_text='License Assignments', permissions=['netbox_lifecycle.view_licenseassignment'], + buttons=( + PluginMenuButton( + link='plugins:netbox_lifecycle:licenseassignment_add', + title='Add', + icon_class=COL_ADD, + permissions=['netbox_lifecycle.add_licenseassignment'], + ), + PluginMenuButton( + link='plugins:netbox_lifecycle:licenseassignment_bulk_import', + title='Import', + icon_class=COL_IMPORT, + permissions=['netbox_lifecycle.add_licenseassignment'], + ), + ), ) diff --git a/netbox_lifecycle/template_content.py b/netbox_lifecycle/template_content.py index 052450f..da3fbd6 100644 --- a/netbox_lifecycle/template_content.py +++ b/netbox_lifecycle/template_content.py @@ -1,19 +1,93 @@ from django.conf import settings from django.contrib.contenttypes.models import ContentType -from django.urls import reverse +from django.utils.translation import gettext_lazy as _ from netbox.plugins import PluginTemplateExtension +from netbox.ui import panels, actions from .models import hardware -from .models.license import LicenseAssignment +from .ui import HardwareLifecyclePanel, HardwareLifecycleDatesPanel PLUGIN_SETTINGS = settings.PLUGINS_CONFIG.get('netbox_lifecycle', {}) -class BaseLifecycleContent(PluginTemplateExtension): - """Base class for lifecycle template extensions.""" +class BaseMixin: + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.model_name = None + self.field_name = None + self.lifecycle_content_type = None + self.lifecycle_object_id_attr = None + + def get_context(self, context): + """ + Return the context data to be used when rendering the panel. + Borrowed from netbox/ui/panels.py + + Parameters: + context (dict): The template context + """ + obj = context.get('object') + self.model_name = obj._meta.model_name if obj is not None else None + if self.model_name in ['device', 'devicetype']: + self.lifecycle_content_type = 'devicetype' + self.lifecycle_object_id_attr = 'device_type_id' + elif self.model_name in ['module', 'moduletype']: + self.lifecycle_content_type = 'moduletype' + self.lifecycle_object_id_attr = 'module_type_id' + else: + self.lifecycle_content_type = None + + if self.model_name == 'device': + self.field_name = 'device_id' + elif self.model_name == 'module': + self.field_name = 'module_id' + elif self.model_name == 'virtualmachine': + self.field_name = 'virtual_machine_id' + else: + self.field_name = None + + return { + 'request': context.get('request'), + 'object': context.get('object'), + 'perms': context.get('perms'), + 'panel_class': self.__class__.__name__, + } - lifecycle_content_type = None # Override: 'devicetype' or 'moduletype' - lifecycle_object_id_attr = None # Override: attribute name for object ID + def right_page(self): + result = '' + if hasattr(self, '_render_lifecycle_info'): + result += self._render_lifecycle_info('right_page') + if hasattr(self, '_render_contract_card'): + result += self._render_contract_card('right_page', expired=False) + result += self._render_contract_card('right_page', expired=True) + if hasattr(self, '_render_license_card'): + result += self._render_license_card('right_page') + return result + + def left_page(self): + result = '' + if hasattr(self, '_render_lifecycle_info'): + result += self._render_lifecycle_info('left_page') + if hasattr(self, '_render_contract_card'): + result += self._render_contract_card('left_page', expired=False) + result += self._render_contract_card('left_page', expired=True) + if hasattr(self, '_render_license_card'): + result += self._render_license_card('left_page') + return result + + def full_width_page(self): + result = '' + if hasattr(self, '_render_lifecycle_info'): + result += self._render_lifecycle_info('full_width_page') + if hasattr(self, '_render_contract_card'): + result += self._render_contract_card('full_width_page', expired=False) + result += self._render_contract_card('full_width_page', expired=True) + if hasattr(self, '_render_license_card'): + result += self._render_license_card('full_width_page') + return result + + +class LifecycleMixin: def get_lifecycle_card_position(self): return PLUGIN_SETTINGS.get('lifecycle_card_position', 'right_page') @@ -29,178 +103,146 @@ def _get_lifecycle_info(self): assigned_object_type_id=content_type.id, ).first() - def _render_lifecycle_info(self): - return self.render( - 'netbox_lifecycle/inc/hardware_lifecycle_info.html', - extra_context={'lifecycle_info': self._get_lifecycle_info()}, - ) - - def right_page(self): - if self.get_lifecycle_card_position() == 'right_page': - return self._render_lifecycle_info() - return '' + def _render_lifecycle_info(self, location=None): + if self.get_lifecycle_card_position() != location: + return '' - def left_page(self): - if self.get_lifecycle_card_position() == 'left_page': - return self._render_lifecycle_info() - return '' + context = self.get_context(self.context) + obj = self._get_lifecycle_info() + context['object'] = obj - def full_width_page(self): - if self.get_lifecycle_card_position() == 'full_width_page': - return self._render_lifecycle_info() - return '' + content = HardwareLifecyclePanel().render( + context + ) + HardwareLifecycleDatesPanel().render(context) + return content -class DeviceLifecycleContent(BaseLifecycleContent): - models = ['dcim.device'] - lifecycle_content_type = 'devicetype' - lifecycle_object_id_attr = 'device_type_id' +class ContractMixin: def get_contract_card_position(self): return PLUGIN_SETTINGS.get('contract_card_position', 'right_page') - def get_license_card_position(self): - return PLUGIN_SETTINGS.get('license_card_position', 'right_page') + def _render_contract_card(self, location=None, expired=None): + if self.get_contract_card_position() != location: + return '' - def _render_contract_card(self): - obj = self.context.get('object') - return self.render( - 'netbox_lifecycle/inc/contract_card_placeholder.html', - extra_context={ - 'htmx_url': reverse( - 'plugins:netbox_lifecycle:device_contracts_htmx', - kwargs={'pk': obj.pk}, + title = _('Contracts') + filter = {} + action = [] + if expired is True or expired is False: + filter = {'expired': expired} + title = _('Expired Contracts') if expired else _('Active Contracts') + + if not expired: + action = [ + actions.AddObject( + 'netbox_lifecycle.SupportContractAssignment', + url_params={ + self.model_name: lambda ctx: ctx['object'].pk, + }, ), - }, + ] + + context = self.get_context(self.context) + panel = panels.ObjectsTablePanel( + title=title, + model='netbox_lifecycle.supportcontractassignment', + filters={self.field_name: lambda ctx: ctx['object'].pk, **filter}, + include_columns=[ + 'contract', + 'sku', + ], + exclude_columns=[ + 'device_name', + 'module_name', + 'virtual_machine_name', + 'license_name', + 'device_model', + 'device_serial', + 'module_serial', + 'device_status', + 'virtual_machine_status', + 'quantity', + 'renewal', + 'end', + 'description', + 'comments', + 'actions', + ], + actions=action, ) + return panel.render(context=context) - def _render_license_card(self): - obj = self.context.get('object') - if not LicenseAssignment.objects.filter(device=obj).exists(): + +class LicenseMixin: + + def get_license_card_position(self): + return PLUGIN_SETTINGS.get('license_card_position', 'right_page') + + def _render_license_card(self, location=None, exclude=None, include=None): + if self.get_license_card_position() != location: return '' - return self.render( - 'netbox_lifecycle/inc/license_card_placeholder.html', - extra_context={ - 'htmx_url': reverse( - 'plugins:netbox_lifecycle:device_licenses_htmx', - kwargs={'pk': obj.pk}, - ), - }, + + action = [ + actions.AddObject( + 'netbox_lifecycle.LicenseAssignment', + url_params={ + self.model_name: lambda ctx: ctx['object'].pk, + }, + ), + ] + + context = self.get_context(self.context) + panel = panels.ObjectsTablePanel( + title=_('Licenses'), + model='netbox_lifecycle.licenseassignment', + filters={self.field_name: lambda ctx: ctx['object'].pk}, + include_columns=[ + 'vendor' 'license', + 'quantity', + ], + exclude_columns=[ + 'device', + 'virtual_machine', + 'description', + 'comments', + 'actions', + ], + actions=action, ) + return panel.render(context=context) - def right_page(self): - result = '' - if self.get_lifecycle_card_position() == 'right_page': - result += self._render_lifecycle_info() - if self.get_contract_card_position() == 'right_page': - result += self._render_contract_card() - if self.get_license_card_position() == 'right_page': - result += self._render_license_card() - return result - def left_page(self): - result = '' - if self.get_lifecycle_card_position() == 'left_page': - result += self._render_lifecycle_info() - if self.get_contract_card_position() == 'left_page': - result += self._render_contract_card() - if self.get_license_card_position() == 'left_page': - result += self._render_license_card() - return result - - def full_width_page(self): - result = '' - if self.get_lifecycle_card_position() == 'full_width_page': - result += self._render_lifecycle_info() - if self.get_contract_card_position() == 'full_width_page': - result += self._render_contract_card() - if self.get_license_card_position() == 'full_width_page': - result += self._render_license_card() - return result +class DeviceContent( + ContractMixin, LicenseMixin, LifecycleMixin, BaseMixin, PluginTemplateExtension +): + models = ['dcim.device'] + lifecycle_content_type = 'devicetype' + lifecycle_object_id_attr = 'device_type_id' -class ModuleLifecycleContent(BaseLifecycleContent): +class ModuleLifecycleContent( + ContractMixin, LifecycleMixin, BaseMixin, PluginTemplateExtension +): models = ['dcim.module'] - lifecycle_content_type = 'moduletype' - lifecycle_object_id_attr = 'module_type_id' -class DeviceTypeLifecycleContent(BaseLifecycleContent): +class DeviceTypeLifecycleContent(LifecycleMixin, BaseMixin, PluginTemplateExtension): models = ['dcim.devicetype'] - lifecycle_content_type = 'devicetype' - lifecycle_object_id_attr = 'id' -class ModuleTypeLifecycleContent(BaseLifecycleContent): +class ModuleTypeLifecycleContent(LifecycleMixin, BaseMixin, PluginTemplateExtension): models = ['dcim.moduletype'] - lifecycle_content_type = 'moduletype' - lifecycle_object_id_attr = 'id' -class VirtualMachineContractContent(PluginTemplateExtension): +class VirtualMachineContractContent(LicenseMixin, BaseMixin, PluginTemplateExtension): """Template extension for VirtualMachine detail pages showing contracts and licenses.""" models = ['virtualization.virtualmachine'] - def get_contract_card_position(self): - return PLUGIN_SETTINGS.get('contract_card_position', 'right_page') - - def get_license_card_position(self): - return PLUGIN_SETTINGS.get('license_card_position', 'right_page') - - def _render_contract_card(self): - obj = self.context.get('object') - return self.render( - 'netbox_lifecycle/inc/contract_card_placeholder.html', - extra_context={ - 'htmx_url': reverse( - 'plugins:netbox_lifecycle:virtualmachine_contracts_htmx', - kwargs={'pk': obj.pk}, - ), - }, - ) - - def _render_license_card(self): - obj = self.context.get('object') - if not LicenseAssignment.objects.filter(virtual_machine=obj).exists(): - return '' - return self.render( - 'netbox_lifecycle/inc/license_card_placeholder.html', - extra_context={ - 'htmx_url': reverse( - 'plugins:netbox_lifecycle:virtualmachine_licenses_htmx', - kwargs={'pk': obj.pk}, - ), - }, - ) - - def right_page(self): - result = '' - if self.get_contract_card_position() == 'right_page': - result += self._render_contract_card() - if self.get_license_card_position() == 'right_page': - result += self._render_license_card() - return result - - def left_page(self): - result = '' - if self.get_contract_card_position() == 'left_page': - result += self._render_contract_card() - if self.get_license_card_position() == 'left_page': - result += self._render_license_card() - return result - - def full_width_page(self): - result = '' - if self.get_contract_card_position() == 'full_width_page': - result += self._render_contract_card() - if self.get_license_card_position() == 'full_width_page': - result += self._render_license_card() - return result - template_extensions = ( - DeviceLifecycleContent, + DeviceContent, ModuleLifecycleContent, DeviceTypeLifecycleContent, ModuleTypeLifecycleContent, diff --git a/netbox_lifecycle/templates/netbox_lifecycle/generic/base.html b/netbox_lifecycle/templates/netbox_lifecycle/generic/base.html deleted file mode 100644 index f59544b..0000000 --- a/netbox_lifecycle/templates/netbox_lifecycle/generic/base.html +++ /dev/null @@ -1 +0,0 @@ -{% extends 'generic/object.html' %} \ No newline at end of file diff --git a/netbox_lifecycle/templates/netbox_lifecycle/hardwarelifecycle.html b/netbox_lifecycle/templates/netbox_lifecycle/hardwarelifecycle.html deleted file mode 100644 index 0fbad41..0000000 --- a/netbox_lifecycle/templates/netbox_lifecycle/hardwarelifecycle.html +++ /dev/null @@ -1,78 +0,0 @@ -{% extends 'generic/object.html' %} -{% load buttons %} -{% load custom_links %} -{% load helpers %} -{% load netbox_lifecycle_filters %} -{% load perms %} -{% load plugins %} -{% load tabs %} - -{% block content %} -
-
-
-
{{ object.assigned_object_type.name|capfirst}}
-
- - - - - - - - - - - - - -
Manufacturer{{ object.assigned_object.manufacturer|linkify }}
Object{{ object.assigned_object|linkify }}
Description{{ object.description }}
-
-
-
-
Dates
-
- - - - - - - - - - - - - - - - - - - - - - - - - -
End of Sale{{ object.end_of_sale|placeholder }}
End of Maintenance Updates{{ object.end_of_maintenance|placeholder }}
End of Security Updates{{ object.end_of_security|placeholder }}
Last Support Contract Attach{{ object.last_contract_attach|placeholder }}
Last Support Contract Renewal{{ object.last_contract_renewal|placeholder }}
End of Support{{ object.end_of_support|placeholder }}
-
-
- {% plugin_left_page object %} - {% include 'inc/panels/tags.html' %} -
-
- {% include 'inc/panels/related_objects.html' %} - {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/comments.html' %} - {% plugin_right_page object %} -
-
-
-
- {% plugin_full_width_page object %} -
-
-{% endblock %} diff --git a/netbox_lifecycle/templates/netbox_lifecycle/htmx/contract_list.html b/netbox_lifecycle/templates/netbox_lifecycle/htmx/contract_list.html deleted file mode 100644 index e8a3e4f..0000000 --- a/netbox_lifecycle/templates/netbox_lifecycle/htmx/contract_list.html +++ /dev/null @@ -1,23 +0,0 @@ -{% load i18n %} -{% load netbox_lifecycle_filters %} - - - - - - - - - - - - {% for assignment in assignments %} - - - - - - - {% endfor %} - -
{% trans "Contract" %}{% trans "SKU" %}{% trans "Vendor" %}{% trans "Ended" %}
{{ assignment.contract.contract_id }}{% if assignment.sku %}{{ assignment.sku.sku }}{% else %}-{% endif %}{{ assignment.contract.vendor|default:"-" }}{{ assignment.end_date|date:"Y-m-d"|default:"-" }}
diff --git a/netbox_lifecycle/templates/netbox_lifecycle/htmx/device_contracts.html b/netbox_lifecycle/templates/netbox_lifecycle/htmx/device_contracts.html deleted file mode 100644 index 6c5ec0e..0000000 --- a/netbox_lifecycle/templates/netbox_lifecycle/htmx/device_contracts.html +++ /dev/null @@ -1,130 +0,0 @@ -{% load i18n %} -{% load netbox_lifecycle_filters %} - -
-
{% trans "Support Contracts" %}
- {% if active or future or unspecified or expired_count %} -
- -
- {% if active %} -
- - - - - - - - - - - {% for assignment in active %} - - - - - - - {% endfor %} - -
{% trans "Contract" %}{% trans "SKU" %}{% trans "Vendor" %}{% trans "End Date" %}
{{ assignment.contract.contract_id }}{% if assignment.sku %}{{ assignment.sku.sku }}{% else %}-{% endif %}{{ assignment.contract.vendor|default:"-" }}{{ assignment.end_date|date:"Y-m-d" }}
-
- {% endif %} - {% if future %} -
- - - - - - - - - - - - {% for assignment in future %} - - - - - - - - {% endfor %} - -
{% trans "Contract" %}{% trans "SKU" %}{% trans "Vendor" %}{% trans "Starts" %}{% trans "Ends" %}
{{ assignment.contract.contract_id }}{% if assignment.sku %}{{ assignment.sku.sku }}{% else %}-{% endif %}{{ assignment.contract.vendor|default:"-" }}{{ assignment.contract.start|date:"Y-m-d" }}{{ assignment.end_date|date:"Y-m-d" }}
-
- {% endif %} - {% if unspecified %} -
- - - - - - - - - - - {% for assignment in unspecified %} - - - - - - - {% endfor %} - -
{% trans "Contract" %}{% trans "SKU" %}{% trans "Vendor" %}{% trans "Started" %}
{{ assignment.contract.contract_id }}{% if assignment.sku %}{{ assignment.sku.sku }}{% else %}-{% endif %}{{ assignment.contract.vendor|default:"-" }}{{ assignment.contract.start|date:"Y-m-d"|default:"-" }}
-
- {% endif %} - {% if expired_count %} -
-
-
-
-
- {% endif %} -
-
- {% else %} -
- {% trans "None" %} -
- {% endif %} -
diff --git a/netbox_lifecycle/templates/netbox_lifecycle/htmx/device_licenses.html b/netbox_lifecycle/templates/netbox_lifecycle/htmx/device_licenses.html deleted file mode 100644 index 6150c84..0000000 --- a/netbox_lifecycle/templates/netbox_lifecycle/htmx/device_licenses.html +++ /dev/null @@ -1,47 +0,0 @@ -{% load i18n %} - -
-
- {% trans "Licenses" %} - -
- {% if assignments %} -
- - - - - - - - - - - - {% for assignment in assignments %} - - - - - - - - {% endfor %} - -
{% trans "License" %}{% trans "Manufacturer" %}{% trans "Vendor" %}{% trans "Quantity" %}{% trans "Description" %}
{{ assignment.license.name }}{{ assignment.license.manufacturer|default:"-" }}{{ assignment.vendor|default:"-" }}{{ assignment.quantity|default:"-" }}{{ assignment.description|default:"-" }}
- {% if show_all_url %} - - {% endif %} -
- {% else %} -
- {% trans "None" %} -
- {% endif %} -
diff --git a/netbox_lifecycle/templates/netbox_lifecycle/htmx/virtualmachine_contracts.html b/netbox_lifecycle/templates/netbox_lifecycle/htmx/virtualmachine_contracts.html deleted file mode 100644 index 9d94a89..0000000 --- a/netbox_lifecycle/templates/netbox_lifecycle/htmx/virtualmachine_contracts.html +++ /dev/null @@ -1,130 +0,0 @@ -{% load i18n %} -{% load netbox_lifecycle_filters %} - -
-
{% trans "Support Contracts" %}
- {% if active or future or unspecified or expired_count %} -
- -
- {% if active %} -
- - - - - - - - - - - {% for assignment in active %} - - - - - - - {% endfor %} - -
{% trans "Contract" %}{% trans "SKU" %}{% trans "Vendor" %}{% trans "End Date" %}
{{ assignment.contract.contract_id }}{% if assignment.sku %}{{ assignment.sku.sku }}{% else %}-{% endif %}{{ assignment.contract.vendor|default:"-" }}{{ assignment.end_date|date:"Y-m-d" }}
-
- {% endif %} - {% if future %} -
- - - - - - - - - - - - {% for assignment in future %} - - - - - - - - {% endfor %} - -
{% trans "Contract" %}{% trans "SKU" %}{% trans "Vendor" %}{% trans "Starts" %}{% trans "Ends" %}
{{ assignment.contract.contract_id }}{% if assignment.sku %}{{ assignment.sku.sku }}{% else %}-{% endif %}{{ assignment.contract.vendor|default:"-" }}{{ assignment.contract.start|date:"Y-m-d" }}{{ assignment.end_date|date:"Y-m-d" }}
-
- {% endif %} - {% if unspecified %} -
- - - - - - - - - - - {% for assignment in unspecified %} - - - - - - - {% endfor %} - -
{% trans "Contract" %}{% trans "SKU" %}{% trans "Vendor" %}{% trans "Started" %}
{{ assignment.contract.contract_id }}{% if assignment.sku %}{{ assignment.sku.sku }}{% else %}-{% endif %}{{ assignment.contract.vendor|default:"-" }}{{ assignment.contract.start|date:"Y-m-d"|default:"-" }}
-
- {% endif %} - {% if expired_count %} -
-
-
-
-
- {% endif %} -
-
- {% else %} -
- {% trans "None" %} -
- {% endif %} -
diff --git a/netbox_lifecycle/templates/netbox_lifecycle/htmx/virtualmachine_licenses.html b/netbox_lifecycle/templates/netbox_lifecycle/htmx/virtualmachine_licenses.html deleted file mode 100644 index 4d9f549..0000000 --- a/netbox_lifecycle/templates/netbox_lifecycle/htmx/virtualmachine_licenses.html +++ /dev/null @@ -1,47 +0,0 @@ -{% load i18n %} - -
-
- {% trans "Licenses" %} - -
- {% if assignments %} -
- - - - - - - - - - - - {% for assignment in assignments %} - - - - - - - - {% endfor %} - -
{% trans "License" %}{% trans "Manufacturer" %}{% trans "Vendor" %}{% trans "Quantity" %}{% trans "Description" %}
{{ assignment.license.name }}{{ assignment.license.manufacturer|default:"-" }}{{ assignment.vendor|default:"-" }}{{ assignment.quantity|default:"-" }}{{ assignment.description|default:"-" }}
- {% if show_all_url %} - - {% endif %} -
- {% else %} -
- {% trans "None" %} -
- {% endif %} -
diff --git a/netbox_lifecycle/templates/netbox_lifecycle/inc/contract_card_placeholder.html b/netbox_lifecycle/templates/netbox_lifecycle/inc/contract_card_placeholder.html deleted file mode 100644 index 7383d35..0000000 --- a/netbox_lifecycle/templates/netbox_lifecycle/inc/contract_card_placeholder.html +++ /dev/null @@ -1,12 +0,0 @@ -{% load i18n %} - -
-
-
{% trans "Support Contracts" %}
-
-
-
-
-
diff --git a/netbox_lifecycle/templates/netbox_lifecycle/inc/hardware_lifecycle_info.html b/netbox_lifecycle/templates/netbox_lifecycle/inc/hardware_lifecycle_info.html deleted file mode 100644 index 0f1addc..0000000 --- a/netbox_lifecycle/templates/netbox_lifecycle/inc/hardware_lifecycle_info.html +++ /dev/null @@ -1,43 +0,0 @@ - -{% load netbox_lifecycle_filters %} -{% load helpers %} -{# renders panel on object with lifecycle info assigned to it #} - -
-
Lifecycle Dates - {% if lifecycle_info %} - -
- - - - - - - - - - - - - - - - - - - - - - - - - -
End of Sale{{ lifecycle_info.end_of_sale|placeholder }}
End of Maintenance Updates{{ lifecycle_info.end_of_maintenance|placeholder }}
End of Security Updates{{ lifecycle_info.end_of_security|placeholder }}
Last Support Contract Attach{{ lifecycle_info.last_contract_attach|placeholder }}
Last Support Contract Renewal{{ lifecycle_info.last_contract_renewal|placeholder }}
End of Support{{ lifecycle_info.end_of_support|placeholder }}
- {% else %} - -
No Lifecycle Dates Defined
- {% endif %} -
diff --git a/netbox_lifecycle/templates/netbox_lifecycle/inc/license_card_placeholder.html b/netbox_lifecycle/templates/netbox_lifecycle/inc/license_card_placeholder.html deleted file mode 100644 index eae75b8..0000000 --- a/netbox_lifecycle/templates/netbox_lifecycle/inc/license_card_placeholder.html +++ /dev/null @@ -1,12 +0,0 @@ -{% load i18n %} - -
-
-
{% trans "Licenses" %}
-
-
-
-
-
diff --git a/netbox_lifecycle/templates/netbox_lifecycle/inc/support_contract_info.html b/netbox_lifecycle/templates/netbox_lifecycle/inc/support_contract_info.html deleted file mode 100644 index 6ee8570..0000000 --- a/netbox_lifecycle/templates/netbox_lifecycle/inc/support_contract_info.html +++ /dev/null @@ -1,45 +0,0 @@ - -{% load netbox_lifecycle_filters %} -{% load helpers %} -{# renders panel on object (device) with support contract info assigned to it #} - -
-
Support Contract - {% if support_contract %} - -
- - - - - - - - - - - - - - - - - - - - {% if support_contract.end == None %} - - {% else %} - - {% endif %} - -
Vendor{{ support_contract.contract.vendor|linkify|placeholder }}
Contract Number{{ support_contract.contract|linkify:"contract_id"|placeholder }}
Support SKU{{ support_contract.sku|linkify|placeholder }}
Start Date{{ support_contract.contract.start }}
End Date{{ support_contract.contract.end }}{{ support_contract.end }}
- {% else %} - -
No Support Contract Assigned
- {% endif %} -
- -{% include "netbox_lifecycle/inc/hardware_lifecycle_info.html" %} diff --git a/netbox_lifecycle/templates/netbox_lifecycle/license.html b/netbox_lifecycle/templates/netbox_lifecycle/license.html deleted file mode 100644 index b6304f6..0000000 --- a/netbox_lifecycle/templates/netbox_lifecycle/license.html +++ /dev/null @@ -1,46 +0,0 @@ -{% extends 'generic/object.html' %} -{% load buttons %} -{% load custom_links %} -{% load helpers %} -{% load perms %} -{% load plugins %} -{% load tabs %} - -{% block content %} -
-
-
-
License
-
- - - - - - - - - - - - - -
Manufacturer{{ object.manufacturer|linkify }}
Name{{ object.name }}
Description{{ object.description }}
-
-
- {% plugin_left_page object %} - {% include 'inc/panels/tags.html' %} -
-
- {% include 'inc/panels/related_objects.html' %} - {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/comments.html' %} - {% plugin_right_page object %} -
-
-
-
- {% plugin_full_width_page object %} -
-
-{% endblock %} diff --git a/netbox_lifecycle/templates/netbox_lifecycle/license/assignments.html b/netbox_lifecycle/templates/netbox_lifecycle/license/assignments.html deleted file mode 100644 index 99ea27d..0000000 --- a/netbox_lifecycle/templates/netbox_lifecycle/license/assignments.html +++ /dev/null @@ -1,49 +0,0 @@ -{% extends 'netbox_lifecycle/generic/base.html' %} -{% load render_table from django_tables2 %} -{% load helpers %} -{% load static %} - -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal="LicenseAssignment_config" %} - -
- {% csrf_token %} - -
-
- {% include 'htmx/table.html' %} -
-
- -
-
- {% if 'bulk_edit' in actions %} -
- -
- {% endif %} -
- {% if 'bulk_delete' in actions %} - - {% endif %} -
-
- {% if 'add' in actions %} - - {% endif %} -
-
-{% endblock %} - -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} \ No newline at end of file diff --git a/netbox_lifecycle/templates/netbox_lifecycle/licenseassignment.html b/netbox_lifecycle/templates/netbox_lifecycle/licenseassignment.html deleted file mode 100644 index 934e651..0000000 --- a/netbox_lifecycle/templates/netbox_lifecycle/licenseassignment.html +++ /dev/null @@ -1,61 +0,0 @@ -{% extends 'generic/object.html' %} -{% load buttons %} -{% load custom_links %} -{% load helpers %} -{% load perms %} -{% load plugins %} -{% load tabs %} - -{% block content %} -
-
-
-
Contract
-
- - - - - - - - - - {% if object.device %} - - - - - {% elif object.virtual_machine %} - - - - - {% endif %} - - - - - - - - -
License{{ object.license|linkify }}
Vendor{{ object.vendor|linkify }}
Device{{ object.device|linkify }}
Virtual Machine{{ object.virtual_machine|linkify }}
Quantity{{ object.quantity }}
Description{{ object.description }}
-
-
- {% plugin_left_page object %} - {% include 'inc/panels/tags.html' %} -
-
- {% include 'inc/panels/related_objects.html' %} - {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/comments.html' %} - {% plugin_right_page object %} -
-
-
-
- {% plugin_full_width_page object %} -
-
-{% endblock %} diff --git a/netbox_lifecycle/templates/netbox_lifecycle/supportcontract.html b/netbox_lifecycle/templates/netbox_lifecycle/supportcontract.html deleted file mode 100644 index 6325d3d..0000000 --- a/netbox_lifecycle/templates/netbox_lifecycle/supportcontract.html +++ /dev/null @@ -1,78 +0,0 @@ -{% extends 'generic/object.html' %} -{% load buttons %} -{% load custom_links %} -{% load helpers %} -{% load perms %} -{% load plugins %} -{% load tabs %} - -{% if perms.netbox_lifecycle.add_supportcontractassignment %} - {% block extra_controls %} - - Add Assignment - - {% endblock %} -{% endif %} - -{% block content %} -
-
-
-
Contract
-
- - - - - - - - - - - - - - - - - -
Manufacturer{{ object.manufacturer|linkify|placeholder }}
Vendor{{ object.vendor|linkify|placeholder }}
Contract ID{{ object.contract_id }}
Description{{ object.description }}
-
-
-
-
Dates
-
- - - - - - - - - - - - - -
Start{{ object.start }}
Last renewal{{ object.renewal }}
End{{ object.end }}
-
-
- {% plugin_left_page object %} - {% include 'inc/panels/tags.html' %} -
-
- {% include 'inc/panels/related_objects.html' %} - {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/comments.html' %} - {% plugin_right_page object %} -
-
-
-
- {% plugin_full_width_page object %} -
-
-{% endblock %} diff --git a/netbox_lifecycle/templates/netbox_lifecycle/supportcontract/assignments.html b/netbox_lifecycle/templates/netbox_lifecycle/supportcontract/assignments.html deleted file mode 100644 index f9942f8..0000000 --- a/netbox_lifecycle/templates/netbox_lifecycle/supportcontract/assignments.html +++ /dev/null @@ -1,49 +0,0 @@ -{% extends 'netbox_lifecycle/generic/base.html' %} -{% load render_table from django_tables2 %} -{% load helpers %} -{% load static %} - -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal="ObjectTable_config" %} - -
- {% csrf_token %} - -
-
- {% include 'htmx/table.html' %} -
-
- -
-
- {% if 'bulk_edit' in actions %} -
- -
- {% endif %} -
- {% if 'bulk_delete' in actions %} - - {% endif %} -
-
- {% if 'add' in actions %} - - {% endif %} -
-
-{% endblock %} - -{% block modals %} - {{ block.super }} - {% table_config_form table table_name="ObjectTable" %} -{% endblock modals %} \ No newline at end of file diff --git a/netbox_lifecycle/templates/netbox_lifecycle/supportcontractassignment.html b/netbox_lifecycle/templates/netbox_lifecycle/supportcontractassignment.html deleted file mode 100644 index bb1c2f8..0000000 --- a/netbox_lifecycle/templates/netbox_lifecycle/supportcontractassignment.html +++ /dev/null @@ -1,74 +0,0 @@ -{% extends 'generic/object.html' %} -{% load buttons %} -{% load custom_links %} -{% load helpers %} -{% load perms %} -{% load plugins %} -{% load tabs %} - -{% block content %} -
-
-
-
Contract
-
- - - - - - - - - - {% if object.device %} - - - - - {% endif %} - {% if object.module %} - - - - - {% endif %} - {% if object.virtual_machine %} - - - - - {% endif %} - {% if object.license %} - - - - - {% endif %} - - - - - - - - -
Contract{{ object.contract|linkify }}
SKU{{ object.sku|linkify }}
Device{{ object.device|linkify }}
Module{{ object.module|linkify }}
Virtual Machine{{ object.virtual_machine|linkify }}
License{{ object.license|linkify }}
End Date{{ object.end }}
Description{{ object.description }}
-
-
- {% plugin_left_page object %} - {% include 'inc/panels/tags.html' %} -
-
- {% include 'inc/panels/related_objects.html' %} - {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/comments.html' %} - {% plugin_right_page object %} -
-
-
-
- {% plugin_full_width_page object %} -
-
-{% endblock %} diff --git a/netbox_lifecycle/templates/netbox_lifecycle/supportsku.html b/netbox_lifecycle/templates/netbox_lifecycle/supportsku.html deleted file mode 100644 index ef4fd16..0000000 --- a/netbox_lifecycle/templates/netbox_lifecycle/supportsku.html +++ /dev/null @@ -1,46 +0,0 @@ -{% extends 'generic/object.html' %} -{% load buttons %} -{% load custom_links %} -{% load helpers %} -{% load perms %} -{% load plugins %} -{% load tabs %} - -{% block content %} -
-
-
-
SKU
-
- - - - - - - - - - - - - -
Manufacturer{{ object.manufacturer|linkify }}
SKU{{ object.sku }}
Description{{ object.description }}
-
-
- {% plugin_left_page object %} - {% include 'inc/panels/tags.html' %} -
-
- {% include 'inc/panels/related_objects.html' %} - {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/comments.html' %} - {% plugin_right_page object %} -
-
-
-
- {% plugin_full_width_page object %} -
-
-{% endblock %} diff --git a/netbox_lifecycle/templates/netbox_lifecycle/vendor.html b/netbox_lifecycle/templates/netbox_lifecycle/vendor.html deleted file mode 100644 index de4c8bd..0000000 --- a/netbox_lifecycle/templates/netbox_lifecycle/vendor.html +++ /dev/null @@ -1,42 +0,0 @@ -{% extends 'generic/object.html' %} -{% load buttons %} -{% load custom_links %} -{% load helpers %} -{% load perms %} -{% load plugins %} -{% load tabs %} - -{% block content %} -
-
-
-
Vendor
-
- - - - - - - - - -
Name{{ object.name }}
Description{{ object.description }}
-
-
- {% plugin_left_page object %} - {% include 'inc/panels/tags.html' %} -
-
- {% include 'inc/panels/related_objects.html' %} - {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/comments.html' %} - {% plugin_right_page object %} -
-
-
-
- {% plugin_full_width_page object %} -
-
-{% endblock %} diff --git a/netbox_lifecycle/templatetags/__init__.py b/netbox_lifecycle/templatetags/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/netbox_lifecycle/templatetags/netbox_lifecycle_filters.py b/netbox_lifecycle/templatetags/netbox_lifecycle_filters.py deleted file mode 100644 index 44855d5..0000000 --- a/netbox_lifecycle/templatetags/netbox_lifecycle_filters.py +++ /dev/null @@ -1,38 +0,0 @@ -from datetime import datetime, timezone - -from dateutil.relativedelta import relativedelta -from django import template -from django.utils.safestring import mark_safe - -register = template.Library() - - -def is_expired(value): - return value < datetime.now(tz=timezone.utc).date() - - -def expires_within_six_months(value): - return value < (datetime.now(tz=timezone.utc).date() + relativedelta(months=+6)) - - -@register.filter(is_safe=True) -def date_badge_class(value): - if not value: - return - - if is_expired(value): - return mark_safe('class="badge text-bg-danger"') - elif expires_within_six_months(value): - return mark_safe('class="badge text-bg-warning"') - else: - return mark_safe('class="badge text-bg-success"') - - -@register.filter(is_safe=True) -def contract_status_badge(status): - from netbox_lifecycle.constants import CONTRACT_STATUS_COLOR - - if not status or status not in CONTRACT_STATUS_COLOR: - return '' - label, color = CONTRACT_STATUS_COLOR[status] - return mark_safe(f'{label}') diff --git a/netbox_lifecycle/tests/test_htmx_views.py b/netbox_lifecycle/tests/test_htmx_views.py deleted file mode 100644 index 6b67a56..0000000 --- a/netbox_lifecycle/tests/test_htmx_views.py +++ /dev/null @@ -1,511 +0,0 @@ -from datetime import datetime, timedelta, timezone - -from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site -from django.contrib.auth import get_user_model -from django.test import TestCase -from django.urls import reverse -from virtualization.models import Cluster, ClusterType, VirtualMachine - -from netbox_lifecycle.models import ( - License, - LicenseAssignment, - SupportContract, - SupportContractAssignment, - SupportSKU, - Vendor, -) -from netbox_lifecycle.views.htmx import MAX_LICENSE_DISPLAY - -User = get_user_model() - - -class DeviceContractsHTMXViewTest(TestCase): - @classmethod - def setUpTestData(cls): - cls.manufacturer = Manufacturer.objects.create( - name='Test Manufacturer', slug='test-manufacturer' - ) - device_type = DeviceType.objects.create( - manufacturer=cls.manufacturer, model='Test Model', slug='test-model' - ) - device_role = DeviceRole.objects.create(name='Test Role', slug='test-role') - site = Site.objects.create(name='Test Site', slug='test-site') - cls.device = Device.objects.create( - name='Test Device', - device_type=device_type, - role=device_role, - site=site, - ) - cls.vendor = Vendor.objects.create(name='Test Vendor') - cls.user = User.objects.create_user(username='testuser', password='testpass') - - def test_device_contracts_htmx_view(self): - contract = SupportContract.objects.create( - vendor=self.vendor, - contract_id='TEST-001', - end=datetime.now(tz=timezone.utc).date() + timedelta(days=30), - ) - SupportContractAssignment.objects.create(contract=contract, device=self.device) - - self.client.force_login(self.user) - url = reverse( - 'plugins:netbox_lifecycle:device_contracts_htmx', - kwargs={'pk': self.device.pk}, - ) - response = self.client.get(url) - - self.assertEqual(response.status_code, 200) - self.assertContains(response, 'TEST-001') - self.assertContains(response, 'Active') - - def test_device_contracts_expired_htmx_view(self): - contract = SupportContract.objects.create( - vendor=self.vendor, - contract_id='EXPIRED-001', - end=datetime.now(tz=timezone.utc).date() - timedelta(days=1), - ) - SupportContractAssignment.objects.create(contract=contract, device=self.device) - - self.client.force_login(self.user) - url = reverse( - 'plugins:netbox_lifecycle:device_contracts_expired', - kwargs={'pk': self.device.pk}, - ) - response = self.client.get(url) - - self.assertEqual(response.status_code, 200) - self.assertContains(response, 'EXPIRED-001') - - def test_device_contracts_requires_login(self): - url = reverse( - 'plugins:netbox_lifecycle:device_contracts_htmx', - kwargs={'pk': self.device.pk}, - ) - response = self.client.get(url) - self.assertEqual(response.status_code, 302) # Redirect to login - - def test_device_contracts_with_sku(self): - """Test that SKU is displayed in the contract list.""" - sku = SupportSKU.objects.create( - manufacturer=self.manufacturer, - sku='SKU-PREMIUM-24x7', - ) - contract = SupportContract.objects.create( - vendor=self.vendor, - contract_id='TEST-SKU-001', - end=datetime.now(tz=timezone.utc).date() + timedelta(days=30), - ) - SupportContractAssignment.objects.create( - contract=contract, device=self.device, sku=sku - ) - - self.client.force_login(self.user) - url = reverse( - 'plugins:netbox_lifecycle:device_contracts_htmx', - kwargs={'pk': self.device.pk}, - ) - response = self.client.get(url) - - self.assertEqual(response.status_code, 200) - self.assertContains(response, 'TEST-SKU-001') - self.assertContains(response, 'SKU-PREMIUM-24x7') - - def test_device_contracts_future(self): - """Test that future contracts are displayed in the Future tab.""" - contract = SupportContract.objects.create( - vendor=self.vendor, - contract_id='FUTURE-001', - start=datetime.now(tz=timezone.utc).date() + timedelta(days=30), - end=datetime.now(tz=timezone.utc).date() + timedelta(days=365), - ) - SupportContractAssignment.objects.create(contract=contract, device=self.device) - - self.client.force_login(self.user) - url = reverse( - 'plugins:netbox_lifecycle:device_contracts_htmx', - kwargs={'pk': self.device.pk}, - ) - response = self.client.get(url) - - self.assertEqual(response.status_code, 200) - self.assertContains(response, 'FUTURE-001') - self.assertContains(response, 'Future') - - def test_device_contracts_unspecified(self): - """Test that contracts without end date show in Unspecified tab.""" - contract = SupportContract.objects.create( - vendor=self.vendor, - contract_id='UNSPEC-001', - start=datetime.now(tz=timezone.utc).date() - timedelta(days=30), - end=None, - ) - SupportContractAssignment.objects.create(contract=contract, device=self.device) - - self.client.force_login(self.user) - url = reverse( - 'plugins:netbox_lifecycle:device_contracts_htmx', - kwargs={'pk': self.device.pk}, - ) - response = self.client.get(url) - - self.assertEqual(response.status_code, 200) - self.assertContains(response, 'UNSPEC-001') - self.assertContains(response, 'Unspecified') - - def test_device_contracts_no_contracts(self): - """Test empty state when device has no contracts.""" - self.client.force_login(self.user) - url = reverse( - 'plugins:netbox_lifecycle:device_contracts_htmx', - kwargs={'pk': self.device.pk}, - ) - response = self.client.get(url) - - self.assertEqual(response.status_code, 200) - self.assertContains(response, 'None') - - def test_device_contracts_multiple_statuses(self): - """Test that multiple contract statuses are grouped correctly.""" - # Active contract - active_contract = SupportContract.objects.create( - vendor=self.vendor, - contract_id='ACTIVE-MULTI', - end=datetime.now(tz=timezone.utc).date() + timedelta(days=30), - ) - SupportContractAssignment.objects.create( - contract=active_contract, device=self.device - ) - - # Future contract - future_contract = SupportContract.objects.create( - vendor=self.vendor, - contract_id='FUTURE-MULTI', - start=datetime.now(tz=timezone.utc).date() + timedelta(days=30), - end=datetime.now(tz=timezone.utc).date() + timedelta(days=365), - ) - SupportContractAssignment.objects.create( - contract=future_contract, device=self.device - ) - - # Expired contract - expired_contract = SupportContract.objects.create( - vendor=self.vendor, - contract_id='EXPIRED-MULTI', - end=datetime.now(tz=timezone.utc).date() - timedelta(days=1), - ) - SupportContractAssignment.objects.create( - contract=expired_contract, device=self.device - ) - - self.client.force_login(self.user) - url = reverse( - 'plugins:netbox_lifecycle:device_contracts_htmx', - kwargs={'pk': self.device.pk}, - ) - response = self.client.get(url) - - self.assertEqual(response.status_code, 200) - # Check all tabs are present - self.assertContains(response, 'Active') - self.assertContains(response, 'Future') - self.assertContains(response, 'Expired') - # Check contracts - self.assertContains(response, 'ACTIVE-MULTI') - self.assertContains(response, 'FUTURE-MULTI') - # Expired is lazy-loaded, so contract ID won't be in initial response - - def test_device_contracts_with_license(self): - """Test contract assignment with license is displayed.""" - license_obj = License.objects.create( - manufacturer=self.manufacturer, - name='Enterprise License', - ) - license_assignment = LicenseAssignment.objects.create( - license=license_obj, - vendor=self.vendor, - device=self.device, - quantity=1, - ) - contract = SupportContract.objects.create( - vendor=self.vendor, - contract_id='LICENSE-001', - end=datetime.now(tz=timezone.utc).date() + timedelta(days=30), - ) - SupportContractAssignment.objects.create( - contract=contract, - device=self.device, - license=license_assignment, - ) - - self.client.force_login(self.user) - url = reverse( - 'plugins:netbox_lifecycle:device_contracts_htmx', - kwargs={'pk': self.device.pk}, - ) - response = self.client.get(url) - - self.assertEqual(response.status_code, 200) - self.assertContains(response, 'LICENSE-001') - - def test_expired_contracts_requires_login(self): - """Test that expired contracts endpoint requires authentication.""" - url = reverse( - 'plugins:netbox_lifecycle:device_contracts_expired', - kwargs={'pk': self.device.pk}, - ) - response = self.client.get(url) - self.assertEqual(response.status_code, 302) # Redirect to login - - def test_expired_contracts_with_sku(self): - """Test that expired contracts show SKU.""" - sku = SupportSKU.objects.create( - manufacturer=self.manufacturer, - sku='SKU-EXPIRED-SUPPORT', - ) - contract = SupportContract.objects.create( - vendor=self.vendor, - contract_id='EXPIRED-SKU-001', - end=datetime.now(tz=timezone.utc).date() - timedelta(days=1), - ) - SupportContractAssignment.objects.create( - contract=contract, device=self.device, sku=sku - ) - - self.client.force_login(self.user) - url = reverse( - 'plugins:netbox_lifecycle:device_contracts_expired', - kwargs={'pk': self.device.pk}, - ) - response = self.client.get(url) - - self.assertEqual(response.status_code, 200) - self.assertContains(response, 'EXPIRED-SKU-001') - self.assertContains(response, 'SKU-EXPIRED-SUPPORT') - - -class DeviceLicensesHTMXViewTest(TestCase): - @classmethod - def setUpTestData(cls): - cls.manufacturer = Manufacturer.objects.create( - name='License Manufacturer', slug='license-manufacturer' - ) - device_type = DeviceType.objects.create( - manufacturer=cls.manufacturer, model='License Model', slug='license-model' - ) - device_role = DeviceRole.objects.create( - name='License Role', slug='license-role' - ) - site = Site.objects.create(name='License Site', slug='license-site') - cls.device = Device.objects.create( - name='License Device', - device_type=device_type, - role=device_role, - site=site, - ) - cls.vendor = Vendor.objects.create(name='License Vendor') - cls.license = License.objects.create( - manufacturer=cls.manufacturer, - name='Test License', - ) - cls.user = User.objects.create_user(username='licenseuser', password='testpass') - - def test_device_licenses_requires_login(self): - url = reverse( - 'plugins:netbox_lifecycle:device_licenses_htmx', - kwargs={'pk': self.device.pk}, - ) - response = self.client.get(url) - self.assertEqual(response.status_code, 302) - - def test_device_licenses_empty(self): - self.client.force_login(self.user) - url = reverse( - 'plugins:netbox_lifecycle:device_licenses_htmx', - kwargs={'pk': self.device.pk}, - ) - response = self.client.get(url) - - self.assertEqual(response.status_code, 200) - self.assertContains(response, 'None') - - def test_device_licenses_with_assignment(self): - LicenseAssignment.objects.create( - license=self.license, - vendor=self.vendor, - device=self.device, - quantity=5, - ) - - self.client.force_login(self.user) - url = reverse( - 'plugins:netbox_lifecycle:device_licenses_htmx', - kwargs={'pk': self.device.pk}, - ) - response = self.client.get(url) - - self.assertEqual(response.status_code, 200) - self.assertContains(response, 'Test License') - self.assertContains(response, 'License Manufacturer') - self.assertContains(response, 'License Vendor') - self.assertContains(response, '5') - - def test_device_licenses_assign_button(self): - LicenseAssignment.objects.create( - license=self.license, - vendor=self.vendor, - device=self.device, - ) - - self.client.force_login(self.user) - url = reverse( - 'plugins:netbox_lifecycle:device_licenses_htmx', - kwargs={'pk': self.device.pk}, - ) - response = self.client.get(url) - - self.assertEqual(response.status_code, 200) - self.assertContains(response, 'Assign License') - self.assertContains(response, f'device={self.device.pk}') - - def test_device_licenses_show_all_link(self): - for i in range(MAX_LICENSE_DISPLAY + 1): - lic = License.objects.create( - manufacturer=self.manufacturer, - name=f'Bulk License {i}', - ) - LicenseAssignment.objects.create( - license=lic, - vendor=self.vendor, - device=self.device, - ) - - self.client.force_login(self.user) - url = reverse( - 'plugins:netbox_lifecycle:device_licenses_htmx', - kwargs={'pk': self.device.pk}, - ) - response = self.client.get(url) - - self.assertEqual(response.status_code, 200) - self.assertContains(response, 'Show all Licenses') - - def test_device_licenses_no_show_all_when_under_limit(self): - LicenseAssignment.objects.create( - license=self.license, - vendor=self.vendor, - device=self.device, - ) - - self.client.force_login(self.user) - url = reverse( - 'plugins:netbox_lifecycle:device_licenses_htmx', - kwargs={'pk': self.device.pk}, - ) - response = self.client.get(url) - - self.assertEqual(response.status_code, 200) - self.assertNotContains(response, 'Show all Licenses') - - -class VirtualMachineLicensesHTMXViewTest(TestCase): - @classmethod - def setUpTestData(cls): - cls.manufacturer = Manufacturer.objects.create( - name='VM License Mfg', slug='vm-license-mfg' - ) - cluster_type = ClusterType.objects.create( - name='VM Cluster Type', slug='vm-cluster-type' - ) - cluster = Cluster.objects.create(name='VM Cluster', type=cluster_type) - cls.virtual_machine = VirtualMachine.objects.create( - name='License VM', - cluster=cluster, - ) - cls.vendor = Vendor.objects.create(name='VM License Vendor') - cls.license = License.objects.create( - manufacturer=cls.manufacturer, - name='VM Test License', - ) - cls.user = User.objects.create_user( - username='vmlicenseuser', password='testpass' - ) - - def test_vm_licenses_requires_login(self): - url = reverse( - 'plugins:netbox_lifecycle:virtualmachine_licenses_htmx', - kwargs={'pk': self.virtual_machine.pk}, - ) - response = self.client.get(url) - self.assertEqual(response.status_code, 302) - - def test_vm_licenses_empty(self): - self.client.force_login(self.user) - url = reverse( - 'plugins:netbox_lifecycle:virtualmachine_licenses_htmx', - kwargs={'pk': self.virtual_machine.pk}, - ) - response = self.client.get(url) - - self.assertEqual(response.status_code, 200) - self.assertContains(response, 'None') - - def test_vm_licenses_with_assignment(self): - LicenseAssignment.objects.create( - license=self.license, - vendor=self.vendor, - virtual_machine=self.virtual_machine, - quantity=10, - ) - - self.client.force_login(self.user) - url = reverse( - 'plugins:netbox_lifecycle:virtualmachine_licenses_htmx', - kwargs={'pk': self.virtual_machine.pk}, - ) - response = self.client.get(url) - - self.assertEqual(response.status_code, 200) - self.assertContains(response, 'VM Test License') - self.assertContains(response, 'VM License Mfg') - self.assertContains(response, 'VM License Vendor') - self.assertContains(response, '10') - - def test_vm_licenses_assign_button(self): - LicenseAssignment.objects.create( - license=self.license, - vendor=self.vendor, - virtual_machine=self.virtual_machine, - ) - - self.client.force_login(self.user) - url = reverse( - 'plugins:netbox_lifecycle:virtualmachine_licenses_htmx', - kwargs={'pk': self.virtual_machine.pk}, - ) - response = self.client.get(url) - - self.assertEqual(response.status_code, 200) - self.assertContains(response, 'Assign License') - self.assertContains(response, f'virtual_machine={self.virtual_machine.pk}') - - def test_vm_licenses_show_all_link(self): - for i in range(MAX_LICENSE_DISPLAY + 1): - lic = License.objects.create( - manufacturer=self.manufacturer, - name=f'VM Bulk License {i}', - ) - LicenseAssignment.objects.create( - license=lic, - vendor=self.vendor, - virtual_machine=self.virtual_machine, - ) - - self.client.force_login(self.user) - url = reverse( - 'plugins:netbox_lifecycle:virtualmachine_licenses_htmx', - kwargs={'pk': self.virtual_machine.pk}, - ) - response = self.client.get(url) - - self.assertEqual(response.status_code, 200) - self.assertContains(response, 'Show all Licenses') diff --git a/netbox_lifecycle/tests/test_templatetags.py b/netbox_lifecycle/tests/test_templatetags.py deleted file mode 100644 index baf26f2..0000000 --- a/netbox_lifecycle/tests/test_templatetags.py +++ /dev/null @@ -1,39 +0,0 @@ -from django.test import TestCase - -from netbox_lifecycle.constants import ( - CONTRACT_STATUS_ACTIVE, - CONTRACT_STATUS_EXPIRED, - CONTRACT_STATUS_FUTURE, - CONTRACT_STATUS_UNSPECIFIED, -) -from netbox_lifecycle.templatetags.netbox_lifecycle_filters import contract_status_badge - - -class ContractStatusBadgeFilterTest(TestCase): - def test_active_status_badge(self): - result = contract_status_badge(CONTRACT_STATUS_ACTIVE) - self.assertIn('text-bg-success', result) - self.assertIn('Active', result) - - def test_future_status_badge(self): - result = contract_status_badge(CONTRACT_STATUS_FUTURE) - self.assertIn('text-bg-info', result) - self.assertIn('Future', result) - - def test_unspecified_status_badge(self): - result = contract_status_badge(CONTRACT_STATUS_UNSPECIFIED) - self.assertIn('text-bg-secondary', result) - self.assertIn('Unspecified', result) - - def test_expired_status_badge(self): - result = contract_status_badge(CONTRACT_STATUS_EXPIRED) - self.assertIn('text-bg-danger', result) - self.assertIn('Expired', result) - - def test_invalid_status_returns_empty(self): - result = contract_status_badge('invalid') - self.assertEqual(result, '') - - def test_none_status_returns_empty(self): - result = contract_status_badge(None) - self.assertEqual(result, '') diff --git a/netbox_lifecycle/ui/__init__.py b/netbox_lifecycle/ui/__init__.py new file mode 100644 index 0000000..e8fe246 --- /dev/null +++ b/netbox_lifecycle/ui/__init__.py @@ -0,0 +1,18 @@ +from .panels import * +from .attributes import * + +__all__ = ( + 'ColoredDateTimeAttr', + 'VendorPanel', + 'SupportSKUPanel', + 'SupportContractPanel', + 'SupportContractDatesPanel', + 'SupportContractAssignmentPanel', + 'SupportContractAssignmentDevicePanel', + 'SupportContractAssignmentVMPanel', + 'SupportContractAssignmentLicensePanel', + 'LicensePanel', + 'LicenseAssignmentPanel', + 'HardwareLifecyclePanel', + 'HardwareLifecycleDatesPanel', +) diff --git a/netbox_lifecycle/ui/attributes/__init__.py b/netbox_lifecycle/ui/attributes/__init__.py new file mode 100644 index 0000000..656a66f --- /dev/null +++ b/netbox_lifecycle/ui/attributes/__init__.py @@ -0,0 +1,3 @@ +from .datetime import * + +__all__ = ('ColoredDateTimeAttr',) diff --git a/netbox_lifecycle/ui/attributes/datetime.py b/netbox_lifecycle/ui/attributes/datetime.py new file mode 100644 index 0000000..3a7ffd6 --- /dev/null +++ b/netbox_lifecycle/ui/attributes/datetime.py @@ -0,0 +1,35 @@ +import datetime + +from django.utils.safestring import mark_safe + +from netbox.ui.attrs import DateTimeAttr + +__all__ = ('ColoredDateTimeAttr',) + + +class ColoredDateTimeAttr(DateTimeAttr): + """ + A DateTimeAttr that applies a badge to the value when rendered. + """ + + def get_class(self, value): + if datetime.datetime.now(tz=datetime.timezone.utc).date() > value: + return 'text-bg-danger' + elif ( + datetime.datetime.now(tz=datetime.timezone.utc).date() + + datetime.timedelta(days=183) + > value + ): + return 'text-bg-warning' + else: + return 'badge-neutral' + return '' + + def render(self, obj, context): + value = self.get_value(obj) + rendered = super().render(obj, context) + if value: + rendered = mark_safe( + f'{rendered}' + ) + return rendered diff --git a/netbox_lifecycle/ui/panels/__init__.py b/netbox_lifecycle/ui/panels/__init__.py new file mode 100644 index 0000000..be61e38 --- /dev/null +++ b/netbox_lifecycle/ui/panels/__init__.py @@ -0,0 +1,18 @@ +from .contract import * +from .license import * +from .hardware import * + +__all__ = ( + 'VendorPanel', + 'SupportSKUPanel', + 'SupportContractPanel', + 'SupportContractDatesPanel', + 'SupportContractAssignmentPanel', + 'SupportContractAssignmentDevicePanel', + 'SupportContractAssignmentVMPanel', + 'SupportContractAssignmentLicensePanel', + 'LicensePanel', + 'LicenseAssignmentPanel', + 'HardwareLifecyclePanel', + 'HardwareLifecycleDatesPanel', +) diff --git a/netbox_lifecycle/ui/panels/contract.py b/netbox_lifecycle/ui/panels/contract.py new file mode 100644 index 0000000..a8ae1b0 --- /dev/null +++ b/netbox_lifecycle/ui/panels/contract.py @@ -0,0 +1,86 @@ +from django.utils.translation import gettext_lazy as _ + +from netbox.ui import attrs, panels + +__all__ = ( + 'VendorPanel', + 'SupportSKUPanel', + 'SupportContractPanel', + 'SupportContractDatesPanel', + 'SupportContractAssignmentPanel', + 'SupportContractAssignmentDevicePanel', + 'SupportContractAssignmentVMPanel', + 'SupportContractAssignmentLicensePanel', +) + +from netbox_lifecycle.ui import ColoredDateTimeAttr + + +class VendorPanel(panels.ObjectAttributesPanel): + name = attrs.TextAttr('name') + description = attrs.TextAttr('description') + + +class SupportSKUPanel(panels.ObjectAttributesPanel): + manufacturer = attrs.RelatedObjectAttr('manufacturer', linkify=True) + sku = attrs.TextAttr('sku', label=_('SKU')) + description = attrs.TextAttr('description') + + +class SupportContractPanel(panels.ObjectAttributesPanel): + vendor = attrs.RelatedObjectAttr('vendor', linkify=True) + contract_id = attrs.TextAttr('contract_id') + description = attrs.TextAttr('description') + + +class SupportContractDatesPanel(panels.ObjectAttributesPanel): + title = _('Dates') + start = attrs.DateTimeAttr('start', spec='date') + renewal = attrs.DateTimeAttr('renewal', spec='date') + end = ColoredDateTimeAttr('end', spec='date') + + +class SupportContractAssignmentPanel(panels.ObjectAttributesPanel): + contract = attrs.RelatedObjectAttr('contract', linkify=True) + sku = attrs.RelatedObjectAttr('sku', linkify=True) + end = attrs.DateTimeAttr('end', spec='date') + description = attrs.TextAttr('description') + + +class SupportContractAssignmentDevicePanel(panels.ObjectAttributesPanel): + title = _('Device Assignment') + + device = attrs.RelatedObjectAttr('device', linkify=True) + module = attrs.RelatedObjectAttr('module', linkify=True) + + def should_render(self, context): + if context.get('object').device: + return True + return False + + +class SupportContractAssignmentVMPanel(panels.ObjectAttributesPanel): + title = _('Virtual Machine Assignment') + + virtual_machine = attrs.RelatedObjectAttr('virtual_machine', linkify=True) + + def should_render(self, context): + if context.get('object').virtual_machine: + return True + return False + + +class SupportContractAssignmentLicensePanel(panels.ObjectAttributesPanel): + title = _('License Assignment') + + license = attrs.RelatedObjectAttr('license', linkify=True) + + def should_render(self, context): + if context.get('object').license: + return True + return False + + +class SupportContractAssignmentsPanel(panels.ObjectsTablePanel): + model = 'netbox_lifecycle.SupportContractAssignment' + title = _('Support Contracts') diff --git a/netbox_lifecycle/ui/panels/hardware.py b/netbox_lifecycle/ui/panels/hardware.py new file mode 100644 index 0000000..9b73ea4 --- /dev/null +++ b/netbox_lifecycle/ui/panels/hardware.py @@ -0,0 +1,32 @@ +from django.utils.translation import gettext_lazy as _ + +from netbox.ui import attrs, panels + +__all__ = ( + 'HardwareLifecyclePanel', + 'HardwareLifecycleDatesPanel', +) + +from netbox_lifecycle.ui.attributes.datetime import ColoredDateTimeAttr + + +class HardwareLifecyclePanel(panels.ObjectAttributesPanel): + manufacturer = attrs.RelatedObjectAttr('assigned_object.manufacturer', linkify=True) + assigned_object = attrs.RelatedObjectAttr( + 'assigned_object', linkify=True, label=_('Assigned Object') + ) + description = attrs.TextAttr('description') + + def render(self, context): + + return super().render(context) + + +class HardwareLifecycleDatesPanel(panels.ObjectAttributesPanel): + title = _('Dates') + end_of_sale = ColoredDateTimeAttr('end_of_sale', spec='date') + end_of_maintenance = ColoredDateTimeAttr('end_of_maintenance', spec='date') + end_of_security = ColoredDateTimeAttr('end_of_security', spec='date') + last_contract_attach = ColoredDateTimeAttr('last_contract_attach', spec='date') + last_contract_renewal = ColoredDateTimeAttr('last_contract_renewal', spec='date') + end_of_support = ColoredDateTimeAttr('end_of_support', spec='date') diff --git a/netbox_lifecycle/ui/panels/license.py b/netbox_lifecycle/ui/panels/license.py new file mode 100644 index 0000000..99c90c8 --- /dev/null +++ b/netbox_lifecycle/ui/panels/license.py @@ -0,0 +1,42 @@ +from django.utils.translation import gettext_lazy as _ + +from netbox.ui import attrs, panels + +__all__ = ( + 'LicensePanel', + 'LicenseAssignmentPanel', +) + + +class LicensePanel(panels.ObjectAttributesPanel): + manufacturer = attrs.RelatedObjectAttr('manufacturer', linkify=True) + name = attrs.TextAttr('name') + description = attrs.TextAttr('description') + + +class LicenseAssignmentPanel(panels.ObjectAttributesPanel): + license = attrs.RelatedObjectAttr('license', linkify=True) + name = attrs.TextAttr('name') + description = attrs.TextAttr('description') + + +class LicenseAssignmentDevicePanel(panels.ObjectAttributesPanel): + title = _('Device Assignment') + + device = attrs.RelatedObjectAttr('device', linkify=True) + + def should_render(self, context): + if context.get('object').device: + return True + return False + + +class LicenseAssignmentVMPanel(panels.ObjectAttributesPanel): + title = _('Virtual Machine Assignment') + + virtual_machine = attrs.RelatedObjectAttr('virtual_machine', linkify=True) + + def should_render(self, context): + if context.get('object').virtual_machine: + return True + return False diff --git a/netbox_lifecycle/urls.py b/netbox_lifecycle/urls.py index c0252a3..2a73ce4 100644 --- a/netbox_lifecycle/urls.py +++ b/netbox_lifecycle/urls.py @@ -1,288 +1,43 @@ -from django.urls import path -from netbox.views.generic import ObjectChangeLogView +from django.urls import path, include +from utilities.urls import get_model_urls from . import views -from .models import ( - HardwareLifecycle, - License, - LicenseAssignment, - SupportContract, - SupportContractAssignment, - SupportSKU, - Vendor, -) + +app_name = 'netbox_lifecycle' + urlpatterns = [ path( - 'lifecycle/', - views.HardwareLifecycleListView.as_view(), - name='hardwarelifecycle_list', - ), - path( - 'lifecycle/add', - views.HardwareLifecycleEditView.as_view(), - name='hardwarelifecycle_add', - ), - path( - 'lifecycle/edit', - views.HardwareLifecycleBulkEditView.as_view(), - name='hardwarelifecycle_bulk_edit', - ), - path( - 'lifecycle/delete', - views.HardwareLifecycleBulkDeleteView.as_view(), - name='hardwarelifecycle_bulk_delete', - ), - path( - 'lifecycle/', - views.HardwareLifecycleView.as_view(), - name='hardwarelifecycle', - ), - path( - 'lifecycle//edit', - views.HardwareLifecycleEditView.as_view(), - name='hardwarelifecycle_edit', - ), - path( - 'lifecycle//delete', - views.HardwareLifecycleDeleteView.as_view(), - name='hardwarelifecycle_delete', - ), - path( - 'lifecycle//changelog', - ObjectChangeLogView.as_view(), - name='hardwarelifecycle_changelog', - kwargs={'model': HardwareLifecycle}, - ), - path('vendors/', views.VendorListView.as_view(), name='vendor_list'), - path('vendors/add', views.VendorEditView.as_view(), name='vendor_add'), - path('vendors/edit', views.VendorBulkEditView.as_view(), name='vendor_bulk_edit'), - path( - 'vendors/delete', - views.VendorBulkDeleteView.as_view(), - name='vendor_bulk_delete', - ), - path('vendors/', views.VendorView.as_view(), name='vendor'), - path('vendors//edit', views.VendorEditView.as_view(), name='vendor_edit'), - path( - 'vendors//delete', - views.VendorDeleteView.as_view(), - name='vendor_delete', - ), - path( - 'vendors//changelog', - ObjectChangeLogView.as_view(), - name='vendor_changelog', - kwargs={'model': Vendor}, - ), - path( - 'contracts/', - views.SupportContractListView.as_view(), - name='supportcontract_list', - ), - path( - 'contracts/add', - views.SupportContractEditView.as_view(), - name='supportcontract_add', - ), - path( - 'contracts/edit', - views.SupportContractBulkEditView.as_view(), - name='supportcontract_bulk_edit', - ), - path( - 'contracts/delete', - views.SupportContractBulkDeleteView.as_view(), - name='supportcontract_bulk_delete', - ), - path( - 'contracts/', - views.SupportContractView.as_view(), - name='supportcontract', - ), - path( - 'contracts//devices', - views.SupportContractAssignmentsView.as_view(), - name='supportcontract_assignments', - ), - path( - 'contracts//edit', - views.SupportContractEditView.as_view(), - name='supportcontract_edit', - ), - path( - 'contracts//delete', - views.SupportContractDeleteView.as_view(), - name='supportcontract_delete', - ), - path( - 'contracts//changelog', - ObjectChangeLogView.as_view(), - name='supportcontract_changelog', - kwargs={'model': SupportContract}, - ), - path( - 'contract-assignment/', - views.SupportContractAssignmentListView.as_view(), - name='supportcontractassignment_list', - ), - path( - 'contract-assignment/add', - views.SupportContractAssignmentEditView.as_view(), - name='supportcontractassignment_add', - ), - path( - 'contract-assignment/edit/', - views.SupportContractAssignmentBulkEditView.as_view(), - name='supportcontractassignment_bulk_edit', - ), - path( - 'contract-assignment/delete/', - views.SupportContractAssignmentBulkDeleteView.as_view(), - name='supportcontractassignment_bulk_delete', - ), - path( - 'contract-assignment/', - views.SupportContractAssignmentView.as_view(), - name='supportcontractassignment', - ), - path( - 'contract-assignment//edit', - views.SupportContractAssignmentEditView.as_view(), - name='supportcontractassignment_edit', - ), - path( - 'contract-assignment//delete', - views.SupportContractAssignmentDeleteView.as_view(), - name='supportcontractassignment_delete', - ), - path( - 'contract-assignment//changelog', - ObjectChangeLogView.as_view(), - name='supportcontractassignment_changelog', - kwargs={'model': SupportContractAssignment}, - ), - path('license/', views.LicenseListView.as_view(), name='license_list'), - path('license/add', views.LicenseEditView.as_view(), name='license_add'), - path('license/edit', views.LicenseBulkEditView.as_view(), name='license_bulk_edit'), - path( - 'license/delete', - views.LicenseBulkDeleteView.as_view(), - name='license_bulk_delete', - ), - path('license/', views.LicenseView.as_view(), name='license'), - path( - 'license//assignments', - views.LicenseAssignmentsView.as_view(), - name='license_assignments', - ), - path('license//edit', views.LicenseEditView.as_view(), name='license_edit'), - path( - 'license//delete', - views.LicenseDeleteView.as_view(), - name='license_delete', - ), - path( - 'license//changelog', - ObjectChangeLogView.as_view(), - name='license_changelog', - kwargs={'model': License}, - ), - path('sku/', views.SupportSKUListView.as_view(), name='supportsku_list'), - path('sku/add', views.SupportSKUEditView.as_view(), name='supportsku_add'), - path( - 'sku/edit', views.SupportSKUBulkEditView.as_view(), name='supportsku_bulk_edit' - ), - path( - 'sku/delete', - views.SupportSKUBulkDeleteView.as_view(), - name='supportsku_bulk_delete', - ), - path('sku/', views.SupportSKUView.as_view(), name='supportsku'), - path( - 'sku//edit', views.SupportSKUEditView.as_view(), name='supportsku_edit' - ), - path( - 'sku//delete', - views.SupportSKUDeleteView.as_view(), - name='supportsku_delete', - ), - path( - 'sku//changelog', - ObjectChangeLogView.as_view(), - name='supportsku_changelog', - kwargs={'model': SupportSKU}, - ), - path( - 'license-assignment/', - views.LicenseAssignmentListView.as_view(), - name='licenseassignment_list', - ), - path( - 'license-assignment/add', - views.LicenseAssignmentEditView.as_view(), - name='licenseassignment_add', - ), - path( - 'license-assignment/edit', - views.LicenseAssignmentBulkEditView.as_view(), - name='licenseassignment_bulk_edit', - ), - path( - 'license-assignment/delete/', - views.LicenseAssignmentBulkDeleteView.as_view(), - name='licenseassignment_bulk_delete', - ), - path( - 'license-assignment/', - views.LicenseAssignmentView.as_view(), - name='licenseassignment', - ), - path( - 'license-assignment//edit', - views.LicenseAssignmentEditView.as_view(), - name='licenseassignment_edit', - ), - path( - 'license-assignment//delete', - views.LicenseAssignmentDeleteView.as_view(), - name='licenseassignment_delete', - ), - path( - 'license-assignment//changelog', - ObjectChangeLogView.as_view(), - name='licenseassignment_changelog', - kwargs={'model': LicenseAssignment}, + 'lifecycles/', + include(get_model_urls(app_name, 'hardwarelifecycle', detail=False)), ), - # HTMX endpoints path( - 'htmx/device//contracts/', - views.DeviceContractsHTMXView.as_view(), - name='device_contracts_htmx', + 'lifecycles//', include(get_model_urls(app_name, 'hardwarelifecycle')) ), + path('vendor/', include(get_model_urls(app_name, 'vendor', detail=False))), + path('vendor//', include(get_model_urls(app_name, 'vendor'))), path( - 'htmx/device//contracts/expired/', - views.DeviceContractsExpiredHTMXView.as_view(), - name='device_contracts_expired', + 'contract/', include(get_model_urls(app_name, 'supportcontract', detail=False)) ), + path('contract//', include(get_model_urls(app_name, 'supportcontract'))), path( - 'htmx/virtualmachine//contracts/', - views.VirtualMachineContractsHTMXView.as_view(), - name='virtualmachine_contracts_htmx', + 'contract-assignments/', + include(get_model_urls(app_name, 'supportcontractassignment', detail=False)), ), path( - 'htmx/virtualmachine//contracts/expired/', - views.VirtualMachineContractsExpiredHTMXView.as_view(), - name='virtualmachine_contracts_expired', + 'contract-assignments//', + include(get_model_urls(app_name, 'supportcontractassignment')), ), + path('sku/', include(get_model_urls(app_name, 'supportsku', detail=False))), + path('sku//', include(get_model_urls(app_name, 'supportsku'))), + path('license/', include(get_model_urls(app_name, 'license', detail=False))), + path('license//', include(get_model_urls(app_name, 'license'))), path( - 'htmx/device//licenses/', - views.DeviceLicensesHTMXView.as_view(), - name='device_licenses_htmx', + 'license-assignments/', + include(get_model_urls(app_name, 'licenseassignment', detail=False)), ), path( - 'htmx/virtualmachine//licenses/', - views.VirtualMachineLicensesHTMXView.as_view(), - name='virtualmachine_licenses_htmx', + 'license-assignments//', + include(get_model_urls(app_name, 'licenseassignment')), ), ] diff --git a/netbox_lifecycle/views/__init__.py b/netbox_lifecycle/views/__init__.py index 21fcea4..edd31fe 100644 --- a/netbox_lifecycle/views/__init__.py +++ b/netbox_lifecycle/views/__init__.py @@ -1,4 +1,3 @@ from .contract import * from .hardware import * -from .htmx import * from .license import * diff --git a/netbox_lifecycle/views/contract.py b/netbox_lifecycle/views/contract.py index 11558a6..ed35ec7 100644 --- a/netbox_lifecycle/views/contract.py +++ b/netbox_lifecycle/views/contract.py @@ -1,3 +1,4 @@ +from extras.ui.panels import TagsPanel, CustomFieldsPanel from netbox.views.generic import ( BulkDeleteView, BulkEditView, @@ -8,6 +9,7 @@ ObjectListView, ObjectView, ) +from netbox.ui import panels, layout from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view from netbox_lifecycle.filtersets import ( @@ -46,6 +48,16 @@ SupportSKUTable, VendorTable, ) +from netbox_lifecycle.ui import ( + VendorPanel, + SupportContractPanel, + SupportContractDatesPanel, + SupportContractAssignmentPanel, + SupportContractAssignmentDevicePanel, + SupportContractAssignmentVMPanel, + SupportContractAssignmentLicensePanel, + SupportSKUPanel, +) __all__ = ( # SupportContractAssignment @@ -84,7 +96,7 @@ ) -@register_model_view(Vendor, name='list') +@register_model_view(Vendor, name='list', path='', detail=False) class VendorListView(ObjectListView): queryset = Vendor.objects.all() table = VendorTable @@ -95,6 +107,16 @@ class VendorListView(ObjectListView): @register_model_view(Vendor) class VendorView(GetRelatedModelsMixin, ObjectView): queryset = Vendor.objects.all() + template_name = 'generic/object.html' + layout = layout.SimpleLayout( + left_panels=[ + VendorPanel(), + ], + right_panels=[ + panels.RelatedObjectsPanel(), + panels.CommentsPanel(), + ], + ) def get_extra_context(self, request, instance): assignments = SupportContractAssignment.objects.filter( @@ -107,13 +129,19 @@ def get_extra_context(self, request, instance): } +@register_model_view(Vendor, 'add', detail=False) @register_model_view(Vendor, 'edit') class VendorEditView(ObjectEditView): queryset = Vendor.objects.all() form = VendorForm -@register_model_view(Vendor, 'bulk_edit') +@register_model_view(Vendor, 'delete') +class VendorDeleteView(ObjectDeleteView): + queryset = Vendor.objects.all() + + +@register_model_view(Vendor, 'bulk_edit', detail=False) class VendorBulkEditView(BulkEditView): queryset = Vendor.objects.all() filterset = VendorFilterSet @@ -121,25 +149,20 @@ class VendorBulkEditView(BulkEditView): form = VendorBulkEditForm -@register_model_view(Vendor, 'delete') -class VendorDeleteView(ObjectDeleteView): - queryset = Vendor.objects.all() - - -@register_model_view(Vendor, 'bulk_delete') +@register_model_view(Vendor, 'bulk_delete', detail=False) class VendorBulkDeleteView(BulkDeleteView): queryset = Vendor.objects.all() filterset = VendorFilterSet table = VendorTable -@register_model_view(Vendor, 'bulk_import') +@register_model_view(Vendor, 'bulk_import', detail=False) class VendorBulkImportView(BulkImportView): queryset = Vendor.objects.all() model_form = VendorImportForm -@register_model_view(SupportSKU, name='list') +@register_model_view(SupportSKU, name='list', path='', detail=False) class SupportSKUListView(ObjectListView): queryset = SupportSKU.objects.all() table = SupportSKUTable @@ -150,43 +173,55 @@ class SupportSKUListView(ObjectListView): @register_model_view(SupportSKU) class SupportSKUView(ObjectView): queryset = SupportSKU.objects.all() + template_name = 'generic/object.html' + layout = layout.SimpleLayout( + left_panels=[ + SupportSKUPanel(), + TagsPanel(), + ], + right_panels=[ + panels.CommentsPanel(), + panels.RelatedObjectsPanel(), + ], + ) +@register_model_view(SupportSKU, 'add', detail=False) @register_model_view(SupportSKU, 'edit') class SupportSKUEditView(ObjectEditView): queryset = SupportSKU.objects.all() form = SupportSKUForm -@register_model_view(SupportSKU, 'bulk_edit') -class SupportSKUBulkEditView(BulkEditView): +@register_model_view(SupportSKU, 'delete') +class SupportSKUDeleteView(ObjectDeleteView): queryset = SupportSKU.objects.all() filterset = SupportSKUFilterSet table = SupportSKUTable - form = SupportSKUBulkEditForm -@register_model_view(SupportSKU, 'delete') -class SupportSKUDeleteView(ObjectDeleteView): +@register_model_view(SupportSKU, 'bulk_edit', detail=False) +class SupportSKUBulkEditView(BulkEditView): queryset = SupportSKU.objects.all() filterset = SupportSKUFilterSet table = SupportSKUTable + form = SupportSKUBulkEditForm -@register_model_view(SupportSKU, 'bulk_delete') +@register_model_view(SupportSKU, 'bulk_delete', detail=False) class SupportSKUBulkDeleteView(BulkDeleteView): queryset = SupportSKU.objects.all() filterset = SupportSKUFilterSet table = SupportSKUTable -@register_model_view(SupportSKU, 'bulk_import') +@register_model_view(SupportSKU, 'bulk_import', detail=False) class SupportSKUBulkImportView(BulkImportView): queryset = SupportSKU.objects.all() model_form = SupportSKUImportForm -@register_model_view(SupportContract, name='list') +@register_model_view(SupportContract, name='list', path='', detail=False) class SupportContractListView(ObjectListView): queryset = SupportContract.objects.all() table = SupportContractTable @@ -197,16 +232,26 @@ class SupportContractListView(ObjectListView): @register_model_view(SupportContract) class SupportContractView(ObjectView): queryset = SupportContract.objects.all() + template_name = 'generic/object.html' + layout = layout.SimpleLayout( + left_panels=[ + SupportContractPanel(), + SupportContractDatesPanel(), + TagsPanel(), + ], + right_panels=[ + panels.CommentsPanel(), + panels.RelatedObjectsPanel(), + ], + ) @register_model_view(SupportContract, name='assignments') class SupportContractAssignmentsView(ObjectChildrenView): - template_name = 'netbox_lifecycle/supportcontract/assignments.html' queryset = SupportContract.objects.all() child_model = SupportContractAssignment table = SupportContractAssignmentTable filterset = SupportContractAssignmentFilterSet - actions = {'add': {'add'}, 'edit': {'change'}, 'delete': {'delete'}} tab = ViewTab( label='Assignments', badge=lambda obj: SupportContractAssignment.objects.filter( @@ -218,13 +263,19 @@ def get_children(self, request, parent): return self.child_model.objects.filter(contract=parent) +@register_model_view(SupportContract, 'add', detail=False) @register_model_view(SupportContract, 'edit') class SupportContractEditView(ObjectEditView): queryset = SupportContract.objects.all() form = SupportContractForm -@register_model_view(SupportContract, 'bulk_edit') +@register_model_view(SupportContract, 'delete') +class SupportContractDeleteView(ObjectDeleteView): + queryset = SupportContract.objects.all() + + +@register_model_view(SupportContract, 'bulk_edit', detail=False) class SupportContractBulkEditView(BulkEditView): queryset = SupportContract.objects.all() filterset = SupportContractFilterSet @@ -232,25 +283,20 @@ class SupportContractBulkEditView(BulkEditView): form = SupportContractBulkEditForm -@register_model_view(SupportContract, 'delete') -class SupportContractDeleteView(ObjectDeleteView): - queryset = SupportContract.objects.all() - - -@register_model_view(SupportContract, 'bulk_delete') +@register_model_view(SupportContract, 'bulk_delete', detail=False) class SupportContractBulkDeleteView(BulkDeleteView): queryset = SupportContract.objects.all() filterset = SupportContractFilterSet table = SupportContractTable -@register_model_view(SupportContract, 'bulk_import') +@register_model_view(SupportContract, 'bulk_import', detail=False) class SupportContractBulkImportView(BulkImportView): queryset = SupportContract.objects.all() model_form = SupportContractImportForm -@register_model_view(SupportContractAssignment, name='list') +@register_model_view(SupportContractAssignment, name='list', path='', detail=False) class SupportContractAssignmentListView(ObjectListView): queryset = SupportContractAssignment.objects.all() table = SupportContractAssignmentTable @@ -264,11 +310,27 @@ class SupportContractAssignmentListView(ObjectListView): } -@register_model_view(SupportContract) +@register_model_view(SupportContractAssignment) class SupportContractAssignmentView(ObjectView): queryset = SupportContractAssignment.objects.all() + template_name = 'generic/object.html' + layout = layout.SimpleLayout( + left_panels=[ + SupportContractAssignmentPanel(), + SupportContractAssignmentDevicePanel(), + SupportContractAssignmentVMPanel(), + SupportContractAssignmentLicensePanel(), + TagsPanel(), + ], + right_panels=[ + CustomFieldsPanel(), + panels.CommentsPanel(), + panels.RelatedObjectsPanel(), + ], + ) +@register_model_view(SupportContractAssignment, 'add', detail=False) @register_model_view(SupportContractAssignment, 'edit') class SupportContractAssignmentEditView(ObjectEditView): queryset = SupportContractAssignment.objects.all() @@ -280,6 +342,7 @@ class SupportContractAssignmentDeleteView(ObjectDeleteView): queryset = SupportContractAssignment.objects.all() +@register_model_view(SupportContractAssignment, 'bulk_edit', detail=False) class SupportContractAssignmentBulkEditView(BulkEditView): queryset = SupportContractAssignment.objects.all() filterset = SupportContractAssignmentFilterSet @@ -287,13 +350,14 @@ class SupportContractAssignmentBulkEditView(BulkEditView): form = SupportContractAssignmentBulkEditForm +@register_model_view(SupportContractAssignment, 'bulk_delete', detail=False) class SupportContractAssignmentBulkDeleteView(BulkDeleteView): queryset = SupportContractAssignment.objects.all() filterset = SupportContractAssignmentFilterSet table = SupportContractAssignmentTable -@register_model_view(SupportContractAssignment, 'bulk_import') +@register_model_view(SupportContractAssignment, 'bulk_import', detail=False) class SupportContractAssignmentBulkImportView(BulkImportView): queryset = SupportContractAssignment.objects.all() model_form = SupportContractAssignmentImportForm diff --git a/netbox_lifecycle/views/hardware.py b/netbox_lifecycle/views/hardware.py index 7c8c3d7..ca4edc5 100644 --- a/netbox_lifecycle/views/hardware.py +++ b/netbox_lifecycle/views/hardware.py @@ -1,3 +1,4 @@ +from extras.ui.panels import TagsPanel, CustomFieldsPanel from netbox.views.generic import ( BulkDeleteView, BulkEditView, @@ -7,6 +8,8 @@ ObjectListView, ObjectView, ) +from netbox.ui import panels, layout +from netbox_lifecycle.ui import HardwareLifecyclePanel, HardwareLifecycleDatesPanel from utilities.views import register_model_view from netbox_lifecycle.filtersets import HardwareLifecycleFilterSet @@ -30,7 +33,7 @@ ) -@register_model_view(HardwareLifecycle, name='list') +@register_model_view(HardwareLifecycle, name='list', path='', detail=False) class HardwareLifecycleListView(ObjectListView): queryset = HardwareLifecycle.objects.all() table = HardwareLifecycleTable @@ -41,19 +44,34 @@ class HardwareLifecycleListView(ObjectListView): @register_model_view(HardwareLifecycle) class HardwareLifecycleView(ObjectView): queryset = HardwareLifecycle.objects.all() - - def get_extra_context(self, request, instance): - - return {} - - + template_name = 'generic/object.html' + layout = layout.SimpleLayout( + left_panels=[ + HardwareLifecyclePanel(), + HardwareLifecycleDatesPanel(), + TagsPanel(), + ], + right_panels=[ + CustomFieldsPanel(), + panels.RelatedObjectsPanel(), + panels.CommentsPanel(), + ], + ) + + +@register_model_view(HardwareLifecycle, 'add', detail=False) @register_model_view(HardwareLifecycle, 'edit') class HardwareLifecycleEditView(ObjectEditView): queryset = HardwareLifecycle.objects.all() form = HardwareLifecycleForm -@register_model_view(HardwareLifecycle, 'bulk_edit') +@register_model_view(HardwareLifecycle, 'delete') +class HardwareLifecycleDeleteView(ObjectDeleteView): + queryset = HardwareLifecycle.objects.all() + + +@register_model_view(HardwareLifecycle, 'bulk_edit', detail=False) class HardwareLifecycleBulkEditView(BulkEditView): queryset = HardwareLifecycle.objects.all() filterset = HardwareLifecycleFilterSet @@ -61,19 +79,14 @@ class HardwareLifecycleBulkEditView(BulkEditView): form = HardwareLifecycleBulkEditForm -@register_model_view(HardwareLifecycle, 'delete') -class HardwareLifecycleDeleteView(ObjectDeleteView): - queryset = HardwareLifecycle.objects.all() - - -@register_model_view(HardwareLifecycle, 'bulk_delete') +@register_model_view(HardwareLifecycle, 'bulk_delete', detail=False) class HardwareLifecycleBulkDeleteView(BulkDeleteView): queryset = HardwareLifecycle.objects.all() filterset = HardwareLifecycleFilterSet table = HardwareLifecycleTable -@register_model_view(HardwareLifecycle, 'bulk_import') +@register_model_view(HardwareLifecycle, 'bulk_import', detail=False) class HardwareLifecycleBulkImportView(BulkImportView): queryset = HardwareLifecycle.objects.all() model_form = HardwareLifecycleImportForm diff --git a/netbox_lifecycle/views/htmx.py b/netbox_lifecycle/views/htmx.py deleted file mode 100644 index d085f4b..0000000 --- a/netbox_lifecycle/views/htmx.py +++ /dev/null @@ -1,192 +0,0 @@ -from dcim.models import Device -from django.contrib.auth.mixins import LoginRequiredMixin -from django.shortcuts import get_object_or_404, render -from django.views import View -from virtualization.models import VirtualMachine - -from netbox_lifecycle.constants import ( - CONTRACT_STATUS_ACTIVE, - CONTRACT_STATUS_EXPIRED, - CONTRACT_STATUS_FUTURE, - CONTRACT_STATUS_UNSPECIFIED, -) -from netbox_lifecycle.models import LicenseAssignment, SupportContractAssignment - -MAX_LICENSE_DISPLAY = 10 - -__all__ = ( - 'DeviceContractsExpiredHTMXView', - 'DeviceContractsHTMXView', - 'DeviceLicensesHTMXView', - 'VirtualMachineContractsExpiredHTMXView', - 'VirtualMachineContractsHTMXView', - 'VirtualMachineLicensesHTMXView', -) - - -class DeviceContractsHTMXView(LoginRequiredMixin, View): - """HTMX endpoint for device contract card content.""" - - def get(self, request, pk): - device = get_object_or_404(Device, pk=pk) - assignments = SupportContractAssignment.objects.filter( - device=device - ).select_related( - 'contract', 'contract__vendor', 'sku', 'sku__manufacturer', 'license' - ) - - grouped = { - CONTRACT_STATUS_ACTIVE: [], - CONTRACT_STATUS_FUTURE: [], - CONTRACT_STATUS_UNSPECIFIED: [], - CONTRACT_STATUS_EXPIRED: [], - } - for assignment in assignments: - grouped[assignment.status].append(assignment) - - return render( - request, - 'netbox_lifecycle/htmx/device_contracts.html', - { - 'device': device, - 'active': grouped[CONTRACT_STATUS_ACTIVE], - 'future': grouped[CONTRACT_STATUS_FUTURE], - 'unspecified': grouped[CONTRACT_STATUS_UNSPECIFIED], - 'expired_count': len(grouped[CONTRACT_STATUS_EXPIRED]), - }, - ) - - -class DeviceContractsExpiredHTMXView(LoginRequiredMixin, View): - """HTMX endpoint for expired contracts only.""" - - def get(self, request, pk): - device = get_object_or_404(Device, pk=pk) - expired = [ - a - for a in SupportContractAssignment.objects.filter( - device=device - ).select_related( - 'contract', 'contract__vendor', 'sku', 'sku__manufacturer', 'license' - ) - if a.status == CONTRACT_STATUS_EXPIRED - ] - - return render( - request, - 'netbox_lifecycle/htmx/contract_list.html', - { - 'assignments': expired, - }, - ) - - -class VirtualMachineContractsHTMXView(LoginRequiredMixin, View): - """HTMX endpoint for virtual machine contract card content.""" - - def get(self, request, pk): - virtual_machine = get_object_or_404(VirtualMachine, pk=pk) - assignments = SupportContractAssignment.objects.filter( - virtual_machine=virtual_machine - ).select_related( - 'contract', 'contract__vendor', 'sku', 'sku__manufacturer', 'license' - ) - - grouped = { - CONTRACT_STATUS_ACTIVE: [], - CONTRACT_STATUS_FUTURE: [], - CONTRACT_STATUS_UNSPECIFIED: [], - CONTRACT_STATUS_EXPIRED: [], - } - for assignment in assignments: - grouped[assignment.status].append(assignment) - - return render( - request, - 'netbox_lifecycle/htmx/virtualmachine_contracts.html', - { - 'virtual_machine': virtual_machine, - 'active': grouped[CONTRACT_STATUS_ACTIVE], - 'future': grouped[CONTRACT_STATUS_FUTURE], - 'unspecified': grouped[CONTRACT_STATUS_UNSPECIFIED], - 'expired_count': len(grouped[CONTRACT_STATUS_EXPIRED]), - }, - ) - - -class DeviceLicensesHTMXView(LoginRequiredMixin, View): - """HTMX endpoint for device license card content.""" - - def get(self, request, pk): - device = get_object_or_404(Device, pk=pk) - assignments = LicenseAssignment.objects.filter(device=device).select_related( - 'license', 'license__manufacturer', 'vendor' - ) - - total_count = assignments.count() - show_all_url = None - if total_count > MAX_LICENSE_DISPLAY: - show_all_url = ( - f'/plugins/lifecycle/license-assignment/?device_id={device.pk}' - ) - assignments = assignments[:MAX_LICENSE_DISPLAY] - - return render( - request, - 'netbox_lifecycle/htmx/device_licenses.html', - { - 'device': device, - 'assignments': assignments, - 'show_all_url': show_all_url, - }, - ) - - -class VirtualMachineLicensesHTMXView(LoginRequiredMixin, View): - """HTMX endpoint for virtual machine license card content.""" - - def get(self, request, pk): - virtual_machine = get_object_or_404(VirtualMachine, pk=pk) - assignments = LicenseAssignment.objects.filter( - virtual_machine=virtual_machine - ).select_related('license', 'license__manufacturer', 'vendor') - - total_count = assignments.count() - show_all_url = None - if total_count > MAX_LICENSE_DISPLAY: - show_all_url = f'/plugins/lifecycle/license-assignment/?virtual_machine_id={virtual_machine.pk}' - assignments = assignments[:MAX_LICENSE_DISPLAY] - - return render( - request, - 'netbox_lifecycle/htmx/virtualmachine_licenses.html', - { - 'virtual_machine': virtual_machine, - 'assignments': assignments, - 'show_all_url': show_all_url, - }, - ) - - -class VirtualMachineContractsExpiredHTMXView(LoginRequiredMixin, View): - """HTMX endpoint for expired contracts only (virtual machine).""" - - def get(self, request, pk): - virtual_machine = get_object_or_404(VirtualMachine, pk=pk) - expired = [ - a - for a in SupportContractAssignment.objects.filter( - virtual_machine=virtual_machine - ).select_related( - 'contract', 'contract__vendor', 'sku', 'sku__manufacturer', 'license' - ) - if a.status == CONTRACT_STATUS_EXPIRED - ] - - return render( - request, - 'netbox_lifecycle/htmx/contract_list.html', - { - 'assignments': expired, - }, - ) diff --git a/netbox_lifecycle/views/license.py b/netbox_lifecycle/views/license.py index b10e341..c7f408c 100644 --- a/netbox_lifecycle/views/license.py +++ b/netbox_lifecycle/views/license.py @@ -1,3 +1,4 @@ +from extras.ui.panels import TagsPanel, CustomFieldsPanel from netbox.views.generic import ( BulkDeleteView, BulkEditView, @@ -8,6 +9,12 @@ ObjectListView, ObjectView, ) +from netbox.ui import panels, layout +from netbox_lifecycle.ui import LicensePanel, LicenseAssignmentPanel +from netbox_lifecycle.ui.panels.license import ( + LicenseAssignmentDevicePanel, + LicenseAssignmentVMPanel, +) from utilities.views import ViewTab, register_model_view from netbox_lifecycle.filtersets import LicenseAssignmentFilterSet, LicenseFilterSet @@ -43,7 +50,7 @@ ) -@register_model_view(License, name='list') +@register_model_view(License, name='list', path='', detail=False) class LicenseListView(ObjectListView): queryset = License.objects.all() table = LicenseTable @@ -54,15 +61,33 @@ class LicenseListView(ObjectListView): @register_model_view(License) class LicenseView(ObjectView): queryset = License.objects.all() + template_name = 'generic/object.html' + layout = layout.SimpleLayout( + left_panels=[ + LicensePanel(), + TagsPanel(), + ], + right_panels=[ + CustomFieldsPanel(), + panels.CommentsPanel(), + panels.RelatedObjectsPanel(), + ], + ) +@register_model_view(License, 'add', detail=False) @register_model_view(License, 'edit') class LicenseEditView(ObjectEditView): queryset = License.objects.all() form = LicenseForm -@register_model_view(License, 'bulk_edit') +@register_model_view(License, 'delete') +class LicenseDeleteView(ObjectDeleteView): + queryset = License.objects.all() + + +@register_model_view(License, 'bulk_edit', detail=False) class LicenseBulkEditView(BulkEditView): queryset = License.objects.all() filterset = LicenseFilterSet @@ -70,19 +95,14 @@ class LicenseBulkEditView(BulkEditView): form = LicenseBulkEditForm -@register_model_view(License, 'delete') -class LicenseDeleteView(ObjectDeleteView): - queryset = License.objects.all() - - -@register_model_view(License, 'bulk_delete') +@register_model_view(License, 'bulk_delete', detail=False) class LicenseBulkDeleteView(BulkDeleteView): queryset = License.objects.all() filterset = LicenseFilterSet table = LicenseTable -@register_model_view(License, 'bulk_import') +@register_model_view(License, 'bulk_import', detail=False) class LicenseBulkImportView(BulkImportView): queryset = License.objects.all() model_form = LicenseImportForm @@ -90,13 +110,11 @@ class LicenseBulkImportView(BulkImportView): @register_model_view(License, 'assignments') class LicenseAssignmentsView(ObjectChildrenView): - template_name = 'netbox_lifecycle/license/assignments.html' queryset = License.objects.all() child_model = LicenseAssignment table = LicenseAssignmentTable filterset = LicenseAssignmentFilterSet viewname = None - actions = {'add': {'add'}, 'edit': {'change'}, 'delete': {'delete'}} tab = ViewTab( label='License Assignments', badge=lambda obj: LicenseAssignment.objects.filter(license=obj).count(), @@ -106,7 +124,7 @@ def get_children(self, request, parent): return self.child_model.objects.filter(license=parent) -@register_model_view(LicenseAssignment, name='list') +@register_model_view(LicenseAssignment, name='list', path='', detail=False) class LicenseAssignmentListView(ObjectListView): queryset = LicenseAssignment.objects.all() table = LicenseAssignmentTable @@ -117,8 +135,23 @@ class LicenseAssignmentListView(ObjectListView): @register_model_view(LicenseAssignment) class LicenseAssignmentView(ObjectView): queryset = LicenseAssignment.objects.all() + template_name = 'generic/object.html' + layout = layout.SimpleLayout( + left_panels=[ + LicenseAssignmentPanel(), + LicenseAssignmentDevicePanel(), + LicenseAssignmentVMPanel(), + TagsPanel(), + ], + right_panels=[ + CustomFieldsPanel(), + panels.CommentsPanel(), + panels.RelatedObjectsPanel(), + ], + ) +@register_model_view(LicenseAssignment, 'add', detail=False) @register_model_view(LicenseAssignment, 'edit') class LicenseAssignmentEditView(ObjectEditView): queryset = LicenseAssignment.objects.all() @@ -130,7 +163,7 @@ class LicenseAssignmentDeleteView(ObjectDeleteView): queryset = LicenseAssignment.objects.all() -@register_model_view(LicenseAssignment, 'bulk_edit') +@register_model_view(LicenseAssignment, 'bulk_edit', detail=False) class LicenseAssignmentBulkEditView(BulkEditView): queryset = LicenseAssignment.objects.all() filterset = LicenseAssignmentFilterSet @@ -138,14 +171,14 @@ class LicenseAssignmentBulkEditView(BulkEditView): form = LicenseAssignmentBulkEditForm -@register_model_view(LicenseAssignment, 'bulk_delete') +@register_model_view(LicenseAssignment, 'bulk_delete', detail=False) class LicenseAssignmentBulkDeleteView(BulkDeleteView): queryset = LicenseAssignment.objects.all() filterset = LicenseAssignmentFilterSet table = LicenseAssignmentTable -@register_model_view(LicenseAssignment, 'bulk_import') +@register_model_view(LicenseAssignment, 'bulk_import', detail=False) class LicenseAssignmentBulkImportView(BulkImportView): queryset = LicenseAssignment.objects.all() model_form = LicenseAssignmentImportForm diff --git a/ruff.toml b/ruff.toml index afe1757..819488d 100644 --- a/ruff.toml +++ b/ruff.toml @@ -13,8 +13,8 @@ ignore = ["F403", "F405", "RUF012"] preview = true [lint.per-file-ignores] -"template_code.py" = ["E501"] -"netbox_lifecycle/graphql/filters.py" = ["F821"] +"urls.py" = ["F401", ] +"netbox_lifecycle/graphql/filters.py" = ["F821", ] [format] quote-style = "single" From 850dfaed6dd5914b2622ebe233b9ca52eb41bdfe Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Mon, 11 May 2026 23:01:26 -0500 Subject: [PATCH 2/9] Add quickadd to relevant forms --- netbox_lifecycle/forms/model_forms.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/netbox_lifecycle/forms/model_forms.py b/netbox_lifecycle/forms/model_forms.py index 7e8d50d..88466c4 100644 --- a/netbox_lifecycle/forms/model_forms.py +++ b/netbox_lifecycle/forms/model_forms.py @@ -46,6 +46,7 @@ class SupportSKUForm(NetBoxModelForm): manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), selector=False, + quick_add=True, ) class Meta: @@ -63,6 +64,7 @@ class SupportContractForm(NetBoxModelForm): vendor = DynamicModelChoiceField( queryset=Vendor.objects.all(), selector=True, + quick_add=True, ) class Meta: @@ -88,11 +90,13 @@ class SupportContractAssignmentForm(NetBoxModelForm): contract = DynamicModelChoiceField( queryset=SupportContract.objects.all(), selector=True, + quick_add=True, ) sku = DynamicModelChoiceField( queryset=SupportSKU.objects.all(), required=False, selector=True, + quick_add=True, label=_('SKU'), ) device = DynamicModelChoiceField( @@ -238,6 +242,7 @@ class LicenseForm(NetBoxModelForm): manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), selector=False, + quick_add=True, ) class Meta: @@ -255,10 +260,12 @@ class LicenseAssignmentForm(NetBoxModelForm): vendor = DynamicModelChoiceField( queryset=Vendor.objects.all(), selector=True, + quick_add=True, ) license = DynamicModelChoiceField( queryset=License.objects.all(), selector=True, + quick_add=True, ) device = DynamicModelChoiceField( queryset=Device.objects.all(), From 84d3b598be08db27d55d05e064a45fb343b7d1b1 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Mon, 11 May 2026 23:02:15 -0500 Subject: [PATCH 3/9] Fix Circular Import --- netbox_lifecycle/ui/panels/contract.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox_lifecycle/ui/panels/contract.py b/netbox_lifecycle/ui/panels/contract.py index a8ae1b0..0e92818 100644 --- a/netbox_lifecycle/ui/panels/contract.py +++ b/netbox_lifecycle/ui/panels/contract.py @@ -2,6 +2,8 @@ from netbox.ui import attrs, panels +from netbox_lifecycle.ui.attributes import ColoredDateTimeAttr + __all__ = ( 'VendorPanel', 'SupportSKUPanel', @@ -13,8 +15,6 @@ 'SupportContractAssignmentLicensePanel', ) -from netbox_lifecycle.ui import ColoredDateTimeAttr - class VendorPanel(panels.ObjectAttributesPanel): name = attrs.TextAttr('name') From 4b47917b4b436e6cc2edbf759080622c8ed7588e Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Tue, 12 May 2026 08:35:02 -0500 Subject: [PATCH 4/9] Update linting configuration to ignore additional rules --- ruff.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ruff.toml b/ruff.toml index 819488d..1a750db 100644 --- a/ruff.toml +++ b/ruff.toml @@ -9,12 +9,14 @@ output-format = "github" [lint] extend-select = ["E1", "E2", "E3", "E501", "W"] -ignore = ["F403", "F405", "RUF012"] +ignore = ["F403", "F405", "RUF012", "I001", "RUF022", ] preview = true [lint.per-file-ignores] "urls.py" = ["F401", ] "netbox_lifecycle/graphql/filters.py" = ["F821", ] +"netbox_lifecycle/ui/panels/contract.py" = ["SIM103", ] +"netbox_lifecycle/ui/panels/license.py" = ["SIM103", ] [format] quote-style = "single" From 9d09c928dcf07c5c50f6567db7f4ce02103a4bc0 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Tue, 12 May 2026 08:57:57 -0500 Subject: [PATCH 5/9] Fix test error --- netbox_lifecycle/filtersets/contract.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox_lifecycle/filtersets/contract.py b/netbox_lifecycle/filtersets/contract.py index 653be04..8e6d5a6 100644 --- a/netbox_lifecycle/filtersets/contract.py +++ b/netbox_lifecycle/filtersets/contract.py @@ -62,6 +62,7 @@ class Meta: 'id', 'q', 'manufacturer_id', + 'sku', ) def search(self, queryset, name, value): From d13a2b0119b151d47f52450e3afbdb1cb522eec4 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Tue, 12 May 2026 15:05:13 -0500 Subject: [PATCH 6/9] Update netbox_lifecycle/template_content.py Co-authored-by: Jonathan Senecal --- netbox_lifecycle/template_content.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox_lifecycle/template_content.py b/netbox_lifecycle/template_content.py index da3fbd6..dbf48a9 100644 --- a/netbox_lifecycle/template_content.py +++ b/netbox_lifecycle/template_content.py @@ -235,7 +235,7 @@ class ModuleTypeLifecycleContent(LifecycleMixin, BaseMixin, PluginTemplateExtens models = ['dcim.moduletype'] -class VirtualMachineContractContent(LicenseMixin, BaseMixin, PluginTemplateExtension): +class VirtualMachineContractContent(ContractMixin, LicenseMixin, BaseMixin, PluginTemplateExtension): """Template extension for VirtualMachine detail pages showing contracts and licenses.""" models = ['virtualization.virtualmachine'] From 75dc83edf628da998221ba5627bbc7198a738f58 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Tue, 12 May 2026 15:11:35 -0500 Subject: [PATCH 7/9] Apply suggestions from code review Co-authored-by: Jonathan Senecal Co-authored-by: Daniel Sheppard --- netbox_lifecycle/filtersets/contract.py | 6 ++++-- netbox_lifecycle/template_content.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/netbox_lifecycle/filtersets/contract.py b/netbox_lifecycle/filtersets/contract.py index 8e6d5a6..a2b774b 100644 --- a/netbox_lifecycle/filtersets/contract.py +++ b/netbox_lifecycle/filtersets/contract.py @@ -225,6 +225,8 @@ def search(self, queryset, name, value): return queryset.filter(qs_filter).distinct() def filter_expired(self, queryset, name, value): + today = timezone.now().date() + expired = Q(end__lt=today) | Q(end__isnull=True, contract__end__lt=today) if value: - return queryset.filter(end__lt=timezone.now()) - return queryset.exclude(end__lt=timezone.now()) + return queryset.filter(expired) + return queryset.exclude(expired) diff --git a/netbox_lifecycle/template_content.py b/netbox_lifecycle/template_content.py index dbf48a9..628a4a4 100644 --- a/netbox_lifecycle/template_content.py +++ b/netbox_lifecycle/template_content.py @@ -198,7 +198,7 @@ def _render_license_card(self, location=None, exclude=None, include=None): model='netbox_lifecycle.licenseassignment', filters={self.field_name: lambda ctx: ctx['object'].pk}, include_columns=[ - 'vendor' 'license', + 'vendor', 'license', 'quantity', ], exclude_columns=[ From 77775f096a636a6d32062466f262f86f41fb29af Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Tue, 12 May 2026 17:40:02 -0500 Subject: [PATCH 8/9] Fix missing contract tabs --- netbox_lifecycle/choices/__init__.py | 3 + netbox_lifecycle/choices/contract.py | 22 +++ netbox_lifecycle/filtersets/contract.py | 23 +++- netbox_lifecycle/template_content.py | 129 +++++++++++------- .../ui/panels/tabbed_table.html | 39 ++++++ netbox_lifecycle/ui/panels/tabbed.py | 54 ++++++++ 6 files changed, 214 insertions(+), 56 deletions(-) create mode 100644 netbox_lifecycle/choices/__init__.py create mode 100644 netbox_lifecycle/choices/contract.py create mode 100644 netbox_lifecycle/templates/netbox_lifecycle/ui/panels/tabbed_table.html create mode 100644 netbox_lifecycle/ui/panels/tabbed.py diff --git a/netbox_lifecycle/choices/__init__.py b/netbox_lifecycle/choices/__init__.py new file mode 100644 index 0000000..3d4ad1f --- /dev/null +++ b/netbox_lifecycle/choices/__init__.py @@ -0,0 +1,3 @@ +from .contract import ContractStatusChoices + +__all__ = ('ContractStatusChoices',) diff --git a/netbox_lifecycle/choices/contract.py b/netbox_lifecycle/choices/contract.py new file mode 100644 index 0000000..48967c6 --- /dev/null +++ b/netbox_lifecycle/choices/contract.py @@ -0,0 +1,22 @@ +from django.utils.translation import gettext as _ +from utilities.choices import ChoiceSet + +from netbox_lifecycle import constants + + +class ContractStatusChoices(ChoiceSet): + """ + Support contract status choices. + """ + + ACTIVE = constants.CONTRACT_STATUS_ACTIVE + EXPIRED = constants.CONTRACT_STATUS_EXPIRED + FUTURE = constants.CONTRACT_STATUS_FUTURE + UNSPECIFIED = constants.CONTRACT_STATUS_UNSPECIFIED + + CHOICES = ( + (ACTIVE, _('Active')), + (FUTURE, _('Future')), + (UNSPECIFIED, _('Unspecified')), + (EXPIRED, _('Expired')), + ) diff --git a/netbox_lifecycle/filtersets/contract.py b/netbox_lifecycle/filtersets/contract.py index a2b774b..786d4db 100644 --- a/netbox_lifecycle/filtersets/contract.py +++ b/netbox_lifecycle/filtersets/contract.py @@ -8,6 +8,8 @@ from utilities.filtersets import register_filterset from virtualization.models import VirtualMachine +from netbox_lifecycle import constants +from netbox_lifecycle.choices import ContractStatusChoices from netbox_lifecycle.models import ( License, SupportContract, @@ -187,9 +189,10 @@ class SupportContractAssignmentFilterSet(NetBoxModelFilterSet): to_field_name='status', label=_('Device Status'), ) - expired = django_filters.BooleanFilter( - method='filter_expired', - label=_('Expired'), + status = django_filters.ChoiceFilter( + choices=ContractStatusChoices.CHOICES, + method='filter_status', + label=_('Status'), ) class Meta: @@ -205,6 +208,7 @@ class Meta: 'virtual_machine_id', 'license_id', 'device_status', + 'status', ) def search(self, queryset, name, value): @@ -224,9 +228,16 @@ def search(self, queryset, name, value): ) return queryset.filter(qs_filter).distinct() - def filter_expired(self, queryset, name, value): + def filter_status(self, queryset, name, value): today = timezone.now().date() expired = Q(end__lt=today) | Q(end__isnull=True, contract__end__lt=today) - if value: + future = Q(contract__start__gt=today) + if value == constants.CONTRACT_STATUS_ACTIVE: + return queryset.exclude(expired).exclude(future) + elif value == constants.CONTRACT_STATUS_FUTURE: + return queryset.filter(future) + elif value == constants.CONTRACT_STATUS_EXPIRED: return queryset.filter(expired) - return queryset.exclude(expired) + elif value == constants.CONTRACT_STATUS_UNSPECIFIED: + return queryset.filter(end__isnull=True, contract__end__isnull=True) + return queryset diff --git a/netbox_lifecycle/template_content.py b/netbox_lifecycle/template_content.py index 628a4a4..820e220 100644 --- a/netbox_lifecycle/template_content.py +++ b/netbox_lifecycle/template_content.py @@ -4,8 +4,10 @@ from netbox.plugins import PluginTemplateExtension from netbox.ui import panels, actions -from .models import hardware -from .ui import HardwareLifecyclePanel, HardwareLifecycleDatesPanel +from netbox_lifecycle import constants +from netbox_lifecycle.models import hardware +from netbox_lifecycle.ui import HardwareLifecyclePanel, HardwareLifecycleDatesPanel +from netbox_lifecycle.ui.panels.tabbed import TabbedTablePanel PLUGIN_SETTINGS = settings.PLUGINS_CONFIG.get('netbox_lifecycle', {}) @@ -58,8 +60,7 @@ def right_page(self): if hasattr(self, '_render_lifecycle_info'): result += self._render_lifecycle_info('right_page') if hasattr(self, '_render_contract_card'): - result += self._render_contract_card('right_page', expired=False) - result += self._render_contract_card('right_page', expired=True) + result += self._render_contract_card('right_page') if hasattr(self, '_render_license_card'): result += self._render_license_card('right_page') return result @@ -69,8 +70,7 @@ def left_page(self): if hasattr(self, '_render_lifecycle_info'): result += self._render_lifecycle_info('left_page') if hasattr(self, '_render_contract_card'): - result += self._render_contract_card('left_page', expired=False) - result += self._render_contract_card('left_page', expired=True) + result += self._render_contract_card('left_page') if hasattr(self, '_render_license_card'): result += self._render_license_card('left_page') return result @@ -80,8 +80,7 @@ def full_width_page(self): if hasattr(self, '_render_lifecycle_info'): result += self._render_lifecycle_info('full_width_page') if hasattr(self, '_render_contract_card'): - result += self._render_contract_card('full_width_page', expired=False) - result += self._render_contract_card('full_width_page', expired=True) + result += self._render_contract_card('full_width_page') if hasattr(self, '_render_license_card'): result += self._render_license_card('full_width_page') return result @@ -122,53 +121,80 @@ class ContractMixin: def get_contract_card_position(self): return PLUGIN_SETTINGS.get('contract_card_position', 'right_page') - def _render_contract_card(self, location=None, expired=None): + def _render_contract_card(self, location=None): if self.get_contract_card_position() != location: return '' title = _('Contracts') - filter = {} - action = [] - if expired is True or expired is False: - filter = {'expired': expired} - title = _('Expired Contracts') if expired else _('Active Contracts') - - if not expired: - action = [ - actions.AddObject( - 'netbox_lifecycle.SupportContractAssignment', - url_params={ - self.model_name: lambda ctx: ctx['object'].pk, - }, - ), - ] + action = [ + actions.AddObject( + 'netbox_lifecycle.SupportContractAssignment', + url_params={ + self.model_name: lambda ctx: ctx['object'].pk, + }, + ), + ] + include_columns = [ + 'contract', + 'sku', + ] + exclude_columns = [ + 'device_name', + 'module_name', + 'virtual_machine_name', + 'license_name', + 'device_model', + 'device_serial', + 'module_serial', + 'device_status', + 'virtual_machine_status', + 'quantity', + 'renewal', + 'end', + 'description', + 'comments', + 'actions', + ] + filter = { + self.field_name: lambda ctx: ctx['object'].pk, + } context = self.get_context(self.context) - panel = panels.ObjectsTablePanel( + panel = TabbedTablePanel( title=title, - model='netbox_lifecycle.supportcontractassignment', - filters={self.field_name: lambda ctx: ctx['object'].pk, **filter}, - include_columns=[ - 'contract', - 'sku', - ], - exclude_columns=[ - 'device_name', - 'module_name', - 'virtual_machine_name', - 'license_name', - 'device_model', - 'device_serial', - 'module_serial', - 'device_status', - 'virtual_machine_status', - 'quantity', - 'renewal', - 'end', - 'description', - 'comments', - 'actions', - ], + tabs={ + 'active': panels.ObjectsTablePanel( + title=_('Active'), + model='netbox_lifecycle.supportcontractassignment', + filters={**filter, 'status': constants.CONTRACT_STATUS_ACTIVE}, + include_columns=include_columns, + exclude_columns=exclude_columns, + ), + 'expired': panels.ObjectsTablePanel( + title=_('Expired'), + model='netbox_lifecycle.supportcontractassignment', + filters={**filter, 'status': constants.CONTRACT_STATUS_EXPIRED}, + include_columns=include_columns, + exclude_columns=exclude_columns, + ), + 'future': panels.ObjectsTablePanel( + title=_('Future'), + model='netbox_lifecycle.supportcontractassignment', + filters={**filter, 'status': constants.CONTRACT_STATUS_FUTURE}, + include_columns=include_columns, + exclude_columns=exclude_columns, + ), + 'unspecified': panels.ObjectsTablePanel( + title=_('Unspecified'), + model='netbox_lifecycle.supportcontractassignment', + filters={ + **filter, + 'status': constants.CONTRACT_STATUS_UNSPECIFIED, + }, + include_columns=include_columns, + exclude_columns=exclude_columns, + ), + }, actions=action, ) return panel.render(context=context) @@ -198,7 +224,8 @@ def _render_license_card(self, location=None, exclude=None, include=None): model='netbox_lifecycle.licenseassignment', filters={self.field_name: lambda ctx: ctx['object'].pk}, include_columns=[ - 'vendor', 'license', + 'vendor', + 'license', 'quantity', ], exclude_columns=[ @@ -235,7 +262,9 @@ class ModuleTypeLifecycleContent(LifecycleMixin, BaseMixin, PluginTemplateExtens models = ['dcim.moduletype'] -class VirtualMachineContractContent(ContractMixin, LicenseMixin, BaseMixin, PluginTemplateExtension): +class VirtualMachineContractContent( + ContractMixin, LicenseMixin, BaseMixin, PluginTemplateExtension +): """Template extension for VirtualMachine detail pages showing contracts and licenses.""" models = ['virtualization.virtualmachine'] diff --git a/netbox_lifecycle/templates/netbox_lifecycle/ui/panels/tabbed_table.html b/netbox_lifecycle/templates/netbox_lifecycle/ui/panels/tabbed_table.html new file mode 100644 index 0000000..41fe313 --- /dev/null +++ b/netbox_lifecycle/templates/netbox_lifecycle/ui/panels/tabbed_table.html @@ -0,0 +1,39 @@ +{% extends "ui/panels/_base.html" %} +{% load i18n %} + +{% block panel_content %} + +
+ {% for tab in tabs %} + {% if tab.active %} +
+
+
+ {% else %} +
+
+
+ {% endif %} + {% endfor %} +
+{% endblock panel_content %} \ No newline at end of file diff --git a/netbox_lifecycle/ui/panels/tabbed.py b/netbox_lifecycle/ui/panels/tabbed.py new file mode 100644 index 0000000..ab4a38f --- /dev/null +++ b/netbox_lifecycle/ui/panels/tabbed.py @@ -0,0 +1,54 @@ +from netbox.ui.panels import Panel, ObjectsTablePanel +from utilities.querydict import dict_to_querydict +from utilities.string import title +from utilities.views import get_viewname + + +class TabbedTablePanel(Panel): + """ + A panel which displays panels within tabs. + """ + + template_name = 'netbox_lifecycle/ui/panels/tabbed_table.html' + + def __init__(self, tabs: dict, **kwargs): + super().__init__(**kwargs) + for tab in tabs.values(): + if tab is ObjectsTablePanel: + raise TypeError( + f"TabbedTablePanel only accepts ObjectsTablePanel instances, got {type(tab)}" + ) + self.tabs = tabs + + def get_context(self, context): + tabs = [] + first = True + for name, panel in self.tabs.items(): + # If no title is specified, derive one from the model name + url_params = { + k: v(context) if callable(v) else v for k, v in panel.filters.items() + } + + if 'return_url' not in url_params and 'object' in context: + url_params['return_url'] = context['object'].get_absolute_url() + if panel.include_columns: + url_params['include_columns'] = ','.join(panel.include_columns) + if panel.exclude_columns: + url_params['exclude_columns'] = ','.join(panel.exclude_columns) + + tab = { + 'name': name, + 'model': panel.model, + 'viewname': get_viewname(panel.model, 'list'), + 'title': panel.title or title(panel.model._meta.verbose_name_plural), + 'active': first, + 'url_params': dict_to_querydict(url_params), + } + first = False + tabs.append(tab) + + return { + **super().get_context(context), + 'title': self.title, + 'tabs': tabs, + } From 7ffda13b5576539d3449d5ff434a1b0115877ac7 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Thu, 14 May 2026 09:57:25 -0500 Subject: [PATCH 9/9] Adjust `filter_status` filter to exclude future contracts --- netbox_lifecycle/filtersets/contract.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox_lifecycle/filtersets/contract.py b/netbox_lifecycle/filtersets/contract.py index 786d4db..957bf54 100644 --- a/netbox_lifecycle/filtersets/contract.py +++ b/netbox_lifecycle/filtersets/contract.py @@ -232,6 +232,7 @@ def filter_status(self, queryset, name, value): today = timezone.now().date() expired = Q(end__lt=today) | Q(end__isnull=True, contract__end__lt=today) future = Q(contract__start__gt=today) + unspecified = Q(end__isnull=True, contract__end__isnull=True) if value == constants.CONTRACT_STATUS_ACTIVE: return queryset.exclude(expired).exclude(future) elif value == constants.CONTRACT_STATUS_FUTURE: @@ -239,5 +240,5 @@ def filter_status(self, queryset, name, value): elif value == constants.CONTRACT_STATUS_EXPIRED: return queryset.filter(expired) elif value == constants.CONTRACT_STATUS_UNSPECIFIED: - return queryset.filter(end__isnull=True, contract__end__isnull=True) + return queryset.filter(unspecified).exclude(future) return queryset