From a95b6699ddf79867d17689bd643ae18669e1ef47 Mon Sep 17 00:00:00 2001 From: cgiestas Date: Thu, 16 Jul 2026 16:26:17 -0300 Subject: [PATCH 1/2] feature: Add optional Front/Rear Image and Images columns to Device/Module Type tables Closes #22030 --- netbox/dcim/filtersets.py | 14 +++++++++++++ netbox/dcim/forms/filtersets.py | 17 +++++++++++++++- netbox/dcim/tables/devicetypes.py | 14 ++++++++++++- netbox/dcim/tables/modules.py | 5 ++++- netbox/dcim/views.py | 34 ++++++++++++++++++++++++++++++- 5 files changed, 80 insertions(+), 4 deletions(-) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 9ebd2ad5b2f..b9b6f1924d6 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -722,6 +722,10 @@ class DeviceTypeFilterSet(PrimaryModelFilterSet): method='_inventory_items', label=_('Has inventory items'), ) + has_images = django_filters.BooleanFilter( + method='_has_images', + label=('Has images'), + ) class Meta: model = DeviceType @@ -794,6 +798,9 @@ def _device_bays(self, queryset, name, value): def _inventory_items(self, queryset, name, value): return queryset.exclude(inventoryitemtemplates__isnull=value) + def _has_images(self, queryset, name, value): + return queryset.exclude(images__isnull=value) + @register_filterset class ModuleTypeProfileFilterSet(PrimaryModelFilterSet): @@ -866,6 +873,10 @@ class ModuleTypeFilterSet(AttributeFiltersMixin, PrimaryModelFilterSet): method='_module_bays', label=_('Has module bays'), ) + has_images = django_filters.BooleanFilter( + method='_has_images', + label=_('Has images'), + ) class Meta: model = ModuleType @@ -919,6 +930,9 @@ def _pass_through_ports(self, queryset, name, value): def _module_bays(self, queryset, name, value): return queryset.exclude(modulebaytemplates__isnull=value) + def _has_images(self, queryset, name, value): + return queryset.exclude(images__isnull=value) + class DeviceTypeComponentFilterSet(django_filters.FilterSet): q = django_filters.CharFilter( diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 0cb86a26851..e7747f77f60 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -563,7 +563,7 @@ class DeviceTypeFilterForm(PrimaryModelFilterSetForm): 'manufacturer_id', 'default_platform_id', 'part_number', 'device_count', 'subdevice_role', 'airflow', name=_('Hardware') ), - FieldSet('has_front_image', 'has_rear_image', name=_('Images')), + FieldSet('has_front_image', 'has_rear_image', 'has_images', name=_('Images')), FieldSet( 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports', 'device_bays', 'module_bays', 'inventory_items', name=_('Components') @@ -615,6 +615,13 @@ class DeviceTypeFilterForm(PrimaryModelFilterSetForm): choices=BOOLEAN_WITH_BLANK_CHOICES ) ) + has_images = forms.NullBooleanField( + required=False, + label=_('Has images'), + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) console_ports = forms.NullBooleanField( required=False, label=_('Has console ports'), @@ -712,6 +719,7 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm): 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports', 'module_bays', name=_('Components') ), + FieldSet('has_images', name=_('Images')), FieldSet('weight', 'weight_unit', name=_('Weight')), FieldSet('owner_group_id', 'owner_id', name=_('Ownership')), ) @@ -800,6 +808,13 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm): choices=add_blank_choice(WeightUnitChoices), required=False ) + has_images = forms.NullBooleanField( + required=False, + label=_('Has images'), + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) class DeviceRoleFilterForm(NestedGroupModelFilterSetForm): diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index f952c2d4a0e..114e35e8ece 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -146,13 +146,25 @@ class DeviceTypeTable(PrimaryModelTable): inventory_item_template_count = tables.Column( verbose_name=_('Inventory Items') ) + front_image = columns.BooleanColumn( + verbose_name=_('Front Image'), + false_mark=None + ) + rear_image = columns.BooleanColumn( + verbose_name=_('Rear Image'), + false_mark=None + ) + image_count = tables.Column( + verbose_name=_('Images') + ) class Meta(PrimaryModelTable.Meta): model = models.DeviceType fields = ( 'pk', 'id', 'model', 'manufacturer', 'default_platform', 'slug', 'part_number', 'u_height', 'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', - 'description', 'comments', 'device_count', 'tags', 'created', 'last_updated', + 'description', 'comments', 'device_count', 'tags', 'created', 'last_updated', + 'front_image', 'rear_image', 'image_count', ) default_columns = ( 'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'device_count', diff --git a/netbox/dcim/tables/modules.py b/netbox/dcim/tables/modules.py index 67959e92e04..9d6f0218351 100644 --- a/netbox/dcim/tables/modules.py +++ b/netbox/dcim/tables/modules.py @@ -67,12 +67,15 @@ class ModuleTypeTable(PrimaryModelTable): tags = columns.TagColumn( url_name='dcim:moduletype_list' ) + image_count = tables.Column( + verbose_name=_('Images') + ) class Meta(PrimaryModelTable.Meta): model = ModuleType fields = ( 'pk', 'id', 'model', 'profile', 'manufacturer', 'part_number', 'airflow', 'weight', 'description', - 'attributes', 'module_count', 'comments', 'tags', 'created', 'last_updated', + 'attributes', 'module_count', 'comments', 'tags', 'created', 'last_updated', 'image_count', ) default_columns = ( 'pk', 'model', 'profile', 'manufacturer', 'part_number', 'module_count', diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index cc888597d75..2a84e92265b 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -3,7 +3,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.paginator import EmptyPage, PageNotAnInteger from django.db import router, transaction -from django.db.models import Func, IntegerField, Prefetch +from django.db.models import Count, Func, IntegerField, Prefetch from django.forms import ModelMultipleChoiceField, MultipleHiddenInput, modelformset_factory from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse @@ -1408,7 +1408,23 @@ class DeviceTypeListView(generic.ObjectListView): filterset = filtersets.DeviceTypeFilterSet filterset_form = forms.DeviceTypeFilterForm table = tables.DeviceTypeTable + + def get_table(self, data, request, bulk_actions=True): + #Figures out which columns the user has picked before building the tables, + #because configure() tries to apply an existing sort, that may include image_count + #and it needs the existing annotation from the queryset. + if request.user.is_authenticated: + selected_columns = request.user.config.get(f'tables.{self.table.__name__}.columns') + else: + selected_columns = None + if selected_columns is None: + selected_columns = self.table.Meta.default_columns + + if 'image_count' in selected_columns: + data = data.annotate(image_count=Count('images')) + return super().get_table(data, request, bulk_actions) + @register_model_view(DeviceType) class DeviceTypeView(GetRelatedModelsMixin, generic.ObjectView): @@ -1758,6 +1774,22 @@ class ModuleTypeListView(generic.ObjectListView): filterset = filtersets.ModuleTypeFilterSet filterset_form = forms.ModuleTypeFilterForm table = tables.ModuleTypeTable + + def get_table(self, data, request, bulk_actions=True): + #Figures out which columns the user has picked before building the tables, + #because configure() tries to apply an existing sort, that may include image_count + #and it needs the existing annotation from the queryset. + if request.user.is_authenticated: + selected_columns = request.user.config.get(f'tables.{self.table.__name__}.columns') + else: + selected_columns = None + if selected_columns is None: + selected_columns = self.table.Meta.default_columns + + if 'image_count' in selected_columns: + data = data.annotate(image_count=Count('images')) + + return super().get_table(data, request, bulk_actions) @register_model_view(ModuleType) From 7ad33541f2f87dc53e344b7343c7fb81e0e0bc7a Mon Sep 17 00:00:00 2001 From: cgiestas Date: Fri, 17 Jul 2026 09:14:00 -0300 Subject: [PATCH 2/2] =?UTF-8?q?define=20a=20coluna=20de=20imagens=20como?= =?UTF-8?q?=20n=C3=A3o=20orden=C3=A1vel=20e=20faz=20pequenos=20ajustes=20d?= =?UTF-8?q?e=20indenta=C3=A7=C3=A3o=20de=20coment=C3=A1rios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Christiane Giestas --- .gitignore | 3 +++ netbox/dcim/forms/filtersets.py | 2 +- netbox/dcim/tables/devicetypes.py | 5 +++-- netbox/dcim/tables/modules.py | 3 ++- netbox/dcim/views.py | 18 +++++++++--------- 5 files changed, 18 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index f789c158a64..b3de1691e7d 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,6 @@ yarn-error.log* /dist/ /build/ *.egg-info/ + +# Ruff's locally generated cache +.ruff_cache \ No newline at end of file diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index e7747f77f60..6a448bd37c4 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -621,7 +621,7 @@ class DeviceTypeFilterForm(PrimaryModelFilterSetForm): widget=forms.Select( choices=BOOLEAN_WITH_BLANK_CHOICES ) - ) + ) console_ports = forms.NullBooleanField( required=False, label=_('Has console ports'), diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index 114e35e8ece..5dcf438e984 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -155,7 +155,8 @@ class DeviceTypeTable(PrimaryModelTable): false_mark=None ) image_count = tables.Column( - verbose_name=_('Images') + verbose_name=_('Images'), + orderable=False, ) class Meta(PrimaryModelTable.Meta): @@ -163,7 +164,7 @@ class Meta(PrimaryModelTable.Meta): fields = ( 'pk', 'id', 'model', 'manufacturer', 'default_platform', 'slug', 'part_number', 'u_height', 'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', - 'description', 'comments', 'device_count', 'tags', 'created', 'last_updated', + 'description', 'comments', 'device_count', 'tags', 'created', 'last_updated', 'front_image', 'rear_image', 'image_count', ) default_columns = ( diff --git a/netbox/dcim/tables/modules.py b/netbox/dcim/tables/modules.py index 9d6f0218351..c282a6f7370 100644 --- a/netbox/dcim/tables/modules.py +++ b/netbox/dcim/tables/modules.py @@ -68,7 +68,8 @@ class ModuleTypeTable(PrimaryModelTable): url_name='dcim:moduletype_list' ) image_count = tables.Column( - verbose_name=_('Images') + verbose_name=_('Images'), + orderable=False, ) class Meta(PrimaryModelTable.Meta): diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 2a84e92265b..9ea500ab415 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1408,11 +1408,11 @@ class DeviceTypeListView(generic.ObjectListView): filterset = filtersets.DeviceTypeFilterSet filterset_form = forms.DeviceTypeFilterForm table = tables.DeviceTypeTable - + def get_table(self, data, request, bulk_actions=True): - #Figures out which columns the user has picked before building the tables, - #because configure() tries to apply an existing sort, that may include image_count - #and it needs the existing annotation from the queryset. + # Figures out which columns the user has picked before building the tables, + # because configure() tries to apply an existing sort, that may include image_count + # and it needs the existing annotation from the queryset. if request.user.is_authenticated: selected_columns = request.user.config.get(f'tables.{self.table.__name__}.columns') else: @@ -1424,7 +1424,7 @@ def get_table(self, data, request, bulk_actions=True): data = data.annotate(image_count=Count('images')) return super().get_table(data, request, bulk_actions) - + @register_model_view(DeviceType) class DeviceTypeView(GetRelatedModelsMixin, generic.ObjectView): @@ -1774,11 +1774,11 @@ class ModuleTypeListView(generic.ObjectListView): filterset = filtersets.ModuleTypeFilterSet filterset_form = forms.ModuleTypeFilterForm table = tables.ModuleTypeTable - + def get_table(self, data, request, bulk_actions=True): - #Figures out which columns the user has picked before building the tables, - #because configure() tries to apply an existing sort, that may include image_count - #and it needs the existing annotation from the queryset. + # Figures out which columns the user has picked before building the tables, + # because configure() tries to apply an existing sort, that may include image_count + # and it needs the existing annotation from the queryset. if request.user.is_authenticated: selected_columns = request.user.config.get(f'tables.{self.table.__name__}.columns') else: