mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +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:
parent
cbd94fc4b5
commit
6abd33f060
@ -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)
|
||||
}}
|
||||
|
Loading…
Reference in New Issue
Block a user