diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py
index 179bbd692c..5d46d338ea 100644
--- a/src/backend/InvenTree/InvenTree/api_version.py
+++ b/src/backend/InvenTree/InvenTree/api_version.py
@@ -1,13 +1,16 @@
"""InvenTree API version information."""
# InvenTree API version
-INVENTREE_API_VERSION = 251
+INVENTREE_API_VERSION = 252
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
+v252 - 2024-09-30 : https://github.com/inventree/InvenTree/pull/8035
+ - Add endpoint for listing all known units
+
v251 - 2024-09-06 : https://github.com/inventree/InvenTree/pull/8018
- Adds "attach_to_model" field to the ReporTemplate model
diff --git a/src/backend/InvenTree/common/api.py b/src/backend/InvenTree/common/api.py
index 7d3a47ea8d..e257c31a2f 100644
--- a/src/backend/InvenTree/common/api.py
+++ b/src/backend/InvenTree/common/api.py
@@ -1,6 +1,7 @@
"""Provides a JSON API for common components."""
import json
+from typing import Type
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
@@ -19,6 +20,7 @@ from django_q.tasks import async_task
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
from drf_spectacular.utils import OpenApiResponse, extend_schema
from error_report.models import Error
+from pint._typing import UnitLike
from rest_framework import permissions, serializers
from rest_framework.exceptions import NotAcceptable, NotFound, PermissionDenied
from rest_framework.permissions import IsAdminUser
@@ -27,6 +29,7 @@ from rest_framework.views import APIView
import common.models
import common.serializers
+import InvenTree.conversion
from common.icons import get_icon_packs
from common.settings import get_global_setting
from generic.states.api import urlpattern as generic_states_api_urls
@@ -533,6 +536,36 @@ class CustomUnitDetail(RetrieveUpdateDestroyAPI):
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly]
+class AllUnitList(ListAPI):
+ """List of all defined units."""
+
+ serializer_class = common.serializers.AllUnitListResponseSerializer
+ permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly]
+
+ def get(self, request, *args, **kwargs):
+ """Return a list of all available units."""
+ reg = InvenTree.conversion.get_unit_registry()
+ all_units = {k: self.get_unit(reg, k) for k in reg}
+ data = {
+ 'default_system': reg.default_system,
+ 'available_systems': dir(reg.sys),
+ 'available_units': {k: v for k, v in all_units.items() if v},
+ }
+ return Response(data)
+
+ def get_unit(self, reg, k):
+ """Parse a unit from the registry."""
+ if not hasattr(reg, k):
+ return None
+ unit: Type[UnitLike] = getattr(reg, k)
+ return {
+ 'name': k,
+ 'is_alias': reg.get_name(k) == k,
+ 'compatible_units': [str(a) for a in unit.compatible_units()],
+ 'isdimensionless': unit.dimensionless,
+ }
+
+
class ErrorMessageList(BulkDeleteMixin, ListAPI):
"""List view for server error messages."""
@@ -900,6 +933,7 @@ common_api_urls = [
path('', CustomUnitDetail.as_view(), name='api-custom-unit-detail')
]),
),
+ path('all/', AllUnitList.as_view(), name='api-all-unit-list'),
path('', CustomUnitList.as_view(), name='api-custom-unit-list'),
]),
),
diff --git a/src/backend/InvenTree/common/serializers.py b/src/backend/InvenTree/common/serializers.py
index e0eec23a49..991254a1e2 100644
--- a/src/backend/InvenTree/common/serializers.py
+++ b/src/backend/InvenTree/common/serializers.py
@@ -382,6 +382,22 @@ class CustomUnitSerializer(DataImportExportSerializerMixin, InvenTreeModelSerial
fields = ['pk', 'name', 'symbol', 'definition']
+class AllUnitListResponseSerializer(serializers.Serializer):
+ """Serializer for the AllUnitList."""
+
+ class Unit(serializers.Serializer):
+ """Serializer for the AllUnitListResponseSerializer."""
+
+ name = serializers.CharField()
+ is_alias = serializers.BooleanField()
+ compatible_units = serializers.ListField(child=serializers.CharField())
+ isdimensionless = serializers.BooleanField()
+
+ default_system = serializers.CharField()
+ available_systems = serializers.ListField(child=serializers.CharField())
+ available_units = Unit(many=True)
+
+
class ErrorMessageSerializer(InvenTreeModelSerializer):
"""DRF serializer for server error messages."""
diff --git a/src/backend/InvenTree/common/tests.py b/src/backend/InvenTree/common/tests.py
index 826bce15b7..6990fc9c18 100644
--- a/src/backend/InvenTree/common/tests.py
+++ b/src/backend/InvenTree/common/tests.py
@@ -1495,6 +1495,14 @@ class CustomUnitAPITest(InvenTreeAPITestCase):
for name in invalid_name_values:
self.patch(url, {'name': name}, expected_code=400)
+ def test_api(self):
+ """Test the CustomUnit API."""
+ response = self.get(reverse('api-all-unit-list'))
+ self.assertIn('default_system', response.data)
+ self.assertIn('available_systems', response.data)
+ self.assertIn('available_units', response.data)
+ self.assertEqual(len(response.data['available_units']) > 100, True)
+
class ContentTypeAPITest(InvenTreeAPITestCase):
"""Unit tests for the ContentType API."""
diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx
index 17a981b978..015a22a6a6 100644
--- a/src/frontend/src/enums/ApiEndpoints.tsx
+++ b/src/frontend/src/enums/ApiEndpoints.tsx
@@ -31,6 +31,7 @@ export enum ApiEndpoints {
// Generic API endpoints
currency_list = 'currency/exchange/',
currency_refresh = 'currency/refresh/',
+ all_units = 'units/all/',
task_overview = 'background-task/',
task_pending_list = 'background-task/pending/',
task_scheduled_list = 'background-task/scheduled/',
diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx
index 7c0b51c8d7..0b980a1df9 100644
--- a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx
+++ b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx
@@ -52,6 +52,8 @@ const CurrencyManagmentPanel = Loadable(
lazy(() => import('./CurrencyManagmentPanel'))
);
+const UnitManagmentPanel = Loadable(lazy(() => import('./UnitManagmentPanel')));
+
const PluginManagementPanel = Loadable(
lazy(() => import('./PluginManagementPanel'))
);
@@ -76,10 +78,6 @@ const CustomStateTable = Loadable(
lazy(() => import('../../../../tables/settings/CustomStateTable'))
);
-const CustomUnitsTable = Loadable(
- lazy(() => import('../../../../tables/settings/CustomUnitsTable'))
-);
-
const PartParameterTemplateTable = Loadable(
lazy(() => import('../../../../tables/part/PartParameterTemplateTable'))
);
@@ -149,7 +147,7 @@ export default function AdminCenter() {
name: 'customunits',
label: t`Custom Units`,
icon: ,
- content:
+ content:
},
{
name: 'part-parameters',
diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/UnitManagmentPanel.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/UnitManagmentPanel.tsx
new file mode 100644
index 0000000000..6c947b71b5
--- /dev/null
+++ b/src/frontend/src/pages/Index/Settings/AdminCenter/UnitManagmentPanel.tsx
@@ -0,0 +1,75 @@
+import { t } from '@lingui/macro';
+import { Accordion, Stack } from '@mantine/core';
+import { useMemo } from 'react';
+
+import { StylishText } from '../../../../components/items/StylishText';
+import { ApiEndpoints } from '../../../../enums/ApiEndpoints';
+import { useTable } from '../../../../hooks/UseTable';
+import { apiUrl } from '../../../../states/ApiState';
+import { BooleanColumn } from '../../../../tables/ColumnRenderers';
+import { InvenTreeTable } from '../../../../tables/InvenTreeTable';
+import CustomUnitsTable from '../../../../tables/settings/CustomUnitsTable';
+
+function AllUnitTable() {
+ const table = useTable('all-units');
+ const columns = useMemo(() => {
+ return [
+ {
+ accessor: 'name',
+ title: t`Name`
+ },
+ BooleanColumn({ accessor: 'is_alias', title: t`Alias` }),
+ BooleanColumn({ accessor: 'isdimensionless', title: t`Dimensionless` })
+ ];
+ }, []);
+
+ return (
+ {
+ let units = data.available_units ?? {};
+ return Object.entries(units).map(([key, values]: [string, any]) => {
+ return {
+ name: values.name,
+ is_alias: values.is_alias,
+ compatible_units: values.compatible_units,
+ isdimensionless: values.isdimensionless
+ };
+ });
+ }
+ }}
+ />
+ );
+}
+
+export default function UnitManagmentPanel() {
+ return (
+
+
+
+
+ {t`Custom Units`}
+
+
+
+
+
+
+
+ {t`All units`}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/frontend/src/tables/InvenTreeTable.tsx b/src/frontend/src/tables/InvenTreeTable.tsx
index c5164e1946..6d70ba72f7 100644
--- a/src/frontend/src/tables/InvenTreeTable.tsx
+++ b/src/frontend/src/tables/InvenTreeTable.tsx
@@ -517,6 +517,11 @@ export function InvenTreeTable({
// Update tableState.records when new data received
useEffect(() => {
tableState.setRecords(data ?? []);
+
+ // set pagesize to length if pagination is disabled
+ if (!tableProps.enablePagination) {
+ tableState.setPageSize(data?.length ?? defaultPageSize);
+ }
}, [data]);
const deleteRecords = useDeleteApiFormModal({
@@ -594,6 +599,15 @@ export function InvenTreeTable({
tableState.refreshTable();
}
+ const optionalParams = useMemo(() => {
+ let optionalParamsa: Record = {};
+ if (tableProps.enablePagination) {
+ optionalParamsa['recordsPerPageOptions'] = PAGE_SIZES;
+ optionalParamsa['onRecordsPerPageChange'] = updatePageSize;
+ }
+ return optionalParamsa;
+ }, [tableProps.enablePagination]);
+
return (
<>
{deleteRecords.modal}
@@ -739,8 +753,7 @@ export function InvenTreeTable({
overflow: 'hidden'
})
}}
- recordsPerPageOptions={PAGE_SIZES}
- onRecordsPerPageChange={updatePageSize}
+ {...optionalParams}
/>
diff --git a/src/frontend/tests/baseFixtures.ts b/src/frontend/tests/baseFixtures.ts
index e233712c88..39c1e7fb55 100644
--- a/src/frontend/tests/baseFixtures.ts
+++ b/src/frontend/tests/baseFixtures.ts
@@ -71,6 +71,7 @@ export const test = baseTest.extend({
url != 'http://localhost:8000/api/user/token/' &&
url != 'http://localhost:8000/api/barcode/' &&
url != 'https://docs.inventree.org/en/versions.json' &&
+ url != 'http://localhost:5173/favicon.ico' &&
!url.startsWith('http://localhost:8000/api/news/') &&
!url.startsWith('http://localhost:8000/api/notifications/') &&
!url.startsWith('chrome://') &&