Refactor template helpers for displaying uploaded images (#3377)

* Refactor template helpers for displaying uploaded images

* Unit test for asset tag

* Unit tests for 'uploaded_image' tag

* Add simple tests for part_image and company_image functions

* Unit test for barcode constructor

* Unit tests for qrcode

* Refactor the 'company_logo.png' to be a new template tag

- Add unit tests

* Adds a new field to the report asset model

- Unique key which can be used to identify particular assets
- e.g. company logo

* Refactor logo image tags

- Make use of existing CUSTOM_LOGO setting
- Adds a "logo_image" template tag for reports

* Remove previous migration - strategy no longer required
This commit is contained in:
Oliver 2022-07-22 12:01:56 +10:00 committed by GitHub
parent 2bc8556993
commit d2ab6b012d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 312 additions and 83 deletions

View File

@ -2,13 +2,16 @@
import io import io
import json import json
import logging
import os.path import os.path
import re import re
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from wsgiref.util import FileWrapper from wsgiref.util import FileWrapper
from django.conf import settings
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.core.exceptions import FieldError, ValidationError from django.core.exceptions import FieldError, ValidationError
from django.core.files.storage import default_storage
from django.http import StreamingHttpResponse from django.http import StreamingHttpResponse
from django.test import TestCase from django.test import TestCase
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -25,6 +28,8 @@ from common.settings import currency_code_default
from .api_tester import UserMixin from .api_tester import UserMixin
from .settings import MEDIA_URL, STATIC_URL from .settings import MEDIA_URL, STATIC_URL
logger = logging.getLogger('inventree')
def getSetting(key, backup_value=None): def getSetting(key, backup_value=None):
"""Shortcut for reading a setting value from the database.""" """Shortcut for reading a setting value from the database."""
@ -82,6 +87,15 @@ def construct_absolute_url(*arg):
return url return url
def TestIfImage(img):
"""Test if an image file is indeed an image."""
try:
Image.open(img).verify()
return True
except Exception:
return False
def getBlankImage(): def getBlankImage():
"""Return the qualified path for the 'blank image' placeholder.""" """Return the qualified path for the 'blank image' placeholder."""
return getStaticUrl("img/blank_image.png") return getStaticUrl("img/blank_image.png")
@ -92,13 +106,23 @@ def getBlankThumbnail():
return getStaticUrl("img/blank_image.thumbnail.png") return getStaticUrl("img/blank_image.thumbnail.png")
def TestIfImage(img): def getLogoImage(as_file=False):
"""Test if an image file is indeed an image.""" """Return the InvenTree logo image, or a custom logo if available."""
try:
Image.open(img).verify() """Return the path to the logo-file."""
return True if settings.CUSTOM_LOGO:
except Exception:
return False if as_file:
return f"file://{default_storage.path(settings.CUSTOM_LOGO)}"
else:
return default_storage.url(settings.CUSTOM_LOGO)
else:
if as_file:
path = os.path.join(settings.STATIC_ROOT, 'img/inventree.png')
return f"file://{path}"
else:
return getStaticUrl('img/inventree.png')
def TestIfImageURL(url): def TestIfImageURL(url):

View File

@ -967,5 +967,5 @@ CUSTOM_LOGO = get_setting(
# check that the logo-file exsists in media # check that the logo-file exsists in media
if CUSTOM_LOGO and not default_storage.exists(CUSTOM_LOGO): # pragma: no cover if CUSTOM_LOGO and not default_storage.exists(CUSTOM_LOGO): # pragma: no cover
logger.warning(f"The custom logo file '{CUSTOM_LOGO}' could not be found in the default media storage")
CUSTOM_LOGO = False CUSTOM_LOGO = False
logger.warning("The custom logo file could not be found in the default media storage")

View File

@ -240,6 +240,17 @@ class TestHelpers(TestCase):
self.assertEqual(helpers.decimal2string(Decimal('1.2345000')), '1.2345') self.assertEqual(helpers.decimal2string(Decimal('1.2345000')), '1.2345')
self.assertEqual(helpers.decimal2string('test'), 'test') self.assertEqual(helpers.decimal2string('test'), 'test')
def test_logo_image(self):
"""Test for retrieving logo image"""
# By default, there is no custom logo provided
logo = helpers.getLogoImage()
self.assertEqual(logo, '/static/img/inventree.png')
logo = helpers.getLogoImage(as_file=True)
self.assertEqual(logo, f'file://{settings.STATIC_ROOT}/img/inventree.png')
class TestQuoteWrap(TestCase): class TestQuoteWrap(TestCase):
"""Tests for string wrapping.""" """Tests for string wrapping."""

View File

@ -17,10 +17,10 @@ from stdimage.models import StdImageField
import common.models import common.models
import common.settings import common.settings
import InvenTree.fields import InvenTree.fields
import InvenTree.helpers
import InvenTree.validators import InvenTree.validators
from common.settings import currency_code_default from common.settings import currency_code_default
from InvenTree.fields import InvenTreeURLField from InvenTree.fields import InvenTreeURLField
from InvenTree.helpers import getBlankImage, getBlankThumbnail, getMediaUrl
from InvenTree.models import InvenTreeAttachment from InvenTree.models import InvenTreeAttachment
from InvenTree.status_codes import PurchaseOrderStatus from InvenTree.status_codes import PurchaseOrderStatus
@ -177,16 +177,16 @@ class Company(models.Model):
def get_image_url(self): def get_image_url(self):
"""Return the URL of the image for this company.""" """Return the URL of the image for this company."""
if self.image: if self.image:
return getMediaUrl(self.image.url) return InvenTree.helpers.getMediaUrl(self.image.url)
else: else:
return getBlankImage() return InvenTree.helpers.getBlankImage()
def get_thumbnail_url(self): def get_thumbnail_url(self):
"""Return the URL for the thumbnail image for this Company.""" """Return the URL for the thumbnail image for this Company."""
if self.image: if self.image:
return getMediaUrl(self.image.thumbnail.url) return InvenTree.helpers.getMediaUrl(self.image.thumbnail.url)
else: else:
return getBlankThumbnail() return InvenTree.helpers.getBlankThumbnail()
@property @property
def parts(self): def parts(self):

View File

@ -7,8 +7,7 @@ from datetime import date, datetime
from django import template from django import template
from django.conf import settings as djangosettings from django.conf import settings as djangosettings
from django.core.files.storage import default_storage from django.templatetags.static import StaticNode
from django.templatetags.static import StaticNode, static
from django.urls import reverse from django.urls import reverse
from django.utils.html import format_html from django.utils.html import format_html
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
@ -174,6 +173,16 @@ def inventree_title(*args, **kwargs):
return version.inventreeInstanceTitle() return version.inventreeInstanceTitle()
@register.simple_tag()
def inventree_logo(**kwargs):
"""Return the InvenTree logo, *or* a custom logo if the user has uploaded one.
Returns a path to an image file, which can be rendered in the web interface
"""
return InvenTree.helpers.getLogoImage(**kwargs)
@register.simple_tag() @register.simple_tag()
def inventree_base_url(*args, **kwargs): def inventree_base_url(*args, **kwargs):
"""Return the INVENTREE_BASE_URL setting.""" """Return the INVENTREE_BASE_URL setting."""
@ -473,14 +482,6 @@ def inventree_customize(reference, *args, **kwargs):
return djangosettings.CUSTOMIZE.get(reference, '') return djangosettings.CUSTOMIZE.get(reference, '')
@register.simple_tag()
def inventree_logo(*args, **kwargs):
"""Return the path to the logo-file."""
if settings.CUSTOM_LOGO:
return default_storage.url(settings.CUSTOM_LOGO)
return static('img/inventree.png')
class I18nStaticNode(StaticNode): class I18nStaticNode(StaticNode):
"""Custom StaticNode. """Custom StaticNode.

View File

@ -535,14 +535,23 @@ class ReportAsset(models.Model):
and can be loaded in a template using the {% report_asset <filename> %} tag. and can be loaded in a template using the {% report_asset <filename> %} tag.
""" """
# String keys used for uniquely indentifying particular assets
ASSET_COMPANY_LOGO = "COMPANY_LOGO"
def __str__(self): def __str__(self):
"""String representation of a ReportAsset instance""" """String representation of a ReportAsset instance"""
return os.path.basename(self.asset.name) return os.path.basename(self.asset.name)
# Asset file
asset = models.FileField( asset = models.FileField(
upload_to=rename_asset, upload_to=rename_asset,
verbose_name=_('Asset'), verbose_name=_('Asset'),
help_text=_("Report asset file"), help_text=_("Report asset file"),
) )
description = models.CharField(max_length=250, verbose_name=_('Description'), help_text=_("Asset file description")) # Asset description (user facing string, not used internally)
description = models.CharField(
max_length=250,
verbose_name=_('Description'),
help_text=_("Asset file description")
)

View File

@ -78,8 +78,7 @@ content: "v{{report_revision}} - {{ date.isoformat }}";
{% endblock %} {% endblock %}
{% block header_content %} {% block header_content %}
<!-- TODO - Make the company logo asset generic --> <img class='logo' src="{% logo_image %}" alt="logo" width="150">
<img class='logo' src="{% asset 'company_logo.png' %}" alt="logo" width="150">
<div class='header-right'> <div class='header-right'>
<h3> <h3>

View File

@ -28,21 +28,29 @@ def image_data(img, fmt='PNG'):
def qrcode(data, **kwargs): def qrcode(data, **kwargs):
"""Return a byte-encoded QR code image. """Return a byte-encoded QR code image.
Optional kwargs kwargs:
--------------- fill_color: Fill color (default = black)
back_color: Background color (default = white)
version: Default = 1
box_size: Default = 20
border: Default = 1
Returns:
base64 encoded image data
fill_color: Fill color (default = black)
back_color: Background color (default = white)
""" """
# Construct "default" values # Construct "default" values
params = dict( params = dict(
box_size=20, box_size=20,
border=1, border=1,
version=1,
) )
fill_color = kwargs.pop('fill_color', 'black') fill_color = kwargs.pop('fill_color', 'black')
back_color = kwargs.pop('back_color', 'white') back_color = kwargs.pop('back_color', 'white')
format = kwargs.pop('format', 'PNG')
params.update(**kwargs) params.update(**kwargs)
qr = python_qrcode.QRCode(**params) qr = python_qrcode.QRCode(**params)
@ -50,9 +58,13 @@ def qrcode(data, **kwargs):
qr.add_data(data, optimize=20) qr.add_data(data, optimize=20)
qr.make(fit=True) qr.make(fit=True)
qri = qr.make_image(fill_color=fill_color, back_color=back_color) qri = qr.make_image(
fill_color=fill_color,
back_color=back_color
)
return image_data(qri) # Render to byte-encoded image
return image_data(qri, fmt=format)
@register.simple_tag() @register.simple_tag()
@ -60,6 +72,8 @@ def barcode(data, barcode_class='code128', **kwargs):
"""Render a barcode.""" """Render a barcode."""
constructor = python_barcode.get_barcode_class(barcode_class) constructor = python_barcode.get_barcode_class(barcode_class)
format = kwargs.pop('format', 'PNG')
data = str(data).zfill(constructor.digits) data = str(data).zfill(constructor.digits)
writer = python_barcode.writer.ImageWriter writer = python_barcode.writer.ImageWriter
@ -68,5 +82,5 @@ def barcode(data, barcode_class='code128', **kwargs):
image = barcode_image.render(writer_options=kwargs) image = barcode_image.render(writer_options=kwargs)
# Render to byte-encoded PNG # Render to byte-encoded image
return image_data(image) return image_data(image, fmt=format)

View File

@ -10,89 +10,133 @@ import InvenTree.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
from stock.models import StockItem
register = template.Library() register = template.Library()
@register.simple_tag() @register.simple_tag()
def asset(filename): def asset(filename):
"""Return fully-qualified path for an upload report asset file.""" """Return fully-qualified path for an upload report asset file.
Arguments:
filename: Asset filename (relative to the 'assets' media directory)
Raises:
FileNotFoundError if file does not exist
"""
# If in debug mode, return URL to the image, not a local file # If in debug mode, return URL to the image, not a local file
debug_mode = InvenTreeSetting.get_setting('REPORT_DEBUG_MODE') debug_mode = InvenTreeSetting.get_setting('REPORT_DEBUG_MODE')
if debug_mode: # Test if the file actually exists
path = os.path.join(settings.MEDIA_URL, 'report', 'assets', filename) full_path = os.path.join(settings.MEDIA_ROOT, 'report', 'assets', filename)
else:
path = os.path.join(settings.MEDIA_ROOT, 'report', 'assets', filename) if not os.path.exists(full_path) or not os.path.isfile(full_path):
path = os.path.abspath(path) raise FileNotFoundError(f"Asset file '{filename}' does not exist")
if debug_mode:
return os.path.join(settings.MEDIA_URL, 'report', 'assets', filename)
else:
return f"file://{full_path}"
@register.simple_tag()
def uploaded_image(filename, replace_missing=True, replacement_file='blank_image.png'):
"""Return a fully-qualified path for an 'uploaded' image.
Arguments:
filename: The filename of the image relative to the MEDIA_ROOT directory
replace_missing: Optionally return a placeholder image if the provided filename does not exist
Returns:
A fully qualified path to the image
"""
# If in debug mode, return URL to the image, not a local file
debug_mode = InvenTreeSetting.get_setting('REPORT_DEBUG_MODE')
# Check if the file exists
if not filename:
exists = False
else:
try:
full_path = os.path.join(settings.MEDIA_ROOT, filename)
full_path = os.path.abspath(full_path)
exists = os.path.exists(full_path) and os.path.isfile(full_path)
except Exception:
exists = False
if not exists and not replace_missing:
raise FileNotFoundError(f"Image file '{filename}' not found")
if debug_mode:
# In debug mode, return a web path
if exists:
return os.path.join(settings.MEDIA_URL, filename)
else:
return os.path.join(settings.STATIC_URL, 'img', replacement_file)
else:
# Return file path
if exists:
path = os.path.join(settings.MEDIA_ROOT, filename)
path = os.path.abspath(path)
else:
path = os.path.join(settings.STATIC_ROOT, 'img', replacement_file)
path = os.path.abspath(path)
return f"file://{path}" return f"file://{path}"
@register.simple_tag() @register.simple_tag()
def part_image(part): def part_image(part):
"""Return a fully-qualified path for a part image.""" """Return a fully-qualified path for a part image.
# If in debug mode, return URL to the image, not a local file
debug_mode = InvenTreeSetting.get_setting('REPORT_DEBUG_MODE') Arguments:
part: a Part model instance
Raises:
TypeError if provided part is not a Part instance
"""
if type(part) is Part: if type(part) is Part:
img = part.image.name img = part.image.name
elif type(part) is StockItem:
img = part.part.image.name
else: else:
img = '' raise TypeError("part_image tag requires a Part instance")
if debug_mode: return uploaded_image(img)
if img:
return os.path.join(settings.MEDIA_URL, img)
else:
return os.path.join(settings.STATIC_URL, 'img', 'blank_image.png')
else:
path = os.path.join(settings.MEDIA_ROOT, img)
path = os.path.abspath(path)
if not os.path.exists(path) or not os.path.isfile(path):
# Image does not exist
# Return the 'blank' image
path = os.path.join(settings.STATIC_ROOT, 'img', 'blank_image.png')
path = os.path.abspath(path)
return f"file://{path}"
@register.simple_tag() @register.simple_tag()
def company_image(company): def company_image(company):
"""Return a fully-qualified path for a company image.""" """Return a fully-qualified path for a company image.
# If in debug mode, return the URL to the image, not a local file
debug_mode = InvenTreeSetting.get_setting('REPORT_DEBUG_MODE') Arguments:
company: a Company model instance
Raises:
TypeError if provided company is not a Company instance
"""
if type(company) is Company: if type(company) is Company:
img = company.image.name img = company.image.name
else: else:
img = '' raise TypeError("company_image tag requires a Company instance")
if debug_mode: return uploaded_image(img)
if img:
return os.path.join(settings.MEDIA_URL, img)
else:
return os.path.join(settings.STATIC_URL, 'img', 'blank_image.png')
else:
path = os.path.join(settings.MEDIA_ROOT, img)
path = os.path.abspath(path)
if not os.path.exists(path) or not os.path.isfile(path): @register.simple_tag()
# Image does not exist def logo_image():
# Return the 'blank' image """Return a fully-qualified path for the logo image.
path = os.path.join(settings.STATIC_ROOT, 'img', 'blank_image.png')
path = os.path.abspath(path)
return f"file://{path}" - If a custom logo has been provided, return a path to that logo
- Otherwise, return a path to the default InvenTree logo
"""
# If in debug mode, return URL to the image, not a local file
debug_mode = InvenTreeSetting.get_setting('REPORT_DEBUG_MODE')
return InvenTree.helpers.getLogoImage(as_file=not debug_mode)
@register.simple_tag() @register.simple_tag()

View File

@ -6,15 +6,142 @@ import shutil
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.http.response import StreamingHttpResponse from django.http.response import StreamingHttpResponse
from django.test import TestCase
from django.urls import reverse from django.urls import reverse
import report.models as report_models import report.models as report_models
from build.models import Build from build.models import Build
from common.models import InvenTreeSetting, InvenTreeUserSetting from common.models import InvenTreeSetting, InvenTreeUserSetting
from InvenTree.api_tester import InvenTreeAPITestCase from InvenTree.api_tester import InvenTreeAPITestCase
from report.templatetags import barcode as barcode_tags
from report.templatetags import report as report_tags
from stock.models import StockItem, StockItemAttachment from stock.models import StockItem, StockItemAttachment
class ReportTagTest(TestCase):
"""Unit tests for the report template tags"""
def debug_mode(self, value: bool):
"""Enable or disable debug mode for reports"""
InvenTreeSetting.set_setting('REPORT_DEBUG_MODE', value, change_user=None)
def test_asset(self):
"""Tests for asset files"""
# Test that an error is raised if the file does not exist
for b in [True, False]:
self.debug_mode(b)
with self.assertRaises(FileNotFoundError):
report_tags.asset("bad_file.txt")
# Create an asset file
asset_dir = os.path.join(settings.MEDIA_ROOT, 'report', 'assets')
os.makedirs(asset_dir, exist_ok=True)
asset_path = os.path.join(asset_dir, 'test.txt')
with open(asset_path, 'w') as f:
f.write("dummy data")
self.debug_mode(True)
asset = report_tags.asset('test.txt')
self.assertEqual(asset, '/media/report/assets/test.txt')
self.debug_mode(False)
asset = report_tags.asset('test.txt')
self.assertEqual(asset, f'file://{asset_dir}/test.txt')
def test_uploaded_image(self):
"""Tests for retrieving uploaded images"""
# Test for a missing image
for b in [True, False]:
self.debug_mode(b)
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)
# Create a dummy image
img_path = 'part/images/'
img_path = os.path.join(settings.MEDIA_ROOT, img_path)
img_file = os.path.join(img_path, 'test.jpg')
os.makedirs(img_path, exist_ok=True)
with open(img_file, 'w') as f:
f.write("dummy data")
# Test in debug mode
self.debug_mode(True)
img = report_tags.uploaded_image('part/images/test.jpg')
self.assertEqual(img, '/media/part/images/test.jpg')
self.debug_mode(False)
img = report_tags.uploaded_image('part/images/test.jpg')
self.assertEqual(img, f'file://{img_path}test.jpg')
def test_part_image(self):
"""Unit tests for the 'part_image' tag"""
with self.assertRaises(TypeError):
report_tags.part_image(None)
def test_company_image(self):
"""Unit tests for the 'company_image' tag"""
with self.assertRaises(TypeError):
report_tags.company_image(None)
def test_logo_image(self):
"""Unit tests for the 'logo_image' tag"""
# By default, should return the core InvenTree logo
for b in [True, False]:
self.debug_mode(b)
logo = report_tags.logo_image()
self.assertIn('inventree.png', logo)
class BarcodeTagTest(TestCase):
"""Unit tests for the barcode template tags"""
def test_barcode(self):
"""Test the barcode generation tag"""
barcode = barcode_tags.barcode("12345")
self.assertTrue(type(barcode) == str)
self.assertTrue(barcode.startswith('data:image/png;'))
# Try with a different format
barcode = barcode_tags.barcode('99999', format='BMP')
self.assertTrue(type(barcode) == str)
self.assertTrue(barcode.startswith('data:image/bmp;'))
def test_qrcode(self):
"""Test the qrcode generation tag"""
# Test with default settings
qrcode = barcode_tags.qrcode("hello world")
self.assertTrue(type(qrcode) == str)
self.assertTrue(qrcode.startswith('data:image/png;'))
self.assertEqual(len(qrcode), 700)
# Generate a much larger qrcode
qrcode = barcode_tags.qrcode(
"hello_world",
version=2,
box_size=50,
format='BMP',
)
self.assertTrue(type(qrcode) == str)
self.assertTrue(qrcode.startswith('data:image/bmp;'))
self.assertEqual(len(qrcode), 309720)
class ReportTest(InvenTreeAPITestCase): class ReportTest(InvenTreeAPITestCase):
"""Base class for unit testing reporting models""" """Base class for unit testing reporting models"""
fixtures = [ fixtures = [