2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-07-04 06:00:38 +00:00

Report Generation Updates (#12187)

* Fix for TemplateEditor

- Allow dragging of split section

* Cleaner report template code

* Pass correct error message through

* Prevent multiple retries if running in worker thread

* Handle report merge error

* Add playwright tests for broken report printing

* Reduce scope for exception messages

* Reduce comment deltas

* Adjust unit test

* Raise ValidaitonError

* Handle message parsing

* Additional comment

* Fix unit tests
This commit is contained in:
Oliver
2026-06-18 13:41:44 +10:00
committed by GitHub
parent 4b29032c6e
commit fc15f30f8f
7 changed files with 96 additions and 32 deletions
+1 -1
View File
@@ -568,7 +568,7 @@ class InvenTreeParameterMixin(InvenTreePermissionCheckMixin, models.Model):
if 'parameters_list' in cache: if 'parameters_list' in cache:
return cache['parameters_list'] return cache['parameters_list']
return self.parameters_list.all() return self.parameters_list.all().prefetch_related('template')
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
"""Handle the deletion of a model instance. """Handle the deletion of a model instance.
+18 -4
View File
@@ -25,6 +25,7 @@ from pypdf import PdfWriter
import InvenTree.exceptions import InvenTree.exceptions
import InvenTree.helpers import InvenTree.helpers
import InvenTree.models import InvenTree.models
import InvenTree.ready
import report.helpers import report.helpers
import report.validators import report.validators
from common.models import DataOutput, RenderChoices, UpdatedUserMixin from common.models import DataOutput, RenderChoices, UpdatedUserMixin
@@ -578,10 +579,10 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
raise ValidationError(msg) raise ValidationError(msg)
except TemplateSyntaxError as e: except TemplateSyntaxError as e:
msg = _('Template syntax error') msg = _('Template syntax error')
output.mark_failure(msg) output.mark_failure(str(e) or msg)
raise ValidationError(f'{msg}: {e!s}') raise ValidationError(f'{msg}: {e!s}')
except ValidationError as e: except ValidationError as e:
output.mark_failure(str(e)) output.mark_failure(','.join(e.messages))
raise e raise e
except Exception as e: except Exception as e:
msg = _('Error rendering report') msg = _('Error rendering report')
@@ -617,10 +618,10 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
raise ValidationError(msg) raise ValidationError(msg)
except TemplateSyntaxError as e: except TemplateSyntaxError as e:
msg = _('Template syntax error') msg = _('Template syntax error')
output.mark_failure(error=_('Template syntax error')) output.mark_failure(error=str(e) or msg)
raise ValidationError(f'{msg}: {e!s}') raise ValidationError(f'{msg}: {e!s}')
except ValidationError as e: except ValidationError as e:
output.mark_failure(str(e)) output.mark_failure(', '.join(e.messages))
raise e raise e
except Exception as e: except Exception as e:
msg = _('Error rendering report') msg = _('Error rendering report')
@@ -643,6 +644,13 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
# Something went wrong during the report generation process # Something went wrong during the report generation process
log_report_error('ReportTemplate.print') log_report_error('ReportTemplate.print')
# If the error occurred in a worker thread, we do not want to raise an error,
# as this would cause the worker to retry the task indefinitely
if InvenTree.ready.isInWorkerThread():
return
# Raise a ValidationError with the error message
# This will be caught by the caller and displayed to the user
raise ValidationError({ raise ValidationError({
'error': _('Error generating report'), 'error': _('Error generating report'),
'detail': str(exc), 'detail': str(exc),
@@ -677,6 +685,12 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
log_report_error('ReportTemplate.print') log_report_error('ReportTemplate.print')
msg = _('Error merging report outputs') msg = _('Error merging report outputs')
output.mark_failure(error=msg) output.mark_failure(error=msg)
# If the error occurred in a worker thread, we do not want to raise an error,
# as this would cause the worker to retry the task indefinitely
if InvenTree.ready.isInWorkerThread():
return
raise ValidationError(msg) raise ValidationError(msg)
# Save the generated report to the database # Save the generated report to the database
@@ -461,7 +461,7 @@ def part_image(part: Part, preview: bool = False, thumbnail: bool = False, **kwa
TypeError: If provided part is not a Part instance TypeError: If provided part is not a Part instance
""" """
if not part or not isinstance(part, Part): if not part or not isinstance(part, Part):
raise TypeError(_('part_image tag requires a Part instance')) raise ValidationError(_('part_image tag requires a Part instance'))
image_filename = InvenTree.helpers.image2name(part.image, preview, thumbnail) image_filename = InvenTree.helpers.image2name(part.image, preview, thumbnail)
@@ -487,28 +487,22 @@ def parameter(
Returns: Returns:
A Parameter object, or the provided default value if not found A Parameter object, or the provided default value if not found
""" """
if instance is None: if instance is None or not isinstance(instance, Model):
raise ValueError('parameter tag requires a valid Model instance') raise ValidationError('parameter tag requires a valid Model instance')
if not isinstance(instance, Model) or not hasattr(instance, 'parameters'): if not hasattr(instance, 'parameters'):
raise TypeError("parameter tag requires a Model with 'parameters' attribute") raise ValidationError(
"parameter tag requires a Model with 'parameters' attribute"
)
parameters = instance.parameters_list.all().prefetch_related('template')
# First try with exact match # First try with exact match
if ( if parameter := parameters.filter(template__name=parameter_name).first():
parameter := instance.parameters
.prefetch_related('template')
.filter(template__name=parameter_name)
.first()
):
return parameter return parameter
# Next, try with case-insensitive match # Next, try with case-insensitive match
if ( if parameter := parameters.filter(template__name__iexact=parameter_name).first():
parameter := instance.parameters
.prefetch_related('template')
.filter(template__name__iexact=parameter_name)
.first()
):
return parameter return parameter
return None return None
+4 -4
View File
@@ -15,7 +15,7 @@ from PIL import Image
from common.models import InvenTreeSetting, Parameter, ParameterTemplate from common.models import InvenTreeSetting, Parameter, ParameterTemplate
from InvenTree.unit_test import InvenTreeTestCase from InvenTree.unit_test import InvenTreeTestCase
from part.models import Part # TODO fix import: PartParameter, PartParameterTemplate from part.models import Part
from part.test_api import PartImageTestMixin from part.test_api import PartImageTestMixin
from report.templatetags import barcode as barcode_tags from report.templatetags import barcode as barcode_tags
from report.templatetags import report as report_tags from report.templatetags import report as report_tags
@@ -184,7 +184,7 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase):
def test_part_image(self): def test_part_image(self):
"""Unit tests for the 'part_image' tag.""" """Unit tests for the 'part_image' tag."""
with self.assertRaises(TypeError): with self.assertRaises(ValidationError):
report_tags.part_image(None) report_tags.part_image(None)
obj = Part.objects.create(name='test', description='test') obj = Part.objects.create(name='test', description='test')
@@ -502,11 +502,11 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase):
self.assertEqual(report_tags.parameter(part, 'Template 1'), parameter) self.assertEqual(report_tags.parameter(part, 'Template 1'), parameter)
# Test with a null part # Test with a null part
with self.assertRaises(ValueError): with self.assertRaises(ValidationError):
report_tags.parameter(None, 'name') report_tags.parameter(None, 'name')
# Test with an invalid model type # Test with an invalid model type
with self.assertRaises(TypeError): with self.assertRaises(ValidationError):
report_tags.parameter(parameter, 'name') report_tags.parameter(parameter, 'name')
def test_render_currency(self): def test_render_currency(self):
@@ -68,9 +68,13 @@ export const PdfPreviewComponent: PreviewAreaComponent = forwardRef(
api api
.get(apiUrl(ApiEndpoints.data_output, preview.data.pk)) .get(apiUrl(ApiEndpoints.data_output, preview.data.pk))
.then((response) => { .then((response) => {
if (response.data.error) { if (response.data.errors || response.data.error) {
clearInterval(interval); clearInterval(interval);
rej(response.data.error); rej(
response.data.error ??
response.data.errors?.error ??
t`Process failed`
);
} }
if (response.data.complete) { if (response.data.complete) {
@@ -223,7 +223,7 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
}); });
}) })
.catch((error) => { .catch((error) => {
const msg = error?.message; const msg = error?.message || error?.toString();
if (msg) { if (msg) {
if (Array.isArray(msg)) { if (Array.isArray(msg)) {
@@ -272,7 +272,7 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
return ( return (
<Boundary label='TemplateEditor'> <Boundary label='TemplateEditor'>
<Stack style={{ height: '100%', flex: '1' }}> <Stack style={{ height: '100%', flex: '1' }}>
<Split style={{ gap: '10px' }}> <Split visible style={{ flex: 1 }}>
<Tabs <Tabs
value={editorValue} value={editorValue}
onChange={async (v) => { onChange={async (v) => {
@@ -282,7 +282,7 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
keepMounted={false} keepMounted={false}
style={{ style={{
minWidth: '300px', minWidth: '300px',
flex: '1', width: '50%',
display: 'flex', display: 'flex',
flexDirection: 'column' flexDirection: 'column'
}} }}
@@ -348,6 +348,7 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
keepMounted={false} keepMounted={false}
style={{ style={{
minWidth: '200px', minWidth: '200px',
width: '50%',
display: 'flex', display: 'flex',
flexDirection: 'column' flexDirection: 'column'
}} }}
+52 -1
View File
@@ -1,7 +1,7 @@
import type { Locator } from '@playwright/test'; import type { Locator } from '@playwright/test';
import { expect, test } from './baseFixtures.js'; import { expect, test } from './baseFixtures.js';
import { adminuser } from './defaults.js'; import { adminuser } from './defaults.js';
import { activateTableView, loadTab } from './helpers.js'; import { activateTableView, loadTab, navigate } from './helpers.js';
import { doCachedLogin } from './login.js'; import { doCachedLogin } from './login.js';
import { setPluginState } from './settings.js'; import { setPluginState } from './settings.js';
@@ -207,3 +207,54 @@ test('Printing - Report Editing', async ({ browser }) => {
state: false state: false
}); });
}); });
// Test report printing with an intentionally broken template, to verify that errors are handled gracefully
test('Printing - Broken Template', async ({ browser }) => {
const page = await doCachedLogin(browser, {
user: adminuser,
url: 'sales/sales-order/14/detail'
});
// Print report from the "sales order" detail page
await page
.getByRole('button', { name: 'action-menu-printing-actions' })
.click();
await page
.getByRole('menuitem', {
name: 'action-menu-printing-actions-print-reports'
})
.click();
await page
.getByRole('combobox', { name: 'related-field-template' })
.fill('broken');
await page.getByText('Broken Sales Order Report').click();
await page.getByRole('button', { name: 'Print', exact: true }).click();
// Expected error message
await page
.getByText('parameter tag requires a valid Model instance')
.waitFor();
// Next, check error message from the template editor preview
await navigate(page, 'settings/admin/reports');
await page
.getByRole('textbox', { name: 'table-search-input' })
.fill('broken');
await page.getByRole('cell', { name: 'Broken Sales Order Report' }).click();
await page.getByLabel('split-button-preview-options-action').click();
await page
.getByLabel('split-button-preview-options-item-preview-save', {
exact: true
})
.click();
await page.getByRole('button', { name: 'Save & Reload' }).click();
// Expected error messages
await page.getByText('Error rendering template').waitFor();
await page
.getByText('parameter tag requires a valid Model instance')
.waitFor();
});