From 12821b80fbc30698dda47661ea7a13d3988ec007 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 12 Feb 2021 20:28:12 +1100 Subject: [PATCH 01/23] Add BOMReport model --- InvenTree/report/admin.py | 8 +++- .../migrations/0011_auto_20210212_2024.py | 35 ++++++++++++++++ InvenTree/report/models.py | 41 ++++++++++++++++++- 3 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 InvenTree/report/migrations/0011_auto_20210212_2024.py diff --git a/InvenTree/report/admin.py b/InvenTree/report/admin.py index 610b32dd4c..66f6a1cd6d 100644 --- a/InvenTree/report/admin.py +++ b/InvenTree/report/admin.py @@ -3,7 +3,9 @@ from __future__ import unicode_literals from django.contrib import admin -from .models import ReportSnippet, TestReport, ReportAsset +from .models import ReportSnippet, ReportAsset +from .models import TestReport +from .models import BillOfMaterialsReport class ReportTemplateAdmin(admin.ModelAdmin): @@ -22,5 +24,7 @@ class ReportAssetAdmin(admin.ModelAdmin): admin.site.register(ReportSnippet, ReportSnippetAdmin) -admin.site.register(TestReport, ReportTemplateAdmin) admin.site.register(ReportAsset, ReportAssetAdmin) + +admin.site.register(TestReport, ReportTemplateAdmin) +admin.site.register(BillOfMaterialsReport, ReportTemplateAdmin) diff --git a/InvenTree/report/migrations/0011_auto_20210212_2024.py b/InvenTree/report/migrations/0011_auto_20210212_2024.py new file mode 100644 index 0000000000..b1a93656cf --- /dev/null +++ b/InvenTree/report/migrations/0011_auto_20210212_2024.py @@ -0,0 +1,35 @@ +# Generated by Django 3.0.7 on 2021-02-12 09:24 + +import django.core.validators +from django.db import migrations, models +import report.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('report', '0010_auto_20210205_1201'), + ] + + operations = [ + migrations.CreateModel( + name='BillOfMaterialsReport', + 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, verbose_name='Name')), + ('template', models.FileField(help_text='Report template file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Template')), + ('description', models.CharField(help_text='Report template description', max_length=250, verbose_name='Description')), + ('revision', models.PositiveIntegerField(default=1, editable=False, help_text='Report revision number (auto-increments)', verbose_name='Revision')), + ('enabled', models.BooleanField(default=True, help_text='Report template is enabled', verbose_name='Enabled')), + ('filters', models.CharField(blank=True, help_text='Part query filters (comma-separated list of key=value pairs', max_length=250, validators=[report.models.validate_part_report_filters], verbose_name='Part Filters')), + ], + options={ + 'abstract': False, + }, + ), + migrations.AlterField( + model_name='testreport', + name='filters', + field=models.CharField(blank=True, help_text='StockItem query filters (comma-separated list of key=value pairs)', max_length=250, validators=[report.models.validate_stock_item_report_filters], verbose_name='Filters'), + ), + ] diff --git a/InvenTree/report/models.py b/InvenTree/report/models.py index ce1a01e872..42d308dcbc 100644 --- a/InvenTree/report/models.py +++ b/InvenTree/report/models.py @@ -20,6 +20,7 @@ from django.template.loader import render_to_string from django.core.files.storage import FileSystemStorage from django.core.validators import FileExtensionValidator +import part.models import stock.models import common.models @@ -70,10 +71,21 @@ def rename_template(instance, filename): def validate_stock_item_report_filters(filters): + """ + Validate filter string against StockItem model + """ return validateFilterString(filters, model=stock.models.StockItem) +def validate_part_report_filters(filters): + """ + Validate filter string against Part model + """ + + return validateFilterString(filters, model=part.models.Part) + + class WeasyprintReportMixin(WeasyTemplateResponseMixin): """ Class for rendering a HTML template to a PDF. @@ -252,7 +264,7 @@ class TestReport(ReportTemplateBase): blank=True, max_length=250, verbose_name=_('Filters'), - help_text=_("Part query filters (comma-separated list of key=value pairs)"), + help_text=_("StockItem query filters (comma-separated list of key=value pairs)"), validators=[ validate_stock_item_report_filters ] @@ -302,6 +314,33 @@ def rename_snippet(instance, filename): return path +class BillOfMaterialsReport(ReportTemplateBase): + """ + Render a Bill of Materials against a Part object + """ + + def getSubDir(self): + return 'bom' + + # Requires a part object to be given to it before rendering + + filters = models.CharField( + blank=True, + max_length=250, + verbose_name=_('Part Filters'), + help_text=_('Part query filters (comma-separated list of key=value pairs'), + validators=[ + validate_part_report_filters + ] + ) + + def get_context_data(self, request): + return { + 'part': self.part, + 'category': self.category, + } + + class ReportSnippet(models.Model): """ Report template 'snippet' which can be used to make templates From ba85ff63bf55b99f5c84969d1fa412a23a633f8d Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 12 Feb 2021 20:38:30 +1100 Subject: [PATCH 02/23] Refactor selectTestReport into selectReport --- InvenTree/templates/js/report.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/InvenTree/templates/js/report.js b/InvenTree/templates/js/report.js index 26907b52df..ce780f857c 100644 --- a/InvenTree/templates/js/report.js +++ b/InvenTree/templates/js/report.js @@ -1,10 +1,10 @@ {% load i18n %} -function selectTestReport(reports, items, options={}) { +function selectReport(reports, items, options={}) { /** - * Present the user with the available test reports, - * and allow them to select which test report to print. + * Present the user with the available reports, + * and allow them to select which report to print. * * The intent is that the available report templates have been requested * (via AJAX) from the server. @@ -44,7 +44,7 @@ function selectTestReport(reports, items, options={}) { html += `
- ${items.length} {% trans "stock items selected" %} + ${items.length} {% trans "items selected" %}
`; } @@ -121,7 +121,7 @@ function printTestReports(items, options={}) { } // Select report template to print - selectTestReport( + selectReport( response, items, { From a1cf893eb20517b6f5ecdb0cae11451bd773cb40 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 12 Feb 2021 20:55:13 +1100 Subject: [PATCH 03/23] List API endpint for BOM reports --- InvenTree/report/api.py | 111 +++++++++++++++++++++++++++++++- InvenTree/report/serializers.py | 31 +++++++-- 2 files changed, 134 insertions(+), 8 deletions(-) diff --git a/InvenTree/report/api.py b/InvenTree/report/api.py index 68a1f23f0d..4030874a51 100644 --- a/InvenTree/report/api.py +++ b/InvenTree/report/api.py @@ -3,7 +3,7 @@ from __future__ import unicode_literals from django.utils.translation import ugettext as _ from django.conf.urls import url, include -from django.core.exceptions import FieldError +from django.core.exceptions import ValidationError, FieldError from django.http import HttpResponse from django_filters.rest_framework import DjangoFilterBackend @@ -16,8 +16,13 @@ import InvenTree.helpers from stock.models import StockItem +import part.models + from .models import TestReport +from .models import BillOfMaterialsReport + from .serializers import TestReportSerializer +from .serializers import BOMReportSerializer class ReportListView(generics.ListAPIView): @@ -76,6 +81,42 @@ class StockItemReportMixin: return valid_items +class PartReportMixin: + """ + Mixin for extracting part items from query params + """ + + def get_parts(self): + """ + Return a list of requested part objects + """ + + parts = [] + + params = self.request.query_params + + if 'parts[]' in params: + parts = params.getlist('parts[]', []) + elif 'part' in params: + parts = [params.get('part', None)] + + if type(parts) not in [list, tuple]: + parts = [parts] + + valid_ids = [] + + for p in parts: + try: + valid_ids.append(int(p)) + except (ValueError): + continue + + # Extract a valid set of Part objects + valid_parts = part.models.Part.objects.filter(pk__in=valid_ids) + + return valid_parts + + class StockItemTestReportList(ReportListView, StockItemReportMixin): """ API endpoint for viewing list of TestReport objects. @@ -224,8 +265,76 @@ class StockItemTestReportPrint(generics.RetrieveAPIView, StockItemReportMixin): ) +class BOMReportList(ReportListView, PartReportMixin): + """ + API endpoint for viewing a list of BillOfMaterialReport objects. + + Filterably by: + + - enabled: Filter by enabled / disabled status + - part: Filter by single part + - parts: Filter by list of parts + """ + + queryset = BillOfMaterialsReport.objects.all() + serializer_class = BOMReportSerializer + + def filter_queryset(self, queryset): + + queryset = super().filter_queryset(queryset) + + # List of Part objects to match against + parts = self.get_parts() + + if len(parts) > 0: + """ + We wish to filter by part(s). + + We need to compare the 'filters' string of each report, + and see if it matches against each of the specified parts. + """ + + valid_report_ids = set() + + for report in queryset.all(): + + matches = True + + try: + filters = InvenTree.helpers.validateFilterString(report.filters) + except ValidationError: + # Filters are ill-defined + continue + + for p in parts: + part_query = part.models.Part.objects.filter(pk=p.pk) + + try: + if not part_query.filter(**filters).exists(): + matches = False + break + except FieldError: + matches = False + break + + if matches: + valid_report_ids.add(report.pk) + else: + continue + + # Reduce queryset to only valid matches + queryset = queryset.filter(pk__in=[pk for pk in valid_report_ids]) + + return queryset + + report_api_urls = [ + url(r'bom/', include([ + # List view + url(r'^.*$', BOMReportList.as_view(), name='api-bom-report-list'), + ])), + # Stock item test reports url(r'test/', include([ # Detail views diff --git a/InvenTree/report/serializers.py b/InvenTree/report/serializers.py index 0cd4d4f40a..4474a276a2 100644 --- a/InvenTree/report/serializers.py +++ b/InvenTree/report/serializers.py @@ -5,6 +5,7 @@ from InvenTree.serializers import InvenTreeModelSerializer from InvenTree.serializers import InvenTreeAttachmentSerializerField from .models import TestReport +from .models import BillOfMaterialsReport class TestReportSerializer(InvenTreeModelSerializer): @@ -14,10 +15,26 @@ class TestReportSerializer(InvenTreeModelSerializer): class Meta: model = TestReport fields = [ - 'pk', - 'name', - 'description', - 'template', - 'filters', - 'enabled', - ] + 'pk', + 'name', + 'description', + 'template', + 'filters', + 'enabled', + ] + + +class BOMReportSerializer(InvenTreeModelSerializer): + + template = InvenTreeAttachmentSerializerField(required=True) + + class Meta: + model = BillOfMaterialsReport + fields = [ + 'pk', + 'name', + 'description', + 'template', + 'filters', + 'enabled', + ] From 9be2989971a8ce780fd888b9cd03186ca8babea0 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 12 Feb 2021 21:08:33 +1100 Subject: [PATCH 04/23] Refactor printing code into ReportPrintMixin --- InvenTree/report/api.py | 157 +++++++++++++++++++++++-------------- InvenTree/report/models.py | 21 ++--- 2 files changed, 109 insertions(+), 69 deletions(-) diff --git a/InvenTree/report/api.py b/InvenTree/report/api.py index 4030874a51..2b8a3cccf1 100644 --- a/InvenTree/report/api.py +++ b/InvenTree/report/api.py @@ -117,6 +117,74 @@ class PartReportMixin: return valid_parts +class ReportPrintMixin: + """ + Mixin for printing reports + """ + + def print(self, request, items_to_print): + """ + Print this report template against a number of pre-validated items. + """ + + if len(items_to_print) == 0: + # No valid items provided, return an error message + data = { + 'error': _('No valid objects provided to template'), + } + + return Response(data, status=400) + + outputs = [] + + # In debug mode, generate single HTML output, rather than PDF + debug_mode = common.models.InvenTreeSetting.get_setting('REPORT_DEBUG_MODE') + + # Merge one or more PDF files into a single download + for item in items_to_print: + report = self.get_object() + report.object_to_print = item + + if debug_mode: + outputs.append(report.render_to_string(request)) + else: + outputs.append(report.render(request)) + + if debug_mode: + """ + Contatenate all rendered templates into a single HTML string, + and return the string as a HTML response. + """ + + html = "\n".join(outputs) + + return HttpResponse(html) + else: + """ + Concatenate all rendered pages into a single PDF object, + and return the resulting document! + """ + + pages = [] + + if len(outputs) > 1: + # If more than one output is generated, merge them into a single file + for output in outputs: + doc = output.get_document() + for page in doc.pages: + pages.append(page) + + pdf = outputs[0].get_document().copy(pages).write_pdf() + else: + pdf = outputs[0].get_document().write_pdf() + + return InvenTree.helpers.DownloadFile( + pdf, + 'test_report.pdf', + content_type='application/pdf' + ) + + class StockItemTestReportList(ReportListView, StockItemReportMixin): """ API endpoint for viewing list of TestReport objects. @@ -191,7 +259,7 @@ class StockItemTestReportDetail(generics.RetrieveUpdateDestroyAPIView): serializer_class = TestReportSerializer -class StockItemTestReportPrint(generics.RetrieveAPIView, StockItemReportMixin): +class StockItemTestReportPrint(generics.RetrieveAPIView, StockItemReportMixin, ReportPrintMixin): """ API endpoint for printing a TestReport object """ @@ -206,64 +274,8 @@ class StockItemTestReportPrint(generics.RetrieveAPIView, StockItemReportMixin): items = self.get_items() - if len(items) == 0: - # No valid items provided, return an error message - data = { - 'error': _('Must provide valid StockItem(s)') - } - - return Response(data, status=400) - - outputs = [] - - # In debug mode, generate single HTML output, rather than PDF - debug_mode = common.models.InvenTreeSetting.get_setting('REPORT_DEBUG_MODE') - - # Merge one or more PDF files into a single download - for item in items: - report = self.get_object() - report.stock_item = item - - if debug_mode: - outputs.append(report.render_to_string(request)) - else: - outputs.append(report.render(request)) - - if debug_mode: - """ - Contatenate all rendered templates into a single HTML string, - and return the string as a HTML response. - """ - - html = "\n".join(outputs) - - return HttpResponse(html) - - else: - """ - Concatenate all rendered pages into a single PDF object, - and return the resulting document! - """ - - pages = [] - - if len(outputs) > 1: - # If more than one output is generated, merge them into a single file - for output in outputs: - doc = output.get_document() - for page in doc.pages: - pages.append(page) - - pdf = outputs[0].get_document().copy(pages).write_pdf() - else: - pdf = outputs[0].get_document().write_pdf() - - return InvenTree.helpers.DownloadFile( - pdf, - 'test_report.pdf', - content_type='application/pdf' - ) - + return self.print(request, items) + class BOMReportList(ReportListView, PartReportMixin): """ @@ -328,6 +340,33 @@ class BOMReportList(ReportListView, PartReportMixin): return queryset +class BOMReportDetail(generics.RetrieveUpdateDestroyAPIView): + """ + API endpoint for a single BillOfMaterialReport object + """ + + queryset = BillOfMaterialsReport.objects.all() + serializer_class = BOMReportSerializer + + +class BOMReportPrint(generics.RetrieveUpdateDestroyAPIView, PartReportMixin, ReportPrintMixin): + """ + API endpoint for printing a BillOfMaterialReport object + """ + + queryset = BillOfMaterialsReport.objects.all() + serializer_class = BOMReportSerializer + + def get(self, request, *args, **kwargs): + """ + Check if valid part item(s) have been provided + """ + + parts = self.get_parts() + + return self.print(request, parts) + + report_api_urls = [ url(r'bom/', include([ diff --git a/InvenTree/report/models.py b/InvenTree/report/models.py index 42d308dcbc..25b0c09da8 100644 --- a/InvenTree/report/models.py +++ b/InvenTree/report/models.py @@ -183,6 +183,9 @@ class ReportTemplateBase(ReportBase): """ + # Pass a single top-level object to the report template + object_to_print = None + def get_context_data(self, request): """ Supply context data to the template for rendering @@ -257,9 +260,6 @@ class TestReport(ReportTemplateBase): def getSubdir(self): return 'test' - # Requires a stock_item object to be given to it before rendering - stock_item = None - filters = models.CharField( blank=True, max_length=250, @@ -287,11 +287,14 @@ class TestReport(ReportTemplateBase): return items.exists() def get_context_data(self, request): + + stock_item = self.object_to_print + return { - 'stock_item': self.stock_item, - 'part': self.stock_item.part, - 'results': self.stock_item.testResultMap(), - 'result_list': self.stock_item.testResultList() + 'stock_item': stock_item, + 'part': stock_item.part, + 'results': stock_item.testResultMap(), + 'result_list': stock_item.testResultList() } @@ -322,8 +325,6 @@ class BillOfMaterialsReport(ReportTemplateBase): def getSubDir(self): return 'bom' - # Requires a part object to be given to it before rendering - filters = models.CharField( blank=True, max_length=250, @@ -336,7 +337,7 @@ class BillOfMaterialsReport(ReportTemplateBase): def get_context_data(self, request): return { - 'part': self.part, + 'part': self.object_to_print, 'category': self.category, } From 4e9b9ee6fd68c30102bfc455329cb62340868eca Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 12 Feb 2021 21:15:03 +1100 Subject: [PATCH 05/23] Detail and print view for the BOM report --- InvenTree/report/api.py | 8 ++++++++ InvenTree/report/models.py | 7 +++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/InvenTree/report/api.py b/InvenTree/report/api.py index 2b8a3cccf1..9d3eea573c 100644 --- a/InvenTree/report/api.py +++ b/InvenTree/report/api.py @@ -369,7 +369,15 @@ class BOMReportPrint(generics.RetrieveUpdateDestroyAPIView, PartReportMixin, Rep report_api_urls = [ + # Bill of Material reports url(r'bom/', include([ + + # Detail views + url(r'^(?P\d+)/', include([ + url(r'print/?', BOMReportPrint.as_view(), name='api-bom-report-print'), + url(r'^.*$', BOMReportDetail.as_view(), name='api-bom-report-detail'), + ])), + # List view url(r'^.*$', BOMReportList.as_view(), name='api-bom-report-list'), ])), diff --git a/InvenTree/report/models.py b/InvenTree/report/models.py index 25b0c09da8..45fbe052fa 100644 --- a/InvenTree/report/models.py +++ b/InvenTree/report/models.py @@ -336,9 +336,12 @@ class BillOfMaterialsReport(ReportTemplateBase): ) def get_context_data(self, request): + + part = self.object_to_print + return { - 'part': self.object_to_print, - 'category': self.category, + 'part': part, + 'category': part.category, } From 11099676ef3c1ed7b7059dfeec3ed54844b59287 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 12 Feb 2021 21:23:56 +1100 Subject: [PATCH 06/23] Dialog for printing BOM reports --- InvenTree/part/templates/part/bom.html | 23 ++++++---- InvenTree/templates/js/report.js | 58 +++++++++++++++++++++++++- 2 files changed, 71 insertions(+), 10 deletions(-) diff --git a/InvenTree/part/templates/part/bom.html b/InvenTree/part/templates/part/bom.html index 515cae37a0..d8e8854791 100644 --- a/InvenTree/part/templates/part/bom.html +++ b/InvenTree/part/templates/part/bom.html @@ -35,34 +35,37 @@ {% if part.variant_of %} {% endif %} {% elif part.active %} {% if roles.part.change %} {% if part.is_bom_valid == False %} {% endif %} {% endif %} + {% endif %} + - {% endif %}
@@ -215,4 +218,8 @@ {% endif %} + $("#print-bom-report").click(function() { + printBomReports([{{ part.pk }}]); + }); + {% endblock %} diff --git a/InvenTree/templates/js/report.js b/InvenTree/templates/js/report.js index ce780f857c..e79d80dc41 100644 --- a/InvenTree/templates/js/report.js +++ b/InvenTree/templates/js/report.js @@ -102,7 +102,7 @@ function printTestReports(items, options={}) { return; } - // Request available labels from the server + // Request available reports from the server inventreeGet( '{% url "api-stockitem-testreport-list" %}', { @@ -139,4 +139,58 @@ function printTestReports(items, options={}) { } } ); -} \ No newline at end of file +} + + +function printBomReports(parts, options={}) { + /** + * Print BOM reports for the provided part(s) + */ + + if (parts.length == 0) { + showAlertDialog( + '{% trans "Select Parts" %}', + '{% trans "Part(s) must be selected before printing reports" %}' + ); + + return; + } + + // Request available reports from the server + inventreeGet( + '{% url "api-bom-report-list" %}', + { + enabled: true, + parts: parts, + }, + { + success: function(response) { + if (response.length == 0) { + showAlertDialog( + '{% trans "No Reports Found" %}', + '{% trans "No report templates found which match selected part(s)" %}', + ); + + return; + } + + // Select which report to print + selectReport( + response, + parts, + { + success: function(pk) { + var href = `/api/report/bom/${pk}/print/?`; + + parts.forEach(function(part) { + href += `parts[]=${part}&`; + }); + + window.location.href = href; + } + } + ); + } + } + ) +} From e8fd336612ff5a1e005c8849e30e33ffef86dc3f Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 12 Feb 2021 21:32:26 +1100 Subject: [PATCH 07/23] Fix getSubdir function --- 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 45fbe052fa..669cbd6fb6 100644 --- a/InvenTree/report/models.py +++ b/InvenTree/report/models.py @@ -322,7 +322,7 @@ class BillOfMaterialsReport(ReportTemplateBase): Render a Bill of Materials against a Part object """ - def getSubDir(self): + def getSubdir(self): return 'bom' filters = models.CharField( From a349e77866a43dbe8dc6470d43a2644a50848b48 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 16 Feb 2021 08:25:04 +1100 Subject: [PATCH 08/23] Adds model for BuildReport - List / Detail / Print --- InvenTree/report/admin.py | 2 + InvenTree/report/api.py | 138 +++++++++++++++++- .../report/migrations/0012_buildreport.py | 30 ++++ InvenTree/report/models.py | 73 +++++++-- InvenTree/report/serializers.py | 17 +++ 5 files changed, 245 insertions(+), 15 deletions(-) create mode 100644 InvenTree/report/migrations/0012_buildreport.py diff --git a/InvenTree/report/admin.py b/InvenTree/report/admin.py index 66f6a1cd6d..2c008877cc 100644 --- a/InvenTree/report/admin.py +++ b/InvenTree/report/admin.py @@ -5,6 +5,7 @@ from django.contrib import admin from .models import ReportSnippet, ReportAsset from .models import TestReport +from .models import BuildReport from .models import BillOfMaterialsReport @@ -27,4 +28,5 @@ admin.site.register(ReportSnippet, ReportSnippetAdmin) admin.site.register(ReportAsset, ReportAssetAdmin) admin.site.register(TestReport, ReportTemplateAdmin) +admin.site.register(BuildReport, ReportTemplateAdmin) admin.site.register(BillOfMaterialsReport, ReportTemplateAdmin) diff --git a/InvenTree/report/api.py b/InvenTree/report/api.py index 9d3eea573c..ede938af8b 100644 --- a/InvenTree/report/api.py +++ b/InvenTree/report/api.py @@ -16,12 +16,15 @@ import InvenTree.helpers from stock.models import StockItem +import build.models import part.models from .models import TestReport +from .models import BuildReport from .models import BillOfMaterialsReport from .serializers import TestReportSerializer +from .serializers import BuildReportSerializer from .serializers import BOMReportSerializer @@ -81,6 +84,39 @@ class StockItemReportMixin: return valid_items +class BuildReportMixin: + """ + Mixin for extracting Build items from query params + """ + + def get_builds(self): + """ + Return a list of requested Build objects + """ + + builds = [] + + params = self.request.query_params + + if 'builds[]' in params: + builds = params.getlist('builds[]', []) + elif 'build' in params: + builds = [params.get('build', None)] + + if type(builds) not in [list, tuple]: + builds = [builds] + + valid_ids = [] + + for b in builds: + try: + valid_ids.append(int(b)) + except (ValueError): + continue + + return build.models.Build.objects.filter(pk__in=valid_ids) + + class PartReportMixin: """ Mixin for extracting part items from query params @@ -349,7 +385,7 @@ class BOMReportDetail(generics.RetrieveUpdateDestroyAPIView): serializer_class = BOMReportSerializer -class BOMReportPrint(generics.RetrieveUpdateDestroyAPIView, PartReportMixin, ReportPrintMixin): +class BOMReportPrint(generics.RetrieveAPIView, PartReportMixin, ReportPrintMixin): """ API endpoint for printing a BillOfMaterialReport object """ @@ -367,8 +403,108 @@ class BOMReportPrint(generics.RetrieveUpdateDestroyAPIView, PartReportMixin, Rep return self.print(request, parts) +class BuildReportList(ReportListView, BuildReportMixin): + """ + API endpoint for viewing a list of BuildReport objects. + + Can be filtered by: + + - enabled: Filter by enabled / disabled status + - build: Filter by a single build + - builds[]: Filter by a list of builds + """ + + queryset = BuildReport.objects.all() + serializer_class = BuildReportSerializer + + def filter_queryset(self, queryset): + + queryset = super().filter_queryset(queryset) + + # List of Build objects to match against + builds = self.get_builds() + + if len(builds) > 0: + """ + We wish to filter by Build(s) + + We need to compare the 'filters' string of each report, + and see if it matches against each of the specified parts + + # TODO: This code needs some refactoring! + """ + + valid_build_ids = set() + + for report in queryset.all(): + + matches = True + + try: + filters = InvenTree.helpers.validateFilterString(report.filters) + except ValidationError: + continue + + for b in builds: + build_query = build.models.Build.objects.filter(pk=b.pk) + + try: + if not build_query.filter(**filters).exists(): + matches = False + break + except FieldError: + matches = False + break + + if matches: + valid_build_ids.add(report.pk) + else: + continue + + # Reduce queryset to only valid matches + queryset = queryset.filter(pk__in=[pk for pk in valid_build_ids]) + + return queryset + + +class BuildReportDetail(generics.RetrieveUpdateDestroyAPIView): + """ + API endpoint for a single BuildReport object + """ + + queryset = BuildReport.objects.all() + serializer_class = BuildReportSerializer + + +class BuildReportPrint(generics.RetrieveAPIView, BuildReportMixin, ReportPrintMixin): + """ + API endpoint for printing a BuildReport + """ + + queryset = BuildReport.objects.all() + serializer_class = BuildReportSerializer + + def get(self, request, *ars, **kwargs): + + builds = self.get_builds() + + return self.print(request, builds) + + report_api_urls = [ + # Build reports + url(r'build/', include([ + # Detail views + url(r'^(?P\d+)/', include([ + url(r'print/?', BuildReportPrint.as_view(), name='api-build-report-print'), + url(r'^.*$', BuildReportDetail.as_view(), name='api-build-report-detail'), + ])), + + # List view + url(r'^.*$', BuildReportList.as_view(), name='api-build-report-list'), + ])), + # Bill of Material reports url(r'bom/', include([ diff --git a/InvenTree/report/migrations/0012_buildreport.py b/InvenTree/report/migrations/0012_buildreport.py new file mode 100644 index 0000000000..b2d3603480 --- /dev/null +++ b/InvenTree/report/migrations/0012_buildreport.py @@ -0,0 +1,30 @@ +# Generated by Django 3.0.7 on 2021-02-15 21:08 + +import django.core.validators +from django.db import migrations, models +import report.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('report', '0011_auto_20210212_2024'), + ] + + operations = [ + migrations.CreateModel( + name='BuildReport', + 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, verbose_name='Name')), + ('template', models.FileField(help_text='Report template file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Template')), + ('description', models.CharField(help_text='Report template description', max_length=250, verbose_name='Description')), + ('revision', models.PositiveIntegerField(default=1, editable=False, help_text='Report revision number (auto-increments)', verbose_name='Revision')), + ('enabled', models.BooleanField(default=True, help_text='Report template is enabled', verbose_name='Enabled')), + ('filters', models.CharField(blank=True, help_text='Build query filters (comma-separated list of key=value pairs', max_length=250, validators=[report.models.validate_build_report_filters], verbose_name='Build Filters')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/InvenTree/report/models.py b/InvenTree/report/models.py index 669cbd6fb6..f052c0321d 100644 --- a/InvenTree/report/models.py +++ b/InvenTree/report/models.py @@ -20,9 +20,10 @@ from django.template.loader import render_to_string from django.core.files.storage import FileSystemStorage from django.core.validators import FileExtensionValidator +import build.models +import common.models import part.models import stock.models -import common.models from InvenTree.helpers import validateFilterString @@ -86,6 +87,14 @@ def validate_part_report_filters(filters): return validateFilterString(filters, model=part.models.Part) +def validate_build_report_filters(filters): + """ + Validate filter string against Build model + """ + + return validateFilterString(filters, model=build.models.Build) + + class WeasyprintReportMixin(WeasyTemplateResponseMixin): """ Class for rendering a HTML template to a PDF. @@ -298,23 +307,40 @@ class TestReport(ReportTemplateBase): } -def rename_snippet(instance, filename): +class BuildReport(ReportTemplateBase): + """ + Build order / work order report + """ - filename = os.path.basename(filename) + def getSubdir(self): + return 'build' - path = os.path.join('report', 'snippets', filename) + filters = models.CharField( + blank=True, + max_length=250, + verbose_name=_('Build Filters'), + help_text=_('Build query filters (comma-separated list of key=value pairs'), + validators=[ + validate_build_report_filters, + ] + ) - # If the snippet file is the *same* filename as the one being uploaded, - # delete the original one from the media directory - if str(filename) == str(instance.snippet): - fullpath = os.path.join(settings.MEDIA_ROOT, path) - fullpath = os.path.abspath(fullpath) - - if os.path.exists(fullpath): - logger.info(f"Deleting existing snippet file: '{filename}'") - os.remove(fullpath) + def get_context_data(self, request): + """ + Custom context data for the build report + """ - return path + my_build = self.object_to_print + + if not type(my_build) == build.models.Build: + raise TypeError('Provided model is not a Build object') + + return { + 'build': my_build, + 'part': my_build.part, + 'reference': my_build.reference, + 'quantity': my_build.quantity, + } class BillOfMaterialsReport(ReportTemplateBase): @@ -345,6 +371,25 @@ class BillOfMaterialsReport(ReportTemplateBase): } +def rename_snippet(instance, filename): + + filename = os.path.basename(filename) + + path = os.path.join('report', 'snippets', filename) + + # If the snippet file is the *same* filename as the one being uploaded, + # delete the original one from the media directory + if str(filename) == str(instance.snippet): + fullpath = os.path.join(settings.MEDIA_ROOT, path) + fullpath = os.path.abspath(fullpath) + + if os.path.exists(fullpath): + logger.info(f"Deleting existing snippet file: '{filename}'") + os.remove(fullpath) + + return path + + class ReportSnippet(models.Model): """ Report template 'snippet' which can be used to make templates diff --git a/InvenTree/report/serializers.py b/InvenTree/report/serializers.py index 4474a276a2..4868406ed5 100644 --- a/InvenTree/report/serializers.py +++ b/InvenTree/report/serializers.py @@ -5,6 +5,7 @@ from InvenTree.serializers import InvenTreeModelSerializer from InvenTree.serializers import InvenTreeAttachmentSerializerField from .models import TestReport +from .models import BuildReport from .models import BillOfMaterialsReport @@ -24,6 +25,22 @@ class TestReportSerializer(InvenTreeModelSerializer): ] +class BuildReportSerializer(InvenTreeModelSerializer): + + template = InvenTreeAttachmentSerializerField(required=True) + + class Meta: + model = BuildReport + fields = [ + 'pk', + 'name', + 'description', + 'template', + 'filters', + 'enabled', + ] + + class BOMReportSerializer(InvenTreeModelSerializer): template = InvenTreeAttachmentSerializerField(required=True) From e72aaf2e070af76e9c6d4ee3314734734263f143 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 16 Feb 2021 08:25:52 +1100 Subject: [PATCH 09/23] PEP fixes --- InvenTree/report/serializers.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/InvenTree/report/serializers.py b/InvenTree/report/serializers.py index 4868406ed5..f0a449ae49 100644 --- a/InvenTree/report/serializers.py +++ b/InvenTree/report/serializers.py @@ -16,13 +16,13 @@ class TestReportSerializer(InvenTreeModelSerializer): class Meta: model = TestReport fields = [ - 'pk', - 'name', - 'description', - 'template', - 'filters', - 'enabled', - ] + 'pk', + 'name', + 'description', + 'template', + 'filters', + 'enabled', + ] class BuildReportSerializer(InvenTreeModelSerializer): @@ -48,10 +48,10 @@ class BOMReportSerializer(InvenTreeModelSerializer): class Meta: model = BillOfMaterialsReport fields = [ - 'pk', - 'name', - 'description', - 'template', - 'filters', - 'enabled', - ] + 'pk', + 'name', + 'description', + 'template', + 'filters', + 'enabled', + ] From b222119653ebec44212a61b3bc3e92afc924439a Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 16 Feb 2021 08:36:04 +1100 Subject: [PATCH 10/23] Add option to print build report --- .../build/templates/build/build_base.html | 50 ++++++++++------- InvenTree/templates/js/report.js | 53 +++++++++++++++++++ 2 files changed, 84 insertions(+), 19 deletions(-) diff --git a/InvenTree/build/templates/build/build_base.html b/InvenTree/build/templates/build/build_base.html index 9ca3fff818..671f748331 100644 --- a/InvenTree/build/templates/build/build_base.html +++ b/InvenTree/build/templates/build/build_base.html @@ -45,27 +45,35 @@ src="{% static 'img/blank_image.png' %}"

{{ build.title }}

-
-
- {% if roles.build.change %} - - {% if build.is_active %} - - - {% endif %} - {% endif %} - {% if build.status == BuildStatus.CANCELLED and roles.build.delete %} - - {% endif %} +
+ {% if roles.build.change %} + + {% if build.is_active %} + + + {% endif %} + {% endif %} + {% if build.status == BuildStatus.CANCELLED and roles.build.delete %} + + {% endif %}
{% endblock %} @@ -151,6 +159,10 @@ src="{% static 'img/blank_image.png' %}" ); }); + $('#print-build-report').click(function() { + printBuildReports([{{ build.pk }}]); + }); + $("#build-delete").on('click', function() { launchModalForm( "{% url 'build-delete' build.id %}", diff --git a/InvenTree/templates/js/report.js b/InvenTree/templates/js/report.js index e79d80dc41..bb8068681a 100644 --- a/InvenTree/templates/js/report.js +++ b/InvenTree/templates/js/report.js @@ -142,6 +142,59 @@ function printTestReports(items, options={}) { } +function printBuildReports(builds, options={}) { + /** + * Print Build report for the provided build(s) + */ + + if (builds.length == 0) { + showAlertDialog( + '{% trans "Select Builds" %}', + '{% trans "Build(s) must be selected before printing reports" %}', + ); + + return; + } + + inventreeGet( + '{% url "api-build-report-list" %}', + { + enabled: true, + builds: builds, + }, + { + success: function(response) { + if (response.length == 0) { + showAlertDialog( + '{% trans "No Reports Found" %}', + '{% trans "No report templates found which match selected build(s)" %}' + ); + + return; + } + + // Select which report to print + selectReport( + response, + builds, + { + success: function(pk) { + var href = `/api/report/build/${pk}/print/?`; + + builds.forEach(function(build) { + href += `builds[]=${build}&`; + }); + + window.location.href = href; + } + } + ) + } + } + ) +} + + function printBomReports(parts, options={}) { /** * Print BOM reports for the provided part(s) From 247c4bdb4b6a8d75c1c45b4eb3232f0db9564794 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 16 Feb 2021 08:45:28 +1100 Subject: [PATCH 11/23] Print multiple build reports --- InvenTree/build/templates/build/index.html | 70 +++++++++++++++------- InvenTree/templates/js/build.js | 13 ++++ 2 files changed, 61 insertions(+), 22 deletions(-) diff --git a/InvenTree/build/templates/build/index.html b/InvenTree/build/templates/build/index.html index 37993107a7..05864cd780 100644 --- a/InvenTree/build/templates/build/index.html +++ b/InvenTree/build/templates/build/index.html @@ -22,19 +22,33 @@ InvenTree | {% trans "Build Orders" %}
- {% if roles.build.add %} - - {% endif %} - - -
- +
+ {% if roles.build.add %} + + {% endif %} + + + + +
+ +
@@ -157,17 +171,29 @@ $("#view-list").click(function() { $("#view-calendar").show(); }); - $("#collapse-item-active").collapse().show(); +$("#collapse-item-active").collapse().show(); - $("#new-build").click(function() { - newBuildOrder(); +$("#new-build").click(function() { + newBuildOrder(); +}); + +loadBuildTable($("#build-table"), { + url: "{% url 'api-build-list' %}", + params: { + part_detail: "true", + }, +}); + +$('#multi-build-print').click(function() { + var rows = $("#build-table").bootstrapTable('getSelections'); + + var build_ids = []; + + rows.forEach(function(row) { + build_ids.push(row.pk); }); - loadBuildTable($("#build-table"), { - url: "{% url 'api-build-list' %}", - params: { - part_detail: "true", - }, - }); + printBuildReports(build_ids); +}); {% endblock %} \ No newline at end of file diff --git a/InvenTree/templates/js/build.js b/InvenTree/templates/js/build.js index 0455c8a6c4..965207de66 100644 --- a/InvenTree/templates/js/build.js +++ b/InvenTree/templates/js/build.js @@ -637,6 +637,12 @@ function loadBuildTable(table, options) { visible: false, switchable: false, }, + { + checkbox: true, + title: '{% trans "Select" %}', + searchable: false, + switchable: false, + }, { field: 'reference', title: '{% trans "Build" %}', @@ -717,6 +723,13 @@ function loadBuildTable(table, options) { }, ], }); + + linkButtonsToSelection( + table, + [ + '#build-print-options', + ] + ); } From 6cc0880b4a432629744727b797d1c32411e5e4d7 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 16 Feb 2021 15:31:04 +1100 Subject: [PATCH 12/23] Add INVENTREE_BASE_URL setting - Also adds callable validator! --- InvenTree/common/models.py | 14 +++++++++++++- InvenTree/report/models.py | 1 + InvenTree/templates/InvenTree/settings/global.html | 1 + 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 9e6c6e868d..d893206126 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -18,7 +18,7 @@ from djmoney.contrib.exchange.models import convert_money from djmoney.contrib.exchange.exceptions import MissingRate from django.utils.translation import ugettext as _ -from django.core.validators import MinValueValidator +from django.core.validators import MinValueValidator, URLValidator from django.core.exceptions import ValidationError import InvenTree.helpers @@ -64,6 +64,13 @@ class InvenTreeSetting(models.Model): 'default': 'My company name', }, + 'INVENTREE_BASE_URL': { + 'name': _('Base URL'), + 'description': _('Base URL for server instance'), + 'validator': URLValidator(), + 'default': '', + }, + 'INVENTREE_DEFAULT_CURRENCY': { 'name': _('Default Currency'), 'description': _('Default currency'), @@ -528,6 +535,11 @@ class InvenTreeSetting(models.Model): return + if callable(validator): + # We can accept function validators with a single argument + print("Running validator function") + validator(self.value) + # Boolean validator if validator == bool: # Value must "look like" a boolean value diff --git a/InvenTree/report/models.py b/InvenTree/report/models.py index f052c0321d..906b65be0b 100644 --- a/InvenTree/report/models.py +++ b/InvenTree/report/models.py @@ -209,6 +209,7 @@ class ReportTemplateBase(ReportBase): context = self.get_context_data(request) + context['base_url'] = common.models.InvenTreeSetting.get_setting('INVENTREE_BASE_URL') context['date'] = datetime.datetime.now().date() context['datetime'] = datetime.datetime.now() context['default_page_size'] = common.models.InvenTreeSetting.get_setting('REPORT_DEFAULT_PAGE_SIZE') diff --git a/InvenTree/templates/InvenTree/settings/global.html b/InvenTree/templates/InvenTree/settings/global.html index 6d2f14bfd9..d63593d866 100644 --- a/InvenTree/templates/InvenTree/settings/global.html +++ b/InvenTree/templates/InvenTree/settings/global.html @@ -16,6 +16,7 @@ {% include "InvenTree/settings/header.html" %} {% include "InvenTree/settings/setting.html" with key="INVENTREE_INSTANCE" icon="fa-info-circle" %} + {% include "InvenTree/settings/setting.html" with key="INVENTREE_BASE_URL" icon="fa-globe" %} {% include "InvenTree/settings/setting.html" with key="INVENTREE_COMPANY_NAME" icon="fa-building" %} {% include "InvenTree/settings/setting.html" with key="INVENTREE_DEFAULT_CURRENCY" icon="fa-dollar-sign" %} From 31a8c94d2f145d2af9e5e12299e1fc791f3dc940 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 16 Feb 2021 15:40:27 +1100 Subject: [PATCH 13/23] Adds 'issued_by' and 'responsible' field to BuildOrder - issued_by is a user - responsible is a user or a group --- InvenTree/build/forms.py | 2 ++ .../migrations/0026_auto_20210216_1539.py | 27 +++++++++++++++++++ InvenTree/build/models.py | 20 ++++++++++++++ 3 files changed, 49 insertions(+) create mode 100644 InvenTree/build/migrations/0026_auto_20210216_1539.py diff --git a/InvenTree/build/forms.py b/InvenTree/build/forms.py index 136d21d553..f5764b9d93 100644 --- a/InvenTree/build/forms.py +++ b/InvenTree/build/forms.py @@ -53,6 +53,8 @@ class EditBuildForm(HelperForm): 'parent', 'sales_order', 'link', + 'issued_by', + 'responsible', ] diff --git a/InvenTree/build/migrations/0026_auto_20210216_1539.py b/InvenTree/build/migrations/0026_auto_20210216_1539.py new file mode 100644 index 0000000000..aee7c44bd6 --- /dev/null +++ b/InvenTree/build/migrations/0026_auto_20210216_1539.py @@ -0,0 +1,27 @@ +# Generated by Django 3.0.7 on 2021-02-16 04:39 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0005_owner_model'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('build', '0025_build_target_date'), + ] + + operations = [ + migrations.AddField( + model_name='build', + name='issued_by', + field=models.ForeignKey(blank=True, help_text='User who issued this build order', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='builds_issued', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='build', + name='responsible', + field=models.ForeignKey(blank=True, help_text='User responsible for this build order', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='builds_responsible', to='users.Owner'), + ), + ] diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index c1d4aa8026..3908816454 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -33,6 +33,7 @@ import InvenTree.fields from stock import models as StockModels from part import models as PartModels +from users import models as UserModels class Build(MPTTModel): @@ -53,6 +54,9 @@ class Build(MPTTModel): completion_date: Date the build was completed (or, if incomplete, the expected date of completion) link: External URL for extra information notes: Text notes + completed_by: User that completed the build + issued_by: User that issued the build + responsible: User (or group) responsible for completing the build """ OVERDUE_FILTER = Q(status__in=BuildStatus.ACTIVE_CODES) & ~Q(target_date=None) & Q(target_date__lte=datetime.now().date()) @@ -214,6 +218,22 @@ class Build(MPTTModel): blank=True, null=True, related_name='builds_completed' ) + + issued_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + blank=True, null=True, + help_text=_('User who issued this build order'), + related_name='builds_issued', + ) + + responsible = models.ForeignKey( + UserModels.Owner, + on_delete=models.SET_NULL, + blank=True, null=True, + help_text=_('User responsible for this build order'), + related_name='builds_responsible', + ) link = InvenTree.fields.InvenTreeURLField( verbose_name=_('External Link'), From a722057dab2660aa0fadcd34316899325fe18d9d Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 16 Feb 2021 15:46:18 +1100 Subject: [PATCH 14/23] Display responsible and issuing users for build orders --- .../build/templates/build/build_base.html | 14 +++++ InvenTree/build/templates/build/detail.html | 61 ++++++++++++------- 2 files changed, 54 insertions(+), 21 deletions(-) diff --git a/InvenTree/build/templates/build/build_base.html b/InvenTree/build/templates/build/build_base.html index 671f748331..9a145b4ad6 100644 --- a/InvenTree/build/templates/build/build_base.html +++ b/InvenTree/build/templates/build/build_base.html @@ -129,6 +129,20 @@ src="{% static 'img/blank_image.png' %}" {{ build.sales_order }} {% endif %} + {% if build.issued_by %} + + + {% trans "Issued By" %} + {{ build.issued_by }} + + {% endif %} + {% if build.responsible %} + + + {% trans "Responsible" %} + {{ build.responsible }} + + {% endif %} {% endblock %} diff --git a/InvenTree/build/templates/build/detail.html b/InvenTree/build/templates/build/detail.html index a9a2288c4e..40dc772c4f 100644 --- a/InvenTree/build/templates/build/detail.html +++ b/InvenTree/build/templates/build/detail.html @@ -90,31 +90,50 @@ {{ build.link }} {% endif %} + {% if build.issued_by %} - - {% trans "Created" %} - {{ build.creation_date }} + + {% trans "Issued By" %} + {{ build.issued_by }} + {% endif %} + {% if build.responsible %} - - {% trans "Target Date" %} - {% if build.target_date %} - - {{ build.target_date }}{% if build.is_overdue %} {% endif %} - - {% else %} - {% trans "No target date set" %} - {% endif %} - - - - {% trans "Completed" %} - {% if build.completion_date %} - {{ build.completion_date }}{% if build.completed_by %}{{ build.completed_by }}{% endif %} - {% else %} - {% trans "Build not complete" %} - {% endif %} + + {% trans "Responsible" %} + {{ build.responsible }} + {% endif %} + +
+
+ + + + + + + + + + + {% if build.target_date %} + + {% else %} + + {% endif %} + + + + + {% if build.completion_date %} + + {% else %} + + {% endif %} +
{% trans "Created" %}{{ build.creation_date }}
{% trans "Target Date" %} + {{ build.target_date }}{% if build.is_overdue %} {% endif %} + {% trans "No target date set" %}
{% trans "Completed" %}{{ build.completion_date }}{% if build.completed_by %}{{ build.completed_by }}{% endif %}{% trans "Build not complete" %}
From a416c56e5ac4ebe82006b0b147e86e0bf86addfb Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 16 Feb 2021 15:55:09 +1100 Subject: [PATCH 15/23] pre-fill 'issued_by' user --- InvenTree/build/views.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/InvenTree/build/views.py b/InvenTree/build/views.py index 0887c49397..76cc9bcfde 100644 --- a/InvenTree/build/views.py +++ b/InvenTree/build/views.py @@ -696,6 +696,9 @@ class BuildCreate(AjaxCreateView): initials['quantity'] = self.request.GET.get('quantity', 1) + # Pre-fill the issued_by user + initials['issued_by'] = self.request.user + return initials def get_data(self): From 81cac0927d47357bd3afaecc3fdaf77bf7ecf8ba Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 16 Feb 2021 16:04:24 +1100 Subject: [PATCH 16/23] Layout tweask --- .../build/templates/build/build_base.html | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/InvenTree/build/templates/build/build_base.html b/InvenTree/build/templates/build/build_base.html index 9a145b4ad6..fab9eb8353 100644 --- a/InvenTree/build/templates/build/build_base.html +++ b/InvenTree/build/templates/build/build_base.html @@ -56,23 +56,23 @@ src="{% static 'img/blank_image.png' %}"
  • {% trans "Print Build Order" %}
  • + {% if roles.build.change %} - - {% if build.is_active %} - - - {% endif %} - {% endif %} - {% if build.status == BuildStatus.CANCELLED and roles.build.delete %} - +
    + + +
    {% endif %} {% endblock %} From fdca3d842d6a11cdede680d8475a5afc78420049 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 16 Feb 2021 16:45:13 +1100 Subject: [PATCH 17/23] Add report function for generating an internal link --- InvenTree/report/templatetags/report.py | 30 +++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/InvenTree/report/templatetags/report.py b/InvenTree/report/templatetags/report.py index 0383aad562..67c3a48cb4 100644 --- a/InvenTree/report/templatetags/report.py +++ b/InvenTree/report/templatetags/report.py @@ -6,10 +6,13 @@ import os from django import template from django.conf import settings +from django.utils.safestring import mark_safe from part.models import Part from stock.models import StockItem +from common.models import InvenTreeSetting + register = template.Library() @@ -50,3 +53,30 @@ def part_image(part): path = os.path.abspath(path) return f"file://{path}" + + +@register.simple_tag() +def internal_link(link, text): + """ + Make a href which points to an InvenTree URL. + + Important Note: This only works if the INVENTREE_BASE_URL parameter is set! + + If the INVENTREE_BASE_URL parameter is not configured, + the text will be returned (unlinked) + """ + + text = str(text) + + base_url = InvenTreeSetting.get_setting('INVENTREE_BASE_URL') + + # If the base URL is not set, just return the text + if not base_url: + return text + + url = f"{base_url}/{link}/" + + # Remove any double quotes + url = url.replace("//", "/") + + return mark_safe(f'{text}') From b09e9c0781fdbec08e76c08beb5f50ff4c767863 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 16 Feb 2021 17:16:36 +1100 Subject: [PATCH 18/23] Fixes for URL generation --- InvenTree/report/templatetags/report.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/InvenTree/report/templatetags/report.py b/InvenTree/report/templatetags/report.py index 67c3a48cb4..ad4a37972c 100644 --- a/InvenTree/report/templatetags/report.py +++ b/InvenTree/report/templatetags/report.py @@ -74,9 +74,12 @@ def internal_link(link, text): if not base_url: return text - url = f"{base_url}/{link}/" + if not base_url.endswith('/'): + base_url += '/' - # Remove any double quotes - url = url.replace("//", "/") + if base_url.endswith('/') and link.startswith('/'): + link = link[1:] + + url = f"{base_url}{link}" return mark_safe(f'{text}') From f87b15e4eadb5ca04b005059e757ca85832436f2 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 16 Feb 2021 20:14:13 +1100 Subject: [PATCH 19/23] Refactoring --- InvenTree/report/api.py | 35 +++++++------------------------- InvenTree/templates/js/report.js | 6 +++--- 2 files changed, 10 insertions(+), 31 deletions(-) diff --git a/InvenTree/report/api.py b/InvenTree/report/api.py index ede938af8b..dd937f0aba 100644 --- a/InvenTree/report/api.py +++ b/InvenTree/report/api.py @@ -62,13 +62,7 @@ class StockItemReportMixin: params = self.request.query_params - if 'items[]' in params: - items = params.getlist('items[]', []) - elif 'item' in params: - items = [params.get('item', None)] - - if type(items) not in [list, tuple]: - item = [items] + items = params.getlist('item', []) valid_ids = [] @@ -98,13 +92,7 @@ class BuildReportMixin: params = self.request.query_params - if 'builds[]' in params: - builds = params.getlist('builds[]', []) - elif 'build' in params: - builds = [params.get('build', None)] - - if type(builds) not in [list, tuple]: - builds = [builds] + builds = params.getlist('build', []) valid_ids = [] @@ -131,13 +119,7 @@ class PartReportMixin: params = self.request.query_params - if 'parts[]' in params: - parts = params.getlist('parts[]', []) - elif 'part' in params: - parts = [params.get('part', None)] - - if type(parts) not in [list, tuple]: - parts = [parts] + parts = params.getlist('part', []) valid_ids = [] @@ -216,7 +198,7 @@ class ReportPrintMixin: return InvenTree.helpers.DownloadFile( pdf, - 'test_report.pdf', + 'inventree_report.pdf', content_type='application/pdf' ) @@ -228,8 +210,7 @@ class StockItemTestReportList(ReportListView, StockItemReportMixin): Filterable by: - enabled: Filter by enabled / disabled status - - item: Filter by single stock item - - items: Filter by list of stock items + - item: Filter by stock item(s) """ @@ -320,8 +301,7 @@ class BOMReportList(ReportListView, PartReportMixin): Filterably by: - enabled: Filter by enabled / disabled status - - part: Filter by single part - - parts: Filter by list of parts + - part: Filter by part(s) """ queryset = BillOfMaterialsReport.objects.all() @@ -410,8 +390,7 @@ class BuildReportList(ReportListView, BuildReportMixin): Can be filtered by: - enabled: Filter by enabled / disabled status - - build: Filter by a single build - - builds[]: Filter by a list of builds + - build: Filter by Build object """ queryset = BuildReport.objects.all() diff --git a/InvenTree/templates/js/report.js b/InvenTree/templates/js/report.js index bb8068681a..6b6ce39db9 100644 --- a/InvenTree/templates/js/report.js +++ b/InvenTree/templates/js/report.js @@ -129,7 +129,7 @@ function printTestReports(items, options={}) { var href = `/api/report/test/${pk}/print/?`; items.forEach(function(item) { - href += `items[]=${item}&`; + href += `item=${item}&`; }); window.location.href = href; @@ -182,7 +182,7 @@ function printBuildReports(builds, options={}) { var href = `/api/report/build/${pk}/print/?`; builds.forEach(function(build) { - href += `builds[]=${build}&`; + href += `build=${build}&`; }); window.location.href = href; @@ -236,7 +236,7 @@ function printBomReports(parts, options={}) { var href = `/api/report/bom/${pk}/print/?`; parts.forEach(function(part) { - href += `parts[]=${part}&`; + href += `part=${part}&`; }); window.location.href = href; From 46f20593c5b24b5979b07e419ea51379c992b12f Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 16 Feb 2021 20:39:07 +1100 Subject: [PATCH 20/23] Add default build order report Toot toot refactor tractor --- InvenTree/report/apps.py | 126 +++++++----- InvenTree/report/models.py | 13 +- .../report/inventree_build_order.html | 3 + .../report/inventree_build_order_base.html | 185 ++++++++++++++++++ 4 files changed, 276 insertions(+), 51 deletions(-) create mode 100644 InvenTree/report/templates/report/inventree_build_order.html create mode 100644 InvenTree/report/templates/report/inventree_build_order_base.html diff --git a/InvenTree/report/apps.py b/InvenTree/report/apps.py index bb1c5f0cb7..941133e481 100644 --- a/InvenTree/report/apps.py +++ b/InvenTree/report/apps.py @@ -18,6 +18,66 @@ class ReportConfig(AppConfig): """ self.create_default_test_reports() + self.create_default_build_reports() + + def create_default_reports(self, model, reports): + """ + Copy defualt report files across to the media directory. + """ + + # Source directory for report templates + src_dir = os.path.join( + os.path.dirname(os.path.realpath(__file__)), + 'templates', + 'report', + ) + + # Destination directory + dst_dir = os.path.join( + settings.MEDIA_ROOT, + 'report', + 'inventree', + model.getSubdir(), + ) + + if not os.path.exists(dst_dir): + logger.info(f"Creating missing directory: '{dst_dir}'") + os.makedirs(dst_dir, exist_ok=True) + + # Copy each report template across (if required) + for report in reports: + + # Destination filename + filename = os.path.join( + 'report', + 'inventree', + model.getSubdir(), + report['file'], + ) + + src_file = os.path.join(src_dir, report['file']) + dst_file = os.path.join(settings.MEDIA_ROOT, filename) + + if not os.path.exists(dst_file): + logger.info(f"Copying test report template '{dst_file}'") + shutil.copyfile(src_file, dst_file) + + try: + # Check if a report matching the template already exists + if model.objects.filter(template=filename).exists(): + continue + + logger.info(f"Creating new TestReport for '{report['name']}'") + + model.objects.create( + name=report['name'], + description=report['description'], + template=filename, + enabled=True + ) + + except: + pass def create_default_test_reports(self): """ @@ -31,23 +91,6 @@ class ReportConfig(AppConfig): # Database is not ready yet return - src_dir = os.path.join( - os.path.dirname(os.path.realpath(__file__)), - 'templates', - 'report', - ) - - dst_dir = os.path.join( - settings.MEDIA_ROOT, - 'report', - 'inventree', # Stored in secret directory! - 'test', - ) - - if not os.path.exists(dst_dir): - logger.info(f"Creating missing directory: '{dst_dir}'") - os.makedirs(dst_dir, exist_ok=True) - # List of test reports to copy across reports = [ { @@ -57,36 +100,27 @@ class ReportConfig(AppConfig): }, ] - for report in reports: + self.create_default_reports(TestReport, reports) - # Create destination file name - filename = os.path.join( - 'report', - 'inventree', - 'test', - report['file'] - ) + def create_default_build_reports(self): + """ + Create database entries for the default BuildReport templates + (if they do not already exist) + """ - src_file = os.path.join(src_dir, report['file']) - dst_file = os.path.join(settings.MEDIA_ROOT, filename) + try: + from .models import BuildReport + except: + # Database is not ready yet + return - if not os.path.exists(dst_file): - logger.info(f"Copying test report template '{dst_file}'") - shutil.copyfile(src_file, dst_file) + # List of Build reports to copy across + reports = [ + { + 'file': 'inventree_build_order.html', + 'name': 'InvenTree Build Order', + 'description': 'Build Order job sheet', + } + ] - try: - # Check if a report matching the template already exists - if TestReport.objects.filter(template=filename).exists(): - continue - - logger.info(f"Creating new TestReport for '{report['name']}'") - - TestReport.objects.create( - name=report['name'], - description=report['description'], - template=filename, - filters='', - enabled=True - ) - except: - pass + self.create_default_reports(BuildReport, reports) diff --git a/InvenTree/report/models.py b/InvenTree/report/models.py index 906b65be0b..83810f7344 100644 --- a/InvenTree/report/models.py +++ b/InvenTree/report/models.py @@ -62,7 +62,6 @@ class ReportFileUpload(FileSystemStorage): def get_available_name(self, name, max_length=None): - print("Name:", name) return super().get_available_name(name, max_length) @@ -128,7 +127,8 @@ class ReportBase(models.Model): def __str__(self): return "{n} - {d}".format(n=self.name, d=self.description) - def getSubdir(self): + @classmethod + def getSubdir(cls): return '' def rename_file(self, filename): @@ -267,7 +267,8 @@ class TestReport(ReportTemplateBase): Render a TestReport against a StockItem object. """ - def getSubdir(self): + @classmethod + def getSubdir(cls): return 'test' filters = models.CharField( @@ -313,7 +314,8 @@ class BuildReport(ReportTemplateBase): Build order / work order report """ - def getSubdir(self): + @classmethod + def getSubdir(cls): return 'build' filters = models.CharField( @@ -349,7 +351,8 @@ class BillOfMaterialsReport(ReportTemplateBase): Render a Bill of Materials against a Part object """ - def getSubdir(self): + @classmethod + def getSubdir(cls): return 'bom' filters = models.CharField( diff --git a/InvenTree/report/templates/report/inventree_build_order.html b/InvenTree/report/templates/report/inventree_build_order.html new file mode 100644 index 0000000000..72e52a889a --- /dev/null +++ b/InvenTree/report/templates/report/inventree_build_order.html @@ -0,0 +1,3 @@ +{% extends "report/inventree_build_order_base.html" %} + + diff --git a/InvenTree/report/templates/report/inventree_build_order_base.html b/InvenTree/report/templates/report/inventree_build_order_base.html new file mode 100644 index 0000000000..6e8959ffbf --- /dev/null +++ b/InvenTree/report/templates/report/inventree_build_order_base.html @@ -0,0 +1,185 @@ +{% extends "report/inventree_report_base.html" %} + +{% load i18n %} +{% load report %} +{% load inventree_extras %} +{% load markdownify %} + +{% block page_margin %} +margin: 2cm; +margin-top: 4cm; +{% endblock %} + +{% block style %} + +.header-right { + text-align: right; + float: right; +} + +.logo { + height: 20mm; + vertical-align: middle; +} + +.float-right { + float: right; +} + +.part-image { + border: 1px solid; + border-radius: 2px; + vertical-align: middle; + height: 40mm; + display: inline-block; + z-index: 100; +} + +.details-image { + max-width: 25%; + float: right; +} + +.details { + width: 100%; + border: 1px solid; + border-radius: 3px; + padding: 5px; + min-height: 42mm; +} + +.details table { + overflow-x: scroll + overflow-wrap: break-word; + word-wrap: break-word; + width: 70%; + table-layout: fixed; + font-size: 75%; +} + +.details table td:not(:last-child){ + white-space: nowrap; +} + +.details table td:last-child{ + width: 50%; + padding-left: 1cm; + padding-right: 1cm; +} + +.details-table td { + padding-left: 10px; + padding-top: 5px; + padding-bottom: 5px; + border-bottom: 1px solid #555; +} + +{% endblock %} + +{% block bottom_left %} +content: "v{{report_revision}} - {{ date.isoformat }}"; +{% endblock %} + +{% block bottom_center %} +content: "www.currawong.aero"; +{% endblock %} + +{% block header_content %} + + +
    +

    + Build Order {{ build }} +

    + {{ quantity }} x {{ part.full_name }} +
    +
    + +
    +{% endblock %} + +{% block page_content %} + +
    +
    + +
    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% if build.parent %} + + + + + {% endif %} + {% if build.issued_by %} + + + + + {% endif %} + {% if build.responsible %} + + + + + {% endif %} + {% if build.link %} + + + + + {% endif %} +
    {% trans "Build Order" %}{% internal_link build.get_absolute_url build %}
    {% trans "Part" %}{% internal_link part.get_absolute_url part.full_name %}
    {% trans "Quantity" %}{{ build.quantity }}
    {% trans "Description" %}{{ build.title }}
    {% trans "Issued" %}{{ build.creation_date }}
    {% trans "Target Date" %} + {% if build.target_date %} + {{ build.target_date }} + {% else %} + Not specified + {% endif %} +
    {% trans "Sales Order" %} + {% if build.sales_order %} + {% internal_link build.sales_order.get_absolute_url build.sales_order %} + {% else %} + Not specified + {% endif %} +
    {% trans "Required For" %}{% internal_link build.parent.get_absolute_url build.parent %}
    {% trans "Issued By" %}{{ build.issued_by }}
    {% trans "Responsible" %}{{ build.responsible }}
    {% trans "Link" %}{{ build.link }}
    +
    +
    + +

    {% trans "Notes" %}

    + +{% if build.notes %} +{{ build.notes|markdownify }} +{% endif %} + +{% endblock %} \ No newline at end of file From 7d30e75bc6c190acbc78af3fce015df52c77046c Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 16 Feb 2021 20:40:09 +1100 Subject: [PATCH 21/23] Display images in report debug mode --- InvenTree/report/templatetags/report.py | 37 ++++++++++++++++++------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/InvenTree/report/templatetags/report.py b/InvenTree/report/templatetags/report.py index ad4a37972c..9cd28139f2 100644 --- a/InvenTree/report/templatetags/report.py +++ b/InvenTree/report/templatetags/report.py @@ -22,10 +22,17 @@ def asset(filename): Return fully-qualified path for an upload report asset file. """ - path = os.path.join(settings.MEDIA_ROOT, 'report', 'assets', filename) - path = os.path.abspath(path) + # If in debug mode, return URL to the image, not a local file + debug_mode = InvenTreeSetting.get_setting('REPORT_DEBUG_MODE') - return f"file://{path}" + if debug_mode: + path = os.path.join(settings.MEDIA_URL, 'report', 'assets', filename) + else: + + path = os.path.join(settings.MEDIA_ROOT, 'report', 'assets', filename) + path = os.path.abspath(path) + + return f"file://{path}" @register.simple_tag() @@ -34,6 +41,9 @@ def part_image(part): Return a fully-qualified path for a part image """ + # If in debug mode, return URL to the image, not a local file + debug_mode = InvenTreeSetting.get_setting('REPORT_DEBUG_MODE') + if type(part) is Part: img = part.image.name @@ -43,16 +53,23 @@ def part_image(part): else: img = '' - path = os.path.join(settings.MEDIA_ROOT, img) - path = os.path.abspath(path) + if debug_mode: + if img: + return os.path.join(settings.MEDIA_URL, img) + else: + return os.path.join(settings.STATIC_URL, 'img', 'blank_image.png') - if not os.path.exists(path) or not os.path.isfile(path): - # Image does not exist - # Return the 'blank' image - path = os.path.join(settings.STATIC_ROOT, 'img', 'blank_image.png') + else: + path = os.path.join(settings.MEDIA_ROOT, img) path = os.path.abspath(path) - return f"file://{path}" + if not os.path.exists(path) or not os.path.isfile(path): + # Image does not exist + # Return the 'blank' image + path = os.path.join(settings.STATIC_ROOT, 'img', 'blank_image.png') + path = os.path.abspath(path) + + return f"file://{path}" @register.simple_tag() From 7071ef5a5c29e8aa51feafee738966ec72cfa072 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 16 Feb 2021 20:53:28 +1100 Subject: [PATCH 22/23] Fixes for build report template --- .../templates/report/inventree_build_order_base.html | 12 ++++-------- .../templates/report/inventree_report_base.html | 8 ++++++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/InvenTree/report/templates/report/inventree_build_order_base.html b/InvenTree/report/templates/report/inventree_build_order_base.html index 6e8959ffbf..1e2dd26c07 100644 --- a/InvenTree/report/templates/report/inventree_build_order_base.html +++ b/InvenTree/report/templates/report/inventree_build_order_base.html @@ -22,22 +22,19 @@ margin-top: 4cm; vertical-align: middle; } -.float-right { - float: right; -} - .part-image { border: 1px solid; border-radius: 2px; vertical-align: middle; height: 40mm; + width: 100%; display: inline-block; z-index: 100; } .details-image { - max-width: 25%; float: right; + width: 30%; } .details { @@ -49,10 +46,9 @@ margin-top: 4cm; } .details table { - overflow-x: scroll overflow-wrap: break-word; word-wrap: break-word; - width: 70%; + width: 65%; table-layout: fixed; font-size: 75%; } @@ -101,7 +97,7 @@ content: "www.currawong.aero"; {% block page_content %}
    -
    +
    diff --git a/InvenTree/report/templates/report/inventree_report_base.html b/InvenTree/report/templates/report/inventree_report_base.html index 7c2cff8b4a..a8919ac72d 100644 --- a/InvenTree/report/templates/report/inventree_report_base.html +++ b/InvenTree/report/templates/report/inventree_report_base.html @@ -4,8 +4,12 @@