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'), diff --git a/InvenTree/build/templates/build/build_base.html b/InvenTree/build/templates/build/build_base.html index 9ca3fff818..fab9eb8353 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 %} +
+ + +
+ {% endif %}
{% endblock %} @@ -121,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 %} @@ -151,6 +173,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/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" %}
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/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): 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/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/report/admin.py b/InvenTree/report/admin.py index 610b32dd4c..2c008877cc 100644 --- a/InvenTree/report/admin.py +++ b/InvenTree/report/admin.py @@ -3,7 +3,10 @@ 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 BuildReport +from .models import BillOfMaterialsReport class ReportTemplateAdmin(admin.ModelAdmin): @@ -22,5 +25,8 @@ 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(BuildReport, ReportTemplateAdmin) +admin.site.register(BillOfMaterialsReport, ReportTemplateAdmin) diff --git a/InvenTree/report/api.py b/InvenTree/report/api.py index 68a1f23f0d..dd937f0aba 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,16 @@ 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 class ReportListView(generics.ListAPIView): @@ -54,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 = [] @@ -76,6 +78,131 @@ 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 + + builds = params.getlist('build', []) + + 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 + """ + + def get_parts(self): + """ + Return a list of requested part objects + """ + + parts = [] + + params = self.request.query_params + + parts = params.getlist('part', []) + + 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 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, + 'inventree_report.pdf', + content_type='application/pdf' + ) + + class StockItemTestReportList(ReportListView, StockItemReportMixin): """ API endpoint for viewing list of TestReport objects. @@ -83,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) """ @@ -150,7 +276,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 """ @@ -165,67 +291,212 @@ 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 self.print(request, items) + - return Response(data, status=400) +class BOMReportList(ReportListView, PartReportMixin): + """ + API endpoint for viewing a list of BillOfMaterialReport objects. - outputs = [] + Filterably by: - # In debug mode, generate single HTML output, rather than PDF - debug_mode = common.models.InvenTreeSetting.get_setting('REPORT_DEBUG_MODE') + - enabled: Filter by enabled / disabled status + - part: Filter by part(s) + """ - # Merge one or more PDF files into a single download - for item in items: - report = self.get_object() - report.stock_item = item + queryset = BillOfMaterialsReport.objects.all() + serializer_class = BOMReportSerializer - if debug_mode: - outputs.append(report.render_to_string(request)) - else: - outputs.append(report.render(request)) + def filter_queryset(self, queryset): - if debug_mode: + queryset = super().filter_queryset(queryset) + + # List of Part objects to match against + parts = self.get_parts() + + if len(parts) > 0: """ - Contatenate all rendered templates into a single HTML string, - and return the string as a HTML response. + 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. """ - html = "\n".join(outputs) + valid_report_ids = set() - return HttpResponse(html) + for report in queryset.all(): - else: + 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 + + +class BOMReportDetail(generics.RetrieveUpdateDestroyAPIView): + """ + API endpoint for a single BillOfMaterialReport object + """ + + queryset = BillOfMaterialsReport.objects.all() + serializer_class = BOMReportSerializer + + +class BOMReportPrint(generics.RetrieveAPIView, 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) + + +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 Build object + """ + + 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: """ - Concatenate all rendered pages into a single PDF object, - and return the resulting document! + 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! """ - pages = [] + valid_build_ids = set() - 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) + for report in queryset.all(): - pdf = outputs[0].get_document().copy(pages).write_pdf() - else: - pdf = outputs[0].get_document().write_pdf() + matches = True - return InvenTree.helpers.DownloadFile( - pdf, - 'test_report.pdf', - content_type='application/pdf' - ) + 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([ + + # 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'), + ])), + # Stock item test reports url(r'test/', include([ # Detail views 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/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/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 ce1a01e872..83810f7344 100644 --- a/InvenTree/report/models.py +++ b/InvenTree/report/models.py @@ -20,8 +20,10 @@ from django.template.loader import render_to_string from django.core.files.storage import FileSystemStorage from django.core.validators import FileExtensionValidator -import stock.models +import build.models import common.models +import part.models +import stock.models from InvenTree.helpers import validateFilterString @@ -60,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) @@ -70,10 +71,29 @@ 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) + + +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. @@ -107,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): @@ -171,6 +192,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 @@ -185,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') @@ -242,17 +267,15 @@ class TestReport(ReportTemplateBase): Render a TestReport against a StockItem object. """ - def getSubdir(self): + @classmethod + def getSubdir(cls): 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, 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 ] @@ -275,11 +298,80 @@ 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() + } + + +class BuildReport(ReportTemplateBase): + """ + Build order / work order report + """ + + @classmethod + def getSubdir(cls): + return 'build' + + 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, + ] + ) + + def get_context_data(self, request): + """ + Custom context data for the build report + """ + + 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): + """ + Render a Bill of Materials against a Part object + """ + + @classmethod + def getSubdir(cls): + return 'bom' + + 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): + + part = self.object_to_print + + return { + 'part': part, + 'category': part.category, } diff --git a/InvenTree/report/serializers.py b/InvenTree/report/serializers.py index 0cd4d4f40a..f0a449ae49 100644 --- a/InvenTree/report/serializers.py +++ b/InvenTree/report/serializers.py @@ -5,6 +5,8 @@ from InvenTree.serializers import InvenTreeModelSerializer from InvenTree.serializers import InvenTreeAttachmentSerializerField from .models import TestReport +from .models import BuildReport +from .models import BillOfMaterialsReport class TestReportSerializer(InvenTreeModelSerializer): @@ -21,3 +23,35 @@ class TestReportSerializer(InvenTreeModelSerializer): 'filters', 'enabled', ] + + +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) + + class Meta: + model = BillOfMaterialsReport + fields = [ + 'pk', + 'name', + 'description', + 'template', + 'filters', + 'enabled', + ] 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..1e2dd26c07 --- /dev/null +++ b/InvenTree/report/templates/report/inventree_build_order_base.html @@ -0,0 +1,181 @@ +{% 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; +} + +.part-image { + border: 1px solid; + border-radius: 2px; + vertical-align: middle; + height: 40mm; + width: 100%; + display: inline-block; + z-index: 100; +} + +.details-image { + float: right; + width: 30%; +} + +.details { + width: 100%; + border: 1px solid; + border-radius: 3px; + padding: 5px; + min-height: 42mm; +} + +.details table { + overflow-wrap: break-word; + word-wrap: break-word; + width: 65%; + 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 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 @@