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/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..6a448bd37c4 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..5dcf438e984 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -146,6 +146,18 @@ 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'), + orderable=False, + ) class Meta(PrimaryModelTable.Meta): model = models.DeviceType @@ -153,6 +165,7 @@ class Meta(PrimaryModelTable.Meta): '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', + '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..c282a6f7370 100644 --- a/netbox/dcim/tables/modules.py +++ b/netbox/dcim/tables/modules.py @@ -67,12 +67,16 @@ class ModuleTypeTable(PrimaryModelTable): tags = columns.TagColumn( url_name='dcim:moduletype_list' ) + image_count = tables.Column( + verbose_name=_('Images'), + orderable=False, + ) 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..9ea500ab415 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 @@ -1409,6 +1409,22 @@ class DeviceTypeListView(generic.ObjectListView): 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): @@ -1759,6 +1775,22 @@ class ModuleTypeListView(generic.ObjectListView): 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) class ModuleTypeView(GetRelatedModelsMixin, generic.ObjectView):