From c1182274b3a07075ce5f3c22e03dcad2ae95be5b Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Sun, 21 May 2023 23:34:33 +1000
Subject: [PATCH] Display part units column in part table

- Also allow ordering by part units
- Allow filtering to show parts which have defined units
---
 InvenTree/part/api.py                         |  87 +++++-------
 InvenTree/part/serializers.py                 | 132 ++++++++----------
 InvenTree/templates/js/translated/part.js     |   6 +
 .../templates/js/translated/table_filters.js  |   5 +
 4 files changed, 110 insertions(+), 120 deletions(-)

diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py
index b9a390d9d5..662b938d58 100644
--- a/InvenTree/part/api.py
+++ b/InvenTree/part/api.py
@@ -804,19 +804,31 @@ class PartFilter(rest_filters.FilterSet):
     Uses the django_filters extension framework
     """
 
+    class Meta:
+        """Metaclass options for this filter set"""
+        model = Part
+        fields = []
+
+    has_units = rest_filters.BooleanFilter(label='Has units', method='filter_has_units')
+
+    def filter_has_units(self, queryset, name, value):
+        """Filter by whether the Part has units or not"""
+
+        if str2bool(value):
+            return queryset.exclude(units='')
+        else:
+            return queryset.filter(units='')
+
     # Filter by parts which have (or not) an IPN value
     has_ipn = rest_filters.BooleanFilter(label='Has IPN', method='filter_has_ipn')
 
     def filter_has_ipn(self, queryset, name, value):
         """Filter by whether the Part has an IPN (internal part number) or not"""
-        value = str2bool(value)
 
-        if value:
-            queryset = queryset.exclude(IPN='')
+        if str2bool(value):
+            return queryset.exclude(IPN='')
         else:
-            queryset = queryset.filter(IPN='')
-
-        return queryset
+            return queryset.filter(IPN='')
 
     # Regex filter for name
     name_regex = rest_filters.CharFilter(label='Filter by name (regex)', field_name='name', lookup_expr='iregex')
@@ -836,46 +848,36 @@ class PartFilter(rest_filters.FilterSet):
 
     def filter_low_stock(self, queryset, name, value):
         """Filter by "low stock" status."""
-        value = str2bool(value)
 
-        if value:
+        if str2bool(value):
             # Ignore any parts which do not have a specified 'minimum_stock' level
-            queryset = queryset.exclude(minimum_stock=0)
             # Filter items which have an 'in_stock' level lower than 'minimum_stock'
-            queryset = queryset.filter(Q(in_stock__lt=F('minimum_stock')))
+            return queryset.exclude(minimum_stock=0).filter(Q(in_stock__lt=F('minimum_stock')))
         else:
             # Filter items which have an 'in_stock' level higher than 'minimum_stock'
-            queryset = queryset.filter(Q(in_stock__gte=F('minimum_stock')))
-
-        return queryset
+            return queryset.filter(Q(in_stock__gte=F('minimum_stock')))
 
     # has_stock filter
     has_stock = rest_filters.BooleanFilter(label='Has stock', method='filter_has_stock')
 
     def filter_has_stock(self, queryset, name, value):
         """Filter by whether the Part has any stock"""
-        value = str2bool(value)
 
-        if value:
-            queryset = queryset.filter(Q(in_stock__gt=0))
+        if str2bool(value):
+            return queryset.filter(Q(in_stock__gt=0))
         else:
-            queryset = queryset.filter(Q(in_stock__lte=0))
-
-        return queryset
+            return queryset.filter(Q(in_stock__lte=0))
 
     # unallocated_stock filter
     unallocated_stock = rest_filters.BooleanFilter(label='Unallocated stock', method='filter_unallocated_stock')
 
     def filter_unallocated_stock(self, queryset, name, value):
         """Filter by whether the Part has unallocated stock"""
-        value = str2bool(value)
 
-        if value:
-            queryset = queryset.filter(Q(unallocated_stock__gt=0))
+        if str2bool(value):
+            return queryset.filter(Q(unallocated_stock__gt=0))
         else:
-            queryset = queryset.filter(Q(unallocated_stock__lte=0))
-
-        return queryset
+            return queryset.filter(Q(unallocated_stock__lte=0))
 
     convert_from = rest_filters.ModelChoiceFilter(label="Can convert from", queryset=Part.objects.all(), method='filter_convert_from')
 
@@ -894,9 +896,7 @@ class PartFilter(rest_filters.FilterSet):
 
         children = part.get_descendants(include_self=True)
 
-        queryset = queryset.exclude(id__in=children)
-
-        return queryset
+        return queryset.exclude(id__in=children)
 
     ancestor = rest_filters.ModelChoiceFilter(label='Ancestor', queryset=Part.objects.all(), method='filter_ancestor')
 
@@ -904,17 +904,14 @@ class PartFilter(rest_filters.FilterSet):
         """Limit queryset to descendants of the specified ancestor part"""
 
         descendants = part.get_descendants(include_self=False)
-        queryset = queryset.filter(id__in=descendants)
-
-        return queryset
+        return queryset.filter(id__in=descendants)
 
     variant_of = rest_filters.ModelChoiceFilter(label='Variant Of', queryset=Part.objects.all(), method='filter_variant_of')
 
     def filter_variant_of(self, queryset, name, part):
         """Limit queryset to direct children (variants) of the specified part"""
 
-        queryset = queryset.filter(id__in=part.get_children())
-        return queryset
+        return queryset.filter(id__in=part.get_children())
 
     in_bom_for = rest_filters.ModelChoiceFilter(label='In BOM Of', queryset=Part.objects.all(), method='filter_in_bom')
 
@@ -922,39 +919,30 @@ class PartFilter(rest_filters.FilterSet):
         """Limit queryset to parts in the BOM for the specified part"""
 
         bom_parts = part.get_parts_in_bom()
-        queryset = queryset.filter(id__in=[p.pk for p in bom_parts])
-        return queryset
+        return queryset.filter(id__in=[p.pk for p in bom_parts])
 
     has_pricing = rest_filters.BooleanFilter(label="Has Pricing", method="filter_has_pricing")
 
     def filter_has_pricing(self, queryset, name, value):
         """Filter the queryset based on whether pricing information is available for the sub_part"""
 
-        value = str2bool(value)
-
         q_a = Q(pricing_data=None)
         q_b = Q(pricing_data__overall_min=None, pricing_data__overall_max=None)
 
-        if value:
-            queryset = queryset.exclude(q_a | q_b)
+        if str2bool(value):
+            return queryset.exclude(q_a | q_b)
         else:
-            queryset = queryset.filter(q_a | q_b)
-
-        return queryset
+            return queryset.filter(q_a | q_b)
 
     stocktake = rest_filters.BooleanFilter(label="Has stocktake", method='filter_has_stocktake')
 
     def filter_has_stocktake(self, queryset, name, value):
         """Filter the queryset based on whether stocktake data is available"""
 
-        value = str2bool(value)
-
-        if (value):
-            queryset = queryset.exclude(last_stocktake=None)
+        if str2bool(value):
+            return queryset.exclude(last_stocktake=None)
         else:
-            queryset = queryset.filter(last_stocktake=None)
-
-        return queryset
+            return queryset.filter(last_stocktake=None)
 
     is_template = rest_filters.BooleanFilter()
 
@@ -1259,6 +1247,7 @@ class PartList(PartMixin, APIDownloadMixin, ListCreateAPI):
         'unallocated_stock',
         'category',
         'last_stocktake',
+        'units',
     ]
 
     # Default ordering
diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py
index 9d7b76275e..98f97906c4 100644
--- a/InvenTree/part/serializers.py
+++ b/InvenTree/part/serializers.py
@@ -20,21 +20,11 @@ from taggit.serializers import TagListSerializerField
 import common.models
 import company.models
 import InvenTree.helpers
+import InvenTree.serializers
 import InvenTree.status
 import part.filters
 import part.tasks
 import stock.models
-from InvenTree.serializers import (DataFileExtractSerializer,
-                                   DataFileUploadSerializer,
-                                   InvenTreeAttachmentSerializer,
-                                   InvenTreeAttachmentSerializerField,
-                                   InvenTreeCurrencySerializer,
-                                   InvenTreeDecimalField,
-                                   InvenTreeImageSerializerField,
-                                   InvenTreeModelSerializer,
-                                   InvenTreeMoneySerializer,
-                                   InvenTreeTagModelSerializer,
-                                   RemoteImageMixin, UserSerializer)
 from InvenTree.status_codes import BuildStatus
 from InvenTree.tasks import offload_task
 
@@ -48,7 +38,7 @@ from .models import (BomItem, BomItemSubstitute, Part, PartAttachment,
 logger = logging.getLogger("inventree")
 
 
-class CategorySerializer(InvenTreeModelSerializer):
+class CategorySerializer(InvenTree.serializers.InvenTreeModelSerializer):
     """Serializer for PartCategory."""
 
     class Meta:
@@ -94,7 +84,7 @@ class CategorySerializer(InvenTreeModelSerializer):
     starred = serializers.SerializerMethodField()
 
 
-class CategoryTree(InvenTreeModelSerializer):
+class CategoryTree(InvenTree.serializers.InvenTreeModelSerializer):
     """Serializer for PartCategory tree."""
 
     class Meta:
@@ -108,19 +98,19 @@ class CategoryTree(InvenTreeModelSerializer):
         ]
 
 
-class PartAttachmentSerializer(InvenTreeAttachmentSerializer):
+class PartAttachmentSerializer(InvenTree.serializers.InvenTreeAttachmentSerializer):
     """Serializer for the PartAttachment class."""
 
     class Meta:
         """Metaclass defining serializer fields"""
         model = PartAttachment
 
-        fields = InvenTreeAttachmentSerializer.attachment_fields([
+        fields = InvenTree.serializers.InvenTreeAttachmentSerializer.attachment_fields([
             'part',
         ])
 
 
-class PartTestTemplateSerializer(InvenTreeModelSerializer):
+class PartTestTemplateSerializer(InvenTree.serializers.InvenTreeModelSerializer):
     """Serializer for the PartTestTemplate class."""
 
     class Meta:
@@ -141,7 +131,7 @@ class PartTestTemplateSerializer(InvenTreeModelSerializer):
     key = serializers.CharField(read_only=True)
 
 
-class PartSalePriceSerializer(InvenTreeModelSerializer):
+class PartSalePriceSerializer(InvenTree.serializers.InvenTreeModelSerializer):
     """Serializer for sale prices for Part model."""
 
     class Meta:
@@ -155,14 +145,14 @@ class PartSalePriceSerializer(InvenTreeModelSerializer):
             'price_currency',
         ]
 
-    quantity = InvenTreeDecimalField()
+    quantity = InvenTree.serializers.InvenTreeDecimalField()
 
-    price = InvenTreeMoneySerializer(allow_null=True)
+    price = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True)
 
-    price_currency = InvenTreeCurrencySerializer(help_text=_('Purchase currency of this stock item'))
+    price_currency = InvenTree.serializers.InvenTreeCurrencySerializer(help_text=_('Purchase currency of this stock item'))
 
 
-class PartInternalPriceSerializer(InvenTreeModelSerializer):
+class PartInternalPriceSerializer(InvenTree.serializers.InvenTreeModelSerializer):
     """Serializer for internal prices for Part model."""
 
     class Meta:
@@ -176,13 +166,13 @@ class PartInternalPriceSerializer(InvenTreeModelSerializer):
             'price_currency',
         ]
 
-    quantity = InvenTreeDecimalField()
+    quantity = InvenTree.serializers.InvenTreeDecimalField()
 
-    price = InvenTreeMoneySerializer(
+    price = InvenTree.serializers.InvenTreeMoneySerializer(
         allow_null=True
     )
 
-    price_currency = InvenTreeCurrencySerializer(help_text=_('Purchase currency of this stock item'))
+    price_currency = InvenTree.serializers.InvenTreeCurrencySerializer(help_text=_('Purchase currency of this stock item'))
 
 
 class PartThumbSerializer(serializers.Serializer):
@@ -195,7 +185,7 @@ class PartThumbSerializer(serializers.Serializer):
     count = serializers.IntegerField(read_only=True)
 
 
-class PartThumbSerializerUpdate(InvenTreeModelSerializer):
+class PartThumbSerializerUpdate(InvenTree.serializers.InvenTreeModelSerializer):
     """Serializer for updating Part thumbnail."""
 
     class Meta:
@@ -212,10 +202,10 @@ class PartThumbSerializerUpdate(InvenTreeModelSerializer):
             raise serializers.ValidationError("File is not an image")
         return value
 
-    image = InvenTreeAttachmentSerializerField(required=True)
+    image = InvenTree.serializers.InvenTreeAttachmentSerializerField(required=True)
 
 
-class PartParameterTemplateSerializer(InvenTreeModelSerializer):
+class PartParameterTemplateSerializer(InvenTree.serializers.InvenTreeModelSerializer):
     """JSON serializer for the PartParameterTemplate model."""
 
     class Meta:
@@ -229,7 +219,7 @@ class PartParameterTemplateSerializer(InvenTreeModelSerializer):
         ]
 
 
-class PartParameterSerializer(InvenTreeModelSerializer):
+class PartParameterSerializer(InvenTree.serializers.InvenTreeModelSerializer):
     """JSON serializers for the PartParameter model."""
 
     class Meta:
@@ -260,7 +250,7 @@ class PartParameterSerializer(InvenTreeModelSerializer):
     template_detail = PartParameterTemplateSerializer(source='template', many=False, read_only=True)
 
 
-class PartBriefSerializer(InvenTreeModelSerializer):
+class PartBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer):
     """Serializer for Part (brief detail)"""
 
     class Meta:
@@ -295,8 +285,8 @@ class PartBriefSerializer(InvenTreeModelSerializer):
     thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True)
 
     # Pricing fields
-    pricing_min = InvenTreeMoneySerializer(source='pricing_data.overall_min', allow_null=True, read_only=True)
-    pricing_max = InvenTreeMoneySerializer(source='pricing_data.overall_max', allow_null=True, read_only=True)
+    pricing_min = InvenTree.serializers.InvenTreeMoneySerializer(source='pricing_data.overall_min', allow_null=True, read_only=True)
+    pricing_max = InvenTree.serializers.InvenTreeMoneySerializer(source='pricing_data.overall_max', allow_null=True, read_only=True)
 
 
 class DuplicatePartSerializer(serializers.Serializer):
@@ -406,7 +396,7 @@ class InitialSupplierSerializer(serializers.Serializer):
         return data
 
 
-class PartSerializer(RemoteImageMixin, InvenTreeTagModelSerializer):
+class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serializers.InvenTreeTagModelSerializer):
     """Serializer for complete detail information of a part.
 
     Used when displaying all details of a single component.
@@ -607,7 +597,7 @@ class PartSerializer(RemoteImageMixin, InvenTreeTagModelSerializer):
     stock_item_count = serializers.IntegerField(read_only=True)
     suppliers = serializers.IntegerField(read_only=True)
 
-    image = InvenTreeImageSerializerField(required=False, allow_null=True)
+    image = InvenTree.serializers.InvenTreeImageSerializerField(required=False, allow_null=True)
     thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True)
     starred = serializers.SerializerMethodField()
 
@@ -615,8 +605,8 @@ class PartSerializer(RemoteImageMixin, InvenTreeTagModelSerializer):
     category = serializers.PrimaryKeyRelatedField(queryset=PartCategory.objects.all())
 
     # Pricing fields
-    pricing_min = InvenTreeMoneySerializer(source='pricing_data.overall_min', allow_null=True, read_only=True)
-    pricing_max = InvenTreeMoneySerializer(source='pricing_data.overall_max', allow_null=True, read_only=True)
+    pricing_min = InvenTree.serializers.InvenTreeMoneySerializer(source='pricing_data.overall_min', allow_null=True, read_only=True)
+    pricing_max = InvenTree.serializers.InvenTreeMoneySerializer(source='pricing_data.overall_max', allow_null=True, read_only=True)
 
     parameters = PartParameterSerializer(
         many=True,
@@ -771,7 +761,7 @@ class PartSerializer(RemoteImageMixin, InvenTreeTagModelSerializer):
         return self.instance
 
 
-class PartStocktakeSerializer(InvenTreeModelSerializer):
+class PartStocktakeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
     """Serializer for the PartStocktake model"""
 
     class Meta:
@@ -800,13 +790,13 @@ class PartStocktakeSerializer(InvenTreeModelSerializer):
 
     quantity = serializers.FloatField()
 
-    user_detail = UserSerializer(source='user', read_only=True, many=False)
+    user_detail = InvenTree.serializers.UserSerializer(source='user', read_only=True, many=False)
 
-    cost_min = InvenTreeMoneySerializer(allow_null=True)
-    cost_min_currency = InvenTreeCurrencySerializer()
+    cost_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True)
+    cost_min_currency = InvenTree.serializers.InvenTreeCurrencySerializer()
 
-    cost_max = InvenTreeMoneySerializer(allow_null=True)
-    cost_max_currency = InvenTreeCurrencySerializer()
+    cost_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True)
+    cost_max_currency = InvenTree.serializers.InvenTreeCurrencySerializer()
 
     def save(self):
         """Called when this serializer is saved"""
@@ -820,7 +810,7 @@ class PartStocktakeSerializer(InvenTreeModelSerializer):
         super().save()
 
 
-class PartStocktakeReportSerializer(InvenTreeModelSerializer):
+class PartStocktakeReportSerializer(InvenTree.serializers.InvenTreeModelSerializer):
     """Serializer for stocktake report class"""
 
     class Meta:
@@ -836,9 +826,9 @@ class PartStocktakeReportSerializer(InvenTreeModelSerializer):
             'user_detail',
         ]
 
-    user_detail = UserSerializer(source='user', read_only=True, many=False)
+    user_detail = InvenTree.serializers.UserSerializer(source='user', read_only=True, many=False)
 
-    report = InvenTreeAttachmentSerializerField(read_only=True)
+    report = InvenTree.serializers.InvenTreeAttachmentSerializerField(read_only=True)
 
 
 class PartStocktakeReportGenerateSerializer(serializers.Serializer):
@@ -906,7 +896,7 @@ class PartStocktakeReportGenerateSerializer(serializers.Serializer):
         )
 
 
-class PartPricingSerializer(InvenTreeModelSerializer):
+class PartPricingSerializer(InvenTree.serializers.InvenTreeModelSerializer):
     """Serializer for Part pricing information"""
 
     class Meta:
@@ -942,29 +932,29 @@ class PartPricingSerializer(InvenTreeModelSerializer):
     scheduled_for_update = serializers.BooleanField(read_only=True)
 
     # Custom serializers
-    bom_cost_min = InvenTreeMoneySerializer(allow_null=True, read_only=True)
-    bom_cost_max = InvenTreeMoneySerializer(allow_null=True, read_only=True)
+    bom_cost_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
+    bom_cost_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
 
-    purchase_cost_min = InvenTreeMoneySerializer(allow_null=True, read_only=True)
-    purchase_cost_max = InvenTreeMoneySerializer(allow_null=True, read_only=True)
+    purchase_cost_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
+    purchase_cost_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
 
-    internal_cost_min = InvenTreeMoneySerializer(allow_null=True, read_only=True)
-    internal_cost_max = InvenTreeMoneySerializer(allow_null=True, read_only=True)
+    internal_cost_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
+    internal_cost_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
 
-    supplier_price_min = InvenTreeMoneySerializer(allow_null=True, read_only=True)
-    supplier_price_max = InvenTreeMoneySerializer(allow_null=True, read_only=True)
+    supplier_price_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
+    supplier_price_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
 
-    variant_cost_min = InvenTreeMoneySerializer(allow_null=True, read_only=True)
-    variant_cost_max = InvenTreeMoneySerializer(allow_null=True, read_only=True)
+    variant_cost_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
+    variant_cost_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
 
-    overall_min = InvenTreeMoneySerializer(allow_null=True, read_only=True)
-    overall_max = InvenTreeMoneySerializer(allow_null=True, read_only=True)
+    overall_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
+    overall_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
 
-    sale_price_min = InvenTreeMoneySerializer(allow_null=True, read_only=True)
-    sale_price_max = InvenTreeMoneySerializer(allow_null=True, read_only=True)
+    sale_price_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
+    sale_price_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
 
-    sale_history_min = InvenTreeMoneySerializer(allow_null=True, read_only=True)
-    sale_history_max = InvenTreeMoneySerializer(allow_null=True, read_only=True)
+    sale_history_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
+    sale_history_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
 
     update = serializers.BooleanField(
         write_only=True,
@@ -984,7 +974,7 @@ class PartPricingSerializer(InvenTreeModelSerializer):
             pricing.update_pricing()
 
 
-class PartRelationSerializer(InvenTreeModelSerializer):
+class PartRelationSerializer(InvenTree.serializers.InvenTreeModelSerializer):
     """Serializer for a PartRelated model."""
 
     class Meta:
@@ -1002,7 +992,7 @@ class PartRelationSerializer(InvenTreeModelSerializer):
     part_2_detail = PartSerializer(source='part_2', read_only=True, many=False)
 
 
-class PartStarSerializer(InvenTreeModelSerializer):
+class PartStarSerializer(InvenTree.serializers.InvenTreeModelSerializer):
     """Serializer for a PartStar object."""
 
     class Meta:
@@ -1020,7 +1010,7 @@ class PartStarSerializer(InvenTreeModelSerializer):
     username = serializers.CharField(source='user.username', read_only=True)
 
 
-class BomItemSubstituteSerializer(InvenTreeModelSerializer):
+class BomItemSubstituteSerializer(InvenTree.serializers.InvenTreeModelSerializer):
     """Serializer for the BomItemSubstitute class."""
 
     class Meta:
@@ -1036,7 +1026,7 @@ class BomItemSubstituteSerializer(InvenTreeModelSerializer):
     part_detail = PartBriefSerializer(source='part', read_only=True, many=False)
 
 
-class BomItemSerializer(InvenTreeModelSerializer):
+class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
     """Serializer for BomItem object."""
 
     class Meta:
@@ -1087,7 +1077,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
         if sub_part_detail is not True:
             self.fields.pop('sub_part_detail')
 
-    quantity = InvenTreeDecimalField(required=True)
+    quantity = InvenTree.serializers.InvenTreeDecimalField(required=True)
 
     def validate_quantity(self, quantity):
         """Perform validation for the BomItem quantity field"""
@@ -1109,8 +1099,8 @@ class BomItemSerializer(InvenTreeModelSerializer):
     on_order = serializers.FloatField(read_only=True)
 
     # Cached pricing fields
-    pricing_min = InvenTreeMoneySerializer(source='sub_part.pricing.overall_min', allow_null=True, read_only=True)
-    pricing_max = InvenTreeMoneySerializer(source='sub_part.pricing.overall_max', allow_null=True, read_only=True)
+    pricing_min = InvenTree.serializers.InvenTreeMoneySerializer(source='sub_part.pricing.overall_min', allow_null=True, read_only=True)
+    pricing_max = InvenTree.serializers.InvenTreeMoneySerializer(source='sub_part.pricing.overall_max', allow_null=True, read_only=True)
 
     # Annotated fields for available stock
     available_stock = serializers.FloatField(read_only=True)
@@ -1212,7 +1202,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
         return queryset
 
 
-class CategoryParameterTemplateSerializer(InvenTreeModelSerializer):
+class CategoryParameterTemplateSerializer(InvenTree.serializers.InvenTreeModelSerializer):
     """Serializer for the PartCategoryParameterTemplate model."""
 
     class Meta:
@@ -1297,7 +1287,7 @@ class PartCopyBOMSerializer(serializers.Serializer):
         )
 
 
-class BomImportUploadSerializer(DataFileUploadSerializer):
+class BomImportUploadSerializer(InvenTree.serializers.DataFileUploadSerializer):
     """Serializer for uploading a file and extracting data from it."""
 
     TARGET_MODEL = BomItem
@@ -1333,7 +1323,7 @@ class BomImportUploadSerializer(DataFileUploadSerializer):
                 part.bom_items.all().delete()
 
 
-class BomImportExtractSerializer(DataFileExtractSerializer):
+class BomImportExtractSerializer(InvenTree.serializers.DataFileExtractSerializer):
     """Serializer class for exatracting BOM data from an uploaded file.
 
     The parent class DataFileExtractSerializer does most of the heavy lifting here.
diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js
index 9c629caf62..06c73bc5f2 100644
--- a/InvenTree/templates/js/translated/part.js
+++ b/InvenTree/templates/js/translated/part.js
@@ -2038,6 +2038,12 @@ function loadPartTable(table, url, options={}) {
         }
     });
 
+    columns.push({
+        field: 'units',
+        title: '{% trans "Units" %}',
+        sortable: true,
+    });
+
     columns.push({
         sortName: 'category',
         field: 'category_detail',
diff --git a/InvenTree/templates/js/translated/table_filters.js b/InvenTree/templates/js/translated/table_filters.js
index f9b925bd0f..b2567743c9 100644
--- a/InvenTree/templates/js/translated/table_filters.js
+++ b/InvenTree/templates/js/translated/table_filters.js
@@ -626,6 +626,11 @@ function getPartTableFilters() {
             type: 'bool',
             title: '{% trans "Component" %}',
         },
+        has_units: {
+            type: 'bool',
+            title: '{% trans "Has Units" %}',
+            description: '{% trans "Part has defined units" %}',
+        },
         has_ipn: {
             type: 'bool',
             title: '{% trans "Has IPN" %}',