Merge pull request #309 from SchrodingersGat/fuzzy-search

Fuzzy search
This commit is contained in:
Oliver 2019-05-11 18:47:48 +10:00 committed by GitHub
commit 08d743a735
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 160 additions and 31 deletions

View File

@ -104,9 +104,9 @@ def str2bool(text, test=True):
True if the text looks like the selected boolean value True if the text looks like the selected boolean value
""" """
if test: 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: 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='"'): def WrapWithQuotes(text, quote='"'):

View File

@ -9,7 +9,7 @@ from .models import BomItem
class PartAdmin(ImportExportModelAdmin): class PartAdmin(ImportExportModelAdmin):
list_display = ('name', 'IPN', 'description', 'total_stock', 'category') list_display = ('long_name', 'IPN', 'description', 'total_stock', 'category')
class PartCategoryAdmin(ImportExportModelAdmin): class PartCategoryAdmin(ImportExportModelAdmin):

View File

@ -60,9 +60,12 @@ class EditPartAttachmentForm(HelperForm):
class EditPartForm(HelperForm): class EditPartForm(HelperForm):
""" Form for editing a Part object """ """ Form for editing a Part object """
confirm_creation = forms.BooleanField(required=False, initial=False, help_text='Confirm part creation', widget=forms.HiddenInput())
class Meta: class Meta:
model = Part model = Part
fields = [ fields = [
'confirm_creation',
'category', 'category',
'name', 'name',
'variant', 'variant',

View File

@ -24,6 +24,8 @@ from django.contrib.auth.models import User
from django.db.models.signals import pre_delete from django.db.models.signals import pre_delete
from django.dispatch import receiver from django.dispatch import receiver
from fuzzywuzzy import fuzz
from InvenTree import helpers from InvenTree import helpers
from InvenTree import validators from InvenTree import validators
from InvenTree.models import InvenTreeTree from InvenTree.models import InvenTreeTree
@ -88,8 +90,6 @@ def before_delete_part_category(sender, instance, using, **kwargs):
child.save() child.save()
# Function to automatically rename a part image on upload
# Format: part_pk.<img>
def rename_part_image(instance, filename): def rename_part_image(instance, filename):
""" Function for renaming a part image file """ Function for renaming a part image file
@ -116,6 +116,56 @@ def rename_part_image(instance, filename):
return os.path.join(base, fn) 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): class Part(models.Model):
""" The Part object represents an abstract part, the 'concept' of an actual entity. """ The Part object represents an abstract part, the 'concept' of an actual entity.

View File

@ -0,0 +1,19 @@
{% extends "modal_form.html" %}
{% block pre_form_content %}
{{ block.super }}
{% if matches %}
<b>Possible Matching Parts</b>
<p>The new part may be a duplicate of these existing parts:</p>
<ul class='list-group'>
{% for match in matches %}
<li class='list-group-item list-group-item-condensed'>
{{ match.part.name }} - <i>{{ match.part.description }}</i> ({{ match.ratio }}%)
</li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}

View File

@ -10,21 +10,15 @@ from django.shortcuts import get_object_or_404
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.views.generic import DetailView, ListView from django.views.generic import DetailView, ListView
from django.forms.models import model_to_dict 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 company.models import Company
from .models import PartCategory, Part, PartAttachment from .models import PartCategory, Part, PartAttachment
from .models import BomItem from .models import BomItem
from .models import SupplierPart from .models import SupplierPart
from .models import match_part_names
from .forms import PartImageForm from . import forms as part_forms
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 InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView
from InvenTree.views import QRCodeView 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 - The view only makes sense if a Part object is passed to it
""" """
model = PartAttachment model = PartAttachment
form_class = EditPartAttachmentForm form_class = part_forms.EditPartAttachmentForm
ajax_form_title = "Add part attachment" ajax_form_title = "Add part attachment"
ajax_template_name = "modal_form.html" ajax_template_name = "modal_form.html"
@ -99,7 +93,7 @@ class PartAttachmentCreate(AjaxCreateView):
class PartAttachmentEdit(AjaxUpdateView): class PartAttachmentEdit(AjaxUpdateView):
""" View for editing a PartAttachment object """ """ View for editing a PartAttachment object """
model = PartAttachment model = PartAttachment
form_class = EditPartAttachmentForm form_class = part_forms.EditPartAttachmentForm
ajax_template_name = 'modal_form.html' ajax_template_name = 'modal_form.html'
ajax_form_title = 'Edit attachment' ajax_form_title = 'Edit attachment'
@ -139,10 +133,10 @@ class PartCreate(AjaxCreateView):
- Copy an existing Part - Copy an existing Part
""" """
model = Part model = Part
form_class = EditPartForm form_class = part_forms.EditPartForm
ajax_form_title = 'Create new part' ajax_form_title = 'Create new part'
ajax_template_name = 'modal_form.html' ajax_template_name = 'part/create_part.html'
def get_data(self): def get_data(self):
return { return {
@ -181,6 +175,51 @@ class PartCreate(AjaxCreateView):
return form 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): def get_initial(self):
""" Get initial data for the new Part object: """ Get initial data for the new Part object:
@ -260,7 +299,7 @@ class PartImage(AjaxUpdateView):
model = Part model = Part
ajax_template_name = 'modal_form.html' ajax_template_name = 'modal_form.html'
ajax_form_title = 'Upload Part Image' ajax_form_title = 'Upload Part Image'
form_class = PartImageForm form_class = part_forms.PartImageForm
def get_data(self): def get_data(self):
return { return {
@ -272,7 +311,7 @@ class PartEdit(AjaxUpdateView):
""" View for editing Part object """ """ View for editing Part object """
model = Part model = Part
form_class = EditPartForm form_class = part_forms.EditPartForm
ajax_template_name = 'modal_form.html' ajax_template_name = 'modal_form.html'
ajax_form_title = 'Edit Part Properties' ajax_form_title = 'Edit Part Properties'
context_object_name = 'part' context_object_name = 'part'
@ -298,7 +337,7 @@ class BomExport(AjaxView):
ajax_form_title = 'Export BOM' ajax_form_title = 'Export BOM'
ajax_template_name = 'part/bom_export.html' ajax_template_name = 'part/bom_export.html'
context_object_name = 'part' context_object_name = 'part'
form_class = BomExportForm form_class = part_forms.BomExportForm
def get_object(self): def get_object(self):
return get_object_or_404(Part, pk=self.kwargs['pk']) return get_object_or_404(Part, pk=self.kwargs['pk'])
@ -396,7 +435,7 @@ class CategoryDetail(DetailView):
class CategoryEdit(AjaxUpdateView): class CategoryEdit(AjaxUpdateView):
""" Update view to edit a PartCategory """ """ Update view to edit a PartCategory """
model = PartCategory model = PartCategory
form_class = EditCategoryForm form_class = part_forms.EditCategoryForm
ajax_template_name = 'modal_form.html' ajax_template_name = 'modal_form.html'
ajax_form_title = 'Edit Part Category' ajax_form_title = 'Edit Part Category'
@ -449,7 +488,7 @@ class CategoryCreate(AjaxCreateView):
ajax_form_action = reverse_lazy('category-create') ajax_form_action = reverse_lazy('category-create')
ajax_form_title = 'Create new part category' ajax_form_title = 'Create new part category'
ajax_template_name = 'modal_form.html' ajax_template_name = 'modal_form.html'
form_class = EditCategoryForm form_class = part_forms.EditCategoryForm
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
""" Add extra context data to template. """ Add extra context data to template.
@ -496,7 +535,7 @@ class BomItemDetail(DetailView):
class BomItemCreate(AjaxCreateView): class BomItemCreate(AjaxCreateView):
""" Create view for making a new BomItem object """ """ Create view for making a new BomItem object """
model = BomItem model = BomItem
form_class = EditBomItemForm form_class = part_forms.EditBomItemForm
ajax_template_name = 'modal_form.html' ajax_template_name = 'modal_form.html'
ajax_form_title = 'Create BOM item' ajax_form_title = 'Create BOM item'
@ -554,7 +593,7 @@ class BomItemEdit(AjaxUpdateView):
""" Update view for editing BomItem """ """ Update view for editing BomItem """
model = BomItem model = BomItem
form_class = EditBomItemForm form_class = part_forms.EditBomItemForm
ajax_template_name = 'modal_form.html' ajax_template_name = 'modal_form.html'
ajax_form_title = 'Edit BOM item' ajax_form_title = 'Edit BOM item'
@ -580,7 +619,7 @@ class SupplierPartEdit(AjaxUpdateView):
model = SupplierPart model = SupplierPart
context_object_name = 'part' context_object_name = 'part'
form_class = EditSupplierPartForm form_class = part_forms.EditSupplierPartForm
ajax_template_name = 'modal_form.html' ajax_template_name = 'modal_form.html'
ajax_form_title = 'Edit Supplier Part' ajax_form_title = 'Edit Supplier Part'
@ -589,7 +628,7 @@ class SupplierPartCreate(AjaxCreateView):
""" Create view for making new SupplierPart """ """ Create view for making new SupplierPart """
model = SupplierPart model = SupplierPart
form_class = EditSupplierPartForm form_class = part_forms.EditSupplierPartForm
ajax_template_name = 'modal_form.html' ajax_template_name = 'modal_form.html'
ajax_form_title = 'Create new Supplier Part' ajax_form_title = 'Create new Supplier Part'
context_object_name = 'part' context_object_name = 'part'

View File

@ -27,6 +27,10 @@
padding: 6px 12px; padding: 6px 12px;
} }
.list-group-item-condensed {
padding: 5px 10px;
}
/* Force select2 elements in modal forms to be full width */ /* Force select2 elements in modal forms to be full width */
.select-full-width { .select-full-width {
width: 100%; width: 100%;

View File

@ -1,13 +1,26 @@
{% block pre_form_content %} <div>
{% endblock %} {% if form.pre_form_info %}
<div class='alert alert-info' role='alert' style='display: block;'>
{{ form.pre_form_info }}
</div>
{% endif %}
{% if form.pre_form_warning %}
<div class='alert alert-warning' role='alert' style='display: block;'>
{{ form.pre_form_warning }}
</div>
{% endif %}
{% block non_field_error %}
{% if form.non_field_errors %} {% if form.non_field_errors %}
<div class='alert alert-danger' role='alert' style='display: block;'> <div class='alert alert-danger' role='alert' style='display: block;'>
<b>Error Submitting Form:</b> <b>Error Submitting Form:</b>
{{ form.non_field_errors }} {{ form.non_field_errors }}
</div> </div>
{% endif %} {% endif %}
{% endblock %}
</div>
{% block pre_form_content %}
{% endblock %}
<form method="post" action='' class='js-modal-form' enctype="multipart/form-data"> <form method="post" action='' class='js-modal-form' enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
{% load crispy_forms_tags %} {% load crispy_forms_tags %}

View File

@ -15,3 +15,4 @@ django-qr-code==1.0.0 # Generate QR codes
flake8==3.3.0 # PEP checking flake8==3.3.0 # PEP checking
coverage>=4.5.3 # Unit test coverage coverage>=4.5.3 # Unit test coverage
python-coveralls==2.9.1 # Coveralls linking (for Travis) python-coveralls==2.9.1 # Coveralls linking (for Travis)
fuzzywuzzy>=0.17.0 # Fuzzy string matching