Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2021-02-18 00:37:11 +11:00
commit cdeb41e963
52 changed files with 4060 additions and 1962 deletions

View File

@ -47,7 +47,12 @@ class AuthRequiredMiddleware(object):
authorized = False
if 'Authorization' in request.headers.keys():
# Allow static files to be accessed without auth
# Important for e.g. login page
if request.path_info.startswith('/static/'):
authorized = True
elif 'Authorization' in request.headers.keys():
auth = request.headers['Authorization'].strip()
if auth.startswith('Token') and len(auth.split()) == 2:
@ -56,7 +61,7 @@ class AuthRequiredMiddleware(object):
# Does the provided token match a valid user?
if Token.objects.filter(key=token).exists():
allowed = ['/api/', '/media/', '/static/']
allowed = ['/api/', '/media/']
# Only allow token-auth for /media/ or /static/ dirs!
if any([request.path_info.startswith(a) for a in allowed]):

View File

@ -11,6 +11,45 @@
--label-yellow: #fdc82a;
}
.login-screen {
background-image: url("/static/img/paper_splash.jpg");
background-size: cover;
background-repeat: no-repeat;
height: 100%;
font-family: 'Numans', sans-serif;
color: #eee;
}
.login-container {
left: 50%;
position: fixed;
top: 50%;
transform: translate(-50%, -50%);
width: 30%;
align-content: center;
border-radius: 15px;
padding: 20px;
padding-bottom: 35px;
background-color: rgba(50, 50, 50, 0.75);
}
.login-header {
padding-right: 30px;
margin-right: 30px;
}
.login-container input {
background-color: rgba(250, 250, 250, 0.9);
}
.login-button {
background-color: rgba(250, 250, 250, 0.9);
color: #333;
border-color: #AAA;
width: 100%;
border-radius: 5px;
}
.markdownx .row {
margin: 5px;
padding: 5px;
@ -268,6 +307,10 @@
font-style: italic;
}
.rowinherited {
background-color: #dde;
}
.dropdown {
padding-left: 1px;
margin-left: 1px;
@ -574,7 +617,7 @@
margin-bottom: 3px;
}
.modal-form-content {
.modal-form-content-wrapper {
border-radius: 0;
position:relative;
height: auto !important;
@ -708,13 +751,6 @@ input[type="submit"] {
color: #e00;
}
.login {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.part-allocation {
padding: 3px 10px;
border: 1px solid #ccc;

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@ -39,6 +39,11 @@ class EditBuildForm(HelperForm):
help_text=_('Target date for build completion. Build will be overdue after this date.')
)
quantity = RoundingDecimalFormField(
max_digits=10, decimal_places=5,
help_text=_('Number of items to build')
)
class Meta:
model = Build
fields = [
@ -53,6 +58,8 @@ class EditBuildForm(HelperForm):
'parent',
'sales_order',
'link',
'issued_by',
'responsible',
]

View 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'),
),
]

View File

@ -23,7 +23,7 @@ from markdownx.models import MarkdownxField
from mptt.models import MPTTModel, TreeForeignKey
from InvenTree.status_codes import BuildStatus
from InvenTree.helpers import increment, getSetting, normalize
from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode
from InvenTree.validators import validate_build_order_reference
from InvenTree.models import InvenTreeAttachment
@ -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())
@ -61,6 +65,20 @@ class Build(MPTTModel):
verbose_name = _("Build Order")
verbose_name_plural = _("Build Orders")
def format_barcode(self, **kwargs):
"""
Return a JSON string to represent this build as a barcode
"""
return MakeBarcode(
"buildorder",
self.pk,
{
"reference": self.title,
"url": self.get_absolute_url(),
}
)
@staticmethod
def filterByDate(queryset, min_date, max_date):
"""
@ -214,6 +232,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'),

View File

@ -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 %}",

View File

@ -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>

View File

@ -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 %}

View File

@ -675,6 +675,13 @@ class BuildCreate(AjaxCreateView):
initials = super(BuildCreate, self).get_initial().copy()
initials['parent'] = self.request.GET.get('parent', None)
# User has provided a SalesOrder ID
initials['sales_order'] = self.request.GET.get('sales_order', None)
initials['quantity'] = self.request.GET.get('quantity', 1)
part = self.request.GET.get('part', None)
if part:
@ -684,17 +691,20 @@ class BuildCreate(AjaxCreateView):
# User has provided a Part ID
initials['part'] = part
initials['destination'] = part.get_default_location()
to_order = part.quantity_to_order
if to_order < 1:
to_order = 1
initials['quantity'] = to_order
except (ValueError, Part.DoesNotExist):
pass
initials['reference'] = Build.getNextBuildNumber()
initials['parent'] = self.request.GET.get('parent', None)
# User has provided a SalesOrder ID
initials['sales_order'] = self.request.GET.get('sales_order', None)
initials['quantity'] = self.request.GET.get('quantity', 1)
# Pre-fill the issued_by user
initials['issued_by'] = self.request.user
return initials

View File

@ -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

View File

@ -5,6 +5,7 @@ import sys
from django.utils.translation import ugettext as _
from django.conf.urls import url, include
from django.core.exceptions import ValidationError, FieldError
from django_filters.rest_framework import DjangoFilterBackend
@ -119,13 +120,20 @@ class StockItemLabelList(LabelListView, StockItemLabelMixin):
matches = True
# Filter string defined for the StockItemLabel object
filters = InvenTree.helpers.validateFilterString(label.filters)
try:
filters = InvenTree.helpers.validateFilterString(label.filters)
except ValidationError:
continue
for item in items:
item_query = StockItem.objects.filter(pk=item.pk)
if not item_query.filter(**filters).exists():
try:
if not item_query.filter(**filters).exists():
matches = False
break
except FieldError:
matches = False
break
@ -273,13 +281,21 @@ class StockLocationLabelList(LabelListView, StockLocationLabelMixin):
matches = True
# Filter string defined for the StockLocationLabel object
filters = InvenTree.helpers.validateFilterString(label.filters)
try:
filters = InvenTree.helpers.validateFilterString(label.filters)
except:
# Skip if there was an error validating the filters...
continue
for loc in locations:
loc_query = StockLocation.objects.filter(pk=loc.pk)
if not loc_query.filter(**filters).exists():
try:
if not loc_query.filter(**filters).exists():
matches = False
break
except FieldError:
matches = False
break

View File

@ -12,6 +12,7 @@ from blabel import LabelWriter
from django.db import models
from django.core.validators import FileExtensionValidator
from django.core.exceptions import ValidationError, FieldError
from django.utils.translation import gettext_lazy as _
@ -145,9 +146,12 @@ class StockItemLabel(LabelTemplate):
Test if this label template matches a given StockItem object
"""
filters = validateFilterString(self.filters)
items = stock.models.StockItem.objects.filter(**filters)
try:
filters = validateFilterString(self.filters)
items = stock.models.StockItem.objects.filter(**filters)
except (ValidationError, FieldError):
# If an error exists with the "filters" field, return False
return False
items = items.filter(pk=item.pk)
@ -198,9 +202,11 @@ class StockLocationLabel(LabelTemplate):
Test if this label template matches a given StockLocation object
"""
filters = validateFilterString(self.filters)
locs = stock.models.StockLocation.objects.filter(**filters)
try:
filters = validateFilterString(self.filters)
locs = stock.models.StockLocation.objects.filter(**filters)
except (ValidationError, FieldError):
return False
locs = locs.filter(pk=location.pk)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -810,11 +810,35 @@ class BomList(generics.ListCreateAPIView):
queryset = queryset.filter(optional=optional)
# Filter by "inherited" status
inherited = params.get('inherited', None)
if inherited is not None:
inherited = str2bool(inherited)
queryset = queryset.filter(inherited=inherited)
# Filter by part?
part = params.get('part', None)
if part is not None:
queryset = queryset.filter(part=part)
"""
If we are filtering by "part", there are two cases to consider:
a) Bom items which are defined for *this* part
b) Inherited parts which are defined for a *parent* part
So we need to construct two queries!
"""
# First, check that the part is actually valid!
try:
part = Part.objects.get(pk=part)
queryset = queryset.filter(part.get_bom_item_filter())
except (ValueError, Part.DoesNotExist):
pass
# Filter by sub-part?
sub_part = params.get('sub_part', None)

View File

@ -331,6 +331,7 @@ class EditBomItemForm(HelperForm):
'reference',
'overage',
'note',
'inherited',
'optional',
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.0.7 on 2021-02-17 10:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('part', '0062_merge_20210105_0056'),
]
operations = [
migrations.AddField(
model_name='bomitem',
name='inherited',
field=models.BooleanField(default=False, help_text='This BOM item is inherited by BOMs for variant parts', verbose_name='Inherited'),
),
]

View File

@ -14,7 +14,7 @@ from django.urls import reverse
from django.db import models, transaction
from django.db.utils import IntegrityError
from django.db.models import Sum, UniqueConstraint
from django.db.models import Q, Sum, UniqueConstraint
from django.db.models.functions import Coalesce
from django.core.validators import MinValueValidator
@ -41,7 +41,7 @@ from InvenTree.models import InvenTreeTree, InvenTreeAttachment
from InvenTree.fields import InvenTreeURLField
from InvenTree.helpers import decimal2string, normalize
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus, SalesOrderStatus
from build import models as BuildModels
from order import models as OrderModels
@ -418,8 +418,10 @@ class Part(MPTTModel):
p2=str(parent)
))})
bom_items = self.get_bom_items()
# Ensure that the parent part does not appear under any child BOM item!
for item in self.bom_items.all():
for item in bom_items.all():
# Check for simple match
if item.sub_part == parent:
@ -884,20 +886,135 @@ class Part(MPTTModel):
return max(total, 0)
def requiring_build_orders(self):
"""
Return list of outstanding build orders which require this part
"""
# List of BOM that this part is required for
boms = BomItem.objects.filter(sub_part=self)
part_ids = [bom.part.pk for bom in boms]
# Now, get a list of outstanding build orders which require this part
builds = BuildModels.Build.objects.filter(
part__in=part_ids,
status__in=BuildStatus.ACTIVE_CODES
)
return builds
def required_build_order_quantity(self):
"""
Return the quantity of this part required for active build orders
"""
# List of BOM that this part is required for
boms = BomItem.objects.filter(sub_part=self)
part_ids = [bom.part.pk for bom in boms]
# Now, get a list of outstanding build orders which require this part
builds = BuildModels.Build.objects.filter(
part__in=part_ids,
status__in=BuildStatus.ACTIVE_CODES
)
quantity = 0
for build in builds:
bom_item = None
# Match BOM item to build
for bom in boms:
if bom.part == build.part:
bom_item = bom
break
if bom_item is None:
logger.warning("Found null BomItem when calculating required quantity")
continue
build_quantity = build.quantity * bom_item.quantity
quantity += build_quantity
return quantity
def requiring_sales_orders(self):
"""
Return a list of sales orders which require this part
"""
orders = set()
# Get a list of line items for open orders which match this part
open_lines = OrderModels.SalesOrderLineItem.objects.filter(
order__status__in=SalesOrderStatus.OPEN,
part=self
)
for line in open_lines:
orders.add(line.order)
return orders
def required_sales_order_quantity(self):
"""
Return the quantity of this part required for active sales orders
"""
# Get a list of line items for open orders which match this part
open_lines = OrderModels.SalesOrderLineItem.objects.filter(
order__status__in=SalesOrderStatus.OPEN,
part=self
)
quantity = 0
for line in open_lines:
quantity += line.quantity
return quantity
def required_order_quantity(self):
"""
Return total required to fulfil orders
"""
return self.required_build_order_quantity() + self.required_sales_order_quantity()
@property
def quantity_to_order(self):
""" Return the quantity needing to be ordered for this part. """
"""
Return the quantity needing to be ordered for this part.
Here, an "order" could be one of:
- Build Order
- Sales Order
# How many do we need to have "on hand" at any point?
required = self.net_stock - self.minimum_stock
To work out how many we need to order:
if required < 0:
return abs(required)
Stock on hand = self.total_stock
Required for orders = self.required_order_quantity()
Currently on order = self.on_order
Currently building = self.quantity_being_built
"""
# Do not need to order any
return 0
# Total requirement
required = self.required_order_quantity()
# Subtract stock levels
required -= max(self.total_stock, self.minimum_stock)
# Subtract quantity on order
required -= self.on_order
# Subtract quantity being built
required -= self.quantity_being_built
required = self.net_stock
return max(required, 0)
@property
@ -943,8 +1060,10 @@ class Part(MPTTModel):
total = None
bom_items = self.get_bom_items().prefetch_related('sub_part__stock_items')
# Calculate the minimum number of parts that can be built using each sub-part
for item in self.bom_items.all().prefetch_related('sub_part__stock_items'):
for item in bom_items.all():
stock = item.sub_part.available_stock
# If (by some chance) we get here but the BOM item quantity is invalid,
@ -979,16 +1098,22 @@ class Part(MPTTModel):
@property
def quantity_being_built(self):
""" Return the current number of parts currently being built
"""
Return the current number of parts currently being built.
Note: This is the total quantity of Build orders, *not* the number of build outputs.
In this fashion, it is the "projected" quantity of builds
"""
stock_items = self.stock_items.filter(is_building=True)
builds = self.active_builds
query = stock_items.aggregate(
quantity=Coalesce(Sum('quantity'), Decimal(0))
)
quantity = 0
return query['quantity']
for build in builds:
# The remaining items in the build
quantity += build.remaining
return quantity
def build_order_allocations(self):
"""
@ -1068,9 +1193,56 @@ class Part(MPTTModel):
return query['t']
def get_bom_item_filter(self, include_inherited=True):
"""
Returns a query filter for all BOM items associated with this Part.
There are some considerations:
a) BOM items can be defined against *this* part
b) BOM items can be inherited from a *parent* part
We will construct a filter to grab *all* the BOM items!
Note: This does *not* return a queryset, it returns a Q object,
which can be used by some other query operation!
Because we want to keep our code DRY!
"""
bom_filter = Q(part=self)
if include_inherited:
# We wish to include parent parts
parents = self.get_ancestors(include_self=False)
# There are parents available
if parents.count() > 0:
parent_ids = [p.pk for p in parents]
parent_filter = Q(
part__id__in=parent_ids,
inherited=True
)
# OR the filters together
bom_filter |= parent_filter
return bom_filter
def get_bom_items(self, include_inherited=True):
"""
Return a queryset containing all BOM items for this part
By default, will include inherited BOM items
"""
return BomItem.objects.filter(self.get_bom_item_filter(include_inherited=include_inherited))
@property
def has_bom(self):
return self.bom_count > 0
return self.get_bom_items().count() > 0
@property
def has_trackable_parts(self):
@ -1079,7 +1251,7 @@ class Part(MPTTModel):
This is important when building the part.
"""
for bom_item in self.bom_items.all():
for bom_item in self.get_bom_items().all():
if bom_item.sub_part.trackable:
return True
@ -1088,7 +1260,7 @@ class Part(MPTTModel):
@property
def bom_count(self):
""" Return the number of items contained in the BOM for this part """
return self.bom_items.count()
return self.get_bom_items().count()
@property
def used_in_count(self):
@ -1106,7 +1278,10 @@ class Part(MPTTModel):
hash = hashlib.md5(str(self.id).encode())
for item in self.bom_items.all().prefetch_related('sub_part'):
# List *all* BOM items (including inherited ones!)
bom_items = self.get_bom_items().all().prefetch_related('sub_part')
for item in bom_items:
hash.update(str(item.get_item_hash()).encode())
return str(hash.digest())
@ -1125,8 +1300,10 @@ class Part(MPTTModel):
- Saves the current date and the checking user
"""
# Validate each line item too
for item in self.bom_items.all():
# Validate each line item, ignoring inherited ones
bom_items = self.get_bom_items(include_inherited=False)
for item in bom_items.all():
item.validate_hash()
self.bom_checksum = self.get_bom_hash()
@ -1137,7 +1314,10 @@ class Part(MPTTModel):
@transaction.atomic
def clear_bom(self):
""" Clear the BOM items for the part (delete all BOM lines).
"""
Clear the BOM items for the part (delete all BOM lines).
Note: Does *NOT* delete inherited BOM items!
"""
self.bom_items.all().delete()
@ -1154,9 +1334,9 @@ class Part(MPTTModel):
if parts is None:
parts = set()
items = BomItem.objects.filter(part=self.pk)
bom_items = self.get_bom_items().all()
for bom_item in items:
for bom_item in bom_items:
sub_part = bom_item.sub_part
@ -1204,7 +1384,7 @@ class Part(MPTTModel):
def has_complete_bom_pricing(self):
""" Return true if there is pricing information for each item in the BOM. """
for item in self.bom_items.all().select_related('sub_part'):
for item in self.get_bom_items().all().select_related('sub_part'):
if not item.sub_part.has_pricing_info:
return False
@ -1271,7 +1451,7 @@ class Part(MPTTModel):
min_price = None
max_price = None
for item in self.bom_items.all().select_related('sub_part'):
for item in self.get_bom_items().all().select_related('sub_part'):
if item.sub_part.pk == self.pk:
print("Warning: Item contains itself in BOM")
@ -1339,8 +1519,11 @@ class Part(MPTTModel):
if clear:
# Remove existing BOM items
# Note: Inherited BOM items are *not* deleted!
self.bom_items.all().delete()
# Copy existing BOM items from another part
# Note: Inherited BOM Items will *not* be duplicated!!
for bom_item in other.bom_items.all():
# If this part already has a BomItem pointing to the same sub-part,
# delete that BomItem from this part first!
@ -1856,6 +2039,7 @@ class BomItem(models.Model):
overage: Estimated losses for a Build. Can be expressed as absolute value (e.g. '7') or a percentage (e.g. '2%')
note: Note field for this BOM item
checksum: Validation checksum for the particular BOM line item
inherited: This BomItem can be inherited by the BOMs of variant parts
"""
def save(self, *args, **kwargs):
@ -1895,6 +2079,12 @@ class BomItem(models.Model):
checksum = models.CharField(max_length=128, blank=True, help_text=_('BOM line checksum'))
inherited = models.BooleanField(
default=False,
verbose_name=_('Inherited'),
help_text=_('This BOM item is inherited by BOMs for variant parts'),
)
def get_item_hash(self):
""" Calculate the checksum hash of this BOM line item:

View File

@ -381,17 +381,18 @@ class BomItemSerializer(InvenTreeModelSerializer):
class Meta:
model = BomItem
fields = [
'inherited',
'note',
'optional',
'overage',
'pk',
'part',
'part_detail',
'sub_part',
'sub_part_detail',
'quantity',
'reference',
'sub_part',
'sub_part_detail',
# 'price_range',
'optional',
'overage',
'note',
'validated',
]

View File

@ -35,45 +35,46 @@
<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>
</div>
</div>
<table class='table table-striped table-condensed' data-toolbar="#button-toolbar" id='bom-table'>
<table class='table table-bom table-condensed' data-toolbar="#button-toolbar" id='bom-table'>
</table>
<table class='table table-striped table-condensed' id='test-table'></table>
{% endblock %}
{% block js_load %}
@ -215,4 +216,8 @@
{% endif %}
$("#print-bom-report").click(function() {
printBomReports([{{ part.pk }}]);
});
{% endblock %}

View File

@ -97,11 +97,13 @@
</a></td>
</tr>
{% endif %}
{% if part.units %}
<tr>
<td></td>
<td><b>{% trans "Units" %}</b></td>
<td>{{ part.units }}</td>
</tr>
{% endif %}
{% if part.minimum_stock > 0 %}
<tr>
<td><span class='fas fa-greater-than-equal'></span></td>

View File

@ -119,36 +119,35 @@
<td>
<h4>{% trans "Available Stock" %}</h4>
</td>
<td><h4>{% decimal part.available_stock %} {{ part.units }}</h4></td>
<td><h4>{% decimal available %}{% if part.units %} {{ part.units }}{% endif %}</h4></td>
</tr>
<tr>
<td><span class='fas fa-map-marker-alt'></span></td>
<td>{% trans "In Stock" %}</td>
<td>{% include "part/stock_count.html" %}</td>
</tr>
{% if not part.is_template %}
{% if part.build_order_allocation_count > 0 %}
<tr>
<td><span class='fas fa-dolly'></span></td>
<td>{% trans "Allocated to Build Orders" %}</td>
<td>{% decimal part.build_order_allocation_count %}</td>
</tr>
{% endif %}
{% if part.sales_order_allocation_count > 0 %}
<tr>
<td><span class='fas fa-dolly'></span></td>
<td>{% trans "Allocated to Sales Orders" %}</td>
<td>{% decimal part.sales_order_allocation_count %}</td>
</tr>
{% endif %}
{% if part.on_order > 0 %}
{% if on_order > 0 %}
<tr>
<td><span class='fas fa-shopping-cart'></span></td>
<td>{% trans "On Order" %}</td>
<td>{% decimal part.on_order %}</td>
<td>{% decimal on_order %}</td>
</tr>
{% endif %}
{% if required > 0 %}
<tr>
<td><span class='fas fa-clipboard-list'></span></td>
<td>{% trans "Required for Orders" %}</td>
<td>{% decimal required %}
</tr>
{% endif %}
{% if allocated > 0 %}
<tr>
<td><span class='fas fa-dolly'></span></td>
<td>{% trans "Allocated to Orders" %}</td>
<td>{% decimal allocated %}</td>
</tr>
{% endif %}
{% if not part.is_template %}
{% if part.assembly %}
<tr>
@ -162,11 +161,11 @@
<td>{% trans "Can Build" %}</td>
<td>{% decimal part.can_build %}</td>
</tr>
{% if part.quantity_being_built > 0 %}
{% if quantity_being_built > 0 %}
<tr>
<td></td>
<td>{% trans "Underway" %}</td>
<td>{% decimal part.quantity_being_built %}</td>
<td>{% trans "Building" %}</td>
<td>{% decimal quantity_being_built %}</td>
</tr>
{% endif %}
{% endif %}

View File

@ -1,10 +1,10 @@
{% load inventree_extras %}
{% load i18n %}
{% decimal part.total_stock %}
{% decimal total_stock %}
{% if part.total_stock == 0 %}
{% if total_stock == 0 %}
<span class='label label-danger label-right'>{% trans "No Stock" %}</span>
{% elif part.total_stock < part.minimum_stock %}
{% elif total_stock < part.minimum_stock %}
<span class='label label-warning label-right'>{% trans "Low Stock" %}</span>
{% endif %}

View File

@ -15,12 +15,12 @@
{% endif %}
{% if not part.virtual %}
<li{% ifequal tab 'stock' %} class="active"{% endifequal %}>
<a href="{% url 'part-stock' part.id %}">{% trans "Stock" %} <span class="badge">{% decimal part.total_stock %}</span></a>
<a href="{% url 'part-stock' part.id %}">{% trans "Stock" %} <span class="badge">{% decimal total_stock %}</span></a>
</li>
{% endif %}
{% if part.component or part.salable or part.used_in_count > 0 %}
<li{% ifequal tab 'allocation' %} class="active"{% endifequal %}>
<a href="{% url 'part-allocation' part.id %}">{% trans "Allocated" %} <span class="badge">{% decimal part.allocation_count %}</span></a>
<a href="{% url 'part-allocation' part.id %}">{% trans "Allocated" %} <span class="badge">{% decimal allocated %}</span></a>
</li>
{% endif %}
{% if part.assembly %}

View File

@ -792,6 +792,22 @@ class PartDetail(InvenTreeRoleMixin, DetailView):
context['starred'] = part.isStarredBy(self.request.user)
context['disabled'] = not part.active
# Pre-calculate complex queries so they only need to be performed once
context['total_stock'] = part.total_stock
context['quantity_being_built'] = part.quantity_being_built
context['required_build_order_quantity'] = part.required_build_order_quantity()
context['allocated_build_order_quantity'] = part.build_order_allocation_count()
context['required_sales_order_quantity'] = part.required_sales_order_quantity()
context['allocated_sales_order_quantity'] = part.sales_order_allocation_count()
context['available'] = part.available_stock
context['on_order'] = part.on_order
context['required'] = context['required_build_order_quantity'] + context['required_sales_order_quantity']
context['allocated'] = context['allocated_build_order_quantity'] + context['allocated_sales_order_quantity']
return context

View File

@ -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)

View File

@ -3,6 +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 ValidationError, FieldError
from django.http import HttpResponse
from django_filters.rest_framework import DjangoFilterBackend
@ -15,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):
@ -53,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 = []
@ -75,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.
@ -82,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)
"""
@ -114,12 +241,19 @@ class StockItemTestReportList(ReportListView, StockItemReportMixin):
matches = True
# Filter string defined for the report object
filters = InvenTree.helpers.validateFilterString(report.filters)
try:
filters = InvenTree.helpers.validateFilterString(report.filters)
except:
continue
for item in items:
item_query = StockItem.objects.filter(pk=item.pk)
if not item_query.filter(**filters).exists():
try:
if not item_query.filter(**filters).exists():
matches = False
break
except FieldError:
matches = False
break
@ -142,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
"""
@ -157,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

View File

@ -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)

View 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'),
),
]

View 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,
},
),
]

View File

@ -13,14 +13,17 @@ import datetime
from django.db import models
from django.conf import settings
from django.core.exceptions import ValidationError, FieldError
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
@ -59,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)
@ -69,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.
@ -106,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):
@ -170,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
@ -184,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')
@ -241,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
]
@ -262,9 +286,11 @@ class TestReport(ReportTemplateBase):
Test if this report template matches a given StockItem objects
"""
filters = validateFilterString(self.filters)
items = stock.models.StockItem.objects.filter(**filters)
try:
filters = validateFilterString(self.filters)
items = stock.models.StockItem.objects.filter(**filters)
except (ValidationError, FieldError):
return False
# Ensure the provided StockItem object matches the filters
items = items.filter(pk=item.pk)
@ -272,11 +298,82 @@ 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,
'bom_items': my_build.part.get_bom_items(),
'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,
'bom_items': part.get_bom_items(),
}

View File

@ -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',
]

View File

@ -0,0 +1,3 @@
{% extends "report/inventree_build_order_base.html" %}
<!-- Refer to the inventree_build_order_base template -->

View File

@ -0,0 +1,182 @@
{% extends "report/inventree_report_base.html" %}
{% load i18n %}
{% load report %}
{% load inventree_extras %}
{% load markdownify %}
{% load qr_code %}
{% 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 %}

View File

@ -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 %}

View File

@ -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>')

View File

@ -9,7 +9,7 @@ from __future__ import unicode_literals
import os
from django.utils.translation import gettext_lazy as _
from django.core.exceptions import ValidationError
from django.core.exceptions import ValidationError, FieldError
from django.urls import reverse
from django.db import models, transaction
@ -1365,10 +1365,13 @@ class StockItem(MPTTModel):
for test_report in report.models.TestReport.objects.filter(enabled=True):
filters = helpers.validateFilterString(test_report.filters)
if item_query.filter(**filters).exists():
reports.append(test_report)
# Attempt to validate report filter (skip if invalid)
try:
filters = helpers.validateFilterString(test_report.filters)
if item_query.filter(**filters).exists():
reports.append(test_report)
except (ValidationError, FieldError):
continue
return reports
@ -1391,10 +1394,13 @@ class StockItem(MPTTModel):
for lbl in label.models.StockItemLabel.objects.filter(enabled=True):
filters = helpers.validateFilterString(lbl.filters)
try:
filters = helpers.validateFilterString(lbl.filters)
if item_query.filter(**filters).exists():
labels.append(lbl)
if item_query.filter(**filters).exists():
labels.append(lbl)
except (ValidationError, FieldError):
continue
return labels

View File

@ -110,7 +110,7 @@ class StockTest(TestCase):
# The "is_building" quantity should not be counted here
self.assertEqual(part.total_stock, n + 5)
self.assertEqual(part.quantity_being_built, 100)
self.assertEqual(part.quantity_being_built, 1)
def test_loc_count(self):
self.assertEqual(StockLocation.objects.count(), 7)

View File

@ -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>

View File

@ -137,6 +137,16 @@ function loadBomTable(table, options) {
checkbox: true,
visible: true,
switchable: false,
formatter: function(value, row, index, field) {
// Disable checkbox if the row is defined for a *different* part!
if (row.part != options.parent_id) {
return {
disabled: true,
};
} else {
return value;
}
}
});
}
@ -254,6 +264,32 @@ function loadBomTable(table, options) {
});
*/
cols.push({
field: 'optional',
title: '{% trans "Optional" %}',
searchable: false,
});
cols.push({
field: 'inherited',
title: '{% trans "Inherited" %}',
searchable: false,
formatter: function(value, row, index, field) {
// This BOM item *is* inheritable, but is defined for this BOM
if (!row.inherited) {
return "-";
} else if (row.part == options.parent_id) {
return '{% trans "Inherited" %}';
} else {
// If this BOM item is inherited from a parent part
return renderLink(
'{% trans "View BOM" %}',
`/part/${row.part}/bom/`,
);
}
}
});
cols.push(
{
'field': 'can_build',
@ -330,7 +366,12 @@ function loadBomTable(table, options) {
return html;
} else {
return '';
// Return a link to the external BOM
return renderLink(
'{% trans "View BOM" %}',
`/part/${row.part}/bom/`
);
}
}
});
@ -379,15 +420,24 @@ function loadBomTable(table, options) {
sortable: true,
search: true,
rowStyle: function(row, index) {
if (row.validated) {
return {
classes: 'rowvalid'
};
} else {
return {
classes: 'rowinvalid'
};
var classes = [];
// Shade rows differently if they are for different parent parts
if (row.part != options.parent_id) {
classes.push('rowinherited');
}
if (row.validated) {
classes.push('rowvalid');
} else {
classes.push('rowinvalid');
}
return {
classes: classes.join(' '),
};
},
formatNoMatches: function() {
return '{% trans "No BOM items found" %}';

View File

@ -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',
]
);
}

View File

@ -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;
}
}
);
}
}
)
}

View File

@ -101,7 +101,7 @@ function loadStockTestResultsTable(table, options) {
},
{
field: 'test_name',
title: "{% trans "Test Name" %}",
title: '{% trans "Test Name" %}',
sortable: true,
formatter: function(value, row) {
var html = value;
@ -925,7 +925,7 @@ function loadStockTrackingTable(table, options) {
}
else
{
return "{% trans "No user information" %}";
return '{% trans "No user information" %}';
}
}
});
@ -1206,7 +1206,7 @@ function loadInstalledInTable(table, options) {
columns: [
{
checkbox: true,
title: '{% trans 'Select' %}',
title: '{% trans "Select" %}',
searchable: false,
switchable: false,
},

View File

@ -44,6 +44,10 @@ function getAvailableTableFilters(tableKey) {
type: 'bool',
title: '{% trans "Validated" %}',
},
inherited: {
type: 'bool',
title: '{% trans "Inherited" %}',
}
};
}

View File

@ -11,7 +11,7 @@ function deleteButton(url, text='Delete') {
function renderLink(text, url, options={}) {
if (url == null || url === '') {
if (url === null || url === undefined || url === '') {
return text;
}
@ -19,14 +19,6 @@ function renderLink(text, url, options={}) {
var remove_http = options.remove_http || false;
if (remove_http) {
if (text.startsWith('http://')) {
text = text.slice(7);
} else if (text.startsWith('https://')) {
text = text.slice(8);
}
}
// Shorten the displayed length if required
if ((max_length > 0) && (text.length > max_length)) {
var slice_length = (max_length - 3) / 2;
@ -53,6 +45,10 @@ function linkButtonsToSelection(table, buttons) {
* The buttons will only be enabled if there is at least one row selected
*/
if (typeof table === 'string') {
table = $(table);
}
// Initially set the enable state of the buttons
enableButtons(buttons, table.bootstrapTable('getSelections').length > 0);

View File

@ -9,10 +9,13 @@
</button>
<h3 id='modal-title'><i>Form Title Here</i></h3>
</div>
<div class='alert alert-block alert-danger' id='form-validation-warning' style='display: none;'>
{% trans "Form errors exist" %}
</div>
<div class='modal-form-content'>
<div class='modal-form-content-wrapper'>
<div class='alert alert-block alert-danger' id='form-validation-warning' style='display: none;'>
{% trans "Form errors exist" %}
</div>
<div class='modal-form-content'>
<!-- Form content will be injected here-->
</div>
</div>
<div class='modal-footer'>
<button type='button' class='btn btn-default' id='modal-form-close' data-dismiss='modal'>{% trans "Close" %}</button>
@ -57,8 +60,8 @@
<div class='modal-form-content'>
</div>
<div class='modal-footer'>
<button type='button' class='btn btn-default' id='modal-form-cancel' data-dismiss='modal'>Cancel</button>
<button type='button' class='btn btn-primary' id='modal-form-accept'>Accept</button>
<button type='button' class='btn btn-default' id='modal-form-cancel' data-dismiss='modal'>{% trans "Cancel" %}</button>
<button type='button' class='btn btn-primary' id='modal-form-accept'>{% trans "Accept" %}</button>
</div>
</div>
</div>
@ -76,7 +79,7 @@
<div class='modal-form-content'>
</div>
<div class='modal-footer'>
<button type='button' class='btn btn-default' data-dismiss='modal'>Close</button>
<button type='button' class='btn btn-default' data-dismiss='modal'>{% trans "Close" %}</button>
</div>
</div>
</div>

View File

@ -14,38 +14,85 @@
<link rel="stylesheet" href="{% static 'css/select2.css' %}">
<link rel="stylesheet" href="{% static 'css/bootstrap-table.css' %}">
<link rel="stylesheet" href="{% static 'css/inventree.css' %}">
<link rel="stylesheet" href="{% static 'fontawesome/css/brands.css' %}">
<link rel="stylesheet" href="{% static 'fontawesome/css/solid.css' %}">
<script type='text/javascript' src="{% static 'fontawesome/js/solid.js' %}"></script>
<script type='text/javascript' src="{% static 'fontawesome/js/brands.js' %}"></script>
<script type='text/javascript' src="{% static 'fontawesome/js/fontawesome.js' %}"></script>
<style>
.login-error {
color: #F88;
}
</style>
<title>
InvenTree
</title>
</head>
<body>
<body class='login-screen'>
<!--
Background Image Attribution: https://unsplash.com/photos/Ixvv3YZkd7w
-->
<div class='main body-wrapper login-screen'>
<div class='main body-wrapper'>
<div class='login-container'>
<div class="row">
<div class='container-fluid'>
<div class='clearfix content-heading login-header'>
<img class="pull-left" src="{% static 'img/inventree.png' %}" width="60" height="60"/>
<span><h3>InvenTree</h3></span>
</div>
<hr>
<div class='container-fluid'>
<form method="post" action=''>
{% csrf_token %}
<div class='login'>
<div class="row">
<div class='container-fluid'>
<div class='clearfix content-heading'>
<img class="pull-left" src="{% static 'img/inventree.png' %}" width="60" height="60"/> <h3>InvenTree</h3>
</div>
<hr>
{% load crispy_forms_tags %}
<div class='container-fluid'>
<form method="post" action=''>
{% csrf_token %}
{% load crispy_forms_tags %}
{{ form|crispy }}
<div id="div_id_username" class="form-group">
<label for="id_username" class="control-label requiredField">{% trans "Username" %}<span class="asteriskField">*</span></label>
<div class="controls ">
<div class='input-group'>
<div class='input-group-addon'>
<span class='fas fa-user'></span>
</div>
<input type="text" name="username" autofocus autocapitalize="none" autocomplete="username" maxlength="150" class="textinput textInput form-control" required id="id_username" placeholder='{% trans "Enter username" %}'>
</div>
</div>
</div>
<div id="div_id_password" class="form-group">
<label for="id_password" class="control-label requiredField">{% trans "Password" %}<span class="asteriskField">*</span></label>
<div class='controls'>
<div class="input-group">
<div class='input-group-addon'>
<span class='fas fa-key'></span>
</div>
<input type="password" name="password" autocomplete="current-password" class="textinput textInput form-control" required id="id_password" placeholder='{% trans "Enter password" %}'>
</div>
</div>
</div>
{% if form.errors %}
<div class='login-error'>
<b>{% trans "Username / password combination is incorrect" %}</b>
</div>
{% endif %}
<hr>
<button class='pull-right btn btn-primary login-button' type="submit">{% trans "Login" %}</button>
<button class='pull-right btn btn-primary' type="submit">{% trans "Login" %}</button>
</form>
</form>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -120,6 +120,8 @@ class RuleSet(models.Model):
'report_reportasset',
'report_reportsnippet',
'report_testreport',
'report_buildreport',
'report_billofmaterialsreport',
'part_partstar',
'users_owner',

View File

@ -349,12 +349,22 @@ def backup(c):
Create a backup of database models and uploaded media files.
Backup files will be written to the 'backup_dir' file specified in 'config.yaml'
"""
manage(c, 'dbbackup')
manage(c, 'mediabackup')
@task
def restore(c):
"""
Restores database models and media files.
Backup files are read from the 'backup_dir' file specified in 'config.yaml'
"""
manage(c, 'dbrestore')
manage(c, 'mediarestore')
@task(help={'address': 'Server address:port (default=127.0.0.1:8000)'})
def server(c, address="127.0.0.1:8000"):
"""