diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py
index 898bfa3658..02f8159c6e 100644
--- a/InvenTree/InvenTree/helpers.py
+++ b/InvenTree/InvenTree/helpers.py
@@ -104,9 +104,9 @@ def str2bool(text, test=True):
True if the text looks like the selected boolean value
"""
if test:
- return str(text).lower() in ['1', 'y', 'yes', 't', 'true', 'ok', ]
+ return str(text).lower() in ['1', 'y', 'yes', 't', 'true', 'ok', 'on', ]
else:
- return str(text).lower() in ['0', 'n', 'no', 'none', 'f', 'false', ]
+ return str(text).lower() in ['0', 'n', 'no', 'none', 'f', 'false', 'off', ]
def WrapWithQuotes(text, quote='"'):
diff --git a/InvenTree/part/admin.py b/InvenTree/part/admin.py
index 11a5b2c28a..c377c27989 100644
--- a/InvenTree/part/admin.py
+++ b/InvenTree/part/admin.py
@@ -9,7 +9,7 @@ from .models import BomItem
class PartAdmin(ImportExportModelAdmin):
- list_display = ('name', 'IPN', 'description', 'total_stock', 'category')
+ list_display = ('long_name', 'IPN', 'description', 'total_stock', 'category')
class PartCategoryAdmin(ImportExportModelAdmin):
diff --git a/InvenTree/part/forms.py b/InvenTree/part/forms.py
index 1a3f8b6125..9678819e41 100644
--- a/InvenTree/part/forms.py
+++ b/InvenTree/part/forms.py
@@ -60,9 +60,12 @@ class EditPartAttachmentForm(HelperForm):
class EditPartForm(HelperForm):
""" Form for editing a Part object """
+ confirm_creation = forms.BooleanField(required=False, initial=False, help_text='Confirm part creation', widget=forms.HiddenInput())
+
class Meta:
model = Part
fields = [
+ 'confirm_creation',
'category',
'name',
'variant',
diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py
index 0318c11f57..562fe27f2b 100644
--- a/InvenTree/part/models.py
+++ b/InvenTree/part/models.py
@@ -24,6 +24,8 @@ from django.contrib.auth.models import User
from django.db.models.signals import pre_delete
from django.dispatch import receiver
+from fuzzywuzzy import fuzz
+
from InvenTree import helpers
from InvenTree import validators
from InvenTree.models import InvenTreeTree
@@ -88,8 +90,6 @@ def before_delete_part_category(sender, instance, using, **kwargs):
child.save()
-# Function to automatically rename a part image on upload
-# Format: part_pk.
def rename_part_image(instance, filename):
""" Function for renaming a part image file
@@ -116,6 +116,56 @@ def rename_part_image(instance, filename):
return os.path.join(base, fn)
+def match_part_names(match, threshold=80, reverse=True, compare_length=False):
+ """ Return a list of parts whose name matches the search term using fuzzy search.
+
+ Args:
+ match: Term to match against
+ threshold: Match percentage that must be exceeded (default = 65)
+ reverse: Ordering for search results (default = True - highest match is first)
+ compare_length: Include string length checks
+
+ Returns:
+ A sorted dict where each element contains the following key:value pairs:
+ - 'part' : The matched part
+ - 'ratio' : The matched ratio
+ """
+
+ match = str(match).strip().lower()
+
+ if len(match) == 0:
+ return []
+
+ parts = Part.objects.all()
+
+ matches = []
+
+ for part in parts:
+ compare = str(part.name).strip().lower()
+
+ if len(compare) == 0:
+ continue
+
+ ratio = fuzz.partial_token_sort_ratio(compare, match)
+
+ if compare_length:
+ # Also employ primitive length comparison
+ l_min = min(len(match), len(compare))
+ l_max = max(len(match), len(compare))
+
+ ratio *= (l_min / l_max)
+
+ if ratio >= threshold:
+ matches.append({
+ 'part': part,
+ 'ratio': ratio
+ })
+
+ matches = sorted(matches, key=lambda item: item['ratio'], reverse=reverse)
+
+ return matches
+
+
class Part(models.Model):
""" The Part object represents an abstract part, the 'concept' of an actual entity.
diff --git a/InvenTree/part/templates/part/create_part.html b/InvenTree/part/templates/part/create_part.html
new file mode 100644
index 0000000000..30bbf099f1
--- /dev/null
+++ b/InvenTree/part/templates/part/create_part.html
@@ -0,0 +1,19 @@
+{% extends "modal_form.html" %}
+
+{% block pre_form_content %}
+
+{{ block.super }}
+
+{% if matches %}
+Possible Matching Parts
+
The new part may be a duplicate of these existing parts:
+
+{% for match in matches %}
+-
+ {{ match.part.name }} - {{ match.part.description }} ({{ match.ratio }}%)
+
+{% endfor %}
+
+{% endif %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py
index a018d578bf..890cb8a519 100644
--- a/InvenTree/part/views.py
+++ b/InvenTree/part/views.py
@@ -10,21 +10,15 @@ from django.shortcuts import get_object_or_404
from django.urls import reverse_lazy
from django.views.generic import DetailView, ListView
from django.forms.models import model_to_dict
-from django.forms import HiddenInput
+from django.forms import HiddenInput, CheckboxInput
from company.models import Company
from .models import PartCategory, Part, PartAttachment
from .models import BomItem
from .models import SupplierPart
+from .models import match_part_names
-from .forms import PartImageForm
-from .forms import EditPartForm
-from .forms import EditPartAttachmentForm
-from .forms import EditCategoryForm
-from .forms import EditBomItemForm
-from .forms import BomExportForm
-
-from .forms import EditSupplierPartForm
+from . import forms as part_forms
from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView
from InvenTree.views import QRCodeView
@@ -60,7 +54,7 @@ class PartAttachmentCreate(AjaxCreateView):
- The view only makes sense if a Part object is passed to it
"""
model = PartAttachment
- form_class = EditPartAttachmentForm
+ form_class = part_forms.EditPartAttachmentForm
ajax_form_title = "Add part attachment"
ajax_template_name = "modal_form.html"
@@ -99,7 +93,7 @@ class PartAttachmentCreate(AjaxCreateView):
class PartAttachmentEdit(AjaxUpdateView):
""" View for editing a PartAttachment object """
model = PartAttachment
- form_class = EditPartAttachmentForm
+ form_class = part_forms.EditPartAttachmentForm
ajax_template_name = 'modal_form.html'
ajax_form_title = 'Edit attachment'
@@ -139,10 +133,10 @@ class PartCreate(AjaxCreateView):
- Copy an existing Part
"""
model = Part
- form_class = EditPartForm
+ form_class = part_forms.EditPartForm
ajax_form_title = 'Create new part'
- ajax_template_name = 'modal_form.html'
+ ajax_template_name = 'part/create_part.html'
def get_data(self):
return {
@@ -181,6 +175,51 @@ class PartCreate(AjaxCreateView):
return form
+ def post(self, request, *args, **kwargs):
+
+ form = self.get_form()
+
+ context = {}
+
+ valid = form.is_valid()
+
+ name = request.POST.get('name', None)
+
+ if name:
+ matches = match_part_names(name)
+
+ if len(matches) > 0:
+ context['matches'] = matches
+
+ # Enforce display of the checkbox
+ form.fields['confirm_creation'].widget = CheckboxInput()
+
+ # Check if the user has checked the 'confirm_creation' input
+ confirmed = str2bool(request.POST.get('confirm_creation', False))
+
+ if not confirmed:
+ form.errors['confirm_creation'] = ['Possible matches exist - confirm creation of new part']
+
+ form.pre_form_warning = 'Possible matches exist - confirm creation of new part'
+ valid = False
+
+ data = {
+ 'form_valid': valid
+ }
+
+ if valid:
+ # Create the new Part
+ part = form.save()
+
+ data['pk'] = part.pk
+
+ try:
+ data['url'] = part.get_absolute_url()
+ except AttributeError:
+ pass
+
+ return self.renderJsonResponse(request, form, data, context=context)
+
def get_initial(self):
""" Get initial data for the new Part object:
@@ -260,7 +299,7 @@ class PartImage(AjaxUpdateView):
model = Part
ajax_template_name = 'modal_form.html'
ajax_form_title = 'Upload Part Image'
- form_class = PartImageForm
+ form_class = part_forms.PartImageForm
def get_data(self):
return {
@@ -272,7 +311,7 @@ class PartEdit(AjaxUpdateView):
""" View for editing Part object """
model = Part
- form_class = EditPartForm
+ form_class = part_forms.EditPartForm
ajax_template_name = 'modal_form.html'
ajax_form_title = 'Edit Part Properties'
context_object_name = 'part'
@@ -298,7 +337,7 @@ class BomExport(AjaxView):
ajax_form_title = 'Export BOM'
ajax_template_name = 'part/bom_export.html'
context_object_name = 'part'
- form_class = BomExportForm
+ form_class = part_forms.BomExportForm
def get_object(self):
return get_object_or_404(Part, pk=self.kwargs['pk'])
@@ -396,7 +435,7 @@ class CategoryDetail(DetailView):
class CategoryEdit(AjaxUpdateView):
""" Update view to edit a PartCategory """
model = PartCategory
- form_class = EditCategoryForm
+ form_class = part_forms.EditCategoryForm
ajax_template_name = 'modal_form.html'
ajax_form_title = 'Edit Part Category'
@@ -449,7 +488,7 @@ class CategoryCreate(AjaxCreateView):
ajax_form_action = reverse_lazy('category-create')
ajax_form_title = 'Create new part category'
ajax_template_name = 'modal_form.html'
- form_class = EditCategoryForm
+ form_class = part_forms.EditCategoryForm
def get_context_data(self, **kwargs):
""" Add extra context data to template.
@@ -496,7 +535,7 @@ class BomItemDetail(DetailView):
class BomItemCreate(AjaxCreateView):
""" Create view for making a new BomItem object """
model = BomItem
- form_class = EditBomItemForm
+ form_class = part_forms.EditBomItemForm
ajax_template_name = 'modal_form.html'
ajax_form_title = 'Create BOM item'
@@ -554,7 +593,7 @@ class BomItemEdit(AjaxUpdateView):
""" Update view for editing BomItem """
model = BomItem
- form_class = EditBomItemForm
+ form_class = part_forms.EditBomItemForm
ajax_template_name = 'modal_form.html'
ajax_form_title = 'Edit BOM item'
@@ -580,7 +619,7 @@ class SupplierPartEdit(AjaxUpdateView):
model = SupplierPart
context_object_name = 'part'
- form_class = EditSupplierPartForm
+ form_class = part_forms.EditSupplierPartForm
ajax_template_name = 'modal_form.html'
ajax_form_title = 'Edit Supplier Part'
@@ -589,7 +628,7 @@ class SupplierPartCreate(AjaxCreateView):
""" Create view for making new SupplierPart """
model = SupplierPart
- form_class = EditSupplierPartForm
+ form_class = part_forms.EditSupplierPartForm
ajax_template_name = 'modal_form.html'
ajax_form_title = 'Create new Supplier Part'
context_object_name = 'part'
diff --git a/InvenTree/static/css/inventree.css b/InvenTree/static/css/inventree.css
index 0961ed2bf1..9ce6f52400 100644
--- a/InvenTree/static/css/inventree.css
+++ b/InvenTree/static/css/inventree.css
@@ -27,6 +27,10 @@
padding: 6px 12px;
}
+.list-group-item-condensed {
+ padding: 5px 10px;
+}
+
/* Force select2 elements in modal forms to be full width */
.select-full-width {
width: 100%;
diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py
index 0bd048087e..9f305b657a 100644
--- a/InvenTree/stock/api.py
+++ b/InvenTree/stock/api.py
@@ -178,7 +178,11 @@ class StockMove(APIView):
for item in stock_list:
try:
stock_id = int(item['pk'])
- quantity = int(item['quantity'])
+ if 'quantity' in item:
+ quantity = int(item['quantity'])
+ else:
+ # If quantity not supplied, we'll move the entire stock
+ quantity = None
except ValueError:
# Ignore this one
continue
@@ -192,6 +196,9 @@ class StockMove(APIView):
except StockItem.DoesNotExist:
continue
+ if quantity is None:
+ quantity = stock.quantity
+
stock.move(location, data.get('notes'), request.user, quantity=quantity)
return Response({'success': 'Moved parts to {loc}'.format(
diff --git a/InvenTree/templates/modal_form.html b/InvenTree/templates/modal_form.html
index 3c0674fec8..e25d2587da 100644
--- a/InvenTree/templates/modal_form.html
+++ b/InvenTree/templates/modal_form.html
@@ -1,13 +1,26 @@
-{% block pre_form_content %}
-{% endblock %}
-
+
+{% if form.pre_form_info %}
+
+{{ form.pre_form_info }}
+
+{% endif %}
+{% if form.pre_form_warning %}
+
+{{ form.pre_form_warning }}
+
+{% endif %}
+{% block non_field_error %}
{% if form.non_field_errors %}
Error Submitting Form:
{{ form.non_field_errors }}
{% endif %}
+{% endblock %}
+
+{% block pre_form_content %}
+{% endblock %}