Refactor autocomplete view logic to leverage new "autocompleters"

finally!  this cleans up some view config and AFAIK there is no loss
in functionality etc.
This commit is contained in:
Lance Edgar 2021-09-30 19:26:57 -04:00
parent e0dff55ffa
commit a7f4b2e6ef
12 changed files with 65 additions and 375 deletions

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# Rattail -- Retail Software Framework # Rattail -- Retail Software Framework
# Copyright © 2010-2019 Lance Edgar # Copyright © 2010-2021 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -29,9 +29,6 @@ from __future__ import unicode_literals, absolute_import
from .core import View from .core import View
from .master import MasterView from .master import MasterView
# TODO: deprecate / remove some of this
from .autocomplete import AutocompleteView
def includeme(config): def includeme(config):

View file

@ -1,95 +0,0 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2021 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Autocomplete View
"""
from __future__ import unicode_literals, absolute_import
import sqlalchemy as sa
from tailbone.views.core import View
from tailbone.db import Session
class AutocompleteView(View):
"""
Base class for generic autocomplete views.
"""
def prepare_term(self, term):
"""
If necessary, massage the incoming search term for use with the query.
"""
return term
def filter_query(self, q):
return q
def make_query(self, term):
"""
Make and return the "complete" query for the given search term.
"""
# we are querying one table (and column) primarily
query = Session.query(self.mapped_class)
column = getattr(self.mapped_class, self.fieldname)
# filter according to business logic, if applicable
query = self.filter_query(query)
# filter according to search term(s)
criteria = [column.ilike('%{}%'.format(word))
for word in term.split()]
query = query.filter(sa.and_(*criteria))
# sort results by something meaningful
query = query.order_by(column)
return query
def query(self, term):
return self.make_query(term)
def display(self, instance):
return getattr(instance, self.fieldname)
def value(self, instance):
"""
Determine the data value for a query result instance.
"""
return instance.uuid
def get_data(self, term):
return self.query(term).all()
def __call__(self):
"""
View implementation.
"""
term = self.request.params.get(u'term') or u''
term = term.strip()
if term:
term = self.prepare_term(term)
if not term:
return []
results = self.get_data(term)
return [{'label': self.display(x), 'value': self.value(x)} for x in results]

View file

@ -28,7 +28,7 @@ from __future__ import unicode_literals, absolute_import
from rattail.db import model from rattail.db import model
from tailbone.views import MasterView, AutocompleteView from tailbone.views import MasterView
class BrandView(MasterView): class BrandView(MasterView):
@ -38,6 +38,7 @@ class BrandView(MasterView):
model_class = model.Brand model_class = model.Brand
has_versions = True has_versions = True
bulk_deletable = True bulk_deletable = True
supports_autocomplete = True
mergeable = True mergeable = True
merge_additive_fields = [ merge_additive_fields = [
@ -133,21 +134,6 @@ class BrandView(MasterView):
self.Session.flush() self.Session.flush()
self.Session.delete(removing) self.Session.delete(removing)
# TODO: deprecate / remove this
BrandsView = BrandView
class BrandsAutocomplete(AutocompleteView):
mapped_class = model.Brand
fieldname = 'name'
def includeme(config): def includeme(config):
# autocomplete
config.add_route('brands.autocomplete', '/brands/autocomplete')
config.add_view(BrandsAutocomplete, route_name='brands.autocomplete',
renderer='json', permission='brands.list')
BrandView.defaults(config) BrandView.defaults(config)

View file

@ -26,11 +26,8 @@ Customer Views
from __future__ import unicode_literals, absolute_import from __future__ import unicode_literals, absolute_import
import re
import six import six
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import orm
import colander import colander
from pyramid.httpexceptions import HTTPNotFound from pyramid.httpexceptions import HTTPNotFound
@ -38,7 +35,7 @@ from webhelpers2.html import HTML, tags
from tailbone import grids from tailbone import grids
from tailbone.db import Session from tailbone.db import Session
from tailbone.views import MasterView, AutocompleteView from tailbone.views import MasterView
from rattail.db import model from rattail.db import model
@ -52,6 +49,7 @@ class CustomerView(MasterView):
has_versions = True has_versions = True
people_detachable = True people_detachable = True
touchable = True touchable = True
supports_autocomplete = True
# whether to show "view full profile" helper for customer view # whether to show "view full profile" helper for customer view
show_profiles_helper = True show_profiles_helper = True
@ -451,9 +449,6 @@ class CustomerView(MasterView):
route_name='{}.detach_person'.format(route_prefix), route_name='{}.detach_person'.format(route_prefix),
permission='{}.detach_person'.format(permission_prefix)) permission='{}.detach_person'.format(permission_prefix))
# TODO: deprecate / remove this
CustomersView = CustomerView
# # TODO: this is referenced by some custom apps, but should be moved?? # # TODO: this is referenced by some custom apps, but should be moved??
# def unique_id(value, field): # def unique_id(value, field):
@ -473,43 +468,6 @@ def unique_id(node, value):
raise colander.Invalid(node, "Customer ID must be unique") raise colander.Invalid(node, "Customer ID must be unique")
class CustomerNameAutocomplete(AutocompleteView):
"""
Autocomplete view which operates on customer name.
"""
mapped_class = model.Customer
fieldname = 'name'
class CustomerPhoneAutocomplete(AutocompleteView):
"""
Autocomplete view which operates on customer phone number.
.. note::
As currently implemented, this view will only work with a PostgreSQL
database. It normalizes the user's search term and the database values
to numeric digits only (i.e. removes special characters from each) in
order to be able to perform smarter matching. However normalizing the
database value currently uses the PG SQL ``regexp_replace()`` function.
"""
invalid_pattern = re.compile(r'\D')
def prepare_term(self, term):
return self.invalid_pattern.sub('', term)
def query(self, term):
return Session.query(model.CustomerPhoneNumber)\
.filter(sa.func.regexp_replace(model.CustomerPhoneNumber.number, r'\D', '', 'g').like('%{0}%'.format(term)))\
.order_by(model.CustomerPhoneNumber.number)\
.options(orm.joinedload(model.CustomerPhoneNumber.customer))
def display(self, phone):
return "{0} {1}".format(phone.number, phone.customer)
def value(self, phone):
return phone.customer.uuid
def customer_info(request): def customer_info(request):
""" """
View which returns simple dictionary of info for a particular customer. View which returns simple dictionary of info for a particular customer.
@ -527,14 +485,6 @@ def customer_info(request):
def includeme(config): def includeme(config):
# autocomplete
config.add_route('customers.autocomplete', '/customers/autocomplete')
config.add_view(CustomerNameAutocomplete, route_name='customers.autocomplete',
renderer='json', permission='customers.list')
config.add_route('customers.autocomplete.phone', '/customers/autocomplete/phone')
config.add_view(CustomerPhoneAutocomplete, route_name='customers.autocomplete.phone',
renderer='json', permission='customers.list')
# info # info
config.add_route('customer.info', '/customers/info') config.add_route('customer.info', '/customers/info')
config.add_view(customer_info, route_name='customer.info', config.add_view(customer_info, route_name='customer.info',

View file

@ -30,11 +30,10 @@ import six
from rattail.db import model from rattail.db import model
from deform import widget as dfwidget
from webhelpers2.html import HTML from webhelpers2.html import HTML
from tailbone import grids from tailbone import grids
from tailbone.views import MasterView, AutocompleteView from tailbone.views import MasterView
class DepartmentView(MasterView): class DepartmentView(MasterView):
@ -44,6 +43,7 @@ class DepartmentView(MasterView):
model_class = model.Department model_class = model.Department
touchable = True touchable = True
has_versions = True has_versions = True
supports_autocomplete = True
grid_columns = [ grid_columns = [
'number', 'number',
@ -239,21 +239,6 @@ class DepartmentView(MasterView):
cls._defaults(config) cls._defaults(config)
# TODO: deprecate / remove this
DepartmentsView = DepartmentView
class DepartmentsAutocomplete(AutocompleteView):
mapped_class = model.Department
fieldname = 'name'
def includeme(config): def includeme(config):
# autocomplete
config.add_route('departments.autocomplete', '/departments/autocomplete')
config.add_view(DepartmentsAutocomplete, route_name='departments.autocomplete',
renderer='json', permission='departments.list')
DepartmentView.defaults(config) DepartmentView.defaults(config)

View file

@ -36,8 +36,7 @@ from deform import widget as dfwidget
from webhelpers2.html import tags, HTML from webhelpers2.html import tags, HTML
from tailbone import grids from tailbone import grids
from tailbone.db import Session from tailbone.views import MasterView
from tailbone.views import MasterView, AutocompleteView
class EmployeeView(MasterView): class EmployeeView(MasterView):
@ -47,6 +46,7 @@ class EmployeeView(MasterView):
model_class = model.Employee model_class = model.Employee
has_versions = True has_versions = True
touchable = True touchable = True
supports_autocomplete = True
labels = { labels = {
'id': "ID", 'id': "ID",
@ -310,31 +310,6 @@ class EmployeeView(MasterView):
(model.EmployeeDepartment, 'employee_uuid'), (model.EmployeeDepartment, 'employee_uuid'),
] ]
# TODO: deprecate / remove this
EmployeesView = EmployeeView
class EmployeesAutocomplete(AutocompleteView):
"""
Autocomplete view for the Employee model, but restricted to return only
results for current employees.
"""
mapped_class = model.Person
fieldname = 'display_name'
def filter_query(self, q):
return q.join(model.Employee)\
.filter(model.Employee.status == self.enum.EMPLOYEE_STATUS_CURRENT)
def value(self, person):
return person.employee.uuid
def includeme(config): def includeme(config):
# autocomplete
config.add_route('employees.autocomplete', '/employees/autocomplete')
config.add_view(EmployeesAutocomplete, route_name='employees.autocomplete',
renderer='json', permission='employees.list')
EmployeeView.defaults(config) EmployeeView.defaults(config)

View file

@ -97,6 +97,7 @@ class MasterView(View):
delete_confirm = 'full' delete_confirm = 'full'
bulk_deletable = False bulk_deletable = False
set_deletable = False set_deletable = False
supports_autocomplete = False
supports_set_enabled_toggle = False supports_set_enabled_toggle = False
populatable = False populatable = False
mergeable = False mergeable = False
@ -3573,6 +3574,35 @@ class MasterView(View):
return self.after_delete_url return self.after_delete_url
return self.get_index_url() return self.get_index_url()
##############################
# Autocomplete Stuff
##############################
def autocomplete(self):
"""
View which accepts a single ``term`` param, and returns a list
of autocomplete results to match.
"""
app = self.get_rattail_app()
key = self.get_autocompleter_key()
# url may include key, for more specific autocompleter
if 'key' in self.request.matchdict:
key = '{}.{}'.format(key, self.request.matchdict['key'])
autocompleter = app.get_autocompleter(key)
term = self.request.params.get('term', '')
return autocompleter.autocomplete(self.Session(), term)
def get_autocompleter_key(self):
"""
Must return the "key" to be used when locating the
Autocompleter object, for use with autocomplete view.
"""
if hasattr(self, 'autocompleter_key'):
if self.autocompleter_key:
return self.autocompleter_key
return self.get_route_prefix()
############################## ##############################
# Associated Rows Stuff # Associated Rows Stuff
############################## ##############################
@ -3965,6 +3995,25 @@ class MasterView(View):
config.add_view(cls, attr='quickie', route_name='{}.quickie'.format(route_prefix), config.add_view(cls, attr='quickie', route_name='{}.quickie'.format(route_prefix),
permission='{}.quickie'.format(permission_prefix)) permission='{}.quickie'.format(permission_prefix))
# autocomplete
if cls.supports_autocomplete:
# default
config.add_route('{}.autocomplete'.format(route_prefix),
'{}/autocomplete'.format(url_prefix))
config.add_view(cls, attr='autocomplete',
route_name='{}.autocomplete'.format(route_prefix),
renderer='json',
permission='{}.list'.format(permission_prefix))
# special
config.add_route('{}.autocomplete_special'.format(route_prefix),
'{}/autocomplete/{{key}}'.format(url_prefix))
config.add_view(cls, attr='autocomplete',
route_name='{}.autocomplete_special'.format(route_prefix),
renderer='json',
permission='{}.list'.format(permission_prefix))
# create # create
if cls.creatable: if cls.creatable:
config.add_tailbone_permission(permission_prefix, '{}.create'.format(permission_prefix), config.add_tailbone_permission(permission_prefix, '{}.create'.format(permission_prefix),

View file

@ -41,7 +41,7 @@ from pyramid.httpexceptions import HTTPFound, HTTPNotFound
from webhelpers2.html import HTML, tags from webhelpers2.html import HTML, tags
from tailbone import forms, grids from tailbone import forms, grids
from tailbone.views import MasterView, AutocompleteView from tailbone.views import MasterView
class PersonView(MasterView): class PersonView(MasterView):
@ -56,6 +56,7 @@ class PersonView(MasterView):
bulk_deletable = True bulk_deletable = True
is_contact = True is_contact = True
manage_notes_from_profile_view = False manage_notes_from_profile_view = False
supports_autocomplete = True
labels = { labels = {
'default_phone': "Phone Number", 'default_phone': "Phone Number",
@ -854,25 +855,6 @@ class PersonView(MasterView):
config.add_view(cls, attr='request_merge', route_name='{}.request_merge'.format(route_prefix), config.add_view(cls, attr='request_merge', route_name='{}.request_merge'.format(route_prefix),
permission='{}.request_merge'.format(permission_prefix)) permission='{}.request_merge'.format(permission_prefix))
# TODO: deprecate / remove this
PeopleView = PersonView
class PeopleAutocomplete(AutocompleteView):
mapped_class = model.Person
fieldname = 'display_name'
class PeopleEmployeesAutocomplete(PeopleAutocomplete):
"""
Autocomplete view for the Person model, but restricted to return only
results for people who are employees.
"""
def filter_query(self, q):
return q.join(model.Employee)
class PersonNoteView(MasterView): class PersonNoteView(MasterView):
""" """
@ -1044,15 +1026,6 @@ class MergePeopleRequestView(MasterView):
def includeme(config): def includeme(config):
# autocomplete
config.add_route('people.autocomplete', '/people/autocomplete')
config.add_view(PeopleAutocomplete, route_name='people.autocomplete',
renderer='json', permission='people.list')
config.add_route('people.autocomplete.employees', '/people/autocomplete/employees')
config.add_view(PeopleEmployeesAutocomplete, route_name='people.autocomplete.employees',
renderer='json', permission='people.list')
PersonView.defaults(config) PersonView.defaults(config)
PersonNoteView.defaults(config) PersonNoteView.defaults(config)
MergePeopleRequestView.defaults(config) MergePeopleRequestView.defaults(config)

View file

@ -51,7 +51,7 @@ from webhelpers2.html import tags, HTML
from tailbone import forms, grids from tailbone import forms, grids
from tailbone.db import Session from tailbone.db import Session
from tailbone.views import MasterView, AutocompleteView from tailbone.views import MasterView
from tailbone.util import raw_datetime from tailbone.util import raw_datetime
@ -84,6 +84,7 @@ class ProductView(MasterView):
model_class = model.Product model_class = model.Product
has_versions = True has_versions = True
results_downloadable_xlsx = True results_downloadable_xlsx = True
supports_autocomplete = True
labels = { labels = {
'item_id': "Item ID", 'item_id': "Item ID",
@ -1886,31 +1887,6 @@ class ProductView(MasterView):
renderer='json', renderer='json',
permission='{}.versions'.format(permission_prefix)) permission='{}.versions'.format(permission_prefix))
# TODO: deprecate / remove this
ProductsView = ProductView
class ProductsAutocomplete(AutocompleteView):
"""
Autocomplete view for products.
"""
mapped_class = model.Product
fieldname = 'description'
def query(self, term):
q = Session.query(model.Product).outerjoin(model.Brand)
q = q.filter(sa.or_(
model.Brand.name.ilike('%{}%'.format(term)),
model.Product.description.ilike('%{}%'.format(term))))
if not self.request.has_perm('products.view_deleted'):
q = q.filter(model.Product.deleted == False)
q = q.order_by(model.Brand.name, model.Product.description)
q = q.options(orm.joinedload(model.Product.brand))
return q
def display(self, product):
return product.full_description
def print_labels(request): def print_labels(request):
profile = request.params.get('profile') profile = request.params.get('profile')
@ -1942,10 +1918,6 @@ def print_labels(request):
def includeme(config): def includeme(config):
config.add_route('products.autocomplete', '/products/autocomplete')
config.add_view(ProductsAutocomplete, route_name='products.autocomplete',
renderer='json', permission='products.list')
config.add_route('products.print_labels', '/products/labels') config.add_route('products.print_labels', '/products/labels')
config.add_view(print_labels, route_name='products.print_labels', config.add_view(print_labels, route_name='products.print_labels',
renderer='json', permission='products.print_labels') renderer='json', permission='products.print_labels')

View file

@ -26,9 +26,7 @@ Views pertaining to vendors
from __future__ import unicode_literals, absolute_import from __future__ import unicode_literals, absolute_import
from .core import VendorView, VendorsAutocomplete from .core import VendorView
# TODO: deprecate / remove this
from .core import VendorsView
def includeme(config): def includeme(config):

View file

@ -32,7 +32,7 @@ from rattail.db import model
from webhelpers2.html import tags from webhelpers2.html import tags
from tailbone.views import MasterView, AutocompleteView from tailbone.views import MasterView
class VendorView(MasterView): class VendorView(MasterView):
@ -42,6 +42,7 @@ class VendorView(MasterView):
model_class = model.Vendor model_class = model.Vendor
has_versions = True has_versions = True
touchable = True touchable = True
supports_autocomplete = True
labels = { labels = {
'id': "ID", 'id': "ID",
@ -167,21 +168,6 @@ class VendorView(MasterView):
(model.VendorContact, 'vendor_uuid'), (model.VendorContact, 'vendor_uuid'),
] ]
# TODO: deprecate / remove this
VendorsView = VendorView
class VendorsAutocomplete(AutocompleteView):
mapped_class = model.Vendor
fieldname = 'name'
def includeme(config): def includeme(config):
# autocomplete
config.add_route('vendors.autocomplete', '/vendors/autocomplete')
config.add_view(VendorsAutocomplete, route_name='vendors.autocomplete',
renderer='json', permission='vendors.list')
VendorView.defaults(config) VendorView.defaults(config)

View file

@ -1,86 +0,0 @@
from mock import Mock
from pyramid import testing
import sqlalchemy as sa
from .. import TestCase, mock_query
from tailbone.views import autocomplete
class BareAutocompleteViewTests(TestCase):
def view(self, **kwargs):
request = testing.DummyRequest(**kwargs)
return autocomplete.AutocompleteView(request)
def test_attributes(self):
view = self.view()
self.assertRaises(AttributeError, getattr, view, 'mapped_class')
self.assertRaises(AttributeError, getattr, view, 'fieldname')
def test_filter_query(self):
view = self.view()
query = Mock()
filtered = view.filter_query(query)
self.assertTrue(filtered is query)
def test_make_query(self):
view = self.view()
# No mapped_class defined for view.
self.assertRaises(AttributeError, view.make_query, 'test')
def test_query(self):
view = self.view()
query = Mock()
view.make_query = Mock(return_value=query)
filtered = view.query('test')
self.assertTrue(filtered is query)
def test_display(self):
view = self.view()
instance = Mock()
# No fieldname defined for view.
self.assertRaises(AttributeError, view.display, instance)
def test_call(self):
# Empty or missing query term yields empty list.
view = self.view(params={})
self.assertEqual(view(), [])
view = self.view(params={'term': None})
self.assertEqual(view(), [])
view = self.view(params={'term': ''})
self.assertEqual(view(), [])
view = self.view(params={'term': '\t'})
self.assertEqual(view(), [])
# No mapped_class defined for view.
view = self.view(params={'term': 'bogus'})
self.assertRaises(AttributeError, view)
class SampleAutocompleteViewTests(TestCase):
def setUp(self):
super(SampleAutocompleteViewTests, self).setUp()
self.Session_query = autocomplete.Session.query
self.query = mock_query()
autocomplete.Session.query = self.query
def tearDown(self):
super(SampleAutocompleteViewTests, self).tearDown()
autocomplete.Session.query = self.Session_query
def view(self, **kwargs):
request = testing.DummyRequest(**kwargs)
view = autocomplete.AutocompleteView(request)
view.mapped_class = Mock()
view.fieldname = 'thing'
return view
def test_make_query(self):
view = self.view()
whatever = sa.text('whatever')
view.mapped_class.thing.ilike.return_value = whatever
self.assertTrue(view.make_query('test') is self.query)
view.mapped_class.thing.ilike.assert_called_with('%test%')
self.query.filter.assert_called_with(whatever)
self.query.order_by.assert_called_with(view.mapped_class.thing)