diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index dda110e834..8e4feade23 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -72,6 +72,27 @@ if DEBUG: format='%(asctime)s %(levelname)s %(message)s', ) +# Web URL endpoint for served static files +STATIC_URL = '/static/' + +# The filesystem location for served static files +STATIC_ROOT = os.path.abspath(CONFIG.get('static_root', os.path.join(BASE_DIR, 'static'))) + +STATICFILES_DIRS = [ + os.path.join(BASE_DIR, 'InvenTree', 'static'), +] + +# Web URL endpoint for served media files +MEDIA_URL = '/media/' + +# The filesystem location for served static files +MEDIA_ROOT = os.path.abspath(CONFIG.get('media_root', os.path.join(BASE_DIR, 'media'))) + +if DEBUG: + print("InvenTree running in DEBUG mode") + print("MEDIA_ROOT:", MEDIA_ROOT) + print("STATIC_ROOT:", STATIC_ROOT) + # Does the user wish to use the sentry.io integration? sentry_opts = CONFIG.get('sentry', {}) @@ -106,12 +127,13 @@ INSTALLED_APPS = [ 'django.contrib.staticfiles', # InvenTree apps - 'common.apps.CommonConfig', - 'part.apps.PartConfig', - 'stock.apps.StockConfig', - 'company.apps.CompanyConfig', 'build.apps.BuildConfig', + 'common.apps.CommonConfig', + 'company.apps.CompanyConfig', 'order.apps.OrderConfig', + 'part.apps.PartConfig', + 'report.apps.ReportConfig', + 'stock.apps.StockConfig', # Third part add-ons 'django_filters', # Extended filter functionality @@ -126,6 +148,7 @@ INSTALLED_APPS = [ 'mptt', # Modified Preorder Tree Traversal 'markdownx', # Markdown editing 'markdownify', # Markdown template rendering + 'django_tex', # LaTeX output ] LOGGING = { @@ -160,7 +183,11 @@ ROOT_URLCONF = 'InvenTree.urls' TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [os.path.join(BASE_DIR, 'templates')], + 'DIRS': [ + os.path.join(BASE_DIR, 'templates'), + # Allow templates in the reporting directory to be accessed + os.path.join(MEDIA_ROOT, 'report'), + ], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ @@ -173,6 +200,14 @@ TEMPLATES = [ ], }, }, + # Backend for LaTeX report rendering + { + 'NAME': 'tex', + 'BACKEND': 'django_tex.engine.TeXEngine', + 'DIRS': [ + os.path.join(MEDIA_ROOT, 'report'), + ] + }, ] REST_FRAMEWORK = { @@ -315,31 +350,19 @@ DATE_INPUT_FORMATS = [ "%Y-%m-%d", ] +# LaTeX rendering settings (django-tex) +latex_settings = CONFIG.get('latex', {}) -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/1.10/howto/static-files/ +# Set the latex interpreter in the config.yaml settings file +LATEX_INTERPRETER = latex_settings.get('interpreter', 'pdflatex') -# Web URL endpoint for served static files -STATIC_URL = '/static/' +LATEX_INTERPRETER_OPTIONS = latex_settings.get('options', '') -# The filesystem location for served static files -STATIC_ROOT = os.path.abspath(CONFIG.get('static_root', os.path.join(BASE_DIR, 'static'))) - -STATICFILES_DIRS = [ - os.path.join(BASE_DIR, 'InvenTree', 'static'), +LATEX_GRAPHICSPATH = [ + # Allow LaTeX files to access the report assets directory + os.path.join(MEDIA_ROOT, "report", "assets"), ] -# Web URL endpoint for served media files -MEDIA_URL = '/media/' - -# The filesystem location for served static files -MEDIA_ROOT = os.path.abspath(CONFIG.get('media_root', os.path.join(BASE_DIR, 'media'))) - -if DEBUG: - print("InvenTree running in DEBUG mode") - print("MEDIA_ROOT:", MEDIA_ROOT) - print("STATIC_ROOT:", STATIC_ROOT) - # crispy forms use the bootstrap templates CRISPY_TEMPLATE_PACK = 'bootstrap3' diff --git a/InvenTree/InvenTree/views.py b/InvenTree/InvenTree/views.py index c988859042..34cc5a1d7b 100644 --- a/InvenTree/InvenTree/views.py +++ b/InvenTree/InvenTree/views.py @@ -166,6 +166,13 @@ class AjaxMixin(object): except AttributeError: context = {} + # If no 'form' argument is supplied, look at the underlying class + if form is None: + try: + form = self.get_form() + except AttributeError: + pass + if form: context['form'] = form else: diff --git a/InvenTree/config_template.yaml b/InvenTree/config_template.yaml index 64c5db0a06..5cb0b1073c 100644 --- a/InvenTree/config_template.yaml +++ b/InvenTree/config_template.yaml @@ -73,3 +73,14 @@ log_queries: False sentry: enabled: False # dsn: add-your-sentry-dsn-here + +# LaTeX report rendering +# InvenTree uses the django-tex plugin to enable LaTeX report rendering +# Ref: https://pypi.org/project/django-tex/ +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 + interpreter: pdflatex + # Extra options to pass through to the LaTeX interpreter + options: '' \ No newline at end of file diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 4ca77739ac..735a6de574 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -41,6 +41,7 @@ from InvenTree.helpers import decimal2string, normalize from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus +from report import models as ReportModels from build import models as BuildModels from order import models as OrderModels from company.models import SupplierPart @@ -358,6 +359,24 @@ class Part(MPTTModel): self.category = category self.save() + def get_test_report_templates(self): + """ + Return all the TestReport template objects which map to this Part. + """ + + templates = [] + + for report in ReportModels.TestReport.objects.all(): + if report.matches_part(self): + templates.append(report) + + return templates + + def has_test_report_templates(self): + """ Return True if this part has a TestReport defined """ + + return len(self.get_test_report_templates()) > 0 + def get_absolute_url(self): """ Return the web URL for viewing this part """ return reverse('part-detail', kwargs={'pk': self.id}) diff --git a/InvenTree/report/__init__.py b/InvenTree/report/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/InvenTree/report/admin.py b/InvenTree/report/admin.py new file mode 100644 index 0000000000..4183e8ee83 --- /dev/null +++ b/InvenTree/report/admin.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.contrib import admin + +from .models import ReportTemplate, ReportAsset +from .models import TestReport + + +class ReportTemplateAdmin(admin.ModelAdmin): + + list_display = ('name', 'description', 'template') + + +class ReportAssetAdmin(admin.ModelAdmin): + + list_display = ('asset', 'description') + + +admin.site.register(ReportTemplate, ReportTemplateAdmin) +admin.site.register(TestReport, ReportTemplateAdmin) +admin.site.register(ReportAsset, ReportAssetAdmin) diff --git a/InvenTree/report/apps.py b/InvenTree/report/apps.py new file mode 100644 index 0000000000..138ba20404 --- /dev/null +++ b/InvenTree/report/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ReportConfig(AppConfig): + name = 'report' diff --git a/InvenTree/report/migrations/0001_initial.py b/InvenTree/report/migrations/0001_initial.py new file mode 100644 index 0000000000..8b5c2af09f --- /dev/null +++ b/InvenTree/report/migrations/0001_initial.py @@ -0,0 +1,49 @@ +# Generated by Django 3.0.5 on 2020-05-22 11:00 + +import django.core.validators +from django.db import migrations, models +import report.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='ReportAsset', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('asset', models.FileField(help_text='Report asset file', upload_to=report.models.rename_asset)), + ('description', models.CharField(help_text='Asset file description', max_length=250)), + ], + ), + migrations.CreateModel( + name='ReportTemplate', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='Template name', max_length=100, unique=True)), + ('template', models.FileField(help_text='Report template file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm', 'tex'])])), + ('description', models.CharField(help_text='Report template description', max_length=250)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='TestReport', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='Template name', max_length=100, unique=True)), + ('template', models.FileField(help_text='Report template file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm', 'tex'])])), + ('description', models.CharField(help_text='Report template description', max_length=250)), + ('part_filters', models.CharField(blank=True, help_text='Part query filters (comma-separated list of key=value pairs)', max_length=250, validators=[report.models.validateFilterString])), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/InvenTree/report/migrations/__init__.py b/InvenTree/report/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/InvenTree/report/models.py b/InvenTree/report/models.py new file mode 100644 index 0000000000..2ab12ad258 --- /dev/null +++ b/InvenTree/report/models.py @@ -0,0 +1,250 @@ +""" +Report template model definitions +""" + +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +import os + +from django.db import models +from django.core.validators import FileExtensionValidator +from django.core.exceptions import ValidationError + +from django.utils.translation import gettext_lazy as _ + +from part import models as PartModels + +from django_tex.shortcuts import render_to_pdf +from django_weasyprint import WeasyTemplateResponseMixin + + +def rename_template(instance, filename): + + filename = os.path.basename(filename) + + return os.path.join('report', 'report_template', instance.getSubdir(), filename) + + +def validateFilterString(value): + """ + Validate that a provided filter string looks like a list of comma-separated key=value pairs + + These should nominally match to a valid database filter based on the model being filtered. + + e.g. "category=6, IPN=12" + e.g. "part__name=widget" + + The ReportTemplate class uses the filter string to work out which items a given report applies to. + For example, an acceptance test report template might only apply to stock items with a given IPN, + so the string could be set to: + + filters = "IPN = ACME0001" + + Returns a map of key:value pairs + """ + + # Empty results map + results = {} + + value = str(value).strip() + + if not value or len(value) == 0: + return results + + groups = value.split(',') + + for group in groups: + group = group.strip() + + pair = group.split('=') + + if not len(pair) == 2: + raise ValidationError( + "Invalid group: {g}".format(g=group) + ) + + k, v = pair + + k = k.strip() + v = v.strip() + + if not k or not v: + raise ValidationError( + "Invalid group: {g}".format(g=group) + ) + + results[k] = v + + return results + + +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): + + self.request = request + self.template_name = template + self.pdf_filename = kwargs.get('filename', 'report.pdf') + + +class ReportTemplateBase(models.Model): + """ + Reporting template model. + """ + + def __str__(self): + return "{n} - {d}".format(n=self.name, d=self.description) + + def getSubdir(self): + return '' + + @property + def extension(self): + return os.path.splitext(self.template.name)[1].lower() + + @property + def template_name(self): + return os.path.join('report_template', self.getSubdir(), os.path.basename(self.template.name)) + + def get_context_data(self, request): + """ + Supply context data to the template for rendering + """ + + return {} + + def render(self, request, **kwargs): + """ + 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 + """ + + filename = kwargs.get('filename', 'report.pdf') + + context = self.get_context_data(request) + + context['request'] = request + + if self.extension == '.tex': + # Render LaTeX template to PDF + return render_to_pdf(request, self.template_name, context, filename=filename) + 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) + + name = models.CharField( + blank=False, max_length=100, + help_text=_('Template name'), + unique=True, + ) + + template = models.FileField( + upload_to=rename_template, + help_text=_("Report template file"), + validators=[FileExtensionValidator(allowed_extensions=['html', 'htm', 'tex'])], + ) + + description = models.CharField(max_length=250, help_text=_("Report template description")) + + class Meta: + abstract = True + + +class ReportTemplate(ReportTemplateBase): + """ + A simple reporting template which is used to upload template files, + which can then be used in other concrete template classes. + """ + + pass + + +class PartFilterMixin(models.Model): + """ + A model mixin used for matching a report type against a Part object. + Used to assign a report to a given part using custom filters. + """ + + class Meta: + abstract = True + + def matches_part(self, part): + """ + Test if this report matches a given part. + """ + + filters = self.get_part_filters() + + parts = PartModels.Part.objects.filter(**filters) + + parts = parts.filter(pk=part.pk) + + return parts.exists() + + def get_part_filters(self): + """ Return a map of filters to be used for Part filtering """ + return validateFilterString(self.part_filters) + + part_filters = models.CharField( + blank=True, + max_length=250, + help_text=_("Part query filters (comma-separated list of key=value pairs)"), + validators=[validateFilterString] + ) + + +class TestReport(ReportTemplateBase, PartFilterMixin): + """ + Render a TestReport against a StockItem object. + """ + + def getSubdir(self): + return 'test' + + # Requires a stock_item object to be given to it before rendering + stock_item = None + + def get_context_data(self, request): + return { + 'stock_item': self.stock_item, + 'part': self.stock_item.part, + 'results': self.stock_item.testResultMap(), + 'result_list': self.stock_item.testResultList() + } + + +def rename_asset(instance, filename): + + filename = os.path.basename(filename) + + return os.path.join('report', 'assets', filename) + + +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 %} tag. + """ + + def __str__(self): + return os.path.basename(self.asset.name) + + asset = models.FileField( + upload_to=rename_asset, + help_text=_("Report asset file"), + ) + + description = models.CharField(max_length=250, help_text=_("Asset file description")) diff --git a/InvenTree/report/tests.py b/InvenTree/report/tests.py new file mode 100644 index 0000000000..a2b5079b33 --- /dev/null +++ b/InvenTree/report/tests.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals diff --git a/InvenTree/report/views.py b/InvenTree/report/views.py new file mode 100644 index 0000000000..a2b5079b33 --- /dev/null +++ b/InvenTree/report/views.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py index 9576447997..edf3d28df8 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -142,6 +142,34 @@ class SerializeStockForm(HelperForm): ] +class TestReportFormatForm(HelperForm): + """ Form for selection a test report template """ + + class Meta: + model = StockItem + fields = [ + 'template', + ] + + def __init__(self, stock_item, *args, **kwargs): + self.stock_item = stock_item + + super().__init__(*args, **kwargs) + self.fields['template'].choices = self.get_template_choices() + + def get_template_choices(self): + """ Available choices """ + + choices = [] + + for report in self.stock_item.part.get_test_report_templates(): + choices.append((report.pk, report)) + + return choices + + template = forms.ChoiceField(label=_('Template'), help_text=_('Select test report template')) + + class ExportOptionsForm(HelperForm): """ Form for selecting stock export options """ diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 512a156b08..f34344383a 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -963,6 +963,13 @@ class StockItem(MPTTModel): return result_map + def testResultList(self, **kwargs): + """ + Return a list of test-result objects for this StockItem + """ + + return self.testResultMap(**kwargs).values() + def requiredTestStatus(self): """ Return the status of the tests required for this StockItem. diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index 782e51a2b8..819c138988 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -93,8 +93,13 @@ InvenTree | {% trans "Stock Item" %} - {{ item }} {% endif %} + {% if item.part.has_test_report_templates %} + + {% endif %} {% if item.can_delete %}