From d2ab6b012d395803eae380fc1bfd0875d1735a9b Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 22 Jul 2022 12:01:56 +1000 Subject: [PATCH] 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 --- InvenTree/InvenTree/helpers.py | 38 ++++- InvenTree/InvenTree/settings.py | 2 +- InvenTree/InvenTree/tests.py | 11 ++ InvenTree/company/models.py | 10 +- .../part/templatetags/inventree_extras.py | 21 +-- InvenTree/report/models.py | 11 +- .../report/inventree_build_order_base.html | 3 +- InvenTree/report/templatetags/barcode.py | 30 +++- InvenTree/report/templatetags/report.py | 142 ++++++++++++------ InvenTree/report/tests.py | 127 ++++++++++++++++ 10 files changed, 312 insertions(+), 83 deletions(-) diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py index fdc6380138..4326d3b377 100644 --- a/InvenTree/InvenTree/helpers.py +++ b/InvenTree/InvenTree/helpers.py @@ -2,13 +2,16 @@ import io import json +import logging import os.path import re from decimal import Decimal, InvalidOperation from wsgiref.util import FileWrapper +from django.conf import settings from django.contrib.auth.models import Permission from django.core.exceptions import FieldError, ValidationError +from django.core.files.storage import default_storage from django.http import StreamingHttpResponse from django.test import TestCase 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 .settings import MEDIA_URL, STATIC_URL +logger = logging.getLogger('inventree') + def getSetting(key, backup_value=None): """Shortcut for reading a setting value from the database.""" @@ -82,6 +87,15 @@ def construct_absolute_url(*arg): 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(): """Return the qualified path for the 'blank image' placeholder.""" return getStaticUrl("img/blank_image.png") @@ -92,13 +106,23 @@ def getBlankThumbnail(): return getStaticUrl("img/blank_image.thumbnail.png") -def TestIfImage(img): - """Test if an image file is indeed an image.""" - try: - Image.open(img).verify() - return True - except Exception: - return False +def getLogoImage(as_file=False): + """Return the InvenTree logo image, or a custom logo if available.""" + + """Return the path to the logo-file.""" + if settings.CUSTOM_LOGO: + + 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): diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index 447e07b09c..eb18342bd1 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -967,5 +967,5 @@ CUSTOM_LOGO = get_setting( # check that the logo-file exsists in media 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 - logger.warning("The custom logo file could not be found in the default media storage") diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py index 1f9c11e7fc..c0bd93b2c1 100644 --- a/InvenTree/InvenTree/tests.py +++ b/InvenTree/InvenTree/tests.py @@ -240,6 +240,17 @@ class TestHelpers(TestCase): self.assertEqual(helpers.decimal2string(Decimal('1.2345000')), '1.2345') 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): """Tests for string wrapping.""" diff --git a/InvenTree/company/models.py b/InvenTree/company/models.py index 1a18d39d14..fa68dfcf1b 100644 --- a/InvenTree/company/models.py +++ b/InvenTree/company/models.py @@ -17,10 +17,10 @@ from stdimage.models import StdImageField import common.models import common.settings import InvenTree.fields +import InvenTree.helpers import InvenTree.validators from common.settings import currency_code_default from InvenTree.fields import InvenTreeURLField -from InvenTree.helpers import getBlankImage, getBlankThumbnail, getMediaUrl from InvenTree.models import InvenTreeAttachment from InvenTree.status_codes import PurchaseOrderStatus @@ -177,16 +177,16 @@ class Company(models.Model): def get_image_url(self): """Return the URL of the image for this company.""" if self.image: - return getMediaUrl(self.image.url) + return InvenTree.helpers.getMediaUrl(self.image.url) else: - return getBlankImage() + return InvenTree.helpers.getBlankImage() def get_thumbnail_url(self): """Return the URL for the thumbnail image for this Company.""" if self.image: - return getMediaUrl(self.image.thumbnail.url) + return InvenTree.helpers.getMediaUrl(self.image.thumbnail.url) else: - return getBlankThumbnail() + return InvenTree.helpers.getBlankThumbnail() @property def parts(self): diff --git a/InvenTree/part/templatetags/inventree_extras.py b/InvenTree/part/templatetags/inventree_extras.py index 4f250959ed..92f760e1d4 100644 --- a/InvenTree/part/templatetags/inventree_extras.py +++ b/InvenTree/part/templatetags/inventree_extras.py @@ -7,8 +7,7 @@ from datetime import date, datetime from django import template from django.conf import settings as djangosettings -from django.core.files.storage import default_storage -from django.templatetags.static import StaticNode, static +from django.templatetags.static import StaticNode from django.urls import reverse from django.utils.html import format_html from django.utils.safestring import mark_safe @@ -174,6 +173,16 @@ def inventree_title(*args, **kwargs): 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() def inventree_base_url(*args, **kwargs): """Return the INVENTREE_BASE_URL setting.""" @@ -473,14 +482,6 @@ def inventree_customize(reference, *args, **kwargs): 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): """Custom StaticNode. diff --git a/InvenTree/report/models.py b/InvenTree/report/models.py index 5072028394..50837926d9 100644 --- a/InvenTree/report/models.py +++ b/InvenTree/report/models.py @@ -535,14 +535,23 @@ class ReportAsset(models.Model): and can be loaded in a template using the {% report_asset %} tag. """ + # String keys used for uniquely indentifying particular assets + ASSET_COMPANY_LOGO = "COMPANY_LOGO" + def __str__(self): """String representation of a ReportAsset instance""" return os.path.basename(self.asset.name) + # Asset file asset = models.FileField( upload_to=rename_asset, verbose_name=_('Asset'), 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") + ) diff --git a/InvenTree/report/templates/report/inventree_build_order_base.html b/InvenTree/report/templates/report/inventree_build_order_base.html index 5e73491d39..20f72e58ee 100644 --- a/InvenTree/report/templates/report/inventree_build_order_base.html +++ b/InvenTree/report/templates/report/inventree_build_order_base.html @@ -78,8 +78,7 @@ content: "v{{report_revision}} - {{ date.isoformat }}"; {% endblock %} {% block header_content %} - - +

diff --git a/InvenTree/report/templatetags/barcode.py b/InvenTree/report/templatetags/barcode.py index 0f1d885201..38152917d5 100644 --- a/InvenTree/report/templatetags/barcode.py +++ b/InvenTree/report/templatetags/barcode.py @@ -28,21 +28,29 @@ def image_data(img, fmt='PNG'): def qrcode(data, **kwargs): """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 params = dict( box_size=20, border=1, + version=1, ) fill_color = kwargs.pop('fill_color', 'black') back_color = kwargs.pop('back_color', 'white') + format = kwargs.pop('format', 'PNG') + params.update(**kwargs) qr = python_qrcode.QRCode(**params) @@ -50,9 +58,13 @@ def qrcode(data, **kwargs): qr.add_data(data, optimize=20) 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() @@ -60,6 +72,8 @@ def barcode(data, barcode_class='code128', **kwargs): """Render a barcode.""" constructor = python_barcode.get_barcode_class(barcode_class) + format = kwargs.pop('format', 'PNG') + data = str(data).zfill(constructor.digits) writer = python_barcode.writer.ImageWriter @@ -68,5 +82,5 @@ def barcode(data, barcode_class='code128', **kwargs): image = barcode_image.render(writer_options=kwargs) - # Render to byte-encoded PNG - return image_data(image) + # Render to byte-encoded image + return image_data(image, fmt=format) diff --git a/InvenTree/report/templatetags/report.py b/InvenTree/report/templatetags/report.py index 9e82202196..94211fbfed 100644 --- a/InvenTree/report/templatetags/report.py +++ b/InvenTree/report/templatetags/report.py @@ -10,89 +10,133 @@ import InvenTree.helpers from common.models import InvenTreeSetting from company.models import Company from part.models import Part -from stock.models import StockItem register = template.Library() @register.simple_tag() 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 debug_mode = InvenTreeSetting.get_setting('REPORT_DEBUG_MODE') - if debug_mode: - path = os.path.join(settings.MEDIA_URL, 'report', 'assets', filename) - else: + # Test if the file actually exists + full_path = os.path.join(settings.MEDIA_ROOT, 'report', 'assets', filename) - path = os.path.join(settings.MEDIA_ROOT, 'report', 'assets', filename) - path = os.path.abspath(path) + if not os.path.exists(full_path) or not os.path.isfile(full_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}" @register.simple_tag() def part_image(part): - """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') + """Return a fully-qualified path for a part image. + + Arguments: + part: a Part model instance + + Raises: + TypeError if provided part is not a Part instance + """ if type(part) is Part: img = part.image.name - elif type(part) is StockItem: - img = part.part.image.name - else: - img = '' + raise TypeError("part_image tag requires a Part instance") - if debug_mode: - 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}" + return uploaded_image(img) @register.simple_tag() def company_image(company): - """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') + """Return a fully-qualified path for a company image. + + Arguments: + company: a Company model instance + + Raises: + TypeError if provided company is not a Company instance + """ if type(company) is Company: img = company.image.name else: - img = '' + raise TypeError("company_image tag requires a Company instance") - if debug_mode: - if img: - return os.path.join(settings.MEDIA_URL, img) - else: - return os.path.join(settings.STATIC_URL, 'img', 'blank_image.png') + return uploaded_image(img) - 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) +@register.simple_tag() +def logo_image(): + """Return a fully-qualified path for the logo image. - 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() diff --git a/InvenTree/report/tests.py b/InvenTree/report/tests.py index 63128fb2c6..bed13b198c 100644 --- a/InvenTree/report/tests.py +++ b/InvenTree/report/tests.py @@ -6,15 +6,142 @@ import shutil from django.conf import settings from django.core.cache import cache from django.http.response import StreamingHttpResponse +from django.test import TestCase from django.urls import reverse import report.models as report_models from build.models import Build from common.models import InvenTreeSetting, InvenTreeUserSetting 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 +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): """Base class for unit testing reporting models""" fixtures = [