From 83d2624a451b6b922c7c975e470389accdc12ce9 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Fri, 13 Sep 2024 00:22:04 +0200 Subject: [PATCH] [PUI] Add view of all defined units to Admin center (#8040) * Add API endpoint for all defined units Fixes #7858 * render all units in API * bump API * Add display for all units * remove logging * fix types * ignore favicon errors * fix for new pint version * add tests * prove against units that are not defined * append trailing slash to url * make pagination disableable again --- .../InvenTree/InvenTree/api_version.py | 5 +- src/backend/InvenTree/common/api.py | 34 +++++++++ src/backend/InvenTree/common/serializers.py | 16 ++++ src/backend/InvenTree/common/tests.py | 8 ++ src/frontend/src/enums/ApiEndpoints.tsx | 1 + .../Index/Settings/AdminCenter/Index.tsx | 8 +- .../AdminCenter/UnitManagmentPanel.tsx | 75 +++++++++++++++++++ src/frontend/src/tables/InvenTreeTable.tsx | 17 ++++- src/frontend/tests/baseFixtures.ts | 1 + 9 files changed, 157 insertions(+), 8 deletions(-) create mode 100644 src/frontend/src/pages/Index/Settings/AdminCenter/UnitManagmentPanel.tsx 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://') &&