Merge pull request #1270 from SchrodingersGat/report-templates

Adds "report snippet" class allowing re-usable report snippets
This commit is contained in:
Oliver 2021-02-04 22:14:38 +11:00 committed by GitHub
commit 4d91a34136
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 559 additions and 118 deletions

View File

@ -208,7 +208,6 @@ INSTALLED_APPS = [
'mptt', # Modified Preorder Tree Traversal
'markdownx', # Markdown editing
'markdownify', # Markdown template rendering
'django_tex', # LaTeX output
'django_admin_shell', # Python shell for the admin interface
'djmoney', # django-money integration
'djmoney.contrib.exchange', # django-money exchange rates
@ -265,14 +264,6 @@ TEMPLATES = [
],
},
},
# Backend for LaTeX report rendering
{
'NAME': 'tex',
'BACKEND': 'django_tex.engine.TeXEngine',
'DIRS': [
os.path.join(MEDIA_ROOT, 'report'),
]
},
]
REST_FRAMEWORK = {
@ -485,22 +476,6 @@ DATE_INPUT_FORMATS = [
"%Y-%m-%d",
]
# LaTeX rendering settings (django-tex)
LATEX_SETTINGS = CONFIG.get('latex', {})
# Is LaTeX rendering enabled? (Off by default)
LATEX_ENABLED = LATEX_SETTINGS.get('enabled', False)
# Set the latex interpreter in the config.yaml settings file
LATEX_INTERPRETER = LATEX_SETTINGS.get('interpreter', 'pdflatex')
LATEX_INTERPRETER_OPTIONS = LATEX_SETTINGS.get('options', '')
LATEX_GRAPHICSPATH = [
# Allow LaTeX files to access the report assets directory
os.path.join(MEDIA_ROOT, "report", "assets"),
]
# crispy forms use the bootstrap templates
CRISPY_TEMPLATE_PACK = 'bootstrap3'

View File

@ -79,6 +79,7 @@ settings_urls = [
url(r'^theme/?', ColorThemeSelectView.as_view(), name='settings-theme'),
url(r'^global/?', SettingsView.as_view(template_name='InvenTree/settings/global.html'), name='settings-global'),
url(r'^report/?', SettingsView.as_view(template_name='InvenTree/settings/report.html'), name='settings-report'),
url(r'^category/?', SettingCategorySelectView.as_view(), name='settings-category'),
url(r'^part/?', SettingsView.as_view(template_name='InvenTree/settings/part.html'), name='settings-part'),
url(r'^stock/?', SettingsView.as_view(template_name='InvenTree/settings/stock.html'), name='settings-stock'),

View File

@ -174,6 +174,13 @@ class InvenTreeSetting(models.Model):
'validator': bool,
},
'REPORT_ENABLE_TEST_REPORT': {
'name': _('Test Reports'),
'description': _('Enable generation of test reports'),
'default': True,
'validator': bool,
},
'STOCK_ENABLE_EXPIRY': {
'name': _('Stock Expiry'),
'description': _('Enable stock expiry functionality'),

View File

@ -107,19 +107,6 @@ static_root: '../inventree_static'
# If unspecified, the local user's temp directory will be used
#backup_dir: '/home/inventree/backup/'
# LaTeX report rendering
# InvenTree uses the django-tex plugin to enable LaTeX report rendering
# Ref: https://pypi.org/project/django-tex/
# Note: Ensure that a working LaTeX toolchain is installed and working *before* starting the server
latex:
# Select the LaTeX interpreter to use for PDF rendering
# Note: The intepreter needs to be installed on the system!
# e.g. to install pdflatex: apt-get texlive-latex-base
enabled: False
interpreter: pdflatex
# Extra options to pass through to the LaTeX interpreter
options: ''
# Permit custom authentication backends
#authentication_backends:
# - 'django.contrib.auth.backends.ModelBackend'

View File

@ -44,7 +44,6 @@
<span id='part-star-icon' class='fas fa-star {% if starred %}icon-yellow{% endif %}'/>
</button>
{% settings_value 'BARCODE_ENABLE' as barcodes %}
{% if barcodes %}
<!-- Barcode actions menu -->
<div class='btn-group'>

View File

@ -3,7 +3,7 @@ from __future__ import unicode_literals
from django.contrib import admin
from .models import TestReport, ReportAsset
from .models import ReportSnippet, TestReport, ReportAsset
class ReportTemplateAdmin(admin.ModelAdmin):
@ -11,10 +11,16 @@ class ReportTemplateAdmin(admin.ModelAdmin):
list_display = ('name', 'description', 'template', 'filters', 'enabled')
class ReportSnippetAdmin(admin.ModelAdmin):
list_display = ('id', 'snippet', 'description')
class ReportAssetAdmin(admin.ModelAdmin):
list_display = ('asset', 'description')
list_display = ('id', 'asset', 'description')
admin.site.register(ReportSnippet, ReportSnippetAdmin)
admin.site.register(TestReport, ReportTemplateAdmin)
admin.site.register(ReportAsset, ReportAssetAdmin)

View File

@ -1,5 +1,92 @@
import os
import shutil
import logging
from django.apps import AppConfig
from django.conf import settings
logger = logging.getLogger(__name__)
class ReportConfig(AppConfig):
name = 'report'
def ready(self):
"""
This function is called whenever the report app is loaded
"""
self.create_default_test_reports()
def create_default_test_reports(self):
"""
Create database entries for the default TestReport templates,
if they do not already exist
"""
try:
from .models import TestReport
except:
# Database is not ready yet
return
src_dir = os.path.join(
os.path.dirname(os.path.realpath(__file__)),
'templates',
'report',
)
dst_dir = os.path.join(
settings.MEDIA_ROOT,
'report',
'inventree', # Stored in secret directory!
'test',
)
if not os.path.exists(dst_dir):
logger.info(f"Creating missing directory: '{dst_dir}'")
os.makedirs(dst_dir, exist_ok=True)
# List of test reports to copy across
reports = [
{
'file': 'inventree_test_report.html',
'name': 'InvenTree Test Report',
'description': 'Stock item test report',
},
]
for report in reports:
# Create destination file name
filename = os.path.join(
'report',
'inventree',
'test',
report['file']
)
src_file = os.path.join(src_dir, report['file'])
dst_file = os.path.join(settings.MEDIA_ROOT, filename)
if not os.path.exists(dst_file):
logger.info(f"Copying test report template '{dst_file}'")
shutil.copyfile(src_file, dst_file)
try:
# Check if a report matching the template already exists
if TestReport.objects.filter(template=filename).exists():
continue
logger.info(f"Creating new TestReport for '{report['name']}'")
TestReport.objects.create(
name=report['name'],
description=report['description'],
template=filename,
filters='',
enabled=True
)
except:
pass

View File

@ -0,0 +1,23 @@
# Generated by Django 3.0.7 on 2021-02-04 04:37
import django.core.validators
from django.db import migrations, models
import report.models
class Migration(migrations.Migration):
dependencies = [
('report', '0005_auto_20210119_0815'),
]
operations = [
migrations.CreateModel(
name='ReportSnippet',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('snippet', models.FileField(help_text='Report snippet file', upload_to=report.models.rename_snippet, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])])),
('description', models.CharField(help_text='Snippet file description', max_length=250)),
],
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 3.0.7 on 2021-02-04 05:17
import django.core.validators
from django.db import migrations, models
import report.models
class Migration(migrations.Migration):
dependencies = [
('report', '0006_reportsnippet'),
]
operations = [
migrations.AlterField(
model_name='testreport',
name='template',
field=models.FileField(help_text='Report template file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Template'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.0.7 on 2021-02-04 10:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('report', '0007_auto_20210204_1617'),
]
operations = [
migrations.AlterField(
model_name='testreport',
name='name',
field=models.CharField(help_text='Template name', max_length=100, verbose_name='Name'),
),
]

View File

@ -14,7 +14,6 @@ from django.db import models
from django.conf import settings
from django.core.validators import FileExtensionValidator
from django.core.exceptions import ValidationError
import stock.models
@ -29,32 +28,10 @@ except OSError as err:
print("You may require some further system packages to be installed.")
sys.exit(1)
# Conditional import if LaTeX templating is enabled
if settings.LATEX_ENABLED:
try:
from django_tex.shortcuts import render_to_pdf
from django_tex.core import render_template_with_context
from django_tex.exceptions import TexError
except OSError as err:
print("OSError: {e}".format(e=err))
print("You may not have a working LaTeX toolchain installed?")
sys.exit(1)
from django.http import HttpResponse
class TexResponse(HttpResponse):
def __init__(self, content, filename=None):
super().__init__(content_type="application/txt")
self["Content-Disposition"] = 'filename="{}"'.format(filename)
self.write(content)
def rename_template(instance, filename):
filename = os.path.basename(filename)
return os.path.join('report', 'report_template', instance.getSubdir(), filename)
return instance.rename_file(filename)
def validate_stock_item_report_filters(filters):
@ -77,17 +54,27 @@ class WeasyprintReportMixin(WeasyTemplateResponseMixin):
self.pdf_filename = kwargs.get('filename', 'report.pdf')
class ReportTemplateBase(models.Model):
class ReportBase(models.Model):
"""
Reporting template model.
Base class for uploading html templates
"""
class Meta:
abstract = True
def __str__(self):
return "{n} - {d}".format(n=self.name, d=self.description)
def getSubdir(self):
return ''
def rename_file(self, filename):
# Function for renaming uploaded file
filename = os.path.basename(filename)
return os.path.join('report', 'report_template', self.getSubdir(), filename)
@property
def extension(self):
return os.path.splitext(self.template.name)[1].lower()
@ -96,15 +83,45 @@ class ReportTemplateBase(models.Model):
def template_name(self):
"""
Returns the file system path to the template file.
Required for passing the file to an external process (e.g. LaTeX)
Required for passing the file to an external process
"""
template = os.path.join('report_template', self.getSubdir(), os.path.basename(self.template.name))
template = self.template.name
template = template.replace('/', os.path.sep)
template = template.replace('\\', os.path.sep)
template = os.path.join(settings.MEDIA_ROOT, 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")
)
class ReportTemplateBase(ReportBase):
"""
Reporting template model.
Able to be passed context data
"""
def get_context_data(self, request):
"""
Supply context data to the template for rendering
@ -116,56 +133,34 @@ class ReportTemplateBase(models.Model):
"""
Render the template to a PDF file.
Supported template formats:
.tex - Uses django-tex plugin to render LaTeX template against an installed LaTeX engine
.html - Uses django-weasyprint plugin to render HTML template against Weasyprint
Uses django-weasyprint plugin to render HTML template against Weasyprint
"""
filename = kwargs.get('filename', 'report.pdf')
# TODO: Support custom filename generation!
# filename = kwargs.get('filename', 'report.pdf')
context = self.get_context_data(request)
context['media'] = settings.MEDIA_ROOT
context['report_name'] = self.name
context['report_description'] = self.description
context['request'] = request
context['user'] = request.user
context['date'] = datetime.datetime.now().date()
context['datetime'] = datetime.datetime.now()
if self.extension == '.tex':
# Render LaTeX template to PDF
if settings.LATEX_ENABLED:
# Attempt to render to LaTeX template
# If there is a rendering error, return the (partially rendered) template,
# so at least we can debug what is going on
try:
rendered = render_template_with_context(self.template_name, context)
return render_to_pdf(request, self.template_name, context, filename=filename)
except TexError:
return TexResponse(rendered, filename="error.tex")
else:
raise ValidationError("Enable LaTeX support in config.yaml")
elif self.extension in ['.htm', '.html']:
# Render HTML template to PDF
wp = WeasyprintReportMixin(request, self.template_name, **kwargs)
return wp.render_to_response(context, **kwargs)
# Render HTML template to PDF
wp = WeasyprintReportMixin(
request,
self.template_name,
base_url=request.build_absolute_uri("/"),
presentational_hints=True,
**kwargs)
name = models.CharField(
blank=False, max_length=100,
verbose_name=_('Name'),
help_text=_('Template name'),
unique=True,
)
template = models.FileField(
upload_to=rename_template,
verbose_name=_('Template'),
help_text=_("Report template file"),
validators=[FileExtensionValidator(allowed_extensions=['html', 'htm', 'tex'])],
)
description = models.CharField(
max_length=250,
verbose_name=_('Description'),
help_text=_("Report template description")
)
return wp.render_to_response(
context,
**kwargs)
enabled = models.BooleanField(
default=True,
@ -221,6 +216,30 @@ class TestReport(ReportTemplateBase):
}
def rename_snippet(instance, filename):
filename = os.path.basename(filename)
return os.path.join('report', 'snippets', filename)
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
"""
snippet = models.FileField(
upload_to=rename_snippet,
help_text=_('Report snippet file'),
validators=[FileExtensionValidator(allowed_extensions=['html', 'htm'])],
)
description = models.CharField(max_length=250, help_text=_("Snippet file description"))
def rename_asset(instance, filename):
filename = os.path.basename(filename)

View File

@ -0,0 +1,103 @@
{% load report %}
<head>
<style>
@page {
{% block page_size %}
size: A4;
{% endblock %}
{% block page_margin %}
margin: 2cm;
{% endblock %}
font-family: Arial, Helvetica, sans-serif;
font-size: 75%;
@top-left {
{% block top_left %}
{% endblock %}
}
@top-center {
{% block top_center %}
{% endblock %}
}
@top-right {
{% block top_right %}
{% endblock %}
}
@bottom-left {
{% block bottom_left %}
{% endblock %}
}
@bottom-center {
{% block bottom_center %}
{% endblock %}
}
@bottom-right {
{% block bottom_right %}
content: "Page " counter(page) " of " counter(pages);
{% endblock %}
}
}
body {
font-family: Arial, Helvetica, sans-serif;
}
.header {
{% block header_style %}
top: 0px;
left: 0px;
position: fixed;
width: 100%;
margin-top: -2.5cm;
{% endblock %}
}
.content {
{% block content_style %}
width: 100%;
page-break-inside: auto;
position: relative;
{% endblock %}
}
.footer {
{% block footer_style %}
bottom: 0px;
left: 0px;
position: fixed;
width: 100%;
margin-bottom: -20mm;
{% endblock %}
}
{% block style %}
/* User defined styles go here */
{% endblock %}
</style>
</head>
<body>
<div class='header'>
{% block header_content %}
{% endblock %}
</div>
<div class='content'>
{% block page_content %}
{% endblock %}
</div>
<div class='footer'>
{% block footer_content %}
{% endblock %}
</div>
</body>

View File

@ -0,0 +1,112 @@
{% extends "report/inventree_report_base.html" %}
{% load i18n %}
{% load report %}
{% load inventree_extras %}
{% block style %}
.test-table {
width: 100%;
}
{% block bottom_left %}
content: "{{ date.isoformat }}";
{% endblock %}
{% block bottom_center %}
content: "InvenTree v{% inventree_version %}";
{% endblock %}
{% block top_center %}
content: "{% trans 'Stock Item Test Report' %}";
{% endblock %}
.test-row {
padding: 3px;
}
.test-pass {
color: #5f5;
}
.test-fail {
color: #F55;
}
.container {
padding: 5px;
border: 1px solid;
}
.text-left {
display: inline-block;
width: 50%;
}
.img-right {
display: inline;
align-content: right;
align-items: right;
width: 50%;
}
{% endblock %}
{% block page_content %}
<div class='container'>
<div class='text-left'>
<h2>
{{ part.full_name }}
</h2>
<p>{{ part.description }}</p>
<p><i>{{ stock_item.location }}</i></p>
<p><i>Stock Item ID: {{ stock_item.pk }}</i></p>
</div>
<div class='img-right'>
<img src="{% part_image part %}">
<hr>
<h4>
{% if stock_item.is_serialized %}
{% trans "Serial Number" %}: {{ stock_item.serial }}
{% else %}
{% trans "Quantity" %}: {% decimal stock_item.quantity %}
{% endif %}
</h4>
</div>
</div>
<h3>{% trans "Test Results" %}</h3>
<table class='table test-table'>
<thead>
<tr>
<th>{% trans "Test" %}</th>
<th>{% trans "Result" %}</th>
<th>{% trans "Value" %}</th>
<th>{% trans "User" %}</th>
<th>{% trans "Date" %}</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan='5'><hr></td>
</tr>
{% for test in result_list %}
<tr class='test-row'>
<td>{{ test.test }}</td>
{% if test.result %}
<td class='test-pass'>{% trans "Pass" %}</td>
{% else %}
<td class='test-fail'>{% trans "Fail" %}</td>
{% endif %}
<td>{{ test.value }}</td>
<td>{{ test.user.username }}</td>
<td>{{ test.date.date.isoformat }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View File

@ -0,0 +1,52 @@
"""
Custom template tags for report generation
"""
import os
from django import template
from django.conf import settings
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.
"""
path = os.path.join(settings.MEDIA_ROOT, 'report', 'assets', filename)
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 type(part) is Part:
img = part.image.name
elif type(part) is StockItem:
img = part.part.image.name
else:
img = ''
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}"

View File

@ -120,7 +120,7 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
</div>
<div class='btn-group action-buttons' role='group'>
{% settings_value 'BARCODE_ENABLE' as barcodes %}
{% if barcodes %}
<!-- Barcode actions menu -->
<div class='btn-group'>
@ -139,19 +139,15 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
</div>
{% endif %}
<!-- Document / label menu -->
{% if item.has_labels or item.has_test_reports %}
<div class='btn-group'>
<button id='document-options' title='{% trans "Printing actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'><span class='fas fa-print'></span> <span class='caret'></span></button>
<ul class='dropdown-menu' role='menu'>
{% if item.has_labels %}
<li><a href='#' id='print-label'><span class='fas fa-tag'></span> {% trans "Print Label" %}</a></li>
{% endif %}
{% if item.has_test_reports %}
{% if test_report_enabled %}
<li><a href='#' id='stock-test-report'><span class='fas fa-file-pdf'></span> {% trans "Test Report" %}</a></li>
{% endif %}
</ul>
</div>
{% endif %}
<!-- Stock adjustment menu -->
<!-- Check permissions and owner -->
{% if owner_control.value == "False" or owner_control.value == "True" and user in owners or user.is_superuser %}

View File

@ -36,8 +36,7 @@
<span class='fas fa-plus-circle icon-green'/>
</button>
{% endif %}
{% endif %}
{% settings_value 'BARCODE_ENABLE' as barcodes %}
{% endif %}
{% if barcodes %}
<!-- Barcode actions menu -->
{% if location %}

View File

@ -0,0 +1,22 @@
{% extends "InvenTree/settings/settings.html" %}
{% load i18n %}
{% load inventree_extras %}
{% block tabs %}
{% include "InvenTree/settings/tabs.html" with tab='report' %}
{% endblock %}
{% block subtitle %}
{% trans "Report Settings" %}
{% endblock %}
{% block settings %}
<table class='table table-striped table-condensed'>
{% include "InvenTree/settings/header.html" %}
<tbody>
{% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE_TEST_REPORT" %}
</tbody>
</table>
{% endblock %}

View File

@ -15,6 +15,9 @@
<li {% if tab == 'global' %} class='active' {% endif %}>
<a href='{% url "settings-global" %}'><span class='fas fa-globe'></span> {% trans "Global" %}</a>
</li>
<li {% if tab == 'report' %} class='active' {% endif %}>
<a href='{% url "settings-report" %}'><span class='fas fa-file-pdf'></span> {% trans "Report" %}</a>
</li>
<li{% ifequal tab 'category' %} class='active'{% endifequal %}>
<a href="{% url 'settings-category' %}"><span class='fa fa-sitemap'></span> {% trans "Categories" %}</a>
</li>

View File

@ -3,6 +3,7 @@
{% load inventree_extras %}
{% settings_value 'BARCODE_ENABLE' as barcodes %}
{% settings_value 'REPORT_ENABLE_TEST_REPORT' as test_report_enabled %}
<!DOCTYPE html>
<html lang="en">

View File

@ -10,6 +10,15 @@ function selectTestReport(reports, items, options={}) {
* (via AJAX) from the server.
*/
// If there is only a single report available, just print!
if (reports.length == 1) {
if (options.success) {
options.success(reports[0].pk);
}
return;
}
var modal = options.modal || '#modal-form';
var report_list = makeOptionsList(

View File

@ -38,7 +38,9 @@
</button>
<ul class='dropdown-menu'>
<li><a href='#' id='multi-item-print-label' title='{% trans "Print labels" %}'><span class='fas fa-tags'></span> {% trans "Print labels" %}</a></li>
{% if test_report_enabled %}
<li><a href='#' id='multi-item-print-test-report' title='{% trans "Print test reports" %}'><span class='fas fa-file-pdf'></span> {% trans "Print test reports" %}</a></li>
{% endif %}
</ul>
</div>
{% if roles.stock.change or roles.stock.delete %}

View File

@ -118,6 +118,7 @@ class RuleSet(models.Model):
'label_stockitemlabel',
'label_stocklocationlabel',
'report_reportasset',
'report_reportsnippet',
'report_testreport',
'part_partstar',
'users_owner',

View File

@ -23,7 +23,6 @@ coverage==5.3 # Unit test coverage
coveralls==2.1.2 # Coveralls linking (for Travis)
rapidfuzz==0.7.6 # Fuzzy string matching
django-stdimage==5.1.1 # Advanced ImageField management
django-tex==1.1.7 # LaTeX PDF export
django-weasyprint==1.0.1 # HTML PDF export
django-debug-toolbar==2.2 # Debug / profiling toolbar
django-admin-shell==0.1.2 # Python shell for the admin interface