diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index 0abe3e5575..9dee3bd637 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -2,10 +2,15 @@ # InvenTree API version -INVENTREE_API_VERSION = 150 +INVENTREE_API_VERSION = 151 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ + +v151 -> 2023-11-13 : https://github.com/inventree/InvenTree/pull/5906 + - Allow user list API to be filtered by user active status + - Allow owner list API to be filtered by user active status + v150 -> 2023-11-07: https://github.com/inventree/InvenTree/pull/5875 - Extended user API endpoints to enable ordering - Extended user API endpoints to enable user role changes diff --git a/InvenTree/label/tests.py b/InvenTree/label/tests.py index 5823cc9175..ac82639388 100644 --- a/InvenTree/label/tests.py +++ b/InvenTree/label/tests.py @@ -106,7 +106,7 @@ class LabelTest(InvenTreeAPITestCase): url: {{ qr_url|safe }} - image: {% part_image part %} + image: {% part_image part width=128 %} logo: {% logo_image %} @@ -154,8 +154,9 @@ class LabelTest(InvenTreeAPITestCase): self.assertIn(f"part: {part_pk} - {part_name}", content) self.assertIn(f'data: {{"part": {part_pk}}}', content) self.assertIn(f'http://testserver/part/{part_pk}/', content) - self.assertIn("img/blank_image.png", content) - self.assertIn("img/inventree.png", content) + + # Check that a encoded image has been generated + self.assertIn('data:image/png;charset=utf-8;base64,', content) def test_metadata(self): """Unit tests for the metadata field.""" diff --git a/InvenTree/report/helpers.py b/InvenTree/report/helpers.py index e399dcb908..2088913db5 100644 --- a/InvenTree/report/helpers.py +++ b/InvenTree/report/helpers.py @@ -1,5 +1,7 @@ """Helper functions for report generation.""" +import base64 +import io import logging from django.utils.translation import gettext_lazy as _ @@ -48,3 +50,24 @@ def report_page_size_default(): page_size = 'A4' return page_size + + +def encode_image_base64(image, format: str = 'PNG'): + """Return a base-64 encoded image which can be rendered in an tag + + Arguments: + image {Image} -- Image object + format {str} -- Image format (e.g. 'PNG') + + Returns: + str -- Base64 encoded image data e.g. 'data:image/png;base64,xxxxxxxxx' + """ + + fmt = format.lower() + + buffered = io.BytesIO() + image.save(buffered, fmt) + + img_str = base64.b64encode(buffered.getvalue()) + + return f"data:image/{fmt};charset=utf-8;base64," + img_str.decode() diff --git a/InvenTree/report/templates/report/inventree_bill_of_materials_report.html b/InvenTree/report/templates/report/inventree_bill_of_materials_report.html index aa9cfe2a1e..f2dd287b5b 100644 --- a/InvenTree/report/templates/report/inventree_bill_of_materials_report.html +++ b/InvenTree/report/templates/report/inventree_bill_of_materials_report.html @@ -123,7 +123,7 @@ table td.expand { @@ -145,7 +145,7 @@ table td.expand {
- {% trans "Image" %} + {% trans "Image" %}
{{ line.sub_part.full_name }} diff --git a/InvenTree/report/templates/report/inventree_build_order_base.html b/InvenTree/report/templates/report/inventree_build_order_base.html index b60d38173f..23b76f85b2 100644 --- a/InvenTree/report/templates/report/inventree_build_order_base.html +++ b/InvenTree/report/templates/report/inventree_build_order_base.html @@ -95,7 +95,7 @@ content: "v{{ report_revision }} - {{ date.isoformat }}";
- {% trans 'Part image' %} + {% trans 'Part image' %}
diff --git a/InvenTree/report/templates/report/inventree_po_report_base.html b/InvenTree/report/templates/report/inventree_po_report_base.html index 847fe656e2..44f3714e3a 100644 --- a/InvenTree/report/templates/report/inventree_po_report_base.html +++ b/InvenTree/report/templates/report/inventree_po_report_base.html @@ -37,7 +37,7 @@
- {% trans 'Part image' %} + {% trans 'Part image' %}
{{ line.part.part.full_name }} diff --git a/InvenTree/report/templates/report/inventree_return_order_report_base.html b/InvenTree/report/templates/report/inventree_return_order_report_base.html index c6e24b4741..0dbc062e71 100644 --- a/InvenTree/report/templates/report/inventree_return_order_report_base.html +++ b/InvenTree/report/templates/report/inventree_return_order_report_base.html @@ -32,7 +32,7 @@
- {% trans "Image" %} + {% trans "Image" %}
{{ line.item.part.full_name }} diff --git a/InvenTree/report/templates/report/inventree_so_report_base.html b/InvenTree/report/templates/report/inventree_so_report_base.html index 4a39a7402d..a5f4a75750 100644 --- a/InvenTree/report/templates/report/inventree_so_report_base.html +++ b/InvenTree/report/templates/report/inventree_so_report_base.html @@ -37,7 +37,7 @@
- {% trans "Part image" %} + {% trans "Part image" %}
{{ line.part.full_name }} diff --git a/InvenTree/report/templates/report/inventree_test_report_base.html b/InvenTree/report/templates/report/inventree_test_report_base.html index 0242ffe0c9..3afcdb474b 100644 --- a/InvenTree/report/templates/report/inventree_test_report_base.html +++ b/InvenTree/report/templates/report/inventree_test_report_base.html @@ -81,7 +81,7 @@ content: "{% trans 'Stock Item Test Report' %}";

Stock Item ID: {{ stock_item.pk }}

- {% trans "Part image" %} + {% trans "Part image" %}

{% if stock_item.is_serialized %} @@ -160,7 +160,7 @@ content: "{% trans 'Stock Item Test Report' %}"; {% for sub_item in installed_items %} - {% trans "Part image" %} + {% trans "Part image" %} {{ sub_item.part.full_name }} diff --git a/InvenTree/report/templatetags/barcode.py b/InvenTree/report/templatetags/barcode.py index 8593526d15..723064f984 100644 --- a/InvenTree/report/templatetags/barcode.py +++ b/InvenTree/report/templatetags/barcode.py @@ -1,13 +1,12 @@ """Template tags for rendering various barcodes.""" -import base64 -from io import BytesIO - from django import template import barcode as python_barcode import qrcode as python_qrcode +import report.helpers + register = template.Library() @@ -16,12 +15,8 @@ def image_data(img, fmt='PNG'): Returns a string ``data:image/FMT;base64,xxxxxxxxx`` which can be rendered to an tag """ - buffered = BytesIO() - img.save(buffered, format=fmt) - img_str = base64.b64encode(buffered.getvalue()) - - return f"data:image/{fmt.lower()};charset=utf-8;base64," + img_str.decode() + return report.helpers.encode_image_base64(img, fmt) @register.simple_tag() diff --git a/InvenTree/report/templatetags/report.py b/InvenTree/report/templatetags/report.py index 7673b9808d..726703312d 100644 --- a/InvenTree/report/templatetags/report.py +++ b/InvenTree/report/templatetags/report.py @@ -7,9 +7,13 @@ import os from django import template from django.conf import settings from django.utils.safestring import SafeString, mark_safe +from django.utils.translation import gettext_lazy as _ + +from PIL import Image import InvenTree.helpers import InvenTree.helpers_model +import report.helpers from common.models import InvenTreeSetting from company.models import Company from part.models import Part @@ -88,7 +92,7 @@ def asset(filename): full_path = settings.MEDIA_ROOT.joinpath('report', 'assets', filename).resolve() if not full_path.exists() or not full_path.is_file(): - raise FileNotFoundError(f"Asset file '{filename}' does not exist") + raise FileNotFoundError(_("Asset file does not exist") + f": '{filename}'") if debug_mode: return os.path.join(settings.MEDIA_URL, 'report', 'assets', filename) @@ -96,7 +100,7 @@ def asset(filename): @register.simple_tag() -def uploaded_image(filename, replace_missing=True, replacement_file='blank_image.png', validate=True): +def uploaded_image(filename, replace_missing=True, replacement_file='blank_image.png', validate=True, **kwargs): """Return a fully-qualified path for an 'uploaded' image. Arguments: @@ -104,8 +108,16 @@ def uploaded_image(filename, replace_missing=True, replacement_file='blank_image replace_missing: Optionally return a placeholder image if the provided filename does not exist validate: Optionally validate that the file is a valid image file (default = True) + kwargs: + width: Optional width of the image (default = None) + height: Optional height of the image (default = None) + rotate: Optional rotation to apply to the image + Returns: A fully qualified path to the image + + Raises: + FileNotFoundError if the file does not exist """ if type(filename) is SafeString: # Prepend an empty string to enforce 'stringiness' @@ -129,21 +141,51 @@ def uploaded_image(filename, replace_missing=True, replacement_file='blank_image exists = False if not exists and not replace_missing: - raise FileNotFoundError(f"Image file '{filename}' not found") + raise FileNotFoundError(_("Image file not found") + f": '{filename}'") if debug_mode: - # In debug mode, return a web path + # In debug mode, return a web path (rather than an encoded image blob) if exists: return os.path.join(settings.MEDIA_URL, filename) return os.path.join(settings.STATIC_URL, 'img', replacement_file) - else: - # Return file path - if exists: - path = settings.MEDIA_ROOT.joinpath(filename).resolve() - else: - path = settings.STATIC_ROOT.joinpath('img', replacement_file).resolve() - return f"file://{path}" + elif not exists: + full_path = settings.STATIC_ROOT.joinpath('img', replacement_file).resolve() + + # Load the image, check that it is valid + if full_path.exists() and full_path.is_file(): + img = Image.open(full_path) + else: + # A placeholder image showing that the image is missing + img = Image.new('RGB', (64, 64), color='red') + + width = kwargs.get('width', None) + height = kwargs.get('height', None) + + if width is not None and height is not None: + # Resize the image, width *and* height are provided + img = img.resize((width, height)) + elif width is not None: + # Resize the image, width only + wpercent = (width / float(img.size[0])) + hsize = int((float(img.size[1]) * float(wpercent))) + img = img.resize((width, hsize)) + elif height is not None: + # Resize the image, height only + hpercent = (height / float(img.size[1])) + wsize = int((float(img.size[0]) * float(hpercent))) + img = img.resize((wsize, height)) + + # Optionally rotate the image + rotate = kwargs.get('rotate', None) + + if rotate is not None: + img = img.rotate(rotate) + + # Return a base-64 encoded image + img_data = report.helpers.encode_image_base64(img) + + return img_data @register.simple_tag() @@ -164,7 +206,7 @@ def encode_svg_image(filename): exists = False if not exists: - raise FileNotFoundError(f"Image file '{filename}' not found") + raise FileNotFoundError(_("Image file not found") + f": '{filename}'") # Read the file data with open(full_path, 'rb') as f: @@ -175,7 +217,7 @@ def encode_svg_image(filename): @register.simple_tag() -def part_image(part: Part): +def part_image(part: Part, preview=False, thumbnail=False, **kwargs): """Return a fully-qualified path for a part image. Arguments: @@ -184,13 +226,17 @@ def part_image(part: Part): Raises: TypeError if provided part is not a Part instance """ - if type(part) is Part: + if type(part) is not Part: + raise TypeError(_("part_image tag requires a Part instance")) + + if preview: + img = part.image.preview.name + elif thumbnail: + img = part.image.thumbnail.name + else: img = part.image.name - else: - raise TypeError("part_image tag requires a Part instance") - - return uploaded_image(img) + return uploaded_image(img, **kwargs) @register.simple_tag() @@ -210,7 +256,7 @@ def part_parameter(part: Part, parameter_name: str): @register.simple_tag() -def company_image(company): +def company_image(company, preview=False, thumbnail=False, **kwargs): """Return a fully-qualified path for a company image. Arguments: @@ -219,12 +265,17 @@ def company_image(company): Raises: TypeError if provided company is not a Company instance """ - if type(company) is Company: - img = company.image.name - else: - raise TypeError("company_image tag requires a Company instance") + if type(company) is not Company: + raise TypeError(_("company_image tag requires a Company instance")) - return uploaded_image(img) + if preview: + img = company.image.preview.name + elif thumbnail: + img = company.image.thumbnail.name + else: + img = company.image.name + + return uploaded_image(img, **kwargs) @register.simple_tag() diff --git a/InvenTree/report/tests.py b/InvenTree/report/tests.py index e92fc980dc..4f81623fc4 100644 --- a/InvenTree/report/tests.py +++ b/InvenTree/report/tests.py @@ -91,8 +91,12 @@ class ReportTagTest(TestCase): with self.assertRaises(FileNotFoundError): report_tags.uploaded_image('/part/something/test.png', replace_missing=False) - img = report_tags.uploaded_image('/part/something/other.png') - self.assertTrue('blank_image.png' in img) + img = str(report_tags.uploaded_image('/part/something/other.png')) + + if b: + self.assertIn('blank_image.png', img) + else: + self.assertIn('data:image/png;charset=utf-8;base64,', img) # Create a dummy image img_path = 'part/images/' @@ -121,10 +125,10 @@ class ReportTagTest(TestCase): self.debug_mode(False) img = report_tags.uploaded_image('part/images/test.jpg') - self.assertEqual(img, f'file://{img_path.joinpath("test.jpg")}') + self.assertTrue(img.startswith('data:image/png;charset=utf-8;base64,')) img = report_tags.uploaded_image(SafeString('part/images/test.jpg')) - self.assertEqual(img, f'file://{img_path.joinpath("test.jpg")}') + self.assertTrue(img.startswith('data:image/png;charset=utf-8;base64,')) def test_part_image(self): """Unit tests for the 'part_image' tag""" diff --git a/docs/docs/report/helpers.md b/docs/docs/report/helpers.md index 6ed679a44b..88be1e6a59 100644 --- a/docs/docs/report/helpers.md +++ b/docs/docs/report/helpers.md @@ -138,7 +138,7 @@ You can access an uploaded image file if you know the *path* of the image, relat {% raw %} {% load report %} - + {% endraw %} ``` @@ -148,6 +148,16 @@ You can access an uploaded image file if you know the *path* of the image, relat !!! warning "Invalid Image" If the supplied file is not a valid image, it will be replaced with a placeholder image file +#### Image Manipulation + +The `{% raw %}{% uploaded_image %}{% endraw %}` tag supports some optional parameters for image manipulation. These can be used to adjust or resize the image - to reduce the size of the generated report file, for example. + +```html +{% raw %} +{% load report %} + +{% endraw %}``` + ### SVG Images @@ -173,6 +183,26 @@ A shortcut function is provided for rendering an image associated with a Part in {% endraw %} ``` +#### Image Arguments + +Any optional arguments which can be used in the [uploaded_image tag](#uploaded-images) can be used here too. + +#### Image Variations + +The *Part* model supports *preview* (256 x 256) and *thumbnail* (128 x 128) versions of the uploaded image. These variations can be used in the generated reports (e.g. to reduce generated file size): + +```html +{% raw %} +{% load report %} + + + + + +{% endraw %} +``` + + ### Company Images A shortcut function is provided for rendering an image associated with a Company instance. You can render the image of the company using the `{% raw %}{% company_image ... %}{% endraw %}` template tag: @@ -185,6 +215,10 @@ A shortcut function is provided for rendering an image associated with a Company {% endraw %} ``` +#### Image Variations + +*Preview* and *thumbnail* image variations can be rendered for the `company_image` tag, in a similar manner to [part image variations](#image-variations) + ## InvenTree Logo A template tag is provided to load the InvenTree logo image into a report. You can render the logo using the `{% raw %}{% logo_image %}{% endraw %}` tag: