From 6abd33f060ed471526ca8d6ce99acb12e2ecd944 Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 15 Mar 2024 00:24:48 +1100 Subject: [PATCH] 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 --- InvenTree/InvenTree/api_version.py | 6 +- InvenTree/report/api.py | 113 +++++++++++------- InvenTree/report/models.py | 81 +++++++++++-- InvenTree/report/serializers.py | 52 +++++--- .../src/tables/settings/TemplateTable.tsx | 23 ++-- 5 files changed, 200 insertions(+), 75 deletions(-) diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index f02c790ece..7d62e9b909 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -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 diff --git a/InvenTree/report/api.py b/InvenTree/report/api.py index a9bfec0952..315c6b0159 100644 --- a/InvenTree/report/api.py +++ b/InvenTree/report/api.py @@ -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( + '/', ReportAssetDetail.as_view(), name='api-report-asset-detail' + ), + path('', ReportAssetList.as_view(), name='api-report-asset-list'), + ]), + ), + # Report snippets + path( + 'snippet/', + include([ + path( + '/', + 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( diff --git a/InvenTree/report/models.py b/InvenTree/report/models.py index 269b38ee6c..7958ffb08d 100644 --- a/InvenTree/report/models.py +++ b/InvenTree/report/models.py @@ -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( diff --git a/InvenTree/report/serializers.py b/InvenTree/report/serializers.py index e3632df733..320c489a37 100644 --- a/InvenTree/report/serializers.py +++ b/InvenTree/report/serializers.py @@ -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() diff --git a/src/frontend/src/tables/settings/TemplateTable.tsx b/src/frontend/src/tables/settings/TemplateTable.tsx index 5c8b0e2b9a..55deef787c 100644 --- a/src/frontend/src/tables/settings/TemplateTable.tsx +++ b/src/frontend/src/tables/settings/TemplateTable.tsx @@ -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 [ 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) }}