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 }}
-
-
+
+
+
+
+ |
+ {% trans "Created" %} |
+ {{ build.creation_date }} |
+
+
+ |
+ {% 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 %}
+
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" %}
@@ -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 %}
+
+
+
+
+
+{% endblock %}
+
+{% block page_content %}
+
+
+
+

+
+
+
+
+
+
+ {% 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 %}
+ |
+
+ {% if build.parent %}
+
+ {% trans "Required For" %} |
+ {% internal_link build.parent.get_absolute_url build.parent %} |
+
+ {% endif %}
+ {% if build.issued_by %}
+
+ {% trans "Issued By" %} |
+ {{ build.issued_by }} |
+
+ {% endif %}
+ {% if build.responsible %}
+
+ {% trans "Responsible" %} |
+ {{ build.responsible }} |
+
+ {% endif %}
+ {% if build.link %}
+
+ {% trans "Link" %} |
+ {{ build.link }} |
+
+ {% endif %}
+
+
+
+
+{% 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 @@