diff --git a/specifyweb/backend/report_runner/tests.py b/specifyweb/backend/report_runner/tests.py index 60fcd8a1f22..89129fb674a 100644 --- a/specifyweb/backend/report_runner/tests.py +++ b/specifyweb/backend/report_runner/tests.py @@ -3,12 +3,18 @@ from django.test import Client, override_settings +from specifyweb.backend.report_runner.views import ( + _expand_rows_by_count, + _expand_rows_by_field, + _expand_rows_for_repeat, + _expand_rows_for_repeat_count, +) from specifyweb.backend.stored_queries.tests.test_views.raw_query import ( get_simple_query, ) from specifyweb.backend.stored_queries.tests.tests import SQLAlchemySetup from specifyweb.specify.api.crud import post_resource -from specifyweb.specify.models import Spreport +from specifyweb.specify.models import Spappresourcedir, Spreport from specifyweb.specify.tests.test_api import ApiTests @@ -89,6 +95,177 @@ def test_run_returns_pdf( report_data, ) + def _make_report(self, **report_kwargs): + """Create a real SpReport (with app resource) for run-view tests.""" + query = report_kwargs.pop('query', None) or post_resource( + self.collection, + self.agent, + 'spquery', + get_simple_query(self.specifyuser), + ) + appdir = Spappresourcedir.objects.create(discipline=self.discipline) + appresource = appdir.sppersistedappresources.create( + version=0, + mimetype='jrxml/label', + level=0, + name='Repeat Label', + description='Repeat Label', + specifyuser=self.specifyuser, + metadata='tableid=-1;reporttype=Report;', + ) + return Spreport.objects.create( + version=0, + name='Repeat Label', + appresource=appresource, + query=query, + specifyuser=self.specifyuser, + **report_kwargs, + ) + + @override_settings( + REPORT_RUNNER_HOST='report-runner', + REPORT_RUNNER_PORT='8080', + ) + @patch('specifyweb.backend.report_runner.views.requests.post') + @patch('specifyweb.backend.report_runner.views.run_query') + def test_run_expands_rows_from_repeat_field( + self, + run_query: Mock, + requests_post: Mock, + ): + """End-to-end: the run view reads RepeatField from a real SpReport row + and expands each result row by that field's value before sending the + data to the report renderer.""" + report = self._make_report( + repeatfield='1,63-preparations.preparation.countAmt', + ) + + report_data = { + 'fields': ['id', '1,63-preparations.preparation.countAmt'], + 'rows': [ + [self.collectionobjects[0].id, 3], + [self.collectionobjects[1].id, 2], + ], + } + run_query.return_value = report_data + requests_post.return_value.status_code = 200 + requests_post.return_value.content = b'%PDF-1.4 test' + + client = Client() + client.force_login(self.specifyuser) + + response = client.post( + '/report_runner/run/', + { + 'query': json.dumps({ + 'id': report.query_id, + 'name': 'New Query', + }), + 'parameters': json.dumps({}), + 'report': '', + 'reportId': report.id, + }, + ) + + self._assertStatusCodeEqual(response, 200) + + sent = json.loads(requests_post.call_args.kwargs['data']['data']) + self.assertEqual(len(sent['rows']), 5) # 3 + 2 + self.assertEqual(sent['rows'][0], [self.collectionobjects[0].id, 3]) + self.assertEqual(sent['rows'][3], [self.collectionobjects[1].id, 2]) + + @override_settings( + REPORT_RUNNER_HOST='report-runner', + REPORT_RUNNER_PORT='8080', + ) + @patch('specifyweb.backend.report_runner.views.requests.post') + @patch('specifyweb.backend.report_runner.views.run_query') + def test_run_expands_rows_from_repeat_count( + self, + run_query: Mock, + requests_post: Mock, + ): + """End-to-end: the run view repeats every row a fixed number of times + when the SpReport row has RepeatCount but no RepeatField.""" + report = self._make_report(repeatcount=3) + + report_data = { + 'fields': ['id', '1,63-preparations.preparation.countAmt'], + 'rows': [ + [self.collectionobjects[0].id, 9], + [self.collectionobjects[1].id, 9], + ], + } + run_query.return_value = report_data + requests_post.return_value.status_code = 200 + requests_post.return_value.content = b'%PDF-1.4 test' + + client = Client() + client.force_login(self.specifyuser) + + response = client.post( + '/report_runner/run/', + { + 'query': json.dumps({ + 'id': report.query_id, + 'name': 'New Query', + }), + 'parameters': json.dumps({}), + 'report': '', + 'reportId': report.id, + }, + ) + + self._assertStatusCodeEqual(response, 200) + + sent = json.loads(requests_post.call_args.kwargs['data']['data']) + self.assertEqual(len(sent['rows']), 6) # 2 rows * 3 + + @override_settings( + REPORT_RUNNER_HOST='report-runner', + REPORT_RUNNER_PORT='8080', + ) + @patch('specifyweb.backend.report_runner.views.requests.post') + @patch('specifyweb.backend.report_runner.views.run_query') + def test_run_ignores_repeat_settings_from_mismatched_report( + self, + run_query: Mock, + requests_post: Mock, + ): + report = self._make_report(repeatcount=3) + other_query = post_resource( + self.collection, + self.agent, + 'spquery', + get_simple_query(self.specifyuser), + ) + report_data = { + 'fields': ['id'], + 'rows': [[self.collectionobjects[0].id]], + } + run_query.return_value = report_data + requests_post.return_value.status_code = 200 + requests_post.return_value.content = b'%PDF-1.4 test' + + client = Client() + client.force_login(self.specifyuser) + response = client.post( + '/report_runner/run/', + { + 'query': json.dumps({ + 'id': other_query.id, + 'name': 'Other Query', + }), + 'parameters': json.dumps({}), + 'report': '', + 'reportId': report.id, + }, + ) + + self._assertStatusCodeEqual(response, 200) + sent = json.loads(requests_post.call_args.kwargs['data']['data']) + self.assertEqual(sent, report_data) + class TestCreateReport(SQLAlchemySetup): @@ -177,4 +354,184 @@ def test_create_label_from_new_query(self, context: Mock): self.assertIn( '1.collectionobject.catalogNumber', label_data.get_decoded_data(), - ) \ No newline at end of file + ) + + +_JASPER_NS = 'http://jasperreports.sourceforge.net/jasperreports' + +def _make_jrxml(repeat_field=None): + """Build a minimal jrxml string, optionally with a repeat-count property.""" + prop = ( + f'' + if repeat_field else '' + ) + return ( + f'' + f'{prop}' + f'' + ) + + +class TestExpandRowsForRepeatCount(ApiTests): + + def _data(self, rows): + return { + 'fields': ['id', '1,63-preparations.preparation.countAmt'], + 'rows': rows, + } + + def test_no_property_returns_data_unchanged(self): + data = self._data([[1, 3], [2, 2]]) + result = _expand_rows_for_repeat_count(_make_jrxml(), data) + self.assertEqual(result, data) + + def test_rows_expanded_by_count_field(self): + data = self._data([[1, 3], [2, 2]]) + result = _expand_rows_for_repeat_count( + _make_jrxml('1,63-preparations.preparation.countAmt'), data + ) + self.assertEqual(result['fields'], data['fields']) + self.assertEqual(len(result['rows']), 5) # 3 + 2 + self.assertEqual(result['rows'][0], [1, 3]) + self.assertEqual(result['rows'][3], [2, 2]) + + def test_count_of_one_does_not_duplicate(self): + data = self._data([[1, 1]]) + result = _expand_rows_for_repeat_count( + _make_jrxml('1,63-preparations.preparation.countAmt'), data + ) + self.assertEqual(len(result['rows']), 1) + + def test_null_count_defaults_to_one(self): + data = self._data([[1, None]]) + result = _expand_rows_for_repeat_count( + _make_jrxml('1,63-preparations.preparation.countAmt'), data + ) + self.assertEqual(len(result['rows']), 1) + + def test_zero_count_defaults_to_one(self): + data = self._data([[1, 0]]) + result = _expand_rows_for_repeat_count( + _make_jrxml('1,63-preparations.preparation.countAmt'), data + ) + self.assertEqual(len(result['rows']), 1) + + def test_field_not_in_result_set_returns_data_unchanged(self): + data = self._data([[1, 3]]) + result = _expand_rows_for_repeat_count( + _make_jrxml('1,63-preparations.preparation.nonexistent'), data + ) + self.assertEqual(result, data) + + def test_invalid_jrxml_returns_data_unchanged(self): + data = self._data([[1, 3]]) + result = _expand_rows_for_repeat_count('not valid xml', data) + self.assertEqual(result, data) + + +class TestExpandRowsByField(ApiTests): + + def _data(self, rows): + return { + 'fields': ['id', '1,63-preparations.preparation.countAmt'], + 'rows': rows, + } + + def test_rows_expanded_by_field_value(self): + data = self._data([[1, 3], [2, 2]]) + result = _expand_rows_by_field( + data, '1,63-preparations.preparation.countAmt' + ) + self.assertEqual(len(result['rows']), 5) + self.assertEqual(result['rows'][0], [1, 3]) + self.assertEqual(result['rows'][3], [2, 2]) + + def test_null_zero_and_invalid_default_to_one(self): + data = self._data([[1, None], [2, 0], [3, 'x']]) + result = _expand_rows_by_field( + data, '1,63-preparations.preparation.countAmt' + ) + self.assertEqual(len(result['rows']), 3) + + def test_missing_field_returns_data_unchanged(self): + data = self._data([[1, 3]]) + result = _expand_rows_by_field(data, 'not.a.field') + self.assertEqual(result, data) + + def test_empty_field_returns_data_unchanged(self): + data = self._data([[1, 3]]) + result = _expand_rows_by_field(data, None) + self.assertEqual(result, data) + + +class TestExpandRowsByCount(ApiTests): + + def _data(self, rows): + return {'fields': ['id'], 'rows': rows} + + def test_every_row_repeated_fixed_count(self): + data = self._data([[1], [2]]) + result = _expand_rows_by_count(data, 3) + self.assertEqual(len(result['rows']), 6) + self.assertEqual(result['rows'][:3], [[1], [1], [1]]) + + def test_count_of_one_returns_data_unchanged(self): + data = self._data([[1], [2]]) + result = _expand_rows_by_count(data, 1) + self.assertEqual(result, data) + + def test_invalid_count_returns_data_unchanged(self): + data = self._data([[1]]) + self.assertEqual(_expand_rows_by_count(data, None), data) + self.assertEqual(_expand_rows_by_count(data, 'x'), data) + + +class TestExpandRowsForRepeat(ApiTests): + + def _data(self, rows): + return { + 'fields': ['id', '1,63-preparations.preparation.countAmt'], + 'rows': rows, + } + + def test_repeat_field_takes_precedence(self): + report = Mock( + repeatfield='1,63-preparations.preparation.countAmt', + repeatcount=99, + ) + data = self._data([[1, 3], [2, 2]]) + result = _expand_rows_for_repeat(_make_jrxml(), data, report) + self.assertEqual(len(result['rows']), 5) + + def test_repeat_count_used_when_no_field(self): + report = Mock(repeatfield=None, repeatcount=3) + data = self._data([[1, 9], [2, 9]]) + result = _expand_rows_for_repeat(_make_jrxml(), data, report) + self.assertEqual(len(result['rows']), 6) + + def test_falls_back_to_jrxml_when_report_has_no_config(self): + report = Mock(repeatfield=None, repeatcount=None) + data = self._data([[1, 3]]) + result = _expand_rows_for_repeat( + _make_jrxml('1,63-preparations.preparation.countAmt'), + data, + report, + ) + self.assertEqual(len(result['rows']), 3) + + def test_no_report_id_uses_jrxml(self): + data = self._data([[1, 3]]) + result = _expand_rows_for_repeat( + _make_jrxml('1,63-preparations.preparation.countAmt'), data + ) + self.assertEqual(len(result['rows']), 3) + + def test_missing_report_falls_back_to_jrxml(self): + data = self._data([[1, 3]]) + result = _expand_rows_for_repeat( + _make_jrxml('1,63-preparations.preparation.countAmt'), + data, + None, + ) + self.assertEqual(len(result['rows']), 3) \ No newline at end of file diff --git a/specifyweb/backend/report_runner/views.py b/specifyweb/backend/report_runner/views.py index 6f394bec3a2..ba1e3da164e 100644 --- a/specifyweb/backend/report_runner/views.py +++ b/specifyweb/backend/report_runner/views.py @@ -1,4 +1,5 @@ import json +import xml.etree.ElementTree as ET import requests from django.conf import settings @@ -25,6 +26,124 @@ class ReportException(Exception): pass + +_JASPER_NS = 'http://jasperreports.sourceforge.net/jasperreports' +# jrxml property that opts a report into per-row label repetition. +# Set its value to the field name whose integer value controls how many +# times each row should be repeated, e.g.: +# +_REPEAT_COUNT_PROPERTY = 'specify.repeat.count.field' + + +def _expand_rows_by_field(report_data, repeat_field): + """Duplicate each row by the integer value of the named result field. + + Rows whose value is null, zero, or non-numeric default to a single copy. + If the field is not present in the result set, the data is unchanged. + """ + fields = report_data['fields'] + if not repeat_field or repeat_field not in fields: + return report_data + + idx = fields.index(repeat_field) + expanded = [] + for row in report_data['rows']: + try: + count = max(1, int(row[idx])) + except (TypeError, ValueError): + count = 1 + expanded.extend([row] * count) + + return {'fields': fields, 'rows': expanded} + + +def _expand_rows_by_count(report_data, repeat_count): + """Duplicate every row a fixed number of times (Specify 6 RepeatCount).""" + try: + count = max(1, int(repeat_count)) + except (TypeError, ValueError): + return report_data + + if count == 1: + return report_data + + expanded = [row for row in report_data['rows'] for _ in range(count)] + return {'fields': report_data['fields'], 'rows': expanded} + + +def _repeat_field_from_jrxml(report_jrxml): + """Return the field named by the jrxml repeat-count property, or None.""" + try: + root = ET.fromstring(report_jrxml) + except ET.ParseError: + return None + + return next( + (p.get('value') for p in root.findall('{%s}property' % _JASPER_NS) + if p.get('name') == _REPEAT_COUNT_PROPERTY), + None, + ) + + +def _expand_rows_for_repeat_count(report_jrxml, report_data): + """Duplicate rows according to a repeat-count field declared in the jrxml. + + If the jrxml does not declare the ``specify.repeat.count.field`` property, + or the named field is not present in the result set, the data is returned + unchanged. This restores the Specify 6 behaviour where a label could be + printed N times based on an integer preparation field (e.g. countAmt). + """ + return _expand_rows_by_field( + report_data, _repeat_field_from_jrxml(report_jrxml) + ) + + +def _expand_rows_for_repeat(report_jrxml, report_data, report=None): + """Expand rows for label repetition, mirroring Specify 6. + + Precedence: + 1. ``spreport.RepeatField`` — repeat each row by the integer value of the + named result field (e.g. preparation.countAmt). + 2. ``spreport.RepeatCount`` — repeat every row a fixed number of times. + 3. ``specify.repeat.count.field`` jrxml property — for self-contained + labels that carry the setting in their own XML. + + The first two mirror how Specify 6 stores this configuration on the report + record, so labels migrated from Specify 6 repeat without editing the jrxml. + """ + if report is not None: + if report.repeatfield: + return _expand_rows_by_field(report_data, report.repeatfield) + if report.repeatcount is not None: + return _expand_rows_by_count(report_data, report.repeatcount) + + return _expand_rows_for_repeat_count(report_jrxml, report_data) + + +def _get_report_for_repeat(report_id, query_json, collection, user): + if report_id in (None, ''): + return None + + try: + query_id = int(json.loads(query_json).get('id')) + report_id = int(report_id) + except (AttributeError, TypeError, ValueError): + return None + + return Spreport.objects.filter( + id=report_id, + query_id=query_id, + appresource__spappresourcedir__discipline=collection.discipline, + ).filter( + Q(appresource__spappresourcedir__collection=None) | + Q(appresource__spappresourcedir__collection=collection) + ).filter( + Q(appresource__spappresourcedir__specifyuser=user) | + Q(appresource__spappresourcedir__ispersonal=False) + ).first() + + class ReportsPT(PermissionTarget): resource = "/report" execute = PermissionTargetAction() @@ -50,10 +169,22 @@ def run(request): port = settings.REPORT_RUNNER_PORT if port == '': port = 80 - report_data = run_query(request.specify_collection, request.specify_user, request.POST['query']) + query_json = request.POST['query'] + report_data = run_query(request.specify_collection, request.specify_user, query_json) if len(report_data['rows']) < 1: return HttpResponse(_("The report query returned no results."), content_type="text/plain") + report_data = _expand_rows_for_repeat( + request.POST['report'], + report_data, + _get_report_for_repeat( + request.POST.get('reportId'), + query_json, + request.specify_collection, + request.specify_user, + ), + ) + r = requests.post("http://%s:%s/report" % (settings.REPORT_RUNNER_HOST, port), data={'report': request.POST['report'], diff --git a/specifyweb/frontend/js_src/lib/components/Reports/ForRecord.tsx b/specifyweb/frontend/js_src/lib/components/Reports/ForRecord.tsx index 1bf932b9328..162b05877a0 100644 --- a/specifyweb/frontend/js_src/lib/components/Reports/ForRecord.tsx +++ b/specifyweb/frontend/js_src/lib/components/Reports/ForRecord.tsx @@ -18,6 +18,7 @@ export function ReportForRecord({ definition, table, resourceId, + reportId, onClose: handleClose, }: { readonly query: SerializedResource; @@ -25,6 +26,7 @@ export function ReportForRecord({ readonly parameters: IR; readonly table: SpecifyTable; readonly resourceId: number; + readonly reportId: number | undefined; readonly onClose: () => void; }): JSX.Element { const queryFieldFilterSpecs = useQueryFieldFilterSpecs(); @@ -79,6 +81,7 @@ export function ReportForRecord({ parameters={parameters} query={query} recordSetId={undefined} + reportId={reportId} onClose={handleClose} /> ) : ( @@ -87,6 +90,7 @@ export function ReportForRecord({ parameters={parameters} query={query} recordSetId={undefined} + reportId={reportId} onClose={handleClose} /> ); diff --git a/specifyweb/frontend/js_src/lib/components/Reports/Parameters.tsx b/specifyweb/frontend/js_src/lib/components/Reports/Parameters.tsx index 5b792028f9b..22fea92d4dd 100644 --- a/specifyweb/frontend/js_src/lib/components/Reports/Parameters.tsx +++ b/specifyweb/frontend/js_src/lib/components/Reports/Parameters.tsx @@ -28,6 +28,7 @@ export function QueryParametersDialog({ recordSetId, definition, parameters, + reportId, autoRun, onClose: handleClose, }: { @@ -35,6 +36,7 @@ export function QueryParametersDialog({ readonly recordSetId: number | undefined; readonly definition: Element; readonly parameters: IR; + readonly reportId: number | undefined; readonly autoRun: boolean; readonly onClose: () => void; }): JSX.Element { @@ -77,6 +79,7 @@ export function QueryParametersDialog({ parameters={parameters} query={state.query} recordSetId={recordSetId} + reportId={reportId} onClose={(): void => setState({ type: 'Main' })} /> ) : ( diff --git a/specifyweb/frontend/js_src/lib/components/Reports/RecordSets.tsx b/specifyweb/frontend/js_src/lib/components/Reports/RecordSets.tsx index aea626d06c2..6e0c56be44a 100644 --- a/specifyweb/frontend/js_src/lib/components/Reports/RecordSets.tsx +++ b/specifyweb/frontend/js_src/lib/components/Reports/RecordSets.tsx @@ -21,12 +21,14 @@ export function ReportRecordSets({ appResource, definition, parameters, + reportId, onClose: handleClose, }: { readonly query: SerializedResource; readonly appResource: SerializedResource; readonly definition: Element; readonly parameters: IR; + readonly reportId: number | undefined; readonly onClose: () => void; }): JSX.Element { const table = React.useMemo( @@ -105,6 +107,7 @@ export function ReportRecordSets({ parameters={parameters} query={query} recordSetId={state.type === 'RecordSet' ? state.recordSet.id : undefined} + reportId={reportId} onClose={handleClose} /> ); diff --git a/specifyweb/frontend/js_src/lib/components/Reports/Report.tsx b/specifyweb/frontend/js_src/lib/components/Reports/Report.tsx index 9cb770e52ee..a9683398238 100644 --- a/specifyweb/frontend/js_src/lib/components/Reports/Report.tsx +++ b/specifyweb/frontend/js_src/lib/components/Reports/Report.tsx @@ -121,6 +121,7 @@ function ReportDialog({ appResource={appResource} definition={definition} query={query} + reportId={report?.id} resourceId={resourceId} table={table} onClose={handleClose} @@ -247,6 +248,7 @@ function ParametersDialog({ definition, query, appResource, + reportId, resourceId, table, onClose: handleClose, @@ -254,6 +256,7 @@ function ParametersDialog({ readonly definition: Element; readonly query: SerializedResource | false | undefined; readonly appResource: SerializedResource; + readonly reportId: number | undefined; readonly resourceId: number | undefined; readonly table: SpecifyTable | undefined; readonly onClose: () => void; @@ -289,6 +292,7 @@ function ParametersDialog({ definition={definition} parameters={parameters} query={query} + reportId={reportId} resourceId={resourceId} table={table} onClose={handleClose} @@ -299,6 +303,7 @@ function ParametersDialog({ definition={definition} parameters={parameters} query={query} + reportId={reportId} onClose={handleClose} /> ) diff --git a/specifyweb/frontend/js_src/lib/components/Reports/Run.tsx b/specifyweb/frontend/js_src/lib/components/Reports/Run.tsx index 98dbbb05928..10e0437e474 100644 --- a/specifyweb/frontend/js_src/lib/components/Reports/Run.tsx +++ b/specifyweb/frontend/js_src/lib/components/Reports/Run.tsx @@ -13,12 +13,14 @@ export function RunReport({ recordSetId, definition, parameters, + reportId, onClose: handleClose, }: { readonly query: SerializedResource; readonly recordSetId: number | undefined; readonly definition: Element; readonly parameters: IR; + readonly reportId: number | undefined; readonly onClose: () => void; }): JSX.Element { const reportWindowContext = useId('report-window')(''); @@ -76,6 +78,9 @@ export function RunReport({ readOnly type="hidden" /> + {typeof reportId === 'number' ? ( + + ) : undefined} );