2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-05-01 13:06:45 +00:00

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
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.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ 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 v150 -> 2023-11-07: https://github.com/inventree/InvenTree/pull/5875
- Extended user API endpoints to enable ordering - Extended user API endpoints to enable ordering
- Extended user API endpoints to enable user role changes - Extended user API endpoints to enable user role changes

View File

@ -106,7 +106,7 @@ class LabelTest(InvenTreeAPITestCase):
<!-- Test InvenTree URL --> <!-- Test InvenTree URL -->
url: {{ qr_url|safe }} url: {{ qr_url|safe }}
<!-- Test image URL generation --> <!-- Test image URL generation -->
image: {% part_image part %} image: {% part_image part width=128 %}
<!-- Test InvenTree logo --> <!-- Test InvenTree logo -->
logo: {% logo_image %} logo: {% logo_image %}
</html> </html>
@ -154,8 +154,9 @@ class LabelTest(InvenTreeAPITestCase):
self.assertIn(f"part: {part_pk} - {part_name}", content) self.assertIn(f"part: {part_pk} - {part_name}", content)
self.assertIn(f'data: {{"part": {part_pk}}}', content) self.assertIn(f'data: {{"part": {part_pk}}}', content)
self.assertIn(f'http://testserver/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): def test_metadata(self):
"""Unit tests for the metadata field.""" """Unit tests for the metadata field."""

View File

@ -1,5 +1,7 @@
"""Helper functions for report generation.""" """Helper functions for report generation."""
import base64
import io
import logging import logging
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -48,3 +50,24 @@ def report_page_size_default():
page_size = 'A4' page_size = 'A4'
return page_size 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>
<td> <td>
<div class='part-logo'> <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> </div>
</td> </td>
</tr> </tr>
@ -145,7 +145,7 @@ table td.expand {
<tr> <tr>
<td> <td>
<div class='thumb-container'> <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>
<div class='part-text'> <div class='part-text'>
{{ line.sub_part.full_name }} {{ line.sub_part.full_name }}

View File

@ -95,7 +95,7 @@ content: "v{{ report_revision }} - {{ date.isoformat }}";
<div class='details'> <div class='details'>
<div class='details-image'> <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>
<div class='details-container'> <div class='details-container'>

View File

@ -37,7 +37,7 @@
<tr> <tr>
<td> <td>
<div class='thumb-container'> <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>
<div class='part-text'> <div class='part-text'>
{{ line.part.part.full_name }} {{ line.part.part.full_name }}

View File

@ -32,7 +32,7 @@
<tr> <tr>
<td> <td>
<div class='thumb-container'> <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>
<div class='part-text'> <div class='part-text'>
{{ line.item.part.full_name }} {{ line.item.part.full_name }}

View File

@ -37,7 +37,7 @@
<tr> <tr>
<td> <td>
<div class='thumb-container'> <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>
<div class='part-text'> <div class='part-text'>
{{ line.part.full_name }} {{ 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> <p><em>Stock Item ID: {{ stock_item.pk }}</em></p>
</div> </div>
<div class='img-right'> <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> <hr>
<h4> <h4>
{% if stock_item.is_serialized %} {% if stock_item.is_serialized %}
@ -160,7 +160,7 @@ content: "{% trans 'Stock Item Test Report' %}";
{% for sub_item in installed_items %} {% for sub_item in installed_items %}
<tr> <tr>
<td> <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 }} {{ sub_item.part.full_name }}
</td> </td>
<td> <td>

View File

@ -1,13 +1,12 @@
"""Template tags for rendering various barcodes.""" """Template tags for rendering various barcodes."""
import base64
from io import BytesIO
from django import template from django import template
import barcode as python_barcode import barcode as python_barcode
import qrcode as python_qrcode import qrcode as python_qrcode
import report.helpers
register = template.Library() 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 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 report.helpers.encode_image_base64(img, fmt)
return f"data:image/{fmt.lower()};charset=utf-8;base64," + img_str.decode()
@register.simple_tag() @register.simple_tag()

View File

@ -7,9 +7,13 @@ import os
from django import template from django import template
from django.conf import settings from django.conf import settings
from django.utils.safestring import SafeString, mark_safe 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
import InvenTree.helpers_model import InvenTree.helpers_model
import report.helpers
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
from company.models import Company from company.models import Company
from part.models import Part from part.models import Part
@ -88,7 +92,7 @@ def asset(filename):
full_path = settings.MEDIA_ROOT.joinpath('report', 'assets', filename).resolve() full_path = settings.MEDIA_ROOT.joinpath('report', 'assets', filename).resolve()
if not full_path.exists() or not full_path.is_file(): 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: if debug_mode:
return os.path.join(settings.MEDIA_URL, 'report', 'assets', filename) return os.path.join(settings.MEDIA_URL, 'report', 'assets', filename)
@ -96,7 +100,7 @@ def asset(filename):
@register.simple_tag() @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. """Return a fully-qualified path for an 'uploaded' image.
Arguments: 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 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) 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: Returns:
A fully qualified path to the image A fully qualified path to the image
Raises:
FileNotFoundError if the file does not exist
""" """
if type(filename) is SafeString: if type(filename) is SafeString:
# Prepend an empty string to enforce 'stringiness' # Prepend an empty string to enforce 'stringiness'
@ -129,21 +141,51 @@ def uploaded_image(filename, replace_missing=True, replacement_file='blank_image
exists = False exists = False
if not exists and not replace_missing: 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: 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: if exists:
return os.path.join(settings.MEDIA_URL, filename) return os.path.join(settings.MEDIA_URL, filename)
return os.path.join(settings.STATIC_URL, 'img', replacement_file) 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() @register.simple_tag()
@ -164,7 +206,7 @@ def encode_svg_image(filename):
exists = False exists = False
if not exists: if not exists:
raise FileNotFoundError(f"Image file '{filename}' not found") raise FileNotFoundError(_("Image file not found") + f": '{filename}'")
# Read the file data # Read the file data
with open(full_path, 'rb') as f: with open(full_path, 'rb') as f:
@ -175,7 +217,7 @@ def encode_svg_image(filename):
@register.simple_tag() @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. """Return a fully-qualified path for a part image.
Arguments: Arguments:
@ -184,13 +226,17 @@ def part_image(part: Part):
Raises: Raises:
TypeError if provided part is not a Part instance 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 img = part.image.name
else: return uploaded_image(img, **kwargs)
raise TypeError("part_image tag requires a Part instance")
return uploaded_image(img)
@register.simple_tag() @register.simple_tag()
@ -210,7 +256,7 @@ def part_parameter(part: Part, parameter_name: str):
@register.simple_tag() @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. """Return a fully-qualified path for a company image.
Arguments: Arguments:
@ -219,12 +265,17 @@ def company_image(company):
Raises: Raises:
TypeError if provided company is not a Company instance TypeError if provided company is not a Company instance
""" """
if type(company) is Company: if type(company) is not Company:
img = company.image.name raise TypeError(_("company_image tag requires a Company instance"))
else:
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() @register.simple_tag()

View File

@ -91,8 +91,12 @@ class ReportTagTest(TestCase):
with self.assertRaises(FileNotFoundError): with self.assertRaises(FileNotFoundError):
report_tags.uploaded_image('/part/something/test.png', replace_missing=False) report_tags.uploaded_image('/part/something/test.png', replace_missing=False)
img = report_tags.uploaded_image('/part/something/other.png') img = str(report_tags.uploaded_image('/part/something/other.png'))
self.assertTrue('blank_image.png' in img)
if b:
self.assertIn('blank_image.png', img)
else:
self.assertIn('data:image/png;charset=utf-8;base64,', img)
# Create a dummy image # Create a dummy image
img_path = 'part/images/' img_path = 'part/images/'
@ -121,10 +125,10 @@ class ReportTagTest(TestCase):
self.debug_mode(False) self.debug_mode(False)
img = report_tags.uploaded_image('part/images/test.jpg') 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')) 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): def test_part_image(self):
"""Unit tests for the 'part_image' tag""" """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 %} {% raw %}
<!-- Load the report helper functions --> <!-- Load the report helper functions -->
{% load report %} {% load report %}
<img src='{% uploaded_image "subdir/my_image.png" %}'/> <img src='{% uploaded_image "subdir/my_image.png" width=480 rotate=45 %}'/>
{% endraw %} {% endraw %}
``` ```
@ -148,6 +148,16 @@ You can access an uploaded image file if you know the *path* of the image, relat
!!! warning "Invalid Image" !!! warning "Invalid Image"
If the supplied file is not a valid image, it will be replaced with a placeholder image file 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 ### SVG Images
@ -173,6 +183,26 @@ A shortcut function is provided for rendering an image associated with a Part in
{% endraw %} {% 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 ### 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: 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 %} {% 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 ## 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: 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: