From aa39582d8966398ed78fd92e20db71156f2f0168 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 22 May 2024 10:17:01 +1000 Subject: [PATCH] Report printing refactor (#7074) * Adds a new "generic" ReportTemplate model * expose API endpoints * Update model / migrations / serializer * Add new mixin class to existing database models * - Add detail view for report template - Revert filters field behaviour * Filter report list by provided item IDs - Greatly simplify filtering logic compared to existing implemetation - Expose to API schema * Create data migration for converting *old* report templates * Ignore internal reports for data migration * Add report mixin to StockLocation model * Provide model choices in admin interface * Offload context data generation to the model classes * Remove old report template models * Refactor JS code in CUI * Fix for API filtering * Add data migration to delete old models * Remove dead URL * Updates * Construct sample report templates on app start * Bump API version * Typo fix * Fix incorrect context calls * Add new LabelTemplate model - ReportTemplate and LabelTemplate share common base - Refactor previous migration * Expose to admin interface * Add in extra context from existing label models * Add migration to create LabelTemplate instances from existing labels * Add API endpoints for listing and updating LabelTemplate objects * Adjust 'upload_to' path * Refactor label printing * Move default label templates * Update API endpoints * Update migrations * Handle LookupError in migration * Redirect the "label" API endpoint * Add new model for handling result of template printing * Refactor LabelPrinting mixin * Unlink "labels" app entirely * Fix typo * Record 'plugin' used to generate a particular output * Fix imports * Generate label print response - Still not good yet * Refactoring label printing in CUI * add "items" count to TemplateOutput model * Fix for InvenTreeLabelSheetPlugin * Remove old "label" app * Make request object optional * Fix filename generation * Add help text for "model_type" * Simplify TemplateTable * Tweak TemplateTable * Get template editor to display template data again * Stringify template name - Important, otherwise you get a TypeError instead of TemplateDoesNotExist * Add hooks to reset plugin state * fix context for StockLocation model * Tweak log messages * Fix incorrect serializer * Cleanup TemplateTable * Fix broken import * Filter by target model type * Remove manual file operations * Update old migrations - Remove references to functions that no longer exist * Refactor asset / snippet uploading * Update comments * Retain original filename when editing templatese * Cleanup * Refactor model type filter to use new hook * Add placeholder actions for printing labels and reports * Improve hookiness * Add new ReportOutput class * Report printing works from PUI now! * More inspired filename pattern for generated reports * Fix template preview window - Use new "output" response field across the board * Remove outdated task * Update data migration to use raw SQL - If the 'labels' app is no longer available, this will fail - So, use raw SQL instead * Add more API endpoint defs * Adds placeholder API endpoint for label printing * Expose plugin field to the printing endpoint * Adds plugin model type * Hook to print labels * Refactor action dropdown items * Refactor report printing for CUI * Refactor label print for CUI - Still needs to handle custom printing options for plugin * Fix migration * Update ModelType dict * playwright test fix * Unit test fixes * Fix model ruleset associations * Fix for report.js * Add support for "dynamic" fields in metadata.py * Add in custom fields based on plugin * Refactoring * Reset plugin on form close * Set custom timeout values * Update migration - Not atomic * Cleanup * Implement more printing actions * Reduce timeout * Unit test updates * Fix part serializers * Label printing works in CUI again * js linting * Update * Fix for label printing API endpoint * Fix filterselectdrawer * Improve button rendering * Allow printing from StockLocationTable * Add aria-labels to modal form fields * Add test for printing stock item labels from table * Add test for report printing * Add unit testing for report template editing / preview * Message refactor * Refactor InvenTreeReportMixin class * Update playwright test * Update 'verbose_name' for a number of models * Additional admin filtering * Playwright test updates * Run checks against new python lib branch (temporary, will be reverted) * remove old app reference * fix testing ref * fix app init * remove old tests * Revert custom target branch * Expose label and report output objects to API * refactor * fix a few tests * factor plugin_ref out * fix options testing * Update table field header * re-enable full options testing * fix missing plugin matching * disable call assert * Add custom related field for PluginConfig - Uses 'key' rather than 'pk' - Revert label print plugin to use slug * Add support for custom pk field in metadata * switch to labels for testing * re-align report testing code * disable version check * fix url * Implement lazy loading * Allow blank plugin for printing - Uses the builtin label printer if not specified * Add printing actions for StockItem * Fix for metadata helper * Use key instead of pk in printing actions * Support non-standard pk values in RelatedModelField * pass context data to report serializers * disable template / item discovery * fix call * Tweak unit test * Run python checks against specific branch * Add task for running docs server - Option to compile schema as part of task * Custom branch no longer needed * Starting on documentation updates * fix tests for reports * fix label testing * Update template context variables * Refactor report context documentation * Documentation cleanup * Docs cleanup * Include sample report files * Fix links * Link cleanup * Integrate plugin example code into docs * Code cleanup * Fix type annotation * Revert deleted variable * remove templatetype * remove unused imports * extend context testing * test if plg can print * re-enable version check * Update unit tests * Fix test * Adjust unit test * Add debug statement to test * Fix unit test - Labels get printed against LabelTemplate items, duh * Unit test update * Unit test updates * Test update * Patch fix for component * Fix ReportSerialierBase class - Re-initialize field options if not already set * Fix unit test for sqlite * Fix kwargs for non-blocking label printing * Update playwright tests * Tweak unit test --------- Co-authored-by: Matthias Mair --- .gitignore | 1 + .../images/report/add_report_template.png | Bin 44561 -> 0 bytes .../images/report/report_template_admin.png | Bin 0 -> 42660 bytes docs/docs/extend/plugins/action.md | 12 +- docs/docs/extend/plugins/api.md | 12 + docs/docs/extend/plugins/barcode.md | 18 +- docs/docs/extend/plugins/currency.md | 19 +- docs/docs/extend/plugins/event.md | 56 +- docs/docs/extend/plugins/label.md | 8 + docs/docs/extend/plugins/locate.md | 12 + docs/docs/extend/plugins/panel.md | 12 + docs/docs/extend/plugins/report.md | 52 +- docs/docs/extend/plugins/schedule.md | 47 +- docs/docs/extend/plugins/settings.md | 2 +- docs/docs/extend/plugins/validation.md | 14 +- docs/docs/extend/themes.md | 2 +- docs/docs/order/return_order.md | 2 +- docs/docs/releases/0.1.6.md | 2 +- docs/docs/releases/0.2.1.md | 2 +- docs/docs/releases/0.7.0.md | 2 +- docs/docs/report/bom.md | 186 --- docs/docs/report/build.md | 324 ------ docs/docs/report/context_variables.md | 276 ++++- docs/docs/report/helpers.md | 6 +- docs/docs/report/labels.md | 11 - docs/docs/report/labels/build_labels.md | 118 -- docs/docs/report/labels/location_labels.md | 24 - docs/docs/report/labels/part_labels.md | 91 -- docs/docs/report/labels/stock_labels.md | 61 - docs/docs/report/purchase_order.md | 65 -- docs/docs/report/report.md | 297 +---- docs/docs/report/return_order.md | 26 - docs/docs/report/sales_order.md | 31 - docs/docs/report/samples.md | 78 ++ docs/docs/report/stock_location.md | 16 - docs/docs/report/templates.md | 228 ++++ docs/docs/report/test.md | 87 -- docs/docs/report/weasyprint.md | 111 ++ docs/docs/start/installer.md | 2 +- docs/main.py | 27 + docs/mkdocs.yml | 25 +- docs/requirements.in | 2 +- .../InvenTree/InvenTree/api_version.py | 6 +- src/backend/InvenTree/InvenTree/apps.py | 10 +- src/backend/InvenTree/InvenTree/metadata.py | 17 +- src/backend/InvenTree/InvenTree/settings.py | 8 +- src/backend/InvenTree/InvenTree/tests.py | 3 +- src/backend/InvenTree/InvenTree/urls.py | 3 +- src/backend/InvenTree/build/admin.py | 2 +- src/backend/InvenTree/build/api.py | 2 +- .../build/migrations/0043_buildline.py | 1 + src/backend/InvenTree/build/models.py | 45 +- .../build/templates/build/build_base.html | 6 +- src/backend/InvenTree/common/tasks.py | 2 +- .../InvenTree/generic/templating/__init__.py | 0 .../InvenTree/generic/templating/apps.py | 134 --- src/backend/InvenTree/label/__init__.py | 0 src/backend/InvenTree/label/admin.py | 17 - src/backend/InvenTree/label/api.py | 504 -------- src/backend/InvenTree/label/apps.py | 107 -- .../label/migrations/0001_initial.py | 30 - .../migrations/0002_stockitemlabel_enabled.py | 18 - .../migrations/0003_stocklocationlabel.py | 30 - .../migrations/0004_auto_20210111_2302.py | 56 - .../migrations/0005_auto_20210113_2302.py | 24 - .../migrations/0006_auto_20210222_1535.py | 34 - .../migrations/0007_auto_20210513_1327.py | 23 - .../migrations/0008_auto_20210708_2106.py | 37 - .../migrations/0009_auto_20230317_0816.py | 28 - .../label/migrations/0010_buildlinelabel.py | 33 - .../migrations/0011_auto_20230623_2158.py | 29 - .../label/migrations/0012_labeloutput.py | 26 - .../InvenTree/label/migrations/__init__.py | 0 src/backend/InvenTree/label/models.py | 429 ------- src/backend/InvenTree/label/serializers.py | 67 -- src/backend/InvenTree/label/tasks.py | 15 - .../label/buildline/buildline_label.html | 3 - src/backend/InvenTree/label/test_api.py | 301 ----- src/backend/InvenTree/label/tests.py | 166 --- .../machine/machine_types/label_printer.py | 19 +- src/backend/InvenTree/machine/tests.py | 16 +- .../order/migrations/0001_initial.py | 1 + .../migrations/0020_auto_20200420_0940.py | 1 + .../migrations/0081_auto_20230314_0725.py | 1 + src/backend/InvenTree/order/models.py | 40 + .../order/templates/order/order_base.html | 6 +- .../templates/order/return_order_base.html | 6 +- .../templates/order/sales_order_base.html | 6 +- src/backend/InvenTree/part/models.py | 26 +- src/backend/InvenTree/part/serializers.py | 15 + .../InvenTree/part/templates/part/detail.html | 6 +- .../part/templates/part/part_base.html | 3 +- .../plugin/base/integration/ReportMixin.py | 13 + .../InvenTree/plugin/base/label/mixins.py | 124 +- .../plugin/base/label/test_label_mixin.py | 236 ++-- .../plugin/builtin/labels/inventree_label.py | 86 +- .../builtin/labels/inventree_machine.py | 11 +- .../plugin/builtin/labels/label_sheet.py | 26 +- src/backend/InvenTree/plugin/models.py | 7 +- .../samples/integration/label_sample.py | 4 +- .../integration/report_plugin_sample.py | 4 +- src/backend/InvenTree/plugin/serializers.py | 23 + src/backend/InvenTree/report/admin.py | 46 +- src/backend/InvenTree/report/api.py | 1017 +++++++---------- src/backend/InvenTree/report/apps.py | 272 +++-- src/backend/InvenTree/report/helpers.py | 26 + .../report/migrations/0001_initial.py | 8 +- .../migrations/0005_auto_20210119_0815.py | 5 +- .../report/migrations/0006_reportsnippet.py | 3 +- .../migrations/0007_auto_20210204_1617.py | 2 +- .../migrations/0011_auto_20210212_2024.py | 7 +- .../report/migrations/0012_buildreport.py | 5 +- ...14_purchaseorderreport_salesorderreport.py | 9 +- .../migrations/0015_auto_20210403_1837.py | 4 +- .../migrations/0018_returnorderreport.py | 5 +- .../migrations/0020_stocklocationreport.py | 5 +- .../report/migrations/0022_reporttemplate.py | 40 + .../migrations/0023_auto_20240421_0455.py | 102 ++ ...rialsreport_delete_buildreport_and_more.py | 34 + .../report/migrations/0025_labeltemplate.py | 39 + .../migrations/0026_auto_20240422_1301.py | 136 +++ ...alter_labeltemplate_model_type_and_more.py | 77 ++ src/backend/InvenTree/report/mixins.py | 23 + src/backend/InvenTree/report/models.py | 977 ++++++---------- src/backend/InvenTree/report/serializers.py | 186 ++- src/backend/InvenTree/report/tasks.py | 17 + .../templates/label/buildline_label.html} | 0 .../templates/label/label_base.html | 0 .../templates/label}/part_label.html | 0 .../templates/label}/part_label_code128.html | 0 .../templates/label/stockitem_qr.html} | 0 .../templates/label/stocklocation_qr.html} | 0 .../label/stocklocation_qr_and_text.html} | 0 .../report/inventree_build_order.html | 3 - ...html => inventree_build_order_report.html} | 0 .../report/inventree_order_report_base.html | 2 +- .../templates/report/inventree_po_report.html | 1 - ...l => inventree_purchase_order_report.html} | 0 .../report/inventree_return_order_report.html | 63 +- .../inventree_return_order_report_base.html | 62 - ...html => inventree_sales_order_report.html} | 0 .../templates/report/inventree_so_report.html | 1 - ...l => inventree_stock_location_report.html} | 0 .../report/inventree_test_report.html | 185 ++- .../report/inventree_test_report_base.html | 184 --- src/backend/InvenTree/report/tests.py | 361 ++---- src/backend/InvenTree/report/validators.py | 20 + .../stock/migrations/0001_initial.py | 3 + src/backend/InvenTree/stock/models.py | 110 +- .../InvenTree/stock/templates/stock/item.html | 6 +- .../stock/templates/stock/item_base.html | 9 +- .../stock/templates/stock/location.html | 9 +- .../InvenTree/templates/js/translated/api.js | 2 +- .../templates/js/translated/filters.js | 9 +- .../templates/js/translated/forms.js | 4 + .../templates/js/translated/label.js | 210 ++-- .../js/translated/model_renderers.js | 45 + .../InvenTree/templates/js/translated/part.js | 3 +- .../templates/js/translated/purchase_order.js | 3 +- .../templates/js/translated/report.js | 157 +-- .../templates/js/translated/return_order.js | 3 +- .../templates/js/translated/sales_order.js | 3 +- .../templates/js/translated/stock.js | 9 +- src/backend/InvenTree/users/models.py | 23 +- .../components/buttons/PrintingActions.tsx | 191 ++++ .../src/components/buttons/SplitButton.tsx | 8 + .../TemplateEditor/PdfPreview/PdfPreview.tsx | 43 +- .../editors/TemplateEditor/TemplateEditor.tsx | 65 +- .../components/forms/fields/ApiFormField.tsx | 5 + .../components/forms/fields/ChoiceField.tsx | 1 + .../src/components/forms/fields/DateField.tsx | 1 + .../forms/fields/RelatedModelField.tsx | 25 +- .../components/forms/fields/TableField.tsx | 4 +- .../src/components/items/ActionDropdown.tsx | 25 +- .../src/components/render/Instance.tsx | 7 +- .../src/components/render/ModelType.tsx | 21 + src/frontend/src/components/render/Plugin.tsx | 21 + src/frontend/src/components/render/Report.tsx | 29 + .../src/components/render/StatusRenderer.tsx | 2 +- src/frontend/src/enums/ApiEndpoints.tsx | 10 +- src/frontend/src/enums/ModelType.tsx | 5 +- src/frontend/src/functions/conversion.tsx | 5 + src/frontend/src/hooks/UseInstance.tsx | 9 +- src/frontend/src/hooks/UseTable.tsx | 8 + src/frontend/src/pages/Index/Playground.tsx | 1 - src/frontend/src/pages/Index/Scan.tsx | 1 - .../Index/Settings/AdminCenter/Index.tsx | 31 +- .../AdminCenter/LabelTemplatePanel.tsx | 17 + .../AdminCenter/ReportTemplatePanel.tsx | 17 + .../AdminCenter/TemplateManagementPanel.tsx | 211 ---- src/frontend/src/pages/build/BuildDetail.tsx | 20 +- .../src/pages/company/CompanyDetail.tsx | 1 - .../pages/company/ManufacturerPartDetail.tsx | 1 - .../src/pages/company/SupplierPartDetail.tsx | 1 - .../src/pages/part/CategoryDetail.tsx | 1 - src/frontend/src/pages/part/PartDetail.tsx | 2 - .../pages/purchasing/PurchaseOrderDetail.tsx | 7 +- .../src/pages/sales/ReturnOrderDetail.tsx | 7 +- .../src/pages/sales/SalesOrderDetail.tsx | 7 +- .../src/pages/stock/LocationDetail.tsx | 24 +- src/frontend/src/pages/stock/StockDetail.tsx | 9 +- src/frontend/src/tables/ColumnRenderers.tsx | 14 +- src/frontend/src/tables/DownloadAction.tsx | 54 +- .../src/tables/FilterSelectDrawer.tsx | 15 +- src/frontend/src/tables/InvenTreeTable.tsx | 66 +- .../src/tables/build/BuildOrderTable.tsx | 6 +- .../tables/purchasing/PurchaseOrderTable.tsx | 5 +- .../src/tables/sales/ReturnOrderTable.tsx | 5 +- .../src/tables/sales/SalesOrderTable.tsx | 5 +- .../src/tables/settings/TemplateTable.tsx | 300 +++-- .../src/tables/stock/StockItemTable.tsx | 4 +- .../src/tables/stock/StockLocationTable.tsx | 3 + src/frontend/tests/pages/pui_index.spec.ts | 9 +- src/frontend/tests/pui_general.spec.ts | 3 +- src/frontend/tests/pui_printing.spec.ts | 108 ++ src/frontend/tests/pui_stock.spec.ts | 12 +- tasks.py | 19 +- 217 files changed, 4507 insertions(+), 6762 deletions(-) delete mode 100644 docs/docs/assets/images/report/add_report_template.png create mode 100644 docs/docs/assets/images/report/report_template_admin.png delete mode 100644 docs/docs/report/bom.md delete mode 100644 docs/docs/report/build.md delete mode 100644 docs/docs/report/labels/build_labels.md delete mode 100644 docs/docs/report/labels/location_labels.md delete mode 100644 docs/docs/report/labels/part_labels.md delete mode 100644 docs/docs/report/labels/stock_labels.md delete mode 100644 docs/docs/report/purchase_order.md delete mode 100644 docs/docs/report/return_order.md delete mode 100644 docs/docs/report/sales_order.md create mode 100644 docs/docs/report/samples.md delete mode 100644 docs/docs/report/stock_location.md create mode 100644 docs/docs/report/templates.md delete mode 100644 docs/docs/report/test.md create mode 100644 docs/docs/report/weasyprint.md delete mode 100644 src/backend/InvenTree/generic/templating/__init__.py delete mode 100644 src/backend/InvenTree/generic/templating/apps.py delete mode 100644 src/backend/InvenTree/label/__init__.py delete mode 100644 src/backend/InvenTree/label/admin.py delete mode 100644 src/backend/InvenTree/label/api.py delete mode 100644 src/backend/InvenTree/label/apps.py delete mode 100644 src/backend/InvenTree/label/migrations/0001_initial.py delete mode 100644 src/backend/InvenTree/label/migrations/0002_stockitemlabel_enabled.py delete mode 100644 src/backend/InvenTree/label/migrations/0003_stocklocationlabel.py delete mode 100644 src/backend/InvenTree/label/migrations/0004_auto_20210111_2302.py delete mode 100644 src/backend/InvenTree/label/migrations/0005_auto_20210113_2302.py delete mode 100644 src/backend/InvenTree/label/migrations/0006_auto_20210222_1535.py delete mode 100644 src/backend/InvenTree/label/migrations/0007_auto_20210513_1327.py delete mode 100644 src/backend/InvenTree/label/migrations/0008_auto_20210708_2106.py delete mode 100644 src/backend/InvenTree/label/migrations/0009_auto_20230317_0816.py delete mode 100644 src/backend/InvenTree/label/migrations/0010_buildlinelabel.py delete mode 100644 src/backend/InvenTree/label/migrations/0011_auto_20230623_2158.py delete mode 100644 src/backend/InvenTree/label/migrations/0012_labeloutput.py delete mode 100644 src/backend/InvenTree/label/migrations/__init__.py delete mode 100644 src/backend/InvenTree/label/models.py delete mode 100644 src/backend/InvenTree/label/serializers.py delete mode 100644 src/backend/InvenTree/label/tasks.py delete mode 100644 src/backend/InvenTree/label/templates/label/buildline/buildline_label.html delete mode 100644 src/backend/InvenTree/label/test_api.py delete mode 100644 src/backend/InvenTree/label/tests.py create mode 100644 src/backend/InvenTree/report/migrations/0022_reporttemplate.py create mode 100644 src/backend/InvenTree/report/migrations/0023_auto_20240421_0455.py create mode 100644 src/backend/InvenTree/report/migrations/0024_delete_billofmaterialsreport_delete_buildreport_and_more.py create mode 100644 src/backend/InvenTree/report/migrations/0025_labeltemplate.py create mode 100644 src/backend/InvenTree/report/migrations/0026_auto_20240422_1301.py create mode 100644 src/backend/InvenTree/report/migrations/0027_alter_labeltemplate_model_type_and_more.py create mode 100644 src/backend/InvenTree/report/mixins.py create mode 100644 src/backend/InvenTree/report/tasks.py rename src/backend/InvenTree/{label/templates/label/buildline/buildline_label_base.html => report/templates/label/buildline_label.html} (100%) rename src/backend/InvenTree/{label => report}/templates/label/label_base.html (100%) rename src/backend/InvenTree/{label/templates/label/part => report/templates/label}/part_label.html (100%) rename src/backend/InvenTree/{label/templates/label/part => report/templates/label}/part_label_code128.html (100%) rename src/backend/InvenTree/{label/templates/label/stockitem/qr.html => report/templates/label/stockitem_qr.html} (100%) rename src/backend/InvenTree/{label/templates/label/stocklocation/qr.html => report/templates/label/stocklocation_qr.html} (100%) rename src/backend/InvenTree/{label/templates/label/stocklocation/qr_and_text.html => report/templates/label/stocklocation_qr_and_text.html} (100%) delete mode 100644 src/backend/InvenTree/report/templates/report/inventree_build_order.html rename src/backend/InvenTree/report/templates/report/{inventree_build_order_base.html => inventree_build_order_report.html} (100%) delete mode 100644 src/backend/InvenTree/report/templates/report/inventree_po_report.html rename src/backend/InvenTree/report/templates/report/{inventree_po_report_base.html => inventree_purchase_order_report.html} (100%) delete mode 100644 src/backend/InvenTree/report/templates/report/inventree_return_order_report_base.html rename src/backend/InvenTree/report/templates/report/{inventree_so_report_base.html => inventree_sales_order_report.html} (100%) delete mode 100644 src/backend/InvenTree/report/templates/report/inventree_so_report.html rename src/backend/InvenTree/report/templates/report/{inventree_slr_report.html => inventree_stock_location_report.html} (100%) delete mode 100644 src/backend/InvenTree/report/templates/report/inventree_test_report_base.html create mode 100644 src/backend/InvenTree/report/validators.py create mode 100644 src/frontend/src/components/buttons/PrintingActions.tsx create mode 100644 src/frontend/src/components/render/Plugin.tsx create mode 100644 src/frontend/src/components/render/Report.tsx create mode 100644 src/frontend/src/pages/Index/Settings/AdminCenter/LabelTemplatePanel.tsx create mode 100644 src/frontend/src/pages/Index/Settings/AdminCenter/ReportTemplatePanel.tsx delete mode 100644 src/frontend/src/pages/Index/Settings/AdminCenter/TemplateManagementPanel.tsx create mode 100644 src/frontend/tests/pui_printing.spec.ts diff --git a/.gitignore b/.gitignore index 8c119f2ab2..4bc362f653 100644 --- a/.gitignore +++ b/.gitignore @@ -108,5 +108,6 @@ src/backend/InvenTree/web/static InvenTree/web/static # Generated docs files +docs/schema.yml docs/docs/api/*.yml docs/docs/api/schema/*.yml diff --git a/docs/docs/assets/images/report/add_report_template.png b/docs/docs/assets/images/report/add_report_template.png deleted file mode 100644 index 3268a7ab26eed54cd7d714912d647482d16dcece..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 44561 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 -

{% trans "Bill of Materials" %}

- -{% endblock %} - -{% block page_content %} - - - - - - -
Board{{ part.IPN }}
Description{{ part.description }}
User{{ user }}
Date{{ date }}
Number of different components (codes){{ bom_items.count }}
-
- - - - - - - - - - - - - {% for line in bom_items.all %} - - - - - - - - - {% endfor %} - -
{% trans "IPN" %}{% trans "MPN" %}{% trans "Manufacturer" %}{% trans "Quantity" %}{% trans "Reference" %}{% trans "Substitute" %}
{{ line.sub_part.IPN }}{{ line.sub_part.name }} - {% for manf in line.sub_part.manufacturer_parts.all %} - {{ manf.manufacturer.name }} - {% endfor %} - {% decimal line.quantity %}{{ line.reference }} - {% for sub in line.substitutes.all %} - {{ sub.part.IPN }}
- {% endfor %} -
- -{% endblock %} -{% endraw %} -``` - -#### Pick List - -When all material has been allocated someone has to pick all things from the warehouse. -In case you need a printed pick list you can use the following template. This it just the -table. All other info and CSS has been left out for simplicity. Please have a look at the -BOM report for details. - -{% raw %} -```html - - - - - - - - - - - {% for line in build.allocated_stock.all %} - - - {% if line.stock_item.part.IPN != line.bom_item.sub_part.IPN %} - - {% else %} - - {% endif %} - - - - {% endfor %} - -
Original IPNAllocated PartLocationPCS
{{ line.bom_item.sub_part.IPN }} {{ line.stock_item.part.IPN }} {{ line.stock_item.part.IPN }} {{ line.stock_item.location.pathstring }} {{ line.quantity }}
-``` -{% endraw %} - -Here we have a loop that runs through all allocated parts for the build. For each part -we list the original IPN from the BOM and the IPN of the allocated part. These can differ -in case you have substitutes or template/variants in the BOM. In case the parts differ -we use a different format for the table cell e.g. print bold font or red color. -For the picker we list the full path names of the stock locations and the quantity -that is needed for the build. This will result in the following printout: - -{% with id="picklist", url="report/picklist.png", description="Picklist Example" %} {% include "img.html" %} {% endwith %} - -For those of you who would like to replace the "/" by something else because it is hard -to read in some fonts use the following trick: - -{% raw %} -```html - {% for loc in line.stock_item.location.path %}{{ loc.name }}{% if not forloop.last %}-{% endif %}{% endfor %} -``` -{% endraw %} - -Here we use location.path which is a query set that contains the location path up to the -topmost parent. We use a loop to cycle through that and print the .name of the entry followed -by a "-". The foorloop.last is a Django trick that allows us to not print the "-" after -the last entry. The result looks like here: - -{% with id="picklist_with_path", url="report/picklist_with_path.png", description="Picklist Example" %} {% include "img.html" %} {% endwith %} - -Finally added a `{% raw %}|floatformat:0{% endraw %}` to the quantity that removes the trailing zeros. - -### Default Report Template - -A default *BOM Report* template is provided out of the box, which is useful for generating simple test reports. Furthermore, it may be used as a starting point for developing custom BOM reports: - -View the [source code](https://github.com/inventree/InvenTree/blob/master/src/backend/InvenTree/report/templates/report/inventree_bill_of_materials_report.html) for the default test report template. diff --git a/docs/docs/report/build.md b/docs/docs/report/build.md deleted file mode 100644 index e249fe108b..0000000000 --- a/docs/docs/report/build.md +++ /dev/null @@ -1,324 +0,0 @@ ---- -title: Build Order Report ---- - -## Build Order Report - -Custom build order reports may be generated against any given [Build Order](../build/build.md). For example, build order reports can be used to generate work orders. - -### Build Filters - -A build order report template may define a set of filters against which [Build Order](../build/build.md) items are sorted. - -### Context Variables - -In addition to the default report context variables, the following context variables are made available to the build order report template for rendering: - -| Variable | Description | -| --- | --- | -| build | The build object the report is being generated against | -| part | The [Part](./context_variables.md#part) object that the build references | -| line_items | A shortcut for [build.line_items](#build) | -| bom_items | A shortcut for [build.bom_items](#build) | -| build_outputs | A shortcut for [build.build_outputs](#build) | -| reference | The build order reference string | -| quantity | Build order quantity (number of assemblies being built) | - -#### build - -The following variables are accessed by build.variable - -| Variable | Description | -| --- | --- | -| active | Boolean that tells if the build is active | -| batch | Batch code transferred to build parts (optional) | -| line_items | A query set with all the build line items associated with the build | -| bom_items | A query set with all BOM items for the part being assembled | -| build_outputs | A queryset containing all build output ([Stock Item](../stock/stock.md)) objects associated with this build | -| can_complete | Boolean that tells if the build can be completed. Means: All material allocated and all parts have been build. | -| are_untracked_parts_allocated | Boolean that tells if all bom_items have allocated stock_items. | -| creation_date | Date where the build has been created | -| completion_date | Date the build was completed (or, if incomplete, the expected date of completion) | -| completed_by | The [User](./context_variables.md#user) that completed the build | -| is_overdue | Boolean that tells if the build is overdue | -| is_complete | Boolean that tells if the build is complete | -| issued_by | The [User](./context_variables.md#user) who created the build | -| link | External URL for extra information | -| notes | Text notes | -| parent | Reference to a parent build object if this is a sub build | -| part | The [Part](./context_variables.md#part) to be built (from component BOM items) | -| quantity | Build order quantity (total number of assembly outputs) | -| completed | The number out outputs which have been completed | -| reference | Build order reference (required, must be unique) | -| required_parts | A query set with all parts that are required for the build | -| responsible | Owner responsible for completing the build. This can be a user or a group. Depending on that further context variables differ | -| sales_order | References to a [Sales Order](./context_variables.md#salesorder) object for which this build is required (e.g. the output of this build will be used to fulfil a sales order) | -| status | The status of the build. 20 means 'Production' | -| sub_build_count | Number of sub builds | -| sub_builds | Query set with all sub builds | -| target_date | Date the build will be overdue | -| take_from | [StockLocation](./context_variables.md#stocklocation) to take stock from to make this build (if blank, can take from anywhere) | -| title | The full name of the build | -| description | The description of the build | -| allocated_stock.all | A query set with all allocated stock items for the build | - -As usual items in a query sets can be selected by adding a .n to the set e.g. build.required_parts.0 -will result in the first part of the list. Each query set has again its own context variables. - -#### line_items - -The `line_items` variable is a list of all build line items associated with the selected build. The following attributes are available for each individual line_item instance: - -| Attribute | Description | -| --- | --- | -| .build | A reference back to the parent build order | -| .bom_item | A reference to the BOMItem which defines this line item | -| .quantity | The required quantity which is to be allocated against this line item | -| .part | A shortcut for .bom_item.sub_part | -| .allocations | A list of BuildItem objects which allocate stock items against this line item | -| .allocated_quantity | The total stock quantity which has been allocated against this line | -| .unallocated_quantity | The remaining quantity to allocate | -| .is_fully_allocated | Boolean value, returns True if the line item has sufficient stock allocated against it | -| .is_overallocated | Boolean value, returns True if the line item has more allocated stock than is required | - -#### bom_items - -| Attribute | Description | -| --- | --- | -| .reference | The reference designators of the components | -| .quantity | The number of components required to build | -| .overage | The extra amount required to assembly | -| .consumable | Boolean field, True if this is a "consumable" part which is not tracked through builds | -| .sub_part | The part at this position | -| .substitutes.all | A query set with all allowed substitutes for that part | -| .note | Extra text field which can contain additional information | - - -#### allocated_stock.all - -| Attribute | Description | -| --- | --- | -| .bom_item | The bom item where this part belongs to | -| .stock_item | The allocated [StockItem](./context_variables.md#stockitem) | -| .quantity | The number of components needed for the build (components in BOM x parts to build) | - -### Example - -The following example will create a report with header and BOM. In the BOM table substitutes will be listed. - -{% raw %} -```html -{% extends "report/inventree_report_base.html" %} - -{% load i18n %} -{% load report %} -{% load barcode %} -{% load inventree_extras %} -{% load markdownify %} - -{% block page_margin %} -margin: 2cm; -margin-top: 4cm; -{% endblock %} - -{% block style %} - -.header-right { - text-align: right; - float: right; -} - -.logo { - height: 20mm; - vertical-align: middle; -} - -.details { - width: 100%; - border: 1px solid; - border-radius: 3px; - padding: 5px; - min-height: 42mm; -} - -.details table { - overflow-wrap: break-word; - word-wrap: break-word; - width: 65%; - table-layout: fixed; - font-size: 75%; -} -.changes table { - overflow-wrap: break-word; - word-wrap: break-word; - width: 100%; - table-layout: fixed; - font-size: 75%; - border: 1px solid; -} - -.changes-table th { - font-size: 100%; - border: 1px solid; -} - -.changes-table td { - border: 1px solid; -} - -.details table td:not(:last-child){ - white-space: nowrap; -} - -.details table td:last-child{ - width: 50%; - padding-left: 1cm; - padding-right: 1cm; -} - -.details-table td { - padding-left: 10px; - padding-top: 5px; - padding-bottom: 5px; - border-bottom: 1px solid #555; -} - -{% endblock %} - -{% block bottom_left %} -content: "v{{report_revision}} - {% format_date date %}"; -{% endblock %} - -{% block header_content %} - - - -
-

- Build Order {{ build }} -

-
-
- -
-{% endblock %} - -{% block page_content %} - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {% if build.parent %} - - - - - {% endif %} - {% if build.issued_by %} - - - - - {% endif %} - {% if build.responsible %} - - - - - {% endif %} - - - - - {% if build.sub_build_count > 0 %} - - - - - {% endif %} - - - - - - - - -
{% trans "Build Order" %}{% internal_link build.get_absolute_url build %}
{% trans "Order" %}{{ reference }}
{% trans "Part" %}{% internal_link part.get_absolute_url part.IPN %}
{% trans "Quantity" %}{{ build.quantity }}
{% trans "Description" %}{{ build.title }}
{% trans "Issued" %}{% format_date build.creation_date %}
{% trans "Target Date" %} - {% if build.target_date %} - {% format_date build.target_date %} - {% else %} - Not specified - {% endif %} -
{% trans "Required For" %}{% internal_link build.parent.get_absolute_url build.parent %}
{% trans "Issued By" %}{{ build.issued_by }}
{% trans "Responsible" %}{{ build.responsible }}
{% trans "Sub builds count" %}{{ build.sub_build_count }}
{% trans "Sub Builds" %}{{ build.sub_builds }}
{% trans "Overdue" %}{{ build.is_overdue }}
{% trans "Can complete" %}{{ build.can_complete }}
-
- -

{% trans "Notes" %}

-{% if build.notes %} -{{ build.notes|markdownify }} -{% endif %} - -

{% trans "Parts" %}

- -
- - - - - - - - - - {% for line in build.bom_items %} - - - - - - {% endfor %} - -
Original IPNReferenceReplace width IPN
{{ line.sub_part.IPN }} {{ line.reference }} {{ line.substitutes.all.0.part.IPN }}
-
-{% endblock %} -``` - -{% endraw %} - -This will result a report page like this: - -{% with id="report-options", url="build/report-61.png", description="Report Example Builds" %} {% include "img.html" %} {% endwith %} - -### Default Report Template - -A default *Build Report* template is provided out of the box, which is useful for generating simple test reports. Furthermore, it may be used as a starting point for developing custom BOM reports: - -View the [source code](https://github.com/inventree/InvenTree/blob/master/src/backend/InvenTree/report/templates/report/inventree_build_order_base.html) for the default build report template. diff --git a/docs/docs/report/context_variables.md b/docs/docs/report/context_variables.md index 4704d1560a..10180a0504 100644 --- a/docs/docs/report/context_variables.md +++ b/docs/docs/report/context_variables.md @@ -2,64 +2,245 @@ title: Context Variables --- + ## Context Variables -### Report +Context variables are provided to each template when it is rendered. The available context variables depend on the model type for which the template is being rendered. -!!! info "Specific Report Context" - Specific report types may have additional context variables, see below. +### Global Context -Each report has access to a number of context variables by default. The following context variables are provided to every report template: +In addition to the model-specific context variables, the following global context variables are available to all templates: | Variable | Description | | --- | --- | +| base_url | The base URL for the InvenTree instance | | date | Current date, represented as a Python datetime.date object | | datetime | Current datetime, represented as a Python datetime object | -| page_size | The specified page size for this report, e.g. `A4` or `Letter landscape` | -| report_template | The report template model instance | -| report_name | Name of the report template | -| report_description | Description of the report template | -| report_revision | Revision of the report template | -| request | Django request object | +| request | The Django request object associated with the printing process | +| template | The report template instance which is being rendered against | +| template_description | Description of the report template | +| template_name | Name of the report template | +| template_revision | Revision of the report template | | user | User who made the request to render the template | -#### Label +::: report.models.ReportTemplateBase.base_context + options: + show_source: True -Certain types of labels have different context variables then other labels. +### Report Context -##### Stock Item Label - -The following variables are made available to the StockItem label template: +In addition to the [global context](#global-context), all *report* templates have access to the following context variables: | Variable | Description | -| -------- | ----------- | -| item | The [StockItem](./context_variables.md#stockitem) object itself | -| part | The [Part](./context_variables.md#part) object which is referenced by the [StockItem](./context_variables.md#stockitem) object | -| name | The `name` field of the associated Part object | -| ipn | The `IPN` field of the associated Part object | -| revision | The `revision` field of the associated Part object | -| quantity | The `quantity` field of the StockItem object | -| serial | The `serial` field of the StockItem object | -| uid | The `uid` field of the StockItem object | -| tests | Dict object of TestResult data associated with the StockItem | +| --- | --- | +| page_size | The page size of the report | +| landscape | Boolean value, True if the report is in landscape mode | + +Note that custom plugins may also add additional context variables to the report context. + +::: report.models.ReportTemplate.get_context + options: + show_source: True + +### Label Context + +In addition to the [global context](#global-context), all *label* templates have access to the following context variables: + +| Variable | Description | +| --- | --- | +| width | The width of the label (in mm) | +| height | The height of the label (in mm) | + +Note that custom plugins may also add additional context variables to the label context. + +::: report.models.LabelTemplate.get_context + options: + show_source: True + + +## Template Types + +Templates (whether for generating [reports](./report.md) or [labels](./labels.md)) are rendered against a particular "model" type. The following model types are supported, and can have templates renderer against them: + +| Model Type | Description | +| --- | --- | +| [build](#build-order) | A [Build Order](../build/build.md) instance | +| [buildline](#build-line) | A [Build Order Line Item](../build/build.md) instance | +| [salesorder](#sales-order) | A [Sales Order](../order/sales_order.md) instance | +| [returnorder](#return-order) | A [Return Order](../order/return_order.md) instance | +| [purchaseorder](#purchase-order) | A [Purchase Order](../order/purchase_order.md) instance | +| [stockitem](#stock-item) | A [StockItem](../stock/stock.md#stock-item) instance | +| [stocklocation](#stock-location) | A [StockLocation](../stock/stock.md#stock-location) instance | +| [part](#part) | A [Part](../part/part.md) instance | + +### Build Order + +When printing a report or label against a [Build Order](../build/build.md) object, the following context variables are available: + +| Variable | Description | +| --- | --- | +| bom_items | Query set of all BuildItem objects associated with the BuildOrder | +| build | The BuildOrder instance itself | +| build_outputs | Query set of all BuildItem objects associated with the BuildOrder | +| line_items | Query set of all build line items associated with the BuildOrder | +| part | The Part object which is being assembled in the build order | +| quantity | The total quantity of the part being assembled | +| reference | The reference field of the BuildOrder | +| title | The title field of the BuildOrder | + +::: build.models.Build.report_context + options: + show_source: True + +### Build Line + +When printing a report or label against a [BuildOrderLineItem](../build/build.md) object, the following context variables are available: + +| Variable | Description | +| --- | --- | +| allocated_quantity | The quantity of the part which has been allocated to this build | +| allocations | A query set of all StockItem objects which have been allocated to this build line | +| bom_item | The BomItem associated with this line item | +| build | The BuildOrder instance associated with this line item | +| build_line | The build line instance itself | +| part | The sub-part (component) associated with the linked BomItem instance | +| quantity | The quantity required for this line item | + +::: build.models.BuildLine.report_context + options: + show_source: True + + +### Sales Order + +When printing a report or label against a [SalesOrder](../order/sales_order.md) object, the following context variables are available: + +| Variable | Description | +| --- | --- | +| customer | The customer object associated with the SalesOrder | +| description | The description field of the SalesOrder | +| extra_lines | Query set of all extra lines associated with the SalesOrder | +| lines | Query set of all line items associated with the SalesOrder | +| order | The SalesOrder instance itself | +| reference | The reference field of the SalesOrder | +| title | The title (string representation) of the SalesOrder | + +::: order.models.Order.report_context + options: + show_source: True + +### Return Order + +When printing a report or label against a [ReturnOrder](../order/return_order.md) object, the following context variables are available: + +| Variable | Description | +| --- | --- | +| customer | The customer object associated with the ReturnOrder | +| description | The description field of the ReturnOrder | +| extra_lines | Query set of all extra lines associated with the ReturnOrder | +| lines | Query set of all line items associated with the ReturnOrder | +| order | The ReturnOrder instance itself | +| reference | The reference field of the ReturnOrder | +| title | The title (string representation) of the ReturnOrder | + +### Purchase Order + +When printing a report or label against a [PurchaseOrder](../order/purchase_order.md) object, the following context variables are available: + +| Variable | Description | +| --- | --- | +| description | The description field of the PurchaseOrder | +| extra_lines | Query set of all extra lines associated with the PurchaseOrder | +| lines | Query set of all line items associated with the PurchaseOrder | +| order | The PurchaseOrder instance itself | +| reference | The reference field of the PurchaseOrder | +| supplier | The supplier object associated with the PurchaseOrder | +| title | The title (string representation) of the PurchaseOrder | + +### Stock Item + +When printing a report or label against a [StockItem](../stock/stock.md#stock-item) object, the following context variables are available: + +| Variable | Description | +| --- | --- | +| barcode_data | Generated barcode data for the StockItem | +| barcode_hash | Hash of the barcode data | +| batch | The batch code for the StockItem | +| child_items | Query set of all StockItem objects which are children of this StockItem | +| ipn | The IPN (internal part number) of the associated Part | +| installed_items | Query set of all StockItem objects which are installed in this StockItem | +| item | The StockItem object itself | +| name | The name of the associated Part | +| part | The Part object which is associated with the StockItem | +| qr_data | Generated QR code data for the StockItem | +| qr_url | Generated URL for embedding in a QR code | | parameters | Dict object containing the parameters associated with the base Part | +| quantity | The quantity of the StockItem | +| result_list | FLattened list of TestResult data associated with the stock item | +| results | Dict object of TestResult data associated with the StockItem | +| serial | The serial number of the StockItem | +| stock_item | The StockItem object itself (shadow of 'item') | +| tests | Dict object of TestResult data associated with the StockItem (shadow of 'results') | +| test_keys | List of test keys associated with the StockItem | +| test_template_list | List of test templates associated with the StockItem | +| test_templates | Dict object of test templates associated with the StockItem | + +::: stock.models.StockItem.report_context + options: + show_source: True -##### Stock Location Label +### Stock Location -The following variables are made available to the StockLocation label template: +When printing a report or label against a [StockLocation](../stock/stock.md#stock-location) object, the following context variables are available: | Variable | Description | -| -------- | ----------- | -| location | The [StockLocation](./context_variables.md#stocklocation) object itself | +| --- | --- | +| location | The StockLocation object itself | +| qr_data | Formatted QR code data for the StockLocation | +| parent | The parent StockLocation object | +| stock_location | The StockLocation object itself (shadow of 'location') | +| stock_items | Query set of all StockItem objects which are located in the StockLocation | + +::: stock.models.StockLocation.report_context + options: + show_source: True + + +### Part + +When printing a report or label against a [Part](../part/part.md) object, the following context variables are available: + +| Variable | Description | +| --- | --- | +| bom_items | Query set of all BomItem objects associated with the Part | +| category | The PartCategory object associated with the Part | +| description | The description field of the Part | +| IPN | The IPN (internal part number) of the Part | +| name | The name of the Part | +| parameters | Dict object containing the parameters associated with the Part | +| part | The Part object itself | +| qr_data | Formatted QR code data for the Part | +| qr_url | Generated URL for embedding in a QR code | +| revision | The revision of the Part | +| test_template_list | List of test templates associated with the Part | +| test_templates | Dict object of test templates associated with the Part | + +::: part.models.Part.report_context + options: + show_source: True + +## Model Variables + +Additional to the context variables provided directly to each template, each model type has a number of attributes and methods which can be accessedd via the template. + +For each model type, a subset of the most commonly used attributes are listed below. For a full list of attributes and methods, refer to the source code for the particular model type. ### Parts -!!! incomplete "TODO" - This section requires further work - #### Part -Each part object has access to a lot of context variables about the part. The following context variables are provided when accessing a `Part` object: + +Each part object has access to a lot of context variables about the part. The following context variables are provided when accessing a `Part` object from within the template. | Variable | Description | |----------|-------------| @@ -106,6 +287,7 @@ Each part object has access to a lot of context variables about the part. The fo #### Part Category + | Variable | Description | |----------|-------------| | name | Name of this category | @@ -117,6 +299,7 @@ Each part object has access to a lot of context variables about the part. The fo #### StockItem + | Variable | Description | |----------|-------------| | parent | Link to another [StockItem](./context_variables.md#stockitem) from which this StockItem was created | @@ -139,7 +322,7 @@ Each part object has access to a lot of context variables about the part. The fo | notes | Extra notes field | | build | Link to a Build (if this stock item was created from a build) | | is_building | Boolean field indicating if this stock item is currently being built (or is "in production") | -| purchase_order | Link to a [PurchaseOrder](./context_variables.md#purchaseorder) (if this stock item was created from a PurchaseOrder) | +| purchase_order | Link to a [PurchaseOrder](./context_variables.md#purchase-order) (if this stock item was created from a PurchaseOrder) | | infinite | If True this [StockItem](./context_variables.md#stockitem) can never be exhausted | | sales_order | Link to a [SalesOrder](./context_variables.md#salesorder) object (if the StockItem has been assigned to a SalesOrder) | | purchase_price | The unit purchase price for this [StockItem](./context_variables.md#stockitem) - this is the unit price at time of purchase (if this item was purchased from an external supplier) | @@ -164,6 +347,7 @@ Each part object has access to a lot of context variables about the part. The fo #### Company + | Variable | Description | |----------|-------------| | name | Name of the company | @@ -184,6 +368,7 @@ Each part object has access to a lot of context variables about the part. The fo #### Address + | Variable | Description | |----------|-------------| | line1 | First line of the postal address | @@ -194,9 +379,6 @@ Each part object has access to a lot of context variables about the part. The fo #### Contact -Contacts are added to companies. Actually the company has no link to the contacts. -You can search the company object of the contact. - | Variable | Description | |----------|-------------| | company | Company object where the contact belongs to | @@ -207,6 +389,7 @@ You can search the company object of the contact. #### SupplierPart + | Variable | Description | |----------|-------------| | part | Link to the master Part (Obsolete) | @@ -226,24 +409,13 @@ You can search the company object of the contact. | has_price_breaks | Whether this [SupplierPart](./context_variables.md#supplierpart) has price breaks | | manufacturer_string | Format a MPN string for this [SupplierPart](./context_variables.md#supplierpart). Concatenates manufacture name and part number. | -### Manufacturers - -!!! incomplete "TODO" - This section requires further work - -#### Manufacturer - -| Variable | Description | -|----------|-------------| - -#### ManufacturerPart - -| Variable | Description | -|----------|-------------| ### Orders -The [Purchase Order](../order/purchase_order.md) context variables are described in the [Purchase Order](./purchase_order.md) section. +#### Purchase Order + +!!! note "TODO" + This section is incomplete #### SalesOrder diff --git a/docs/docs/report/helpers.md b/docs/docs/report/helpers.md index 605d3e50cd..fc64f5d52b 100644 --- a/docs/docs/report/helpers.md +++ b/docs/docs/report/helpers.md @@ -18,7 +18,7 @@ Some common functions are provided for use in custom report and label templates. When making use of helper functions within a template, it can be useful to store the result of the function to a variable, rather than immediately rendering the output. -For example, using the [render_currency](#rendering-currency) helper function, we can store the output to a variable which can be used at a later point in the template: +For example, using the [render_currency](#currency-formatting) helper function, we can store the output to a variable which can be used at a later point in the template: ```html {% raw %} @@ -272,7 +272,7 @@ A template tag is provided to load the InvenTree logo image into a report. You c ### Custom Logo -If the system administrator has enabled a [custom logo](../start/config.md#customisation-options), then this logo will be used instead of the base InvenTree logo. +If the system administrator has enabled a [custom logo](../start/config.md#customization-options) then this logo will be used instead of the base InvenTree logo. This is a useful way to get a custom company logo into your reports. @@ -287,7 +287,7 @@ If you have a custom logo, but explicitly wish to load the InvenTree logo itself ## Report Assets -[Report Assets](./report.md#report-assets) are files specifically uploaded by the user for inclusion in generated reports and labels. +[Report Assets](./templates.md#report-assets) are files specifically uploaded by the user for inclusion in generated reports and labels. You can add asset images to the reports and labels by using the `{% raw %}{% asset ... %}{% endraw %}` template tag: diff --git a/docs/docs/report/labels.md b/docs/docs/report/labels.md index 5518210559..f2f455ec36 100644 --- a/docs/docs/report/labels.md +++ b/docs/docs/report/labels.md @@ -11,17 +11,6 @@ Custom labels can be generated using simple HTML templates, with support for QR- Simple (generic) label templates are supplied 'out of the box' with InvenTree - however support is provided for generation of extremely specific custom labels, to meet any particular requirement. -## Label Types - -The following types of labels are available - -| Label Type | Description | -| --- | --- | -| [Part Labels](./labels/part_labels.md) | Print labels for individual parts | -| [Stock Labels](./labels/stock_labels.md) | Print labels for individual stock items | -| [Location Labels](./labels/location_labels.md) | Print labels for individual stock locations -| [Build Labels](./labels/build_labels.md) | Print labels for individual build order line items | - ## Label Templates Label templates are written using a mixture of [HTML](https://www.w3schools.com/html/) and [CSS](https://www.w3schools.com/css). [Weasyprint](https://weasyprint.org/) templates support a *subset* of HTML and CSS features. In addition to supporting HTML and CSS formatting, the label templates support the Django templating engine, allowing conditional formatting of the label data. diff --git a/docs/docs/report/labels/build_labels.md b/docs/docs/report/labels/build_labels.md deleted file mode 100644 index 0a3924a106..0000000000 --- a/docs/docs/report/labels/build_labels.md +++ /dev/null @@ -1,118 +0,0 @@ ---- -title: Build Labels ---- - -## Build Line Labels - -Build label templates are used to generate labels for individual build order line items. - -### Creating Build Line Label Templates - -Build label templates are added (and edited) via the [admin interface](../../settings/admin.md). - -### Printing Build Line Labels - -Build line labels are printed from the Build Order page, under the *Allocate Stock* tab. Multiple line items can be selected for printing: - -{% with id='print_build_labels', url='report/label_build_print.png', description='Print build line labels' %} -{% include 'img.html' %} -{% endwith %} - -### Context Data - -The following context variables are made available to the Build Line label template: - -| Variable | Description | -| --- | --- | -| build_line | The build_line instance | -| build | The build order to which the build_line is linked | -| bom_item | The bom_item to which the build_line is linked | -| part | The required part for this build_line instance. References bom_item.sub_part | -| quantity | The total quantity required for the build line | -| allocated_quantity | The total quantity which has been allocated against the build line | -| allocations | A queryset containing the allocations made against the build_line | - -## Example - -A simple example template is shown below: - -```html -{% raw %} -{% extends "label/label_base.html" %} -{% load barcode report %} -{% load inventree_extras %} - -{% block style %} - -{{ block.super }} - -.label { - margin: 1mm; -} - -.qr { - height: 28mm; - width: 28mm; - position: relative; - top: 0mm; - right: 0mm; - float: right; -} - -.label-table { - width: 100%; - border-collapse: collapse; - border: 1pt solid black; -} - -.label-table tr { - width: 100%; - border-bottom: 1pt solid black; - padding: 2.5mm; -} - -.label-table td { - padding: 3mm; -} - -{% endblock style %} - -{% block content %} - -
- - - - - - - - - -
- Build Order: {{ build.reference }}
- Build Qty: {% decimal build.quantity %}
-
- build qr -
- Part: {{ part.name }}
- {% if part.IPN %} - IPN: {{ part.IPN }}
- {% endif %} - Qty / Unit: {% decimal bom_item.quantity %} {% if part.units %}[{{ part.units }}]{% endif %}
- Qty Total: {% decimal quantity %} {% if part.units %}[{{ part.units }}]{% endif %} -
- part qr -
-
- -{% endblock content %} - -{% endraw %} -``` - -Which results in a label like: - -{% with id='build_label_example', url='report/label_build_example.png', description='Example build line labels' %} -{% include 'img.html' %} -{% endwith %} diff --git a/docs/docs/report/labels/location_labels.md b/docs/docs/report/labels/location_labels.md deleted file mode 100644 index e7af905d75..0000000000 --- a/docs/docs/report/labels/location_labels.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -title: Location Labels ---- - - -## Stock Location Labels - -Stock Location label templates are used to generate labels for individual Stock Locations. - -### Creating Stock Location Label Templates - -Stock Location label templates are added (and edited) via the admin interface. - -### Printing Stock Location Labels - -To print a single label from the Stock Location detail view, select the *Print Label* option. - -### Context Data - -The following variables are made available to the StockLocation label template: - -| Variable | Description | -| -------- | ----------- | -| location | The [StockLocation](../context_variables.md#stocklocation) object itself | diff --git a/docs/docs/report/labels/part_labels.md b/docs/docs/report/labels/part_labels.md deleted file mode 100644 index 7e9e5606b8..0000000000 --- a/docs/docs/report/labels/part_labels.md +++ /dev/null @@ -1,91 +0,0 @@ ---- -title: Part Labels ---- - - -## Part Labels - -Part label templates are used to generate labels for individual Part instances. - -### Creating Part Label Templates - -Part label templates are added (and edited) via the admin interface. - -### Printing Part Labels - -Part label can be printed using the following approaches: - -To print a single part label from the Part detail view, select the *Print Label* option. - -To print multiple part labels, select multiple parts in the part table and select the *Print Labels* option. - -### Context Data - -The following context variables are made available to the Part label template: - -| Variable | Description | -| -------- | ----------- | -| part | The [Part](../context_variables.md#part) object | -| category | The [Part Category](../context_variables.md#part-category) which contains the Part | -| name | The name of the part | -| description | The description text for the part | -| IPN | Internal part number (IPN) for the part | -| revision | Part revision code | -| qr_data | String data which can be rendered to a QR code | -| parameters | Map (Python dictionary) object containing the parameters associated with the part instance | - -#### Parameter Values - -The part parameter *values* can be accessed by parameter name lookup in the template, as follows: - -```html -{% raw %} - -Part: {{ part.name }} -Length: {{ parameters.length }} - -{% endraw %} -``` - -!!! warning "Spaces" - Note that for parameters which include a `space` character in their name, lookup using the "dot" notation won't work! In this case, try using the [key lookup](../helpers.md#key-access) method: - -```html -{% raw %} - -Voltage Rating: {% getkey parameters "Voltage Rating" %} -{% endraw %} -``` - -#### Parameter Data - -If you require access to the parameter data itself, and not just the "value" of a particular parameter, you can use the `part_parameter` [helper function](../helpers.md#part-parameters). - -For example, the following label template can be used to generate a label which contains parameter data in addition to parameter units: - -```html -{% raw %} -{% extends "label/label_base.html" %} - -{% load report %} - -{% block content %} - -{% part_parameter part "Width" as width %} -{% part_parameter part "Length" as length %} - -
- Part: {{ part.full_name }}
- Width: {{ width.data }} [{{ width.units }}]
- Length: {{ length.data }} [{{ length.units }}] -
- -{% endblock content %} -{% endraw %} -``` - -The following label is produced: - -{% with id="report-parameters", url="report/label_with_parameters.png", description="Label with parameters" %} -{% include 'img.html' %} -{% endwith %} diff --git a/docs/docs/report/labels/stock_labels.md b/docs/docs/report/labels/stock_labels.md deleted file mode 100644 index eb72147284..0000000000 --- a/docs/docs/report/labels/stock_labels.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -title: Stock Labels ---- - - -## Stock Item Labels - -Stock Item label templates are used to generate labels for individual Stock Items. - -### Creating Stock Item Label Templates - -Stock Item label templates are added (and edited) via the admin interface. - -### Printing Stock Item Labels - -Stock Item labels can be printed using the following approaches: - -To print a single stock item from the Stock Item detail view, select the *Print Label* option as shown below: - -{% with id='item_label_single', url='report/label_stock_print_single.png', description='Print single stock item label' %} -{% include 'img.html' %} -{% endwith %} - -To print multiple stock items from the Stock table view, select the *Print Labels* option as shown below: - -{% with id='item_label_multiple', url='report/label_stock_print_multiple.png', description='Print multiple stock item labels' %} -{% include 'img.html' %} -{% endwith %} - -### Context Data - -The following variables are made available to the StockItem label template: - -| Variable | Description | -| -------- | ----------- | -| item | The [StockItem](../context_variables.md#stockitem) object itself | -| part | The [Part](../context_variables.md#part) object which is referenced by the [StockItem](../context_variables.md#stockitem) object | -| name | The `name` field of the associated Part object | -| ipn | The `IPN` field of the associated Part object | -| revision | The `revision` field of the associated Part object | -| quantity | The `quantity` field of the StockItem object | -| serial | The `serial` field of the StockItem object | -| uid | The `uid` field of the StockItem object | -| tests | Dict object of TestResult data associated with the StockItem | -| parameters | Dict object containing the parameters associated with the base Part | - -### URL-style QR code - -Stock Item labels support [QR code](../barcodes.md#qr-code) containing the stock item URL, which can be -scanned and opened directly -on a portable device using the camera or a QR code scanner. To generate a URL-style QR code for stock item in the [label HTML template](../labels.md#label-templates), add the -following HTML tag: - -``` html -{% raw %} - -{% endraw %} -``` - -Make sure to customize the `custom_qr_class` CSS class to define the position of the QR code -on the label. diff --git a/docs/docs/report/purchase_order.md b/docs/docs/report/purchase_order.md deleted file mode 100644 index 045e1afa7a..0000000000 --- a/docs/docs/report/purchase_order.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -title: Purchase Order Report ---- - -## Purchase Order Reports - -Custom purchase order reports may be generated against any given [Purchase Order](../order/purchase_order.md). For example, purchase order reports could be used to generate a pdf of the order to send to a supplier. - -### Purchase Order Filters - -The report template can be filtered against available [Purchase Order](../order/purchase_order.md) instances. - -### Context Variables - -In addition to the default report context variables, the following variables are made available to the purchase order report template for rendering: - -| Variable | Description | -| --- | --- | -| order | The specific Purchase Order object | -| reference | The order reference field (can also be accessed as `{% raw %}{{ order.reference }}{% endraw %}`) | -| description | The order description field | -| supplier | The [supplier](../order/company.md#suppliers) associated with this purchase order | -| lines | A list of available line items for this order | -| extra_lines | A list of available *extra* line items for this order | -| order.created_by | The user who created the order | -| order.responsible | The user or group who is responsible for the order | -| order.creation_date | The date when the order was created | -| order.target_date | The date when the order should arrive | -| order.if_overdue | Boolean value that tells if the target date has passed | -| order.currency | The currency code associated with this order, e.g. 'AUD' | -| order.contact | The [contact](./context_variables.md#contact) object associated with this order | - -#### Lines - -Each line item (available within the `lines` list) has sub variables, as follows: - -| Variable | Description | -| --- | --- | -| quantity | The quantity of the part to be ordered | -| part | The [supplierpart ](./context_variables.md#supplierpart) object to be ordered | -| reference | The reference given in the part of the order | -| notes | The notes given in the part of the order | -| target_date | The date when the part should arrive. Each part can have an individual date | -| price | The unit price the line item | -| total_line_price | The total price for this line item, calculated from the unit price and quantity | -| destination | The stock location where the part will be stored | - -A simple example below shows how to use the context variables for each line item: - -```html -{% raw %} -{% for line in lines %} -Internal Part: {{ line.part.part.name }} - {{ line.part.part.description }} -SKU: {{ line.part.SKU }} -Price: {% render_currency line.total_line_price %} -{% endfor %} -{% endraw %} -``` - - -### Default Report Template - -A default *Purchase Order Report* template is provided out of the box, which is useful for generating simple test reports. Furthermore, it may be used as a starting point for developing custom BOM reports: - -View the [source code](https://github.com/inventree/InvenTree/blob/master/src/backend/InvenTree/report/templates/report/inventree_po_report_base.html) for the default purchase order report template. diff --git a/docs/docs/report/report.md b/docs/docs/report/report.md index 468802bf5c..cff02f5141 100644 --- a/docs/docs/report/report.md +++ b/docs/docs/report/report.md @@ -1,14 +1,14 @@ --- -title: Report Generation +title: Report and LabelGeneration --- -## Custom Reporting +## Custom Reports -InvenTree supports a customizable reporting ecosystem, allowing the user to develop reporting templates that meet their particular needs. +InvenTree supports a customizable reporting ecosystem, allowing the user to develop document templates that meet their particular needs. -PDF reports are generated from custom HTML template files which are written by the user. +PDF files are generated from custom HTML template files which are written by the user. -Reports are used in a variety of situations to format data in a friendly format for printing, distribution, conformance and testing. +Templates can be used to generate *reports* or *labels* which can be used in a variety of situations to format data in a friendly format for printing, distribution, conformance and testing. In addition to providing the ability for end-users to provide their own reporting templates, some report types offer "built-in" report templates ready for use. @@ -44,290 +44,3 @@ For example, rendering the name of a part (which is available in the particular

{% endraw %} ``` - -### Context Variables - -!!! info "Context Variables" - Templates will have different variables available to them depending on the report type. Read the detailed information on each available report type for further information. - -Please refer to the [Context variables](./context_variables.md) page. - -### Conditional Rendering - -The django template system allows for conditional rendering, providing conditional flow statements such as: - -``` -{% raw %} -{% if %} -{% do_something %} -{% elif %} - -{% else %} - -{% endif %} -{% endraw %} -``` - -``` -{% raw %} -{% for in %} -Item: {{ item }} -{% endfor %} -{% endraw %} -``` - -!!! info "Conditionals" - Refer to the [django template language documentation]({% include "django.html" %}/ref/templates/language/) for more information. - -### Localization Issues - -Depending on your localization scheme, inputting raw numbers into the formatting section template can cause some unintended issues. Consider the block below which specifies the page size for a rendered template: - -```html -{% raw %} - - - -{% endraw %} -``` - -If localization settings on the InvenTree server use a comma (`,`) character as a decimal separator, this may produce an output like: - -```html -{% raw %} -{% endraw %} - - - -``` - -The resulting `{% raw %} - -{% endraw %} -``` - -!!! tip "Close it out" - Don't forget to end with a `{% raw %}{% endlocalize %}{% endraw %}` tag! - -!!! tip "l10n" - You will need to add `{% raw %}{% load l10n %}{% endraw %}` to the top of your template file to use the `{% raw %}{% localize %}{% endraw %}` tag. - -### Extending with Plugins - -The [ReportMixin plugin class](../extend/plugins/report.md) allows reporting functionality to be extended with custom features. - -## Report Types - -InvenTree supports the following reporting functionality: - -| Report Type | Description | -| --- | --- | -| [Test Report](./test.md) | Format results of a test report against for a particular StockItem | -| [Build Order Report](./build.md) | Format a build order report | -| [Purchase Order Report](./purchase_order.md) | Format a purchase order report | -| [Sales Order Report](./sales_order.md) | Format a sales order report | -| [Return Order Report](./return_order.md) | Format a return order report | -| [Stock Location Report](./stock_location.md) | Format a stock location report | - -### Default Reports - -InvenTree is supplied with a number of default templates "out of the box". These are generally quite simple, but serve as a starting point for building custom reports to suit a specific need. - -!!! tip "Read the Source" - The source code for the default reports is [available on GitHub](https://github.com/inventree/InvenTree/tree/master/src/backend/InvenTree/report/templates/report). Use this as a guide for generating your own reports! - -## Creating Reports - -Report templates are created (and edited) via the [admin interface](../settings/admin.md), under the *Report* section. Select the certain type of report template you are wanting to create, and press the *Add* button in the top right corner: - -{% with id="report-create", url="report/add_report_template.png", description="Create new report" %} -{% include 'img.html' %} -{% endwith %} - -!!! tip "Staff Access Only" - Only users with staff access can upload or edit report template files. - -!!! info "Editing Reports" - Existing reports can be edited from the admin interface, in the same location as described above. To change the contents of the template, re-upload a template file, to override the existing template data. - -### Name and Description - -Each report template requires a name and description, which identify and describe the report template. - -### Enabled Status - -Boolean field which determines if the specific report template is enabled, and available for use. Reports can be disabled to remove them from the list of available templates, but without deleting them from the database. - -### Filename Pattern - -The filename pattern used to generate the output `.pdf` file. Defaults to "report.pdf". - -The filename pattern allows custom rendering with any context variables which are available to the report. For example, a [test report](./test.md) for a particular [Stock Item](../stock/stock.md#stock-item) can use the part name and serial number of the stock item when generating the report name: - -{% with id="report-filename-pattern", url="report/filename_pattern.png", description="Report filename pattern" %} -{% include 'img.html' %} -{% endwith %} - - -### Report Filters - -Each type of report provides a *filters* field, which can be used to filter which items a report can be generated against. The target of the *filters* field depends on the type of report - refer to the documentation on the specific report type for more information. - -For example, the [Test Report](./test.md) filter targets the linked [Stock Item](../stock/status.md) object, and can be used to select which stock items are allowed for the given report. Let's say that a certain test report should only be generated for "trackable" stock items. A filter could easily be constructed to accommodate this, by limiting available items to those where the associated [Part](../part/part.md) is *trackable*: - -{% with id="report-filter-valid", url="report/filters_valid.png", description="Report filter selection" %} -{% include 'img.html' %} -{% endwith %} - -If you enter an invalid option for the filter field, an error message will be displayed: - -{% with id="report-filter-invalid", url="report/filters_invalid.png", description="Invalid filter selection" %} -{% include 'img.html' %} -{% endwith %} - -!!! warning "Advanced Users" - Report filtering is an advanced topic, and requires a little bit of knowledge of the underlying data structure! - -### Metadata - -A JSON field made available to any [plugins](../extend/plugins.md) - but not used by internal code. - -## Report Options - -A number of global reporting options are available for customizing InvenTree reports: - -{% with id="report-options", url="report/report.png", description="Report Options" %} -{% include 'img.html' %} -{% endwith %} - -### Enable Reports - -By default, the reporting feature is disabled. It must be enabled in the global settings. - - -### Default Page Size - -The built-in InvenTree report templates (and any reports which are derived from the built-in templates) use the *Page Size* option to set the page size of the generated reports. - -!!! info "Override Page Size" - Custom report templates do not have to make use of the *Page Size* option, although it is made available to the template context. - -### Debug Mode - -As templates are rendered directly to a PDF object, it can be difficult to debug problems when the PDF does not render exactly as expected. - -Setting the *Debug Mode* option renders the template as raw HTML instead of PDF, allowing the rendering output to be introspected. This feature allows template designers to understand any issues with the generated HTML (before it is passed to the PDF generation engine). - -!!! warning "HTML Rendering Limitations" - When rendered in debug mode, @page attributes (such as size, etc) will **not** be observed. Additionally, any asset files stored on the InvenTree server will not be rendered. Debug mode is not intended to produce "good looking" documents! - -## Report Assets - -User can upload asset files (e.g. images) which can be used when generating reports. For example, you may wish to generate a report with your company logo in the header. Asset files are uploaded via the admin interface. - -Asset files can be rendered directly into the template as follows - -```html -{% raw %} - -{% load report %} - - - - - - - - - - - - - - - -{% endraw %} -``` - -!!! warning "Asset Naming" - If the requested asset name does not match the name of an uploaded asset, the template will continue without loading the image. - -!!! info "Assets location" - You need to ensure your asset images to the report/assets directory in the [data directory](../start/intro.md#file-storage). Upload new assets via the [admin interface](../settings/admin.md) to ensure they are uploaded to the correct location on the server. - - -## Report Snippets - -A powerful feature provided by the django / WeasyPrint templating framework is the ability to include external template files. This allows commonly used template features to be broken out into separate files and re-used across multiple templates. - -To support this, InvenTree provides report "snippets" - short (or not so short) template files which cannot be rendered by themselves, but can be called from other templates. - -Similar to assets files, snippet template files are uploaded via the admin interface. - -Snippets are included in a template as follows: - -``` -{% raw %}{% include 'snippets/' %}{% endraw %} -``` - -For example, consider a stocktake report for a particular stock location, where we wish to render a table with a row for each item in that location. - -```html -{% raw %} - - - - - - - {% for item in location.stock_items %} - {% include 'snippets/stock_row.html' with item=item %} - {% endfor %} - - -{% endraw %} -``` - -!!! info "Snippet Arguments" - Note above that named argument variables can be passed through to the snippet! - -And the snippet file `stock_row.html` may be written as follows: - -```html -{% raw %} - - - - - -{% endraw %} -``` diff --git a/docs/docs/report/return_order.md b/docs/docs/report/return_order.md deleted file mode 100644 index c3403c7f98..0000000000 --- a/docs/docs/report/return_order.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -title: Return Order Reports ---- - -## Return Order Reports - -Custom reports may be generated against any given [Return Order](../order/return_order.md). For example, return order reports can be used to generate an RMA request to send to a customer. - -### Context Variables - -In addition to the default report context variables, the following context variables are made available to the return order report template for rendering: - -| Variable | Description | -| --- | --- | -| order | The return order object the report is being generated against | -| description | The description of the order, also accessed through `order.description` | -| reference | The reference of the order, also accessed through `order.reference` | -| customer | The customer object related to this order | -| lines | The list of line items linked to this order | -| extra_lines | The list of extra line items linked to this order | - -### Default Report Template - -A default report template is provided out of the box, which can be used as a starting point for developing custom return order report templates. - -View the [source code](https://github.com/inventree/InvenTree/blob/master/src/backend/InvenTree/report/templates/report/inventree_return_order_report_base.html) for the default return order report template. diff --git a/docs/docs/report/sales_order.md b/docs/docs/report/sales_order.md deleted file mode 100644 index 46e3aeddb5..0000000000 --- a/docs/docs/report/sales_order.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -title: Sales Order Reports ---- - -## Sales Order Reports - -Custom sales order reports may be generated against any given [Sales Order](../order/sales_order.md). For example, a sales order report could be used to generate an invoice to send to a customer. - -### Sales Order Filters - -The report template can be filtered against available [Sales Order](../order/sales_order.md) instances. - -### Context Variables - -In addition to the default report context variables, the following variables are made available to the sales order report template for rendering: - -| Variable | Description | -| --- | --- | -| order | The specific Sales Order object | -| reference | The order reference field (can also be accessed as `{% raw %}{{ order.description }}{% endraw %}`) | -| description | The order description field | -| customer | The [customer](../order/company.md#customers) associated with the particular sales order | -| lines | A list of available line items for this order | -| extra_lines | A list of available *extra* line items for this order | -| order.currency | The currency code associated with this order, e.g. 'CAD' | - -### Default Report Template - -A default *Sales Order Report* template is provided out of the box, which is useful for generating simple test reports. Furthermore, it may be used as a starting point for developing custom BOM reports: - -View the [source code](https://github.com/inventree/InvenTree/blob/master/src/backend/InvenTree/report/templates/report/inventree_so_report_base.html) for the default sales order report template. diff --git a/docs/docs/report/samples.md b/docs/docs/report/samples.md new file mode 100644 index 0000000000..d6dff5678b --- /dev/null +++ b/docs/docs/report/samples.md @@ -0,0 +1,78 @@ +--- +title: Sample Templates +--- + +## Sample Templates + +A number of pre-built templates are provided with InvenTree, which can be used as a starting point for creating custom reports and labels. + +Users can create their own custom templates, or modify the provided templates to suit their needs. + +## Report Templates + +The following report templates are provided "out of the box" and can be used as a starting point, or as a reference for creating custom reports templates: + +| Template | Model Type | Description | +| --- | --- | --- | +| [Bill of Materials](#bill-of-materials-report) | [Part](../part/part.md) | Bill of Materials report | +| [Build Order](#build-order) | [BuildOrder](../build/build.md) | Build Order report | +| [Purchase Order](#purchase-order) | [PurchaseOrder](../order/purchase_order.md) | Purchase Order report | +| [Return Order](#return-order) | [ReturnOrder](../order/return_order.md) | Return Order report | +| [Sales Order](#sales-order) | [SalesOrder](../order/sales_order.md) | Sales Order report | +| [Stock Location](#stock-location) | [StockLocation](../stock/stock.md#stock-location) | Stock Location report | +| [Test Report](#test-report) | [StockItem](../stock/stock.md#stock-item) | Test Report | + +### Bill of Materials Report + +{{ templatefile("report/inventree_bill_of_materials_report.html") }} + +### Build Order + +{{ templatefile("report/inventree_build_order_report.html") }} + +### Purchase Order + +{{ templatefile("report/inventree_bill_of_materials_report.html") }} + +### Return Order + +{{ templatefile("report/inventree_return_order_report.html") }} + +### Sales Order + +{{ templatefile("report/inventree_sales_order_report.html") }} + +### Stock Location + +{{ templatefile("report/inventree_stock_location_report.html") }} + +### Test Report + +{{ templatefile("report/inventree_test_report.html") }} + +## Label Templates + +The following label templates are provided "out of the box" and can be used as a starting point, or as a reference for creating custom label templates: + +| Template | Model Type | Description | +| --- | --- | --- | +| [Build Line](#build-line-label) | [Build line item](../build/build.md) | Build Line label | +| [Part](#part-label) | [Part](../part/part.md) | Part label | +| [Stock Item](#stock-item-label) | [StockItem](../stock/stock.md#stock-item) | Stock Item label | +| [Stock Location](#stock-location-label) | [StockLocation](../stock/stock.md#stock-location) | Stock Location label | + +### Build Line Label + +{{ templatefile("label/buildline_label.html") }} + +### Part Label + +{{ templatefile("label/part_label_code128.html") }} + +### Stock Item Label + +{{ templatefile("label/stockitem_qr.html") }} + +### Stock Location Label + +{{ templatefile("label/stocklocation_qr_and_text.html") }} diff --git a/docs/docs/report/stock_location.md b/docs/docs/report/stock_location.md deleted file mode 100644 index e1712c06d9..0000000000 --- a/docs/docs/report/stock_location.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -title: Stock Location Reports ---- - -## Stock location Reports - -You can print a formatted report of a stock location. This makes sense if you have several parts inside one location, e.g. a box that is sent out to a manufacturing partner. Whit a report you can create a box content list. - -### Context Variables -You can use all content variables from the [StockLocation](./context_variables.md#stocklocation) object. - -### Default Report Template - -A default report template is provided out of the box, which can be used as a starting point for developing custom return order report templates. - -View the [source code](https://github.com/inventree/InvenTree/blob/master/src/backend/InvenTree/report/templates/report/inventree_slr_report.html) for the default stock location report template. diff --git a/docs/docs/report/templates.md b/docs/docs/report/templates.md new file mode 100644 index 0000000000..0910532667 --- /dev/null +++ b/docs/docs/report/templates.md @@ -0,0 +1,228 @@ +--- +title: InvenTree Templates +--- + +## Template Overview + +InvenTree supports a customizable reporting ecosystem, allowing the user to develop document templates that meet their particular needs. + +PDF files are generated from custom HTML template files which are written by the user. + +Templates can be used to generate *reports* or *labels* which can be used in a variety of situations to format data in a friendly format for printing, distribution, conformance and testing. + +In addition to providing the ability for end-users to provide their own reporting templates, some report types offer "built-in" report templates ready for use. + +## Template Types + +The following types of templates are available: + +### Reports + +Reports are intended to serve as formal documents, and can be used to generate formatted PDF outputs for a variety of purposes. + +Refer to the [report templates](./report.md) documentation for further information. + +### Labels + +Labels can also be generated using the templating system. Labels are intended to be used for printing small, formatted labels for items, parts, locations, etc. + +Refer to the [label templates](./labels.md) documentation for further information. + +### Template Model Types + +When generating a particular template (to render a report or label output), the template is rendered against a particular "model" type. The model type determines the data that is available to the template, and how it is formatted. + +To read more about the model types for which templates can be rendered, and the associated context information, refer to the [context variables](./context_variables.md) documentation. + +### Default Reports + +InvenTree is supplied with a number of default templates "out of the box" - for generating both labels and reports. These are generally quite simple, but serve as a starting point for building custom reports to suit a specific need. + +!!! tip "Read the Source" + The source code for the default reports is [available on GitHub](https://github.com/inventree/InvenTree/tree/master/src/backend/InvenTree/report/templates/report). Use this as a guide for generating your own reports! + +### Extending with Plugins + +The [ReportMixin plugin class](../extend/plugins/report.md) allows reporting functionality to be extended with custom features. + +## WeasyPrint Template Rendering + +InvenTree report templates utilize the powerful [WeasyPrint](https://weasyprint.org/) PDF generation engine. + +To read more about the capabilities of the report templating engine, and how to use it, refer to the [weasyprint documentation](./weasyprint.md). + +## Creating Templates + +Report and label templates can be created (and edited) via the [admin interface](../settings/admin.md), under the *Report* section. + +Select the type of template you are wanting to create (a *Report Template* or *Label Template*) and press the *Add* button in the top right corner: + +{% with id="report-list", url="report/report_template_admin.png", description="Report templates in admin interface" %} +{% include 'img.html' %} +{% endwith %} + +!!! tip "Staff Access Only" + Only users with staff access can upload or edit report template files. + +!!! info "Editing Reports" + Existing reports can be edited from the admin interface, in the same location as described above. To change the contents of the template, re-upload a template file, to override the existing template data. + +!!! tip "Template Editor" + InvenTree also provides a powerful [template editor](./template_editor.md) which allows for the creation and editing of report templates directly within the browser. + +### Name and Description + +Each report template requires a name and description, which identify and describe the report template. + +### Enabled Status + +Boolean field which determines if the specific report template is enabled, and available for use. Reports can be disabled to remove them from the list of available templates, but without deleting them from the database. + +### Filename Pattern + +The filename pattern used to generate the output `.pdf` file. Defaults to "report.pdf". + +The filename pattern allows custom rendering with any context variables which are available to the report. For example, a test report for a particular [Stock Item](../stock/stock.md#stock-item) can use the part name and serial number of the stock item when generating the report name: + +{% with id="report-filename-pattern", url="report/filename_pattern.png", description="Report filename pattern" %} +{% include 'img.html' %} +{% endwith %} + + +### Template Filters + +Each template instance provides a *filters* field, which can be used to filter which items a report or label template can be generated against. The target of the *filters* field depends on the model type associated with the particular template. + +As an example, let's say that a certain `StockItem` report should only be generated for "trackable" stock items. A filter could easily be constructed to accommodate this, by limiting available items to those where the associated [Part](../part/part.md) is *trackable*: + +{% with id="report-filter-valid", url="report/filters_valid.png", description="Report filter selection" %} +{% include 'img.html' %} +{% endwith %} + +If you enter an invalid option for the filter field, an error message will be displayed: + +{% with id="report-filter-invalid", url="report/filters_invalid.png", description="Invalid filter selection" %} +{% include 'img.html' %} +{% endwith %} + +!!! warning "Advanced Users" + Report filtering is an advanced topic, and requires a little bit of knowledge of the underlying data structure! + +### Metadata + +A JSON field made available to any [plugins](../extend/plugins.md) - but not used by internal code. + +## Reporting Options + +A number of global reporting options are available for customizing InvenTree reports: + +{% with id="report-options", url="report/report.png", description="Report Options" %} +{% include 'img.html' %} +{% endwith %} + +### Enable Reports + +By default, the reporting feature is disabled. It must be enabled in the global settings. + + +### Default Page Size + +The built-in InvenTree report templates (and any reports which are derived from the built-in templates) use the *Page Size* option to set the page size of the generated reports. + +!!! info "Override Page Size" + Custom report templates do not have to make use of the *Page Size* option, although it is made available to the template context. + +### Debug Mode + +As templates are rendered directly to a PDF object, it can be difficult to debug problems when the PDF does not render exactly as expected. + +Setting the *Debug Mode* option renders the template as raw HTML instead of PDF, allowing the rendering output to be introspected. This feature allows template designers to understand any issues with the generated HTML (before it is passed to the PDF generation engine). + +!!! warning "HTML Rendering Limitations" + When rendered in debug mode, @page attributes (such as size, etc) will **not** be observed. Additionally, any asset files stored on the InvenTree server will not be rendered. Debug mode is not intended to produce "good looking" documents! + +## Report Assets + +User can upload asset files (e.g. images) which can be used when generating reports. For example, you may wish to generate a report with your company logo in the header. Asset files are uploaded via the admin interface. + +Asset files can be rendered directly into the template as follows + +```html +{% raw %} + +{% load report %} + + + + + + + + + + + + + + + +{% endraw %} +``` + +!!! warning "Asset Naming" + If the requested asset name does not match the name of an uploaded asset, the template will continue without loading the image. + +!!! info "Assets location" + You need to ensure your asset images to the report/assets directory in the [data directory](../start/intro.md#file-storage). Upload new assets via the [admin interface](../settings/admin.md) to ensure they are uploaded to the correct location on the server. + + +## Report Snippets + +A powerful feature provided by the django / WeasyPrint templating framework is the ability to include external template files. This allows commonly used template features to be broken out into separate files and re-used across multiple templates. + +To support this, InvenTree provides report "snippets" - short (or not so short) template files which cannot be rendered by themselves, but can be called from other templates. + +Similar to assets files, snippet template files are uploaded via the admin interface. + +Snippets are included in a template as follows: + +``` +{% raw %}{% include 'snippets/' %}{% endraw %} +``` + +For example, consider a stocktake report for a particular stock location, where we wish to render a table with a row for each item in that location. + +```html +{% raw %} + +
{{ item.part.full_name }}{{ item.quantity }}
+ + + + + {% for item in location.stock_items %} + {% include 'snippets/stock_row.html' with item=item %} + {% endfor %} + + +{% endraw %} +``` + +!!! info "Snippet Arguments" + Note above that named argument variables can be passed through to the snippet! + +And the snippet file `stock_row.html` may be written as follows: + +```html +{% raw %} + + + + + +{% endraw %} +``` diff --git a/docs/docs/report/test.md b/docs/docs/report/test.md deleted file mode 100644 index 7e6dcd67a2..0000000000 --- a/docs/docs/report/test.md +++ /dev/null @@ -1,87 +0,0 @@ ---- -title: Test Report ---- - -## Test Report - -InvenTree provides [test result](../stock/test.md) tracking functionality which allows the users to keep track of any tests which have been performed on a given [stock item](../stock/stock.md). - -Custom test reports may be generated against any given stock item. All testing data is made available to the template for custom rendering as required. - -For example, an "Acceptance Test" report template may be customized to the particular device, with the results for certain tests rendering in a particular part of the page, with any tests which have not passed highlighted. - -### Stock Item Filters - -A TestReport template may define a set of filters against which stock items are sorted. Any [StockItem](../stock/stock.md) objects which match the provided filters can use the given TestReport. - -This allows each TestReport to easily be assigned to a particular StockItem, or even multiple items. - -In the example below, a test report template is uploaded and available to any stock items linked to a part with the name *"My Widget"*. Any combination of fields relevant to the StockItem model can be used here. - -{% with id="test-report-filters", url="report/test_report_filters.png", description="Test report filters" %} -{% include 'img.html' %} -{% endwith %} - - -### Context Variables - -In addition to the default report context variables, the following context variables are made available to the TestReport template for rendering: - -| Variable | Description | -| --- | --- | -| stock_item | The individual [Stock Item](./context_variables.md#stockitem) object for which this test report is being generated | -| serial | The serial number of the linked Stock Item | -| part | The [Part](./context_variables.md#part) object of which the stock_item is an instance | -| parameters | A dict object representing the [parameters](../part/parameter.md) of the referenced part | -| test_keys | A list of the available 'keys' for the test results recorded against the stock item | -| test_template_list | A list of the available [test templates](../part/test.md#part-test-templates) for the referenced part | -| test_template_map | A map / dict of the available test templates | -| results | A dict of test result objects, where the 'key' for each test result is a shortened version of the test name (see below) | -| result_list | A list of each test result object | -| installed_items | A flattened list representing all [Stock Item](./context_variables.md#stockitem) objects which are *installed inside* the referenced [Stock Item](./context_variables.md#stockitem) object | - -#### Results - -The *results* context variable provides a very convenient method of callout out a particular test result by name. - -#### Example - -Say for example that a Part "Electronic Widget" has a stock item with serial number #123, and has a test result uploaded called "Firmware Checksum". The templated file can reference this data as follows: - -``` html -

Part: {% raw %}{{ part.name }}{% endraw %}

-Serial Number: {% raw %}{{ stock_item.serial }}{% endraw %} -
-

-Firmware Checksum: {% raw %}{{ results.firmwarechecksum.value }}. -Uploaded by {{ results.firmwarechecksum.user }}{% endraw %} -

-``` - -#### Installed Items - -The *installed_items* context variable is a list of all [StockItem](./context_variables.md#stockitem) instances which are installed inside the [StockItem](./context_variables.md#stockitem) referenced by the report template. Each [StockItem](./context_variables.md#stockitem) can be dereferenced as follows: - -```html -{% raw %} -
{{ item.part.full_name }}{{ item.quantity }}
- {% for sub_item in installed_items %} - - - - - - {% endfor %} -
{{ sub_item.full_name }}Serial Number: {{ sub_item.serial }}Pass: {{ sub_item.passedAllRequiredTests }}
-{% endraw %} -``` - -### Default Report Template - -A default *Test Report* template is provided out of the box, which is useful for generating simple test reports. Furthermore, it may be used as a starting point for developing custom test reports: - -{% with id="test-report-example", url="report/test_report_example.png", description="Example Test Report" %} -{% include "img.html" %} -{% endwith %} - -View the [source code](https://github.com/inventree/InvenTree/blob/master/src/backend/InvenTree/report/templates/report/inventree_test_report_base.html) for the default test report template. diff --git a/docs/docs/report/weasyprint.md b/docs/docs/report/weasyprint.md new file mode 100644 index 0000000000..e2aaaccad5 --- /dev/null +++ b/docs/docs/report/weasyprint.md @@ -0,0 +1,111 @@ +--- +title: Weasyprint Templates +--- + +## WeasyPrint Templates + +We use the powerful [WeasyPrint](https://weasyprint.org/) PDF generation engine to create custom reports and labels. + +!!! info "WeasyPrint" + WeasyPrint is an extremely powerful and flexible reporting library. Refer to the [WeasyPrint docs](https://doc.courtbouillon.org/weasyprint/stable/) for further information. + +### Stylesheets + +Templates are rendered using standard HTML / CSS - if you are familiar with web page layout, you're ready to go! + +### Template Language + +Uploaded report template files are passed through the [django template rendering framework]({% include "django.html" %}/topics/templates/), and as such accept the same variable template strings as any other django template file. Different variables are passed to the report template (based on the context of the report) and can be used to customize the contents of the generated PDF. + +### Context Variables + +!!! info "Context Variables" + Templates will have different variables available to them depending on the report type. Read the detailed information on each available report type for further information. + +Please refer to the [Context variables](./context_variables.md) page. + + +### Conditional Rendering + +The django template system allows for conditional rendering, providing conditional flow statements such as: + +``` +{% raw %} +{% if %} +{% do_something %} +{% elif %} + +{% else %} + +{% endif %} +{% endraw %} +``` + +``` +{% raw %} +{% for in %} +Item: {{ item }} +{% endfor %} +{% endraw %} +``` + +!!! info "Conditionals" + Refer to the [django template language documentation]({% include "django.html" %}/ref/templates/language/) for more information. + +### Localization Issues + +Depending on your localization scheme, inputting raw numbers into the formatting section template can cause some unintended issues. Consider the block below which specifies the page size for a rendered template: + +```html +{% raw %} + + + +{% endraw %} +``` + +If localization settings on the InvenTree server use a comma (`,`) character as a decimal separator, this may produce an output like: + +```html +{% raw %} +{% endraw %} + + + +``` + +The resulting `{% raw %} + +{% endraw %} +``` + +!!! tip "Close it out" + Don't forget to end with a `{% raw %}{% endlocalize %}{% endraw %}` tag! + +!!! tip "l10n" + You will need to add `{% raw %}{% load l10n %}{% endraw %}` to the top of your template file to use the `{% raw %}{% localize %}{% endraw %}` tag. diff --git a/docs/docs/start/installer.md b/docs/docs/start/installer.md index 721a30fbed..450cb58807 100644 --- a/docs/docs/start/installer.md +++ b/docs/docs/start/installer.md @@ -171,4 +171,4 @@ The packages are provided by [packager.io](https://packager.io/). They are built The package sets up [services](#controlling-inventree) that run the needed processes as the unprivileged user `inventree`. This keeps the privileges of InvenTree as low as possible. -A CLI is provided to interface with low-level management functions like [variable management](#enviroment-variables), log access, commands, process scaling, etc. +A CLI is provided to interface with low-level management functions like [variable management](#environment-variables), log access, commands, process scaling, etc. diff --git a/docs/main.py b/docs/main.py index ad5c2fe75a..129732a0ca 100644 --- a/docs/main.py +++ b/docs/main.py @@ -1,6 +1,7 @@ """Main entry point for the documentation build process.""" import os +import textwrap def define_env(env): @@ -22,3 +23,29 @@ def define_env(env): assets.append(os.path.join(subdir, asset)) return assets + + @env.macro + def templatefile(filename): + """Include code for a provided template file.""" + here = os.path.dirname(__file__) + template_dir = os.path.join( + here, '..', 'src', 'backend', 'InvenTree', 'report', 'templates' + ) + template_file = os.path.join(template_dir, filename) + template_file = os.path.abspath(template_file) + + basename = os.path.basename(filename) + + if not os.path.exists(template_file): + raise FileNotFoundError(f'Report template file {filename} does not exist.') + + with open(template_file, 'r') as f: + content = f.read() + + data = f'??? abstract "Template: {basename}"\n\n' + data += ' ```html\n' + data += textwrap.indent(content, ' ') + data += '\n\n' + data += ' ```\n\n' + + return data diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index c02a03896a..14c8ddd832 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -135,25 +135,15 @@ nav: - Return Orders: order/return_order.md - Project Codes: order/project_codes.md - Report: - - Templates: report/report.md + - Templates: report/templates.md + - Template Rendering: report/weasyprint.md - Template Editor: report/template_editor.md - - Report Types: - - Test Reports: report/test.md - - Build Order: report/build.md - - Purchase Order: report/purchase_order.md - - Sales Order: report/sales_order.md - - Return Order: report/return_order.md - - BOM: report/bom.md - - Stock Location: report/stock_location.md - - Labels: - - Custom Labels: report/labels.md - - Part Labels: report/labels/part_labels.md - - Stock Labels: report/labels/stock_labels.md - - Location Labels: report/labels/location_labels.md - - Build Labels: report/labels/build_labels.md + - Reports: report/report.md + - Labels: report/labels.md + - Context Variables: report/context_variables.md - Helper Functions: report/helpers.md - Barcodes: report/barcodes.md - - Context Variables: report/context_variables.md + - Sample Templates: report/samples.md - Admin: - Global Settings: settings/global.md - User Settings: settings/user.md @@ -241,6 +231,7 @@ plugins: on_config: "docs.docs.hooks:on_config" - macros: include_dir: docs/_includes + module_name: main - mkdocstrings: default_handler: python handlers: @@ -250,6 +241,8 @@ plugins: options: show_symbol_type_heading: true show_symbol_type_toc: true + show_root_heading: false + show_root_toc_entry: false # Extensions markdown_extensions: diff --git a/docs/requirements.in b/docs/requirements.in index 303f8bb6f5..a2442f3814 100644 --- a/docs/requirements.in +++ b/docs/requirements.in @@ -5,4 +5,4 @@ mkdocs-git-revision-date-localized-plugin>=1.1,<2.0 mkdocs-simple-hooks>=0.1,<1.0 mkdocs-include-markdown-plugin neoteroi-mkdocs -mkdocstrings[python]>=0.24.0 +mkdocstrings[python]>=0.25.0 diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index d4bdd7f537..0ad025b8a7 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,12 +1,16 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 200 +INVENTREE_API_VERSION = 201 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v201 - 2024-05-21 : https://github.com/inventree/InvenTree/pull/7074 + - Major refactor of the report template / report printing interface + - This is a *breaking change* to the report template API + v200 - 2024-05-20 : https://github.com/inventree/InvenTree/pull/7000 - Adds API endpoint for generating custom batch codes - Adds API endpoint for generating custom serial numbers diff --git a/src/backend/InvenTree/InvenTree/apps.py b/src/backend/InvenTree/InvenTree/apps.py index 2e90780efa..c10a2e156e 100644 --- a/src/backend/InvenTree/InvenTree/apps.py +++ b/src/backend/InvenTree/InvenTree/apps.py @@ -74,6 +74,7 @@ class InvenTreeConfig(AppConfig): obsolete = [ 'InvenTree.tasks.delete_expired_sessions', 'stock.tasks.delete_old_stock_items', + 'label.tasks.cleanup_old_label_outputs', ] try: @@ -83,7 +84,14 @@ class InvenTreeConfig(AppConfig): # Remove any existing obsolete tasks try: - Schedule.objects.filter(func__in=obsolete).delete() + obsolete_tasks = Schedule.objects.filter(func__in=obsolete) + + if obsolete_tasks.exists(): + logger.info( + 'Removing %s obsolete background tasks', obsolete_tasks.count() + ) + obsolete_tasks.delete() + except Exception: logger.exception('Failed to remove obsolete tasks - database not ready') diff --git a/src/backend/InvenTree/InvenTree/metadata.py b/src/backend/InvenTree/InvenTree/metadata.py index 9811a41d7c..242516f59b 100644 --- a/src/backend/InvenTree/InvenTree/metadata.py +++ b/src/backend/InvenTree/InvenTree/metadata.py @@ -121,6 +121,16 @@ class InvenTreeMetadata(SimpleMetadata): serializer_info = super().get_serializer_info(serializer) + # Look for any dynamic fields which were not available when the serializer was instantiated + for field_name in serializer.Meta.fields: + if field_name in serializer_info: + # Already know about this one + continue + + if hasattr(serializer, field_name): + field = getattr(serializer, field_name) + serializer_info[field_name] = self.get_field_info(field) + model_class = None # Attributes to copy extra attributes from the model to the field (if they don't exist) @@ -264,7 +274,9 @@ class InvenTreeMetadata(SimpleMetadata): # Introspect writable related fields if field_info['type'] == 'field' and not field_info['read_only']: # If the field is a PrimaryKeyRelatedField, we can extract the model from the queryset - if isinstance(field, serializers.PrimaryKeyRelatedField): + if isinstance(field, serializers.PrimaryKeyRelatedField) or issubclass( + field.__class__, serializers.PrimaryKeyRelatedField + ): model = field.queryset.model else: logger.debug( @@ -285,6 +297,9 @@ class InvenTreeMetadata(SimpleMetadata): else: field_info['api_url'] = model.get_api_url() + # Handle custom 'primary key' field + field_info['pk_field'] = getattr(field, 'pk_field', 'pk') or 'pk' + # Add more metadata about dependent fields if field_info['type'] == 'dependent field': field_info['depends_on'] = field.depends_on diff --git a/src/backend/InvenTree/InvenTree/settings.py b/src/backend/InvenTree/InvenTree/settings.py index 13da19b11b..c99da3b461 100644 --- a/src/backend/InvenTree/InvenTree/settings.py +++ b/src/backend/InvenTree/InvenTree/settings.py @@ -193,7 +193,6 @@ INSTALLED_APPS = [ 'common.apps.CommonConfig', 'company.apps.CompanyConfig', 'plugin.apps.PluginAppConfig', # Plugin app runs before all apps that depend on the isPluginRegistryLoaded function - 'label.apps.LabelConfig', 'order.apps.OrderConfig', 'part.apps.PartConfig', 'report.apps.ReportConfig', @@ -434,12 +433,7 @@ ROOT_URLCONF = 'InvenTree.urls' TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [ - BASE_DIR.joinpath('templates'), - # Allow templates in the reporting directory to be accessed - MEDIA_ROOT.joinpath('report'), - MEDIA_ROOT.joinpath('label'), - ], + 'DIRS': [BASE_DIR.joinpath('templates'), MEDIA_ROOT.joinpath('report')], 'OPTIONS': { 'context_processors': [ 'django.template.context_processors.debug', diff --git a/src/backend/InvenTree/InvenTree/tests.py b/src/backend/InvenTree/InvenTree/tests.py index b0e3e075c2..891759c885 100644 --- a/src/backend/InvenTree/InvenTree/tests.py +++ b/src/backend/InvenTree/InvenTree/tests.py @@ -1065,7 +1065,8 @@ class TestVersionNumber(TestCase): subprocess.check_output('git rev-parse --short HEAD'.split()), 'utf-8' ).strip() - self.assertEqual(hash, version.inventreeCommitHash()) + # On some systems the hash is a different length, so just check the first 6 characters + self.assertEqual(hash[:6], version.inventreeCommitHash()[:6]) d = ( str(subprocess.check_output('git show -s --format=%ci'.split()), 'utf-8') diff --git a/src/backend/InvenTree/InvenTree/urls.py b/src/backend/InvenTree/InvenTree/urls.py index bd97236ca6..e9cd4ead4c 100644 --- a/src/backend/InvenTree/InvenTree/urls.py +++ b/src/backend/InvenTree/InvenTree/urls.py @@ -21,7 +21,6 @@ from sesame.views import LoginView import build.api import common.api import company.api -import label.api import machine.api import order.api import part.api @@ -104,7 +103,7 @@ apipatterns = [ path('stock/', include(stock.api.stock_api_urls)), path('build/', include(build.api.build_api_urls)), path('order/', include(order.api.order_api_urls)), - path('label/', include(label.api.label_api_urls)), + path('label/', include(report.api.label_api_urls)), path('report/', include(report.api.report_api_urls)), path('machine/', include(machine.api.machine_api_urls)), path('user/', include(users.api.user_urls)), diff --git a/src/backend/InvenTree/build/admin.py b/src/backend/InvenTree/build/admin.py index b3d14c6ec6..1a12166fa4 100644 --- a/src/backend/InvenTree/build/admin.py +++ b/src/backend/InvenTree/build/admin.py @@ -18,7 +18,7 @@ class BuildResource(InvenTreeResource): # TODO: 2022-05-12 - Need to investigate why this is the case! class Meta: - """Metaclass options""" + """Metaclass options.""" models = Build skip_unchanged = True report_skipped = False diff --git a/src/backend/InvenTree/build/api.py b/src/backend/InvenTree/build/api.py index 09926ffb23..e47011ae8b 100644 --- a/src/backend/InvenTree/build/api.py +++ b/src/backend/InvenTree/build/api.py @@ -30,7 +30,7 @@ class BuildFilter(rest_filters.FilterSet): """Custom filterset for BuildList API endpoint.""" class Meta: - """Metaclass options""" + """Metaclass options.""" model = Build fields = [ 'parent', diff --git a/src/backend/InvenTree/build/migrations/0043_buildline.py b/src/backend/InvenTree/build/migrations/0043_buildline.py index 8da86bc015..981b533649 100644 --- a/src/backend/InvenTree/build/migrations/0043_buildline.py +++ b/src/backend/InvenTree/build/migrations/0043_buildline.py @@ -23,6 +23,7 @@ class Migration(migrations.Migration): ], options={ 'unique_together': {('build', 'bom_item')}, + 'verbose_name': 'Build Order Line Item', }, ), ] diff --git a/src/backend/InvenTree/build/models.py b/src/backend/InvenTree/build/models.py index bf878a91bf..7257cafda3 100644 --- a/src/backend/InvenTree/build/models.py +++ b/src/backend/InvenTree/build/models.py @@ -38,6 +38,7 @@ from common.notifications import trigger_notification, InvenTreeNotificationBodi from plugin.events import trigger_event import part.models +import report.mixins import stock.models import users.models @@ -45,7 +46,14 @@ import users.models logger = logging.getLogger('inventree') -class Build(InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNotesMixin, InvenTree.models.MetadataMixin, InvenTree.models.PluginValidationMixin, InvenTree.models.ReferenceIndexingMixin, MPTTModel): +class Build( + report.mixins.InvenTreeReportMixin, + InvenTree.models.InvenTreeBarcodeMixin, + InvenTree.models.InvenTreeNotesMixin, + InvenTree.models.MetadataMixin, + InvenTree.models.PluginValidationMixin, + InvenTree.models.ReferenceIndexingMixin, + MPTTModel): """A Build object organises the creation of new StockItem objects from other existing StockItem objects. Attributes: @@ -139,6 +147,21 @@ class Build(InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNo 'part': _('Build order part cannot be changed') }) + def report_context(self) -> dict: + """Generate custom report context data.""" + + return { + 'bom_items': self.part.get_bom_items(), + 'build': self, + 'build_outputs': self.build_outputs.all(), + 'line_items': self.build_lines.all(), + 'part': self.part, + 'quantity': self.quantity, + 'reference': self.reference, + 'title': str(self) + } + + @staticmethod def filterByDate(queryset, min_date, max_date): """Filter by 'minimum and maximum date range'. @@ -1291,7 +1314,7 @@ class BuildOrderAttachment(InvenTree.models.InvenTreeAttachment): build = models.ForeignKey(Build, on_delete=models.CASCADE, related_name='attachments') -class BuildLine(InvenTree.models.InvenTreeModel): +class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeModel): """A BuildLine object links a BOMItem to a Build. When a new Build is created, the BuildLine objects are created automatically. @@ -1308,7 +1331,8 @@ class BuildLine(InvenTree.models.InvenTreeModel): """ class Meta: - """Model meta options""" + """Model meta options.""" + verbose_name = _('Build Order Line Item') unique_together = [ ('build', 'bom_item'), ] @@ -1318,6 +1342,19 @@ class BuildLine(InvenTree.models.InvenTreeModel): """Return the API URL used to access this model""" return reverse('api-build-line-list') + def report_context(self): + """Generate custom report context for this BuildLine object.""" + + return { + 'allocated_quantity': self.allocated_quantity, + 'allocations': self.allocations, + 'bom_item': self.bom_item, + 'build': self.build, + 'build_line': self, + 'part': self.bom_item.sub_part, + 'quantity': self.quantity, + } + build = models.ForeignKey( Build, on_delete=models.CASCADE, related_name='build_lines', help_text=_('Build object') @@ -1384,7 +1421,7 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel): """ class Meta: - """Model meta options""" + """Model meta options.""" unique_together = [ ('build_line', 'stock_item', 'install_into'), ] diff --git a/src/backend/InvenTree/build/templates/build/build_base.html b/src/backend/InvenTree/build/templates/build/build_base.html index 4ead85bca8..8254673fc7 100644 --- a/src/backend/InvenTree/build/templates/build/build_base.html +++ b/src/backend/InvenTree/build/templates/build/build_base.html @@ -257,11 +257,7 @@ src="{% static 'img/blank_image.png' %}" {% if report_enabled %} $('#print-build-report').click(function() { - printReports({ - items: [{{ build.pk }}], - key: 'build', - url: '{% url "api-build-report-list" %}', - }); + printReports('build', [{{ build.pk }}]); }); {% endif %} diff --git a/src/backend/InvenTree/common/tasks.py b/src/backend/InvenTree/common/tasks.py index 92a666cfe7..ffb67311b9 100644 --- a/src/backend/InvenTree/common/tasks.py +++ b/src/backend/InvenTree/common/tasks.py @@ -55,7 +55,7 @@ def update_news_feed(): # Fetch and parse feed try: - feed = requests.get(settings.INVENTREE_NEWS_URL) + feed = requests.get(settings.INVENTREE_NEWS_URL, timeout=30) d = feedparser.parse(feed.content) except Exception: # pragma: no cover logger.warning('update_news_feed: Error parsing the newsfeed') diff --git a/src/backend/InvenTree/generic/templating/__init__.py b/src/backend/InvenTree/generic/templating/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/backend/InvenTree/generic/templating/apps.py b/src/backend/InvenTree/generic/templating/apps.py deleted file mode 100644 index 1fde7e47e9..0000000000 --- a/src/backend/InvenTree/generic/templating/apps.py +++ /dev/null @@ -1,134 +0,0 @@ -"""Shared templating code.""" - -import logging -import warnings -from pathlib import Path - -from django.core.exceptions import AppRegistryNotReady -from django.core.files.storage import default_storage -from django.db.utils import IntegrityError, OperationalError, ProgrammingError - -from maintenance_mode.core import maintenance_mode_on, set_maintenance_mode - -import InvenTree.helpers -from InvenTree.config import ensure_dir - -logger = logging.getLogger('inventree') - - -class TemplatingMixin: - """Mixin that contains shared templating code.""" - - name: str = '' - db: str = '' - - def __init__(self, *args, **kwargs): - """Ensure that the required properties are set.""" - super().__init__(*args, **kwargs) - if self.name == '': - raise NotImplementedError('ref must be set') - if self.db == '': - raise NotImplementedError('db must be set') - - def create_defaults(self): - """Function that creates all default templates for the app.""" - raise NotImplementedError('create_defaults must be implemented') - - def get_src_dir(self, ref_name): - """Get the source directory for the default templates.""" - raise NotImplementedError('get_src_dir must be implemented') - - def get_new_obj_data(self, data, filename): - """Get the data for a new template db object.""" - raise NotImplementedError('get_new_obj_data must be implemented') - - # Standardized code - def ready(self): - """This function is called whenever the app is loaded.""" - import InvenTree.ready - - # skip loading if plugin registry is not loaded or we run in a background thread - if ( - not InvenTree.ready.isPluginRegistryLoaded() - or not InvenTree.ready.isInMainThread() - ): - return - - if not InvenTree.ready.canAppAccessDatabase(allow_test=False): - return # pragma: no cover - - with maintenance_mode_on(): - try: - self.create_defaults() - except ( - AppRegistryNotReady, - IntegrityError, - OperationalError, - ProgrammingError, - ): - # Database might not yet be ready - warnings.warn( - f'Database was not ready for creating {self.name}s', stacklevel=2 - ) - - set_maintenance_mode(False) - - def create_template_dir(self, model, data): - """Create folder and database entries for the default templates, if they do not already exist.""" - ref_name = model.getSubdir() - - # Create root dir for templates - src_dir = self.get_src_dir(ref_name) - ensure_dir(Path(self.name, 'inventree', ref_name), default_storage) - - # Copy each template across (if required) - for entry in data: - self.create_template_file(model, src_dir, entry, ref_name) - - def create_template_file(self, model, src_dir, data, ref_name): - """Ensure a label template is in place.""" - # Destination filename - filename = Path(self.name, 'inventree', ref_name, data['file']) - src_file = src_dir.joinpath(data['file']) - - do_copy = False - - if not default_storage.exists(filename): - logger.info("%s template '%s' is not present", self.name, filename) - do_copy = True - else: - # Check if the file contents are different - src_hash = InvenTree.helpers.hash_file(src_file) - dst_hash = InvenTree.helpers.hash_file(filename, default_storage) - - if src_hash != dst_hash: - logger.info("Hash differs for '%s'", filename) - do_copy = True - - if do_copy: - logger.info("Copying %s template '%s'", self.name, filename) - # Ensure destination dir exists - ensure_dir(filename.parent, default_storage) - - # Copy file - default_storage.save(filename, src_file.open('rb')) - - # Check if a file matching the template already exists - try: - if model.objects.filter(**{self.db: filename}).exists(): - return # pragma: no cover - except Exception: - logger.exception( - "Failed to query %s for '%s' - you should run 'invoke update' first!", - self.name, - filename, - ) - - logger.info("Creating entry for %s '%s'", model, data.get('name')) - - try: - model.objects.create(**self.get_new_obj_data(data, str(filename))) - except Exception as _e: - logger.warning( - "Failed to create %s '%s' with error '%s'", self.name, data['name'], _e - ) diff --git a/src/backend/InvenTree/label/__init__.py b/src/backend/InvenTree/label/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/backend/InvenTree/label/admin.py b/src/backend/InvenTree/label/admin.py deleted file mode 100644 index fd11629134..0000000000 --- a/src/backend/InvenTree/label/admin.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Admin functionality for the 'label' app.""" - -from django.contrib import admin - -import label.models - - -class LabelAdmin(admin.ModelAdmin): - """Admin class for the various label models.""" - - list_display = ('name', 'description', 'label', 'filters', 'enabled') - - -admin.site.register(label.models.StockItemLabel, LabelAdmin) -admin.site.register(label.models.StockLocationLabel, LabelAdmin) -admin.site.register(label.models.PartLabel, LabelAdmin) -admin.site.register(label.models.BuildLineLabel, LabelAdmin) diff --git a/src/backend/InvenTree/label/api.py b/src/backend/InvenTree/label/api.py deleted file mode 100644 index 9cf252b2eb..0000000000 --- a/src/backend/InvenTree/label/api.py +++ /dev/null @@ -1,504 +0,0 @@ -"""API functionality for the 'label' app.""" - -from django.core.exceptions import FieldError, ValidationError -from django.http import JsonResponse -from django.urls import include, path, re_path -from django.utils.decorators import method_decorator -from django.utils.translation import gettext_lazy as _ -from django.views.decorators.cache import cache_page, never_cache - -from django_filters.rest_framework import DjangoFilterBackend -from rest_framework import serializers -from rest_framework.exceptions import NotFound -from rest_framework.request import clone_request - -import build.models -import common.models -import InvenTree.exceptions -import InvenTree.helpers -import label.models -import label.serializers -from InvenTree.api import MetadataView -from InvenTree.filters import InvenTreeSearchFilter -from InvenTree.mixins import ListCreateAPI, RetrieveAPI, RetrieveUpdateDestroyAPI -from part.models import Part -from plugin.builtin.labels.inventree_label import InvenTreeLabelPlugin -from plugin.registry import registry -from stock.models import StockItem, StockLocation - - -class LabelFilterMixin: - """Mixin for filtering a queryset by a list of object ID values. - - Each implementing class defines a database model to lookup, - and a "key" (query parameter) for providing a list of ID (PK) values. - - This mixin defines a 'get_items' method which provides a generic - implementation to return a list of matching database model instances. - """ - - # Database model for instances to actually be "printed" against this label template - ITEM_MODEL = None - - # Default key for looking up database model instances - ITEM_KEY = 'item' - - def get_items(self): - """Return a list of database objects from query parameter.""" - ids = [] - - # Construct a list of possible query parameter value options - # e.g. if self.ITEM_KEY = 'part' -> ['part', 'part[]', 'parts', parts[]'] - for k in [self.ITEM_KEY + x for x in ['', '[]', 's', 's[]']]: - if ids := self.request.query_params.getlist(k, []): - # Return the first list of matches - break - - # Next we must validate each provided object ID - valid_ids = [] - - for id in ids: - try: - valid_ids.append(int(id)) - except ValueError: - pass - - # Filter queryset by matching ID values - return self.ITEM_MODEL.objects.filter(pk__in=valid_ids) - - -class LabelListView(LabelFilterMixin, ListCreateAPI): - """Generic API class for label templates.""" - - def filter_queryset(self, queryset): - """Filter the queryset based on the provided label ID values. - - As each 'label' instance may optionally define its own filters, - the resulting queryset is the 'union' of the two. - """ - queryset = super().filter_queryset(queryset) - - items = self.get_items() - - if len(items) > 0: - """ - At this point, we are basically forced to be inefficient, - as we need to compare the 'filters' string of each label, - and see if it matches against each of the requested items. - - TODO: In the future, if this becomes excessively slow, it - will need to be readdressed. - """ - valid_label_ids = set() - - for lbl in queryset.all(): - matches = True - - try: - filters = InvenTree.helpers.validateFilterString(lbl.filters) - except ValidationError: - continue - - for item in items: - item_query = self.ITEM_MODEL.objects.filter(pk=item.pk) - - try: - if not item_query.filter(**filters).exists(): - matches = False - break - except FieldError: - matches = False - break - - # Matched all items - if matches: - valid_label_ids.add(lbl.pk) - else: - continue - - # Reduce queryset to only valid matches - queryset = queryset.filter(pk__in=list(valid_label_ids)) - - return queryset - - filter_backends = [DjangoFilterBackend, InvenTreeSearchFilter] - - filterset_fields = ['enabled'] - - search_fields = ['name', 'description'] - - -@method_decorator(cache_page(5), name='dispatch') -class LabelPrintMixin(LabelFilterMixin): - """Mixin for printing labels.""" - - rolemap = {'GET': 'view', 'POST': 'view'} - - def check_permissions(self, request): - """Override request method to GET so that also non superusers can print using a post request.""" - if request.method == 'POST': - request = clone_request(request, 'GET') - return super().check_permissions(request) - - @method_decorator(never_cache) - def dispatch(self, *args, **kwargs): - """Prevent caching when printing report templates.""" - return super().dispatch(*args, **kwargs) - - def get_serializer(self, *args, **kwargs): - """Define a get_serializer method to be discoverable by the OPTIONS request.""" - # Check the request to determine if the user has selected a label printing plugin - plugin = self.get_plugin(self.request) - - kwargs.setdefault('context', self.get_serializer_context()) - serializer = plugin.get_printing_options_serializer( - self.request, *args, **kwargs - ) - - # if no serializer is defined, return an empty serializer - if not serializer: - return serializers.Serializer() - - return serializer - - def get(self, request, *args, **kwargs): - """Perform a GET request against this endpoint to print labels.""" - common.models.InvenTreeUserSetting.set_setting( - 'DEFAULT_' + self.ITEM_KEY.upper() + '_LABEL_TEMPLATE', - self.get_object().pk, - None, - user=request.user, - ) - return self.print(request, self.get_items()) - - def post(self, request, *args, **kwargs): - """Perform a GET request against this endpoint to print labels.""" - return self.get(request, *args, **kwargs) - - def get_plugin(self, request): - """Return the label printing plugin associated with this request. - - This is provided in the url, e.g. ?plugin=myprinter - - Requires: - - settings.PLUGINS_ENABLED is True - - matching plugin can be found - - matching plugin implements the 'labels' mixin - - matching plugin is enabled - """ - plugin_key = request.query_params.get('plugin', None) - - # No plugin provided! - if plugin_key is None: - # Default to the builtin label printing plugin - plugin_key = InvenTreeLabelPlugin.NAME.lower() - - plugin = registry.get_plugin(plugin_key) - - if not plugin: - raise NotFound(f"Plugin '{plugin_key}' not found") - - if not plugin.is_active(): - raise ValidationError(f"Plugin '{plugin_key}' is not enabled") - - if not plugin.mixin_enabled('labels'): - raise ValidationError( - f"Plugin '{plugin_key}' is not a label printing plugin" - ) - - # Only return the plugin if it is enabled and has the label printing mixin - return plugin - - def print(self, request, items_to_print): - """Print this label template against a number of pre-validated items.""" - # Check the request to determine if the user has selected a label printing plugin - plugin = self.get_plugin(request) - - if len(items_to_print) == 0: - # No valid items provided, return an error message - raise ValidationError('No valid objects provided to label template') - - # Label template - label = self.get_object() - - # Check the label dimensions - if label.width <= 0 or label.height <= 0: - raise ValidationError('Label has invalid dimensions') - - # if the plugin returns a serializer, validate the data - if serializer := plugin.get_printing_options_serializer( - request, data=request.data, context=self.get_serializer_context() - ): - serializer.is_valid(raise_exception=True) - - # At this point, we offload the label(s) to the selected plugin. - # The plugin is responsible for handling the request and returning a response. - - try: - result = plugin.print_labels( - label, - items_to_print, - request, - printing_options=(serializer.data if serializer else {}), - ) - except ValidationError as e: - raise (e) - except Exception as e: - raise ValidationError([_('Error printing label'), str(e)]) - - if isinstance(result, JsonResponse): - result['plugin'] = plugin.plugin_slug() - return result - raise ValidationError( - f"Plugin '{plugin.plugin_slug()}' returned invalid response type '{type(result)}'" - ) - - -class StockItemLabelMixin: - """Mixin for StockItemLabel endpoints.""" - - queryset = label.models.StockItemLabel.objects.all() - serializer_class = label.serializers.StockItemLabelSerializer - - ITEM_MODEL = StockItem - ITEM_KEY = 'item' - - -class StockItemLabelList(StockItemLabelMixin, LabelListView): - """API endpoint for viewing list of StockItemLabel objects. - - Filterable by: - - - enabled: Filter by enabled / disabled status - - item: Filter by single stock item - - items: Filter by list of stock items - """ - - pass - - -class StockItemLabelDetail(StockItemLabelMixin, RetrieveUpdateDestroyAPI): - """API endpoint for a single StockItemLabel object.""" - - pass - - -class StockItemLabelPrint(StockItemLabelMixin, LabelPrintMixin, RetrieveAPI): - """API endpoint for printing a StockItemLabel object.""" - - pass - - -class StockLocationLabelMixin: - """Mixin for StockLocationLabel endpoints.""" - - queryset = label.models.StockLocationLabel.objects.all() - serializer_class = label.serializers.StockLocationLabelSerializer - - ITEM_MODEL = StockLocation - ITEM_KEY = 'location' - - -class StockLocationLabelList(StockLocationLabelMixin, LabelListView): - """API endpoint for viewiing list of StockLocationLabel objects. - - Filterable by: - - - enabled: Filter by enabled / disabled status - - location: Filter by a single stock location - - locations: Filter by list of stock locations - """ - - pass - - -class StockLocationLabelDetail(StockLocationLabelMixin, RetrieveUpdateDestroyAPI): - """API endpoint for a single StockLocationLabel object.""" - - pass - - -class StockLocationLabelPrint(StockLocationLabelMixin, LabelPrintMixin, RetrieveAPI): - """API endpoint for printing a StockLocationLabel object.""" - - pass - - -class PartLabelMixin: - """Mixin for PartLabel endpoints.""" - - queryset = label.models.PartLabel.objects.all() - serializer_class = label.serializers.PartLabelSerializer - - ITEM_MODEL = Part - ITEM_KEY = 'part' - - -class PartLabelList(PartLabelMixin, LabelListView): - """API endpoint for viewing list of PartLabel objects.""" - - pass - - -class PartLabelDetail(PartLabelMixin, RetrieveUpdateDestroyAPI): - """API endpoint for a single PartLabel object.""" - - pass - - -class PartLabelPrint(PartLabelMixin, LabelPrintMixin, RetrieveAPI): - """API endpoint for printing a PartLabel object.""" - - pass - - -class BuildLineLabelMixin: - """Mixin class for BuildLineLabel endpoints.""" - - queryset = label.models.BuildLineLabel.objects.all() - serializer_class = label.serializers.BuildLineLabelSerializer - - ITEM_MODEL = build.models.BuildLine - ITEM_KEY = 'line' - - -class BuildLineLabelList(BuildLineLabelMixin, LabelListView): - """API endpoint for viewing a list of BuildLineLabel objects.""" - - pass - - -class BuildLineLabelDetail(BuildLineLabelMixin, RetrieveUpdateDestroyAPI): - """API endpoint for a single BuildLineLabel object.""" - - pass - - -class BuildLineLabelPrint(BuildLineLabelMixin, LabelPrintMixin, RetrieveAPI): - """API endpoint for printing a BuildLineLabel object.""" - - pass - - -label_api_urls = [ - # Stock item labels - path( - 'stock/', - include([ - # Detail views - path( - '/', - include([ - re_path( - r'print/?', - StockItemLabelPrint.as_view(), - name='api-stockitem-label-print', - ), - path( - 'metadata/', - MetadataView.as_view(), - {'model': label.models.StockItemLabel}, - name='api-stockitem-label-metadata', - ), - path( - '', - StockItemLabelDetail.as_view(), - name='api-stockitem-label-detail', - ), - ]), - ), - # List view - path('', StockItemLabelList.as_view(), name='api-stockitem-label-list'), - ]), - ), - # Stock location labels - path( - 'location/', - include([ - # Detail views - path( - '/', - include([ - re_path( - r'print/?', - StockLocationLabelPrint.as_view(), - name='api-stocklocation-label-print', - ), - path( - 'metadata/', - MetadataView.as_view(), - {'model': label.models.StockLocationLabel}, - name='api-stocklocation-label-metadata', - ), - path( - '', - StockLocationLabelDetail.as_view(), - name='api-stocklocation-label-detail', - ), - ]), - ), - # List view - path( - '', - StockLocationLabelList.as_view(), - name='api-stocklocation-label-list', - ), - ]), - ), - # Part labels - path( - 'part/', - include([ - # Detail views - path( - '/', - include([ - re_path( - r'print/?', - PartLabelPrint.as_view(), - name='api-part-label-print', - ), - path( - 'metadata/', - MetadataView.as_view(), - {'model': label.models.PartLabel}, - name='api-part-label-metadata', - ), - path('', PartLabelDetail.as_view(), name='api-part-label-detail'), - ]), - ), - # List view - path('', PartLabelList.as_view(), name='api-part-label-list'), - ]), - ), - # BuildLine labels - path( - 'buildline/', - include([ - # Detail views - path( - '/', - include([ - re_path( - r'print/?', - BuildLineLabelPrint.as_view(), - name='api-buildline-label-print', - ), - path( - 'metadata/', - MetadataView.as_view(), - {'model': label.models.BuildLineLabel}, - name='api-buildline-label-metadata', - ), - path( - '', - BuildLineLabelDetail.as_view(), - name='api-buildline-label-detail', - ), - ]), - ), - # List view - path('', BuildLineLabelList.as_view(), name='api-buildline-label-list'), - ]), - ), -] diff --git a/src/backend/InvenTree/label/apps.py b/src/backend/InvenTree/label/apps.py deleted file mode 100644 index 583d2a2591..0000000000 --- a/src/backend/InvenTree/label/apps.py +++ /dev/null @@ -1,107 +0,0 @@ -"""Config options for the label app.""" - -from pathlib import Path - -from django.apps import AppConfig - -from generic.templating.apps import TemplatingMixin - - -class LabelConfig(TemplatingMixin, AppConfig): - """Configuration class for the "label" app.""" - - name = 'label' - db = 'label' - - def create_defaults(self): - """Create all default templates.""" - # Test if models are ready - try: - import label.models - except Exception: # pragma: no cover - # Database is not ready yet - return - assert bool(label.models.StockLocationLabel is not None) - - # Create the categories - self.create_template_dir( - label.models.StockItemLabel, - [ - { - 'file': 'qr.html', - 'name': 'QR Code', - 'description': 'Simple QR code label', - 'width': 24, - 'height': 24, - } - ], - ) - - self.create_template_dir( - label.models.StockLocationLabel, - [ - { - 'file': 'qr.html', - 'name': 'QR Code', - 'description': 'Simple QR code label', - 'width': 24, - 'height': 24, - }, - { - 'file': 'qr_and_text.html', - 'name': 'QR and text', - 'description': 'Label with QR code and name of location', - 'width': 50, - 'height': 24, - }, - ], - ) - - self.create_template_dir( - label.models.PartLabel, - [ - { - 'file': 'part_label.html', - 'name': 'Part Label', - 'description': 'Simple part label', - 'width': 70, - 'height': 24, - }, - { - 'file': 'part_label_code128.html', - 'name': 'Barcode Part Label', - 'description': 'Simple part label with Code128 barcode', - 'width': 70, - 'height': 24, - }, - ], - ) - - self.create_template_dir( - label.models.BuildLineLabel, - [ - { - 'file': 'buildline_label.html', - 'name': 'Build Line Label', - 'description': 'Example build line label', - 'width': 125, - 'height': 48, - } - ], - ) - - def get_src_dir(self, ref_name): - """Get the source directory.""" - return Path(__file__).parent.joinpath('templates', self.name, ref_name) - - def get_new_obj_data(self, data, filename): - """Get the data for a new template db object.""" - return { - 'name': data['name'], - 'description': data['description'], - 'label': filename, - 'filters': '', - 'enabled': True, - 'width': data['width'], - 'height': data['height'], - } diff --git a/src/backend/InvenTree/label/migrations/0001_initial.py b/src/backend/InvenTree/label/migrations/0001_initial.py deleted file mode 100644 index e960bcef67..0000000000 --- a/src/backend/InvenTree/label/migrations/0001_initial.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 3.0.7 on 2020-08-15 23:27 - -import InvenTree.helpers -import django.core.validators -from django.db import migrations, models -import label.models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ] - - operations = [ - migrations.CreateModel( - name='StockItemLabel', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(help_text='Label name', max_length=100, unique=True)), - ('description', models.CharField(blank=True, help_text='Label description', max_length=250, null=True)), - ('label', models.FileField(help_text='Label template file', upload_to=label.models.rename_label, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html'])])), - ('filters', models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs', max_length=250, validators=[InvenTree.helpers.validateFilterString])), - ], - options={ - 'abstract': False, - }, - ), - ] diff --git a/src/backend/InvenTree/label/migrations/0002_stockitemlabel_enabled.py b/src/backend/InvenTree/label/migrations/0002_stockitemlabel_enabled.py deleted file mode 100644 index 684299e184..0000000000 --- a/src/backend/InvenTree/label/migrations/0002_stockitemlabel_enabled.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.0.7 on 2020-08-22 23:04 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('label', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='stockitemlabel', - name='enabled', - field=models.BooleanField(default=True, help_text='Label template is enabled', verbose_name='Enabled'), - ), - ] diff --git a/src/backend/InvenTree/label/migrations/0003_stocklocationlabel.py b/src/backend/InvenTree/label/migrations/0003_stocklocationlabel.py deleted file mode 100644 index d15fcfa396..0000000000 --- a/src/backend/InvenTree/label/migrations/0003_stocklocationlabel.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 3.0.7 on 2021-01-08 12:06 - -import InvenTree.helpers -import django.core.validators -from django.db import migrations, models -import label.models - - -class Migration(migrations.Migration): - - dependencies = [ - ('label', '0002_stockitemlabel_enabled'), - ] - - operations = [ - migrations.CreateModel( - name='StockLocationLabel', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(help_text='Label name', max_length=100, unique=True)), - ('description', models.CharField(blank=True, help_text='Label description', max_length=250, null=True)), - ('label', models.FileField(help_text='Label template file', upload_to=label.models.rename_label, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html'])])), - ('filters', models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs', max_length=250, validators=[InvenTree.helpers.validateFilterString])), - ('enabled', models.BooleanField(default=True, help_text='Label template is enabled', verbose_name='Enabled')), - ], - options={ - 'abstract': False, - }, - ), - ] diff --git a/src/backend/InvenTree/label/migrations/0004_auto_20210111_2302.py b/src/backend/InvenTree/label/migrations/0004_auto_20210111_2302.py deleted file mode 100644 index 5194a4bda1..0000000000 --- a/src/backend/InvenTree/label/migrations/0004_auto_20210111_2302.py +++ /dev/null @@ -1,56 +0,0 @@ -# Generated by Django 3.0.7 on 2021-01-11 12:02 - -import InvenTree.helpers -import django.core.validators -from django.db import migrations, models -import label.models - - -class Migration(migrations.Migration): - - dependencies = [ - ('label', '0003_stocklocationlabel'), - ] - - operations = [ - migrations.AlterField( - model_name='stockitemlabel', - name='description', - field=models.CharField(blank=True, help_text='Label description', max_length=250, null=True, verbose_name='Description'), - ), - migrations.AlterField( - model_name='stockitemlabel', - name='filters', - field=models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs', max_length=250, validators=[InvenTree.helpers.validateFilterString], verbose_name='Filters'), - ), - migrations.AlterField( - model_name='stockitemlabel', - name='label', - field=models.FileField(help_text='Label template file', unique=True, upload_to=label.models.rename_label, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html'])], verbose_name='Label'), - ), - migrations.AlterField( - model_name='stockitemlabel', - name='name', - field=models.CharField(help_text='Label name', max_length=100, verbose_name='Name'), - ), - migrations.AlterField( - model_name='stocklocationlabel', - name='description', - field=models.CharField(blank=True, help_text='Label description', max_length=250, null=True, verbose_name='Description'), - ), - migrations.AlterField( - model_name='stocklocationlabel', - name='filters', - field=models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs', max_length=250, validators=[InvenTree.helpers.validateFilterString], verbose_name='Filters'), - ), - migrations.AlterField( - model_name='stocklocationlabel', - name='label', - field=models.FileField(help_text='Label template file', unique=True, upload_to=label.models.rename_label, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html'])], verbose_name='Label'), - ), - migrations.AlterField( - model_name='stocklocationlabel', - name='name', - field=models.CharField(help_text='Label name', max_length=100, verbose_name='Name'), - ), - ] diff --git a/src/backend/InvenTree/label/migrations/0005_auto_20210113_2302.py b/src/backend/InvenTree/label/migrations/0005_auto_20210113_2302.py deleted file mode 100644 index ad256412ac..0000000000 --- a/src/backend/InvenTree/label/migrations/0005_auto_20210113_2302.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 3.0.7 on 2021-01-13 12:02 - -from django.db import migrations, models -import label.models - - -class Migration(migrations.Migration): - - dependencies = [ - ('label', '0004_auto_20210111_2302'), - ] - - operations = [ - migrations.AlterField( - model_name='stockitemlabel', - name='filters', - field=models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs', max_length=250, validators=[label.models.validate_stock_item_filters], verbose_name='Filters'), - ), - migrations.AlterField( - model_name='stocklocationlabel', - name='filters', - field=models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs', max_length=250, validators=[label.models.validate_stock_location_filters], verbose_name='Filters'), - ), - ] diff --git a/src/backend/InvenTree/label/migrations/0006_auto_20210222_1535.py b/src/backend/InvenTree/label/migrations/0006_auto_20210222_1535.py deleted file mode 100644 index ea3441b64f..0000000000 --- a/src/backend/InvenTree/label/migrations/0006_auto_20210222_1535.py +++ /dev/null @@ -1,34 +0,0 @@ -# Generated by Django 3.0.7 on 2021-02-22 04:35 - -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('label', '0005_auto_20210113_2302'), - ] - - operations = [ - migrations.AddField( - model_name='stockitemlabel', - name='height', - field=models.FloatField(default=20, help_text='Label height, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Height [mm]'), - ), - migrations.AddField( - model_name='stockitemlabel', - name='width', - field=models.FloatField(default=50, help_text='Label width, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Width [mm]'), - ), - migrations.AddField( - model_name='stocklocationlabel', - name='height', - field=models.FloatField(default=20, help_text='Label height, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Height [mm]'), - ), - migrations.AddField( - model_name='stocklocationlabel', - name='width', - field=models.FloatField(default=50, help_text='Label width, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Width [mm]'), - ), - ] diff --git a/src/backend/InvenTree/label/migrations/0007_auto_20210513_1327.py b/src/backend/InvenTree/label/migrations/0007_auto_20210513_1327.py deleted file mode 100644 index d49c83c92b..0000000000 --- a/src/backend/InvenTree/label/migrations/0007_auto_20210513_1327.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 3.2 on 2021-05-13 03:27 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('label', '0006_auto_20210222_1535'), - ] - - operations = [ - migrations.AddField( - model_name='stockitemlabel', - name='filename_pattern', - field=models.CharField(default='label.pdf', help_text='Pattern for generating label filenames', max_length=100, verbose_name='Filename Pattern'), - ), - migrations.AddField( - model_name='stocklocationlabel', - name='filename_pattern', - field=models.CharField(default='label.pdf', help_text='Pattern for generating label filenames', max_length=100, verbose_name='Filename Pattern'), - ), - ] diff --git a/src/backend/InvenTree/label/migrations/0008_auto_20210708_2106.py b/src/backend/InvenTree/label/migrations/0008_auto_20210708_2106.py deleted file mode 100644 index ea57526909..0000000000 --- a/src/backend/InvenTree/label/migrations/0008_auto_20210708_2106.py +++ /dev/null @@ -1,37 +0,0 @@ -# Generated by Django 3.2.4 on 2021-07-08 11:06 - -import django.core.validators -from django.db import migrations, models -import label.models - - -class Migration(migrations.Migration): - - dependencies = [ - ('label', '0007_auto_20210513_1327'), - ] - - operations = [ - migrations.CreateModel( - name='PartLabel', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(help_text='Label name', max_length=100, verbose_name='Name')), - ('description', models.CharField(blank=True, help_text='Label description', max_length=250, null=True, verbose_name='Description')), - ('label', models.FileField(help_text='Label template file', unique=True, upload_to=label.models.rename_label, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html'])], verbose_name='Label')), - ('enabled', models.BooleanField(default=True, help_text='Label template is enabled', verbose_name='Enabled')), - ('width', models.FloatField(default=50, help_text='Label width, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Width [mm]')), - ('height', models.FloatField(default=20, help_text='Label height, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Height [mm]')), - ('filename_pattern', models.CharField(default='label.pdf', help_text='Pattern for generating label filenames', max_length=100, verbose_name='Filename Pattern')), - ('filters', models.CharField(blank=True, help_text='Part query filters (comma-separated value of key=value pairs)', max_length=250, validators=[label.models.validate_part_filters], verbose_name='Filters')), - ], - options={ - 'abstract': False, - }, - ), - migrations.AlterField( - model_name='stockitemlabel', - name='filters', - field=models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs),', max_length=250, validators=[label.models.validate_stock_item_filters], verbose_name='Filters'), - ), - ] diff --git a/src/backend/InvenTree/label/migrations/0009_auto_20230317_0816.py b/src/backend/InvenTree/label/migrations/0009_auto_20230317_0816.py deleted file mode 100644 index 16b81a5f7f..0000000000 --- a/src/backend/InvenTree/label/migrations/0009_auto_20230317_0816.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 3.2.18 on 2023-03-17 08:16 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('label', '0008_auto_20210708_2106'), - ] - - operations = [ - migrations.AddField( - model_name='partlabel', - name='metadata', - field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'), - ), - migrations.AddField( - model_name='stockitemlabel', - name='metadata', - field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'), - ), - migrations.AddField( - model_name='stocklocationlabel', - name='metadata', - field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'), - ), - ] diff --git a/src/backend/InvenTree/label/migrations/0010_buildlinelabel.py b/src/backend/InvenTree/label/migrations/0010_buildlinelabel.py deleted file mode 100644 index 329c274367..0000000000 --- a/src/backend/InvenTree/label/migrations/0010_buildlinelabel.py +++ /dev/null @@ -1,33 +0,0 @@ -# Generated by Django 3.2.19 on 2023-06-13 11:10 - -import django.core.validators -from django.db import migrations, models -import label.models - - -class Migration(migrations.Migration): - - dependencies = [ - ('label', '0009_auto_20230317_0816'), - ] - - operations = [ - migrations.CreateModel( - name='BuildLineLabel', - 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='Label name', max_length=100, verbose_name='Name')), - ('description', models.CharField(blank=True, help_text='Label description', max_length=250, null=True, verbose_name='Description')), - ('label', models.FileField(help_text='Label template file', unique=True, upload_to=label.models.rename_label, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html'])], verbose_name='Label')), - ('enabled', models.BooleanField(default=True, help_text='Label template is enabled', verbose_name='Enabled')), - ('width', models.FloatField(default=50, help_text='Label width, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Width [mm]')), - ('height', models.FloatField(default=20, help_text='Label height, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Height [mm]')), - ('filename_pattern', models.CharField(default='label.pdf', help_text='Pattern for generating label filenames', max_length=100, verbose_name='Filename Pattern')), - ('filters', models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs)', max_length=250, validators=[label.models.validate_build_line_filters], verbose_name='Filters')), - ], - options={ - 'abstract': False, - }, - ), - ] diff --git a/src/backend/InvenTree/label/migrations/0011_auto_20230623_2158.py b/src/backend/InvenTree/label/migrations/0011_auto_20230623_2158.py deleted file mode 100644 index 764925fc07..0000000000 --- a/src/backend/InvenTree/label/migrations/0011_auto_20230623_2158.py +++ /dev/null @@ -1,29 +0,0 @@ -# Generated by Django 3.2.19 on 2023-06-23 21:58 - -from django.db import migrations, models -import label.models - - -class Migration(migrations.Migration): - - dependencies = [ - ('label', '0010_buildlinelabel'), - ] - - operations = [ - migrations.AlterField( - model_name='partlabel', - name='filters', - field=models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs)', max_length=250, validators=[label.models.validate_part_filters], verbose_name='Filters'), - ), - migrations.AlterField( - model_name='stockitemlabel', - name='filters', - field=models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs)', max_length=250, validators=[label.models.validate_stock_item_filters], verbose_name='Filters'), - ), - migrations.AlterField( - model_name='stocklocationlabel', - name='filters', - field=models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs)', max_length=250, validators=[label.models.validate_stock_location_filters], verbose_name='Filters'), - ), - ] diff --git a/src/backend/InvenTree/label/migrations/0012_labeloutput.py b/src/backend/InvenTree/label/migrations/0012_labeloutput.py deleted file mode 100644 index 3a69fb9a9b..0000000000 --- a/src/backend/InvenTree/label/migrations/0012_labeloutput.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 3.2.20 on 2023-07-14 11:55 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion -import label.models - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('label', '0011_auto_20230623_2158'), - ] - - operations = [ - migrations.CreateModel( - name='LabelOutput', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('label', models.FileField(unique=True, upload_to=label.models.rename_label_output)), - ('created', models.DateField(auto_now_add=True)), - ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), - ], - ), - ] diff --git a/src/backend/InvenTree/label/migrations/__init__.py b/src/backend/InvenTree/label/migrations/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/backend/InvenTree/label/models.py b/src/backend/InvenTree/label/models.py deleted file mode 100644 index 17cc252afb..0000000000 --- a/src/backend/InvenTree/label/models.py +++ /dev/null @@ -1,429 +0,0 @@ -"""Label printing models.""" - -import logging -import os -import sys - -from django.conf import settings -from django.contrib.auth.models import User -from django.core.validators import FileExtensionValidator, MinValueValidator -from django.db import models -from django.template import Context, Template -from django.template.loader import render_to_string -from django.urls import reverse -from django.utils.translation import gettext_lazy as _ - -import build.models -import InvenTree.helpers -import InvenTree.models -import part.models -import stock.models -from InvenTree.helpers import normalize, validateFilterString -from InvenTree.helpers_model import get_base_url -from plugin.registry import registry - -try: - from django_weasyprint import WeasyTemplateResponseMixin -except OSError as err: # pragma: no cover - print(f'OSError: {err}') - print('You may require some further system packages to be installed.') - sys.exit(1) - - -logger = logging.getLogger('inventree') - - -def rename_label(instance, filename): - """Place the label file into the correct subdirectory.""" - filename = os.path.basename(filename) - - return os.path.join('label', 'template', instance.SUBDIR, filename) - - -def rename_label_output(instance, filename): - """Place the label output file into the correct subdirectory.""" - filename = os.path.basename(filename) - - return os.path.join('label', 'output', filename) - - -def validate_stock_item_filters(filters): - """Validate query filters for the StockItemLabel model.""" - filters = validateFilterString(filters, model=stock.models.StockItem) - - return filters - - -def validate_stock_location_filters(filters): - """Validate query filters for the StockLocationLabel model.""" - filters = validateFilterString(filters, model=stock.models.StockLocation) - - return filters - - -def validate_part_filters(filters): - """Validate query filters for the PartLabel model.""" - filters = validateFilterString(filters, model=part.models.Part) - - return filters - - -def validate_build_line_filters(filters): - """Validate query filters for the BuildLine model.""" - filters = validateFilterString(filters, model=build.models.BuildLine) - - return filters - - -class WeasyprintLabelMixin(WeasyTemplateResponseMixin): - """Class for rendering a label to a PDF.""" - - pdf_filename = 'label.pdf' - pdf_attachment = True - - def __init__(self, request, template, **kwargs): - """Initialize a label mixin with certain properties.""" - self.request = request - self.template_name = template - self.pdf_filename = kwargs.get('filename', 'label.pdf') - - -class LabelTemplate(InvenTree.models.InvenTreeMetadataModel): - """Base class for generic, filterable labels.""" - - class Meta: - """Metaclass options. Abstract ensures no database table is created.""" - - abstract = True - - @classmethod - def getSubdir(cls) -> str: - """Return the subdirectory for this label.""" - return cls.SUBDIR - - # Each class of label files will be stored in a separate subdirectory - SUBDIR: str = 'label' - - # Object we will be printing against (will be filled out later) - object_to_print = None - - @property - def template(self): - """Return the file path of the template associated with this label instance.""" - return self.label.path - - def __str__(self): - """Format a string representation of a label instance.""" - return f'{self.name} - {self.description}' - - name = models.CharField( - blank=False, max_length=100, verbose_name=_('Name'), help_text=_('Label name') - ) - - description = models.CharField( - max_length=250, - blank=True, - null=True, - verbose_name=_('Description'), - help_text=_('Label description'), - ) - - label = models.FileField( - upload_to=rename_label, - unique=True, - blank=False, - null=False, - verbose_name=_('Label'), - help_text=_('Label template file'), - validators=[FileExtensionValidator(allowed_extensions=['html'])], - ) - - enabled = models.BooleanField( - default=True, - verbose_name=_('Enabled'), - help_text=_('Label template is enabled'), - ) - - width = models.FloatField( - default=50, - verbose_name=_('Width [mm]'), - help_text=_('Label width, specified in mm'), - validators=[MinValueValidator(2)], - ) - - height = models.FloatField( - default=20, - verbose_name=_('Height [mm]'), - help_text=_('Label height, specified in mm'), - validators=[MinValueValidator(2)], - ) - - filename_pattern = models.CharField( - default='label.pdf', - verbose_name=_('Filename Pattern'), - help_text=_('Pattern for generating label filenames'), - max_length=100, - ) - - @property - def template_name(self): - """Returns the file system path to the template file. - - Required for passing the file to an external process - """ - template = self.label.name - template = template.replace('/', os.path.sep) - template = template.replace('\\', os.path.sep) - - template = settings.MEDIA_ROOT.joinpath(template) - - return template - - def get_context_data(self, request): - """Supply custom context data to the template for rendering. - - Note: Override this in any subclass - """ - return {} # pragma: no cover - - def generate_filename(self, request, **kwargs): - """Generate a filename for this label.""" - template_string = Template(self.filename_pattern) - - ctx = self.context(request) - - context = Context(ctx) - - return template_string.render(context) - - def generate_page_style(self, **kwargs): - """Generate @page style for the label template. - - This is inserted at the top of the style block for a given label - """ - width = kwargs.get('width', self.width) - height = kwargs.get('height', self.height) - margin = kwargs.get('margin', 0) - - return f""" - @page {{ - size: {width}mm {height}mm; - margin: {margin}mm; - }} - """ - - def context(self, request, **kwargs): - """Provides context data to the template. - - Arguments: - request: The HTTP request object - kwargs: Additional keyword arguments - """ - context = self.get_context_data(request) - - # By default, each label is supplied with '@page' data - # However, it can be excluded, e.g. when rendering a label sheet - if kwargs.get('insert_page_style', True): - context['page_style'] = self.generate_page_style() - - # Add "basic" context data which gets passed to every label - context['base_url'] = get_base_url(request=request) - context['date'] = InvenTree.helpers.current_date() - context['datetime'] = InvenTree.helpers.current_time() - context['request'] = request - context['user'] = request.user - context['width'] = self.width - context['height'] = self.height - - # Pass the context through to any registered plugins - plugins = registry.with_mixin('report') - - for plugin in plugins: - # Let each plugin add its own context data - plugin.add_label_context(self, self.object_to_print, request, context) - - return context - - def render_as_string(self, request, target_object=None, **kwargs): - """Render the label to a HTML string.""" - if target_object: - self.object_to_print = target_object - - context = self.context(request, **kwargs) - - return render_to_string(self.template_name, context, request) - - def render(self, request, target_object=None, **kwargs): - """Render the label template to a PDF file. - - Uses django-weasyprint plugin to render HTML template - """ - if target_object: - self.object_to_print = target_object - - context = self.context(request, **kwargs) - - wp = WeasyprintLabelMixin( - request, - self.template_name, - base_url=request.build_absolute_uri('/'), - presentational_hints=True, - filename=self.generate_filename(request), - **kwargs, - ) - - return wp.render_to_response(context, **kwargs) - - -class LabelOutput(models.Model): - """Class representing a label output file. - - 'Printing' a label may generate a file object (such as PDF) - which is made available for download. - - Future work will offload this task to the background worker, - and provide a 'progress' bar for the user. - """ - - # File will be stored in a subdirectory - label = models.FileField( - upload_to=rename_label_output, unique=True, blank=False, null=False - ) - - # Creation date of label output - created = models.DateField(auto_now_add=True, editable=False) - - # User who generated the label - user = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True) - - -class StockItemLabel(LabelTemplate): - """Template for printing StockItem labels.""" - - @staticmethod - def get_api_url(): - """Return the API URL associated with the StockItemLabel model.""" - return reverse('api-stockitem-label-list') # pragma: no cover - - SUBDIR = 'stockitem' - - filters = models.CharField( - blank=True, - max_length=250, - help_text=_('Query filters (comma-separated list of key=value pairs)'), - verbose_name=_('Filters'), - validators=[validate_stock_item_filters], - ) - - def get_context_data(self, request): - """Generate context data for each provided StockItem.""" - stock_item = self.object_to_print - - return { - 'item': stock_item, - 'part': stock_item.part, - 'name': stock_item.part.full_name, - 'ipn': stock_item.part.IPN, - 'revision': stock_item.part.revision, - 'quantity': normalize(stock_item.quantity), - 'serial': stock_item.serial, - 'barcode_data': stock_item.barcode_data, - 'barcode_hash': stock_item.barcode_hash, - 'qr_data': stock_item.format_barcode(brief=True), - 'qr_url': request.build_absolute_uri(stock_item.get_absolute_url()), - 'tests': stock_item.testResultMap(), - 'parameters': stock_item.part.parameters_map(), - } - - -class StockLocationLabel(LabelTemplate): - """Template for printing StockLocation labels.""" - - @staticmethod - def get_api_url(): - """Return the API URL associated with the StockLocationLabel model.""" - return reverse('api-stocklocation-label-list') # pragma: no cover - - SUBDIR = 'stocklocation' - - filters = models.CharField( - blank=True, - max_length=250, - help_text=_('Query filters (comma-separated list of key=value pairs)'), - verbose_name=_('Filters'), - validators=[validate_stock_location_filters], - ) - - def get_context_data(self, request): - """Generate context data for each provided StockLocation.""" - location = self.object_to_print - - return {'location': location, 'qr_data': location.format_barcode(brief=True)} - - -class PartLabel(LabelTemplate): - """Template for printing Part labels.""" - - @staticmethod - def get_api_url(): - """Return the API url associated with the PartLabel model.""" - return reverse('api-part-label-list') # pragma: no cover - - SUBDIR = 'part' - - filters = models.CharField( - blank=True, - max_length=250, - help_text=_('Query filters (comma-separated list of key=value pairs)'), - verbose_name=_('Filters'), - validators=[validate_part_filters], - ) - - def get_context_data(self, request): - """Generate context data for each provided Part object.""" - part = self.object_to_print - - return { - 'part': part, - 'category': part.category, - 'name': part.name, - 'description': part.description, - 'IPN': part.IPN, - 'revision': part.revision, - 'qr_data': part.format_barcode(brief=True), - 'qr_url': request.build_absolute_uri(part.get_absolute_url()), - 'parameters': part.parameters_map(), - } - - -class BuildLineLabel(LabelTemplate): - """Template for printing labels against BuildLine objects.""" - - @staticmethod - def get_api_url(): - """Return the API URL associated with the BuildLineLabel model.""" - return reverse('api-buildline-label-list') - - SUBDIR = 'buildline' - - filters = models.CharField( - blank=True, - max_length=250, - help_text=_('Query filters (comma-separated list of key=value pairs)'), - verbose_name=_('Filters'), - validators=[validate_build_line_filters], - ) - - def get_context_data(self, request): - """Generate context data for each provided BuildLine object.""" - build_line = self.object_to_print - - return { - 'build_line': build_line, - 'build': build_line.build, - 'bom_item': build_line.bom_item, - 'part': build_line.bom_item.sub_part, - 'quantity': build_line.quantity, - 'allocated_quantity': build_line.allocated_quantity, - 'allocations': build_line.allocations, - } diff --git a/src/backend/InvenTree/label/serializers.py b/src/backend/InvenTree/label/serializers.py deleted file mode 100644 index ef1f467937..0000000000 --- a/src/backend/InvenTree/label/serializers.py +++ /dev/null @@ -1,67 +0,0 @@ -"""API serializers for the label app.""" - -import label.models -from InvenTree.serializers import ( - InvenTreeAttachmentSerializerField, - InvenTreeModelSerializer, -) - - -class LabelSerializerBase(InvenTreeModelSerializer): - """Base class for label serializer.""" - - label = InvenTreeAttachmentSerializerField(required=True) - - @staticmethod - def label_fields(): - """Generic serializer fields for a label template.""" - return [ - 'pk', - 'name', - 'description', - 'label', - 'filters', - 'width', - 'height', - 'enabled', - ] - - -class StockItemLabelSerializer(LabelSerializerBase): - """Serializes a StockItemLabel object.""" - - class Meta: - """Metaclass options.""" - - model = label.models.StockItemLabel - fields = LabelSerializerBase.label_fields() - - -class StockLocationLabelSerializer(LabelSerializerBase): - """Serializes a StockLocationLabel object.""" - - class Meta: - """Metaclass options.""" - - model = label.models.StockLocationLabel - fields = LabelSerializerBase.label_fields() - - -class PartLabelSerializer(LabelSerializerBase): - """Serializes a PartLabel object.""" - - class Meta: - """Metaclass options.""" - - model = label.models.PartLabel - fields = LabelSerializerBase.label_fields() - - -class BuildLineLabelSerializer(LabelSerializerBase): - """Serializes a BuildLineLabel object.""" - - class Meta: - """Metaclass options.""" - - model = label.models.BuildLineLabel - fields = LabelSerializerBase.label_fields() diff --git a/src/backend/InvenTree/label/tasks.py b/src/backend/InvenTree/label/tasks.py deleted file mode 100644 index b1630f6296..0000000000 --- a/src/backend/InvenTree/label/tasks.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Background tasks for the label app.""" - -from datetime import timedelta - -from django.utils import timezone - -from InvenTree.tasks import ScheduledTask, scheduled_task -from label.models import LabelOutput - - -@scheduled_task(ScheduledTask.DAILY) -def cleanup_old_label_outputs(): - """Remove old label outputs from the database.""" - # Remove any label outputs which are older than 30 days - LabelOutput.objects.filter(created__lte=timezone.now() - timedelta(days=5)).delete() diff --git a/src/backend/InvenTree/label/templates/label/buildline/buildline_label.html b/src/backend/InvenTree/label/templates/label/buildline/buildline_label.html deleted file mode 100644 index efbb9a3db6..0000000000 --- a/src/backend/InvenTree/label/templates/label/buildline/buildline_label.html +++ /dev/null @@ -1,3 +0,0 @@ -{% extends "label/buildline/buildline_label_base.html" %} - - diff --git a/src/backend/InvenTree/label/test_api.py b/src/backend/InvenTree/label/test_api.py deleted file mode 100644 index f2e4d728ec..0000000000 --- a/src/backend/InvenTree/label/test_api.py +++ /dev/null @@ -1,301 +0,0 @@ -"""Unit tests for label API.""" - -import json -from io import StringIO - -from django.core.cache import cache -from django.urls import reverse - -import label.models as label_models -from build.models import BuildLine -from InvenTree.unit_test import InvenTreeAPITestCase -from part.models import Part -from stock.models import StockItem, StockLocation - - -class LabelTest(InvenTreeAPITestCase): - """Base class for unit testing label model API endpoints.""" - - fixtures = ['category', 'part', 'location', 'stock', 'bom', 'build'] - - superuser = True - - model = None - list_url = None - detail_url = None - metadata_url = None - - print_url = None - print_itemname = None - print_itemmodel = None - - def setUp(self): - """Ensure cache is cleared as part of test setup.""" - cache.clear() - return super().setUp() - - def test_api_url(self): - """Test returned API Url against URL tag defined in this file.""" - if not self.list_url: - return - - self.assertEqual(reverse(self.list_url), self.model.get_api_url()) - - def test_list_endpoint(self): - """Test that the LIST endpoint works for each model.""" - if not self.list_url: - return - - url = reverse(self.list_url) - - response = self.get(url) - self.assertEqual(response.status_code, 200) - - labels = self.model.objects.all() - n = len(labels) - - # API endpoint must return correct number of reports - self.assertEqual(len(response.data), n) - - # Filter by "enabled" status - response = self.get(url, {'enabled': True}) - self.assertEqual(len(response.data), n) - - response = self.get(url, {'enabled': False}) - self.assertEqual(len(response.data), 0) - - # Filter by "enabled" status - response = self.get(url, {'enabled': True}) - self.assertEqual(len(response.data), 0) - - response = self.get(url, {'enabled': False}) - self.assertEqual(len(response.data), n) - - def test_create_endpoint(self): - """Test that creating a new report works for each label.""" - if not self.list_url: - return - - url = reverse(self.list_url) - - # Create a new label - # Django REST API "APITestCase" does not work like requests - to send a file without it existing on disk, - # create it as a StringIO object, and upload it under parameter template - filestr = StringIO( - '{% extends "label/label_base.html" %}{% block content %}
TEST LABEL
{% endblock content %}' - ) - filestr.name = 'ExampleTemplate.html' - - response = self.post( - url, - data={ - 'name': 'New label', - 'description': 'A fancy new label created through API test', - 'label': filestr, - }, - format=None, - expected_code=201, - ) - - # Make sure the expected keys are in the response - self.assertIn('pk', response.data) - self.assertIn('name', response.data) - self.assertIn('description', response.data) - self.assertIn('label', response.data) - self.assertIn('filters', response.data) - self.assertIn('enabled', response.data) - - self.assertEqual(response.data['name'], 'New label') - self.assertEqual( - response.data['description'], 'A fancy new label created through API test' - ) - self.assertEqual(response.data['label'].count('ExampleTemplate'), 1) - - def test_detail_endpoint(self): - """Test that the DETAIL endpoint works for each label.""" - if not self.detail_url: - return - - # Create an item first - self.test_create_endpoint() - - labels = self.model.objects.all() - - n = len(labels) - - # Make sure at least one report defined - self.assertGreaterEqual(n, 1) - - # Check detail page for first report - response = self.get( - reverse(self.detail_url, kwargs={'pk': labels[0].pk}), expected_code=200 - ) - - # Make sure the expected keys are in the response - self.assertIn('pk', response.data) - self.assertIn('name', response.data) - self.assertIn('description', response.data) - self.assertIn('label', response.data) - self.assertIn('filters', response.data) - self.assertIn('enabled', response.data) - - filestr = StringIO( - '{% extends "label/label_base.html" %}{% block content %}
TEST LABEL
{% endblock content %}' - ) - filestr.name = 'ExampleTemplate_Updated.html' - - # Check PATCH method - response = self.patch( - reverse(self.detail_url, kwargs={'pk': labels[0].pk}), - { - 'name': 'Changed name during test', - 'description': 'New version of the template', - 'label': filestr, - }, - format=None, - expected_code=200, - ) - - # Make sure the expected keys are in the response - self.assertIn('pk', response.data) - self.assertIn('name', response.data) - self.assertIn('description', response.data) - self.assertIn('label', response.data) - self.assertIn('filters', response.data) - self.assertIn('enabled', response.data) - - self.assertEqual(response.data['name'], 'Changed name during test') - self.assertEqual(response.data['description'], 'New version of the template') - - self.assertEqual(response.data['label'].count('ExampleTemplate_Updated'), 1) - - def test_delete(self): - """Test deleting, after other test are done.""" - if not self.detail_url: - return - - # Create an item first - self.test_create_endpoint() - - labels = self.model.objects.all() - n = len(labels) - # Make sure at least one label defined - self.assertGreaterEqual(n, 1) - - # Delete the last report - self.delete( - reverse(self.detail_url, kwargs={'pk': labels[n - 1].pk}), expected_code=204 - ) - - def test_print_label(self): - """Test printing a label.""" - if not self.print_url: - return - - # Create an item first - self.test_create_endpoint() - - labels = self.model.objects.all() - n = len(labels) - # Make sure at least one label defined - self.assertGreaterEqual(n, 1) - - url = reverse(self.print_url, kwargs={'pk': labels[0].pk}) - - # Try to print without providing a valid item - self.get(url, expected_code=400) - - # Try to print with an invalid item - self.get(url, {self.print_itemname: 9999}, expected_code=400) - - # Now print with a valid item - print(f'{self.print_itemmodel = }') - print(f'{self.print_itemmodel.objects.all() = }') - - item = self.print_itemmodel.objects.first() - self.assertIsNotNone(item) - - response = self.get(url, {self.print_itemname: item.pk}, expected_code=200) - - response_json = json.loads(response.content.decode('utf-8')) - - self.assertIn('file', response_json) - self.assertIn('success', response_json) - self.assertIn('message', response_json) - self.assertTrue(response_json['success']) - - def test_metadata_endpoint(self): - """Unit tests for the metadata field.""" - if not self.metadata_url: - return - - # Create an item first - self.test_create_endpoint() - - labels = self.model.objects.all() - n = len(labels) - # Make sure at least one label defined - self.assertGreaterEqual(n, 1) - - # Test getting metadata - response = self.get( - reverse(self.metadata_url, kwargs={'pk': labels[0].pk}), expected_code=200 - ) - - self.assertEqual(response.data, {'metadata': {}}) - - -class TestStockItemLabel(LabelTest): - """Unit testing class for the StockItemLabel model.""" - - model = label_models.StockItemLabel - - list_url = 'api-stockitem-label-list' - detail_url = 'api-stockitem-label-detail' - metadata_url = 'api-stockitem-label-metadata' - - print_url = 'api-stockitem-label-print' - print_itemname = 'item' - print_itemmodel = StockItem - - -class TestStockLocationLabel(LabelTest): - """Unit testing class for the StockLocationLabel model.""" - - model = label_models.StockLocationLabel - - list_url = 'api-stocklocation-label-list' - detail_url = 'api-stocklocation-label-detail' - metadata_url = 'api-stocklocation-label-metadata' - - print_url = 'api-stocklocation-label-print' - print_itemname = 'location' - print_itemmodel = StockLocation - - -class TestPartLabel(LabelTest): - """Unit testing class for the PartLabel model.""" - - model = label_models.PartLabel - - list_url = 'api-part-label-list' - detail_url = 'api-part-label-detail' - metadata_url = 'api-part-label-metadata' - - print_url = 'api-part-label-print' - print_itemname = 'part' - print_itemmodel = Part - - -class TestBuildLineLabel(LabelTest): - """Unit testing class for the BuildLine model.""" - - model = label_models.BuildLineLabel - - list_url = 'api-buildline-label-list' - detail_url = 'api-buildline-label-detail' - metadata_url = 'api-buildline-label-metadata' - - print_url = 'api-buildline-label-print' - print_itemname = 'line' - print_itemmodel = BuildLine diff --git a/src/backend/InvenTree/label/tests.py b/src/backend/InvenTree/label/tests.py deleted file mode 100644 index 62a84c6439..0000000000 --- a/src/backend/InvenTree/label/tests.py +++ /dev/null @@ -1,166 +0,0 @@ -"""Tests for labels.""" - -import io -import json - -from django.apps import apps -from django.conf import settings -from django.core.exceptions import ValidationError -from django.core.files.base import ContentFile -from django.http import JsonResponse -from django.urls import reverse - -from common.models import InvenTreeSetting -from InvenTree.helpers import validateFilterString -from InvenTree.unit_test import InvenTreeAPITestCase -from label.models import LabelOutput -from part.models import Part -from plugin.registry import registry -from stock.models import StockItem - -from .models import PartLabel, StockItemLabel, StockLocationLabel - - -class LabelTest(InvenTreeAPITestCase): - """Unit test class for label models.""" - - fixtures = ['category', 'part', 'location', 'stock'] - - @classmethod - def setUpTestData(cls): - """Ensure that some label instances exist as part of init routine.""" - super().setUpTestData() - apps.get_app_config('label').create_defaults() - - def test_default_labels(self): - """Test that the default label templates are copied across.""" - labels = StockItemLabel.objects.all() - - self.assertGreater(labels.count(), 0) - - labels = StockLocationLabel.objects.all() - - self.assertGreater(labels.count(), 0) - - def test_default_files(self): - """Test that label files exist in the MEDIA directory.""" - - def test_subdir(ref_name): - item_dir = settings.MEDIA_ROOT.joinpath('label', 'inventree', ref_name) - self.assertGreater(len([item_dir.iterdir()]), 0) - - test_subdir('stockitem') - test_subdir('stocklocation') - test_subdir('part') - - def test_filters(self): - """Test the label filters.""" - filter_string = 'part__pk=10' - - filters = validateFilterString(filter_string, model=StockItem) - - self.assertEqual(type(filters), dict) - - bad_filter_string = 'part_pk=10' - - with self.assertRaises(ValidationError): - validateFilterString(bad_filter_string, model=StockItem) - - def test_label_rendering(self): - """Test label rendering.""" - labels = PartLabel.objects.all() - part = Part.objects.first() - - for label in labels: - url = reverse('api-part-label-print', kwargs={'pk': label.pk}) - - # Check that label printing returns the correct response type - response = self.get(f'{url}?parts={part.pk}', expected_code=200) - self.assertIsInstance(response, JsonResponse) - data = json.loads(response.content) - - self.assertIn('message', data) - self.assertIn('file', data) - label_file = data['file'] - self.assertIn('/media/label/output/', label_file) - - def test_print_part_label(self): - """Actually 'print' a label, and ensure that the correct information is contained.""" - label_data = """ - {% load barcode %} - {% load report %} - - - - part: {{ part.pk }} - {{ part.name }} - - data: {{ qr_data|safe }} - - url: {{ qr_url|safe }} - - image: {% part_image part width=128 %} - - logo: {% logo_image %} - - """ - - buffer = io.StringIO() - buffer.write(label_data) - - template = ContentFile(buffer.getvalue(), 'label.html') - - # Construct a label template - label = PartLabel.objects.create( - name='test', description='Test label', enabled=True, label=template - ) - - # Ensure we are in "debug" mode (so the report is generated as HTML) - InvenTreeSetting.set_setting('REPORT_ENABLE', True, None) - - # Set the 'debug' setting for the plugin - plugin = registry.get_plugin('inventreelabel') - plugin.set_setting('DEBUG', True) - - # Print via the API (Note: will default to the builtin plugin if no plugin supplied) - url = reverse('api-part-label-print', kwargs={'pk': label.pk}) - - prt = Part.objects.first() - part_pk = prt.pk - part_name = prt.name - - response = self.get(f'{url}?parts={part_pk}', expected_code=200) - data = json.loads(response.content) - self.assertIn('file', data) - - # Find the generated file - output = LabelOutput.objects.last() - - # Open the file and read data - with open(output.label.path, 'r') as f: - content = f.read() - - # Test that each element has been rendered correctly - self.assertIn(f'part: {part_pk} - {part_name}', content) - self.assertIn(f'data: {{"part": {part_pk}}}', content) - if settings.ENABLE_CLASSIC_FRONTEND: - self.assertIn(f'http://testserver/part/{part_pk}/', content) - - # Check that a encoded image has been generated - self.assertIn('data:image/png;charset=utf-8;base64,', content) - - def test_metadata(self): - """Unit tests for the metadata field.""" - for model in [StockItemLabel, StockLocationLabel, PartLabel]: - p = model.objects.first() - - self.assertIsNone(p.get_metadata('test')) - self.assertEqual(p.get_metadata('test', backup_value=123), 123) - - # Test update via the set_metadata() method - p.set_metadata('test', 3) - self.assertEqual(p.get_metadata('test'), 3) - - for k in ['apple', 'banana', 'carrot', 'carrot', 'banana']: - p.set_metadata(k, k) - - self.assertEqual(len(p.metadata.keys()), 4) diff --git a/src/backend/InvenTree/machine/machine_types/label_printer.py b/src/backend/InvenTree/machine/machine_types/label_printer.py index bfbff04a45..4fd7f04405 100644 --- a/src/backend/InvenTree/machine/machine_types/label_printer.py +++ b/src/backend/InvenTree/machine/machine_types/label_printer.py @@ -2,6 +2,7 @@ from typing import Union, cast +from django.db import models from django.db.models.query import QuerySet from django.http import HttpResponse, JsonResponse from django.utils.translation import gettext_lazy as _ @@ -10,10 +11,10 @@ from PIL.Image import Image from rest_framework import serializers from rest_framework.request import Request -from label.models import LabelTemplate from machine.machine_type import BaseDriver, BaseMachineType, MachineStatus from plugin import registry as plg_registry -from plugin.base.label.mixins import LabelItemType, LabelPrintingMixin +from plugin.base.label.mixins import LabelPrintingMixin +from report.models import LabelTemplate from stock.models import StockLocation @@ -32,7 +33,7 @@ class LabelPrinterBaseDriver(BaseDriver): self, machine: 'LabelPrinterMachine', label: LabelTemplate, - item: LabelItemType, + item: models.Model, request: Request, **kwargs, ) -> None: @@ -56,7 +57,7 @@ class LabelPrinterBaseDriver(BaseDriver): self, machine: 'LabelPrinterMachine', label: LabelTemplate, - items: QuerySet[LabelItemType], + items: QuerySet, request: Request, **kwargs, ) -> Union[None, JsonResponse]: @@ -83,7 +84,7 @@ class LabelPrinterBaseDriver(BaseDriver): self.print_label(machine, label, item, request, **kwargs) def get_printers( - self, label: LabelTemplate, items: QuerySet[LabelItemType], **kwargs + self, label: LabelTemplate, items: QuerySet, **kwargs ) -> list['LabelPrinterMachine']: """Get all printers that would be available to print this job. @@ -122,7 +123,7 @@ class LabelPrinterBaseDriver(BaseDriver): return cast(LabelPrintingMixin, plg) def render_to_pdf( - self, label: LabelTemplate, item: LabelItemType, request: Request, **kwargs + self, label: LabelTemplate, item: models.Model, request: Request, **kwargs ) -> HttpResponse: """Helper method to render a label to PDF format for a specific item. @@ -137,7 +138,7 @@ class LabelPrinterBaseDriver(BaseDriver): return response def render_to_pdf_data( - self, label: LabelTemplate, item: LabelItemType, request: Request, **kwargs + self, label: LabelTemplate, item: models.Model, request: Request, **kwargs ) -> bytes: """Helper method to render a label to PDF and return it as bytes for a specific item. @@ -153,7 +154,7 @@ class LabelPrinterBaseDriver(BaseDriver): ) def render_to_html( - self, label: LabelTemplate, item: LabelItemType, request: Request, **kwargs + self, label: LabelTemplate, item: models.Model, request: Request, **kwargs ) -> str: """Helper method to render a label to HTML format for a specific item. @@ -168,7 +169,7 @@ class LabelPrinterBaseDriver(BaseDriver): return html def render_to_png( - self, label: LabelTemplate, item: LabelItemType, request: Request, **kwargs + self, label: LabelTemplate, item: models.Model, request: Request, **kwargs ) -> Image: """Helper method to render a label to PNG format for a specific item. diff --git a/src/backend/InvenTree/machine/tests.py b/src/backend/InvenTree/machine/tests.py index ef37f5d5b3..992ca3fe0e 100755 --- a/src/backend/InvenTree/machine/tests.py +++ b/src/backend/InvenTree/machine/tests.py @@ -10,7 +10,6 @@ from django.urls import reverse from rest_framework import serializers from InvenTree.unit_test import InvenTreeAPITestCase -from label.models import PartLabel from machine.machine_type import BaseDriver, BaseMachineType, MachineStatus from machine.machine_types.label_printer import LabelPrinterBaseDriver from machine.models import MachineConfig @@ -18,6 +17,7 @@ from machine.registry import registry from part.models import Part from plugin.models import PluginConfig from plugin.registry import registry as plg_registry +from report.models import LabelTemplate class TestMachineRegistryMixin(TestCase): @@ -247,31 +247,33 @@ class TestLabelPrinterMachineType(TestMachineRegistryMixin, InvenTreeAPITestCase plugin_ref = 'inventreelabelmachine' # setup the label app - apps.get_app_config('label').create_defaults() # type: ignore + apps.get_app_config('report').create_default_labels() # type: ignore plg_registry.reload_plugins() config = cast(PluginConfig, plg_registry.get_plugin(plugin_ref).plugin_config()) # type: ignore config.active = True config.save() parts = Part.objects.all()[:2] - label = cast(PartLabel, PartLabel.objects.first()) + template = LabelTemplate.objects.filter(enabled=True, model_type='part').first() - url = reverse('api-part-label-print', kwargs={'pk': label.pk}) - url += f'/?plugin={plugin_ref}&part[]={parts[0].pk}&part[]={parts[1].pk}' + url = reverse('api-label-print') self.post( url, { + 'plugin': config.key, + 'items': [a.pk for a in parts], + 'template': template.pk, 'machine': str(self.machine.pk), 'driver_options': {'copies': '1', 'test_option': '2'}, }, - expected_code=200, + expected_code=201, ) # test the print labels method call self.print_labels.assert_called_once() self.assertEqual(self.print_labels.call_args.args[0], self.machine.machine) - self.assertEqual(self.print_labels.call_args.args[1], label) + self.assertEqual(self.print_labels.call_args.args[1], template) # TODO re-activate test # self.assertQuerySetEqual( diff --git a/src/backend/InvenTree/order/migrations/0001_initial.py b/src/backend/InvenTree/order/migrations/0001_initial.py index 642b321d47..edceeffc11 100644 --- a/src/backend/InvenTree/order/migrations/0001_initial.py +++ b/src/backend/InvenTree/order/migrations/0001_initial.py @@ -30,6 +30,7 @@ class Migration(migrations.Migration): ], options={ 'abstract': False, + 'verbose_name': 'Purchase Order' }, ), migrations.CreateModel( diff --git a/src/backend/InvenTree/order/migrations/0020_auto_20200420_0940.py b/src/backend/InvenTree/order/migrations/0020_auto_20200420_0940.py index aef57a3437..76e903b45a 100644 --- a/src/backend/InvenTree/order/migrations/0020_auto_20200420_0940.py +++ b/src/backend/InvenTree/order/migrations/0020_auto_20200420_0940.py @@ -35,6 +35,7 @@ class Migration(migrations.Migration): ], options={ 'abstract': False, + 'verbose_name': 'Sales Order', }, ), migrations.AlterField( diff --git a/src/backend/InvenTree/order/migrations/0081_auto_20230314_0725.py b/src/backend/InvenTree/order/migrations/0081_auto_20230314_0725.py index 719a7a5037..f15b800ffe 100644 --- a/src/backend/InvenTree/order/migrations/0081_auto_20230314_0725.py +++ b/src/backend/InvenTree/order/migrations/0081_auto_20230314_0725.py @@ -39,6 +39,7 @@ class Migration(migrations.Migration): ], options={ 'abstract': False, + 'verbose_name': 'Return Order', }, ), migrations.AlterField( diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index 8eae8562ed..b149f31503 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -30,6 +30,7 @@ import InvenTree.ready import InvenTree.tasks import InvenTree.validators import order.validators +import report.mixins import stock.models import users.models as UserModels from common.notifications import InvenTreeNotificationBodies @@ -185,6 +186,7 @@ class Order( StateTransitionMixin, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNotesMixin, + report.mixins.InvenTreeReportMixin, InvenTree.models.MetadataMixin, InvenTree.models.ReferenceIndexingMixin, InvenTree.models.InvenTreeModel, @@ -246,6 +248,17 @@ class Order( 'contact': _('Contact does not match selected company') }) + def report_context(self): + """Generate context data for the reporting interface.""" + return { + 'description': self.description, + 'extra_lines': self.extra_lines, + 'lines': self.lines, + 'order': self, + 'reference': self.reference, + 'title': str(self), + } + @classmethod def overdue_filter(cls): """A generic implementation of an 'overdue' filter for the Model class. @@ -362,6 +375,15 @@ class PurchaseOrder(TotalPriceMixin, Order): REFERENCE_PATTERN_SETTING = 'PURCHASEORDER_REFERENCE_PATTERN' REQUIRE_RESPONSIBLE_SETTING = 'PURCHASEORDER_REQUIRE_RESPONSIBLE' + class Meta: + """Model meta options.""" + + verbose_name = _('Purchase Order') + + def report_context(self): + """Return report context data for this PurchaseOrder.""" + return {**super().report_context(), 'supplier': self.supplier} + def get_absolute_url(self): """Get the 'web' URL for this order.""" if settings.ENABLE_CLASSIC_FRONTEND: @@ -820,6 +842,15 @@ class SalesOrder(TotalPriceMixin, Order): REFERENCE_PATTERN_SETTING = 'SALESORDER_REFERENCE_PATTERN' REQUIRE_RESPONSIBLE_SETTING = 'SALESORDER_REQUIRE_RESPONSIBLE' + class Meta: + """Model meta options.""" + + verbose_name = _('Sales Order') + + def report_context(self): + """Generate report context data for this SalesOrder.""" + return {**super().report_context(), 'customer': self.customer} + def get_absolute_url(self): """Get the 'web' URL for this order.""" if settings.ENABLE_CLASSIC_FRONTEND: @@ -1977,6 +2008,15 @@ class ReturnOrder(TotalPriceMixin, Order): REFERENCE_PATTERN_SETTING = 'RETURNORDER_REFERENCE_PATTERN' REQUIRE_RESPONSIBLE_SETTING = 'RETURNORDER_REQUIRE_RESPONSIBLE' + class Meta: + """Model meta options.""" + + verbose_name = _('Return Order') + + def report_context(self): + """Generate report context data for this ReturnOrder.""" + return {**super().report_context(), 'customer': self.customer} + def get_absolute_url(self): """Get the 'web' URL for this order.""" if settings.ENABLE_CLASSIC_FRONTEND: diff --git a/src/backend/InvenTree/order/templates/order/order_base.html b/src/backend/InvenTree/order/templates/order/order_base.html index 173eaeb119..af422e6794 100644 --- a/src/backend/InvenTree/order/templates/order/order_base.html +++ b/src/backend/InvenTree/order/templates/order/order_base.html @@ -253,11 +253,7 @@ $("#place-order").click(function() { {% if report_enabled %} $('#print-order-report').click(function() { - printReports({ - items: [{{ order.pk }}], - key: 'order', - url: '{% url "api-po-report-list" %}', - }); + printReports('purchaseorder', [{{ order.pk }}]); }); {% endif %} diff --git a/src/backend/InvenTree/order/templates/order/return_order_base.html b/src/backend/InvenTree/order/templates/order/return_order_base.html index eb80a70d53..32ccd23f85 100644 --- a/src/backend/InvenTree/order/templates/order/return_order_base.html +++ b/src/backend/InvenTree/order/templates/order/return_order_base.html @@ -248,11 +248,7 @@ $('#cancel-order').click(function() { {% if report_enabled %} $('#print-order-report').click(function() { - printReports({ - items: [{{ order.pk }}], - key: 'order', - url: '{% url "api-return-order-report-list" %}', - }); + printReports('returnorder', [{{ order.pk }}]); }); {% endif %} diff --git a/src/backend/InvenTree/order/templates/order/sales_order_base.html b/src/backend/InvenTree/order/templates/order/sales_order_base.html index e1ed302804..6bfb9b0989 100644 --- a/src/backend/InvenTree/order/templates/order/sales_order_base.html +++ b/src/backend/InvenTree/order/templates/order/sales_order_base.html @@ -310,11 +310,7 @@ $("#complete-order").click(function() { {% if report_enabled %} $('#print-order-report').click(function() { - printReports({ - items: [{{ order.pk }}], - key: 'order', - url: '{% url "api-so-report-list" %}', - }); + printReports('salesorder', [{{ order.pk }}]); }); {% endif %} diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py index 80254c61cf..6364683c5d 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -7,7 +7,7 @@ import hashlib import logging import os import re -from datetime import datetime, timedelta +from datetime import timedelta from decimal import Decimal, InvalidOperation from django.conf import settings @@ -43,6 +43,7 @@ import InvenTree.ready import InvenTree.tasks import part.helpers as part_helpers import part.settings as part_settings +import report.mixins import users.models from build import models as BuildModels from common.models import InvenTreeSetting @@ -340,6 +341,7 @@ class PartManager(TreeManager): class Part( InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNotesMixin, + report.mixins.InvenTreeReportMixin, InvenTree.models.MetadataMixin, InvenTree.models.PluginValidationMixin, MPTTModel, @@ -409,8 +411,28 @@ class Part( """Return API query filters for limiting field results against this instance.""" return {'variant_of': {'exclude_tree': self.pk}} + def report_context(self): + """Return custom report context information.""" + return { + 'bom_items': self.get_bom_items(), + 'category': self.category, + 'description': self.description, + 'IPN': self.IPN, + 'name': self.name, + 'parameters': self.parameters_map(), + 'part': self, + 'qr_data': self.format_barcode(brief=True), + 'qr_url': self.get_absolute_url(), + 'revision': self.revision, + 'test_template_list': self.getTestTemplates(), + 'test_templates': self.getTestTemplates(), + } + def get_context_data(self, request, **kwargs): - """Return some useful context data about this part for template rendering.""" + """Return some useful context data about this part for template rendering. + + TODO: 2024-04-21 - Remove this method once the legacy UI code is removed + """ context = {} context['disabled'] = not self.active diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index 7bed55d416..e2182b1635 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -432,6 +432,11 @@ class DuplicatePartSerializer(serializers.Serializer): The fields in this serializer control how the Part is duplicated. """ + class Meta: + """Metaclass options.""" + + fields = ['part', 'copy_image', 'copy_bom', 'copy_parameters', 'copy_notes'] + part = serializers.PrimaryKeyRelatedField( queryset=Part.objects.all(), label=_('Original Part'), @@ -471,6 +476,11 @@ class DuplicatePartSerializer(serializers.Serializer): class InitialStockSerializer(serializers.Serializer): """Serializer for creating initial stock quantity.""" + class Meta: + """Metaclass options.""" + + fields = ['quantity', 'location'] + quantity = serializers.DecimalField( max_digits=15, decimal_places=5, @@ -494,6 +504,11 @@ class InitialStockSerializer(serializers.Serializer): class InitialSupplierSerializer(serializers.Serializer): """Serializer for adding initial supplier / manufacturer information.""" + class Meta: + """Metaclass options.""" + + fields = ['supplier', 'sku', 'manufacturer', 'mpn'] + supplier = serializers.PrimaryKeyRelatedField( queryset=company.models.Company.objects.all(), label=_('Supplier'), diff --git a/src/backend/InvenTree/part/templates/part/detail.html b/src/backend/InvenTree/part/templates/part/detail.html index 4cf36dc387..1e0c8d4718 100644 --- a/src/backend/InvenTree/part/templates/part/detail.html +++ b/src/backend/InvenTree/part/templates/part/detail.html @@ -629,11 +629,7 @@ {% if report_enabled %} $("#print-bom-report").click(function() { - printReports({ - items: [{{ part.pk }}], - key: 'part', - url: '{% url "api-bom-report-list" %}' - }); + printReports('part', [{{ part.pk }}]); }); {% endif %} }); diff --git a/src/backend/InvenTree/part/templates/part/part_base.html b/src/backend/InvenTree/part/templates/part/part_base.html index 16ce73b291..3b6b57542d 100644 --- a/src/backend/InvenTree/part/templates/part/part_base.html +++ b/src/backend/InvenTree/part/templates/part/part_base.html @@ -468,9 +468,8 @@ $('#print-label').click(function() { printLabels({ items: [{{ part.pk }}], - key: 'part', + model_type: 'part', singular_name: 'part', - url: '{% url "api-part-label-list" %}', }); }); {% endif %} diff --git a/src/backend/InvenTree/plugin/base/integration/ReportMixin.py b/src/backend/InvenTree/plugin/base/integration/ReportMixin.py index 1dd2a7ae2f..1c81df722f 100644 --- a/src/backend/InvenTree/plugin/base/integration/ReportMixin.py +++ b/src/backend/InvenTree/plugin/base/integration/ReportMixin.py @@ -47,3 +47,16 @@ class ReportMixin: context: The context dictionary to add to """ pass + + def report_callback(self, template, instance, report, request): + """Callback function called after a report is generated. + + Arguments: + template: The ReportTemplate model + instance: The instance of the target model + report: The generated report object + request: The initiating request object + + The default implementation does nothing. + """ + pass diff --git a/src/backend/InvenTree/plugin/base/label/mixins.py b/src/backend/InvenTree/plugin/base/label/mixins.py index 736d431d00..68236ec29f 100644 --- a/src/backend/InvenTree/plugin/base/label/mixins.py +++ b/src/backend/InvenTree/plugin/base/label/mixins.py @@ -11,17 +11,12 @@ import pdf2image from rest_framework import serializers from rest_framework.request import Request -from build.models import BuildLine from common.models import InvenTreeSetting from InvenTree.exceptions import log_error from InvenTree.tasks import offload_task -from label.models import LabelTemplate -from part.models import Part from plugin.base.label import label as plugin_label from plugin.helpers import MixinNotImplementedError -from stock.models import StockItem, StockLocation - -LabelItemType = Union[StockItem, StockLocation, Part, BuildLine] +from report.models import LabelTemplate, TemplateOutput class LabelPrintingMixin: @@ -34,11 +29,6 @@ class LabelPrintingMixin: Note that the print_labels() function can also be overridden to provide custom behavior. """ - # If True, the print_label() method will block until the label is printed - # If False, the offload_label() method will be called instead - # By default, this is False, which means that labels will be printed in the background - BLOCKING_PRINT = False - class MixinMeta: """Meta options for this mixin.""" @@ -49,37 +39,42 @@ class LabelPrintingMixin: super().__init__() self.add_mixin('labels', True, __class__) - def render_to_pdf(self, label: LabelTemplate, request, **kwargs): + BLOCKING_PRINT = True + + def render_to_pdf(self, label: LabelTemplate, instance, request, **kwargs): """Render this label to PDF format. Arguments: - label: The LabelTemplate object to render + label: The LabelTemplate object to render against + instance: The model instance to render request: The HTTP request object which triggered this print job """ try: - return label.render(request) + return label.render(instance, request) except Exception: log_error('label.render_to_pdf') raise ValidationError(_('Error rendering label to PDF')) - def render_to_html(self, label: LabelTemplate, request, **kwargs): + def render_to_html(self, label: LabelTemplate, instance, request, **kwargs): """Render this label to HTML format. Arguments: - label: The LabelTemplate object to render + label: The LabelTemplate object to render against + instance: The model instance to render request: The HTTP request object which triggered this print job """ try: - return label.render_as_string(request) + return label.render_as_string(instance, request) except Exception: log_error('label.render_to_html') raise ValidationError(_('Error rendering label to HTML')) - def render_to_png(self, label: LabelTemplate, request=None, **kwargs): + def render_to_png(self, label: LabelTemplate, instance, request=None, **kwargs): """Render this label to PNG format. Arguments: - label: The LabelTemplate object to render + label: The LabelTemplate object to render against + item: The model instance to render request: The HTTP request object which triggered this print job Keyword Arguments: pdf_data: The raw PDF data of the rendered label (if already rendered) @@ -94,7 +89,9 @@ class LabelPrintingMixin: if not pdf_data: pdf_data = ( - self.render_to_pdf(label, request, **kwargs).get_document().write_pdf() + self.render_to_pdf(label, instance, request, **kwargs) + .get_document() + .write_pdf() ) pdf2image_kwargs = { @@ -108,19 +105,21 @@ class LabelPrintingMixin: return pdf2image.convert_from_bytes(pdf_data, **pdf2image_kwargs)[0] except Exception: log_error('label.render_to_png') - raise ValidationError(_('Error rendering label to PNG')) + return None def print_labels( self, label: LabelTemplate, - items: QuerySet[LabelItemType], + output: TemplateOutput, + items: list, request: Request, **kwargs, - ): + ) -> None: """Print one or more labels with the provided template and items. Arguments: label: The LabelTemplate object to use for printing + output: The TemplateOutput object used to store the results items: The list of database items to print (e.g. StockItem instances) request: The HTTP request object which triggered this print job @@ -128,7 +127,10 @@ class LabelPrintingMixin: printing_options: The printing options set for this print job defined in the PrintingOptionsSerializer Returns: - A JSONResponse object which indicates outcome to the user + None. Output data should be stored in the provided TemplateOutput object + + Raises: + ValidationError if there is an error during the print process The default implementation simply calls print_label() for each label, producing multiple single label output "jobs" but this can be overridden by the particular plugin. @@ -138,19 +140,30 @@ class LabelPrintingMixin: except AttributeError: user = None + # Initial state for the output print job + output.progress = 0 + output.complete = False + output.save() + + N = len(items) + # Generate a label output for each provided item for item in items: - label.object_to_print = item - filename = label.generate_filename(request) - pdf_file = self.render_to_pdf(label, request, **kwargs) + context = label.get_context(item, request) + filename = label.generate_filename(context) + pdf_file = self.render_to_pdf(label, item, request, **kwargs) pdf_data = pdf_file.get_document().write_pdf() - png_file = self.render_to_png(label, request, pdf_data=pdf_data, **kwargs) + png_file = self.render_to_png( + label, item, request, pdf_data=pdf_data, **kwargs + ) print_args = { 'pdf_file': pdf_file, 'pdf_data': pdf_data, 'png_file': png_file, 'filename': filename, + 'context': context, + 'output': output, 'label_instance': label, 'item_instance': item, 'user': user, @@ -160,19 +173,34 @@ class LabelPrintingMixin: } if self.BLOCKING_PRINT: - # Blocking print job + # Print the label (blocking) self.print_label(**print_args) else: - # Non-blocking print job + # Offload the print task to the background worker + # Exclude the 'pdf_file' object - cannot be pickled - # Offload the print job to a background worker - self.offload_label(**print_args) + kwargs.pop('pdf_file', None) + offload_task(plugin_label.print_label, self.plugin_slug(), **print_args) - # Return a JSON response to the user - return JsonResponse({ - 'success': True, - 'message': f'{len(items)} labels printed', - }) + # Update the progress of the print job + output.progress += int(100 / N) + output.save() + + # Mark the output as complete + output.complete = True + output.progress = 100 + + # Add in the generated file (if applicable) + output.output = self.get_generated_file(**print_args) + + output.save() + + def get_generated_file(self, **kwargs): + """Return the generated file for download (or None, if this plugin does not generate a file output). + + The default implementation returns None, but this can be overridden by the particular plugin. + """ + return None def print_label(self, **kwargs): """Print a single label (blocking). @@ -183,6 +211,7 @@ class LabelPrintingMixin: filename: The filename of this PDF label label_instance: The instance of the label model which triggered the print_label() method item_instance: The instance of the database model against which the label is printed + output: The TemplateOutput object used to store the results of the print job user: The user who triggered this print job width: The expected width of the label (in mm) height: The expected height of the label (in mm) @@ -195,19 +224,6 @@ class LabelPrintingMixin: 'This Plugin must implement a `print_label` method' ) - def offload_label(self, **kwargs): - """Offload a single label (non-blocking). - - Instead of immediately printing the label (which is a blocking process), - this method should offload the label to a background worker process. - - Offloads a call to the 'print_label' method (of this plugin) to a background worker. - """ - # Exclude the 'pdf_file' object - cannot be pickled - kwargs.pop('pdf_file', None) - - offload_task(plugin_label.print_label, self.plugin_slug(), **kwargs) - def get_printing_options_serializer( self, request: Request, *args, **kwargs ) -> Union[serializers.Serializer, None]: @@ -227,3 +243,11 @@ class LabelPrintingMixin: return None return serializer(*args, **kwargs) + + def before_printing(self): + """Hook method called before printing labels.""" + pass + + def after_printing(self): + """Hook method called after printing labels.""" + pass diff --git a/src/backend/InvenTree/plugin/base/label/test_label_mixin.py b/src/backend/InvenTree/plugin/base/label/test_label_mixin.py index 3fa1409687..5845163e63 100644 --- a/src/backend/InvenTree/plugin/base/label/test_label_mixin.py +++ b/src/backend/InvenTree/plugin/base/label/test_label_mixin.py @@ -12,62 +12,28 @@ from PIL import Image from InvenTree.settings import BASE_DIR from InvenTree.unit_test import InvenTreeAPITestCase -from label.models import PartLabel, StockItemLabel, StockLocationLabel from part.models import Part from plugin.base.label.mixins import LabelPrintingMixin from plugin.helpers import MixinNotImplementedError from plugin.plugin import InvenTreePlugin from plugin.registry import registry +from report.models import LabelTemplate +from report.tests import PrintTestMixins from stock.models import StockItem, StockLocation -class LabelMixinTests(InvenTreeAPITestCase): +class LabelMixinTests(PrintTestMixins, InvenTreeAPITestCase): """Test that the Label mixin operates correctly.""" fixtures = ['category', 'part', 'location', 'stock'] roles = 'all' + plugin_ref = 'samplelabelprinter' - def do_activate_plugin(self): - """Activate the 'samplelabel' plugin.""" - config = registry.get_plugin('samplelabelprinter').plugin_config() - config.active = True - config.save() - - def do_url( - self, - parts, - plugin_ref, - label, - url_name: str = 'api-part-label-print', - url_single: str = 'part', - invalid: bool = False, - ): - """Generate an URL to print a label.""" - # Construct URL - kwargs = {} - if label: - kwargs['pk'] = label.pk - - url = reverse(url_name, kwargs=kwargs) - - # Append part filters - if not parts: - pass - elif len(parts) == 1: - url += f'?{url_single}={parts[0].pk}' - elif len(parts) > 1: - url += '?' + '&'.join([f'{url_single}s={item.pk}' for item in parts]) - - # Append an invalid item - if invalid: - url += f'&{url_single}{"s" if len(parts) > 1 else ""}=abc' - - # Append plugin reference - if plugin_ref: - url += f'&plugin={plugin_ref}' - - return url + @property + def printing_url(self): + """Return the label printing URL.""" + return reverse('api-label-print') def test_wrong_implementation(self): """Test that a wrong implementation raises an error.""" @@ -121,52 +87,106 @@ class LabelMixinTests(InvenTreeAPITestCase): def test_printing_process(self): """Test that a label can be printed.""" # Ensure the labels were created - apps.get_app_config('label').create_defaults() + apps.get_app_config('report').create_default_labels() + apps.get_app_config('report').create_default_reports() + + test_path = BASE_DIR / '_testfolder' / 'label' - # Lookup references - part = Part.objects.first() parts = Part.objects.all()[:2] - plugin_ref = 'samplelabelprinter' - label = PartLabel.objects.first() - url = self.do_url([part], plugin_ref, label) + template = LabelTemplate.objects.filter(enabled=True, model_type='part').first() - # Non-existing plugin - response = self.get(f'{url}123', expected_code=404) - self.assertIn( - f"Plugin '{plugin_ref}123' not found", str(response.content, 'utf8') + self.assertIsNotNone(template) + self.assertTrue(template.enabled) + + url = self.printing_url + + # Template does not exist + response = self.post( + url, {'template': 9999, 'plugin': 9999, 'items': []}, expected_code=400 ) - # Inactive plugin - response = self.get(url, expected_code=400) - self.assertIn( - f"Plugin '{plugin_ref}' is not enabled", str(response.content, 'utf8') + self.assertIn('object does not exist', str(response.data['template'])) + self.assertIn('list may not be empty', str(response.data['items'])) + + # Plugin is not a label plugin + no_valid_plg = registry.get_plugin('digikeyplugin').plugin_config() + + response = self.post( + url, + {'template': template.pk, 'plugin': no_valid_plg.key, 'items': [1, 2, 3]}, + expected_code=400, ) + self.assertIn('Plugin does not support label printing', str(response.data)) + + # Find available plugins + plugins = registry.with_mixin('labels') + self.assertGreater(len(plugins), 0) + + plugin = registry.get_plugin('samplelabelprinter') + config = plugin.plugin_config() + + # Ensure that the plugin is not active + registry.set_plugin_state(plugin.slug, False) + + # Plugin is not active - should return error + response = self.post( + url, + {'template': template.pk, 'plugin': config.key, 'items': [1, 2, 3]}, + expected_code=400, + ) + self.assertIn('Plugin is not active', str(response.data['plugin'])) + # Active plugin self.do_activate_plugin() # Print one part - self.get(url, expected_code=200) + response = self.post( + url, + {'template': template.pk, 'plugin': config.key, 'items': [parts[0].pk]}, + expected_code=201, + ) + + self.assertEqual(response.data['plugin'], 'samplelabelprinter') + self.assertIsNone(response.data['output']) # Print multiple parts - self.get(self.do_url(parts, plugin_ref, label), expected_code=200) + response = self.post( + url, + { + 'template': template.pk, + 'plugin': config.key, + 'items': [item.pk for item in parts], + }, + expected_code=201, + ) + + self.assertEqual(response.data['plugin'], 'samplelabelprinter') + self.assertIsNone(response.data['output']) # Print multiple parts without a plugin - self.get(self.do_url(parts, None, label), expected_code=200) + response = self.post( + url, + {'template': template.pk, 'items': [item.pk for item in parts]}, + expected_code=201, + ) - # Print multiple parts without a plugin in debug mode - response = self.get(self.do_url(parts, None, label), expected_code=200) + self.assertEqual(response.data['plugin'], 'inventreelabel') + self.assertIsNotNone(response.data['output']) data = json.loads(response.content) - self.assertIn('file', data) + self.assertIn('output', data) # Print no part - self.get(self.do_url(None, plugin_ref, label), expected_code=400) + self.post( + url, + {'template': template.pk, 'plugin': plugin.pk, 'items': None}, + expected_code=400, + ) # Test that the labels have been printed # The sample labelling plugin simply prints to file - test_path = BASE_DIR / '_testfolder' / 'label' self.assertTrue(os.path.exists(f'{test_path}.pdf')) # Read the raw .pdf data - ensure it contains some sensible information @@ -183,27 +203,30 @@ class LabelMixinTests(InvenTreeAPITestCase): def test_printing_options(self): """Test printing options.""" # Ensure the labels were created - apps.get_app_config('label').create_defaults() + apps.get_app_config('report').create_default_labels() # Lookup references parts = Part.objects.all()[:2] - plugin_ref = 'samplelabelprinter' - label = PartLabel.objects.first() - + template = LabelTemplate.objects.filter(enabled=True, model_type='part').first() self.do_activate_plugin() + plugin = registry.get_plugin(self.plugin_ref) # test options response options = self.options( - self.do_url(parts, plugin_ref, label), expected_code=200 + self.printing_url, data={'plugin': plugin.slug}, expected_code=200 ).json() self.assertIn('amount', options['actions']['POST']) - plg = registry.get_plugin(plugin_ref) - with mock.patch.object(plg, 'print_label') as print_label: + with mock.patch.object(plugin, 'print_label') as print_label: # wrong value type res = self.post( - self.do_url(parts, plugin_ref, label), - data={'amount': '-no-valid-int-'}, + self.printing_url, + { + 'plugin': plugin.slug, + 'template': template.pk, + 'items': [a.pk for a in parts], + 'amount': '-no-valid-int-', + }, expected_code=400, ).json() self.assertIn('amount', res) @@ -211,9 +234,14 @@ class LabelMixinTests(InvenTreeAPITestCase): # correct value type self.post( - self.do_url(parts, plugin_ref, label), - data={'amount': 13}, - expected_code=200, + self.printing_url, + { + 'template': template.pk, + 'plugin': plugin.slug, + 'items': [a.pk for a in parts], + 'amount': 13, + }, + expected_code=201, ).json() self.assertEqual( print_label.call_args.kwargs['printing_options'], {'amount': 13} @@ -221,57 +249,15 @@ class LabelMixinTests(InvenTreeAPITestCase): def test_printing_endpoints(self): """Cover the endpoints not covered by `test_printing_process`.""" - plugin_ref = 'samplelabelprinter' - # Activate the label components - apps.get_app_config('label').create_defaults() + apps.get_app_config('report').create_default_labels() self.do_activate_plugin() - def run_print_test(label, qs, url_name, url_single): - """Run tests on single and multiple page printing. + # Test StockItemLabel + self.run_print_test(StockItem, 'stockitem') - Args: - label: class of the label - qs: class of the base queryset - url_name: url for endpoints - url_single: item lookup reference - """ - label = label.objects.first() - qs = qs.objects.all() + # Test StockLocationLabel + self.run_print_test(StockLocation, 'stocklocation') - # List endpoint - self.get( - self.do_url(None, None, None, f'{url_name}-list', url_single), - expected_code=200, - ) - - # List endpoint with filter - self.get( - self.do_url( - qs[:2], None, None, f'{url_name}-list', url_single, invalid=True - ), - expected_code=200, - ) - - # Single page printing - self.get( - self.do_url(qs[:1], plugin_ref, label, f'{url_name}-print', url_single), - expected_code=200, - ) - - # Multi page printing - self.get( - self.do_url(qs[:2], plugin_ref, label, f'{url_name}-print', url_single), - expected_code=200, - ) - - # Test StockItemLabels - run_print_test(StockItemLabel, StockItem, 'api-stockitem-label', 'item') - - # Test StockLocationLabels - run_print_test( - StockLocationLabel, StockLocation, 'api-stocklocation-label', 'location' - ) - - # Test PartLabels - run_print_test(PartLabel, Part, 'api-part-label', 'part') + # Test PartLabel + self.run_print_test(Part, 'part') diff --git a/src/backend/InvenTree/plugin/builtin/labels/inventree_label.py b/src/backend/InvenTree/plugin/builtin/labels/inventree_label.py index ed2d6c70d9..07bb3c8ef1 100644 --- a/src/backend/InvenTree/plugin/builtin/labels/inventree_label.py +++ b/src/backend/InvenTree/plugin/builtin/labels/inventree_label.py @@ -1,10 +1,9 @@ """Default label printing plugin (supports PDF generation).""" from django.core.files.base import ContentFile -from django.http import JsonResponse from django.utils.translation import gettext_lazy as _ -from label.models import LabelOutput, LabelTemplate +from InvenTree.helpers import str2bool from plugin import InvenTreePlugin from plugin.mixins import LabelPrintingMixin, SettingsMixin @@ -19,7 +18,7 @@ class InvenTreeLabelPlugin(LabelPrintingMixin, SettingsMixin, InvenTreePlugin): NAME = 'InvenTreeLabel' TITLE = _('InvenTree PDF label printer') DESCRIPTION = _('Provides native support for printing PDF labels') - VERSION = '1.0.0' + VERSION = '1.1.0' AUTHOR = _('InvenTree contributors') BLOCKING_PRINT = True @@ -33,58 +32,57 @@ class InvenTreeLabelPlugin(LabelPrintingMixin, SettingsMixin, InvenTreePlugin): } } - def print_labels(self, label: LabelTemplate, items: list, request, **kwargs): - """Handle printing of multiple labels. + # Keep track of individual label outputs + # These will be stitched together at the end of printing + outputs = [] + debug = None - - Label outputs are concatenated together, and we return a single PDF file. - - If DEBUG mode is enabled, we return a single HTML file. - """ - debug = self.get_setting('DEBUG') + def before_printing(self): + """Reset the list of label outputs.""" + self.outputs = [] + self.debug = None - outputs = [] - output_file = None + def in_debug_mode(self): + """Check if the plugin is printing in debug mode.""" + if self.debug is None: + self.debug = str2bool(self.get_setting('DEBUG')) - for item in items: - label.object_to_print = item + return self.debug - outputs.append(self.print_label(label, request, debug=debug, **kwargs)) + def print_label(self, **kwargs): + """Print a single label.""" + label = kwargs['label_instance'] + instance = kwargs['item_instance'] - if self.get_setting('DEBUG'): - html = '\n'.join(outputs) - - output_file = ContentFile(html, 'labels.html') + if self.in_debug_mode(): + # In debug mode, return raw HTML output + output = self.render_to_html(label, instance, None, **kwargs) else: + # Output is already provided + output = kwargs['pdf_file'] + + self.outputs.append(output) + + def get_generated_file(self, **kwargs): + """Return the generated file, by stitching together the individual label outputs.""" + if len(self.outputs) == 0: + return None + + if self.in_debug_mode(): + # Simple HTML output + data = '\n'.join(self.outputs) + filename = 'labels.html' + else: + # Stitch together the PDF outputs pages = [] - # Following process is required to stitch labels together into a single PDF - for output in outputs: + for output in self.outputs: doc = output.get_document() for page in doc.pages: pages.append(page) - pdf = outputs[0].get_document().copy(pages).write_pdf() + data = self.outputs[0].get_document().copy(pages).write_pdf() + filename = kwargs.get('filename', 'labels.pdf') - # Create label output file - output_file = ContentFile(pdf, 'labels.pdf') - - # Save the generated file to the database - output = LabelOutput.objects.create(label=output_file, user=request.user) - - return JsonResponse({ - 'file': output.label.url, - 'success': True, - 'message': f'{len(items)} labels generated', - }) - - def print_label(self, label: LabelTemplate, request, **kwargs): - """Handle printing of a single label. - - Returns either a PDF or HTML output, depending on the DEBUG setting. - """ - debug = kwargs.get('debug', self.get_setting('DEBUG')) - - if debug: - return self.render_to_html(label, request, **kwargs) - - return self.render_to_pdf(label, request, **kwargs) + return ContentFile(data, name=filename) diff --git a/src/backend/InvenTree/plugin/builtin/labels/inventree_machine.py b/src/backend/InvenTree/plugin/builtin/labels/inventree_machine.py index 1d5d8c6651..39af2e7cca 100644 --- a/src/backend/InvenTree/plugin/builtin/labels/inventree_machine.py +++ b/src/backend/InvenTree/plugin/builtin/labels/inventree_machine.py @@ -10,11 +10,11 @@ from rest_framework import serializers from common.models import InvenTreeUserSetting from InvenTree.serializers import DependentField from InvenTree.tasks import offload_task -from label.models import LabelTemplate from machine.machine_types import LabelPrinterBaseDriver, LabelPrinterMachine from plugin import InvenTreePlugin from plugin.machine import registry from plugin.mixins import LabelPrintingMixin +from report.models import LabelTemplate def get_machine_and_driver(machine_pk: str): @@ -63,7 +63,7 @@ class InvenTreeLabelPlugin(LabelPrintingMixin, InvenTreePlugin): VERSION = '1.0.0' AUTHOR = _('InvenTree contributors') - def print_labels(self, label: LabelTemplate, items, request, **kwargs): + def print_labels(self, label: LabelTemplate, output, items, request, **kwargs): """Print labels implementation that calls the correct machine driver print_labels method.""" machine, driver = get_machine_and_driver( kwargs['printing_options'].get('machine', '') @@ -111,9 +111,10 @@ class InvenTreeLabelPlugin(LabelPrintingMixin, InvenTreePlugin): """Custom __init__ method to dynamically override the machine choices based on the request.""" super().__init__(*args, **kwargs) - view = kwargs['context']['view'] - template = view.get_object() - items_to_print = view.get_items() + # TODO @matmair Re-enable this when the need is clear + # view = kwargs['context']['view'] + template = None # view.get_object() + items_to_print = None # view.get_items() # get all available printers for each driver machines: list[LabelPrinterMachine] = [] diff --git a/src/backend/InvenTree/plugin/builtin/labels/label_sheet.py b/src/backend/InvenTree/plugin/builtin/labels/label_sheet.py index 5f6ca952e4..bf88e0f132 100644 --- a/src/backend/InvenTree/plugin/builtin/labels/label_sheet.py +++ b/src/backend/InvenTree/plugin/builtin/labels/label_sheet.py @@ -12,9 +12,9 @@ import weasyprint from rest_framework import serializers import report.helpers -from label.models import LabelOutput, LabelTemplate from plugin import InvenTreePlugin from plugin.mixins import LabelPrintingMixin, SettingsMixin +from report.models import LabelOutput, LabelTemplate logger = logging.getLogger('inventree') @@ -68,8 +68,13 @@ class InvenTreeLabelSheetPlugin(LabelPrintingMixin, SettingsMixin, InvenTreePlug PrintingOptionsSerializer = LabelPrintingOptionsSerializer - def print_labels(self, label: LabelTemplate, items: list, request, **kwargs): - """Handle printing of the provided labels.""" + def print_labels( + self, label: LabelTemplate, output: LabelOutput, items: list, request, **kwargs + ): + """Handle printing of the provided labels. + + Note that we override the entire print_labels method for this plugin. + """ printing_options = kwargs['printing_options'] # Extract page size for the label sheet @@ -134,15 +139,10 @@ class InvenTreeLabelSheetPlugin(LabelPrintingMixin, SettingsMixin, InvenTreePlug html = weasyprint.HTML(string=html_data) document = html.render().write_pdf() - output_file = ContentFile(document, 'labels.pdf') - - output = LabelOutput.objects.create(label=output_file, user=request.user) - - return JsonResponse({ - 'file': output.label.url, - 'success': True, - 'message': f'{len(items)} labels generated', - }) + output.output = ContentFile(document, 'labels.pdf') + output.progress = 100 + output.complete = True + output.save() def print_page(self, label: LabelTemplate, items: list, request, **kwargs): """Generate a single page of labels. @@ -185,7 +185,7 @@ class InvenTreeLabelSheetPlugin(LabelPrintingMixin, SettingsMixin, InvenTreePlug # Render the individual label template # Note that we disable @page styling for this cell = label.render_as_string( - request, target_object=items[idx], insert_page_style=False + items[idx], request, insert_page_style=False ) html += cell except Exception as exc: diff --git a/src/backend/InvenTree/plugin/models.py b/src/backend/InvenTree/plugin/models.py index 065eb4f9af..c6fee27fd3 100644 --- a/src/backend/InvenTree/plugin/models.py +++ b/src/backend/InvenTree/plugin/models.py @@ -7,7 +7,7 @@ from django.conf import settings from django.contrib import admin from django.contrib.auth.models import User from django.db import models -from django.db.utils import IntegrityError +from django.urls import reverse from django.utils.translation import gettext_lazy as _ import common.models @@ -24,6 +24,11 @@ class PluginConfig(InvenTree.models.MetadataMixin, models.Model): active: Should the plugin be loaded? """ + @staticmethod + def get_api_url(): + """Return the API URL associated with the PluginConfig model.""" + return reverse('api-plugin-list') + class Meta: """Meta for PluginConfig.""" diff --git a/src/backend/InvenTree/plugin/samples/integration/label_sample.py b/src/backend/InvenTree/plugin/samples/integration/label_sample.py index 7e7c5c8645..312205314e 100644 --- a/src/backend/InvenTree/plugin/samples/integration/label_sample.py +++ b/src/backend/InvenTree/plugin/samples/integration/label_sample.py @@ -34,7 +34,9 @@ class SampleLabelPrinter(LabelPrintingMixin, InvenTreePlugin): print(f"Printing Label: {kwargs['filename']} (User: {kwargs['user']})") pdf_data = kwargs['pdf_data'] - png_file = self.render_to_png(label=None, pdf_data=pdf_data) + png_file = self.render_to_png( + kwargs['label_instance'], kwargs['item_instance'], **kwargs + ) filename = str(BASE_DIR / '_testfolder' / 'label.pdf') diff --git a/src/backend/InvenTree/plugin/samples/integration/report_plugin_sample.py b/src/backend/InvenTree/plugin/samples/integration/report_plugin_sample.py index a0b37b53f1..d81fcd1873 100644 --- a/src/backend/InvenTree/plugin/samples/integration/report_plugin_sample.py +++ b/src/backend/InvenTree/plugin/samples/integration/report_plugin_sample.py @@ -4,7 +4,7 @@ import random from plugin import InvenTreePlugin from plugin.mixins import ReportMixin -from report.models import PurchaseOrderReport +from report.models import ReportTemplate class SampleReportPlugin(ReportMixin, InvenTreePlugin): @@ -32,7 +32,7 @@ class SampleReportPlugin(ReportMixin, InvenTreePlugin): context['random_int'] = self.some_custom_function() # We can also add extra data to the context which is specific to the report type - context['is_purchase_order'] = isinstance(report_instance, PurchaseOrderReport) + context['is_purchase_order'] = report_instance.model_type == 'purchaseorder' # We can also use the 'request' object to add extra context data context['request_method'] = request.method diff --git a/src/backend/InvenTree/plugin/serializers.py b/src/backend/InvenTree/plugin/serializers.py index b2bca31d4a..ffe9b066e2 100644 --- a/src/backend/InvenTree/plugin/serializers.py +++ b/src/backend/InvenTree/plugin/serializers.py @@ -268,3 +268,26 @@ class PluginRegistryStatusSerializer(serializers.Serializer): active_plugins = serializers.IntegerField(read_only=True) registry_errors = serializers.ListField(child=PluginRegistryErrorSerializer()) + + +class PluginRelationSerializer(serializers.PrimaryKeyRelatedField): + """Serializer for a plugin field. Uses the 'slug' of the plugin as the lookup.""" + + def __init__(self, **kwargs): + """Custom init routine for the serializer.""" + kwargs['pk_field'] = 'key' + kwargs['queryset'] = PluginConfig.objects.all() + + super().__init__(**kwargs) + + def use_pk_only_optimization(self): + """Disable the PK optimization.""" + return False + + def to_internal_value(self, data): + """Lookup the PluginConfig object based on the slug.""" + return PluginConfig.objects.filter(key=data).first() + + def to_representation(self, value): + """Return the 'key' of the PluginConfig object.""" + return value.key diff --git a/src/backend/InvenTree/report/admin.py b/src/backend/InvenTree/report/admin.py index 6a715f9134..04cab87349 100644 --- a/src/backend/InvenTree/report/admin.py +++ b/src/backend/InvenTree/report/admin.py @@ -2,32 +2,32 @@ from django.contrib import admin +from .helpers import report_model_options from .models import ( - BillOfMaterialsReport, - BuildReport, - PurchaseOrderReport, + LabelOutput, + LabelTemplate, ReportAsset, + ReportOutput, ReportSnippet, - ReturnOrderReport, - SalesOrderReport, - StockLocationReport, - TestReport, + ReportTemplate, ) -@admin.register( - BillOfMaterialsReport, - BuildReport, - PurchaseOrderReport, - ReturnOrderReport, - SalesOrderReport, - StockLocationReport, - TestReport, -) -class ReportTemplateAdmin(admin.ModelAdmin): - """Admin class for the various reporting models.""" +@admin.register(LabelTemplate) +@admin.register(ReportTemplate) +class ReportAdmin(admin.ModelAdmin): + """Admin class for the LabelTemplate and ReportTemplate models.""" - list_display = ('name', 'description', 'template', 'filters', 'enabled', 'revision') + list_display = ('name', 'description', 'model_type', 'enabled') + + list_filter = ('model_type', 'enabled') + + def formfield_for_dbfield(self, db_field, request, **kwargs): + """Provide custom choices for 'model_type' field.""" + if db_field.name == 'model_type': + db_field.choices = report_model_options() + + return super().formfield_for_dbfield(db_field, request, **kwargs) @admin.register(ReportSnippet) @@ -42,3 +42,11 @@ class ReportAssetAdmin(admin.ModelAdmin): """Admin class for the ReportAsset model.""" list_display = ('id', 'asset', 'description') + + +@admin.register(LabelOutput) +@admin.register(ReportOutput) +class TemplateOutputAdmin(admin.ModelAdmin): + """Admin class for the TemplateOutput model.""" + + list_display = ('id', 'output', 'progress', 'complete') diff --git a/src/backend/InvenTree/report/api.py b/src/backend/InvenTree/report/api.py index 315c6b0159..d710359b51 100644 --- a/src/backend/InvenTree/report/api.py +++ b/src/backend/InvenTree/report/api.py @@ -1,164 +1,324 @@ """API functionality for the 'report' app.""" -from django.core.exceptions import FieldError, ValidationError +from django.core.exceptions import ValidationError from django.core.files.base import ContentFile -from django.http import HttpResponse from django.template.exceptions import TemplateDoesNotExist -from django.urls import include, path, re_path +from django.urls import include, path from django.utils.decorators import method_decorator from django.utils.translation import gettext_lazy as _ from django.views.decorators.cache import cache_page, never_cache +from django_filters import rest_framework as rest_filters from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import permissions +from rest_framework.generics import GenericAPIView +from rest_framework.request import clone_request from rest_framework.response import Response -import build.models import common.models +import InvenTree.exceptions import InvenTree.helpers -import order.models -import part.models +import report.helpers import report.models import report.serializers -from InvenTree.api import MetadataView +from InvenTree.api import BulkDeleteMixin, MetadataView from InvenTree.exceptions import log_error from InvenTree.filters import InvenTreeSearchFilter -from InvenTree.mixins import ListCreateAPI, RetrieveAPI, RetrieveUpdateDestroyAPI -from stock.models import StockItem, StockItemAttachment, StockLocation - - -class ReportListView(ListCreateAPI): - """Generic API class for report templates.""" - - filter_backends = [DjangoFilterBackend, InvenTreeSearchFilter] - - filterset_fields = ['enabled'] - - search_fields = ['name', 'description'] - - -class ReportFilterMixin: - """Mixin for extracting multiple objects from query params. - - Each subclass *must* have an attribute called 'ITEM_KEY', - which is used to determine what 'key' is used in the query parameters. - - This mixin defines a 'get_items' method which provides a generic implementation - to return a list of matching database model instances - """ - - # Database model for instances to actually be "printed" against this report template - ITEM_MODEL = None - - # Default key for looking up database model instances - ITEM_KEY = 'item' - - def get_items(self): - """Return a list of database objects from query parameters.""" - if not self.ITEM_MODEL: - raise NotImplementedError( - f'ITEM_MODEL attribute not defined for {__class__}' - ) - - ids = [] - - # Construct a list of possible query parameter value options - # e.g. if self.ITEM_KEY = 'order' -> ['order', 'order[]', 'orders', 'orders[]'] - for k in [self.ITEM_KEY + x for x in ['', '[]', 's', 's[]']]: - if ids := self.request.query_params.getlist(k, []): - # Return the first list of matches - break - - # Next we must validated each provided object ID - valid_ids = [] - - for id in ids: - try: - valid_ids.append(int(id)) - except ValueError: - pass - - # Filter queryset by matching ID values - return self.ITEM_MODEL.objects.filter(pk__in=valid_ids) - - def filter_queryset(self, queryset): - """Filter the queryset based on the provided report ID values. - - As each 'report' instance may optionally define its own filters, - the resulting queryset is the 'union' of the two - """ - queryset = super().filter_queryset(queryset) - - items = self.get_items() - - if len(items) > 0: - """At this point, we are basically forced to be inefficient: - - We need to compare the 'filters' string of each report template, - and see if it matches against each of the requested items. - - In practice, this is not too bad. - """ - - valid_report_ids = set() - - for report in queryset.all(): - matches = True - - try: - filters = InvenTree.helpers.validateFilterString(report.filters) - except ValidationError: - continue - - for item in items: - item_query = self.ITEM_MODEL.objects.filter(pk=item.pk) - - try: - if not item_query.filter(**filters).exists(): - matches = False - break - except FieldError: - matches = False - break - - # Matched all items - if matches: - valid_report_ids.add(report.pk) - - # Reduce queryset to only valid matches - queryset = queryset.filter(pk__in=list(valid_report_ids)) - - return queryset +from InvenTree.mixins import ( + ListAPI, + ListCreateAPI, + RetrieveAPI, + RetrieveUpdateDestroyAPI, +) +from plugin.builtin.labels.inventree_label import InvenTreeLabelPlugin +from plugin.registry import registry @method_decorator(cache_page(5), name='dispatch') -class ReportPrintMixin: - """Mixin for printing reports.""" +class TemplatePrintBase(RetrieveAPI): + """Base class for printing against templates.""" @method_decorator(never_cache) def dispatch(self, *args, **kwargs): """Prevent caching when printing report templates.""" return super().dispatch(*args, **kwargs) - def report_callback(self, object, report, request): - """Callback function for each object/report combination. + def check_permissions(self, request): + """Override request method to GET so that also non superusers can print using a post request.""" + if request.method == 'POST': + request = clone_request(request, 'GET') + return super().check_permissions(request) - Allows functionality to be performed before returning the consolidated PDF + def post(self, request, *args, **kwargs): + """Respond as if a POST request was provided.""" + return self.get(request, *args, **kwargs) - Arguments: - object: The model instance to be printed - report: The individual PDF file object - request: The request instance associated with this print call + def get(self, request, *args, **kwargs): + """GET action for a template printing endpoint. + + - Items are expected to be passed as a list of valid IDs """ - ... + # Extract a list of items to print from the queryset + item_ids = [] - def print(self, request, items_to_print): - """Print this report template against a number of pre-validated items.""" - if len(items_to_print) == 0: - # No valid items provided, return an error message - data = {'error': _('No valid objects provided to template')} + for value in request.query_params.get('items', '').split(','): + try: + item_ids.append(int(value)) + except Exception: + pass - return Response(data, status=400) + template = self.get_object() + items = template.get_model().objects.filter(pk__in=item_ids) + + if len(items) == 0: + # At least one item must be provided + return Response( + {'error': _('No valid objects provided to template')}, status=400 + ) + + return self.print(request, items) + + +class ReportFilterBase(rest_filters.FilterSet): + """Base filter class for label and report templates.""" + + enabled = rest_filters.BooleanFilter() + + model_type = rest_filters.ChoiceFilter( + choices=report.helpers.report_model_options(), label=_('Model Type') + ) + + items = rest_filters.CharFilter(method='filter_items', label=_('Items')) + + def filter_items(self, queryset, name, values): + """Filter against a comma-separated list of provided items. + + Note: This filter is only applied if the 'model_type' is also provided. + """ + model_type = self.data.get('model_type', None) + values = values.strip().split(',') + + if model_class := report.helpers.report_model_from_name(model_type): + model_items = model_class.objects.filter(pk__in=values) + + # Ensure that we have already filtered by model_type + queryset = queryset.filter(model_type=model_type) + + # Construct a list of templates which match the list of provided IDs + matching_template_ids = [] + + for template in queryset.all(): + filters = template.get_filters() + results = model_items.filter(**filters) + # If the resulting queryset is *shorter* than the provided items, then this template does not match + if results.count() == model_items.count(): + matching_template_ids.append(template.pk) + + queryset = queryset.filter(pk__in=matching_template_ids) + + return queryset + + +class ReportFilter(ReportFilterBase): + """Filter class for report template list.""" + + class Meta: + """Filter options.""" + + model = report.models.ReportTemplate + fields = ['landscape'] + + +class LabelFilter(ReportFilterBase): + """Filter class for label template list.""" + + class Meta: + """Filter options.""" + + model = report.models.LabelTemplate + fields = [] + + +class LabelPrint(GenericAPIView): + """API endpoint for printing labels.""" + + permission_classes = [permissions.IsAuthenticated] + serializer_class = report.serializers.LabelPrintSerializer + + def get_plugin_class(self, plugin_slug: str, raise_error=False): + """Return the plugin class for the given plugin key.""" + from plugin.models import PluginConfig + + if plugin_slug is None: + # Use the default label printing plugin + plugin_slug = InvenTreeLabelPlugin.NAME.lower() + + plugin = None + + try: + plugin_config = PluginConfig.objects.get(key=plugin_slug) + plugin = plugin_config.plugin + except (ValueError, PluginConfig.DoesNotExist): + pass + + error = None + + if not plugin: + error = _('Plugin not found') + elif not plugin.is_active(): + error = _('Plugin is not active') + elif not plugin.mixin_enabled('labels'): + error = _('Plugin does not support label printing') + + if error: + plugin = None + + if raise_error: + raise ValidationError({'plugin': error}) + + return plugin + + def get_plugin_serializer(self, plugin): + """Return the serializer for the given plugin.""" + if plugin and hasattr(plugin, 'get_printing_options_serializer'): + return plugin.get_printing_options_serializer( + self.request, + data=self.request.data, + context=self.get_serializer_context(), + ) + + return None + + def get_serializer(self, *args, **kwargs): + """Return serializer information for the label print endpoint.""" + plugin = None + + # Plugin information provided? + if self.request: + plugin_key = self.request.data.get('plugin', None) + # Legacy url based lookup + if not plugin_key: + plugin_key = self.request.query_params.get('plugin', None) + plugin = self.get_plugin_class(plugin_key) + plugin_serializer = self.get_plugin_serializer(plugin) + + if plugin_serializer: + kwargs['plugin_serializer'] = plugin_serializer + + serializer = super().get_serializer(*args, **kwargs) + return serializer + + @method_decorator(never_cache) + def post(self, request, *args, **kwargs): + """POST action for printing labels.""" + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + template = serializer.validated_data['template'] + + if template.width <= 0 or template.height <= 0: + raise ValidationError({'template': _('Invalid label dimensions')}) + + items = serializer.validated_data['items'] + + # Default to the InvenTreeLabelPlugin + plugin_key = InvenTreeLabelPlugin.NAME.lower() + + if plugin_config := serializer.validated_data.get('plugin', None): + plugin_key = plugin_config.key + + plugin = self.get_plugin_class(plugin_key, raise_error=True) + + instances = template.get_model().objects.filter(pk__in=items) + + if instances.count() == 0: + raise ValidationError(_('No valid items provided to template')) + + return self.print(template, instances, plugin, request) + + def print(self, template, items_to_print, plugin, request): + """Print this label template against a number of provided items.""" + if plugin_serializer := plugin.get_printing_options_serializer( + request, data=request.data, context=self.get_serializer_context() + ): + plugin_serializer.is_valid(raise_exception=True) + + # Create a new LabelOutput instance to print against + output = report.models.LabelOutput.objects.create( + template=template, + items=len(items_to_print), + plugin=plugin.slug, + user=request.user, + progress=0, + complete=False, + ) + + try: + plugin.before_printing() + plugin.print_labels( + template, + output, + items_to_print, + request, + printing_options=(plugin_serializer.data if plugin_serializer else {}), + ) + plugin.after_printing() + except ValidationError as e: + raise (e) + except Exception as e: + InvenTree.exceptions.log_error(f'plugins.{plugin.slug}.print_labels') + raise ValidationError([_('Error printing label'), str(e)]) + + output.refresh_from_db() + + return Response( + report.serializers.LabelOutputSerializer(output).data, status=201 + ) + + +class LabelTemplateList(ListCreateAPI): + """API endpoint for viewing list of LabelTemplate objects.""" + + queryset = report.models.LabelTemplate.objects.all() + serializer_class = report.serializers.LabelTemplateSerializer + filterset_class = LabelFilter + filter_backends = [DjangoFilterBackend, InvenTreeSearchFilter] + search_fields = ['name', 'description'] + ordering_fields = ['name', 'enabled'] + + +class LabelTemplateDetail(RetrieveUpdateDestroyAPI): + """Detail API endpoint for label template model.""" + + queryset = report.models.LabelTemplate.objects.all() + serializer_class = report.serializers.LabelTemplateSerializer + + +class ReportPrint(GenericAPIView): + """API endpoint for printing reports.""" + + permission_classes = [permissions.IsAuthenticated] + serializer_class = report.serializers.ReportPrintSerializer + + @method_decorator(never_cache) + def post(self, request, *args, **kwargs): + """POST action for printing a report.""" + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + template = serializer.validated_data['template'] + items = serializer.validated_data['items'] + + instances = template.get_model().objects.filter(pk__in=items) + + if instances.count() == 0: + raise ValidationError(_('No valid items provided to template')) + + return self.print(template, instances, request) + + def print(self, template, items_to_print, request): + """Print this report template against a number of provided items.""" outputs = [] # In debug mode, generate single HTML output, rather than PDF @@ -171,25 +331,30 @@ class ReportPrintMixin: try: # Merge one or more PDF files into a single download - for item in items_to_print: - report = self.get_object() - report.object_to_print = item + for instance in items_to_print: + context = template.get_context(instance, request) + report_name = template.generate_filename(context) - report_name = report.generate_filename(request) - output = report.render(request) + output = template.render(instance, request) - # Run report callback for each generated report - self.report_callback(item, output, request) + # Provide generated report to any interested plugins + for plugin in registry.with_mixin('report'): + try: + plugin.report_callback(self, instance, output, request) + except Exception as e: + InvenTree.exceptions.log_error( + f'plugins.{plugin.slug}.report_callback' + ) try: if debug_mode: - outputs.append(report.render_as_string(request)) + outputs.append(template.render_as_string(instance, request)) else: - outputs.append(output) + outputs.append(template.render(instance, request)) except TemplateDoesNotExist as e: template = str(e) if not template: - template = report.template + template = template.template return Response( { @@ -206,9 +371,8 @@ class ReportPrintMixin: if debug_mode: """Concatenate all rendered templates into a single HTML string, and return the string as a HTML response.""" - html = '\n'.join(outputs) - - return HttpResponse(html) + data = '\n'.join(outputs) + report_name = report_name.replace('.pdf', '.html') else: """Concatenate all rendered pages into a single PDF object, and return the resulting document!""" @@ -220,13 +384,13 @@ class ReportPrintMixin: for page in doc.pages: pages.append(page) - pdf = outputs[0].get_document().copy(pages).write_pdf() + data = outputs[0].get_document().copy(pages).write_pdf() except TemplateDoesNotExist as e: template = str(e) if not template: - template = report.template + template = template.template return Response( { @@ -237,14 +401,6 @@ class ReportPrintMixin: status=400, ) - inline = common.models.InvenTreeUserSetting.get_setting( - 'REPORT_INLINE', user=request.user, cache=False - ) - - return InvenTree.helpers.DownloadFile( - pdf, report_name, content_type='application/pdf', inline=inline - ) - except Exception as exc: # Log the exception to the database if InvenTree.helpers.str2bool( @@ -261,242 +417,39 @@ class ReportPrintMixin: 'path': request.path, }) - def get(self, request, *args, **kwargs): - """Default implementation of GET for a print endpoint. - - Note that it expects the class has defined a get_items() method - """ - items = self.get_items() - return self.print(request, items) - - -class StockItemTestReportMixin(ReportFilterMixin): - """Mixin for StockItemTestReport report template.""" - - ITEM_MODEL = StockItem - ITEM_KEY = 'item' - queryset = report.models.TestReport.objects.all() - serializer_class = report.serializers.TestReportSerializer - - -class StockItemTestReportList(StockItemTestReportMixin, ReportListView): - """API endpoint for viewing list of TestReport objects. - - Filterable by: - - - enabled: Filter by enabled / disabled status - - item: Filter by stock item(s) - """ - - pass - - -class StockItemTestReportDetail(StockItemTestReportMixin, RetrieveUpdateDestroyAPI): - """API endpoint for a single TestReport object.""" - - pass - - -class StockItemTestReportPrint(StockItemTestReportMixin, ReportPrintMixin, RetrieveAPI): - """API endpoint for printing a TestReport object.""" - - def report_callback(self, item, report, request): - """Callback to (optionally) save a copy of the generated report.""" - if common.models.InvenTreeSetting.get_setting( - 'REPORT_ATTACH_TEST_REPORT', cache=False - ): - # Construct a PDF file object - try: - pdf = report.get_document().write_pdf() - pdf_content = ContentFile(pdf, 'test_report.pdf') - except TemplateDoesNotExist: - return - - StockItemAttachment.objects.create( - attachment=pdf_content, - stock_item=item, - user=request.user, - comment=_('Test report'), - ) - - -class BOMReportMixin(ReportFilterMixin): - """Mixin for BillOfMaterialsReport report template.""" - - ITEM_MODEL = part.models.Part - ITEM_KEY = 'part' - - queryset = report.models.BillOfMaterialsReport.objects.all() - serializer_class = report.serializers.BOMReportSerializer - - -class BOMReportList(BOMReportMixin, ReportListView): - """API endpoint for viewing a list of BillOfMaterialReport objects. - - Filterably by: - - - enabled: Filter by enabled / disabled status - - part: Filter by part(s) - """ - - pass - - -class BOMReportDetail(BOMReportMixin, RetrieveUpdateDestroyAPI): - """API endpoint for a single BillOfMaterialReport object.""" - - pass - - -class BOMReportPrint(BOMReportMixin, ReportPrintMixin, RetrieveAPI): - """API endpoint for printing a BillOfMaterialReport object.""" - - pass - - -class BuildReportMixin(ReportFilterMixin): - """Mixin for the BuildReport report template.""" - - ITEM_MODEL = build.models.Build - ITEM_KEY = 'build' - - queryset = report.models.BuildReport.objects.all() - serializer_class = report.serializers.BuildReportSerializer - - -class BuildReportList(BuildReportMixin, ReportListView): - """API endpoint for viewing a list of BuildReport objects. - - Can be filtered by: - - - enabled: Filter by enabled / disabled status - - build: Filter by Build object - """ - - pass - - -class BuildReportDetail(BuildReportMixin, RetrieveUpdateDestroyAPI): - """API endpoint for a single BuildReport object.""" - - pass - - -class BuildReportPrint(BuildReportMixin, ReportPrintMixin, RetrieveAPI): - """API endpoint for printing a BuildReport.""" - - pass - - -class PurchaseOrderReportMixin(ReportFilterMixin): - """Mixin for the PurchaseOrderReport report template.""" - - ITEM_MODEL = order.models.PurchaseOrder - ITEM_KEY = 'order' - - queryset = report.models.PurchaseOrderReport.objects.all() - serializer_class = report.serializers.PurchaseOrderReportSerializer - - -class PurchaseOrderReportList(PurchaseOrderReportMixin, ReportListView): - """API list endpoint for the PurchaseOrderReport model.""" - - pass - - -class PurchaseOrderReportDetail(PurchaseOrderReportMixin, RetrieveUpdateDestroyAPI): - """API endpoint for a single PurchaseOrderReport object.""" - - pass - - -class PurchaseOrderReportPrint(PurchaseOrderReportMixin, ReportPrintMixin, RetrieveAPI): - """API endpoint for printing a PurchaseOrderReport object.""" - - pass - - -class SalesOrderReportMixin(ReportFilterMixin): - """Mixin for the SalesOrderReport report template.""" - - ITEM_MODEL = order.models.SalesOrder - ITEM_KEY = 'order' - - queryset = report.models.SalesOrderReport.objects.all() - serializer_class = report.serializers.SalesOrderReportSerializer - - -class SalesOrderReportList(SalesOrderReportMixin, ReportListView): - """API list endpoint for the SalesOrderReport model.""" - - pass - - -class SalesOrderReportDetail(SalesOrderReportMixin, RetrieveUpdateDestroyAPI): - """API endpoint for a single SalesOrderReport object.""" - - pass - - -class SalesOrderReportPrint(SalesOrderReportMixin, ReportPrintMixin, RetrieveAPI): - """API endpoint for printing a PurchaseOrderReport object.""" - - pass - - -class ReturnOrderReportMixin(ReportFilterMixin): - """Mixin for the ReturnOrderReport report template.""" - - ITEM_MODEL = order.models.ReturnOrder - ITEM_KEY = 'order' - - queryset = report.models.ReturnOrderReport.objects.all() - serializer_class = report.serializers.ReturnOrderReportSerializer - - -class ReturnOrderReportList(ReturnOrderReportMixin, ReportListView): - """API list endpoint for the ReturnOrderReport model.""" - - pass - - -class ReturnOrderReportDetail(ReturnOrderReportMixin, RetrieveUpdateDestroyAPI): - """API endpoint for a single ReturnOrderReport object.""" - - pass - - -class ReturnOrderReportPrint(ReturnOrderReportMixin, ReportPrintMixin, RetrieveAPI): - """API endpoint for printing a ReturnOrderReport object.""" - - pass - - -class StockLocationReportMixin(ReportFilterMixin): - """Mixin for StockLocation report template.""" - - ITEM_MODEL = StockLocation - ITEM_KEY = 'location' - queryset = report.models.StockLocationReport.objects.all() - serializer_class = report.serializers.StockLocationReportSerializer - - -class StockLocationReportList(StockLocationReportMixin, ReportListView): - """API list endpoint for the StockLocationReportList model.""" - - pass - - -class StockLocationReportDetail(StockLocationReportMixin, RetrieveUpdateDestroyAPI): - """API endpoint for a single StockLocationReportDetail object.""" - - pass - - -class StockLocationReportPrint(StockLocationReportMixin, ReportPrintMixin, RetrieveAPI): - """API endpoint for printing a StockLocationReportPrint object.""" - - pass + # Generate a report output object + # TODO: This should be moved to a separate function + # TODO: Allow background printing of reports, with progress reporting + output = report.models.ReportOutput.objects.create( + template=template, + items=len(items_to_print), + user=request.user, + progress=100, + complete=True, + output=ContentFile(data, report_name), + ) + + return Response( + report.serializers.ReportOutputSerializer(output).data, status=201 + ) + + +class ReportTemplateList(ListCreateAPI): + """API endpoint for viewing list of ReportTemplate objects.""" + + queryset = report.models.ReportTemplate.objects.all() + serializer_class = report.serializers.ReportTemplateSerializer + filterset_class = ReportFilter + filter_backends = [DjangoFilterBackend, InvenTreeSearchFilter] + search_fields = ['name', 'description'] + ordering_fields = ['name', 'enabled'] + + +class ReportTemplateDetail(RetrieveUpdateDestroyAPI): + """Detail API endpoint for report template model.""" + + queryset = report.models.ReportTemplate.objects.all() + serializer_class = report.serializers.ReportTemplateSerializer class ReportSnippetList(ListCreateAPI): @@ -527,7 +480,84 @@ class ReportAssetDetail(RetrieveUpdateDestroyAPI): serializer_class = report.serializers.ReportAssetSerializer +class LabelOutputList(BulkDeleteMixin, ListAPI): + """List endpoint for LabelOutput objects.""" + + queryset = report.models.LabelOutput.objects.all() + serializer_class = report.serializers.LabelOutputSerializer + + +class ReportOutputList(BulkDeleteMixin, ListAPI): + """List endpoint for ReportOutput objects.""" + + queryset = report.models.ReportOutput.objects.all() + serializer_class = report.serializers.ReportOutputSerializer + + +label_api_urls = [ + # Printing endpoint + path('print/', LabelPrint.as_view(), name='api-label-print'), + # Label templates + path( + 'template/', + include([ + path( + '/', + include([ + path( + 'metadata/', + MetadataView.as_view(), + {'model': report.models.LabelTemplate}, + name='api-label-template-metadata', + ), + path( + '', + LabelTemplateDetail.as_view(), + name='api-label-template-detail', + ), + ]), + ), + path('', LabelTemplateList.as_view(), name='api-label-template-list'), + ]), + ), + # Label outputs + path( + 'output/', + include([path('', LabelOutputList.as_view(), name='api-label-output-list')]), + ), +] + report_api_urls = [ + # Printing endpoint + path('print/', ReportPrint.as_view(), name='api-report-print'), + # Report templates + path( + 'template/', + include([ + path( + '/', + include([ + path( + 'metadata/', + MetadataView.as_view(), + {'model': report.models.ReportTemplate}, + name='api-report-template-metadata', + ), + path( + '', + ReportTemplateDetail.as_view(), + name='api-report-template-detail', + ), + ]), + ), + path('', ReportTemplateList.as_view(), name='api-report-template-list'), + ]), + ), + # Generated report outputs + path( + 'output/', + include([path('', ReportOutputList.as_view(), name='api-report-output-list')]), + ), # Report assets path( 'asset/', @@ -550,215 +580,4 @@ report_api_urls = [ path('', ReportSnippetList.as_view(), name='api-report-snippet-list'), ]), ), - # Purchase order reports - path( - 'po/', - include([ - # Detail views - path( - '/', - include([ - re_path( - r'print/?', - PurchaseOrderReportPrint.as_view(), - name='api-po-report-print', - ), - path( - 'metadata/', - MetadataView.as_view(), - {'model': report.models.PurchaseOrderReport}, - name='api-po-report-metadata', - ), - path( - '', - PurchaseOrderReportDetail.as_view(), - name='api-po-report-detail', - ), - ]), - ), - # List view - path('', PurchaseOrderReportList.as_view(), name='api-po-report-list'), - ]), - ), - # Sales order reports - path( - 'so/', - include([ - # Detail views - path( - '/', - include([ - re_path( - r'print/?', - SalesOrderReportPrint.as_view(), - name='api-so-report-print', - ), - path( - 'metadata/', - MetadataView.as_view(), - {'model': report.models.SalesOrderReport}, - name='api-so-report-metadata', - ), - path( - '', - SalesOrderReportDetail.as_view(), - name='api-so-report-detail', - ), - ]), - ), - path('', SalesOrderReportList.as_view(), name='api-so-report-list'), - ]), - ), - # Return order reports - path( - 'ro/', - include([ - path( - '/', - include([ - path( - r'print/', - ReturnOrderReportPrint.as_view(), - name='api-return-order-report-print', - ), - path( - 'metadata/', - MetadataView.as_view(), - {'model': report.models.ReturnOrderReport}, - name='api-so-report-metadata', - ), - path( - '', - ReturnOrderReportDetail.as_view(), - name='api-return-order-report-detail', - ), - ]), - ), - path( - '', ReturnOrderReportList.as_view(), name='api-return-order-report-list' - ), - ]), - ), - # Build reports - path( - 'build/', - include([ - # Detail views - path( - '/', - include([ - re_path( - r'print/?', - BuildReportPrint.as_view(), - name='api-build-report-print', - ), - path( - 'metadata/', - MetadataView.as_view(), - {'model': report.models.BuildReport}, - name='api-build-report-metadata', - ), - path( - '', BuildReportDetail.as_view(), name='api-build-report-detail' - ), - ]), - ), - # List view - path('', BuildReportList.as_view(), name='api-build-report-list'), - ]), - ), - # Bill of Material reports - path( - 'bom/', - include([ - # Detail views - path( - '/', - include([ - re_path( - r'print/?', - BOMReportPrint.as_view(), - name='api-bom-report-print', - ), - path( - 'metadata/', - MetadataView.as_view(), - {'model': report.models.BillOfMaterialsReport}, - name='api-bom-report-metadata', - ), - path('', BOMReportDetail.as_view(), name='api-bom-report-detail'), - ]), - ), - # List view - path('', BOMReportList.as_view(), name='api-bom-report-list'), - ]), - ), - # Stock item test reports - path( - 'test/', - include([ - # Detail views - path( - '/', - include([ - re_path( - r'print/?', - StockItemTestReportPrint.as_view(), - name='api-stockitem-testreport-print', - ), - path( - 'metadata/', - MetadataView.as_view(), - {'report': report.models.TestReport}, - name='api-stockitem-testreport-metadata', - ), - path( - '', - StockItemTestReportDetail.as_view(), - name='api-stockitem-testreport-detail', - ), - ]), - ), - # List view - path( - '', - StockItemTestReportList.as_view(), - name='api-stockitem-testreport-list', - ), - ]), - ), - # Stock Location reports (Stock Location Reports -> sir) - path( - 'slr/', - include([ - # Detail views - path( - '/', - include([ - re_path( - r'print/?', - StockLocationReportPrint.as_view(), - name='api-stocklocation-report-print', - ), - path( - 'metadata/', - MetadataView.as_view(), - {'report': report.models.StockLocationReport}, - name='api-stocklocation-report-metadata', - ), - path( - '', - StockLocationReportDetail.as_view(), - name='api-stocklocation-report-detail', - ), - ]), - ), - # List view - path( - '', - StockLocationReportList.as_view(), - name='api-stocklocation-report-list', - ), - ]), - ), ] diff --git a/src/backend/InvenTree/report/apps.py b/src/backend/InvenTree/report/apps.py index 7926d4f76a..c5c874f79e 100644 --- a/src/backend/InvenTree/report/apps.py +++ b/src/backend/InvenTree/report/apps.py @@ -1,18 +1,25 @@ """Config options for the report app.""" import logging +import os from pathlib import Path from django.apps import AppConfig +from django.core.exceptions import AppRegistryNotReady +from django.core.files.base import ContentFile +from django.db.utils import IntegrityError, OperationalError, ProgrammingError -from generic.templating.apps import TemplatingMixin +from maintenance_mode.core import maintenance_mode_on, set_maintenance_mode + +import InvenTree.ready + +logger = logging.getLogger('inventree') -class ReportConfig(TemplatingMixin, AppConfig): +class ReportConfig(AppConfig): """Configuration class for the "report" app.""" name = 'report' - db = 'template' def ready(self): """This function is called whenever the app is loaded.""" @@ -22,103 +29,192 @@ class ReportConfig(TemplatingMixin, AppConfig): super().ready() - def create_defaults(self): - """Create all default templates.""" + # skip loading if plugin registry is not loaded or we run in a background thread + if ( + not InvenTree.ready.isPluginRegistryLoaded() + or not InvenTree.ready.isInMainThread() + ): + return + + if not InvenTree.ready.canAppAccessDatabase(allow_test=False): + return # pragma: no cover + + with maintenance_mode_on(): + try: + self.create_default_labels() + self.create_default_reports() + except ( + AppRegistryNotReady, + IntegrityError, + OperationalError, + ProgrammingError, + ): + logger.warning( + 'Database not ready for creating default report templates' + ) + + set_maintenance_mode(False) + + def create_default_labels(self): + """Create default label templates.""" # Test if models are ready try: import report.models except Exception: # pragma: no cover # Database is not ready yet return - assert bool(report.models.TestReport is not None) - # Create the categories - self.create_template_dir( - report.models.TestReport, - [ - { - 'file': 'inventree_test_report.html', - 'name': 'InvenTree Test Report', - 'description': 'Stock item test report', - } - ], - ) + assert bool(report.models.LabelTemplate is not None) - self.create_template_dir( - report.models.BuildReport, - [ - { - 'file': 'inventree_build_order.html', - 'name': 'InvenTree Build Order', - 'description': 'Build Order job sheet', - } - ], - ) + label_templates = [ + { + 'file': 'part_label.html', + 'name': 'InvenTree Part Label', + 'description': 'Sample part label', + 'model_type': 'part', + }, + { + 'file': 'part_label_code128.html', + 'name': 'InvenTree Part Label (Code128)', + 'description': 'Sample part label with Code128 barcode', + 'model_type': 'part', + }, + { + 'file': 'stockitem_qr.html', + 'name': 'InvenTree Stock Item Label (QR)', + 'description': 'Sample stock item label with QR code', + 'model_type': 'stockitem', + }, + { + 'file': 'stocklocation_qr_and_text.html', + 'name': 'InvenTree Stock Location Label (QR + Text)', + 'description': 'Sample stock item label with QR code and text', + 'model_type': 'stocklocation', + }, + { + 'file': 'stocklocation_qr.html', + 'name': 'InvenTree Stock Location Label (QR)', + 'description': 'Sample stock location label with QR code', + 'model_type': 'stocklocation', + }, + { + 'file': 'buildline_label.html', + 'name': 'InvenTree Build Line Label', + 'description': 'Sample build line label', + 'model_type': 'buildline', + }, + ] - self.create_template_dir( - report.models.BillOfMaterialsReport, - [ - { - 'file': 'inventree_bill_of_materials_report.html', - 'name': 'Bill of Materials', - 'description': 'Bill of Materials report', - } - ], - ) + for template in label_templates: + # Ignore matching templates which are already in the database + if report.models.LabelTemplate.objects.filter( + name=template['name'] + ).exists(): + continue - self.create_template_dir( - report.models.PurchaseOrderReport, - [ - { - 'file': 'inventree_po_report.html', - 'name': 'InvenTree Purchase Order', - 'description': 'Purchase Order example report', - } - ], - ) + filename = template.pop('file') - self.create_template_dir( - report.models.SalesOrderReport, - [ - { - 'file': 'inventree_so_report.html', - 'name': 'InvenTree Sales Order', - 'description': 'Sales Order example report', - } - ], - ) + template_file = Path(__file__).parent.joinpath( + 'templates', 'label', filename + ) - self.create_template_dir( - report.models.ReturnOrderReport, - [ - { - 'file': 'inventree_return_order_report.html', - 'name': 'InvenTree Return Order', - 'description': 'Return Order example report', - } - ], - ) + if not template_file.exists(): + logger.warning("Missing template file: '%s'", template['name']) + continue - self.create_template_dir( - report.models.StockLocationReport, - [ - { - 'file': 'inventree_slr_report.html', - 'name': 'InvenTree Stock Location', - 'description': 'Stock Location example report', - } - ], - ) + # Read the existing template file + data = template_file.open('r').read() - def get_src_dir(self, ref_name): - """Get the source directory.""" - return Path(__file__).parent.joinpath('templates', self.name) + logger.info("Creating new label template: '%s'", template['name']) - def get_new_obj_data(self, data, filename): - """Get the data for a new template db object.""" - return { - 'name': data['name'], - 'description': data['description'], - 'template': filename, - 'enabled': True, - } + # Create a new entry + report.models.LabelTemplate.objects.create( + **template, template=ContentFile(data, os.path.basename(filename)) + ) + + def create_default_reports(self): + """Create default report templates.""" + # Test if models are ready + try: + import report.models + except Exception: # pragma: no cover + # Database is not ready yet + return + + assert bool(report.models.ReportTemplate is not None) + + # Construct a set of default ReportTemplate instances + report_templates = [ + { + 'file': 'inventree_bill_of_materials_report.html', + 'name': 'InvenTree Bill of Materials', + 'description': 'Sample bill of materials report', + 'model_type': 'part', + }, + { + 'file': 'inventree_build_order_report.html', + 'name': 'InvenTree Build Order', + 'description': 'Sample build order report', + 'model_type': 'build', + }, + { + 'file': 'inventree_purchase_order_report.html', + 'name': 'InvenTree Purchase Order', + 'description': 'Sample purchase order report', + 'model_type': 'purchaseorder', + 'filename_pattern': 'PurchaseOrder-{{ reference }}.pdf', + }, + { + 'file': 'inventree_sales_order_report.html', + 'name': 'InvenTree Sales Order', + 'description': 'Sample sales order report', + 'model_type': 'salesorder', + 'filename_pattern': 'SalesOrder-{{ reference }}.pdf', + }, + { + 'file': 'inventree_return_order_report.html', + 'name': 'InvenTree Return Order', + 'description': 'Sample return order report', + 'model_type': 'returnorder', + 'filename_pattern': 'ReturnOrder-{{ reference }}.pdf', + }, + { + 'file': 'inventree_test_report.html', + 'name': 'InvenTree Test Report', + 'description': 'Sample stock item test report', + 'model_type': 'stockitem', + }, + { + 'file': 'inventree_stock_location_report.html', + 'name': 'InvenTree Stock Location Report', + 'description': 'Sample stock location report', + 'model_type': 'stocklocation', + }, + ] + + for template in report_templates: + # Ignore matching templates which are already in the database + if report.models.ReportTemplate.objects.filter( + name=template['name'] + ).exists(): + continue + + filename = template.pop('file') + + template_file = Path(__file__).parent.joinpath( + 'templates', 'report', filename + ) + + if not template_file.exists(): + logger.warning("Missing template file: '%s'", template['name']) + continue + + # Read the existing template file + data = template_file.open('r').read() + + logger.info("Creating new report template: '%s'", template['name']) + + # Create a new entry + report.models.ReportTemplate.objects.create( + **template, template=ContentFile(data, os.path.basename(filename)) + ) diff --git a/src/backend/InvenTree/report/helpers.py b/src/backend/InvenTree/report/helpers.py index 43f2baab72..c0720ce348 100644 --- a/src/backend/InvenTree/report/helpers.py +++ b/src/backend/InvenTree/report/helpers.py @@ -9,6 +9,32 @@ from django.utils.translation import gettext_lazy as _ logger = logging.getLogger('inventree') +def report_model_types(): + """Return a list of database models for which reports can be generated.""" + from InvenTree.helpers_model import getModelsWithMixin + from report.mixins import InvenTreeReportMixin + + return list(getModelsWithMixin(InvenTreeReportMixin)) + + +def report_model_from_name(model_name: str): + """Returns the internal model class from the provided name.""" + if not model_name: + return None + + for model in report_model_types(): + if model.__name__.lower() == model_name: + return model + + +def report_model_options(): + """Return a list of options for models which support report printing.""" + return [ + (model.__name__.lower(), model._meta.verbose_name) + for model in report_model_types() + ] + + def report_page_size_options(): """Returns a list of page size options for PDF reports.""" return [ diff --git a/src/backend/InvenTree/report/migrations/0001_initial.py b/src/backend/InvenTree/report/migrations/0001_initial.py index 8b5c2af09f..60b199c44c 100644 --- a/src/backend/InvenTree/report/migrations/0001_initial.py +++ b/src/backend/InvenTree/report/migrations/0001_initial.py @@ -17,7 +17,7 @@ class Migration(migrations.Migration): name='ReportAsset', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('asset', models.FileField(help_text='Report asset file', upload_to=report.models.rename_asset)), + ('asset', models.FileField(help_text='Report asset file', upload_to='report/assets')), ('description', models.CharField(help_text='Asset file description', max_length=250)), ], ), @@ -26,7 +26,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(help_text='Template name', max_length=100, unique=True)), - ('template', models.FileField(help_text='Report template file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm', 'tex'])])), + ('template', models.FileField(help_text='Report template file', upload_to='report', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm', 'tex'])])), ('description', models.CharField(help_text='Report template description', max_length=250)), ], options={ @@ -38,9 +38,9 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(help_text='Template name', max_length=100, unique=True)), - ('template', models.FileField(help_text='Report template file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm', 'tex'])])), + ('template', models.FileField(help_text='Report template file', upload_to='report', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm', 'tex'])])), ('description', models.CharField(help_text='Report template description', max_length=250)), - ('part_filters', models.CharField(blank=True, help_text='Part query filters (comma-separated list of key=value pairs)', max_length=250, validators=[report.models.validateFilterString])), + ('part_filters', models.CharField(blank=True, help_text='Part query filters (comma-separated list of key=value pairs)', max_length=250)), ], options={ 'abstract': False, diff --git a/src/backend/InvenTree/report/migrations/0005_auto_20210119_0815.py b/src/backend/InvenTree/report/migrations/0005_auto_20210119_0815.py index 717176e390..714267e6fd 100644 --- a/src/backend/InvenTree/report/migrations/0005_auto_20210119_0815.py +++ b/src/backend/InvenTree/report/migrations/0005_auto_20210119_0815.py @@ -2,7 +2,6 @@ import django.core.validators from django.db import migrations, models -import report.models class Migration(migrations.Migration): @@ -20,7 +19,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='testreport', name='filters', - field=models.CharField(blank=True, help_text='Part query filters (comma-separated list of key=value pairs)', max_length=250, validators=[report.models.validate_stock_item_report_filters], verbose_name='Filters'), + field=models.CharField(blank=True, help_text='Part query filters (comma-separated list of key=value pairs)', max_length=250, verbose_name='Filters'), ), migrations.AlterField( model_name='testreport', @@ -30,6 +29,6 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='testreport', name='template', - field=models.FileField(help_text='Report template file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm', 'tex'])], verbose_name='Template'), + field=models.FileField(help_text='Report template file', upload_to='report', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm', 'tex'])], verbose_name='Template'), ), ] diff --git a/src/backend/InvenTree/report/migrations/0006_reportsnippet.py b/src/backend/InvenTree/report/migrations/0006_reportsnippet.py index 6875ebb530..8a550b1b3f 100644 --- a/src/backend/InvenTree/report/migrations/0006_reportsnippet.py +++ b/src/backend/InvenTree/report/migrations/0006_reportsnippet.py @@ -2,7 +2,6 @@ import django.core.validators from django.db import migrations, models -import report.models class Migration(migrations.Migration): @@ -16,7 +15,7 @@ class Migration(migrations.Migration): name='ReportSnippet', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('snippet', models.FileField(help_text='Report snippet file', upload_to=report.models.rename_snippet, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])])), + ('snippet', models.FileField(help_text='Report snippet file', upload_to='report/snippets', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])])), ('description', models.CharField(help_text='Snippet file description', max_length=250)), ], ), diff --git a/src/backend/InvenTree/report/migrations/0007_auto_20210204_1617.py b/src/backend/InvenTree/report/migrations/0007_auto_20210204_1617.py index b110f63365..5cdcb3c189 100644 --- a/src/backend/InvenTree/report/migrations/0007_auto_20210204_1617.py +++ b/src/backend/InvenTree/report/migrations/0007_auto_20210204_1617.py @@ -15,6 +15,6 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='testreport', name='template', - field=models.FileField(help_text='Report template file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Template'), + field=models.FileField(help_text='Report template file', upload_to='report', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Template'), ), ] diff --git a/src/backend/InvenTree/report/migrations/0011_auto_20210212_2024.py b/src/backend/InvenTree/report/migrations/0011_auto_20210212_2024.py index b1a93656cf..9545e0fae6 100644 --- a/src/backend/InvenTree/report/migrations/0011_auto_20210212_2024.py +++ b/src/backend/InvenTree/report/migrations/0011_auto_20210212_2024.py @@ -2,7 +2,6 @@ import django.core.validators from django.db import migrations, models -import report.models class Migration(migrations.Migration): @@ -17,11 +16,11 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(help_text='Template name', max_length=100, verbose_name='Name')), - ('template', models.FileField(help_text='Report template file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Template')), + ('template', models.FileField(help_text='Report template file', upload_to='report', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Template')), ('description', models.CharField(help_text='Report template description', max_length=250, verbose_name='Description')), ('revision', models.PositiveIntegerField(default=1, editable=False, help_text='Report revision number (auto-increments)', verbose_name='Revision')), ('enabled', models.BooleanField(default=True, help_text='Report template is enabled', verbose_name='Enabled')), - ('filters', models.CharField(blank=True, help_text='Part query filters (comma-separated list of key=value pairs', max_length=250, validators=[report.models.validate_part_report_filters], verbose_name='Part Filters')), + ('filters', models.CharField(blank=True, help_text='Part query filters (comma-separated list of key=value pairs', max_length=250, verbose_name='Part Filters')), ], options={ 'abstract': False, @@ -30,6 +29,6 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='testreport', name='filters', - field=models.CharField(blank=True, help_text='StockItem query filters (comma-separated list of key=value pairs)', max_length=250, validators=[report.models.validate_stock_item_report_filters], verbose_name='Filters'), + field=models.CharField(blank=True, help_text='StockItem query filters (comma-separated list of key=value pairs)', max_length=250, verbose_name='Filters'), ), ] diff --git a/src/backend/InvenTree/report/migrations/0012_buildreport.py b/src/backend/InvenTree/report/migrations/0012_buildreport.py index b2d3603480..900ef3d2fc 100644 --- a/src/backend/InvenTree/report/migrations/0012_buildreport.py +++ b/src/backend/InvenTree/report/migrations/0012_buildreport.py @@ -2,7 +2,6 @@ import django.core.validators from django.db import migrations, models -import report.models class Migration(migrations.Migration): @@ -17,11 +16,11 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(help_text='Template name', max_length=100, verbose_name='Name')), - ('template', models.FileField(help_text='Report template file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Template')), + ('template', models.FileField(help_text='Report template file', upload_to='report', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Template')), ('description', models.CharField(help_text='Report template description', max_length=250, verbose_name='Description')), ('revision', models.PositiveIntegerField(default=1, editable=False, help_text='Report revision number (auto-increments)', verbose_name='Revision')), ('enabled', models.BooleanField(default=True, help_text='Report template is enabled', verbose_name='Enabled')), - ('filters', models.CharField(blank=True, help_text='Build query filters (comma-separated list of key=value pairs', max_length=250, validators=[report.models.validate_build_report_filters], verbose_name='Build Filters')), + ('filters', models.CharField(blank=True, help_text='Build query filters (comma-separated list of key=value pairs', max_length=250, verbose_name='Build Filters')), ], options={ 'abstract': False, diff --git a/src/backend/InvenTree/report/migrations/0014_purchaseorderreport_salesorderreport.py b/src/backend/InvenTree/report/migrations/0014_purchaseorderreport_salesorderreport.py index ab734b7b48..58504048a3 100644 --- a/src/backend/InvenTree/report/migrations/0014_purchaseorderreport_salesorderreport.py +++ b/src/backend/InvenTree/report/migrations/0014_purchaseorderreport_salesorderreport.py @@ -2,7 +2,6 @@ import django.core.validators from django.db import migrations, models -import report.models class Migration(migrations.Migration): @@ -17,11 +16,11 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(help_text='Template name', max_length=100, verbose_name='Name')), - ('template', models.FileField(help_text='Report template file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Template')), + ('template', models.FileField(help_text='Report template file', upload_to='report', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Template')), ('description', models.CharField(help_text='Report template description', max_length=250, verbose_name='Description')), ('revision', models.PositiveIntegerField(default=1, editable=False, help_text='Report revision number (auto-increments)', verbose_name='Revision')), ('enabled', models.BooleanField(default=True, help_text='Report template is enabled', verbose_name='Enabled')), - ('filters', models.CharField(blank=True, help_text='Purchase order query filters', max_length=250, validators=[report.models.validate_purchase_order_filters], verbose_name='Filters')), + ('filters', models.CharField(blank=True, help_text='Purchase order query filters', max_length=250, verbose_name='Filters')), ], options={ 'abstract': False, @@ -32,11 +31,11 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(help_text='Template name', max_length=100, verbose_name='Name')), - ('template', models.FileField(help_text='Report template file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Template')), + ('template', models.FileField(help_text='Report template file', upload_to='report', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Template')), ('description', models.CharField(help_text='Report template description', max_length=250, verbose_name='Description')), ('revision', models.PositiveIntegerField(default=1, editable=False, help_text='Report revision number (auto-increments)', verbose_name='Revision')), ('enabled', models.BooleanField(default=True, help_text='Report template is enabled', verbose_name='Enabled')), - ('filters', models.CharField(blank=True, help_text='Sales order query filters', max_length=250, validators=[report.models.validate_sales_order_filters], verbose_name='Filters')), + ('filters', models.CharField(blank=True, help_text='Sales order query filters', max_length=250, verbose_name='Filters')), ], options={ 'abstract': False, diff --git a/src/backend/InvenTree/report/migrations/0015_auto_20210403_1837.py b/src/backend/InvenTree/report/migrations/0015_auto_20210403_1837.py index 0348db5b35..ceb0ff9baa 100644 --- a/src/backend/InvenTree/report/migrations/0015_auto_20210403_1837.py +++ b/src/backend/InvenTree/report/migrations/0015_auto_20210403_1837.py @@ -15,7 +15,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='reportasset', name='asset', - field=models.FileField(help_text='Report asset file', upload_to=report.models.rename_asset, verbose_name='Asset'), + field=models.FileField(help_text='Report asset file', upload_to='report/assets', verbose_name='Asset'), ), migrations.AlterField( model_name='reportasset', @@ -30,6 +30,6 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='reportsnippet', name='snippet', - field=models.FileField(help_text='Report snippet file', upload_to=report.models.rename_snippet, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Snippet'), + field=models.FileField(help_text='Report snippet file', upload_to='report/snippets', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Snippet'), ), ] diff --git a/src/backend/InvenTree/report/migrations/0018_returnorderreport.py b/src/backend/InvenTree/report/migrations/0018_returnorderreport.py index 8bdbb6ebe8..04822b4a78 100644 --- a/src/backend/InvenTree/report/migrations/0018_returnorderreport.py +++ b/src/backend/InvenTree/report/migrations/0018_returnorderreport.py @@ -2,7 +2,6 @@ import django.core.validators from django.db import migrations, models -import report.models class Migration(migrations.Migration): @@ -17,12 +16,12 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(help_text='Template name', max_length=100, verbose_name='Name')), - ('template', models.FileField(help_text='Report template file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Template')), + ('template', models.FileField(help_text='Report template file', upload_to='report', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Template')), ('description', models.CharField(help_text='Report template description', max_length=250, verbose_name='Description')), ('revision', models.PositiveIntegerField(default=1, editable=False, help_text='Report revision number (auto-increments)', verbose_name='Revision')), ('filename_pattern', models.CharField(default='report.pdf', help_text='Pattern for generating report filenames', max_length=100, verbose_name='Filename Pattern')), ('enabled', models.BooleanField(default=True, help_text='Report template is enabled', verbose_name='Enabled')), - ('filters', models.CharField(blank=True, help_text='Return order query filters', max_length=250, validators=[report.models.validate_return_order_filters], verbose_name='Filters')), + ('filters', models.CharField(blank=True, help_text='Return order query filters', max_length=250, verbose_name='Filters')), ], options={ 'abstract': False, diff --git a/src/backend/InvenTree/report/migrations/0020_stocklocationreport.py b/src/backend/InvenTree/report/migrations/0020_stocklocationreport.py index b43c414e3b..048d5455cf 100644 --- a/src/backend/InvenTree/report/migrations/0020_stocklocationreport.py +++ b/src/backend/InvenTree/report/migrations/0020_stocklocationreport.py @@ -2,7 +2,6 @@ import django.core.validators from django.db import migrations, models -import report.models class Migration(migrations.Migration): @@ -18,12 +17,12 @@ class Migration(migrations.Migration): ('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='Template name', max_length=100, verbose_name='Name')), - ('template', models.FileField(help_text='Report template file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Template')), + ('template', models.FileField(help_text='Report template file', upload_to='report', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Template')), ('description', models.CharField(help_text='Report template description', max_length=250, verbose_name='Description')), ('revision', models.PositiveIntegerField(default=1, editable=False, help_text='Report revision number (auto-increments)', verbose_name='Revision')), ('filename_pattern', models.CharField(default='report.pdf', help_text='Pattern for generating report filenames', max_length=100, verbose_name='Filename Pattern')), ('enabled', models.BooleanField(default=True, help_text='Report template is enabled', verbose_name='Enabled')), - ('filters', models.CharField(blank=True, help_text='stock location query filters (comma-separated list of key=value pairs)', max_length=250, validators=[report.models.validate_stock_location_report_filters], verbose_name='Filters')), + ('filters', models.CharField(blank=True, help_text='stock location query filters (comma-separated list of key=value pairs)', max_length=250, verbose_name='Filters')), ], options={ 'abstract': False, diff --git a/src/backend/InvenTree/report/migrations/0022_reporttemplate.py b/src/backend/InvenTree/report/migrations/0022_reporttemplate.py new file mode 100644 index 0000000000..9d3677c999 --- /dev/null +++ b/src/backend/InvenTree/report/migrations/0022_reporttemplate.py @@ -0,0 +1,40 @@ +# Generated by Django 4.2.11 on 2024-04-21 03:11 + +import InvenTree.models +import django.core.validators +from django.db import migrations, models +import report.helpers +import report.models +import report.validators + + +class Migration(migrations.Migration): + + dependencies = [ + ('report', '0021_auto_20231009_0144'), + ] + + operations = [ + migrations.CreateModel( + name='ReportTemplate', + 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='Template name', unique=True, max_length=100, verbose_name='Name')), + ('template', models.FileField(help_text='Template file', upload_to='report/report', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Template')), + ('description', models.CharField(help_text='Template description', max_length=250, verbose_name='Description')), + ('revision', models.PositiveIntegerField(default=1, editable=False, help_text='Revision number (auto-increments)', verbose_name='Revision')), + ('page_size', models.CharField(default=report.helpers.report_page_size_default, help_text='Page size for PDF reports', max_length=20, verbose_name='Page Size')), + ('landscape', models.BooleanField(default=False, help_text='Render report in landscape orientation', verbose_name='Landscape')), + ('filename_pattern', models.CharField(default='output.pdf', help_text='Pattern for generating filenames', max_length=100, verbose_name='Filename Pattern')), + ('enabled', models.BooleanField(default=True, help_text='Template is enabled', verbose_name='Enabled')), + ('model_type', models.CharField(max_length=100, help_text='Target model type for template', validators=[report.validators.validate_report_model_type])), + ('filters', models.CharField(blank=True, help_text='Template query filters (comma-separated list of key=value pairs)', max_length=250, validators=[report.validators.validate_filters], verbose_name='Filters')), + ], + options={ + 'abstract': False, + 'unique_together': [('name', 'model_type')] + }, + bases=(InvenTree.models.PluginValidationMixin, models.Model), + ), + ] diff --git a/src/backend/InvenTree/report/migrations/0023_auto_20240421_0455.py b/src/backend/InvenTree/report/migrations/0023_auto_20240421_0455.py new file mode 100644 index 0000000000..f33ca0f98a --- /dev/null +++ b/src/backend/InvenTree/report/migrations/0023_auto_20240421_0455.py @@ -0,0 +1,102 @@ +# Generated by Django 4.2.11 on 2024-04-21 04:55 + +import os + +from django.db import migrations +from django.core.files.base import ContentFile + + +def report_model_map(): + """Return a map of model_type: report_type keys.""" + + return { + 'stockitem': 'testreport', + 'stocklocation': 'stocklocationreport', + 'build': 'buildreport', + 'part': 'billofmaterialsreport', + 'purchaseorder': 'purchaseorderreport', + 'salesorder': 'salesorderreport', + 'returnorder': 'returnorderreport' + } + + +def forward(apps, schema_editor): + """Run forwards migration. + + - Create a new ReportTemplate instance for each existing report + """ + + # New 'generic' report template model + ReportTemplate = apps.get_model('report', 'reporttemplate') + + count = 0 + + for model_type, report_model in report_model_map().items(): + + model = apps.get_model('report', report_model) + + for template in model.objects.all(): + # Construct a new ReportTemplate instance + + filename = template.template.path + + if '/report/inventree/' in filename: + # Do not migrate internal report templates + continue + + filename = os.path.basename(filename) + filedata = template.template.open('r').read() + + name = template.name + offset = 1 + + # Prevent duplicate names during migration + while ReportTemplate.objects.filter(name=name, model_type=model_type).exists(): + name = template.name + f"_{offset}" + offset += 1 + + ReportTemplate.objects.create( + name=name, + template=ContentFile(filedata, filename), + model_type=model_type, + description=template.description, + revision=template.revision, + filters=template.filters, + filename_pattern=template.filename_pattern, + enabled=template.enabled, + page_size=template.page_size, + landscape=template.landscape, + ) + + count += 1 + + if count > 0: + print(f"Migrated {count} report templates to new ReportTemplate model.") + + +def reverse(apps, schema_editor): + """Run reverse migration. + + - Delete any ReportTemplate instances in the database + """ + ReportTemplate = apps.get_model('report', 'reporttemplate') + + n = ReportTemplate.objects.count() + + if n > 0: + for item in ReportTemplate.objects.all(): + item.template.delete() + item.delete() + + print(f"Deleted {n} ReportTemplate objects and templates") + + +class Migration(migrations.Migration): + + dependencies = [ + ('report', '0022_reporttemplate'), + ] + + operations = [ + migrations.RunPython(forward, reverse_code=reverse) + ] diff --git a/src/backend/InvenTree/report/migrations/0024_delete_billofmaterialsreport_delete_buildreport_and_more.py b/src/backend/InvenTree/report/migrations/0024_delete_billofmaterialsreport_delete_buildreport_and_more.py new file mode 100644 index 0000000000..429a8c2a78 --- /dev/null +++ b/src/backend/InvenTree/report/migrations/0024_delete_billofmaterialsreport_delete_buildreport_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 4.2.11 on 2024-04-21 14:03 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('report', '0023_auto_20240421_0455'), + ] + + operations = [ + migrations.DeleteModel( + name='BillOfMaterialsReport', + ), + migrations.DeleteModel( + name='BuildReport', + ), + migrations.DeleteModel( + name='PurchaseOrderReport', + ), + migrations.DeleteModel( + name='ReturnOrderReport', + ), + migrations.DeleteModel( + name='SalesOrderReport', + ), + migrations.DeleteModel( + name='StockLocationReport', + ), + migrations.DeleteModel( + name='TestReport', + ), + ] diff --git a/src/backend/InvenTree/report/migrations/0025_labeltemplate.py b/src/backend/InvenTree/report/migrations/0025_labeltemplate.py new file mode 100644 index 0000000000..eaeb54daf6 --- /dev/null +++ b/src/backend/InvenTree/report/migrations/0025_labeltemplate.py @@ -0,0 +1,39 @@ +# Generated by Django 4.2.11 on 2024-04-22 12:48 + +import InvenTree.models +import django.core.validators +from django.db import migrations, models +import report.models +import report.validators + + +class Migration(migrations.Migration): + + dependencies = [ + ('report', '0024_delete_billofmaterialsreport_delete_buildreport_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='LabelTemplate', + 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='Template name', max_length=100, unique=True, verbose_name='Name')), + ('description', models.CharField(help_text='Template description', max_length=250, verbose_name='Description')), + ('template', models.FileField(help_text='Template file', upload_to='report/label', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Template')), + ('revision', models.PositiveIntegerField(default=1, editable=False, help_text='Revision number (auto-increments)', verbose_name='Revision')), + ('filename_pattern', models.CharField(default='output.pdf', help_text='Pattern for generating filenames', max_length=100, verbose_name='Filename Pattern')), + ('enabled', models.BooleanField(default=True, help_text='Template is enabled', verbose_name='Enabled')), + ('model_type', models.CharField(max_length=100, validators=[report.validators.validate_report_model_type])), + ('filters', models.CharField(blank=True, help_text='Template query filters (comma-separated list of key=value pairs)', max_length=250, validators=[report.validators.validate_filters], verbose_name='Filters')), + ('width', models.FloatField(default=50, help_text='Label width, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Width [mm]')), + ('height', models.FloatField(default=20, help_text='Label height, specified in mm', validators=[django.core.validators.MinValueValidator(2)], verbose_name='Height [mm]')), + ], + options={ + 'abstract': False, + 'unique_together': {('name', 'model_type')}, + }, + bases=(InvenTree.models.PluginValidationMixin, models.Model), + ), + ] diff --git a/src/backend/InvenTree/report/migrations/0026_auto_20240422_1301.py b/src/backend/InvenTree/report/migrations/0026_auto_20240422_1301.py new file mode 100644 index 0000000000..036fc62faa --- /dev/null +++ b/src/backend/InvenTree/report/migrations/0026_auto_20240422_1301.py @@ -0,0 +1,136 @@ +# Generated by Django 4.2.11 on 2024-04-22 13:01 + +import os + +from django.db import connection, migrations +from django.core.files.base import ContentFile +from django.core.files.storage import default_storage + + +def label_model_map(): + """Map legacy label template models to model_type values.""" + + return { + "stockitemlabel": "stockitem", + "stocklocationlabel": "stocklocation", + "partlabel": "part", + "buildlinelabel": "buildline", + } + + +def convert_legacy_labels(table_name, model_name, template_model): + """Map labels from an existing table to a new model type + + Arguments: + table_name: The name of the existing table + model_name: The name of the new model type + template_model: The model class for the new template model + + Note: We use raw SQL queries here, as the original 'label' app has been removed entirely. + """ + count = 0 + + fields = [ + 'name', 'description', 'label', 'enabled', 'height', 'width', 'filename_pattern', 'filters' + ] + + fieldnames = ', '.join(fields) + + query = f"SELECT {fieldnames} FROM {table_name};" + + with connection.cursor() as cursor: + try: + cursor.execute(query) + except Exception: + # Table likely does not exist + print(f"Legacy label table {table_name} not found - skipping migration") + return 0 + + rows = cursor.fetchall() + + for row in rows: + data = { + fields[idx]: row[idx] for idx in range(len(fields)) + } + + # Skip any "builtin" labels + if 'label/inventree/' in data['label']: + continue + + print(f"Creating new LabelTemplate for {model_name} - {data['name']}") + + if template_model.objects.filter(name=data['name'], model_type=model_name).exists(): + print(f"LabelTemplate {data['name']} already exists for {model_name} - skipping") + continue + + + if not default_storage.exists(data['label']): + print(f"Label template file {data['label']} does not exist - skipping") + continue + + # Create a new template file object + filedata = default_storage.open(data['label']).read() + filename = os.path.basename(data['label']) + + # Remove the 'label' key from the data dictionary + data.pop('label') + + data['template'] = ContentFile(filedata, filename) + data['model_type'] = model_name + + template_model.objects.create(**data) + + count += 1 + + return count + + +def forward(apps, schema_editor): + """Run forwards migrations. + + - Create a new LabelTemplate instance for each existing legacy label template. + """ + + LabelTemplate = apps.get_model('report', 'labeltemplate') + + count = 0 + + for template_class, model_type in label_model_map().items(): + + table_name = f'label_{template_class}' + + count += convert_legacy_labels(table_name, model_type, LabelTemplate) or 0 + + if count > 0: + print(f"Migrated {count} report templates to new LabelTemplate model.") + +def reverse(apps, schema_editor): + """Run reverse migrations. + + - Delete any LabelTemplate instances in the database + """ + + LabelTemplate = apps.get_model('report', 'labeltemplate') + + n = LabelTemplate.objects.count() + + if n > 0: + for item in LabelTemplate.objects.all(): + + item.template.delete() + item.delete() + + print(f"Deleted {n} LabelTemplate objects and templates") + +class Migration(migrations.Migration): + + atomic = False + + dependencies = [ + ('report', '0025_labeltemplate'), + ] + + operations = [ + migrations.RunPython(forward, reverse_code=reverse) + ] + diff --git a/src/backend/InvenTree/report/migrations/0027_alter_labeltemplate_model_type_and_more.py b/src/backend/InvenTree/report/migrations/0027_alter_labeltemplate_model_type_and_more.py new file mode 100644 index 0000000000..4fa5b23aff --- /dev/null +++ b/src/backend/InvenTree/report/migrations/0027_alter_labeltemplate_model_type_and_more.py @@ -0,0 +1,77 @@ +# Generated by Django 4.2.11 on 2024-04-30 09:50 + +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import report.models +import report.validators + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('report', '0026_auto_20240422_1301'), + ] + + operations = [ + migrations.AlterField( + model_name='labeltemplate', + name='model_type', + field=models.CharField(help_text='Target model type for template', max_length=100, validators=[report.validators.validate_report_model_type]), + ), + migrations.AlterField( + model_name='labeltemplate', + name='template', + field=models.FileField(help_text='Template file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Template'), + ), + migrations.AlterField( + model_name='reportasset', + name='asset', + field=models.FileField(help_text='Report asset file', upload_to=report.models.rename_template, verbose_name='Asset'), + ), + migrations.AlterField( + model_name='reportsnippet', + name='snippet', + field=models.FileField(help_text='Report snippet file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Snippet'), + ), + migrations.AlterField( + model_name='reporttemplate', + name='template', + field=models.FileField(help_text='Template file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Template'), + ), + migrations.CreateModel( + name='ReportOutput', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateField(auto_now_add=True)), + ('items', models.PositiveIntegerField(default=0, help_text='Number of items to process', verbose_name='Items')), + ('complete', models.BooleanField(default=False, help_text='Report generation is complete', verbose_name='Complete')), + ('progress', models.PositiveIntegerField(default=0, help_text='Report generation progress', verbose_name='Progress')), + ('output', models.FileField(blank=True, help_text='Generated output file', null=True, upload_to='report/output', verbose_name='Output File')), + ('template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='report.reporttemplate', verbose_name='Report Template')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='LabelOutput', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateField(auto_now_add=True)), + ('items', models.PositiveIntegerField(default=0, help_text='Number of items to process', verbose_name='Items')), + ('complete', models.BooleanField(default=False, help_text='Report generation is complete', verbose_name='Complete')), + ('progress', models.PositiveIntegerField(default=0, help_text='Report generation progress', verbose_name='Progress')), + ('output', models.FileField(blank=True, help_text='Generated output file', null=True, upload_to='label/output', verbose_name='Output File')), + ('plugin', models.CharField(blank=True, help_text='Label output plugin', max_length=100, verbose_name='Plugin')), + ('template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='report.labeltemplate', verbose_name='Label Template')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/src/backend/InvenTree/report/mixins.py b/src/backend/InvenTree/report/mixins.py new file mode 100644 index 0000000000..12142dc162 --- /dev/null +++ b/src/backend/InvenTree/report/mixins.py @@ -0,0 +1,23 @@ +"""Report mixin classes.""" + +from django.db import models + + +class InvenTreeReportMixin(models.Model): + """A mixin class for adding report generation functionality to a model class. + + In addition to exposing the model to the report generation interface, + this mixin provides a hook for providing extra context information to the reports. + """ + + class Meta: + """Metaclass options for this mixin.""" + + abstract = True + + def report_context(self) -> dict: + """Generate a dict of context data to provide to the reporting framework. + + The default implementation returns an empty dict object. + """ + return {} diff --git a/src/backend/InvenTree/report/models.py b/src/backend/InvenTree/report/models.py index 6575b494cb..692ed75530 100644 --- a/src/backend/InvenTree/report/models.py +++ b/src/backend/InvenTree/report/models.py @@ -1,30 +1,26 @@ """Report template model definitions.""" -import datetime import logging import os import sys from django.conf import settings -from django.core.cache import cache +from django.contrib.auth.models import User from django.core.exceptions import ValidationError -from django.core.validators import FileExtensionValidator +from django.core.files.storage import default_storage +from django.core.validators import FileExtensionValidator, MinValueValidator from django.db import models from django.template import Context, Template from django.template.loader import render_to_string from django.urls import reverse from django.utils.translation import gettext_lazy as _ -import build.models import common.models import InvenTree.exceptions import InvenTree.helpers import InvenTree.models -import order.models -import part.models import report.helpers -import stock.models -from InvenTree.helpers import validateFilterString +import report.validators from InvenTree.helpers_model import get_base_url from InvenTree.models import MetadataMixin from plugin.registry import registry @@ -33,6 +29,7 @@ try: from django_weasyprint import WeasyTemplateResponseMixin except OSError as err: # pragma: no cover print(f'OSError: {err}') + print("Unable to import 'django_weasyprint' module.") print('You may require some further system packages to be installed.') sys.exit(1) @@ -40,70 +37,250 @@ except OSError as err: # pragma: no cover logger = logging.getLogger('inventree') -def rename_template(instance, filename): - """Helper function for 'renaming' uploaded report files. - - Pass responsibility back to the calling class, - to ensure that files are uploaded to the correct directory. - """ - return instance.rename_file(filename) - - -def validate_stock_item_report_filters(filters): - """Validate filter string against StockItem model.""" - return validateFilterString(filters, model=stock.models.StockItem) - - -def validate_part_report_filters(filters): - """Validate filter string against Part model.""" - return validateFilterString(filters, model=part.models.Part) - - -def validate_build_report_filters(filters): - """Validate filter string against Build model.""" - return validateFilterString(filters, model=build.models.Build) - - -def validate_purchase_order_filters(filters): - """Validate filter string against PurchaseOrder model.""" - return validateFilterString(filters, model=order.models.PurchaseOrder) - - -def validate_sales_order_filters(filters): - """Validate filter string against SalesOrder model.""" - return validateFilterString(filters, model=order.models.SalesOrder) - - -def validate_return_order_filters(filters): - """Validate filter string against ReturnOrder model.""" - return validateFilterString(filters, model=order.models.ReturnOrder) - - -def validate_stock_location_report_filters(filters): - """Validate filter string against StockLocation model.""" - return validateFilterString(filters, model=stock.models.StockLocation) - - -class WeasyprintReportMixin(WeasyTemplateResponseMixin): +class WeasyprintReport(WeasyTemplateResponseMixin): """Class for rendering a HTML template to a PDF.""" - pdf_filename = 'report.pdf' - pdf_attachment = True - def __init__(self, request, template, **kwargs): """Initialize the report mixin with some standard attributes.""" self.request = request self.template_name = template - self.pdf_filename = kwargs.get('filename', 'report.pdf') + self.pdf_filename = kwargs.get('filename', 'output.pdf') -class ReportBase(InvenTree.models.InvenTreeModel): - """Base class for uploading html templates.""" +def rename_template(instance, filename): + """Function to rename a report template once uploaded. + + - Retains the original uploaded filename + - Checks for duplicate filenames across instance class + """ + path = instance.get_upload_path(filename) + + # Throw error if any other model instances reference this path + instance.check_existing_file(path, raise_error=True) + + # Delete file with this name if it already exists + if default_storage.exists(path): + logger.info(f'Deleting existing template file: {path}') + default_storage.delete(path) + + return path + + +class TemplateUploadMixin: + """Mixin class for providing template pathing functions. + + - Provides generic method for determining the upload path for a template + - Provides generic method for checking for duplicate filenames + + Classes which inherit this mixin can guarantee that uploaded templates are unique, + and that the same filename will be retained when uploaded. + """ + + # Directory in which to store uploaded templates + SUBDIR = '' + + # Name of the template field + TEMPLATE_FIELD = 'template' + + def __str__(self) -> str: + """String representation of a TemplateUploadMixin instance.""" + return str(os.path.basename(self.template_name)) + + @property + def template_name(self): + """Return the filename of the template associated with this model class.""" + template = getattr(self, self.TEMPLATE_FIELD).name + template = template.replace('/', os.path.sep) + template = template.replace('\\', os.path.sep) + + template = settings.MEDIA_ROOT.joinpath(template) + + return str(template) + + @property + def extension(self): + """Return the filename extension of the associated template file.""" + return os.path.splitext(self.template.name)[1].lower() + + def get_upload_path(self, filename): + """Generate an upload path for the given filename.""" + fn = os.path.basename(filename) + return os.path.join('report', self.SUBDIR, fn) + + def check_existing_file(self, path, raise_error=False): + """Check if a file already exists with the given filename.""" + filters = {self.TEMPLATE_FIELD: self.get_upload_path(path)} + + exists = self.__class__.objects.filter(**filters).exclude(pk=self.pk).exists() + + if exists and raise_error: + raise ValidationError({ + self.TEMPLATE_FIELD: _('Template file with this name already exists') + }) + + return exists + + def validate_unique(self, exclude=None): + """Validate that this template is unique.""" + proposed_path = self.get_upload_path(self.template_name) + self.check_existing_file(proposed_path, raise_error=True) + return super().validate_unique(exclude) + + +class ReportTemplateBase(MetadataMixin, InvenTree.models.InvenTreeModel): + """Base class for reports, labels.""" class Meta: - """Metaclass options. Abstract ensures no database table is created.""" + """Metaclass options.""" abstract = True + unique_together = ('name', 'model_type') + + def save(self, *args, **kwargs): + """Perform additional actions when the report is saved.""" + # Increment revision number + self.revision += 1 + + super().save() + + name = models.CharField( + blank=False, + max_length=100, + verbose_name=_('Name'), + help_text=_('Template name'), + unique=True, + ) + + description = models.CharField( + max_length=250, + verbose_name=_('Description'), + help_text=_('Template description'), + ) + + revision = models.PositiveIntegerField( + default=1, + verbose_name=_('Revision'), + help_text=_('Revision number (auto-increments)'), + editable=False, + ) + + def generate_filename(self, context, **kwargs): + """Generate a filename for this report.""" + template_string = Template(self.filename_pattern) + + return template_string.render(Context(context)) + + def render_as_string(self, instance, request=None, **kwargs): + """Render the report to a HTML string. + + Useful for debug mode (viewing generated code) + """ + context = self.get_context(instance, request, **kwargs) + + return render_to_string(self.template_name, context, request) + + def render(self, instance, request=None, **kwargs): + """Render the template to a PDF file. + + Uses django-weasyprint plugin to render HTML template against Weasyprint + """ + context = self.get_context(instance, request) + + # Render HTML template to PDF + wp = WeasyprintReport( + request, + self.template_name, + base_url=request.build_absolute_uri('/'), + presentational_hints=True, + filename=self.generate_filename(context), + **kwargs, + ) + + return wp.render_to_response(context, **kwargs) + + filename_pattern = models.CharField( + default='output.pdf', + verbose_name=_('Filename Pattern'), + help_text=_('Pattern for generating filenames'), + max_length=100, + ) + + enabled = models.BooleanField( + default=True, verbose_name=_('Enabled'), help_text=_('Template is enabled') + ) + + model_type = models.CharField( + max_length=100, + validators=[report.validators.validate_report_model_type], + help_text=_('Target model type for template'), + ) + + def clean(self): + """Clean model instance, and ensure validity.""" + super().clean() + + model = self.get_model() + filters = self.filters + + if model and filters: + report.validators.validate_filters(filters, model=model) + + def get_model(self): + """Return the database model class associated with this report template.""" + return report.helpers.report_model_from_name(self.model_type) + + filters = models.CharField( + blank=True, + max_length=250, + verbose_name=_('Filters'), + help_text=_('Template query filters (comma-separated list of key=value pairs)'), + validators=[report.validators.validate_filters], + ) + + def get_filters(self): + """Return a filter dict which can be applied to the target model.""" + return report.validators.validate_filters(self.filters, model=self.get_model()) + + def base_context(self, request=None): + """Return base context data (available to all templates).""" + return { + 'base_url': get_base_url(request=request), + 'date': InvenTree.helpers.current_date(), + 'datetime': InvenTree.helpers.current_time(), + 'request': request, + 'template': self, + 'template_description': self.description, + 'template_name': self.name, + 'template_revision': self.revision, + 'user': request.user if request else None, + } + + def get_context(self, instance, request=None, **kwargs): + """Supply context data to the generic template for rendering. + + Arguments: + instance: The model instance we are printing against + request: The request object (optional) + """ + # Provide base context information to all templates + base_context = self.base_context(request=request) + + # Add in an context information provided by the model instance itself + context = {**base_context, **instance.report_context()} + + return context + + +class ReportTemplate(TemplateUploadMixin, ReportTemplateBase): + """Class representing the ReportTemplate database model.""" + + SUBDIR = 'report' + TEMPLATE_FIELD = 'template' + + @staticmethod + def get_api_url(): + """Return the API endpoint for the ReportTemplate model.""" + return reverse('api-report-template-list') def __init__(self, *args, **kwargs): """Initialize the particular report instance.""" @@ -113,90 +290,13 @@ class ReportBase(InvenTree.models.InvenTreeModel): 'page_size' ).choices = report.helpers.report_page_size_options() - def save(self, *args, **kwargs): - """Perform additional actions when the report is saved.""" - # Increment revision number - self.revision += 1 - - super().save() - - def __str__(self): - """Format a string representation of a report instance.""" - return f'{self.name} - {self.description}' - - @classmethod - def getSubdir(cls): - """Return the subdirectory where template files for this report model will be located.""" - return '' - - def rename_file(self, filename): - """Function for renaming uploaded file.""" - filename = os.path.basename(filename) - - path = os.path.join('report', 'report_template', self.getSubdir(), filename) - - fullpath = settings.MEDIA_ROOT.joinpath(path).resolve() - - # If the report file is the *same* filename as the one being uploaded, - # remove the original one from the media directory - if str(filename) == str(self.template): - if fullpath.exists(): - logger.info("Deleting existing report template: '%s'", filename) - os.remove(fullpath) - - # Ensure that the cache is cleared for this template! - cache.delete(fullpath) - - return path - - @property - def extension(self): - """Return the filename extension of the associated template file.""" - return os.path.splitext(self.template.name)[1].lower() - - @property - def template_name(self): - """Returns the file system path to the template file. - - Required for passing the file to an external process - """ - template = self.template.name - - # TODO @matmair change to using new file objects - template = template.replace('/', os.path.sep) - template = template.replace('\\', os.path.sep) - - template = settings.MEDIA_ROOT.joinpath(template) - - return template - - name = models.CharField( - blank=False, - max_length=100, - verbose_name=_('Name'), - help_text=_('Template name'), - ) - template = models.FileField( upload_to=rename_template, verbose_name=_('Template'), - help_text=_('Report template file'), + help_text=_('Template file'), validators=[FileExtensionValidator(allowed_extensions=['html', 'htm'])], ) - description = models.CharField( - max_length=250, - verbose_name=_('Description'), - help_text=_('Report template description'), - ) - - revision = models.PositiveIntegerField( - default=1, - verbose_name=_('Revision'), - help_text=_('Report revision number (auto-increments)'), - editable=False, - ) - page_size = models.CharField( max_length=20, default=report.helpers.report_page_size_default, @@ -210,25 +310,6 @@ class ReportBase(InvenTree.models.InvenTreeModel): help_text=_('Render report in landscape orientation'), ) - -class ReportTemplateBase(MetadataMixin, ReportBase): - """Reporting template model. - - Able to be passed context data - """ - - class Meta: - """Metaclass options. Abstract ensures no database table is created.""" - - abstract = True - - # Pass a single top-level object to the report template - object_to_print = None - - def get_context_data(self, request): - """Supply context data to the template for rendering.""" - return {} - def get_report_size(self): """Return the printable page size for this report.""" try: @@ -245,29 +326,18 @@ class ReportTemplateBase(MetadataMixin, ReportBase): return page_size - def context(self, request): - """All context to be passed to the renderer.""" - # Generate custom context data based on the particular report subclass - context = self.get_context_data(request) + def get_context(self, instance, request=None, **kwargs): + """Supply context data to the report template for rendering.""" + context = { + **super().get_context(instance, request), + 'page_size': self.get_report_size(), + 'landscape': self.landscape, + } - context['base_url'] = get_base_url(request=request) - context['date'] = InvenTree.helpers.current_date() - context['datetime'] = InvenTree.helpers.current_time() - context['page_size'] = self.get_report_size() - context['report_template'] = self - context['report_description'] = self.description - context['report_name'] = self.name - context['report_revision'] = self.revision - context['request'] = request - context['user'] = request.user - - # Pass the context through to any active reporting plugins - plugins = registry.with_mixin('report') - - for plugin in plugins: - # Let each plugin add its own context data + # Pass the context through to the plugin registry for any additional information + for plugin in registry.with_mixin('report'): try: - plugin.add_report_context(self, self.object_to_print, request, context) + plugin.add_report_context(self, instance, request, context) except Exception: InvenTree.exceptions.log_error( f'plugins.{plugin.slug}.add_report_context' @@ -275,376 +345,162 @@ class ReportTemplateBase(MetadataMixin, ReportBase): return context - def generate_filename(self, request, **kwargs): - """Generate a filename for this report.""" - template_string = Template(self.filename_pattern) - ctx = self.context(request) +class LabelTemplate(TemplateUploadMixin, ReportTemplateBase): + """Class representing the LabelTemplate database model.""" - context = Context(ctx) - - return template_string.render(context) - - def render_as_string(self, request, **kwargs): - """Render the report to a HTML string. - - Useful for debug mode (viewing generated code) - """ - return render_to_string(self.template_name, self.context(request), request) - - def render(self, request, **kwargs): - """Render the template to a PDF file. - - Uses django-weasyprint plugin to render HTML template against Weasyprint - """ - # TODO: Support custom filename generation! - # filename = kwargs.get('filename', 'report.pdf') - - # Render HTML template to PDF - wp = WeasyprintReportMixin( - request, - self.template_name, - base_url=request.build_absolute_uri('/'), - presentational_hints=True, - filename=self.generate_filename(request), - **kwargs, - ) - - return wp.render_to_response(self.context(request), **kwargs) - - filename_pattern = models.CharField( - default='report.pdf', - verbose_name=_('Filename Pattern'), - help_text=_('Pattern for generating report filenames'), - max_length=100, - ) - - enabled = models.BooleanField( - default=True, - verbose_name=_('Enabled'), - help_text=_('Report template is enabled'), - ) - - -class TestReport(ReportTemplateBase): - """Render a TestReport against a StockItem object.""" + SUBDIR = 'label' + TEMPLATE_FIELD = 'template' @staticmethod def get_api_url(): - """Return the API URL associated with the TestReport model.""" - return reverse('api-stockitem-testreport-list') + """Return the API endpoint for the LabelTemplate model.""" + return reverse('api-label-template-list') - @classmethod - def getSubdir(cls): - """Return the subdirectory where TestReport templates are located.""" - return 'test' - - filters = models.CharField( - blank=True, - max_length=250, - verbose_name=_('Filters'), - help_text=_( - 'StockItem query filters (comma-separated list of key=value pairs)' - ), - validators=[validate_stock_item_report_filters], + template = models.FileField( + upload_to=rename_template, + verbose_name=_('Template'), + help_text=_('Template file'), + validators=[FileExtensionValidator(allowed_extensions=['html', 'htm'])], ) - include_installed = models.BooleanField( + width = models.FloatField( + default=50, + verbose_name=_('Width [mm]'), + help_text=_('Label width, specified in mm'), + validators=[MinValueValidator(2)], + ) + + height = models.FloatField( + default=20, + verbose_name=_('Height [mm]'), + help_text=_('Label height, specified in mm'), + validators=[MinValueValidator(2)], + ) + + def generate_page_style(self, **kwargs): + """Generate @page style for the label template. + + This is inserted at the top of the style block for a given label + """ + width = kwargs.get('width', self.width) + height = kwargs.get('height', self.height) + margin = kwargs.get('margin', 0) + + return f""" + @page {{ + size: {width}mm {height}mm; + margin: {margin}mm; + }} + """ + + def get_context(self, instance, request=None, **kwargs): + """Supply context data to the label template for rendering.""" + context = { + **super().get_context(instance, request, **kwargs), + 'width': self.width, + 'height': self.height, + } + + if kwargs.pop('insert_page_style', True): + context['page_style'] = self.generate_page_style() + + # Pass the context through to any registered plugins + plugins = registry.with_mixin('report') + + for plugin in plugins: + # Let each plugin add its own context data + plugin.add_label_context(self, self.object_to_print, request, context) + + return context + + +class TemplateOutput(models.Model): + """Base class representing a generated file from a template. + + As reports (or labels) may take a long time to render, + this process is offloaded to the background worker process. + + The result is either a file made available for download, + or a message indicating that the output is handled externally. + """ + + class Meta: + """Metaclass options.""" + + abstract = True + + created = models.DateField(auto_now_add=True, editable=False) + + user = models.ForeignKey( + User, on_delete=models.SET_NULL, blank=True, null=True, related_name='+' + ) + + items = models.PositiveIntegerField( + default=0, verbose_name=_('Items'), help_text=_('Number of items to process') + ) + + complete = models.BooleanField( default=False, - verbose_name=_('Include Installed Tests'), - help_text=_( - 'Include test results for stock items installed inside assembled item' - ), + verbose_name=_('Complete'), + help_text=_('Report generation is complete'), ) - def get_test_keys(self, stock_item): - """Construct a flattened list of test 'keys' for this StockItem. - - The list is constructed as follows: - - First, any 'required' tests - - Second, any 'non required' tests - - Finally, any test results which do not match a test - """ - keys = [] - - for test in stock_item.part.getTestTemplates(required=True): - if test.key not in keys: - keys.append(test.key) - - for test in stock_item.part.getTestTemplates(required=False): - if test.key not in keys: - keys.append(test.key) - - for result in stock_item.testResultList( - include_installed=self.include_installed - ): - if result.key not in keys: - keys.append(result.key) - - return list(keys) - - def get_context_data(self, request): - """Return custom context data for the TestReport template.""" - stock_item = self.object_to_print - - return { - 'stock_item': stock_item, - 'serial': stock_item.serial, - 'part': stock_item.part, - 'parameters': stock_item.part.parameters_map(), - 'test_keys': self.get_test_keys(stock_item), - 'test_template_list': stock_item.part.getTestTemplates(), - 'test_template_map': stock_item.part.getTestTemplateMap(), - 'results': stock_item.testResultMap( - include_installed=self.include_installed - ), - 'result_list': stock_item.testResultList( - include_installed=self.include_installed - ), - 'installed_items': stock_item.get_installed_items(cascade=True), - } + progress = models.PositiveIntegerField( + default=0, verbose_name=_('Progress'), help_text=_('Report generation progress') + ) -class BuildReport(ReportTemplateBase): - """Build order / work order report.""" +class ReportOutput(TemplateOutput): + """Class representing a generated report output file.""" - @staticmethod - def get_api_url(): - """Return the API URL associated with the BuildReport model.""" - return reverse('api-build-report-list') + template = models.ForeignKey( + ReportTemplate, on_delete=models.CASCADE, verbose_name=_('Report Template') + ) - @classmethod - def getSubdir(cls): - """Return the subdirectory where BuildReport templates are located.""" - return 'build' - - filters = models.CharField( + output = models.FileField( + upload_to='report/output', blank=True, - max_length=250, - verbose_name=_('Build Filters'), - help_text=_('Build query filters (comma-separated list of key=value pairs'), - validators=[validate_build_report_filters], + null=True, + verbose_name=_('Output File'), + help_text=_('Generated output file'), ) - def get_context_data(self, request): - """Custom context data for the build report.""" - my_build = self.object_to_print - if not isinstance(my_build, build.models.Build): - raise TypeError('Provided model is not a Build object') +class LabelOutput(TemplateOutput): + """Class representing a generated label output file.""" - return { - 'build': my_build, - 'part': my_build.part, - 'build_outputs': my_build.build_outputs.all(), - 'line_items': my_build.build_lines.all(), - 'bom_items': my_build.part.get_bom_items(), - 'reference': my_build.reference, - 'quantity': my_build.quantity, - 'title': str(my_build), - } - - -class BillOfMaterialsReport(ReportTemplateBase): - """Render a Bill of Materials against a Part object.""" - - @staticmethod - def get_api_url(): - """Return the API URL associated with the BillOfMaterialsReport model.""" - return reverse('api-bom-report-list') - - @classmethod - def getSubdir(cls): - """Return the directory where BillOfMaterialsReport templates are located.""" - return 'bom' - - filters = models.CharField( + plugin = models.CharField( + max_length=100, blank=True, - max_length=250, - verbose_name=_('Part Filters'), - help_text=_('Part query filters (comma-separated list of key=value pairs'), - validators=[validate_part_report_filters], + verbose_name=_('Plugin'), + help_text=_('Label output plugin'), ) - def get_context_data(self, request): - """Return custom context data for the BillOfMaterialsReport template.""" - part = self.object_to_print + template = models.ForeignKey( + LabelTemplate, on_delete=models.CASCADE, verbose_name=_('Label Template') + ) - return { - 'part': part, - 'category': part.category, - 'bom_items': part.get_bom_items(), - } - - -class PurchaseOrderReport(ReportTemplateBase): - """Render a report against a PurchaseOrder object.""" - - @staticmethod - def get_api_url(): - """Return the API URL associated with the PurchaseOrderReport model.""" - return reverse('api-po-report-list') - - @classmethod - def getSubdir(cls): - """Return the directory where PurchaseOrderReport templates are stored.""" - return 'purchaseorder' - - filters = models.CharField( + output = models.FileField( + upload_to='label/output', blank=True, - max_length=250, - verbose_name=_('Filters'), - help_text=_('Purchase order query filters'), - validators=[validate_purchase_order_filters], + null=True, + verbose_name=_('Output File'), + help_text=_('Generated output file'), ) - def get_context_data(self, request): - """Return custom context data for the PurchaseOrderReport template.""" - order = self.object_to_print - return { - 'description': order.description, - 'lines': order.lines, - 'extra_lines': order.extra_lines, - 'order': order, - 'reference': order.reference, - 'supplier': order.supplier, - 'title': str(order), - } - - -class SalesOrderReport(ReportTemplateBase): - """Render a report against a SalesOrder object.""" - - @staticmethod - def get_api_url(): - """Return the API URL associated with the SalesOrderReport model.""" - return reverse('api-so-report-list') - - @classmethod - def getSubdir(cls): - """Return the subdirectory where SalesOrderReport templates are located.""" - return 'salesorder' - - filters = models.CharField( - blank=True, - max_length=250, - verbose_name=_('Filters'), - help_text=_('Sales order query filters'), - validators=[validate_sales_order_filters], - ) - - def get_context_data(self, request): - """Return custom context data for a SalesOrderReport template.""" - order = self.object_to_print - - return { - 'customer': order.customer, - 'description': order.description, - 'lines': order.lines, - 'extra_lines': order.extra_lines, - 'order': order, - 'reference': order.reference, - 'title': str(order), - } - - -class ReturnOrderReport(ReportTemplateBase): - """Render a custom report against a ReturnOrder object.""" - - @staticmethod - def get_api_url(): - """Return the API URL associated with the ReturnOrderReport model.""" - return reverse('api-return-order-report-list') - - @classmethod - def getSubdir(cls): - """Return the directory where the ReturnOrderReport templates are stored.""" - return 'returnorder' - - filters = models.CharField( - blank=True, - max_length=250, - verbose_name=_('Filters'), - help_text=_('Return order query filters'), - validators=[validate_return_order_filters], - ) - - def get_context_data(self, request): - """Return custom context data for the ReturnOrderReport template.""" - order = self.object_to_print - - return { - 'order': order, - 'description': order.description, - 'reference': order.reference, - 'customer': order.customer, - 'lines': order.lines, - 'extra_lines': order.extra_lines, - 'title': str(order), - } - - -def rename_snippet(instance, filename): - """Function to rename a report snippet once uploaded.""" - path = ReportSnippet.snippet_path(filename) - fullpath = settings.MEDIA_ROOT.joinpath(path).resolve() - - # If the snippet file is the *same* filename as the one being uploaded, - # delete the original one from the media directory - if str(filename) == str(instance.snippet): - if fullpath.exists(): - logger.info("Deleting existing snippet file: '%s'", filename) - os.remove(fullpath) - - # Ensure that the cache is deleted for this snippet - cache.delete(fullpath) - - return path - - -class ReportSnippet(models.Model): +class ReportSnippet(TemplateUploadMixin, models.Model): """Report template 'snippet' which can be used to make templates that can then be included in other reports. Useful for 'common' template actions, sub-templates, etc """ - def __str__(self) -> str: - """String representation of a ReportSnippet instance.""" - return f'snippets/{self.filename}' - - @property - def filename(self): - """Return the filename of the asset.""" - path = self.snippet.name - if path: - return os.path.basename(path) - else: - return '-' - - @staticmethod - def snippet_path(filename): - """Return the fully-qualified snippet path for the given filename.""" - return os.path.join('report', 'snippets', os.path.basename(str(filename))) - - def validate_unique(self, exclude=None): - """Validate that this report asset is unique.""" - proposed_path = self.snippet_path(self.snippet) - - if ( - ReportSnippet.objects.filter(snippet=proposed_path) - .exclude(pk=self.pk) - .count() - > 0 - ): - raise ValidationError({ - 'snippet': _('Snippet file with this name already exists') - }) - - return super().validate_unique(exclude) + SUBDIR = 'snippets' + TEMPLATE_FIELD = 'snippet' snippet = models.FileField( - upload_to=rename_snippet, + upload_to=rename_template, verbose_name=_('Snippet'), help_text=_('Report snippet file'), validators=[FileExtensionValidator(allowed_extensions=['html', 'htm'])], @@ -657,26 +513,7 @@ class ReportSnippet(models.Model): ) -def rename_asset(instance, filename): - """Function to rename an asset file when uploaded.""" - path = ReportAsset.asset_path(filename) - fullpath = settings.MEDIA_ROOT.joinpath(path).resolve() - - # If the asset file is the *same* filename as the one being uploaded, - # delete the original one from the media directory - if str(filename) == str(instance.asset): - if fullpath.exists(): - # Check for existing asset file with the same name - logger.info("Deleting existing asset file: '%s'", filename) - os.remove(fullpath) - - # Ensure the cache is deleted for this asset - cache.delete(fullpath) - - return path - - -class ReportAsset(models.Model): +class ReportAsset(TemplateUploadMixin, models.Model): """Asset file for use in report templates. For example, an image to use in a header file. @@ -684,41 +521,12 @@ class ReportAsset(models.Model): and can be loaded in a template using the {% report_asset %} tag. """ - def __str__(self): - """String representation of a ReportAsset instance.""" - return f'assets/{self.filename}' - - @property - def filename(self): - """Return the filename of the asset.""" - path = self.asset.name - if path: - return os.path.basename(path) - else: - return '-' - - @staticmethod - def asset_path(filename): - """Return the fully-qualified asset path for the given filename.""" - return os.path.join('report', 'assets', os.path.basename(str(filename))) - - def validate_unique(self, exclude=None): - """Validate that this report asset is unique.""" - proposed_path = self.asset_path(self.asset) - - if ( - ReportAsset.objects.filter(asset=proposed_path).exclude(pk=self.pk).count() - > 0 - ): - raise ValidationError({ - 'asset': _('Asset file with this name already exists') - }) - - return super().validate_unique(exclude) + SUBDIR = 'assets' + TEMPLATE_FIELD = 'asset' # Asset file asset = models.FileField( - upload_to=rename_asset, + upload_to=rename_template, verbose_name=_('Asset'), help_text=_('Report asset file'), ) @@ -729,42 +537,3 @@ class ReportAsset(models.Model): verbose_name=_('Description'), help_text=_('Asset file description'), ) - - -class StockLocationReport(ReportTemplateBase): - """Render a StockLocationReport against a StockLocation object.""" - - @staticmethod - def get_api_url(): - """Return the API URL associated with the StockLocationReport model.""" - return reverse('api-stocklocation-report-list') - - @classmethod - def getSubdir(cls): - """Return the subdirectory where StockLocationReport templates are located.""" - return 'slr' - - filters = models.CharField( - blank=True, - max_length=250, - verbose_name=_('Filters'), - help_text=_( - 'stock location query filters (comma-separated list of key=value pairs)' - ), - validators=[validate_stock_location_report_filters], - ) - - def get_context_data(self, request): - """Return custom context data for the StockLocationReport template.""" - stock_location = self.object_to_print - - if not isinstance(stock_location, stock.models.StockLocation): - raise TypeError( - 'Provided model is not a StockLocation object -> ' - + str(type(stock_location)) - ) - - return { - 'stock_location': stock_location, - 'stock_items': stock_location.get_stock_items(), - } diff --git a/src/backend/InvenTree/report/serializers.py b/src/backend/InvenTree/report/serializers.py index 320c489a37..1dadc7114f 100644 --- a/src/backend/InvenTree/report/serializers.py +++ b/src/backend/InvenTree/report/serializers.py @@ -1,102 +1,208 @@ """API serializers for the reporting models.""" +from django.utils.translation import gettext_lazy as _ + from rest_framework import serializers +import plugin.models +import plugin.serializers +import report.helpers import report.models from InvenTree.serializers import ( InvenTreeAttachmentSerializerField, InvenTreeModelSerializer, + UserSerializer, ) class ReportSerializerBase(InvenTreeModelSerializer): - """Base class for report serializer.""" + """Base serializer class for report and label templates.""" - template = InvenTreeAttachmentSerializerField(required=True) + def __init__(self, *args, **kwargs): + """Override the constructor for the ReportSerializerBase. + + The primary goal here is to ensure that the 'choices' attribute + is set correctly for the 'model_type' field. + """ + super().__init__(*args, **kwargs) + + if len(self.fields['model_type'].choices) == 0: + self.fields['model_type'].choices = report.helpers.report_model_options() @staticmethod - def report_fields(): - """Generic serializer fields for a report template.""" + def base_fields(): + """Base serializer field set.""" return [ 'pk', 'name', 'description', + 'model_type', 'template', 'filters', - 'page_size', - 'landscape', + 'filename_pattern', 'enabled', + 'revision', ] + template = InvenTreeAttachmentSerializerField(required=True) -class TestReportSerializer(ReportSerializerBase): - """Serializer class for the TestReport model.""" + revision = serializers.IntegerField(read_only=True) + + # Note: The choices are overridden at run-time + model_type = serializers.ChoiceField( + label=_('Model Type'), + choices=report.helpers.report_model_options(), + required=True, + allow_blank=False, + allow_null=False, + ) + + +class ReportTemplateSerializer(ReportSerializerBase): + """Serializer class for report template model.""" class Meta: """Metaclass options.""" - model = report.models.TestReport - fields = ReportSerializerBase.report_fields() + model = report.models.ReportTemplate + fields = [*ReportSerializerBase.base_fields(), 'page_size', 'landscape'] + + page_size = serializers.ChoiceField( + required=False, + default=report.helpers.report_page_size_default(), + choices=report.helpers.report_page_size_options(), + ) -class BuildReportSerializer(ReportSerializerBase): - """Serializer class for the BuildReport model.""" +class ReportPrintSerializer(serializers.Serializer): + """Serializer class for printing a report.""" class Meta: """Metaclass options.""" - model = report.models.BuildReport - fields = ReportSerializerBase.report_fields() + fields = ['template', 'items'] + + template = serializers.PrimaryKeyRelatedField( + queryset=report.models.ReportTemplate.objects.all(), + many=False, + required=True, + allow_null=False, + label=_('Template'), + help_text=_('Select report template'), + ) + + items = serializers.ListField( + child=serializers.IntegerField(), + required=True, + allow_empty=False, + label=_('Items'), + help_text=_('List of item primary keys to include in the report'), + ) -class BOMReportSerializer(ReportSerializerBase): - """Serializer class for the BillOfMaterialsReport model.""" +class LabelPrintSerializer(serializers.Serializer): + """Serializer class for printing a label.""" + + # List of extra plugin field names + plugin_fields = [] class Meta: """Metaclass options.""" - model = report.models.BillOfMaterialsReport - fields = ReportSerializerBase.report_fields() + fields = ['template', 'items', 'plugin'] + + def __init__(self, *args, **kwargs): + """Override the constructor to add the extra plugin fields.""" + # Reset to a known state + self.Meta.fields = ['template', 'items', 'plugin'] + + if plugin_serializer := kwargs.pop('plugin_serializer', None): + for key, field in plugin_serializer.fields.items(): + self.Meta.fields.append(key) + setattr(self, key, field) + + super().__init__(*args, **kwargs) + + template = serializers.PrimaryKeyRelatedField( + queryset=report.models.LabelTemplate.objects.all(), + many=False, + required=True, + allow_null=False, + label=_('Template'), + help_text=_('Select label template'), + ) + + # Plugin field - note that we use the 'key' (not the pk) for lookup + plugin = plugin.serializers.PluginRelationSerializer( + many=False, + required=False, + allow_null=False, + label=_('Printing Plugin'), + help_text=_('Select plugin to use for label printing'), + ) + + items = serializers.ListField( + child=serializers.IntegerField(), + required=True, + allow_empty=False, + label=_('Items'), + help_text=_('List of item primary keys to include in the report'), + ) -class PurchaseOrderReportSerializer(ReportSerializerBase): - """Serializer class for the PurchaseOrdeReport model.""" +class LabelTemplateSerializer(ReportSerializerBase): + """Serializer class for label template model.""" class Meta: """Metaclass options.""" - model = report.models.PurchaseOrderReport - fields = ReportSerializerBase.report_fields() + model = report.models.LabelTemplate + fields = [*ReportSerializerBase.base_fields(), 'width', 'height'] -class SalesOrderReportSerializer(ReportSerializerBase): - """Serializer class for the SalesOrderReport model.""" +class BaseOutputSerializer(InvenTreeModelSerializer): + """Base serializer class for template output.""" + + @staticmethod + def base_fields(): + """Basic field set.""" + return [ + 'pk', + 'created', + 'user', + 'user_detail', + 'model_type', + 'items', + 'complete', + 'progress', + 'output', + 'template', + ] + + output = InvenTreeAttachmentSerializerField() + model_type = serializers.CharField(source='template.model_type', read_only=True) + + user_detail = UserSerializer(source='user', read_only=True, many=False) + + +class LabelOutputSerializer(BaseOutputSerializer): + """Serializer class for the LabelOutput model.""" class Meta: """Metaclass options.""" - model = report.models.SalesOrderReport - fields = ReportSerializerBase.report_fields() + model = report.models.LabelOutput + fields = [*BaseOutputSerializer.base_fields(), 'plugin'] -class ReturnOrderReportSerializer(ReportSerializerBase): - """Serializer class for the ReturnOrderReport model.""" +class ReportOutputSerializer(BaseOutputSerializer): + """Serializer class for the ReportOutput model.""" class Meta: """Metaclass options.""" - model = report.models.ReturnOrderReport - fields = ReportSerializerBase.report_fields() - - -class StockLocationReportSerializer(ReportSerializerBase): - """Serializer class for the StockLocationReport model.""" - - class Meta: - """Metaclass options.""" - - model = report.models.StockLocationReport - fields = ReportSerializerBase.report_fields() + model = report.models.ReportOutput + fields = BaseOutputSerializer.base_fields() class ReportSnippetSerializer(InvenTreeModelSerializer): diff --git a/src/backend/InvenTree/report/tasks.py b/src/backend/InvenTree/report/tasks.py new file mode 100644 index 0000000000..606cd71304 --- /dev/null +++ b/src/backend/InvenTree/report/tasks.py @@ -0,0 +1,17 @@ +"""Background tasks for the report app.""" + +from datetime import timedelta + +from InvenTree.helpers import current_time +from InvenTree.tasks import ScheduledTask, scheduled_task +from report.models import LabelOutput, ReportOutput + + +@scheduled_task(ScheduledTask.DAILY) +def cleanup_old_report_outputs(): + """Remove old report/label outputs from the database.""" + # Remove any outputs which are older than 5 days + threshold = current_time() - timedelta(days=5) + + LabelOutput.objects.filter(created__lte=threshold).delete() + ReportOutput.objects.filter(created__lte=threshold).delete() diff --git a/src/backend/InvenTree/label/templates/label/buildline/buildline_label_base.html b/src/backend/InvenTree/report/templates/label/buildline_label.html similarity index 100% rename from src/backend/InvenTree/label/templates/label/buildline/buildline_label_base.html rename to src/backend/InvenTree/report/templates/label/buildline_label.html diff --git a/src/backend/InvenTree/label/templates/label/label_base.html b/src/backend/InvenTree/report/templates/label/label_base.html similarity index 100% rename from src/backend/InvenTree/label/templates/label/label_base.html rename to src/backend/InvenTree/report/templates/label/label_base.html diff --git a/src/backend/InvenTree/label/templates/label/part/part_label.html b/src/backend/InvenTree/report/templates/label/part_label.html similarity index 100% rename from src/backend/InvenTree/label/templates/label/part/part_label.html rename to src/backend/InvenTree/report/templates/label/part_label.html diff --git a/src/backend/InvenTree/label/templates/label/part/part_label_code128.html b/src/backend/InvenTree/report/templates/label/part_label_code128.html similarity index 100% rename from src/backend/InvenTree/label/templates/label/part/part_label_code128.html rename to src/backend/InvenTree/report/templates/label/part_label_code128.html diff --git a/src/backend/InvenTree/label/templates/label/stockitem/qr.html b/src/backend/InvenTree/report/templates/label/stockitem_qr.html similarity index 100% rename from src/backend/InvenTree/label/templates/label/stockitem/qr.html rename to src/backend/InvenTree/report/templates/label/stockitem_qr.html diff --git a/src/backend/InvenTree/label/templates/label/stocklocation/qr.html b/src/backend/InvenTree/report/templates/label/stocklocation_qr.html similarity index 100% rename from src/backend/InvenTree/label/templates/label/stocklocation/qr.html rename to src/backend/InvenTree/report/templates/label/stocklocation_qr.html diff --git a/src/backend/InvenTree/label/templates/label/stocklocation/qr_and_text.html b/src/backend/InvenTree/report/templates/label/stocklocation_qr_and_text.html similarity index 100% rename from src/backend/InvenTree/label/templates/label/stocklocation/qr_and_text.html rename to src/backend/InvenTree/report/templates/label/stocklocation_qr_and_text.html diff --git a/src/backend/InvenTree/report/templates/report/inventree_build_order.html b/src/backend/InvenTree/report/templates/report/inventree_build_order.html deleted file mode 100644 index 72e52a889a..0000000000 --- a/src/backend/InvenTree/report/templates/report/inventree_build_order.html +++ /dev/null @@ -1,3 +0,0 @@ -{% extends "report/inventree_build_order_base.html" %} - - diff --git a/src/backend/InvenTree/report/templates/report/inventree_build_order_base.html b/src/backend/InvenTree/report/templates/report/inventree_build_order_report.html similarity index 100% rename from src/backend/InvenTree/report/templates/report/inventree_build_order_base.html rename to src/backend/InvenTree/report/templates/report/inventree_build_order_report.html diff --git a/src/backend/InvenTree/report/templates/report/inventree_order_report_base.html b/src/backend/InvenTree/report/templates/report/inventree_order_report_base.html index 6f936681dc..f099b8425d 100644 --- a/src/backend/InvenTree/report/templates/report/inventree_order_report_base.html +++ b/src/backend/InvenTree/report/templates/report/inventree_order_report_base.html @@ -12,7 +12,7 @@ margin-top: 4cm; {% endblock page_margin %} {% block bottom_left %} -content: "v{{ report_revision }} - {% format_date date %}"; +content: "v{{ template_revision }} - {% format_date date %}"; {% endblock bottom_left %} {% block bottom_center %} diff --git a/src/backend/InvenTree/report/templates/report/inventree_po_report.html b/src/backend/InvenTree/report/templates/report/inventree_po_report.html deleted file mode 100644 index 184ea896e1..0000000000 --- a/src/backend/InvenTree/report/templates/report/inventree_po_report.html +++ /dev/null @@ -1 +0,0 @@ -{% extends "report/inventree_po_report_base.html" %} diff --git a/src/backend/InvenTree/report/templates/report/inventree_po_report_base.html b/src/backend/InvenTree/report/templates/report/inventree_purchase_order_report.html similarity index 100% rename from src/backend/InvenTree/report/templates/report/inventree_po_report_base.html rename to src/backend/InvenTree/report/templates/report/inventree_purchase_order_report.html diff --git a/src/backend/InvenTree/report/templates/report/inventree_return_order_report.html b/src/backend/InvenTree/report/templates/report/inventree_return_order_report.html index cece937a0e..0dbc062e71 100644 --- a/src/backend/InvenTree/report/templates/report/inventree_return_order_report.html +++ b/src/backend/InvenTree/report/templates/report/inventree_return_order_report.html @@ -1 +1,62 @@ -{% extends "report/inventree_return_order_report_base.html" %} +{% extends "report/inventree_order_report_base.html" %} + +{% load i18n %} +{% load report %} +{% load barcode %} +{% load inventree_extras %} +{% load markdownify %} + +{% block header_content %} + + +
+

{% trans "Return Order" %} {{ prefix }}{{ reference }}

+ {% if customer %}{{ customer.name }}{% endif %} +
+{% endblock header_content %} + +{% block page_content %} +

{% trans "Line Items" %}

+ + + + + + + + + + + + {% for line in lines.all %} + + + + + + + {% endfor %} + + {% if extra_lines %} + + {% for line in extra_lines.all %} + + + + + + + {% endfor %} + {% endif %} + + +
{% trans "Part" %}{% trans "Serial Number" %}{% trans "Reference" %}{% trans "Note" %}
+
+ {% trans "Image" %} +
+
+ {{ line.item.part.full_name }} +
+
{{ line.item.serial }}{{ line.reference }}{{ line.notes }}
{% trans "Extra Line Items" %}
{{ line.reference }}{{ line.notes }}
+ +{% endblock page_content %} diff --git a/src/backend/InvenTree/report/templates/report/inventree_return_order_report_base.html b/src/backend/InvenTree/report/templates/report/inventree_return_order_report_base.html deleted file mode 100644 index 0dbc062e71..0000000000 --- a/src/backend/InvenTree/report/templates/report/inventree_return_order_report_base.html +++ /dev/null @@ -1,62 +0,0 @@ -{% extends "report/inventree_order_report_base.html" %} - -{% load i18n %} -{% load report %} -{% load barcode %} -{% load inventree_extras %} -{% load markdownify %} - -{% block header_content %} - - -
-

{% trans "Return Order" %} {{ prefix }}{{ reference }}

- {% if customer %}{{ customer.name }}{% endif %} -
-{% endblock header_content %} - -{% block page_content %} -

{% trans "Line Items" %}

- - - - - - - - - - - - {% for line in lines.all %} - - - - - - - {% endfor %} - - {% if extra_lines %} - - {% for line in extra_lines.all %} - - - - - - - {% endfor %} - {% endif %} - - -
{% trans "Part" %}{% trans "Serial Number" %}{% trans "Reference" %}{% trans "Note" %}
-
- {% trans "Image" %} -
-
- {{ line.item.part.full_name }} -
-
{{ line.item.serial }}{{ line.reference }}{{ line.notes }}
{% trans "Extra Line Items" %}
{{ line.reference }}{{ line.notes }}
- -{% endblock page_content %} diff --git a/src/backend/InvenTree/report/templates/report/inventree_so_report_base.html b/src/backend/InvenTree/report/templates/report/inventree_sales_order_report.html similarity index 100% rename from src/backend/InvenTree/report/templates/report/inventree_so_report_base.html rename to src/backend/InvenTree/report/templates/report/inventree_sales_order_report.html diff --git a/src/backend/InvenTree/report/templates/report/inventree_so_report.html b/src/backend/InvenTree/report/templates/report/inventree_so_report.html deleted file mode 100644 index dbb4543bf3..0000000000 --- a/src/backend/InvenTree/report/templates/report/inventree_so_report.html +++ /dev/null @@ -1 +0,0 @@ -{% extends "report/inventree_so_report_base.html" %} diff --git a/src/backend/InvenTree/report/templates/report/inventree_slr_report.html b/src/backend/InvenTree/report/templates/report/inventree_stock_location_report.html similarity index 100% rename from src/backend/InvenTree/report/templates/report/inventree_slr_report.html rename to src/backend/InvenTree/report/templates/report/inventree_stock_location_report.html diff --git a/src/backend/InvenTree/report/templates/report/inventree_test_report.html b/src/backend/InvenTree/report/templates/report/inventree_test_report.html index 4607494f8f..4e25b4598f 100644 --- a/src/backend/InvenTree/report/templates/report/inventree_test_report.html +++ b/src/backend/InvenTree/report/templates/report/inventree_test_report.html @@ -1,3 +1,184 @@ -{% extends "report/inventree_test_report_base.html" %} +{% extends "report/inventree_report_base.html" %} - +{% load i18n %} +{% load report %} +{% load inventree_extras %} + +{% block style %} +.test-table { + width: 100%; +} + +{% block bottom_left %} +content: "{% format_date date %}"; +{% endblock bottom_left %} + +{% block bottom_center %} +content: "{% inventree_version shortstring=True %}"; +{% endblock bottom_center %} + +{% block top_center %} +content: "{% trans 'Stock Item Test Report' %}"; +{% endblock top_center %} + +.test-row { + padding: 3px; +} + +.test-pass { + color: #5f5; +} + +.test-fail { + color: #F55; +} + +.test-not-found { + color: #33A; +} + +.required-test-not-found { + color: #EEE; + background-color: #F55; +} + +.container { + padding: 5px; + border: 1px solid; +} + +.text-left { + display: inline-block; + width: 50%; +} + +.img-right { + display: inline; + align-content: right; + align-items: right; + width: 50%; +} + +.part-img { + height: 4cm; +} + +{% endblock style %} + +{% block pre_page_content %} + +{% endblock pre_page_content %} + +{% block page_content %} + +
+
+

+ {{ part.full_name }} +

+

{{ part.description }}

+

{{ stock_item.location }}

+

Stock Item ID: {{ stock_item.pk }}

+
+
+ {% trans "Part image" %} +
+

+ {% if stock_item.is_serialized %} + {% trans "Serial Number" %}: {{ stock_item.serial }} + {% else %} + {% trans "Quantity" %}: {% decimal stock_item.quantity %} + {% endif %} +

+
+
+ +{% if test_keys|length > 0 %} +

{% trans "Test Results" %}

+ + + + + + + + + + + + + + + + {% for key in test_keys %} + + {% getkey test_template_map key as test_template %} + {% getkey results key as test_result %} + + + {% if test_result %} + {% if test_result.result %} + + {% else %} + + {% endif %} + + + + {% else %} + {% if test_template.required %} + + {% else %} + + {% endif %} + {% endif %} + + {% endfor %} + + +
{% trans "Test" %}{% trans "Result" %}{% trans "Value" %}{% trans "User" %}{% trans "Date" %}

+ {% if test_template %} + {% render_html_text test_template.test_name bold=test_template.required %} + {% elif test_result %} + {% render_html_text test_result.test italic=True %} + {% else %} + + {{ key }} + {% endif %} + {% trans "Pass" %}{% trans "Fail" %}{{ test_result.value }}{{ test_result.user.username }}{% format_date test_result.date.date %}{% trans "No result (required)" %}{% trans "No result" %}
+{% else %} +No tests defined for this stock item +{% endif %} + +{% if installed_items|length > 0 %} +

{% trans "Installed Items" %}

+ + + + + + {% for sub_item in installed_items %} + + + + + {% endfor %} + +
+ {% trans "Part image" %} + {{ sub_item.part.full_name }} + + {% if sub_item.serialized %} + {% trans "Serial" %}: {{ sub_item.serial }} + {% else %} + {% trans "Quantity" %}: {% decimal sub_item.quantity %} + {% endif %} +
+ +{% endif %} + +{% endblock page_content %} + +{% block post_page_content %} + +{% endblock post_page_content %} diff --git a/src/backend/InvenTree/report/templates/report/inventree_test_report_base.html b/src/backend/InvenTree/report/templates/report/inventree_test_report_base.html deleted file mode 100644 index 4e25b4598f..0000000000 --- a/src/backend/InvenTree/report/templates/report/inventree_test_report_base.html +++ /dev/null @@ -1,184 +0,0 @@ -{% extends "report/inventree_report_base.html" %} - -{% load i18n %} -{% load report %} -{% load inventree_extras %} - -{% block style %} -.test-table { - width: 100%; -} - -{% block bottom_left %} -content: "{% format_date date %}"; -{% endblock bottom_left %} - -{% block bottom_center %} -content: "{% inventree_version shortstring=True %}"; -{% endblock bottom_center %} - -{% block top_center %} -content: "{% trans 'Stock Item Test Report' %}"; -{% endblock top_center %} - -.test-row { - padding: 3px; -} - -.test-pass { - color: #5f5; -} - -.test-fail { - color: #F55; -} - -.test-not-found { - color: #33A; -} - -.required-test-not-found { - color: #EEE; - background-color: #F55; -} - -.container { - padding: 5px; - border: 1px solid; -} - -.text-left { - display: inline-block; - width: 50%; -} - -.img-right { - display: inline; - align-content: right; - align-items: right; - width: 50%; -} - -.part-img { - height: 4cm; -} - -{% endblock style %} - -{% block pre_page_content %} - -{% endblock pre_page_content %} - -{% block page_content %} - -
-
-

- {{ part.full_name }} -

-

{{ part.description }}

-

{{ stock_item.location }}

-

Stock Item ID: {{ stock_item.pk }}

-
-
- {% trans "Part image" %} -
-

- {% if stock_item.is_serialized %} - {% trans "Serial Number" %}: {{ stock_item.serial }} - {% else %} - {% trans "Quantity" %}: {% decimal stock_item.quantity %} - {% endif %} -

-
-
- -{% if test_keys|length > 0 %} -

{% trans "Test Results" %}

- - - - - - - - - - - - - - - - {% for key in test_keys %} - - {% getkey test_template_map key as test_template %} - {% getkey results key as test_result %} - - - {% if test_result %} - {% if test_result.result %} - - {% else %} - - {% endif %} - - - - {% else %} - {% if test_template.required %} - - {% else %} - - {% endif %} - {% endif %} - - {% endfor %} - - -
{% trans "Test" %}{% trans "Result" %}{% trans "Value" %}{% trans "User" %}{% trans "Date" %}

- {% if test_template %} - {% render_html_text test_template.test_name bold=test_template.required %} - {% elif test_result %} - {% render_html_text test_result.test italic=True %} - {% else %} - - {{ key }} - {% endif %} - {% trans "Pass" %}{% trans "Fail" %}{{ test_result.value }}{{ test_result.user.username }}{% format_date test_result.date.date %}{% trans "No result (required)" %}{% trans "No result" %}
-{% else %} -No tests defined for this stock item -{% endif %} - -{% if installed_items|length > 0 %} -

{% trans "Installed Items" %}

- - - - - - {% for sub_item in installed_items %} - - - - - {% endfor %} - -
- {% trans "Part image" %} - {{ sub_item.part.full_name }} - - {% if sub_item.serialized %} - {% trans "Serial" %}: {{ sub_item.serial }} - {% else %} - {% trans "Quantity" %}: {% decimal sub_item.quantity %} - {% endif %} -
- -{% endif %} - -{% endblock page_content %} - -{% block post_page_content %} - -{% endblock post_page_content %} diff --git a/src/backend/InvenTree/report/tests.py b/src/backend/InvenTree/report/tests.py index 4d40a2c460..eeec4f80ea 100644 --- a/src/backend/InvenTree/report/tests.py +++ b/src/backend/InvenTree/report/tests.py @@ -1,12 +1,10 @@ """Unit testing for the various report models.""" -import os -import shutil from io import StringIO +from django.apps import apps from django.conf import settings from django.core.cache import cache -from django.http.response import StreamingHttpResponse from django.test import TestCase, override_settings from django.urls import reverse from django.utils import timezone @@ -17,9 +15,11 @@ from PIL import Image import report.models as report_models from build.models import Build -from common.models import InvenTreeSetting, InvenTreeUserSetting -from InvenTree.files import MEDIA_STORAGE_DIR, TEMPLATES_DIR +from common.models import InvenTreeSetting from InvenTree.unit_test import InvenTreeAPITestCase +from order.models import ReturnOrder, SalesOrder +from plugin.registry import registry +from report.models import LabelTemplate, ReportTemplate from report.templatetags import barcode as barcode_tags from report.templatetags import report as report_tags from stock.models import StockItem, StockItemAttachment @@ -233,64 +233,29 @@ class ReportTest(InvenTreeAPITestCase): 'stock_tests', 'bom', 'build', + 'order', + 'return_order', + 'sales_order', ] superuser = True - model = None - list_url = None - detail_url = None - print_url = None - def setUp(self): """Ensure cache is cleared as part of test setup.""" cache.clear() + + apps.get_app_config('report').create_default_reports() + return super().setUp() - def copyReportTemplate(self, filename, description): - """Copy the provided report template into the required media directory.""" - src_dir = TEMPLATES_DIR.joinpath('report', 'templates', 'report') - template_dir = os.path.join('report', 'inventree', self.model.getSubdir()) - dst_dir = MEDIA_STORAGE_DIR.joinpath(template_dir) - - if not dst_dir.exists(): # pragma: no cover - dst_dir.mkdir(parents=True, exist_ok=True) - - src_file = src_dir.joinpath(filename) - dst_file = dst_dir.joinpath(filename) - - if not dst_file.exists(): # pragma: no cover - shutil.copyfile(src_file, dst_file) - - # Convert to an "internal" filename - db_filename = os.path.join(template_dir, filename) - - # Create a database entry for this report template! - self.model.objects.create( - name=os.path.splitext(filename)[0], - description=description, - template=db_filename, - enabled=True, - ) - - def test_api_url(self): - """Test returned API Url against URL tag defined in this file.""" - if not self.list_url: - return - - self.assertEqual(reverse(self.list_url), self.model.get_api_url()) - def test_list_endpoint(self): """Test that the LIST endpoint works for each report.""" - if not self.list_url: - return - - url = reverse(self.list_url) + url = reverse('api-report-template-list') response = self.get(url) self.assertEqual(response.status_code, 200) - reports = self.model.objects.all() + reports = ReportTemplate.objects.all() n = len(reports) # API endpoint must return correct number of reports @@ -317,10 +282,7 @@ class ReportTest(InvenTreeAPITestCase): def test_create_endpoint(self): """Test that creating a new report works for each report.""" - if not self.list_url: - return - - url = reverse(self.list_url) + url = reverse('api-report-template-list') # Create a new report # Django REST API "APITestCase" does not work like requests - to send a file without it existing on disk, @@ -330,16 +292,23 @@ class ReportTest(InvenTreeAPITestCase): ) filestr.name = 'ExampleTemplate.html' - response = self.post( - url, - data={ - 'name': 'New report', - 'description': 'A fancy new report created through API test', - 'template': filestr, - }, - format=None, - expected_code=201, - ) + data = { + 'name': 'New report', + 'description': 'A fancy new report created through API test', + 'template': filestr, + 'model_type': 'part2', + } + + # Test with invalid model type + response = self.post(url, data=data, expected_code=400) + + self.assertIn('"part2" is not a valid choice', str(response.data['model_type'])) + + # With valid model type + data['model_type'] = 'part' + filestr.seek(0) + + response = self.post(url, data=data, format=None, expected_code=201) # Make sure the expected keys are in the response self.assertIn('pk', response.data) @@ -357,10 +326,7 @@ class ReportTest(InvenTreeAPITestCase): def test_detail_endpoint(self): """Test that the DETAIL endpoint works for each report.""" - if not self.detail_url: - return - - reports = self.model.objects.all() + reports = ReportTemplate.objects.all() n = len(reports) @@ -369,7 +335,8 @@ class ReportTest(InvenTreeAPITestCase): # Check detail page for first report response = self.get( - reverse(self.detail_url, kwargs={'pk': reports[0].pk}), expected_code=200 + reverse('api-report-template-detail', kwargs={'pk': reports[0].pk}), + expected_code=200, ) # Make sure the expected keys are in the response @@ -387,7 +354,7 @@ class ReportTest(InvenTreeAPITestCase): # Check PATCH method response = self.patch( - reverse(self.detail_url, kwargs={'pk': reports[0].pk}), + reverse('api-report-template-detail', kwargs={'pk': reports[0].pk}), { 'name': 'Changed name during test', 'description': 'New version of the template', @@ -414,218 +381,142 @@ class ReportTest(InvenTreeAPITestCase): # Delete the last report response = self.delete( - reverse(self.detail_url, kwargs={'pk': reports[n - 1].pk}), + reverse('api-report-template-detail', kwargs={'pk': reports[n - 1].pk}), expected_code=204, ) def test_metadata(self): """Unit tests for the metadata field.""" - if self.model is not None: - p = self.model.objects.first() + p = ReportTemplate.objects.first() - self.assertEqual(p.metadata, {}) + self.assertEqual(p.metadata, {}) - self.assertIsNone(p.get_metadata('test')) - self.assertEqual(p.get_metadata('test', backup_value=123), 123) + self.assertIsNone(p.get_metadata('test')) + self.assertEqual(p.get_metadata('test', backup_value=123), 123) - # Test update via the set_metadata() method - p.set_metadata('test', 3) - self.assertEqual(p.get_metadata('test'), 3) + # Test update via the set_metadata() method + p.set_metadata('test', 3) + self.assertEqual(p.get_metadata('test'), 3) - for k in ['apple', 'banana', 'carrot', 'carrot', 'banana']: - p.set_metadata(k, k) + for k in ['apple', 'banana', 'carrot', 'carrot', 'banana']: + p.set_metadata(k, k) - self.assertEqual(len(p.metadata.keys()), 4) + self.assertEqual(len(p.metadata.keys()), 4) -class TestReportTest(ReportTest): +class PrintTestMixins: + """Mixin that enables e2e printing tests.""" + + plugin_ref = 'samplelabelprinter' + + def do_activate_plugin(self): + """Activate the 'samplelabel' plugin.""" + config = registry.get_plugin(self.plugin_ref).plugin_config() + config.active = True + config.save() + + def run_print_test(self, qs, model_type, label: bool = True): + """Run tests on single and multiple page printing. + + Args: + qs: class of the base queryset + model_type: the model type of the queryset + label: whether the model is a label or report + """ + mdl = LabelTemplate if label else ReportTemplate + url = reverse('api-label-print' if label else 'api-report-print') + + qs = qs.objects.all() + template = mdl.objects.filter(enabled=True, model_type=model_type).first() + plugin = registry.get_plugin(self.plugin_ref) + + # Single page printing + self.post( + url, + {'template': template.pk, 'plugin': plugin.pk, 'items': [qs[0].pk]}, + expected_code=201, + ) + + # Multi page printing + self.post( + url, + { + 'template': template.pk, + 'plugin': plugin.pk, + 'items': [item.pk for item in qs], + }, + expected_code=201, + ) + + +class TestReportTest(PrintTestMixins, ReportTest): """Unit testing class for the stock item TestReport model.""" - model = report_models.TestReport + model = report_models.ReportTemplate - list_url = 'api-stockitem-testreport-list' - detail_url = 'api-stockitem-testreport-detail' - print_url = 'api-stockitem-testreport-print' + list_url = 'api-report-template-list' + detail_url = 'api-report-template-detail' + print_url = 'api-report-print' def setUp(self): """Setup function for the stock item TestReport.""" - self.copyReportTemplate('inventree_test_report.html', 'stock item test report') + apps.get_app_config('report').create_default_reports() + self.do_activate_plugin() return super().setUp() def test_print(self): """Printing tests for the TestReport.""" - report = self.model.objects.first() + template = ReportTemplate.objects.filter( + enabled=True, model_type='stockitem' + ).first() + self.assertIsNotNone(template) - url = reverse(self.print_url, kwargs={'pk': report.pk}) + url = reverse(self.print_url) # Try to print without providing a valid StockItem - response = self.get(url, expected_code=400) + self.post(url, {'template': template.pk}, expected_code=400) # Try to print with an invalid StockItem - response = self.get(url, {'item': 9999}, expected_code=400) + self.post(url, {'template': template.pk, 'items': [9999]}, expected_code=400) # Now print with a valid StockItem item = StockItem.objects.first() - response = self.get(url, {'item': item.pk}, expected_code=200) + response = self.post( + url, {'template': template.pk, 'items': [item.pk]}, expected_code=201 + ) - # Response should be a StreamingHttpResponse (PDF file) - self.assertEqual(type(response), StreamingHttpResponse) - - headers = response.headers - self.assertEqual(headers['Content-Type'], 'application/pdf') + # There should be a link to the generated PDF + self.assertEqual(response.data['output'].startswith('/media/report/'), True) # By default, this should *not* have created an attachment against this stockitem self.assertFalse(StockItemAttachment.objects.filter(stock_item=item).exists()) + return + # TODO @matmair - Re-add this test after https://github.com/inventree/InvenTree/pull/7074/files#r1600694356 is resolved # Change the setting, now the test report should be attached automatically InvenTreeSetting.set_setting('REPORT_ATTACH_TEST_REPORT', True, None) - response = self.get(url, {'item': item.pk}, expected_code=200) + response = self.post( + url, {'template': report.pk, 'items': [item.pk]}, expected_code=201 + ) - headers = response.headers - self.assertEqual(headers['Content-Type'], 'application/pdf') + # There should be a link to the generated PDF + self.assertEqual(response.data['output'].startswith('/media/report/'), True) # Check that a report has been uploaded attachment = StockItemAttachment.objects.filter(stock_item=item).first() self.assertIsNotNone(attachment) + def test_mdl_build(self): + """Test the Build model.""" + self.run_print_test(Build, 'build', label=False) -class BuildReportTest(ReportTest): - """Unit test class for the BuildReport model.""" + def test_mdl_returnorder(self): + """Test the ReturnOrder model.""" + self.run_print_test(ReturnOrder, 'returnorder', label=False) - model = report_models.BuildReport - - list_url = 'api-build-report-list' - detail_url = 'api-build-report-detail' - print_url = 'api-build-report-print' - - def setUp(self): - """Setup unit testing functions.""" - self.copyReportTemplate('inventree_build_order.html', 'build order template') - - return super().setUp() - - def test_print(self): - """Printing tests for the BuildReport.""" - report = self.model.objects.first() - - url = reverse(self.print_url, kwargs={'pk': report.pk}) - - # Try to print without providing a valid BuildOrder - response = self.get(url, expected_code=400) - - # Try to print with an invalid BuildOrder - response = self.get(url, {'build': 9999}, expected_code=400) - - # Now print with a valid BuildOrder - - build = Build.objects.first() - - response = self.get(url, {'build': build.pk}) - - self.assertEqual(type(response), StreamingHttpResponse) - - headers = response.headers - - self.assertEqual(headers['Content-Type'], 'application/pdf') - self.assertEqual( - headers['Content-Disposition'], 'attachment; filename="report.pdf"' - ) - - # Now, set the download type to be "inline" - inline = InvenTreeUserSetting.get_setting_object( - 'REPORT_INLINE', cache=False, user=self.user - ) - inline.value = True - inline.save() - - response = self.get(url, {'build': 1}) - headers = response.headers - self.assertEqual(headers['Content-Type'], 'application/pdf') - self.assertEqual( - headers['Content-Disposition'], 'inline; filename="report.pdf"' - ) - - -class BOMReportTest(ReportTest): - """Unit test class for the BillOfMaterialsReport model.""" - - model = report_models.BillOfMaterialsReport - - list_url = 'api-bom-report-list' - detail_url = 'api-bom-report-detail' - print_url = 'api-bom-report-print' - - def setUp(self): - """Setup function for the bill of materials Report.""" - self.copyReportTemplate( - 'inventree_bill_of_materials_report.html', 'bill of materials report' - ) - - return super().setUp() - - -class PurchaseOrderReportTest(ReportTest): - """Unit test class for the PurchaseOrderReport model.""" - - model = report_models.PurchaseOrderReport - - list_url = 'api-po-report-list' - detail_url = 'api-po-report-detail' - print_url = 'api-po-report-print' - - def setUp(self): - """Setup function for the purchase order Report.""" - self.copyReportTemplate('inventree_po_report.html', 'purchase order report') - - return super().setUp() - - -class SalesOrderReportTest(ReportTest): - """Unit test class for the SalesOrderReport model.""" - - model = report_models.SalesOrderReport - - list_url = 'api-so-report-list' - detail_url = 'api-so-report-detail' - print_url = 'api-so-report-print' - - def setUp(self): - """Setup function for the sales order Report.""" - self.copyReportTemplate('inventree_so_report.html', 'sales order report') - - return super().setUp() - - -class ReturnOrderReportTest(ReportTest): - """Unit tests for the ReturnOrderReport model.""" - - model = report_models.ReturnOrderReport - list_url = 'api-return-order-report-list' - detail_url = 'api-return-order-report-detail' - print_url = 'api-return-order-report-print' - - def setUp(self): - """Setup function for the ReturnOrderReport tests.""" - self.copyReportTemplate( - 'inventree_return_order_report.html', 'return order report' - ) - - return super().setUp() - - -class StockLocationReportTest(ReportTest): - """Unit tests for the StockLocationReport model.""" - - model = report_models.StockLocationReport - list_url = 'api-stocklocation-report-list' - detail_url = 'api-stocklocation-report-detail' - print_url = 'api-stocklocation-report-print' - - def setUp(self): - """Setup function for the StockLocationReport tests.""" - self.copyReportTemplate('inventree_slr_report.html', 'stock location report') - - return super().setUp() + def test_mdl_salesorder(self): + """Test the SalesOrder model.""" + self.run_print_test(SalesOrder, 'salesorder', label=False) diff --git a/src/backend/InvenTree/report/validators.py b/src/backend/InvenTree/report/validators.py new file mode 100644 index 0000000000..ad9e8e0d33 --- /dev/null +++ b/src/backend/InvenTree/report/validators.py @@ -0,0 +1,20 @@ +"""Validators for report models.""" + +from django.core.exceptions import ValidationError + +import report.helpers + + +def validate_report_model_type(value): + """Ensure that the selected model type is valid.""" + model_options = [el[0] for el in report.helpers.report_model_options()] + + if value not in model_options: + raise ValidationError('Not a valid model type') + + +def validate_filters(value, model=None): + """Validate that the provided model filters are valid.""" + from InvenTree.helpers import validateFilterString + + return validateFilterString(value, model=model) diff --git a/src/backend/InvenTree/stock/migrations/0001_initial.py b/src/backend/InvenTree/stock/migrations/0001_initial.py index 09c033c06b..040a48efeb 100644 --- a/src/backend/InvenTree/stock/migrations/0001_initial.py +++ b/src/backend/InvenTree/stock/migrations/0001_initial.py @@ -35,6 +35,9 @@ class Migration(migrations.Migration): ('belongs_to', models.ForeignKey(blank=True, help_text='Is this item installed in another item?', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='owned_parts', to='stock.StockItem')), ('customer', models.ForeignKey(blank=True, help_text='Item assigned to customer?', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='stockitems', to='company.Company')), ], + options={ + 'verbose_name': 'Stock Item', + } ), migrations.CreateModel( name='StockLocation', diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py index ab20bc59aa..17a4385a39 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -30,7 +30,7 @@ import InvenTree.helpers import InvenTree.models import InvenTree.ready import InvenTree.tasks -import label.models +import report.mixins import report.models from company import models as CompanyModels from InvenTree.fields import InvenTreeModelMoneyField, InvenTreeURLField @@ -107,7 +107,9 @@ class StockLocationManager(TreeManager): class StockLocation( - InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeTree + InvenTree.models.InvenTreeBarcodeMixin, + report.mixins.InvenTreeReportMixin, + InvenTree.models.InvenTreeTree, ): """Organization tree for StockItem objects. @@ -142,6 +144,16 @@ class StockLocation( """Return API url.""" return reverse('api-location-list') + def report_context(self): + """Return report context data for this StockLocation.""" + return { + 'location': self, + 'qr_data': self.format_barcode(brief=True), + 'parent': self.parent, + 'stock_location': self, + 'stock_items': self.get_stock_items(), + } + custom_icon = models.CharField( blank=True, max_length=100, @@ -313,6 +325,7 @@ def default_delete_on_deplete(): class StockItem( InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNotesMixin, + report.mixins.InvenTreeReportMixin, InvenTree.models.MetadataMixin, InvenTree.models.PluginValidationMixin, common.models.MetaMixin, @@ -345,6 +358,11 @@ class StockItem( packaging: Description of how the StockItem is packaged (e.g. "reel", "loose", "tape" etc) """ + class Meta: + """Model meta options.""" + + verbose_name = _('Stock Item') + @staticmethod def get_api_url(): """Return API url.""" @@ -354,6 +372,50 @@ class StockItem( """Custom API instance filters.""" return {'parent': {'exclude_tree': self.pk}} + def get_test_keys(self, include_installed=True): + """Construct a flattened list of test 'keys' for this StockItem.""" + keys = [] + + for test in self.part.getTestTemplates(required=True): + if test.key not in keys: + keys.append(test.key) + + for test in self.part.getTestTemplates(required=False): + if test.key not in keys: + keys.append(test.key) + + for result in self.testResultList(include_installed=include_installed): + if result.key not in keys: + keys.append(result.key) + + return list(keys) + + def report_context(self): + """Generate custom report context data for this StockItem.""" + return { + 'barcode_data': self.barcode_data, + 'barcode_hash': self.barcode_hash, + 'batch': self.batch, + 'child_items': self.get_children(), + 'ipn': self.part.IPN, + 'installed_items': self.get_installed_items(cascade=True), + 'item': self, + 'name': self.part.full_name, + 'part': self.part, + 'qr_data': self.format_barcode(brief=True), + 'qr_url': self.get_absolute_url(), + 'parameters': self.part.parameters_map(), + 'quantity': InvenTree.helpers.normalize(self.quantity), + 'result_list': self.testResultList(include_installed=True), + 'results': self.testResultMap(include_installed=True), + 'serial': self.serial, + 'stock_item': self, + 'tests': self.testResultMap(), + 'test_keys': self.get_test_keys(), + 'test_template_list': self.part.getTestTemplates(), + 'test_templates': self.part.getTestTemplateMap(), + } + tags = TaggableManager(blank=True) # A Query filter which will be re-used in multiple places to determine if a StockItem is actually "in stock" @@ -2144,50 +2206,6 @@ class StockItem( return status['passed'] >= status['total'] - def available_test_reports(self): - """Return a list of TestReport objects which match this StockItem.""" - reports = [] - - item_query = StockItem.objects.filter(pk=self.pk) - - for test_report in report.models.TestReport.objects.filter(enabled=True): - # Attempt to validate report filter (skip if invalid) - try: - filters = InvenTree.helpers.validateFilterString(test_report.filters) - if item_query.filter(**filters).exists(): - reports.append(test_report) - except (ValidationError, FieldError): - continue - - return reports - - @property - def has_test_reports(self): - """Return True if there are test reports available for this stock item.""" - return len(self.available_test_reports()) > 0 - - def available_labels(self): - """Return a list of Label objects which match this StockItem.""" - labels = [] - - item_query = StockItem.objects.filter(pk=self.pk) - - for lbl in label.models.StockItemLabel.objects.filter(enabled=True): - try: - filters = InvenTree.helpers.validateFilterString(lbl.filters) - - if item_query.filter(**filters).exists(): - labels.append(lbl) - except (ValidationError, FieldError): - continue - - return labels - - @property - def has_labels(self): - """Return True if there are any label templates available for this stock item.""" - return len(self.available_labels()) > 0 - @receiver(pre_delete, sender=StockItem, dispatch_uid='stock_item_pre_delete_log') def before_delete_stock_item(sender, instance, using, **kwargs): diff --git a/src/backend/InvenTree/stock/templates/stock/item.html b/src/backend/InvenTree/stock/templates/stock/item.html index 33810b6da6..ab647bb795 100644 --- a/src/backend/InvenTree/stock/templates/stock/item.html +++ b/src/backend/InvenTree/stock/templates/stock/item.html @@ -242,11 +242,7 @@ ); $("#test-report").click(function() { - printReports({ - items: [{{ item.pk }}], - key: 'item', - url: '{% url "api-stockitem-testreport-list" %}', - }); + printReports('stockitem', [{{ item.pk }}]); }); {% if user.is_staff %} diff --git a/src/backend/InvenTree/stock/templates/stock/item_base.html b/src/backend/InvenTree/stock/templates/stock/item_base.html index c0e12b172c..05107cac28 100644 --- a/src/backend/InvenTree/stock/templates/stock/item_base.html +++ b/src/backend/InvenTree/stock/templates/stock/item_base.html @@ -494,19 +494,14 @@ $('#stock-uninstall').click(function() { }); $("#stock-test-report").click(function() { - printReports({ - items: [{{ item.pk }}], - key: 'item', - url: '{% url "api-stockitem-testreport-list" %}', - }); + printReports('stockitem', [{{ item.pk }}]); }); $("#print-label").click(function() { printLabels({ items: [{{ item.pk }}], + model_type: 'stockitem', singular_name: '{% trans "stock item" escape %}', - url: '{% url "api-stockitem-label-list" %}', - key: 'item', }); }); diff --git a/src/backend/InvenTree/stock/templates/stock/location.html b/src/backend/InvenTree/stock/templates/stock/location.html index 5cce5657cd..50483ab0a3 100644 --- a/src/backend/InvenTree/stock/templates/stock/location.html +++ b/src/backend/InvenTree/stock/templates/stock/location.html @@ -291,9 +291,8 @@ printLabels({ items: locs, + model_type: 'stocklocation', singular_name: '{% trans "stock location" escape %}', - key: 'location', - url: '{% url "api-stocklocation-label-list" %}', }); }); {% endif %} @@ -301,11 +300,7 @@ {% if report_enabled %} $('#print-location-report').click(function() { - printReports({ - items: [{{ location.pk }}], - key: 'location', - url: '{% url "api-stocklocation-report-list" %}', - }); + printReports('stocklocation', [{{ location.pk }}]); }); {% endif %} diff --git a/src/backend/InvenTree/templates/js/translated/api.js b/src/backend/InvenTree/templates/js/translated/api.js index c9bd3dbd1a..58f001bdbf 100644 --- a/src/backend/InvenTree/templates/js/translated/api.js +++ b/src/backend/InvenTree/templates/js/translated/api.js @@ -60,7 +60,7 @@ function inventreeGet(url, filters={}, options={}) { xhr.setRequestHeader('X-CSRFToken', csrftoken); }, url: url, - type: 'GET', + type: options.method ?? 'GET', data: filters, dataType: options.dataType || 'json', contentType: options.contentType || 'application/json', diff --git a/src/backend/InvenTree/templates/js/translated/filters.js b/src/backend/InvenTree/templates/js/translated/filters.js index 51fcc265b6..a6ad9e505c 100644 --- a/src/backend/InvenTree/templates/js/translated/filters.js +++ b/src/backend/InvenTree/templates/js/translated/filters.js @@ -526,11 +526,7 @@ function setupFilterList(tableKey, table, target, options={}) { items.push(row.pk); }); - printReports({ - items: items, - url: options.report.url, - key: options.report.key - }); + printReports(options.report.key, items); }); } @@ -548,8 +544,7 @@ function setupFilterList(tableKey, table, target, options={}) { items: items, singular_name: options.singular_name, plural_name: options.plural_name, - url: options.labels.url, - key: options.labels.key, + model_type: options.labels?.model_type ?? options.model_type, }); }); } diff --git a/src/backend/InvenTree/templates/js/translated/forms.js b/src/backend/InvenTree/templates/js/translated/forms.js index 3f5539b7d8..dd187dc829 100644 --- a/src/backend/InvenTree/templates/js/translated/forms.js +++ b/src/backend/InvenTree/templates/js/translated/forms.js @@ -1178,6 +1178,10 @@ function getFormFieldValue(name, field={}, options={}) { return null; } + if (field.hidden && !!field.value) { + return field.value; + } + var value = null; let guessed_type = guessFieldType(el); diff --git a/src/backend/InvenTree/templates/js/translated/label.js b/src/backend/InvenTree/templates/js/translated/label.js index a9a75f0f56..0366f5b8b1 100644 --- a/src/backend/InvenTree/templates/js/translated/label.js +++ b/src/backend/InvenTree/templates/js/translated/label.js @@ -41,13 +41,15 @@ const defaultLabelTemplates = { * - Request printed labels * * Required options: - * - url: The list URL for the particular template type + * - model_type: The "type" of label template to print against * - items: The list of items to be printed * - key: The key to use in the query parameters * - plural_name: The plural name of the item type */ function printLabels(options) { + let pluginId = -1; + if (!options.items || options.items.length == 0) { showAlertDialog( '{% trans "Select Items" %}', @@ -56,145 +58,103 @@ function printLabels(options) { return; } + // Join the items with a comma character + const item_string = options.items.join(','); + let params = { enabled: true, + model_type: options.model_type, + items: item_string, }; - params[options.key] = options.items; + function getPrintingFields(plugin_id, callback) { + let url = '{% url "api-label-print" %}' + `?plugin=${plugin_id}`; - // Request a list of available label templates from the server - let labelTemplates = []; - inventreeGet(options.url, params, { - async: false, - success: function (response) { - if (response.length == 0) { - showAlertDialog( - '{% trans "No Labels Found" %}', - '{% trans "No label templates found which match the selected items" %}', - ); - return; + inventreeGet( + url, + { + plugin: plugin_id, + }, + { + method: 'OPTIONS', + success: function(response) { + let fields = response?.actions?.POST ?? {}; + callback(fields); + } } - - labelTemplates = response; - } - }); - - // Request a list of available label printing plugins from the server - let plugins = []; - inventreeGet(`/api/plugins/`, { mixin: 'labels' }, { - async: false, - success: function (response) { - plugins = response; - } - }); - - let header_html = ""; - - // show how much items are selected if there is more than one item selected - if (options.items.length > 1) { - header_html += ` -
- ${options.items.length} ${options.plural_name} {% trans "selected" %} -
- `; + ); } - const updateFormUrl = (formOptions) => { - const plugin = getFormFieldValue("_plugin", formOptions.fields._plugin, formOptions); - const labelTemplate = getFormFieldValue("_label_template", formOptions.fields._label_template, formOptions); - const params = $.param({ plugin, [options.key]: options.items }) - formOptions.url = `${options.url}${labelTemplate ?? "1"}/print/?${params}`; - } + // Callback when a particular label printing plugin is selected + function onPluginSelected(value, name, field, formOptions) { - const updatePrintingOptions = (formOptions) => { - let printingOptionsRes = null; - $.ajax({ - url: formOptions.url, - type: "OPTIONS", - contentType: "application/json", - dataType: "json", - accepts: { json: "application/json" }, - async: false, - success: (res) => { printingOptionsRes = res }, - error: (xhr) => showApiError(xhr, formOptions.url) + if (value == pluginId) { + return; + } + + pluginId = value; + + // Request new printing options for the selected plugin + getPrintingFields(value, function(fields) { + formOptions.fields = getFormFields(fields); + updateForm(formOptions); + + // workaround to fix a bug where one cannot scroll after changing the plugin + // without opening and closing the select box again manually + $("#id__plugin").select2("open"); + $("#id__plugin").select2("close"); }); - - const printingOptions = printingOptionsRes.actions.POST || {}; - - // clear all other options - formOptions.fields = { - _label_template: formOptions.fields._label_template, - _plugin: formOptions.fields._plugin, - } - - if (Object.keys(printingOptions).length > 0) { - formOptions.fields = { - ...formOptions.fields, - divider: { type: "candy", html: `
{% trans "Printing Options" %}
` }, - ...printingOptions, - }; - } - - // update form - updateForm(formOptions); - - // workaround to fix a bug where one cannot scroll after changing the plugin - // without opening and closing the select box again manually - $("#id__plugin").select2("open"); - $("#id__plugin").select2("close"); } - const printingFormOptions = { - title: options.items.length === 1 ? `{% trans "Print label" %}` : `{% trans "Print labels" %}`, - submitText: `{% trans "Print" %}`, - method: "POST", - disableSuccessMessage: true, - header_html, - fields: { - _label_template: { - label: `{% trans "Select label template" %}`, - type: "choice", - localOnly: true, - value: defaultLabelTemplates[options.key], - choices: labelTemplates.map(t => ({ - value: t.pk, - display_name: `${t.name} - ${t.description}`, - })), - onEdit: (_value, _name, _field, formOptions) => { - updateFormUrl(formOptions); + const baseFields = { + template: {}, + plugin: {}, + items: {} + }; + + function getFormFields(customFields={}) { + let fields = { + ...baseFields, + ...customFields, + }; + + fields['template'].filters = { + enabled: true, + model_type: options.model_type, + items: item_string, + }; + + fields['plugin'].filters = { + active: true, + mixin: 'labels' + }; + + fields['plugin'].onEdit = onPluginSelected; + + fields['items'].hidden = true; + fields['items'].value = options.items; + + return fields; + } + + constructForm('{% url "api-label-print" %}', { + method: 'POST', + title: '{% trans "Print Label" %}', + fields: getFormFields(), + onSuccess: function(response) { + if (response.complete) { + if (response.output) { + window.open(response.output, '_blank'); + } else { + showMessage('{% trans "Labels sent to printer" %}', { + style: 'success' + }); } - }, - _plugin: { - label: `{% trans "Select plugin" %}`, - type: "choice", - localOnly: true, - value: user_settings.LABEL_DEFAULT_PRINTER || plugins[0].key, - choices: plugins.map(p => ({ - value: p.key, - display_name: `${p.name} - ${p.meta.human_name}`, - })), - onEdit: (_value, _name, _field, formOptions) => { - updateFormUrl(formOptions); - updatePrintingOptions(formOptions); - } - }, - }, - onSuccess: (response) => { - if (response.file) { - // Download the generated file - window.open(response.file); } else { - showMessage('{% trans "Labels sent to printer" %}', { - style: 'success', + showMessage('{% trans "Label printing failed" %}', { + style: 'warning', }); } } - }; - - // construct form - constructForm(null, printingFormOptions); - - // fetch the options for the default plugin - updateFormUrl(printingFormOptions); - updatePrintingOptions(printingFormOptions); + }); } diff --git a/src/backend/InvenTree/templates/js/translated/model_renderers.js b/src/backend/InvenTree/templates/js/translated/model_renderers.js index 66b05bf44d..d2d0573ab8 100644 --- a/src/backend/InvenTree/templates/js/translated/model_renderers.js +++ b/src/backend/InvenTree/templates/js/translated/model_renderers.js @@ -92,6 +92,12 @@ function getModelRenderer(model) { return renderGroup; case 'projectcode': return renderProjectCode; + case 'labeltemplate': + return renderLabelTemplate; + case 'reporttemplate': + return renderReportTemplate; + case 'pluginconfig': + return renderPluginConfig; default: // Un-handled model type console.error(`Rendering not implemented for model '${model}'`); @@ -540,3 +546,42 @@ function renderProjectCode(data, parameters={}) { parameters ); } + + +// Renderer for "LabelTemplate" model +function renderLabelTemplate(data, parameters={}) { + + return renderModel( + { + text: data.name, + textSecondary: data.description, + }, + parameters + ); +} + + +// Renderer for "ReportTemplate" model +function renderReportTemplate(data, parameters={}) { + + return renderModel( + { + text: data.name, + textSecondary: data.description, + }, + parameters + ); +} + + +// Renderer for "PluginConfig" model +function renderPluginConfig(data, parameters={}) { + + return renderModel( + { + text: data.name, + textSecondary: data.meta?.description, + }, + parameters + ); +} diff --git a/src/backend/InvenTree/templates/js/translated/part.js b/src/backend/InvenTree/templates/js/translated/part.js index ffcea223d3..48b3dcfdbb 100644 --- a/src/backend/InvenTree/templates/js/translated/part.js +++ b/src/backend/InvenTree/templates/js/translated/part.js @@ -2281,8 +2281,7 @@ function loadPartTable(table, url, options={}) { setupFilterList('parts', $(table), options.filterTarget, { download: true, labels: { - url: '{% url "api-part-label-list" %}', - key: 'part', + model_type: 'part', }, singular_name: '{% trans "part" %}', plural_name: '{% trans "parts" %}', diff --git a/src/backend/InvenTree/templates/js/translated/purchase_order.js b/src/backend/InvenTree/templates/js/translated/purchase_order.js index c829fed140..eb0571f763 100644 --- a/src/backend/InvenTree/templates/js/translated/purchase_order.js +++ b/src/backend/InvenTree/templates/js/translated/purchase_order.js @@ -1568,8 +1568,7 @@ function loadPurchaseOrderTable(table, options) { setupFilterList('purchaseorder', $(table), '#filter-list-purchaseorder', { download: true, report: { - url: '{% url "api-po-report-list" %}', - key: 'order', + key: 'purchaseorder', } }); diff --git a/src/backend/InvenTree/templates/js/translated/report.js b/src/backend/InvenTree/templates/js/translated/report.js index a6d124a3cd..65dee7479c 100644 --- a/src/backend/InvenTree/templates/js/translated/report.js +++ b/src/backend/InvenTree/templates/js/translated/report.js @@ -3,6 +3,7 @@ /* globals attachSelect, closeModal, + constructForm, inventreeGet, openModal, makeOptionsList, @@ -11,98 +12,13 @@ modalSetTitle, modalSubmit, showAlertDialog, + showMessage, */ /* exported printReports, */ -/** - * Present the user with the available reports, - * and allow them to select which report to print. - * - * The intent is that the available report templates have been requested - * (via AJAX) from the server. - */ -function selectReport(reports, items, options={}) { - - // If there is only a single report available, just print! - if (reports.length == 1) { - if (options.success) { - options.success(reports[0].pk); - } - - return; - } - - var modal = options.modal || '#modal-form'; - - var report_list = makeOptionsList( - reports, - function(item) { - var text = item.name; - - if (item.description) { - text += ` - ${item.description}`; - } - - return text; - }, - function(item) { - return item.pk; - } - ); - - // Construct form - var html = ''; - - if (items.length > 0) { - - html += ` -
- ${items.length} {% trans "items selected" %} -
`; - } - - html += ` -
-
- -
- -
-
-
`; - - openModal({ - modal: modal, - }); - - modalEnable(modal, true); - modalSetTitle(modal, '{% trans "Select Test Report Template" %}'); - modalSetContent(modal, html); - - attachSelect(modal); - - modalSubmit(modal, function() { - - var label = $(modal).find('#id_report'); - - var pk = label.val(); - - closeModal(modal); - - if (options.success) { - options.success(pk); - } - }); - -} - /* * Print report(s) for the selected items: @@ -112,49 +28,52 @@ function selectReport(reports, items, options={}) { * - Request printed document * * Required options: - * - url: The list URL for the particular template type + * - model_type: The "type" of report template to print against * - items: The list of objects to print - * - key: The key to use in the query parameters */ -function printReports(options) { +function printReports(model_type, items) { - if (!options.items || options.items.length == 0) { + if (!items || items.length == 0) { showAlertDialog( '{% trans "Select Items" %}', - '{% trans "No items selected for printing" }', + '{% trans "No items selected for printing" %}', ); return; } - let params = { - enabled: true, - }; + // Join the items with a comma character + const item_string = items.join(','); - params[options.key] = options.items; - - // Request a list of available report templates - inventreeGet(options.url, params, { - success: function(response) { - if (response.length == 0) { - showAlertDialog( - '{% trans "No Reports Found" %}', - '{% trans "No report templates found which match the selected items" %}', - ); - return; - } - - // Select report template for printing - selectReport(response, options.items, { - success: function(pk) { - let href = `${options.url}${pk}/print/?`; - - options.items.forEach(function(item) { - href += `${options.key}=${item}&`; - }); - - window.open(href); + constructForm('{% url "api-report-print" %}', { + method: 'POST', + title: '{% trans "Print Report" %}', + fields: { + template: { + filters: { + enabled: true, + model_type: model_type, + items: item_string, } - }); + }, + items: { + hidden: true, + value: items, + } + }, + onSuccess: function(response) { + if (response.complete) { + if (response.output) { + window.open(response.output, '_blank'); + } else { + showMessage('{% trans "Report print successful" %}', { + style: 'success' + }); + } + } else { + showMessage('{% trans "Report printing failed" %}', { + style: 'warning', + }); + } } - }); + }) } diff --git a/src/backend/InvenTree/templates/js/translated/return_order.js b/src/backend/InvenTree/templates/js/translated/return_order.js index 5163b9696d..23c921dfc2 100644 --- a/src/backend/InvenTree/templates/js/translated/return_order.js +++ b/src/backend/InvenTree/templates/js/translated/return_order.js @@ -242,8 +242,7 @@ function loadReturnOrderTable(table, options={}) { setupFilterList('returnorder', $(table), '#filter-list-returnorder', { download: true, report: { - url: '{% url "api-return-order-report-list" %}', - key: 'order', + key: 'returnorder', } }); diff --git a/src/backend/InvenTree/templates/js/translated/sales_order.js b/src/backend/InvenTree/templates/js/translated/sales_order.js index f7c6eea8a3..358c91b2ae 100644 --- a/src/backend/InvenTree/templates/js/translated/sales_order.js +++ b/src/backend/InvenTree/templates/js/translated/sales_order.js @@ -682,8 +682,7 @@ function loadSalesOrderTable(table, options) { setupFilterList('salesorder', $(table), '#filter-list-salesorder', { download: true, report: { - url: '{% url "api-so-report-list" %}', - key: 'order' + key: 'salesorder' } }); diff --git a/src/backend/InvenTree/templates/js/translated/stock.js b/src/backend/InvenTree/templates/js/translated/stock.js index e88f0602bb..d5d9e15e4b 100644 --- a/src/backend/InvenTree/templates/js/translated/stock.js +++ b/src/backend/InvenTree/templates/js/translated/stock.js @@ -1944,12 +1944,10 @@ function loadStockTable(table, options) { setupFilterList(filterKey, table, filterTarget, { download: true, report: { - url: '{% url "api-stockitem-testreport-list" %}', - key: 'item', + key: 'stockitem', }, labels: { - url: '{% url "api-stockitem-label-list" %}', - key: 'item', + model_type: 'stockitem', }, singular_name: '{% trans "stock item" %}', plural_name: '{% trans "stock items" %}', @@ -2569,8 +2567,7 @@ function loadStockLocationTable(table, options) { setupFilterList(filterKey, table, filterListElement, { download: true, labels: { - url: '{% url "api-stocklocation-label-list" %}', - key: 'location' + model_type: 'stocklocation', }, singular_name: '{% trans "stock location" %}', plural_name: '{% trans "stock locations" %}', diff --git a/src/backend/InvenTree/users/models.py b/src/backend/InvenTree/users/models.py index a816bde6e2..7c4ee84349 100644 --- a/src/backend/InvenTree/users/models.py +++ b/src/backend/InvenTree/users/models.py @@ -224,11 +224,12 @@ class RuleSet(models.Model): 'auth_permission', 'users_apitoken', 'users_ruleset', + 'report_labeloutput', + 'report_labeltemplate', 'report_reportasset', + 'report_reportoutput', 'report_reportsnippet', - 'report_billofmaterialsreport', - 'report_purchaseorderreport', - 'report_salesorderreport', + 'report_reporttemplate', 'account_emailaddress', 'account_emailconfirmation', 'socialaccount_socialaccount', @@ -270,22 +271,14 @@ class RuleSet(models.Model): 'company_manufacturerpart', 'company_manufacturerpartparameter', 'company_manufacturerpartattachment', - 'label_partlabel', ], 'stocktake': ['part_partstocktake', 'part_partstocktakereport'], - 'stock_location': [ - 'stock_stocklocation', - 'stock_stocklocationtype', - 'label_stocklocationlabel', - 'report_stocklocationreport', - ], + 'stock_location': ['stock_stocklocation', 'stock_stocklocationtype'], 'stock': [ 'stock_stockitem', 'stock_stockitemattachment', 'stock_stockitemtracking', 'stock_stockitemtestresult', - 'report_testreport', - 'label_stockitemlabel', ], 'build': [ 'part_part', @@ -298,8 +291,6 @@ class RuleSet(models.Model): 'build_buildorderattachment', 'stock_stockitem', 'stock_stocklocation', - 'report_buildreport', - 'label_buildlinelabel', ], 'purchase_order': [ 'company_company', @@ -314,7 +305,6 @@ class RuleSet(models.Model): 'order_purchaseorderattachment', 'order_purchaseorderlineitem', 'order_purchaseorderextraline', - 'report_purchaseorderreport', ], 'sales_order': [ 'company_company', @@ -327,7 +317,6 @@ class RuleSet(models.Model): 'order_salesorderlineitem', 'order_salesorderextraline', 'order_salesordershipment', - 'report_salesorderreport', ], 'return_order': [ 'company_company', @@ -338,7 +327,6 @@ class RuleSet(models.Model): 'order_returnorderlineitem', 'order_returnorderextraline', 'order_returnorderattachment', - 'report_returnorderreport', ], } @@ -366,7 +354,6 @@ class RuleSet(models.Model): 'common_projectcode', 'common_webhookendpoint', 'common_webhookmessage', - 'label_labeloutput', 'users_owner', # Third-party tables 'error_report_error', diff --git a/src/frontend/src/components/buttons/PrintingActions.tsx b/src/frontend/src/components/buttons/PrintingActions.tsx new file mode 100644 index 0000000000..81c5d8ddd6 --- /dev/null +++ b/src/frontend/src/components/buttons/PrintingActions.tsx @@ -0,0 +1,191 @@ +import { t } from '@lingui/macro'; +import { notifications } from '@mantine/notifications'; +import { IconPrinter, IconReport, IconTags } from '@tabler/icons-react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { api } from '../../App'; +import { ApiEndpoints } from '../../enums/ApiEndpoints'; +import { ModelType } from '../../enums/ModelType'; +import { extractAvailableFields } from '../../functions/forms'; +import { useCreateApiFormModal } from '../../hooks/UseForm'; +import { apiUrl } from '../../states/ApiState'; +import { useLocalState } from '../../states/LocalState'; +import { ApiFormFieldSet } from '../forms/fields/ApiFormField'; +import { ActionDropdown } from '../items/ActionDropdown'; + +export function PrintingActions({ + items, + enableLabels, + enableReports, + modelType +}: { + items: number[]; + enableLabels?: boolean; + enableReports?: boolean; + modelType?: ModelType; +}) { + const { host } = useLocalState.getState(); + + const enabled = useMemo(() => items.length > 0, [items]); + + const [pluginKey, setPluginKey] = useState(''); + + const loadFields = useCallback(() => { + if (!enableLabels) { + return; + } + + api + .options(apiUrl(ApiEndpoints.label_print), { + params: { + plugin: pluginKey || undefined + } + }) + .then((response: any) => { + setExtraFields(extractAvailableFields(response, 'POST') || {}); + }) + .catch(() => {}); + }, [enableLabels, pluginKey]); + + useEffect(() => { + loadFields(); + }, [loadFields, pluginKey]); + + const [extraFields, setExtraFields] = useState({}); + + const labelFields: ApiFormFieldSet = useMemo(() => { + let fields: ApiFormFieldSet = extraFields; + + // Override field values + fields['template'] = { + ...fields['template'], + filters: { + enabled: true, + model_type: modelType, + items: items.join(',') + } + }; + + fields['items'] = { + ...fields['items'], + value: items, + hidden: true + }; + + fields['plugin'] = { + ...fields['plugin'], + filters: { + active: true, + mixin: 'labels' + }, + onValueChange: (value: string, record?: any) => { + console.log('onValueChange:', value, record); + + if (record?.key && record?.key != pluginKey) { + setPluginKey(record.key); + } + } + }; + + return fields; + }, [extraFields, items, loadFields]); + + const labelModal = useCreateApiFormModal({ + url: apiUrl(ApiEndpoints.label_print), + title: t`Print Label`, + fields: labelFields, + timeout: (items.length + 1) * 1000, + onClose: () => { + setPluginKey(''); + }, + successMessage: t`Label printing completed successfully`, + onFormSuccess: (response: any) => { + if (!response.complete) { + // TODO: Periodically check for completion (requires server-side changes) + notifications.show({ + title: t`Error`, + message: t`The label could not be generated`, + color: 'red' + }); + return; + } + + if (response.output) { + // An output file was generated + const url = `${host}${response.output}`; + window.open(url, '_blank'); + } + } + }); + + const reportModal = useCreateApiFormModal({ + title: t`Print Report`, + url: apiUrl(ApiEndpoints.report_print), + timeout: (items.length + 1) * 1000, + fields: { + template: { + filters: { + enabled: true, + model_type: modelType, + items: items.join(',') + } + }, + items: { + hidden: true, + value: items + } + }, + successMessage: t`Report printing completed successfully`, + onFormSuccess: (response: any) => { + if (!response.complete) { + // TODO: Periodically check for completion (requires server-side changes) + notifications.show({ + title: t`Error`, + message: t`The report could not be generated`, + color: 'red' + }); + return; + } + + if (response.output) { + // An output file was generated + const url = `${host}${response.output}`; + window.open(url, '_blank'); + } + } + }); + + if (!modelType) { + return null; + } + + if (!enableLabels && !enableReports) { + return null; + } + + return ( + <> + {reportModal.modal} + {labelModal.modal} + } + disabled={!enabled} + actions={[ + { + name: t`Print Labels`, + icon: , + onClick: () => labelModal.open(), + hidden: !enableLabels + }, + { + name: t`Print Reports`, + icon: , + onClick: () => reportModal.open(), + hidden: !enableReports + } + ]} + /> + + ); +} diff --git a/src/frontend/src/components/buttons/SplitButton.tsx b/src/frontend/src/components/buttons/SplitButton.tsx index 8bb73882bd..ae497bb6dd 100644 --- a/src/frontend/src/components/buttons/SplitButton.tsx +++ b/src/frontend/src/components/buttons/SplitButton.tsx @@ -10,6 +10,7 @@ import { import { IconChevronDown } from '@tabler/icons-react'; import { useEffect, useMemo, useState } from 'react'; +import { identifierString } from '../../functions/conversion'; import { TablerIconType } from '../../functions/icons'; import * as classes from './SplitButton.css'; @@ -25,6 +26,7 @@ interface SplitButtonOption { interface SplitButtonProps { options: SplitButtonOption[]; defaultSelected: string; + name: string; selected?: string; setSelected?: (value: string) => void; loading?: boolean; @@ -34,6 +36,7 @@ export function SplitButton({ options, defaultSelected, selected, + name, setSelected, loading }: Readonly) { @@ -61,6 +64,7 @@ export function SplitButton({ disabled={loading ? false : currentOption?.disabled} className={classes.button} loading={loading} + aria-label={`split-button-${name}`} > {currentOption?.name} @@ -75,6 +79,7 @@ export function SplitButton({ color={theme.primaryColor} size={36} className={classes.icon} + aria-label={`split-button-${name}-action`} > @@ -88,6 +93,9 @@ export function SplitButton({ setCurrent(option.key); option.onClick(); }} + aria-label={`split-button-${name}-item-${identifierString( + option.key + )}`} disabled={option.disabled} leftSection={} > diff --git a/src/frontend/src/components/editors/TemplateEditor/PdfPreview/PdfPreview.tsx b/src/frontend/src/components/editors/TemplateEditor/PdfPreview/PdfPreview.tsx index a732935a93..c38e65f8a0 100644 --- a/src/frontend/src/components/editors/TemplateEditor/PdfPreview/PdfPreview.tsx +++ b/src/frontend/src/components/editors/TemplateEditor/PdfPreview/PdfPreview.tsx @@ -1,4 +1,4 @@ -import { Trans, t } from '@lingui/macro'; +import { Trans } from '@lingui/macro'; import { forwardRef, useImperativeHandle, useState } from 'react'; import { api } from '../../../../App'; @@ -13,54 +13,53 @@ export const PdfPreviewComponent: PreviewAreaComponent = forwardRef( code, previewItem, saveTemplate, - { uploadKey, uploadUrl, preview: { itemKey }, templateType } + { templateUrl, printingUrl, template } ) => { if (saveTemplate) { const formData = new FormData(); - formData.append(uploadKey, new File([code], 'template.html')); - const res = await api.patch(uploadUrl, formData); + const filename = + template.template?.split('/').pop() ?? 'template.html'; + + formData.append('template', new File([code], filename)); + + const res = await api.patch(templateUrl, formData); if (res.status !== 200) { throw new Error(res.data); } } - // ---- TODO: Fix this when implementing the new API ---- - let preview = await api.get( - uploadUrl + `print/?plugin=inventreelabel&${itemKey}=${previewItem}`, + let preview = await api.post( + printingUrl, { - responseType: templateType === 'label' ? 'json' : 'blob', + items: [previewItem], + template: template.pk + }, + { + responseType: 'json', timeout: 30000, validateStatus: () => true } ); - if (preview.status !== 200) { - if (templateType === 'report') { - let data; - try { - data = JSON.parse(await preview.data.text()); - } catch (err) { - throw new Error(t`Failed to parse error response from server.`); - } - - throw new Error(data.detail?.join(', ')); - } else if (preview.data?.non_field_errors) { + if (preview.status !== 200 && preview.status !== 201) { + if (preview.data?.non_field_errors) { throw new Error(preview.data?.non_field_errors.join(', ')); } throw new Error(preview.data); } - if (templateType === 'label') { - preview = await api.get(preview.data.file, { + if (preview?.data?.output) { + preview = await api.get(preview.data.output, { responseType: 'blob' }); } - // ---- + let pdf = new Blob([preview.data], { type: preview.headers['content-type'] }); + let srcUrl = URL.createObjectURL(pdf); setPdfUrl(srcUrl + '#view=fitH'); diff --git a/src/frontend/src/components/editors/TemplateEditor/TemplateEditor.tsx b/src/frontend/src/components/editors/TemplateEditor/TemplateEditor.tsx index 01bc779645..f1eac290a4 100644 --- a/src/frontend/src/components/editors/TemplateEditor/TemplateEditor.tsx +++ b/src/frontend/src/components/editors/TemplateEditor/TemplateEditor.tsx @@ -9,7 +9,7 @@ import { Tabs } from '@mantine/core'; import { openConfirmModal } from '@mantine/modals'; -import { showNotification } from '@mantine/notifications'; +import { notifications, showNotification } from '@mantine/notifications'; import { IconAlertTriangle, IconDeviceFloppy, @@ -70,25 +70,16 @@ export type PreviewArea = { component: PreviewAreaComponent; }; -export type TemplatePreviewProps = { - itemKey: string; - model: ModelType; - filters?: Record; -}; - -type TemplateEditorProps = { - downloadUrl: string; - uploadUrl: string; - uploadKey: string; - preview: TemplatePreviewProps; - templateType: 'label' | 'report'; +export type TemplateEditorProps = { + templateUrl: string; + printingUrl: string; editors: Editor[]; previewAreas: PreviewArea[]; template: TemplateI; }; export function TemplateEditor(props: Readonly) { - const { downloadUrl, editors, previewAreas, preview } = props; + const { templateUrl, editors, previewAreas, template } = props; const editorRef = useRef(); const previewRef = useRef(); @@ -131,13 +122,17 @@ export function TemplateEditor(props: Readonly) { }, []); useEffect(() => { - if (!downloadUrl) return; + if (!templateUrl) return; - api.get(downloadUrl).then((res) => { - codeRef.current = res.data; - loadCodeToEditor(res.data); + api.get(templateUrl).then((response: any) => { + if (response.data?.template) { + api.get(response.data.template).then((res) => { + codeRef.current = res.data; + loadCodeToEditor(res.data); + }); + } }); - }, [downloadUrl]); + }, [templateUrl]); useEffect(() => { if (codeRef.current === undefined) return; @@ -148,7 +143,7 @@ export function TemplateEditor(props: Readonly) { async (confirmed: boolean, saveTemplate: boolean = true) => { if (!confirmed) { openConfirmModal({ - title: t`Save & Reload preview?`, + title: t`Save & Reload Preview`, children: ( ) { ) .then(() => { setErrorOverlay(null); + + notifications.hide('template-preview'); + showNotification({ title: t`Preview updated`, message: t`The preview has been updated successfully.`, - color: 'green' + color: 'green', + id: 'template-preview' }); }) .catch((error) => { @@ -204,18 +203,25 @@ export function TemplateEditor(props: Readonly) { ); const previewApiUrl = useMemo( - () => ModelInformationDict[preview.model].api_endpoint, - [preview.model] + () => + ModelInformationDict[template.model_type ?? ModelType.stockitem] + .api_endpoint, + [template] ); + const templateFilters: Record = useMemo(() => { + // TODO: Extract custom filters from template + return {}; + }, [template]); + useEffect(() => { api - .get(apiUrl(previewApiUrl), { params: { limit: 1, ...preview.filters } }) + .get(apiUrl(previewApiUrl), { params: { limit: 1, ...templateFilters } }) .then((res) => { if (res.data.results.length === 0) return; setPreviewItem(res.data.results[0].pk); }); - }, [previewApiUrl, preview.filters]); + }, [previewApiUrl, templateFilters]); return ( @@ -249,6 +255,7 @@ export function TemplateEditor(props: Readonly) { ) { }, { key: 'preview_save', - name: t`Save & Reload preview`, + name: t`Save & Reload Preview`, tooltip: t`Save the current template and reload the preview`, icon: IconDeviceFloppy, onClick: () => updatePreview(hasSaveConfirmed), @@ -319,10 +326,10 @@ export function TemplateEditor(props: Readonly) { field_type: 'related field', api_url: apiUrl(previewApiUrl), description: '', - label: t`Select` + ' ' + preview.model + ' ' + t`to preview`, - model: preview.model, + label: t`Select instance to preview`, + model: template.model_type, value: previewItem, - filters: preview.filters, + filters: templateFilters, onValueChange: (value) => setPreviewItem(value) }} /> diff --git a/src/frontend/src/components/forms/fields/ApiFormField.tsx b/src/frontend/src/components/forms/fields/ApiFormField.tsx index 90446eef3b..be86befa10 100644 --- a/src/frontend/src/components/forms/fields/ApiFormField.tsx +++ b/src/frontend/src/components/forms/fields/ApiFormField.tsx @@ -40,6 +40,7 @@ export type ApiFormAdjustFilterType = { * @param icon : An icon to display next to the field * @param field_type : The type of field to render * @param api_url : The API endpoint to fetch data from (for related fields) + * @param pk_field : The primary key field for the related field (default = "pk") * @param model : The model to use for related fields * @param filters : Optional API filters to apply to related fields * @param required : Whether the field is required @@ -74,6 +75,7 @@ export type ApiFormFieldType = { | 'nested object' | 'table'; api_url?: string; + pk_field?: string; model?: ModelType; modelRenderer?: (instance: any) => ReactNode; filters?: any; @@ -190,6 +192,7 @@ export function ApiFormField({ {...reducedDefinition} ref={field.ref} id={fieldId} + aria-label={`text-field-${field.name}`} type={definition.field_type} value={value || ''} error={error?.message} @@ -208,6 +211,7 @@ export function ApiFormField({ {...reducedDefinition} ref={ref} id={fieldId} + aria-label={`boolean-field-${field.name}`} radius="lg" size="sm" checked={isTrue(value)} @@ -228,6 +232,7 @@ export function ApiFormField({ radius="sm" ref={field.ref} id={fieldId} + aria-label={`number-field-${field.name}`} value={numericalValue} error={error?.message} decimalScale={definition.field_type == 'integer' ? 0 : 10} diff --git a/src/frontend/src/components/forms/fields/ChoiceField.tsx b/src/frontend/src/components/forms/fields/ChoiceField.tsx index 5b7757645d..2f47c72718 100644 --- a/src/frontend/src/components/forms/fields/ChoiceField.tsx +++ b/src/frontend/src/components/forms/fields/ChoiceField.tsx @@ -51,6 +51,7 @@ export function ChoiceField({ return ( + {definition.headers?.map((header) => { - return ; + return {header}; })} diff --git a/src/frontend/src/components/items/ActionDropdown.tsx b/src/frontend/src/components/items/ActionDropdown.tsx index 59045bb928..73777a9c56 100644 --- a/src/frontend/src/components/items/ActionDropdown.tsx +++ b/src/frontend/src/components/items/ActionDropdown.tsx @@ -14,9 +14,9 @@ import { IconTrash, IconUnlink } from '@tabler/icons-react'; -import { color } from '@uiw/react-codemirror'; import { ReactNode, useMemo } from 'react'; +import { identifierString } from '../../functions/conversion'; import { InvenTreeIcon } from '../../functions/icons'; import { notYetImplemented } from '../../functions/notifications'; @@ -42,19 +42,24 @@ export function ActionDropdown({ disabled = false }: { icon: ReactNode; - tooltip?: string; + tooltip: string; actions: ActionDropdownItem[]; disabled?: boolean; }) { const hasActions = useMemo(() => { return actions.some((action) => !action.hidden); }, [actions]); + const indicatorProps = useMemo(() => { return actions.find((action) => action.indicator); }, [actions]); + const menuName: string = useMemo(() => { + return identifierString(`action-menu-${tooltip}`); + }, [tooltip]); + return hasActions ? ( - + - {actions.map((action) => - action.hidden ? null : ( + {actions.map((action) => { + const id: string = identifierString(`${menuName}-${action.name}`); + return action.hidden ? null : ( - + - ) - )} + ); + })} ) : null; @@ -108,7 +116,6 @@ export function BarcodeActionDropdown({ }) { return ( } actions={actions} diff --git a/src/frontend/src/components/render/Instance.tsx b/src/frontend/src/components/render/Instance.tsx index 905882ccfe..1844fe5030 100644 --- a/src/frontend/src/components/render/Instance.tsx +++ b/src/frontend/src/components/render/Instance.tsx @@ -26,6 +26,8 @@ import { RenderPartParameterTemplate, RenderPartTestTemplate } from './Part'; +import { RenderPlugin } from './Plugin'; +import { RenderLabelTemplate, RenderReportTemplate } from './Report'; import { RenderStockItem, RenderStockLocation, @@ -72,7 +74,10 @@ const RendererLookup: EnumDictionary< [ModelType.stockitem]: RenderStockItem, [ModelType.stockhistory]: RenderStockItem, [ModelType.supplierpart]: RenderSupplierPart, - [ModelType.user]: RenderUser + [ModelType.user]: RenderUser, + [ModelType.reporttemplate]: RenderReportTemplate, + [ModelType.labeltemplate]: RenderLabelTemplate, + [ModelType.pluginconfig]: RenderPlugin }; export type RenderInstanceProps = { diff --git a/src/frontend/src/components/render/ModelType.tsx b/src/frontend/src/components/render/ModelType.tsx index e4c9848f7e..27407f3e4a 100644 --- a/src/frontend/src/components/render/ModelType.tsx +++ b/src/frontend/src/components/render/ModelType.tsx @@ -195,6 +195,27 @@ export const ModelInformationDict: ModelDict = { url_overview: '/user', url_detail: '/user/:pk/', api_endpoint: ApiEndpoints.user_list + }, + labeltemplate: { + label: t`Label Template`, + label_multiple: t`Label Templates`, + url_overview: '/labeltemplate', + url_detail: '/labeltemplate/:pk/', + api_endpoint: ApiEndpoints.label_list + }, + reporttemplate: { + label: t`Report Template`, + label_multiple: t`Report Templates`, + url_overview: '/reporttemplate', + url_detail: '/reporttemplate/:pk/', + api_endpoint: ApiEndpoints.report_list + }, + pluginconfig: { + label: t`Plugin Configuration`, + label_multiple: t`Plugin Configurations`, + url_overview: '/pluginconfig', + url_detail: '/pluginconfig/:pk/', + api_endpoint: ApiEndpoints.plugin_list } }; diff --git a/src/frontend/src/components/render/Plugin.tsx b/src/frontend/src/components/render/Plugin.tsx new file mode 100644 index 0000000000..6ba246ef4d --- /dev/null +++ b/src/frontend/src/components/render/Plugin.tsx @@ -0,0 +1,21 @@ +import { t } from '@lingui/macro'; +import { Badge } from '@mantine/core'; +import { ReactNode } from 'react'; + +import { RenderInlineModel } from './Instance'; + +export function RenderPlugin({ + instance +}: { + instance: Readonly; +}): ReactNode { + return ( + {t`Inactive`} + } + /> + ); +} diff --git a/src/frontend/src/components/render/Report.tsx b/src/frontend/src/components/render/Report.tsx new file mode 100644 index 0000000000..f87fa9956f --- /dev/null +++ b/src/frontend/src/components/render/Report.tsx @@ -0,0 +1,29 @@ +import { ReactNode } from 'react'; + +import { RenderInlineModel } from './Instance'; + +export function RenderReportTemplate({ + instance +}: { + instance: any; +}): ReactNode { + return ( + + ); +} + +export function RenderLabelTemplate({ + instance +}: { + instance: any; +}): ReactNode { + return ( + + ); +} diff --git a/src/frontend/src/components/render/StatusRenderer.tsx b/src/frontend/src/components/render/StatusRenderer.tsx index 5c284533dc..2cfd2b2f2c 100644 --- a/src/frontend/src/components/render/StatusRenderer.tsx +++ b/src/frontend/src/components/render/StatusRenderer.tsx @@ -80,7 +80,7 @@ export const StatusRenderer = ({ const statusCodes = statusCodeList[type]; if (statusCodes === undefined) { - console.log('StatusRenderer: statusCodes is undefined'); + console.warn('StatusRenderer: statusCodes is undefined'); return null; } diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx index 73272cf08b..436f3e4697 100644 --- a/src/frontend/src/enums/ApiEndpoints.tsx +++ b/src/frontend/src/enums/ApiEndpoints.tsx @@ -127,8 +127,14 @@ export enum ApiEndpoints { return_order_attachment_list = 'order/ro/attachment/', // Template API endpoints - label_list = 'label/:variant/', - report_list = 'report/:variant/', + label_list = 'label/template/', + label_print = 'label/print/', + label_output = 'label/output/', + report_list = 'report/template/', + report_print = 'report/print/', + report_output = 'report/output/', + report_snippet = 'report/snippet/', + report_asset = 'report/asset/', // Plugin API endpoints plugin_list = 'plugins/', diff --git a/src/frontend/src/enums/ModelType.tsx b/src/frontend/src/enums/ModelType.tsx index 8f0574ef91..136eded78c 100644 --- a/src/frontend/src/enums/ModelType.tsx +++ b/src/frontend/src/enums/ModelType.tsx @@ -24,5 +24,8 @@ export enum ModelType { address = 'address', contact = 'contact', owner = 'owner', - user = 'user' + user = 'user', + reporttemplate = 'reporttemplate', + labeltemplate = 'labeltemplate', + pluginconfig = 'pluginconfig' } diff --git a/src/frontend/src/functions/conversion.tsx b/src/frontend/src/functions/conversion.tsx index afc4e536d2..1eed5db9e7 100644 --- a/src/frontend/src/functions/conversion.tsx +++ b/src/frontend/src/functions/conversion.tsx @@ -32,3 +32,8 @@ export function resolveItem(obj: any, path: string): any { let properties = path.split('.'); return properties.reduce((prev, curr) => prev?.[curr], obj); } + +export function identifierString(value: string): string { + // Convert an input string e.g. "Hello World" into a string that can be used as an identifier, e.g. "hello-world" + return value.toLowerCase().replace(/[^a-z0-9]/g, '-'); +} diff --git a/src/frontend/src/hooks/UseInstance.tsx b/src/frontend/src/hooks/UseInstance.tsx index c48cc8d07e..88298cc1d0 100644 --- a/src/frontend/src/hooks/UseInstance.tsx +++ b/src/frontend/src/hooks/UseInstance.tsx @@ -27,7 +27,7 @@ export function useInstance({ updateInterval }: { endpoint: ApiEndpoints; - pk?: string | undefined; + pk?: string | number | undefined; hasPrimaryKey?: boolean; params?: any; pathParams?: PathParams; @@ -43,7 +43,12 @@ export function useInstance({ queryKey: ['instance', endpoint, pk, params, pathParams], queryFn: async () => { if (hasPrimaryKey) { - if (pk == null || pk == undefined || pk.length == 0 || pk == '-1') { + if ( + pk == null || + pk == undefined || + pk.toString().length == 0 || + pk == '-1' + ) { setInstance(defaultValue); return defaultValue; } diff --git a/src/frontend/src/hooks/UseTable.tsx b/src/frontend/src/hooks/UseTable.tsx index 48d583ccf5..ff9362291f 100644 --- a/src/frontend/src/hooks/UseTable.tsx +++ b/src/frontend/src/hooks/UseTable.tsx @@ -24,6 +24,7 @@ export type TableState = { expandedRecords: any[]; setExpandedRecords: (records: any[]) => void; selectedRecords: any[]; + selectedIds: number[]; hasSelectedRecords: boolean; setSelectedRecords: (records: any[]) => void; clearSelectedRecords: () => void; @@ -77,6 +78,12 @@ export function useTable(tableName: string): TableState { // Array of selected records const [selectedRecords, setSelectedRecords] = useState([]); + // Array of selected primary key values + const selectedIds = useMemo( + () => selectedRecords.map((r) => r.pk ?? r.id), + [selectedRecords] + ); + const clearSelectedRecords = useCallback(() => { setSelectedRecords([]); }, []); @@ -135,6 +142,7 @@ export function useTable(tableName: string): TableState { expandedRecords, setExpandedRecords, selectedRecords, + selectedIds, setSelectedRecords, clearSelectedRecords, hasSelectedRecords, diff --git a/src/frontend/src/pages/Index/Playground.tsx b/src/frontend/src/pages/Index/Playground.tsx index 902858b5d3..ec8e0ba7f8 100644 --- a/src/frontend/src/pages/Index/Playground.tsx +++ b/src/frontend/src/pages/Index/Playground.tsx @@ -202,7 +202,6 @@ function SpotlighPlayground() { onClick: () => console.log('Secret') } ]); - console.log('registed'); firstSpotlight.open(); }} > diff --git a/src/frontend/src/pages/Index/Scan.tsx b/src/frontend/src/pages/Index/Scan.tsx index 889eebd18b..f05de24f6c 100644 --- a/src/frontend/src/pages/Index/Scan.tsx +++ b/src/frontend/src/pages/Index/Scan.tsx @@ -684,7 +684,6 @@ function InputImageBarcode({ action }: Readonly) { useEffect(() => { if (cameraValue === null) return; if (cameraValue === camId?.id) { - console.log('matching value and id'); return; } diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx index cca88e567b..a5d16d530a 100644 --- a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx +++ b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx @@ -9,9 +9,10 @@ import { IconListDetails, IconPackages, IconPlugConnected, + IconReport, IconScale, IconSitemap, - IconTemplate, + IconTags, IconUsersGroup } from '@tabler/icons-react'; import { lazy, useMemo } from 'react'; @@ -22,6 +23,12 @@ import { SettingsHeader } from '../../../../components/nav/SettingsHeader'; import { GlobalSettingList } from '../../../../components/settings/SettingList'; import { Loadable } from '../../../../functions/loading'; +const ReportTemplatePanel = Loadable( + lazy(() => import('./ReportTemplatePanel')) +); + +const LabelTemplatePanel = Loadable(lazy(() => import('./LabelTemplatePanel'))); + const UserManagementPanel = Loadable( lazy(() => import('./UserManagementPanel')) ); @@ -66,10 +73,6 @@ const CurrencyTable = Loadable( lazy(() => import('../../../../tables/settings/CurrencyTable')) ); -const TemplateManagementPanel = Loadable( - lazy(() => import('./TemplateManagementPanel')) -); - export default function AdminCenter() { const adminCenterPanels: PanelType[] = useMemo(() => { return [ @@ -127,18 +130,24 @@ export default function AdminCenter() { icon: , content: }, + { + name: 'labels', + label: t`Label Templates`, + icon: , + content: + }, + { + name: 'reports', + label: t`Report Templates`, + icon: , + content: + }, { name: 'location-types', label: t`Location types`, icon: , content: }, - { - name: 'templates', - label: t`Templates`, - icon: , - content: - }, { name: 'plugin', label: t`Plugins`, diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/LabelTemplatePanel.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/LabelTemplatePanel.tsx new file mode 100644 index 0000000000..2e6e258159 --- /dev/null +++ b/src/frontend/src/pages/Index/Settings/AdminCenter/LabelTemplatePanel.tsx @@ -0,0 +1,17 @@ +import { ApiEndpoints } from '../../../../enums/ApiEndpoints'; +import { TemplateTable } from '../../../../tables/settings/TemplateTable'; + +export default function LabelTemplatePanel() { + return ( + + ); +} diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/ReportTemplatePanel.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/ReportTemplatePanel.tsx new file mode 100644 index 0000000000..7eab843ddf --- /dev/null +++ b/src/frontend/src/pages/Index/Settings/AdminCenter/ReportTemplatePanel.tsx @@ -0,0 +1,17 @@ +import { ApiEndpoints } from '../../../../enums/ApiEndpoints'; +import { TemplateTable } from '../../../../tables/settings/TemplateTable'; + +export default function ReportTemplateTable() { + return ( + + ); +} diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/TemplateManagementPanel.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/TemplateManagementPanel.tsx deleted file mode 100644 index 833caba5b5..0000000000 --- a/src/frontend/src/pages/Index/Settings/AdminCenter/TemplateManagementPanel.tsx +++ /dev/null @@ -1,211 +0,0 @@ -import { t } from '@lingui/macro'; -import { Stack } from '@mantine/core'; -import { useMemo } from 'react'; - -import { TemplatePreviewProps } from '../../../../components/editors/TemplateEditor/TemplateEditor'; -import { ApiFormFieldSet } from '../../../../components/forms/fields/ApiFormField'; -import { PanelGroup } from '../../../../components/nav/PanelGroup'; -import { - defaultLabelTemplate, - defaultReportTemplate -} from '../../../../defaults/templates'; -import { ApiEndpoints } from '../../../../enums/ApiEndpoints'; -import { ModelType } from '../../../../enums/ModelType'; -import { InvenTreeIcon, InvenTreeIconType } from '../../../../functions/icons'; -import { TemplateTable } from '../../../../tables/settings/TemplateTable'; - -type TemplateType = { - type: 'label' | 'report'; - name: string; - singularName: string; - apiEndpoints: ApiEndpoints; - templateKey: string; - additionalFormFields?: ApiFormFieldSet; - defaultTemplate: string; - variants: { - name: string; - key: string; - icon: InvenTreeIconType; - preview: TemplatePreviewProps; - }[]; -}; - -export default function TemplateManagementPanel() { - const templateTypes = useMemo(() => { - const templateTypes: TemplateType[] = [ - { - type: 'label', - name: t`Labels`, - singularName: t`Label`, - apiEndpoints: ApiEndpoints.label_list, - templateKey: 'label', - additionalFormFields: { - width: {}, - height: {} - }, - defaultTemplate: defaultLabelTemplate, - variants: [ - { - name: t`Part`, - key: 'part', - icon: 'part', - preview: { - itemKey: 'part', - model: ModelType.part - } - }, - { - name: t`Location`, - key: 'location', - icon: 'location', - preview: { - itemKey: 'location', - model: ModelType.stocklocation - } - }, - { - name: t`Stock Item`, - key: 'stock', - icon: 'stock', - preview: { - itemKey: 'item', - model: ModelType.stockitem - } - }, - { - name: t`Build Line`, - key: 'buildline', - icon: 'builds', - preview: { - itemKey: 'line', - model: ModelType.buildline - } - } - ] - }, - { - type: 'report', - name: t`Reports`, - singularName: t`Report`, - apiEndpoints: ApiEndpoints.report_list, - templateKey: 'template', - additionalFormFields: { - page_size: {}, - landscape: {} - }, - defaultTemplate: defaultReportTemplate, - variants: [ - { - name: t`Purchase Order`, - key: 'po', - icon: 'purchase_orders', - preview: { - itemKey: 'order', - model: ModelType.purchaseorder - } - }, - { - name: t`Sales Order`, - key: 'so', - icon: 'sales_orders', - preview: { - itemKey: 'order', - model: ModelType.salesorder - } - }, - { - name: t`Return Order`, - key: 'ro', - icon: 'return_orders', - preview: { - itemKey: 'order', - model: ModelType.returnorder - } - }, - { - name: t`Build`, - key: 'build', - icon: 'builds', - preview: { - itemKey: 'build', - model: ModelType.build - } - }, - { - name: t`Bill of Materials`, - key: 'bom', - icon: 'bom', - preview: { - itemKey: 'part', - model: ModelType.part, - filters: { assembly: true } - } - }, - { - name: t`Tests`, - key: 'test', - icon: 'test_templates', - preview: { - itemKey: 'item', - model: ModelType.stockitem - } - }, - { - name: t`Stock Location`, - key: 'slr', - icon: 'default_location', - preview: { - itemKey: 'location', - model: ModelType.stocklocation - } - } - ] - } - ]; - - return templateTypes; - }, []); - - const panels = useMemo(() => { - return templateTypes.flatMap((templateType) => { - return [ - // Add panel headline - { name: templateType.type, label: templateType.name, disabled: true }, - - // Add panel for each variant - ...templateType.variants.map((variant) => { - return { - name: variant.key, - label: variant.name, - content: ( - - ), - icon: , - showHeadline: false - }; - }) - ]; - }); - }, [templateTypes]); - - return ( - - - - ); -} diff --git a/src/frontend/src/pages/build/BuildDetail.tsx b/src/frontend/src/pages/build/BuildDetail.tsx index 58c9871325..62e9178222 100644 --- a/src/frontend/src/pages/build/BuildDetail.tsx +++ b/src/frontend/src/pages/build/BuildDetail.tsx @@ -4,13 +4,11 @@ import { IconClipboardCheck, IconClipboardList, IconDots, - IconFileTypePdf, IconInfoCircle, IconList, IconListCheck, IconNotes, IconPaperclip, - IconPrinter, IconQrcode, IconSitemap } from '@tabler/icons-react'; @@ -18,6 +16,7 @@ import { useMemo } from 'react'; import { useParams } from 'react-router-dom'; import AdminButton from '../../components/buttons/AdminButton'; +import { PrintingActions } from '../../components/buttons/PrintingActions'; import { DetailsField, DetailsTable } from '../../components/details/Details'; import { DetailsImage } from '../../components/details/DetailsImage'; import { ItemDetailsGrid } from '../../components/details/ItemDetails'; @@ -358,7 +357,6 @@ export default function BuildDetail() { return [ , } actions={[ @@ -371,20 +369,12 @@ export default function BuildDetail() { }) ]} />, - } - actions={[ - { - icon: , - name: t`Report`, - tooltip: t`Print build report` - } - ]} + , } actions={[ diff --git a/src/frontend/src/pages/company/CompanyDetail.tsx b/src/frontend/src/pages/company/CompanyDetail.tsx index bdfe135f27..57137cfe29 100644 --- a/src/frontend/src/pages/company/CompanyDetail.tsx +++ b/src/frontend/src/pages/company/CompanyDetail.tsx @@ -289,7 +289,6 @@ export default function CompanyDetail(props: Readonly) { return [ , } actions={[ diff --git a/src/frontend/src/pages/company/ManufacturerPartDetail.tsx b/src/frontend/src/pages/company/ManufacturerPartDetail.tsx index 708a744f70..8865d98b7a 100644 --- a/src/frontend/src/pages/company/ManufacturerPartDetail.tsx +++ b/src/frontend/src/pages/company/ManufacturerPartDetail.tsx @@ -210,7 +210,6 @@ export default function ManufacturerPartDetail() { pk={manufacturerPart.pk} />, } actions={[ diff --git a/src/frontend/src/pages/company/SupplierPartDetail.tsx b/src/frontend/src/pages/company/SupplierPartDetail.tsx index 28a08425d4..bef8168c48 100644 --- a/src/frontend/src/pages/company/SupplierPartDetail.tsx +++ b/src/frontend/src/pages/company/SupplierPartDetail.tsx @@ -246,7 +246,6 @@ export default function SupplierPartDetail() { return [ , } actions={[ diff --git a/src/frontend/src/pages/part/CategoryDetail.tsx b/src/frontend/src/pages/part/CategoryDetail.tsx index 6d03903d4a..9ac93e71ed 100644 --- a/src/frontend/src/pages/part/CategoryDetail.tsx +++ b/src/frontend/src/pages/part/CategoryDetail.tsx @@ -204,7 +204,6 @@ export default function CategoryDetail({}: {}) { return [ , } actions={[ diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index 672c4c15aa..e54ee81035 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -761,7 +761,6 @@ export default function PartDetail() { key="action_dropdown" />, } actions={[ @@ -790,7 +789,6 @@ export default function PartDetail() { ]} />, } actions={[ diff --git a/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx b/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx index d90e877574..bb8f097da7 100644 --- a/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx +++ b/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx @@ -12,6 +12,7 @@ import { ReactNode, useMemo } from 'react'; import { useParams } from 'react-router-dom'; import AdminButton from '../../components/buttons/AdminButton'; +import { PrintingActions } from '../../components/buttons/PrintingActions'; import { DetailsField, DetailsTable } from '../../components/details/Details'; import { DetailsImage } from '../../components/details/DetailsImage'; import { ItemDetailsGrid } from '../../components/details/ItemDetails'; @@ -313,8 +314,12 @@ export default function PurchaseOrderDetail() { }) ]} />, + , } actions={[ diff --git a/src/frontend/src/pages/sales/ReturnOrderDetail.tsx b/src/frontend/src/pages/sales/ReturnOrderDetail.tsx index 94e405098f..3cfd4f19a8 100644 --- a/src/frontend/src/pages/sales/ReturnOrderDetail.tsx +++ b/src/frontend/src/pages/sales/ReturnOrderDetail.tsx @@ -11,6 +11,7 @@ import { ReactNode, useMemo } from 'react'; import { useParams } from 'react-router-dom'; import AdminButton from '../../components/buttons/AdminButton'; +import { PrintingActions } from '../../components/buttons/PrintingActions'; import { DetailsField, DetailsTable } from '../../components/details/Details'; import { DetailsImage } from '../../components/details/DetailsImage'; import { ItemDetailsGrid } from '../../components/details/ItemDetails'; @@ -287,8 +288,12 @@ export default function ReturnOrderDetail() { const orderActions = useMemo(() => { return [ , + , } actions={[ diff --git a/src/frontend/src/pages/sales/SalesOrderDetail.tsx b/src/frontend/src/pages/sales/SalesOrderDetail.tsx index 4f540fca14..eceba7274b 100644 --- a/src/frontend/src/pages/sales/SalesOrderDetail.tsx +++ b/src/frontend/src/pages/sales/SalesOrderDetail.tsx @@ -14,6 +14,7 @@ import { ReactNode, useMemo } from 'react'; import { useParams } from 'react-router-dom'; import AdminButton from '../../components/buttons/AdminButton'; +import { PrintingActions } from '../../components/buttons/PrintingActions'; import { DetailsField, DetailsTable } from '../../components/details/Details'; import { DetailsImage } from '../../components/details/DetailsImage'; import { ItemDetailsGrid } from '../../components/details/ItemDetails'; @@ -299,8 +300,12 @@ export default function SalesOrderDetail() { const soActions = useMemo(() => { return [ , + , } actions={[ diff --git a/src/frontend/src/pages/stock/LocationDetail.tsx b/src/frontend/src/pages/stock/LocationDetail.tsx index a7db455caf..e3c83dea6d 100644 --- a/src/frontend/src/pages/stock/LocationDetail.tsx +++ b/src/frontend/src/pages/stock/LocationDetail.tsx @@ -11,6 +11,7 @@ import { useNavigate, useParams } from 'react-router-dom'; import { ActionButton } from '../../components/buttons/ActionButton'; import AdminButton from '../../components/buttons/AdminButton'; +import { PrintingActions } from '../../components/buttons/PrintingActions'; import { DetailsField, DetailsTable } from '../../components/details/Details'; import { ItemDetailsGrid } from '../../components/details/ItemDetails'; import { @@ -290,24 +291,14 @@ export default function Stock() { } ]} />, - } - actions={[ - { - name: 'Print Label', - icon: '', - tooltip: 'Print label' - }, - { - name: 'Print Location Report', - icon: '', - tooltip: 'Print Report' - } - ]} + , } actions={[ { @@ -329,7 +320,6 @@ export default function Stock() { ]} />, } actions={[ diff --git a/src/frontend/src/pages/stock/StockDetail.tsx b/src/frontend/src/pages/stock/StockDetail.tsx index 205f419aba..8c9669e17b 100644 --- a/src/frontend/src/pages/stock/StockDetail.tsx +++ b/src/frontend/src/pages/stock/StockDetail.tsx @@ -16,6 +16,7 @@ import { ReactNode, useMemo, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import AdminButton from '../../components/buttons/AdminButton'; +import { PrintingActions } from '../../components/buttons/PrintingActions'; import { DetailsField, DetailsTable } from '../../components/details/Details'; import DetailsBadge from '../../components/details/DetailsBadge'; import { DetailsImage } from '../../components/details/DetailsImage'; @@ -429,8 +430,13 @@ export default function StockDetail() { }) ]} />, + , } actions={[ @@ -473,7 +479,6 @@ export default function StockDetail() { ]} />, } actions={[ diff --git a/src/frontend/src/tables/ColumnRenderers.tsx b/src/frontend/src/tables/ColumnRenderers.tsx index d4f59d9a09..b3076954c8 100644 --- a/src/frontend/src/tables/ColumnRenderers.tsx +++ b/src/frontend/src/tables/ColumnRenderers.tsx @@ -2,7 +2,7 @@ * Common rendering functions for table column data. */ import { t } from '@lingui/macro'; -import { Anchor, Text } from '@mantine/core'; +import { Anchor, Skeleton, Text } from '@mantine/core'; import { YesNoButton } from '../components/buttons/YesNoButton'; import { Thumbnail } from '../components/images/Thumbnail'; @@ -18,11 +18,13 @@ import { ProjectCodeHoverCard } from './TableHoverCard'; // Render a Part instance within a table export function PartColumn(part: any, full_name?: boolean) { - return ( + return part ? ( + ) : ( + ); } @@ -226,8 +228,8 @@ export function CurrencyColumn({ sortable: sortable ?? true, render: (record: any) => { let currency_key = currency_accessor ?? `${accessor}_currency`; - return formatCurrency(record[accessor], { - currency: currency ?? record[currency_key] + return formatCurrency(resolveItem(record, accessor), { + currency: currency ?? resolveItem(record, currency_key) }); } }; diff --git a/src/frontend/src/tables/DownloadAction.tsx b/src/frontend/src/tables/DownloadAction.tsx index 34abdfff7f..1de56b9803 100644 --- a/src/frontend/src/tables/DownloadAction.tsx +++ b/src/frontend/src/tables/DownloadAction.tsx @@ -1,6 +1,17 @@ import { t } from '@lingui/macro'; import { ActionIcon, Menu, Tooltip } from '@mantine/core'; -import { IconDownload } from '@tabler/icons-react'; +import { + IconDownload, + IconFileSpreadsheet, + IconFileText, + IconFileTypeCsv +} from '@tabler/icons-react'; +import { useMemo } from 'react'; + +import { + ActionDropdown, + ActionDropdownItem +} from '../components/items/ActionDropdown'; export function DownloadAction({ downloadCallback @@ -8,34 +19,27 @@ export function DownloadAction({ downloadCallback: (fileFormat: string) => void; }) { const formatOptions = [ - { value: 'csv', label: t`CSV` }, - { value: 'tsv', label: t`TSV` }, - { value: 'xlsx', label: t`Excel` } + { value: 'csv', label: t`CSV`, icon: }, + { value: 'tsv', label: t`TSV`, icon: }, + { value: 'xls', label: t`Excel (.xls)`, icon: }, + { value: 'xlsx', label: t`Excel (.xlsx)`, icon: } ]; + const actions: ActionDropdownItem[] = useMemo(() => { + return formatOptions.map((format) => ({ + name: format.label, + icon: format.icon, + onClick: () => downloadCallback(format.value) + })); + }, [formatOptions, downloadCallback]); + return ( <> - - - - - - - - - - {formatOptions.map((format) => ( - { - downloadCallback(format.value); - }} - > - {format.label} - - ))} - - + } + actions={actions} + /> ); } diff --git a/src/frontend/src/tables/FilterSelectDrawer.tsx b/src/frontend/src/tables/FilterSelectDrawer.tsx index 841c420fb5..c159a6c59a 100644 --- a/src/frontend/src/tables/FilterSelectDrawer.tsx +++ b/src/frontend/src/tables/FilterSelectDrawer.tsx @@ -12,7 +12,7 @@ import { Text, Tooltip } from '@mantine/core'; -import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { StylishText } from '../components/items/StylishText'; import { TableState } from '../hooks/UseTable'; @@ -63,18 +63,6 @@ interface FilterProps extends React.ComponentPropsWithoutRef<'div'> { description?: string; } -/* - * Custom component for the filter select - */ -const FilterSelectItem = forwardRef( - ({ label, description, ...others }, ref) => ( -
- {label} - {description} -
- ) -); - function FilterAddGroup({ tableState, availableFilters @@ -144,7 +132,6 @@ function FilterAddGroup({
{header}