Merge branch 'inventree:master' into webhooks

This commit is contained in:
Matthias Mair 2021-12-29 23:54:48 +01:00 committed by GitHub
commit b6b6e10f4d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
63 changed files with 20963 additions and 16113 deletions

View File

@ -26,9 +26,9 @@ jobs:
- name: Build Docker Image - name: Build Docker Image
run: | run: |
cd docker cd docker
docker-compose -f docker-compose.dev.yml build docker-compose -f docker-compose.sqlite.yml build
docker-compose -f docker-compose.dev.yml run inventree-dev-server invoke update docker-compose -f docker-compose.sqlite.yml run inventree-dev-server invoke update
docker-compose -f docker-compose.dev.yml up -d docker-compose -f docker-compose.sqlite.yml up -d
- name: Sleepy Time - name: Sleepy Time
run: sleep 60 run: sleep 60
- name: Test API - name: Test API

View File

@ -14,6 +14,10 @@
--bs-body-color: #68686a; --bs-body-color: #68686a;
} }
main {
overflow-x: clip;
}
.login-screen { .login-screen {
background: url(/static/img/paper_splash.jpg) no-repeat center fixed; background: url(/static/img/paper_splash.jpg) no-repeat center fixed;
background-size: cover; background-size: cover;

View File

@ -252,6 +252,9 @@ class StockHistoryCode(StatusCode):
SPLIT_FROM_PARENT = 40 SPLIT_FROM_PARENT = 40
SPLIT_CHILD_ITEM = 42 SPLIT_CHILD_ITEM = 42
# Stock merging operations
MERGED_STOCK_ITEMS = 45
# Build order codes # Build order codes
BUILD_OUTPUT_CREATED = 50 BUILD_OUTPUT_CREATED = 50
BUILD_OUTPUT_COMPLETED = 55 BUILD_OUTPUT_COMPLETED = 55
@ -288,6 +291,8 @@ class StockHistoryCode(StatusCode):
SPLIT_FROM_PARENT: _('Split from parent item'), SPLIT_FROM_PARENT: _('Split from parent item'),
SPLIT_CHILD_ITEM: _('Split child item'), SPLIT_CHILD_ITEM: _('Split child item'),
MERGED_STOCK_ITEMS: _('Merged stock items'),
SENT_TO_CUSTOMER: _('Sent to customer'), SENT_TO_CUSTOMER: _('Sent to customer'),
RETURNED_FROM_CUSTOMER: _('Returned from customer'), RETURNED_FROM_CUSTOMER: _('Returned from customer'),

View File

@ -12,10 +12,14 @@ import common.models
INVENTREE_SW_VERSION = "0.6.0 dev" INVENTREE_SW_VERSION = "0.6.0 dev"
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 21 INVENTREE_API_VERSION = 22
""" """
Increment this API version number whenever there is a significant change to the API that any clients need to know about Increment this API version number whenever there is a significant change to the API that any clients need to know about
v22 -> 2021-12-20
- Adds API endpoint to "merge" multiple stock items
v21 -> 2021-12-04 v21 -> 2021-12-04
- Adds support for multiple "Shipments" against a SalesOrder - Adds support for multiple "Shipments" against a SalesOrder
- Refactors process for stock allocation against a SalesOrder - Refactors process for stock allocation against a SalesOrder

View File

@ -45,10 +45,10 @@
{% endif %} {% endif %}
</ul> </ul>
</div> </div>
</div>
{% include "filter_list.html" with id="supplier-part" %} {% include "filter_list.html" with id="supplier-part" %}
</div> </div>
</div> </div>
</div>
{% endif %} {% endif %}
<table class='table table-striped table-condensed' id='supplier-part-table' data-toolbar='#supplier-part-button-toolbar'> <table class='table table-striped table-condensed' id='supplier-part-table' data-toolbar='#supplier-part-button-toolbar'>
@ -92,8 +92,8 @@
{% endif %} {% endif %}
</ul> </ul>
</div> </div>
{% include "filter_list.html" with id="manufacturer-part" %}
</div> </div>
{% include "filter_list.html" with id="supplier-part" %}
</div> </div>
</div> </div>
{% endif %} {% endif %}
@ -169,10 +169,10 @@
</div> </div>
<div class='panel-content'> <div class='panel-content'>
<div id='assigned-stock-button-toolbar'> <div id='assigned-stock-button-toolbar'>
{% include "filter_list.html" with id="stock" %} {% include "filter_list.html" with id="customerstock" %}
</div> </div>
<table class='table table-striped table-condensed' id='assigned-stock-table' data-toolbar='#bassigned-stock-utton-toolbar'></table> <table class='table table-striped table-condensed' id='assigned-stock-table' data-toolbar='#assigned-stock-button-toolbar'></table>
</div> </div>
</div> </div>
@ -225,6 +225,7 @@
}, },
url: "{% url 'api-stock-list' %}", url: "{% url 'api-stock-list' %}",
filterKey: "customerstock", filterKey: "customerstock",
filterTarget: '#filter-list-customerstock',
}); });
{% if company.is_customer %} {% if company.is_customer %}

View File

@ -25,6 +25,7 @@
<div class='panel-content'> <div class='panel-content'>
<div id='button-toolbar'> <div id='button-toolbar'>
{% include "filter_list.html" with id='company' %}
</div> </div>
<table class='table table-striped table-condensed' id='company-table' data-toolbar='#button-toolbar'> <table class='table table-striped table-condensed' id='company-table' data-toolbar='#button-toolbar'>

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

View File

@ -628,29 +628,31 @@ class SalesOrder(Order):
Throws a ValidationError if cannot be completed. Throws a ValidationError if cannot be completed.
""" """
try:
# Order without line items cannot be completed # Order without line items cannot be completed
if self.lines.count() == 0: if self.lines.count() == 0:
if raise_error:
raise ValidationError(_('Order cannot be completed as no parts have been assigned')) raise ValidationError(_('Order cannot be completed as no parts have been assigned'))
# Only a PENDING order can be marked as SHIPPED # Only a PENDING order can be marked as SHIPPED
elif self.status != SalesOrderStatus.PENDING: elif self.status != SalesOrderStatus.PENDING:
if raise_error:
raise ValidationError(_('Only a pending order can be marked as complete')) raise ValidationError(_('Only a pending order can be marked as complete'))
elif self.pending_shipment_count > 0: elif self.pending_shipment_count > 0:
if raise_error:
raise ValidationError(_("Order cannot be completed as there are incomplete shipments")) raise ValidationError(_("Order cannot be completed as there are incomplete shipments"))
elif self.pending_line_count > 0: elif self.pending_line_count > 0:
if raise_error:
raise ValidationError(_("Order cannot be completed as there are incomplete line items")) raise ValidationError(_("Order cannot be completed as there are incomplete line items"))
else: except ValidationError as e:
return True
if raise_error:
raise e
else:
return False return False
return True
def complete_order(self, user): def complete_order(self, user):
""" """
Mark this order as "complete" Mark this order as "complete"

View File

@ -454,6 +454,76 @@ class PartSerialNumberDetail(generics.RetrieveAPIView):
return Response(data) return Response(data)
class PartCopyBOM(generics.CreateAPIView):
"""
API endpoint for duplicating a BOM
"""
queryset = Part.objects.all()
serializer_class = part_serializers.PartCopyBOMSerializer
def get_serializer_context(self):
ctx = super().get_serializer_context()
try:
ctx['part'] = Part.objects.get(pk=self.kwargs.get('pk', None))
except:
pass
return ctx
class PartValidateBOM(generics.RetrieveUpdateAPIView):
"""
API endpoint for 'validating' the BOM for a given Part
"""
class BOMValidateSerializer(serializers.ModelSerializer):
class Meta:
model = Part
fields = [
'checksum',
'valid',
]
checksum = serializers.CharField(
read_only=True,
source='bom_checksum',
)
valid = serializers.BooleanField(
write_only=True,
default=False,
label=_('Valid'),
help_text=_('Validate entire Bill of Materials'),
)
def validate_valid(self, valid):
if not valid:
raise ValidationError(_('This option must be selected'))
queryset = Part.objects.all()
serializer_class = BOMValidateSerializer
def update(self, request, *args, **kwargs):
part = self.get_object()
partial = kwargs.pop('partial', False)
serializer = self.get_serializer(part, data=request.data, partial=partial)
serializer.is_valid(raise_exception=True)
part.validate_bom(request.user)
return Response({
'checksum': part.bom_checksum,
})
class PartDetail(generics.RetrieveUpdateDestroyAPIView): class PartDetail(generics.RetrieveUpdateDestroyAPIView):
""" API endpoint for detail view of a single Part object """ """ API endpoint for detail view of a single Part object """
@ -1585,6 +1655,12 @@ part_api_urls = [
# Endpoint for extra serial number information # Endpoint for extra serial number information
url(r'^serial-numbers/', PartSerialNumberDetail.as_view(), name='api-part-serial-number-detail'), url(r'^serial-numbers/', PartSerialNumberDetail.as_view(), name='api-part-serial-number-detail'),
# Endpoint for duplicating a BOM for the specific Part
url(r'^bom-copy/', PartCopyBOM.as_view(), name='api-part-bom-copy'),
# Endpoint for validating a BOM for the specific Part
url(r'^bom-validate/', PartValidateBOM.as_view(), name='api-part-bom-validate'),
# Part detail endpoint # Part detail endpoint
url(r'^.*$', PartDetail.as_view(), name='api-part-detail'), url(r'^.*$', PartDetail.as_view(), name='api-part-detail'),
])), ])),

View File

@ -55,54 +55,6 @@ class PartImageDownloadForm(HelperForm):
] ]
class BomDuplicateForm(HelperForm):
"""
Simple confirmation form for BOM duplication.
Select which parent to select from.
"""
parent = PartModelChoiceField(
label=_('Parent Part'),
help_text=_('Select parent part to copy BOM from'),
queryset=Part.objects.filter(is_template=True),
)
clear = forms.BooleanField(
required=False, initial=True,
help_text=_('Clear existing BOM items')
)
confirm = forms.BooleanField(
required=False, initial=False,
label=_('Confirm'),
help_text=_('Confirm BOM duplication')
)
class Meta:
model = Part
fields = [
'parent',
'clear',
'confirm',
]
class BomValidateForm(HelperForm):
""" Simple confirmation form for BOM validation.
User is presented with a single checkbox input,
to confirm that the BOM for this part is valid
"""
validate = forms.BooleanField(required=False, initial=False, label=_('validate'), help_text=_('Confirm that the BOM is correct'))
class Meta:
model = Part
fields = [
'validate'
]
class BomMatchItemForm(MatchItemForm): class BomMatchItemForm(MatchItemForm):
""" Override MatchItemForm fields """ """ Override MatchItemForm fields """

View File

@ -481,7 +481,7 @@ class Part(MPTTModel):
def __str__(self): def __str__(self):
return f"{self.full_name} - {self.description}" return f"{self.full_name} - {self.description}"
def checkAddToBOM(self, parent): def check_add_to_bom(self, parent, raise_error=False, recursive=True):
""" """
Check if this Part can be added to the BOM of another part. Check if this Part can be added to the BOM of another part.
@ -491,13 +491,11 @@ class Part(MPTTModel):
b) The parent part is used in the BOM for *this* part b) The parent part is used in the BOM for *this* part
c) The parent part is used in the BOM for any child parts under this one c) The parent part is used in the BOM for any child parts under this one
Failing this check raises a ValidationError!
""" """
if parent is None: result = True
return
try:
if self.pk == parent.pk: if self.pk == parent.pk:
raise ValidationError({'sub_part': _("Part '{p1}' is used in BOM for '{p2}' (recursive)").format( raise ValidationError({'sub_part': _("Part '{p1}' is used in BOM for '{p2}' (recursive)").format(
p1=str(self), p1=str(self),
@ -517,7 +515,20 @@ class Part(MPTTModel):
)}) )})
# And recursively check too # And recursively check too
item.sub_part.checkAddToBOM(parent) if recursive:
result = result and item.sub_part.check_add_to_bom(
parent,
recursive=True,
raise_error=raise_error
)
except ValidationError as e:
if raise_error:
raise e
else:
return False
return result
def checkIfSerialNumberExists(self, sn, exclude_self=False): def checkIfSerialNumberExists(self, sn, exclude_self=False):
""" """
@ -1816,23 +1827,45 @@ class Part(MPTTModel):
clear - Remove existing BOM items first (default=True) clear - Remove existing BOM items first (default=True)
""" """
# Ignore if the other part is actually this part?
if other == self:
return
if clear: if clear:
# Remove existing BOM items # Remove existing BOM items
# Note: Inherited BOM items are *not* deleted! # Note: Inherited BOM items are *not* deleted!
self.bom_items.all().delete() self.bom_items.all().delete()
# List of "ancestor" parts above this one
my_ancestors = self.get_ancestors(include_self=False)
raise_error = not kwargs.get('skip_invalid', True)
include_inherited = kwargs.get('include_inherited', False)
# Copy existing BOM items from another part # Copy existing BOM items from another part
# Note: Inherited BOM Items will *not* be duplicated!! # Note: Inherited BOM Items will *not* be duplicated!!
for bom_item in other.get_bom_items(include_inherited=False).all(): for bom_item in other.get_bom_items(include_inherited=include_inherited).all():
# If this part already has a BomItem pointing to the same sub-part, # If this part already has a BomItem pointing to the same sub-part,
# delete that BomItem from this part first! # delete that BomItem from this part first!
try: # Ignore invalid BomItem objects
existing = BomItem.objects.get(part=self, sub_part=bom_item.sub_part) if not bom_item.part or not bom_item.sub_part:
existing.delete() continue
except (BomItem.DoesNotExist):
pass
# Ignore ancestor parts which are inherited
if bom_item.part in my_ancestors and bom_item.inherited:
continue
# Skip if already exists
if BomItem.objects.filter(part=self, sub_part=bom_item.sub_part).exists():
continue
# Skip (or throw error) if BomItem is not valid
if not bom_item.sub_part.check_add_to_bom(self, raise_error=raise_error):
continue
# Construct a new BOM item
bom_item.part = self bom_item.part = self
bom_item.pk = None bom_item.pk = None
@ -2677,7 +2710,7 @@ class BomItem(models.Model):
try: try:
# Check for circular BOM references # Check for circular BOM references
if self.sub_part: if self.sub_part:
self.sub_part.checkAddToBOM(self.part) self.sub_part.check_add_to_bom(self.part, raise_error=True)
# If the sub_part is 'trackable' then the 'quantity' field must be an integer # If the sub_part is 'trackable' then the 'quantity' field must be an integer
if self.sub_part.trackable: if self.sub_part.trackable:

View File

@ -9,6 +9,7 @@ from django.urls import reverse_lazy
from django.db import models from django.db import models
from django.db.models import Q from django.db.models import Q
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.utils.translation import ugettext_lazy as _
from rest_framework import serializers from rest_framework import serializers
from sql_util.utils import SubqueryCount, SubquerySum from sql_util.utils import SubqueryCount, SubquerySum
@ -636,3 +637,65 @@ class CategoryParameterTemplateSerializer(InvenTreeModelSerializer):
'parameter_template', 'parameter_template',
'default_value', 'default_value',
] ]
class PartCopyBOMSerializer(serializers.Serializer):
"""
Serializer for copying a BOM from another part
"""
class Meta:
fields = [
'part',
'remove_existing',
]
part = serializers.PrimaryKeyRelatedField(
queryset=Part.objects.all(),
many=False,
required=True,
allow_null=False,
label=_('Part'),
help_text=_('Select part to copy BOM from'),
)
def validate_part(self, part):
"""
Check that a 'valid' part was selected
"""
return part
remove_existing = serializers.BooleanField(
label=_('Remove Existing Data'),
help_text=_('Remove existing BOM items before copying'),
default=True,
)
include_inherited = serializers.BooleanField(
label=_('Include Inherited'),
help_text=_('Include BOM items which are inherited from templated parts'),
default=False,
)
skip_invalid = serializers.BooleanField(
label=_('Skip Invalid Rows'),
help_text=_('Enable this option to skip invalid rows'),
default=False,
)
def save(self):
"""
Actually duplicate the BOM
"""
base_part = self.context['part']
data = self.validated_data
base_part.copy_bom_from(
data['part'],
clear=data.get('remove_existing', True),
skip_invalid=data.get('skip_invalid', False),
include_inherited=data.get('include_inherited', False),
)

View File

@ -1,17 +0,0 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% block pre_form_content %}
<p>
{% trans "Select parent part to copy BOM from" %}
</p>
{% if part.has_bom %}
<div class='alert alert-block alert-danger'>
<strong>{% trans "Warning" %}</strong><br>
{% trans "This part already has a Bill of Materials" %}<br>
</div>
{% endif %}
{% endblock %}

View File

@ -1,12 +0,0 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% block pre_form_content %}
{% blocktrans with part.full_name as part %}Confirm that the Bill of Materials (BOM) is valid for:<br><em>{{ part }}</em>{% endblocktrans %}
<div class='alert alert-warning alert-block'>
{% trans 'This will validate each line in the BOM.' %}
</div>
{% endblock %}

View File

@ -181,6 +181,9 @@
<div class='panel-content'> <div class='panel-content'>
<div id='param-button-toolbar'> <div id='param-button-toolbar'>
<div class='button-toolbar container-fluid' style='float: right;'> <div class='button-toolbar container-fluid' style='float: right;'>
<div class='btn-group' role='group'>
{% include "filter_list.html" with id="parameters" %}
</div>
</div> </div>
</div> </div>
<table id='parameter-table' class='table table-condensed table-striped' data-toolbar="#param-button-toolbar"></table> <table id='parameter-table' class='table table-condensed table-striped' data-toolbar="#param-button-toolbar"></table>
@ -217,7 +220,7 @@
</div> </div>
</div> </div>
<div class='panel-content'> <div class='panel-content'>
<div id='related-button-bar'> <div id='related-button-toolbar'>
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
{% include "filter_list.html" with id="related" %} {% include "filter_list.html" with id="related" %}
</div> </div>
@ -344,6 +347,7 @@
<li><a class='dropdown-item' href='#' id='supplier-part-delete' title='{% trans "Delete supplier parts" %}'>{% trans "Delete" %}</a></li> <li><a class='dropdown-item' href='#' id='supplier-part-delete' title='{% trans "Delete supplier parts" %}'>{% trans "Delete" %}</a></li>
</ul> </ul>
</div> </div>
{% include "filter_list.html" with id="supplier-part" %}
</div> </div>
</div> </div>
@ -371,6 +375,7 @@
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<li><a class='dropdown-item' href='#' id='manufacturer-part-delete' title='{% trans "Delete manufacturer parts" %}'><span class='fas fa-trash-alt icon-red'></span> {% trans "Delete" %}</a></li> <li><a class='dropdown-item' href='#' id='manufacturer-part-delete' title='{% trans "Delete manufacturer parts" %}'><span class='fas fa-trash-alt icon-red'></span> {% trans "Delete" %}</a></li>
</ul> </ul>
{% include "filter_list.html" with id="manufacturer-part" %}
</div> </div>
</div> </div>
</div> </div>
@ -580,14 +585,12 @@
}); });
$('#bom-duplicate').click(function() { $('#bom-duplicate').click(function() {
launchModalForm(
"{% url 'duplicate-bom' part.id %}", duplicateBom({{ part.pk }}, {
{ success: function(response) {
success: function() {
$('#bom-table').bootstrapTable('refresh'); $('#bom-table').bootstrapTable('refresh');
} }
} });
);
}); });
$("#bom-item-new").click(function () { $("#bom-item-new").click(function () {
@ -611,12 +614,10 @@
}); });
$("#validate-bom").click(function() { $("#validate-bom").click(function() {
launchModalForm(
"{% url 'bom-validate' part.id %}", validateBom({{ part.id }}, {
{ reload: true
reload: true, });
}
);
}); });
$("#download-bom").click(function () { $("#download-bom").click(function () {

View File

@ -35,12 +35,10 @@ part_detail_urls = [
url(r'^delete/?', views.PartDelete.as_view(), name='part-delete'), url(r'^delete/?', views.PartDelete.as_view(), name='part-delete'),
url(r'^bom-export/?', views.BomExport.as_view(), name='bom-export'), url(r'^bom-export/?', views.BomExport.as_view(), name='bom-export'),
url(r'^bom-download/?', views.BomDownload.as_view(), name='bom-download'), url(r'^bom-download/?', views.BomDownload.as_view(), name='bom-download'),
url(r'^validate-bom/', views.BomValidate.as_view(), name='bom-validate'),
url(r'^pricing/', views.PartPricing.as_view(), name='part-pricing'), url(r'^pricing/', views.PartPricing.as_view(), name='part-pricing'),
url(r'^bom-upload/?', views.BomUpload.as_view(), name='upload-bom'), url(r'^bom-upload/?', views.BomUpload.as_view(), name='upload-bom'),
url(r'^bom-duplicate/?', views.BomDuplicate.as_view(), name='duplicate-bom'),
url(r'^qr_code/?', views.PartQRCode.as_view(), name='part-qr'), url(r'^qr_code/?', views.PartQRCode.as_view(), name='part-qr'),

View File

@ -694,100 +694,6 @@ class PartImageSelect(AjaxUpdateView):
return self.renderJsonResponse(request, form, data) return self.renderJsonResponse(request, form, data)
class BomDuplicate(AjaxUpdateView):
"""
View for duplicating BOM from a parent item.
"""
model = Part
context_object_name = 'part'
ajax_form_title = _('Duplicate BOM')
ajax_template_name = 'part/bom_duplicate.html'
form_class = part_forms.BomDuplicateForm
def get_form(self):
form = super().get_form()
# Limit choices to parents of the current part
parents = self.get_object().get_ancestors()
form.fields['parent'].queryset = parents
return form
def get_initial(self):
initials = super().get_initial()
parents = self.get_object().get_ancestors()
if parents.count() == 1:
initials['parent'] = parents[0]
return initials
def validate(self, part, form):
confirm = str2bool(form.cleaned_data.get('confirm', False))
if not confirm:
form.add_error('confirm', _('Confirm duplication of BOM from parent'))
def save(self, part, form):
"""
Duplicate BOM from the specified parent
"""
parent = form.cleaned_data.get('parent', None)
clear = str2bool(form.cleaned_data.get('clear', True))
if parent:
part.copy_bom_from(parent, clear=clear)
class BomValidate(AjaxUpdateView):
"""
Modal form view for validating a part BOM
"""
model = Part
ajax_form_title = _("Validate BOM")
ajax_template_name = 'part/bom_validate.html'
context_object_name = 'part'
form_class = part_forms.BomValidateForm
def get_context(self):
return {
'part': self.get_object(),
}
def get(self, request, *args, **kwargs):
form = self.get_form()
return self.renderJsonResponse(request, form, context=self.get_context())
def validate(self, part, form, **kwargs):
confirm = str2bool(form.cleaned_data.get('validate', False))
if not confirm:
form.add_error('validate', _('Confirm that the BOM is valid'))
def save(self, part, form, **kwargs):
"""
Mark the BOM as validated
"""
part.validate_bom(self.request.user)
def get_data(self):
return {
'success': _('Validated Bill of Materials')
}
class BomUpload(InvenTreeRoleMixin, FileManagementFormView): class BomUpload(InvenTreeRoleMixin, FileManagementFormView):
""" View for uploading a BOM file, and handling BOM data importing. """ View for uploading a BOM file, and handling BOM data importing.

View File

@ -4,6 +4,7 @@ This script calculates translation coverage for various languages
import os import os
import json import json
import sys
def calculate_coverage(filename): def calculate_coverage(filename):
@ -42,7 +43,7 @@ if __name__ == '__main__':
locales = {} locales = {}
locales_perc = {} locales_perc = {}
print("InvenTree translation coverage:") verbose = '-v' in sys.argv
for locale in os.listdir(LC_DIR): for locale in os.listdir(LC_DIR):
path = os.path.join(LC_DIR, locale) path = os.path.join(LC_DIR, locale)
@ -53,8 +54,11 @@ if __name__ == '__main__':
if os.path.exists(locale_file) and os.path.isfile(locale_file): if os.path.exists(locale_file) and os.path.isfile(locale_file):
locales[locale] = locale_file locales[locale] = locale_file
if verbose:
print("-" * 16) print("-" * 16)
percentages = []
for locale in locales.keys(): for locale in locales.keys():
locale_file = locales[locale] locale_file = locales[locale]
stats = calculate_coverage(locale_file) stats = calculate_coverage(locale_file)
@ -66,11 +70,23 @@ if __name__ == '__main__':
else: else:
percentage = 0 percentage = 0
if verbose:
print(f"| {locale.ljust(4, ' ')} : {str(percentage).rjust(4, ' ')}% |") print(f"| {locale.ljust(4, ' ')} : {str(percentage).rjust(4, ' ')}% |")
locales_perc[locale] = percentage locales_perc[locale] = percentage
percentages.append(percentage)
if verbose:
print("-" * 16) print("-" * 16)
# write locale stats # write locale stats
with open(STAT_FILE, 'w') as target: with open(STAT_FILE, 'w') as target:
json.dump(locales_perc, target) json.dump(locales_perc, target)
if len(percentages) > 0:
avg = int(sum(percentages) / len(percentages))
else:
avg = 0
print(f"InvenTree translation coverage: {avg}%")

View File

@ -180,6 +180,20 @@ class StockAssign(generics.CreateAPIView):
return ctx return ctx
class StockMerge(generics.CreateAPIView):
"""
API endpoint for merging multiple stock items
"""
queryset = StockItem.objects.none()
serializer_class = StockSerializers.StockMergeSerializer
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['request'] = self.request
return ctx
class StockLocationList(generics.ListCreateAPIView): class StockLocationList(generics.ListCreateAPIView):
""" """
API endpoint for list view of StockLocation objects: API endpoint for list view of StockLocation objects:
@ -1213,6 +1227,7 @@ stock_api_urls = [
url(r'^remove/', StockRemove.as_view(), name='api-stock-remove'), url(r'^remove/', StockRemove.as_view(), name='api-stock-remove'),
url(r'^transfer/', StockTransfer.as_view(), name='api-stock-transfer'), url(r'^transfer/', StockTransfer.as_view(), name='api-stock-transfer'),
url(r'^assign/', StockAssign.as_view(), name='api-stock-assign'), url(r'^assign/', StockAssign.as_view(), name='api-stock-assign'),
url(r'^merge/', StockMerge.as_view(), name='api-stock-merge'),
# StockItemAttachment API endpoints # StockItemAttachment API endpoints
url(r'^attachment/', include([ url(r'^attachment/', include([

View File

@ -114,19 +114,6 @@
lft: 0 lft: 0
rght: 0 rght: 0
- model: stock.stockitem
pk: 501
fields:
part: 10001
location: 7
batch: "AAA"
quantity: 1
serial: 1
level: 0
tree_id: 0
lft: 0
rght: 0
- model: stock.stockitem - model: stock.stockitem
pk: 501 pk: 501
fields: fields:

View File

@ -0,0 +1,19 @@
# Generated by Django 3.2.10 on 2021-12-20 21:49
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('stock', '0072_remove_stockitem_scheduled_for_deletion'),
]
operations = [
migrations.AlterField(
model_name='stockitem',
name='belongs_to',
field=models.ForeignKey(blank=True, help_text='Is this item installed in another item?', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='installed_parts', to='stock.stockitem', verbose_name='Installed In'),
),
]

View File

@ -455,6 +455,7 @@ class StockItem(MPTTModel):
uid = models.CharField(blank=True, max_length=128, help_text=("Unique identifier field")) uid = models.CharField(blank=True, max_length=128, help_text=("Unique identifier field"))
# Note: When a StockItem is deleted, a pre_delete signal handles the parent/child relationship
parent = TreeForeignKey( parent = TreeForeignKey(
'self', 'self',
verbose_name=_('Parent Stock Item'), verbose_name=_('Parent Stock Item'),
@ -477,6 +478,7 @@ class StockItem(MPTTModel):
help_text=_('Select a matching supplier part for this stock item') help_text=_('Select a matching supplier part for this stock item')
) )
# Note: When a StockLocation is deleted, stock items are updated via a signal
location = TreeForeignKey( location = TreeForeignKey(
StockLocation, on_delete=models.DO_NOTHING, StockLocation, on_delete=models.DO_NOTHING,
verbose_name=_('Stock Location'), verbose_name=_('Stock Location'),
@ -492,10 +494,11 @@ class StockItem(MPTTModel):
help_text=_('Packaging this stock item is stored in') help_text=_('Packaging this stock item is stored in')
) )
# When deleting a stock item with installed items, those installed items are also installed
belongs_to = models.ForeignKey( belongs_to = models.ForeignKey(
'self', 'self',
verbose_name=_('Installed In'), verbose_name=_('Installed In'),
on_delete=models.DO_NOTHING, on_delete=models.CASCADE,
related_name='installed_parts', blank=True, null=True, related_name='installed_parts', blank=True, null=True,
help_text=_('Is this item installed in another item?') help_text=_('Is this item installed in another item?')
) )
@ -800,14 +803,14 @@ class StockItem(MPTTModel):
def can_delete(self): def can_delete(self):
""" Can this stock item be deleted? It can NOT be deleted under the following circumstances: """ Can this stock item be deleted? It can NOT be deleted under the following circumstances:
- Has child StockItems - Has installed stock items
- Has a serial number and is tracked - Has a serial number and is tracked
- Is installed inside another StockItem - Is installed inside another StockItem
- It has been assigned to a SalesOrder - It has been assigned to a SalesOrder
- It has been assigned to a BuildOrder - It has been assigned to a BuildOrder
""" """
if self.child_count > 0: if self.installed_item_count() > 0:
return False return False
if self.part.trackable and self.serial is not None: if self.part.trackable and self.serial is not None:
@ -853,20 +856,13 @@ class StockItem(MPTTModel):
return installed return installed
def installedItemCount(self): def installed_item_count(self):
""" """
Return the number of stock items installed inside this one. Return the number of stock items installed inside this one.
""" """
return self.installed_parts.count() return self.installed_parts.count()
def hasInstalledItems(self):
"""
Returns true if this stock item has other stock items installed in it.
"""
return self.installedItemCount() > 0
@transaction.atomic @transaction.atomic
def installStockItem(self, other_item, quantity, user, notes): def installStockItem(self, other_item, quantity, user, notes):
""" """
@ -1153,6 +1149,124 @@ class StockItem(MPTTModel):
result.stock_item = self result.stock_item = self
result.save() result.save()
def can_merge(self, other=None, raise_error=False, **kwargs):
"""
Check if this stock item can be merged into another stock item
"""
allow_mismatched_suppliers = kwargs.get('allow_mismatched_suppliers', False)
allow_mismatched_status = kwargs.get('allow_mismatched_status', False)
try:
# Generic checks (do not rely on the 'other' part)
if self.sales_order:
raise ValidationError(_('Stock item has been assigned to a sales order'))
if self.belongs_to:
raise ValidationError(_('Stock item is installed in another item'))
if self.installed_item_count() > 0:
raise ValidationError(_('Stock item contains other items'))
if self.customer:
raise ValidationError(_('Stock item has been assigned to a customer'))
if self.is_building:
raise ValidationError(_('Stock item is currently in production'))
if self.serialized:
raise ValidationError(_("Serialized stock cannot be merged"))
if other:
# Specific checks (rely on the 'other' part)
# Prevent stock item being merged with itself
if self == other:
raise ValidationError(_('Duplicate stock items'))
# Base part must match
if self.part != other.part:
raise ValidationError(_("Stock items must refer to the same part"))
# Check if supplier part references match
if self.supplier_part != other.supplier_part and not allow_mismatched_suppliers:
raise ValidationError(_("Stock items must refer to the same supplier part"))
# Check if stock status codes match
if self.status != other.status and not allow_mismatched_status:
raise ValidationError(_("Stock status codes must match"))
except ValidationError as e:
if raise_error:
raise e
else:
return False
return True
@transaction.atomic
def merge_stock_items(self, other_items, raise_error=False, **kwargs):
"""
Merge another stock item into this one; the two become one!
*This* stock item subsumes the other, which is essentially deleted:
- The quantity of this StockItem is increased
- Tracking history for the *other* item is deleted
- Any allocations (build order, sales order) are moved to this StockItem
"""
if len(other_items) == 0:
return
user = kwargs.get('user', None)
location = kwargs.get('location', None)
notes = kwargs.get('notes', None)
parent_id = self.parent.pk if self.parent else None
for other in other_items:
# If the stock item cannot be merged, return
if not self.can_merge(other, raise_error=raise_error, **kwargs):
return
for other in other_items:
self.quantity += other.quantity
# Any "build order allocations" for the other item must be assigned to this one
for allocation in other.allocations.all():
allocation.stock_item = self
allocation.save()
# Any "sales order allocations" for the other item must be assigned to this one
for allocation in other.sales_order_allocations.all():
allocation.stock_item = self()
allocation.save()
# Prevent atomicity issues when we are merging our own "parent" part in
if parent_id and parent_id == other.pk:
self.parent = None
self.save()
other.delete()
self.add_tracking_entry(
StockHistoryCode.MERGED_STOCK_ITEMS,
user,
quantity=self.quantity,
notes=notes,
deltas={
'location': location.pk,
}
)
self.location = location
self.save()
@transaction.atomic @transaction.atomic
def splitStock(self, quantity, location, user, **kwargs): def splitStock(self, quantity, location, user, **kwargs):
""" Split this stock item into two items, in the same location. """ Split this stock item into two items, in the same location.
@ -1648,7 +1762,8 @@ class StockItem(MPTTModel):
@receiver(pre_delete, sender=StockItem, dispatch_uid='stock_item_pre_delete_log') @receiver(pre_delete, sender=StockItem, dispatch_uid='stock_item_pre_delete_log')
def before_delete_stock_item(sender, instance, using, **kwargs): def before_delete_stock_item(sender, instance, using, **kwargs):
""" Receives pre_delete signal from StockItem object. """
Receives pre_delete signal from StockItem object.
Before a StockItem is deleted, ensure that each child object is updated, Before a StockItem is deleted, ensure that each child object is updated,
to point to the new parent item. to point to the new parent item.

View File

@ -674,6 +674,149 @@ class StockAssignmentSerializer(serializers.Serializer):
) )
class StockMergeItemSerializer(serializers.Serializer):
"""
Serializer for a single StockItem within the StockMergeSerializer class.
Here, the individual StockItem is being checked for merge compatibility.
"""
class Meta:
fields = [
'item',
]
item = serializers.PrimaryKeyRelatedField(
queryset=StockItem.objects.all(),
many=False,
allow_null=False,
required=True,
label=_('Stock Item'),
)
def validate_item(self, item):
# Check that the stock item is able to be merged
item.can_merge(raise_error=True)
return item
class StockMergeSerializer(serializers.Serializer):
"""
Serializer for merging two (or more) stock items together
"""
class Meta:
fields = [
'items',
'location',
'notes',
'allow_mismatched_suppliers',
'allow_mismatched_status',
]
items = StockMergeItemSerializer(
many=True,
required=True,
)
location = serializers.PrimaryKeyRelatedField(
queryset=StockLocation.objects.all(),
many=False,
required=True,
allow_null=False,
label=_('Location'),
help_text=_('Destination stock location'),
)
notes = serializers.CharField(
required=False,
allow_blank=True,
label=_('Notes'),
help_text=_('Stock merging notes'),
)
allow_mismatched_suppliers = serializers.BooleanField(
required=False,
label=_('Allow mismatched suppliers'),
help_text=_('Allow stock items with different supplier parts to be merged'),
)
allow_mismatched_status = serializers.BooleanField(
required=False,
label=_('Allow mismatched status'),
help_text=_('Allow stock items with different status codes to be merged'),
)
def validate(self, data):
data = super().validate(data)
items = data['items']
if len(items) < 2:
raise ValidationError(_('At least two stock items must be provided'))
unique_items = set()
# The "base item" is the first item
base_item = items[0]['item']
data['base_item'] = base_item
# Ensure stock items are unique!
for element in items:
item = element['item']
if item.pk in unique_items:
raise ValidationError(_('Duplicate stock items'))
unique_items.add(item.pk)
# Checks from here refer to the "base_item"
if item == base_item:
continue
# Check that this item can be merged with the base_item
item.can_merge(
raise_error=True,
other=base_item,
allow_mismatched_suppliers=data.get('allow_mismatched_suppliers', False),
allow_mismatched_status=data.get('allow_mismatched_status', False),
)
return data
def save(self):
"""
Actually perform the stock merging action.
At this point we are confident that the merge can take place
"""
data = self.validated_data
base_item = data['base_item']
items = data['items'][1:]
request = self.context['request']
user = getattr(request, 'user', None)
items = []
for item in data['items'][1:]:
items.append(item['item'])
base_item.merge_stock_items(
items,
allow_mismatched_suppliers=data.get('allow_mismatched_suppliers', False),
allow_mismatched_status=data.get('allow_mismatched_status', False),
user=user,
location=data['location'],
notes=data.get('notes', None)
)
class StockAdjustmentItemSerializer(serializers.Serializer): class StockAdjustmentItemSerializer(serializers.Serializer):
""" """
Serializer for a single StockItem within a stock adjument request. Serializer for a single StockItem within a stock adjument request.
@ -837,7 +980,7 @@ class StockTransferSerializer(StockAdjustmentSerializer):
def validate(self, data): def validate(self, data):
super().validate(data) data = super().validate(data)
# TODO: Any specific validation of location field? # TODO: Any specific validation of location field?

View File

@ -274,14 +274,6 @@
<div class='alert alert-block alert-warning'> <div class='alert alert-block alert-warning'>
{% trans "This stock item is serialized - it has a unique serial number and the quantity cannot be adjusted." %} {% trans "This stock item is serialized - it has a unique serial number and the quantity cannot be adjusted." %}
</div> </div>
{% elif item.child_count > 0 %}
<div class='alert alert-block alert-warning'>
{% trans "This stock item cannot be deleted as it has child items" %}
</div>
{% elif item.delete_on_deplete and item.can_delete %}
<div class='alert alert-block alert-warning'>
{% trans "This stock item will be automatically deleted when all stock is depleted." %}
</div>
{% endif %} {% endif %}
</div> </div>

View File

@ -842,3 +842,189 @@ class StockAssignTest(StockAPITestCase):
# 5 stock items should now have been assigned to this customer # 5 stock items should now have been assigned to this customer
self.assertEqual(customer.assigned_stock.count(), 5) self.assertEqual(customer.assigned_stock.count(), 5)
class StockMergeTest(StockAPITestCase):
"""
Unit tests for merging stock items via the API
"""
URL = reverse('api-stock-merge')
def setUp(self):
super().setUp()
self.part = part.models.Part.objects.get(pk=25)
self.loc = StockLocation.objects.get(pk=1)
self.sp_1 = company.models.SupplierPart.objects.get(pk=100)
self.sp_2 = company.models.SupplierPart.objects.get(pk=101)
self.item_1 = StockItem.objects.create(
part=self.part,
supplier_part=self.sp_1,
quantity=100,
)
self.item_2 = StockItem.objects.create(
part=self.part,
supplier_part=self.sp_2,
quantity=100,
)
self.item_3 = StockItem.objects.create(
part=self.part,
supplier_part=self.sp_2,
quantity=50,
)
def test_missing_data(self):
"""
Test responses which are missing required data
"""
# Post completely empty
data = self.post(
self.URL,
{},
expected_code=400
).data
self.assertIn('This field is required', str(data['items']))
self.assertIn('This field is required', str(data['location']))
# Post with a location and empty items list
data = self.post(
self.URL,
{
'items': [],
'location': 1,
},
expected_code=400
).data
self.assertIn('At least two stock items', str(data))
def test_invalid_data(self):
"""
Test responses which have invalid data
"""
# Serialized stock items should be rejected
data = self.post(
self.URL,
{
'items': [
{
'item': 501,
},
{
'item': 502,
}
],
'location': 1,
},
expected_code=400,
).data
self.assertIn('Serialized stock cannot be merged', str(data))
# Prevent item duplication
data = self.post(
self.URL,
{
'items': [
{
'item': 11,
},
{
'item': 11,
}
],
'location': 1,
},
expected_code=400,
).data
self.assertIn('Duplicate stock items', str(data))
# Check for mismatching stock items
data = self.post(
self.URL,
{
'items': [
{
'item': 1234,
},
{
'item': 11,
}
],
'location': 1,
},
expected_code=400,
).data
self.assertIn('Stock items must refer to the same part', str(data))
# Check for mismatching supplier parts
payload = {
'items': [
{
'item': self.item_1.pk,
},
{
'item': self.item_2.pk,
},
],
'location': 1,
}
data = self.post(
self.URL,
payload,
expected_code=400,
).data
self.assertIn('Stock items must refer to the same supplier part', str(data))
def test_valid_merge(self):
"""
Test valid merging of stock items
"""
# Check initial conditions
n = StockItem.objects.filter(part=self.part).count()
self.assertEqual(self.item_1.quantity, 100)
payload = {
'items': [
{
'item': self.item_1.pk,
},
{
'item': self.item_2.pk,
},
{
'item': self.item_3.pk,
},
],
'location': 1,
'allow_mismatched_suppliers': True,
}
self.post(
self.URL,
payload,
expected_code=201,
)
self.item_1.refresh_from_db()
# Stock quantity should have been increased!
self.assertEqual(self.item_1.quantity, 250)
# Total number of stock items has been reduced!
self.assertEqual(StockItem.objects.filter(part=self.part).count(), n - 2)

View File

@ -16,12 +16,6 @@
<div class='panel panel-inventree'> <div class='panel panel-inventree'>
<div class='panel-content'> <div class='panel-content'>
{% include "search_form.html" with query_text=query %} {% include "search_form.html" with query_text=query %}
{% if query %}
{% else %}
<div id='empty-search-query'>
<h4><em>{% trans "Enter a search query" %}</em></h4>
</div>
{% endif %}
</div> </div>
</div> </div>

View File

@ -2,7 +2,7 @@
<div id='attachment-buttons'> <div id='attachment-buttons'>
<div class='btn-group' role='group'> <div class='btn-group' role='group'>
{% include "filter_list.html" with id="related" %} {% include "filter_list.html" with id="attachments" %}
</div> </div>
</div> </div>

View File

@ -207,6 +207,11 @@ function showApiError(xhr, url) {
title = '{% trans "Error 404: Resource Not Found" %}'; title = '{% trans "Error 404: Resource Not Found" %}';
message = '{% trans "The requested resource could not be located on the server" %}'; message = '{% trans "The requested resource could not be located on the server" %}';
break; break;
// Method not allowed
case 405:
title = '{% trans "Error 405: Method Not Allowed" %}';
message = '{% trans "HTTP method not allowed at URL" %}';
break;
// Timeout // Timeout
case 408: case 408:
title = '{% trans "Error 408: Timeout" %}'; title = '{% trans "Error 408: Timeout" %}';

View File

@ -67,6 +67,8 @@ function loadAttachmentTable(url, options) {
var table = options.table || '#attachment-table'; var table = options.table || '#attachment-table';
setupFilterList('attachments', $(table), '#filter-list-attachments');
addAttachmentButtonCallbacks(url, options.fields || {}); addAttachmentButtonCallbacks(url, options.fields || {});
$(table).inventreeTable({ $(table).inventreeTable({

View File

@ -661,7 +661,7 @@ function loadBomTable(table, options={}) {
if (!row.inherited) { if (!row.inherited) {
return yesNoLabel(false); return yesNoLabel(false);
} else if (row.part == options.parent_id) { } else if (row.part == options.parent_id) {
return '{% trans "Inherited" %}'; return yesNoLabel(true);
} else { } else {
// If this BOM item is inherited from a parent part // If this BOM item is inherited from a parent part
return renderLink( return renderLink(

View File

@ -380,6 +380,7 @@ function loadCompanyTable(table, url, options={}) {
url: url, url: url,
method: 'get', method: 'get',
queryParams: filters, queryParams: filters,
original: params,
groupBy: false, groupBy: false,
sidePagination: 'server', sidePagination: 'server',
formatNoMatches: function() { formatNoMatches: function() {
@ -463,7 +464,9 @@ function loadManufacturerPartTable(table, url, options) {
filters[key] = params[key]; filters[key] = params[key];
} }
setupFilterList('manufacturer-part', $(table)); var filterTarget = options.filterTarget || '#filter-list-manufacturer-part';
setupFilterList('manufacturer-part', $(table), filterTarget);
$(table).inventreeTable({ $(table).inventreeTable({
url: url, url: url,

View File

@ -21,6 +21,7 @@
*/ */
/* exported /* exported
duplicateBom,
duplicatePart, duplicatePart,
editCategory, editCategory,
editPart, editPart,
@ -39,6 +40,7 @@
loadStockPricingChart, loadStockPricingChart,
partStockLabel, partStockLabel,
toggleStar, toggleStar,
validateBom,
*/ */
/* Part API functions /* Part API functions
@ -428,6 +430,59 @@ function toggleStar(options) {
} }
/* Validate a BOM */
function validateBom(part_id, options={}) {
var html = `
<div class='alert alert-block alert-success'>
{% trans "Validating the BOM will mark each line item as valid" %}
</div>
`;
constructForm(`/api/part/${part_id}/bom-validate/`, {
method: 'PUT',
fields: {
valid: {},
},
preFormContent: html,
title: '{% trans "Validate Bill of Materials" %}',
reload: options.reload,
onSuccess: function(response) {
showMessage('{% trans "Validated Bill of Materials" %}');
}
});
}
/* Duplicate a BOM */
function duplicateBom(part_id, options={}) {
constructForm(`/api/part/${part_id}/bom-copy/`, {
method: 'POST',
fields: {
part: {
icon: 'fa-shapes',
filters: {
assembly: true,
exclude_tree: part_id,
}
},
include_inherited: {},
remove_existing: {},
skip_invalid: {},
},
confirm: true,
title: '{% trans "Copy Bill of Materials" %}',
onSuccess: function(response) {
if (options.success) {
options.success(response);
}
},
});
}
function partStockLabel(part, options={}) { function partStockLabel(part, options={}) {
if (part.in_stock) { if (part.in_stock) {
@ -621,7 +676,9 @@ function loadPartParameterTable(table, url, options) {
filters[key] = params[key]; filters[key] = params[key];
} }
// setupFilterList("#part-parameters", $(table)); var filterTarget = options.filterTarget || '#filter-list-parameters';
setupFilterList('part-parameters', $(table), filterTarget);
$(table).inventreeTable({ $(table).inventreeTable({
url: url, url: url,
@ -727,7 +784,7 @@ function loadPartPurchaseOrderTable(table, part_id, options={}) {
options.params.part_detail = true; options.params.part_detail = true;
options.params.order_detail = true; options.params.order_detail = true;
var filters = loadTableFilters('partpurchaseorders'); var filters = loadTableFilters('purchaseorderlineitem');
for (var key in options.params) { for (var key in options.params) {
filters[key] = options.params[key]; filters[key] = options.params[key];
@ -871,7 +928,7 @@ function loadPartPurchaseOrderTable(table, part_id, options={}) {
if (row.received >= row.quantity) { if (row.received >= row.quantity) {
// Already recevied // Already recevied
return `<span class='badge bg-success rounded-pill'>{% trans "Received" %}</span>`; return `<span class='badge bg-success rounded-pill'>{% trans "Received" %}</span>`;
} else { } else if (row.order_detail && row.order_detail.status == {{ PurchaseOrderStatus.PLACED }}) {
var html = `<div class='btn-group' role='group'>`; var html = `<div class='btn-group' role='group'>`;
var pk = row.pk; var pk = row.pk;
@ -879,6 +936,8 @@ function loadPartPurchaseOrderTable(table, part_id, options={}) {
html += `</div>`; html += `</div>`;
return html; return html;
} else {
return '';
} }
} }
} }

View File

@ -52,6 +52,7 @@
loadStockTestResultsTable, loadStockTestResultsTable,
loadStockTrackingTable, loadStockTrackingTable,
loadTableFilters, loadTableFilters,
mergeStockItems,
removeStockRow, removeStockRow,
serializeStockItem, serializeStockItem,
stockItemFields, stockItemFields,
@ -595,7 +596,7 @@ function assignStockToCustomer(items, options={}) {
buttons += '</div>'; buttons += '</div>';
html += ` html += `
<tr id='stock_item_${pk}' class='stock-item'row'> <tr id='stock_item_${pk}' class='stock-item-row'>
<td id='part_${pk}'>${thumbnail} ${part.full_name}</td> <td id='part_${pk}'>${thumbnail} ${part.full_name}</td>
<td id='stock_${pk}'> <td id='stock_${pk}'>
<div id='div_id_items_item_${pk}'> <div id='div_id_items_item_${pk}'>
@ -615,13 +616,13 @@ function assignStockToCustomer(items, options={}) {
method: 'POST', method: 'POST',
preFormContent: html, preFormContent: html,
fields: { fields: {
'customer': { customer: {
value: options.customer, value: options.customer,
filters: { filters: {
is_customer: true, is_customer: true,
}, },
}, },
'notes': {}, notes: {},
}, },
confirm: true, confirm: true,
confirmMessage: '{% trans "Confirm stock assignment" %}', confirmMessage: '{% trans "Confirm stock assignment" %}',
@ -694,6 +695,184 @@ function assignStockToCustomer(items, options={}) {
} }
/**
* Merge multiple stock items together
*/
function mergeStockItems(items, options={}) {
// Generate HTML content for the form
var html = `
<div class='alert alert-block alert-danger'>
<h5>{% trans "Warning: Merge operation cannot be reversed" %}</h5>
<strong>{% trans "Some information will be lost when merging stock items" %}:</strong>
<ul>
<li>{% trans "Stock transaction history will be deleted for merged items" %}</li>
<li>{% trans "Supplier part information will be deleted for merged items" %}</li>
</ul>
</div>
`;
html += `
<table class='table table-striped table-condensed' id='stock-merge-table'>
<thead>
<tr>
<th>{% trans "Part" %}</th>
<th>{% trans "Stock Item" %}</th>
<th>{% trans "Location" %}</th>
<th></th>
</tr>
</thead>
<tbody>
`;
// Keep track of how many "locations" there are
var locations = [];
for (var idx = 0; idx < items.length; idx++) {
var item = items[idx];
var pk = item.pk;
if (item.location && !locations.includes(item.location)) {
locations.push(item.location);
}
var part = item.part_detail;
var location = locationDetail(item, false);
var thumbnail = thumbnailImage(part.thumbnail || part.image);
var quantity = '';
if (item.serial && item.quantity == 1) {
quantity = `{% trans "Serial" %}: ${item.serial}`;
} else {
quantity = `{% trans "Quantity" %}: ${item.quantity}`;
}
quantity += stockStatusDisplay(item.status, {classes: 'float-right'});
var buttons = `<div class='btn-group' role='group'>`;
buttons += makeIconButton(
'fa-times icon-red',
'button-stock-item-remove',
pk,
'{% trans "Remove row" %}',
);
html += `
<tr id='stock_item_${pk}' class='stock-item-row'>
<td id='part_${pk}'>${thumbnail} ${part.full_name}</td>
<td id='stock_${pk}'>
<div id='div_id_items_item_${pk}'>
${quantity}
<div id='errors-items_item_${pk}'></div>
</div>
</td>
<td id='location_${pk}'>${location}</td>
<td id='buttons_${pk}'>${buttons}</td>
</tr>
`;
}
html += '</tbody></table>';
var location = locations.length == 1 ? locations[0] : null;
constructForm('{% url "api-stock-merge" %}', {
method: 'POST',
preFormContent: html,
fields: {
location: {
value: location,
icon: 'fa-sitemap',
},
notes: {},
allow_mismatched_suppliers: {},
allow_mismatched_status: {},
},
confirm: true,
confirmMessage: '{% trans "Confirm stock item merge" %}',
title: '{% trans "Merge Stock Items" %}',
afterRender: function(fields, opts) {
// Add button callbacks to remove rows
$(opts.modal).find('.button-stock-item-remove').click(function() {
var pk = $(this).attr('pk');
$(opts.modal).find(`#stock_item_${pk}`).remove();
});
},
onSubmit: function(fields, opts) {
// Extract data elements from the form
var data = {
items: [],
};
var item_pk_values = [];
items.forEach(function(item) {
var pk = item.pk;
// Does the row still exist in the form?
var row = $(opts.modal).find(`#stock_item_${pk}`);
if (row.exists()) {
item_pk_values.push(pk);
data.items.push({
item: pk,
});
}
});
var extra_fields = [
'location',
'notes',
'allow_mismatched_suppliers',
'allow_mismatched_status',
];
extra_fields.forEach(function(field) {
data[field] = getFormFieldValue(field, fields[field], opts);
});
opts.nested = {
'items': item_pk_values
};
// Submit the form data
inventreePut(
'{% url "api-stock-merge" %}',
data,
{
method: 'POST',
success: function(response) {
$(opts.modal).modal('hide');
if (options.success) {
options.success(response);
}
},
error: function(xhr) {
switch (xhr.status) {
case 400:
handleFormErrors(xhr.responseJSON, fields, opts);
break;
default:
$(opts.modal).modal('hide');
showApiError(xhr, opts.url);
break;
}
}
}
);
}
});
}
/** /**
* Perform stock adjustments * Perform stock adjustments
*/ */
@ -1289,7 +1468,7 @@ function loadStockTable(table, options) {
var params = options.params || {}; var params = options.params || {};
var filterListElement = options.filterList || '#filter-list-stock'; var filterTarget = options.filterTarget || '#filter-list-stock';
var filters = {}; var filters = {};
@ -1305,7 +1484,7 @@ function loadStockTable(table, options) {
original[k] = params[k]; original[k] = params[k];
} }
setupFilterList(filterKey, table, filterListElement); setupFilterList(filterKey, table, filterTarget);
// Override the default values, or add new ones // Override the default values, or add new ones
for (var key in params) { for (var key in params) {
@ -1458,7 +1637,7 @@ function loadStockTable(table, options) {
} }
if (row.quantity <= 0) { if (row.quantity <= 0) {
html += `<span class='badge rounded-pill bg-danger'>{% trans "Depleted" %}</span>`; html += `<span class='badge badge-right rounded-pill bg-danger'>{% trans "Depleted" %}</span>`;
} }
return html; return html;
@ -1875,6 +2054,20 @@ function loadStockTable(table, options) {
stockAdjustment('move'); stockAdjustment('move');
}); });
$('#multi-item-merge').click(function() {
var items = $(table).bootstrapTable('getSelections');
mergeStockItems(items, {
success: function(response) {
$(table).bootstrapTable('refresh');
showMessage('{% trans "Merged stock items" %}', {
style: 'success',
});
}
});
});
$('#multi-item-assign').click(function() { $('#multi-item-assign').click(function() {
var items = $(table).bootstrapTable('getSelections'); var items = $(table).bootstrapTable('getSelections');

View File

@ -381,6 +381,24 @@ function getAvailableTableFilters(tableKey) {
}; };
} }
// Filters for "company" table
if (tableKey == 'company') {
return {
is_manufacturer: {
type: 'bool',
title: '{% trans "Manufacturer" %}',
},
is_supplier: {
type: 'bool',
title: '{% trans "Supplier" %}',
},
is_customer: {
type: 'bool',
title: '{% trans "Customer" %}',
},
};
}
// Filters for the "Parts" table // Filters for the "Parts" table
if (tableKey == 'parts') { if (tableKey == 'parts') {
return { return {

View File

@ -49,6 +49,7 @@
<li><a class='dropdown-item' href="#" id='multi-item-remove' title='{% trans "Remove from selected stock items" %}'><span class='fas fa-minus-circle'></span> {% trans "Remove stock" %}</a></li> <li><a class='dropdown-item' href="#" id='multi-item-remove' title='{% trans "Remove from selected stock items" %}'><span class='fas fa-minus-circle'></span> {% trans "Remove stock" %}</a></li>
<li><a class='dropdown-item' href="#" id='multi-item-stocktake' title='{% trans "Stocktake selected stock items" %}'><span class='fas fa-check-circle'></span> {% trans "Count stock" %}</a></li> <li><a class='dropdown-item' href="#" id='multi-item-stocktake' title='{% trans "Stocktake selected stock items" %}'><span class='fas fa-check-circle'></span> {% trans "Count stock" %}</a></li>
<li><a class='dropdown-item' href='#' id='multi-item-move' title='{% trans "Move selected stock items" %}'><span class='fas fa-exchange-alt'></span> {% trans "Move stock" %}</a></li> <li><a class='dropdown-item' href='#' id='multi-item-move' title='{% trans "Move selected stock items" %}'><span class='fas fa-exchange-alt'></span> {% trans "Move stock" %}</a></li>
<li><a class='dropdown-item' href='#' id='multi-item-merge' title='{% trans "Merge selected stock items" %}'><span class='fas fa-object-group'></span> {% trans "Merge stock" %}</a></li>
<li><a class='dropdown-item' href='#' id='multi-item-order' title='{% trans "Order selected items" %}'><span class='fas fa-shopping-cart'></span> {% trans "Order stock" %}</a></li> <li><a class='dropdown-item' href='#' id='multi-item-order' title='{% trans "Order selected items" %}'><span class='fas fa-shopping-cart'></span> {% trans "Order stock" %}</a></li>
<li><a class='dropdown-item' href='#' id='multi-item-assign' title='{% trans "Assign to customer" %}'><span class='fas fa-user-tie'></span> {% trans "Assign to customer" %}</a></li> <li><a class='dropdown-item' href='#' id='multi-item-assign' title='{% trans "Assign to customer" %}'><span class='fas fa-user-tie'></span> {% trans "Assign to customer" %}</a></li>
<li><a class='dropdown-item' href='#' id='multi-item-set-status' title='{% trans "Change status" %}'><span class='fas fa-exclamation-circle'></span> {% trans "Change stock status" %}</a></li> <li><a class='dropdown-item' href='#' id='multi-item-set-status' title='{% trans "Change status" %}'><span class='fas fa-exclamation-circle'></span> {% trans "Change stock status" %}</a></li>

View File

@ -12,7 +12,7 @@ InvenTree is an open-source Inventory Management System which provides powerful
InvenTree is designed to be lightweight and easy to use for SME or hobbyist applications, where many existing stock management solutions are bloated and cumbersome to use. Updating stock is a single-action process and does not require a complex system of work orders or stock transactions. InvenTree is designed to be lightweight and easy to use for SME or hobbyist applications, where many existing stock management solutions are bloated and cumbersome to use. Updating stock is a single-action process and does not require a complex system of work orders or stock transactions.
However, powerful business logic works in the background to ensure that stock tracking history is maintained, and users have ready access to stock level information. Powerful business logic works in the background to ensure that stock tracking history is maintained, and users have ready access to stock level information.
# Demo # Demo

View File

@ -101,7 +101,7 @@ RUN chown -R inventree:inventreegroup ${INVENTREE_HOME}/*
USER inventree USER inventree
# Install InvenTree packages # Install InvenTree packages
RUN pip3 install --no-cache-dir --disable-pip-version-check -r ${INVENTREE_HOME}/requirements.txt RUN pip3 install --user --no-cache-dir --disable-pip-version-check -r ${INVENTREE_HOME}/requirements.txt
# Need to be running from within this directory # Need to be running from within this directory
WORKDIR ${INVENTREE_MNG_DIR} WORKDIR ${INVENTREE_MNG_DIR}

View File

@ -2,14 +2,13 @@
# Set DEBUG to False for a production environment! # Set DEBUG to False for a production environment!
INVENTREE_DEBUG=True INVENTREE_DEBUG=True
# Change verbosity level for debug output
INVENTREE_DEBUG_LEVEL=INFO INVENTREE_DEBUG_LEVEL=INFO
# Database linking options # Database configuration options
INVENTREE_DB_ENGINE=sqlite3 # Note: The example setup is for a PostgreSQL database (change as required)
INVENTREE_DB_NAME=/home/inventree/dev/inventree_db.sqlite3 INVENTREE_DB_ENGINE=postgresql
# INVENTREE_DB_HOST=hostaddress INVENTREE_DB_NAME=inventree
# INVENTREE_DB_PORT=5432 INVENTREE_DB_HOST=inventree-dev-db
# INVENTREE_DB_USERNAME=dbuser INVENTREE_DB_PORT=5432
# INVENTREE_DB_PASSWEORD=dbpassword INVENTREE_DB_USER=pguser
INVENTREE_DB_PASSWORD=pgpassword

View File

@ -1,8 +1,10 @@
version: "3.8" version: "3.8"
# Docker compose recipe for InvenTree development server # Docker compose recipe for InvenTree development server
# - Runs sqlite3 as the database backend # - Runs PostgreSQL as the database backend
# - Uses built-in django webserver # - Uses built-in django webserver
# - Runs the InvenTree background worker process
# - Serves media and static content directly from Django webserver
# IMPORANT NOTE: # IMPORANT NOTE:
# The InvenTree docker image does not clone source code from git. # The InvenTree docker image does not clone source code from git.
@ -11,10 +13,32 @@ version: "3.8"
# The django server will auto-detect any code changes and reload the server. # The django server will auto-detect any code changes and reload the server.
services: services:
# Database service
# Use PostgreSQL as the database backend
# Note: This can be changed to a different backend if required
inventree-dev-db:
container_name: inventree-dev-db
image: postgres:13
ports:
- 5432/tcp
environment:
- PGDATA=/var/lib/postgresql/data/dev/pgdb
# The pguser and pgpassword values must be the same in the other containers
# Ensure that these are correctly configured in your dev-config.env file
- POSTGRES_USER=pguser
- POSTGRES_PASSWORD=pgpassword
volumes:
# Map 'data' volume such that postgres database is stored externally
- src:/var/lib/postgresql/data
restart: unless-stopped
# InvenTree web server services # InvenTree web server services
# Uses gunicorn as the web server # Uses gunicorn as the web server
inventree-dev-server: inventree-dev-server:
container_name: inventree-dev-server container_name: inventree-dev-server
depends_on:
- inventree-dev-db
build: build:
context: . context: .
target: dev target: dev

View File

@ -0,0 +1,62 @@
version: "3.8"
# Docker compose recipe for InvenTree development server
# - Runs sqlite database
# - Uses built-in django webserver
# - Runs the InvenTree background worker process
# - Serves media and static content directly from Django webserver
# IMPORANT NOTE:
# The InvenTree docker image does not clone source code from git.
# Instead, you must specify *where* the source code is located,
# (on your local machine).
# The django server will auto-detect any code changes and reload the server.
services:
# InvenTree web server services
# Uses gunicorn as the web server
inventree-dev-server:
container_name: inventree-dev-server
build:
context: .
target: dev
ports:
# Expose web server on port 8000
- 8000:8000
volumes:
# Ensure you specify the location of the 'src' directory at the end of this file
- src:/home/inventree
env_file:
# Environment variables required for the dev server are configured in dev-config.env
- sqlite-config.env
restart: unless-stopped
# Background worker process handles long-running or periodic tasks
inventree-dev-worker:
container_name: inventree-dev-worker
build:
context: .
target: dev
command: invoke worker
depends_on:
- inventree-dev-server
volumes:
# Ensure you specify the location of the 'src' directory at the end of this file
- src:/home/inventree
env_file:
# Environment variables required for the dev server are configured in dev-config.env
- sqlite-config.env
restart: unless-stopped
volumes:
# NOTE: Change "../" to a directory on your local machine, where the InvenTree source code is located
# Persistent data, stored external to the container(s)
src:
driver: local
driver_opts:
type: none
o: bind
# This directory specified where InvenTree source code is stored "outside" the docker containers
# By default, this directory is one level above the "docker" directory
device: ../

View File

@ -27,7 +27,7 @@ fi
if [[ -n "$INVENTREE_PY_ENV" ]]; then if [[ -n "$INVENTREE_PY_ENV" ]]; then
echo "Using Python virtual environment: ${INVENTREE_PY_ENV}" echo "Using Python virtual environment: ${INVENTREE_PY_ENV}"
# Setup a virtual environment (within the "dev" directory) # Setup a virtual environment (within the "dev" directory)
python3 -m venv ${INVENTREE_PY_ENV} python3 -m venv ${INVENTREE_PY_ENV} --system-site-packages
# Activate the virtual environment # Activate the virtual environment
source ${INVENTREE_PY_ENV}/bin/activate source ${INVENTREE_PY_ENV}/bin/activate

View File

@ -6,7 +6,7 @@
INVENTREE_DEBUG=False INVENTREE_DEBUG=False
INVENTREE_LOG_LEVEL=WARNING INVENTREE_LOG_LEVEL=WARNING
# Database configuration # Database configuration options
# Note: The example setup is for a PostgreSQL database (change as required) # Note: The example setup is for a PostgreSQL database (change as required)
INVENTREE_DB_ENGINE=postgresql INVENTREE_DB_ENGINE=postgresql
INVENTREE_DB_NAME=inventree INVENTREE_DB_NAME=inventree

View File

@ -4,7 +4,6 @@
setuptools>=57.4.0 setuptools>=57.4.0
wheel>=0.37.0 wheel>=0.37.0
invoke>=1.4.0 # Invoke build tool invoke>=1.4.0 # Invoke build tool
gunicorn>=20.1.0 # Gunicorn web server
# Database links # Database links
psycopg2>=2.9.1 psycopg2>=2.9.1
@ -12,5 +11,5 @@ mysqlclient>=2.0.3
pgcli>=3.1.0 pgcli>=3.1.0
mariadb>=1.0.7 mariadb>=1.0.7
# Cache # gunicorn web server
django-redis>=5.0.0 gunicorn>=20.1.0

10
docker/sqlite-config.env Normal file
View File

@ -0,0 +1,10 @@
# InvenTree environment variables for a development setup
# Set DEBUG to False for a production environment!
INVENTREE_DEBUG=True
INVENTREE_DEBUG_LEVEL=INFO
# Database configuration options
# Note: The example setup is for a PostgreSQL database (change as required)
INVENTREE_DB_ENGINE=sqlite
INVENTREE_DB_NAME=/home/inventree/dev/inventree_db.sqlite3

View File

@ -21,6 +21,7 @@ django-markdownify==0.8.0 # Markdown rendering
django-markdownx==3.0.1 # Markdown form fields django-markdownx==3.0.1 # Markdown form fields
django-money==1.1 # Django app for currency management django-money==1.1 # Django app for currency management
django-mptt==0.11.0 # Modified Preorder Tree Traversal django-mptt==0.11.0 # Modified Preorder Tree Traversal
django-redis>=5.0.0
django-q==1.3.4 # Background task scheduling django-q==1.3.4 # Background task scheduling
django-sql-utils==0.5.0 # Advanced query annotation / aggregation django-sql-utils==0.5.0 # Advanced query annotation / aggregation
django-stdimage==5.1.1 # Advanced ImageField management django-stdimage==5.1.1 # Advanced ImageField management