mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
commit
a444f21e64
@ -53,6 +53,8 @@ class EditBuildForm(HelperForm):
|
||||
'parent',
|
||||
'sales_order',
|
||||
'link',
|
||||
'issued_by',
|
||||
'responsible',
|
||||
]
|
||||
|
||||
|
||||
|
27
InvenTree/build/migrations/0026_auto_20210216_1539.py
Normal file
27
InvenTree/build/migrations/0026_auto_20210216_1539.py
Normal file
@ -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'),
|
||||
),
|
||||
]
|
@ -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'),
|
||||
|
@ -45,27 +45,35 @@ src="{% static 'img/blank_image.png' %}"
|
||||
</h3>
|
||||
<hr>
|
||||
<p>{{ build.title }}</p>
|
||||
<div class='btn-row'>
|
||||
<div class='btn-group action-buttons'>
|
||||
{% if roles.build.change %}
|
||||
<button type='button' class='btn btn-default' id='build-edit' title='{% trans "Edit Build" %}'>
|
||||
<span class='fas fa-edit icon-green'/>
|
||||
|
||||
<div class='btn-group action-buttons' role='group'>
|
||||
<!-- Printing options -->
|
||||
<div class='btn-group'>
|
||||
<button id='print-options' title='{% trans "Print actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'>
|
||||
<span class='fas fa-print'></span> <span class='caret'></span>
|
||||
</button>
|
||||
{% if build.is_active %}
|
||||
<button type='button' class='btn btn-default' id='build-complete' title='{% trans "Complete Build" %}'>
|
||||
<span class='fas fa-tools'/>
|
||||
</button>
|
||||
<button type='button' class='btn btn-default btn-glyph' id='build-cancel' title='{% trans "Cancel Build" %}'>
|
||||
<span class='fas fa-times-circle icon-red'/>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if build.status == BuildStatus.CANCELLED and roles.build.delete %}
|
||||
<button type='button' class='btn btn-default btn-glyph' id='build-delete' title='{% trans "Delete Build" %}'>
|
||||
<span class='fas fa-trash-alt icon-red'/>
|
||||
</button>
|
||||
{% endif %}
|
||||
<ul class='dropdown-menu' role='menu'>
|
||||
<li><a href='#' id='print-build-report'><span class='fas fa-file-pdf'></span> {% trans "Print Build Order" %}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<!-- Build actions -->
|
||||
{% if roles.build.change %}
|
||||
<div class='btn-group'>
|
||||
<button id='build-options' title='{% trans "Build actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'>
|
||||
<span class='fas fa-tools'></span> <span class='caret'></span>
|
||||
</button>
|
||||
<ul class='dropdown-menu' role='menu'>
|
||||
<li><a href='#' id='build-edit'><span class='fas fa-edit icon-green'></span> {% trans "Edit Build" %}</a></li>
|
||||
{% if build.is_active %}
|
||||
<li><a href='#' id='build-complete'><span class='fas fa-tools'></span> {% trans "Complete Build" %}</a></li>
|
||||
<li><a href='#' id='build-cancel'><span class='fas fa-times-circle icon-red'></span> {% trans "Cancel Build" %}</a></li>
|
||||
{% endif %}
|
||||
{% if build.status == BuildStatus.CANCELLED and roles.build.delete %}
|
||||
<li><a href='#' id='build-delete'><span class='fas fa-trash-alt'></span> {% trans "Delete Build"% }</a>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@ -121,6 +129,20 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<td><a href="{% url 'so-detail' build.sales_order.id %}">{{ build.sales_order }}</a></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if build.issued_by %}
|
||||
<tr>
|
||||
<td><span class='fas fa-user'></span></td>
|
||||
<td>{% trans "Issued By" %}</td>
|
||||
<td>{{ build.issued_by }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if build.responsible %}
|
||||
<tr>
|
||||
<td><span class='fas fa-users'></span></td>
|
||||
<td>{% trans "Responsible" %}</td>
|
||||
<td>{{ build.responsible }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
{% 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 %}",
|
||||
|
@ -90,31 +90,50 @@
|
||||
<td><a href="{{ build.link }}">{{ build.link }}</a></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if build.issued_by %}
|
||||
<tr>
|
||||
<td><span class='fas fa-calendar-alt'></span></td>
|
||||
<td>{% trans "Created" %}</td>
|
||||
<td>{{ build.creation_date }}</td>
|
||||
<td><span class='fas fa-user'></span></td>
|
||||
<td>{% trans "Issued By" %}</td>
|
||||
<td>{{ build.issued_by }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if build.responsible %}
|
||||
<tr>
|
||||
<td><span class='fas fa-calendar-alt'></span></td>
|
||||
<td>{% trans "Target Date" %}</td>
|
||||
{% if build.target_date %}
|
||||
<td>
|
||||
{{ build.target_date }}{% if build.is_overdue %} <span class='fas fa-calendar-times icon-red'></span>{% endif %}
|
||||
</td>
|
||||
{% else %}
|
||||
<td><i>{% trans "No target date set" %}</i></td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class='fas fa-calendar-alt'></span></td>
|
||||
<td>{% trans "Completed" %}</td>
|
||||
{% if build.completion_date %}
|
||||
<td>{{ build.completion_date }}{% if build.completed_by %}<span class='badge'>{{ build.completed_by }}</span>{% endif %}</td>
|
||||
{% else %}
|
||||
<td><i>{% trans "Build not complete" %}</i></td>
|
||||
{% endif %}
|
||||
<td><span class='fas fa-users'></span></td>
|
||||
<td>{% trans "Responsible" %}</td>
|
||||
<td>{{ build.responsible }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
<div class='col-sm-6'>
|
||||
<table class='table table-striped'>
|
||||
<col width='25'>
|
||||
<tr>
|
||||
<td><span class='fas fa-calendar-alt'></span></td>
|
||||
<td>{% trans "Created" %}</td>
|
||||
<td>{{ build.creation_date }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class='fas fa-calendar-alt'></span></td>
|
||||
<td>{% trans "Target Date" %}</td>
|
||||
{% if build.target_date %}
|
||||
<td>
|
||||
{{ build.target_date }}{% if build.is_overdue %} <span class='fas fa-calendar-times icon-red'></span>{% endif %}
|
||||
</td>
|
||||
{% else %}
|
||||
<td><i>{% trans "No target date set" %}</i></td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class='fas fa-calendar-alt'></span></td>
|
||||
<td>{% trans "Completed" %}</td>
|
||||
{% if build.completion_date %}
|
||||
<td>{{ build.completion_date }}{% if build.completed_by %}<span class='badge'>{{ build.completed_by }}</span>{% endif %}</td>
|
||||
{% else %}
|
||||
<td><i>{% trans "Build not complete" %}</i></td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -22,19 +22,33 @@ InvenTree | {% trans "Build Orders" %}
|
||||
|
||||
<div id='button-toolbar'>
|
||||
<div class='button-toolbar container-fluid' style='float: right;'>
|
||||
{% if roles.build.add %}
|
||||
<button type='button' class="btn btn-success" id='new-build'>
|
||||
<span class='fas fa-tools'></span> {% trans "New Build Order" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
<button class='btn btn-default' type='button' id='view-calendar' title='{% trans "Display calendar view" %}'>
|
||||
<span class='fas fa-calendar-alt'></span>
|
||||
</button>
|
||||
<button class='btn btn-default' type='button' id='view-list' title='{% trans "Display list view" %}'>
|
||||
<span class='fas fa-th-list'></span>
|
||||
</button>
|
||||
<div class='filter-list' id='filter-list-build'>
|
||||
<!-- An empty div in which the filter list will be constructed -->
|
||||
<div class='btn-group'>
|
||||
{% if roles.build.add %}
|
||||
<button type='button' class="btn btn-success" id='new-build'>
|
||||
<span class='fas fa-tools'></span> {% trans "New Build Order" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
<div class='btn-group'>
|
||||
<!-- Print actions -->
|
||||
<button id='build-print-options' class='btn btn-primary dropdown-toggle' data-toggle='dropdown'>
|
||||
<span class='fas fa-print'></span> <span class='caret'></span>
|
||||
</button>
|
||||
<ul class='dropdown-menu'>
|
||||
<li><a href='#' id='multi-build-print' title='{% trans "Print Build Orders" %}'>
|
||||
<span class='fas fa-file-pdf'></span> {% trans "Print Build Orders" %}
|
||||
</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<!-- Buttons to switch between list and calendar views -->
|
||||
<button class='btn btn-default' type='button' id='view-calendar' title='{% trans "Display calendar view" %}'>
|
||||
<span class='fas fa-calendar-alt'></span>
|
||||
</button>
|
||||
<button class='btn btn-default' type='button' id='view-list' title='{% trans "Display list view" %}'>
|
||||
<span class='fas fa-th-list'></span>
|
||||
</button>
|
||||
<div class='filter-list' id='filter-list-build'>
|
||||
<!-- An empty div in which the filter list will be constructed -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -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 %}
|
@ -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):
|
||||
|
@ -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
|
||||
|
@ -35,34 +35,37 @@
|
||||
<span class='fas fa-trash-alt icon-red'></span>
|
||||
</button>
|
||||
<button class='btn btn-primary' type='button' title='{% trans "Import BOM data" %}' id='bom-upload'>
|
||||
<span class='fas fa-file-upload'></span> {% trans "Import from File" %}
|
||||
<span class='fas fa-file-upload'></span>
|
||||
</button>
|
||||
{% if part.variant_of %}
|
||||
<button class='btn btn-default' type='button' title='{% trans "Copy BOM from parent part" %}' id='bom-duplicate'>
|
||||
<span class='fas fa-clone'></span> {% trans "Copy from Parent" %}
|
||||
<span class='fas fa-clone'></span>
|
||||
</button>
|
||||
{% endif %}
|
||||
<button class='btn btn-default' type='button' title='{% trans "New BOM Item" %}' id='bom-item-new'>
|
||||
<span class='fas fa-plus-circle'></span> {% trans "Add Item" %}
|
||||
<span class='fas fa-plus-circle'></span>
|
||||
</button>
|
||||
<button class='btn btn-success' type='button' title='{% trans "Finish Editing" %}' id='editing-finished'>
|
||||
<span class='fas fa-check-circle'></span> {% trans "Finished" %}
|
||||
<span class='fas fa-check-circle'></span>
|
||||
</button>
|
||||
{% elif part.active %}
|
||||
{% if roles.part.change %}
|
||||
<button class='btn btn-primary' type='button' title='{% trans "Edit BOM" %}' id='edit-bom'>
|
||||
<span class='fas fa-edit'></span> {% trans "Edit" %}
|
||||
<span class='fas fa-edit'></span>
|
||||
</button>
|
||||
{% if part.is_bom_valid == False %}
|
||||
<button class='btn btn-success' id='validate-bom' title='{% trans "Validate Bill of Materials" %}' type='button'>
|
||||
<span class='fas fa-clipboard-check'></span> {% trans "Validate" %}
|
||||
<span class='fas fa-clipboard-check'></span>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<button title='{% trans "Export Bill of Materials" %}' class='btn btn-default' id='download-bom' type='button'>
|
||||
<span class='fas fa-file-download'></span> {% trans "Export" %}
|
||||
<span class='fas fa-file-download'></span>
|
||||
</button>
|
||||
<button title='{% trans "Print BOM Report" %}' class='btn btn-default' id='print-bom-report' type='button'>
|
||||
<span class='fas fa-file-pdf'></span>
|
||||
</button>
|
||||
{% endif %}
|
||||
<div class='filter-list' id='filter-list-bom'>
|
||||
<!-- Empty div (will be filled out with avilable BOM filters) -->
|
||||
</div>
|
||||
@ -215,4 +218,8 @@
|
||||
|
||||
{% endif %}
|
||||
|
||||
$("#print-bom-report").click(function() {
|
||||
printBomReports([{{ part.pk }}]);
|
||||
});
|
||||
|
||||
{% endblock %}
|
||||
|
@ -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)
|
||||
|
@ -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<pk>\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<pk>\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
|
||||
|
@ -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)
|
||||
|
35
InvenTree/report/migrations/0011_auto_20210212_2024.py
Normal file
35
InvenTree/report/migrations/0011_auto_20210212_2024.py
Normal file
@ -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'),
|
||||
),
|
||||
]
|
30
InvenTree/report/migrations/0012_buildreport.py
Normal file
30
InvenTree/report/migrations/0012_buildreport.py
Normal file
@ -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,
|
||||
},
|
||||
),
|
||||
]
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
@ -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',
|
||||
]
|
||||
|
@ -0,0 +1,3 @@
|
||||
{% extends "report/inventree_build_order_base.html" %}
|
||||
|
||||
<!-- Refer to the inventree_build_order_base template -->
|
@ -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 %}
|
||||
<img class='logo' src="{% asset 'logo_black_with_black_bird.png' %}" alt="hello" width="150">
|
||||
|
||||
<div class='header-right'>
|
||||
<h3>
|
||||
Build Order {{ build }}
|
||||
</h3>
|
||||
<small>{{ quantity }} x {{ part.full_name }}</small>
|
||||
<br>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
{% endblock %}
|
||||
|
||||
{% block page_content %}
|
||||
|
||||
<div class='details'>
|
||||
<div class='details-image'>
|
||||
<img class='part-image' src="{% part_image part %}">
|
||||
</div>
|
||||
|
||||
<div class='details-container'>
|
||||
|
||||
<table class='details-table'>
|
||||
<tr>
|
||||
<th>{% trans "Build Order" %}</th>
|
||||
<td>{% internal_link build.get_absolute_url build %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{% trans "Part" %}</th>
|
||||
<td>{% internal_link part.get_absolute_url part.full_name %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{% trans "Quantity" %}</th>
|
||||
<td>{{ build.quantity }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{% trans "Description" %}</th>
|
||||
<td>{{ build.title }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{% trans "Issued" %}</th>
|
||||
<td>{{ build.creation_date }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{% trans "Target Date" %}</th>
|
||||
<td>
|
||||
{% if build.target_date %}
|
||||
{{ build.target_date }}
|
||||
{% else %}
|
||||
<i>Not specified</i>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{% trans "Sales Order" %}</th>
|
||||
<td>
|
||||
{% if build.sales_order %}
|
||||
{% internal_link build.sales_order.get_absolute_url build.sales_order %}
|
||||
{% else %}
|
||||
<i>Not specified</i>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% if build.parent %}
|
||||
<tr>
|
||||
<th>{% trans "Required For" %}</th>
|
||||
<td>{% internal_link build.parent.get_absolute_url build.parent %}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if build.issued_by %}
|
||||
<tr>
|
||||
<th>{% trans "Issued By" %}</th>
|
||||
<td>{{ build.issued_by }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if build.responsible %}
|
||||
<tr>
|
||||
<th>{% trans "Responsible" %}</th>
|
||||
<td>{{ build.responsible }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if build.link %}
|
||||
<tr>
|
||||
<th>{% trans "Link" %}</th>
|
||||
<td><a href="{{ build.link }}">{{ build.link }}</a></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>{% trans "Notes" %}</h3>
|
||||
|
||||
{% if build.notes %}
|
||||
{{ build.notes|markdownify }}
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
@ -4,8 +4,12 @@
|
||||
<style>
|
||||
@page {
|
||||
{% block page_style %}
|
||||
size: {% block page_size %}{{ default_page_size }}{% endblock %};
|
||||
margin: {% block page_margin %}2cm{% endblock %};
|
||||
{% block page_size %}
|
||||
size: {{ default_page_size }};
|
||||
{% endblock %}
|
||||
{% block page_margin %}
|
||||
margin: 2cm;
|
||||
{% endblock %}
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-size: 75%;
|
||||
{% endblock %}
|
||||
|
@ -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()
|
||||
|
||||
|
||||
@ -19,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()
|
||||
@ -31,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
|
||||
|
||||
@ -40,13 +53,50 @@ 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()
|
||||
def internal_link(link, text):
|
||||
"""
|
||||
Make a <a></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
|
||||
|
||||
if not base_url.endswith('/'):
|
||||
base_url += '/'
|
||||
|
||||
if base_url.endswith('/') and link.startswith('/'):
|
||||
link = link[1:]
|
||||
|
||||
url = f"{base_url}{link}"
|
||||
|
||||
return mark_safe(f'<a href="{url}">{text}</a>')
|
||||
|
@ -16,6 +16,7 @@
|
||||
{% include "InvenTree/settings/header.html" %}
|
||||
<tbody>
|
||||
{% 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" %}
|
||||
</tbody>
|
||||
|
@ -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',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
@ -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 += `
|
||||
<div class='alert alert-block alert-info'>
|
||||
${items.length} {% trans "stock items selected" %}
|
||||
${items.length} {% trans "items selected" %}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@ -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" %}',
|
||||
{
|
||||
@ -121,7 +121,7 @@ function printTestReports(items, options={}) {
|
||||
}
|
||||
|
||||
// Select report template to print
|
||||
selectTestReport(
|
||||
selectReport(
|
||||
response,
|
||||
items,
|
||||
{
|
||||
@ -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;
|
||||
@ -139,4 +139,111 @@ 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 += `build=${build}&`;
|
||||
});
|
||||
|
||||
window.location.href = href;
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
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 += `part=${part}&`;
|
||||
});
|
||||
|
||||
window.location.href = href;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
@ -120,6 +120,8 @@ class RuleSet(models.Model):
|
||||
'report_reportasset',
|
||||
'report_reportsnippet',
|
||||
'report_testreport',
|
||||
'report_buildreport',
|
||||
'report_billofmaterialsreport',
|
||||
'part_partstar',
|
||||
'users_owner',
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user