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
"""
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='"'):

View File

@ -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):

View File

@ -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',

View File

@ -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.<img>
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.

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.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'

View File

@ -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%;

View File

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

View File

@ -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)
python-coveralls==2.9.1 # Coveralls linking (for Travis)
fuzzywuzzy>=0.17.0 # Fuzzy string matching