diff --git a/docs/docs/report/helpers.md b/docs/docs/report/helpers.md index 2d1723fb14..a43d8cdab6 100644 --- a/docs/docs/report/helpers.md +++ b/docs/docs/report/helpers.md @@ -80,6 +80,52 @@ To return an element corresponding to a certain key in a container which support {% endraw %} ``` +## Session Variables + +Variables assigned with the `as` keyword (as described above) are only visible within the template block they were assigned in - for example, a variable assigned inside a `{% raw %}{% for %}{% endraw %}` loop is not visible once the loop ends. This makes it awkward to accumulate a value (such as a running total) across a loop. + +To get around this, the `set_var` and `get_var` functions can be used to store and retrieve a named variable which remains visible for the remainder of the current report or label render, regardless of which template block it was set within: + +### set_var + +Assign a value to a named variable within the current rendering context. This variable will remain accessible for the remainder of the report or label render, regardless of which template block it was set within. + +::: report.templatetags.report.set_var + options: + show_docstring_description: false + show_source: False + +### get_var + +Retrieve the value of a named variable previously stored with `set_var`. If the variable has not been set, a backup value can be provided. + +::: report.templatetags.report.get_var + options: + show_docstring_description: false + show_source: False + +#### Example + +```html +{% raw %} +{% load report %} + +{% set_var "total" 0 %} + +{% for line in lines %} + {% get_var "total" as total %} + {% add total line.quantity as new_total %} + {% set_var "total" new_total %} +{% endfor %} + +{% get_var "total" as final_total %} +Total quantity: {{ final_total }} +{% endraw %} +``` + +!!! info "Isolated per Render" + The variables stored with `set_var` are private to the report or label instance currently being rendered. They are reset for every instance, and are never shared between reports, requests, or users. + ## Database Helpers A number of helper functions are available for accessing database objects: diff --git a/src/backend/InvenTree/report/models.py b/src/backend/InvenTree/report/models.py index 2fbaf3e0bd..f3f601c50e 100644 --- a/src/backend/InvenTree/report/models.py +++ b/src/backend/InvenTree/report/models.py @@ -152,6 +152,7 @@ class BaseContextExtension(TypedDict): template_name: Name of the report template template_revision: Revision of the report template user: User who is creating the report (if available) + report_vars: Private dict for the {% get_var %} / {% set_var %} template tags """ base_url: str @@ -162,6 +163,7 @@ class BaseContextExtension(TypedDict): template_name: str template_revision: int user: Optional[AbstractUser] + report_vars: dict class LabelContextExtension(TypedDict): @@ -341,6 +343,10 @@ class ReportTemplateBase( 'template_name': self.name, 'template_revision': self.revision, 'user': kwargs.get('user'), + # A private, per-render dict for the {% get_var %} / {% set_var %} tags. + # This is discarded once rendering of *this* instance completes, + # and is never shared between instances, reports, or requests. + 'report_vars': {}, } def get_context(self, instance: models.Model, **kwargs): diff --git a/src/backend/InvenTree/report/templatetags/report.py b/src/backend/InvenTree/report/templatetags/report.py index e43edbc222..fdf620c8e2 100644 --- a/src/backend/InvenTree/report/templatetags/report.py +++ b/src/backend/InvenTree/report/templatetags/report.py @@ -173,6 +173,53 @@ def getkey(container: dict, key: str, backup_value: Optional[Any] = None) -> Any return container.get(key, backup_value) +@register.simple_tag(takes_context=True) +def set_var(context: dict, name: str, value: Any) -> str: + """Store a named variable, for later retrieval with get_var. + + Arguments: + context: The template context, which should contain a 'report_vars' dictionary. + name: The name to store the variable against (must be a string) + value: The value to store + + Returns: + An empty string - this tag does not render any output + """ + if not isinstance(name, str): + logger.warning('set_var() called with non-string name') + return '' + + store = context.get('report_vars') + + if isinstance(store, dict): + store[name] = value + else: + logger.warning('set_var() called outside of a valid report context') + + return '' + + +@register.simple_tag(takes_context=True) +def get_var(context: dict, name: str, backup_value: Optional[Any] = None) -> Any: + """Retrieve a named variable previously stored with set_var. + + Arguments: + context: The template context, which should contain a 'report_vars' dictionary. + name: The name of the variable to retrieve + backup_value: Value to return if the variable has not been set (default = None) + + Returns: + The stored value, or backup_value if the variable has not been set + """ + store = context.get('report_vars') + + if not isinstance(store, dict): + logger.warning('get_var() called outside of a valid report context') + return backup_value + + return store.get(name, backup_value) + + def media_file_exists(path: Path | str) -> bool: """Check if a media file exists at the specified path. diff --git a/src/backend/InvenTree/report/test_tags.py b/src/backend/InvenTree/report/test_tags.py index fecd7fec7f..fb501d36ff 100644 --- a/src/backend/InvenTree/report/test_tags.py +++ b/src/backend/InvenTree/report/test_tags.py @@ -61,6 +61,71 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase): None, report_tags.getkey('not a container', 'not-a-key', 'a value') ) + def test_get_set_var(self): + """Tests for the 'get_var' and 'set_var' template tags.""" + # Directly exercise the tag functions against a report-shaped context + context = Context({'report_vars': {}}) + + # Not yet set - should return the backup value + self.assertIsNone(report_tags.get_var(context, 'foo')) + self.assertEqual(report_tags.get_var(context, 'foo', 'backup'), 'backup') + + # set_var renders no output, and stores the value for later retrieval + self.assertEqual(report_tags.set_var(context, 'foo', 'bar'), '') + self.assertEqual(report_tags.get_var(context, 'foo'), 'bar') + + # Overwrite the value + report_tags.set_var(context, 'foo', 'baz') + self.assertEqual(report_tags.get_var(context, 'foo'), 'baz') + + # A non-string name is rejected + report_tags.set_var(context, 123, 'nope') + self.assertNotIn(123, context['report_vars']) + + # If the report context is missing (or malformed), fail safe rather than crash + broken_context = Context({'report_vars': 'not-a-dict'}) + self.assertEqual(report_tags.set_var(broken_context, 'foo', 'bar'), '') + self.assertEqual(report_tags.get_var(broken_context, 'foo', 'backup'), 'backup') + + missing_context = Context({}) + report_tags.set_var(missing_context, 'foo', 'bar') + self.assertEqual( + report_tags.get_var(missing_context, 'foo', 'backup'), 'backup' + ) + + # set_var / get_var must not expose or mutate other context variables + full_context = Context({'report_vars': {}, 'user': 'sensitive-user-object'}) + report_tags.set_var(full_context, 'user', 'hijacked') + self.assertEqual(full_context['user'], 'sensitive-user-object') + self.assertEqual(full_context['report_vars']['user'], 'hijacked') + + # Exercise the tags via full template rendering, to confirm that a variable + # set inside a {% for %} loop remains visible outside of the loop + # (unlike Django's built-in scoping rules for block-local context changes), + # which is what makes these tags useful for accumulating totals. + template = Template( + '{% load report %}' + '{% set_var "total" 0 %}' + '{% for value in values %}' + '{% get_var "total" as total %}' + '{% add total value as running_total %}' + '{% set_var "total" running_total %}' + '{% endfor %}' + '{% get_var "total" as final_total %}' + 'Total: {{ final_total }}' + ) + + rendered = template.render(Context({'values': [1, 2, 3, 4], 'report_vars': {}})) + self.assertIn('Total: 10', rendered) + + # Two separate renders must not share state + context_a = Context({'report_vars': {}}) + context_b = Context({'report_vars': {}}) + report_tags.set_var(context_a, 'shared_name', 'value-a') + report_tags.set_var(context_b, 'shared_name', 'value-b') + self.assertEqual(report_tags.get_var(context_a, 'shared_name'), 'value-a') + self.assertEqual(report_tags.get_var(context_b, 'shared_name'), 'value-b') + def test_asset(self): """Tests for asset files.""" # Test that an error is raised if the file does not exist