mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 13:15:43 +00:00 
			
		
		
		
	Report enhancements (#6714)
* Add "enabled" filter to template table * Cleanup * API endpoints - Add API endpoints for report snippet - List endpoint - Details endpoint * Update serializers - Add asset serializer - Update * Check for duplicate asset files - Prevent upload of duplicate asset files - Allow re-upload for same PK * Duplicate checks for ReportSnippet * Bump API version
This commit is contained in:
		| @@ -1,11 +1,15 @@ | ||||
| """InvenTree API version information.""" | ||||
|  | ||||
| # InvenTree API version | ||||
| INVENTREE_API_VERSION = 181 | ||||
| INVENTREE_API_VERSION = 182 | ||||
| """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" | ||||
|  | ||||
| INVENTREE_API_TEXT = """ | ||||
|  | ||||
| v182 - 2024-03-15 : https://github.com/inventree/InvenTree/pull/6714 | ||||
|     - Expose ReportSnippet model to the /report/snippet/ API endpoint | ||||
|     - Expose ReportAsset model to the /report/asset/ API endpoint | ||||
|  | ||||
| v181 - 2024-02-21 : https://github.com/inventree/InvenTree/pull/6541 | ||||
|     - Adds "width" and "height" fields to the LabelTemplate API endpoint | ||||
|     - Adds "page_size" and "landscape" fields to the ReportTemplate API endpoint | ||||
|   | ||||
| @@ -17,31 +17,14 @@ import common.models | ||||
| import InvenTree.helpers | ||||
| import order.models | ||||
| import part.models | ||||
| import report.models | ||||
| import report.serializers | ||||
| from InvenTree.api import 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 | ||||
|  | ||||
| from .models import ( | ||||
|     BillOfMaterialsReport, | ||||
|     BuildReport, | ||||
|     PurchaseOrderReport, | ||||
|     ReturnOrderReport, | ||||
|     SalesOrderReport, | ||||
|     StockLocationReport, | ||||
|     TestReport, | ||||
| ) | ||||
| from .serializers import ( | ||||
|     BOMReportSerializer, | ||||
|     BuildReportSerializer, | ||||
|     PurchaseOrderReportSerializer, | ||||
|     ReturnOrderReportSerializer, | ||||
|     SalesOrderReportSerializer, | ||||
|     StockLocationReportSerializer, | ||||
|     TestReportSerializer, | ||||
| ) | ||||
|  | ||||
|  | ||||
| class ReportListView(ListCreateAPI): | ||||
|     """Generic API class for report templates.""" | ||||
| @@ -292,8 +275,8 @@ class StockItemTestReportMixin(ReportFilterMixin): | ||||
|  | ||||
|     ITEM_MODEL = StockItem | ||||
|     ITEM_KEY = 'item' | ||||
|     queryset = TestReport.objects.all() | ||||
|     serializer_class = TestReportSerializer | ||||
|     queryset = report.models.TestReport.objects.all() | ||||
|     serializer_class = report.serializers.TestReportSerializer | ||||
|  | ||||
|  | ||||
| class StockItemTestReportList(StockItemTestReportMixin, ReportListView): | ||||
| @@ -343,8 +326,8 @@ class BOMReportMixin(ReportFilterMixin): | ||||
|     ITEM_MODEL = part.models.Part | ||||
|     ITEM_KEY = 'part' | ||||
|  | ||||
|     queryset = BillOfMaterialsReport.objects.all() | ||||
|     serializer_class = BOMReportSerializer | ||||
|     queryset = report.models.BillOfMaterialsReport.objects.all() | ||||
|     serializer_class = report.serializers.BOMReportSerializer | ||||
|  | ||||
|  | ||||
| class BOMReportList(BOMReportMixin, ReportListView): | ||||
| @@ -377,8 +360,8 @@ class BuildReportMixin(ReportFilterMixin): | ||||
|     ITEM_MODEL = build.models.Build | ||||
|     ITEM_KEY = 'build' | ||||
|  | ||||
|     queryset = BuildReport.objects.all() | ||||
|     serializer_class = BuildReportSerializer | ||||
|     queryset = report.models.BuildReport.objects.all() | ||||
|     serializer_class = report.serializers.BuildReportSerializer | ||||
|  | ||||
|  | ||||
| class BuildReportList(BuildReportMixin, ReportListView): | ||||
| @@ -411,8 +394,8 @@ class PurchaseOrderReportMixin(ReportFilterMixin): | ||||
|     ITEM_MODEL = order.models.PurchaseOrder | ||||
|     ITEM_KEY = 'order' | ||||
|  | ||||
|     queryset = PurchaseOrderReport.objects.all() | ||||
|     serializer_class = PurchaseOrderReportSerializer | ||||
|     queryset = report.models.PurchaseOrderReport.objects.all() | ||||
|     serializer_class = report.serializers.PurchaseOrderReportSerializer | ||||
|  | ||||
|  | ||||
| class PurchaseOrderReportList(PurchaseOrderReportMixin, ReportListView): | ||||
| @@ -439,8 +422,8 @@ class SalesOrderReportMixin(ReportFilterMixin): | ||||
|     ITEM_MODEL = order.models.SalesOrder | ||||
|     ITEM_KEY = 'order' | ||||
|  | ||||
|     queryset = SalesOrderReport.objects.all() | ||||
|     serializer_class = SalesOrderReportSerializer | ||||
|     queryset = report.models.SalesOrderReport.objects.all() | ||||
|     serializer_class = report.serializers.SalesOrderReportSerializer | ||||
|  | ||||
|  | ||||
| class SalesOrderReportList(SalesOrderReportMixin, ReportListView): | ||||
| @@ -467,8 +450,8 @@ class ReturnOrderReportMixin(ReportFilterMixin): | ||||
|     ITEM_MODEL = order.models.ReturnOrder | ||||
|     ITEM_KEY = 'order' | ||||
|  | ||||
|     queryset = ReturnOrderReport.objects.all() | ||||
|     serializer_class = ReturnOrderReportSerializer | ||||
|     queryset = report.models.ReturnOrderReport.objects.all() | ||||
|     serializer_class = report.serializers.ReturnOrderReportSerializer | ||||
|  | ||||
|  | ||||
| class ReturnOrderReportList(ReturnOrderReportMixin, ReportListView): | ||||
| @@ -494,8 +477,8 @@ class StockLocationReportMixin(ReportFilterMixin): | ||||
|  | ||||
|     ITEM_MODEL = StockLocation | ||||
|     ITEM_KEY = 'location' | ||||
|     queryset = StockLocationReport.objects.all() | ||||
|     serializer_class = StockLocationReportSerializer | ||||
|     queryset = report.models.StockLocationReport.objects.all() | ||||
|     serializer_class = report.serializers.StockLocationReportSerializer | ||||
|  | ||||
|  | ||||
| class StockLocationReportList(StockLocationReportMixin, ReportListView): | ||||
| @@ -516,7 +499,57 @@ class StockLocationReportPrint(StockLocationReportMixin, ReportPrintMixin, Retri | ||||
|     pass | ||||
|  | ||||
|  | ||||
| class ReportSnippetList(ListCreateAPI): | ||||
|     """API endpoint for listing ReportSnippet objects.""" | ||||
|  | ||||
|     queryset = report.models.ReportSnippet.objects.all() | ||||
|     serializer_class = report.serializers.ReportSnippetSerializer | ||||
|  | ||||
|  | ||||
| class ReportSnippetDetail(RetrieveUpdateDestroyAPI): | ||||
|     """API endpoint for a single ReportSnippet object.""" | ||||
|  | ||||
|     queryset = report.models.ReportSnippet.objects.all() | ||||
|     serializer_class = report.serializers.ReportSnippetSerializer | ||||
|  | ||||
|  | ||||
| class ReportAssetList(ListCreateAPI): | ||||
|     """API endpoint for listing ReportAsset objects.""" | ||||
|  | ||||
|     queryset = report.models.ReportAsset.objects.all() | ||||
|     serializer_class = report.serializers.ReportAssetSerializer | ||||
|  | ||||
|  | ||||
| class ReportAssetDetail(RetrieveUpdateDestroyAPI): | ||||
|     """API endpoint for a single ReportAsset object.""" | ||||
|  | ||||
|     queryset = report.models.ReportAsset.objects.all() | ||||
|     serializer_class = report.serializers.ReportAssetSerializer | ||||
|  | ||||
|  | ||||
| report_api_urls = [ | ||||
|     # Report assets | ||||
|     path( | ||||
|         'asset/', | ||||
|         include([ | ||||
|             path( | ||||
|                 '<int:pk>/', ReportAssetDetail.as_view(), name='api-report-asset-detail' | ||||
|             ), | ||||
|             path('', ReportAssetList.as_view(), name='api-report-asset-list'), | ||||
|         ]), | ||||
|     ), | ||||
|     # Report snippets | ||||
|     path( | ||||
|         'snippet/', | ||||
|         include([ | ||||
|             path( | ||||
|                 '<int:pk>/', | ||||
|                 ReportSnippetDetail.as_view(), | ||||
|                 name='api-report-snippet-detail', | ||||
|             ), | ||||
|             path('', ReportSnippetList.as_view(), name='api-report-snippet-list'), | ||||
|         ]), | ||||
|     ), | ||||
|     # Purchase order reports | ||||
|     path( | ||||
|         'po/', | ||||
| @@ -533,7 +566,7 @@ report_api_urls = [ | ||||
|                     path( | ||||
|                         'metadata/', | ||||
|                         MetadataView.as_view(), | ||||
|                         {'model': PurchaseOrderReport}, | ||||
|                         {'model': report.models.PurchaseOrderReport}, | ||||
|                         name='api-po-report-metadata', | ||||
|                     ), | ||||
|                     path( | ||||
| @@ -563,7 +596,7 @@ report_api_urls = [ | ||||
|                     path( | ||||
|                         'metadata/', | ||||
|                         MetadataView.as_view(), | ||||
|                         {'model': SalesOrderReport}, | ||||
|                         {'model': report.models.SalesOrderReport}, | ||||
|                         name='api-so-report-metadata', | ||||
|                     ), | ||||
|                     path( | ||||
| @@ -591,7 +624,7 @@ report_api_urls = [ | ||||
|                     path( | ||||
|                         'metadata/', | ||||
|                         MetadataView.as_view(), | ||||
|                         {'model': ReturnOrderReport}, | ||||
|                         {'model': report.models.ReturnOrderReport}, | ||||
|                         name='api-so-report-metadata', | ||||
|                     ), | ||||
|                     path( | ||||
| @@ -622,7 +655,7 @@ report_api_urls = [ | ||||
|                     path( | ||||
|                         'metadata/', | ||||
|                         MetadataView.as_view(), | ||||
|                         {'model': BuildReport}, | ||||
|                         {'model': report.models.BuildReport}, | ||||
|                         name='api-build-report-metadata', | ||||
|                     ), | ||||
|                     path( | ||||
| @@ -650,7 +683,7 @@ report_api_urls = [ | ||||
|                     path( | ||||
|                         'metadata/', | ||||
|                         MetadataView.as_view(), | ||||
|                         {'model': BillOfMaterialsReport}, | ||||
|                         {'model': report.models.BillOfMaterialsReport}, | ||||
|                         name='api-bom-report-metadata', | ||||
|                     ), | ||||
|                     path('', BOMReportDetail.as_view(), name='api-bom-report-detail'), | ||||
| @@ -676,7 +709,7 @@ report_api_urls = [ | ||||
|                     path( | ||||
|                         'metadata/', | ||||
|                         MetadataView.as_view(), | ||||
|                         {'report': TestReport}, | ||||
|                         {'report': report.models.TestReport}, | ||||
|                         name='api-stockitem-testreport-metadata', | ||||
|                     ), | ||||
|                     path( | ||||
| @@ -710,7 +743,7 @@ report_api_urls = [ | ||||
|                     path( | ||||
|                         'metadata/', | ||||
|                         MetadataView.as_view(), | ||||
|                         {'report': StockLocationReport}, | ||||
|                         {'report': report.models.StockLocationReport}, | ||||
|                         name='api-stocklocation-report-metadata', | ||||
|                     ), | ||||
|                     path( | ||||
|   | ||||
| @@ -7,6 +7,7 @@ import sys | ||||
|  | ||||
| from django.conf import settings | ||||
| from django.core.cache import cache | ||||
| from django.core.exceptions import ValidationError | ||||
| from django.core.validators import FileExtensionValidator | ||||
| from django.db import models | ||||
| from django.template import Context, Template | ||||
| @@ -585,10 +586,7 @@ class ReturnOrderReport(ReportTemplateBase): | ||||
|  | ||||
| def rename_snippet(instance, filename): | ||||
|     """Function to rename a report snippet once uploaded.""" | ||||
|     filename = os.path.basename(filename) | ||||
|  | ||||
|     path = os.path.join('report', 'snippets', filename) | ||||
|  | ||||
|     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, | ||||
| @@ -610,6 +608,40 @@ class ReportSnippet(models.Model): | ||||
|     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) | ||||
|  | ||||
|     snippet = models.FileField( | ||||
|         upload_to=rename_snippet, | ||||
|         verbose_name=_('Snippet'), | ||||
| @@ -626,19 +658,20 @@ class ReportSnippet(models.Model): | ||||
|  | ||||
| def rename_asset(instance, filename): | ||||
|     """Function to rename an asset file when uploaded.""" | ||||
|     filename = os.path.basename(filename) | ||||
|  | ||||
|     path = os.path.join('report', 'assets', filename) | ||||
|     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): | ||||
|         fullpath = settings.MEDIA_ROOT.joinpath(path).resolve() | ||||
|  | ||||
|         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 | ||||
|  | ||||
|  | ||||
| @@ -652,7 +685,35 @@ class ReportAsset(models.Model): | ||||
|  | ||||
|     def __str__(self): | ||||
|         """String representation of a ReportAsset instance.""" | ||||
|         return os.path.basename(self.asset.name) | ||||
|         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) | ||||
|  | ||||
|     # Asset file | ||||
|     asset = models.FileField( | ||||
|   | ||||
| @@ -1,20 +1,13 @@ | ||||
| """API serializers for the reporting models.""" | ||||
|  | ||||
| from rest_framework import serializers | ||||
|  | ||||
| import report.models | ||||
| from InvenTree.serializers import ( | ||||
|     InvenTreeAttachmentSerializerField, | ||||
|     InvenTreeModelSerializer, | ||||
| ) | ||||
|  | ||||
| from .models import ( | ||||
|     BillOfMaterialsReport, | ||||
|     BuildReport, | ||||
|     PurchaseOrderReport, | ||||
|     ReturnOrderReport, | ||||
|     SalesOrderReport, | ||||
|     StockLocationReport, | ||||
|     TestReport, | ||||
| ) | ||||
|  | ||||
|  | ||||
| class ReportSerializerBase(InvenTreeModelSerializer): | ||||
|     """Base class for report serializer.""" | ||||
| @@ -42,7 +35,7 @@ class TestReportSerializer(ReportSerializerBase): | ||||
|     class Meta: | ||||
|         """Metaclass options.""" | ||||
|  | ||||
|         model = TestReport | ||||
|         model = report.models.TestReport | ||||
|         fields = ReportSerializerBase.report_fields() | ||||
|  | ||||
|  | ||||
| @@ -52,7 +45,7 @@ class BuildReportSerializer(ReportSerializerBase): | ||||
|     class Meta: | ||||
|         """Metaclass options.""" | ||||
|  | ||||
|         model = BuildReport | ||||
|         model = report.models.BuildReport | ||||
|         fields = ReportSerializerBase.report_fields() | ||||
|  | ||||
|  | ||||
| @@ -62,7 +55,7 @@ class BOMReportSerializer(ReportSerializerBase): | ||||
|     class Meta: | ||||
|         """Metaclass options.""" | ||||
|  | ||||
|         model = BillOfMaterialsReport | ||||
|         model = report.models.BillOfMaterialsReport | ||||
|         fields = ReportSerializerBase.report_fields() | ||||
|  | ||||
|  | ||||
| @@ -72,7 +65,7 @@ class PurchaseOrderReportSerializer(ReportSerializerBase): | ||||
|     class Meta: | ||||
|         """Metaclass options.""" | ||||
|  | ||||
|         model = PurchaseOrderReport | ||||
|         model = report.models.PurchaseOrderReport | ||||
|         fields = ReportSerializerBase.report_fields() | ||||
|  | ||||
|  | ||||
| @@ -82,7 +75,7 @@ class SalesOrderReportSerializer(ReportSerializerBase): | ||||
|     class Meta: | ||||
|         """Metaclass options.""" | ||||
|  | ||||
|         model = SalesOrderReport | ||||
|         model = report.models.SalesOrderReport | ||||
|         fields = ReportSerializerBase.report_fields() | ||||
|  | ||||
|  | ||||
| @@ -92,7 +85,7 @@ class ReturnOrderReportSerializer(ReportSerializerBase): | ||||
|     class Meta: | ||||
|         """Metaclass options.""" | ||||
|  | ||||
|         model = ReturnOrderReport | ||||
|         model = report.models.ReturnOrderReport | ||||
|         fields = ReportSerializerBase.report_fields() | ||||
|  | ||||
|  | ||||
| @@ -102,5 +95,30 @@ class StockLocationReportSerializer(ReportSerializerBase): | ||||
|     class Meta: | ||||
|         """Metaclass options.""" | ||||
|  | ||||
|         model = StockLocationReport | ||||
|         model = report.models.StockLocationReport | ||||
|         fields = ReportSerializerBase.report_fields() | ||||
|  | ||||
|  | ||||
| class ReportSnippetSerializer(InvenTreeModelSerializer): | ||||
|     """Serializer class for the ReportSnippet model.""" | ||||
|  | ||||
|     class Meta: | ||||
|         """Metaclass options.""" | ||||
|  | ||||
|         model = report.models.ReportSnippet | ||||
|  | ||||
|         fields = ['pk', 'snippet', 'description'] | ||||
|  | ||||
|     snippet = InvenTreeAttachmentSerializerField() | ||||
|  | ||||
|  | ||||
| class ReportAssetSerializer(InvenTreeModelSerializer): | ||||
|     """Serializer class for the ReportAsset model.""" | ||||
|  | ||||
|     class Meta: | ||||
|         """Meta class options.""" | ||||
|  | ||||
|         model = report.models.ReportAsset | ||||
|         fields = ['pk', 'asset', 'description'] | ||||
|  | ||||
|     asset = InvenTreeAttachmentSerializerField() | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| import { Trans, t } from '@lingui/macro'; | ||||
| import { Box, Group, LoadingOverlay, Stack, Text, Title } from '@mantine/core'; | ||||
| import { IconDots } from '@tabler/icons-react'; | ||||
| import { useCallback, useMemo, useState } from 'react'; | ||||
| import { ReactNode, useCallback, useMemo, useState } from 'react'; | ||||
| import { useNavigate } from 'react-router-dom'; | ||||
|  | ||||
| import { AddItemButton } from '../../components/buttons/AddItemButton'; | ||||
| @@ -29,6 +29,7 @@ import { useTable } from '../../hooks/UseTable'; | ||||
| import { apiUrl } from '../../states/ApiState'; | ||||
| import { TableColumn } from '../Column'; | ||||
| import { BooleanColumn } from '../ColumnRenderers'; | ||||
| import { TableFilter } from '../Filter'; | ||||
| import { InvenTreeTable } from '../InvenTreeTable'; | ||||
| import { RowAction, RowDeleteAction, RowEditAction } from '../RowActions'; | ||||
|  | ||||
| @@ -257,18 +258,25 @@ export function TemplateTable({ | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   const tableActions = useMemo(() => { | ||||
|     let actions = []; | ||||
|  | ||||
|     actions.push( | ||||
|   const tableActions: ReactNode[] = useMemo(() => { | ||||
|     return [ | ||||
|       <AddItemButton | ||||
|         key={`add-${templateType}`} | ||||
|         onClick={() => newTemplate.open()} | ||||
|         tooltip={t`Add` + ' ' + templateTypeTranslation} | ||||
|       /> | ||||
|     ); | ||||
|     ]; | ||||
|   }, []); | ||||
|  | ||||
|     return actions; | ||||
|   const tableFilters: TableFilter[] = useMemo(() => { | ||||
|     return [ | ||||
|       { | ||||
|         name: 'enabled', | ||||
|         label: t`Enabled`, | ||||
|         description: t`Filter by enabled status`, | ||||
|         type: 'checkbox' | ||||
|       } | ||||
|     ]; | ||||
|   }, []); | ||||
|  | ||||
|   return ( | ||||
| @@ -294,6 +302,7 @@ export function TemplateTable({ | ||||
|         columns={columns} | ||||
|         props={{ | ||||
|           rowActions: rowActions, | ||||
|           tableFilters: tableFilters, | ||||
|           tableActions: tableActions, | ||||
|           onRowClick: (record) => openDetailDrawer(record.pk) | ||||
|         }} | ||||
|   | ||||
		Reference in New Issue
	
	Block a user