From 2d9757f677db536059038454db40be3653868406 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 20 Aug 2024 20:16:19 -0500 Subject: [PATCH 01/13] fix: add setting to auto-redirect anon users to login, from home page --- src/wuttaweb/templates/appinfo/configure.mako | 30 +++++++++++++++++++ src/wuttaweb/templates/base.mako | 2 +- src/wuttaweb/views/common.py | 5 ++++ src/wuttaweb/views/settings.py | 4 +++ tests/views/test_common.py | 11 +++++++ 5 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/wuttaweb/templates/appinfo/configure.mako b/src/wuttaweb/templates/appinfo/configure.mako index da7d94d..d8c3af9 100644 --- a/src/wuttaweb/templates/appinfo/configure.mako +++ b/src/wuttaweb/templates/appinfo/configure.mako @@ -24,6 +24,36 @@ +

User/Auth

+
+ + + + Home Page auto-redirect to Login + + + + + + + +
+

Web Libraries

diff --git a/src/wuttaweb/templates/base.mako b/src/wuttaweb/templates/base.mako index f58f7ec..bae0544 100644 --- a/src/wuttaweb/templates/base.mako +++ b/src/wuttaweb/templates/base.mako @@ -154,7 +154,7 @@ .wutta-form-wrapper { margin-left: 5rem; margin-top: 2rem; - width: 50%; + width: 75%; } diff --git a/src/wuttaweb/views/common.py b/src/wuttaweb/views/common.py index 233ef20..a13fc50 100644 --- a/src/wuttaweb/views/common.py +++ b/src/wuttaweb/views/common.py @@ -53,6 +53,11 @@ class CommonView(View): if not user: return self.redirect(self.request.route_url('setup')) + # maybe auto-redirect anons to login + if not self.request.user: + if self.config.get_bool('wuttaweb.home_redirect_to_login'): + return self.redirect(self.request.route_url('login')) + return { 'index_title': self.app.get_title(), } diff --git a/src/wuttaweb/views/settings.py b/src/wuttaweb/views/settings.py index 1f2447c..087a7df 100644 --- a/src/wuttaweb/views/settings.py +++ b/src/wuttaweb/views/settings.py @@ -63,6 +63,10 @@ class AppInfoView(MasterView): {'name': f'{self.app.appname}.production', 'type': bool}, + # user/auth + {'name': 'wuttaweb.home_redirect_to_login', + 'type': bool, 'default': False}, + # web libs {'name': 'wuttaweb.libver.vue'}, {'name': 'wuttaweb.liburl.vue'}, diff --git a/tests/views/test_common.py b/tests/views/test_common.py index be227e3..bf240b5 100644 --- a/tests/views/test_common.py +++ b/tests/views/test_common.py @@ -24,6 +24,7 @@ class TestCommonView(WebTestCase): def test_home(self): self.pyramid_config.add_route('setup', '/setup') + self.pyramid_config.add_route('login', '/login') model = self.app.model view = self.make_view() @@ -40,6 +41,16 @@ class TestCommonView(WebTestCase): context = view.home(session=self.session) self.assertEqual(context['index_title'], self.app.get_title()) + # but if configured, anons will be redirected to login + self.config.setdefault('wuttaweb.home_redirect_to_login', 'true') + response = view.home(session=self.session) + self.assertEqual(response.status_code, 302) + + # now only an auth'ed user can see home page + self.request.user = user + context = view.home(session=self.session) + self.assertEqual(context['index_title'], self.app.get_title()) + def test_setup(self): self.pyramid_config.add_route('home', '/') self.pyramid_config.add_route('login', '/login') From 3665d69e0ce859751572bfb62d5b1bde9fbd88ca Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 20 Aug 2024 20:41:41 -0500 Subject: [PATCH 02/13] fix: tweak login form to stop extending size of background card --- src/wuttaweb/templates/base.mako | 2 +- src/wuttaweb/templates/configure.mako | 11 +++++++++++ src/wuttaweb/templates/forms/vue_template.mako | 7 ++++++- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/wuttaweb/templates/base.mako b/src/wuttaweb/templates/base.mako index bae0544..f58f7ec 100644 --- a/src/wuttaweb/templates/base.mako +++ b/src/wuttaweb/templates/base.mako @@ -154,7 +154,7 @@ .wutta-form-wrapper { margin-left: 5rem; margin-top: 2rem; - width: 75%; + width: 50%; } diff --git a/src/wuttaweb/templates/configure.mako b/src/wuttaweb/templates/configure.mako index 6b7a766..4c89872 100644 --- a/src/wuttaweb/templates/configure.mako +++ b/src/wuttaweb/templates/configure.mako @@ -3,6 +3,17 @@ <%def name="title()">Configure ${config_title} +<%def name="extra_styles()"> + ${parent.extra_styles()} + + + <%def name="page_content()">
${self.buttons_content()} diff --git a/src/wuttaweb/templates/forms/vue_template.mako b/src/wuttaweb/templates/forms/vue_template.mako index 70540d0..cc62a77 100644 --- a/src/wuttaweb/templates/forms/vue_template.mako +++ b/src/wuttaweb/templates/forms/vue_template.mako @@ -11,7 +11,12 @@ % if not form.readonly: -
+
+
% if form.show_button_cancel: Date: Tue, 20 Aug 2024 21:26:38 -0500 Subject: [PATCH 03/13] fix: show installed python packages on appinfo page --- src/wuttaweb/templates/appinfo/index.mako | 41 +++++++++++++++++++ .../templates/grids/vue_template.mako | 14 +++++++ src/wuttaweb/views/master.py | 6 ++- src/wuttaweb/views/settings.py | 41 ++++++++++++++++++- tests/views/test_master.py | 5 ++- tests/views/test_settings.py | 13 ++++++ 6 files changed, 116 insertions(+), 4 deletions(-) diff --git a/src/wuttaweb/templates/appinfo/index.mako b/src/wuttaweb/templates/appinfo/index.mako index 342617e..0997544 100644 --- a/src/wuttaweb/templates/appinfo/index.mako +++ b/src/wuttaweb/templates/appinfo/index.mako @@ -46,12 +46,53 @@
+ <${b}-collapse class="panel" + :open="false" + @open="openInstalledPackages"> + + + +
+
+ ${grid.render_vue_tag(ref='packagesGrid')} +
+
+ + <%def name="modify_vue_vars()"> ${parent.modify_vue_vars()} diff --git a/src/wuttaweb/templates/grids/vue_template.mako b/src/wuttaweb/templates/grids/vue_template.mako index b161d9f..cb4f7a8 100644 --- a/src/wuttaweb/templates/grids/vue_template.mako +++ b/src/wuttaweb/templates/grids/vue_template.mako @@ -134,6 +134,9 @@ data: ${grid.vue_component}CurrentData, loading: false, + ## nb. this tracks whether grid.fetchFirstData() happened + fetchedFirstData: false, + ## sorting % if grid.sortable: sorters: ${json.dumps(grid.get_vue_active_sorters())|n}, @@ -230,6 +233,17 @@ return params }, + ## nb. this is meant to call for a grid which is hidden at + ## first, when it is first being shown to the user. and if + ## it was initialized with empty data set. + async fetchFirstData() { + if (this.fetchedFirstData) { + return + } + await this.fetchData() + this.fetchedFirstData = true + }, + async fetchData() { let params = new URLSearchParams(this.getBasicParams()) diff --git a/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py index d43de9e..3422fe1 100644 --- a/src/wuttaweb/views/master.py +++ b/src/wuttaweb/views/master.py @@ -1208,8 +1208,10 @@ class MasterView(View): self.set_labels(grid) - for key in self.get_model_key(): - grid.set_link(key) + # TODO: i thought this was a good idea but if so it + # needs a try/catch in case of no model class + # for key in self.get_model_key(): + # grid.set_link(key) def grid_render_notes(self, record, key, value, maxlen=100): """ diff --git a/src/wuttaweb/views/settings.py b/src/wuttaweb/views/settings.py index 087a7df..0cea55b 100644 --- a/src/wuttaweb/views/settings.py +++ b/src/wuttaweb/views/settings.py @@ -24,6 +24,11 @@ Views for app settings """ +import json +import os +import sys +import subprocess + from collections import OrderedDict from wuttjamaican.db.model import Setting @@ -47,13 +52,47 @@ class AppInfoView(MasterView): model_name = 'AppInfo' model_title_plural = "App Info" route_prefix = 'appinfo' - has_grid = False + sort_on_backend = False + sort_defaults = 'name' + paginated = False creatable = False viewable = False editable = False deletable = False configurable = True + grid_columns = [ + 'name', + 'version', + 'editable_project_location', + ] + + def get_grid_data(self, columns=None, session=None): + """ """ + + # nb. init with empty data, only load it upon user request + if not self.request.GET.get('partial'): + return [] + + # TODO: pretty sure this is not cross-platform. probably some + # sort of pip methods belong on the app handler? or it should + # have a pip handler for all that? + pip = os.path.join(sys.prefix, 'bin', 'pip') + output = subprocess.check_output([pip, 'list', '--format=json'], text=True) + data = json.loads(output.strip()) + + # must avoid null values for sort to work right + for pkg in data: + pkg.setdefault('editable_project_location', '') + + return data + + def configure_grid(self, g): + """ """ + super().configure_grid(g) + + g.sort_multiple = False + def configure_get_simple_settings(self): """ """ return [ diff --git a/tests/views/test_master.py b/tests/views/test_master.py index 5cf60db..21da2e5 100644 --- a/tests/views/test_master.py +++ b/tests/views/test_master.py @@ -424,7 +424,10 @@ class TestMasterView(WebTestCase): url_prefix='/appinfo', creatable=False): view = master.MasterView(self.request) - response = view.render_to_response('index', {}) + response = view.render_to_response('index', { + # nb. grid is required for this template + 'grid': MagicMock(), + }) self.assertIsInstance(response, Response) # bad template name causes error diff --git a/tests/views/test_settings.py b/tests/views/test_settings.py index f0485c1..208d0f2 100644 --- a/tests/views/test_settings.py +++ b/tests/views/test_settings.py @@ -18,6 +18,19 @@ class TestAppInfoView(WebTestCase): def make_view(self): return mod.AppInfoView(self.request) + def test_get_grid_data(self): + view = self.make_view() + + # empty data by default + data = view.get_grid_data() + self.assertEqual(data, []) + + # 'partial' request returns data + self.request.GET = {'partial': '1'} + data = view.get_grid_data() + self.assertIsInstance(data, list) + self.assertTrue(data) + def test_index(self): # sanity/coverage check view = self.make_view() From 1b4aaacc10491365e0c0541d84f49f3864b3887a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 20 Aug 2024 22:15:11 -0500 Subject: [PATCH 04/13] fix: expose settings for app node title, type --- src/wuttaweb/templates/appinfo/configure.mako | 29 +++++++++++++++---- src/wuttaweb/templates/appinfo/index.mako | 3 ++ src/wuttaweb/templates/base_meta.mako | 2 +- src/wuttaweb/templates/configure.mako | 5 ++-- src/wuttaweb/views/settings.py | 2 ++ 5 files changed, 32 insertions(+), 9 deletions(-) diff --git a/src/wuttaweb/templates/appinfo/configure.mako b/src/wuttaweb/templates/appinfo/configure.mako index d8c3af9..5d1f7bb 100644 --- a/src/wuttaweb/templates/appinfo/configure.mako +++ b/src/wuttaweb/templates/appinfo/configure.mako @@ -6,11 +6,30 @@

Basics

- - - + + + + + + + + + ## TODO: should be a dropdown, app handler defines choices + + + + + + + + + diff --git a/src/wuttaweb/templates/appinfo/index.mako b/src/wuttaweb/templates/appinfo/index.mako index 0997544..279a41e 100644 --- a/src/wuttaweb/templates/appinfo/index.mako +++ b/src/wuttaweb/templates/appinfo/index.mako @@ -16,6 +16,9 @@ ${app.get_title()} + + ${app.get_node_title()} + ${config.production()} diff --git a/src/wuttaweb/templates/base_meta.mako b/src/wuttaweb/templates/base_meta.mako index 65d1ede..741ac4a 100644 --- a/src/wuttaweb/templates/base_meta.mako +++ b/src/wuttaweb/templates/base_meta.mako @@ -1,6 +1,6 @@ ## -*- coding: utf-8; -*- -<%def name="global_title()">${app.get_title()} +<%def name="global_title()">${app.get_node_title()} <%def name="extra_styles()"> diff --git a/src/wuttaweb/templates/configure.mako b/src/wuttaweb/templates/configure.mako index 4c89872..f0363e0 100644 --- a/src/wuttaweb/templates/configure.mako +++ b/src/wuttaweb/templates/configure.mako @@ -53,15 +53,14 @@ Cancel - ${h.form(request.current_route_url())} + ${h.form(request.current_route_url(), **{'@submit': 'purgingSettings = true'})} ${h.csrf_token(request)} ${h.hidden('remove_settings', 'true')} + icon-left="trash"> {{ purgingSettings ? "Working, please wait..." : "Remove All Settings for ${config_title}" }} ${h.end_form()} diff --git a/src/wuttaweb/views/settings.py b/src/wuttaweb/views/settings.py index 0cea55b..f4d9551 100644 --- a/src/wuttaweb/views/settings.py +++ b/src/wuttaweb/views/settings.py @@ -99,6 +99,8 @@ class AppInfoView(MasterView): # basics {'name': f'{self.app.appname}.app_title'}, + {'name': f'{self.app.appname}.node_type'}, + {'name': f'{self.app.appname}.node_title'}, {'name': f'{self.app.appname}.production', 'type': bool}, From a34b01a6c4a6efaeb072fadd34b3207e95614dc8 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 20 Aug 2024 23:01:46 -0500 Subject: [PATCH 05/13] fix: cleanup logic for appinfo/configure so tailbone can inherit this view and extend --- src/wuttaweb/templates/appinfo/configure.mako | 36 +++--- src/wuttaweb/views/settings.py | 107 +++++++++--------- 2 files changed, 72 insertions(+), 71 deletions(-) diff --git a/src/wuttaweb/templates/appinfo/configure.mako b/src/wuttaweb/templates/appinfo/configure.mako index 5d1f7bb..d2de2cf 100644 --- a/src/wuttaweb/templates/appinfo/configure.mako +++ b/src/wuttaweb/templates/appinfo/configure.mako @@ -46,30 +46,30 @@

User/Auth

- +
Home Page auto-redirect to Login - - - - - + <${b}-tooltip position="${'right' if request.use_oruga else 'is-right'}"> + + + +
diff --git a/src/wuttaweb/views/settings.py b/src/wuttaweb/views/settings.py index f4d9551..fb67ae8 100644 --- a/src/wuttaweb/views/settings.py +++ b/src/wuttaweb/views/settings.py @@ -28,7 +28,6 @@ import json import os import sys import subprocess - from collections import OrderedDict from wuttjamaican.db.model import Setting @@ -67,6 +66,9 @@ class AppInfoView(MasterView): 'editable_project_location', ] + # TODO: for tailbone backward compat with get_liburl() etc. + weblib_config_prefix = None + def get_grid_data(self, columns=None, session=None): """ """ @@ -93,57 +95,9 @@ class AppInfoView(MasterView): g.sort_multiple = False - def configure_get_simple_settings(self): + def get_weblibs(self): """ """ - return [ - - # basics - {'name': f'{self.app.appname}.app_title'}, - {'name': f'{self.app.appname}.node_type'}, - {'name': f'{self.app.appname}.node_title'}, - {'name': f'{self.app.appname}.production', - 'type': bool}, - - # user/auth - {'name': 'wuttaweb.home_redirect_to_login', - 'type': bool, 'default': False}, - - # web libs - {'name': 'wuttaweb.libver.vue'}, - {'name': 'wuttaweb.liburl.vue'}, - {'name': 'wuttaweb.libver.vue_resource'}, - {'name': 'wuttaweb.liburl.vue_resource'}, - {'name': 'wuttaweb.libver.buefy'}, - {'name': 'wuttaweb.liburl.buefy'}, - {'name': 'wuttaweb.libver.buefy.css'}, - {'name': 'wuttaweb.liburl.buefy.css'}, - {'name': 'wuttaweb.libver.fontawesome'}, - {'name': 'wuttaweb.liburl.fontawesome'}, - {'name': 'wuttaweb.libver.bb_vue'}, - {'name': 'wuttaweb.liburl.bb_vue'}, - {'name': 'wuttaweb.libver.bb_oruga'}, - {'name': 'wuttaweb.liburl.bb_oruga'}, - {'name': 'wuttaweb.libver.bb_oruga_bulma'}, - {'name': 'wuttaweb.liburl.bb_oruga_bulma'}, - {'name': 'wuttaweb.libver.bb_oruga_bulma_css'}, - {'name': 'wuttaweb.liburl.bb_oruga_bulma_css'}, - {'name': 'wuttaweb.libver.bb_fontawesome_svg_core'}, - {'name': 'wuttaweb.liburl.bb_fontawesome_svg_core'}, - {'name': 'wuttaweb.libver.bb_free_solid_svg_icons'}, - {'name': 'wuttaweb.liburl.bb_free_solid_svg_icons'}, - {'name': 'wuttaweb.libver.bb_vue_fontawesome'}, - {'name': 'wuttaweb.liburl.bb_vue_fontawesome'}, - - ] - - def configure_get_context(self, **kwargs): - """ """ - - # normal context - context = super().configure_get_context(**kwargs) - - # we will add `weblibs` to context, based on config values - weblibs = OrderedDict([ + return OrderedDict([ ('vue', "(Vue2) Vue"), ('vue_resource', "(Vue2) vue-resource"), ('buefy', "(Vue2) Buefy"), @@ -158,6 +112,48 @@ class AppInfoView(MasterView): ('bb_vue_fontawesome', "(Vue3) @fortawesome/vue-fontawesome"), ]) + def configure_get_simple_settings(self): + """ """ + simple_settings = [ + + # basics + {'name': f'{self.app.appname}.app_title'}, + {'name': f'{self.app.appname}.node_type'}, + {'name': f'{self.app.appname}.node_title'}, + {'name': f'{self.app.appname}.production', + 'type': bool}, + + # user/auth + {'name': 'wuttaweb.home_redirect_to_login', + 'type': bool, 'default': False}, + + ] + + def getval(key): + return self.config.get(f'wuttaweb.{key}') + + weblibs = self.get_weblibs() + for key, title in weblibs.items(): + + simple_settings.append({ + 'name': f'wuttaweb.libver.{key}', + 'default': getval(f'libver.{key}'), + }) + simple_settings.append({ + 'name': f'wuttaweb.liburl.{key}', + 'default': getval(f'liburl.{key}'), + }) + + return simple_settings + + def configure_get_context(self, **kwargs): + """ """ + + # normal context + context = super().configure_get_context(**kwargs) + + # we will add `weblibs` to context, based on config values + weblibs = self.get_weblibs() for key in weblibs: title = weblibs[key] weblibs[key] = { @@ -167,13 +163,18 @@ class AppInfoView(MasterView): # nb. these values are exactly as configured, and are # used for editing the settings 'configured_version': get_libver(self.request, key, + prefix=self.weblib_config_prefix, configured_only=True), 'configured_url': get_liburl(self.request, key, + prefix=self.weblib_config_prefix, configured_only=True), # nb. these are for display only - 'default_version': get_libver(self.request, key, default_only=True), - 'live_url': get_liburl(self.request, key), + 'default_version': get_libver(self.request, key, + prefix=self.weblib_config_prefix, + default_only=True), + 'live_url': get_liburl(self.request, key, + prefix=self.weblib_config_prefix), } context['weblibs'] = list(weblibs.values()) From 4bf2bb42fb9d5a604f2cd4cfa7ecfceeb3614639 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 21 Aug 2024 00:06:10 -0500 Subject: [PATCH 06/13] fix: cleanup templates for home, login pages if context has 'image_url' then use that, otherwise configured logo --- src/wuttaweb/templates/auth/login.mako | 9 +-------- src/wuttaweb/templates/base_meta.mako | 4 ++-- src/wuttaweb/templates/home.mako | 9 +-------- 3 files changed, 4 insertions(+), 18 deletions(-) diff --git a/src/wuttaweb/templates/auth/login.mako b/src/wuttaweb/templates/auth/login.mako index e6b77c6..278992b 100644 --- a/src/wuttaweb/templates/auth/login.mako +++ b/src/wuttaweb/templates/auth/login.mako @@ -4,13 +4,9 @@ <%def name="title()">Login -<%def name="render_this_page()"> - ${self.page_content()} - - <%def name="page_content()">
-
${base_meta.full_logo()}
+
${base_meta.full_logo(image_url or None)}
${form.render_vue_tag()} @@ -44,6 +40,3 @@ - - -${parent.body()} diff --git a/src/wuttaweb/templates/base_meta.mako b/src/wuttaweb/templates/base_meta.mako index 741ac4a..67739fa 100644 --- a/src/wuttaweb/templates/base_meta.mako +++ b/src/wuttaweb/templates/base_meta.mako @@ -12,8 +12,8 @@ ${h.image(config.get('wuttaweb.header_logo_url', default=request.static_url('wuttaweb:static/img/favicon.ico')), "Header Logo", style="height: 49px;")} -<%def name="full_logo()"> - ${h.image(config.get('wuttaweb.logo_url', default=request.static_url('wuttaweb:static/img/logo.png')), f"{app.get_title()} logo")} +<%def name="full_logo(image_url=None)"> + ${h.image(image_url or config.get('wuttaweb.logo_url', default=request.static_url('wuttaweb:static/img/logo.png')), f"{app.get_title()} logo")} <%def name="footer()"> diff --git a/src/wuttaweb/templates/home.mako b/src/wuttaweb/templates/home.mako index 1bb5f0d..0af63ef 100644 --- a/src/wuttaweb/templates/home.mako +++ b/src/wuttaweb/templates/home.mako @@ -4,16 +4,9 @@ <%def name="title()">Home -<%def name="render_this_page()"> - ${self.page_content()} - - <%def name="page_content()">
-
${base_meta.full_logo()}
+
${base_meta.full_logo(image_url or None)}

Welcome to ${app.get_title()}

- - -${parent.body()} From 9d261de45aec8fdfd0a7aedd5d445ff27d40eb65 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 21 Aug 2024 11:46:38 -0500 Subject: [PATCH 07/13] 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()) From 770c4612d560a17e2cbfbbbe214e69892ddd0a4e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 21 Aug 2024 14:38:34 -0500 Subject: [PATCH 08/13] feat: improve page linkage between role/user/person - show Users grid when viewing a Role - add hyperlinks between things --- src/wuttaweb/forms/base.py | 19 ++++ src/wuttaweb/forms/schema.py | 25 ++++++ src/wuttaweb/forms/widgets.py | 86 ++++++++++++++++++- src/wuttaweb/grids/base.py | 71 +++++++++++++++ src/wuttaweb/templates/base.mako | 4 +- .../templates/deform/readonly/objectref.pt | 10 ++- .../templates/deform/readonly/rolerefs.pt | 7 ++ .../templates/forms/vue_template.mako | 8 ++ src/wuttaweb/templates/grids/element.mako | 49 +++++++++++ src/wuttaweb/views/people.py | 19 ++-- src/wuttaweb/views/roles.py | 9 +- tests/forms/test_base.py | 24 ++++++ tests/forms/test_schema.py | 14 +++ tests/forms/test_widgets.py | 61 ++++++++++++- tests/grids/test_base.py | 30 ++++++- tests/views/test_people.py | 25 ++++-- 16 files changed, 440 insertions(+), 21 deletions(-) create mode 100644 src/wuttaweb/templates/deform/readonly/rolerefs.pt create mode 100644 src/wuttaweb/templates/grids/element.mako diff --git a/src/wuttaweb/forms/base.py b/src/wuttaweb/forms/base.py index 5acca59..d5b893a 100644 --- a/src/wuttaweb/forms/base.py +++ b/src/wuttaweb/forms/base.py @@ -25,6 +25,7 @@ Base form classes """ import logging +from collections import OrderedDict import colander import deform @@ -311,6 +312,9 @@ class Form: self.set_fields(fields or self.get_fields()) + # nb. this tracks grid JSON data for inclusion in page template + self.grid_vue_data = OrderedDict() + def __contains__(self, name): """ Custom logic for the ``in`` operator, to allow easily checking @@ -750,6 +754,10 @@ class Form: kwargs['appstruct'] = self.model_instance form = deform.Form(schema, **kwargs) + # nb. must give a reference back to wutta form; this is + # for sake of field schema nodes and widgets, e.g. to + # access the main model instance + form.wutta_form = self self.deform_form = form return self.deform_form @@ -818,6 +826,17 @@ class Form: output = render(template, context) return HTML.literal(output) + def add_grid_vue_data(self, grid): + """ """ + if not grid.key: + raise ValueError("grid must have a key!") + + if grid.key in self.grid_vue_data: + log.warning("grid data with key '%s' already registered, " + "but will be replaced", grid.key) + + self.grid_vue_data[grid.key] = grid.get_vue_data() + def render_vue_field( self, fieldname, diff --git a/src/wuttaweb/forms/schema.py b/src/wuttaweb/forms/schema.py index 8c245f6..a3a464b 100644 --- a/src/wuttaweb/forms/schema.py +++ b/src/wuttaweb/forms/schema.py @@ -246,6 +246,9 @@ class ObjectRef(colander.SchemaType): values.insert(0, self.empty_option) kwargs['values'] = values + if 'url' not in kwargs: + kwargs['url'] = lambda person: self.request.route_url('people.view', uuid=person.uuid) + return widgets.ObjectRefWidget(self.request, **kwargs) @@ -321,6 +324,28 @@ class RoleRefs(WuttaSet): return widgets.RoleRefsWidget(self.request, **kwargs) +class UserRefs(WuttaSet): + """ + Form schema type for the Role + :attr:`~wuttjamaican:wuttjamaican.db.model.auth.Role.users` + association proxy field. + + This is a subclass of :class:`WuttaSet`. It uses a ``set`` of + :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` ``uuid`` + values for underlying data format. + """ + + def widget_maker(self, **kwargs): + """ + Constructs a default widget for the field. + + :returns: Instance of + :class:`~wuttaweb.forms.widgets.UserRefsWidget`. + """ + kwargs.setdefault('session', self.session) + return widgets.UserRefsWidget(self.request, **kwargs) + + class Permissions(WuttaSet): """ Form schema type for the Role diff --git a/src/wuttaweb/forms/widgets.py b/src/wuttaweb/forms/widgets.py index b4d8254..ee58a1a 100644 --- a/src/wuttaweb/forms/widgets.py +++ b/src/wuttaweb/forms/widgets.py @@ -44,6 +44,7 @@ from deform.widget import (Widget, TextInputWidget, TextAreaWidget, from webhelpers2.html import HTML from wuttaweb.db import Session +from wuttaweb.grids import Grid class ObjectRefWidget(SelectWidget): @@ -83,9 +84,19 @@ class ObjectRefWidget(SelectWidget): """ readonly_template = 'readonly/objectref' - def __init__(self, request, *args, **kwargs): + def __init__(self, request, url=None, *args, **kwargs): super().__init__(*args, **kwargs) self.request = request + self.url = url + + def get_template_values(self, field, cstruct, kw): + """ """ + values = super().get_template_values(field, cstruct, kw) + + if 'url' not in values and self.url and field.schema.model_instance: + values['url'] = self.url(field.schema.model_instance) + + return values class NotesWidget(TextAreaWidget): @@ -137,12 +148,17 @@ class RoleRefsWidget(WuttaCheckboxChoiceWidget): """ Widget for use with User :attr:`~wuttjamaican:wuttjamaican.db.model.auth.User.roles` field. + This is the default widget for the + :class:`~wuttaweb.forms.schema.RoleRefs` type. This is a subclass of :class:`WuttaCheckboxChoiceWidget`. """ + readonly_template = 'readonly/rolerefs' def serialize(self, field, cstruct, **kw): """ """ + model = self.app.model + # special logic when field is editable readonly = kw.get('readonly', self.readonly) if not readonly: @@ -159,10 +175,78 @@ class RoleRefsWidget(WuttaCheckboxChoiceWidget): if val[0] != admin.uuid] kw['values'] = values + else: # readonly + + # roles + roles = [] + if cstruct: + for uuid in cstruct: + role = self.session.query(model.Role).get(uuid) + if role: + roles.append(role) + kw['roles'] = roles + + # url + url = lambda role: self.request.route_url('roles.view', uuid=role.uuid) + kw['url'] = url + # default logic from here return super().serialize(field, cstruct, **kw) +class UserRefsWidget(WuttaCheckboxChoiceWidget): + """ + Widget for use with Role + :attr:`~wuttjamaican:wuttjamaican.db.model.auth.Role.users` field. + This is the default widget for the + :class:`~wuttaweb.forms.schema.UserRefs` type. + + This is a subclass of :class:`WuttaCheckboxChoiceWidget`; however + it only supports readonly mode and does not use a template. + Rather, it generates and renders a + :class:`~wuttaweb.grids.base.Grid` showing the users list. + """ + + def serialize(self, field, cstruct, **kw): + """ """ + readonly = kw.get('readonly', self.readonly) + if not readonly: + raise NotImplementedError("edit not allowed for this widget") + + model = self.app.model + columns = ['person', 'username', 'active'] + + # generate data set for users + users = [] + if cstruct: + for uuid in cstruct: + user = self.session.query(model.User).get(uuid) + if user: + users.append(dict([(key, getattr(user, key)) + for key in columns + ['uuid']])) + + # grid + grid = Grid(self.request, key='roles.view.users', + columns=columns, data=users) + + # view action + if self.request.has_perm('users.view'): + url = lambda user, i: self.request.route_url('users.view', uuid=user['uuid']) + grid.add_action('view', icon='eye', url=url) + grid.set_link('person') + grid.set_link('username') + + # edit action + if self.request.has_perm('users.edit'): + url = lambda user, i: self.request.route_url('users.edit', uuid=user['uuid']) + grid.add_action('edit', url=url) + + # render as simple + # nb. must indicate we are a part of this form + form = getattr(field.parent, 'wutta_form', None) + return grid.render_table_element(form) + + class PermissionsWidget(WuttaCheckboxChoiceWidget): """ Widget for use with Role diff --git a/src/wuttaweb/grids/base.py b/src/wuttaweb/grids/base.py index 3f22aad..272845e 100644 --- a/src/wuttaweb/grids/base.py +++ b/src/wuttaweb/grids/base.py @@ -543,6 +543,13 @@ class Grid: return True return False + def add_action(self, key, **kwargs): + """ + Convenience to add a new :class:`GridAction` instance to the + grid's :attr:`actions` list. + """ + self.actions.append(GridAction(self.request, key, **kwargs)) + ############################## # sorting methods ############################## @@ -1251,6 +1258,9 @@ class Grid: """ Render the Vue template block for the grid. + This is what you want for a "full-featured" grid which will + exist as its own unique Vue component on the frontend. + This returns something like: .. code-block:: none @@ -1261,12 +1271,21 @@ class Grid: + + .. todo:: Why can't Sphinx render the above code block as 'html' ? It acts like it can't handle a `` diff --git a/src/wuttaweb/templates/grids/element.mako b/src/wuttaweb/templates/grids/element.mako new file mode 100644 index 0000000..ba35bf3 --- /dev/null +++ b/src/wuttaweb/templates/grids/element.mako @@ -0,0 +1,49 @@ +## -*- coding: utf-8; -*- +<${b}-table :data="gridData['${grid.key}']"> + + % for column in grid.get_vue_columns(): + <${b}-table-column field="${column['field']}" + label="${column['label']}" + v-slot="props" + :sortable="${json.dumps(column.get('sortable', False))|n}" + cell-class="c_${column['field']}"> + % if grid.is_linked(column['field']): + + % else: + + % endif + + % endfor + + % if grid.actions: + <${b}-table-column field="actions" + label="Actions" + v-slot="props"> + % for action in grid.actions: + + ${action.render_icon_and_label()} + +   + % endfor + + % endif + + + + diff --git a/src/wuttaweb/views/people.py b/src/wuttaweb/views/people.py index b137e3c..7aa7596 100644 --- a/src/wuttaweb/views/people.py +++ b/src/wuttaweb/views/people.py @@ -28,6 +28,7 @@ import sqlalchemy as sa from wuttjamaican.db.model import Person from wuttaweb.views import MasterView +from wuttaweb.forms.schema import UserRefs class PersonView(MasterView): @@ -70,23 +71,25 @@ class PersonView(MasterView): # last_name g.set_link('last_name') - # TODO: master should handle this? def configure_form(self, f): """ """ super().configure_form(f) + person = f.model_instance - # first_name + # TODO: master should handle these? (nullable column) f.set_required('first_name', False) - - # middle_name f.set_required('middle_name', False) - - # last_name f.set_required('last_name', False) # users - if 'users' in f: - f.fields.remove('users') + # nb. colanderalchemy wants to do some magic for the true + # 'users' relationship, so we use a different field name + f.remove('users') + if not (self.creating or self.editing): + f.append('_users') + f.set_readonly('_users') + f.set_node('_users', UserRefs(self.request)) + f.set_default('_users', [u.uuid for u in person.users]) def autocomplete_query(self, term): """ """ diff --git a/src/wuttaweb/views/roles.py b/src/wuttaweb/views/roles.py index 0da0712..a8f60e4 100644 --- a/src/wuttaweb/views/roles.py +++ b/src/wuttaweb/views/roles.py @@ -28,7 +28,7 @@ from wuttjamaican.db.model import Role from wuttaweb.views import MasterView from wuttaweb.db import Session from wuttaweb.forms import widgets -from wuttaweb.forms.schema import Permissions +from wuttaweb.forms.schema import UserRefs, Permissions class RoleView(MasterView): @@ -115,6 +115,13 @@ class RoleView(MasterView): # notes f.set_widget('notes', widgets.NotesWidget()) + # users + if not (self.creating or self.editing): + f.append('users') + f.set_readonly('users') + f.set_node('users', UserRefs(self.request)) + f.set_default('users', [u.uuid for u in role.users]) + # permissions f.append('permissions') self.wutta_permissions = self.get_available_permissions() diff --git a/tests/forms/test_base.py b/tests/forms/test_base.py index 70ee55f..50d27bc 100644 --- a/tests/forms/test_base.py +++ b/tests/forms/test_base.py @@ -10,6 +10,7 @@ from pyramid import testing from wuttjamaican.conf import WuttaConfig from wuttaweb.forms import base, widgets from wuttaweb import helpers +from wuttaweb.grids import Grid class TestForm(TestCase): @@ -405,6 +406,29 @@ class TestForm(TestCase): self.assertIn(' + +<%def name="make_wutta_filter_component()"> + + + + +<%def name="make_wutta_filter_value_component()"> + + + diff --git a/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py index 6b20f0a..4258cfd 100644 --- a/src/wuttaweb/views/master.py +++ b/src/wuttaweb/views/master.py @@ -181,6 +181,23 @@ class MasterView(View): This is optional; see also :meth:`get_grid_columns()`. + .. attribute:: filterable + + Boolean indicating whether the grid for the :meth:`index()` + view should allow filtering of data. Default is ``True``. + + This is used by :meth:`make_model_grid()` to set the grid's + :attr:`~wuttaweb.grids.base.Grid.filterable` flag. + + .. attribute:: filter_defaults + + Optional dict of default filter state. + + This is used by :meth:`make_model_grid()` to set the grid's + :attr:`~wuttaweb.grids.base.Grid.filter_defaults`. + + Only relevant if :attr:`filterable` is true. + .. attribute:: sortable Boolean indicating whether the grid for the :meth:`index()` @@ -283,6 +300,8 @@ class MasterView(View): # features listable = True has_grid = True + filterable = True + filter_defaults = None sortable = True sort_on_backend = True sort_defaults = None @@ -337,13 +356,26 @@ class MasterView(View): if self.has_grid: grid = self.make_model_grid() - # so-called 'partial' requests get just data, no html + # handle "full" vs. "partial" differently if self.request.GET.get('partial'): + + # so-called 'partial' requests get just data, no html context = {'data': grid.get_vue_data()} if grid.paginated and grid.paginate_on_backend: context['pager_stats'] = grid.get_vue_pager_stats() return self.json_response(context) + else: # full, not partial + + # nb. when user asks to reset view, it is via the query + # string. if so we then redirect to discard that. + if self.request.GET.get('reset-view'): + + # nb. we want to preserve url hash if applicable + kw = {'_query': None, + '_anchor': self.request.GET.get('hash')} + return self.redirect(self.request.current_route_url(**kw)) + context['grid'] = grid return self.render_to_response('index', context) @@ -1208,6 +1240,8 @@ class MasterView(View): kwargs['actions'] = actions + kwargs.setdefault('filterable', self.filterable) + kwargs.setdefault('filter_defaults', self.filter_defaults) kwargs.setdefault('sortable', self.sortable) kwargs.setdefault('sort_multiple', not self.request.use_oruga) kwargs.setdefault('sort_on_backend', self.sort_on_backend) diff --git a/src/wuttaweb/views/people.py b/src/wuttaweb/views/people.py index 7aa7596..a19df57 100644 --- a/src/wuttaweb/views/people.py +++ b/src/wuttaweb/views/people.py @@ -58,6 +58,10 @@ class PersonView(MasterView): 'last_name', ] + filter_defaults = { + 'full_name': {'active': True}, + } + def configure_grid(self, g): """ """ super().configure_grid(g) diff --git a/src/wuttaweb/views/roles.py b/src/wuttaweb/views/roles.py index a8f60e4..fa7c8fc 100644 --- a/src/wuttaweb/views/roles.py +++ b/src/wuttaweb/views/roles.py @@ -52,6 +52,10 @@ class RoleView(MasterView): 'notes', ] + filter_defaults = { + 'name': {'active': True}, + } + # TODO: master should handle this, possibly via configure_form() def get_query(self, session=None): """ """ diff --git a/src/wuttaweb/views/settings.py b/src/wuttaweb/views/settings.py index 5b24d86..a20e1f6 100644 --- a/src/wuttaweb/views/settings.py +++ b/src/wuttaweb/views/settings.py @@ -201,6 +201,9 @@ class SettingView(MasterView): """ model_class = Setting model_title = "Raw Setting" + filter_defaults = { + 'name': {'active': True}, + } sort_defaults = 'name' # TODO: master should handle this (per model key) diff --git a/src/wuttaweb/views/users.py b/src/wuttaweb/views/users.py index 4f4b6f0..d05b8eb 100644 --- a/src/wuttaweb/views/users.py +++ b/src/wuttaweb/views/users.py @@ -55,6 +55,10 @@ class UserView(MasterView): 'active', ] + filter_defaults = { + 'username': {'active': True}, + } + # TODO: master should handle this, possibly via configure_form() def get_query(self, session=None): """ """ diff --git a/tests/grids/test_base.py b/tests/grids/test_base.py index 3cef14e..5726367 100644 --- a/tests/grids/test_base.py +++ b/tests/grids/test_base.py @@ -383,8 +383,7 @@ class TestGrid(WebTestCase): grid = self.make_grid(key='settings', model_class=model.Setting, filterable=True) self.assertEqual(len(grid.filters), 2) - self.assertFalse(hasattr(grid.filters['name'], 'active')) - self.assertFalse(hasattr(grid.filters['value'], 'active')) + self.assertEqual(len(grid.active_filters), 0) self.assertNotIn('grid.settings.filter.name.active', self.request.session) self.assertNotIn('grid.settings.filter.value.active', self.request.session) self.request.GET = {'name': 'john', 'name.verb': 'contains'} @@ -401,8 +400,7 @@ class TestGrid(WebTestCase): grid = self.make_grid(key='settings', model_class=model.Setting, sortable=True, filterable=True) self.assertEqual(len(grid.filters), 2) - self.assertFalse(hasattr(grid.filters['name'], 'active')) - self.assertFalse(hasattr(grid.filters['value'], 'active')) + self.assertEqual(len(grid.active_filters), 0) self.assertNotIn('grid.settings.filter.name.active', self.request.session) self.assertNotIn('grid.settings.filter.value.active', self.request.session) self.assertNotIn('grid.settings.sorters.length', self.request.session) @@ -419,6 +417,12 @@ class TestGrid(WebTestCase): self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name') self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'asc') + # can reset view to defaults + self.request.GET = {'reset-view': 'true'} + grid.load_settings() + self.assertEqual(grid.active_filters, []) + self.assertIsNone(grid.filters['name'].value) + def test_request_has_settings(self): model = self.app.model grid = self.make_grid(key='settings', model_class=model.Setting) @@ -927,6 +931,10 @@ class TestGrid(WebTestCase): filtr = grid.make_filter(model.Setting.name) self.assertIsInstance(filtr, mod.GridFilter) + # invalid model class + grid = self.make_grid(model_class=42) + self.assertRaises(ValueError, grid.make_filter, 'name') + def test_set_filter(self): model = self.app.model @@ -968,6 +976,22 @@ class TestGrid(WebTestCase): grid.remove_filter('value') self.assertNotIn('value', grid.filters) + def test_set_filter_defaults(self): + model = self.app.model + + # empty by default + grid = self.make_grid(model_class=model.Setting, filterable=True) + self.assertEqual(grid.filter_defaults, {}) + + # can specify via method call + grid.set_filter_defaults(name={'active': True}) + self.assertEqual(grid.filter_defaults, {'name': {'active': True}}) + + # can specify via constructor + grid = self.make_grid(model_class=model.Setting, filterable=True, + filter_defaults={'name': {'active': True}}) + self.assertEqual(grid.filter_defaults, {'name': {'active': True}}) + ############################## # data methods ############################## @@ -1008,11 +1032,82 @@ class TestGrid(WebTestCase): def test_filter_data(self): model = self.app.model + sample_data = [ + {'name': 'foo1', 'value': 'ONE'}, + {'name': 'foo2', 'value': 'two'}, + {'name': 'foo3', 'value': 'ggg'}, + {'name': 'foo4', 'value': 'ggg'}, + {'name': 'foo5', 'value': 'ggg'}, + {'name': 'foo6', 'value': 'six'}, + {'name': 'foo7', 'value': 'seven'}, + {'name': 'foo8', 'value': 'eight'}, + {'name': 'foo9', 'value': 'nine'}, + ] + for setting in sample_data: + self.app.save_setting(self.session, setting['name'], setting['value']) + self.session.commit() + sample_query = self.session.query(model.Setting) - query = self.session.query(model.Setting) - grid = self.make_grid(model_class=model.Setting, filterable=True) + grid = self.make_grid(key='settings', model_class=model.Setting, filterable=True) + + # not filtered by default grid.load_settings() - self.assertRaises(NotImplementedError, grid.filter_data, query) + self.assertEqual(grid.active_filters, []) + filtered_query = grid.filter_data(sample_query) + self.assertIs(filtered_query, sample_query) + + # can be filtered per session settings + self.request.session['grid.settings.filter.value.active'] = True + self.request.session['grid.settings.filter.value.verb'] = 'contains' + self.request.session['grid.settings.filter.value.value'] = 'ggg' + grid.load_settings() + self.assertEqual(len(grid.active_filters), 1) + self.assertEqual(grid.active_filters[0].key, 'value') + filtered_query = grid.filter_data(sample_query) + self.assertIsInstance(filtered_query, orm.Query) + self.assertIsNot(filtered_query, sample_query) + self.assertEqual(filtered_query.count(), 3) + + # can be filtered per request settings + self.request.GET = {'value': 's', 'value.verb': 'contains'} + grid.load_settings() + self.assertEqual(len(grid.active_filters), 1) + self.assertEqual(grid.active_filters[0].key, 'value') + filtered_query = grid.filter_data(sample_query) + self.assertIsInstance(filtered_query, orm.Query) + self.assertEqual(filtered_query.count(), 2) + + # not filtered if verb is invalid + self.request.GET = {'value': 'ggg', 'value.verb': 'doesnotexist'} + grid.load_settings() + self.assertEqual(len(grid.active_filters), 1) + self.assertEqual(grid.active_filters[0].verb, 'doesnotexist') + filtered_query = grid.filter_data(sample_query) + self.assertIs(filtered_query, sample_query) + self.assertEqual(filtered_query.count(), 9) + + # not filtered if error + self.request.GET = {'value': 'ggg', 'value.verb': 'contains'} + grid.load_settings() + self.assertEqual(len(grid.active_filters), 1) + self.assertEqual(grid.active_filters[0].verb, 'contains') + filtered_query = grid.filter_data(sample_query) + self.assertIsNot(filtered_query, sample_query) + self.assertEqual(filtered_query.count(), 3) + with patch.object(grid.active_filters[0], 'filter_contains', side_effect=RuntimeError): + filtered_query = grid.filter_data(sample_query) + self.assertIs(filtered_query, sample_query) + self.assertEqual(filtered_query.count(), 9) + + # joiner is invoked + self.assertEqual(len(grid.active_filters), 1) + self.assertEqual(grid.active_filters[0].key, 'value') + joiner = MagicMock(side_effect=lambda q: q) + grid.joiners = {'value': joiner} + grid.joined = set() + filtered_query = grid.filter_data(sample_query) + joiner.assert_called_once_with(sample_query) + self.assertEqual(filtered_query.count(), 3) def test_sort_data(self): model = self.app.model @@ -1210,6 +1305,15 @@ class TestGrid(WebTestCase): sorters = grid.get_vue_active_sorters() self.assertEqual(sorters, [{'field': 'name', 'order': 'asc'}]) + def test_get_vue_filters(self): + model = self.app.model + + # basic + grid = self.make_grid(key='settings', model_class=model.Setting, filterable=True) + grid.load_settings() + filters = grid.get_vue_filters() + self.assertEqual(len(filters), 2) + def test_get_vue_data(self): # empty if no columns defined @@ -1317,3 +1421,86 @@ class TestGridAction(TestCase): action = self.make_action('blarg', url=lambda o, i: '/yeehaw') url = action.get_url(obj) self.assertEqual(url, '/yeehaw') + + +class TestGridFilter(WebTestCase): + + def setUp(self): + self.setup_web() + + model = self.app.model + self.sample_data = [ + {'name': 'foo1', 'value': 'ONE'}, + {'name': 'foo2', 'value': 'two'}, + {'name': 'foo3', 'value': 'ggg'}, + {'name': 'foo4', 'value': 'ggg'}, + {'name': 'foo5', 'value': 'ggg'}, + {'name': 'foo6', 'value': 'six'}, + {'name': 'foo7', 'value': 'seven'}, + {'name': 'foo8', 'value': 'eight'}, + {'name': 'foo9', 'value': 'nine'}, + ] + for setting in self.sample_data: + self.app.save_setting(self.session, setting['name'], setting['value']) + self.session.commit() + self.sample_query = self.session.query(model.Setting) + + def make_filter(self, model_property, **kwargs): + return mod.GridFilter(self.request, model_property, **kwargs) + + def test_repr(self): + model = self.app.model + filtr = self.make_filter(model.Setting.name) + self.assertEqual(repr(filtr), "GridFilter(key='name', active=False, verb='contains', value=None)") + + def test_apply_filter(self): + model = self.app.model + filtr = self.make_filter(model.Setting.value) + + # default verb used as fallback + self.assertEqual(filtr.default_verb, 'contains') + filtr.verb = None + with patch.object(filtr, 'filter_contains', side_effect=lambda q, v: q) as filter_contains: + filtered_query = filtr.apply_filter(self.sample_query, value='foo') + filter_contains.assert_called_once_with(self.sample_query, 'foo') + self.assertIsNone(filtr.verb) + + # filter verb used as fallback + filtr.verb = 'equal' + with patch.object(filtr, 'filter_equal', create=True, side_effect=lambda q, v: q) as filter_equal: + filtered_query = filtr.apply_filter(self.sample_query, value='foo') + filter_equal.assert_called_once_with(self.sample_query, 'foo') + + # filter value used as fallback + filtr.verb = 'contains' + filtr.value = 'blarg' + with patch.object(filtr, 'filter_contains', side_effect=lambda q, v: q) as filter_contains: + filtered_query = filtr.apply_filter(self.sample_query) + filter_contains.assert_called_once_with(self.sample_query, 'blarg') + + # error if invalid verb + self.assertRaises(mod.VerbNotSupported, filtr.apply_filter, + self.sample_query, verb='doesnotexist') + + def test_filter_contains(self): + model = self.app.model + filtr = self.make_filter(model.Setting.value) + self.assertEqual(self.sample_query.count(), 9) + + # not filtered for empty value + filtered_query = filtr.filter_contains(self.sample_query, None) + self.assertIs(filtered_query, self.sample_query) + filtered_query = filtr.filter_contains(self.sample_query, '') + self.assertIs(filtered_query, self.sample_query) + + # filtered by value + filtered_query = filtr.filter_contains(self.sample_query, 'ggg') + self.assertIsNot(filtered_query, self.sample_query) + self.assertEqual(filtered_query.count(), 3) + + +class TestVerbNotSupported(TestCase): + + def test_basic(self): + error = mod.VerbNotSupported('equal') + self.assertEqual(str(error), "unknown filter verb not supported: equal") diff --git a/tests/views/test_master.py b/tests/views/test_master.py index 6660cd6..7273265 100644 --- a/tests/views/test_master.py +++ b/tests/views/test_master.py @@ -761,6 +761,12 @@ class TestMasterView(WebTestCase): self.assertEqual(response.status_code, 200) self.assertEqual(response.content_type, 'application/json') + # redirects when view is reset + self.request.GET = {'reset-view': '1', 'hash': 'foo'} + with patch.object(self.request, 'current_route_url'): + response = view.index() + self.assertEqual(response.status_code, 302) + def test_create(self): self.pyramid_config.include('wuttaweb.views.common') self.pyramid_config.include('wuttaweb.views.auth') From a5c2931085e7cd87459cc4d8486dc5e145d346c9 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 22 Aug 2024 14:45:25 -0500 Subject: [PATCH 12/13] feat: add "copy link" button for sharing a grid view --- .../templates/grids/vue_template.mako | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/src/wuttaweb/templates/grids/vue_template.mako b/src/wuttaweb/templates/grids/vue_template.mako index 58721a2..84dcb58 100644 --- a/src/wuttaweb/templates/grids/vue_template.mako +++ b/src/wuttaweb/templates/grids/vue_template.mako @@ -16,6 +16,13 @@ ref="gridFilters" />
+ + + + + + + ## dummy input field needed for sharing links on *insecure* sites + % if getattr(request, 'scheme', None) == 'http': + + % endif +
@@ -223,6 +236,11 @@ ## nb. this tracks whether grid.fetchFirstData() happened fetchedFirstData: false, + ## dummy input value needed for sharing links on *insecure* sites + % if getattr(request, 'scheme', None) == 'http': + shareLink: null, + % endif + ## filtering % if grid.filterable: filters: ${json.dumps(grid.get_vue_filters())|n}, @@ -268,6 +286,11 @@ template: '#${grid.vue_tagname}-template', computed: { + directLink() { + const params = new URLSearchParams(this.getAllParams()) + return `${request.path_url}?${'$'}{params}` + }, + % if grid.filterable: addFilterChoices() { @@ -346,12 +369,50 @@ methods: { + copyDirectLink() { + + if (navigator.clipboard) { + // this is the way forward, but requires HTTPS + navigator.clipboard.writeText(this.directLink) + + } else { + // use deprecated 'copy' command, but this just + // tells the browser to copy currently-selected + // text..which means we first must "add" some text + // to screen, and auto-select that, before copying + // to clipboard + this.shareLink = this.directLink + this.$nextTick(() => { + let input = this.$refs.shareLink.$el.firstChild + input.select() + document.execCommand('copy') + // re-hide the dummy input + this.shareLink = null + }) + } + + this.$buefy.toast.open({ + message: "Link was copied to clipboard", + type: 'is-info', + duration: 2000, // 2 seconds + }) + }, + renderNumber(value) { if (value != undefined) { return value.toLocaleString('en') } }, + getAllParams() { + return { + ...this.getBasicParams(), + % if grid.filterable: + ...this.getFilterParams(), + % endif + } + }, + getBasicParams() { const params = { % if grid.paginated and grid.paginate_on_backend: From fab87d33038c7b6edbff49359d4be0e772175c1e Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 22 Aug 2024 14:51:08 -0500 Subject: [PATCH 13/13] =?UTF-8?q?bump:=20version=200.11.0=20=E2=86=92=200.?= =?UTF-8?q?12.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 24 ++++++++++++++++++++++++ pyproject.toml | 4 ++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0935339..b17ddc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,30 @@ All notable changes to wuttaweb will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.12.0 (2024-08-22) + +### Feat + +- add "copy link" button for sharing a grid view +- add initial support for proper grid filters +- add initial filtering logic to grid class +- add "searchable" column support for grids +- improve page linkage between role/user/person +- add basic autocomplete support, for Person + +### Fix + +- cleanup templates for home, login pages +- cleanup logic for appinfo/configure +- expose settings for app node title, type +- show installed python packages on appinfo page +- tweak login form to stop extending size of background card +- add setting to auto-redirect anon users to login, from home page +- add form padding, validators for /configure pages +- add padding around main form, via wrapper css +- show CRUD buttons in header only if relevant and user has access +- tweak style config for home link app title in main menu + ## v0.11.0 (2024-08-20) ### Feat diff --git a/pyproject.toml b/pyproject.toml index fe91ac9..9fc1d83 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "WuttaWeb" -version = "0.11.0" +version = "0.12.0" description = "Web App for Wutta Framework" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] @@ -41,7 +41,7 @@ dependencies = [ "pyramid_tm", "waitress", "WebHelpers2", - "WuttJamaican[db]>=0.12.0", + "WuttJamaican[db]>=0.12.1", "zope.sqlalchemy>=1.5", ]