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 @@ {{ part.revision }} {% endif %} + {% if part.trackable %} + + + {% trans "Next Serial Number" %} + {{ part.getNextSerialNumber }} + + {% endif %} {% trans "Description" %} diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html index 0bb3687fc8..d9f80edf44 100644 --- a/InvenTree/part/templates/part/part_base.html +++ b/InvenTree/part/templates/part/part_base.html @@ -6,11 +6,14 @@ {% block content %} +{% if part.virtual %} +
+ {% trans "This part is a virtual part" %} +
+{% endif %} {% if part.is_template %}
{% trans "This part is a template part." %} -
- {% trans "It is not a real part, but real parts can be based on this template." %}
{% endif %} {% if part.variant_of %} diff --git a/InvenTree/part/templates/part/tabs.html b/InvenTree/part/templates/part/tabs.html index 28aa2cbb4d..ecec2796d6 100644 --- a/InvenTree/part/templates/part/tabs.html +++ b/InvenTree/part/templates/part/tabs.html @@ -13,9 +13,11 @@ {% trans "Variants" %} {{ part.variants.count }} {% endif %} + {% if not part.virtual %} {% trans "Stock" %} {% decimal part.total_stock %} + {% endif %} {% if part.component or part.used_in_count > 0 %} {% trans "Allocated" %} {% decimal part.allocation_count %} diff --git a/InvenTree/part/test_bom_item.py b/InvenTree/part/test_bom_item.py index 95e1dbb2c7..91aa95c17c 100644 --- a/InvenTree/part/test_bom_item.py +++ b/InvenTree/part/test_bom_item.py @@ -44,6 +44,20 @@ class BomItemTest(TestCase): item = BomItem.objects.create(part=self.bob, sub_part=self.bob, quantity=7) item.clean() + def test_integer_quantity(self): + """ + Test integer validation for BomItem + """ + + p = Part.objects.create(name="test", description="d", component=True, trackable=True) + + # Creation of a BOMItem with a non-integer quantity of a trackable Part should fail + with self.assertRaises(django_exceptions.ValidationError): + BomItem.objects.create(part=self.bob, sub_part=p, quantity=21.7) + + # But with an integer quantity, should be fine + BomItem.objects.create(part=self.bob, sub_part=p, quantity=21) + def test_overage(self): """ Test that BOM line overages are calculated correctly """ 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..93f1f1a95f --- /dev/null +++ b/InvenTree/report/models.py @@ -0,0 +1,269 @@ +""" +Report template model definitions +""" + +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +import os +import sys + +from django.db import models +from django.conf import settings + +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 + +try: + from django_weasyprint import WeasyTemplateResponseMixin +except OSError as err: + print("OSError: {e}".format(e=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 + except OSError as err: + print("OSError: {e}".format(e=err)) + print("You may not have a working LaTeX toolchain installed?") + sys.exit(1) + + +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 + if settings.LATEX_ENABLED: + return render_to_pdf(request, self.template_name, context, filename=filename) + else: + return 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) + + 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/api.py b/InvenTree/stock/api.py index aad36cc3dc..23d836b1e1 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -80,21 +80,10 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView): def get_serializer(self, *args, **kwargs): - try: - kwargs['part_detail'] = str2bool(self.request.query_params.get('part_detail', False)) - except AttributeError: - pass - - try: - kwargs['location_detail'] = str2bool(self.request.query_params.get('location_detail', False)) - except AttributeError: - pass - - try: - kwargs['supplier_part_detail'] = str2bool(self.request.query_params.get('supplier_detail', False)) - except AttributeError: - pass - + kwargs['part_detail'] = True + kwargs['location_detail'] = True + kwargs['supplier_part_detail'] = True + kwargs['test_detail'] = True kwargs['context'] = self.get_serializer_context() return self.serializer_class(*args, **kwargs) @@ -498,8 +487,21 @@ class StockList(generics.ListCreateAPIView): if serial_number is not None: queryset = queryset.filter(serial=serial_number) + + # Filter by range of serial numbers? + serial_number_gte = params.get('serial_gte', None) + serial_number_lte = params.get('serial_lte', None) + + if serial_number_gte is not None or serial_number_lte is not None: + queryset = queryset.exclude(serial=None) + + if serial_number_gte is not None: + queryset = queryset.filter(serial__gte=serial_number_gte) - in_stock = self.request.query_params.get('in_stock', None) + if serial_number_lte is not None: + queryset = queryset.filter(serial__lte=serial_number_lte) + + in_stock = params.get('in_stock', None) if in_stock is not None: in_stock = str2bool(in_stock) @@ -512,7 +514,7 @@ class StockList(generics.ListCreateAPIView): queryset = queryset.exclude(StockItem.IN_STOCK_FILTER) # Filter by 'allocated' patrs? - allocated = self.request.query_params.get('allocated', None) + allocated = params.get('allocated', None) if allocated is not None: allocated = str2bool(allocated) @@ -531,8 +533,14 @@ class StockList(generics.ListCreateAPIView): active = str2bool(active) queryset = queryset.filter(part__active=active) + # Filter by internal part number + IPN = params.get('IPN', None) + + if IPN: + queryset = queryset.filter(part__IPN=IPN) + # Does the client wish to filter by the Part ID? - part_id = self.request.query_params.get('part', None) + part_id = params.get('part', None) if part_id: try: @@ -692,17 +700,14 @@ class StockItemTestResultList(generics.ListCreateAPIView): 'value', ] + ordering = 'date' + def get_serializer(self, *args, **kwargs): try: kwargs['user_detail'] = str2bool(self.request.query_params.get('user_detail', False)) except: pass - try: - kwargs['attachment_detail'] = str2bool(self.request.query_params.get('attachment_detail', False)) - except: - pass - kwargs['context'] = self.get_serializer_context() return self.serializer_class(*args, **kwargs) @@ -718,23 +723,6 @@ class StockItemTestResultList(generics.ListCreateAPIView): # Capture the user information test_result = serializer.save() test_result.user = self.request.user - - # Check if a file has been attached to the request - attachment_file = self.request.FILES.get('attachment', None) - - if attachment_file: - # Create a new attachment associated with the stock item - attachment = StockItemAttachment( - attachment=attachment_file, - stock_item=test_result.stock_item, - user=test_result.user - ) - - attachment.save() - - # Link the attachment back to the test result - test_result.attachment = attachment - test_result.save() diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py index 9576447997..ddd7390246 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -63,6 +63,18 @@ class EditStockLocationForm(HelperForm): ] +class ConvertStockItemForm(HelperForm): + """ + Form for converting a StockItem to a variant of its current part. + """ + + class Meta: + model = StockItem + fields = [ + 'part' + ] + + class CreateStockItemForm(HelperForm): """ Form for creating a new StockItem """ @@ -142,6 +154,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/migrations/0042_auto_20200523_0121.py b/InvenTree/stock/migrations/0042_auto_20200523_0121.py new file mode 100644 index 0000000000..66db1441e3 --- /dev/null +++ b/InvenTree/stock/migrations/0042_auto_20200523_0121.py @@ -0,0 +1,19 @@ +# Generated by Django 3.0.5 on 2020-05-23 01:21 + +from django.db import migrations, models +import stock.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0041_stockitemtestresult_notes'), + ] + + operations = [ + migrations.AlterField( + model_name='stockitemtestresult', + name='attachment', + field=models.FileField(blank=True, help_text='Test result attachment', null=True, upload_to=stock.models.rename_stock_item_test_result_attachment, verbose_name='Attachment'), + ), + ] diff --git a/InvenTree/stock/migrations/0043_auto_20200525_0420.py b/InvenTree/stock/migrations/0043_auto_20200525_0420.py new file mode 100644 index 0000000000..4acc19edf2 --- /dev/null +++ b/InvenTree/stock/migrations/0043_auto_20200525_0420.py @@ -0,0 +1,20 @@ +# Generated by Django 3.0.5 on 2020-05-25 04:20 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0042_auto_20200518_0900'), + ('stock', '0042_auto_20200523_0121'), + ] + + operations = [ + migrations.AlterField( + model_name='stockitem', + name='part', + field=models.ForeignKey(help_text='Base part', limit_choices_to={'active': True, 'virtual': False}, on_delete=django.db.models.deletion.CASCADE, related_name='stock_items', to='part.Part', verbose_name='Base Part'), + ), + ] diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index c50a58bea9..3d9394979c 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -331,7 +331,6 @@ class StockItem(MPTTModel): verbose_name=_('Base Part'), related_name='stock_items', help_text=_('Base part'), limit_choices_to={ - 'is_template': False, 'active': True, 'virtual': False }) @@ -647,6 +646,9 @@ class StockItem(MPTTModel): # Copy entire transaction history new_item.copyHistoryFrom(self) + # Copy test result history + new_item.copyTestResultsFrom(self) + # Create a new stock tracking item new_item.addTransactionNote(_('Add serial number'), user, notes=notes) @@ -655,7 +657,7 @@ class StockItem(MPTTModel): @transaction.atomic def copyHistoryFrom(self, other): - """ Copy stock history from another part """ + """ Copy stock history from another StockItem """ for item in other.tracking_info.all(): @@ -663,6 +665,17 @@ class StockItem(MPTTModel): item.pk = None item.save() + @transaction.atomic + def copyTestResultsFrom(self, other, filters={}): + """ Copy all test results from another StockItem """ + + for result in other.test_results.all().filter(**filters): + + # Create a copy of the test result by nulling-out the pk + result.pk = None + result.stock_item = self + result.save() + @transaction.atomic def splitStock(self, quantity, location, user): """ Split this stock item into two items, in the same location. @@ -713,6 +726,9 @@ class StockItem(MPTTModel): # Copy the transaction history of this part into the new one new_stock.copyHistoryFrom(self) + # Copy the test results of this part to the new one + new_stock.copyTestResultsFrom(self) + # Add a new tracking item for the new stock item new_stock.addTransactionNote( "Split from existing stock", @@ -963,6 +979,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. @@ -1000,6 +1023,10 @@ class StockItem(MPTTModel): 'failed': failed, } + @property + def required_test_count(self): + return self.part.getRequiredTests().count() + def hasRequiredTests(self): return self.part.getRequiredTests().count() > 0 @@ -1083,6 +1110,11 @@ class StockItemTracking(models.Model): # file = models.FileField() +def rename_stock_item_test_result_attachment(instance, filename): + + return os.path.join('stock_files', str(instance.stock_item.pk), os.path.basename(filename)) + + class StockItemTestResult(models.Model): """ A StockItemTestResult records results of custom tests against individual StockItem objects. @@ -1102,19 +1134,41 @@ class StockItemTestResult(models.Model): date: Date the test result was recorded """ + def save(self, *args, **kwargs): + + super().clean() + super().validate_unique() + super().save(*args, **kwargs) + def clean(self): super().clean() - # If an attachment is linked to this result, the attachment must also point to the item - try: - if self.attachment: - if not self.attachment.stock_item == self.stock_item: - raise ValidationError({ - 'attachment': _("Test result attachment must be linked to the same StockItem"), - }) - except (StockItem.DoesNotExist, StockItemAttachment.DoesNotExist): - pass + # If this test result corresponds to a template, check the requirements of the template + key = self.key + + templates = self.stock_item.part.getTestTemplates() + + for template in templates: + if key == template.key: + + if template.requires_value: + if not self.value: + raise ValidationError({ + "value": _("Value must be provided for this test"), + }) + + if template.requires_attachment: + if not self.attachment: + raise ValidationError({ + "attachment": _("Attachment must be uploaded for this test"), + }) + + break + + @property + def key(self): + return helpers.generateTestKey(self.test) stock_item = models.ForeignKey( StockItem, @@ -1140,10 +1194,9 @@ class StockItemTestResult(models.Model): help_text=_('Test output value') ) - attachment = models.ForeignKey( - StockItemAttachment, - on_delete=models.SET_NULL, - blank=True, null=True, + attachment = models.FileField( + null=True, blank=True, + upload_to=rename_stock_item_test_result_attachment, verbose_name=_('Attachment'), help_text=_('Test result attachment'), ) diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index 865a63a2c2..0e63de46f2 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -108,11 +108,14 @@ class StockItemSerializer(InvenTreeModelSerializer): quantity = serializers.FloatField() allocated = serializers.FloatField() + required_tests = serializers.IntegerField(source='required_test_count', read_only=True) + def __init__(self, *args, **kwargs): part_detail = kwargs.pop('part_detail', False) location_detail = kwargs.pop('location_detail', False) supplier_part_detail = kwargs.pop('supplier_part_detail', False) + test_detail = kwargs.pop('test_detail', False) super(StockItemSerializer, self).__init__(*args, **kwargs) @@ -125,6 +128,9 @@ class StockItemSerializer(InvenTreeModelSerializer): if supplier_part_detail is not True: self.fields.pop('supplier_part_detail') + if test_detail is not True: + self.fields.pop('required_tests') + class Meta: model = StockItem fields = [ @@ -141,6 +147,7 @@ class StockItemSerializer(InvenTreeModelSerializer): 'part_detail', 'pk', 'quantity', + 'required_tests', 'sales_order', 'serial', 'supplier_part', @@ -222,31 +229,28 @@ class StockItemTestResultSerializer(InvenTreeModelSerializer): """ Serializer for the StockItemTestResult model """ user_detail = UserSerializerBrief(source='user', read_only=True) - attachment_detail = StockItemAttachmentSerializer(source='attachment', read_only=True) + + key = serializers.CharField(read_only=True) def __init__(self, *args, **kwargs): user_detail = kwargs.pop('user_detail', False) - attachment_detail = kwargs.pop('attachment_detail', False) super().__init__(*args, **kwargs) if user_detail is not True: self.fields.pop('user_detail') - if attachment_detail is not True: - self.fields.pop('attachment_detail') - class Meta: model = StockItemTestResult fields = [ 'pk', 'stock_item', + 'key', 'test', 'result', 'value', 'attachment', - 'attachment_detail', 'notes', 'user', 'user_detail', @@ -255,7 +259,6 @@ class StockItemTestResultSerializer(InvenTreeModelSerializer): read_only_fields = [ 'pk', - 'attachment', 'user', 'date', ] diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index 782e51a2b8..bc187588d0 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -93,8 +93,18 @@ InvenTree | {% trans "Stock Item" %} - {{ item }} {% endif %} + {% if item.part.has_variants %} + + {% endif %} + {% if item.part.has_test_report_templates %} + + {% endif %} {% if item.can_delete %} + {% endif %} + {% if item.part.has_test_report_templates %} + + {% endif %}
@@ -40,6 +46,28 @@ function reloadTable() { //$("#test-result-table").bootstrapTable("refresh"); } +{% if item.part.has_test_report_templates %} +$("#test-report").click(function() { + launchModalForm( + "{% url 'stock-item-test-report-select' item.id %}", + { + follow: true, + } + ); +}); +{% endif %} + +{% if user.is_staff %} +$("#delete-test-results").click(function() { + launchModalForm( + "{% url 'stock-item-delete-test-data' item.id %}", + { + success: reloadTable, + } + ); +}); +{% endif %} + $("#add-test-result").click(function() { launchModalForm( "{% url 'stock-item-test-create' %}", { diff --git a/InvenTree/stock/templates/stock/stock_adjust.html b/InvenTree/stock/templates/stock/stock_adjust.html index 566640544c..a72407f735 100644 --- a/InvenTree/stock/templates/stock/stock_adjust.html +++ b/InvenTree/stock/templates/stock/stock_adjust.html @@ -32,7 +32,7 @@ + value='{% decimal item.new_quantity %}' type='number' name='stock-id-{{ item.id }}' id='stock-id-{{ item.id }}'/> {% if item.error %}
{{ item.error }} {% endif %} diff --git a/InvenTree/stock/templates/stock/stockitem_convert.html b/InvenTree/stock/templates/stock/stockitem_convert.html new file mode 100644 index 0000000000..55b565a60b --- /dev/null +++ b/InvenTree/stock/templates/stock/stockitem_convert.html @@ -0,0 +1,17 @@ +{% extends "modal_form.html" %} +{% load i18n %} + +{% block pre_form_content %} + +
+ {% trans "Convert Stock Item" %}
+ {% trans "This stock item is current an instance of " %}{{ item.part }}
+ {% trans "It can be converted to one of the part variants listed below." %} +
+ +
+ {% trans "Warning" %} + {% trans "This action cannot be easily undone" %} +
+ +{% endblock %} \ No newline at end of file diff --git a/InvenTree/stock/tests.py b/InvenTree/stock/tests.py index 045cbe164e..513368c422 100644 --- a/InvenTree/stock/tests.py +++ b/InvenTree/stock/tests.py @@ -458,3 +458,68 @@ class TestResultTest(StockTest): ) self.assertTrue(item.passedAllRequiredTests()) + + def test_duplicate_item_tests(self): + + # Create an example stock item by copying one from the database (because we are lazy) + item = StockItem.objects.get(pk=522) + + item.pk = None + item.serial = None + item.quantity = 50 + + item.save() + + # Do some tests! + StockItemTestResult.objects.create( + stock_item=item, + test="Firmware", + result=True + ) + + StockItemTestResult.objects.create( + stock_item=item, + test="Paint Color", + result=True, + value="Red" + ) + + StockItemTestResult.objects.create( + stock_item=item, + test="Applied Sticker", + result=False + ) + + self.assertEqual(item.test_results.count(), 3) + self.assertEqual(item.quantity, 50) + + # Split some items out + item2 = item.splitStock(20, None, None) + + self.assertEqual(item.quantity, 30) + + self.assertEqual(item.test_results.count(), 3) + self.assertEqual(item2.test_results.count(), 3) + + StockItemTestResult.objects.create( + stock_item=item2, + test='A new test' + ) + + self.assertEqual(item.test_results.count(), 3) + self.assertEqual(item2.test_results.count(), 4) + + # Test StockItem serialization + item2.serializeStock(1, [100], self.user) + + # Add a test result to the parent *after* serialization + StockItemTestResult.objects.create( + stock_item=item2, + test='abcde' + ) + + self.assertEqual(item2.test_results.count(), 5) + + item3 = StockItem.objects.get(serial=100, part=item2.part) + + self.assertEqual(item3.test_results.count(), 4) diff --git a/InvenTree/stock/urls.py b/InvenTree/stock/urls.py index ce99976f8b..7742f96fe2 100644 --- a/InvenTree/stock/urls.py +++ b/InvenTree/stock/urls.py @@ -18,12 +18,16 @@ stock_location_detail_urls = [ stock_item_detail_urls = [ url(r'^edit/', views.StockItemEdit.as_view(), name='stock-item-edit'), + url(r'^convert/', views.StockItemConvert.as_view(), name='stock-item-convert'), url(r'^serialize/', views.StockItemSerialize.as_view(), name='stock-item-serialize'), url(r'^delete/', views.StockItemDelete.as_view(), name='stock-item-delete'), url(r'^qr_code/', views.StockItemQRCode.as_view(), name='stock-item-qr'), + url(r'^delete_test_data/', views.StockItemDeleteTestData.as_view(), name='stock-item-delete-test-data'), url(r'^add_tracking/', views.StockItemTrackingCreate.as_view(), name='stock-tracking-create'), + url(r'^test-report-select/', views.StockItemTestReportSelect.as_view(), name='stock-item-test-report-select'), + url(r'^test/', views.StockItemDetail.as_view(template_name='stock/item_tests.html'), name='stock-item-test-results'), url(r'^children/', views.StockItemDetail.as_view(template_name='stock/item_childs.html'), name='stock-item-children'), url(r'^attachments/', views.StockItemDetail.as_view(template_name='stock/item_attachments.html'), name='stock-item-attachments'), @@ -52,6 +56,8 @@ stock_urls = [ url(r'^item/new/?', views.StockItemCreate.as_view(), name='stock-item-create'), + url(r'^item/test-report-download/', views.StockItemTestReportDownload.as_view(), name='stock-item-test-report-download'), + # URLs for StockItem attachments url(r'^item/attachment/', include([ url(r'^new/', views.StockItemAttachmentCreate.as_view(), name='stock-item-attachment-create'), diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index 29a57f5926..8d3e5ebbf1 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -17,6 +17,7 @@ from django.utils.translation import ugettext as _ from InvenTree.views import AjaxView from InvenTree.views import AjaxUpdateView, AjaxDeleteView, AjaxCreateView from InvenTree.views import QRCodeView +from InvenTree.forms import ConfirmForm from InvenTree.helpers import str2bool, DownloadFile, GetExportFormats from InvenTree.helpers import ExtractSerialNumbers @@ -26,19 +27,12 @@ from datetime import datetime from company.models import Company, SupplierPart from part.models import Part +from report.models import TestReport from .models import StockItem, StockLocation, StockItemTracking, StockItemAttachment, StockItemTestResult from .admin import StockItemResource -from .forms import EditStockLocationForm -from .forms import CreateStockItemForm -from .forms import EditStockItemForm -from .forms import AdjustStockForm -from .forms import TrackingEntryForm -from .forms import SerializeStockForm -from .forms import ExportOptionsForm -from .forms import EditStockItemAttachmentForm -from .forms import EditStockItemTestResultForm +from . import forms as StockForms class StockIndex(ListView): @@ -113,7 +107,7 @@ class StockLocationEdit(AjaxUpdateView): """ model = StockLocation - form_class = EditStockLocationForm + form_class = StockForms.EditStockLocationForm context_object_name = 'location' ajax_template_name = 'modal_form.html' ajax_form_title = _('Edit Stock Location') @@ -157,7 +151,7 @@ class StockItemAttachmentCreate(AjaxCreateView): """ model = StockItemAttachment - form_class = EditStockItemAttachmentForm + form_class = StockForms.EditStockItemAttachmentForm ajax_form_title = _("Add Stock Item Attachment") ajax_template_name = "modal_form.html" @@ -202,7 +196,7 @@ class StockItemAttachmentEdit(AjaxUpdateView): """ model = StockItemAttachment - form_class = EditStockItemAttachmentForm + form_class = StockForms.EditStockItemAttachmentForm ajax_form_title = _("Edit Stock Item Attachment") def get_form(self): @@ -229,13 +223,48 @@ class StockItemAttachmentDelete(AjaxDeleteView): } +class StockItemDeleteTestData(AjaxUpdateView): + """ + View for deleting all test data + """ + + model = StockItem + form_class = ConfirmForm + ajax_form_title = _("Delete All Test Data") + + def get_form(self): + return ConfirmForm() + + def post(self, request, *args, **kwargs): + + valid = False + + stock_item = StockItem.objects.get(pk=self.kwargs['pk']) + form = self.get_form() + + confirm = str2bool(request.POST.get('confirm', False)) + + if confirm is not True: + form.errors['confirm'] = [_('Confirm test data deletion')] + form.non_field_errors = [_('Check the confirmation box')] + else: + stock_item.test_results.all().delete() + valid = True + + data = { + 'form_valid': valid, + } + + return self.renderJsonResponse(request, form, data) + + class StockItemTestResultCreate(AjaxCreateView): """ View for adding a new StockItemTestResult """ model = StockItemTestResult - form_class = EditStockItemTestResultForm + form_class = StockForms.EditStockItemTestResultForm ajax_form_title = _("Add Test Result") def post_save(self, **kwargs): @@ -263,17 +292,6 @@ class StockItemTestResultCreate(AjaxCreateView): form = super().get_form() form.fields['stock_item'].widget = HiddenInput() - # Extract the StockItem object - item_id = form['stock_item'].value() - - # Limit the options for the file attachments - try: - stock_item = StockItem.objects.get(pk=item_id) - form.fields['attachment'].queryset = stock_item.attachments.all() - except (ValueError, StockItem.DoesNotExist): - # Hide the attachments field - form.fields['attachment'].widget = HiddenInput() - return form @@ -283,7 +301,7 @@ class StockItemTestResultEdit(AjaxUpdateView): """ model = StockItemTestResult - form_class = EditStockItemTestResultForm + form_class = StockForms.EditStockItemTestResultForm ajax_form_title = _("Edit Test Result") def get_form(self): @@ -291,8 +309,6 @@ class StockItemTestResultEdit(AjaxUpdateView): form = super().get_form() form.fields['stock_item'].widget = HiddenInput() - - form.fields['attachment'].queryset = self.object.stock_item.attachments.all() return form @@ -307,12 +323,81 @@ class StockItemTestResultDelete(AjaxDeleteView): context_object_name = "result" +class StockItemTestReportSelect(AjaxView): + """ + View for selecting a TestReport template, + and generating a TestReport as a PDF. + """ + + model = StockItem + ajax_form_title = _("Select Test Report Template") + + def get_form(self): + + stock_item = StockItem.objects.get(pk=self.kwargs['pk']) + return StockForms.TestReportFormatForm(stock_item) + + def post(self, request, *args, **kwargs): + + template_id = request.POST.get('template', None) + + try: + template = TestReport.objects.get(pk=template_id) + except (ValueError, TestReport.DoesNoteExist): + raise ValidationError({'template': _("Select valid template")}) + + stock_item = StockItem.objects.get(pk=self.kwargs['pk']) + + url = reverse('stock-item-test-report-download') + + url += '?stock_item={id}'.format(id=stock_item.pk) + url += '&template={id}'.format(id=template.pk) + + data = { + 'form_valid': True, + 'url': url, + } + + return self.renderJsonResponse(request, self.get_form(), data=data) + + +class StockItemTestReportDownload(AjaxView): + """ + Download a TestReport against a StockItem. + + Requires the following arguments to be passed as URL params: + + stock_item - Valid PK of a StockItem object + template - Valid PK of a TestReport template object + + """ + + def get(self, request, *args, **kwargs): + + template = request.GET.get('template', None) + stock_item = request.GET.get('stock_item', None) + + try: + template = TestReport.objects.get(pk=template) + except (ValueError, TestReport.DoesNotExist): + raise ValidationError({'template': 'Invalid template ID'}) + + try: + stock_item = StockItem.objects.get(pk=stock_item) + except (ValueError, StockItem.DoesNotExist): + raise ValidationError({'stock_item': 'Invalid StockItem ID'}) + + template.stock_item = stock_item + + return template.render(request) + + class StockExportOptions(AjaxView): """ Form for selecting StockExport options """ model = StockLocation ajax_form_title = _('Stock Export Options') - form_class = ExportOptionsForm + form_class = StockForms.ExportOptionsForm def post(self, request, *args, **kwargs): @@ -455,7 +540,7 @@ class StockAdjust(AjaxView, FormMixin): ajax_template_name = 'stock/stock_adjust.html' ajax_form_title = _('Adjust Stock') - form_class = AdjustStockForm + form_class = StockForms.AdjustStockForm stock_items = [] def get_GET_items(self): @@ -773,7 +858,7 @@ class StockItemEdit(AjaxUpdateView): """ model = StockItem - form_class = EditStockItemForm + form_class = StockForms.EditStockItemForm context_object_name = 'item' ajax_template_name = 'modal_form.html' ajax_form_title = _('Edit Stock Item') @@ -802,6 +887,30 @@ class StockItemEdit(AjaxUpdateView): return form +class StockItemConvert(AjaxUpdateView): + """ + View for 'converting' a StockItem to a variant of its current part. + """ + + model = StockItem + form_class = StockForms.ConvertStockItemForm + ajax_form_title = _('Convert Stock Item') + ajax_template_name = 'stock/stockitem_convert.html' + context_object_name = 'item' + + def get_form(self): + """ + Filter the available parts. + """ + + form = super().get_form() + item = self.get_object() + + form.fields['part'].queryset = item.part.get_all_variants() + + return form + + class StockLocationCreate(AjaxCreateView): """ View for creating a new StockLocation @@ -809,7 +918,7 @@ class StockLocationCreate(AjaxCreateView): """ model = StockLocation - form_class = EditStockLocationForm + form_class = StockForms.EditStockLocationForm context_object_name = 'location' ajax_template_name = 'modal_form.html' ajax_form_title = _('Create new Stock Location') @@ -834,7 +943,7 @@ class StockItemSerialize(AjaxUpdateView): model = StockItem ajax_template_name = 'stock/item_serialize.html' ajax_form_title = _('Serialize Stock') - form_class = SerializeStockForm + form_class = StockForms.SerializeStockForm def get_form(self): @@ -843,7 +952,7 @@ class StockItemSerialize(AjaxUpdateView): # Pass the StockItem object through to the form context['item'] = self.get_object() - form = SerializeStockForm(**context) + form = StockForms.SerializeStockForm(**context) return form @@ -922,11 +1031,41 @@ class StockItemCreate(AjaxCreateView): """ model = StockItem - form_class = CreateStockItemForm + form_class = StockForms.CreateStockItemForm context_object_name = 'item' ajax_template_name = 'modal_form.html' ajax_form_title = _('Create new Stock Item') + def get_part(self, form=None): + """ + Attempt to get the "part" associted with this new stockitem. + + - May be passed to the form as a query parameter (e.g. ?part=) + - May be passed via the form field itself. + """ + + # Try to extract from the URL query + part_id = self.request.GET.get('part', None) + + if part_id: + try: + part = Part.objects.get(pk=part_id) + return part + except (Part.DoesNotExist, ValueError): + pass + + # Try to get from the form + if form: + try: + part_id = form['part'].value() + part = Part.objects.get(pk=part_id) + return part + except (Part.DoesNotExist, ValueError): + pass + + # Could not extract a part object + return None + def get_form(self): """ Get form for StockItem creation. Overrides the default get_form() method to intelligently limit @@ -935,53 +1074,44 @@ class StockItemCreate(AjaxCreateView): form = super().get_form() - part = None + part = self.get_part(form=form) - # If the user has selected a Part, limit choices for SupplierPart - if form['part'].value(): - part_id = form['part'].value() + if part is not None: + sn = part.getNextSerialNumber() + form.field_placeholder['serial_numbers'] = _('Next available serial number is') + ' ' + str(sn) - try: - part = Part.objects.get(id=part_id) - - sn = part.getNextSerialNumber() - form.field_placeholder['serial_numbers'] = _('Next available serial number is') + ' ' + str(sn) + form.rebuild_layout() - form.rebuild_layout() + # Hide the 'part' field (as a valid part is selected) + form.fields['part'].widget = HiddenInput() - # Hide the 'part' field (as a valid part is selected) - form.fields['part'].widget = HiddenInput() + # trackable parts get special consideration + if part.trackable: + form.fields['delete_on_deplete'].widget = HiddenInput() + form.fields['delete_on_deplete'].initial = False + else: + form.fields.pop('serial_numbers') - # trackable parts get special consideration - if part.trackable: - form.fields['delete_on_deplete'].widget = HiddenInput() - form.fields['delete_on_deplete'].initial = False - else: - form.fields.pop('serial_numbers') + # If the part is NOT purchaseable, hide the supplier_part field + if not part.purchaseable: + form.fields['supplier_part'].widget = HiddenInput() + else: + # Pre-select the allowable SupplierPart options + parts = form.fields['supplier_part'].queryset + parts = parts.filter(part=part.id) - # If the part is NOT purchaseable, hide the supplier_part field - if not part.purchaseable: - form.fields['supplier_part'].widget = HiddenInput() - else: - # Pre-select the allowable SupplierPart options - parts = form.fields['supplier_part'].queryset - parts = parts.filter(part=part.id) + form.fields['supplier_part'].queryset = parts - form.fields['supplier_part'].queryset = parts + # If there is one (and only one) supplier part available, pre-select it + all_parts = parts.all() - # If there is one (and only one) supplier part available, pre-select it - all_parts = parts.all() + if len(all_parts) == 1: - if len(all_parts) == 1: - - # TODO - This does NOT work for some reason? Ref build.views.BuildItemCreate - form.fields['supplier_part'].initial = all_parts[0].id - - except Part.DoesNotExist: - pass + # TODO - This does NOT work for some reason? Ref build.views.BuildItemCreate + form.fields['supplier_part'].initial = all_parts[0].id # Otherwise if the user has selected a SupplierPart, we know what Part they meant! - elif form['supplier_part'].value() is not None: + if form['supplier_part'].value() is not None: pass return form @@ -1004,27 +1134,20 @@ class StockItemCreate(AjaxCreateView): else: initials = super(StockItemCreate, self).get_initial().copy() - part_id = self.request.GET.get('part', None) + part = self.get_part() + loc_id = self.request.GET.get('location', None) sup_part_id = self.request.GET.get('supplier_part', None) - part = None location = None supplier_part = None - # Part field has been specified - if part_id: - try: - part = Part.objects.get(pk=part_id) - - # Check that the supplied part is 'valid' - if not part.is_template and part.active and not part.virtual: - initials['part'] = part - initials['location'] = part.get_default_location() - initials['supplier_part'] = part.default_supplier - - except (ValueError, Part.DoesNotExist): - pass + if part is not None: + # Check that the supplied part is 'valid' + if not part.is_template and part.active and not part.virtual: + initials['part'] = part + initials['location'] = part.get_default_location() + initials['supplier_part'] = part.default_supplier # SupplierPart field has been specified # It must match the Part, if that has been supplied @@ -1229,7 +1352,7 @@ class StockItemTrackingEdit(AjaxUpdateView): model = StockItemTracking ajax_form_title = _('Edit Stock Tracking Entry') - form_class = TrackingEntryForm + form_class = StockForms.TrackingEntryForm class StockItemTrackingCreate(AjaxCreateView): @@ -1238,7 +1361,7 @@ class StockItemTrackingCreate(AjaxCreateView): model = StockItemTracking ajax_form_title = _("Add Stock Tracking Entry") - form_class = TrackingEntryForm + form_class = StockForms.TrackingEntryForm def post(self, request, *args, **kwargs): diff --git a/InvenTree/templates/js/part.html b/InvenTree/templates/js/part.html index 528fc9aa93..1a40c7ad84 100644 --- a/InvenTree/templates/js/part.html +++ b/InvenTree/templates/js/part.html @@ -286,6 +286,14 @@ function loadPartTable(table, url, options={}) { }); } +function yesNoLabel(value) { + if (value) { + return `{% trans "YES" %}`; + } else { + return `{% trans "NO" %}`; + } +} + function loadPartTestTemplateTable(table, options) { /* @@ -332,16 +340,30 @@ function loadPartTestTemplateTable(table, options) { title: "{% trans "Test Name" %}", sortable: true, }, + { + field: 'description', + title: "{% trans "Description" %}", + }, { field: 'required', title: "{% trans 'Required' %}", sortable: true, formatter: function(value) { - if (value) { - return `{% trans "YES" %}`; - } else { - return `{% trans "NO" %}`; - } + return yesNoLabel(value); + } + }, + { + field: 'requires_value', + title: "{% trans "Requires Value" %}", + formatter: function(value) { + return yesNoLabel(value); + } + }, + { + field: 'requires_attachment', + title: "{% trans "Requires Attachment" %}", + formatter: function(value) { + return yesNoLabel(value); } }, { diff --git a/InvenTree/templates/js/stock.html b/InvenTree/templates/js/stock.html index 49c3151ff0..c0737dc250 100644 --- a/InvenTree/templates/js/stock.html +++ b/InvenTree/templates/js/stock.html @@ -32,17 +32,6 @@ function noResultBadge() { return `{% trans "NO RESULT" %}`; } -function testKey(test_name) { - // Convert test name to a unique key without any illegal chars - - test_name = test_name.trim().toLowerCase(); - test_name = test_name.replace(' ', ''); - - test_name = test_name.replace(/[^0-9a-z]/gi, ''); - - return test_name; -} - function loadStockTestResultsTable(table, options) { /* * Load StockItemTestResult table @@ -56,8 +45,8 @@ function loadStockTestResultsTable(table, options) { html += `${row.user_detail.username}`; } - if (row.attachment_detail) { - html += ``; + if (row.attachment) { + html += ``; } return html; @@ -177,14 +166,14 @@ function loadStockTestResultsTable(table, options) { var match = false; var override = false; - var key = testKey(item.test); + var key = item.key; // Try to associate this result with a test row tableData.forEach(function(row, index) { // The result matches the test template row - if (key == testKey(row.test_name)) { + if (key == row.key) { // Force the names to be the same! item.test_name = row.test_name; @@ -348,12 +337,21 @@ function loadStockTable(table, options) { } else { return '-'; } - } else if (field == 'location__path') { + } else if (field == 'location_detail.pathstring') { /* Determine how many locations */ var locations = []; data.forEach(function(item) { - var loc = item.location; + + var loc = null; + + if (item.location_detail) { + loc = item.location_detail.pathstring; + } else { + loc = "{% trans "Undefined location" %}"; + } + + console.log("Location: " + loc); if (!locations.includes(loc)) { locations.push(loc); @@ -364,7 +362,11 @@ function loadStockTable(table, options) { return "In " + locations.length + " locations"; } else { // A single location! - return renderLink(row.location__path, '/stock/location/' + row.location + '/') + if (row.location_detail) { + return renderLink(row.location_detail.pathstring, `/stock/location/${row.location}/`); + } else { + return "{% trans "Undefined location" %}"; + } } } else if (field == 'notes') { var notes = []; diff --git a/InvenTree/templates/js/table_filters.html b/InvenTree/templates/js/table_filters.html index 84b517e8e7..298a034517 100644 --- a/InvenTree/templates/js/table_filters.html +++ b/InvenTree/templates/js/table_filters.html @@ -34,6 +34,14 @@ function getAvailableTableFilters(tableKey) { title: '{% trans "Is allocated" %}', description: '{% trans "Item has been alloacted" %}', }, + serial_gte: { + title: "{% trans "Serial number GTE" %}", + description: "{% trans "Serial number greater than or equal to" %}" + }, + serial_lte: { + title: "{% trans "Serial number LTE" %}", + description: "{% trans "Serial number less than or equal to" %}", + }, }; } diff --git a/Makefile b/Makefile index 630784becf..556813a149 100644 --- a/Makefile +++ b/Makefile @@ -51,12 +51,12 @@ style: # Run unit tests test: cd InvenTree && python3 manage.py check - cd InvenTree && python3 manage.py test build common company order part stock + cd InvenTree && python3 manage.py test build common company order part report stock InvenTree # Run code coverage coverage: cd InvenTree && python3 manage.py check - coverage run InvenTree/manage.py test build common company order part stock InvenTree + coverage run InvenTree/manage.py test build common company order part report stock InvenTree coverage html # Install packages required to generate code docs diff --git a/README.md b/README.md index d4a3dfc869..a66e3c10ff 100644 --- a/README.md +++ b/README.md @@ -33,3 +33,9 @@ For code documentation, refer to the [developer documentation](http://inventree. ## Contributing Contributions are welcomed and encouraged. Please help to make this project even better! Refer to the [contribution page](https://inventree.github.io/pages/contribute). + +## Donate + +If you use InvenTree and find it to be useful, please consider making a donation toward its continued development. + +[Donate via PayPal](https://paypal.me/inventree?locale.x=en_AU) diff --git a/requirements.txt b/requirements.txt index 9d6ad3e3af..33f70b4bcf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,4 +19,6 @@ flake8==3.3.0 # PEP checking coverage==4.0.3 # Unit test coverage python-coveralls==2.9.1 # Coveralls linking (for Travis) rapidfuzz==0.7.6 # Fuzzy string matching -django-stdimage==5.1.1 # Advanced ImageField management \ No newline at end of file +django-stdimage==5.1.1 # Advanced ImageField management +django-tex==1.1.7 # LaTeX PDF export +django-weasyprint==1.0.1 # HTML PDF export \ No newline at end of file