2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-29 12:06:44 +00:00
Oliver 6abd33f060
Report enhancements (#6714)
* Add "enabled" filter to template table

* Cleanup

* API endpoints

- Add API endpoints for report snippet
- List endpoint
- Details endpoint

* Update serializers

- Add asset serializer
- Update

* Check for duplicate asset files

- Prevent upload of duplicate asset files
- Allow re-upload for same PK

* Duplicate checks for ReportSnippet

* Bump API version
2024-03-14 13:24:48 +00:00

770 lines
24 KiB
Python

"""Report template model definitions."""
import datetime
import logging
import os
import sys
from django.conf import settings
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.validators import FileExtensionValidator
from django.db import models
from django.template import Context, Template
from django.template.loader import render_to_string
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
import build.models
import common.models
import InvenTree.exceptions
import InvenTree.models
import order.models
import part.models
import report.helpers
import stock.models
from InvenTree.helpers import validateFilterString
from InvenTree.helpers_model import get_base_url
from InvenTree.models import MetadataMixin
from plugin.registry import registry
try:
from django_weasyprint import WeasyTemplateResponseMixin
except OSError as err: # pragma: no cover
print(f'OSError: {err}')
print('You may require some further system packages to be installed.')
sys.exit(1)
logger = logging.getLogger('inventree')
def rename_template(instance, filename):
"""Helper function for 'renaming' uploaded report files.
Pass responsibility back to the calling class,
to ensure that files are uploaded to the correct directory.
"""
return instance.rename_file(filename)
def validate_stock_item_report_filters(filters):
"""Validate filter string against StockItem model."""
return validateFilterString(filters, model=stock.models.StockItem)
def validate_part_report_filters(filters):
"""Validate filter string against Part model."""
return validateFilterString(filters, model=part.models.Part)
def validate_build_report_filters(filters):
"""Validate filter string against Build model."""
return validateFilterString(filters, model=build.models.Build)
def validate_purchase_order_filters(filters):
"""Validate filter string against PurchaseOrder model."""
return validateFilterString(filters, model=order.models.PurchaseOrder)
def validate_sales_order_filters(filters):
"""Validate filter string against SalesOrder model."""
return validateFilterString(filters, model=order.models.SalesOrder)
def validate_return_order_filters(filters):
"""Validate filter string against ReturnOrder model."""
return validateFilterString(filters, model=order.models.ReturnOrder)
def validate_stock_location_report_filters(filters):
"""Validate filter string against StockLocation model."""
return validateFilterString(filters, model=stock.models.StockLocation)
class WeasyprintReportMixin(WeasyTemplateResponseMixin):
"""Class for rendering a HTML template to a PDF."""
pdf_filename = 'report.pdf'
pdf_attachment = True
def __init__(self, request, template, **kwargs):
"""Initialize the report mixin with some standard attributes."""
self.request = request
self.template_name = template
self.pdf_filename = kwargs.get('filename', 'report.pdf')
class ReportBase(InvenTree.models.InvenTreeModel):
"""Base class for uploading html templates."""
class Meta:
"""Metaclass options. Abstract ensures no database table is created."""
abstract = True
def __init__(self, *args, **kwargs):
"""Initialize the particular report instance."""
super().__init__(*args, **kwargs)
self._meta.get_field(
'page_size'
).choices = report.helpers.report_page_size_options()
def save(self, *args, **kwargs):
"""Perform additional actions when the report is saved."""
# Increment revision number
self.revision += 1
super().save()
def __str__(self):
"""Format a string representation of a report instance."""
return f'{self.name} - {self.description}'
@classmethod
def getSubdir(cls):
"""Return the subdirectory where template files for this report model will be located."""
return ''
def rename_file(self, filename):
"""Function for renaming uploaded file."""
filename = os.path.basename(filename)
path = os.path.join('report', 'report_template', self.getSubdir(), filename)
fullpath = settings.MEDIA_ROOT.joinpath(path).resolve()
# If the report file is the *same* filename as the one being uploaded,
# remove the original one from the media directory
if str(filename) == str(self.template):
if fullpath.exists():
logger.info("Deleting existing report template: '%s'", filename)
os.remove(fullpath)
# Ensure that the cache is cleared for this template!
cache.delete(fullpath)
return path
@property
def extension(self):
"""Return the filename extension of the associated template file."""
return os.path.splitext(self.template.name)[1].lower()
@property
def template_name(self):
"""Returns the file system path to the template file.
Required for passing the file to an external process
"""
template = self.template.name
# TODO @matmair change to using new file objects
template = template.replace('/', os.path.sep)
template = template.replace('\\', os.path.sep)
template = settings.MEDIA_ROOT.joinpath(template)
return template
name = models.CharField(
blank=False,
max_length=100,
verbose_name=_('Name'),
help_text=_('Template name'),
)
template = models.FileField(
upload_to=rename_template,
verbose_name=_('Template'),
help_text=_('Report template file'),
validators=[FileExtensionValidator(allowed_extensions=['html', 'htm'])],
)
description = models.CharField(
max_length=250,
verbose_name=_('Description'),
help_text=_('Report template description'),
)
revision = models.PositiveIntegerField(
default=1,
verbose_name=_('Revision'),
help_text=_('Report revision number (auto-increments)'),
editable=False,
)
page_size = models.CharField(
max_length=20,
default=report.helpers.report_page_size_default,
verbose_name=_('Page Size'),
help_text=_('Page size for PDF reports'),
)
landscape = models.BooleanField(
default=False,
verbose_name=_('Landscape'),
help_text=_('Render report in landscape orientation'),
)
class ReportTemplateBase(MetadataMixin, ReportBase):
"""Reporting template model.
Able to be passed context data
"""
class Meta:
"""Metaclass options. Abstract ensures no database table is created."""
abstract = True
# Pass a single top-level object to the report template
object_to_print = None
def get_context_data(self, request):
"""Supply context data to the template for rendering."""
return {}
def get_report_size(self):
"""Return the printable page size for this report."""
try:
page_size_default = common.models.InvenTreeSetting.get_setting(
'REPORT_DEFAULT_PAGE_SIZE', 'A4'
)
except Exception:
page_size_default = 'A4'
page_size = self.page_size or page_size_default
if self.landscape:
page_size = page_size + ' landscape'
return page_size
def context(self, request):
"""All context to be passed to the renderer."""
# Generate custom context data based on the particular report subclass
context = self.get_context_data(request)
context['base_url'] = get_base_url(request=request)
context['date'] = datetime.datetime.now().date()
context['datetime'] = datetime.datetime.now()
context['page_size'] = self.get_report_size()
context['report_template'] = self
context['report_description'] = self.description
context['report_name'] = self.name
context['report_revision'] = self.revision
context['request'] = request
context['user'] = request.user
# Pass the context through to any active reporting plugins
plugins = registry.with_mixin('report')
for plugin in plugins:
# Let each plugin add its own context data
try:
plugin.add_report_context(self, self.object_to_print, request, context)
except Exception:
InvenTree.exceptions.log_error(
f'plugins.{plugin.slug}.add_report_context'
)
return context
def generate_filename(self, request, **kwargs):
"""Generate a filename for this report."""
template_string = Template(self.filename_pattern)
ctx = self.context(request)
context = Context(ctx)
return template_string.render(context)
def render_as_string(self, request, **kwargs):
"""Render the report to a HTML string.
Useful for debug mode (viewing generated code)
"""
return render_to_string(self.template_name, self.context(request), request)
def render(self, request, **kwargs):
"""Render the template to a PDF file.
Uses django-weasyprint plugin to render HTML template against Weasyprint
"""
# TODO: Support custom filename generation!
# filename = kwargs.get('filename', 'report.pdf')
# Render HTML template to PDF
wp = WeasyprintReportMixin(
request,
self.template_name,
base_url=request.build_absolute_uri('/'),
presentational_hints=True,
filename=self.generate_filename(request),
**kwargs,
)
return wp.render_to_response(self.context(request), **kwargs)
filename_pattern = models.CharField(
default='report.pdf',
verbose_name=_('Filename Pattern'),
help_text=_('Pattern for generating report filenames'),
max_length=100,
)
enabled = models.BooleanField(
default=True,
verbose_name=_('Enabled'),
help_text=_('Report template is enabled'),
)
class TestReport(ReportTemplateBase):
"""Render a TestReport against a StockItem object."""
@staticmethod
def get_api_url():
"""Return the API URL associated with the TestReport model."""
return reverse('api-stockitem-testreport-list')
@classmethod
def getSubdir(cls):
"""Return the subdirectory where TestReport templates are located."""
return 'test'
filters = models.CharField(
blank=True,
max_length=250,
verbose_name=_('Filters'),
help_text=_(
'StockItem query filters (comma-separated list of key=value pairs)'
),
validators=[validate_stock_item_report_filters],
)
include_installed = models.BooleanField(
default=False,
verbose_name=_('Include Installed Tests'),
help_text=_(
'Include test results for stock items installed inside assembled item'
),
)
def get_test_keys(self, stock_item):
"""Construct a flattened list of test 'keys' for this StockItem.
The list is constructed as follows:
- First, any 'required' tests
- Second, any 'non required' tests
- Finally, any test results which do not match a test
"""
keys = []
for test in stock_item.part.getTestTemplates(required=True):
if test.key not in keys:
keys.append(test.key)
for test in stock_item.part.getTestTemplates(required=False):
if test.key not in keys:
keys.append(test.key)
for result in stock_item.testResultList(
include_installed=self.include_installed
):
if result.key not in keys:
keys.append(result.key)
return list(keys)
def get_context_data(self, request):
"""Return custom context data for the TestReport template."""
stock_item = self.object_to_print
return {
'stock_item': stock_item,
'serial': stock_item.serial,
'part': stock_item.part,
'parameters': stock_item.part.parameters_map(),
'test_keys': self.get_test_keys(stock_item),
'test_template_list': stock_item.part.getTestTemplates(),
'test_template_map': stock_item.part.getTestTemplateMap(),
'results': stock_item.testResultMap(
include_installed=self.include_installed
),
'result_list': stock_item.testResultList(
include_installed=self.include_installed
),
'installed_items': stock_item.get_installed_items(cascade=True),
}
class BuildReport(ReportTemplateBase):
"""Build order / work order report."""
@staticmethod
def get_api_url():
"""Return the API URL associated with the BuildReport model."""
return reverse('api-build-report-list')
@classmethod
def getSubdir(cls):
"""Return the subdirectory where BuildReport templates are located."""
return 'build'
filters = models.CharField(
blank=True,
max_length=250,
verbose_name=_('Build Filters'),
help_text=_('Build query filters (comma-separated list of key=value pairs'),
validators=[validate_build_report_filters],
)
def get_context_data(self, request):
"""Custom context data for the build report."""
my_build = self.object_to_print
if not isinstance(my_build, build.models.Build):
raise TypeError('Provided model is not a Build object')
return {
'build': my_build,
'part': my_build.part,
'build_outputs': my_build.build_outputs.all(),
'line_items': my_build.build_lines.all(),
'bom_items': my_build.part.get_bom_items(),
'reference': my_build.reference,
'quantity': my_build.quantity,
'title': str(my_build),
}
class BillOfMaterialsReport(ReportTemplateBase):
"""Render a Bill of Materials against a Part object."""
@staticmethod
def get_api_url():
"""Return the API URL associated with the BillOfMaterialsReport model."""
return reverse('api-bom-report-list')
@classmethod
def getSubdir(cls):
"""Return the directory where BillOfMaterialsReport templates are located."""
return 'bom'
filters = models.CharField(
blank=True,
max_length=250,
verbose_name=_('Part Filters'),
help_text=_('Part query filters (comma-separated list of key=value pairs'),
validators=[validate_part_report_filters],
)
def get_context_data(self, request):
"""Return custom context data for the BillOfMaterialsReport template."""
part = self.object_to_print
return {
'part': part,
'category': part.category,
'bom_items': part.get_bom_items(),
}
class PurchaseOrderReport(ReportTemplateBase):
"""Render a report against a PurchaseOrder object."""
@staticmethod
def get_api_url():
"""Return the API URL associated with the PurchaseOrderReport model."""
return reverse('api-po-report-list')
@classmethod
def getSubdir(cls):
"""Return the directory where PurchaseOrderReport templates are stored."""
return 'purchaseorder'
filters = models.CharField(
blank=True,
max_length=250,
verbose_name=_('Filters'),
help_text=_('Purchase order query filters'),
validators=[validate_purchase_order_filters],
)
def get_context_data(self, request):
"""Return custom context data for the PurchaseOrderReport template."""
order = self.object_to_print
return {
'description': order.description,
'lines': order.lines,
'extra_lines': order.extra_lines,
'order': order,
'reference': order.reference,
'supplier': order.supplier,
'title': str(order),
}
class SalesOrderReport(ReportTemplateBase):
"""Render a report against a SalesOrder object."""
@staticmethod
def get_api_url():
"""Return the API URL associated with the SalesOrderReport model."""
return reverse('api-so-report-list')
@classmethod
def getSubdir(cls):
"""Return the subdirectory where SalesOrderReport templates are located."""
return 'salesorder'
filters = models.CharField(
blank=True,
max_length=250,
verbose_name=_('Filters'),
help_text=_('Sales order query filters'),
validators=[validate_sales_order_filters],
)
def get_context_data(self, request):
"""Return custom context data for a SalesOrderReport template."""
order = self.object_to_print
return {
'customer': order.customer,
'description': order.description,
'lines': order.lines,
'extra_lines': order.extra_lines,
'order': order,
'reference': order.reference,
'title': str(order),
}
class ReturnOrderReport(ReportTemplateBase):
"""Render a custom report against a ReturnOrder object."""
@staticmethod
def get_api_url():
"""Return the API URL associated with the ReturnOrderReport model."""
return reverse('api-return-order-report-list')
@classmethod
def getSubdir(cls):
"""Return the directory where the ReturnOrderReport templates are stored."""
return 'returnorder'
filters = models.CharField(
blank=True,
max_length=250,
verbose_name=_('Filters'),
help_text=_('Return order query filters'),
validators=[validate_return_order_filters],
)
def get_context_data(self, request):
"""Return custom context data for the ReturnOrderReport template."""
order = self.object_to_print
return {
'order': order,
'description': order.description,
'reference': order.reference,
'customer': order.customer,
'lines': order.lines,
'extra_lines': order.extra_lines,
'title': str(order),
}
def rename_snippet(instance, filename):
"""Function to rename a report snippet once uploaded."""
path = ReportSnippet.snippet_path(filename)
fullpath = settings.MEDIA_ROOT.joinpath(path).resolve()
# If the snippet file is the *same* filename as the one being uploaded,
# delete the original one from the media directory
if str(filename) == str(instance.snippet):
if fullpath.exists():
logger.info("Deleting existing snippet file: '%s'", filename)
os.remove(fullpath)
# Ensure that the cache is deleted for this snippet
cache.delete(fullpath)
return path
class ReportSnippet(models.Model):
"""Report template 'snippet' which can be used to make templates that can then be included in other reports.
Useful for 'common' template actions, sub-templates, etc
"""
def __str__(self) -> str:
"""String representation of a ReportSnippet instance."""
return f'snippets/{self.filename}'
@property
def filename(self):
"""Return the filename of the asset."""
path = self.snippet.name
if path:
return os.path.basename(path)
else:
return '-'
@staticmethod
def snippet_path(filename):
"""Return the fully-qualified snippet path for the given filename."""
return os.path.join('report', 'snippets', os.path.basename(str(filename)))
def validate_unique(self, exclude=None):
"""Validate that this report asset is unique."""
proposed_path = self.snippet_path(self.snippet)
if (
ReportSnippet.objects.filter(snippet=proposed_path)
.exclude(pk=self.pk)
.count()
> 0
):
raise ValidationError({
'snippet': _('Snippet file with this name already exists')
})
return super().validate_unique(exclude)
snippet = models.FileField(
upload_to=rename_snippet,
verbose_name=_('Snippet'),
help_text=_('Report snippet file'),
validators=[FileExtensionValidator(allowed_extensions=['html', 'htm'])],
)
description = models.CharField(
max_length=250,
verbose_name=_('Description'),
help_text=_('Snippet file description'),
)
def rename_asset(instance, filename):
"""Function to rename an asset file when uploaded."""
path = ReportAsset.asset_path(filename)
fullpath = settings.MEDIA_ROOT.joinpath(path).resolve()
# If the asset file is the *same* filename as the one being uploaded,
# delete the original one from the media directory
if str(filename) == str(instance.asset):
if fullpath.exists():
# Check for existing asset file with the same name
logger.info("Deleting existing asset file: '%s'", filename)
os.remove(fullpath)
# Ensure the cache is deleted for this asset
cache.delete(fullpath)
return path
class ReportAsset(models.Model):
"""Asset file for use in report templates.
For example, an image to use in a header file.
Uploaded asset files appear in MEDIA_ROOT/report/assets,
and can be loaded in a template using the {% report_asset <filename> %} tag.
"""
def __str__(self):
"""String representation of a ReportAsset instance."""
return f'assets/{self.filename}'
@property
def filename(self):
"""Return the filename of the asset."""
path = self.asset.name
if path:
return os.path.basename(path)
else:
return '-'
@staticmethod
def asset_path(filename):
"""Return the fully-qualified asset path for the given filename."""
return os.path.join('report', 'assets', os.path.basename(str(filename)))
def validate_unique(self, exclude=None):
"""Validate that this report asset is unique."""
proposed_path = self.asset_path(self.asset)
if (
ReportAsset.objects.filter(asset=proposed_path).exclude(pk=self.pk).count()
> 0
):
raise ValidationError({
'asset': _('Asset file with this name already exists')
})
return super().validate_unique(exclude)
# Asset file
asset = models.FileField(
upload_to=rename_asset,
verbose_name=_('Asset'),
help_text=_('Report asset file'),
)
# Asset description (user facing string, not used internally)
description = models.CharField(
max_length=250,
verbose_name=_('Description'),
help_text=_('Asset file description'),
)
class StockLocationReport(ReportTemplateBase):
"""Render a StockLocationReport against a StockLocation object."""
@staticmethod
def get_api_url():
"""Return the API URL associated with the StockLocationReport model."""
return reverse('api-stocklocation-report-list')
@classmethod
def getSubdir(cls):
"""Return the subdirectory where StockLocationReport templates are located."""
return 'slr'
filters = models.CharField(
blank=True,
max_length=250,
verbose_name=_('Filters'),
help_text=_(
'stock location query filters (comma-separated list of key=value pairs)'
),
validators=[validate_stock_location_report_filters],
)
def get_context_data(self, request):
"""Return custom context data for the StockLocationReport template."""
stock_location = self.object_to_print
if not isinstance(stock_location, stock.models.StockLocation):
raise TypeError(
'Provided model is not a StockLocation object -> '
+ str(type(stock_location))
)
return {
'stock_location': stock_location,
'stock_items': stock_location.get_stock_items(),
}