Report image rendering fix (#5907)

* Allow different image variations to be rendered in when using a part image in a report

* Use preview image in default test report

* Fix api_version

- Missed in https://github.com/inventree/InvenTree/pull/5906

* Update docstring

* Add similar functionality for company_image tag

* Update report documentation

* base-64 encode images for rendering in reports

- Allows image manipulation operations to be performed on the images
- Avoids any file pathing issues

* Update docs

* Fix unit tests

* More unit test fixes

* More unit test

* Handle missing file

* Instrument unit test

- Trying to determine what is going on here

* Fix for image resize

* Translate error messages

* Update default report templates

- Specify image size
This commit is contained in:
Oliver 2023-11-14 12:08:18 +11:00 committed by GitHub
parent 3e063750b5
commit 2fcd6ae0b9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 162 additions and 49 deletions

View File

@ -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

View File

@ -106,7 +106,7 @@ class LabelTest(InvenTreeAPITestCase):
<!-- Test InvenTree URL -->
url: {{ qr_url|safe }}
<!-- Test image URL generation -->
image: {% part_image part %}
image: {% part_image part width=128 %}
<!-- Test InvenTree logo -->
logo: {% logo_image %}
</html>
@ -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."""

View File

@ -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 <img> 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()

View File

@ -123,7 +123,7 @@ table td.expand {
</td>
<td>
<div class='part-logo'>
<img src='{% part_image part %}' alt='{% trans "Image" %}' class='part-logo'>
<img src='{% part_image part height=480 %}' alt='{% trans "Image" %}' class='part-logo'>
</div>
</td>
</tr>
@ -145,7 +145,7 @@ table td.expand {
<tr>
<td>
<div class='thumb-container'>
<img src='{% part_image line.sub_part %}' alt='{% trans "Image" %}' class='part-thumb'>
<img src='{% part_image line.sub_part height=240 %}' alt='{% trans "Image" %}' class='part-thumb'>
</div>
<div class='part-text'>
{{ line.sub_part.full_name }}

View File

@ -95,7 +95,7 @@ content: "v{{ report_revision }} - {{ date.isoformat }}";
<div class='details'>
<div class='details-image'>
<img class='part-image' alt="{% trans 'Part image' %}" src="{% part_image part %}">
<img class='part-image' alt="{% trans 'Part image' %}" src="{% part_image part height=480 %}">
</div>
<div class='details-container'>

View File

@ -37,7 +37,7 @@
<tr>
<td>
<div class='thumb-container'>
<img src='{% part_image line.part.part %}' class='part-thumb' alt="{% trans 'Part image' %}">
<img src='{% part_image line.part.part height=240 %}' class='part-thumb' alt="{% trans 'Part image' %}">
</div>
<div class='part-text'>
{{ line.part.part.full_name }}

View File

@ -32,7 +32,7 @@
<tr>
<td>
<div class='thumb-container'>
<img src='{% part_image line.item.part %}' alt='{% trans "Image" %}' class='part-thumb'>
<img src='{% part_image line.item.part height=240 %}' alt='{% trans "Image" %}' class='part-thumb'>
</div>
<div class='part-text'>
{{ line.item.part.full_name }}

View File

@ -37,7 +37,7 @@
<tr>
<td>
<div class='thumb-container'>
<img src='{% part_image line.part %}' alt='{% trans "Part image" %}' class='part-thumb'>
<img src='{% part_image line.part height=240 %}' alt='{% trans "Part image" %}' class='part-thumb'>
</div>
<div class='part-text'>
{{ line.part.full_name }}

View File

@ -81,7 +81,7 @@ content: "{% trans 'Stock Item Test Report' %}";
<p><em>Stock Item ID: {{ stock_item.pk }}</em></p>
</div>
<div class='img-right'>
<img class='part-img' alt='{% trans "Part image" %}' src="{% part_image part %}">
<img class='part-img' alt='{% trans "Part image" %}' src="{% part_image part height=480 %}">
<hr>
<h4>
{% if stock_item.is_serialized %}
@ -160,7 +160,7 @@ content: "{% trans 'Stock Item Test Report' %}";
{% for sub_item in installed_items %}
<tr>
<td>
<img src='{% part_image sub_item.part %}' class='part-img' alt='{% trans "Part image" %}' style='max-width: 24px; max-height: 24px;'>
<img src='{% part_image sub_item.part height=240 %}' class='part-img' alt='{% trans "Part image" %}' style='max-width: 24px; max-height: 24px;'>
{{ sub_item.part.full_name }}
</td>
<td>

View File

@ -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 <img> 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()

View File

@ -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()

View File

@ -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"""

View File

@ -138,7 +138,7 @@ You can access an uploaded image file if you know the *path* of the image, relat
{% raw %}
<!-- Load the report helper functions -->
{% load report %}
<img src='{% uploaded_image "subdir/my_image.png" %}'/>
<img src='{% uploaded_image "subdir/my_image.png" width=480 rotate=45 %}'/>
{% 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 %}
<img src='{% uploaded_image "image_file.png" width=500 rotate=45 %}'>
{% 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 %}
<!-- Render the "preview" image variation -->
<img src='{% part_image part preview=True %}'>
<!-- Render the "thumbnail" image variation -->
<img src='{% part_image part thumbnail=True %}'>
{% 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: