From a6ce5eb21d7ba61f187ac1093abc08b4d9ccdb01 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 15 Aug 2024 14:34:20 -0500 Subject: [PATCH] feat: refactor forms/grids/views/templates per wuttaweb compat this starts to get things more aligned between wuttaweb and tailbone. the use case in mind so far is for a wuttaweb view to be included in a tailbone app. form and grid classes now have some new methods to match wuttaweb, so templates call the shared method names where possible. templates can no longer assume they have tailbone-native master view, form, grid etc. so must inspect context more closely in some cases. --- tailbone/app.py | 13 +- tailbone/auth.py | 29 +--- tailbone/config.py | 5 +- tailbone/forms/core.py | 112 +++++++++++-- tailbone/grids/core.py | 88 +++++++++- tailbone/subscribers.py | 71 +++----- tailbone/templates/base.mako | 8 +- tailbone/templates/form.mako | 8 +- tailbone/templates/forms/deform.mako | 41 +++-- tailbone/templates/forms/vue_template.mako | 3 + tailbone/templates/grids/complete.mako | 94 +++++------ tailbone/templates/grids/vue_template.mako | 3 + tailbone/templates/master/create.mako | 2 +- tailbone/templates/master/delete.mako | 10 +- tailbone/templates/master/form.mako | 6 +- tailbone/templates/master/index.mako | 128 +++++++-------- tailbone/templates/master/view.mako | 10 +- tailbone/templates/people/index.mako | 4 +- tailbone/templates/people/view.mako | 4 +- .../templates/principal/find_by_perm.mako | 4 +- .../templates/themes/butterball/base.mako | 28 ++-- tailbone/views/master.py | 4 +- tailbone/views/principal.py | 2 +- tailbone/views/roles.py | 4 +- tailbone/views/users.py | 2 +- tests/__init__.py | 3 - tests/forms/__init__.py | 0 tests/forms/test_core.py | 153 ++++++++++++++++++ tests/grids/__init__.py | 0 tests/grids/test_core.py | 139 ++++++++++++++++ tests/test_app.py | 43 +++-- tests/test_auth.py | 3 + tests/test_config.py | 12 ++ tests/test_subscribers.py | 58 +++++++ tests/util.py | 75 +++++++++ tests/views/test_master.py | 26 +++ tests/views/test_principal.py | 29 ++++ tests/views/test_roles.py | 80 +++++++++ tests/views/test_users.py | 33 ++++ 39 files changed, 1037 insertions(+), 300 deletions(-) create mode 100644 tailbone/templates/forms/vue_template.mako create mode 100644 tailbone/templates/grids/vue_template.mako create mode 100644 tests/forms/__init__.py create mode 100644 tests/forms/test_core.py create mode 100644 tests/grids/__init__.py create mode 100644 tests/grids/test_core.py create mode 100644 tests/test_auth.py create mode 100644 tests/test_config.py create mode 100644 tests/test_subscribers.py create mode 100644 tests/util.py create mode 100644 tests/views/test_master.py create mode 100644 tests/views/test_principal.py create mode 100644 tests/views/test_roles.py create mode 100644 tests/views/test_users.py diff --git a/tailbone/app.py b/tailbone/app.py index b7220703..5e8e49d9 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -189,9 +189,16 @@ def make_pyramid_config(settings, configure_csrf=True): for spec in includes: config.include(spec) - # Add some permissions magic. - config.add_directive('add_tailbone_permission_group', 'tailbone.auth.add_permission_group') - config.add_directive('add_tailbone_permission', 'tailbone.auth.add_permission') + # add some permissions magic + config.add_directive('add_wutta_permission_group', + 'wuttaweb.auth.add_permission_group') + config.add_directive('add_wutta_permission', + 'wuttaweb.auth.add_permission') + # TODO: deprecate / remove these + config.add_directive('add_tailbone_permission_group', + 'wuttaweb.auth.add_permission_group') + config.add_directive('add_tailbone_permission', + 'wuttaweb.auth.add_permission') # and some similar magic for certain master views config.add_directive('add_tailbone_index_page', 'tailbone.app.add_index_page') diff --git a/tailbone/auth.py b/tailbone/auth.py index 826c5d40..fbe6bf2f 100644 --- a/tailbone/auth.py +++ b/tailbone/auth.py @@ -27,7 +27,7 @@ Authentication & Authorization import logging import re -from rattail.util import prettify, NOTSET +from rattail.util import NOTSET from zope.interface import implementer from pyramid.authentication import SessionAuthenticationHelper @@ -159,30 +159,3 @@ class TailboneSecurityPolicy: user = self.identity(request) return auth.has_permission(Session(), user, permission) - - -def add_permission_group(config, key, label=None, overwrite=True): - """ - Add a permission group to the app configuration. - """ - def action(): - perms = config.get_settings().get('tailbone_permissions', {}) - if key not in perms or overwrite: - group = perms.setdefault(key, {'key': key}) - group['label'] = label or prettify(key) - config.add_settings({'tailbone_permissions': perms}) - config.action(None, action) - - -def add_permission(config, groupkey, key, label=None): - """ - Add a permission to the app configuration. - """ - def action(): - perms = config.get_settings().get('tailbone_permissions', {}) - group = perms.setdefault(groupkey, {'key': groupkey}) - group.setdefault('label', prettify(groupkey)) - perm = group.setdefault('perms', {}).setdefault(key, {'key': key}) - perm['label'] = label or prettify(key) - config.add_settings({'tailbone_permissions': perms}) - config.action(None, action) diff --git a/tailbone/config.py b/tailbone/config.py index ce1691ae..8392ba0a 100644 --- a/tailbone/config.py +++ b/tailbone/config.py @@ -26,13 +26,14 @@ Rattail config extension for Tailbone import warnings -from rattail.config import ConfigExtension as BaseExtension +from wuttjamaican.conf import WuttaConfigExtension + from rattail.db.config import configure_session from tailbone.db import Session -class ConfigExtension(BaseExtension): +class ConfigExtension(WuttaConfigExtension): """ Rattail config extension for Tailbone. Does the following: diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 60c2f61b..eeae4537 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -47,7 +47,7 @@ from pyramid_deform import SessionFileUploadTempStore from pyramid.renderers import render from webhelpers2.html import tags, HTML -from wuttaweb.util import get_form_data +from wuttaweb.util import get_form_data, make_json_safe from tailbone.db import Session from tailbone.util import raw_datetime, render_markdown @@ -328,7 +328,7 @@ class Form(object): """ Base class for all forms. """ - save_label = "Save" + save_label = "Submit" update_label = "Save" show_cancel = True auto_disable = True @@ -339,10 +339,12 @@ class Form(object): model_instance=None, model_class=None, appstruct=UNSPECIFIED, nodes={}, enums={}, labels={}, assume_local_times=False, renderers=None, renderer_kwargs={}, hidden={}, widgets={}, defaults={}, validators={}, required={}, helptext={}, focus_spec=None, - action_url=None, cancel_url=None, component='tailbone-form', + action_url=None, cancel_url=None, + vue_tagname=None, vuejs_component_kwargs=None, vuejs_field_converters={}, json_data={}, included_templates={}, # TODO: ugh this is getting out hand! can_edit_help=False, edit_help_url=None, route_prefix=None, + **kwargs ): self.fields = None if fields is not None: @@ -380,7 +382,17 @@ class Form(object): self.focus_spec = focus_spec self.action_url = action_url self.cancel_url = cancel_url - self.component = component + + # vue_tagname + self.vue_tagname = vue_tagname + if not self.vue_tagname and kwargs.get('component'): + warnings.warn("component kwarg is deprecated for Form(); " + "please use vue_tagname param instead", + DeprecationWarning, stacklevel=2) + self.vue_tagname = kwargs['component'] + if not self.vue_tagname: + self.vue_tagname = 'tailbone-form' + self.vuejs_component_kwargs = vuejs_component_kwargs or {} self.vuejs_field_converters = vuejs_field_converters or {} self.json_data = json_data or {} @@ -393,10 +405,54 @@ class Form(object): return iter(self.fields) @property - def component_studly(self): - words = self.component.split('-') + def vue_component(self): + """ + String name for the Vue component, e.g. ``'TailboneGrid'``. + + This is a generated value based on :attr:`vue_tagname`. + """ + words = self.vue_tagname.split('-') return ''.join([word.capitalize() for word in words]) + @property + def component(self): + """ + DEPRECATED - use :attr:`vue_tagname` instead. + """ + warnings.warn("Form.component is deprecated; " + "please use vue_tagname instead", + DeprecationWarning, stacklevel=2) + return self.vue_tagname + + @property + def component_studly(self): + """ + DEPRECATED - use :attr:`vue_component` instead. + """ + warnings.warn("Form.component_studly is deprecated; " + "please use vue_component instead", + DeprecationWarning, stacklevel=2) + return self.vue_component + + def get_button_label_submit(self): + """ """ + if hasattr(self, '_button_label_submit'): + return self._button_label_submit + + label = getattr(self, 'submit_label', None) + if label: + return label + + return self.save_label + + def set_button_label_submit(self, value): + """ """ + self._button_label_submit = value + + # wutta compat + button_label_submit = property(get_button_label_submit, + set_button_label_submit) + def __contains__(self, item): return item in self.fields @@ -805,6 +861,10 @@ class Form(object): DeprecationWarning, stacklevel=2) return self.render_deform(**kwargs) + def get_deform(self): + """ """ + return self.make_deform_form() + def make_deform_form(self): if not hasattr(self, 'deform_form'): @@ -843,6 +903,10 @@ class Form(object): return self.deform_form + def render_vue_template(self, template='/forms/deform.mako', **context): + """ """ + return self.render_deform(template=template, **context) + def render_deform(self, dform=None, template=None, **kwargs): if not template: template = '/forms/deform.mako' @@ -865,8 +929,8 @@ class Form(object): context.setdefault('form_kwargs', {}) # TODO: deprecate / remove the latter option here if self.auto_disable_save or self.auto_disable: - context['form_kwargs'].setdefault('ref', self.component_studly) - context['form_kwargs']['@submit'] = 'submit{}'.format(self.component_studly) + context['form_kwargs'].setdefault('ref', self.vue_component) + context['form_kwargs']['@submit'] = 'submit{}'.format(self.vue_component) if self.focus_spec: context['form_kwargs']['data-focus'] = self.focus_spec context['request'] = self.request @@ -878,12 +942,13 @@ class Form(object): return dict([(field, self.get_label(field)) for field in self]) - def get_field_markdowns(self): + def get_field_markdowns(self, session=None): app = self.request.rattail_config.get_app() model = app.model + session = session or Session() if not hasattr(self, 'field_markdowns'): - infos = Session.query(model.TailboneFieldInfo)\ + infos = session.query(model.TailboneFieldInfo)\ .filter(model.TailboneFieldInfo.route_prefix == self.route_prefix)\ .all() self.field_markdowns = dict([(info.field_name, info.markdown_text) @@ -891,6 +956,18 @@ class Form(object): return self.field_markdowns + def get_vue_field_value(self, key): + """ """ + if key not in self.fields: + return + + dform = self.get_deform() + if key not in dform: + return + + field = dform[key] + return make_json_safe(field.cstruct) + def get_vuejs_model_value(self, field): """ This method must return "raw" JS which will be assigned as the initial @@ -957,6 +1034,10 @@ class Form(object): def set_vuejs_component_kwargs(self, **kwargs): self.vuejs_component_kwargs.update(kwargs) + def render_vue_tag(self, **kwargs): + """ """ + return self.render_vuejs_component() + def render_vuejs_component(self): """ Render the Vue.js component HTML for the form. @@ -971,7 +1052,7 @@ class Form(object): kwargs = dict(self.vuejs_component_kwargs) if self.can_edit_help: kwargs.setdefault(':configure-fields-help', 'configureFieldsHelp') - return HTML.tag(self.component, **kwargs) + return HTML.tag(self.vue_tagname, **kwargs) def set_json_data(self, key, value): """ @@ -997,7 +1078,12 @@ class Form(object): templates.append(HTML.literal(render(template, context))) return HTML.literal('\n').join(templates) - def render_field_complete(self, fieldname, bfield_attrs={}): + def render_vue_field(self, fieldname, **kwargs): + """ """ + return self.render_field_complete(fieldname, **kwargs) + + def render_field_complete(self, fieldname, bfield_attrs={}, + session=None): """ Render the given field completely, i.e. with ```` wrapper. Note that this is meant to render *editable* fields, @@ -1015,7 +1101,7 @@ class Form(object): if self.field_visible(fieldname): label = self.get_label(fieldname) - markdowns = self.get_field_markdowns() + markdowns = self.get_field_markdowns(session=session) # these attrs will be for the (*not* the widget) attrs = { diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index b4610a18..3f1769cf 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -198,7 +198,8 @@ class Grid: checkable=None, row_uuid_getter=None, clicking_row_checks_box=False, click_handlers=None, main_actions=[], more_actions=[], delete_speedbump=False, - ajax_data_url=None, component='tailbone-grid', + ajax_data_url=None, + vue_tagname=None, expose_direct_link=False, **kwargs): @@ -268,19 +269,63 @@ class Grid: if ajax_data_url: self.ajax_data_url = ajax_data_url elif self.request: - self.ajax_data_url = self.request.current_route_url(_query=None) + self.ajax_data_url = self.request.path_url else: self.ajax_data_url = '' - self.component = component + # vue_tagname + self.vue_tagname = vue_tagname + if not self.vue_tagname and kwargs.get('component'): + warnings.warn("component kwarg is deprecated for Grid(); " + "please use vue_tagname param instead", + DeprecationWarning, stacklevel=2) + self.vue_tagname = kwargs['component'] + if not self.vue_tagname: + self.vue_tagname = 'tailbone-grid' + self.expose_direct_link = expose_direct_link self._whgrid_kwargs = kwargs @property - def component_studly(self): - words = self.component.split('-') + def vue_component(self): + """ + String name for the Vue component, e.g. ``'TailboneGrid'``. + + This is a generated value based on :attr:`vue_tagname`. + """ + words = self.vue_tagname.split('-') return ''.join([word.capitalize() for word in words]) + @property + def component(self): + """ + DEPRECATED - use :attr:`vue_tagname` instead. + """ + warnings.warn("Grid.component is deprecated; " + "please use vue_tagname instead", + DeprecationWarning, stacklevel=2) + return self.vue_tagname + + @property + def component_studly(self): + """ + DEPRECATED - use :attr:`vue_component` instead. + """ + warnings.warn("Grid.component_studly is deprecated; " + "please use vue_component instead", + DeprecationWarning, stacklevel=2) + return self.vue_component + + @property + def actions(self): + """ """ + actions = [] + if self.main_actions: + actions.extend(self.main_actions) + if self.more_actions: + actions.extend(self.more_actions) + return actions + def make_columns(self): """ Return a default list of columns, based on :attr:`model_class`. @@ -1334,6 +1379,21 @@ class Grid: data = self.pager return data + def render_vue_tag(self, master=None, **kwargs): + """ """ + kwargs.setdefault('ref', 'grid') + kwargs.setdefault(':csrftoken', 'csrftoken') + + if (master and master.deletable and master.has_perm('delete') + and master.delete_confirm == 'simple'): + kwargs.setdefault('@deleteActionClicked', 'deleteObject') + + return HTML.tag(self.vue_tagname, **kwargs) + + def render_vue_template(self, template='/grids/complete.mako', **context): + """ """ + return self.render_complete(template=template, **context) + def render_complete(self, template='/grids/complete.mako', **kwargs): """ Render the grid, complete with filters. Note that this also @@ -1359,7 +1419,8 @@ class Grid: context['request'] = self.request context.setdefault('allow_save_defaults', True) context.setdefault('view_click_handler', self.get_view_click_handler()) - return render(template, context) + html = render(template, context) + return HTML.literal(html) def render_buefy(self, **kwargs): warnings.warn("Grid.render_buefy() is deprecated; " @@ -1575,6 +1636,10 @@ class Grid: return True return False + def get_vue_columns(self): + """ """ + return self.get_table_columns() + def get_table_columns(self): """ Return a list of dicts representing all grid columns. Meant @@ -1600,11 +1665,19 @@ class Grid: if hasattr(rowobj, 'uuid'): return rowobj.uuid + def get_vue_data(self): + """ """ + table_data = self.get_table_data() + return table_data['data'] + def get_table_data(self): """ Returns a list of data rows for the grid, for use with client-side JS table. """ + if hasattr(self, '_table_data'): + return self._table_data + # filter / sort / paginate to get "visible" data raw_data = self.make_visible_data() data = [] @@ -1704,7 +1777,8 @@ class Grid: else: results['total_items'] = count - return results + self._table_data = results + return self._table_data def set_action_urls(self, row, rowobj, i): """ diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 02c4e518..268d4818 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -48,7 +48,7 @@ from tailbone.util import get_available_themes, get_global_search_options log = logging.getLogger(__name__) -def new_request(event): +def new_request(event, session=None): """ Event hook called when processing a new request. @@ -64,15 +64,6 @@ def new_request(event): Reference to the app :term:`config object`. Note that this will be the same as :attr:`wuttaweb:request.wutta_config`. - .. method:: request.has_perm(name) - - Function to check if current user has the given permission. - - .. method:: request.has_any_perm(*names) - - Function to check if current user has any of the given - permissions. - .. method:: request.register_component(tagname, classname) Function to register a Vue component for use with the app. @@ -90,6 +81,7 @@ def new_request(event): config = request.wutta_config app = config.get_app() auth = app.get_auth_handler() + session = session or Session() # compatibility rattail_config = config @@ -104,50 +96,31 @@ def new_request(event): return user # invoke upstream hook to set user - base.new_request_set_user(event, user_getter=user_getter, db_session=Session()) + base.new_request_set_user(event, user_getter=user_getter, db_session=session) # assign client IP address to the session, for sake of versioning - Session().continuum_remote_addr = request.client_addr + if hasattr(request, 'client_addr'): + session.continuum_remote_addr = request.client_addr - # TODO: why would this ever be null? - if rattail_config: + # request.register_component() + def register_component(tagname, classname): + """ + Register a Vue 3 component, so the base template knows to + declare it for use within the app (page). + """ + if not hasattr(request, '_tailbone_registered_components'): + request._tailbone_registered_components = OrderedDict() - app = rattail_config.get_app() - auth = app.get_auth_handler() - request.tailbone_cached_permissions = auth.get_permissions( - Session(), request.user) + if tagname in request._tailbone_registered_components: + log.warning("component with tagname '%s' already registered " + "with class '%s' but we are replacing that with " + "class '%s'", + tagname, + request._tailbone_registered_components[tagname], + classname) - def has_perm(name): - if name in request.tailbone_cached_permissions: - return True - return request.is_root - request.has_perm = has_perm - - def has_any_perm(*names): - for name in names: - if has_perm(name): - return True - return False - request.has_any_perm = has_any_perm - - def register_component(tagname, classname): - """ - Register a Vue 3 component, so the base template knows to - declare it for use within the app (page). - """ - if not hasattr(request, '_tailbone_registered_components'): - request._tailbone_registered_components = OrderedDict() - - if tagname in request._tailbone_registered_components: - log.warning("component with tagname '%s' already registered " - "with class '%s' but we are replacing that with " - "class '%s'", - tagname, - request._tailbone_registered_components[tagname], - classname) - - request._tailbone_registered_components[tagname] = classname - request.register_component = register_component + request._tailbone_registered_components[tagname] = classname + request.register_component = register_component def before_render(event): diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index c4cbd648..6811397b 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -153,12 +153,16 @@ @@ -856,7 +860,7 @@ feedbackMessage: "", % if expose_theme_picker and request.has_perm('common.change_app_theme'): - globalTheme: ${json.dumps(theme)|n}, + globalTheme: ${json.dumps(theme or None)|n}, referrer: location.href, % endif @@ -866,7 +870,7 @@ globalSearchActive: false, globalSearchTerm: '', - globalSearchData: ${json.dumps(global_search_data)|n}, + globalSearchData: ${json.dumps(global_search_data or [])|n}, mountedHooks: [], } diff --git a/tailbone/templates/form.mako b/tailbone/templates/form.mako index c9c8ea88..fec721fd 100644 --- a/tailbone/templates/form.mako +++ b/tailbone/templates/form.mako @@ -6,12 +6,12 @@ <%def name="render_form_buttons()"> <%def name="render_form_template()"> - ${form.render_deform(buttons=capture(self.render_form_buttons))|n} + ${form.render_vue_template(buttons=capture(self.render_form_buttons))|n} <%def name="render_form()">
- ${form.render_vuejs_component()} + ${form.render_vue_tag()}
@@ -111,9 +111,9 @@ % if form is not Undefined: % endif diff --git a/tailbone/templates/forms/deform.mako b/tailbone/templates/forms/deform.mako index 00cf2c50..26c8b4ee 100644 --- a/tailbone/templates/forms/deform.mako +++ b/tailbone/templates/forms/deform.mako @@ -1,19 +1,19 @@ ## -*- coding: utf-8; -*- -<% request.register_component(form.component, form.component_studly) %> +<% request.register_component(form.vue_tagname, form.vue_component) %> - diff --git a/tailbone/templates/master/form.mako b/tailbone/templates/master/form.mako index dfe56fa8..dc9743ea 100644 --- a/tailbone/templates/master/form.mako +++ b/tailbone/templates/master/form.mako @@ -6,13 +6,13 @@ - % if form is not Undefined: + % if form is not Undefined and hasattr(form, 'render_included_templates'): ${form.render_included_templates()} % endif diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako index 33592559..81c11213 100644 --- a/tailbone/templates/master/index.mako +++ b/tailbone/templates/master/index.mako @@ -15,7 +15,7 @@ <%def name="grid_tools()"> ## grid totals - % if master.supports_grid_totals: + % if getattr(master, 'supports_grid_totals', False):
<%def name="make_grid_component()"> - ## TODO: stop using |n filter? - ${grid.render_complete(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())|n} + ${grid.render_vue_template(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())} <%def name="render_grid_component()"> - <${grid.component} ref="grid" :csrftoken="csrftoken" - % if master.deletable and request.has_perm('{}.delete'.format(permission_prefix)) and master.delete_confirm == 'simple': - @deleteActionClicked="deleteObject" - % endif - > - + ${grid.render_vue_tag()} <%def name="make_this_page_component()"> @@ -313,10 +307,8 @@ ## finalize grid @@ -328,11 +320,11 @@ ${parent.modify_this_page_vars()}