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
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

View File

@ -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(

View File

@ -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(

View File

@ -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()

View File

@ -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)
}}