From 63114a29f19c0051b4d6886652e55cb57e2cf9b1 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 11 May 2019 09:31:23 +1000 Subject: [PATCH 01/11] Add PIP requirement for fuzzywuzzy https://github.com/seatgeek/fuzzywuzzy --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 From 343850c4f0e71fcd21cbb522cef1456318256aab Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 11 May 2019 09:51:45 +1000 Subject: [PATCH 02/11] Cleanup for part.views --- InvenTree/part/views.py | 33 +++++++++++++-------------------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index a018d578bf..c221cf3a1b 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -17,14 +17,7 @@ from .models import PartCategory, Part, PartAttachment from .models import BomItem from .models import SupplierPart -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 +53,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 +92,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,7 +132,7 @@ 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' @@ -260,7 +253,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 +265,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 +291,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 +389,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 +442,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 +489,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 +547,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 +573,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 +582,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' From 1b8ca34ddb405b1ea87a07f26327c3d1c5eef12c Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 11 May 2019 10:36:24 +1000 Subject: [PATCH 03/11] Added fuzzy search function for matching against part names --- InvenTree/part/models.py | 41 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 0318c11f57..46734f9e15 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,43 @@ def rename_part_image(instance, filename): return os.path.join(base, fn) +def match_part_names(match, include_description=False, threshold=65, reverse=True): + """ Return a list of parts whose name matches the search term using fuzzy search. + + Args: + match: Term to match against + include_description: Also search the part description (default = False) + threshold: Match percentage that must be exceeded (default = 65) + reverse: Ordering for search results (default = True - highest match is first) + + Returns: + A sorted dict where each element contains the following key:value pairs: + - 'part' : The matched part + - 'ratio' : The matched ratio + """ + + parts = Part.objects.all() + + matches = [] + + for part in parts: + compare = part.name + if include_description: + compare += part.description + + ratio = fuzz.partial_ratio(match, compare) + + 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. From 4e4ee2742be0644d1ddcd718f189c7556994ccaf Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 11 May 2019 11:50:00 +1000 Subject: [PATCH 04/11] Move modal form error messages to the top --- InvenTree/templates/modal_form.html | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/InvenTree/templates/modal_form.html b/InvenTree/templates/modal_form.html index 3c0674fec8..b1e1b494cc 100644 --- a/InvenTree/templates/modal_form.html +++ b/InvenTree/templates/modal_form.html @@ -1,13 +1,14 @@ -{% block pre_form_content %} -{% endblock %} - +{% block non_field_error %} {% if form.non_field_errors %} {% endif %} +{% endblock %} +{% block pre_form_content %} +{% endblock %}
{% csrf_token %} {% load crispy_forms_tags %} From d9c0d2f5e345815e7ad61387cc105a043513584f Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 11 May 2019 11:55:17 +1000 Subject: [PATCH 05/11] Update PartCreate form - Display list of close matches - Invalidate form (for now) --- InvenTree/part/models.py | 25 ++++++++++++++----- .../part/templates/part/create_part.html | 18 +++++++++++++ InvenTree/part/views.py | 25 ++++++++++++++++++- InvenTree/static/css/inventree.css | 4 +++ 4 files changed, 65 insertions(+), 7 deletions(-) create mode 100644 InvenTree/part/templates/part/create_part.html diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 46734f9e15..19a0383b36 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -116,14 +116,14 @@ def rename_part_image(instance, filename): return os.path.join(base, fn) -def match_part_names(match, include_description=False, threshold=65, reverse=True): +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 - include_description: Also search the part description (default = False) 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: @@ -131,16 +131,29 @@ def match_part_names(match, include_description=False, threshold=65, reverse=Tru - 'ratio' : The matched ratio """ + match = str(match).strip().lower() + + if len(match) == 0: + return [] + parts = Part.objects.all() matches = [] for part in parts: - compare = part.name - if include_description: - compare += part.description + compare = str(part.name).strip().lower() - ratio = fuzz.partial_ratio(match, compare) + if len(compare) == 0: + continue + + ratio = fuzz.partial_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({ diff --git a/InvenTree/part/templates/part/create_part.html b/InvenTree/part/templates/part/create_part.html new file mode 100644 index 0000000000..25e0d66597 --- /dev/null +++ b/InvenTree/part/templates/part/create_part.html @@ -0,0 +1,18 @@ +{% extends "modal_form.html" %} + +{% block pre_form_content %} + +{{ block.super }} + +{% if matches %} +Matching 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 c221cf3a1b..73555bfc0a 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -16,6 +16,7 @@ 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 . import forms as part_forms @@ -135,7 +136,7 @@ class PartCreate(AjaxCreateView): 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 { @@ -174,6 +175,28 @@ class PartCreate(AjaxCreateView): return form + def post(self, request, *args, **kwargs): + + form = self.get_form() + + name = request.POST.get('name', None) + + context = {} + + if name: + matches = match_part_names(name) + + if len(matches) > 0: + context['matches'] = matches + + form.non_field_errors = 'Check matches' + + data = { + 'form_valid': False + } + + return self.renderJsonResponse(request, form, data, context=context) + def get_initial(self): """ Get initial data for the new Part object: 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%; From 84d2fce8cafba140e485f342310ee98cb805f406 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 11 May 2019 12:29:02 +1000 Subject: [PATCH 06/11] Add a 'confirm_creation' input if there are possible part matches --- InvenTree/part/admin.py | 2 +- InvenTree/part/forms.py | 3 +++ InvenTree/part/models.py | 2 +- InvenTree/part/views.py | 29 +++++++++++++++++++++++++---- 4 files changed, 30 insertions(+), 6 deletions(-) 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 19a0383b36..562fe27f2b 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -146,7 +146,7 @@ def match_part_names(match, threshold=80, reverse=True, compare_length=False): if len(compare) == 0: continue - ratio = fuzz.partial_ratio(compare, match) + ratio = fuzz.partial_token_sort_ratio(compare, match) if compare_length: # Also employ primitive length comparison diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 73555bfc0a..a1e38ea563 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -10,7 +10,7 @@ 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 @@ -179,9 +179,12 @@ class PartCreate(AjaxCreateView): form = self.get_form() - name = request.POST.get('name', None) context = {} + + valid = form.is_valid() + + name = request.POST.get('name', None) if name: matches = match_part_names(name) @@ -189,12 +192,30 @@ class PartCreate(AjaxCreateView): if len(matches) > 0: context['matches'] = matches - form.non_field_errors = 'Check matches' + # Check if the user has checked the 'confirm_creation' input + confirmed = request.POST.get('confirm_creation', False) + + if not confirmed: + form.fields['confirm_creation'].widget = CheckboxInput() + form.errors['confirm_creation'] = ['Possible matches exist - confirm creation of new part'] + form.non_field_errors = 'Possible matches exist - confirm creation of new part' + valid = False data = { - 'form_valid': False + '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): From 2e5b0bc961c9ff8b41adcb146b15e1407c6045c3 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 11 May 2019 12:29:51 +1000 Subject: [PATCH 07/11] PEP --- InvenTree/part/views.py | 1 - 1 file changed, 1 deletion(-) diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index a1e38ea563..141da68af0 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -179,7 +179,6 @@ class PartCreate(AjaxCreateView): form = self.get_form() - context = {} valid = form.is_valid() From 19854b4709d7e187ca8426a36e7209473a474b88 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 11 May 2019 18:06:17 +1000 Subject: [PATCH 08/11] Improve str2bool to validate checkbox return values - A checked checkbox returns 'on' :| --- InvenTree/InvenTree/helpers.py | 4 ++-- InvenTree/part/views.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py index 898bfa3658..d748ba4d2e 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/views.py b/InvenTree/part/views.py index 141da68af0..29431788f7 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -192,7 +192,7 @@ class PartCreate(AjaxCreateView): context['matches'] = matches # Check if the user has checked the 'confirm_creation' input - confirmed = request.POST.get('confirm_creation', False) + confirmed = str2bool(request.POST.get('confirm_creation', False)) if not confirmed: form.fields['confirm_creation'].widget = CheckboxInput() From 7b4584ba2f2164e06d9c8b773e1d06eff6b2afe3 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 11 May 2019 18:06:43 +1000 Subject: [PATCH 09/11] Add some more options for modal forms - Ability to display info or warning panels before the form --- InvenTree/templates/modal_form.html | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/InvenTree/templates/modal_form.html b/InvenTree/templates/modal_form.html index b1e1b494cc..e25d2587da 100644 --- a/InvenTree/templates/modal_form.html +++ b/InvenTree/templates/modal_form.html @@ -1,3 +1,14 @@ +
+{% 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 %} From b3cca1d851847ac3a8d6191848afba8ffb98368f Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 11 May 2019 18:07:37 +1000 Subject: [PATCH 10/11] Improve form management when possible part matches are found - Display the checkbox always - Display a warning message above the form --- InvenTree/part/templates/part/create_part.html | 3 ++- InvenTree/part/views.py | 7 +++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/InvenTree/part/templates/part/create_part.html b/InvenTree/part/templates/part/create_part.html index 25e0d66597..30bbf099f1 100644 --- a/InvenTree/part/templates/part/create_part.html +++ b/InvenTree/part/templates/part/create_part.html @@ -5,7 +5,8 @@ {{ block.super }} {% if matches %} -Matching Parts +Possible Matching Parts +

The new part may be a duplicate of these existing parts:

    {% for match in matches %}
  • diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 29431788f7..890cb8a519 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -191,13 +191,16 @@ class PartCreate(AjaxCreateView): 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.fields['confirm_creation'].widget = CheckboxInput() form.errors['confirm_creation'] = ['Possible matches exist - confirm creation of new part'] - form.non_field_errors = 'Possible matches exist - confirm creation of new part' + + form.pre_form_warning = 'Possible matches exist - confirm creation of new part' valid = False data = { From 988670cdbf7ba350158edbb66c917b3d3106c1ba Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 11 May 2019 18:37:34 +1000 Subject: [PATCH 11/11] PEP --- InvenTree/InvenTree/helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py index d748ba4d2e..02f8159c6e 100644 --- a/InvenTree/InvenTree/helpers.py +++ b/InvenTree/InvenTree/helpers.py @@ -106,7 +106,7 @@ def str2bool(text, test=True): if test: return str(text).lower() in ['1', 'y', 'yes', 't', 'true', 'ok', 'on', ] else: - return str(text).lower() in ['0', 'n', 'no', 'none', 'f', 'false', 'off',] + return str(text).lower() in ['0', 'n', 'no', 'none', 'f', 'false', 'off', ] def WrapWithQuotes(text, quote='"'):