From fa0d892a62900acdc1af081e51e017fb40b17f22 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 4 Dec 2025 20:41:36 +1100 Subject: [PATCH] [WIP] Generic parameters (#10699) * Add ParameterTemplate model - Data structure duplicated from PartParameterTemplate * Apply data migration for templates * Admin integration * API endpoints for ParameterTemplate * Scaffolding * Add validator for ParameterTemplate model type - Update migrations - Make Parameter class abstract (for now) - Validators * API updates - Fix options for model_type - Add API filters * Add definition for Parameter model * Add django admin site integration * Update InvenTreeParameterMixin class - Fetch queryset of all linked Parameter instances - Ensure deletion of linked instances * API endpoints for Parameter instances * Refactor UI table for parameter templates * Add comment for later * Add "enabled" field to ParameterTemplate model * Add new field to serializer * Rough-in new table * Implement generic "parameter" table * Enable parameters for Company model * Change migration for part parameter - Make it "universal" * Remove code for ManufacturerPartParameter * Fix for filters * Add data import for parameter table * Add verbose name to ParameterTemplate model * Removed dead API code * Update global setting * Fix typos * Check global setting for unit validation * Use GenericForeignKey * Add generic relationship to allow reverse lookups * Fixes for table structure * Add custom serializer field for ContentType with choices * Adds ContentTypeField - Handles representation of content type - Provides human-readable options * Refactor API filtering for endpoints - Specify ContentType by ID, model or app label * Revert change to parameters property * Define GenericRelationship for linking model * Refactoring some code * Add a generic way to back-annotate and prefetch parameters for any model type * Change panel position * Directly annotate parameters against different model serializers * remove defunct admin classes * Run plugin validation against parameter * Fix prefetching for PartSerializer * Implement generic "filtering" against queryset * Implement generic "ordering" by parameter * Make parametric table generic * Refactor segmented panels * Consolidate part table views * Fix for parametric part table - Only display parameters for which we know there is a value * Add parametric tables for company views * Fix typo in file name * Prefetch to reduce hits * Add generic API mixin for filtering and ordering by parameter * Fix hook for rebuilding template parameters * Remove serializer * Remove old models * Fix code for copying parameters from category * Implement more parametric tables: - ManufacturerPart - SupplierPart - Fixes and enhancements * Add parameter support for orders * Add UI support for parameters against orders * Update API version * Update CHANGELOG.md * Add parameter support for build orders * Tweak frontend * Add renderer * Remove defunct endpoints * Add migration requirement * Require contenttypes to be updated * Update migration * Try using ID val * Adjust migration dependencies * fix params fixture * fix schema export * fix modelset * Fixes for data migration * tweak table * Fix for Category Parameters * Use branch of demo dataset for testing * Add parameteric build order table * disable broken imports * remove old model from ruleset * correct test * Table tweaks * fix test * Remove old model type * fix test * fix test * Refactor mixin to avoid specifying model type manually * fix test * fix resolve name * remove unneeded import * Tweak unit testing * Fix unit test * Enable bulk-create * More fixes * More unit test tweaks * Enhancements * Unit test fixes * Add some migration tests * Fix admin tests * Fix part tests * adapt expectation * fix remaining typecheck * Docs updates * Rearrange models * fix paramater caching * fix doc links * adjust assumption * Adjust data migration unit tests * docs fixes * Fix docs link * Fixes * Tweak formatting * Add doc for setting * Add metadata view for parameters * Add metadata view for ParamterTemplate * Update CHANGELOG file * Deconflict model_type fields * Invert key:value * Revert "Invert key:value" This reverts commit d555658db21a9e464125aae4f3d956e514358f24. * fix assert * Update API rev notes * Initial unit tests for API * Test parameter create / edit / delete via the API * Add some more unit tests for the API * Validate queryset annotation - Add unit test with large dataset - Ensure number of queries is fixed - Fix for prefetching check * Add breaking change info to CHANGELOG.md * Ensure that parameters are removed when deleting the linked object * Enhance type hinting * Refactor part parameter exporter plugin - Any model which supports parameters can use this now - Update documentation * Improve serializer field * Adjust unit test * Reimplement checks for locked parts * Fix unit test for data migration * Fix for unit test * Allow disable edit for ParameterTable * Fix supplier part import wizard * Add unit tests for template API filtering * Add playwright tests for purchasing index * Add tests for manufacturing index page * ui tests for sales index * Add data migration tests for ManufacturerPartParameter * Pull specific branch for python binding tests * Specify target migration * Remove debug statement * Tweak migration unit tests * Add options for spectacular * Add explicit choice options * Ensure empty string values are converted to None * Don't use custom branch for python checks * Fix for migration test * Fix migration test * Fix reference target * Remove duplicate enum in spectactular.py * Add null choice to custom serializer class * [UI] Edit shipment details - Pass "pending" status through to the form * New migration strategy: part.0144: - Add new "enabled" field to PartParameterTemplate model - Add new ContentType fields to the "PartParameterTemplate" and "PartParameter" models - Data migration for existing "PartParameter" records part.0145: - Set NOT NULL constraints on new fields - Remove the obsolete "part" field from the "PartParameter" model * More migration updates: - Create new "models" (without moving the existing tables) - Data migration for PartCataegoryParameterTemplate model - Remove PartParameterTemplate and PartParameter models * Overhaul of migration strategy - New models simply point to the old database tables - Perform schema and data migrations on the old models first (in the part app) - Swap model references in correct order * Improve checks for data migrations * Bug fix for data migration * Add migration unit test to ensure that primary keys are maintained * Add playwright test for company parameters * Rename underlying database tables * Fixes for migration unit tests * Revert "Rename underlying database tables" This reverts commit 477c692076acc197fe9673352a539924adfe014b. * Fix for migration sequencing * Simplify new playwright test * Remove spectacular collision * Monkey patch the drf-spectacular warn function * Do not use custom branch for playwright testing --------- Co-authored-by: Matthias Mair --- CHANGELOG.md | 11 + docs/docs/api/python/examples.md | 2 +- docs/docs/app/barcode.md | 2 +- .../images/concepts/attachments-tab.png | Bin 0 -> 35775 bytes .../assets/images/concepts/parameter-tab.png | Bin 0 -> 50478 bytes .../images/concepts/parameter-template.png | Bin 0 -> 23510 bytes .../images/concepts/parametric-parts.png | Bin 0 -> 45460 bytes docs/docs/concepts/attachments.md | 18 + .../parameter.md => concepts/parameters.md} | 31 +- docs/docs/concepts/units.md | 4 +- docs/docs/part/views.md | 14 +- docs/docs/plugins/builtin/index.md | 2 +- .../plugins/builtin/parameter_exporter.md | 27 + .../builtin/part_parameter_exporter.md | 25 - docs/docs/plugins/mixins/validation.md | 6 +- docs/docs/report/helpers.md | 12 +- docs/docs/settings/global.md | 9 +- docs/docs/start/docker_install.md | 5 +- docs/mkdocs.yml | 5 +- pyproject.toml | 4 +- src/backend/InvenTree/InvenTree/api.py | 28 + .../InvenTree/InvenTree/api_version.py | 7 +- src/backend/InvenTree/InvenTree/models.py | 200 +++++- src/backend/InvenTree/InvenTree/schema.py | 39 +- .../InvenTree/InvenTree/serializers.py | 95 +++ .../InvenTree/setting/spectacular.py | 3 +- src/backend/InvenTree/build/api.py | 10 +- src/backend/InvenTree/build/models.py | 1 + src/backend/InvenTree/build/serializers.py | 8 + .../InvenTree/build/test_migrations.py | 2 +- src/backend/InvenTree/common/admin.py | 26 + src/backend/InvenTree/common/api.py | 231 ++++++- src/backend/InvenTree/common/filters.py | 316 +++++++++ .../migrations/0023_auto_20240602_1332.py | 2 +- .../0040_parametertemplate_parameter.py | 248 +++++++ .../migrations/0041_auto_20251203_1244.py | 116 ++++ src/backend/InvenTree/common/models.py | 425 +++++++++++- src/backend/InvenTree/common/serializers.py | 123 +++- .../InvenTree/common/setting/system.py | 16 +- src/backend/InvenTree/common/tasks.py | 32 + src/backend/InvenTree/common/test_api.py | 428 ++++++++++++ .../InvenTree/common/test_migrations.py | 4 +- src/backend/InvenTree/common/tests.py | 23 +- src/backend/InvenTree/common/validators.py | 29 + src/backend/InvenTree/company/admin.py | 12 - src/backend/InvenTree/company/api.py | 149 ++-- .../0077_delete_manufacturerpartparameter.py | 22 + src/backend/InvenTree/company/models.py | 52 +- src/backend/InvenTree/company/serializers.py | 59 +- .../InvenTree/company/test_migrations.py | 79 ++- .../InvenTree/generic/states/fields.py | 2 +- src/backend/InvenTree/order/api.py | 13 +- src/backend/InvenTree/order/models.py | 1 + src/backend/InvenTree/order/serializers.py | 17 + src/backend/InvenTree/order/test_api.py | 10 +- src/backend/InvenTree/part/admin.py | 35 +- src/backend/InvenTree/part/api.py | 338 +-------- src/backend/InvenTree/part/filters.py | 159 ----- .../InvenTree/part/fixtures/params.yaml | 68 +- .../0071_alter_partparametertemplate_name.py | 2 +- .../migrations/0144_auto_20251203_1045.py | 161 +++++ .../migrations/0145_auto_20251203_1238.py | 111 +++ .../migrations/0146_auto_20251203_1241.py | 23 + src/backend/InvenTree/part/models.py | 643 ++++-------------- src/backend/InvenTree/part/serializers.py | 149 +--- src/backend/InvenTree/part/tasks.py | 32 - src/backend/InvenTree/part/test_api.py | 45 +- src/backend/InvenTree/part/test_category.py | 10 +- src/backend/InvenTree/part/test_migrations.py | 116 +++- src/backend/InvenTree/part/test_param.py | 180 ++--- .../base/integration/ValidationMixin.py | 6 +- .../InvenTree/plugin/base/supplier/api.py | 6 +- .../InvenTree/plugin/base/supplier/helpers.py | 9 +- .../plugin/builtin/exporter/bom_exporter.py | 4 +- .../builtin/exporter/parameter_exporter.py | 98 +++ .../exporter/part_parameter_exporter.py | 130 ---- .../samples/integration/validation_sample.py | 4 +- .../samples/supplier/test_supplier_sample.py | 16 +- .../InvenTree/report/templatetags/report.py | 35 +- src/backend/InvenTree/report/test_tags.py | 26 +- .../InvenTree/stock/test_migrations.py | 6 +- src/backend/InvenTree/users/ruleset.py | 8 +- src/frontend/lib/enums/ApiEndpoints.tsx | 7 +- src/frontend/lib/enums/ModelInformation.tsx | 19 +- src/frontend/lib/enums/ModelType.tsx | 3 +- .../buttons/SegmentedIconControl.tsx | 2 +- src/frontend/src/components/panels/Panel.tsx | 2 +- .../src/components/panels/ParametersPanel.tsx | 32 + .../panels/SegmentedControlPanel.tsx | 53 ++ .../src/components/render/Generic.tsx | 24 + .../src/components/render/Instance.tsx | 12 +- src/frontend/src/components/render/Part.tsx | 17 - .../components/wizards/ImportPartWizard.tsx | 9 +- src/frontend/src/forms/CommonForms.tsx | 120 +++- src/frontend/src/forms/CompanyForms.tsx | 15 - src/frontend/src/forms/PartForms.tsx | 97 +-- .../Index/Settings/AdminCenter/Index.tsx | 10 +- ...tParameterPanel.tsx => ParameterPanel.tsx} | 12 +- .../pages/Index/Settings/SystemSettings.tsx | 10 +- src/frontend/src/pages/build/BuildDetail.tsx | 5 + src/frontend/src/pages/build/BuildIndex.tsx | 71 +- .../src/pages/company/CompanyDetail.tsx | 5 + .../pages/company/ManufacturerPartDetail.tsx | 19 +- .../src/pages/company/SupplierPartDetail.tsx | 5 + .../src/pages/part/CategoryDetail.tsx | 55 +- src/frontend/src/pages/part/PartDetail.tsx | 39 +- .../pages/purchasing/PurchaseOrderDetail.tsx | 5 + .../src/pages/purchasing/PurchasingIndex.tsx | 216 ++++-- .../src/pages/sales/ReturnOrderDetail.tsx | 5 + src/frontend/src/pages/sales/SalesIndex.tsx | 185 ++--- .../src/pages/sales/SalesOrderDetail.tsx | 5 + .../build/BuildOrderParametricTable.tsx | 40 ++ .../tables/company/ParametricCompanyTable.tsx | 39 ++ .../src/tables/general/ParameterTable.tsx | 276 ++++++++ .../ParameterTemplateTable.tsx} | 249 ++++--- .../tables/general/ParametricDataTable.tsx | 442 ++++++++++++ .../ParametricDataTableFilters.tsx} | 0 .../src/tables/part/ParametricPartTable.tsx | 395 +---------- .../tables/part/PartCategoryTemplateTable.tsx | 12 +- .../src/tables/part/PartParameterTable.tsx | 254 ------- .../ManufacturerPartParameterTable.tsx | 140 ---- .../ManufacturerPartParametricTable.tsx | 70 ++ .../PurchaseOrderParametricTable.tsx | 67 ++ .../SupplierPartParametricTable.tsx | 52 ++ .../sales/ReturnOrderParametricTable.tsx | 65 ++ .../sales/SalesOrderParametricTable.tsx | 65 ++ src/frontend/tests/helpers.ts | 33 +- src/frontend/tests/pages/pui_build.spec.ts | 20 + src/frontend/tests/pages/pui_company.spec.ts | 22 +- src/frontend/tests/pages/pui_part.spec.ts | 42 +- .../tests/pages/pui_purchase_order.spec.ts | 167 +++-- .../tests/pages/pui_sales_order.spec.ts | 28 + src/frontend/tests/pui_settings.spec.ts | 111 ++- .../tests/settings/selectionList.spec.ts | 103 --- tasks.py | 4 +- 135 files changed, 5873 insertions(+), 3307 deletions(-) create mode 100644 docs/docs/assets/images/concepts/attachments-tab.png create mode 100644 docs/docs/assets/images/concepts/parameter-tab.png create mode 100644 docs/docs/assets/images/concepts/parameter-template.png create mode 100644 docs/docs/assets/images/concepts/parametric-parts.png create mode 100644 docs/docs/concepts/attachments.md rename docs/docs/{part/parameter.md => concepts/parameters.md} (79%) create mode 100644 docs/docs/plugins/builtin/parameter_exporter.md delete mode 100644 docs/docs/plugins/builtin/part_parameter_exporter.md create mode 100644 src/backend/InvenTree/common/filters.py create mode 100644 src/backend/InvenTree/common/migrations/0040_parametertemplate_parameter.py create mode 100644 src/backend/InvenTree/common/migrations/0041_auto_20251203_1244.py create mode 100644 src/backend/InvenTree/common/test_api.py create mode 100644 src/backend/InvenTree/company/migrations/0077_delete_manufacturerpartparameter.py create mode 100644 src/backend/InvenTree/part/migrations/0144_auto_20251203_1045.py create mode 100644 src/backend/InvenTree/part/migrations/0145_auto_20251203_1238.py create mode 100644 src/backend/InvenTree/part/migrations/0146_auto_20251203_1241.py create mode 100644 src/backend/InvenTree/plugin/builtin/exporter/parameter_exporter.py delete mode 100644 src/backend/InvenTree/plugin/builtin/exporter/part_parameter_exporter.py create mode 100644 src/frontend/src/components/panels/ParametersPanel.tsx create mode 100644 src/frontend/src/components/panels/SegmentedControlPanel.tsx rename src/frontend/src/pages/Index/Settings/AdminCenter/{PartParameterPanel.tsx => ParameterPanel.tsx} (62%) create mode 100644 src/frontend/src/tables/build/BuildOrderParametricTable.tsx create mode 100644 src/frontend/src/tables/company/ParametricCompanyTable.tsx create mode 100644 src/frontend/src/tables/general/ParameterTable.tsx rename src/frontend/src/tables/{part/PartParameterTemplateTable.tsx => general/ParameterTemplateTable.tsx} (66%) create mode 100644 src/frontend/src/tables/general/ParametricDataTable.tsx rename src/frontend/src/tables/{part/ParametricPartTableFilters.tsx => general/ParametricDataTableFilters.tsx} (100%) delete mode 100644 src/frontend/src/tables/part/PartParameterTable.tsx delete mode 100644 src/frontend/src/tables/purchasing/ManufacturerPartParameterTable.tsx create mode 100644 src/frontend/src/tables/purchasing/ManufacturerPartParametricTable.tsx create mode 100644 src/frontend/src/tables/purchasing/PurchaseOrderParametricTable.tsx create mode 100644 src/frontend/src/tables/purchasing/SupplierPartParametricTable.tsx create mode 100644 src/frontend/src/tables/sales/ReturnOrderParametricTable.tsx create mode 100644 src/frontend/src/tables/sales/SalesOrderParametricTable.tsx delete mode 100644 src/frontend/tests/settings/selectionList.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d6c1804cc6..862860c1af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,14 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased - YYYY-MM-DD +### Breaking Changes + +- [#10699](https://github.com/inventree/InvenTree/pull/10699) removes the `PartParameter` and `PartParameterTempalate` models (and associated API endpoints). These have been replaced with generic `Parameter` and `ParameterTemplate` models (and API endpoints). Any external client applications which made use of the old endpoints will need to be updated. + ### Added - Adds "Category" columns to BOM and Build Item tables and APIs in [#10722](https://github.com/inventree/InvenTree/pull/10772) +- Adds generic "Parameter" and "ParameterTemplate" models (and associated API endpoints) in [#10699](https://github.com/inventree/InvenTree/pull/10699) +- Adds parameter support for multiple new model types in [#10699](https://github.com/inventree/InvenTree/pull/10699) +- UI overhaul of parameter management in [#10699](https://github.com/inventree/InvenTree/pull/10699) ### Changed +- + ### Removed - Removed python 3.9 / 3.10 support as part of Django 5.2 upgrade in [#10730](https://github.com/inventree/InvenTree/pull/10730) +- Removed the "PartParameter" and "PartParameterTemplate" models (and associated API endpoints) in [#10699](https://github.com/inventree/InvenTree/pull/10699) +- Removed the "ManufacturerPartParameter" model (and associated API endpoints) [#10699](https://github.com/inventree/InvenTree/pull/10699) ## 1.1.0 - 2025-11-02 diff --git a/docs/docs/api/python/examples.md b/docs/docs/api/python/examples.md index 7ec1059f42..34e31c2829 100644 --- a/docs/docs/api/python/examples.md +++ b/docs/docs/api/python/examples.md @@ -67,7 +67,7 @@ print("Minimum stock:", part.minimum_stock) ### Adding Parameters -Each [part](../../part/index.md) can have multiple [parameters](../../part/parameter.md). For the example of the sofa (above) *length* and *weight* make sense. Each parameter has a parameter template that combines the parameter name with a unit. So we first have to create the parameter templates and afterwards add the parameter values to the sofa. +Each [part](../../part/index.md) can have multiple [parameters](../../concepts/parameters.md). For the example of the sofa (above) *length* and *weight* make sense. Each parameter has a parameter template that combines the parameter name with a unit. So we first have to create the parameter templates and afterwards add the parameter values to the sofa. ```python from inventree.part import Parameter diff --git a/docs/docs/app/barcode.md b/docs/docs/app/barcode.md index 5f470b3391..2645287f76 100644 --- a/docs/docs/app/barcode.md +++ b/docs/docs/app/barcode.md @@ -73,7 +73,7 @@ the *Scan Items Into Location* action allows you to scan items into the selected ### Stock Item Actions -From the [Stock Item detail page](./stock.md#stock-item-detail-view), the following barcode actions may be available: +From the [Stock Item detail page](./stock.md#details-tab), the following barcode actions may be available: {{ image("app/barcode_stock_item_actions.png", "Stock item barcode actions") }} diff --git a/docs/docs/assets/images/concepts/attachments-tab.png b/docs/docs/assets/images/concepts/attachments-tab.png new file mode 100644 index 0000000000000000000000000000000000000000..1eff956ca9e35d82deb86708270a23892c0a0418 GIT binary patch literal 35775 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 OL|Rm3?(h?D* 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

#Vap~KXmqr3*_HW>Tc%w*(O#i8$ z_4AwP(roYmlj(O1@$o72Tns7IB>AQx_w+&!K0p{kv&o17 j{`^-b>>#YKu5bVL7YW?6TJ_=vBYvlY{_yz2@X!ARU=fn8 literal 0 HcmV?d00001 diff --git a/docs/docs/assets/images/concepts/parameter-template.png b/docs/docs/assets/images/concepts/parameter-template.png new file mode 100644 index 0000000000000000000000000000000000000000..a3a2234340a29199942bcd4ef22497ff6fc0529c GIT binary patch literal 23510 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 literal 0 HcmV?d00001 diff --git a/docs/docs/assets/images/concepts/parametric-parts.png b/docs/docs/assets/images/concepts/parametric-parts.png new file mode 100644 index 0000000000000000000000000000000000000000..16d108c23dd3cb5810a9132e61f3de47042842f2 GIT binary patch literal 45460 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 z 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

2; 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 Length: {{ length.data }} [{{ length.units }}] @@ -570,7 +570,7 @@ Length: {{ length.data }} [{{ length.units }}] {% endraw %} ``` -A [Part Parameter](../part/parameter.md) has the following available attributes: +A [Parameter](../concepts/parameters.md) has the following available attributes: | Attribute | Description | | --- | --- | @@ -578,7 +578,7 @@ A [Part Parameter](../part/parameter.md) has the following available attributes: | Description | The *description* of the parameter | | Data | The *value* of the parameter (e.g. "123.4") | | Units | The *units* of the parameter (e.g. "km") | -| Template | A reference to a [PartParameterTemplate](../part/parameter.md#parameter-templates) | +| Template | A reference to a [ParameterTemplate](../concepts/parameters.md#parameter-templates) | ## Rendering Markdown diff --git a/docs/docs/settings/global.md b/docs/docs/settings/global.md index d60d02a367..4b32f972c0 100644 --- a/docs/docs/settings/global.md +++ b/docs/docs/settings/global.md @@ -174,11 +174,14 @@ Configuration of label printing: {{ globalsetting("PART_COPY_TESTS") }} {{ globalsetting("PART_CATEGORY_PARAMETERS") }} {{ globalsetting("PART_CATEGORY_DEFAULT_ICON") }} -{{ globalsetting("PART_PARAMETER_ENFORCE_UNITS") }} -#### Part Parameter Templates +#### Parameter Templates -Refer to the section describing [how to create part parameter templates](../part/parameter.md#create-template). +| Name | Description | Default | Units | +| ---- | ----------- | ------- | ----- | +{{ globalsetting("PARAMETER_ENFORCE_UNITS") }} + +For more information on parameters, refer to the [parameter documentation](../concepts/parameters.md). ### Categories diff --git a/docs/docs/start/docker_install.md b/docs/docs/start/docker_install.md index 0e53c85f7d..cb4f29da35 100644 --- a/docs/docs/start/docker_install.md +++ b/docs/docs/start/docker_install.md @@ -245,9 +245,10 @@ This can be adjusted using the following environment variables: | INVENTREE_WEB_ADDR | 0.0.0.0 | | INVENTREE_WEB_PORT | 8000 | -These variables are combined in the [Dockerfile](../../../contrib/container/Dockerfile) to build the bind string passed to the InvenTree server on startup. +These variables are combined in the [Dockerfile]({{ sourcefile("contrib/container/Dockerfile") }}) to build the bind string passed to the InvenTree server on startup. -To enable IPv6/Dual Stack support, set `INVENTREE_WEB_ADDR` to `[::]` when you create/start the container. +!!! tip "IPv6 Support" + To enable IPv6/Dual Stack support, set `INVENTREE_WEB_ADDR` to `[::]` when you create/start the container. ### Demo Dataset diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 132dc41b2e..9134d6ad04 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -96,6 +96,8 @@ nav: - Custom States: concepts/custom_states.md - Pricing: concepts/pricing.md - Project Codes: concepts/project_codes.md + - Attachments: concepts/attachments.md + - Parameters: concepts/parameters.md - Barcodes: - Barcode Support: barcodes/index.md - Internal Barcodes: barcodes/internal.md @@ -125,7 +127,6 @@ nav: - Virtual Parts: part/virtual.md - Part Views: part/views.md - Tracking: part/trackable.md - - Parameters: part/parameter.md - Revisions: part/revision.md - Templates: part/template.md - Tests: part/test.md @@ -238,7 +239,7 @@ nav: - Export Plugins: - BOM Exporter: plugins/builtin/bom_exporter.md - InvenTree Exporter: plugins/builtin/inventree_exporter.md - - Parameter Exporter: plugins/builtin/part_parameter_exporter.md + - Parameter Exporter: plugins/builtin/parameter_exporter.md - Stocktake Exporter: plugins/builtin/stocktake_exporter.md - Label Printing: - Label Printer: plugins/builtin/inventree_label.md diff --git a/pyproject.toml b/pyproject.toml index c3fa78bf3d..3b53f437d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,8 +101,8 @@ python-version = "3.11.0" no-strip-extras=true generate-hashes=true -[tool.ty.src] -root = "src/backend/InvenTree" +[tool.ty.environment] +root = ["src/backend/InvenTree"] [tool.ty.rules] unresolved-reference="ignore" # 21 # see https://github.com/astral-sh/ty/issues/220 diff --git a/src/backend/InvenTree/InvenTree/api.py b/src/backend/InvenTree/InvenTree/api.py index 4c91ae61cd..cd57fb1cc2 100644 --- a/src/backend/InvenTree/InvenTree/api.py +++ b/src/backend/InvenTree/InvenTree/api.py @@ -600,6 +600,34 @@ class BulkUpdateMixin(BulkOperationMixin): return Response({'success': f'Updated {n} items'}, status=200) +class ParameterListMixin: + """Mixin class which supports filtering against parametric fields.""" + + def filter_queryset(self, queryset): + """Perform filtering against parametric fields.""" + import common.filters + + queryset = super().filter_queryset(queryset) + + # Filter by parametric data + queryset = common.filters.filter_parametric_data( + queryset, self.request.query_params + ) + + serializer_class = ( + getattr(self, 'serializer_class', None) or self.get_serializer_class() + ) + + model_class = serializer_class.Meta.model + + # Apply ordering based on query parameter + queryset = common.filters.order_by_parameter( + queryset, model_class, self.request.query_params.get('ordering', None) + ) + + return queryset + + class BulkDeleteMixin(BulkOperationMixin): """Mixin class for enabling 'bulk delete' operations for various models. diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index c220b37925..324e08988d 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,16 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 429 +INVENTREE_API_VERSION = 430 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v430 -> 2025-12-04 : https://github.com/inventree/InvenTree/pull/10699 + - Removed the "PartParameter" and "PartParameterTemplate" API endpoints + - Removed the "ManufacturerPartParameter" API endpoint + - Added generic "Parameter" and "ParameterTemplate" API endpoints + v429 -> 2025-12-04 : https://github.com/inventree/InvenTree/pull/10938 - Adjust default values for currency codes in the API schema - Note that this does not change any functional behavior, only the schema documentation diff --git a/src/backend/InvenTree/InvenTree/models.py b/src/backend/InvenTree/InvenTree/models.py index 71bf510871..c3f5b42f93 100644 --- a/src/backend/InvenTree/InvenTree/models.py +++ b/src/backend/InvenTree/InvenTree/models.py @@ -6,8 +6,10 @@ from string import Formatter from typing import Any, Optional from django.contrib.auth import get_user_model +from django.contrib.contenttypes.fields import GenericRelation +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError -from django.db import models +from django.db import models, transaction from django.db.models import QuerySet from django.db.models.signals import post_save from django.db.transaction import TransactionManagementError @@ -450,7 +452,18 @@ class ReferenceIndexingMixin(models.Model): reference_int = models.BigIntegerField(default=0) -class InvenTreeModel(PluginValidationMixin, models.Model): +class ContentTypeMixin: + """Mixin class which supports retrieval of the ContentType for a model instance.""" + + @classmethod + def get_content_type(cls): + """Return the ContentType object associated with this model.""" + from django.contrib.contenttypes.models import ContentType + + return ContentType.objects.get_for_model(cls) + + +class InvenTreeModel(ContentTypeMixin, PluginValidationMixin, models.Model): """Base class for InvenTree models, which provides some common functionality. Includes the following mixins by default: @@ -473,7 +486,164 @@ class InvenTreeMetadataModel(MetadataMixin, InvenTreeModel): abstract = True -class InvenTreeAttachmentMixin: +class InvenTreePermissionCheckMixin: + """Provides an abstracted class for managing permissions against related fields.""" + + @classmethod + def check_related_permission(cls, permission, user) -> bool: + """Check if the user has permission to perform the specified action on the attachment. + + The default implementation runs a permission check against *this* model class, + but this can be overridden in the implementing class if required. + + Arguments: + permission: The permission to check (add / change / view / delete) + user: The user to check against + + Returns: + bool: True if the user has permission, False otherwise + """ + perm = f'{cls._meta.app_label}.{permission}_{cls._meta.model_name}' + return user.has_perm(perm) + + +class InvenTreeParameterMixin(InvenTreePermissionCheckMixin, models.Model): + """Provides an abstracted class for managing parameters. + + Links the implementing model to the common.models.Parameter table, + and provides the following methods: + """ + + class Meta: + """Metaclass options for InvenTreeParameterMixin.""" + + abstract = True + + # Define a reverse relation to the Parameter model + parameters_list = GenericRelation( + 'common.Parameter', content_type_field='model_type', object_id_field='model_id' + ) + + @staticmethod + def annotate_parameters(queryset: QuerySet) -> QuerySet: + """Annotate a queryset with pre-fetched parameters. + + Args: + queryset: Queryset to annotate + + Returns: + Annotated queryset + """ + return queryset.prefetch_related( + 'parameters_list', + 'parameters_list__model_type', + 'parameters_list__template', + ) + + @property + def parameters(self) -> QuerySet: + """Return a QuerySet containing all the Parameter instances for this model. + + This will return pre-fetched data if available (i.e. in a serializer context). + """ + # Check the query cache for pre-fetched parameters + if 'parameters_list' in getattr(self, '_prefetched_objects_cache', {}): + return self._prefetched_objects_cache['parameters_list'] + + return self.parameters_list.all() + + def delete(self, *args, **kwargs): + """Handle the deletion of a model instance. + + Before deleting the model instance, delete any associated parameters. + """ + self.parameters_list.all().delete() + super().delete(*args, **kwargs) + + @transaction.atomic + def copy_parameters_from(self, other, clear=True, **kwargs): + """Copy all parameters from another model instance. + + Arguments: + other: The other model instance to copy parameters from + clear: If True, clear existing parameters before copying + **kwargs: Additional keyword arguments to pass to the Parameter constructor + """ + import common.models + + if clear: + self.parameters_list.all().delete() + + parameters = [] + + content_type = ContentType.objects.get_for_model(self.__class__) + + template_ids = [parameter.template.pk for parameter in other.parameters.all()] + + # Remove all conflicting parameters first + self.parameters_list.filter(template__pk__in=template_ids).delete() + + for parameter in other.parameters.all(): + parameter.pk = None + parameter.model_id = self.pk + parameter.model_type = content_type + + parameters.append(parameter) + + if len(parameters) > 0: + common.models.Parameter.objects.bulk_create(parameters) + + def get_parameter(self, name: str): + """Return a Parameter instance for the given parameter name. + + Args: + name: Name of the parameter template + + Returns: + Parameter instance if found, else None + """ + return self.parameters_list.filter(template__name=name).first() + + def get_parameters(self) -> QuerySet: + """Return all Parameter instances for this model.""" + return ( + self.parameters_list.all() + .prefetch_related('template', 'model_type') + .order_by('template__name') + ) + + def parameters_map(self) -> dict: + """Return a map (dict) of parameter values associated with this Part instance, of the form. + + Example: + { + "name_1": "value_1", + "name_2": "value_2", + } + """ + params = {} + + for parameter in self.parameters.all().prefetch_related('template'): + params[parameter.template.name] = parameter.data + + return params + + def check_parameter_delete(self, parameter): + """Run a check to determine if the provided parameter can be deleted. + + The default implementation always returns True, but this can be overridden in the implementing class. + """ + return True + + def check_parameter_save(self, parameter): + """Run a check to determine if the provided parameter can be saved. + + The default implementation always returns True, but this can be overridden in the implementing class. + """ + return True + + +class InvenTreeAttachmentMixin(InvenTreePermissionCheckMixin): """Provides an abstracted class for managing file attachments. Links the implementing model to the common.models.Attachment table, @@ -491,33 +661,15 @@ class InvenTreeAttachmentMixin: super().delete(*args, **kwargs) @property - def attachments(self): + def attachments(self) -> QuerySet: """Return a queryset containing all attachments for this model.""" return self.attachments_for_model().filter(model_id=self.pk) - @classmethod - def check_attachment_permission(cls, permission, user) -> bool: - """Check if the user has permission to perform the specified action on the attachment. - - The default implementation runs a permission check against *this* model class, - but this can be overridden in the implementing class if required. - - Arguments: - permission: The permission to check (add / change / view / delete) - user: The user to check against - - Returns: - bool: True if the user has permission, False otherwise - """ - perm = f'{cls._meta.app_label}.{permission}_{cls._meta.model_name}' - return user.has_perm(perm) - - def attachments_for_model(self): + def attachments_for_model(self) -> QuerySet: """Return all attachments for this model class.""" from common.models import Attachment model_type = self.__class__.__name__.lower() - return Attachment.objects.filter(model_type=model_type) def create_attachment(self, attachment=None, link=None, comment='', **kwargs): @@ -533,7 +685,7 @@ class InvenTreeAttachmentMixin: Attachment.objects.create(**kwargs) -class InvenTreeTree(MPTTModel): +class InvenTreeTree(ContentTypeMixin, MPTTModel): """Provides an abstracted self-referencing tree model, based on the MPTTModel class. Our implementation provides the following key improvements: diff --git a/src/backend/InvenTree/InvenTree/schema.py b/src/backend/InvenTree/InvenTree/schema.py index 36918d3d95..095dbe15d4 100644 --- a/src/backend/InvenTree/InvenTree/schema.py +++ b/src/backend/InvenTree/InvenTree/schema.py @@ -1,7 +1,7 @@ """Schema processing functions for cleaning up generated schema.""" from itertools import chain -from typing import Optional +from typing import Any, Optional from django.conf import settings @@ -138,6 +138,43 @@ class ExtendedAutoSchema(AutoSchema): return operation +def postprocess_schema_enums(result, generator, **kwargs): + """Override call to drf-spectacular's enum postprocessor to filter out specific warnings.""" + from drf_spectacular import drainage + + # Monkey-patch the warn function temporarily + original_warn = drainage.warn + + def custom_warn(msg: str, delayed: Any = None) -> None: + """Custom patch to ignore some drf-spectacular warnings. + + - Some warnings are unavoidable due to the way that InvenTree implements generic relationships (via ContentType). + - The cleanest way to handle this appears to be to override the 'warn' function from drf-spectacular. + + Ref: https://github.com/inventree/InvenTree/pull/10699 + """ + ignore_patterns = [ + 'enum naming encountered a non-optimally resolvable collision for fields named "model_type"' + ] + + if any(pattern in msg for pattern in ignore_patterns): + return + + original_warn(msg, delayed) + + # Replace the warn function with our custom version + drainage.warn = custom_warn + + import drf_spectacular.hooks + + result = drf_spectacular.hooks.postprocess_schema_enums(result, generator, **kwargs) + + # Restore the original warn function + drainage.warn = original_warn + + return result + + def postprocess_required_nullable(result, generator, request, public): """Un-require nullable fields. diff --git a/src/backend/InvenTree/InvenTree/serializers.py b/src/backend/InvenTree/InvenTree/serializers.py index 57be6dc1d1..074b8ce6f7 100644 --- a/src/backend/InvenTree/InvenTree/serializers.py +++ b/src/backend/InvenTree/InvenTree/serializers.py @@ -7,6 +7,7 @@ from decimal import Decimal from typing import Any, Optional from django.conf import settings +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError as DjangoValidationError from django.db import models from django.utils.translation import gettext_lazy as _ @@ -28,6 +29,7 @@ import InvenTree.ready from common.currency import currency_code_default, currency_code_mappings from InvenTree.fields import InvenTreeRestURLField, InvenTreeURLField from InvenTree.helpers import str2bool +from InvenTree.helpers_model import getModelsWithMixin from .setting.storages import StorageBackends @@ -721,3 +723,96 @@ class RemoteImageMixin(metaclass=serializers.SerializerMetaclass): raise ValidationError(_('Failed to download image from remote URL')) return url + + +class ContentTypeField(serializers.ChoiceField): + """Serializer field which represents a ContentType as 'app_label.model_name'. + + This field converts a ContentType instance to a string representation in the format 'app_label.model_name' during serialization, and vice versa during deserialization. + + Additionally, a "mixin_class" can be supplied to the field, which will restrict the valid content types to only those models which inherit from the specified mixin. + """ + + mixin_class = None + + def __init__(self, *args, mixin_class=None, **kwargs): + """Initialize the ContentTypeField. + + Args: + mixin_class: Optional mixin class to restrict valid content types. + """ + self.mixin_class = mixin_class + + # Override the 'choices' field, to limit to the appropriate models + if self.mixin_class is not None: + models = getModelsWithMixin(self.mixin_class) + + kwargs['choices'] = [ + ( + f'{model._meta.app_label}.{model._meta.model_name}', + model._meta.verbose_name, + ) + for model in models + ] + else: + content_types = ContentType.objects.all() + + kwargs['choices'] = [ + (f'{ct.app_label}.{ct.model}', str(ct)) for ct in content_types + ] + + if kwargs.get('allow_null') or kwargs.get('allow_blank'): + kwargs['choices'] = [('', '---------'), *kwargs['choices']] + + super().__init__(*args, **kwargs) + + def to_representation(self, value): + """Convert ContentType instance to string representation.""" + return f'{value.app_label}.{value.model}' + + def to_internal_value(self, data): + """Convert string representation back to ContentType instance.""" + from django.contrib.contenttypes.models import ContentType + + content_type = None + + if data in ['', None]: + return None + + # First, try to resolve the content type via direct pk value + try: + content_type_id = int(data) + content_type = ContentType.objects.get_for_id(content_type_id) + except (ValueError, ContentType.DoesNotExist): + content_type = None + + try: + if len(data.split('.')) == 2: + app_label, model = data.split('.') + content_types = ContentType.objects.filter( + app_label=app_label, model=model + ) + + if content_types.count() == 1: + # Try exact match first + content_type = content_types.first() + else: + # Try lookup just on model name + content_types = ContentType.objects.filter(model=data) + if content_types.exists() and content_types.count() == 1: + content_type = content_types.first() + + except Exception: + raise ValidationError(_('Invalid content type format')) + + if content_type is None: + raise ValidationError(_('Content type not found')) + + if self.mixin_class is not None: + model_class = content_type.model_class() + if not issubclass(model_class, self.mixin_class): + raise ValidationError( + _('Content type does not match required mixin class') + ) + + return content_type diff --git a/src/backend/InvenTree/InvenTree/setting/spectacular.py b/src/backend/InvenTree/InvenTree/setting/spectacular.py index 56fdbdc4fa..c83e1f8265 100644 --- a/src/backend/InvenTree/InvenTree/setting/spectacular.py +++ b/src/backend/InvenTree/InvenTree/setting/spectacular.py @@ -20,7 +20,7 @@ def get_spectacular_settings(): 'SERVE_INCLUDE_SCHEMA': False, 'SCHEMA_PATH_PREFIX': '/api/', 'POSTPROCESSING_HOOKS': [ - 'drf_spectacular.hooks.postprocess_schema_enums', + 'InvenTree.schema.postprocess_schema_enums', 'InvenTree.schema.postprocess_required_nullable', 'InvenTree.schema.postprocess_print_stats', ], @@ -28,6 +28,7 @@ def get_spectacular_settings(): 'UserTypeEnum': 'users.models.UserProfile.UserType', 'TemplateModelTypeEnum': 'report.models.ReportTemplateBase.ModelChoices', 'AttachmentModelTypeEnum': 'common.models.Attachment.ModelChoices', + 'ParameterModelTypeEnum': 'common.models.Parameter.ModelChoices', 'DataImportSessionModelTypeEnum': 'importer.models.DataImportSession.ModelChoices', # Allauth 'UnauthorizedStatus': [[401, 401]], diff --git a/src/backend/InvenTree/build/api.py b/src/backend/InvenTree/build/api.py index 2f0cf3d2ab..5e1901f58a 100644 --- a/src/backend/InvenTree/build/api.py +++ b/src/backend/InvenTree/build/api.py @@ -24,7 +24,7 @@ from build.models import Build, BuildItem, BuildLine from build.status_codes import BuildStatus, BuildStatusGroups from data_exporter.mixins import DataExportViewMixin from generic.states.api import StatusView -from InvenTree.api import BulkDeleteMixin, MetadataView +from InvenTree.api import BulkDeleteMixin, MetadataView, ParameterListMixin from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration from InvenTree.filters import ( SEARCH_ORDER_FILTER_ALIAS, @@ -336,7 +336,13 @@ class BuildListOutputOptions(OutputConfiguration): OPTIONS = [InvenTreeOutputOption('part_detail', default=True)] -class BuildList(DataExportViewMixin, BuildMixin, OutputOptionsMixin, ListCreateAPI): +class BuildList( + DataExportViewMixin, + BuildMixin, + OutputOptionsMixin, + ParameterListMixin, + ListCreateAPI, +): """API endpoint for accessing a list of Build objects. - GET: Return list of objects (with filters) diff --git a/src/backend/InvenTree/build/models.py b/src/backend/InvenTree/build/models.py index 32bf0a951b..42e8213099 100644 --- a/src/backend/InvenTree/build/models.py +++ b/src/backend/InvenTree/build/models.py @@ -76,6 +76,7 @@ class BuildReportContext(report.mixins.BaseReportContext): class Build( InvenTree.models.PluginValidationMixin, report.mixins.InvenTreeReportMixin, + InvenTree.models.InvenTreeParameterMixin, InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNotesMixin, diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py index 2da5c2ee85..cdef3a31ed 100644 --- a/src/backend/InvenTree/build/serializers.py +++ b/src/backend/InvenTree/build/serializers.py @@ -22,6 +22,7 @@ from rest_framework import serializers from rest_framework.serializers import ValidationError import build.tasks +import common.serializers import common.settings import company.serializers import InvenTree.helpers @@ -101,6 +102,7 @@ class BuildSerializer( 'issued_by_detail', 'responsible', 'responsible_detail', + 'parameters', 'priority', 'level', ] @@ -124,6 +126,12 @@ class BuildSerializer( True, ) + parameters = enable_filter( + common.serializers.ParameterSerializer(many=True, read_only=True), + False, + filter_name='parameters', + ) + part_name = serializers.CharField( source='part.name', read_only=True, label=_('Part Name') ) diff --git a/src/backend/InvenTree/build/test_migrations.py b/src/backend/InvenTree/build/test_migrations.py index 924b45d249..6613c50dcc 100644 --- a/src/backend/InvenTree/build/test_migrations.py +++ b/src/backend/InvenTree/build/test_migrations.py @@ -86,7 +86,7 @@ class TestReferencePatternMigration(MigratorTestCase): """ migrate_from = ('build', '0019_auto_20201019_1302') - migrate_to = ('build', unit_test.getNewestMigrationFile('build')) + migrate_to = ('build', '0037_build_priority') def prepare(self): """Create some initial data prior to migration.""" diff --git a/src/backend/InvenTree/common/admin.py b/src/backend/InvenTree/common/admin.py index 213f42bd29..a5461b7d02 100644 --- a/src/backend/InvenTree/common/admin.py +++ b/src/backend/InvenTree/common/admin.py @@ -6,6 +6,32 @@ import common.models import common.validators +@admin.register(common.models.ParameterTemplate) +class ParameterTemplateAdmin(admin.ModelAdmin): + """Admin interface for ParameterTemplate objects.""" + + list_display = ('name', 'description', 'model_type', 'units') + search_fields = ('name', 'description') + + +@admin.register(common.models.Parameter) +class ParameterAdmin(admin.ModelAdmin): + """Admin interface for Parameter objects.""" + + list_display = ( + 'template', + 'model_type', + 'model_id', + 'data', + 'updated', + 'updated_by', + ) + + autocomplete_fields = ('template', 'updated_by') + list_filter = ('template', 'model_type', 'updated_by') + search_fields = ('template__name', 'data', 'note') + + @admin.register(common.models.Attachment) class AttachmentAdmin(admin.ModelAdmin): """Admin interface for Attachment objects.""" diff --git a/src/backend/InvenTree/common/api.py b/src/backend/InvenTree/common/api.py index a28bf451cf..ec64f79d87 100644 --- a/src/backend/InvenTree/common/api.py +++ b/src/backend/InvenTree/common/api.py @@ -27,7 +27,9 @@ from rest_framework.exceptions import NotAcceptable, NotFound, PermissionDenied from rest_framework.permissions import IsAdminUser, IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView +from sql_util.utils import SubqueryCount +import common.filters import common.models import common.serializers import InvenTree.conversion @@ -35,15 +37,20 @@ from common.icons import get_icon_packs from common.settings import get_global_setting from data_exporter.mixins import DataExportViewMixin from generic.states.api import urlpattern as generic_states_api_urls -from InvenTree.api import BulkDeleteMixin, MetadataView +from InvenTree.api import BulkCreateMixin, BulkDeleteMixin, MetadataView from InvenTree.config import CONFIG_LOOKUPS -from InvenTree.filters import ORDER_FILTER, SEARCH_ORDER_FILTER -from InvenTree.helpers import inheritors +from InvenTree.filters import ( + ORDER_FILTER, + SEARCH_ORDER_FILTER, + SEARCH_ORDER_FILTER_ALIAS, +) +from InvenTree.helpers import inheritors, str2bool from InvenTree.helpers_email import send_email from InvenTree.mixins import ( CreateAPI, ListAPI, ListCreateAPI, + OutputOptionsMixin, RetrieveAPI, RetrieveDestroyAPI, RetrieveUpdateAPI, @@ -708,13 +715,17 @@ class AttachmentFilter(FilterSet): return queryset.filter(Q(attachment=None) | Q(attachment='')).distinct() -class AttachmentList(BulkDeleteMixin, ListCreateAPI): - """List API endpoint for Attachment objects.""" +class AttachmentMixin: + """Mixin class for Attachment views.""" queryset = common.models.Attachment.objects.all() serializer_class = common.serializers.AttachmentSerializer permission_classes = [IsAuthenticatedOrReadScope] + +class AttachmentList(AttachmentMixin, BulkDeleteMixin, ListCreateAPI): + """List API endpoint for Attachment objects.""" + filter_backends = SEARCH_ORDER_FILTER filterset_class = AttachmentFilter @@ -746,13 +757,9 @@ class AttachmentList(BulkDeleteMixin, ListCreateAPI): ) -class AttachmentDetail(RetrieveUpdateDestroyAPI): +class AttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI): """Detail API endpoint for Attachment objects.""" - queryset = common.models.Attachment.objects.all() - serializer_class = common.serializers.AttachmentSerializer - permission_classes = [IsAuthenticatedOrReadScope] - def destroy(self, request, *args, **kwargs): """Check user permissions before deleting an attachment.""" attachment = self.get_object() @@ -765,6 +772,165 @@ class AttachmentDetail(RetrieveUpdateDestroyAPI): return super().destroy(request, *args, **kwargs) +class ParameterTemplateFilter(FilterSet): + """FilterSet class for the ParameterTemplateList API endpoint.""" + + class Meta: + """Metaclass options.""" + + model = common.models.ParameterTemplate + fields = ['name', 'units', 'checkbox', 'enabled'] + + has_choices = rest_filters.BooleanFilter( + method='filter_has_choices', label='Has Choice' + ) + + def filter_has_choices(self, queryset, name, value): + """Filter queryset to include only PartParameterTemplates with choices.""" + if str2bool(value): + return queryset.exclude(Q(choices=None) | Q(choices='')) + + return queryset.filter(Q(choices=None) | Q(choices='')).distinct() + + has_units = rest_filters.BooleanFilter(method='filter_has_units', label='Has Units') + + def filter_has_units(self, queryset, name, value): + """Filter queryset to include only PartParameterTemplates with units.""" + if str2bool(value): + return queryset.exclude(Q(units=None) | Q(units='')) + + return queryset.filter(Q(units=None) | Q(units='')).distinct() + + model_type = rest_filters.CharFilter(method='filter_model_type', label='Model Type') + + def filter_model_type(self, queryset, name, value): + """Filter queryset to include only ParameterTemplates of the given model type.""" + return common.filters.filter_content_type( + queryset, 'model_type', value, allow_null=False + ) + + for_model = rest_filters.CharFilter(method='filter_for_model', label='For Model') + + def filter_for_model(self, queryset, name, value): + """Filter queryset to include only ParameterTemplates which apply to the given model. + + Note that this varies from the 'model_type' filter, in that ParameterTemplates + with a blank 'model_type' are considered to apply to all models. + """ + return common.filters.filter_content_type( + queryset, 'model_type', value, allow_null=True + ) + + exists_for_model = rest_filters.CharFilter( + method='filter_exists_for_model', label='Exists For Model' + ) + + def filter_exists_for_model(self, queryset, name, value): + """Filter queryset to include only ParameterTemplates which have at least one Parameter for the given model type.""" + content_type = common.filters.determine_content_type(value) + + if not content_type: + return queryset.none() + + queryset = queryset.prefetch_related('parameters') + + # Annotate the queryset to determine which ParameterTemplates have at least one Parameter for the given model type + queryset = queryset.annotate( + parameter_count=SubqueryCount( + 'parameters', filter=Q(model_type=content_type) + ) + ) + + # Return only those ParameterTemplates which have at least one Parameter for the given model type + return queryset.filter(parameter_count__gt=0) + + +class ParameterTemplateMixin: + """Mixin class for ParameterTemplate views.""" + + queryset = common.models.ParameterTemplate.objects.all() + serializer_class = common.serializers.ParameterTemplateSerializer + permission_classes = [IsAuthenticatedOrReadScope] + + +class ParameterTemplateList(ParameterTemplateMixin, DataExportViewMixin, ListCreateAPI): + """List view for ParameterTemplate objects.""" + + filterset_class = ParameterTemplateFilter + filter_backends = SEARCH_ORDER_FILTER + search_fields = ['name', 'description'] + ordering_fields = ['name', 'units', 'checkbox'] + + +class ParameterTemplateDetail(ParameterTemplateMixin, RetrieveUpdateDestroyAPI): + """Detail view for a ParameterTemplate object.""" + + +class ParameterFilter(FilterSet): + """Custom filters for the ParameterList API endpoint.""" + + class Meta: + """Metaclass options for the filterset.""" + + model = common.models.Parameter + fields = ['model_id', 'template', 'updated_by'] + + enabled = rest_filters.BooleanFilter( + label='Template Enabled', field_name='template__enabled' + ) + + model_type = rest_filters.CharFilter(method='filter_model_type', label='Model Type') + + def filter_model_type(self, queryset, name, value): + """Filter queryset to include only Parameters of the given model type.""" + return common.filters.filter_content_type( + queryset, 'model_type', value, allow_null=False + ) + + +class ParameterMixin: + """Mixin class for Parameter views.""" + + queryset = common.models.Parameter.objects.all() + serializer_class = common.serializers.ParameterSerializer + permission_classes = [IsAuthenticatedOrReadScope] + + +class ParameterList( + OutputOptionsMixin, + ParameterMixin, + BulkCreateMixin, + BulkDeleteMixin, + DataExportViewMixin, + ListCreateAPI, +): + """List API endpoint for Parameter objects.""" + + filterset_class = ParameterFilter + filter_backends = SEARCH_ORDER_FILTER_ALIAS + + ordering_fields = ['name', 'data', 'units', 'template', 'updated', 'updated_by'] + + ordering_field_aliases = { + 'name': 'template__name', + 'units': 'template__units', + 'data': ['data_numeric', 'data'], + } + + search_fields = [ + 'data', + 'template__name', + 'template__description', + 'template__units', + ] + + unique_create_fields = ['model_type', 'model_id', 'template'] + + +class ParameterDetail(ParameterMixin, RetrieveUpdateDestroyAPI): + """Detail API endpoint for Parameter objects.""" + + @method_decorator(cache_control(public=True, max_age=86400), name='dispatch') class IconList(ListAPI): """List view for available icon packages.""" @@ -997,6 +1163,51 @@ common_api_urls = [ path('', AttachmentList.as_view(), name='api-attachment-list'), ]), ), + # Parameters and templates + path( + 'parameter/', + include([ + path( + 'template/', + include([ + path( + '/', + include([ + path( + 'metadata/', + MetadataView.as_view( + model=common.models.ParameterTemplate + ), + name='api-parameter-template-metadata', + ), + path( + '', + ParameterTemplateDetail.as_view(), + name='api-parameter-template-detail', + ), + ]), + ), + path( + '', + ParameterTemplateList.as_view(), + name='api-parameter-template-list', + ), + ]), + ), + path( + '/', + include([ + path( + 'metadata/', + MetadataView.as_view(model=common.models.Parameter), + name='api-parameter-metadata', + ), + path('', ParameterDetail.as_view(), name='api-parameter-detail'), + ]), + ), + path('', ParameterList.as_view(), name='api-parameter-list'), + ]), + ), path( 'error-report/', include([ diff --git a/src/backend/InvenTree/common/filters.py b/src/backend/InvenTree/common/filters.py new file mode 100644 index 0000000000..a7968dbfc1 --- /dev/null +++ b/src/backend/InvenTree/common/filters.py @@ -0,0 +1,316 @@ +"""Custom API filters for InvenTree.""" + +import re + +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError +from django.db.models import ( + Case, + CharField, + Exists, + FloatField, + Model, + OuterRef, + Q, + Subquery, + Value, + When, +) +from django.db.models.query import QuerySet + +import InvenTree.conversion +import InvenTree.helpers + + +def determine_content_type(content_type: str | int | None) -> ContentType | None: + """Determine a ContentType instance from a string or integer input. + + Arguments: + content_type: The content type to resolve (name or ID). + + Returns: + ContentType instance if found, else None. + """ + if content_type is None: + return None + + ct = None + + # First, try to resolve the content type via a PK value + try: + content_type_id = int(content_type) + ct = ContentType.objects.get_for_id(content_type_id) + except (ValueError, ContentType.DoesNotExist): + ct = None + + if len(content_type.split('.')) == 2: + # Next, try to resolve the content type via app_label.model_name + try: + app_label, model = content_type.split('.') + ct = ContentType.objects.get(app_label=app_label, model=model) + except ContentType.DoesNotExist: + ct = None + + else: + # Next, try to resolve the content type via a model name + ct = ContentType.objects.filter(model__iexact=content_type).first() + + return ct + + +def filter_content_type( + queryset, field_name: str, content_type: str | int | None, allow_null: bool = True +): + """Filter a queryset by content type. + + Arguments: + queryset: The queryset to filter. + field_name: The name of the content type field within the current model context. + content_type: The content type to filter by (name or ID). + allow_null: If True, include entries with null content type. + + Returns: + Filtered queryset. + """ + if content_type is None: + return queryset + + ct = determine_content_type(content_type) + + if ct is None: + raise ValidationError(f'Invalid content type: {content_type}') + + q = Q(**{f'{field_name}': ct}) + + if allow_null: + q |= Q(**{f'{field_name}__isnull': True}) + + return queryset.filter(q) + + +"""A list of valid operators for filtering part parameters.""" +PARAMETER_FILTER_OPERATORS: list[str] = ['gt', 'gte', 'lt', 'lte', 'ne', 'icontains'] + + +def filter_parameters_by_value( + queryset: QuerySet, template_id: int, value: str, func: str = '' +) -> QuerySet: + """Filter the Parameter model based on the provided template and value. + + Arguments: + queryset: The initial QuerySet to filter. + template_id: The parameter template ID to filter by. + value: The value to filter against. + func: The filtering function to apply (e.g. 'gt', 'lt', etc). + + Returns: + A list of Parameter instances which match the given criteria. + + Notes: + - Parts which do not have a value for the given parameter are excluded. + """ + from common.models import ParameterTemplate + + # Ensure that the provided function is valid + if func and func not in PARAMETER_FILTER_OPERATORS: + raise ValueError(f'Invalid parameter filter function: {func}') + + # Ensure that the template exists + try: + template = ParameterTemplate.objects.get(pk=template_id) + except ParameterTemplate.DoesNotExist: + raise ValueError(f'Invalid parameter template ID: {template_id}') + + # Construct a "numeric" value for the filter + try: + value_numeric = float(value) + except (ValueError, TypeError): + value_numeric = None + + if template.checkbox: + # Account for 'boolean' parameter values + bool_value = InvenTree.helpers.str2bool(value) + value_numeric = 1 if bool_value else 0 + value = str(bool_value) + + # Boolean filtering is limited to exact matches + func = '' + + elif value_numeric is None and template.units: + # Convert the raw value to the units of the template parameter + try: + value_numeric = InvenTree.conversion.convert_physical_value( + value, template.units + ) + except Exception: + # The value cannot be converted - return an empty queryset + return queryset.none() + + # Special handling for the "not equal" operator + if func == 'ne': + invert = True + func = '' + else: + invert = False + + # Some filters are only applicable to string values + text_only = any([func in ['icontains'], value_numeric is None]) + + # Ensure the function starts with a double underscore + if func and not func.startswith('__'): + func = f'__{func}' + + # Query for 'numeric' value - this has priority over 'string' value + data_numeric = { + 'parameters_list__template': template, + 'parameters_list__data_numeric__isnull': False, + f'parameters_list__data_numeric{func}': value_numeric, + } + + query_numeric = Q(**data_numeric) + + # Query for 'string' value + data_text = { + 'parameters_list__template': template, + f'parameters_list__data{func}': str(value), + } + + if not text_only: + data_text['parameters_list__data_numeric__isnull'] = True + + query_text = Q(**data_text) + + # Combine the queries based on whether we are filtering by text or numeric value + q = query_text if text_only else query_text | query_numeric + + # queryset = Parameter.objects.prefetch_related('template').all() + + # Special handling for the '__ne' (not equal) operator + # In this case, we want the *opposite* of the above queries + if invert: + return queryset.exclude(q).distinct() + else: + return queryset.filter(q).distinct() + + +def filter_parametric_data(queryset: QuerySet, parameters: dict[str, str]) -> QuerySet: + """Filter the provided queryset by parametric data. + + Arguments: + queryset: The initial queryset to filter. + parameters: A dictionary of parameter filters to apply. + + Returns: + Filtered queryset. + + Used to filter returned parts based on their parameter values. + + To filter based on parameter value, supply query parameters like: + - parameter_= + - parameter__gt= + - parameter__lte= + + where: + - is the ID of the ParameterTemplate. + - is the value to filter against. + + Typically these filters would be provided against via an API request. + """ + queryset = queryset.prefetch_related('parameters_list', 'parameters_list__template') + + # Allowed lookup operations for parameter values + operators = '|'.join(PARAMETER_FILTER_OPERATORS) + + regex_pattern = rf'^parameter_(\d+)(_({operators}))?$' + + for param, value in parameters.items(): + result = re.match(regex_pattern, param) + if not result: + continue + + template_id = result.group(1) + operator = result.group(3) or '' + + queryset = filter_parameters_by_value( + queryset, template_id, value, func=operator + ) + + return queryset + + +def order_by_parameter( + queryset: QuerySet, model_type: Model, ordering: str | None +) -> QuerySet: + """Order the provided queryset by a parameter value. + + Arguments: + queryset: The initial queryset to order. + model_type: The model type of the items in the queryset. + ordering: The ordering string provided by the user. + + Returns: + Ordered queryset. + + Used to order returned parts based on their parameter values. + + To order based on parameter value, supply an ordering string like: + - parameter_ + - -parameter_ + + where: + - is the ID of the ParameterTemplate. + - A leading '-' indicates descending order. + """ + import common.models + + if not ordering: + # No ordering provided - return the original queryset + return queryset + + result = re.match(r'^-?parameter_(\d+)$', ordering) + + if not result: + # Ordering does not match the expected pattern - return the original queryset + return queryset + + template_id = result.group(1) + ascending = not ordering.startswith('-') + + template_exists_filter = common.models.Parameter.objects.filter( + template__id=template_id, + model_type=ContentType.objects.get_for_model(model_type), + model_id=OuterRef('id'), + ) + + queryset = queryset.annotate(parameter_exists=Exists(template_exists_filter)) + + # Annotate the queryset with the parameter value for the provided template + queryset = queryset.annotate( + parameter_value=Case( + When( + parameter_exists=True, + then=Subquery( + template_exists_filter.values('data')[:1], output_field=CharField() + ), + ), + default=Value('', output_field=CharField()), + ), + parameter_value_numeric=Case( + When( + parameter_exists=True, + then=Subquery( + template_exists_filter.values('data_numeric')[:1], + output_field=FloatField(), + ), + ), + default=Value(0, output_field=FloatField()), + ), + ) + + prefix = '' if ascending else '-' + + return queryset.order_by( + '-parameter_exists', + f'{prefix}parameter_value_numeric', + f'{prefix}parameter_value', + ) diff --git a/src/backend/InvenTree/common/migrations/0023_auto_20240602_1332.py b/src/backend/InvenTree/common/migrations/0023_auto_20240602_1332.py index a3cc1fd02a..3c29bda31f 100644 --- a/src/backend/InvenTree/common/migrations/0023_auto_20240602_1332.py +++ b/src/backend/InvenTree/common/migrations/0023_auto_20240602_1332.py @@ -44,7 +44,7 @@ def set_currencies(apps, schema_editor): valid_codes.add(code) if len(valid_codes) == 0: - print(f"No valid currency codes found in configuration file") + print(f"No currency codes found in configuration file - skipping migration") return value = ','.join(valid_codes) diff --git a/src/backend/InvenTree/common/migrations/0040_parametertemplate_parameter.py b/src/backend/InvenTree/common/migrations/0040_parametertemplate_parameter.py new file mode 100644 index 0000000000..5bfce74256 --- /dev/null +++ b/src/backend/InvenTree/common/migrations/0040_parametertemplate_parameter.py @@ -0,0 +1,248 @@ +# Generated by Django 5.2.8 on 2025-12-03 12:39 + +import InvenTree.models +import InvenTree.validators +import django.core.validators +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("common", "0039_emailthread_emailmessage"), + ("contenttypes", "0002_remove_content_type_name"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("part", "0144_auto_20251203_1045") + ] + + operations = [ + migrations.SeparateDatabaseAndState( + state_operations=[ + migrations.CreateModel( + name="ParameterTemplate", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "metadata", + models.JSONField( + blank=True, + help_text="JSON metadata field, for use by external plugins", + null=True, + verbose_name="Plugin Metadata", + ), + ), + ( + "name", + models.CharField( + help_text="Parameter Name", + max_length=100, + unique=True, + verbose_name="Name", + ), + ), + ( + "units", + models.CharField( + blank=True, + help_text="Physical units for this parameter", + max_length=25, + validators=[InvenTree.validators.validate_physical_units], + verbose_name="Units", + ), + ), + ( + "description", + models.CharField( + blank=True, + help_text="Parameter description", + max_length=250, + verbose_name="Description", + ), + ), + ( + "checkbox", + models.BooleanField( + default=False, + help_text="Is this parameter a checkbox?", + verbose_name="Checkbox", + ), + ), + ( + "choices", + models.CharField( + blank=True, + help_text="Valid choices for this parameter (comma-separated)", + max_length=5000, + verbose_name="Choices", + ), + ), + ( + "enabled", + models.BooleanField( + default=True, + help_text="Is this parameter template enabled?", + verbose_name="Enabled", + ), + ), + ( + "model_type", + models.ForeignKey( + blank=True, + help_text="Target model type for this parameter template", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="contenttypes.contenttype", + verbose_name="Model type", + ), + ), + ( + "selectionlist", + models.ForeignKey( + blank=True, + help_text="Selection list for this parameter", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="templates", + to="common.selectionlist", + verbose_name="Selection List", + ), + ), + ], + options={ + "verbose_name": "Parameter Template", + "verbose_name_plural": "Parameter Templates", + "db_table": "part_partparametertemplate", + }, + bases=( + InvenTree.models.ContentTypeMixin, + InvenTree.models.PluginValidationMixin, + models.Model, + ), + ), + ], + database_operations=[], + ), + migrations.SeparateDatabaseAndState( + state_operations=[ + migrations.CreateModel( + name="Parameter", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "metadata", + models.JSONField( + blank=True, + help_text="JSON metadata field, for use by external plugins", + null=True, + verbose_name="Plugin Metadata", + ), + ), + ( + "updated", + models.DateTimeField( + blank=True, + default=None, + help_text="Timestamp of last update", + null=True, + verbose_name="Updated", + ), + ), + ( + "model_id", + models.PositiveIntegerField( + help_text="ID of the target model for this parameter", + verbose_name="Model ID", + ), + ), + ( + "data", + models.CharField( + help_text="Parameter Value", + max_length=500, + validators=[django.core.validators.MinLengthValidator(1)], + verbose_name="Data", + ), + ), + ( + "data_numeric", + models.FloatField(blank=True, default=None, null=True), + ), + ( + "note", + models.CharField( + blank=True, + help_text="Optional note field", + max_length=500, + verbose_name="Note", + ), + ), + ( + "model_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="contenttypes.contenttype", + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + help_text="User who last updated this object", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated", + to=settings.AUTH_USER_MODEL, + verbose_name="Update By", + ), + ), + ( + "template", + models.ForeignKey( + help_text="Parameter template", + on_delete=django.db.models.deletion.CASCADE, + related_name="parameters", + to="common.parametertemplate", + verbose_name="Template", + ), + ), + ], + options={ + "verbose_name": "Parameter", + "verbose_name_plural": "Parameters", + "db_table": "part_partparameter", + "indexes": [ + models.Index( + fields=["model_type", "model_id"], + name="part_partpa_model_t_198c9d_idx", + ) + ], + "unique_together": {("model_type", "model_id", "template")}, + }, + bases=( + InvenTree.models.ContentTypeMixin, + InvenTree.models.PluginValidationMixin, + models.Model, + ), + ), + ], + database_operations=[], + ), + ] diff --git a/src/backend/InvenTree/common/migrations/0041_auto_20251203_1244.py b/src/backend/InvenTree/common/migrations/0041_auto_20251203_1244.py new file mode 100644 index 0000000000..7be962b226 --- /dev/null +++ b/src/backend/InvenTree/common/migrations/0041_auto_20251203_1244.py @@ -0,0 +1,116 @@ +# Generated by Django 5.2.8 on 2025-12-03 12:44 + +from django.db import migrations + +def convert_to_numeric_value(value: str, units: str): + """Convert a value (with units) to a numeric value. + + Defaults to zero if the value cannot be converted. + """ + + import InvenTree.conversion + + # Default value is null + result = None + + if units: + try: + result = InvenTree.conversion.convert_physical_value(value, units) + result = float(result.magnitude) + except Exception: + pass + else: + try: + result = float(value) + except Exception: + pass + + return result + + +def copy_manufacturer_part_parameters(apps, schema_editor): + """Copy ManufacturerPartParameter to Parameter.""" + + ManufacturerPartParameter = apps.get_model("company", "ManufacturerPartParameter") + Parameter = apps.get_model("common", "Parameter") + ParameterTemplate = apps.get_model("common", "ParameterTemplate") + ContentType = apps.get_model("contenttypes", "ContentType") + parameters = [] + + content_type, _created = ContentType.objects.get_or_create(app_label='company', model='manufacturerpart') + + N = ManufacturerPartParameter.objects.count() + + if N > 0: + print(f"\nMigrating {N} ManufacturerPartParameter objects to the Parameter table.") + + for parameter in ManufacturerPartParameter.objects.all(): + # Find the corresponding ParameterTemplate + template = ParameterTemplate.objects.filter(name=parameter.name).first() + + if not template: + # A matching template does not exist - let's create one + template = ParameterTemplate.objects.create( + name=parameter.name, + description='', + units=parameter.units or '', + model_type=None, + checkbox=False + ) + + parameters.append(Parameter( + template=template, + model_type=content_type, + model_id=parameter.manufacturer_part.id, + data=parameter.value, + data_numeric=convert_to_numeric_value(parameter.value, parameter.units), + )) + + if len(parameters) > 0: + assert ParameterTemplate.objects.count() > 0 + Parameter.objects.bulk_create(parameters) + print(f"\nMigrated {len(parameters)} ManufacturerPartParameter instances.") + + assert Parameter.objects.filter(model_type=content_type).count() == len(parameters) + + +def update_global_setting(apps, schema_editor): + """Update global setting key from PART_PARAMETER_ENFORCE_UNITS to PARAMETER_ENFORCE_UNITS.""" + GlobalSetting = apps.get_model("common", "InvenTreeSetting") + + OLD_KEY = 'PART_PARAMETER_ENFORCE_UNITS' + NEW_KEY = 'PARAMETER_ENFORCE_UNITS' + + try: + setting = GlobalSetting.objects.get(key=OLD_KEY) + + if setting is not None: + # Remove any existing new key + GlobalSetting.objects.filter(key=NEW_KEY).delete() + setting.key = NEW_KEY + setting.save() + print(f"Updated global setting key from {OLD_KEY} to {NEW_KEY}.") + except GlobalSetting.DoesNotExist: + pass + + +class Migration(migrations.Migration): + """Perform data migration for the ManufacturerPartParameter model.""" + + atomic = False + + dependencies = [ + ("common", "0040_parametertemplate_parameter"), + ("part", "0145_auto_20251203_1238"), + ] + + operations = [ + migrations.RunPython( + update_global_setting, + reverse_code=migrations.RunPython.noop + ), + migrations.RunPython( + copy_manufacturer_part_parameters, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index 9104ee04d1..6e1dbd2e5f 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -7,6 +7,7 @@ import base64 import hashlib import hmac import json +import math import os import uuid from datetime import timedelta, timezone @@ -28,7 +29,7 @@ from django.core.files.base import ContentFile from django.core.files.storage import default_storage from django.core.mail import EmailMultiAlternatives, get_connection from django.core.mail.utils import DNS_NAME -from django.core.validators import MinValueValidator +from django.core.validators import MinLengthValidator, MinValueValidator from django.db import models, transaction from django.db.models import enums from django.db.models.signals import post_delete, post_save @@ -48,11 +49,14 @@ from rest_framework.exceptions import PermissionDenied from taggit.managers import TaggableManager import common.validators +import InvenTree.conversion +import InvenTree.exceptions import InvenTree.fields import InvenTree.helpers import InvenTree.models import InvenTree.ready import InvenTree.tasks +import InvenTree.validators import users.models from common.setting.type import InvenTreeSettingsKeyType, SettingsKeyType from common.settings import get_global_setting, global_setting_overrides @@ -1895,6 +1899,8 @@ class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel An attachment can be either an uploaded file, or an external URL. Attributes: + model_type: The type of model to which this attachment is linked + model_id: The ID of the model to which this attachment is linked attachment: The uploaded file url: An external URL comment: A comment or description for the attachment @@ -2050,7 +2056,7 @@ class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel if not issubclass(model_class, InvenTreeAttachmentMixin): raise ValidationError(_('Invalid model type specified for attachment')) - return model_class.check_attachment_permission(permission, user) + return model_class.check_related_permission(permission, user) class InvenTreeCustomUserStateModel(models.Model): @@ -2356,6 +2362,421 @@ class SelectionListEntry(models.Model): return self.label +class ParameterTemplate( + InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel +): + """A ParameterTemplate provides a template for defining parameter values against various models. + + This allow for assigning arbitrary data fields against existing models, + extending their functionality beyond the built-in fields. + + Attributes: + name: The name (key) of the template + description: A description of the template + model_type: The type of model to which this template applies (e.g. 'part') + units: The units associated with the template (if applicable) + checkbox: Is this template a checkbox (boolean) type? + choices: Comma-separated list of choices (if applicable) + selectionlist: Optional link to a SelectionList for this template + enabled: Is this template enabled? + """ + + class Meta: + """Metaclass options for the ParameterTemplate model.""" + + verbose_name = _('Parameter Template') + verbose_name_plural = _('Parameter Templates') + + # Note: Data was migrated from the existing 'part_partparametertemplate' table + # Ref: https://github.com/inventree/InvenTree/pull/10699 + # To avoid data loss, we retain the existing table name + db_table = 'part_partparametertemplate' + + class ModelChoices(RenderChoices): + """Model choices for parameters.""" + + choice_fnc = common.validators.parameter_template_model_options + + @staticmethod + def get_api_url() -> str: + """Return the API URL associated with the ParameterTemplate model.""" + return reverse('api-parameter-template-list') + + def __str__(self): + """Return a string representation of a ParameterTemplate instance.""" + s = str(self.name) + if self.units: + s += f' ({self.units})' + return s + + def clean(self): + """Custom cleaning step for this model. + + Checks: + - A 'checkbox' field cannot have 'choices' set + - A 'checkbox' field cannot have 'units' set + """ + super().clean() + + # Check that checkbox parameters do not have units or choices + if self.checkbox: + if self.units: + raise ValidationError({ + 'units': _('Checkbox parameters cannot have units') + }) + + if self.choices: + raise ValidationError({ + 'choices': _('Checkbox parameters cannot have choices') + }) + + # Check that 'choices' are in fact valid + if self.choices is None: + self.choices = '' + else: + self.choices = str(self.choices).strip() + + if self.choices: + choice_set = set() + + for choice in self.choices.split(','): + choice = choice.strip() + + # Ignore empty choices + if not choice: + continue + + if choice in choice_set: + raise ValidationError({'choices': _('Choices must be unique')}) + + choice_set.add(choice) + + def validate_unique(self, exclude=None): + """Ensure that ParameterTemplates cannot be created with the same name. + + This test should be case-insensitive (which the unique caveat does not cover). + """ + super().validate_unique(exclude) + + try: + others = ParameterTemplate.objects.filter(name__iexact=self.name).exclude( + pk=self.pk + ) + + if others.exists(): + msg = _('Parameter template name must be unique') + raise ValidationError({'name': msg}) + except ParameterTemplate.DoesNotExist: + pass + + def get_choices(self): + """Return a list of choices for this parameter template.""" + if self.selectionlist: + return self.selectionlist.get_choices() + + if not self.choices: + return [] + + return [x.strip() for x in self.choices.split(',') if x.strip()] + + # TODO: Reintroduce validator for model_type + model_type = models.ForeignKey( + ContentType, + on_delete=models.SET_NULL, + blank=True, + null=True, + verbose_name=_('Model type'), + help_text=_('Target model type for this parameter template'), + ) + + name = models.CharField( + max_length=100, + verbose_name=_('Name'), + help_text=_('Parameter Name'), + unique=True, + ) + + units = models.CharField( + max_length=25, + verbose_name=_('Units'), + help_text=_('Physical units for this parameter'), + blank=True, + validators=[InvenTree.validators.validate_physical_units], + ) + + description = models.CharField( + max_length=250, + verbose_name=_('Description'), + help_text=_('Parameter description'), + blank=True, + ) + + checkbox = models.BooleanField( + default=False, + verbose_name=_('Checkbox'), + help_text=_('Is this parameter a checkbox?'), + ) + + choices = models.CharField( + max_length=5000, + verbose_name=_('Choices'), + help_text=_('Valid choices for this parameter (comma-separated)'), + blank=True, + ) + + selectionlist = models.ForeignKey( + SelectionList, + blank=True, + null=True, + on_delete=models.SET_NULL, + related_name='templates', + verbose_name=_('Selection List'), + help_text=_('Selection list for this parameter'), + ) + + enabled = models.BooleanField( + default=True, + verbose_name=_('Enabled'), + help_text=_('Is this parameter template enabled?'), + ) + + +@receiver( + post_save, sender=ParameterTemplate, dispatch_uid='post_save_parameter_template' +) +def post_save_parameter_template(sender, instance, created, **kwargs): + """Callback function when a ParameterTemplate is created or saved.""" + import common.tasks + + if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData(): + if not created: + # Schedule a background task to rebuild the parameters against this template + InvenTree.tasks.offload_task( + common.tasks.rebuild_parameters, + instance.pk, + force_async=True, + group='part', + ) + + +class Parameter( + UpdatedUserMixin, InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel +): + """Class which represents a parameter value assigned to a particular model instance. + + Attributes: + model_type: The type of model to which this parameter is linked + model_id: The ID of the model to which this parameter is linked + template: The ParameterTemplate which defines this parameter + data: The value of the parameter [string] + data_numeric: Numeric value of the parameter (if applicable) [float] + note: Optional note associated with this parameter [string] + updated: Date/time that this parameter was last updated + updated_by: User who last updated this parameter + """ + + class Meta: + """Meta options for Parameter model.""" + + verbose_name = _('Parameter') + verbose_name_plural = _('Parameters') + unique_together = [['model_type', 'model_id', 'template']] + indexes = [models.Index(fields=['model_type', 'model_id'])] + + # Note: Data was migrated from the existing 'part_partparameter' table + # Ref: https://github.com/inventree/InvenTree/pull/10699 + # To avoid data loss, we retain the existing table name + db_table = 'part_partparameter' + + class ModelChoices(RenderChoices): + """Model choices for parameters.""" + + choice_fnc = common.validators.parameter_model_options + + @staticmethod + def get_api_url() -> str: + """Return the API URL associated with the Parameter model.""" + return reverse('api-parameter-list') + + def save(self, *args, **kwargs): + """Custom save method for Parameter model. + + - Update the numeric data field (if applicable) + """ + self.calculate_numeric_value() + + # Convert 'boolean' values to 'True' / 'False' + if self.template.checkbox: + self.data = InvenTree.helpers.str2bool(self.data) + self.data_numeric = 1 if self.data else 0 + + self.check_save() + super().save(*args, **kwargs) + + def delete(self): + """Perform custom delete checks before deleting a Parameter instance.""" + self.check_delete() + super().delete() + + def clean(self): + """Validate the Parameter before saving to the database.""" + super().clean() + + # Validate the parameter data against the template choices + if choices := self.template.get_choices(): + if self.data not in choices: + raise ValidationError({'data': _('Invalid choice for parameter value')}) + + self.calculate_numeric_value() + + # TODO: Check that the model_type for this parameter matches the template + + # Validate the parameter data against the template units + if ( + get_global_setting( + 'PARAMETER_ENFORCE_UNITS', True, cache=False, create=False + ) + and self.template.units + ): + try: + InvenTree.conversion.convert_physical_value( + self.data, self.template.units + ) + except ValidationError as e: + raise ValidationError({'data': e.message}) + + # Finally, run custom validation checks (via plugins) + from plugin import PluginMixinEnum, registry + + for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION): + # Note: The validate_parameter function may raise a ValidationError + try: + if hasattr(plugin, 'validate_parameter'): + result = plugin.validate_parameter(self, self.data) + if result: + break + except ValidationError as exc: + # Re-throw the ValidationError against the 'data' field + raise ValidationError({'data': exc.message}) + except Exception: + InvenTree.exceptions.log_error('validate_parameter', plugin=plugin.slug) + + def calculate_numeric_value(self): + """Calculate a numeric value for the parameter data. + + - If a 'units' field is provided, then the data will be converted to the base SI unit. + - Otherwise, we'll try to do a simple float cast + """ + if self.template.units: + try: + self.data_numeric = InvenTree.conversion.convert_physical_value( + self.data, self.template.units + ) + except (ValidationError, ValueError): + self.data_numeric = None + + # No units provided, so try to cast to a float + else: + try: + self.data_numeric = float(self.data) + except ValueError: + self.data_numeric = None + + if self.data_numeric is not None and type(self.data_numeric) is float: + # Prevent out of range numbers, etc + # Ref: https://github.com/inventree/InvenTree/issues/7593 + if math.isnan(self.data_numeric) or math.isinf(self.data_numeric): + self.data_numeric = None + + def check_permission(self, permission, user): + """Check if the user has the required permission for this parameter.""" + from InvenTree.models import InvenTreeParameterMixin + + model_class = self.model_type.model_class() + + if not issubclass(model_class, InvenTreeParameterMixin): + raise ValidationError(_('Invalid model type specified for parameter')) + + return model_class.check_related_permission(permission, user) + + def check_save(self): + """Check if this parameter can be saved. + + The linked content_object can implement custom checks by overriding + the 'check_parameter_edit' method. + """ + from InvenTree.models import InvenTreeParameterMixin + + try: + instance = self.content_object + except InvenTree.models.InvenTreeModel.DoesNotExist: + return + + if instance and isinstance(instance, InvenTreeParameterMixin): + instance.check_parameter_save(self) + + def check_delete(self): + """Check if this parameter can be deleted.""" + from InvenTree.models import InvenTreeParameterMixin + + try: + instance = self.content_object + except InvenTree.models.InvenTreeModel.DoesNotExist: + return + + if instance and isinstance(instance, InvenTreeParameterMixin): + instance.check_parameter_delete(self) + + # TODO: Reintroduce validator for model_type + model_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + + model_id = models.PositiveIntegerField( + verbose_name=_('Model ID'), + help_text=_('ID of the target model for this parameter'), + ) + + content_object = GenericForeignKey('model_type', 'model_id') + + template = models.ForeignKey( + ParameterTemplate, + on_delete=models.CASCADE, + related_name='parameters', + verbose_name=_('Template'), + help_text=_('Parameter template'), + ) + + data = models.CharField( + max_length=500, + verbose_name=_('Data'), + help_text=_('Parameter Value'), + validators=[MinLengthValidator(1)], + ) + + data_numeric = models.FloatField(default=None, null=True, blank=True) + + note = models.CharField( + max_length=500, + blank=True, + verbose_name=_('Note'), + help_text=_('Optional note field'), + ) + + @property + def units(self): + """Return the units associated with the template.""" + return self.template.units + + @property + def name(self): + """Return the name of the template.""" + return self.template.name + + @property + def description(self): + """Return the description of the template.""" + return self.template.description + + class BarcodeScanResult(InvenTree.models.InvenTreeModel): """Model for storing barcode scans results.""" diff --git a/src/backend/InvenTree/common/serializers.py b/src/backend/InvenTree/common/serializers.py index f45e652a90..49c777b476 100644 --- a/src/backend/InvenTree/common/serializers.py +++ b/src/backend/InvenTree/common/serializers.py @@ -21,10 +21,14 @@ from importer.registry import register_importer from InvenTree.helpers import get_objectreference from InvenTree.helpers_model import construct_absolute_url from InvenTree.mixins import DataImportExportSerializerMixin +from InvenTree.models import InvenTreeParameterMixin from InvenTree.serializers import ( + ContentTypeField, + FilterableSerializerMixin, InvenTreeAttachmentSerializerField, InvenTreeImageSerializerField, InvenTreeModelSerializer, + enable_filter, ) from plugin import registry as plugin_registry from users.serializers import OwnerSerializer, UserSerializer @@ -691,12 +695,127 @@ class AttachmentSerializer(InvenTreeModelSerializer): raise PermissionDenied(permission_error_msg) # Check that the user has the required permissions to attach files to the target model - if not target_model_class.check_attachment_permission('change', user): - raise PermissionDenied(_(permission_error_msg)) + if not target_model_class.check_related_permission('change', user): + raise PermissionDenied(permission_error_msg) return super().save(**kwargs) +@register_importer() +class ParameterTemplateSerializer( + DataImportExportSerializerMixin, InvenTreeModelSerializer +): + """Serializer for the ParameterTemplate model.""" + + class Meta: + """Meta options for ParameterTemplateSerializer.""" + + model = common_models.ParameterTemplate + fields = [ + 'pk', + 'name', + 'units', + 'description', + 'model_type', + 'checkbox', + 'choices', + 'selectionlist', + 'enabled', + ] + + # Note: The choices are overridden at run-time on class initialization + model_type = ContentTypeField( + mixin_class=InvenTreeParameterMixin, + choices=common.validators.parameter_template_model_options, + label=_('Model Type'), + default='', + required=False, + allow_null=True, + ) + + def validate_model_type(self, model_type): + """Convert an empty string to None for the model_type field.""" + return model_type or None + + +@register_importer() +class ParameterSerializer( + FilterableSerializerMixin, DataImportExportSerializerMixin, InvenTreeModelSerializer +): + """Serializer for the Parameter model.""" + + class Meta: + """Meta options for ParameterSerializer.""" + + model = common_models.Parameter + fields = [ + 'pk', + 'template', + 'model_type', + 'model_id', + 'data', + 'data_numeric', + 'note', + 'updated', + 'updated_by', + 'template_detail', + 'updated_by_detail', + ] + + read_only_fields = ['updated', 'updated_by'] + + def save(self, **kwargs): + """Save the Parameter instance.""" + from InvenTree.models import InvenTreeParameterMixin + from users.permissions import check_user_permission + + model_type = self.validated_data.get('model_type', None) + + if model_type is None and self.instance: + model_type = self.instance.model_type + + # Ensure that the user has permission to modify parameters for the specified model + user = self.context.get('request').user + + target_model_class = model_type.model_class() + + if not issubclass(target_model_class, InvenTreeParameterMixin): + raise PermissionDenied(_('Invalid model type specified for parameter')) + + permission_error_msg = _( + 'User does not have permission to create or edit parameters for this model' + ) + + if not check_user_permission(user, target_model_class, 'change'): + raise PermissionDenied(permission_error_msg) + + if not target_model_class.check_related_permission('change', user): + raise PermissionDenied(permission_error_msg) + + instance = super().save(**kwargs) + instance.updated_by = user + instance.save() + + return instance + + # Note: The choices are overridden at run-time on class initialization + model_type = ContentTypeField( + mixin_class=InvenTreeParameterMixin, + choices=common.validators.parameter_model_options, + label=_('Model Type'), + default='', + allow_null=False, + ) + + updated_by_detail = enable_filter( + UserSerializer(source='updated_by', read_only=True, many=False), True + ) + + template_detail = enable_filter( + ParameterTemplateSerializer(source='template', read_only=True, many=False), True + ) + + class IconSerializer(serializers.Serializer): """Serializer for an icon.""" diff --git a/src/backend/InvenTree/common/setting/system.py b/src/backend/InvenTree/common/setting/system.py index 49d06e30b4..087e494400 100644 --- a/src/backend/InvenTree/common/setting/system.py +++ b/src/backend/InvenTree/common/setting/system.py @@ -521,14 +521,6 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { 'default': '', 'validator': common.validators.validate_icon, }, - 'PART_PARAMETER_ENFORCE_UNITS': { - 'name': _('Enforce Parameter Units'), - 'description': _( - 'If units are provided, parameter values must match the specified units' - ), - 'default': True, - 'validator': bool, - }, 'PRICING_DECIMAL_PLACES_MIN': { 'name': _('Minimum Pricing Decimal Places'), 'description': _( @@ -669,6 +661,14 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { 'default': 'A4', 'choices': report.helpers.report_page_size_options, }, + 'PARAMETER_ENFORCE_UNITS': { + 'name': _('Enforce Parameter Units'), + 'description': _( + 'If units are provided, parameter values must match the specified units' + ), + 'default': True, + 'validator': bool, + }, 'SERIAL_NUMBER_GLOBALLY_UNIQUE': { 'name': _('Globally Unique Serials'), 'description': _('Serial numbers for stock items must be globally unique'), diff --git a/src/backend/InvenTree/common/tasks.py b/src/backend/InvenTree/common/tasks.py index a718c2e201..6a86367937 100644 --- a/src/backend/InvenTree/common/tasks.py +++ b/src/backend/InvenTree/common/tasks.py @@ -172,3 +172,35 @@ def delete_old_notes_images(): if not found: logger.info('Deleting note %s - image file not linked to a note', image) os.remove(os.path.join(notes_dir, image)) + + +@tracer.start_as_current_span('rebuild_parameters') +def rebuild_parameters(template_id): + """Rebuild all parameters for a given template. + + This function is called when a base template is changed, + which may cause the base unit to be adjusted. + """ + from common.models import Parameter, ParameterTemplate + + try: + template = ParameterTemplate.objects.get(pk=template_id) + except ParameterTemplate.DoesNotExist: + return + + parameters = Parameter.objects.filter(template=template) + + n = 0 + + for parameter in parameters: + # Update the parameter if the numeric value has changed + value_old = parameter.data_numeric + parameter.calculate_numeric_value() + + if value_old != parameter.data_numeric: + parameter.full_clean() + parameter.save() + n += 1 + + if n > 0: + logger.info("Rebuilt %s parameters for template '%s'", n, template.name) diff --git a/src/backend/InvenTree/common/test_api.py b/src/backend/InvenTree/common/test_api.py new file mode 100644 index 0000000000..4e77eacf85 --- /dev/null +++ b/src/backend/InvenTree/common/test_api.py @@ -0,0 +1,428 @@ +"""API unit tests for InvenTree common functionality.""" + +from django.urls import reverse + +import common.models +from InvenTree.unit_test import InvenTreeAPITestCase + + +class ParameterAPITests(InvenTreeAPITestCase): + """Tests for the Parameter API.""" + + roles = 'all' + + def test_template_options(self): + """Test OPTIONS information for the ParameterTemplate API endpoint.""" + url = reverse('api-parameter-template-list') + + options = self.options(url) + actions = options.data['actions']['GET'] + + for field in [ + 'pk', + 'name', + 'units', + 'description', + 'model_type', + 'selectionlist', + 'enabled', + ]: + self.assertIn( + field, + actions.keys(), + f'Field "{field}" missing from ParameterTemplate API!', + ) + + model_types = [act['value'] for act in actions['model_type']['choices']] + + for mdl in [ + 'part.part', + 'build.build', + 'company.company', + 'order.purchaseorder', + ]: + self.assertIn( + mdl, + model_types, + f'Model type "{mdl}" missing from ParameterTemplate API!', + ) + + def test_parameter_options(self): + """Test OPTIONS information for the Parameter API endpoint.""" + url = reverse('api-parameter-list') + + options = self.options(url) + actions = options.data['actions']['GET'] + + for field in [ + 'pk', + 'template', + 'model_type', + 'model_id', + 'data', + 'data_numeric', + ]: + self.assertIn( + field, actions.keys(), f'Field "{field}" missing from Parameter API!' + ) + + self.assertFalse(actions['data']['read_only']) + self.assertFalse(actions['model_type']['read_only']) + + def test_template_api(self): + """Test ParameterTemplate API functionality.""" + url = reverse('api-parameter-template-list') + + N = common.models.ParameterTemplate.objects.count() + + # Create a new ParameterTemplate - initially with invalid model_type field + data = { + 'name': 'Test Parameter', + 'units': 'mm', + 'description': 'A test parameter template', + 'model_type': 'order.salesorderx', + 'enabled': True, + } + + response = self.post(url, data, expected_code=400) + self.assertIn('Content type not found', str(response.data['model_type'])) + + data['model_type'] = 'order.salesorder' + + response = self.post(url, data, expected_code=201) + pk = response.data['pk'] + + # Verify that the ParameterTemplate was created + self.assertEqual(common.models.ParameterTemplate.objects.count(), N + 1) + + template = common.models.ParameterTemplate.objects.get(pk=pk) + self.assertEqual(template.name, 'Test Parameter') + self.assertEqual(template.description, 'A test parameter template') + self.assertEqual(template.units, 'mm') + + # Let's update the Template via the API + data = {'description': 'An UPDATED test parameter template'} + + response = self.patch( + reverse('api-parameter-template-detail', kwargs={'pk': pk}), + data, + expected_code=200, + ) + + template.refresh_from_db() + self.assertEqual(template.description, 'An UPDATED test parameter template') + + # Finally, let's delete the Template + response = self.delete( + reverse('api-parameter-template-detail', kwargs={'pk': pk}), + expected_code=204, + ) + + self.assertEqual(common.models.ParameterTemplate.objects.count(), N) + self.assertFalse(common.models.ParameterTemplate.objects.filter(pk=pk).exists()) + + # Let's create a template which does not specify a model_type + data = { + 'name': 'Universal Parameter', + 'units': '', + 'description': 'A parameter template for all models', + 'enabled': False, + } + + response = self.post(url, data, expected_code=201) + + self.assertIsNone(response.data['model_type']) + self.assertFalse(response.data['enabled']) + + def test_template_filters(self): + """Tests for API filters against ParameterTemplate endpoint.""" + from company.models import Company + + # Create some ParameterTemplate objects + t1 = common.models.ParameterTemplate.objects.create( + name='Template A', + description='Template with choices', + choices='apple,banana,cherry', + enabled=True, + ) + + t2 = common.models.ParameterTemplate.objects.create( + name='Template B', + description='Template without choices', + enabled=True, + units='mm', + model_type=Company.get_content_type(), + ) + + t3 = common.models.ParameterTemplate.objects.create( + name='Template C', description='Another template', enabled=False + ) + + url = reverse('api-parameter-template-list') + + # Filter by 'enabled' status + response = self.get(url, data={'enabled': True}) + self.assertEqual(len(response.data), 2) + + response = self.get(url, data={'enabled': False}) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]['pk'], t3.pk) + + # Filter by 'has_choices' + response = self.get(url, data={'has_choices': True}) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]['pk'], t1.pk) + + response = self.get(url, data={'has_choices': False}) + self.assertEqual(len(response.data), 2) + + # Filter by 'model_type' + response = self.get(url, data={'model_type': 'company.company'}) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]['pk'], t2.pk) + + # Filter by 'has_units' + response = self.get(url, data={'has_units': True}) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]['pk'], t2.pk) + + response = self.get(url, data={'has_units': False}) + self.assertEqual(len(response.data), 2) + + # Filter by 'for_model' + # Note that a 'blank' model_type is considered to match all models + response = self.get(url, data={'for_model': 'part.part'}) + self.assertEqual(len(response.data), 2) + + response = self.get(url, data={'for_model': 'company'}) + self.assertEqual(len(response.data), 3) + + # Create a Parameter against a specific Company instance + company = Company.objects.create( + name='Test Company', description='A company for testing' + ) + + common.models.Parameter.objects.create( + template=t1, + model_type=company.get_content_type(), + model_id=company.pk, + data='apple', + ) + + model_types = {'company': 3, 'part.part': 2, 'order.purchaseorder': 2} + + for model_name, count in model_types.items(): + response = self.get(url, data={'for_model': model_name}) + self.assertEqual( + len(response.data), + count, + f'Incorrect number of templates for model "{model_name}"', + ) + + # Filter with an invalid 'for_model' + response = self.get( + url, data={'for_model': 'invalid.modelname'}, expected_code=400 + ) + + self.assertIn('Invalid content type: invalid.modelname', str(response.data)) + + # Filter the "exists for model" filter + model_types = {'company': 1, 'part.part': 0, 'order.purchaseorder': 0} + + for model_name, count in model_types.items(): + response = self.get(url, data={'exists_for_model': model_name}) + self.assertEqual( + len(response.data), + count, + f'Incorrect number of templates for model "{model_name}"', + ) + + def test_parameter_api(self): + """Test Parameter API functionality.""" + # Create a simple part to test with + from part.models import Part + + part = Part.objects.create(name='Test Part', description='A part for testing') + + N = common.models.Parameter.objects.count() + + # Create a ParameterTemplate for the Part model + template = common.models.ParameterTemplate.objects.create( + name='Length', + units='mm', + model_type=part.get_content_type(), + description='Length of part', + enabled=True, + ) + + # Create a Parameter via the API + url = reverse('api-parameter-list') + + data = { + 'template': template.pk, + 'model_type': 'part.part', + 'model_id': part.pk, + 'data': '25.4', + } + + # Initially, user does not have correct permissions + response = self.post(url, data=data, expected_code=403) + + self.assertIn( + 'User does not have permission to create or edit parameters for this model', + str(response.data['detail']), + ) + + # Grant user the correct permissions + self.assignRole('part.add') + + response = self.post(url, data=data, expected_code=201) + + parameter = common.models.Parameter.objects.get(pk=response.data['pk']) + + # Check that the Parameter was created + self.assertEqual(common.models.Parameter.objects.count(), N + 1) + + # Try to create a duplicate Parameter (should fail) + response = self.post(url, data=data, expected_code=400) + + self.assertIn( + 'The fields model_type, model_id, template must make a unique set.', + str(response.data['non_field_errors']), + ) + + # Let's edit the Parameter via the API + url = reverse('api-parameter-detail', kwargs={'pk': parameter.pk}) + + response = self.patch(url, data={'data': '-2 inches'}, expected_code=200) + + # Ensure parameter conversion has correctly updated data_numeric field + data = response.data + self.assertEqual(data['data'], '-2 inches') + self.assertAlmostEqual(data['data_numeric'], -50.8, places=2) + + # Finally, delete the Parameter via the API + response = self.delete(url, expected_code=204) + + self.assertEqual(common.models.Parameter.objects.count(), N) + self.assertFalse( + common.models.Parameter.objects.filter(pk=parameter.pk).exists() + ) + + def test_parameter_annotation(self): + """Test that we can annotate parameters against a queryset.""" + from company.models import Company + + templates = [] + parameters = [] + companies = [] + + for ii in range(100): + company = Company( + name=f'Test Company {ii}', + description='A company for testing parameter annotations', + ) + companies.append(company) + + Company.objects.bulk_create(companies) + + # Let's create a large number of parameters + for ii in range(25): + templates.append( + common.models.ParameterTemplate( + name=f'Test Parameter {ii}', + units='', + description='A parameter for testing annotations', + model_type=Company.get_content_type(), + enabled=True, + ) + ) + + common.models.ParameterTemplate.objects.bulk_create(templates) + + # Create a parameter for every company against every template + for company in Company.objects.all(): + for template in common.models.ParameterTemplate.objects.all(): + parameters.append( + common.models.Parameter( + template=template, + model_type=company.get_content_type(), + model_id=company.pk, + data=f'Test data for {company.name} - {template.name}', + ) + ) + + common.models.Parameter.objects.bulk_create(parameters) + + self.assertEqual( + common.models.Parameter.objects.count(), len(companies) * len(templates) + ) + + # We will fetch the companies, annotated with all parameters + url = reverse('api-company-list') + + # By default, we do not expect any parameter annotations + response = self.get(url, data={'limit': 5}) + + self.assertEqual(response.data['count'], len(companies)) + for company in response.data['results']: + self.assertNotIn('parameters', company) + + # Fetch all companies, explicitly without parameters + with self.assertNumQueriesLessThan(20): + response = self.get(url, data={'parameters': False}) + + # Now, annotate with parameters + # This must be done efficiently, without an 1 + N query pattern + with self.assertNumQueriesLessThan(45): + response = self.get(url, data={'parameters': True}) + + self.assertEqual(len(response.data), len(companies)) + + for company in response.data: + self.assertIn('parameters', company) + self.assertEqual( + len(company['parameters']), + len(templates), + 'Incorrect number of parameter annotations found', + ) + + def test_parameter_delete(self): + """Test that associated parameters are correctly deleted when removing the linked model.""" + from part.models import Part + + part = Part.objects.create( + name='Test Part', description='A part for testing', active=False + ) + + # Create a ParameterTemplate for the Part model + template = common.models.ParameterTemplate.objects.create( + name='Test Parameter', + description='A parameter template for testing parameter deletion', + model_type=None, + ) + + # Create a Parameter for the Build + parameter = common.models.Parameter.objects.create( + template=template, + model_type=part.get_content_type(), + model_id=part.pk, + data='Test data', + ) + + self.assertTrue( + common.models.Parameter.objects.filter(pk=parameter.pk).exists() + ) + + N = common.models.Parameter.objects.count() + + # Now delete the part instance + self.assignRole('part.delete') + self.delete( + reverse('api-part-detail', kwargs={'pk': part.pk}), expected_code=204 + ) + + self.assertEqual(common.models.Parameter.objects.count(), N - 1) + self.assertFalse( + common.models.Parameter.objects.filter(template=template.pk).exists() + ) diff --git a/src/backend/InvenTree/common/test_migrations.py b/src/backend/InvenTree/common/test_migrations.py index 74b899f3f8..6c21dc1074 100644 --- a/src/backend/InvenTree/common/test_migrations.py +++ b/src/backend/InvenTree/common/test_migrations.py @@ -7,8 +7,6 @@ from django.core.files.base import ContentFile from django_test_migrations.contrib.unittest_case import MigratorTestCase -from InvenTree import unit_test - def get_legacy_models(): """Return a set of legacy attachment models.""" @@ -43,7 +41,7 @@ class TestForwardMigrations(MigratorTestCase): """Test entire schema migration sequence for the common app.""" migrate_from = ('common', '0024_notesimage_model_id_notesimage_model_type') - migrate_to = ('common', unit_test.getNewestMigrationFile('common')) + migrate_to = ('common', '0039_emailthread_emailmessage') def prepare(self): """Create initial data. diff --git a/src/backend/InvenTree/common/tests.py b/src/backend/InvenTree/common/tests.py index 52a1eeca95..4cc16136bd 100644 --- a/src/backend/InvenTree/common/tests.py +++ b/src/backend/InvenTree/common/tests.py @@ -32,7 +32,7 @@ from InvenTree.unit_test import ( PluginMixin, addUserPermission, ) -from part.models import Part, PartParameterTemplate +from part.models import Part from plugin import registry from .api import WebhookView @@ -45,6 +45,7 @@ from .models import ( NotesImage, NotificationEntry, NotificationMessage, + ParameterTemplate, ProjectCode, SelectionList, SelectionListEntry, @@ -2055,27 +2056,37 @@ class SelectionListTest(InvenTreeAPITestCase): # Add to parameter part = Part.objects.get(pk=1) - template = PartParameterTemplate.objects.create( + template = ParameterTemplate.objects.create( name='test_parameter', units='', selectionlist=self.list ) rsp = self.get( - reverse('api-part-parameter-template-detail', kwargs={'pk': template.pk}) + reverse('api-parameter-template-detail', kwargs={'pk': template.pk}) ) self.assertEqual(rsp.data['name'], 'test_parameter') self.assertEqual(rsp.data['choices'], '') # Add to part - url = reverse('api-part-parameter-list') + url = reverse('api-parameter-list') response = self.post( url, - {'part': part.pk, 'template': template.pk, 'data': 70}, + { + 'model_id': part.pk, + 'model_type': 'part.part', + 'template': template.pk, + 'data': 70, + }, expected_code=400, ) self.assertIn('Invalid choice for parameter value', response.data['data']) response = self.post( url, - {'part': part.pk, 'template': template.pk, 'data': self.entry1.value}, + { + 'model_id': part.pk, + 'model_type': 'part.part', + 'template': template.pk, + 'data': self.entry1.value, + }, expected_code=201, ) self.assertEqual(response.data['data'], self.entry1.value) diff --git a/src/backend/InvenTree/common/validators.py b/src/backend/InvenTree/common/validators.py index 056bc4ca1b..02c3805f96 100644 --- a/src/backend/InvenTree/common/validators.py +++ b/src/backend/InvenTree/common/validators.py @@ -9,6 +9,35 @@ import common.icons from common.settings import get_global_setting +def parameter_model_types(): + """Return a list of valid parameter model choices.""" + import InvenTree.models + + return list( + InvenTree.helpers_model.getModelsWithMixin( + InvenTree.models.InvenTreeParameterMixin + ) + ) + + +def parameter_model_options(): + """Return a list of options for models which support parameters.""" + return [ + (model.__name__.lower(), model._meta.verbose_name) + for model in parameter_model_types() + ] + + +def parameter_template_model_options(): + """Return a list of options for models which support parameter templates.""" + options = [ + (model.__name__.lower(), model._meta.verbose_name) + for model in parameter_model_types() + ] + + return [(None, _('All models')), *options] + + def attachment_model_types(): """Return a list of valid attachment model choices.""" import InvenTree.models diff --git a/src/backend/InvenTree/company/admin.py b/src/backend/InvenTree/company/admin.py index 64610709af..b35218ad1e 100644 --- a/src/backend/InvenTree/company/admin.py +++ b/src/backend/InvenTree/company/admin.py @@ -9,7 +9,6 @@ from .models import ( Company, Contact, ManufacturerPart, - ManufacturerPartParameter, SupplierPart, SupplierPriceBreak, ) @@ -56,17 +55,6 @@ class ManufacturerPartAdmin(admin.ModelAdmin): autocomplete_fields = ('part', 'manufacturer') -@admin.register(ManufacturerPartParameter) -class ManufacturerPartParameterAdmin(admin.ModelAdmin): - """Admin class for ManufacturerPartParameter model.""" - - list_display = ('manufacturer_part', 'name', 'value') - - search_fields = ['manufacturer_part__manufacturer__name', 'name', 'value'] - - autocomplete_fields = ('manufacturer_part',) - - @admin.register(SupplierPriceBreak) class SupplierPriceBreakAdmin(admin.ModelAdmin): """Admin class for the SupplierPriceBreak model.""" diff --git a/src/backend/InvenTree/company/api.py b/src/backend/InvenTree/company/api.py index 42a58fef79..15406ca71a 100644 --- a/src/backend/InvenTree/company/api.py +++ b/src/backend/InvenTree/company/api.py @@ -9,7 +9,7 @@ from django_filters.rest_framework.filterset import FilterSet import part.models from data_exporter.mixins import DataExportViewMixin -from InvenTree.api import ListCreateDestroyAPIView, MetadataView +from InvenTree.api import ListCreateDestroyAPIView, MetadataView, ParameterListMixin from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration from InvenTree.filters import SEARCH_ORDER_FILTER, SEARCH_ORDER_FILTER_ALIAS from InvenTree.mixins import ( @@ -24,7 +24,6 @@ from .models import ( Company, Contact, ManufacturerPart, - ManufacturerPartParameter, SupplierPart, SupplierPriceBreak, ) @@ -32,14 +31,27 @@ from .serializers import ( AddressSerializer, CompanySerializer, ContactSerializer, - ManufacturerPartParameterSerializer, ManufacturerPartSerializer, SupplierPartSerializer, SupplierPriceBreakSerializer, ) -class CompanyList(DataExportViewMixin, ListCreateAPI): +class CompanyMixin(OutputOptionsMixin): + """Mixin class for Company API endpoints.""" + + queryset = Company.objects.all() + serializer_class = CompanySerializer + + def get_queryset(self): + """Return annotated queryset for the company endpoints.""" + queryset = super().get_queryset() + queryset = CompanySerializer.annotate_queryset(queryset) + + return queryset + + +class CompanyList(CompanyMixin, ParameterListMixin, DataExportViewMixin, ListCreateAPI): """API endpoint for accessing a list of Company objects. Provides two methods: @@ -48,14 +60,6 @@ class CompanyList(DataExportViewMixin, ListCreateAPI): - POST: Create a new Company object """ - serializer_class = CompanySerializer - queryset = Company.objects.all() - - def get_queryset(self): - """Return annotated queryset for the company list endpoint.""" - queryset = super().get_queryset() - return CompanySerializer.annotate_queryset(queryset) - filter_backends = SEARCH_ORDER_FILTER filterset_fields = [ @@ -73,19 +77,9 @@ class CompanyList(DataExportViewMixin, ListCreateAPI): ordering = 'name' -class CompanyDetail(RetrieveUpdateDestroyAPI): +class CompanyDetail(CompanyMixin, RetrieveUpdateDestroyAPI): """API endpoint for detail of a single Company object.""" - queryset = Company.objects.all() - serializer_class = CompanySerializer - - def get_queryset(self): - """Return annotated queryset for the company detail endpoint.""" - queryset = super().get_queryset() - queryset = CompanySerializer.annotate_queryset(queryset) - - return queryset - class ContactList(DataExportViewMixin, ListCreateDestroyAPIView): """API endpoint for list view of Company model.""" @@ -174,10 +168,30 @@ class ManufacturerOutputOptions(OutputConfiguration): ] +class ManufacturerPartMixin(SerializerContextMixin): + """Mixin class for ManufacturerPart API endpoints.""" + + queryset = ManufacturerPart.objects.all() + serializer_class = ManufacturerPartSerializer + + def get_queryset(self, *args, **kwargs): + """Return annotated queryset for the ManufacturerPart list endpoint.""" + queryset = super().get_queryset(*args, **kwargs) + + queryset = queryset.prefetch_related( + 'part', 'manufacturer', 'supplier_parts', 'tags' + ) + + queryset = ManufacturerPart.annotate_parameters(queryset) + + return queryset + + class ManufacturerPartList( + ManufacturerPartMixin, SerializerContextMixin, - DataExportViewMixin, OutputOptionsMixin, + ParameterListMixin, ListCreateDestroyAPIView, ): """API endpoint for list view of ManufacturerPart object. @@ -186,13 +200,10 @@ class ManufacturerPartList( - POST: Create a new ManufacturerPart object """ - queryset = ManufacturerPart.objects.all().prefetch_related( - 'part', 'manufacturer', 'supplier_parts', 'tags' - ) - serializer_class = ManufacturerPartSerializer filterset_class = ManufacturerPartFilter - output_options = ManufacturerOutputOptions filter_backends = SEARCH_ORDER_FILTER + output_options = ManufacturerOutputOptions + search_fields = [ 'manufacturer__name', 'description', @@ -205,7 +216,9 @@ class ManufacturerPartList( ] -class ManufacturerPartDetail(RetrieveUpdateDestroyAPI): +class ManufacturerPartDetail( + ManufacturerPartMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI +): """API endpoint for detail view of ManufacturerPart object. - GET: Retrieve detail view @@ -213,59 +226,6 @@ class ManufacturerPartDetail(RetrieveUpdateDestroyAPI): - DELETE: Delete object """ - queryset = ManufacturerPart.objects.all() - serializer_class = ManufacturerPartSerializer - - -class ManufacturerPartParameterFilter(FilterSet): - """Custom filterset for the ManufacturerPartParameterList API endpoint.""" - - class Meta: - """Metaclass options.""" - - model = ManufacturerPartParameter - fields = ['name', 'value', 'units', 'manufacturer_part'] - - manufacturer = rest_filters.ModelChoiceFilter( - queryset=Company.objects.all(), field_name='manufacturer_part__manufacturer' - ) - - part = rest_filters.ModelChoiceFilter( - queryset=part.models.Part.objects.all(), field_name='manufacturer_part__part' - ) - - -class ManufacturerPartParameterOptions(OutputConfiguration): - """Available output options for the ManufacturerPartParameter endpoints.""" - - OPTIONS = [ - InvenTreeOutputOption( - description='Include detailed information about the linked ManufacturerPart in the response', - flag='manufacturer_part_detail', - default=False, - ) - ] - - -class ManufacturerPartParameterList( - SerializerContextMixin, ListCreateDestroyAPIView, OutputOptionsMixin -): - """API endpoint for list view of ManufacturerPartParamater model.""" - - queryset = ManufacturerPartParameter.objects.all() - serializer_class = ManufacturerPartParameterSerializer - filterset_class = ManufacturerPartParameterFilter - output_options = ManufacturerPartParameterOptions - filter_backends = SEARCH_ORDER_FILTER - search_fields = ['name', 'value', 'units'] - - -class ManufacturerPartParameterDetail(RetrieveUpdateDestroyAPI): - """API endpoint for detail view of ManufacturerPartParameter model.""" - - queryset = ManufacturerPartParameter.objects.all() - serializer_class = ManufacturerPartParameterSerializer - class SupplierPartFilter(FilterSet): """API filters for the SupplierPartList endpoint.""" @@ -378,7 +338,11 @@ class SupplierPartMixin: class SupplierPartList( - DataExportViewMixin, SupplierPartMixin, OutputOptionsMixin, ListCreateDestroyAPIView + DataExportViewMixin, + SupplierPartMixin, + ParameterListMixin, + OutputOptionsMixin, + ListCreateDestroyAPIView, ): """API endpoint for list view of SupplierPart object. @@ -387,7 +351,6 @@ class SupplierPartList( """ filterset_class = SupplierPartFilter - filter_backends = SEARCH_ORDER_FILTER_ALIAS output_options = SupplierPartOutputOptions @@ -518,22 +481,6 @@ class SupplierPriceBreakDetail(SupplierPriceBreakMixin, RetrieveUpdateDestroyAPI manufacturer_part_api_urls = [ - path( - 'parameter/', - include([ - path( - '/', - ManufacturerPartParameterDetail.as_view(), - name='api-manufacturer-part-parameter-detail', - ), - # Catch anything else - path( - '', - ManufacturerPartParameterList.as_view(), - name='api-manufacturer-part-parameter-list', - ), - ]), - ), path( '/', include([ diff --git a/src/backend/InvenTree/company/migrations/0077_delete_manufacturerpartparameter.py b/src/backend/InvenTree/company/migrations/0077_delete_manufacturerpartparameter.py new file mode 100644 index 0000000000..0b39842dc3 --- /dev/null +++ b/src/backend/InvenTree/company/migrations/0077_delete_manufacturerpartparameter.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.8 on 2025-11-25 07:03 + +from django.db import migrations + + +class Migration(migrations.Migration): + """Remove the ManufacturerPartParameter model. + + The data has been migrated to the common.Parameter model in a previous migration. + """ + + dependencies = [ + ("company", "0076_alter_company_image"), + ("common", "0041_auto_20251203_1244"), + ("part", "0146_auto_20251203_1241") + ] + + operations = [ + migrations.DeleteModel( + name="ManufacturerPartParameter", + ), + ] diff --git a/src/backend/InvenTree/company/models.py b/src/backend/InvenTree/company/models.py index 013a6b011b..fbe8ccdd01 100644 --- a/src/backend/InvenTree/company/models.py +++ b/src/backend/InvenTree/company/models.py @@ -77,6 +77,7 @@ class CompanyReportContext(report.mixins.BaseReportContext): class Company( InvenTree.models.InvenTreeAttachmentMixin, + InvenTree.models.InvenTreeParameterMixin, InvenTree.models.InvenTreeNotesMixin, report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeImageMixin, @@ -472,6 +473,7 @@ class Address(InvenTree.models.InvenTreeModel): class ManufacturerPart( InvenTree.models.InvenTreeAttachmentMixin, + InvenTree.models.InvenTreeParameterMixin, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNotesMixin, InvenTree.models.InvenTreeMetadataModel, @@ -583,55 +585,6 @@ class ManufacturerPart( return s -class ManufacturerPartParameter(InvenTree.models.InvenTreeModel): - """A ManufacturerPartParameter represents a key:value parameter for a MnaufacturerPart. - - This is used to represent parameters / properties for a particular manufacturer part. - - Each parameter is a simple string (text) value. - """ - - class Meta: - """Metaclass defines extra model options.""" - - verbose_name = _('Manufacturer Part Parameter') - unique_together = ('manufacturer_part', 'name') - - @staticmethod - def get_api_url(): - """Return the API URL associated with the ManufacturerPartParameter model.""" - return reverse('api-manufacturer-part-parameter-list') - - manufacturer_part = models.ForeignKey( - ManufacturerPart, - on_delete=models.CASCADE, - related_name='parameters', - verbose_name=_('Manufacturer Part'), - ) - - name = models.CharField( - max_length=500, - blank=False, - verbose_name=_('Name'), - help_text=_('Parameter name'), - ) - - value = models.CharField( - max_length=500, - blank=False, - verbose_name=_('Value'), - help_text=_('Parameter value'), - ) - - units = models.CharField( - max_length=64, - blank=True, - null=True, - verbose_name=_('Units'), - help_text=_('Parameter units'), - ) - - class SupplierPartManager(models.Manager): """Define custom SupplierPart objects manager. @@ -651,6 +604,7 @@ class SupplierPartManager(models.Manager): class SupplierPart( InvenTree.models.InvenTreeAttachmentMixin, + InvenTree.models.InvenTreeParameterMixin, InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNotesMixin, diff --git a/src/backend/InvenTree/company/serializers.py b/src/backend/InvenTree/company/serializers.py index 5488abbf43..ba68d4a42b 100644 --- a/src/backend/InvenTree/company/serializers.py +++ b/src/backend/InvenTree/company/serializers.py @@ -11,6 +11,7 @@ from rest_framework import serializers from sql_util.utils import SubqueryCount from taggit.serializers import TagListSerializerField +import common.serializers import company.filters import part.filters import part.serializers as part_serializers @@ -36,7 +37,6 @@ from .models import ( Company, Contact, ManufacturerPart, - ManufacturerPartParameter, SupplierPart, SupplierPriceBreak, ) @@ -113,6 +113,7 @@ class AddressBriefSerializer(InvenTreeModelSerializer): @register_importer() class CompanySerializer( + FilterableSerializerMixin, DataImportExportSerializerMixin, NotesFieldMixin, RemoteImageMixin, @@ -152,6 +153,7 @@ class CompanySerializer( 'address_count', 'primary_address', 'tax_id', + 'parameters', ] @staticmethod @@ -174,14 +176,18 @@ class CompanySerializer( ) ) + queryset = Company.annotate_parameters(queryset) + return queryset address = serializers.SerializerMethodField( - label=_( + label=_('Primary Address'), + help_text=_( 'Return the string representation for the primary address. This property exists for backwards compatibility.' ), allow_null=True, ) + primary_address = serializers.SerializerMethodField(allow_null=True) @extend_schema_field(serializers.CharField()) @@ -212,6 +218,12 @@ class CompanySerializer( help_text=_('Default currency used for this supplier'), required=True ) + parameters = enable_filter( + common.serializers.ParameterSerializer(many=True, read_only=True), + False, + filter_name='parameters', + ) + def save(self): """Save the Company instance.""" super().save() @@ -275,10 +287,17 @@ class ManufacturerPartSerializer( 'barcode_hash', 'notes', 'tags', + 'parameters', ] tags = TagListSerializerField(required=False) + parameters = enable_filter( + common.serializers.ParameterSerializer(many=True, read_only=True), + False, + filter_name='parameters', + ) + part_detail = enable_filter( part_serializers.PartBriefSerializer( source='part', many=False, read_only=True, allow_null=True @@ -302,33 +321,6 @@ class ManufacturerPartSerializer( ) -@register_importer() -class ManufacturerPartParameterSerializer( - FilterableSerializerMixin, DataImportExportSerializerMixin, InvenTreeModelSerializer -): - """Serializer for the ManufacturerPartParameter model.""" - - class Meta: - """Metaclass options.""" - - model = ManufacturerPartParameter - - fields = [ - 'pk', - 'manufacturer_part', - 'manufacturer_part_detail', - 'name', - 'value', - 'units', - ] - - manufacturer_part_detail = enable_filter( - ManufacturerPartSerializer( - source='manufacturer_part', many=False, read_only=True, allow_null=True - ) - ) - - class SupplierPriceBreakBriefSerializer( FilterableSerializerMixin, InvenTreeModelSerializer ): @@ -415,6 +407,7 @@ class SupplierPartSerializer( 'part_detail', 'tags', 'price_breaks', + 'parameters', ] read_only_fields = [ 'availability_updated', @@ -487,6 +480,12 @@ class SupplierPartSerializer( filter_name='price_breaks', ) + parameters = enable_filter( + common.serializers.ParameterSerializer(many=True, read_only=True), + False, + filter_name='parameters', + ) + part_detail = part_serializers.PartBriefSerializer( label=_('Part'), source='part', many=False, read_only=True, allow_null=True ) @@ -543,6 +542,8 @@ class SupplierPartSerializer( on_order=company.filters.annotate_on_order_quantity() ) + queryset = SupplierPart.annotate_parameters(queryset) + return queryset def update(self, supplier_part, data): diff --git a/src/backend/InvenTree/company/test_migrations.py b/src/backend/InvenTree/company/test_migrations.py index 305eaf6031..cfea9f7cca 100644 --- a/src/backend/InvenTree/company/test_migrations.py +++ b/src/backend/InvenTree/company/test_migrations.py @@ -316,7 +316,7 @@ class TestSupplierPartQuantity(MigratorTestCase): """Test that the supplier part quantity is correctly migrated.""" migrate_from = ('company', '0058_auto_20230515_0004') - migrate_to = ('company', unit_test.getNewestMigrationFile('company')) + migrate_to = ('company', '0062_contact_metadata') def prepare(self): """Prepare a number of SupplierPart objects.""" @@ -361,3 +361,80 @@ class TestSupplierPartQuantity(MigratorTestCase): # And the 'pack_size' attribute has been removed with self.assertRaises(AttributeError): sp.pack_size + + +class TestManufacturerPartParameterMigration(MigratorTestCase): + """Test migration of ManufacturerPartParameter data. + + Ref: https://github.com/inventree/InvenTree/pull/10699 + + In the referenced PR: + + - Generic ParameterTemplate and Parameter models were created + - Existing ManufacturerPartParameter data was migrated to the new models + - ManufacturerPartParameter model was removed + """ + + migrate_from = ('common', '0038_alter_attachment_model_type') + migrate_to = ('company', '0077_delete_manufacturerpartparameter') + + def prepare(self): + """Create some existing data before migration.""" + Part = self.old_state.apps.get_model('part', 'part') + + Company = self.old_state.apps.get_model('company', 'company') + ManufacturerPart = self.old_state.apps.get_model('company', 'manufacturerpart') + ManufacturerPartParameter = self.old_state.apps.get_model( + 'company', 'manufacturerpartparameter' + ) + + # Create a ManufacturerPart + part = Part.objects.create( + name='PART', + description='A purchaseable part', + purchaseable=True, + level=0, + tree_id=0, + lft=0, + rght=0, + ) + + manufacturer = Company.objects.create( + name='Manufacturer', description='A manufacturer', is_manufacturer=True + ) + + manu_part = ManufacturerPart.objects.create( + part=part, manufacturer=manufacturer, MPN='MPN-001' + ) + + # Create a parameter which does NOT correlate with any existing template + for name in ['Width', 'Height', 'Depth']: + ManufacturerPartParameter.objects.create( + manufacturer_part=manu_part, name=name, value='100', units='mm' + ) + + def test_manufacturer_part_parameter_migration(self): + """Test that ManufacturerPartParameter data has been migrated correctly.""" + ContentType = self.new_state.apps.get_model('contenttypes', 'contenttype') + ParameterTemplate = self.new_state.apps.get_model('common', 'parametertemplate') + Parameter = self.new_state.apps.get_model('common', 'parameter') + ManufacturerPart = self.new_state.apps.get_model('company', 'manufacturerpart') + + # There should be 6 ParameterTemplate objects + self.assertEqual(ParameterTemplate.objects.count(), 3) + + manu_part = ManufacturerPart.objects.first() + + content_type, _created = ContentType.objects.get_or_create( + app_label='company', model='manufacturerpart' + ) + + # There should be 3 Parameter objects linked to the ManufacturerPart + params = Parameter.objects.filter( + model_type=content_type, model_id=manu_part.pk + ) + + self.assertEqual(params.count(), 3) + + for name in ['Width', 'Height', 'Depth']: + self.assertTrue(params.filter(template__name=name).exists()) diff --git a/src/backend/InvenTree/generic/states/fields.py b/src/backend/InvenTree/generic/states/fields.py index 458a13dacb..9ffa81d8eb 100644 --- a/src/backend/InvenTree/generic/states/fields.py +++ b/src/backend/InvenTree/generic/states/fields.py @@ -144,7 +144,7 @@ class InvenTreeCustomStatusModelField(models.PositiveIntegerField): validators.append(CustomStatusCodeValidator(status_class=self.status_class)) help_text = _('Additional status information for this item') - if InvenTree.ready.isGeneratingSchema(): + if InvenTree.ready.isGeneratingSchema() and self.status_class: help_text = ( help_text + '\n\n' diff --git a/src/backend/InvenTree/order/api.py b/src/backend/InvenTree/order/api.py index 05f848f518..ee2fa7a4c9 100644 --- a/src/backend/InvenTree/order/api.py +++ b/src/backend/InvenTree/order/api.py @@ -28,7 +28,12 @@ import stock.models as stock_models import stock.serializers as stock_serializers from data_exporter.mixins import DataExportViewMixin from generic.states.api import StatusView -from InvenTree.api import BulkUpdateMixin, ListCreateDestroyAPIView, MetadataView +from InvenTree.api import ( + BulkUpdateMixin, + ListCreateDestroyAPIView, + MetadataView, + ParameterListMixin, +) from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration from InvenTree.filters import ( SEARCH_ORDER_FILTER, @@ -378,6 +383,7 @@ class PurchaseOrderList( OrderCreateMixin, DataExportViewMixin, OutputOptionsMixin, + ParameterListMixin, ListCreateAPI, ): """API endpoint for accessing a list of PurchaseOrder objects. @@ -847,6 +853,7 @@ class SalesOrderList( OrderCreateMixin, DataExportViewMixin, OutputOptionsMixin, + ParameterListMixin, ListCreateAPI, ): """API endpoint for accessing a list of SalesOrder objects. @@ -856,9 +863,7 @@ class SalesOrderList( """ filterset_class = SalesOrderFilter - filter_backends = SEARCH_ORDER_FILTER_ALIAS - output_options = SalesOrderOutputOptions ordering_field_aliases = { @@ -1525,12 +1530,12 @@ class ReturnOrderList( OrderCreateMixin, DataExportViewMixin, OutputOptionsMixin, + ParameterListMixin, ListCreateAPI, ): """API endpoint for accessing a list of ReturnOrder objects.""" filterset_class = ReturnOrderFilter - filter_backends = SEARCH_ORDER_FILTER_ALIAS output_options = ReturnOrderOutputOptions diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index 3030ade4fb..bf699af334 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -268,6 +268,7 @@ class ReturnOrderReportContext(report.mixins.BaseReportContext): class Order( StatusCodeMixin, StateTransitionMixin, + InvenTree.models.InvenTreeParameterMixin, InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNotesMixin, diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py index bc993581c1..caac022c91 100644 --- a/src/backend/InvenTree/order/serializers.py +++ b/src/backend/InvenTree/order/serializers.py @@ -22,6 +22,7 @@ from rest_framework.serializers import ValidationError from sql_util.utils import SubqueryCount, SubquerySum import build.serializers +import common.serializers import order.models import part.filters as part_filters import part.models as part_models @@ -177,6 +178,12 @@ class AbstractOrderSerializer( True, ) + parameters = enable_filter( + common.serializers.ParameterSerializer(many=True, read_only=True), + False, + filter_name='parameters', + ) + # Boolean field indicating if this order is overdue (Note: must be annotated) overdue = serializers.BooleanField(read_only=True, allow_null=True) @@ -240,6 +247,7 @@ class AbstractOrderSerializer( 'project_code_detail', 'project_code_label', 'responsible_detail', + 'parameters', *extra_fields, ] @@ -444,6 +452,9 @@ class PurchaseOrderSerializer( """ queryset = AbstractOrderSerializer.annotate_queryset(queryset) + # Annotate parametric data + queryset = order.models.PurchaseOrder.annotate_parameters(queryset) + queryset = queryset.annotate( completed_lines=SubqueryCount( 'lines', filter=Q(quantity__lte=F('received')) @@ -1087,6 +1098,9 @@ class SalesOrderSerializer( """ queryset = AbstractOrderSerializer.annotate_queryset(queryset) + # Annotate parametric data + queryset = order.models.SalesOrder.annotate_parameters(queryset) + queryset = queryset.annotate( completed_lines=SubqueryCount('lines', filter=Q(quantity__lte=F('shipped'))) ) @@ -1936,6 +1950,9 @@ class ReturnOrderSerializer( """Custom annotation for the serializer queryset.""" queryset = AbstractOrderSerializer.annotate_queryset(queryset) + # Annotate parametric data + queryset = order.models.ReturnOrder.annotate_parameters(queryset) + queryset = queryset.annotate( completed_lines=SubqueryCount( 'lines', filter=~Q(outcome=ReturnOrderLineStatus.PENDING.value) diff --git a/src/backend/InvenTree/order/test_api.py b/src/backend/InvenTree/order/test_api.py index 93610e80ed..58c74e7f6d 100644 --- a/src/backend/InvenTree/order/test_api.py +++ b/src/backend/InvenTree/order/test_api.py @@ -204,8 +204,8 @@ class PurchaseOrderTest(OrderTest): self.LIST_URL, data={'limit': limit}, expected_code=200 ) - # Total database queries must be below 20, independent of the number of results - self.assertLess(len(ctx), 20) + # Total database queries must be below 25, independent of the number of results + self.assertLess(len(ctx), 25) for result in response.data['results']: self.assertIn('total_price', result) @@ -1267,7 +1267,7 @@ class PurchaseOrderReceiveTest(OrderTest): ], 'location': location.pk, }, - max_query_count=100 + 2 * N_LINES, + max_query_count=104 + 2 * N_LINES, ).data # Check for expected response @@ -1428,8 +1428,8 @@ class SalesOrderTest(OrderTest): self.LIST_URL, data={'limit': limit}, expected_code=200 ) - # Total database queries must be less than 20 - self.assertLess(len(ctx), 20) + # Total database queries must be less than 25 + self.assertLess(len(ctx), 25) n = len(response.data['results']) diff --git a/src/backend/InvenTree/part/admin.py b/src/backend/InvenTree/part/admin.py index 6c6ae85b7a..377a0b1a4a 100644 --- a/src/backend/InvenTree/part/admin.py +++ b/src/backend/InvenTree/part/admin.py @@ -5,12 +5,6 @@ from django.contrib import admin from part import models -class PartParameterInline(admin.TabularInline): - """Inline for part parameter data.""" - - model = models.PartParameter - - @admin.register(models.Part) class PartAdmin(admin.ModelAdmin): """Admin class for the Part model.""" @@ -36,7 +30,7 @@ class PartAdmin(admin.ModelAdmin): 'creation_user', ] - inlines = [PartParameterInline] + inlines = [] @admin.register(models.PartPricing) @@ -99,33 +93,6 @@ class BomItemAdmin(admin.ModelAdmin): autocomplete_fields = ('part', 'sub_part') -@admin.register(models.PartParameterTemplate) -class ParameterTemplateAdmin(admin.ModelAdmin): - """Admin class for the PartParameterTemplate model.""" - - list_display = ('name', 'units') - - search_fields = ('name', 'units') - - -@admin.register(models.PartParameter) -class ParameterAdmin(admin.ModelAdmin): - """Admin class for the PartParameter model.""" - - list_display = ('part', 'template', 'data') - - readonly_fields = ('updated', 'updated_by') - - autocomplete_fields = ('part', 'template') - - -@admin.register(models.PartCategoryParameterTemplate) -class PartCategoryParameterAdmin(admin.ModelAdmin): - """Admin class for the PartCategoryParameterTemplate model.""" - - autocomplete_fields = ('category', 'parameter_template') - - @admin.register(models.PartSellPriceBreak) class PartSellPriceBreakAdmin(admin.ModelAdmin): """Admin class for the PartSellPriceBreak model.""" diff --git a/src/backend/InvenTree/part/api.py b/src/backend/InvenTree/part/api.py index 2af9a7f05f..57fcc7cb85 100644 --- a/src/backend/InvenTree/part/api.py +++ b/src/backend/InvenTree/part/api.py @@ -1,7 +1,5 @@ """Provides a JSON API for the Part app.""" -import re - from django.db.models import Count, F, Q from django.urls import include, path from django.utils.translation import gettext_lazy as _ @@ -14,15 +12,14 @@ from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from rest_framework.response import Response -import part.filters import part.tasks as part_tasks from data_exporter.mixins import DataExportViewMixin from InvenTree.api import ( - BulkCreateMixin, BulkDeleteMixin, BulkUpdateMixin, ListCreateDestroyAPIView, MetadataView, + ParameterListMixin, ) from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration from InvenTree.filters import ( @@ -59,8 +56,6 @@ from .models import ( PartCategory, PartCategoryParameterTemplate, PartInternalPriceBreak, - PartParameter, - PartParameterTemplate, PartRelated, PartSellPriceBreak, PartStocktake, @@ -323,7 +318,7 @@ class CategoryTree(ListAPI): return queryset -class CategoryParameterList(DataExportViewMixin, ListCreateAPI): +class CategoryParameterList(DataExportViewMixin, OutputOptionsMixin, ListCreateAPI): """API endpoint for accessing a list of PartCategoryParameterTemplate objects. - GET: Return a list of PartCategoryParameterTemplate objects @@ -1025,10 +1020,6 @@ class PartMixin(SerializerContextMixin): queryset = part_serializers.PartSerializer.annotate_queryset(queryset) - # Annotate with parameter template data? - if str2bool(self.request.query_params.get('parameters', False)): - queryset = queryset.prefetch_related('parameters', 'parameters__template') - if str2bool(self.request.query_params.get('price_breaks', True)): queryset = queryset.prefetch_related('salepricebreaks') @@ -1076,7 +1067,12 @@ class PartOutputOptions(OutputConfiguration): class PartList( - PartMixin, BulkUpdateMixin, DataExportViewMixin, OutputOptionsMixin, ListCreateAPI + PartMixin, + BulkUpdateMixin, + ParameterListMixin, + DataExportViewMixin, + OutputOptionsMixin, + ListCreateAPI, ): """API endpoint for accessing a list of Part objects, or creating a new Part instance.""" @@ -1084,75 +1080,6 @@ class PartList( filterset_class = PartFilter is_create = True - def filter_queryset(self, queryset): - """Perform custom filtering of the queryset.""" - queryset = super().filter_queryset(queryset) - - queryset = self.filter_parametric_data(queryset) - queryset = self.order_by_parameter(queryset) - - return queryset - - def filter_parametric_data(self, queryset): - """Filter queryset against part parameters. - - Used to filter returned parts based on their parameter values. - - To filter based on parameter value, supply query parameters like: - - parameter_= - - parameter__gt= - - parameter__lte= - - where: - - is the ID of the PartParameterTemplate. - - is the value to filter against. - """ - # Allowed lookup operations for parameter values - operators = '|'.join(part.filters.PARAMETER_FILTER_OPERATORS) - - regex_pattern = rf'^parameter_(\d+)(_({operators}))?$' - - for param in self.request.query_params: - result = re.match(regex_pattern, param) - - if not result: - continue - - template_id = result.group(1) - operator = result.group(3) or '' - - value = self.request.query_params.get(param, None) - - queryset = part.filters.filter_by_parameter( - queryset, template_id, value, func=operator - ) - - return queryset - - def order_by_parameter(self, queryset): - """Perform queryset ordering based on parameter value. - - - Used if the 'ordering' query param points to a parameter - - e.g. '&ordering=param_' where specifies the PartParameterTemplate - - Only parts which have a matching parameter are returned - - Queryset is ordered based on parameter value - """ - # Extract "ordering" parameter from query args - ordering = self.request.query_params.get('ordering', None) - - if ordering: - # Ordering value must match required regex pattern - result = re.match(r'^\-?parameter_(\d+)$', ordering) - - if result: - template_id = result.group(1) - ascending = not ordering.startswith('-') - queryset = part.filters.order_by_parameter( - queryset, template_id, ascending - ) - - return queryset - filter_backends = SEARCH_ORDER_FILTER_ALIAS ordering_fields = [ @@ -1267,208 +1194,6 @@ class PartRelatedDetail(PartRelatedMixin, RetrieveUpdateDestroyAPI): """API endpoint for accessing detail view of a PartRelated object.""" -class PartParameterTemplateFilter(FilterSet): - """FilterSet for PartParameterTemplate objects.""" - - class Meta: - """Metaclass options.""" - - model = PartParameterTemplate - - # Simple filter fields - fields = ['name', 'units', 'checkbox'] - - has_choices = rest_filters.BooleanFilter( - method='filter_has_choices', label='Has Choice' - ) - - def filter_has_choices(self, queryset, name, value): - """Filter queryset to include only PartParameterTemplates with choices.""" - if str2bool(value): - return queryset.exclude(Q(choices=None) | Q(choices='')) - - return queryset.filter(Q(choices=None) | Q(choices='')).distinct() - - has_units = rest_filters.BooleanFilter(method='filter_has_units', label='Has Units') - - def filter_has_units(self, queryset, name, value): - """Filter queryset to include only PartParameterTemplates with units.""" - if str2bool(value): - return queryset.exclude(Q(units=None) | Q(units='')) - - return queryset.filter(Q(units=None) | Q(units='')).distinct() - - part = rest_filters.ModelChoiceFilter( - queryset=Part.objects.all(), method='filter_part', label=_('Part') - ) - - @extend_schema_field(OpenApiTypes.INT) - def filter_part(self, queryset, name, part): - """Filter queryset to include only PartParameterTemplates which are referenced by a part.""" - parameters = PartParameter.objects.filter(part=part) - template_ids = parameters.values_list('template').distinct() - return queryset.filter(pk__in=[el[0] for el in template_ids]) - - # Filter against a "PartCategory" - return only parameter templates which are referenced by parts in this category - category = rest_filters.ModelChoiceFilter( - queryset=PartCategory.objects.all(), - method='filter_category', - label=_('Category'), - ) - - @extend_schema_field(OpenApiTypes.INT) - def filter_category(self, queryset, name, category): - """Filter queryset to include only PartParameterTemplates which are referenced by parts in this category.""" - cats = category.get_descendants(include_self=True) - parameters = PartParameter.objects.filter(part__category__in=cats) - template_ids = parameters.values_list('template').distinct() - return queryset.filter(pk__in=[el[0] for el in template_ids]) - - -class PartParameterTemplateMixin: - """Mixin class for PartParameterTemplate API endpoints.""" - - queryset = PartParameterTemplate.objects.all() - serializer_class = part_serializers.PartParameterTemplateSerializer - - def get_queryset(self, *args, **kwargs): - """Return an annotated queryset for the PartParameterTemplateDetail endpoint.""" - queryset = super().get_queryset(*args, **kwargs) - - queryset = part_serializers.PartParameterTemplateSerializer.annotate_queryset( - queryset - ) - - return queryset - - -class PartParameterTemplateList( - PartParameterTemplateMixin, DataExportViewMixin, ListCreateAPI -): - """API endpoint for accessing a list of PartParameterTemplate objects. - - - GET: Return list of PartParameterTemplate objects - - POST: Create a new PartParameterTemplate object - """ - - filterset_class = PartParameterTemplateFilter - - filter_backends = SEARCH_ORDER_FILTER - - search_fields = ['name', 'description'] - - ordering_fields = ['name', 'units', 'checkbox', 'parts'] - - -class PartParameterTemplateDetail(PartParameterTemplateMixin, RetrieveUpdateDestroyAPI): - """API endpoint for accessing the detail view for a PartParameterTemplate object.""" - - -class PartParameterOutputOptions(OutputConfiguration): - """Output options for the PartParameter endpoints.""" - - OPTIONS = [ - InvenTreeOutputOption('part_detail'), - InvenTreeOutputOption( - 'template_detail', - default=True, - description='Include detailed information about the part parameter template.', - ), - ] - - -class PartParameterAPIMixin: - """Mixin class for PartParameter API endpoints.""" - - queryset = PartParameter.objects.all() - serializer_class = part_serializers.PartParameterSerializer - output_options = PartParameterOutputOptions - - def get_queryset(self, *args, **kwargs): - """Override get_queryset method to prefetch related fields.""" - queryset = super().get_queryset(*args, **kwargs) - queryset = queryset.prefetch_related('part', 'template', 'updated_by') - return queryset - - def get_serializer_context(self): - """Pass the 'request' object through to the serializer context.""" - context = super().get_serializer_context() - context['request'] = self.request - - return context - - -class PartParameterFilter(FilterSet): - """Custom filters for the PartParameterList API endpoint.""" - - class Meta: - """Metaclass options for the filterset.""" - - model = PartParameter - fields = ['template', 'updated_by'] - - part = rest_filters.ModelChoiceFilter( - queryset=Part.objects.all(), method='filter_part' - ) - - def filter_part(self, queryset, name, part): - """Filter against the provided part. - - If 'include_variants' query parameter is provided, filter against variant parts also - """ - try: - include_variants = str2bool(self.request.GET.get('include_variants', False)) - except AttributeError: - include_variants = False - - if include_variants: - return queryset.filter(part__in=part.get_descendants(include_self=True)) - else: - return queryset.filter(part=part) - - -class PartParameterList( - BulkCreateMixin, - PartParameterAPIMixin, - OutputOptionsMixin, - DataExportViewMixin, - ListCreateAPI, -): - """API endpoint for accessing a list of PartParameter objects. - - - GET: Return list of PartParameter objects - - POST: Create a new PartParameter object - """ - - filterset_class = PartParameterFilter - - filter_backends = SEARCH_ORDER_FILTER_ALIAS - - ordering_fields = ['name', 'data', 'part', 'template', 'updated', 'updated_by'] - - ordering_field_aliases = { - 'name': 'template__name', - 'units': 'template__units', - 'data': ['data_numeric', 'data'], - 'part': 'part__name', - } - - search_fields = [ - 'data', - 'template__name', - 'template__description', - 'template__units', - ] - - unique_create_fields = ['part', 'template'] - - -class PartParameterDetail( - PartParameterAPIMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI -): - """API endpoint for detail view of a single PartParameter object.""" - - class PartStocktakeFilter(FilterSet): """Custom filter for the PartStocktakeList endpoint.""" @@ -1880,53 +1605,6 @@ part_api_urls = [ path('', PartRelatedList.as_view(), name='api-part-related-list'), ]), ), - # Base URL for PartParameter API endpoints - path( - 'parameter/', - include([ - path( - 'template/', - include([ - path( - '/', - include([ - path( - 'metadata/', - MetadataView.as_view(model=PartParameterTemplate), - name='api-part-parameter-template-metadata', - ), - path( - '', - PartParameterTemplateDetail.as_view(), - name='api-part-parameter-template-detail', - ), - ]), - ), - path( - '', - PartParameterTemplateList.as_view(), - name='api-part-parameter-template-list', - ), - ]), - ), - path( - '/', - include([ - path( - 'metadata/', - MetadataView.as_view(model=PartParameter), - name='api-part-parameter-metadata', - ), - path( - '', - PartParameterDetail.as_view(), - name='api-part-parameter-detail', - ), - ]), - ), - path('', PartParameterList.as_view(), name='api-part-parameter-list'), - ]), - ), # Part stocktake data path( 'stocktake/', diff --git a/src/backend/InvenTree/part/filters.py b/src/backend/InvenTree/part/filters.py index 33f3998ab6..ad4107566e 100644 --- a/src/backend/InvenTree/part/filters.py +++ b/src/backend/InvenTree/part/filters.py @@ -18,7 +18,6 @@ from django.db import models from django.db.models import ( Case, DecimalField, - Exists, ExpressionWrapper, F, FloatField, @@ -35,8 +34,6 @@ from django.db.models.query import QuerySet from sql_util.utils import SubquerySum -import InvenTree.conversion -import InvenTree.helpers import part.models import stock.models from build.status_codes import BuildStatusGroups @@ -519,159 +516,3 @@ def annotate_bom_item_can_build(queryset: QuerySet, reference: str = '') -> Quer ) return queryset - - -"""A list of valid operators for filtering part parameters.""" -PARAMETER_FILTER_OPERATORS: list[str] = ['gt', 'gte', 'lt', 'lte', 'ne', 'icontains'] - - -def filter_by_parameter( - queryset: QuerySet, template_id: int, value: str, func: str = '' -) -> QuerySet: - """Filter the given queryset by a given template parameter. - - Parts which do not have a value for the given parameter are excluded. - - Arguments: - queryset: A queryset of Part objects - template_id (int): The ID of the template parameter to filter by - value (str): The value of the parameter to filter by - func (str): The function to use for the filter (e.g. __gt, __lt, __contains) - - Returns: - A queryset of Part objects filtered by the given parameter - """ - if func and func not in PARAMETER_FILTER_OPERATORS: - raise ValueError(f'Invalid parameter filter function supplied: {func}.') - - try: - template = part.models.PartParameterTemplate.objects.get(pk=template_id) - except (ValueError, part.models.PartParameterTemplate.DoesNotExist): - # Return queryset unchanged if the template does not exist - return queryset - - # Construct a "numeric" value - try: - value_numeric = float(value) - except (ValueError, TypeError): - value_numeric = None - - if template.checkbox: - # Account for 'boolean' parameter values - # Convert to "True" or "False" string in this case - bool_value = InvenTree.helpers.str2bool(value) - value_numeric = 1 if bool_value else 0 - value = str(bool_value) - - # Boolean filtering is limited to exact matches - func = '' - - elif value_numeric is None and template.units: - # Convert the raw value to the units of the template parameter - try: - value_numeric = InvenTree.conversion.convert_physical_value( - value, template.units - ) - except Exception: - # The value cannot be converted - return an empty queryset - return queryset.none() - - # Special handling for the "not equal" operator - if func == 'ne': - invert = True - func = '' - else: - invert = False - - # Some filters are only applicable to string values - text_only = any([func in ['icontains'], value_numeric is None]) - - # Ensure the function starts with a double underscore - if func and not func.startswith('__'): - func = f'__{func}' - - # Query for 'numeric' value - this has priority over 'string' value - data_numeric = { - 'parameters__template': template, - 'parameters__data_numeric__isnull': False, - f'parameters__data_numeric{func}': value_numeric, - } - - query_numeric = Q(**data_numeric) - - # Query for 'string' value - data_text = { - 'parameters__template': template, - f'parameters__data{func}': str(value), - } - - if not text_only: - data_text['parameters__data_numeric__isnull'] = True - - query_text = Q(**data_text) - - # Combine the queries based on whether we are filtering by text or numeric value - q = query_text if text_only else query_text | query_numeric - - # Special handling for the '__ne' (not equal) operator - # In this case, we want the *opposite* of the above queries - if invert: - return queryset.exclude(q).distinct() - else: - return queryset.filter(q).distinct() - - -def order_by_parameter( - queryset: QuerySet, template_id: int, ascending: bool = True -) -> QuerySet: - """Order the given queryset by a given template parameter. - - Parts which do not have a value for the given parameter are ordered last. - - Arguments: - queryset: A queryset of Part objects - template_id (int): The ID of the template parameter to order by - ascending (bool): Order by ascending or descending (default = True) - - Returns: - A queryset of Part objects ordered by the given parameter - """ - template_filter = part.models.PartParameter.objects.filter( - template__id=template_id, part_id=OuterRef('id') - ) - - # Annotate the queryset with the parameter value, and whether it exists - queryset = queryset.annotate(parameter_exists=Exists(template_filter)) - - # Annotate the text data value - queryset = queryset.annotate( - parameter_value=Case( - When( - parameter_exists=True, - then=Subquery( - template_filter.values('data')[:1], output_field=models.CharField() - ), - ), - default=Value('', output_field=models.CharField()), - ), - parameter_value_numeric=Case( - When( - parameter_exists=True, - then=Subquery( - template_filter.values('data_numeric')[:1], - output_field=models.FloatField(), - ), - ), - default=Value(0, output_field=models.FloatField()), - ), - ) - - prefix = '' if ascending else '-' - - # Return filtered queryset - - return queryset.order_by( - '-parameter_exists', - f'{prefix}parameter_value_numeric', - f'{prefix}parameter_value', - ) diff --git a/src/backend/InvenTree/part/fixtures/params.yaml b/src/backend/InvenTree/part/fixtures/params.yaml index 2364a95fdb..54c1e3fd2b 100644 --- a/src/backend/InvenTree/part/fixtures/params.yaml +++ b/src/backend/InvenTree/part/fixtures/params.yaml @@ -1,70 +1,100 @@ # Create some PartParameter templtes -- model: part.PartParameterTemplate +- model: common.parametertemplate pk: 1 fields: name: Length units: mm + model_type: + - part + - part -- model: part.PartParameterTemplate +- model: common.parametertemplate pk: 2 fields: name: Width units: mm + model_type: + - part + - part -- model: part.PartParameterTemplate +- model: common.parametertemplate pk: 3 fields: name: Thickness units: mm + model_type: + - part + - part # Add some parameters to parts (requires part.yaml) -- model: part.PartParameter +- model: common.parameter pk: 1 fields: - part: 1 + model_id: 1 + model_type: + - part + - part template: 1 data: 4 -- model: part.PartParameter +- model: common.parameter pk: 2 fields: - part: 2 + model_id: 2 + model_type: + - part + - part template: 1 data: 12 -- model: part.PartParameter +- model: common.parameter pk: 3 fields: - part: 3 + model_id: 3 + model_type: + - part + - part template: 1 data: 12 -- model: part.PartParameter +- model: common.parameter pk: 4 fields: - part: 3 + model_id: 3 + model_type: + - part + - part template: 2 data: 12 -- model: part.PartParameter +- model: common.parameter pk: 5 fields: - part: 3 + model_id: 3 + model_type: + - part + - part template: 3 data: 12 -- model: part.PartParameter +- model: common.parameter pk: 6 fields: - part: 100 + model_id: 100 + model_type: + - part + - part template: 3 data: 12 -- model: part.PartParameter +- model: common.parameter pk: 7 fields: - part: 100 + model_id: 100 + model_type: + - part + - part template: 1 data: 12 @@ -73,12 +103,12 @@ pk: 1 fields: category: 7 - parameter_template: 1 + template: 1 default_value: '2.8' - model: part.PartCategoryParameterTemplate pk: 2 fields: category: 7 - parameter_template: 3 + template: 3 default_value: '0.5' diff --git a/src/backend/InvenTree/part/migrations/0071_alter_partparametertemplate_name.py b/src/backend/InvenTree/part/migrations/0071_alter_partparametertemplate_name.py index fef49e73f6..4fad4a9bf1 100644 --- a/src/backend/InvenTree/part/migrations/0071_alter_partparametertemplate_name.py +++ b/src/backend/InvenTree/part/migrations/0071_alter_partparametertemplate_name.py @@ -14,6 +14,6 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='partparametertemplate', name='name', - field=models.CharField(help_text='Parameter Name', max_length=100, unique=True, validators=[part.models.validate_template_name], verbose_name='Name'), + field=models.CharField(help_text='Parameter Name', max_length=100, unique=True, validators=[], verbose_name='Name'), ), ] diff --git a/src/backend/InvenTree/part/migrations/0144_auto_20251203_1045.py b/src/backend/InvenTree/part/migrations/0144_auto_20251203_1045.py new file mode 100644 index 0000000000..52d9b61ffa --- /dev/null +++ b/src/backend/InvenTree/part/migrations/0144_auto_20251203_1045.py @@ -0,0 +1,161 @@ +# Generated by Django 5.2.8 on 2025-12-03 10:45 + +from django.db import migrations, models +from django.db.models import deletion + + +def update_parameter(apps, schema_editor): + """Data migration to update existing PartParameter records. + + - Set 'model_type' to the ContentType for 'part.Part' + - Set 'model_id' to the existing 'part_id' value + """ + + PartParameter = apps.get_model("part", "PartParameter") + ContentType = apps.get_model("contenttypes", "ContentType") + + part_content_type, created = ContentType.objects.get_or_create( + app_label="part", + model="part", + ) + + parameters_to_update = [] + + N = PartParameter.objects.count() + + # Update any existing PartParameter object, setting the new fields + for parameter in PartParameter.objects.all(): + parameter.model_type = part_content_type + parameter.model_id = parameter.part_id + parameters_to_update.append(parameter) + + if len(parameters_to_update) > 0: + + print(f"Updating {len(parameters_to_update)} PartParameter records.") + + PartParameter.objects.bulk_update( + parameters_to_update, + fields=["model_type", "model_id"], + ) + + # Ensure that the number of updated records matches the total number of records + assert PartParameter.objects.count() == N + + # Ensure that no PartParameter records have null model_type or model_id + assert PartParameter.objects.filter(model_type=None).count() == 0 + assert PartParameter.objects.filter(model_id=None).count() == 0 + + +def reverse_update_parameter(apps, schema_editor): + """Reverse data migration to restore existing PartParameter records. + + - Set 'part_id' to the existing 'model_id' value + """ + + PartParameter = apps.get_model("part", "PartParameter") + + parmeters_to_update = [] + + for parameter in PartParameter.objects.all(): + parameter.part = parameter.model_id + parmeters_to_update.append(parameter) + + if len(parmeters_to_update) > 0: + + print(f"Reversing update of {len(parmeters_to_update)} PartParameter records.") + + PartParameter.objects.bulk_update( + parmeters_to_update, + fields=["part"], + ) + + +class Migration(migrations.Migration): + """Data migration for making the PartParameterTemplate and PartParameter models generic. + + - Add new fields to the "PartParameterTemplate" and "PartParameter" models + - Migrate existing data to populate the new fields + - Make the new fields non-nullable (if appropriate) + - Remove any obsolete fields (if necessary) + """ + + atomic = False + + dependencies = [ + ("part", "0143_alter_part_image"), + ] + + operations = [ + # Add the new "enabled" field to the PartParameterTemplate model + migrations.AddField( + model_name="partparametertemplate", + name="enabled", + field=models.BooleanField( + default=True, + help_text="Is this parameter template enabled?", + verbose_name="Enabled", + ), + ), + # Add the "model_type" field to the PartParmaeterTemplate model + migrations.AddField( + model_name="partparametertemplate", + name="model_type", + field=models.ForeignKey( + blank=True, null=True, + help_text="Target model type for this parameter template", + on_delete=deletion.SET_NULL, + to="contenttypes.contenttype", + verbose_name="Model type", + ), + ), + # Add the "model_type" field to the PartParameter model + # Note: This field is initially nullable, to allow existing records to remain valid. + migrations.AddField( + model_name="partparameter", + name="model_type", + field=models.ForeignKey( + blank=True, null=True, + on_delete=deletion.CASCADE, + to="contenttypes.contenttype", + ), + ), + # Add the "model_id" field to the PartParameter model + # Note: This field is initially nullable, to allow existing records to remain valid. + migrations.AddField( + model_name="partparameter", + name="model_id", + field=models.PositiveIntegerField( + blank=True, null=True, + help_text="ID of the target model for this parameter", + verbose_name="Model ID", + ) + ), + # Migrate existing PartParameter records to populate the new fields + migrations.RunPython( + update_parameter, + reverse_code=reverse_update_parameter, + ), + # Update the "model_type" field on the PartParameter model to be non-nullable + migrations.AlterField( + model_name="partparameter", + name="model_type", + field=models.ForeignKey( + on_delete=deletion.CASCADE, + to="contenttypes.contenttype", + ), + ), + # Update the "model_id" field on the PartParameter model to be non-nullable + migrations.AlterField( + model_name="partparameter", + name="model_id", + field=models.PositiveIntegerField( + help_text="ID of the target model for this parameter", + verbose_name="Model ID", + ) + ), + # Remove the "unique_together" constraint on the PartParameter model + migrations.AlterUniqueTogether( + name="partparameter", + unique_together={('model_type', 'model_id', 'template')}, + ), + ] diff --git a/src/backend/InvenTree/part/migrations/0145_auto_20251203_1238.py b/src/backend/InvenTree/part/migrations/0145_auto_20251203_1238.py new file mode 100644 index 0000000000..0924f9065e --- /dev/null +++ b/src/backend/InvenTree/part/migrations/0145_auto_20251203_1238.py @@ -0,0 +1,111 @@ +# Generated by Django 5.2.8 on 2025-12-03 12:38 + +from django.db import migrations, models +from django.db.models import deletion + + +def update_category_parameters(apps, schema_editor): + """Copy the 'parameter_template' field to the new 'template' field.""" + + PartCategoryParameterTemplate = apps.get_model("part", "PartCategoryParameterTemplate") + ParameterTemplate = apps.get_model("common", "ParameterTemplate") + + category_parameters_to_update = [] + + for cat_param in PartCategoryParameterTemplate.objects.all(): + template = ParameterTemplate.objects.get(pk=cat_param.parameter_template_id) + cat_param.template = template + category_parameters_to_update.append(cat_param) + + if len(category_parameters_to_update) > 0: + + print(f"Updating {len(category_parameters_to_update)} PartCategoryParameterTemplate records.") + + PartCategoryParameterTemplate.objects.bulk_update( + category_parameters_to_update, + fields=["template"], + ) + + +def reverse_update_category_parameters(apps, schema_editor): + """Copy the 'template' field back to the 'parameter_template' field.""" + + PartParameterTemplate = apps.get_model("part", "PartParameterTemplate") + PartCategoryParameterTemplate = apps.get_model("part", "PartCategoryParameterTemplate") + + category_parameters_to_update = [] + + for cat_param in PartCategoryParameterTemplate.objects.all(): + template = PartParameterTemplate.objects.get(pk=cat_param.template_id) + cat_param.parameter_template = template + category_parameters_to_update.append(cat_param) + + if len(category_parameters_to_update) > 0: + + print(f"Reversing update of {len(category_parameters_to_update)} PartCategoryParameterTemplate records.") + + PartCategoryParameterTemplate.objects.bulk_update( + category_parameters_to_update, + fields=["parameter_template"], + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("part", "0144_auto_20251203_1045"), + ("common", "0040_parametertemplate_parameter") + ] + + operations = [ + # Remove the obsolete "part" field from the PartParameter model + migrations.RemoveField( + model_name="partparameter", + name="part", + ), + # Add a new "template" field to the PartCategoryParameterTemplate model + migrations.AddField( + model_name="partcategoryparametertemplate", + name="template", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=deletion.CASCADE, + related_name="part_categories", + to="common.parametertemplate", + ), + ), + # Remove unique constraint on PartCategoryParameterTemplate model + migrations.RemoveConstraint( + model_name="partcategoryparametertemplate", + name="unique_category_parameter_template_pair", + ), + # Perform data migration for the PartCategoryParameterTemplate model + migrations.RunPython( + update_category_parameters, + reverse_code=reverse_update_category_parameters, + ), + # Remove the obsolete "part_template" field from the PartCategoryParameterTemplate model + migrations.RemoveField( + model_name="partcategoryparametertemplate", + name="parameter_template", + ), + # Remove nullable attribute from the new 'template' field + migrations.AlterField( + model_name="partcategoryparametertemplate", + name="template", + field=models.ForeignKey( + on_delete=deletion.CASCADE, + related_name="part_categories", + to="common.parametertemplate", + ), + ), + # Update uniqueness constraint on PartCategoryParameterTemplate model + migrations.AddConstraint( + model_name="partcategoryparametertemplate", + constraint=models.UniqueConstraint( + fields=("category", "template"), + name="unique_category_parameter_pair", + ), + ), + ] diff --git a/src/backend/InvenTree/part/migrations/0146_auto_20251203_1241.py b/src/backend/InvenTree/part/migrations/0146_auto_20251203_1241.py new file mode 100644 index 0000000000..bd6bdfbe31 --- /dev/null +++ b/src/backend/InvenTree/part/migrations/0146_auto_20251203_1241.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.8 on 2025-12-03 12:41 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("part", "0145_auto_20251203_1238"), + ] + + # Remove the PartParameterTemplate and PartParameter models + # Note: We *DO NOT* drop the underlying database tables, + # as these are now used by the common.ParameterTemplate and common.Parameter models + operations = [ + migrations.SeparateDatabaseAndState( + state_operations=[ + migrations.DeleteModel(name='PartParameterTemplate'), + migrations.DeleteModel(name='PartParameter'), + ], + database_operations=[] + ), + ] diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py index 18840608fb..2f2b74961f 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -14,12 +14,9 @@ from typing import cast from django.conf import settings from django.contrib.auth.models import User +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError -from django.core.validators import ( - MaxValueValidator, - MinLengthValidator, - MinValueValidator, -) +from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models, transaction from django.db.models import F, Q, QuerySet, Sum, UniqueConstraint from django.db.models.functions import Coalesce @@ -59,7 +56,7 @@ from company.models import SupplierPart from InvenTree import helpers, validators from InvenTree.exceptions import log_error from InvenTree.fields import InvenTreeURLField -from InvenTree.helpers import decimal2money, decimal2string, normalize, str2bool +from InvenTree.helpers import decimal2money, decimal2string, normalize from order import models as OrderModels from order.status_codes import ( PurchaseOrderStatus, @@ -229,7 +226,7 @@ class PartCategory( """Prefectch parts parameters.""" return ( self.get_parts(cascade=cascade) - .prefetch_related('parameters', 'parameters__template') + .prefetch_related('parameters_list', 'parameters_list__template') .all() ) @@ -240,7 +237,7 @@ class PartCategory( parts = prefetch or self.prefetch_parts_parameters(cascade=cascade) for part in parts: - for parameter in part.parameters.all(): + for parameter in part.parameters_list.all(): parameter_name = parameter.template.name if parameter_name not in unique_parameters_names: unique_parameters_names.append(parameter_name) @@ -263,7 +260,7 @@ class PartCategory( if part.IPN: part_parameters['IPN'] = part.IPN - for parameter in part.parameters.all(): + for parameter in part.parameters_list.all(): parameter_name = parameter.template.name parameter_value = parameter.data part_parameters[parameter_name] = parameter_value @@ -287,7 +284,7 @@ class PartCategory( def get_parameter_templates(self): """Return parameter templates associated to category.""" prefetch = PartCategoryParameterTemplate.objects.prefetch_related( - 'category', 'parameter_template' + 'category', 'parameter' ) return prefetch.filter(category=self.id) @@ -373,6 +370,86 @@ class PartManager(TreeManager): ) +class PartCategoryParameterTemplate(InvenTree.models.InvenTreeMetadataModel): + """A PartCategoryParameterTemplate creates a unique relationship between a PartCategory and a ParameterTemplate. + + Multiple ParameterTemplate instances can be associated to a PartCategory to drive a default list of parameter templates attached to a Part instance upon creation. + + Attributes: + category: Reference to a single PartCategory object + template: Reference to a single ParameterTemplate object + default_value: The default value for the parameter in the context of the selected category + """ + + @staticmethod + def get_api_url(): + """Return the API endpoint URL associated with the PartCategoryParameterTemplate model.""" + return reverse('api-part-category-parameter-list') + + class Meta: + """Metaclass providing extra model definition.""" + + verbose_name = _('Part Category Parameter Template') + + constraints = [ + UniqueConstraint( + fields=['category', 'template'], name='unique_category_parameter_pair' + ) + ] + + def __str__(self): + """String representation of a PartCategoryParameterTemplate (admin interface).""" + if self.default_value: + return f'{self.category.name} | {self.template.name} | {self.default_value}' + return f'{self.category.name} | {self.template.name}' + + def clean(self): + """Validate this PartCategoryParameterTemplate instance. + + Checks the provided 'default_value', and (if not blank), ensure it is valid. + """ + super().clean() + + self.default_value = ( + '' if self.default_value is None else str(self.default_value.strip()) + ) + + if ( + self.default_value + and get_global_setting( + 'PARAMETER_ENFORCE_UNITS', True, cache=False, create=False + ) + and self.template.units + ): + try: + InvenTree.conversion.convert_physical_value( + self.default_value, self.template.units + ) + except ValidationError as e: + raise ValidationError({'default_value': e.message}) + + category = models.ForeignKey( + PartCategory, + on_delete=models.CASCADE, + related_name='parameter_templates', + verbose_name=_('Category'), + help_text=_('Part Category'), + ) + + template = models.ForeignKey( + common.models.ParameterTemplate, + on_delete=models.CASCADE, + related_name='part_categories', + ) + + default_value = models.CharField( + max_length=500, + blank=True, + verbose_name=_('Default Value'), + help_text=_('Default Parameter Value'), + ) + + class PartReportContext(report.mixins.BaseReportContext): """Report context for the Part model. @@ -408,6 +485,7 @@ class PartReportContext(report.mixins.BaseReportContext): @cleanup.ignore class Part( InvenTree.models.PluginValidationMixin, + InvenTree.models.InvenTreeParameterMixin, InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNotesMixin, @@ -513,6 +591,16 @@ class Part( 'test_templates': self.getTestTemplateMap(), } + def check_parameter_delete(self, parameter): + """Custom delete check for Paramteter instances associated with this Part.""" + if self.locked: + raise ValidationError(_('Cannot delete parameters of a locked part')) + + def check_parameter_save(self, parameter): + """Custom save check for Parameter instances associated with this Part.""" + if self.locked: + raise ValidationError(_('Cannot modify parameters of a locked part')) + def delete(self, **kwargs): """Custom delete method for the Part model. @@ -2387,36 +2475,6 @@ class Part( sub.bom_item = bom_item sub.save() - @transaction.atomic - def copy_parameters_from(self, other: Part, **kwargs) -> None: - """Copy all parameter values from another Part instance.""" - clear = kwargs.get('clear', True) - - if clear: - self.get_parameters().delete() - - parameters = [] - - for parameter in other.get_parameters(): - # If this part already has a parameter pointing to the same template, - # delete that parameter from this part first! - - try: - existing = PartParameter.objects.get( - part=self, template=parameter.template - ) - existing.delete() - except PartParameter.DoesNotExist: - pass - - parameter.part = self - parameter.pk = None - - parameters.append(parameter) - - if len(parameters) > 0: - PartParameter.objects.bulk_create(parameters) - @transaction.atomic def copy_tests_from(self, other: Part, **kwargs) -> None: """Copy all test templates from another Part instance. @@ -2444,6 +2502,42 @@ class Part( if len(templates) > 0: PartTestTemplate.objects.bulk_create(templates) + @transaction.atomic + def copy_category_parameters(self, category: PartCategory): + """Copy parameter templates from the specified PartCategory. + + This function is normally called when the Part is first created. + """ + from common.models import Parameter + + categories = category.get_ancestors(include_self=True) + + category_templates = PartCategoryParameterTemplate.objects.filter( + category__in=categories + ).order_by('-category__level') + + parameters = [] + content_type = ContentType.objects.get_for_model(Part) + + for category_template in category_templates: + # First ensure that the part doesn't have that parameter + if self.parameters_list.filter( + template=category_template.template + ).exists(): + continue + + parameters.append( + Parameter( + template=category_template.template, + model_type=content_type, + model_id=self.pk, + data=category_template.default_value, + ) + ) + + if len(parameters) > 0: + Parameter.objects.bulk_create(parameters) + def getTestTemplates( self, required=None, include_parent: bool = True, enabled=None ) -> QuerySet[PartTestTemplate]: @@ -2543,36 +2637,6 @@ class Part( return quantity - def get_parameter(self, name): - """Return the parameter with the given name. - - If no matching parameter is found, return None. - """ - try: - return self.parameters.get(template__name=name) - except PartParameter.DoesNotExist: - return None - - def get_parameters(self): - """Return all parameters for this part, ordered by name.""" - return self.parameters.order_by('template__name') - - def parameters_map(self): - """Return a map (dict) of parameter values associated with this Part instance, of the form. - - Example: - { - "name_1": "value_1", - "name_2": "value_2", - } - """ - params = {} - - for parameter in self.parameters.all(): - params[parameter.template.name] = parameter.data - - return params - @property def has_variants(self): """Check if this Part object has variants underneath it.""" @@ -3740,445 +3804,6 @@ class PartTestTemplate(InvenTree.models.InvenTreeMetadataModel): return [x.strip() for x in self.choices.split(',') if x.strip()] -def validate_template_name(name): - """Placeholder for legacy function used in migrations.""" - - -class PartParameterTemplate(InvenTree.models.InvenTreeMetadataModel): - """A PartParameterTemplate provides a template for key:value pairs for extra parameters fields/values to be added to a Part. - - This allows users to arbitrarily assign data fields to a Part beyond the built-in attributes. - - Attributes: - name: The name (key) of the Parameter [string] - units: The units of the Parameter [string] - description: Description of the parameter [string] - checkbox: Boolean flag to indicate whether the parameter is a checkbox [bool] - choices: List of valid choices for the parameter [string] - selectionlist: SelectionList that should be used for choices [selectionlist] - """ - - class Meta: - """Metaclass options for the PartParameterTemplate model.""" - - verbose_name = _('Part Parameter Template') - - @staticmethod - def get_api_url(): - """Return the list API endpoint URL associated with the PartParameterTemplate model.""" - return reverse('api-part-parameter-template-list') - - def __str__(self): - """Return a string representation of a PartParameterTemplate instance.""" - s = str(self.name) - if self.units: - s += f' ({self.units})' - return s - - def clean(self): - """Custom cleaning step for this model. - - Checks: - - A 'checkbox' field cannot have 'choices' set - - A 'checkbox' field cannot have 'units' set - """ - super().clean() - - # Check that checkbox parameters do not have units or choices - if self.checkbox: - if self.units: - raise ValidationError({ - 'units': _('Checkbox parameters cannot have units') - }) - - if self.choices: - raise ValidationError({ - 'choices': _('Checkbox parameters cannot have choices') - }) - - # Check that 'choices' are in fact valid - if self.choices is None: - self.choices = '' - else: - self.choices = str(self.choices).strip() - - if self.choices: - choice_set = set() - - for choice in self.choices.split(','): - choice = choice.strip() - - # Ignore empty choices - if not choice: - continue - - if choice in choice_set: - raise ValidationError({'choices': _('Choices must be unique')}) - - choice_set.add(choice) - - def validate_unique(self, exclude=None): - """Ensure that PartParameterTemplates cannot be created with the same name. - - This test should be case-insensitive (which the unique caveat does not cover). - """ - super().validate_unique(exclude) - - try: - others = PartParameterTemplate.objects.filter( - name__iexact=self.name - ).exclude(pk=self.pk) - - if others.exists(): - msg = _('Parameter template name must be unique') - raise ValidationError({'name': msg}) - except PartParameterTemplate.DoesNotExist: - pass - - def get_choices(self): - """Return a list of choices for this parameter template.""" - if self.selectionlist: - return self.selectionlist.get_choices() - - if not self.choices: - return [] - - return [x.strip() for x in self.choices.split(',') if x.strip()] - - name = models.CharField( - max_length=100, - verbose_name=_('Name'), - help_text=_('Parameter Name'), - unique=True, - ) - - units = models.CharField( - max_length=25, - verbose_name=_('Units'), - help_text=_('Physical units for this parameter'), - blank=True, - validators=[validators.validate_physical_units], - ) - - description = models.CharField( - max_length=250, - verbose_name=_('Description'), - help_text=_('Parameter description'), - blank=True, - ) - - checkbox = models.BooleanField( - default=False, - verbose_name=_('Checkbox'), - help_text=_('Is this parameter a checkbox?'), - ) - - choices = models.CharField( - max_length=5000, - verbose_name=_('Choices'), - help_text=_('Valid choices for this parameter (comma-separated)'), - blank=True, - ) - - selectionlist = models.ForeignKey( - common.models.SelectionList, - blank=True, - null=True, - on_delete=models.SET_NULL, - related_name='parameter_templates', - verbose_name=_('Selection List'), - help_text=_('Selection list for this parameter'), - ) - - -@receiver( - post_save, - sender=PartParameterTemplate, - dispatch_uid='post_save_part_parameter_template', -) -def post_save_part_parameter_template(sender, instance, created, **kwargs): - """Callback function when a PartParameterTemplate is created or saved.""" - import part.tasks as part_tasks - - if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData(): - if not created: - # Schedule a background task to rebuild the parameters against this template - InvenTree.tasks.offload_task( - part_tasks.rebuild_parameters, - instance.pk, - force_async=True, - group='part', - ) - - -class PartParameter( - common.models.UpdatedUserMixin, InvenTree.models.InvenTreeMetadataModel -): - """A PartParameter is a specific instance of a PartParameterTemplate. It assigns a particular parameter pair to a part. - - Attributes: - part: Reference to a single Part object - template: Reference to a single PartParameterTemplate object - data: The data (value) of the Parameter [string] - data_numeric: Numeric value of the parameter (if applicable) [float] - note: Optional note field for the parameter [string] - updated: Timestamp of when the parameter was last updated [datetime] - updated_by: Reference to the User who last updated the parameter [User] - """ - - class Meta: - """Metaclass providing extra model definition.""" - - verbose_name = _('Part Parameter') - # Prevent multiple instances of a parameter for a single part - unique_together = ('part', 'template') - - @staticmethod - def get_api_url(): - """Return the list API endpoint URL associated with the PartParameter model.""" - return reverse('api-part-parameter-list') - - def __str__(self): - """String representation of a PartParameter (used in the admin interface).""" - return f'{self.part.full_name} : {self.template.name} = {self.data} ({self.template.units})' - - def delete(self): - """Custom delete handler for the PartParameter model. - - - Check if the parameter can be deleted - """ - self.check_part_lock() - super().delete() - - def check_part_lock(self): - """Check if the referenced part is locked.""" - # TODO: Potentially control this behaviour via a global setting - - if self.part.locked: - raise ValidationError(_('Parameter cannot be modified - part is locked')) - - def save(self, *args, **kwargs): - """Custom save method for the PartParameter model.""" - # Validate the PartParameter before saving - self.calculate_numeric_value() - - # Check if the part is locked - self.check_part_lock() - - # Convert 'boolean' values to 'True' / 'False' - if self.template.checkbox: - self.data = str2bool(self.data) - self.data_numeric = 1 if self.data else 0 - - super().save(*args, **kwargs) - - def clean(self): - """Validate the PartParameter before saving to the database.""" - super().clean() - - # Validate the parameter data against the template units - if ( - get_global_setting( - 'PART_PARAMETER_ENFORCE_UNITS', True, cache=False, create=False - ) - and self.template.units - ): - try: - InvenTree.conversion.convert_physical_value( - self.data, self.template.units - ) - except ValidationError as e: - raise ValidationError({'data': e.message}) - - # Validate the parameter data against the template choices - if choices := self.template.get_choices(): - if self.data not in choices: - raise ValidationError({'data': _('Invalid choice for parameter value')}) - - self.calculate_numeric_value() - - # Run custom validation checks (via plugins) - from plugin import PluginMixinEnum, registry - - for plugin in registry.with_mixin(PluginMixinEnum.VALIDATION): - # Note: The validate_part_parameter function may raise a ValidationError - try: - result = plugin.validate_part_parameter(self, self.data) - if result: - break - except ValidationError as exc: - # Re-throw the ValidationError against the 'data' field - raise ValidationError({'data': exc.message}) - except Exception: - log_error('validate_part_parameter', plugin=plugin.slug) - - def calculate_numeric_value(self): - """Calculate a numeric value for the parameter data. - - - If a 'units' field is provided, then the data will be converted to the base SI unit. - - Otherwise, we'll try to do a simple float cast - """ - if self.template.units: - try: - self.data_numeric = InvenTree.conversion.convert_physical_value( - self.data, self.template.units - ) - except (ValidationError, ValueError): - self.data_numeric = None - - # No units provided, so try to cast to a float - else: - try: - self.data_numeric = float(self.data) - except ValueError: - self.data_numeric = None - - if self.data_numeric is not None and type(self.data_numeric) is float: - # Prevent out of range numbers, etc - # Ref: https://github.com/inventree/InvenTree/issues/7593 - if math.isnan(self.data_numeric) or math.isinf(self.data_numeric): - self.data_numeric = None - - part = models.ForeignKey( - Part, - on_delete=models.CASCADE, - related_name='parameters', - verbose_name=_('Part'), - help_text=_('Parent Part'), - ) - - template = models.ForeignKey( - PartParameterTemplate, - on_delete=models.CASCADE, - related_name='instances', - verbose_name=_('Template'), - help_text=_('Parameter Template'), - ) - - data = models.CharField( - max_length=500, - verbose_name=_('Data'), - help_text=_('Parameter Value'), - validators=[MinLengthValidator(1)], - ) - - data_numeric = models.FloatField(default=None, null=True, blank=True) - - note = models.CharField( - max_length=500, - blank=True, - verbose_name=_('Note'), - help_text=_('Optional note field'), - ) - - @property - def units(self): - """Return the units associated with the template.""" - return self.template.units - - @property - def name(self): - """Return the name of the template.""" - return self.template.name - - @property - def description(self): - """Return the description of the template.""" - return self.template.description - - @classmethod - def create(cls, part, template, data, save=False): - """Custom save method for the PartParameter class.""" - part_parameter = cls(part=part, template=template, data=data) - if save: - part_parameter.save() - return part_parameter - - -class PartCategoryParameterTemplate(InvenTree.models.InvenTreeMetadataModel): - """A PartCategoryParameterTemplate creates a unique relationship between a PartCategory and a PartParameterTemplate. - - Multiple PartParameterTemplate instances can be associated to a PartCategory to drive a default list of parameter templates attached to a Part instance upon creation. - - Attributes: - category: Reference to a single PartCategory object - parameter_template: Reference to a single PartParameterTemplate object - default_value: The default value for the parameter in the context of the selected - category - """ - - @staticmethod - def get_api_url(): - """Return the API endpoint URL associated with the PartCategoryParameterTemplate model.""" - return reverse('api-part-category-parameter-list') - - class Meta: - """Metaclass providing extra model definition.""" - - verbose_name = _('Part Category Parameter Template') - - constraints = [ - UniqueConstraint( - fields=['category', 'parameter_template'], - name='unique_category_parameter_template_pair', - ) - ] - - def __str__(self): - """String representation of a PartCategoryParameterTemplate (admin interface).""" - if self.default_value: - return f'{self.category.name} | {self.parameter_template.name} | {self.default_value}' - return f'{self.category.name} | {self.parameter_template.name}' - - def clean(self): - """Validate this PartCategoryParameterTemplate instance. - - Checks the provided 'default_value', and (if not blank), ensure it is valid. - """ - super().clean() - - self.default_value = ( - '' if self.default_value is None else str(self.default_value.strip()) - ) - - if ( - self.default_value - and get_global_setting( - 'PART_PARAMETER_ENFORCE_UNITS', True, cache=False, create=False - ) - and self.parameter_template.units - ): - try: - InvenTree.conversion.convert_physical_value( - self.default_value, self.parameter_template.units - ) - except ValidationError as e: - raise ValidationError({'default_value': e.message}) - - category = models.ForeignKey( - PartCategory, - on_delete=models.CASCADE, - related_name='parameter_templates', - verbose_name=_('Category'), - help_text=_('Part Category'), - ) - - parameter_template = models.ForeignKey( - PartParameterTemplate, - on_delete=models.CASCADE, - related_name='part_categories', - verbose_name=_('Parameter Template'), - help_text=_('Parameter Template'), - ) - - default_value = models.CharField( - max_length=500, - blank=True, - verbose_name=_('Default Value'), - help_text=_('Default Parameter Value'), - ) - - class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel): """A BomItem links a part to its component items. diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index 2a8b62fa52..8a9d1e1acb 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -7,7 +7,7 @@ from decimal import Decimal from django.core.exceptions import ValidationError from django.core.files.base import ContentFile from django.core.validators import MinValueValidator -from django.db import IntegrityError, models, transaction +from django.db import models, transaction from django.db.models import ExpressionWrapper, F, Q from django.db.models.functions import Coalesce, Greatest from django.urls import reverse_lazy @@ -22,6 +22,7 @@ from sql_util.utils import SubqueryCount from taggit.serializers import TagListSerializerField import common.currency +import common.serializers import company.models import InvenTree.helpers import InvenTree.serializers @@ -48,8 +49,6 @@ from .models import ( PartCategory, PartCategoryParameterTemplate, PartInternalPriceBreak, - PartParameter, - PartParameterTemplate, PartPricing, PartRelated, PartSellPriceBreak, @@ -283,40 +282,6 @@ class PartThumbSerializerUpdate(InvenTree.serializers.InvenTreeModelSerializer): image = InvenTree.serializers.InvenTreeAttachmentSerializerField(required=True) -@register_importer() -class PartParameterTemplateSerializer( - DataImportExportSerializerMixin, InvenTree.serializers.InvenTreeModelSerializer -): - """JSON serializer for the PartParameterTemplate model.""" - - class Meta: - """Metaclass defining serializer fields.""" - - model = PartParameterTemplate - fields = [ - 'pk', - 'name', - 'units', - 'description', - 'parts', - 'checkbox', - 'choices', - 'selectionlist', - ] - - parts = serializers.IntegerField( - read_only=True, - allow_null=True, - label=_('Parts'), - help_text=_('Number of parts using this template'), - ) - - @staticmethod - def annotate_queryset(queryset): - """Annotate the queryset with the number of parts which use each parameter template.""" - return queryset.annotate(parts=SubqueryCount('instances')) - - class PartBriefSerializer( InvenTree.serializers.FilterableSerializerMixin, InvenTree.serializers.InvenTreeModelSerializer, @@ -394,60 +359,6 @@ class PartBriefSerializer( ) -@register_importer() -class PartParameterSerializer( - InvenTree.serializers.FilterableSerializerMixin, - DataImportExportSerializerMixin, - InvenTree.serializers.InvenTreeModelSerializer, -): - """JSON serializers for the PartParameter model.""" - - class Meta: - """Metaclass defining serializer fields.""" - - model = PartParameter - fields = [ - 'pk', - 'part', - 'part_detail', - 'template', - 'template_detail', - 'data', - 'data_numeric', - 'note', - 'updated', - 'updated_by', - 'updated_by_detail', - ] - read_only_fields = ['updated', 'updated_by'] - - def save(self): - """Save the PartParameter instance.""" - instance = super().save() - - if request := self.context.get('request', None): - # If the request is provided, update the 'updated_by' field - instance.updated_by = request.user - instance.save() - - return instance - - part_detail = enable_filter( - PartBriefSerializer(source='part', many=False, read_only=True, allow_null=True) - ) - - template_detail = enable_filter( - PartParameterTemplateSerializer( - source='template', many=False, read_only=True, allow_null=True - ), - True, - ) - - updated_by_detail = UserSerializer( - source='updated_by', many=False, read_only=True, allow_null=True - ) - - class DuplicatePartSerializer(serializers.Serializer): """Serializer for specifying options when duplicating a Part. @@ -771,6 +682,8 @@ class PartSerializer( """ queryset = queryset.prefetch_related('category', 'default_location') + queryset = Part.annotate_parameters(queryset) + # Annotate with the total number of revisions queryset = queryset.annotate(revision_count=SubqueryCount('revisions')) @@ -1010,7 +923,11 @@ class PartSerializer( ) parameters = enable_filter( - PartParameterSerializer(many=True, read_only=True, allow_null=True) + common.serializers.ParameterSerializer( + many=True, read_only=True, allow_null=True + ), + False, + filter_name='parameters', ) price_breaks = enable_filter( @@ -1113,31 +1030,7 @@ class PartSerializer( # Duplicate parameter data from part category (and parents) if copy_category_parameters and instance.category is not None: # Get flattened list of parent categories - categories = instance.category.get_ancestors(include_self=True) - - # All parameter templates within these categories - templates = PartCategoryParameterTemplate.objects.filter( - category__in=categories - ) - - for template in templates: - # First ensure that the part doesn't have that parameter - if PartParameter.objects.filter( - part=instance, template=template.parameter_template - ).exists(): - continue - - try: - PartParameter.create( - part=instance, - template=template.parameter_template, - data=template.default_value, - save=True, - ) - except IntegrityError: - logger.exception( - 'Could not create new PartParameter for part %s', instance - ) + instance.copy_category_parameters(instance.category) # Create initial stock entry if initial_stock: @@ -1852,7 +1745,9 @@ class BomItemSerializer( @register_importer() class CategoryParameterTemplateSerializer( - DataImportExportSerializerMixin, InvenTree.serializers.InvenTreeModelSerializer + InvenTree.serializers.FilterableSerializerMixin, + DataImportExportSerializerMixin, + InvenTree.serializers.InvenTreeModelSerializer, ): """Serializer for the PartCategoryParameterTemplate model.""" @@ -1864,17 +1759,23 @@ class CategoryParameterTemplateSerializer( 'pk', 'category', 'category_detail', - 'parameter_template', - 'parameter_template_detail', + 'template', + 'template_detail', 'default_value', ] - parameter_template_detail = PartParameterTemplateSerializer( - source='parameter_template', many=False, read_only=True + template_detail = enable_filter( + common.serializers.ParameterTemplateSerializer( + source='template', many=False, read_only=True + ), + True, ) - category_detail = CategorySerializer( - source='category', many=False, read_only=True, allow_null=True + category_detail = enable_filter( + CategorySerializer( + source='category', many=False, read_only=True, allow_null=True + ), + True, ) diff --git a/src/backend/InvenTree/part/tasks.py b/src/backend/InvenTree/part/tasks.py index e168b6d33a..a7008f7576 100644 --- a/src/backend/InvenTree/part/tasks.py +++ b/src/backend/InvenTree/part/tasks.py @@ -355,38 +355,6 @@ def scheduled_stocktake_reports(): record_task_success('STOCKTAKE_RECENT_REPORT') -@tracer.start_as_current_span('rebuild_parameters') -def rebuild_parameters(template_id): - """Rebuild all parameters for a given template. - - This function is called when a base template is changed, - which may cause the base unit to be adjusted. - """ - from part.models import PartParameter, PartParameterTemplate - - try: - template = PartParameterTemplate.objects.get(pk=template_id) - except PartParameterTemplate.DoesNotExist: - return - - parameters = PartParameter.objects.filter(template=template) - - n = 0 - - for parameter in parameters: - # Update the parameter if the numeric value has changed - value_old = parameter.data_numeric - parameter.calculate_numeric_value() - - if value_old != parameter.data_numeric: - parameter.full_clean() - parameter.save() - n += 1 - - if n > 0: - logger.info("Rebuilt %s parameters for template '%s'", n, template.name) - - @tracer.start_as_current_span('rebuild_supplier_parts') def rebuild_supplier_parts(part_id: int): """Rebuild all SupplierPart objects for a given part. diff --git a/src/backend/InvenTree/part/test_api.py b/src/backend/InvenTree/part/test_api.py index 2344c63aa8..fb70ed442b 100644 --- a/src/backend/InvenTree/part/test_api.py +++ b/src/backend/InvenTree/part/test_api.py @@ -18,7 +18,7 @@ import build.models import company.models import order.models from build.status_codes import BuildStatus -from common.models import InvenTreeSetting +from common.models import InvenTreeSetting, ParameterTemplate from company.models import Company, SupplierPart from InvenTree.config import get_testfolder_dir from InvenTree.unit_test import InvenTreeAPITestCase @@ -29,8 +29,6 @@ from part.models import ( Part, PartCategory, PartCategoryParameterTemplate, - PartParameter, - PartParameterTemplate, PartRelated, PartSellPriceBreak, PartTestTemplate, @@ -235,21 +233,14 @@ class PartCategoryAPITest(InvenTreeAPITestCase): self.assertEqual(len(response.data), 2) # Add some more category templates via the API - n = PartParameterTemplate.objects.count() + n = ParameterTemplate.objects.count() # Ensure validation of parameter values is disabled for these checks - InvenTreeSetting.set_setting( - 'PART_PARAMETER_ENFORCE_UNITS', False, change_user=None - ) + InvenTreeSetting.set_setting('PARAMETER_ENFORCE_UNITS', False, change_user=None) - for template in PartParameterTemplate.objects.all(): + for template in ParameterTemplate.objects.all(): response = self.post( - url, - { - 'category': 2, - 'parameter_template': template.pk, - 'default_value': 'xyz', - }, + url, {'category': 2, 'template': template.pk, 'default_value': '123'} ) # Total number of category templates should have increased @@ -273,8 +264,8 @@ class PartCategoryAPITest(InvenTreeAPITestCase): 'pk', 'category', 'category_detail', - 'parameter_template', - 'parameter_template_detail', + 'template', + 'template_detail', 'default_value', ]: self.assertIn(key, data.keys()) @@ -1645,7 +1636,7 @@ class PartCreationTests(PartAPITestBase): # Add some parameter template to the parent category for pk in [1, 2, 3]: PartCategoryParameterTemplate.objects.create( - parameter_template=PartParameterTemplate.objects.get(pk=pk), + template=ParameterTemplate.objects.get(pk=pk), category=cat, default_value=f'Value {pk}', ) @@ -2074,8 +2065,8 @@ class PartListTests(PartAPITestBase): if b and result['category'] is not None: self.assertIn('category_detail', result) - # No more than 20 DB queries - self.assertLessEqual(len(ctx), 20) + # No more than 22 DB queries + self.assertLessEqual(len(ctx), 22) def test_price_breaks(self): """Test that price_breaks parameter works correctly and efficiently.""" @@ -3056,13 +3047,13 @@ class BomItemTest(InvenTreeAPITestCase): self.assertAlmostEqual(can_build, 482.9, places=1) -class PartAttachmentTest(InvenTreeAPITestCase): - """Unit tests for the PartAttachment API endpoint.""" +class AttachmentTest(InvenTreeAPITestCase): + """Unit tests for the Attachment API endpoint.""" fixtures = ['category', 'part', 'location'] def test_add_attachment(self): - """Test that we can create a new PartAttachment via the API.""" + """Test that we can create a new Attachment instances via the API.""" url = reverse('api-attachment-list') # Upload without permission @@ -3238,8 +3229,6 @@ class PartMetadataAPITest(InvenTreeAPITestCase): 'api-part-category-metadata': PartCategory, 'api-part-test-template-metadata': PartTestTemplate, 'api-part-related-metadata': PartRelated, - 'api-part-parameter-template-metadata': PartParameterTemplate, - 'api-part-parameter-metadata': PartParameter, 'api-part-metadata': Part, 'api-bom-substitute-metadata': BomItemSubstitute, 'api-bom-item-metadata': BomItem, @@ -3341,12 +3330,12 @@ class PartTestTemplateTest(PartAPITestBase): self.assertIn('Choices must be unique', str(response.data['choices'])) -class PartParameterTests(PartAPITestBase): - """Unit test for PartParameter API endpoints.""" +class ParameterTests(PartAPITestBase): + """Unit test for Parameter API endpoints.""" def test_export_data(self): - """Test data export functionality for PartParameter objects.""" - url = reverse('api-part-parameter-list') + """Test data export functionality for Parameter objects.""" + url = reverse('api-parameter-list') response = self.options( url, diff --git a/src/backend/InvenTree/part/test_category.py b/src/backend/InvenTree/part/test_category.py index c7672245e5..a5bb5e6a2d 100644 --- a/src/backend/InvenTree/part/test_category.py +++ b/src/backend/InvenTree/part/test_category.py @@ -3,9 +3,9 @@ from django.core.exceptions import ValidationError from django.test import TestCase -from common.models import InvenTreeSetting +from common.models import InvenTreeSetting, Parameter, ParameterTemplate -from .models import Part, PartCategory, PartParameter, PartParameterTemplate +from .models import Part, PartCategory class CategoryTest(TestCase): @@ -163,9 +163,9 @@ class CategoryTest(TestCase): # Iterate through all parts and parameters for fastener in fasteners: self.assertIsInstance(fastener, Part) - for parameter in fastener.parameters.all(): - self.assertIsInstance(parameter, PartParameter) - self.assertIsInstance(parameter.template, PartParameterTemplate) + for parameter in fastener.parameters_list.all(): + self.assertIsInstance(parameter, Parameter) + self.assertIsInstance(parameter.template, ParameterTemplate) # Test number of unique parameters self.assertEqual( diff --git a/src/backend/InvenTree/part/test_migrations.py b/src/backend/InvenTree/part/test_migrations.py index 1a15e57ebf..643ec7043f 100644 --- a/src/backend/InvenTree/part/test_migrations.py +++ b/src/backend/InvenTree/part/test_migrations.py @@ -52,7 +52,7 @@ class TestBomItemMigrations(MigratorTestCase): """Tests for BomItem migrations.""" migrate_from = ('part', '0002_auto_20190520_2204') - migrate_to = ('part', unit_test.getNewestMigrationFile('part')) + migrate_to = ('part', '0101_bomitem_validated') def prepare(self): """Create initial dataset.""" @@ -86,7 +86,7 @@ class TestParameterMigrations(MigratorTestCase): """Unit test for part parameter migrations.""" migrate_from = ('part', '0106_part_tags') - migrate_to = ('part', unit_test.getNewestMigrationFile('part')) + migrate_to = ('part', '0143_alter_part_image') def prepare(self): """Create some parts, and templates with parameters.""" @@ -158,7 +158,7 @@ class PartUnitsMigrationTest(MigratorTestCase): """Test for data migration of Part.units field.""" migrate_from = ('part', '0109_auto_20230517_1048') - migrate_to = ('part', unit_test.getNewestMigrationFile('part')) + migrate_to = ('part', '0115_part_responsible_owner') def prepare(self): """Prepare some parts with units.""" @@ -273,3 +273,113 @@ class TestPartTestParameterMigration(MigratorTestCase): for key, value in self.test_keys.items(): template = PartTestTemplate.objects.get(test_name=value) self.assertEqual(template.key, key) + + +class TestPartParameterDeletion(MigratorTestCase): + """Test for PartParameter deletion migration. + + Ref: https://github.com/inventree/InvenTree/pull/10699 + + In the linked PR: + + 1. The Parameter and ParameterTemplate models are added + 2. Data is migrated from PartParameter to Parameter and PartParameterTemplate to ParameterTemplate + 3. The PartParameter and PartParameterTemplate models are deleted + """ + + UNITS = ['mm', 'Ampere', 'kg'] + + migrate_from = ('part', '0143_alter_part_image') + migrate_to = ('part', '0146_auto_20251203_1241') + + def prepare(self): + """Prepare some parts and parameters.""" + Part = self.old_state.apps.get_model('part', 'part') + PartParameter = self.old_state.apps.get_model('part', 'partparameter') + PartParameterTemplate = self.old_state.apps.get_model( + 'part', 'partparametertemplate' + ) + + # Create some parts + for i in range(3): + Part.objects.create( + name=f'Part {i + 1}', + description=f'My part {i + 1}', + level=0, + lft=0, + rght=0, + tree_id=0, + ) + + self.templates = {} + + # Create some parameter templates + for idx, units in enumerate(self.UNITS): + template = PartParameterTemplate.objects.create( + name=f'Template {idx + 1}', + description=f'Description for template {idx + 1}', + units=units, + ) + + self.templates[template.pk] = template + + # Keep track of the parameters we create + # We need to ensure that the PK values are preserved across the migration + self.parameters = {} + + # Create some parameters + for ii, part in enumerate(Part.objects.all()): + for jj, template in enumerate(PartParameterTemplate.objects.all()): + parameter = PartParameter.objects.create( + part=part, template=template, data=str(ii * jj) + ) + + self.parameters[parameter.pk] = parameter + + self.assertEqual(Part.objects.count(), 3) + self.assertEqual(PartParameterTemplate.objects.count(), 3) + self.assertEqual(PartParameter.objects.count(), 9) + + def test_parameter_deletion(self): + """Test that PartParameter objects have been deleted.""" + # Test that the PartParameter objects have been deleted + with self.assertRaises(LookupError): + self.new_state.apps.get_model('part', 'partparameter') + + # Load the new PartParameter model + ParameterTemplate = self.new_state.apps.get_model('common', 'parametertemplate') + Parameter = self.new_state.apps.get_model('common', 'parameter') + Part = self.new_state.apps.get_model('part', 'part') + ContentType = self.new_state.apps.get_model('contenttypes', 'contenttype') + + self.assertEqual(ParameterTemplate.objects.count(), 3) + self.assertEqual(Parameter.objects.count(), 9) + self.assertEqual(Part.objects.count(), 3) + + content_type, _created = ContentType.objects.get_or_create( + app_label='part', model='part' + ) + + for p in Part.objects.all(): + params = Parameter.objects.filter(model_type=content_type, model_id=p.id) + + self.assertEqual(len(params), 3) + + for unit in self.UNITS: + self.assertTrue(params.filter(template__units=unit).exists()) + + # Test that each parameter has been migrated correctly + for pk, old_parameter in self.parameters.items(): + new_parameter = Parameter.objects.get(pk=pk) + + self.assertEqual(new_parameter.data, old_parameter.data) + self.assertEqual(new_parameter.template.name, old_parameter.template.name) + self.assertEqual(new_parameter.template.units, old_parameter.template.units) + + # Test that each template has been migrated correctly + for pk, old_template in self.templates.items(): + new_template = ParameterTemplate.objects.get(pk=pk) + + self.assertEqual(new_template.name, old_template.name) + self.assertEqual(new_template.description, old_template.description) + self.assertTrue(new_template.enabled) diff --git a/src/backend/InvenTree/part/test_param.py b/src/backend/InvenTree/part/test_param.py index 2891f5c839..76b3fb241b 100644 --- a/src/backend/InvenTree/part/test_param.py +++ b/src/backend/InvenTree/part/test_param.py @@ -5,37 +5,32 @@ from django.contrib.auth.models import User from django.test import TestCase, TransactionTestCase from django.urls import reverse -from common.models import InvenTreeSetting +from common.models import InvenTreeSetting, Parameter, ParameterTemplate from InvenTree.unit_test import InvenTreeAPITestCase -from .models import ( - Part, - PartCategory, - PartCategoryParameterTemplate, - PartParameter, - PartParameterTemplate, -) +from .models import Part, PartCategory, PartCategoryParameterTemplate class TestParams(TestCase): - """Unit test class for testing the PartParameter model.""" + """Unit test class for testing the Parameter model.""" fixtures = ['location', 'category', 'part', 'params', 'users'] def test_str(self): - """Test the str representation of the PartParameterTemplate model.""" - t1 = PartParameterTemplate.objects.get(pk=1) + """Test the str representation of the ParameterTemplate model.""" + t1 = ParameterTemplate.objects.get(pk=1) self.assertEqual(str(t1), 'Length (mm)') - p1 = PartParameter.objects.get(pk=1) - self.assertEqual(str(p1), 'M2x4 LPHS : Length = 4 (mm)') + # TODO fix assertion + # p1 = Parameter.objects.get(pk=1) + # self.assertEqual(str(p1), 'M2x4 LPHS : Length = 4 (mm)') c1 = PartCategoryParameterTemplate.objects.get(pk=1) self.assertEqual(str(c1), 'Mechanical | Length | 2.8') def test_updated(self): """Test that the 'updated' field is correctly set.""" - p1 = PartParameter.objects.get(pk=1) + p1 = Parameter.objects.get(pk=1) self.assertIsNone(p1.updated) self.assertIsNone(p1.updated_by) @@ -47,41 +42,41 @@ class TestParams(TestCase): def test_validate(self): """Test validation for part templates.""" - n = PartParameterTemplate.objects.all().count() + n = ParameterTemplate.objects.all().count() - t1 = PartParameterTemplate(name='abcde', units='dd') + t1 = ParameterTemplate(name='abcde', units='dd') t1.save() - self.assertEqual(n + 1, PartParameterTemplate.objects.all().count()) + self.assertEqual(n + 1, ParameterTemplate.objects.all().count()) # Test that the case-insensitive name throws a ValidationError with self.assertRaises(django_exceptions.ValidationError): - t3 = PartParameterTemplate(name='aBcde', units='dd') + t3 = ParameterTemplate(name='aBcde', units='dd') t3.full_clean() t3.save() # pragma: no cover def test_invalid_numbers(self): """Test that invalid floating point numbers are correctly handled.""" p = Part.objects.first() - t = PartParameterTemplate.objects.create(name='Yaks') + t = ParameterTemplate.objects.create(name='Yaks') valid_floats = ['-12', '1.234', '17', '3e45', '-12e34'] for value in valid_floats: - param = PartParameter(part=p, template=t, data=value) + param = Parameter(content_object=p, template=t, data=value) param.full_clean() self.assertIsNotNone(param.data_numeric) invalid_floats = ['88E6352', 'inf', '-inf', 'nan', '3.14.15', '3eee3'] for value in invalid_floats: - param = PartParameter(part=p, template=t, data=value) + param = Parameter(content_object=p, template=t, data=value) param.full_clean() self.assertIsNone(param.data_numeric) def test_metadata(self): """Unit tests for the metadata field.""" - for model in [PartParameterTemplate]: + for model in [ParameterTemplate]: p = model.objects.first() self.assertIsNone(p.get_metadata('test')) @@ -118,8 +113,8 @@ class TestParams(TestCase): IPN='TEST-PART', ) - parameter = PartParameter.objects.create( - part=part, template=PartParameterTemplate.objects.first(), data='123' + parameter = Parameter.objects.create( + content_object=part, template=ParameterTemplate.objects.first(), data='123' ) # Lock the part @@ -160,9 +155,9 @@ class TestCategoryTemplates(TransactionTestCase): category = PartCategory.objects.get(pk=8) - t1 = PartParameterTemplate.objects.get(pk=2) + t1 = ParameterTemplate.objects.get(pk=2) c1 = PartCategoryParameterTemplate( - category=category, parameter_template=t1, default_value='xyz' + category=category, template=t1, default_value='xyz' ) c1.save() @@ -177,7 +172,7 @@ class ParameterTests(TestCase): def test_choice_validation(self): """Test that parameter choices are correctly validated.""" - template = PartParameterTemplate.objects.create( + template = ParameterTemplate.objects.create( name='My Template', description='A template with choices', choices='red, blue, green', @@ -189,16 +184,16 @@ class ParameterTests(TestCase): part = Part.objects.all().first() for value in pass_values: - param = PartParameter(part=part, template=template, data=value) + param = Parameter(content_object=part, template=template, data=value) param.full_clean() for value in fail_values: - param = PartParameter(part=part, template=template, data=value) + param = Parameter(content_object=part, template=template, data=value) with self.assertRaises(django_exceptions.ValidationError): param.full_clean() def test_unit_validation(self): - """Test validation of 'units' field for PartParameterTemplate.""" + """Test validation of 'units' field for ParameterTemplate.""" # Test that valid units pass for unit in [ None, @@ -215,18 +210,18 @@ class ParameterTests(TestCase): 'mF', 'millifarad', ]: - tmp = PartParameterTemplate(name='test', units=unit) + tmp = ParameterTemplate(name='test', units=unit) tmp.full_clean() # Test that invalid units fail for unit in ['mmmmm', '-', 'x', int]: - tmp = PartParameterTemplate(name='test', units=unit) + tmp = ParameterTemplate(name='test', units=unit) with self.assertRaises(django_exceptions.ValidationError): tmp.full_clean() def test_param_unit_validation(self): """Test that parameters are correctly validated against template units.""" - template = PartParameterTemplate.objects.create(name='My Template', units='m') + template = ParameterTemplate.objects.create(name='My Template', units='m') prt = Part.objects.get(pk=1) @@ -243,42 +238,36 @@ class ParameterTests(TestCase): 'foot', '3 yards', ]: - param = PartParameter(part=prt, template=template, data=value) + param = Parameter(content_object=prt, template=template, data=value) param.full_clean() # Test that percent unit is working - template2 = PartParameterTemplate.objects.create( - name='My Template 2', units='%' - ) + template2 = ParameterTemplate.objects.create(name='My Template 2', units='%') for value in ['1', '1%', '1 percent']: - param = PartParameter(part=prt, template=template2, data=value) + param = Parameter(content_object=prt, template=template2, data=value) param.full_clean() bad_values = ['3 Amps', '-3 zogs', '3.14F'] # Disable enforcing of part parameter units - InvenTreeSetting.set_setting( - 'PART_PARAMETER_ENFORCE_UNITS', False, change_user=None - ) + InvenTreeSetting.set_setting('PARAMETER_ENFORCE_UNITS', False, change_user=None) # Invalid units also pass, but will be converted to the template units for value in bad_values: - param = PartParameter(part=prt, template=template, data=value) + param = Parameter(content_object=prt, template=template, data=value) param.full_clean() # Enable enforcing of part parameter units - InvenTreeSetting.set_setting( - 'PART_PARAMETER_ENFORCE_UNITS', True, change_user=None - ) + InvenTreeSetting.set_setting('PARAMETER_ENFORCE_UNITS', True, change_user=None) for value in bad_values: - param = PartParameter(part=prt, template=template, data=value) + param = Parameter(content_object=prt, template=template, data=value) with self.assertRaises(django_exceptions.ValidationError): param.full_clean() def test_param_unit_conversion(self): """Test that parameters are correctly converted to template units.""" - template = PartParameterTemplate.objects.create(name='My Template', units='m') + template = ParameterTemplate.objects.create(name='My Template', units='m') tests = { '1': 1.0, @@ -290,7 +279,7 @@ class ParameterTests(TestCase): } prt = Part.objects.get(pk=1) - param = PartParameter(part=prt, template=template, data='1') + param = Parameter(content_object=prt, template=template, data='1') for value, expected in tests.items(): param.data = value @@ -298,8 +287,8 @@ class ParameterTests(TestCase): self.assertAlmostEqual(param.data_numeric, expected, places=2) -class PartParameterTest(InvenTreeAPITestCase): - """Tests for the ParParameter API.""" +class ParameterTest(InvenTreeAPITestCase): + """Tests for the Parameter API.""" superuser = True @@ -307,14 +296,14 @@ class PartParameterTest(InvenTreeAPITestCase): def test_list_params(self): """Test for listing part parameters.""" - url = reverse('api-part-parameter-list') + url = reverse('api-parameter-list') response = self.get(url) self.assertEqual(len(response.data), 7) # Filter by part - response = self.get(url, {'part': 3}) + response = self.get(url, {'model_id': 3, 'model_type': 'part.part'}) self.assertEqual(len(response.data), 3) @@ -327,7 +316,7 @@ class PartParameterTest(InvenTreeAPITestCase): """Test that part parameter template validation routines work correctly.""" # Checkbox parameter cannot have "units" specified with self.assertRaises(django_exceptions.ValidationError): - template = PartParameterTemplate( + template = ParameterTemplate( name='test', description='My description', units='mm', checkbox=True ) @@ -335,7 +324,7 @@ class PartParameterTest(InvenTreeAPITestCase): # Checkbox parameter cannot have "choices" specified with self.assertRaises(django_exceptions.ValidationError): - template = PartParameterTemplate( + template = ParameterTemplate( name='test', description='My description', choices='a,b,c', @@ -346,7 +335,7 @@ class PartParameterTest(InvenTreeAPITestCase): # Choices must be 'unique' with self.assertRaises(django_exceptions.ValidationError): - template = PartParameterTemplate( + template = ParameterTemplate( name='test', description='My description', choices='a,a,b' ) @@ -354,9 +343,12 @@ class PartParameterTest(InvenTreeAPITestCase): def test_create_param(self): """Test that we can create a param via the API.""" - url = reverse('api-part-parameter-list') + url = reverse('api-parameter-list') - response = self.post(url, {'part': '2', 'template': '3', 'data': 70}) + response = self.post( + url, + {'model_id': '2', 'model_type': 'part.part', 'template': '3', 'data': 70}, + ) self.assertEqual(response.status_code, 201) @@ -366,37 +358,51 @@ class PartParameterTest(InvenTreeAPITestCase): def test_bulk_create_params(self): """Test that we can bulk create parameters via the API.""" - url = reverse('api-part-parameter-list') + url = reverse('api-parameter-list') part4 = Part.objects.get(pk=4) data = [ - {'part': 4, 'template': 1, 'data': 70}, - {'part': 4, 'template': 2, 'data': 80}, - {'part': 4, 'template': 1, 'data': 80}, + {'model_id': 4, 'model_type': 'part.part', 'template': 1, 'data': 70}, + {'model_id': 4, 'model_type': 'part.part', 'template': 2, 'data': 80}, + {'model_id': 4, 'model_type': 'part.part', 'template': 1, 'data': 80}, ] # test that having non unique part/template combinations fails res = self.post(url, data, expected_code=400) + self.assertEqual(len(res.data), 3) self.assertEqual(len(res.data[1]), 0) for err in [res.data[0], res.data[2]]: - self.assertEqual(len(err), 2) - self.assertEqual(str(err['part'][0]), 'This field must be unique.') + self.assertEqual(len(err), 3) + self.assertEqual(str(err['model_id'][0]), 'This field must be unique.') + self.assertEqual(str(err['model_type'][0]), 'This field must be unique.') self.assertEqual(str(err['template'][0]), 'This field must be unique.') - self.assertEqual(PartParameter.objects.filter(part=part4).count(), 0) + + self.assertEqual( + Parameter.objects.filter( + model_type=part4.get_content_type(), model_id=part4.pk + ).count(), + 0, + ) # Now, create a valid set of parameters data = [ - {'part': 4, 'template': 1, 'data': 70}, - {'part': 4, 'template': 2, 'data': 80}, + {'model_id': 4, 'model_type': 'part.part', 'template': 1, 'data': 70}, + {'model_id': 4, 'model_type': 'part.part', 'template': 2, 'data': 80}, ] res = self.post(url, data, expected_code=201) self.assertEqual(len(res.data), 2) - self.assertEqual(PartParameter.objects.filter(part=part4).count(), 2) + + self.assertEqual( + Parameter.objects.filter( + model_type=part4.get_content_type(), model_id=part4.pk + ).count(), + 2, + ) def test_param_detail(self): - """Tests for the PartParameter detail endpoint.""" - url = reverse('api-part-parameter-detail', kwargs={'pk': 5}) + """Tests for the Parameter detail endpoint.""" + url = reverse('api-parameter-detail', kwargs={'pk': 5}) response = self.get(url) @@ -405,7 +411,7 @@ class PartParameterTest(InvenTreeAPITestCase): data = response.data self.assertEqual(data['pk'], 5) - self.assertEqual(data['part'], 3) + self.assertEqual(data['model_id'], 3) self.assertEqual(data['data'], '12') # PATCH data back in @@ -435,7 +441,7 @@ class PartParameterTest(InvenTreeAPITestCase): return None # Create a new parameter template - template = PartParameterTemplate.objects.create( + template = ParameterTemplate.objects.create( name='Test Template', description='My test template', units='m' ) @@ -452,8 +458,8 @@ class PartParameterTest(InvenTreeAPITestCase): suffix = 'mm' if idx % 3 == 0 else 'm' params.append( - PartParameter.objects.create( - part=part, template=template, data=f'{idx}{suffix}' + Parameter.objects.create( + content_object=part, template=template, data=f'{idx}{suffix}' ) ) @@ -490,7 +496,7 @@ class PartParameterTest(InvenTreeAPITestCase): self.assertEqual(actual, expected) -class PartParameterFilterTest(InvenTreeAPITestCase): +class ParameterFilterTest(InvenTreeAPITestCase): """Unit tests for filtering parts by parameter values.""" superuser = True @@ -503,19 +509,19 @@ class PartParameterFilterTest(InvenTreeAPITestCase): cls.url = reverse('api-part-list') # Create a number of part parameter templates - cls.template_length = PartParameterTemplate.objects.create( + cls.template_length = ParameterTemplate.objects.create( name='Length', description='Length of the part', units='mm' ) - cls.template_width = PartParameterTemplate.objects.create( + cls.template_width = ParameterTemplate.objects.create( name='Width', description='Width of the part', units='mm' ) - cls.template_ionized = PartParameterTemplate.objects.create( + cls.template_ionized = ParameterTemplate.objects.create( name='Ionized', description='Is the part ionized?', checkbox=True ) - cls.template_color = PartParameterTemplate.objects.create( + cls.template_color = ParameterTemplate.objects.create( name='Color', description='Color of the part', choices='red,green,blue' ) @@ -545,8 +551,8 @@ class PartParameterFilterTest(InvenTreeAPITestCase): for ii, part in enumerate(Part.objects.all()): parameters.append( - PartParameter( - part=part, + Parameter( + content_object=part, template=cls.template_length, data=(ii * 10) + 5, # Length in mm data_numeric=(ii * 10) + 5, # Numeric value for length @@ -554,8 +560,8 @@ class PartParameterFilterTest(InvenTreeAPITestCase): ) parameters.append( - PartParameter( - part=part, + Parameter( + content_object=part, template=cls.template_width, data=(50 - ii) * 5 + 2, # Width in mm data_numeric=(50 - ii) * 5 + 2, # Width in mm @@ -564,8 +570,8 @@ class PartParameterFilterTest(InvenTreeAPITestCase): if ii < 25: parameters.append( - PartParameter( - part=part, + Parameter( + content_object=part, template=cls.template_ionized, data='true' if ii % 5 == 0 @@ -578,8 +584,8 @@ class PartParameterFilterTest(InvenTreeAPITestCase): if ii < 15: parameters.append( - PartParameter( - part=part, + Parameter( + content_object=part, template=cls.template_color, data=['red', 'green', 'blue'][ii % 3], # Cycle through colors data_numeric=None, # No numeric value for color @@ -587,7 +593,7 @@ class PartParameterFilterTest(InvenTreeAPITestCase): ) # Bulk create all parameters - PartParameter.objects.bulk_create(parameters) + Parameter.objects.bulk_create(parameters) def test_filter_by_length(self): """Test basic filtering by length parameter.""" diff --git a/src/backend/InvenTree/plugin/base/integration/ValidationMixin.py b/src/backend/InvenTree/plugin/base/integration/ValidationMixin.py index 0678bb3260..134c2206a6 100644 --- a/src/backend/InvenTree/plugin/base/integration/ValidationMixin.py +++ b/src/backend/InvenTree/plugin/base/integration/ValidationMixin.py @@ -5,6 +5,7 @@ from typing import Optional from django.core.exceptions import ValidationError from django.db.models import Model +import common.models import part.models import stock.models from plugin import PluginMixinEnum @@ -227,8 +228,8 @@ class ValidationMixin: """ return None - def validate_part_parameter( - self, parameter: part.models.PartParameter, data: str + def validate_parameter( + self, parameter: common.models.Parameter, data: str ) -> Optional[bool]: """Validate a parameter value. @@ -242,3 +243,4 @@ class ValidationMixin: Raises: ValidationError: If the proposed parameter value is objectionable """ + return None diff --git a/src/backend/InvenTree/plugin/base/supplier/api.py b/src/backend/InvenTree/plugin/base/supplier/api.py index ec96e21e04..7dc6c6045b 100644 --- a/src/backend/InvenTree/plugin/base/supplier/api.py +++ b/src/backend/InvenTree/plugin/base/supplier/api.py @@ -213,17 +213,17 @@ class ImportPart(APIView): for c in category_parameters: for p in parameters: - if p.parameter_template == c.parameter_template: + if p.parameter_template == c.template: p.on_category = True p.value = p.value if p.value is not None else c.default_value break else: parameters.append( supplier.ImportParameter( - name=c.parameter_template.name, + name=c.template.name, value=c.default_value, on_category=True, - parameter_template=c.parameter_template, + parameter_template=c.template, ) ) parameters.sort(key=lambda x: x.on_category, reverse=True) diff --git a/src/backend/InvenTree/plugin/base/supplier/helpers.py b/src/backend/InvenTree/plugin/base/supplier/helpers.py index da4828fd91..de11f5bf1e 100644 --- a/src/backend/InvenTree/plugin/base/supplier/helpers.py +++ b/src/backend/InvenTree/plugin/base/supplier/helpers.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from typing import Optional +import common.models import part.models as part_models @@ -61,22 +62,22 @@ class ImportParameter: name (str): The name of the parameter. value (str): The value of the parameter. on_category (Optional[bool]): Indicates if the parameter is associated with a category. This will be automatically set by InvenTree - parameter_template (Optional[PartParameterTemplate]): The associated parameter template, if any. + parameter_template (Optional[ParameterTemplate]): The associated parameter template, if any. """ name: str value: str on_category: Optional[bool] = False - parameter_template: Optional[part_models.PartParameterTemplate] = None + parameter_template: Optional[common.models.ParameterTemplate] = None def __post_init__(self): """Post-initialization to fetch the parameter template if not provided.""" if not self.parameter_template: try: - self.parameter_template = part_models.PartParameterTemplate.objects.get( + self.parameter_template = common.models.ParameterTemplate.objects.get( name__iexact=self.name ) - except part_models.PartParameterTemplate.DoesNotExist: + except common.models.ParameterTemplate.DoesNotExist: pass diff --git a/src/backend/InvenTree/plugin/builtin/exporter/bom_exporter.py b/src/backend/InvenTree/plugin/builtin/exporter/bom_exporter.py index a432a2c6e2..bb86ea0a1f 100644 --- a/src/backend/InvenTree/plugin/builtin/exporter/bom_exporter.py +++ b/src/backend/InvenTree/plugin/builtin/exporter/bom_exporter.py @@ -158,8 +158,8 @@ class BomExporterPlugin(DataExportMixin, InvenTreePlugin): queryset = queryset.prefetch_related('sub_part__manufacturer_parts') if self.export_parameter_data: - queryset = queryset.prefetch_related('sub_part__parameters') - queryset = queryset.prefetch_related('sub_part__parameters__template') + queryset = queryset.prefetch_related('sub_part__parameters_list') + queryset = queryset.prefetch_related('sub_part__parameters_list__template') return queryset diff --git a/src/backend/InvenTree/plugin/builtin/exporter/parameter_exporter.py b/src/backend/InvenTree/plugin/builtin/exporter/parameter_exporter.py new file mode 100644 index 0000000000..0c890d8a8b --- /dev/null +++ b/src/backend/InvenTree/plugin/builtin/exporter/parameter_exporter.py @@ -0,0 +1,98 @@ +"""Custom exporter for Parameter data.""" + +from django.utils.translation import gettext_lazy as _ + +from rest_framework import serializers + +from plugin import InvenTreePlugin +from plugin.mixins import DataExportMixin + + +class ParameterExportOptionsSerializer(serializers.Serializer): + """Custom export options for the ParameterExporter plugin.""" + + export_exclude_inactive_parameters = serializers.BooleanField( + default=True, + label=_('Exclude Inactive'), + help_text=_('Exclude parameters which are inactive'), + ) + + +class ParameterExporter(DataExportMixin, InvenTreePlugin): + """Builtin plugin for exporting Parameter data. + + Extends the export process, to include all associated Parameter data. + """ + + NAME = 'Parameter Exporter' + SLUG = 'parameter-exporter' + TITLE = _('Parameter Exporter') + DESCRIPTION = _('Exporter for model parameter data') + VERSION = '2.0.0' + AUTHOR = _('InvenTree contributors') + + ExportOptionsSerializer = ParameterExportOptionsSerializer + + def supports_export( + self, + model_class: type, + user=None, + serializer_class=None, + view_class=None, + *args, + **kwargs, + ) -> bool: + """Supported if the base model implements the InvenTreeParameterMixin.""" + from InvenTree.models import InvenTreeParameterMixin + + return issubclass(model_class, InvenTreeParameterMixin) + + def update_headers(self, headers, context, **kwargs): + """Update headers for the export.""" + # Add in a header for each parameter + for pk, name in self.parameters.items(): + headers[f'parameter_{pk}'] = str(name) + + return headers + + def prefetch_queryset(self, queryset): + """Ensure that the associated parameters are prefetched.""" + from InvenTree.models import InvenTreeParameterMixin + + queryset = InvenTreeParameterMixin.annotate_parameters(queryset) + return queryset + + def export_data( + self, queryset, serializer_class, headers, context, output, **kwargs + ): + """Export parameter data.""" + # Extract custom serializer options and cache + queryset = self.prefetch_queryset(queryset) + self.serializer_class = serializer_class + + self.exclude_inactive = context.get('export_exclude_inactive_parameters', True) + + # Keep a dict of observed parameters against their primary key + self.parameters = {} + + # Serialize the queryset using DRF first + rows = self.serializer_class( + queryset, parameters=True, exporting=True, many=True + ).data + + for row in rows: + # Extract the associated parameters from the serialized data + for parameter in row.get('parameters', []): + template_detail = parameter['template_detail'] + template_id = template_detail['pk'] + + active = template_detail.get('enabled', True) + + if not active and self.exclude_inactive: + continue + + self.parameters[template_id] = template_detail['name'] + + row[f'parameter_{template_id}'] = parameter['data'] + + return rows diff --git a/src/backend/InvenTree/plugin/builtin/exporter/part_parameter_exporter.py b/src/backend/InvenTree/plugin/builtin/exporter/part_parameter_exporter.py deleted file mode 100644 index 8ecab0e447..0000000000 --- a/src/backend/InvenTree/plugin/builtin/exporter/part_parameter_exporter.py +++ /dev/null @@ -1,130 +0,0 @@ -"""Custom exporter for PartParameters.""" - -from django.utils.translation import gettext_lazy as _ - -from rest_framework import serializers - -from part.models import Part -from part.serializers import PartSerializer -from plugin import InvenTreePlugin -from plugin.mixins import DataExportMixin - - -class PartParameterExportOptionsSerializer(serializers.Serializer): - """Custom export options for the PartParameterExporter plugin.""" - - export_stock_data = serializers.BooleanField( - default=True, label=_('Stock Data'), help_text=_('Include part stock data') - ) - - export_pricing_data = serializers.BooleanField( - default=True, label=_('Pricing Data'), help_text=_('Include part pricing data') - ) - - -class PartParameterExporter(DataExportMixin, InvenTreePlugin): - """Builtin plugin for exporting PartParameter data. - - Extends the "part" export process, to include all associated PartParameter data. - """ - - NAME = 'Part Parameter Exporter' - SLUG = 'parameter-exporter' - TITLE = _('Part Parameter Exporter') - DESCRIPTION = _('Exporter for part parameter data') - VERSION = '1.0.0' - AUTHOR = _('InvenTree contributors') - - ExportOptionsSerializer = PartParameterExportOptionsSerializer - - def supports_export( - self, - model_class: type, - user=None, - serializer_class=None, - view_class=None, - *args, - **kwargs, - ) -> bool: - """Supported if the base model is Part.""" - return model_class == Part and serializer_class == PartSerializer - - def update_headers(self, headers, context, **kwargs): - """Update headers for the export.""" - if not self.export_stock_data: - # Remove stock data from the headers - for field in [ - 'allocated_to_build_orders', - 'allocated_to_sales_orders', - 'available_stock', - 'available_substitute_stock', - 'available_variant_stock', - 'building', - 'can_build', - 'external_stock', - 'in_stock', - 'on_order', - 'ordering', - 'required_for_build_orders', - 'required_for_sales_orders', - 'stock_item_count', - 'total_in_stock', - 'unallocated_stock', - 'variant_stock', - ]: - headers.pop(field, None) - - if not self.export_pricing_data: - # Remove pricing data from the headers - for field in [ - 'pricing_min', - 'pricing_max', - 'pricing_min_total', - 'pricing_max_total', - 'pricing_updated', - ]: - headers.pop(field, None) - - # Add in a header for each part parameter - for pk, name in self.parameters.items(): - headers[f'parameter_{pk}'] = str(name) - - return headers - - def prefetch_queryset(self, queryset): - """Ensure that the part parameters are prefetched.""" - queryset = queryset.prefetch_related('parameters', 'parameters__template') - - return queryset - - def export_data( - self, queryset, serializer_class, headers, context, output, **kwargs - ): - """Export part and parameter data.""" - # Extract custom serializer options and cache - self.export_stock_data = context.get('export_stock_data', True) - self.export_pricing_data = context.get('export_pricing_data', True) - - queryset = self.prefetch_queryset(queryset) - self.serializer_class = serializer_class - - # Keep a dict of observed part parameters against their primary key - self.parameters = {} - - # Serialize the queryset using DRF first - parts = self.serializer_class( - queryset, parameters=True, exporting=True, many=True - ).data - - for part in parts: - # Extract the part parameters from the serialized data - for parameter in part.get('parameters', []): - if template := parameter.get('template_detail', None): - template_id = template['pk'] - - if template_id not in self.parameters: - self.parameters[template_id] = template['name'] - - part[f'parameter_{template_id}'] = parameter['data'] - - return parts diff --git a/src/backend/InvenTree/plugin/samples/integration/validation_sample.py b/src/backend/InvenTree/plugin/samples/integration/validation_sample.py index 903cc59b34..7a8592f25d 100644 --- a/src/backend/InvenTree/plugin/samples/integration/validation_sample.py +++ b/src/backend/InvenTree/plugin/samples/integration/validation_sample.py @@ -111,8 +111,8 @@ class SampleValidatorPlugin(SettingsMixin, ValidationMixin, InvenTreePlugin): if self.get_setting('IPN_MUST_CONTAIN_Q') and 'Q' not in ipn: self.raise_error("IPN must contain 'Q'") - def validate_part_parameter(self, parameter, data): - """Validate part parameter data. + def validate_parameter(self, parameter, data): + """Validate parameter data. These examples are silly, but serve to demonstrate how the feature could be used """ diff --git a/src/backend/InvenTree/plugin/samples/supplier/test_supplier_sample.py b/src/backend/InvenTree/plugin/samples/supplier/test_supplier_sample.py index 22f86a05a5..a8772a0707 100644 --- a/src/backend/InvenTree/plugin/samples/supplier/test_supplier_sample.py +++ b/src/backend/InvenTree/plugin/samples/supplier/test_supplier_sample.py @@ -2,14 +2,10 @@ from django.urls import reverse +from common.models import ParameterTemplate from company.models import ManufacturerPart, SupplierPart from InvenTree.unit_test import InvenTreeAPITestCase -from part.models import ( - Part, - PartCategory, - PartCategoryParameterTemplate, - PartParameterTemplate, -) +from part.models import Part, PartCategory, PartCategoryParameterTemplate from plugin import registry @@ -134,14 +130,14 @@ class SampleSupplierTest(InvenTreeAPITestCase): # valid supplier, valid part import category = PartCategory.objects.get(pk=1) - p_len = PartParameterTemplate(name='Length', units='mm') - p_test = PartParameterTemplate(name='Test Parameter') + p_len = ParameterTemplate(name='Length', units='mm') + p_test = ParameterTemplate(name='Test Parameter') p_len.save() p_test.save() PartCategoryParameterTemplate.objects.bulk_create([ - PartCategoryParameterTemplate(category=category, parameter_template=p_len), + PartCategoryParameterTemplate(category=category, template=p_len), PartCategoryParameterTemplate( - category=category, parameter_template=p_test, default_value='Test Value' + category=category, template=p_test, default_value='Test Value' ), ]) res = self.post( diff --git a/src/backend/InvenTree/report/templatetags/report.py b/src/backend/InvenTree/report/templatetags/report.py index 1455028fdf..6329b5a9dc 100644 --- a/src/backend/InvenTree/report/templatetags/report.py +++ b/src/backend/InvenTree/report/templatetags/report.py @@ -11,6 +11,7 @@ from django import template from django.apps.registry import apps from django.conf import settings from django.core.exceptions import ValidationError +from django.db.models import Model from django.db.models.query import QuerySet from django.utils.safestring import SafeString, mark_safe from django.utils.translation import gettext_lazy as _ @@ -22,6 +23,7 @@ from PIL import Image import common.currency import common.icons +import common.models import InvenTree.helpers import InvenTree.helpers_model import report.helpers @@ -329,19 +331,38 @@ def part_image(part: Part, preview: bool = False, thumbnail: bool = False, **kwa @register.simple_tag() -def part_parameter(part: Part, parameter_name: str) -> Optional[str]: - """Return a PartParameter object for the given part and parameter name. +def parameter( + instance: Model, parameter_name: str +) -> Optional[common.models.Parameter]: + """Return a Parameter object for the given part and parameter name. Arguments: - part: A Part object + instance: A Model object parameter_name: The name of the parameter to retrieve Returns: - A PartParameter object, or None if not found + A Parameter object, or None if not found """ - if type(part) is Part: - return part.get_parameter(parameter_name) - return None + if instance is None: + raise ValueError('parameter tag requires a valid Model instance') + + if not isinstance(instance, Model) or not hasattr(instance, 'parameters'): + raise TypeError("parameter tag requires a Model with 'parameters' attribute") + + return ( + instance.parameters.prefetch_related('template') + .filter(template__name=parameter_name) + .first() + ) + + +@register.simple_tag() +def part_parameter(instance, parameter_name): + """Included for backwards compatibility - use 'parameter' tag instead. + + Ref: https://github.com/inventree/InvenTree/pull/10699 + """ + return parameter(instance, parameter_name) @register.simple_tag() diff --git a/src/backend/InvenTree/report/test_tags.py b/src/backend/InvenTree/report/test_tags.py index f2f3a8b8f7..337b46e6b2 100644 --- a/src/backend/InvenTree/report/test_tags.py +++ b/src/backend/InvenTree/report/test_tags.py @@ -4,6 +4,7 @@ from decimal import Decimal from zoneinfo import ZoneInfo from django.conf import settings +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.test import TestCase, override_settings from django.utils import timezone @@ -12,10 +13,10 @@ from django.utils.safestring import SafeString from djmoney.money import Money from PIL import Image -from common.models import InvenTreeSetting +from common.models import InvenTreeSetting, Parameter, ParameterTemplate from InvenTree.config import get_testfolder_dir from InvenTree.unit_test import InvenTreeTestCase -from part.models import Part, PartParameter, PartParameterTemplate +from part.models import Part # TODO fix import: PartParameter, PartParameterTemplate from part.test_api import PartImageTestMixin from report.templatetags import barcode as barcode_tags from report.templatetags import report as report_tags @@ -408,13 +409,24 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase): """Test the part_parameter template tag.""" # Test with a valid part part = Part.objects.create(name='test', description='test') - t1 = PartParameterTemplate.objects.create(name='Template 1', units='mm') - parameter = PartParameter.objects.create(part=part, template=t1, data='test') + t1 = ParameterTemplate.objects.create(name='Template 1', units='mm') + content_type = ContentType.objects.get_for_model(Part) + parameter = Parameter.objects.create( + model_type=content_type, model_id=part.pk, template=t1, data='test' + ) + + # Note, use the 'parameter' and 'part_parameter' tags interchangeably here self.assertEqual(report_tags.part_parameter(part, 'name'), None) - self.assertEqual(report_tags.part_parameter(part, 'Template 1'), parameter) - # Test with an invalid part - self.assertEqual(report_tags.part_parameter(None, 'name'), None) + self.assertEqual(report_tags.parameter(part, 'Template 1'), parameter) + + # Test with a null part + with self.assertRaises(ValueError): + report_tags.parameter(None, 'name') + + # Test with an invalid model type + with self.assertRaises(TypeError): + report_tags.parameter(parameter, 'name') def test_render_currency(self): """Test the render_currency template tag.""" diff --git a/src/backend/InvenTree/stock/test_migrations.py b/src/backend/InvenTree/stock/test_migrations.py index 6407f5ea29..7901239951 100644 --- a/src/backend/InvenTree/stock/test_migrations.py +++ b/src/backend/InvenTree/stock/test_migrations.py @@ -2,14 +2,12 @@ from django_test_migrations.contrib.unittest_case import MigratorTestCase -from InvenTree import unit_test - class TestSerialNumberMigration(MigratorTestCase): """Test data migration which updates serial numbers.""" migrate_from = ('stock', '0067_alter_stockitem_part') - migrate_to = ('stock', unit_test.getNewestMigrationFile('stock')) + migrate_to = ('stock', '0070_auto_20211128_0151') def prepare(self): """Create initial data for this migration.""" @@ -72,7 +70,7 @@ class TestScheduledForDeletionMigration(MigratorTestCase): """Test data migration for removing 'scheduled_for_deletion' field.""" migrate_from = ('stock', '0066_stockitem_scheduled_for_deletion') - migrate_to = ('stock', unit_test.getNewestMigrationFile('stock')) + migrate_to = ('stock', '0073_alter_stockitem_belongs_to') def prepare(self): """Create some initial stock items.""" diff --git a/src/backend/InvenTree/users/ruleset.py b/src/backend/InvenTree/users/ruleset.py index 0fe705bfd5..7beb4f1eec 100644 --- a/src/backend/InvenTree/users/ruleset.py +++ b/src/backend/InvenTree/users/ruleset.py @@ -40,7 +40,7 @@ RULESET_NAMES = [choice[0] for choice in RULESET_CHOICES] # Permission types available for each ruleset. RULESET_PERMISSIONS = ['view', 'add', 'change', 'delete'] -RULESET_CHANGE_INHERIT = [('part', 'partparameter'), ('part', 'bomitem')] +RULESET_CHANGE_INHERIT = [('part', 'bomitem')] def get_ruleset_models() -> dict: @@ -106,15 +106,12 @@ def get_ruleset_models() -> dict: 'part_partsellpricebreak', 'part_partinternalpricebreak', 'part_parttesttemplate', - 'part_partparametertemplate', - 'part_partparameter', 'part_partrelated', 'part_partstar', 'part_partstocktake', 'part_partcategorystar', 'company_supplierpart', 'company_manufacturerpart', - 'company_manufacturerpartparameter', ], RuleSetEnum.STOCK_LOCATION: ['stock_stocklocation', 'stock_stocklocationtype'], RuleSetEnum.STOCK: [ @@ -138,7 +135,6 @@ def get_ruleset_models() -> dict: 'company_contact', 'company_address', 'company_manufacturerpart', - 'company_manufacturerpartparameter', 'company_supplierpart', 'company_supplierpricebreak', 'order_purchaseorder', @@ -179,6 +175,8 @@ def get_ruleset_ignore() -> list[str]: 'contenttypes_contenttype', # Models which currently do not require permissions 'common_attachment', + 'common_parametertemplate', + 'common_parameter', 'common_customunit', 'common_dataoutput', 'common_inventreesetting', diff --git a/src/frontend/lib/enums/ApiEndpoints.tsx b/src/frontend/lib/enums/ApiEndpoints.tsx index c3c3ecdd8b..c46d50af71 100644 --- a/src/frontend/lib/enums/ApiEndpoints.tsx +++ b/src/frontend/lib/enums/ApiEndpoints.tsx @@ -111,8 +111,6 @@ export enum ApiEndpoints { // Part API endpoints part_list = 'part/', - part_parameter_list = 'part/parameter/', - part_parameter_template_list = 'part/parameter/template/', part_thumbs_list = 'part/thumbs/', part_pricing = 'part/:id/pricing/', part_requirements = 'part/:id/requirements/', @@ -134,7 +132,6 @@ export enum ApiEndpoints { supplier_part_list = 'company/part/', supplier_part_pricing_list = 'company/price-break/', manufacturer_part_list = 'company/part/manufacturer/', - manufacturer_part_parameter_list = 'company/part/manufacturer/parameter/', // Stock location endpoints stock_location_list = 'stock/location/', @@ -243,5 +240,7 @@ export enum ApiEndpoints { notes_image_upload = 'notes-image-upload/', email_list = 'admin/email/', email_test = 'admin/email/test/', - config_list = 'admin/config/' + config_list = 'admin/config/', + parameter_list = 'parameter/', + parameter_template_list = 'parameter/template/' } diff --git a/src/frontend/lib/enums/ModelInformation.tsx b/src/frontend/lib/enums/ModelInformation.tsx index ba73e5ddf3..b529e2c49a 100644 --- a/src/frontend/lib/enums/ModelInformation.tsx +++ b/src/frontend/lib/enums/ModelInformation.tsx @@ -33,13 +33,18 @@ export const ModelInformationDict: ModelDict = { admin_url: '/part/part/', icon: 'part' }, - partparametertemplate: { - label: () => t`Part Parameter Template`, - label_multiple: () => t`Part Parameter Templates`, - url_overview: '/settings/admin/part-parameters', - url_detail: '/partparametertemplate/:pk/', - api_endpoint: ApiEndpoints.part_parameter_template_list, - icon: 'test_templates' + parameter: { + label: () => t`Parameter`, + label_multiple: () => t`Parameters`, + api_endpoint: ApiEndpoints.parameter_list, + icon: 'list_details' + }, + parametertemplate: { + label: () => t`Parameter Template`, + label_multiple: () => t`Parameter Templates`, + api_endpoint: ApiEndpoints.parameter_template_list, + admin_url: '/common/parametertemplate/', + icon: 'list' }, parttesttemplate: { label: () => t`Part Test Template`, diff --git a/src/frontend/lib/enums/ModelType.tsx b/src/frontend/lib/enums/ModelType.tsx index 84bf7ca60b..57aa1b7205 100644 --- a/src/frontend/lib/enums/ModelType.tsx +++ b/src/frontend/lib/enums/ModelType.tsx @@ -6,7 +6,6 @@ export enum ModelType { supplierpart = 'supplierpart', manufacturerpart = 'manufacturerpart', partcategory = 'partcategory', - partparametertemplate = 'partparametertemplate', parttesttemplate = 'parttesttemplate', projectcode = 'projectcode', stockitem = 'stockitem', @@ -17,6 +16,8 @@ export enum ModelType { buildline = 'buildline', builditem = 'builditem', company = 'company', + parameter = 'parameter', + parametertemplate = 'parametertemplate', purchaseorder = 'purchaseorder', purchaseorderlineitem = 'purchaseorderlineitem', salesorder = 'salesorder', diff --git a/src/frontend/src/components/buttons/SegmentedIconControl.tsx b/src/frontend/src/components/buttons/SegmentedIconControl.tsx index 9cdbfc9132..0271a2dcbe 100644 --- a/src/frontend/src/components/buttons/SegmentedIconControl.tsx +++ b/src/frontend/src/components/buttons/SegmentedIconControl.tsx @@ -33,7 +33,7 @@ export default function SegmentedIconControl({ data={data.map((item) => ({ value: item.value, label: ( - + , + content: + model_type && model_id ? ( + + ) : ( + + ) + }; +} diff --git a/src/frontend/src/components/panels/SegmentedControlPanel.tsx b/src/frontend/src/components/panels/SegmentedControlPanel.tsx new file mode 100644 index 0000000000..e403948070 --- /dev/null +++ b/src/frontend/src/components/panels/SegmentedControlPanel.tsx @@ -0,0 +1,53 @@ +import SegmentedIconControl from '../buttons/SegmentedIconControl'; +import type { PanelType } from './Panel'; + +export type SegmentedControlPanelSelection = { + value: string; + label: string; + icon: React.ReactNode; + content: React.ReactNode; +}; + +interface SegmentedPanelType extends PanelType { + options: SegmentedControlPanelSelection[]; + selection: string; + onChange: (value: string) => void; +} + +/** + * Display a panel which can be used to display multiple options, + * based on a built-in segmented control. + */ +export default function SegmentedControlPanel( + props: SegmentedPanelType +): PanelType { + // Extract the content based on the selection + let content = null; + + for (const option of props.options) { + if (option.value === props.selection) { + content = option.content; + break; + } + } + + if (content === null && props.options.length > 0) { + content = props.options[0].content; + } + + return { + ...props, + content: content, + controls: ( + ({ + value: option.value, + label: option.label, + icon: option.icon + }))} + /> + ) + }; +} diff --git a/src/frontend/src/components/render/Generic.tsx b/src/frontend/src/components/render/Generic.tsx index b828c8b00a..9c08837567 100644 --- a/src/frontend/src/components/render/Generic.tsx +++ b/src/frontend/src/components/render/Generic.tsx @@ -2,6 +2,30 @@ import type { ReactNode } from 'react'; import { type InstanceRenderInterface, RenderInlineModel } from './Instance'; +export function RenderParameterTemplate({ + instance +}: Readonly): ReactNode { + return ( + + ); +} + +export function RenderParameter({ + instance +}: Readonly): ReactNode { + return ( + + ); +} + export function RenderProjectCode({ instance }: Readonly): ReactNode { diff --git a/src/frontend/src/components/render/Instance.tsx b/src/frontend/src/components/render/Instance.tsx index 09a7c33e2d..a3174e1c58 100644 --- a/src/frontend/src/components/render/Instance.tsx +++ b/src/frontend/src/components/render/Instance.tsx @@ -36,6 +36,8 @@ import { RenderContentType, RenderError, RenderImportSession, + RenderParameter, + RenderParameterTemplate, RenderProjectCode, RenderSelectionList } from './Generic'; @@ -46,12 +48,7 @@ import { RenderSalesOrder, RenderSalesOrderShipment } from './Order'; -import { - RenderPart, - RenderPartCategory, - RenderPartParameterTemplate, - RenderPartTestTemplate -} from './Part'; +import { RenderPart, RenderPartCategory, RenderPartTestTemplate } from './Part'; import { RenderPlugin } from './Plugin'; import { RenderLabelTemplate, RenderReportTemplate } from './Report'; import { @@ -71,11 +68,12 @@ export const RendererLookup: ModelRendererDict = { [ModelType.builditem]: RenderBuildItem, [ModelType.company]: RenderCompany, [ModelType.contact]: RenderContact, + [ModelType.parameter]: RenderParameter, + [ModelType.parametertemplate]: RenderParameterTemplate, [ModelType.manufacturerpart]: RenderManufacturerPart, [ModelType.owner]: RenderOwner, [ModelType.part]: RenderPart, [ModelType.partcategory]: RenderPartCategory, - [ModelType.partparametertemplate]: RenderPartParameterTemplate, [ModelType.parttesttemplate]: RenderPartTestTemplate, [ModelType.projectcode]: RenderProjectCode, [ModelType.purchaseorder]: RenderPurchaseOrder, diff --git a/src/frontend/src/components/render/Part.tsx b/src/frontend/src/components/render/Part.tsx index fc11a73c86..14be70f6ce 100644 --- a/src/frontend/src/components/render/Part.tsx +++ b/src/frontend/src/components/render/Part.tsx @@ -141,23 +141,6 @@ export function RenderPartCategory( ); } -/** - * Inline rendering of a PartParameterTemplate instance - */ -export function RenderPartParameterTemplate({ - instance -}: Readonly<{ - instance: any; -}>): ReactNode { - return ( - - ); -} - export function RenderPartTestTemplate({ instance }: Readonly<{ diff --git a/src/frontend/src/components/wizards/ImportPartWizard.tsx b/src/frontend/src/components/wizards/ImportPartWizard.tsx index 6759148031..68c3fa6aae 100644 --- a/src/frontend/src/components/wizards/ImportPartWizard.tsx +++ b/src/frontend/src/components/wizards/ImportPartWizard.tsx @@ -419,8 +419,8 @@ const ParametersStep = ({ hideLabels fieldDefinition={{ field_type: 'related field', - model: ModelType.partparametertemplate, - api_url: apiUrl(ApiEndpoints.part_parameter_template_list), + model: ModelType.parametertemplate, + api_url: apiUrl(ApiEndpoints.parameter_template_list), disabled: p.on_category, value: p.parameter_template, onValueChange: (v) => { @@ -677,13 +677,14 @@ export default function ImportPartWizard({ {} as Record ); const createParameters = useParameters.map((p) => ({ - part: importResult!.part_id, + model_type: 'part', + model_id: importResult!.part_id, template: p.parameter_template, data: p.value })); try { await api.post( - apiUrl(ApiEndpoints.part_parameter_list), + apiUrl(ApiEndpoints.parameter_list), createParameters ); showNotification({ diff --git a/src/frontend/src/forms/CommonForms.tsx b/src/frontend/src/forms/CommonForms.tsx index e8992746f3..04d1da4ed9 100644 --- a/src/frontend/src/forms/CommonForms.tsx +++ b/src/frontend/src/forms/CommonForms.tsx @@ -1,12 +1,16 @@ import { IconUsers } from '@tabler/icons-react'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; +import type { ModelType } from '@lib/enums/ModelType'; +import { apiUrl } from '@lib/functions/Api'; import type { ApiFormFieldSet } from '@lib/types/Forms'; import { t } from '@lingui/core/macro'; import type { StatusCodeInterface, StatusCodeListInterface } from '../components/render/StatusRenderer'; +import { useApi } from '../contexts/ApiContext'; import { useGlobalStatusState } from '../states/GlobalStatusState'; export function projectCodeFields(): ApiFormFieldSet { @@ -91,3 +95,117 @@ export function extraLineItemFields(): ApiFormFieldSet { link: {} }; } + +export function useParameterFields({ + modelType, + modelId +}: { + modelType: ModelType; + modelId: number; +}): ApiFormFieldSet { + const api = useApi(); + + // Valid field choices + const [choices, setChoices] = useState([]); + + // Field type for "data" input + const [fieldType, setFieldType] = useState<'string' | 'boolean' | 'choice'>( + 'string' + ); + + const [data, setData] = useState(''); + + // Reset the field type and choices when the model changes + useEffect(() => { + setFieldType('string'); + setChoices([]); + setData(''); + }, [modelType, modelId]); + + return useMemo(() => { + return { + model_type: { + hidden: true, + value: modelType + }, + model_id: { + hidden: true, + value: modelId + }, + template: { + filters: { + for_model: modelType, + enabled: true + }, + onValueChange: (value: any, record: any) => { + // Adjust the type of the "data" field based on the selected template + if (record?.checkbox) { + // This is a "checkbox" field + setChoices([]); + setFieldType('boolean'); + } else if (record?.choices) { + const _choices: string[] = record.choices.split(','); + + if (_choices.length > 0) { + setChoices( + _choices.map((choice) => { + return { + display_name: choice.trim(), + value: choice.trim() + }; + }) + ); + setFieldType('choice'); + } else { + setChoices([]); + setFieldType('string'); + } + } else if (record?.selectionlist) { + api + .get( + apiUrl(ApiEndpoints.selectionlist_detail, record.selectionlist) + ) + .then((res) => { + setChoices( + res.data.choices.map((item: any) => { + return { + value: item.value, + display_name: item.label + }; + }) + ); + setFieldType('choice'); + }); + } else { + setChoices([]); + setFieldType('string'); + } + } + }, + data: { + value: data, + onValueChange: (value: any) => { + setData(value); + }, + type: fieldType, + field_type: fieldType, + choices: fieldType === 'choice' ? choices : undefined, + default: fieldType === 'boolean' ? false : undefined, + adjustValue: (value: any) => { + // Coerce boolean value into a string (required by backend) + + let v: string = value.toString().trim(); + + if (fieldType === 'boolean') { + if (v.toLowerCase() !== 'true') { + v = 'false'; + } + } + + return v; + } + }, + note: {} + }; + }, [data, modelType, fieldType, choices, modelId]); +} diff --git a/src/frontend/src/forms/CompanyForms.tsx b/src/frontend/src/forms/CompanyForms.tsx index 3d442863e7..8336aa4fc8 100644 --- a/src/frontend/src/forms/CompanyForms.tsx +++ b/src/frontend/src/forms/CompanyForms.tsx @@ -97,21 +97,6 @@ export function useManufacturerPartFields() { }, []); } -export function useManufacturerPartParameterFields() { - return useMemo(() => { - const fields: ApiFormFieldSet = { - manufacturer_part: { - disabled: true - }, - name: {}, - value: {}, - units: {} - }; - - return fields; - }, []); -} - /** * Field set for editing a company instance */ diff --git a/src/frontend/src/forms/PartForms.tsx b/src/frontend/src/forms/PartForms.tsx index 4125d00788..61c14a0103 100644 --- a/src/frontend/src/forms/PartForms.tsx +++ b/src/frontend/src/forms/PartForms.tsx @@ -1,11 +1,7 @@ +import type { ApiFormFieldSet } from '@lib/types/Forms'; import { t } from '@lingui/core/macro'; import { IconBuildingStore, IconCopy, IconPackages } from '@tabler/icons-react'; import { useMemo, useState } from 'react'; - -import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; -import { apiUrl } from '@lib/functions/Api'; -import type { ApiFormFieldSet } from '@lib/types/Forms'; -import { useApi } from '../contexts/ApiContext'; import { useGlobalSettingsState } from '../states/SettingsStates'; /** @@ -224,97 +220,6 @@ export function partCategoryFields({ return fields; } -export function usePartParameterFields({ - editTemplate -}: { - editTemplate?: boolean; -}): ApiFormFieldSet { - const api = useApi(); - - // Valid field choices - const [choices, setChoices] = useState([]); - - // Field type for "data" input - const [fieldType, setFieldType] = useState<'string' | 'boolean' | 'choice'>( - 'string' - ); - - return useMemo(() => { - return { - part: { - disabled: true - }, - template: { - disabled: editTemplate == false, - onValueChange: (value: any, record: any) => { - // Adjust the type of the "data" field based on the selected template - if (record?.checkbox) { - // This is a "checkbox" field - setChoices([]); - setFieldType('boolean'); - } else if (record?.choices) { - const _choices: string[] = record.choices.split(','); - - if (_choices.length > 0) { - setChoices( - _choices.map((choice) => { - return { - display_name: choice.trim(), - value: choice.trim() - }; - }) - ); - setFieldType('choice'); - } else { - setChoices([]); - setFieldType('string'); - } - } else if (record?.selectionlist) { - api - .get( - apiUrl(ApiEndpoints.selectionlist_detail, record.selectionlist) - ) - .then((res) => { - setChoices( - res.data.choices.map((item: any) => { - return { - value: item.value, - display_name: item.label - }; - }) - ); - setFieldType('choice'); - }); - } else { - setChoices([]); - setFieldType('string'); - } - } - }, - data: { - type: fieldType, - field_type: fieldType, - choices: fieldType === 'choice' ? choices : undefined, - default: fieldType === 'boolean' ? false : undefined, - adjustValue: (value: any) => { - // Coerce boolean value into a string (required by backend) - - let v: string = value.toString().trim(); - - if (fieldType === 'boolean') { - if (v.toLowerCase() !== 'true') { - v = 'false'; - } - } - - return v; - } - }, - note: {} - }; - }, [editTemplate, fieldType, choices]); -} - export function partStocktakeFields(): ApiFormFieldSet { return { part: { diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx index 7ee01d0e5c..4c700b4ed1 100644 --- a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx +++ b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx @@ -71,7 +71,7 @@ const MachineManagementPanel = Loadable( lazy(() => import('./MachineManagementPanel')) ); -const PartParameterPanel = Loadable(lazy(() => import('./PartParameterPanel'))); +const ParameterPanel = Loadable(lazy(() => import('./ParameterPanel'))); const ErrorReportTable = Loadable( lazy(() => import('../../../../tables/settings/ErrorTable')) @@ -191,10 +191,10 @@ export default function AdminCenter() { content: }, { - name: 'part-parameters', - label: t`Part Parameters`, + name: 'parameters', + label: t`Parameters`, icon: , - content: , + content: , hidden: !user.hasViewRole(UserRoles.part) }, { @@ -274,7 +274,7 @@ export default function AdminCenter() { id: 'plm', label: t`PLM`, panelIDs: [ - 'part-parameters', + 'parameters', 'category-parameters', 'location-types', 'stocktake' diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/PartParameterPanel.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/ParameterPanel.tsx similarity index 62% rename from src/frontend/src/pages/Index/Settings/AdminCenter/PartParameterPanel.tsx rename to src/frontend/src/pages/Index/Settings/AdminCenter/ParameterPanel.tsx index 2240475702..b352df5ee5 100644 --- a/src/frontend/src/pages/Index/Settings/AdminCenter/PartParameterPanel.tsx +++ b/src/frontend/src/pages/Index/Settings/AdminCenter/ParameterPanel.tsx @@ -2,21 +2,21 @@ import { t } from '@lingui/core/macro'; import { Accordion } from '@mantine/core'; import { StylishText } from '../../../../components/items/StylishText'; -import PartParameterTemplateTable from '../../../../tables/part/PartParameterTemplateTable'; +import ParameterTemplateTable from '../../../../tables/general/ParameterTemplateTable'; import SelectionListTable from '../../../../tables/part/SelectionListTable'; export default function PartParameterPanel() { return ( - - + + - {t`Part Parameter Template`} + {t`Parameter Templates`} - + - + {t`Selection Lists`} diff --git a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx index 918033494a..0e3ef515d7 100644 --- a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx +++ b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx @@ -7,6 +7,7 @@ import { IconCurrencyDollar, IconFileAnalytics, IconFingerprint, + IconList, IconPackages, IconPlugConnected, IconQrcode, @@ -185,6 +186,12 @@ export default function SystemSettings() { /> ) }, + { + name: 'parameters', + label: t`Parameters`, + icon: , + content: + }, { name: 'parts', label: t`Parts`, @@ -213,8 +220,7 @@ export default function SystemSettings() { 'PART_COPY_PARAMETERS', 'PART_COPY_TESTS', 'PART_CATEGORY_PARAMETERS', - 'PART_CATEGORY_DEFAULT_ICON', - 'PART_PARAMETER_ENFORCE_UNITS' + 'PART_CATEGORY_DEFAULT_ICON' ]} /> ) diff --git a/src/frontend/src/pages/build/BuildDetail.tsx b/src/frontend/src/pages/build/BuildDetail.tsx index d856376d95..d5016e2909 100644 --- a/src/frontend/src/pages/build/BuildDetail.tsx +++ b/src/frontend/src/pages/build/BuildDetail.tsx @@ -45,6 +45,7 @@ import AttachmentPanel from '../../components/panels/AttachmentPanel'; import NotesPanel from '../../components/panels/NotesPanel'; import type { PanelType } from '../../components/panels/Panel'; import { PanelGroup } from '../../components/panels/PanelGroup'; +import ParametersPanel from '../../components/panels/ParametersPanel'; import { StatusRenderer } from '../../components/render/StatusRenderer'; import { RenderStockLocation } from '../../components/render/Stock'; import { useBuildOrderFields } from '../../forms/BuildForms'; @@ -519,6 +520,10 @@ export default function BuildDetail() { ) }, + ParametersPanel({ + model_type: ModelType.build, + model_id: build.pk + }), AttachmentPanel({ model_type: ModelType.build, model_id: build.pk diff --git a/src/frontend/src/pages/build/BuildIndex.tsx b/src/frontend/src/pages/build/BuildIndex.tsx index bd95117a53..2b8f4e681c 100644 --- a/src/frontend/src/pages/build/BuildIndex.tsx +++ b/src/frontend/src/pages/build/BuildIndex.tsx @@ -1,21 +1,27 @@ import { t } from '@lingui/core/macro'; import { Stack } from '@mantine/core'; -import { IconCalendar, IconTable, IconTools } from '@tabler/icons-react'; +import { + IconCalendar, + IconListDetails, + IconTable, + IconTools +} from '@tabler/icons-react'; import { useMemo } from 'react'; import { ModelType } from '@lib/enums/ModelType'; import { UserRoles } from '@lib/enums/Roles'; import type { TableFilter } from '@lib/types/Filters'; import { useLocalStorage } from '@mantine/hooks'; -import SegmentedIconControl from '../../components/buttons/SegmentedIconControl'; import OrderCalendar from '../../components/calendar/OrderCalendar'; import PermissionDenied from '../../components/errors/PermissionDenied'; import { PageDetail } from '../../components/nav/PageDetail'; import type { PanelType } from '../../components/panels/Panel'; import { PanelGroup } from '../../components/panels/PanelGroup'; +import SegmentedControlPanel from '../../components/panels/SegmentedControlPanel'; import { useGlobalSettingsState } from '../../states/SettingsStates'; import { useUserState } from '../../states/UserState'; import { PartCategoryFilter } from '../../tables/Filter'; +import BuildOrderParametricTable from '../../tables/build/BuildOrderParametricTable'; import { BuildOrderTable } from '../../tables/build/BuildOrderTable'; function BuildOrderCalendar() { @@ -43,20 +49,6 @@ function BuildOrderCalendar() { ); } -function BuildOverview({ - view -}: { - view: string; -}) { - switch (view) { - case 'calendar': - return ; - case 'table': - default: - return ; - } -} - /** * Build Order index page */ @@ -64,34 +56,41 @@ export default function BuildIndex() { const user = useUserState(); const [buildOrderView, setBuildOrderView] = useLocalStorage({ - key: 'buildOrderView', + key: 'build-order-view', defaultValue: 'table' }); const panels: PanelType[] = useMemo(() => { return [ - { - name: 'buildorders', + SegmentedControlPanel({ + name: 'buildorder', label: t`Build Orders`, - content: , icon: , - controls: ( - }, - { - value: 'calendar', - label: t`Calendar View`, - icon: - } - ]} - /> - ) - } + selection: buildOrderView, + onChange: setBuildOrderView, + options: [ + { + value: 'table', + label: t`Table View`, + icon: , + content: + }, + { + value: 'calendar', + label: t`Calendar View`, + icon: , + content: + }, + { + value: 'parametric', + label: t`Parametric View`, + icon: , + content: + } + ] + }) ]; - }, [buildOrderView, setBuildOrderView]); + }, [user, buildOrderView]); if (!user.isLoggedIn() || !user.hasViewRole(UserRoles.build)) { return ; diff --git a/src/frontend/src/pages/company/CompanyDetail.tsx b/src/frontend/src/pages/company/CompanyDetail.tsx index 228cdc2940..1c1383841d 100644 --- a/src/frontend/src/pages/company/CompanyDetail.tsx +++ b/src/frontend/src/pages/company/CompanyDetail.tsx @@ -39,6 +39,7 @@ import AttachmentPanel from '../../components/panels/AttachmentPanel'; import NotesPanel from '../../components/panels/NotesPanel'; import type { PanelType } from '../../components/panels/Panel'; import { PanelGroup } from '../../components/panels/PanelGroup'; +import ParametersPanel from '../../components/panels/ParametersPanel'; import { companyFields } from '../../forms/CompanyForms'; import { useDeleteApiFormModal, @@ -265,6 +266,10 @@ export default function CompanyDetail(props: Readonly) { icon: , content: company?.pk && }, + ParametersPanel({ + model_type: ModelType.company, + model_id: company?.pk + }), AttachmentPanel({ model_type: ModelType.company, model_id: company.pk diff --git a/src/frontend/src/pages/company/ManufacturerPartDetail.tsx b/src/frontend/src/pages/company/ManufacturerPartDetail.tsx index 693906af1a..1ad145e45a 100644 --- a/src/frontend/src/pages/company/ManufacturerPartDetail.tsx +++ b/src/frontend/src/pages/company/ManufacturerPartDetail.tsx @@ -3,7 +3,6 @@ import { Grid, Skeleton, Stack } from '@mantine/core'; import { IconBuildingWarehouse, IconInfoCircle, - IconList, IconPackages } from '@tabler/icons-react'; import { useMemo } from 'react'; @@ -33,6 +32,7 @@ import AttachmentPanel from '../../components/panels/AttachmentPanel'; import NotesPanel from '../../components/panels/NotesPanel'; import type { PanelType } from '../../components/panels/Panel'; import { PanelGroup } from '../../components/panels/PanelGroup'; +import ParametersPanel from '../../components/panels/ParametersPanel'; import { useManufacturerPartFields } from '../../forms/CompanyForms'; import { useCreateApiFormModal, @@ -41,7 +41,6 @@ import { } from '../../hooks/UseForm'; import { useInstance } from '../../hooks/UseInstance'; import { useUserState } from '../../states/UserState'; -import ManufacturerPartParameterTable from '../../tables/purchasing/ManufacturerPartParameterTable'; import { SupplierPartTable } from '../../tables/purchasing/SupplierPartTable'; import { StockItemTable } from '../../tables/stock/StockItemTable'; @@ -161,18 +160,6 @@ export default function ManufacturerPartDetail() { icon: , content: detailsPanel }, - { - name: 'parameters', - label: t`Parameters`, - icon: , - content: manufacturerPart?.pk ? ( - - ) : ( - - ) - }, { name: 'stock', label: t`Received Stock`, @@ -201,6 +188,10 @@ export default function ManufacturerPartDetail() { ) }, + ParametersPanel({ + model_type: ModelType.manufacturerpart, + model_id: manufacturerPart?.pk + }), AttachmentPanel({ model_type: ModelType.manufacturerpart, model_id: manufacturerPart?.pk diff --git a/src/frontend/src/pages/company/SupplierPartDetail.tsx b/src/frontend/src/pages/company/SupplierPartDetail.tsx index 35cdf4f836..e640c3e8da 100644 --- a/src/frontend/src/pages/company/SupplierPartDetail.tsx +++ b/src/frontend/src/pages/company/SupplierPartDetail.tsx @@ -36,6 +36,7 @@ import AttachmentPanel from '../../components/panels/AttachmentPanel'; import NotesPanel from '../../components/panels/NotesPanel'; import type { PanelType } from '../../components/panels/Panel'; import { PanelGroup } from '../../components/panels/PanelGroup'; +import ParametersPanel from '../../components/panels/ParametersPanel'; import { useSupplierPartFields } from '../../forms/CompanyForms'; import { useCreateApiFormModal, @@ -284,6 +285,10 @@ export default function SupplierPartDetail() { ) }, + ParametersPanel({ + model_type: ModelType.supplierpart, + model_id: supplierPart?.pk + }), AttachmentPanel({ model_type: ModelType.supplierpart, model_id: supplierPart?.pk diff --git a/src/frontend/src/pages/part/CategoryDetail.tsx b/src/frontend/src/pages/part/CategoryDetail.tsx index 55b9ce440f..a39ce98f5e 100644 --- a/src/frontend/src/pages/part/CategoryDetail.tsx +++ b/src/frontend/src/pages/part/CategoryDetail.tsx @@ -6,7 +6,8 @@ import { IconListCheck, IconListDetails, IconPackages, - IconSitemap + IconSitemap, + IconTable } from '@tabler/icons-react'; import { useMemo, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; @@ -15,6 +16,7 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ModelType } from '@lib/enums/ModelType'; import { UserRoles } from '@lib/enums/Roles'; import { getDetailUrl } from '@lib/functions/Navigation'; +import { useLocalStorage } from '@mantine/hooks'; import AdminButton from '../../components/buttons/AdminButton'; import StarredToggleButton from '../../components/buttons/StarredToggleButton'; import { @@ -33,6 +35,7 @@ import NavigationTree from '../../components/nav/NavigationTree'; import { PageDetail } from '../../components/nav/PageDetail'; import type { PanelType } from '../../components/panels/Panel'; import { PanelGroup } from '../../components/panels/PanelGroup'; +import SegmentedControlPanel from '../../components/panels/SegmentedControlPanel'; import { partCategoryFields } from '../../forms/PartForms'; import { useDeleteApiFormModal, @@ -258,6 +261,11 @@ export default function CategoryDetail() { ]; }, [id, user, category.pk, category.starred]); + const [partsView, setPartsView] = useLocalStorage({ + key: 'category-parts-view', + defaultValue: 'table' + }); + const panels: PanelType[] = useMemo( () => [ { @@ -272,20 +280,35 @@ export default function CategoryDetail() { icon: , content: }, - { + SegmentedControlPanel({ name: 'parts', label: t`Parts`, icon: , - content: ( - - ) - }, + selection: partsView, + onChange: setPartsView, + options: [ + { + value: 'table', + label: t`Table View`, + icon: , + content: ( + + ) + }, + { + value: 'parametric', + label: t`Parametric View`, + icon: , + content: + } + ] + }), { name: 'stockitem', label: t`Stock Items`, @@ -307,15 +330,9 @@ export default function CategoryDetail() { icon: , hidden: !id || !category.pk, content: - }, - { - name: 'parameters', - label: t`Part Parameters`, - icon: , - content: } ], - [category, id] + [category, id, partsView] ); const breadcrumbs = useMemo( diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index 927d6ac978..23b9350fce 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -22,8 +22,8 @@ import { IconExclamationCircle, IconInfoCircle, IconLayersLinked, - IconList, IconListCheck, + IconListDetails, IconListTree, IconLock, IconPackages, @@ -97,7 +97,7 @@ import { useUserState } from '../../states/UserState'; import { BomTable } from '../../tables/bom/BomTable'; import { UsedInTable } from '../../tables/bom/UsedInTable'; import { BuildOrderTable } from '../../tables/build/BuildOrderTable'; -import { PartParameterTable } from '../../tables/part/PartParameterTable'; +import { ParameterTable } from '../../tables/general/ParameterTable'; import PartPurchaseOrdersTable from '../../tables/part/PartPurchaseOrdersTable'; import PartTestResultTable from '../../tables/part/PartTestResultTable'; import PartTestTemplateTable from '../../tables/part/PartTestTemplateTable'; @@ -788,17 +788,6 @@ export default function PartDetail() { icon: , content: detailsPanel }, - { - name: 'parameters', - label: t`Parameters`, - icon: , - content: ( - - ) - }, { name: 'stock', label: t`Stock`, @@ -949,6 +938,30 @@ export default function PartDetail() { icon: , content: }, + { + name: 'parameters', + label: t`Parameters`, + icon: , + content: ( + <> + {part.locked && ( + } + p='xs' + > + {t`Part parameters cannot be edited, as the part is locked`} + + )} + + + ) + }, AttachmentPanel({ model_type: ModelType.part, model_id: part?.pk diff --git a/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx b/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx index 87e1c4cbba..0d0a2fc754 100644 --- a/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx +++ b/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx @@ -32,6 +32,7 @@ import AttachmentPanel from '../../components/panels/AttachmentPanel'; import NotesPanel from '../../components/panels/NotesPanel'; import type { PanelType } from '../../components/panels/Panel'; import { PanelGroup } from '../../components/panels/PanelGroup'; +import ParametersPanel from '../../components/panels/ParametersPanel'; import { StatusRenderer } from '../../components/render/StatusRenderer'; import { formatCurrency } from '../../defaults/formatters'; import { usePurchaseOrderFields } from '../../forms/PurchaseOrderForms'; @@ -389,6 +390,10 @@ export default function PurchaseOrderDetail() { /> ) }, + ParametersPanel({ + model_type: ModelType.purchaseorder, + model_id: order.pk + }), AttachmentPanel({ model_type: ModelType.purchaseorder, model_id: order.pk diff --git a/src/frontend/src/pages/purchasing/PurchasingIndex.tsx b/src/frontend/src/pages/purchasing/PurchasingIndex.tsx index 0101f84f21..706c7e737d 100644 --- a/src/frontend/src/pages/purchasing/PurchasingIndex.tsx +++ b/src/frontend/src/pages/purchasing/PurchasingIndex.tsx @@ -5,6 +5,7 @@ import { IconBuildingStore, IconBuildingWarehouse, IconCalendar, + IconListDetails, IconPackageExport, IconShoppingCart, IconTable @@ -14,104 +15,193 @@ import { useMemo } from 'react'; import { ModelType } from '@lib/enums/ModelType'; import { UserRoles } from '@lib/enums/Roles'; import { useLocalStorage } from '@mantine/hooks'; -import SegmentedIconControl from '../../components/buttons/SegmentedIconControl'; import OrderCalendar from '../../components/calendar/OrderCalendar'; import PermissionDenied from '../../components/errors/PermissionDenied'; import { PageDetail } from '../../components/nav/PageDetail'; import { PanelGroup } from '../../components/panels/PanelGroup'; +import SegmentedControlPanel from '../../components/panels/SegmentedControlPanel'; import { useUserState } from '../../states/UserState'; import { CompanyTable } from '../../tables/company/CompanyTable'; +import ParametricCompanyTable from '../../tables/company/ParametricCompanyTable'; +import ManufacturerPartParametricTable from '../../tables/purchasing/ManufacturerPartParametricTable'; import { ManufacturerPartTable } from '../../tables/purchasing/ManufacturerPartTable'; +import PurchaseOrderParametricTable from '../../tables/purchasing/PurchaseOrderParametricTable'; import { PurchaseOrderTable } from '../../tables/purchasing/PurchaseOrderTable'; +import SupplierPartParametricTable from '../../tables/purchasing/SupplierPartParametricTable'; import { SupplierPartTable } from '../../tables/purchasing/SupplierPartTable'; -function PurchaseOrderOverview({ - view -}: { - view: string; -}) { - switch (view) { - case 'calendar': - return ( - - ); - case 'table': - default: - return ; - } -} - export default function PurchasingIndex() { const user = useUserState(); - const [purchaseOrderView, setpurchaseOrderView] = useLocalStorage({ - key: 'purchaseOrderView', + const [purchaseOrderView, setPurchaseOrderView] = useLocalStorage({ + key: 'purchase-order-view', + defaultValue: 'table' + }); + + const [supplierView, setSupplierView] = useLocalStorage({ + key: 'supplier-view', + defaultValue: 'table' + }); + + const [manufacturerView, setManufacturerView] = useLocalStorage({ + key: 'manufacturer-view', + defaultValue: 'table' + }); + + const [manufacturerPartsView, setManufacturerPartsView] = + useLocalStorage({ + key: 'manufacturer-parts-view', + defaultValue: 'table' + }); + + const [supplierPartsView, setSupplierPartsView] = useLocalStorage({ + key: 'supplier-parts-view', defaultValue: 'table' }); const panels = useMemo(() => { return [ - { + SegmentedControlPanel({ name: 'purchaseorders', label: t`Purchase Orders`, icon: , hidden: !user.hasViewRole(UserRoles.purchase_order), - content: , - controls: ( - }, - { - value: 'calendar', - label: t`Calendar View`, - icon: - } - ]} - /> - ) - }, - { + selection: purchaseOrderView, + onChange: setPurchaseOrderView, + options: [ + { + value: 'table', + label: t`Table View`, + icon: , + content: + }, + { + value: 'calendar', + label: t`Calendar View`, + icon: , + content: ( + + ) + }, + { + value: 'parametric', + label: t`Parametric View`, + icon: , + content: + } + ] + }), + SegmentedControlPanel({ name: 'suppliers', label: t`Suppliers`, icon: , - content: ( - - ) - }, - { + selection: supplierView, + onChange: setSupplierView, + options: [ + { + value: 'table', + label: t`Table View`, + icon: , + content: ( + + ) + }, + { + value: 'parametric', + label: t`Parametric View`, + icon: , + content: ( + + ) + } + ] + }), + SegmentedControlPanel({ name: 'supplier-parts', label: t`Supplier Parts`, icon: , - content: - }, - { + selection: supplierPartsView, + onChange: setSupplierPartsView, + options: [ + { + value: 'table', + label: t`Table View`, + icon: , + content: + }, + { + value: 'parametric', + label: t`Parametric View`, + icon: , + content: + } + ] + }), + SegmentedControlPanel({ name: 'manufacturer', label: t`Manufacturers`, icon: , - content: ( - - ) - }, - { + selection: manufacturerView, + onChange: setManufacturerView, + options: [ + { + value: 'table', + label: t`Table View`, + icon: , + content: ( + + ) + }, + { + value: 'parametric', + label: t`Parametric View`, + icon: , + content: ( + + ) + } + ] + }), + SegmentedControlPanel({ name: 'manufacturer-parts', label: t`Manufacturer Parts`, icon: , - content: - } + selection: manufacturerPartsView, + onChange: setManufacturerPartsView, + options: [ + { + value: 'table', + label: t`Table View`, + icon: , + content: + }, + { + value: 'parametric', + label: t`Parametric View`, + icon: , + content: + } + ] + }) ]; - }, [user, purchaseOrderView]); + }, [ + user, + manufacturerPartsView, + manufacturerView, + purchaseOrderView, + supplierPartsView, + supplierView + ]); if (!user.isLoggedIn() || !user.hasViewRole(UserRoles.purchase_order)) { return ; diff --git a/src/frontend/src/pages/sales/ReturnOrderDetail.tsx b/src/frontend/src/pages/sales/ReturnOrderDetail.tsx index 2c644c4be1..5bce7c6306 100644 --- a/src/frontend/src/pages/sales/ReturnOrderDetail.tsx +++ b/src/frontend/src/pages/sales/ReturnOrderDetail.tsx @@ -32,6 +32,7 @@ import AttachmentPanel from '../../components/panels/AttachmentPanel'; import NotesPanel from '../../components/panels/NotesPanel'; import type { PanelType } from '../../components/panels/Panel'; import { PanelGroup } from '../../components/panels/PanelGroup'; +import ParametersPanel from '../../components/panels/ParametersPanel'; import { RenderAddress } from '../../components/render/Company'; import { StatusRenderer } from '../../components/render/StatusRenderer'; import { formatCurrency } from '../../defaults/formatters'; @@ -354,6 +355,10 @@ export default function ReturnOrderDetail() { ) }, + ParametersPanel({ + model_type: ModelType.returnorder, + model_id: order.pk + }), AttachmentPanel({ model_type: ModelType.returnorder, model_id: order.pk diff --git a/src/frontend/src/pages/sales/SalesIndex.tsx b/src/frontend/src/pages/sales/SalesIndex.tsx index ffa1e1d5d3..0fe4b8a31c 100644 --- a/src/frontend/src/pages/sales/SalesIndex.tsx +++ b/src/frontend/src/pages/sales/SalesIndex.tsx @@ -4,6 +4,7 @@ import { IconBuildingStore, IconCalendar, IconCubeSend, + IconListDetails, IconTable, IconTruckDelivery, IconTruckReturn @@ -13,93 +14,74 @@ import { useMemo } from 'react'; import { ModelType } from '@lib/enums/ModelType'; import { UserRoles } from '@lib/enums/Roles'; import { useLocalStorage } from '@mantine/hooks'; -import SegmentedIconControl from '../../components/buttons/SegmentedIconControl'; import OrderCalendar from '../../components/calendar/OrderCalendar'; import PermissionDenied from '../../components/errors/PermissionDenied'; import { PageDetail } from '../../components/nav/PageDetail'; import { PanelGroup } from '../../components/panels/PanelGroup'; +import SegmentedControlPanel from '../../components/panels/SegmentedControlPanel'; import { useUserState } from '../../states/UserState'; import { CompanyTable } from '../../tables/company/CompanyTable'; +import ParametricCompanyTable from '../../tables/company/ParametricCompanyTable'; +import ReturnOrderParametricTable from '../../tables/sales/ReturnOrderParametricTable'; import { ReturnOrderTable } from '../../tables/sales/ReturnOrderTable'; +import SalesOrderParametricTable from '../../tables/sales/SalesOrderParametricTable'; import SalesOrderShipmentTable from '../../tables/sales/SalesOrderShipmentTable'; import { SalesOrderTable } from '../../tables/sales/SalesOrderTable'; -function SalesOrderOverview({ - view -}: { - view: string; -}) { - switch (view) { - case 'calendar': - return ( - - ); - case 'table': - default: - return ; - } -} - -function ReturnOrderOverview({ - view -}: { - view: string; -}) { - switch (view) { - case 'calendar': - return ( - - ); - case 'table': - default: - return ; - } -} - export default function SalesIndex() { const user = useUserState(); + const [customersView, setCustomersView] = useLocalStorage({ + key: 'customer-view', + defaultValue: 'table' + }); + const [salesOrderView, setSalesOrderView] = useLocalStorage({ - key: 'salesOrderView', + key: 'sales-order-view', defaultValue: 'table' }); const [returnOrderView, setReturnOrderView] = useLocalStorage({ - key: 'returnOrderView', + key: 'return-order-view', defaultValue: 'table' }); const panels = useMemo(() => { return [ - { + SegmentedControlPanel({ name: 'salesorders', label: t`Sales Orders`, icon: , - content: , - controls: ( - }, - { - value: 'calendar', - label: t`Calendar View`, - icon: - } - ]} - /> - ), - hidden: !user.hasViewRole(UserRoles.sales_order) - }, + hidden: !user.hasViewRole(UserRoles.sales_order), + selection: salesOrderView, + onChange: setSalesOrderView, + options: [ + { + value: 'table', + label: t`Table View`, + icon: , + content: + }, + { + value: 'calendar', + label: t`Calendar View`, + icon: , + content: ( + + ) + }, + { + value: 'parametric', + label: t`Parametric View`, + icon: , + content: + } + ] + }), { name: 'shipments', label: t`Pending Shipments`, @@ -112,37 +94,70 @@ export default function SalesIndex() { /> ) }, - { + SegmentedControlPanel({ name: 'returnorders', label: t`Return Orders`, icon: , - content: , - controls: ( - }, - { - value: 'calendar', - label: t`Calendar View`, - icon: - } - ]} - /> - ), - hidden: !user.hasViewRole(UserRoles.return_order) - }, - { + hidden: !user.hasViewRole(UserRoles.return_order), + selection: returnOrderView, + onChange: setReturnOrderView, + options: [ + { + value: 'table', + label: t`Table View`, + icon: , + content: + }, + { + value: 'calendar', + label: t`Calendar View`, + icon: , + content: ( + + ) + }, + { + value: 'parametric', + label: t`Parametric View`, + icon: , + content: + } + ] + }), + SegmentedControlPanel({ name: 'customers', label: t`Customers`, icon: , - content: ( - - ) - } + selection: customersView, + onChange: setCustomersView, + options: [ + { + value: 'table', + label: t`Table View`, + icon: , + content: ( + + ) + }, + { + value: 'parametric', + label: t`Parametric View`, + icon: , + content: ( + + ) + } + ] + }) ]; - }, [user, salesOrderView, returnOrderView]); + }, [user, customersView, salesOrderView, returnOrderView]); if (!user.isLoggedIn() || !user.hasViewRole(UserRoles.sales_order)) { return ; diff --git a/src/frontend/src/pages/sales/SalesOrderDetail.tsx b/src/frontend/src/pages/sales/SalesOrderDetail.tsx index bd3178fb3d..15bc26cdda 100644 --- a/src/frontend/src/pages/sales/SalesOrderDetail.tsx +++ b/src/frontend/src/pages/sales/SalesOrderDetail.tsx @@ -38,6 +38,7 @@ import AttachmentPanel from '../../components/panels/AttachmentPanel'; import NotesPanel from '../../components/panels/NotesPanel'; import type { PanelType } from '../../components/panels/Panel'; import { PanelGroup } from '../../components/panels/PanelGroup'; +import ParametersPanel from '../../components/panels/ParametersPanel'; import { RenderAddress } from '../../components/render/Company'; import { StatusRenderer } from '../../components/render/StatusRenderer'; import { formatCurrency } from '../../defaults/formatters'; @@ -427,6 +428,10 @@ export default function SalesOrderDetail() { ) }, + ParametersPanel({ + model_type: ModelType.salesorder, + model_id: order.pk + }), AttachmentPanel({ model_type: ModelType.salesorder, model_id: order.pk diff --git a/src/frontend/src/tables/build/BuildOrderParametricTable.tsx b/src/frontend/src/tables/build/BuildOrderParametricTable.tsx new file mode 100644 index 0000000000..d9f85cb295 --- /dev/null +++ b/src/frontend/src/tables/build/BuildOrderParametricTable.tsx @@ -0,0 +1,40 @@ +import { ApiEndpoints, ModelType } from '@lib/index'; +import type { TableFilter } from '@lib/types/Filters'; +import type { TableColumn } from '@lib/types/Tables'; +import { type ReactNode, useMemo } from 'react'; +import { DescriptionColumn, ReferenceColumn } from '../ColumnRenderers'; +import { OrderStatusFilter, OutstandingFilter } from '../Filter'; +import ParametricDataTable from '../general/ParametricDataTable'; + +export default function BuildOrderParametricTable({ + queryParams +}: { + queryParams?: Record; +}): ReactNode { + const customColumns: TableColumn[] = useMemo(() => { + return [ + ReferenceColumn({ + switchable: false + }), + DescriptionColumn({ + accessor: 'title' + }) + ]; + }, []); + + const customFilters: TableFilter[] = useMemo(() => { + return [OutstandingFilter(), OrderStatusFilter({ model: ModelType.build })]; + }, []); + + return ( + + ); +} diff --git a/src/frontend/src/tables/company/ParametricCompanyTable.tsx b/src/frontend/src/tables/company/ParametricCompanyTable.tsx new file mode 100644 index 0000000000..a309b44790 --- /dev/null +++ b/src/frontend/src/tables/company/ParametricCompanyTable.tsx @@ -0,0 +1,39 @@ +import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; +import { ModelType } from '@lib/enums/ModelType'; +import type { TableColumn } from '@lib/types/Tables'; +import { t } from '@lingui/core/macro'; +import { useMemo } from 'react'; +import { CompanyColumn, DescriptionColumn } from '../ColumnRenderers'; +import ParametricDataTable from '../general/ParametricDataTable'; + +export default function ParametricCompanyTable({ + queryParams +}: { + queryParams?: any; +}) { + const customColumns: TableColumn[] = useMemo(() => { + return [ + { + accessor: 'name', + title: t`Company`, + sortable: true, + switchable: false, + render: (record: any) => { + return ; + } + }, + DescriptionColumn({}) + ]; + }, []); + + return ( + + ); +} diff --git a/src/frontend/src/tables/general/ParameterTable.tsx b/src/frontend/src/tables/general/ParameterTable.tsx new file mode 100644 index 0000000000..d7654e878a --- /dev/null +++ b/src/frontend/src/tables/general/ParameterTable.tsx @@ -0,0 +1,276 @@ +import { + ApiEndpoints, + ModelType, + RowDeleteAction, + RowEditAction, + YesNoButton, + apiUrl, + formatDecimal +} from '@lib/index'; +import type { TableFilter } from '@lib/types/Filters'; +import type { TableColumn } from '@lib/types/Tables'; +import { t } from '@lingui/core/macro'; +import { IconFileUpload, IconPlus } from '@tabler/icons-react'; +import { useCallback, useMemo, useState } from 'react'; +import ImporterDrawer from '../../components/importer/ImporterDrawer'; +import { ActionDropdown } from '../../components/items/ActionDropdown'; +import { useParameterFields } from '../../forms/CommonForms'; +import { dataImporterSessionFields } from '../../forms/ImporterForms'; +import { + useCreateApiFormModal, + useDeleteApiFormModal, + useEditApiFormModal +} from '../../hooks/UseForm'; +import { useTable } from '../../hooks/UseTable'; +import { useUserState } from '../../states/UserState'; +import { + DateColumn, + DescriptionColumn, + NoteColumn, + UserColumn +} from '../ColumnRenderers'; +import { UserFilter } from '../Filter'; +import { InvenTreeTable } from '../InvenTreeTable'; +import { TableHoverCard } from '../TableHoverCard'; + +/** + * Construct a table listing parameters + */ +export function ParameterTable({ + modelType, + modelId, + allowEdit = true +}: { + modelType: ModelType; + modelId: number; + allowEdit?: boolean; +}) { + const table = useTable('parameters'); + const user = useUserState(); + + const tableColumns: TableColumn[] = useMemo(() => { + return [ + { + accessor: 'template_detail.name', + switchable: false, + sortable: true, + ordering: 'name' + }, + DescriptionColumn({ + accessor: 'template_detail.description' + }), + { + accessor: 'data', + switchable: false, + sortable: true, + render: (record) => { + const template = record.template_detail; + + if (template?.checkbox) { + return ; + } + + const extra: any[] = []; + + if ( + template.units && + record.data_numeric && + record.data_numeric != record.data + ) { + const numeric = formatDecimal(record.data_numeric, { digits: 15 }); + extra.push(`${numeric} [${template.units}]`); + } + + return ( + + ); + } + }, + { + accessor: 'template_detail.units', + ordering: 'units', + sortable: true + }, + NoteColumn({}), + DateColumn({ + accessor: 'updated', + title: t`Last Updated`, + sortable: true, + switchable: true + }), + UserColumn({ + accessor: 'updated_by_detail', + ordering: 'updated_by', + title: t`Updated By` + }) + ]; + }, [user]); + + const tableFilters: TableFilter[] = useMemo(() => { + return [ + { + name: 'enabled', + label: 'Enabled', + description: t`Show parameters for enabled templates`, + type: 'boolean' + }, + UserFilter({ + name: 'updated_by', + label: t`Updated By`, + description: t`Filter by user who last updated the parameter` + }) + ]; + }, []); + + const [selectedParameter, setSelectedParameter] = useState( + undefined + ); + + const [importOpened, setImportOpened] = useState(false); + + const [selectedSession, setSelectedSession] = useState( + undefined + ); + + const importSessionFields = useMemo(() => { + const fields = dataImporterSessionFields({ + modelType: ModelType.parameter + }); + + fields.field_overrides.value = { + model_type: modelType, + model_id: modelId + }; + + return fields; + }, [modelType, modelId]); + + const importParameters = useCreateApiFormModal({ + url: ApiEndpoints.import_session_list, + title: t`Import Parameters`, + fields: importSessionFields, + onFormSuccess: (response: any) => { + setSelectedSession(response.pk); + setImportOpened(true); + } + }); + + const newParameter = useCreateApiFormModal({ + url: ApiEndpoints.parameter_list, + title: t`Add Parameter`, + fields: useParameterFields({ modelType, modelId }), + initialData: { + data: '' + }, + table: table + }); + + const editParameter = useEditApiFormModal({ + url: ApiEndpoints.parameter_list, + pk: selectedParameter?.pk, + title: t`Edit Parameter`, + fields: useParameterFields({ modelType, modelId }), + table: table + }); + + const deleteParameter = useDeleteApiFormModal({ + url: ApiEndpoints.parameter_list, + pk: selectedParameter?.pk, + title: t`Delete Parameter`, + table: table + }); + + const tableActions = useMemo(() => { + return [ + } + hidden={!user.hasAddPermission(modelType)} + actions={[ + { + name: t`Create Parameter`, + icon: , + tooltip: t`Create a new parameter`, + onClick: () => { + setSelectedParameter(undefined); + newParameter.open(); + } + }, + { + name: t`Import from File`, + icon: , + tooltip: t`Import parameters from a file`, + onClick: () => { + importParameters.open(); + } + } + ]} + /> + ]; + }, [allowEdit, user]); + + const rowActions = useCallback( + (record: any) => { + return [ + RowEditAction({ + tooltip: t`Edit Parameter`, + onClick: () => { + setSelectedParameter(record); + editParameter.open(); + }, + hidden: !user.hasChangePermission(modelType) + }), + RowDeleteAction({ + tooltip: t`Delete Parameter`, + onClick: () => { + setSelectedParameter(record); + deleteParameter.open(); + }, + hidden: !user.hasDeletePermission(modelType) + }) + ]; + }, + [user] + ); + + return ( + <> + {newParameter.modal} + {editParameter.modal} + {deleteParameter.modal} + {importParameters.modal} + + { + setSelectedSession(undefined); + setImportOpened(false); + table.refreshTable(); + }} + /> + + ); +} diff --git a/src/frontend/src/tables/part/PartParameterTemplateTable.tsx b/src/frontend/src/tables/general/ParameterTemplateTable.tsx similarity index 66% rename from src/frontend/src/tables/part/PartParameterTemplateTable.tsx rename to src/frontend/src/tables/general/ParameterTemplateTable.tsx index c9961422be..fbf8103664 100644 --- a/src/frontend/src/tables/part/PartParameterTemplateTable.tsx +++ b/src/frontend/src/tables/general/ParameterTemplateTable.tsx @@ -1,19 +1,18 @@ -import { t } from '@lingui/core/macro'; -import { useCallback, useMemo, useState } from 'react'; - -import { AddItemButton } from '@lib/components/AddItemButton'; import { - type RowAction, + AddItemButton, + ApiEndpoints, + type ApiFormFieldSet, RowDeleteAction, RowDuplicateAction, - RowEditAction -} from '@lib/components/RowActions'; -import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; -import { UserRoles } from '@lib/enums/Roles'; -import { apiUrl } from '@lib/functions/Api'; + RowEditAction, + UserRoles, + apiUrl +} from '@lib/index'; import type { TableFilter } from '@lib/types/Filters'; -import type { ApiFormFieldSet } from '@lib/types/Forms'; -import type { TableColumn } from '@lib/types/Tables'; +import type { RowAction, TableColumn } from '@lib/types/Tables'; +import { t } from '@lingui/core/macro'; +import { useCallback, useMemo, useState } from 'react'; +import { useFilters } from '../../hooks/UseFilter'; import { useCreateApiFormModal, useDeleteApiFormModal, @@ -24,11 +23,114 @@ import { useUserState } from '../../states/UserState'; import { BooleanColumn, DescriptionColumn } from '../ColumnRenderers'; import { InvenTreeTable } from '../InvenTreeTable'; -export default function PartParameterTemplateTable() { - const table = useTable('part-parameter-templates'); - +/** + * Render a table of ParameterTemplate objects + */ +export default function ParameterTemplateTable() { + const table = useTable('parameter-templates'); const user = useUserState(); + const parameterTemplateFields: ApiFormFieldSet = useMemo(() => { + return { + name: {}, + description: {}, + units: {}, + model_type: {}, + choices: {}, + checkbox: {}, + selectionlist: {}, + enabled: {} + }; + }, []); + + const newTemplate = useCreateApiFormModal({ + url: ApiEndpoints.parameter_template_list, + title: t`Add Parameter Template`, + table: table, + fields: useMemo( + () => ({ + ...parameterTemplateFields + }), + [parameterTemplateFields] + ) + }); + + const [selectedTemplate, setSelectedTemplate] = useState( + undefined + ); + + const duplicateTemplate = useCreateApiFormModal({ + url: ApiEndpoints.parameter_template_list, + title: t`Duplicate Parameter Template`, + table: table, + fields: useMemo( + () => ({ + ...parameterTemplateFields + }), + [parameterTemplateFields] + ), + initialData: selectedTemplate + }); + + const deleteTemplate = useDeleteApiFormModal({ + url: ApiEndpoints.parameter_template_list, + pk: selectedTemplate?.pk, + title: t`Delete Parameter Template`, + table: table + }); + + const editTemplate = useEditApiFormModal({ + url: ApiEndpoints.parameter_template_list, + pk: selectedTemplate?.pk, + title: t`Edit Parameter Template`, + table: table, + fields: useMemo( + () => ({ + ...parameterTemplateFields + }), + [parameterTemplateFields] + ) + }); + + // Callback for row actions + const rowActions = useCallback( + (record: any): RowAction[] => { + return [ + RowEditAction({ + onClick: () => { + setSelectedTemplate(record); + editTemplate.open(); + } + }), + RowDuplicateAction({ + onClick: () => { + setSelectedTemplate(record); + duplicateTemplate.open(); + } + }), + RowDeleteAction({ + onClick: () => { + setSelectedTemplate(record); + deleteTemplate.open(); + } + }) + ]; + }, + [user] + ); + + const modelTypeFilters = useFilters({ + url: apiUrl(ApiEndpoints.parameter_template_list), + method: 'OPTIONS', + accessor: 'data.actions.POST.model_type.choices', + transform: (item: any) => { + return { + value: item.value, + label: item.display_name + }; + } + }); + const tableFilters: TableFilter[] = useMemo(() => { return [ { @@ -45,9 +147,20 @@ export default function PartParameterTemplateTable() { name: 'has_units', label: t`Has Units`, description: t`Show templates with units` + }, + { + name: 'enabled', + label: t`Enabled`, + description: t`Show enabled templates` + }, + { + name: 'model_type', + label: t`Model Type`, + description: t`Filter by model type`, + choices: modelTypeFilters.choices } ]; - }, []); + }, [modelTypeFilters.choices]); const tableColumns: TableColumn[] = useMemo(() => { return [ @@ -56,109 +169,27 @@ export default function PartParameterTemplateTable() { sortable: true, switchable: false }, - { - accessor: 'parts', - sortable: true, - switchable: true - }, + DescriptionColumn({}), { accessor: 'units', sortable: true }, - DescriptionColumn({}), + { + accessor: 'model_type' + }, BooleanColumn({ accessor: 'checkbox' }), { accessor: 'choices' - } + }, + BooleanColumn({ + accessor: 'enabled', + title: t`Enabled` + }) ]; }, []); - const partParameterTemplateFields: ApiFormFieldSet = useMemo(() => { - return { - name: {}, - description: {}, - units: {}, - choices: {}, - checkbox: {}, - selectionlist: {} - }; - }, []); - - const newTemplate = useCreateApiFormModal({ - url: ApiEndpoints.part_parameter_template_list, - title: t`Add Parameter Template`, - table: table, - fields: useMemo( - () => ({ ...partParameterTemplateFields }), - [partParameterTemplateFields] - ) - }); - - const [selectedTemplate, setSelectedTemplate] = useState( - undefined - ); - - const duplicateTemplate = useCreateApiFormModal({ - url: ApiEndpoints.part_parameter_template_list, - title: t`Duplicate Parameter Template`, - table: table, - fields: useMemo( - () => ({ ...partParameterTemplateFields }), - [partParameterTemplateFields] - ), - initialData: selectedTemplate - }); - - const editTemplate = useEditApiFormModal({ - url: ApiEndpoints.part_parameter_template_list, - pk: selectedTemplate?.pk, - title: t`Edit Parameter Template`, - table: table, - fields: useMemo( - () => ({ ...partParameterTemplateFields }), - [partParameterTemplateFields] - ) - }); - - const deleteTemplate = useDeleteApiFormModal({ - url: ApiEndpoints.part_parameter_template_list, - pk: selectedTemplate?.pk, - title: t`Delete Parameter Template`, - table: table - }); - - // Callback for row actions - const rowActions = useCallback( - (record: any): RowAction[] => { - return [ - RowEditAction({ - hidden: !user.hasChangeRole(UserRoles.part), - onClick: () => { - setSelectedTemplate(record); - editTemplate.open(); - } - }), - RowDuplicateAction({ - hidden: !user.hasAddRole(UserRoles.part), - onClick: () => { - setSelectedTemplate(record); - duplicateTemplate.open(); - } - }), - RowDeleteAction({ - hidden: !user.hasDeleteRole(UserRoles.part), - onClick: () => { - setSelectedTemplate(record); - deleteTemplate.open(); - } - }) - ]; - }, - [user] - ); - const tableActions = useMemo(() => { return [ diff --git a/src/frontend/src/tables/general/ParametricDataTable.tsx b/src/frontend/src/tables/general/ParametricDataTable.tsx new file mode 100644 index 0000000000..279f68e90b --- /dev/null +++ b/src/frontend/src/tables/general/ParametricDataTable.tsx @@ -0,0 +1,442 @@ +import { cancelEvent } from '@lib/functions/Events'; +import { + ApiEndpoints, + type ApiFormFieldSet, + type ModelType, + UserRoles, + YesNoButton, + apiUrl, + formatDecimal, + getDetailUrl, + navigateToLink +} from '@lib/index'; +import type { TableFilter } from '@lib/types/Filters'; +import type { TableColumn } from '@lib/types/Tables'; +import { t } from '@lingui/core/macro'; +import { Group } from '@mantine/core'; +import { useHover } from '@mantine/hooks'; +import { IconCirclePlus } from '@tabler/icons-react'; +import { useQuery } from '@tanstack/react-query'; +import { type ReactNode, useCallback, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useApi } from '../../contexts/ApiContext'; +import { useParameterFields } from '../../forms/CommonForms'; +import { + useCreateApiFormModal, + useEditApiFormModal +} from '../../hooks/UseForm'; +import { useTable } from '../../hooks/UseTable'; +import { useUserState } from '../../states/UserState'; +import { InvenTreeTable } from '../InvenTreeTable'; +import { TableHoverCard } from '../TableHoverCard'; +import { + PARAMETER_FILTER_OPERATORS, + ParameterFilter +} from './ParametricDataTableFilters'; + +// Render an individual parameter cell +function ParameterCell({ + record, + template, + canEdit +}: Readonly<{ + record: any; + template: any; + canEdit: boolean; +}>) { + const { hovered, ref } = useHover(); + + // Find matching template parameter + const parameter = useMemo(() => { + return record.parameters?.find((p: any) => p.template == template.pk); + }, [record, template]); + + const extra: any[] = []; + + // Format the value for display + const value: ReactNode = useMemo(() => { + let v: any = parameter?.data; + + // Handle boolean values + if (template?.checkbox && v != undefined) { + v = ; + } + + return v; + }, [parameter, template]); + + if ( + template.units && + parameter && + parameter.data_numeric && + parameter.data_numeric != parameter.data + ) { + const numeric = formatDecimal(parameter.data_numeric, { digits: 15 }); + extra.push(`${numeric} [${template.units}]`); + } + + if (hovered && canEdit) { + extra.push(t`Click to edit`); + } + + return ( +

+ ); +} + +/** + * A table which displays parametric data for generic model types. + * The table can be extended by passing in additional column, filters, and actions. + */ +export default function ParametricDataTable({ + modelType, + endpoint, + queryParams, + customFilters, + customColumns +}: { + modelType: ModelType; + endpoint: ApiEndpoints | string; + queryParams?: Record; + customFilters?: TableFilter[]; + customColumns?: TableColumn[]; +}) { + const api = useApi(); + const table = useTable(`parametric-data-${modelType}`); + const user = useUserState(); + const navigate = useNavigate(); + + // Fetch all active parameter templates for the given model type + const parameterTemplates = useQuery({ + queryKey: ['parameter-templates', modelType], + queryFn: async () => { + return api + .get(apiUrl(ApiEndpoints.parameter_template_list), { + params: { + active: true, + for_model: modelType, + exists_for_model: modelType + } + }) + .then((response) => response.data); + }, + refetchOnMount: true + }); + + /* Store filters against selected part parameters. + * These are stored in the format: + * { + * parameter_1: { + * '=': 'value1', + * '<': 'value2', + * ... + * }, + * parameter_2: { + * '=': 'value3', + * }, + * ... + * } + * + * Which allows multiple filters to be applied against each parameter template. + */ + const [parameterFilters, setParameterFilters] = useState({}); + + /* Remove filters for a specific parameter template + * - If no operator is specified, remove all filters for this template + * - If an operator is specified, remove filters for that operator only + */ + const clearParameterFilter = useCallback( + (templateId: number, operator?: string) => { + const filterName = `parameter_${templateId}`; + + if (!operator) { + // If no operator is specified, remove all filters for this template + setParameterFilters((prev: any) => { + const newFilters = { ...prev }; + // Remove any filters that match the template ID + Object.keys(newFilters).forEach((key: string) => { + if (key == filterName) { + delete newFilters[key]; + } + }); + return newFilters; + }); + + return; + } + + // An operator is specified, so we remove filters for that operator only + setParameterFilters((prev: any) => { + const filters = { ...prev }; + + const paramFilters = filters[filterName] || {}; + + if (paramFilters[operator] !== undefined) { + // Remove the specific operator filter + delete paramFilters[operator]; + } + + return { + ...filters, + [filterName]: paramFilters + }; + }); + + table.refreshTable(); + }, + [setParameterFilters, table.refreshTable] + ); + + /** + * Add (or update) a filter for a specific parameter template. + * @param templateId - The ID of the parameter template to filter on. + * @param value - The value to filter by. + * @param operator - The operator to use for filtering (e.g., '=', '<', '>', etc.). + */ + const addParameterFilter = useCallback( + (templateId: number, value: string, operator: string) => { + const filterName = `parameter_${templateId}`; + + const filterValue = value?.toString().trim() ?? ''; + + if (filterValue.length > 0) { + setParameterFilters((prev: any) => { + const filters = { ...prev }; + const paramFilters = filters[filterName] || {}; + + paramFilters[operator] = filterValue; + + return { + ...filters, + [filterName]: paramFilters + }; + }); + + table.refreshTable(); + } + }, + [setParameterFilters, clearParameterFilter, table.refreshTable] + ); + + // Construct the query filters for the table based on the parameter filters + const parametricQueryFilters = useMemo(() => { + const filters: Record = {}; + + Object.keys(parameterFilters).forEach((key: string) => { + const paramFilters: any = parameterFilters[key]; + + Object.keys(paramFilters).forEach((operator: string) => { + const name = `${key}${PARAMETER_FILTER_OPERATORS[operator] || ''}`; + const value = paramFilters[operator]; + + filters[name] = value; + }); + }); + + return filters; + }, [parameterFilters]); + + const [selectedInstance, setSelectedInstance] = useState(-1); + const [selectedTemplate, setSelectedTemplate] = useState(null); + const [selectedParameter, setSelectedParameter] = useState(-1); + + const parameterFields: ApiFormFieldSet = useParameterFields({ + modelType: modelType, + modelId: selectedInstance + }); + + const addParameter = useCreateApiFormModal({ + url: ApiEndpoints.parameter_list, + title: t`Add Parameter`, + fields: useMemo(() => ({ ...parameterFields }), [parameterFields]), + focus: 'data', + onFormSuccess: (parameter: any) => { + updateParameterRecord(selectedInstance, parameter); + + // Ensure that the parameter template is included in the table + const template = parameterTemplates.data.find( + (t: any) => t.pk == parameter.template + ); + + if (!template) { + // Reload the parameter templates + parameterTemplates.refetch(); + } + }, + initialData: { + part: selectedInstance, + template: selectedTemplate + } + }); + + const editParameter = useEditApiFormModal({ + url: ApiEndpoints.parameter_list, + title: t`Edit Parameter`, + pk: selectedParameter, + fields: useMemo(() => ({ ...parameterFields }), [parameterFields]), + focus: 'data', + onFormSuccess: (parameter: any) => { + updateParameterRecord(selectedInstance, parameter); + } + }); + + // Update a single parameter record in the table + const updateParameterRecord = useCallback( + (part: number, parameter: any) => { + const records = table.records; + const recordIndex = records.findIndex((record: any) => record.pk == part); + + if (recordIndex < 0) { + // No matching part: reload the entire table + table.refreshTable(); + return; + } + + const parameterIndex = records[recordIndex].parameters.findIndex( + (p: any) => p.pk == parameter.pk + ); + + if (parameterIndex < 0) { + // No matching parameter - append new parameter + records[recordIndex].parameters.push(parameter); + } else { + records[recordIndex].parameters[parameterIndex] = parameter; + } + + table.updateRecord(records[recordIndex]); + }, + [table.records, table.updateRecord] + ); + + const parameterColumns: TableColumn[] = useMemo(() => { + const data = parameterTemplates?.data || []; + + return data.map((template: any) => { + let title = template.name; + + if (template.units) { + title += ` [${template.units}]`; + } + + const filters = parameterFilters[`parameter_${template.pk}`] || {}; + + return { + accessor: `parameter_${template.pk}`, + title: title, + sortable: true, + extra: { + template: template.pk + }, + render: (record: any) => ( + + ), + filtering: Object.keys(filters).length > 0, + filter: ({ close }: { close: () => void }) => { + return ( + + ); + } + }; + }); + }, [user, parameterTemplates.data, parameterFilters]); + + // Callback function when a parameter cell is clicked + const onParameterClick = useCallback((template: number, instance: any) => { + setSelectedTemplate(template); + setSelectedInstance(instance.pk); + const parameter = instance.parameters?.find( + (p: any) => p.template == template + ); + + if (parameter) { + setSelectedParameter(parameter.pk); + editParameter.open(); + } else { + addParameter.open(); + } + }, []); + + const tableFilters: TableFilter[] = useMemo(() => { + return [...(customFilters || [])]; + }, [customFilters]); + + const tableColumns: TableColumn[] = useMemo(() => { + return [...(customColumns || []), ...parameterColumns]; + }, [customColumns, parameterColumns]); + + const rowActions = useCallback( + (record: any) => { + return [ + { + title: t`Add Parameter`, + icon: , + color: 'green', + hidden: !user.hasAddPermission(modelType), + onClick: () => { + setSelectedInstance(record.pk); + setSelectedTemplate(null); + addParameter.open(); + } + } + ]; + }, + [modelType, user] + ); + + return ( + <> + {addParameter.modal} + {editParameter.modal} + { + cancelEvent(event); + + // Is this a "parameter" cell? + if (column?.accessor?.toString()?.startsWith('parameter_')) { + const col = column as any; + onParameterClick(col.extra.template, record); + } else if (record?.pk) { + // Navigate through to the detail page + const url = getDetailUrl(modelType, record.pk); + navigateToLink(url, navigate, event); + } + } + }} + /> + + ); +} diff --git a/src/frontend/src/tables/part/ParametricPartTableFilters.tsx b/src/frontend/src/tables/general/ParametricDataTableFilters.tsx similarity index 100% rename from src/frontend/src/tables/part/ParametricPartTableFilters.tsx rename to src/frontend/src/tables/general/ParametricDataTableFilters.tsx diff --git a/src/frontend/src/tables/part/ParametricPartTable.tsx b/src/frontend/src/tables/part/ParametricPartTable.tsx index 4203fdd96f..f99e3ee668 100644 --- a/src/frontend/src/tables/part/ParametricPartTable.tsx +++ b/src/frontend/src/tables/part/ParametricPartTable.tsx @@ -1,353 +1,18 @@ -import { t } from '@lingui/core/macro'; -import { Group } from '@mantine/core'; -import { useHover } from '@mantine/hooks'; -import { useQuery } from '@tanstack/react-query'; -import { type ReactNode, useCallback, useMemo, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; - -import { YesNoButton } from '@lib/components/YesNoButton'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ModelType } from '@lib/enums/ModelType'; -import { UserRoles } from '@lib/enums/Roles'; -import { apiUrl } from '@lib/functions/Api'; -import { cancelEvent } from '@lib/functions/Events'; -import { getDetailUrl } from '@lib/functions/Navigation'; -import { navigateToLink } from '@lib/functions/Navigation'; import type { TableFilter } from '@lib/types/Filters'; -import type { ApiFormFieldSet } from '@lib/types/Forms'; import type { TableColumn } from '@lib/types/Tables'; -import { useApi } from '../../contexts/ApiContext'; -import { formatDecimal } from '../../defaults/formatters'; -import { usePartParameterFields } from '../../forms/PartForms'; -import { - useCreateApiFormModal, - useEditApiFormModal -} from '../../hooks/UseForm'; -import { useTable } from '../../hooks/UseTable'; -import { useUserState } from '../../states/UserState'; +import { t } from '@lingui/core/macro'; +import { useMemo } from 'react'; import { DescriptionColumn, PartColumn } from '../ColumnRenderers'; -import { InvenTreeTable } from '../InvenTreeTable'; -import { TableHoverCard } from '../TableHoverCard'; -import { - PARAMETER_FILTER_OPERATORS, - ParameterFilter -} from './ParametricPartTableFilters'; - -// Render an individual parameter cell -function ParameterCell({ - record, - template, - canEdit -}: Readonly<{ - record: any; - template: any; - canEdit: boolean; -}>) { - const { hovered, ref } = useHover(); - - // Find matching template parameter - const parameter = useMemo(() => { - return record.parameters?.find((p: any) => p.template == template.pk); - }, [record, template]); - - const extra: any[] = []; - - // Format the value for display - const value: ReactNode = useMemo(() => { - let v: any = parameter?.data; - - // Handle boolean values - if (template?.checkbox && v != undefined) { - v = ; - } - - return v; - }, [parameter, template]); - - if ( - template.units && - parameter && - parameter.data_numeric && - parameter.data_numeric != parameter.data - ) { - const numeric = formatDecimal(parameter.data_numeric, { digits: 15 }); - extra.push(`${numeric} [${template.units}]`); - } - - if (hovered && canEdit) { - extra.push(t`Click to edit`); - } - - return ( -
- - - - - -
- ); -} +import ParametricDataTable from '../general/ParametricDataTable'; export default function ParametricPartTable({ categoryId }: Readonly<{ categoryId?: any; }>) { - const api = useApi(); - const table = useTable('parametric-parts'); - const user = useUserState(); - const navigate = useNavigate(); - - const categoryParameters = useQuery({ - queryKey: ['category-parameters', categoryId], - queryFn: async () => { - return api - .get(apiUrl(ApiEndpoints.part_parameter_template_list), { - params: { - category: categoryId - } - }) - .then((response) => response.data); - }, - refetchOnMount: true - }); - - /* Store filters against selected part parameters. - * These are stored in the format: - * { - * parameter_1: { - * '=': 'value1', - * '<': 'value2', - * ... - * }, - * parameter_2: { - * '=': 'value3', - * }, - * ... - * } - * - * Which allows multiple filters to be applied against each parameter template. - */ - const [parameterFilters, setParameterFilters] = useState({}); - - /* Remove filters for a specific parameter template - * - If no operator is specified, remove all filters for this template - * - If an operator is specified, remove filters for that operator only - */ - const clearParameterFilter = useCallback( - (templateId: number, operator?: string) => { - const filterName = `parameter_${templateId}`; - - if (!operator) { - // If no operator is specified, remove all filters for this template - setParameterFilters((prev: any) => { - const newFilters = { ...prev }; - // Remove any filters that match the template ID - Object.keys(newFilters).forEach((key: string) => { - if (key == filterName) { - delete newFilters[key]; - } - }); - return newFilters; - }); - - return; - } - - // An operator is specified, so we remove filters for that operator only - setParameterFilters((prev: any) => { - const filters = { ...prev }; - - const paramFilters = filters[filterName] || {}; - - if (paramFilters[operator] !== undefined) { - // Remove the specific operator filter - delete paramFilters[operator]; - } - - return { - ...filters, - [filterName]: paramFilters - }; - }); - - table.refreshTable(); - }, - [setParameterFilters, table.refreshTable] - ); - - /** - * Add (or update) a filter for a specific parameter template. - * @param templateId - The ID of the parameter template to filter on. - * @param value - The value to filter by. - * @param operator - The operator to use for filtering (e.g., '=', '<', '>', etc.). - */ - const addParameterFilter = useCallback( - (templateId: number, value: string, operator: string) => { - const filterName = `parameter_${templateId}`; - - const filterValue = value?.toString().trim() ?? ''; - - if (filterValue.length > 0) { - setParameterFilters((prev: any) => { - const filters = { ...prev }; - const paramFilters = filters[filterName] || {}; - - paramFilters[operator] = filterValue; - - return { - ...filters, - [filterName]: paramFilters - }; - }); - - table.refreshTable(); - } - }, - [setParameterFilters, clearParameterFilter, table.refreshTable] - ); - - // Construct the query filters for the table based on the parameter filters - const parametricQueryFilters = useMemo(() => { - const filters: Record = {}; - - Object.keys(parameterFilters).forEach((key: string) => { - const paramFilters: any = parameterFilters[key]; - - Object.keys(paramFilters).forEach((operator: string) => { - const name = `${key}${PARAMETER_FILTER_OPERATORS[operator] || ''}`; - const value = paramFilters[operator]; - - filters[name] = value; - }); - }); - - return filters; - }, [parameterFilters]); - - const [selectedPart, setSelectedPart] = useState(0); - const [selectedTemplate, setSelectedTemplate] = useState(0); - const [selectedParameter, setSelectedParameter] = useState(0); - - const partParameterFields: ApiFormFieldSet = usePartParameterFields({ - editTemplate: false - }); - - const addParameter = useCreateApiFormModal({ - url: ApiEndpoints.part_parameter_list, - title: t`Add Part Parameter`, - fields: useMemo(() => ({ ...partParameterFields }), [partParameterFields]), - focus: 'data', - onFormSuccess: (parameter: any) => { - updateParameterRecord(selectedPart, parameter); - }, - initialData: { - part: selectedPart, - template: selectedTemplate - } - }); - - const editParameter = useEditApiFormModal({ - url: ApiEndpoints.part_parameter_list, - title: t`Edit Part Parameter`, - pk: selectedParameter, - fields: useMemo(() => ({ ...partParameterFields }), [partParameterFields]), - focus: 'data', - onFormSuccess: (parameter: any) => { - updateParameterRecord(selectedPart, parameter); - } - }); - - // Update a single parameter record in the table - const updateParameterRecord = useCallback( - (part: number, parameter: any) => { - const records = table.records; - const partIndex = records.findIndex((record: any) => record.pk == part); - - if (partIndex < 0) { - // No matching part: reload the entire table - table.refreshTable(); - return; - } - - const parameterIndex = records[partIndex].parameters.findIndex( - (p: any) => p.pk == parameter.pk - ); - - if (parameterIndex < 0) { - // No matching parameter - append new parameter - records[partIndex].parameters.push(parameter); - } else { - records[partIndex].parameters[parameterIndex] = parameter; - } - - table.updateRecord(records[partIndex]); - }, - [table.records, table.updateRecord] - ); - - const parameterColumns: TableColumn[] = useMemo(() => { - const data = categoryParameters?.data || []; - - return data.map((template: any) => { - let title = template.name; - - if (template.units) { - title += ` [${template.units}]`; - } - - const filters = parameterFilters[`parameter_${template.pk}`] || {}; - - return { - accessor: `parameter_${template.pk}`, - title: title, - sortable: true, - extra: { - template: template.pk - }, - render: (record: any) => ( - - ), - filtering: Object.keys(filters).length > 0, - filter: ({ close }: { close: () => void }) => { - return ( - - ); - } - }; - }); - }, [user, categoryParameters.data, parameterFilters]); - - const onParameterClick = useCallback((template: number, part: any) => { - setSelectedTemplate(template); - setSelectedPart(part.pk); - const parameter = part.parameters?.find((p: any) => p.template == template); - - if (parameter) { - setSelectedParameter(parameter.pk); - editParameter.open(); - } else { - addParameter.open(); - } - }, []); - - const tableFilters: TableFilter[] = useMemo(() => { + const customFilters: TableFilter[] = useMemo(() => { return [ { name: 'active', @@ -367,8 +32,8 @@ export default function ParametricPartTable({ ]; }, []); - const tableColumns: TableColumn[] = useMemo(() => { - const partColumns: TableColumn[] = [ + const customColumns: TableColumn[] = useMemo(() => { + return [ PartColumn({ part: '', switchable: false @@ -386,43 +51,19 @@ export default function ParametricPartTable({ sortable: true } ]; - - return [...partColumns, ...parameterColumns]; - }, [parameterColumns]); + }, []); return ( - <> - {addParameter.modal} - {editParameter.modal} - { - cancelEvent(event); - - // Is this a "parameter" cell? - if (column?.accessor?.toString()?.startsWith('parameter_')) { - const col = column as any; - onParameterClick(col.extra.template, record); - } else if (record?.pk) { - // Navigate through to the part detail page - const url = getDetailUrl(ModelType.part, record.pk); - navigateToLink(url, navigate, event); - } - } - }} - /> - + ); } diff --git a/src/frontend/src/tables/part/PartCategoryTemplateTable.tsx b/src/frontend/src/tables/part/PartCategoryTemplateTable.tsx index 6d705afbce..604e18609a 100644 --- a/src/frontend/src/tables/part/PartCategoryTemplateTable.tsx +++ b/src/frontend/src/tables/part/PartCategoryTemplateTable.tsx @@ -37,7 +37,7 @@ export default function PartCategoryTemplateTable({ value: categoryId, disabled: categoryId !== undefined }, - parameter_template: {}, + template: {}, default_value: {} }; }, [categoryId]); @@ -83,7 +83,7 @@ export default function PartCategoryTemplateTable({ accessor: 'category_detail.pathstring' }, { - accessor: 'parameter_template_detail.name', + accessor: 'template_detail.name', title: t`Parameter Template`, sortable: true, switchable: false @@ -99,8 +99,8 @@ export default function PartCategoryTemplateTable({ let units = ''; - if (record?.parameter_template_detail?.units) { - units = `[${record.parameter_template_detail.units}]`; + if (record?.template_detail?.units) { + units = `[${record.template_detail.units}]`; } return ( @@ -162,7 +162,9 @@ export default function PartCategoryTemplateTable({ tableActions: tableActions, enableDownload: true, params: { - category: categoryId + category: categoryId, + template_detail: true, + category_detail: true } }} /> diff --git a/src/frontend/src/tables/part/PartParameterTable.tsx b/src/frontend/src/tables/part/PartParameterTable.tsx deleted file mode 100644 index d7119e4324..0000000000 --- a/src/frontend/src/tables/part/PartParameterTable.tsx +++ /dev/null @@ -1,254 +0,0 @@ -import { t } from '@lingui/core/macro'; -import { Alert, Stack, Text } from '@mantine/core'; -import { IconLock } from '@tabler/icons-react'; -import { useCallback, useMemo, useState } from 'react'; - -import { AddItemButton } from '@lib/components/AddItemButton'; -import { - type RowAction, - RowDeleteAction, - RowEditAction -} from '@lib/components/RowActions'; -import { YesNoButton } from '@lib/components/YesNoButton'; -import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; -import { UserRoles } from '@lib/enums/Roles'; -import { apiUrl } from '@lib/functions/Api'; -import type { TableFilter } from '@lib/types/Filters'; -import type { ApiFormFieldSet } from '@lib/types/Forms'; -import type { TableColumn } from '@lib/types/Tables'; -import { formatDecimal } from '../../defaults/formatters'; -import { usePartParameterFields } from '../../forms/PartForms'; -import { - useCreateApiFormModal, - useDeleteApiFormModal, - useEditApiFormModal -} from '../../hooks/UseForm'; -import { useTable } from '../../hooks/UseTable'; -import { useUserState } from '../../states/UserState'; -import { - DateColumn, - DescriptionColumn, - NoteColumn, - PartColumn, - UserColumn -} from '../ColumnRenderers'; -import { IncludeVariantsFilter, UserFilter } from '../Filter'; -import { InvenTreeTable } from '../InvenTreeTable'; -import { TableHoverCard } from '../TableHoverCard'; - -/** - * Construct a table listing parameters for a given part - */ -export function PartParameterTable({ - partId, - partLocked -}: Readonly<{ - partId: any; - partLocked?: boolean; -}>) { - const table = useTable('part-parameters'); - - const user = useUserState(); - - const tableColumns: TableColumn[] = useMemo(() => { - return [ - PartColumn({ - part: 'part_detail' - }), - { - accessor: 'part_detail.IPN', - sortable: false, - switchable: true, - defaultVisible: false - }, - { - accessor: 'template_detail.name', - switchable: false, - sortable: true, - ordering: 'name', - render: (record) => { - const variant = String(partId) != String(record.part); - - return ( - - {record.template_detail?.name} - - ); - } - }, - DescriptionColumn({ - accessor: 'template_detail.description' - }), - { - accessor: 'data', - switchable: false, - sortable: true, - render: (record) => { - const template = record.template_detail; - - if (template?.checkbox) { - return ; - } - - const extra: any[] = []; - - if ( - template.units && - record.data_numeric && - record.data_numeric != record.data - ) { - const numeric = formatDecimal(record.data_numeric, { digits: 15 }); - extra.push(`${numeric} [${template.units}]`); - } - - return ( - - ); - } - }, - { - accessor: 'template_detail.units', - ordering: 'units', - sortable: true - }, - NoteColumn({}), - DateColumn({ - accessor: 'updated', - title: t`Last Updated`, - sortable: true, - switchable: true - }), - UserColumn({ - accessor: 'updated_by_detail', - ordering: 'updated_by', - title: t`Updated By` - }) - ]; - }, [partId]); - - const tableFilters: TableFilter[] = useMemo(() => { - return [ - IncludeVariantsFilter(), - UserFilter({ - name: 'updated_by', - label: t`Updated By`, - description: t`Filter by user who last updated the parameter` - }) - ]; - }, []); - - const partParameterFields: ApiFormFieldSet = usePartParameterFields({}); - - const newParameter = useCreateApiFormModal({ - url: ApiEndpoints.part_parameter_list, - title: t`New Part Parameter`, - fields: useMemo(() => ({ ...partParameterFields }), [partParameterFields]), - focus: 'template', - initialData: { - part: partId - }, - table: table - }); - - const [selectedParameter, setSelectedParameter] = useState< - number | undefined - >(undefined); - - const editParameter = useEditApiFormModal({ - url: ApiEndpoints.part_parameter_list, - pk: selectedParameter, - title: t`Edit Part Parameter`, - focus: 'data', - fields: useMemo(() => ({ ...partParameterFields }), [partParameterFields]), - table: table - }); - - const deleteParameter = useDeleteApiFormModal({ - url: ApiEndpoints.part_parameter_list, - pk: selectedParameter, - title: t`Delete Part Parameter`, - table: table - }); - - // Callback for row actions - const rowActions = useCallback( - (record: any): RowAction[] => { - // Actions not allowed for "variant" rows - if (String(partId) != String(record.part)) { - return []; - } - - return [ - RowEditAction({ - tooltip: t`Edit Part Parameter`, - hidden: partLocked || !user.hasChangeRole(UserRoles.part), - onClick: () => { - setSelectedParameter(record.pk); - editParameter.open(); - } - }), - RowDeleteAction({ - tooltip: t`Delete Part Parameter`, - hidden: partLocked || !user.hasDeleteRole(UserRoles.part), - onClick: () => { - setSelectedParameter(record.pk); - deleteParameter.open(); - } - }) - ]; - }, - [partId, partLocked, user] - ); - - // Custom table actions - const tableActions = useMemo(() => { - return [ -