From 18b7baa5b9834d0881d43fc96c7dee459a9a85c0 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 18 May 2020 13:32:50 +1000 Subject: [PATCH 01/54] Allow stock item filtering by IPN (cherry picked from commit bd9aad935594e4022299567667faeb149f6d2375) --- InvenTree/stock/api.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index aad36cc3dc..93cf9067f3 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -499,7 +499,7 @@ class StockList(generics.ListCreateAPIView): if serial_number is not None: queryset = queryset.filter(serial=serial_number) - in_stock = self.request.query_params.get('in_stock', None) + in_stock = params.get('in_stock', None) if in_stock is not None: in_stock = str2bool(in_stock) @@ -512,7 +512,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 +531,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: From fce8e3fe0538883a94c1fa0597b7a9541b46d9e2 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 18 May 2020 14:29:35 +1000 Subject: [PATCH 02/54] add button to delete all test data for a given stock item --- InvenTree/InvenTree/forms.py | 21 +++++++++-- .../stock/templates/stock/item_tests.html | 14 +++++++ InvenTree/stock/urls.py | 1 + InvenTree/stock/views.py | 37 +++++++++++++++++++ 4 files changed, 70 insertions(+), 3 deletions(-) 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/stock/templates/stock/item_tests.html b/InvenTree/stock/templates/stock/item_tests.html index 6a69d55eb9..b311f13584 100644 --- a/InvenTree/stock/templates/stock/item_tests.html +++ b/InvenTree/stock/templates/stock/item_tests.html @@ -14,6 +14,9 @@
+ {% if user.is_staff %} + + {% endif %}
@@ -40,6 +43,17 @@ function reloadTable() { //$("#test-result-table").bootstrapTable("refresh"); } +{% 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/urls.py b/InvenTree/stock/urls.py index ce99976f8b..b415381061 100644 --- a/InvenTree/stock/urls.py +++ b/InvenTree/stock/urls.py @@ -21,6 +21,7 @@ stock_item_detail_urls = [ 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'), diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index 29a57f5926..57bff223d5 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 @@ -229,6 +230,42 @@ 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 From 0bdb62f2630b36660509ccaed66bc0b5835e63e7 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 18 May 2020 14:32:30 +1000 Subject: [PATCH 03/54] Fix pep --- InvenTree/stock/views.py | 1 - 1 file changed, 1 deletion(-) diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index 57bff223d5..f83cfe8f96 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -244,7 +244,6 @@ class StockItemDeleteTestData(AjaxUpdateView): def post(self, request, *args, **kwargs): - valid = False stock_item = StockItem.objects.get(pk=self.kwargs['pk']) From e8c402ecd9db6b8a6afef234e70c156b11c40f70 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 18 May 2020 19:00:45 +1000 Subject: [PATCH 04/54] Add some more fields to the PartTestTemplate model --- .../migrations/0042_auto_20200518_0900.py | 33 +++++++++++++++++++ InvenTree/part/models.py | 20 ++++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 InvenTree/part/migrations/0042_auto_20200518_0900.py 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..4ca77739ac 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -1204,16 +1204,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): """ From fc6cad475adc63e974301e96e0090711549c6587 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 18 May 2020 19:11:43 +1000 Subject: [PATCH 05/54] Add validation for StockItemTestResult based on the matching PartTestTemplate --- InvenTree/part/forms.py | 5 ++++- InvenTree/stock/models.py | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) 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/stock/models.py b/InvenTree/stock/models.py index c50a58bea9..bb3621a7ac 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -1115,6 +1115,28 @@ class StockItemTestResult(models.Model): }) except (StockItem.DoesNotExist, StockItemAttachment.DoesNotExist): pass + + # If this test result corresponds to a template, check the requirements of the template + key = helpers.generateTestKey(self.test) + + 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 stock_item = models.ForeignKey( StockItem, From 2f6d03388d50eb3455d2e66185b3d8ecd9f36c8a Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 18 May 2020 19:15:40 +1000 Subject: [PATCH 06/54] Add serializer / table display --- InvenTree/part/serializers.py | 8 +++++++- InvenTree/templates/js/part.html | 32 +++++++++++++++++++++++++++----- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 396f19ea58..64cf656f36 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', ] 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); } }, { From bf296057b3bf9ba397e93f535f8317f87b5db61b Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 19 May 2020 16:56:41 +1000 Subject: [PATCH 07/54] Enable attachments to be uploaded via the API --- InvenTree/stock/models.py | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index bb3621a7ac..e4a9e9ae52 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -1102,21 +1102,17 @@ 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 + # If this test result corresponds to a template, check the requirements of the template key = helpers.generateTestKey(self.test) templates = self.stock_item.part.getTestTemplates() @@ -1130,14 +1126,27 @@ class StockItemTestResult(models.Model): "value": _("Value must be provided for this test"), }) + """ + TODO: Re-introduce this at a later stage, it is buggy when uplaoding an attachment via the API if template.requires_attachment: if not self.attachment: raise ValidationError({ "attachment": _("Attachment must be uploaded for this test"), }) - + """ + break + # 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 + stock_item = models.ForeignKey( StockItem, on_delete=models.CASCADE, From 1cfe4458977e0f71a55187f4606f0fcce53e0231 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 19 May 2020 16:59:21 +1000 Subject: [PATCH 08/54] PEP fix --- InvenTree/stock/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index e4a9e9ae52..9caf9f568b 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -1112,7 +1112,7 @@ class StockItemTestResult(models.Model): super().clean() - # If this test result corresponds to a template, check the requirements of the template + # If this test result corresponds to a template, check the requirements of the template key = helpers.generateTestKey(self.test) templates = self.stock_item.part.getTestTemplates() @@ -1134,7 +1134,7 @@ class StockItemTestResult(models.Model): "attachment": _("Attachment must be uploaded for this test"), }) """ - + break # If an attachment is linked to this result, the attachment must also point to the item From 9cb039f68584b91b77523b65fe079e100f66ac4c Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 19 May 2020 17:08:19 +1000 Subject: [PATCH 09/54] Remove a test (for now) which is causing issues... --- InvenTree/stock/models.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 9caf9f568b..8dfd25760f 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -1117,6 +1117,8 @@ class StockItemTestResult(models.Model): templates = self.stock_item.part.getTestTemplates() + """ + TODO: Re-introduce this at a later stage, it is buggy when uplaoding an attachment via the API for template in templates: if key == template.key: @@ -1126,16 +1128,14 @@ class StockItemTestResult(models.Model): "value": _("Value must be provided for this test"), }) - """ - TODO: Re-introduce this at a later stage, it is buggy when uplaoding an attachment via the API if template.requires_attachment: if not self.attachment: raise ValidationError({ "attachment": _("Attachment must be uploaded for this test"), }) - """ break + """ # If an attachment is linked to this result, the attachment must also point to the item try: From b121262af12e884e10f2631d56075c6c3b3787a8 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 19 May 2020 17:37:00 +1000 Subject: [PATCH 10/54] pep FIX --- InvenTree/stock/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 8dfd25760f..512a156b08 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -1112,12 +1112,12 @@ class StockItemTestResult(models.Model): super().clean() + """ # If this test result corresponds to a template, check the requirements of the template key = helpers.generateTestKey(self.test) templates = self.stock_item.part.getTestTemplates() - """ TODO: Re-introduce this at a later stage, it is buggy when uplaoding an attachment via the API for template in templates: if key == template.key: From 5018f899f76e12a1969507d75ebfdc7f64e89742 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 20 May 2020 10:45:43 +1000 Subject: [PATCH 11/54] Update README.md --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) 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) From 3d8c059a43a2ef2cbb5751bc422ed3cadb5e9884 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 21 May 2020 13:51:13 +1000 Subject: [PATCH 12/54] Add "report" app - Define ReportTemplate model which contains a report file template --- InvenTree/InvenTree/settings.py | 9 ++--- InvenTree/report/__init__.py | 0 InvenTree/report/admin.py | 14 ++++++++ InvenTree/report/apps.py | 5 +++ InvenTree/report/migrations/0001_initial.py | 23 ++++++++++++ .../migrations/0002_auto_20200521_0350.py | 20 +++++++++++ InvenTree/report/migrations/__init__.py | 0 InvenTree/report/models.py | 36 +++++++++++++++++++ InvenTree/report/tests.py | 3 ++ InvenTree/report/views.py | 3 ++ Makefile | 4 +-- 11 files changed, 111 insertions(+), 6 deletions(-) create mode 100644 InvenTree/report/__init__.py create mode 100644 InvenTree/report/admin.py create mode 100644 InvenTree/report/apps.py create mode 100644 InvenTree/report/migrations/0001_initial.py create mode 100644 InvenTree/report/migrations/0002_auto_20200521_0350.py create mode 100644 InvenTree/report/migrations/__init__.py create mode 100644 InvenTree/report/models.py create mode 100644 InvenTree/report/tests.py create mode 100644 InvenTree/report/views.py diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index dda110e834..3d87527216 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -106,12 +106,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 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..1d5d1fee01 --- /dev/null +++ b/InvenTree/report/admin.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.contrib import admin + +from .models import ReportTemplate + + +class ReportTemplateAdmin(admin.ModelAdmin): + + list_display = ('template', 'description') + + +admin.site.register(ReportTemplate, ReportTemplateAdmin) 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..5d50070c64 --- /dev/null +++ b/InvenTree/report/migrations/0001_initial.py @@ -0,0 +1,23 @@ +# Generated by Django 3.0.5 on 2020-05-21 03:43 + +from django.db import migrations, models +import report.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='ReportTemplate', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('template', models.FileField(help_text='Report template file', upload_to=report.models.rename_template)), + ('description', models.CharField(help_text='Report template description', max_length=250)), + ], + ), + ] diff --git a/InvenTree/report/migrations/0002_auto_20200521_0350.py b/InvenTree/report/migrations/0002_auto_20200521_0350.py new file mode 100644 index 0000000000..6a88531438 --- /dev/null +++ b/InvenTree/report/migrations/0002_auto_20200521_0350.py @@ -0,0 +1,20 @@ +# Generated by Django 3.0.5 on 2020-05-21 03:50 + +import django.core.validators +from django.db import migrations, models +import report.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('report', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='reporttemplate', + name='template', + field=models.FileField(help_text='Report template file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'tex'])]), + ), + ] 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..f155407573 --- /dev/null +++ b/InvenTree/report/models.py @@ -0,0 +1,36 @@ +""" +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.utils.translation import gettext_lazy as _ + + +def rename_template(instance, filename): + + filename = os.path.basename(filename) + + return os.path.join('report', 'template', filename) + +class ReportTemplate(models.Model): + """ + Reporting template model. + """ + + def __str__(self): + return os.path.basename(self.template.name) + + template = models.FileField( + upload_to=rename_template, + help_text=_("Report template file"), + validators=[FileExtensionValidator(allowed_extensions=['html', 'tex'])], + ) + + description = models.CharField(max_length=250, help_text=_("Report template description")) diff --git a/InvenTree/report/tests.py b/InvenTree/report/tests.py new file mode 100644 index 0000000000..7ce503c2dd --- /dev/null +++ b/InvenTree/report/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/InvenTree/report/views.py b/InvenTree/report/views.py new file mode 100644 index 0000000000..91ea44a218 --- /dev/null +++ b/InvenTree/report/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. 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 From b78fe88c269cf26b7483972562adb274f968bfc4 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 21 May 2020 13:53:17 +1000 Subject: [PATCH 13/54] PEP fixes --- InvenTree/report/models.py | 1 + InvenTree/report/tests.py | 5 ++--- InvenTree/report/views.py | 5 ++--- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/InvenTree/report/models.py b/InvenTree/report/models.py index f155407573..2658a28f4d 100644 --- a/InvenTree/report/models.py +++ b/InvenTree/report/models.py @@ -19,6 +19,7 @@ def rename_template(instance, filename): return os.path.join('report', 'template', filename) + class ReportTemplate(models.Model): """ Reporting template model. diff --git a/InvenTree/report/tests.py b/InvenTree/report/tests.py index 7ce503c2dd..a2b5079b33 100644 --- a/InvenTree/report/tests.py +++ b/InvenTree/report/tests.py @@ -1,3 +1,2 @@ -from django.test import TestCase - -# Create your tests here. +# -*- coding: utf-8 -*- +from __future__ import unicode_literals diff --git a/InvenTree/report/views.py b/InvenTree/report/views.py index 91ea44a218..a2b5079b33 100644 --- a/InvenTree/report/views.py +++ b/InvenTree/report/views.py @@ -1,3 +1,2 @@ -from django.shortcuts import render - -# Create your views here. +# -*- coding: utf-8 -*- +from __future__ import unicode_literals From 70c5b27d2243c912b22dd00c04e87446fe037e5c Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 21 May 2020 14:05:25 +1000 Subject: [PATCH 14/54] Add ReportAsset model - Files which can be embedded into a report --- InvenTree/InvenTree/settings.py | 52 +++++++++---------- InvenTree/report/admin.py | 8 ++- .../report/migrations/0003_reportasset.py | 22 ++++++++ InvenTree/report/models.py | 28 +++++++++- .../stock/templates/stock/item_base.html | 2 + 5 files changed, 84 insertions(+), 28 deletions(-) create mode 100644 InvenTree/report/migrations/0003_reportasset.py diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index 3d87527216..6cadb09150 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', {}) @@ -161,7 +182,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': [ @@ -316,31 +341,6 @@ DATE_INPUT_FORMATS = [ "%Y-%m-%d", ] - -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/1.10/howto/static-files/ - -# 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) - # crispy forms use the bootstrap templates CRISPY_TEMPLATE_PACK = 'bootstrap3' diff --git a/InvenTree/report/admin.py b/InvenTree/report/admin.py index 1d5d1fee01..fc4c914358 100644 --- a/InvenTree/report/admin.py +++ b/InvenTree/report/admin.py @@ -3,7 +3,7 @@ from __future__ import unicode_literals from django.contrib import admin -from .models import ReportTemplate +from .models import ReportTemplate, ReportAsset class ReportTemplateAdmin(admin.ModelAdmin): @@ -11,4 +11,10 @@ class ReportTemplateAdmin(admin.ModelAdmin): list_display = ('template', 'description') +class ReportAssetAdmin(admin.ModelAdmin): + + list_display = ('asset', 'description') + + admin.site.register(ReportTemplate, ReportTemplateAdmin) +admin.site.register(ReportAsset, ReportAssetAdmin) diff --git a/InvenTree/report/migrations/0003_reportasset.py b/InvenTree/report/migrations/0003_reportasset.py new file mode 100644 index 0000000000..6f9c8efd03 --- /dev/null +++ b/InvenTree/report/migrations/0003_reportasset.py @@ -0,0 +1,22 @@ +# Generated by Django 3.0.5 on 2020-05-21 04:04 + +from django.db import migrations, models +import report.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('report', '0002_auto_20200521_0350'), + ] + + 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)), + ], + ), + ] diff --git a/InvenTree/report/models.py b/InvenTree/report/models.py index 2658a28f4d..eeebbfbee9 100644 --- a/InvenTree/report/models.py +++ b/InvenTree/report/models.py @@ -17,7 +17,7 @@ def rename_template(instance, filename): filename = os.path.basename(filename) - return os.path.join('report', 'template', filename) + return os.path.join('report', 'report_template', filename) class ReportTemplate(models.Model): @@ -35,3 +35,29 @@ class ReportTemplate(models.Model): ) description = models.CharField(max_length=250, help_text=_("Report template description")) + + +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/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index 782e51a2b8..4a12faff5e 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -15,6 +15,8 @@ InvenTree | {% trans "Stock Item" %} - {{ item }} {% block pre_content %} {% include 'stock/loc_link.html' with location=item.location %} +{% include "report_template/test.html" with item=item %} + {% if item.hasRequiredTests and not item.passedAllRequiredTests %}
{% trans "This stock item has not passed all required tests" %} From 05be4da25cbafd40727e0e3b7f23e97d08026f0b Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 21 May 2020 14:06:29 +1000 Subject: [PATCH 15/54] remove test code --- InvenTree/stock/templates/stock/item_base.html | 2 -- 1 file changed, 2 deletions(-) diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index 4a12faff5e..782e51a2b8 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -15,8 +15,6 @@ InvenTree | {% trans "Stock Item" %} - {{ item }} {% block pre_content %} {% include 'stock/loc_link.html' with location=item.location %} -{% include "report_template/test.html" with item=item %} - {% if item.hasRequiredTests and not item.passedAllRequiredTests %}
{% trans "This stock item has not passed all required tests" %} From c3dcabcaad05f48e0c835bdc77770458d4a501b8 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 21 May 2020 23:03:01 +1000 Subject: [PATCH 16/54] Render an uploaded template to LaTeX --- InvenTree/InvenTree/settings.py | 20 ++++++++++++ InvenTree/config_template.yaml | 9 ++++++ .../migrations/0004_auto_20200521_1217.py | 25 +++++++++++++++ InvenTree/report/models.py | 31 +++++++++++++++++++ 4 files changed, 85 insertions(+) create mode 100644 InvenTree/report/migrations/0004_auto_20200521_1217.py diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index 6cadb09150..e325e092da 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -148,6 +148,7 @@ INSTALLED_APPS = [ 'mptt', # Modified Preorder Tree Traversal 'markdownx', # Markdown editing 'markdownify', # Markdown template rendering + 'django_tex', # LaTeX output ] LOGGING = { @@ -199,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 = { @@ -341,6 +350,17 @@ DATE_INPUT_FORMATS = [ "%Y-%m-%d", ] +# LaTeX rendering settings (django-tex) +latex_settings = CONFIG.get('latex', {}) + +# Set the latex interpreter in the config.yaml settings file +LATEX_INTERPRETER = latex_settings.get('interpreter', 'pdflatex') + +LATEX_GRAPHICSPATH = [ + # Allow LaTeX files to access the report assets directory + os.path.join(MEDIA_ROOT, "report", "assets"), +] + # crispy forms use the bootstrap templates CRISPY_TEMPLATE_PACK = 'bootstrap3' diff --git a/InvenTree/config_template.yaml b/InvenTree/config_template.yaml index 64c5db0a06..686f910fbf 100644 --- a/InvenTree/config_template.yaml +++ b/InvenTree/config_template.yaml @@ -73,3 +73,12 @@ 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 pdflatx: apt-get texlive-latex-base + interpreter: pdflatex diff --git a/InvenTree/report/migrations/0004_auto_20200521_1217.py b/InvenTree/report/migrations/0004_auto_20200521_1217.py new file mode 100644 index 0000000000..c4342ada3d --- /dev/null +++ b/InvenTree/report/migrations/0004_auto_20200521_1217.py @@ -0,0 +1,25 @@ +# Generated by Django 3.0.5 on 2020-05-21 12:17 + +from django.db import migrations, models +import report.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('report', '0003_reportasset'), + ] + + operations = [ + migrations.AddField( + model_name='reporttemplate', + name='filters', + field=models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs)', max_length=250, validators=[report.models.validate_filter_string]), + ), + migrations.AddField( + model_name='reporttemplate', + name='name', + field=models.CharField(default='Test', help_text='Template name', max_length=100, unique=True), + preserve_default=False, + ), + ] diff --git a/InvenTree/report/models.py b/InvenTree/report/models.py index eeebbfbee9..4785f79ab8 100644 --- a/InvenTree/report/models.py +++ b/InvenTree/report/models.py @@ -12,6 +12,8 @@ from django.core.validators import FileExtensionValidator from django.utils.translation import gettext_lazy as _ +from django_tex.shortcuts import render_to_pdf + def rename_template(instance, filename): @@ -28,6 +30,28 @@ class ReportTemplate(models.Model): def __str__(self): return os.path.basename(self.template.name) + @property + def extension(self): + return os.path.splitext(self.template.name)[1].lower() + + def render(self, request, context={}, **kwargs): + """ + Render to template. + """ + + filename = kwargs.get('filename', 'report.pdf') + + template = os.path.join('report_template', os.path.basename(self.template.name)) + + if 1 or self.extension == '.tex': + return render_to_pdf(request, template, context, filename=filename) + + 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"), @@ -36,6 +60,13 @@ class ReportTemplate(models.Model): description = models.CharField(max_length=250, help_text=_("Report template description")) + filters = models.CharField( + blank=True, + max_length=250, + help_text=_("Query filters (comma-separated list of key=value pairs)"), + validators=[validate_filter_string] + ) + def rename_asset(instance, filename): From b93ba6339a3d0414dd00a815a004c416e0a3577e Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 21 May 2020 23:41:47 +1000 Subject: [PATCH 17/54] Option for rendering HTML template --- InvenTree/report/models.py | 38 +++++++++++++++++++++++++++++++++----- requirements.txt | 4 +++- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/InvenTree/report/models.py b/InvenTree/report/models.py index 4785f79ab8..1e30fd1c3e 100644 --- a/InvenTree/report/models.py +++ b/InvenTree/report/models.py @@ -13,6 +13,7 @@ from django.core.validators import FileExtensionValidator from django.utils.translation import gettext_lazy as _ from django_tex.shortcuts import render_to_pdf +from django_weasyprint import WeasyTemplateResponseMixin def rename_template(instance, filename): @@ -22,6 +23,21 @@ def rename_template(instance, filename): return os.path.join('report', 'report_template', filename) +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 ReportTemplate(models.Model): """ Reporting template model. @@ -34,17 +50,29 @@ class ReportTemplate(models.Model): def extension(self): return os.path.splitext(self.template.name)[1].lower() + @property + def template_name(self): + return os.path.join('report_template', os.path.basename(self.template.name)) + def render(self, request, context={}, **kwargs): """ - Render to template. + 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') - template = os.path.join('report_template', os.path.basename(self.template.name)) + context['request'] = request + + if self.extension == '.tex': + return render_to_pdf(request, self.template_name, context, filename=filename) + elif self.extension in ['.htm', '.html']: + wp = WeasyprintReportMixin(request, self.template_name, **kwargs) + return wp.render_to_response(context, **kwargs) - if 1 or self.extension == '.tex': - return render_to_pdf(request, template, context, filename=filename) name = models.CharField( blank=False, max_length=100, @@ -55,7 +83,7 @@ class ReportTemplate(models.Model): template = models.FileField( upload_to=rename_template, help_text=_("Report template file"), - validators=[FileExtensionValidator(allowed_extensions=['html', 'tex'])], + validators=[FileExtensionValidator(allowed_extensions=['html', 'htm', 'tex'])], ) description = models.CharField(max_length=250, help_text=_("Report template description")) 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 From cab87a686046e7a2c881e8ed5eddc573cb6441f6 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 22 May 2020 00:01:36 +1000 Subject: [PATCH 18/54] Update admin --- InvenTree/report/admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/report/admin.py b/InvenTree/report/admin.py index fc4c914358..683a188c80 100644 --- a/InvenTree/report/admin.py +++ b/InvenTree/report/admin.py @@ -8,7 +8,7 @@ from .models import ReportTemplate, ReportAsset class ReportTemplateAdmin(admin.ModelAdmin): - list_display = ('template', 'description') + list_display = ('name', 'description', 'template') class ReportAssetAdmin(admin.ModelAdmin): From 251a23d1275fe05137710b8d3b0fe993149a2d7d Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 22 May 2020 00:09:51 +1000 Subject: [PATCH 19/54] Cleanup --- InvenTree/InvenTree/settings.py | 4 +++- InvenTree/config_template.yaml | 2 ++ InvenTree/report/models.py | 14 ++++++++++++-- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index e325e092da..8e4feade23 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -203,7 +203,7 @@ TEMPLATES = [ # Backend for LaTeX report rendering { 'NAME': 'tex', - 'BACKEND': 'django_tex.engine.TeXEngine', + 'BACKEND': 'django_tex.engine.TeXEngine', 'DIRS': [ os.path.join(MEDIA_ROOT, 'report'), ] @@ -356,6 +356,8 @@ latex_settings = CONFIG.get('latex', {}) # Set the latex interpreter in the config.yaml settings file LATEX_INTERPRETER = latex_settings.get('interpreter', 'pdflatex') +LATEX_INTERPRETER_OPTIONS = latex_settings.get('options', '') + LATEX_GRAPHICSPATH = [ # Allow LaTeX files to access the report assets directory os.path.join(MEDIA_ROOT, "report", "assets"), diff --git a/InvenTree/config_template.yaml b/InvenTree/config_template.yaml index 686f910fbf..b5446b8e89 100644 --- a/InvenTree/config_template.yaml +++ b/InvenTree/config_template.yaml @@ -82,3 +82,5 @@ latex: # Note: The intepreter needs to be installed on the system! # e.g. to install pdflatx: 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/report/models.py b/InvenTree/report/models.py index 1e30fd1c3e..a2dae298e7 100644 --- a/InvenTree/report/models.py +++ b/InvenTree/report/models.py @@ -54,7 +54,14 @@ class ReportTemplate(models.Model): def template_name(self): return os.path.join('report_template', os.path.basename(self.template.name)) - def render(self, request, context={}, **kwargs): + 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. @@ -65,15 +72,18 @@ class ReportTemplate(models.Model): 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'), From 174c4cc591d21ebfafe4a78a6a3d0252273b8697 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 22 May 2020 13:01:21 +1000 Subject: [PATCH 20/54] Add subclass models for report types --- InvenTree/config_template.yaml | 2 +- InvenTree/report/admin.py | 3 ++- InvenTree/report/models.py | 36 ++++++++++++++++++++++++++++++++-- 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/InvenTree/config_template.yaml b/InvenTree/config_template.yaml index b5446b8e89..5cb0b1073c 100644 --- a/InvenTree/config_template.yaml +++ b/InvenTree/config_template.yaml @@ -80,7 +80,7 @@ sentry: latex: # Select the LaTeX interpreter to use for PDF rendering # Note: The intepreter needs to be installed on the system! - # e.g. to install pdflatx: apt-get texlive-latex-base + # 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/report/admin.py b/InvenTree/report/admin.py index 683a188c80..15d44931df 100644 --- a/InvenTree/report/admin.py +++ b/InvenTree/report/admin.py @@ -4,7 +4,7 @@ from __future__ import unicode_literals from django.contrib import admin from .models import ReportTemplate, ReportAsset - +from .models import TestReport class ReportTemplateAdmin(admin.ModelAdmin): @@ -17,4 +17,5 @@ class ReportAssetAdmin(admin.ModelAdmin): admin.site.register(ReportTemplate, ReportTemplateAdmin) +admin.site.register(TestReport, ReportTemplateAdmin) admin.site.register(ReportAsset, ReportAssetAdmin) diff --git a/InvenTree/report/models.py b/InvenTree/report/models.py index a2dae298e7..626d8a5923 100644 --- a/InvenTree/report/models.py +++ b/InvenTree/report/models.py @@ -38,7 +38,7 @@ class WeasyprintReportMixin(WeasyTemplateResponseMixin): self.pdf_filename = kwargs.get('filename', 'report.pdf') -class ReportTemplate(models.Model): +class ReportTemplateBase(models.Model): """ Reporting template model. """ @@ -46,13 +46,16 @@ class ReportTemplate(models.Model): def __str__(self): return os.path.basename(self.template.name) + 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', os.path.basename(self.template.name)) + return os.path.join('report_template', self.getSubdir(), os.path.basename(self.template.name)) def get_context_data(self, request): """ @@ -105,6 +108,35 @@ class ReportTemplate(models.Model): validators=[validate_filter_string] ) + 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 TestReport(ReportTemplateBase): + """ + Render a TestReport against a StockItem object. + """ + + def getSubdir(self): + return 'test' + + stock_item = None + + def get_context_data(self, request): + return { + 'stock_item': self.stock_item, + 'results': self.stock_item.testResultMap() + } + def rename_asset(instance, filename): From 865a6db828231b32c5be66ff4a65c091ae8efbcf Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 22 May 2020 13:05:12 +1000 Subject: [PATCH 21/54] Fix subdirectory lookup --- InvenTree/report/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/report/models.py b/InvenTree/report/models.py index 626d8a5923..46fdd965e7 100644 --- a/InvenTree/report/models.py +++ b/InvenTree/report/models.py @@ -20,7 +20,7 @@ def rename_template(instance, filename): filename = os.path.basename(filename) - return os.path.join('report', 'report_template', filename) + return os.path.join('report', 'report_template', instance.getSubdir(), filename) class WeasyprintReportMixin(WeasyTemplateResponseMixin): From d6cad372db485e6bd3fa66b22c0d57bb13109ddc Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 22 May 2020 13:05:30 +1000 Subject: [PATCH 22/54] Add migration --- .../migrations/0005_auto_20200522_0220.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 InvenTree/report/migrations/0005_auto_20200522_0220.py diff --git a/InvenTree/report/migrations/0005_auto_20200522_0220.py b/InvenTree/report/migrations/0005_auto_20200522_0220.py new file mode 100644 index 0000000000..d3c2d52520 --- /dev/null +++ b/InvenTree/report/migrations/0005_auto_20200522_0220.py @@ -0,0 +1,33 @@ +# Generated by Django 3.0.5 on 2020-05-22 02:20 + +import django.core.validators +from django.db import migrations, models +import report.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('report', '0004_auto_20200521_1217'), + ] + + operations = [ + 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)), + ('filters', models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs)', max_length=250, validators=[report.models.validate_filter_string])), + ], + options={ + 'abstract': False, + }, + ), + migrations.AlterField( + model_name='reporttemplate', + name='template', + field=models.FileField(help_text='Report template file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm', 'tex'])]), + ), + ] From 616f17d08a8f2137e5b10ff0ea8281e482e3b281 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 22 May 2020 21:01:08 +1000 Subject: [PATCH 23/54] Reset the report app migrations --- InvenTree/report/migrations/0001_initial.py | 30 +++++++++++++++-- .../migrations/0002_auto_20200521_0350.py | 20 ----------- .../report/migrations/0003_reportasset.py | 22 ------------- .../migrations/0004_auto_20200521_1217.py | 25 -------------- .../migrations/0005_auto_20200522_0220.py | 33 ------------------- 5 files changed, 28 insertions(+), 102 deletions(-) delete mode 100644 InvenTree/report/migrations/0002_auto_20200521_0350.py delete mode 100644 InvenTree/report/migrations/0003_reportasset.py delete mode 100644 InvenTree/report/migrations/0004_auto_20200521_1217.py delete mode 100644 InvenTree/report/migrations/0005_auto_20200522_0220.py diff --git a/InvenTree/report/migrations/0001_initial.py b/InvenTree/report/migrations/0001_initial.py index 5d50070c64..8b5c2af09f 100644 --- a/InvenTree/report/migrations/0001_initial.py +++ b/InvenTree/report/migrations/0001_initial.py @@ -1,5 +1,6 @@ -# Generated by Django 3.0.5 on 2020-05-21 03:43 +# 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 @@ -12,12 +13,37 @@ class Migration(migrations.Migration): ] 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')), - ('template', models.FileField(help_text='Report template file', upload_to=report.models.rename_template)), + ('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/0002_auto_20200521_0350.py b/InvenTree/report/migrations/0002_auto_20200521_0350.py deleted file mode 100644 index 6a88531438..0000000000 --- a/InvenTree/report/migrations/0002_auto_20200521_0350.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 3.0.5 on 2020-05-21 03:50 - -import django.core.validators -from django.db import migrations, models -import report.models - - -class Migration(migrations.Migration): - - dependencies = [ - ('report', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='reporttemplate', - name='template', - field=models.FileField(help_text='Report template file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'tex'])]), - ), - ] diff --git a/InvenTree/report/migrations/0003_reportasset.py b/InvenTree/report/migrations/0003_reportasset.py deleted file mode 100644 index 6f9c8efd03..0000000000 --- a/InvenTree/report/migrations/0003_reportasset.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 3.0.5 on 2020-05-21 04:04 - -from django.db import migrations, models -import report.models - - -class Migration(migrations.Migration): - - dependencies = [ - ('report', '0002_auto_20200521_0350'), - ] - - 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)), - ], - ), - ] diff --git a/InvenTree/report/migrations/0004_auto_20200521_1217.py b/InvenTree/report/migrations/0004_auto_20200521_1217.py deleted file mode 100644 index c4342ada3d..0000000000 --- a/InvenTree/report/migrations/0004_auto_20200521_1217.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 3.0.5 on 2020-05-21 12:17 - -from django.db import migrations, models -import report.models - - -class Migration(migrations.Migration): - - dependencies = [ - ('report', '0003_reportasset'), - ] - - operations = [ - migrations.AddField( - model_name='reporttemplate', - name='filters', - field=models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs)', max_length=250, validators=[report.models.validate_filter_string]), - ), - migrations.AddField( - model_name='reporttemplate', - name='name', - field=models.CharField(default='Test', help_text='Template name', max_length=100, unique=True), - preserve_default=False, - ), - ] diff --git a/InvenTree/report/migrations/0005_auto_20200522_0220.py b/InvenTree/report/migrations/0005_auto_20200522_0220.py deleted file mode 100644 index d3c2d52520..0000000000 --- a/InvenTree/report/migrations/0005_auto_20200522_0220.py +++ /dev/null @@ -1,33 +0,0 @@ -# Generated by Django 3.0.5 on 2020-05-22 02:20 - -import django.core.validators -from django.db import migrations, models -import report.models - - -class Migration(migrations.Migration): - - dependencies = [ - ('report', '0004_auto_20200521_1217'), - ] - - operations = [ - 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)), - ('filters', models.CharField(blank=True, help_text='Query filters (comma-separated list of key=value pairs)', max_length=250, validators=[report.models.validate_filter_string])), - ], - options={ - 'abstract': False, - }, - ), - migrations.AlterField( - model_name='reporttemplate', - name='template', - field=models.FileField(help_text='Report template file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm', 'tex'])]), - ), - ] From 7215a563b19cf470d77adc299109861e0166a098 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 22 May 2020 21:22:43 +1000 Subject: [PATCH 24/54] Add PartFilterMixin --- InvenTree/report/models.py | 100 ++++++++++++++++++++++++++++++++++--- 1 file changed, 92 insertions(+), 8 deletions(-) diff --git a/InvenTree/report/models.py b/InvenTree/report/models.py index 46fdd965e7..ffd77a09b1 100644 --- a/InvenTree/report/models.py +++ b/InvenTree/report/models.py @@ -9,9 +9,12 @@ 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.models import Part + from django_tex.shortcuts import render_to_pdf from django_weasyprint import WeasyTemplateResponseMixin @@ -23,6 +26,59 @@ def rename_template(instance, 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. @@ -101,13 +157,6 @@ class ReportTemplateBase(models.Model): description = models.CharField(max_length=250, help_text=_("Report template description")) - filters = models.CharField( - blank=True, - max_length=250, - help_text=_("Query filters (comma-separated list of key=value pairs)"), - validators=[validate_filter_string] - ) - class Meta: abstract = True @@ -121,7 +170,42 @@ class ReportTemplate(ReportTemplateBase): pass -class TestReport(ReportTemplateBase): +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 = 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. """ From 0ec880290b3858b26014fcb71f5c71d7b85face5 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 22 May 2020 21:29:58 +1000 Subject: [PATCH 25/54] Functionality for retrieving test templates associated with a given part --- InvenTree/part/models.py | 15 +++++++++++++++ InvenTree/report/models.py | 4 ++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 4ca77739ac..58eeaef82a 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,20 @@ 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 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/models.py b/InvenTree/report/models.py index ffd77a09b1..1c363059e7 100644 --- a/InvenTree/report/models.py +++ b/InvenTree/report/models.py @@ -13,7 +13,7 @@ from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ -from part.models import Part +from part import models as PartModels from django_tex.shortcuts import render_to_pdf from django_weasyprint import WeasyTemplateResponseMixin @@ -186,7 +186,7 @@ class PartFilterMixin(models.Model): filters = self.get_part_filters() - parts = Part.objects.filter(**filters) + parts = PartModels.Part.objects.filter(**filters) parts = parts.filter(pk=part.pk) From 1ad7e699a9fa5c4467ccbe2fcd028459845da365 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 22 May 2020 21:31:21 +1000 Subject: [PATCH 26/54] PEP --- InvenTree/part/models.py | 1 - InvenTree/report/admin.py | 1 + InvenTree/report/models.py | 1 - 3 files changed, 1 insertion(+), 2 deletions(-) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 58eeaef82a..31a35d8cd3 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -372,7 +372,6 @@ class Part(MPTTModel): return templates - 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/admin.py b/InvenTree/report/admin.py index 15d44931df..4183e8ee83 100644 --- a/InvenTree/report/admin.py +++ b/InvenTree/report/admin.py @@ -6,6 +6,7 @@ from django.contrib import admin from .models import ReportTemplate, ReportAsset from .models import TestReport + class ReportTemplateAdmin(admin.ModelAdmin): list_display = ('name', 'description', 'template') diff --git a/InvenTree/report/models.py b/InvenTree/report/models.py index 1c363059e7..60580b900d 100644 --- a/InvenTree/report/models.py +++ b/InvenTree/report/models.py @@ -192,7 +192,6 @@ class PartFilterMixin(models.Model): return parts.exists() - def get_part_filters(self): """ Return a map of filters to be used for Part filtering """ return validateFilterString(self.part_filters) From 71681bfda1c3ab8e2c3549c8c26f4197c88eff87 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 22 May 2020 21:38:05 +1000 Subject: [PATCH 27/54] Add a button if a stock item test report is available --- InvenTree/part/models.py | 5 +++++ InvenTree/report/models.py | 1 + InvenTree/stock/templates/stock/item_base.html | 7 ++++++- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 31a35d8cd3..735a6de574 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -372,6 +372,11 @@ class Part(MPTTModel): 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/models.py b/InvenTree/report/models.py index 60580b900d..9cf7778641 100644 --- a/InvenTree/report/models.py +++ b/InvenTree/report/models.py @@ -212,6 +212,7 @@ class TestReport(ReportTemplateBase, PartFilterMixin): 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): diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index 782e51a2b8..b01442f3b5 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 %} {% if user.is_staff %} {% endif %} + + {% if item.part.has_test_report_templates %} + + {% endif %}
@@ -43,6 +46,17 @@ 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( From e63342418f6fa5d6e2c2ebf42d1070fd1ca6c4dc Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 23 May 2020 11:30:42 +1000 Subject: [PATCH 33/54] Improve / simplify logic for file attachments against test result object --- InvenTree/stock/api.py | 22 ---------------- .../migrations/0042_auto_20200523_0121.py | 19 ++++++++++++++ InvenTree/stock/models.py | 25 ++++++------------- InvenTree/stock/serializers.py | 6 ----- InvenTree/stock/views.py | 13 ---------- InvenTree/templates/js/stock.html | 4 +-- 6 files changed, 29 insertions(+), 60 deletions(-) create mode 100644 InvenTree/stock/migrations/0042_auto_20200523_0121.py diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 9972b1c982..9abbd8cda2 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -693,11 +693,6 @@ class StockItemTestResultList(generics.ListCreateAPIView): 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) @@ -713,23 +708,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/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/models.py b/InvenTree/stock/models.py index 468f72df37..9ed2a55d4a 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -1094,6 +1094,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. @@ -1123,13 +1128,11 @@ class StockItemTestResult(models.Model): super().clean() - """ # If this test result corresponds to a template, check the requirements of the template key = helpers.generateTestKey(self.test) templates = self.stock_item.part.getTestTemplates() - TODO: Re-introduce this at a later stage, it is buggy when uplaoding an attachment via the API for template in templates: if key == template.key: @@ -1146,17 +1149,6 @@ class StockItemTestResult(models.Model): }) break - """ - - # 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 stock_item = models.ForeignKey( StockItem, @@ -1182,10 +1174,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 a84ea92540..d0d7162370 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -229,20 +229,15 @@ class StockItemTestResultSerializer(InvenTreeModelSerializer): """ Serializer for the StockItemTestResult model """ user_detail = UserSerializerBrief(source='user', read_only=True) - attachment_detail = StockItemAttachmentSerializer(source='attachment', 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 @@ -253,7 +248,6 @@ class StockItemTestResultSerializer(InvenTreeModelSerializer): 'result', 'value', 'attachment', - 'attachment_detail', 'notes', 'user', 'user_detail', diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index d0a57d3d1f..02a7ee9719 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -292,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 @@ -320,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 diff --git a/InvenTree/templates/js/stock.html b/InvenTree/templates/js/stock.html index 49c3151ff0..68f1a0c5c5 100644 --- a/InvenTree/templates/js/stock.html +++ b/InvenTree/templates/js/stock.html @@ -56,8 +56,8 @@ function loadStockTestResultsTable(table, options) { html += `${row.user_detail.username}`; } - if (row.attachment_detail) { - html += ``; + if (row.attachment) { + html += ``; } return html; From 01481ef5c9f965a88aee0e0c400794d82f4395ab Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 23 May 2020 14:28:25 +1000 Subject: [PATCH 34/54] Add function to get the number of required tests for a part --- InvenTree/part/models.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 735a6de574..451c916542 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -1033,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. From e4d10279fa6ceea68e043560445ff26847a54947 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 24 May 2020 20:04:34 +1000 Subject: [PATCH 35/54] Include 'key' field in StockItemTestResult serializer --- InvenTree/stock/api.py | 2 ++ InvenTree/stock/models.py | 6 +++++- InvenTree/stock/serializers.py | 3 +++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 9abbd8cda2..516edc5040 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -687,6 +687,8 @@ 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)) diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 9ed2a55d4a..02393023b9 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -1129,7 +1129,7 @@ class StockItemTestResult(models.Model): super().clean() # If this test result corresponds to a template, check the requirements of the template - key = helpers.generateTestKey(self.test) + key = self.key templates = self.stock_item.part.getTestTemplates() @@ -1150,6 +1150,10 @@ class StockItemTestResult(models.Model): break + @property + def key(self): + return helpers.generateTestKey(self.test) + stock_item = models.ForeignKey( StockItem, on_delete=models.CASCADE, diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index d0d7162370..19d05d7b7b 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -230,6 +230,8 @@ class StockItemTestResultSerializer(InvenTreeModelSerializer): user_detail = UserSerializerBrief(source='user', read_only=True) + key = serializers.CharField(read_only=True) + def __init__(self, *args, **kwargs): user_detail = kwargs.pop('user_detail', False) @@ -244,6 +246,7 @@ class StockItemTestResultSerializer(InvenTreeModelSerializer): fields = [ 'pk', 'stock_item', + 'key', 'test', 'result', 'value', From c44205273c7a25377008b39503076296766aa75c Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 24 May 2020 20:05:34 +1000 Subject: [PATCH 36/54] Simplify javascript --- InvenTree/templates/js/stock.html | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/InvenTree/templates/js/stock.html b/InvenTree/templates/js/stock.html index 68f1a0c5c5..fd2dd61746 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 @@ -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; From 68b9a690f271ec681146ec6fb6dee39c9dc33b5c Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 24 May 2020 20:22:15 +1000 Subject: [PATCH 37/54] Integer value required for trackable bom item --- InvenTree/part/models.py | 15 +++++++++++++++ InvenTree/part/test_bom_item.py | 14 ++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 451c916542..4acd4a594e 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -1339,6 +1339,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}) @@ -1431,6 +1436,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/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 """ From 95cc3d2a7a61353a6184fbad6a19af067227866b Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 24 May 2020 21:09:43 +1000 Subject: [PATCH 38/54] Copy test results when a stock item is split or serialized --- InvenTree/stock/models.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 02393023b9..90a6fd365c 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -647,6 +647,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 +658,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 +666,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 +727,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", From 22220493bdc107d0421ecf4aa9374c03e5a58030 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 24 May 2020 21:10:00 +1000 Subject: [PATCH 39/54] Add unit tests --- InvenTree/stock/tests.py | 65 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) 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) From 009adaf528b2e1377b3f0b815a34a834059f6276 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 25 May 2020 13:13:28 +1000 Subject: [PATCH 40/54] Code to get and test for variants of a part --- InvenTree/part/models.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 4acd4a594e..1a8048a985 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -1109,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 From fdf57891fc224e4a124e53ff2219e63d89bcf6c3 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 25 May 2020 14:16:38 +1000 Subject: [PATCH 41/54] Form / view / etc for performing StockItem conversion --- InvenTree/stock/forms.py | 12 ++++++++++ .../stock/templates/stock/item_base.html | 15 ++++++++++++ .../templates/stock/stockitem_convert.html | 17 +++++++++++++ InvenTree/stock/urls.py | 1 + InvenTree/stock/views.py | 24 +++++++++++++++++++ 5 files changed, 69 insertions(+) create mode 100644 InvenTree/stock/templates/stock/stockitem_convert.html diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py index edf3d28df8..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 """ diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index 819c138988..bc187588d0 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -93,6 +93,11 @@ InvenTree | {% trans "Stock Item" %} - {{ item }} {% endif %} + {% if item.part.has_variants %} + + {% endif %} {% if item.part.has_test_report_templates %}