diff --git a/src/backend/InvenTree/InvenTree/helpers_model.py b/src/backend/InvenTree/InvenTree/helpers_model.py
index 3461b26254..41503f5741 100644
--- a/src/backend/InvenTree/InvenTree/helpers_model.py
+++ b/src/backend/InvenTree/InvenTree/helpers_model.py
@@ -211,16 +211,16 @@ def render_currency(
except Exception:
pass
- if min_decimal_places is None:
+ if min_decimal_places is None or not isinstance(min_decimal_places, (int, float)):
min_decimal_places = get_global_setting('PRICING_DECIMAL_PLACES_MIN', 0)
- if max_decimal_places is None:
+ if max_decimal_places is None or not isinstance(max_decimal_places, (int, float)):
max_decimal_places = get_global_setting('PRICING_DECIMAL_PLACES', 6)
value = Decimal(str(money.amount)).normalize()
value = str(value)
- if decimal_places is not None:
+ if decimal_places is not None and isinstance(decimal_places, (int, float)):
# Decimal place count is provided, use it
pass
elif '.' in value:
diff --git a/src/backend/InvenTree/InvenTree/templatetags/inventree_extras.py b/src/backend/InvenTree/InvenTree/templatetags/inventree_extras.py
index ca85a755b3..73130d0c60 100644
--- a/src/backend/InvenTree/InvenTree/templatetags/inventree_extras.py
+++ b/src/backend/InvenTree/InvenTree/templatetags/inventree_extras.py
@@ -64,12 +64,6 @@ def render_date(date_object):
return date_object
-@register.simple_tag
-def render_currency(money, **kwargs):
- """Render a currency / Money object."""
- return InvenTree.helpers_model.render_currency(money, **kwargs)
-
-
@register.simple_tag()
def str2bool(x, *args, **kwargs):
"""Convert a string to a boolean value."""
diff --git a/src/backend/InvenTree/part/test_api.py b/src/backend/InvenTree/part/test_api.py
index bd99f1186b..36b107bb91 100644
--- a/src/backend/InvenTree/part/test_api.py
+++ b/src/backend/InvenTree/part/test_api.py
@@ -39,6 +39,48 @@ from stock.models import StockItem, StockLocation
from stock.status_codes import StockStatus
+class PartImageTestMixin:
+ """Mixin for testing part images."""
+
+ roles = [
+ 'part.change',
+ 'part.add',
+ 'part.delete',
+ 'part_category.change',
+ 'part_category.add',
+ ]
+
+ @classmethod
+ def setUpTestData(cls):
+ """Custom setup routine for this class."""
+ super().setUpTestData()
+
+ # Create a custom APIClient for file uploads
+ # Ref: https://stackoverflow.com/questions/40453947/how-to-generate-a-file-upload-test-request-with-django-rest-frameworks-apireq
+ cls.upload_client = APIClient()
+ cls.upload_client.force_authenticate(user=cls.user)
+
+ def create_test_image(self):
+ """Create a test image file."""
+ p = Part.objects.first()
+
+ fn = BASE_DIR / '_testfolder' / 'part_image_123abc.png'
+
+ img = PIL.Image.new('RGB', (128, 128), color='blue')
+ img.save(fn)
+
+ with open(fn, 'rb') as img_file:
+ response = self.upload_client.patch(
+ reverse('api-part-detail', kwargs={'pk': p.pk}),
+ {'image': img_file},
+ expected_code=200,
+ )
+ print(response.data)
+ image_name = response.data['image']
+ self.assertTrue(image_name.startswith('/media/part_images/part_image'))
+ return image_name
+
+
class PartCategoryAPITest(InvenTreeAPITestCase):
"""Unit tests for the PartCategory API."""
@@ -1463,7 +1505,7 @@ class PartCreationTests(PartAPITestBase):
self.assertEqual(prt.parameters.count(), 3)
-class PartDetailTests(PartAPITestBase):
+class PartDetailTests(PartImageTestMixin, PartAPITestBase):
"""Test that we can create / edit / delete Part objects via the API."""
@classmethod
@@ -1656,22 +1698,7 @@ class PartDetailTests(PartAPITestBase):
def test_existing_image(self):
"""Test that we can allocate an existing uploaded image to a new Part."""
# First, upload an image for an existing part
- p = Part.objects.first()
-
- fn = BASE_DIR / '_testfolder' / 'part_image_123abc.png'
-
- img = PIL.Image.new('RGB', (128, 128), color='blue')
- img.save(fn)
-
- with open(fn, 'rb') as img_file:
- response = self.upload_client.patch(
- reverse('api-part-detail', kwargs={'pk': p.pk}),
- {'image': img_file},
- expected_code=200,
- )
-
- image_name = response.data['image']
- self.assertTrue(image_name.startswith('/media/part_images/part_image'))
+ image_name = self.create_test_image()
# Attempt to create, but with an invalid image name
response = self.post(
diff --git a/src/backend/InvenTree/report/templatetags/report.py b/src/backend/InvenTree/report/templatetags/report.py
index 9c84d20732..a8556166e4 100644
--- a/src/backend/InvenTree/report/templatetags/report.py
+++ b/src/backend/InvenTree/report/templatetags/report.py
@@ -44,6 +44,8 @@ def filter_queryset(queryset: QuerySet, **kwargs) -> QuerySet:
Example:
{% filter_queryset companies is_supplier=True as suppliers %}
"""
+ if not isinstance(queryset, QuerySet):
+ return queryset
return queryset.filter(**kwargs)
@@ -60,7 +62,10 @@ def filter_db_model(model_name: str, **kwargs) -> QuerySet:
Example:
{% filter_db_model 'part.partcategory' is_template=True as template_parts %}
"""
- app_name, model_name = model_name.split('.')
+ try:
+ app_name, model_name = model_name.split('.')
+ except ValueError:
+ return None
try:
model = apps.get_model(app_name, model_name)
@@ -93,13 +98,7 @@ def getindex(container: list, index: int) -> Any:
if index < 0 or index >= len(container):
return None
-
- try:
- value = container[index]
- except IndexError:
- value = None
-
- return value
+ return container[index]
@register.simple_tag()
@@ -191,8 +190,8 @@ def uploaded_image(
try:
full_path = settings.MEDIA_ROOT.joinpath(filename).resolve()
exists = full_path.exists() and full_path.is_file()
- except Exception:
- exists = False
+ except Exception: # pragma: no cover
+ exists = False # pragma: no cover
if exists and validate and not InvenTree.helpers.TestIfImage(full_path):
logger.warning("File '%s' is not a valid image", filename)
@@ -302,10 +301,14 @@ def part_image(part: Part, preview: bool = False, thumbnail: bool = False, **kwa
if type(part) is not Part:
raise TypeError(_('part_image tag requires a Part instance'))
- if preview:
- img = part.image.preview.name
+ if not part.image:
+ img = None
+ elif preview:
+ img = None if not hasattr(part.image, 'preview') else part.image.preview.name
elif thumbnail:
- img = part.image.thumbnail.name
+ img = (
+ None if not hasattr(part.image, 'thumbnail') else part.image.thumbnail.name
+ )
else:
img = part.image.name
@@ -376,10 +379,14 @@ def internal_link(link, text) -> str:
"""
text = str(text)
- url = InvenTree.helpers_model.construct_absolute_url(link)
+ try:
+ url = InvenTree.helpers_model.construct_absolute_url(link)
+ except Exception:
+ url = None
# If the base URL is not set, just return the text
if not url:
+ logger.warning('Failed to construct absolute URL for internal link')
return text
return mark_safe(f'{text}')
@@ -525,7 +532,10 @@ def format_date(dt: date, timezone: Optional[str] = None, fmt: Optional[str] = N
timezone: The timezone to use for the date (defaults to the server timezone)
fmt: The format string to use (defaults to ISO formatting)
"""
- dt = InvenTree.helpers.to_local_time(dt, timezone).date()
+ try:
+ dt = InvenTree.helpers.to_local_time(dt, timezone).date()
+ except TypeError:
+ return str(dt)
if fmt:
return dt.strftime(fmt)
@@ -558,15 +568,18 @@ def icon(name, **kwargs):
@register.simple_tag()
-def include_icon_fonts():
+def include_icon_fonts(ttf: bool = False, woff: bool = False):
"""Return the CSS font-face rule for the icon fonts used on the current page (or all)."""
fonts = []
+ if not ttf and not woff:
+ ttf = woff = True
+
for font in common.icons.get_icon_packs().values():
# generate the font src string (prefer ttf over woff, woff2 is not supported by weasyprint)
- if 'truetype' in font.fonts:
+ if 'truetype' in font.fonts and ttf:
font_format, url = 'truetype', font.fonts['truetype']
- elif 'woff' in font.fonts:
+ elif 'woff' in font.fonts and woff:
font_format, url = 'woff', font.fonts['woff']
fonts.append(f"""
diff --git a/src/backend/InvenTree/report/test_tags.py b/src/backend/InvenTree/report/test_tags.py
new file mode 100644
index 0000000000..bf6265a354
--- /dev/null
+++ b/src/backend/InvenTree/report/test_tags.py
@@ -0,0 +1,447 @@
+"""Test for custom report tags."""
+
+from zoneinfo import ZoneInfo
+
+from django.conf import settings
+from django.test import TestCase, override_settings
+from django.utils import timezone
+from django.utils.safestring import SafeString
+
+from djmoney.money import Money
+from PIL import Image
+
+from common.models import InvenTreeSetting
+from InvenTree.unit_test import InvenTreeTestCase
+from part.models import Part, PartParameter, PartParameterTemplate
+from part.test_api import PartImageTestMixin
+from report.templatetags import barcode as barcode_tags
+from report.templatetags import report as report_tags
+
+
+class ReportTagTest(PartImageTestMixin, InvenTreeTestCase):
+ """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_getindex(self):
+ """Tests for the 'getindex' template tag."""
+ fn = report_tags.getindex
+ data = [1, 2, 3, 4, 5, 6]
+
+ # Out of bounds or invalid
+ self.assertEqual(fn(data, -1), None)
+ self.assertEqual(fn(data, 99), None)
+ self.assertEqual(fn(data, 'xx'), None)
+
+ for idx in range(len(data)):
+ self.assertEqual(fn(data, idx), data[idx])
+
+ def test_getkey(self):
+ """Tests for the 'getkey' template tag."""
+ data = {'hello': 'world', 'foo': 'bar', 'with spaces': 'withoutspaces', 1: 2}
+
+ # Valid case
+ for k, v in data.items():
+ self.assertEqual(report_tags.getkey(data, k), v)
+
+ # Error case
+ self.assertEqual(
+ None, report_tags.getkey('not a container', 'not-a-key', 'a value')
+ )
+
+ 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 = settings.MEDIA_ROOT.joinpath('report', 'assets')
+ asset_dir.mkdir(parents=True, exist_ok=True)
+ asset_path = asset_dir.joinpath('test.txt')
+
+ asset_path.write_text('dummy data')
+
+ self.debug_mode(True)
+ asset = report_tags.asset('test.txt')
+ self.assertEqual(asset, '/media/report/assets/test.txt')
+
+ # Ensure that a 'safe string' also works
+ asset = report_tags.asset(SafeString('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 = 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/'
+ img_path = settings.MEDIA_ROOT.joinpath(img_path)
+ img_file = img_path.joinpath('test.jpg')
+
+ img_path.mkdir(parents=True, exist_ok=True)
+ img_file.write_text('dummy data')
+
+ # Test in debug mode. Returns blank image as dummy file is not a valid image
+ self.debug_mode(True)
+ img = report_tags.uploaded_image('part/images/test.jpg')
+ self.assertEqual(img, '/static/img/blank_image.png')
+
+ # Now, let's create a proper image
+ img = Image.new('RGB', (128, 128), color='RED')
+ img.save(img_file)
+
+ # Try again
+ img = report_tags.uploaded_image('part/images/test.jpg')
+ self.assertEqual(img, '/media/part/images/test.jpg')
+
+ # Ensure that a 'safe string' also works
+ img = report_tags.uploaded_image(SafeString('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.assertTrue(img.startswith('data:image/png;charset=utf-8;base64,'))
+
+ img = report_tags.uploaded_image(SafeString('part/images/test.jpg'))
+ self.assertTrue(img.startswith('data:image/png;charset=utf-8;base64,'))
+
+ # Check width, height, rotate
+ img = report_tags.uploaded_image(
+ 'part/images/test.jpg', width=100, height=200, rotate=90
+ )
+ self.assertTrue(img.startswith('data:image/png;charset=utf-8;base64,'))
+
+ img = report_tags.uploaded_image('part/images/test.jpg', width=100)
+ self.assertTrue(img.startswith('data:image/png;charset=utf-8;base64,'))
+
+ # Invalid args
+ img = report_tags.uploaded_image(
+ 'part/images/test.jpg', width='a', height='b', rotate='c'
+ )
+ self.assertTrue(img.startswith('data:image/png;charset=utf-8;base64,'))
+
+ def test_part_image(self):
+ """Unit tests for the 'part_image' tag."""
+ with self.assertRaises(TypeError):
+ report_tags.part_image(None)
+
+ obj = Part.objects.create(name='test', description='test')
+ self.create_test_image()
+
+ report_tags.part_image(obj, preview=True)
+ report_tags.part_image(obj, thumbnail=True)
+
+ def test_company_image(self):
+ """Unit tests for the 'company_image' tag."""
+ with self.assertRaises(TypeError):
+ report_tags.company_image(None)
+ with self.assertRaises(TypeError):
+ report_tags.company_image(None, preview=True)
+ with self.assertRaises(TypeError):
+ report_tags.company_image(None, thumbnail=True)
+
+ def test_internal_link(self):
+ """Unit tests for the 'internal_link' tag."""
+ # Test with a valid object
+ obj = Part.objects.create(name='test', description='test')
+ self.assertEqual(report_tags.internal_link(obj, 'test123'), 'test123')
+ link = report_tags.internal_link(obj.get_absolute_url(), 'test')
+ self.assertEqual(
+ link, f'test'
+ )
+
+ # Test with an invalid object
+ link = report_tags.internal_link(None, None)
+ self.assertEqual(link, '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)
+
+ def test_maths_tags(self):
+ """Simple tests for mathematical operator tags."""
+ self.assertEqual(report_tags.add(1, 2), 3)
+ self.assertEqual(report_tags.subtract(10, 4.2), 5.8)
+ self.assertEqual(report_tags.multiply(2.3, 4), 9.2)
+ self.assertEqual(report_tags.divide(100, 5), 20)
+
+ def test_number_tags(self):
+ """Simple tests for number formatting tags."""
+ fn = report_tags.format_number
+
+ self.assertEqual(fn(1234), '1234')
+ self.assertEqual(fn(1234.5678, decimal_places=2), '1234.57')
+ self.assertEqual(fn(1234.5678, decimal_places=3), '1234.568')
+ self.assertEqual(fn(-9999.5678, decimal_places=2, separator=','), '-9,999.57')
+ self.assertEqual(
+ fn(9988776655.4321, integer=True, separator=' '), '9 988 776 655'
+ )
+
+ # Failure cases
+ self.assertEqual(fn('abc'), 'abc')
+ self.assertEqual(fn(1234.456, decimal_places='a'), '1234.456')
+ self.assertEqual(fn(1234.456, leading='a'), '1234.456')
+
+ @override_settings(TIME_ZONE='America/New_York')
+ def test_date_tags(self):
+ """Test for date formatting tags.
+
+ - Source timezone is Australia/Sydney
+ - Server timezone is America/New York
+ """
+ time = timezone.datetime(
+ year=2024,
+ month=3,
+ day=13,
+ hour=12,
+ minute=30,
+ second=0,
+ tzinfo=ZoneInfo('Australia/Sydney'),
+ )
+
+ # Format a set of tests: timezone, format, expected
+ tests = [
+ (None, None, '2024-03-12T21:30:00-04:00'),
+ (None, '%d-%m-%y', '12-03-24'),
+ ('UTC', None, '2024-03-13T01:30:00+00:00'),
+ ('UTC', '%d-%B-%Y', '13-March-2024'),
+ ('Europe/Amsterdam', None, '2024-03-13T02:30:00+01:00'),
+ ('Europe/Amsterdam', '%y-%m-%d %H:%M', '24-03-13 02:30'),
+ ]
+
+ for tz, fmt, expected in tests:
+ result = report_tags.format_datetime(time, tz, fmt)
+ self.assertEqual(result, expected)
+
+ def test_icon(self):
+ """Test the icon template tag."""
+ for icon in [None, '', 'not:the-correct-format', 'any-non-existent-icon']:
+ self.assertEqual(report_tags.icon(icon), '')
+
+ self.assertEqual(
+ report_tags.icon('ti:package:outline'),
+ f'{chr(int("eaff", 16))}',
+ )
+ self.assertEqual(
+ report_tags.icon(
+ 'ti:package:outline', **{'class': 'my-custom-class my-seconds-class'}
+ ),
+ f'{chr(int("eaff", 16))}',
+ )
+
+ def test_include_icon_fonts(self):
+ """Test the include_icon_fonts template tag."""
+ # ttf
+ style = report_tags.include_icon_fonts()
+
+ self.assertIn('@font-face {', style)
+ self.assertIn("font-family: 'inventree-icon-font-ti';", style)
+ self.assertIn('tabler-icons/tabler-icons.ttf', style)
+ self.assertIn('.icon {', style)
+
+ # woff
+ style = report_tags.include_icon_fonts(woff=True)
+ self.assertIn('tabler-icons/tabler-icons.woff', style)
+
+ def test_filter_queryset(self):
+ """Test the filter_queryset template tag."""
+ # Test with a valid queryset
+ qs = Part.objects.all()
+ self.assertEqual(
+ list(report_tags.filter_queryset(qs, name='test')),
+ list(qs.filter(name='test')),
+ )
+
+ # Test with an invalid queryset
+ self.assertEqual(report_tags.filter_queryset(None, name='test'), None)
+
+ def test_filter_db_model(self):
+ """Test the filter_db_model template tag."""
+ self.assertEqual(list(report_tags.filter_db_model('part.part')), [])
+
+ part = Part.objects.create(name='test', description='test')
+ self.assertEqual(
+ list(report_tags.filter_db_model('part.part', name='test')), [part]
+ )
+ self.assertEqual(
+ list(report_tags.filter_db_model('part.part', name='test1')), []
+ )
+
+ # Invalid model
+ self.assertEqual(report_tags.filter_db_model('part.abcd'), None)
+ self.assertEqual(report_tags.filter_db_model(''), None)
+
+ def test_encode_svg_image(self):
+ """Test the encode_svg_image template tag."""
+ # Generate smallest possible SVG for testing
+ svg_path = settings.BASE_DIR / '_testfolder' / 'part_image_123abc.png'
+ with open(svg_path, 'w', encoding='utf8') as f:
+ f.write('