2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 11:36:44 +00:00
Oliver 610ea7b0b1
Report: Add date rendering (#6706)
* Validate timezone in settings.py

* Add helper functions for timezone information

- Extract server timezone
- Convert provided time to specified timezone

* Add more unit tests

* Remove debug print

* Test fix

* Add report helper tags

- format_date
- format_datetime
- Update report templates
- Unit tests

* Add setting to control report errors

- Only log errors to DB if setting is enabled

* Update example report

* Fixes for to_local_time

* Update type hinting

* Fix unit test typo
2024-03-14 12:09:14 +11:00

634 lines
21 KiB
Python

"""Unit testing for the various report models."""
import os
import shutil
from io import StringIO
from pathlib import Path
from django.conf import settings
from django.core.cache import cache
from django.http.response import StreamingHttpResponse
from django.test import TestCase, override_settings
from django.urls import reverse
from django.utils import timezone
from django.utils.safestring import SafeString
import pytz
from PIL import Image
import report.models as report_models
from build.models import Build
from common.models import InvenTreeSetting, InvenTreeUserSetting
from InvenTree.unit_test 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_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}
for k, v in data.items():
self.assertEqual(report_tags.getkey(data, k), v)
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,'))
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)
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)
@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=pytz.timezone('Australia/Sydney'),
)
# Format a set of tests: timezone, format, expected
tests = [
(None, None, '2024-03-12T22:25:00-04:00'),
(None, '%d-%m-%y', '12-03-24'),
('UTC', None, '2024-03-13T02:25:00+00:00'),
('UTC', '%d-%B-%Y', '13-March-2024'),
('Europe/Amsterdam', None, '2024-03-13T03:25:00+01:00'),
('Europe/Amsterdam', '%y-%m-%d %H:%M', '24-03-13 03:25'),
]
for tz, fmt, expected in tests:
result = report_tags.format_datetime(time, tz, fmt)
self.assertEqual(result, expected)
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(isinstance(barcode, str))
self.assertTrue(barcode.startswith('data:image/png;'))
# Try with a different format
barcode = barcode_tags.barcode('99999', format='BMP')
self.assertTrue(isinstance(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(isinstance(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(isinstance(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 = [
'category',
'part',
'company',
'location',
'test_templates',
'supplier_part',
'stock',
'stock_tests',
'bom',
'build',
]
superuser = True
model = None
list_url = None
detail_url = None
print_url = None
def setUp(self):
"""Ensure cache is cleared as part of test setup."""
cache.clear()
return super().setUp()
def copyReportTemplate(self, filename, description):
"""Copy the provided report template into the required media directory."""
src_dir = Path(__file__).parent.joinpath('templates', 'report')
template_dir = os.path.join('report', 'inventree', self.model.getSubdir())
dst_dir = settings.MEDIA_ROOT.joinpath(template_dir)
if not dst_dir.exists(): # pragma: no cover
dst_dir.mkdir(parents=True, exist_ok=True)
src_file = src_dir.joinpath(filename)
dst_file = dst_dir.joinpath(filename)
if not dst_file.exists(): # pragma: no cover
shutil.copyfile(src_file, dst_file)
# Convert to an "internal" filename
db_filename = os.path.join(template_dir, filename)
# Create a database entry for this report template!
self.model.objects.create(
name=os.path.splitext(filename)[0],
description=description,
template=db_filename,
enabled=True,
)
def test_api_url(self):
"""Test returned API Url against URL tag defined in this file."""
if not self.list_url:
return
self.assertEqual(reverse(self.list_url), self.model.get_api_url())
def test_list_endpoint(self):
"""Test that the LIST endpoint works for each report."""
if not self.list_url:
return
url = reverse(self.list_url)
response = self.get(url)
self.assertEqual(response.status_code, 200)
reports = self.model.objects.all()
n = len(reports)
# API endpoint must return correct number of reports
self.assertEqual(len(response.data), n)
# Filter by "enabled" status
response = self.get(url, {'enabled': True})
self.assertEqual(len(response.data), n)
response = self.get(url, {'enabled': False})
self.assertEqual(len(response.data), 0)
# Disable each report
for report in reports:
report.enabled = False
report.save()
# Filter by "enabled" status
response = self.get(url, {'enabled': True})
self.assertEqual(len(response.data), 0)
response = self.get(url, {'enabled': False})
self.assertEqual(len(response.data), n)
def test_create_endpoint(self):
"""Test that creating a new report works for each report."""
if not self.list_url:
return
url = reverse(self.list_url)
# Create a new report
# Django REST API "APITestCase" does not work like requests - to send a file without it existing on disk,
# create it as a StringIO object, and upload it under parameter template
filestr = StringIO(
'{% extends "label/report_base.html" %}{% block content %}<pre>TEST REPORT</pre>{% endblock content %}'
)
filestr.name = 'ExampleTemplate.html'
response = self.post(
url,
data={
'name': 'New report',
'description': 'A fancy new report created through API test',
'template': filestr,
},
format=None,
expected_code=201,
)
# Make sure the expected keys are in the response
self.assertIn('pk', response.data)
self.assertIn('name', response.data)
self.assertIn('description', response.data)
self.assertIn('template', response.data)
self.assertIn('filters', response.data)
self.assertIn('enabled', response.data)
self.assertEqual(response.data['name'], 'New report')
self.assertEqual(
response.data['description'], 'A fancy new report created through API test'
)
self.assertTrue(response.data['template'].endswith('ExampleTemplate.html'))
def test_detail_endpoint(self):
"""Test that the DETAIL endpoint works for each report."""
if not self.detail_url:
return
reports = self.model.objects.all()
n = len(reports)
# Make sure at least one report defined
self.assertGreaterEqual(n, 1)
# Check detail page for first report
response = self.get(
reverse(self.detail_url, kwargs={'pk': reports[0].pk}), expected_code=200
)
# Make sure the expected keys are in the response
self.assertIn('pk', response.data)
self.assertIn('name', response.data)
self.assertIn('description', response.data)
self.assertIn('template', response.data)
self.assertIn('filters', response.data)
self.assertIn('enabled', response.data)
filestr = StringIO(
'{% extends "label/report_base.html" %}{% block content %}<pre>TEST REPORT VERSION 2</pre>{% endblock content %}'
)
filestr.name = 'ExampleTemplate_Updated.html'
# Check PATCH method
response = self.patch(
reverse(self.detail_url, kwargs={'pk': reports[0].pk}),
{
'name': 'Changed name during test',
'description': 'New version of the template',
'template': filestr,
},
format=None,
expected_code=200,
)
# Make sure the expected keys are in the response
self.assertIn('pk', response.data)
self.assertIn('name', response.data)
self.assertIn('description', response.data)
self.assertIn('template', response.data)
self.assertIn('filters', response.data)
self.assertIn('enabled', response.data)
self.assertEqual(response.data['name'], 'Changed name during test')
self.assertEqual(response.data['description'], 'New version of the template')
self.assertTrue(
response.data['template'].endswith('ExampleTemplate_Updated.html')
)
# Delete the last report
response = self.delete(
reverse(self.detail_url, kwargs={'pk': reports[n - 1].pk}),
expected_code=204,
)
def test_metadata(self):
"""Unit tests for the metadata field."""
if self.model is not None:
p = self.model.objects.first()
self.assertEqual(p.metadata, {})
self.assertIsNone(p.get_metadata('test'))
self.assertEqual(p.get_metadata('test', backup_value=123), 123)
# Test update via the set_metadata() method
p.set_metadata('test', 3)
self.assertEqual(p.get_metadata('test'), 3)
for k in ['apple', 'banana', 'carrot', 'carrot', 'banana']:
p.set_metadata(k, k)
self.assertEqual(len(p.metadata.keys()), 4)
class TestReportTest(ReportTest):
"""Unit testing class for the stock item TestReport model."""
model = report_models.TestReport
list_url = 'api-stockitem-testreport-list'
detail_url = 'api-stockitem-testreport-detail'
print_url = 'api-stockitem-testreport-print'
def setUp(self):
"""Setup function for the stock item TestReport."""
self.copyReportTemplate('inventree_test_report.html', 'stock item test report')
return super().setUp()
def test_print(self):
"""Printing tests for the TestReport."""
report = self.model.objects.first()
url = reverse(self.print_url, kwargs={'pk': report.pk})
# Try to print without providing a valid StockItem
response = self.get(url, expected_code=400)
# Try to print with an invalid StockItem
response = self.get(url, {'item': 9999}, expected_code=400)
# Now print with a valid StockItem
item = StockItem.objects.first()
response = self.get(url, {'item': item.pk}, expected_code=200)
# Response should be a StreamingHttpResponse (PDF file)
self.assertEqual(type(response), StreamingHttpResponse)
headers = response.headers
self.assertEqual(headers['Content-Type'], 'application/pdf')
# By default, this should *not* have created an attachment against this stockitem
self.assertFalse(StockItemAttachment.objects.filter(stock_item=item).exists())
# Change the setting, now the test report should be attached automatically
InvenTreeSetting.set_setting('REPORT_ATTACH_TEST_REPORT', True, None)
response = self.get(url, {'item': item.pk}, expected_code=200)
headers = response.headers
self.assertEqual(headers['Content-Type'], 'application/pdf')
# Check that a report has been uploaded
attachment = StockItemAttachment.objects.filter(stock_item=item).first()
self.assertIsNotNone(attachment)
class BuildReportTest(ReportTest):
"""Unit test class for the BuildReport model."""
model = report_models.BuildReport
list_url = 'api-build-report-list'
detail_url = 'api-build-report-detail'
print_url = 'api-build-report-print'
def setUp(self):
"""Setup unit testing functions."""
self.copyReportTemplate('inventree_build_order.html', 'build order template')
return super().setUp()
def test_print(self):
"""Printing tests for the BuildReport."""
report = self.model.objects.first()
url = reverse(self.print_url, kwargs={'pk': report.pk})
# Try to print without providing a valid BuildOrder
response = self.get(url, expected_code=400)
# Try to print with an invalid BuildOrder
response = self.get(url, {'build': 9999}, expected_code=400)
# Now print with a valid BuildOrder
build = Build.objects.first()
response = self.get(url, {'build': build.pk})
self.assertEqual(type(response), StreamingHttpResponse)
headers = response.headers
self.assertEqual(headers['Content-Type'], 'application/pdf')
self.assertEqual(
headers['Content-Disposition'], 'attachment; filename="report.pdf"'
)
# Now, set the download type to be "inline"
inline = InvenTreeUserSetting.get_setting_object(
'REPORT_INLINE', cache=False, user=self.user
)
inline.value = True
inline.save()
response = self.get(url, {'build': 1})
headers = response.headers
self.assertEqual(headers['Content-Type'], 'application/pdf')
self.assertEqual(
headers['Content-Disposition'], 'inline; filename="report.pdf"'
)
class BOMReportTest(ReportTest):
"""Unit test class for the BillOfMaterialsReport model."""
model = report_models.BillOfMaterialsReport
list_url = 'api-bom-report-list'
detail_url = 'api-bom-report-detail'
print_url = 'api-bom-report-print'
def setUp(self):
"""Setup function for the bill of materials Report."""
self.copyReportTemplate(
'inventree_bill_of_materials_report.html', 'bill of materials report'
)
return super().setUp()
class PurchaseOrderReportTest(ReportTest):
"""Unit test class for the PurchaseOrderReport model."""
model = report_models.PurchaseOrderReport
list_url = 'api-po-report-list'
detail_url = 'api-po-report-detail'
print_url = 'api-po-report-print'
def setUp(self):
"""Setup function for the purchase order Report."""
self.copyReportTemplate('inventree_po_report.html', 'purchase order report')
return super().setUp()
class SalesOrderReportTest(ReportTest):
"""Unit test class for the SalesOrderReport model."""
model = report_models.SalesOrderReport
list_url = 'api-so-report-list'
detail_url = 'api-so-report-detail'
print_url = 'api-so-report-print'
def setUp(self):
"""Setup function for the sales order Report."""
self.copyReportTemplate('inventree_so_report.html', 'sales order report')
return super().setUp()
class ReturnOrderReportTest(ReportTest):
"""Unit tests for the ReturnOrderReport model."""
model = report_models.ReturnOrderReport
list_url = 'api-return-order-report-list'
detail_url = 'api-return-order-report-detail'
print_url = 'api-return-order-report-print'
def setUp(self):
"""Setup function for the ReturnOrderReport tests."""
self.copyReportTemplate(
'inventree_return_order_report.html', 'return order report'
)
return super().setUp()
class StockLocationReportTest(ReportTest):
"""Unit tests for the StockLocationReport model."""
model = report_models.StockLocationReport
list_url = 'api-stocklocation-report-list'
detail_url = 'api-stocklocation-report-detail'
print_url = 'api-stocklocation-report-print'
def setUp(self):
"""Setup function for the StockLocationReport tests."""
self.copyReportTemplate('inventree_slr_report.html', 'stock location report')
return super().setUp()