From 9d261de45aec8fdfd0a7aedd5d445ff27d40eb65 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 21 Aug 2024 11:46:38 -0500 Subject: [PATCH] feat: add basic autocomplete support, for Person URL endpoint only for now, form widget to come later --- src/wuttaweb/views/master.py | 94 +++++++ src/wuttaweb/views/people.py | 14 ++ src/wuttaweb/views/settings.py | 8 + tests/views/test_master.py | 446 ++++++++++++++++++--------------- tests/views/test_people.py | 24 ++ tests/views/test_settings.py | 8 + 6 files changed, 391 insertions(+), 203 deletions(-) diff --git a/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py index 3422fe1..6b20f0a 100644 --- a/src/wuttaweb/views/master.py +++ b/src/wuttaweb/views/master.py @@ -263,6 +263,12 @@ class MasterView(View): This is optional; see also :meth:`get_form_fields()`. + .. attribute:: has_autocomplete + + Boolean indicating whether the view model supports + "autocomplete" - i.e. it should have an :meth:`autocomplete()` + view. Default is ``False``. + .. attribute:: configurable Boolean indicating whether the master view supports @@ -286,6 +292,7 @@ class MasterView(View): viewable = True editable = True deletable = True + has_autocomplete = False configurable = False # current action @@ -573,6 +580,84 @@ class MasterView(View): session = self.app.get_session(obj) session.delete(obj) + ############################## + # autocomplete methods + ############################## + + def autocomplete(self): + """ + View which accepts a single ``term`` param, and returns a JSON + list of autocomplete results to match. + + By default, this view is included only if + :attr:`has_autocomplete` is true. It usually maps to a URL + like ``/widgets/autocomplete``. + + Subclass generally does not need to override this method, but + rather should override the others which this calls: + + * :meth:`autocomplete_data()` + * :meth:`autocomplete_normalize()` + """ + term = self.request.GET.get('term', '') + if not term: + return [] + + data = self.autocomplete_data(term) + if not data: + return [] + + max_results = 100 # TODO + + results = [] + for obj in data[:max_results]: + normal = self.autocomplete_normalize(obj) + if normal: + results.append(normal) + + return results + + def autocomplete_data(self, term): + """ + Should return the data/query for the "matching" model records, + based on autocomplete search term. This is called by + :meth:`autocomplete()`. + + Subclass must override this; default logic returns no data. + + :param term: String search term as-is from user, e.g. "foo bar". + + :returns: List of data records, or SQLAlchemy query. + """ + + def autocomplete_normalize(self, obj): + """ + Should return a "normalized" version of the given model + record, suitable for autocomplete JSON results. This is + called by :meth:`autocomplete()`. + + Subclass may need to override this; default logic is + simplistic but will work for basic models. It returns the + "autocomplete results" dict for the object:: + + { + 'value': obj.uuid, + 'label': str(obj), + } + + The 2 keys shown are required; any other keys will be ignored + by the view logic but may be useful on the frontend widget. + + :param obj: Model record/instance. + + :returns: Dict of "autocomplete results" format, as shown + above. + """ + return { + 'value': obj.uuid, + 'label': str(obj), + } + ############################## # configure methods ############################## @@ -1888,6 +1973,15 @@ class MasterView(View): f'{permission_prefix}.delete', f"Delete {model_title}") + # autocomplete + if cls.has_autocomplete: + config.add_route(f'{route_prefix}.autocomplete', + f'{url_prefix}/autocomplete') + config.add_view(cls, attr='autocomplete', + route_name=f'{route_prefix}.autocomplete', + renderer='json', + permission=f'{route_prefix}.list') + # configure if cls.configurable: config.add_route(f'{route_prefix}.configure', diff --git a/src/wuttaweb/views/people.py b/src/wuttaweb/views/people.py index 5097d06..b137e3c 100644 --- a/src/wuttaweb/views/people.py +++ b/src/wuttaweb/views/people.py @@ -24,6 +24,8 @@ Views for people """ +import sqlalchemy as sa + from wuttjamaican.db.model import Person from wuttaweb.views import MasterView @@ -46,6 +48,7 @@ class PersonView(MasterView): model_title_plural = "People" route_prefix = 'people' sort_defaults = 'full_name' + has_autocomplete = True grid_columns = [ 'full_name', @@ -85,6 +88,17 @@ class PersonView(MasterView): if 'users' in f: f.fields.remove('users') + def autocomplete_query(self, term): + """ """ + model = self.app.model + session = self.Session() + query = session.query(model.Person) + criteria = [model.Person.full_name.ilike(f'%{word}%') + for word in term.split()] + query = query.filter(sa.and_(*criteria))\ + .order_by(model.Person.full_name) + return query + def view_profile(self, session=None): """ """ person = self.get_instance(session=session) diff --git a/src/wuttaweb/views/settings.py b/src/wuttaweb/views/settings.py index fb67ae8..bf77894 100644 --- a/src/wuttaweb/views/settings.py +++ b/src/wuttaweb/views/settings.py @@ -197,6 +197,14 @@ class SettingView(MasterView): model_title = "Raw Setting" sort_defaults = 'name' + # TODO: master should handle this (per model key) + def configure_grid(self, g): + """ """ + super().configure_grid(g) + + # name + g.set_link('name') + def configure_form(self, f): """ """ super().configure_form(f) diff --git a/tests/views/test_master.py b/tests/views/test_master.py index 21da2e5..6660cd6 100644 --- a/tests/views/test_master.py +++ b/tests/views/test_master.py @@ -10,7 +10,7 @@ from pyramid.response import Response from pyramid.httpexceptions import HTTPNotFound from wuttjamaican.conf import WuttaConfig -from wuttaweb.views import master +from wuttaweb.views import master as mod from wuttaweb.views import View from wuttaweb.subscribers import new_request_set_user from tests.util import WebTestCase @@ -19,14 +19,15 @@ from tests.util import WebTestCase class TestMasterView(WebTestCase): def make_view(self): - return master.MasterView(self.request) + return mod.MasterView(self.request) def test_defaults(self): - with patch.multiple(master.MasterView, create=True, + with patch.multiple(mod.MasterView, create=True, model_name='Widget', model_key='uuid', + has_autocomplete=True, configurable=True): - master.MasterView.defaults(self.pyramid_config) + mod.MasterView.defaults(self.pyramid_config) ############################## # class methods @@ -35,317 +36,315 @@ class TestMasterView(WebTestCase): def test_get_model_class(self): # no model class by default - self.assertIsNone(master.MasterView.get_model_class()) + self.assertIsNone(mod.MasterView.get_model_class()) # subclass may specify MyModel = MagicMock() - with patch.multiple(master.MasterView, create=True, + with patch.multiple(mod.MasterView, create=True, model_class=MyModel): - self.assertIs(master.MasterView.get_model_class(), MyModel) + self.assertIs(mod.MasterView.get_model_class(), MyModel) def test_get_model_name(self): # error by default (since no model class) - self.assertRaises(AttributeError, master.MasterView.get_model_name) + self.assertRaises(AttributeError, mod.MasterView.get_model_name) # subclass may specify model name - master.MasterView.model_name = 'Widget' - self.assertEqual(master.MasterView.get_model_name(), 'Widget') - del master.MasterView.model_name + with patch.multiple(mod.MasterView, create=True, + model_name='Widget'): + self.assertEqual(mod.MasterView.get_model_name(), 'Widget') # or it may specify model class MyModel = MagicMock(__name__='Blaster') - with patch.multiple(master.MasterView, create=True, + with patch.multiple(mod.MasterView, create=True, model_class=MyModel): - self.assertEqual(master.MasterView.get_model_name(), 'Blaster') + self.assertEqual(mod.MasterView.get_model_name(), 'Blaster') def test_get_model_name_normalized(self): # error by default (since no model class) - self.assertRaises(AttributeError, master.MasterView.get_model_name_normalized) + self.assertRaises(AttributeError, mod.MasterView.get_model_name_normalized) # subclass may specify *normalized* model name - master.MasterView.model_name_normalized = 'widget' - self.assertEqual(master.MasterView.get_model_name_normalized(), 'widget') - del master.MasterView.model_name_normalized + with patch.multiple(mod.MasterView, create=True, + model_name_normalized='widget'): + self.assertEqual(mod.MasterView.get_model_name_normalized(), 'widget') # or it may specify *standard* model name - master.MasterView.model_name = 'Blaster' - self.assertEqual(master.MasterView.get_model_name_normalized(), 'blaster') - del master.MasterView.model_name + with patch.multiple(mod.MasterView, create=True, + model_name='Blaster'): + self.assertEqual(mod.MasterView.get_model_name_normalized(), 'blaster') # or it may specify model class MyModel = MagicMock(__name__='Dinosaur') - with patch.multiple(master.MasterView, create=True, + with patch.multiple(mod.MasterView, create=True, model_class=MyModel): - self.assertEqual(master.MasterView.get_model_name_normalized(), 'dinosaur') + self.assertEqual(mod.MasterView.get_model_name_normalized(), 'dinosaur') def test_get_model_title(self): # error by default (since no model class) - self.assertRaises(AttributeError, master.MasterView.get_model_title) + self.assertRaises(AttributeError, mod.MasterView.get_model_title) # subclass may specify model title - master.MasterView.model_title = 'Wutta Widget' - self.assertEqual(master.MasterView.get_model_title(), "Wutta Widget") - del master.MasterView.model_title + with patch.multiple(mod.MasterView, create=True, + model_title='Wutta Widget'): + self.assertEqual(mod.MasterView.get_model_title(), "Wutta Widget") # or it may specify model name - master.MasterView.model_name = 'Blaster' - self.assertEqual(master.MasterView.get_model_title(), "Blaster") - del master.MasterView.model_name + with patch.multiple(mod.MasterView, create=True, + model_name='Blaster'): + self.assertEqual(mod.MasterView.get_model_title(), "Blaster") # or it may specify model class MyModel = MagicMock(__name__='Dinosaur') - with patch.multiple(master.MasterView, create=True, + with patch.multiple(mod.MasterView, create=True, model_class=MyModel): - self.assertEqual(master.MasterView.get_model_title(), "Dinosaur") + self.assertEqual(mod.MasterView.get_model_title(), "Dinosaur") def test_get_model_title_plural(self): # error by default (since no model class) - self.assertRaises(AttributeError, master.MasterView.get_model_title_plural) + self.assertRaises(AttributeError, mod.MasterView.get_model_title_plural) # subclass may specify *plural* model title - master.MasterView.model_title_plural = 'People' - self.assertEqual(master.MasterView.get_model_title_plural(), "People") - del master.MasterView.model_title_plural + with patch.multiple(mod.MasterView, create=True, + model_title_plural='People'): + self.assertEqual(mod.MasterView.get_model_title_plural(), "People") # or it may specify *singular* model title - master.MasterView.model_title = 'Wutta Widget' - self.assertEqual(master.MasterView.get_model_title_plural(), "Wutta Widgets") - del master.MasterView.model_title + with patch.multiple(mod.MasterView, create=True, + model_title='Wutta Widget'): + self.assertEqual(mod.MasterView.get_model_title_plural(), "Wutta Widgets") # or it may specify model name - master.MasterView.model_name = 'Blaster' - self.assertEqual(master.MasterView.get_model_title_plural(), "Blasters") - del master.MasterView.model_name + with patch.multiple(mod.MasterView, create=True, + model_name='Blaster'): + self.assertEqual(mod.MasterView.get_model_title_plural(), "Blasters") # or it may specify model class MyModel = MagicMock(__name__='Dinosaur') - with patch.multiple(master.MasterView, create=True, + with patch.multiple(mod.MasterView, create=True, model_class=MyModel): - self.assertEqual(master.MasterView.get_model_title_plural(), "Dinosaurs") + self.assertEqual(mod.MasterView.get_model_title_plural(), "Dinosaurs") def test_get_model_key(self): # error by default (since no model class) - self.assertRaises(AttributeError, master.MasterView.get_model_key) + self.assertRaises(AttributeError, mod.MasterView.get_model_key) # subclass may specify model key - master.MasterView.model_key = 'uuid' - self.assertEqual(master.MasterView.get_model_key(), ('uuid',)) - del master.MasterView.model_key + with patch.multiple(mod.MasterView, create=True, + model_key='uuid'): + self.assertEqual(mod.MasterView.get_model_key(), ('uuid',)) def test_get_route_prefix(self): # error by default (since no model class) - self.assertRaises(AttributeError, master.MasterView.get_route_prefix) + self.assertRaises(AttributeError, mod.MasterView.get_route_prefix) # subclass may specify route prefix - master.MasterView.route_prefix = 'widgets' - self.assertEqual(master.MasterView.get_route_prefix(), 'widgets') - del master.MasterView.route_prefix + with patch.multiple(mod.MasterView, create=True, + route_prefix='widgets'): + self.assertEqual(mod.MasterView.get_route_prefix(), 'widgets') # subclass may specify *normalized* model name - master.MasterView.model_name_normalized = 'blaster' - self.assertEqual(master.MasterView.get_route_prefix(), 'blasters') - del master.MasterView.model_name_normalized + with patch.multiple(mod.MasterView, create=True, + model_name_normalized='blaster'): + self.assertEqual(mod.MasterView.get_route_prefix(), 'blasters') # or it may specify *standard* model name - master.MasterView.model_name = 'Dinosaur' - self.assertEqual(master.MasterView.get_route_prefix(), 'dinosaurs') - del master.MasterView.model_name + with patch.multiple(mod.MasterView, create=True, + model_name = 'Dinosaur'): + self.assertEqual(mod.MasterView.get_route_prefix(), 'dinosaurs') # or it may specify model class MyModel = MagicMock(__name__='Truck') - with patch.multiple(master.MasterView, create=True, + with patch.multiple(mod.MasterView, create=True, model_class=MyModel): - self.assertEqual(master.MasterView.get_route_prefix(), 'trucks') + self.assertEqual(mod.MasterView.get_route_prefix(), 'trucks') def test_get_permission_prefix(self): # error by default (since no model class) - self.assertRaises(AttributeError, master.MasterView.get_permission_prefix) + self.assertRaises(AttributeError, mod.MasterView.get_permission_prefix) # subclass may specify permission prefix - with patch.object(master.MasterView, 'permission_prefix', new='widgets', create=True): - self.assertEqual(master.MasterView.get_permission_prefix(), 'widgets') + with patch.object(mod.MasterView, 'permission_prefix', new='widgets', create=True): + self.assertEqual(mod.MasterView.get_permission_prefix(), 'widgets') # subclass may specify route prefix - with patch.object(master.MasterView, 'route_prefix', new='widgets', create=True): - self.assertEqual(master.MasterView.get_permission_prefix(), 'widgets') + with patch.object(mod.MasterView, 'route_prefix', new='widgets', create=True): + self.assertEqual(mod.MasterView.get_permission_prefix(), 'widgets') # or it may specify model class Truck = MagicMock(__name__='Truck') - with patch.object(master.MasterView, 'model_class', new=Truck, create=True): - self.assertEqual(master.MasterView.get_permission_prefix(), 'trucks') + with patch.object(mod.MasterView, 'model_class', new=Truck, create=True): + self.assertEqual(mod.MasterView.get_permission_prefix(), 'trucks') def test_get_url_prefix(self): # error by default (since no model class) - self.assertRaises(AttributeError, master.MasterView.get_url_prefix) + self.assertRaises(AttributeError, mod.MasterView.get_url_prefix) # subclass may specify url prefix - master.MasterView.url_prefix = '/widgets' - self.assertEqual(master.MasterView.get_url_prefix(), '/widgets') - del master.MasterView.url_prefix + with patch.multiple(mod.MasterView, create=True, + url_prefix='/widgets'): + self.assertEqual(mod.MasterView.get_url_prefix(), '/widgets') # or it may specify route prefix - master.MasterView.route_prefix = 'trucks' - self.assertEqual(master.MasterView.get_url_prefix(), '/trucks') - del master.MasterView.route_prefix + with patch.multiple(mod.MasterView, create=True, + route_prefix='trucks'): + self.assertEqual(mod.MasterView.get_url_prefix(), '/trucks') # or it may specify *normalized* model name - master.MasterView.model_name_normalized = 'blaster' - self.assertEqual(master.MasterView.get_url_prefix(), '/blasters') - del master.MasterView.model_name_normalized + with patch.multiple(mod.MasterView, create=True, + model_name_normalized='blaster'): + self.assertEqual(mod.MasterView.get_url_prefix(), '/blasters') # or it may specify *standard* model name - master.MasterView.model_name = 'Dinosaur' - self.assertEqual(master.MasterView.get_url_prefix(), '/dinosaurs') - del master.MasterView.model_name + with patch.multiple(mod.MasterView, create=True, + model_name='Dinosaur'): + self.assertEqual(mod.MasterView.get_url_prefix(), '/dinosaurs') # or it may specify model class MyModel = MagicMock(__name__='Machine') - with patch.multiple(master.MasterView, create=True, + with patch.multiple(mod.MasterView, create=True, model_class=MyModel): - self.assertEqual(master.MasterView.get_url_prefix(), '/machines') + self.assertEqual(mod.MasterView.get_url_prefix(), '/machines') def test_get_instance_url_prefix(self): # error by default (since no model class) - self.assertRaises(AttributeError, master.MasterView.get_instance_url_prefix) + self.assertRaises(AttributeError, mod.MasterView.get_instance_url_prefix) # typical example with url_prefix and simple key - master.MasterView.url_prefix = '/widgets' - master.MasterView.model_key = 'uuid' - self.assertEqual(master.MasterView.get_instance_url_prefix(), '/widgets/{uuid}') - del master.MasterView.url_prefix - del master.MasterView.model_key + with patch.multiple(mod.MasterView, create=True, + url_prefix='/widgets', + model_key='uuid'): + self.assertEqual(mod.MasterView.get_instance_url_prefix(), '/widgets/{uuid}') # typical example with composite key - master.MasterView.url_prefix = '/widgets' - master.MasterView.model_key = ('foo', 'bar') - self.assertEqual(master.MasterView.get_instance_url_prefix(), '/widgets/{foo}|{bar}') - del master.MasterView.url_prefix - del master.MasterView.model_key + with patch.multiple(mod.MasterView, create=True, + url_prefix='/widgets', + model_key=('foo', 'bar')): + self.assertEqual(mod.MasterView.get_instance_url_prefix(), '/widgets/{foo}|{bar}') def test_get_template_prefix(self): # error by default (since no model class) - self.assertRaises(AttributeError, master.MasterView.get_template_prefix) + self.assertRaises(AttributeError, mod.MasterView.get_template_prefix) # subclass may specify template prefix - master.MasterView.template_prefix = '/widgets' - self.assertEqual(master.MasterView.get_template_prefix(), '/widgets') - del master.MasterView.template_prefix + with patch.multiple(mod.MasterView, create=True, + template_prefix='/widgets'): + self.assertEqual(mod.MasterView.get_template_prefix(), '/widgets') # or it may specify url prefix - master.MasterView.url_prefix = '/trees' - self.assertEqual(master.MasterView.get_template_prefix(), '/trees') - del master.MasterView.url_prefix + with patch.multiple(mod.MasterView, create=True, + url_prefix='/trees'): + self.assertEqual(mod.MasterView.get_template_prefix(), '/trees') # or it may specify route prefix - master.MasterView.route_prefix = 'trucks' - self.assertEqual(master.MasterView.get_template_prefix(), '/trucks') - del master.MasterView.route_prefix + with patch.multiple(mod.MasterView, create=True, + route_prefix='trucks'): + self.assertEqual(mod.MasterView.get_template_prefix(), '/trucks') # or it may specify *normalized* model name - master.MasterView.model_name_normalized = 'blaster' - self.assertEqual(master.MasterView.get_template_prefix(), '/blasters') - del master.MasterView.model_name_normalized + with patch.multiple(mod.MasterView, create=True, + model_name_normalized='blaster'): + self.assertEqual(mod.MasterView.get_template_prefix(), '/blasters') # or it may specify *standard* model name - master.MasterView.model_name = 'Dinosaur' - self.assertEqual(master.MasterView.get_template_prefix(), '/dinosaurs') - del master.MasterView.model_name + with patch.multiple(mod.MasterView, create=True, + model_name='Dinosaur'): + self.assertEqual(mod.MasterView.get_template_prefix(), '/dinosaurs') # or it may specify model class MyModel = MagicMock(__name__='Machine') - with patch.multiple(master.MasterView, create=True, + with patch.multiple(mod.MasterView, create=True, model_class=MyModel): - self.assertEqual(master.MasterView.get_template_prefix(), '/machines') + self.assertEqual(mod.MasterView.get_template_prefix(), '/machines') def test_get_grid_key(self): # error by default (since no model class) - self.assertRaises(AttributeError, master.MasterView.get_grid_key) + self.assertRaises(AttributeError, mod.MasterView.get_grid_key) # subclass may specify grid key - master.MasterView.grid_key = 'widgets' - self.assertEqual(master.MasterView.get_grid_key(), 'widgets') - del master.MasterView.grid_key + with patch.multiple(mod.MasterView, create=True, + grid_key='widgets'): + self.assertEqual(mod.MasterView.get_grid_key(), 'widgets') # or it may specify route prefix - master.MasterView.route_prefix = 'trucks' - self.assertEqual(master.MasterView.get_grid_key(), 'trucks') - del master.MasterView.route_prefix + with patch.multiple(mod.MasterView, create=True, + route_prefix='trucks'): + self.assertEqual(mod.MasterView.get_grid_key(), 'trucks') # or it may specify *normalized* model name - master.MasterView.model_name_normalized = 'blaster' - self.assertEqual(master.MasterView.get_grid_key(), 'blasters') - del master.MasterView.model_name_normalized + with patch.multiple(mod.MasterView, create=True, + model_name_normalized='blaster'): + self.assertEqual(mod.MasterView.get_grid_key(), 'blasters') # or it may specify *standard* model name - master.MasterView.model_name = 'Dinosaur' - self.assertEqual(master.MasterView.get_grid_key(), 'dinosaurs') - del master.MasterView.model_name + with patch.multiple(mod.MasterView, create=True, + model_name='Dinosaur'): + self.assertEqual(mod.MasterView.get_grid_key(), 'dinosaurs') # or it may specify model class MyModel = MagicMock(__name__='Machine') - with patch.multiple(master.MasterView, create=True, + with patch.multiple(mod.MasterView, create=True, model_class=MyModel): - self.assertEqual(master.MasterView.get_grid_key(), 'machines') + self.assertEqual(mod.MasterView.get_grid_key(), 'machines') def test_get_config_title(self): # error by default (since no model class) - self.assertRaises(AttributeError, master.MasterView.get_config_title) + self.assertRaises(AttributeError, mod.MasterView.get_config_title) # subclass may specify config title - master.MasterView.config_title = 'Widgets' - self.assertEqual(master.MasterView.get_config_title(), "Widgets") - del master.MasterView.config_title + with patch.multiple(mod.MasterView, create=True, + config_title='Widgets'): + self.assertEqual(mod.MasterView.get_config_title(), "Widgets") # subclass may specify *plural* model title - master.MasterView.model_title_plural = 'People' - self.assertEqual(master.MasterView.get_config_title(), "People") - del master.MasterView.model_title_plural + with patch.multiple(mod.MasterView, create=True, + model_title_plural='People'): + self.assertEqual(mod.MasterView.get_config_title(), "People") # or it may specify *singular* model title - master.MasterView.model_title = 'Wutta Widget' - self.assertEqual(master.MasterView.get_config_title(), "Wutta Widgets") - del master.MasterView.model_title + with patch.multiple(mod.MasterView, create=True, + model_title='Wutta Widget'): + self.assertEqual(mod.MasterView.get_config_title(), "Wutta Widgets") # or it may specify model name - master.MasterView.model_name = 'Blaster' - self.assertEqual(master.MasterView.get_config_title(), "Blasters") - del master.MasterView.model_name + with patch.multiple(mod.MasterView, create=True, + model_name='Blaster'): + self.assertEqual(mod.MasterView.get_config_title(), "Blasters") # or it may specify model class MyModel = MagicMock(__name__='Dinosaur') - with patch.multiple(master.MasterView, create=True, + with patch.multiple(mod.MasterView, create=True, model_class=MyModel): - self.assertEqual(master.MasterView.get_config_title(), "Dinosaurs") + self.assertEqual(mod.MasterView.get_config_title(), "Dinosaurs") ############################## # support methods ############################## def test_get_class_hierarchy(self): - class MyView(master.MasterView): + class MyView(mod.MasterView): pass view = MyView(self.request) classes = view.get_class_hierarchy() - self.assertEqual(classes, [View, master.MasterView, MyView]) + self.assertEqual(classes, [View, mod.MasterView, MyView]) def test_has_perm(self): model = self.app.model auth = self.app.get_auth_handler() - with patch.multiple(master.MasterView, create=True, + with patch.multiple(mod.MasterView, create=True, model_name='Setting'): view = self.make_view() @@ -374,7 +373,7 @@ class TestMasterView(WebTestCase): model = self.app.model auth = self.app.get_auth_handler() - with patch.multiple(master.MasterView, create=True, + with patch.multiple(mod.MasterView, create=True, model_name='Setting'): view = self.make_view() @@ -410,20 +409,20 @@ class TestMasterView(WebTestCase): # basic sanity check using /master/index.mako # (nb. it skips /widgets/index.mako since that doesn't exist) - with patch.multiple(master.MasterView, create=True, + with patch.multiple(mod.MasterView, create=True, model_name='Widget', creatable=False): - view = master.MasterView(self.request) + view = mod.MasterView(self.request) response = view.render_to_response('index', {}) self.assertIsInstance(response, Response) # basic sanity check using /appinfo/index.mako - with patch.multiple(master.MasterView, create=True, + with patch.multiple(mod.MasterView, create=True, model_name='AppInfo', route_prefix='appinfo', url_prefix='/appinfo', creatable=False): - view = master.MasterView(self.request) + view = mod.MasterView(self.request) response = view.render_to_response('index', { # nb. grid is required for this template 'grid': MagicMock(), @@ -431,15 +430,15 @@ class TestMasterView(WebTestCase): self.assertIsInstance(response, Response) # bad template name causes error - master.MasterView.model_name = 'Widget' - self.assertRaises(IOError, view.render_to_response, 'nonexistent', {}) - del master.MasterView.model_name + with patch.multiple(mod.MasterView, create=True, + model_name='Widget'): + self.assertRaises(IOError, view.render_to_response, 'nonexistent', {}) def test_get_index_title(self): - master.MasterView.model_title_plural = "Wutta Widgets" - view = master.MasterView(self.request) - self.assertEqual(view.get_index_title(), "Wutta Widgets") - del master.MasterView.model_title_plural + with patch.multiple(mod.MasterView, create=True, + model_title_plural = "Wutta Widgets"): + view = mod.MasterView(self.request) + self.assertEqual(view.get_index_title(), "Wutta Widgets") def test_collect_labels(self): @@ -450,14 +449,14 @@ class TestMasterView(WebTestCase): # labels come from all classes; subclass wins with patch.object(View, 'labels', new={'foo': "Foo", 'bar': "Bar"}, create=True): - with patch.object(master.MasterView, 'labels', new={'foo': "FOO FIGHTERS"}, create=True): + with patch.object(mod.MasterView, 'labels', new={'foo': "FOO FIGHTERS"}, create=True): view = self.make_view() labels = view.collect_labels() self.assertEqual(labels, {'foo': "FOO FIGHTERS", 'bar': "Bar"}) def test_set_labels(self): model = self.app.model - with patch.object(master.MasterView, 'model_class', new=model.Setting, create=True): + with patch.object(mod.MasterView, 'model_class', new=model.Setting, create=True): # no labels by default view = self.make_view() @@ -466,7 +465,7 @@ class TestMasterView(WebTestCase): self.assertEqual(grid.labels, {}) # labels come from all classes; subclass wins - with patch.object(master.MasterView, 'labels', new={'name': "SETTING NAME"}, create=True): + with patch.object(mod.MasterView, 'labels', new={'name': "SETTING NAME"}, create=True): view = self.make_view() view.set_labels(grid) self.assertEqual(grid.labels, {'name': "SETTING NAME"}) @@ -475,27 +474,27 @@ class TestMasterView(WebTestCase): model = self.app.model # no model class - with patch.multiple(master.MasterView, create=True, + with patch.multiple(mod.MasterView, create=True, model_name='Widget', model_key='uuid'): - view = master.MasterView(self.request) + view = mod.MasterView(self.request) grid = view.make_model_grid() self.assertIsNone(grid.model_class) # explicit model class - with patch.multiple(master.MasterView, create=True, + with patch.multiple(mod.MasterView, create=True, model_class=model.Setting): grid = view.make_model_grid(session=self.session) self.assertIs(grid.model_class, model.Setting) # no actions by default - with patch.multiple(master.MasterView, create=True, + with patch.multiple(mod.MasterView, create=True, model_class=model.Setting): grid = view.make_model_grid(session=self.session) self.assertEqual(grid.actions, []) # now let's test some more actions logic - with patch.multiple(master.MasterView, create=True, + with patch.multiple(mod.MasterView, create=True, model_class=model.Setting, viewable=True, editable=True, @@ -518,14 +517,14 @@ class TestMasterView(WebTestCase): view = self.make_view() # empty by default - self.assertFalse(hasattr(master.MasterView, 'model_class')) + self.assertFalse(hasattr(mod.MasterView, 'model_class')) data = view.get_grid_data(session=self.session) self.assertEqual(data, []) # grid with model class will produce data query - with patch.multiple(master.MasterView, create=True, + with patch.multiple(mod.MasterView, create=True, model_class=model.Setting): - view = master.MasterView(self.request) + view = mod.MasterView(self.request) query = view.get_grid_data(session=self.session) self.assertIsInstance(query, orm.Query) data = query.all() @@ -536,9 +535,9 @@ class TestMasterView(WebTestCase): model = self.app.model # uuid field is pruned - with patch.multiple(master.MasterView, create=True, + with patch.multiple(mod.MasterView, create=True, model_class=model.Setting): - view = master.MasterView(self.request) + view = mod.MasterView(self.request) grid = view.make_grid(model_class=model.Setting, columns=['uuid', 'name', 'value']) self.assertIn('uuid', grid.columns) @@ -574,13 +573,13 @@ class TestMasterView(WebTestCase): self.assertEqual(self.session.query(model.Setting).count(), 1) # default not implemented - view = master.MasterView(self.request) + view = mod.MasterView(self.request) self.assertRaises(NotImplementedError, view.get_instance) # fetch from DB if model class is known - with patch.multiple(master.MasterView, create=True, + with patch.multiple(mod.MasterView, create=True, model_class=model.Setting): - view = master.MasterView(self.request) + view = mod.MasterView(self.request) # existing setting is returned self.request.matchdict = {'name': 'foo'} @@ -599,9 +598,9 @@ class TestMasterView(WebTestCase): self.session.add(setting) self.session.commit() - with patch.multiple(master.MasterView, create=True, + with patch.multiple(mod.MasterView, create=True, model_class=model.Setting): - master.MasterView.defaults(self.pyramid_config) + mod.MasterView.defaults(self.pyramid_config) view = self.make_view() url = view.get_action_url_view(setting, 0) self.assertEqual(url, self.request.route_url('settings.view', name='foo')) @@ -611,9 +610,9 @@ class TestMasterView(WebTestCase): setting = model.Setting(name='foo', value='bar') self.session.add(setting) self.session.commit() - with patch.multiple(master.MasterView, create=True, + with patch.multiple(mod.MasterView, create=True, model_class=model.Setting): - master.MasterView.defaults(self.pyramid_config) + mod.MasterView.defaults(self.pyramid_config) view = self.make_view() # typical @@ -630,9 +629,9 @@ class TestMasterView(WebTestCase): setting = model.Setting(name='foo', value='bar') self.session.add(setting) self.session.commit() - with patch.multiple(master.MasterView, create=True, + with patch.multiple(mod.MasterView, create=True, model_class=model.Setting): - master.MasterView.defaults(self.pyramid_config) + mod.MasterView.defaults(self.pyramid_config) view = self.make_view() # typical @@ -648,15 +647,15 @@ class TestMasterView(WebTestCase): model = self.app.model # no model class - with patch.multiple(master.MasterView, create=True, + with patch.multiple(mod.MasterView, create=True, model_name='Widget', model_key='uuid'): - view = master.MasterView(self.request) + view = mod.MasterView(self.request) form = view.make_model_form() self.assertIsNone(form.model_class) # explicit model class - with patch.multiple(master.MasterView, create=True, + with patch.multiple(mod.MasterView, create=True, model_class=model.Setting): form = view.make_model_form() self.assertIs(form.model_class, model.Setting) @@ -665,9 +664,9 @@ class TestMasterView(WebTestCase): model = self.app.model # uuid field is pruned - with patch.multiple(master.MasterView, create=True, + with patch.multiple(mod.MasterView, create=True, model_class=model.Setting): - view = master.MasterView(self.request) + view = mod.MasterView(self.request) form = view.make_form(model_class=model.Setting, fields=['uuid', 'name', 'value']) self.assertIn('uuid', form.fields) @@ -681,17 +680,17 @@ class TestMasterView(WebTestCase): self.assertEqual(self.session.query(model.Setting).count(), 1) # no model class - with patch.multiple(master.MasterView, create=True, + with patch.multiple(mod.MasterView, create=True, model_name='Widget', model_key='uuid'): - view = master.MasterView(self.request) + view = mod.MasterView(self.request) form = view.make_model_form(fields=['name', 'description']) form.validated = {'name': 'first'} obj = view.objectify(form) self.assertIs(obj, form.validated) # explicit model class (editing) - with patch.multiple(master.MasterView, create=True, + with patch.multiple(mod.MasterView, create=True, model_class=model.Setting, editing=True): form = view.make_model_form() @@ -703,7 +702,7 @@ class TestMasterView(WebTestCase): self.assertEqual(obj.value, 'blarg') # explicit model class (creating) - with patch.multiple(master.MasterView, create=True, + with patch.multiple(mod.MasterView, create=True, model_class=model.Setting, creating=True): form = view.make_model_form() @@ -715,9 +714,9 @@ class TestMasterView(WebTestCase): def test_persist(self): model = self.app.model - with patch.multiple(master.MasterView, create=True, + with patch.multiple(mod.MasterView, create=True, model_class=model.Setting): - view = master.MasterView(self.request) + view = mod.MasterView(self.request) # new instance is persisted setting = model.Setting(name='foo', value='bar') @@ -741,12 +740,12 @@ class TestMasterView(WebTestCase): self.pyramid_config.add_route('settings.delete', '/settings/{name}/delete') # sanity/coverage check using /settings/ - with patch.multiple(master.MasterView, create=True, + with patch.multiple(mod.MasterView, create=True, model_name='Setting', model_key='name', get_index_url=MagicMock(return_value='/settings/'), grid_columns=['name', 'value']): - view = master.MasterView(self.request) + view = mod.MasterView(self.request) response = view.index() # then again with data, to include view action url @@ -769,12 +768,12 @@ class TestMasterView(WebTestCase): model = self.app.model # sanity/coverage check using /settings/new - with patch.multiple(master.MasterView, create=True, + with patch.multiple(mod.MasterView, create=True, model_name='Setting', model_key='name', get_index_url=MagicMock(return_value='/settings/'), form_fields=['name', 'value']): - view = master.MasterView(self.request) + view = mod.MasterView(self.request) # no setting yet self.assertIsNone(self.app.get_setting(self.session, 'foo.bar')) @@ -825,13 +824,13 @@ class TestMasterView(WebTestCase): # sanity/coverage check using /settings/XXX setting = {'name': 'foo.bar', 'value': 'baz'} self.request.matchdict = {'name': 'foo.bar'} - with patch.multiple(master.MasterView, create=True, + with patch.multiple(mod.MasterView, create=True, model_name='Setting', model_key='name', get_index_url=MagicMock(return_value='/settings/'), grid_columns=['name', 'value'], form_fields=['name', 'value']): - view = master.MasterView(self.request) + view = mod.MasterView(self.request) with patch.object(view, 'get_instance', return_value=setting): response = view.view() @@ -854,12 +853,12 @@ class TestMasterView(WebTestCase): # sanity/coverage check using /settings/XXX/edit self.request.matchdict = {'name': 'foo.bar'} - with patch.multiple(master.MasterView, create=True, + with patch.multiple(mod.MasterView, create=True, model_name='Setting', model_key='name', get_index_url=MagicMock(return_value='/settings/'), form_fields=['name', 'value']): - view = master.MasterView(self.request) + view = mod.MasterView(self.request) with patch.object(view, 'get_instance', new=get_instance): # get the form page @@ -918,12 +917,12 @@ class TestMasterView(WebTestCase): # sanity/coverage check using /settings/XXX/delete self.request.matchdict = {'name': 'foo.bar'} - with patch.multiple(master.MasterView, create=True, + with patch.multiple(mod.MasterView, create=True, model_name='Setting', model_key='name', get_index_url=MagicMock(return_value='/settings/'), form_fields=['name', 'value']): - view = master.MasterView(self.request) + view = mod.MasterView(self.request) with patch.object(view, 'get_instance', new=get_instance): # get the form page @@ -960,14 +959,55 @@ class TestMasterView(WebTestCase): self.session.commit() setting = self.session.query(model.Setting).one() - with patch.multiple(master.MasterView, create=True, + with patch.multiple(mod.MasterView, create=True, model_class=model.Setting, form_fields=['name', 'value']): - view = master.MasterView(self.request) + view = mod.MasterView(self.request) view.delete_instance(setting) self.session.commit() self.assertEqual(self.session.query(model.Setting).count(), 0) + def test_autocomplete(self): + model = self.app.model + + person1 = model.Person(full_name="George Jones") + self.session.add(person1) + person2 = model.Person(full_name="George Strait") + self.session.add(person2) + self.session.commit() + + # no results for empty term + self.request.GET = {} + view = self.make_view() + results = view.autocomplete() + self.assertEqual(len(results), 0) + + # search yields no results + self.request.GET = {'term': 'sally'} + view = self.make_view() + with patch.object(view, 'autocomplete_data', return_value=[]): + view = self.make_view() + results = view.autocomplete() + self.assertEqual(len(results), 0) + + # search yields 2 results + self.request.GET = {'term': 'george'} + view = self.make_view() + with patch.object(view, 'autocomplete_data', return_value=[person1, person2]): + results = view.autocomplete() + self.assertEqual(len(results), 2) + self.assertEqual([res['value'] for res in results], + [p.uuid for p in [person1, person2]]) + + def test_autocomplete_normalize(self): + model = self.app.model + view = self.make_view() + + person = model.Person(full_name="Betty Boop", uuid='bogus') + normal = view.autocomplete_normalize(person) + self.assertEqual(normal, {'value': 'bogus', + 'label': "Betty Boop"}) + def test_configure(self): self.pyramid_config.include('wuttaweb.views.common') self.pyramid_config.include('wuttaweb.views.auth') @@ -983,10 +1023,10 @@ class TestMasterView(WebTestCase): {'name': 'wutta.value2', 'save_if_empty': False}, ] - view = master.MasterView(self.request) + view = mod.MasterView(self.request) with patch.object(self.request, 'current_route_url', return_value='/appinfo/configure'): - with patch.object(master, 'Session', return_value=self.session): - with patch.multiple(master.MasterView, create=True, + with patch.object(mod, 'Session', return_value=self.session): + with patch.multiple(mod.MasterView, create=True, model_name='AppInfo', route_prefix='appinfo', template_prefix='/appinfo', diff --git a/tests/views/test_people.py b/tests/views/test_people.py index d8bd715..d05d705 100644 --- a/tests/views/test_people.py +++ b/tests/views/test_people.py @@ -41,6 +41,30 @@ class TestPersonView(WebTestCase): self.assertTrue(form.required_fields) self.assertFalse(form.required_fields['middle_name']) + def test_autocomplete_query(self): + model = self.app.model + + person1 = model.Person(full_name="George Jones") + self.session.add(person1) + person2 = model.Person(full_name="George Strait") + self.session.add(person2) + self.session.commit() + + view = self.make_view() + with patch.object(view, 'Session', return_value=self.session): + + # both people match + query = view.autocomplete_query('george') + self.assertEqual(query.count(), 2) + + # just 1 match + query = view.autocomplete_query('jones') + self.assertEqual(query.count(), 1) + + # no matches + query = view.autocomplete_query('sally') + self.assertEqual(query.count(), 0) + def test_view_profile(self): self.pyramid_config.include('wuttaweb.views.common') self.pyramid_config.include('wuttaweb.views.auth') diff --git a/tests/views/test_settings.py b/tests/views/test_settings.py index 208d0f2..c0810fa 100644 --- a/tests/views/test_settings.py +++ b/tests/views/test_settings.py @@ -67,6 +67,14 @@ class TestSettingView(WebTestCase): data = query.all() self.assertEqual(len(data), 1) + def test_configure_grid(self): + model = self.app.model + view = self.make_view() + grid = view.make_grid(model_class=model.Setting) + self.assertFalse(grid.is_linked('name')) + view.configure_grid(grid) + self.assertTrue(grid.is_linked('name')) + def test_configure_form(self): view = self.make_view() form = view.make_form(fields=view.get_form_fields())