mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +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:
parent
3e063750b5
commit
2fcd6ae0b9
@ -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
|
||||
|
@ -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."""
|
||||
|
@ -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()
|
||||
|
@ -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 }}
|
||||
|
@ -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'>
|
||||
|
@ -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 }}
|
||||
|
@ -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 }}
|
||||
|
@ -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 }}
|
||||
|
@ -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>
|
||||
|
@ -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()
|
||||
|
@ -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()
|
||||
|
@ -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"""
|
||||
|
@ -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:
|
||||
|
Loading…
Reference in New Issue
Block a user