diff --git a/InvenTree/InvenTree/forms.py b/InvenTree/InvenTree/forms.py
index ad4b810e32..1e70b525c6 100644
--- a/InvenTree/InvenTree/forms.py
+++ b/InvenTree/InvenTree/forms.py
@@ -5,6 +5,7 @@ Helper forms which subclass Django forms to provide additional functionality
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
+from django.utils.translation import ugettext as _
from django import forms
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Field
@@ -92,6 +93,20 @@ class HelperForm(forms.ModelForm):
self.helper.layout = Layout(*layouts)
+class ConfirmForm(forms.Form):
+ """ Generic confirmation form """
+
+ confirm = forms.BooleanField(
+ required=False, initial=False,
+ help_text=_("Confirm")
+ )
+
+ class Meta:
+ fields = [
+ 'confirm'
+ ]
+
+
class DeleteForm(forms.Form):
""" Generic deletion form which provides simple user confirmation
"""
@@ -99,7 +114,7 @@ class DeleteForm(forms.Form):
confirm_delete = forms.BooleanField(
required=False,
initial=False,
- help_text='Confirm item deletion'
+ help_text=_('Confirm item deletion')
)
class Meta:
@@ -131,14 +146,14 @@ class SetPasswordForm(HelperForm):
required=True,
initial='',
widget=forms.PasswordInput(attrs={'autocomplete': 'off'}),
- help_text='Enter new password')
+ help_text=_('Enter new password'))
confirm_password = forms.CharField(max_length=100,
min_length=8,
required=True,
initial='',
widget=forms.PasswordInput(attrs={'autocomplete': 'off'}),
- help_text='Confirm new password')
+ help_text=_('Confirm new password'))
class Meta:
model = User
diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index dda110e834..e5b14314b5 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,22 @@ 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/
+# Is LaTeX rendering enabled? (Off by default)
+LATEX_ENABLED = LATEX_SETTINGS.get('enabled', False)
-# Web URL endpoint for served static files
-STATIC_URL = '/static/'
+# Set the latex interpreter in the config.yaml settings file
+LATEX_INTERPRETER = LATEX_SETTINGS.get('interpreter', 'pdflatex')
-# The filesystem location for served static files
-STATIC_ROOT = os.path.abspath(CONFIG.get('static_root', os.path.join(BASE_DIR, 'static')))
+LATEX_INTERPRETER_OPTIONS = LATEX_SETTINGS.get('options', '')
-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/static/script/inventree/filters.js b/InvenTree/InvenTree/static/script/inventree/filters.js
index 8c9ebbec6d..3209ba3beb 100644
--- a/InvenTree/InvenTree/static/script/inventree/filters.js
+++ b/InvenTree/InvenTree/static/script/inventree/filters.js
@@ -272,8 +272,9 @@ function setupFilterList(tableKey, table, target) {
for (var key in filters) {
var value = getFilterOptionValue(tableKey, key, filters[key]);
var title = getFilterTitle(tableKey, key);
+ var description = getFilterDescription(tableKey, key);
- element.append(`
${title} = ${value}x
`);
+ element.append(`
${title} = ${value}x
`);
}
// Add a callback for adding a new filter
@@ -362,6 +363,15 @@ function getFilterTitle(tableKey, filterKey) {
}
+/**
+ * Return the pretty description for the given table and filter selection
+ */
+function getFilterDescription(tableKey, filterKey) {
+ var settings = getFilterSettings(tableKey, filterKey);
+
+ return settings.title;
+}
+
/*
* Return a description for the given table and filter selection.
*/
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..5447606337 100644
--- a/InvenTree/config_template.yaml
+++ b/InvenTree/config_template.yaml
@@ -73,3 +73,16 @@ 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/
+# 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: ''
\ No newline at end of file
diff --git a/InvenTree/part/forms.py b/InvenTree/part/forms.py
index a276d62c54..c86027118c 100644
--- a/InvenTree/part/forms.py
+++ b/InvenTree/part/forms.py
@@ -39,7 +39,10 @@ class EditPartTestTemplateForm(HelperForm):
fields = [
'part',
'test_name',
- 'required'
+ 'description',
+ 'required',
+ 'requires_value',
+ 'requires_attachment',
]
diff --git a/InvenTree/part/migrations/0042_auto_20200518_0900.py b/InvenTree/part/migrations/0042_auto_20200518_0900.py
new file mode 100644
index 0000000000..30b1734472
--- /dev/null
+++ b/InvenTree/part/migrations/0042_auto_20200518_0900.py
@@ -0,0 +1,33 @@
+# Generated by Django 3.0.5 on 2020-05-18 09:00
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('part', '0041_auto_20200517_0348'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='parttesttemplate',
+ name='description',
+ field=models.CharField(help_text='Enter description for this test', max_length=100, null=True, verbose_name='Test Description'),
+ ),
+ migrations.AddField(
+ model_name='parttesttemplate',
+ name='requires_attachment',
+ field=models.BooleanField(default=False, help_text='Does this test require a file attachment when adding a test result?', verbose_name='Requires Attachment'),
+ ),
+ migrations.AddField(
+ model_name='parttesttemplate',
+ name='requires_value',
+ field=models.BooleanField(default=False, help_text='Does this test require a value when adding a test result?', verbose_name='Requires Value'),
+ ),
+ migrations.AlterField(
+ model_name='parttesttemplate',
+ name='test_name',
+ field=models.CharField(help_text='Enter a name for the test', max_length=100, verbose_name='Test Name'),
+ ),
+ ]
diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py
index d37d666be4..1a8048a985 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})
@@ -1014,6 +1033,9 @@ class Part(MPTTModel):
# Return the tests which are required by this part
return self.getTestTemplates(required=True)
+ def requiredTestCount(self):
+ return self.getRequiredTests().count()
+
@property
def attachment_count(self):
""" Count the number of attachments for this part.
@@ -1087,6 +1109,17 @@ class Part(MPTTModel):
return self.parameters.order_by('template__name')
+ @property
+ def has_variants(self):
+ """ Check if this Part object has variants underneath it. """
+
+ return self.get_all_variants().count() > 0
+
+ def get_all_variants(self):
+ """ Return all Part object which exist as a variant under this part. """
+
+ return self.get_descendants(include_self=False)
+
def attach_file(instance, filename):
""" Function for storing a file for a PartAttachment
@@ -1204,16 +1237,34 @@ class PartTestTemplate(models.Model):
test_name = models.CharField(
blank=False, max_length=100,
- verbose_name=_("Test name"),
+ verbose_name=_("Test Name"),
help_text=_("Enter a name for the test")
)
+ description = models.CharField(
+ blank=False, null=True, max_length=100,
+ verbose_name=_("Test Description"),
+ help_text=_("Enter description for this test")
+ )
+
required = models.BooleanField(
default=True,
verbose_name=_("Required"),
help_text=_("Is this test required to pass?")
)
+ requires_value = models.BooleanField(
+ default=False,
+ verbose_name=_("Requires Value"),
+ help_text=_("Does this test require a value when adding a test result?")
+ )
+
+ requires_attachment = models.BooleanField(
+ default=False,
+ verbose_name=_("Requires Attachment"),
+ help_text=_("Does this test require a file attachment when adding a test result?")
+ )
+
class PartParameterTemplate(models.Model):
"""
@@ -1299,6 +1350,11 @@ class BomItem(models.Model):
checksum: Validation checksum for the particular BOM line item
"""
+ def save(self, *args, **kwargs):
+
+ self.clean()
+ super().save(*args, **kwargs)
+
def get_absolute_url(self):
return reverse('bom-item-detail', kwargs={'pk': self.id})
@@ -1391,6 +1447,16 @@ class BomItem(models.Model):
- A part cannot refer to a part which refers to it
"""
+ # If the sub_part is 'trackable' then the 'quantity' field must be an integer
+ try:
+ if self.sub_part.trackable:
+ if not self.quantity == int(self.quantity):
+ raise ValidationError({
+ "quantity": _("Quantity must be integer value for trackable parts")
+ })
+ except Part.DoesNotExist:
+ pass
+
# A part cannot refer to itself in its BOM
try:
if self.sub_part is not None and self.part is not None:
diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py
index 396f19ea58..2cb893b304 100644
--- a/InvenTree/part/serializers.py
+++ b/InvenTree/part/serializers.py
@@ -62,14 +62,20 @@ class PartTestTemplateSerializer(InvenTreeModelSerializer):
Serializer for the PartTestTemplate class
"""
+ key = serializers.CharField(read_only=True)
+
class Meta:
model = PartTestTemplate
fields = [
'pk',
+ 'key',
'part',
'test_name',
- 'required'
+ 'description',
+ 'required',
+ 'requires_value',
+ 'requires_attachment',
]
@@ -99,9 +105,11 @@ class PartBriefSerializer(InvenTreeModelSerializer):
'thumbnail',
'active',
'assembly',
+ 'is_template',
'purchaseable',
'salable',
'stock',
+ 'trackable',
'virtual',
]
diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html
index e9e09959fb..1e7d36e127 100644
--- a/InvenTree/part/templates/part/detail.html
+++ b/InvenTree/part/templates/part/detail.html
@@ -33,6 +33,13 @@