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:
Oliver 2024-03-15 00:24:48 +11:00 committed by GitHub
parent cbd94fc4b5
commit 6abd33f060
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 200 additions and 75 deletions

View File

@ -1,11 +1,15 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # 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.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ 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 v181 - 2024-02-21 : https://github.com/inventree/InvenTree/pull/6541
- Adds "width" and "height" fields to the LabelTemplate API endpoint - Adds "width" and "height" fields to the LabelTemplate API endpoint
- Adds "page_size" and "landscape" fields to the ReportTemplate API endpoint - Adds "page_size" and "landscape" fields to the ReportTemplate API endpoint

View File

@ -17,31 +17,14 @@ import common.models
import InvenTree.helpers import InvenTree.helpers
import order.models import order.models
import part.models import part.models
import report.models
import report.serializers
from InvenTree.api import MetadataView from InvenTree.api import MetadataView
from InvenTree.exceptions import log_error from InvenTree.exceptions import log_error
from InvenTree.filters import InvenTreeSearchFilter from InvenTree.filters import InvenTreeSearchFilter
from InvenTree.mixins import ListCreateAPI, RetrieveAPI, RetrieveUpdateDestroyAPI from InvenTree.mixins import ListCreateAPI, RetrieveAPI, RetrieveUpdateDestroyAPI
from stock.models import StockItem, StockItemAttachment, StockLocation 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): class ReportListView(ListCreateAPI):
"""Generic API class for report templates.""" """Generic API class for report templates."""
@ -292,8 +275,8 @@ class StockItemTestReportMixin(ReportFilterMixin):
ITEM_MODEL = StockItem ITEM_MODEL = StockItem
ITEM_KEY = 'item' ITEM_KEY = 'item'
queryset = TestReport.objects.all() queryset = report.models.TestReport.objects.all()
serializer_class = TestReportSerializer serializer_class = report.serializers.TestReportSerializer
class StockItemTestReportList(StockItemTestReportMixin, ReportListView): class StockItemTestReportList(StockItemTestReportMixin, ReportListView):
@ -343,8 +326,8 @@ class BOMReportMixin(ReportFilterMixin):
ITEM_MODEL = part.models.Part ITEM_MODEL = part.models.Part
ITEM_KEY = 'part' ITEM_KEY = 'part'
queryset = BillOfMaterialsReport.objects.all() queryset = report.models.BillOfMaterialsReport.objects.all()
serializer_class = BOMReportSerializer serializer_class = report.serializers.BOMReportSerializer
class BOMReportList(BOMReportMixin, ReportListView): class BOMReportList(BOMReportMixin, ReportListView):
@ -377,8 +360,8 @@ class BuildReportMixin(ReportFilterMixin):
ITEM_MODEL = build.models.Build ITEM_MODEL = build.models.Build
ITEM_KEY = 'build' ITEM_KEY = 'build'
queryset = BuildReport.objects.all() queryset = report.models.BuildReport.objects.all()
serializer_class = BuildReportSerializer serializer_class = report.serializers.BuildReportSerializer
class BuildReportList(BuildReportMixin, ReportListView): class BuildReportList(BuildReportMixin, ReportListView):
@ -411,8 +394,8 @@ class PurchaseOrderReportMixin(ReportFilterMixin):
ITEM_MODEL = order.models.PurchaseOrder ITEM_MODEL = order.models.PurchaseOrder
ITEM_KEY = 'order' ITEM_KEY = 'order'
queryset = PurchaseOrderReport.objects.all() queryset = report.models.PurchaseOrderReport.objects.all()
serializer_class = PurchaseOrderReportSerializer serializer_class = report.serializers.PurchaseOrderReportSerializer
class PurchaseOrderReportList(PurchaseOrderReportMixin, ReportListView): class PurchaseOrderReportList(PurchaseOrderReportMixin, ReportListView):
@ -439,8 +422,8 @@ class SalesOrderReportMixin(ReportFilterMixin):
ITEM_MODEL = order.models.SalesOrder ITEM_MODEL = order.models.SalesOrder
ITEM_KEY = 'order' ITEM_KEY = 'order'
queryset = SalesOrderReport.objects.all() queryset = report.models.SalesOrderReport.objects.all()
serializer_class = SalesOrderReportSerializer serializer_class = report.serializers.SalesOrderReportSerializer
class SalesOrderReportList(SalesOrderReportMixin, ReportListView): class SalesOrderReportList(SalesOrderReportMixin, ReportListView):
@ -467,8 +450,8 @@ class ReturnOrderReportMixin(ReportFilterMixin):
ITEM_MODEL = order.models.ReturnOrder ITEM_MODEL = order.models.ReturnOrder
ITEM_KEY = 'order' ITEM_KEY = 'order'
queryset = ReturnOrderReport.objects.all() queryset = report.models.ReturnOrderReport.objects.all()
serializer_class = ReturnOrderReportSerializer serializer_class = report.serializers.ReturnOrderReportSerializer
class ReturnOrderReportList(ReturnOrderReportMixin, ReportListView): class ReturnOrderReportList(ReturnOrderReportMixin, ReportListView):
@ -494,8 +477,8 @@ class StockLocationReportMixin(ReportFilterMixin):
ITEM_MODEL = StockLocation ITEM_MODEL = StockLocation
ITEM_KEY = 'location' ITEM_KEY = 'location'
queryset = StockLocationReport.objects.all() queryset = report.models.StockLocationReport.objects.all()
serializer_class = StockLocationReportSerializer serializer_class = report.serializers.StockLocationReportSerializer
class StockLocationReportList(StockLocationReportMixin, ReportListView): class StockLocationReportList(StockLocationReportMixin, ReportListView):
@ -516,7 +499,57 @@ class StockLocationReportPrint(StockLocationReportMixin, ReportPrintMixin, Retri
pass 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_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 # Purchase order reports
path( path(
'po/', 'po/',
@ -533,7 +566,7 @@ report_api_urls = [
path( path(
'metadata/', 'metadata/',
MetadataView.as_view(), MetadataView.as_view(),
{'model': PurchaseOrderReport}, {'model': report.models.PurchaseOrderReport},
name='api-po-report-metadata', name='api-po-report-metadata',
), ),
path( path(
@ -563,7 +596,7 @@ report_api_urls = [
path( path(
'metadata/', 'metadata/',
MetadataView.as_view(), MetadataView.as_view(),
{'model': SalesOrderReport}, {'model': report.models.SalesOrderReport},
name='api-so-report-metadata', name='api-so-report-metadata',
), ),
path( path(
@ -591,7 +624,7 @@ report_api_urls = [
path( path(
'metadata/', 'metadata/',
MetadataView.as_view(), MetadataView.as_view(),
{'model': ReturnOrderReport}, {'model': report.models.ReturnOrderReport},
name='api-so-report-metadata', name='api-so-report-metadata',
), ),
path( path(
@ -622,7 +655,7 @@ report_api_urls = [
path( path(
'metadata/', 'metadata/',
MetadataView.as_view(), MetadataView.as_view(),
{'model': BuildReport}, {'model': report.models.BuildReport},
name='api-build-report-metadata', name='api-build-report-metadata',
), ),
path( path(
@ -650,7 +683,7 @@ report_api_urls = [
path( path(
'metadata/', 'metadata/',
MetadataView.as_view(), MetadataView.as_view(),
{'model': BillOfMaterialsReport}, {'model': report.models.BillOfMaterialsReport},
name='api-bom-report-metadata', name='api-bom-report-metadata',
), ),
path('', BOMReportDetail.as_view(), name='api-bom-report-detail'), path('', BOMReportDetail.as_view(), name='api-bom-report-detail'),
@ -676,7 +709,7 @@ report_api_urls = [
path( path(
'metadata/', 'metadata/',
MetadataView.as_view(), MetadataView.as_view(),
{'report': TestReport}, {'report': report.models.TestReport},
name='api-stockitem-testreport-metadata', name='api-stockitem-testreport-metadata',
), ),
path( path(
@ -710,7 +743,7 @@ report_api_urls = [
path( path(
'metadata/', 'metadata/',
MetadataView.as_view(), MetadataView.as_view(),
{'report': StockLocationReport}, {'report': report.models.StockLocationReport},
name='api-stocklocation-report-metadata', name='api-stocklocation-report-metadata',
), ),
path( path(

View File

@ -7,6 +7,7 @@ import sys
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.validators import FileExtensionValidator from django.core.validators import FileExtensionValidator
from django.db import models from django.db import models
from django.template import Context, Template from django.template import Context, Template
@ -585,10 +586,7 @@ class ReturnOrderReport(ReportTemplateBase):
def rename_snippet(instance, filename): def rename_snippet(instance, filename):
"""Function to rename a report snippet once uploaded.""" """Function to rename a report snippet once uploaded."""
filename = os.path.basename(filename) path = ReportSnippet.snippet_path(filename)
path = os.path.join('report', 'snippets', filename)
fullpath = settings.MEDIA_ROOT.joinpath(path).resolve() fullpath = settings.MEDIA_ROOT.joinpath(path).resolve()
# If the snippet file is the *same* filename as the one being uploaded, # 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 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( snippet = models.FileField(
upload_to=rename_snippet, upload_to=rename_snippet,
verbose_name=_('Snippet'), verbose_name=_('Snippet'),
@ -626,19 +658,20 @@ class ReportSnippet(models.Model):
def rename_asset(instance, filename): def rename_asset(instance, filename):
"""Function to rename an asset file when uploaded.""" """Function to rename an asset file when uploaded."""
filename = os.path.basename(filename) path = ReportAsset.asset_path(filename)
fullpath = settings.MEDIA_ROOT.joinpath(path).resolve()
path = os.path.join('report', 'assets', filename)
# If the asset file is the *same* filename as the one being uploaded, # If the asset file is the *same* filename as the one being uploaded,
# delete the original one from the media directory # delete the original one from the media directory
if str(filename) == str(instance.asset): if str(filename) == str(instance.asset):
fullpath = settings.MEDIA_ROOT.joinpath(path).resolve()
if fullpath.exists(): if fullpath.exists():
# Check for existing asset file with the same name
logger.info("Deleting existing asset file: '%s'", filename) logger.info("Deleting existing asset file: '%s'", filename)
os.remove(fullpath) os.remove(fullpath)
# Ensure the cache is deleted for this asset
cache.delete(fullpath)
return path return path
@ -652,7 +685,35 @@ class ReportAsset(models.Model):
def __str__(self): def __str__(self):
"""String representation of a ReportAsset instance.""" """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 file
asset = models.FileField( asset = models.FileField(

View File

@ -1,20 +1,13 @@
"""API serializers for the reporting models.""" """API serializers for the reporting models."""
from rest_framework import serializers
import report.models
from InvenTree.serializers import ( from InvenTree.serializers import (
InvenTreeAttachmentSerializerField, InvenTreeAttachmentSerializerField,
InvenTreeModelSerializer, InvenTreeModelSerializer,
) )
from .models import (
BillOfMaterialsReport,
BuildReport,
PurchaseOrderReport,
ReturnOrderReport,
SalesOrderReport,
StockLocationReport,
TestReport,
)
class ReportSerializerBase(InvenTreeModelSerializer): class ReportSerializerBase(InvenTreeModelSerializer):
"""Base class for report serializer.""" """Base class for report serializer."""
@ -42,7 +35,7 @@ class TestReportSerializer(ReportSerializerBase):
class Meta: class Meta:
"""Metaclass options.""" """Metaclass options."""
model = TestReport model = report.models.TestReport
fields = ReportSerializerBase.report_fields() fields = ReportSerializerBase.report_fields()
@ -52,7 +45,7 @@ class BuildReportSerializer(ReportSerializerBase):
class Meta: class Meta:
"""Metaclass options.""" """Metaclass options."""
model = BuildReport model = report.models.BuildReport
fields = ReportSerializerBase.report_fields() fields = ReportSerializerBase.report_fields()
@ -62,7 +55,7 @@ class BOMReportSerializer(ReportSerializerBase):
class Meta: class Meta:
"""Metaclass options.""" """Metaclass options."""
model = BillOfMaterialsReport model = report.models.BillOfMaterialsReport
fields = ReportSerializerBase.report_fields() fields = ReportSerializerBase.report_fields()
@ -72,7 +65,7 @@ class PurchaseOrderReportSerializer(ReportSerializerBase):
class Meta: class Meta:
"""Metaclass options.""" """Metaclass options."""
model = PurchaseOrderReport model = report.models.PurchaseOrderReport
fields = ReportSerializerBase.report_fields() fields = ReportSerializerBase.report_fields()
@ -82,7 +75,7 @@ class SalesOrderReportSerializer(ReportSerializerBase):
class Meta: class Meta:
"""Metaclass options.""" """Metaclass options."""
model = SalesOrderReport model = report.models.SalesOrderReport
fields = ReportSerializerBase.report_fields() fields = ReportSerializerBase.report_fields()
@ -92,7 +85,7 @@ class ReturnOrderReportSerializer(ReportSerializerBase):
class Meta: class Meta:
"""Metaclass options.""" """Metaclass options."""
model = ReturnOrderReport model = report.models.ReturnOrderReport
fields = ReportSerializerBase.report_fields() fields = ReportSerializerBase.report_fields()
@ -102,5 +95,30 @@ class StockLocationReportSerializer(ReportSerializerBase):
class Meta: class Meta:
"""Metaclass options.""" """Metaclass options."""
model = StockLocationReport model = report.models.StockLocationReport
fields = ReportSerializerBase.report_fields() 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()

View File

@ -1,7 +1,7 @@
import { Trans, t } from '@lingui/macro'; import { Trans, t } from '@lingui/macro';
import { Box, Group, LoadingOverlay, Stack, Text, Title } from '@mantine/core'; import { Box, Group, LoadingOverlay, Stack, Text, Title } from '@mantine/core';
import { IconDots } from '@tabler/icons-react'; 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 { useNavigate } from 'react-router-dom';
import { AddItemButton } from '../../components/buttons/AddItemButton'; import { AddItemButton } from '../../components/buttons/AddItemButton';
@ -29,6 +29,7 @@ import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState'; import { apiUrl } from '../../states/ApiState';
import { TableColumn } from '../Column'; import { TableColumn } from '../Column';
import { BooleanColumn } from '../ColumnRenderers'; import { BooleanColumn } from '../ColumnRenderers';
import { TableFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable'; import { InvenTreeTable } from '../InvenTreeTable';
import { RowAction, RowDeleteAction, RowEditAction } from '../RowActions'; import { RowAction, RowDeleteAction, RowEditAction } from '../RowActions';
@ -257,18 +258,25 @@ export function TemplateTable({
} }
}); });
const tableActions = useMemo(() => { const tableActions: ReactNode[] = useMemo(() => {
let actions = []; return [
actions.push(
<AddItemButton <AddItemButton
key={`add-${templateType}`} key={`add-${templateType}`}
onClick={() => newTemplate.open()} onClick={() => newTemplate.open()}
tooltip={t`Add` + ' ' + templateTypeTranslation} 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 ( return (
@ -294,6 +302,7 @@ export function TemplateTable({
columns={columns} columns={columns}
props={{ props={{
rowActions: rowActions, rowActions: rowActions,
tableFilters: tableFilters,
tableActions: tableActions, tableActions: tableActions,
onRowClick: (record) => openDetailDrawer(record.pk) onRowClick: (record) => openDetailDrawer(record.pk)
}} }}