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:

+ +{% 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 %} + +{% endif %} +{% if form.pre_form_warning %} + +{% endif %} +{% block non_field_error %} {% if form.non_field_errors %} {% endif %} +{% endblock %} +
+{% block pre_form_content %} +{% endblock %}
{% csrf_token %} {% load crispy_forms_tags %} diff --git a/requirements.txt b/requirements.txt index baa43eb7fe..a860dd3726 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,4 +14,5 @@ django-cleanup>=2.1.0 # Manage deletion of old / unused uploaded files django-qr-code==1.0.0 # Generate QR codes flake8==3.3.0 # PEP checking coverage>=4.5.3 # Unit test coverage -python-coveralls==2.9.1 # Coveralls linking (for Travis) \ No newline at end of file +python-coveralls==2.9.1 # Coveralls linking (for Travis) +fuzzywuzzy>=0.17.0 # Fuzzy string matching \ No newline at end of file