diff --git a/tailbone/views/__init__.py b/tailbone/views/__init__.py index 135e45b9..6b6ebc19 100644 --- a/tailbone/views/__init__.py +++ b/tailbone/views/__init__.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -29,9 +29,6 @@ from __future__ import unicode_literals, absolute_import from .core import View from .master import MasterView -# TODO: deprecate / remove some of this -from .autocomplete import AutocompleteView - def includeme(config): diff --git a/tailbone/views/autocomplete.py b/tailbone/views/autocomplete.py deleted file mode 100644 index f2a12d0e..00000000 --- a/tailbone/views/autocomplete.py +++ /dev/null @@ -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 . -# -################################################################################ -""" -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] diff --git a/tailbone/views/brands.py b/tailbone/views/brands.py index 29cd6adc..b73060a3 100644 --- a/tailbone/views/brands.py +++ b/tailbone/views/brands.py @@ -28,7 +28,7 @@ from __future__ import unicode_literals, absolute_import from rattail.db import model -from tailbone.views import MasterView, AutocompleteView +from tailbone.views import MasterView class BrandView(MasterView): @@ -38,6 +38,7 @@ class BrandView(MasterView): model_class = model.Brand has_versions = True bulk_deletable = True + supports_autocomplete = True mergeable = True merge_additive_fields = [ @@ -133,21 +134,6 @@ class BrandView(MasterView): self.Session.flush() self.Session.delete(removing) -# TODO: deprecate / remove this -BrandsView = BrandView - - -class BrandsAutocomplete(AutocompleteView): - - mapped_class = model.Brand - fieldname = 'name' - 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) diff --git a/tailbone/views/customers.py b/tailbone/views/customers.py index f2e5f2dd..27b19e94 100644 --- a/tailbone/views/customers.py +++ b/tailbone/views/customers.py @@ -26,11 +26,8 @@ Customer Views from __future__ import unicode_literals, absolute_import -import re - import six import sqlalchemy as sa -from sqlalchemy import orm import colander from pyramid.httpexceptions import HTTPNotFound @@ -38,7 +35,7 @@ from webhelpers2.html import HTML, tags from tailbone import grids from tailbone.db import Session -from tailbone.views import MasterView, AutocompleteView +from tailbone.views import MasterView from rattail.db import model @@ -52,6 +49,7 @@ class CustomerView(MasterView): has_versions = True people_detachable = True touchable = True + supports_autocomplete = True # whether to show "view full profile" helper for customer view show_profiles_helper = True @@ -451,9 +449,6 @@ class CustomerView(MasterView): route_name='{}.detach_person'.format(route_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?? # def unique_id(value, field): @@ -473,43 +468,6 @@ def unique_id(node, value): 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): """ View which returns simple dictionary of info for a particular customer. @@ -527,14 +485,6 @@ def customer_info(request): 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 config.add_route('customer.info', '/customers/info') config.add_view(customer_info, route_name='customer.info', diff --git a/tailbone/views/departments.py b/tailbone/views/departments.py index 1d3c36c6..8c841f6b 100644 --- a/tailbone/views/departments.py +++ b/tailbone/views/departments.py @@ -30,11 +30,10 @@ import six from rattail.db import model -from deform import widget as dfwidget from webhelpers2.html import HTML from tailbone import grids -from tailbone.views import MasterView, AutocompleteView +from tailbone.views import MasterView class DepartmentView(MasterView): @@ -44,6 +43,7 @@ class DepartmentView(MasterView): model_class = model.Department touchable = True has_versions = True + supports_autocomplete = True grid_columns = [ 'number', @@ -239,21 +239,6 @@ class DepartmentView(MasterView): cls._defaults(config) -# TODO: deprecate / remove this -DepartmentsView = DepartmentView - - -class DepartmentsAutocomplete(AutocompleteView): - - mapped_class = model.Department - fieldname = 'name' - 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) diff --git a/tailbone/views/employees.py b/tailbone/views/employees.py index aa97b9b7..3ad331ab 100644 --- a/tailbone/views/employees.py +++ b/tailbone/views/employees.py @@ -36,8 +36,7 @@ from deform import widget as dfwidget from webhelpers2.html import tags, HTML from tailbone import grids -from tailbone.db import Session -from tailbone.views import MasterView, AutocompleteView +from tailbone.views import MasterView class EmployeeView(MasterView): @@ -47,6 +46,7 @@ class EmployeeView(MasterView): model_class = model.Employee has_versions = True touchable = True + supports_autocomplete = True labels = { 'id': "ID", @@ -310,31 +310,6 @@ class EmployeeView(MasterView): (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): - - # autocomplete - config.add_route('employees.autocomplete', '/employees/autocomplete') - config.add_view(EmployeesAutocomplete, route_name='employees.autocomplete', - renderer='json', permission='employees.list') - EmployeeView.defaults(config) diff --git a/tailbone/views/master.py b/tailbone/views/master.py index d238a4bb..ce7fcca7 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -97,6 +97,7 @@ class MasterView(View): delete_confirm = 'full' bulk_deletable = False set_deletable = False + supports_autocomplete = False supports_set_enabled_toggle = False populatable = False mergeable = False @@ -3573,6 +3574,35 @@ class MasterView(View): return self.after_delete_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 ############################## @@ -3965,6 +3995,25 @@ class MasterView(View): config.add_view(cls, attr='quickie', route_name='{}.quickie'.format(route_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 if cls.creatable: config.add_tailbone_permission(permission_prefix, '{}.create'.format(permission_prefix), diff --git a/tailbone/views/people.py b/tailbone/views/people.py index a393df99..a5adb399 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -41,7 +41,7 @@ from pyramid.httpexceptions import HTTPFound, HTTPNotFound from webhelpers2.html import HTML, tags from tailbone import forms, grids -from tailbone.views import MasterView, AutocompleteView +from tailbone.views import MasterView class PersonView(MasterView): @@ -56,6 +56,7 @@ class PersonView(MasterView): bulk_deletable = True is_contact = True manage_notes_from_profile_view = False + supports_autocomplete = True labels = { '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), 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): """ @@ -1044,15 +1026,6 @@ class MergePeopleRequestView(MasterView): 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) PersonNoteView.defaults(config) MergePeopleRequestView.defaults(config) diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 4a52f682..3419ccfe 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -51,7 +51,7 @@ from webhelpers2.html import tags, HTML from tailbone import forms, grids from tailbone.db import Session -from tailbone.views import MasterView, AutocompleteView +from tailbone.views import MasterView from tailbone.util import raw_datetime @@ -84,6 +84,7 @@ class ProductView(MasterView): model_class = model.Product has_versions = True results_downloadable_xlsx = True + supports_autocomplete = True labels = { 'item_id': "Item ID", @@ -1886,31 +1887,6 @@ class ProductView(MasterView): renderer='json', 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): profile = request.params.get('profile') @@ -1942,10 +1918,6 @@ def print_labels(request): 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_view(print_labels, route_name='products.print_labels', renderer='json', permission='products.print_labels') diff --git a/tailbone/views/vendors/__init__.py b/tailbone/views/vendors/__init__.py index 885ec712..6a31777c 100644 --- a/tailbone/views/vendors/__init__.py +++ b/tailbone/views/vendors/__init__.py @@ -26,9 +26,7 @@ Views pertaining to vendors from __future__ import unicode_literals, absolute_import -from .core import VendorView, VendorsAutocomplete -# TODO: deprecate / remove this -from .core import VendorsView +from .core import VendorView def includeme(config): diff --git a/tailbone/views/vendors/core.py b/tailbone/views/vendors/core.py index 7a6f4eca..ceac1c71 100644 --- a/tailbone/views/vendors/core.py +++ b/tailbone/views/vendors/core.py @@ -32,7 +32,7 @@ from rattail.db import model from webhelpers2.html import tags -from tailbone.views import MasterView, AutocompleteView +from tailbone.views import MasterView class VendorView(MasterView): @@ -42,6 +42,7 @@ class VendorView(MasterView): model_class = model.Vendor has_versions = True touchable = True + supports_autocomplete = True labels = { 'id': "ID", @@ -167,21 +168,6 @@ class VendorView(MasterView): (model.VendorContact, 'vendor_uuid'), ] -# TODO: deprecate / remove this -VendorsView = VendorView - - -class VendorsAutocomplete(AutocompleteView): - - mapped_class = model.Vendor - fieldname = 'name' - 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) diff --git a/tests/views/test_autocomplete.py b/tests/views/test_autocomplete.py deleted file mode 100644 index 717a2621..00000000 --- a/tests/views/test_autocomplete.py +++ /dev/null @@ -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)