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", ] 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..3e7695c 100644 --- a/src/wuttaweb/grids/base.py +++ b/src/wuttaweb/grids/base.py @@ -39,6 +39,7 @@ from webhelpers2.html import HTML from wuttaweb.db import Session from wuttaweb.util import FieldList, get_model_fields, make_json_safe +from wuttjamaican.util import UNSPECIFIED log = logging.getLogger(__name__) @@ -282,6 +283,40 @@ class Grid: Only relevant if :attr:`paginated` is true. If not specified, constructor will assume ``1`` (first page). + + .. attribute:: searchable_columns + + Set of columns declared as searchable for the Vue component. + + See also :meth:`set_searchable()` and :meth:`is_searchable()`. + + .. attribute:: filterable + + Boolean indicating whether the grid should show a "filters" + section where user can filter data in various ways. Default is + ``False``. + + .. attribute:: filters + + Dict of :class:`~wuttaweb.grids.filters.GridFilter` instances + available for use with backend filtering. + + Only relevant if :attr:`filterable` is true. + + See also :meth:`set_filter()`. + + .. attribute:: filter_defaults + + Dict containing default state preferences for the filters. + + See also :meth:`set_filter_defaults()`. + + .. attribute:: joiners + + Dict of "joiner" functions for use with backend filtering and + sorting. + + See :meth:`set_joiner()` for more info. """ def __init__( @@ -306,6 +341,11 @@ class Grid: pagesize_options=None, pagesize=None, page=1, + searchable_columns=None, + filterable=False, + filters=None, + filter_defaults=None, + joiners=None, ): self.request = request self.vue_tagname = vue_tagname @@ -316,6 +356,7 @@ class Grid: self.renderers = renderers or {} self.actions = actions or [] self.linked_columns = linked_columns or [] + self.joiners = joiners or {} self.config = self.request.wutta_config self.app = self.config.get_app() @@ -344,6 +385,19 @@ class Grid: self.pagesize = pagesize or self.get_pagesize() self.page = page + # searching + self.searchable_columns = set(searchable_columns or []) + + # filtering + self.filterable = filterable + if filters is not None: + self.filters = filters + elif self.filterable: + self.filters = self.make_backend_filters() + else: + self.filters = {} + self.set_filter_defaults(**(filter_defaults or {})) + def get_columns(self): """ Returns the official list of column names for the grid, or @@ -432,7 +486,7 @@ class Grid: if key in self.columns: self.columns.remove(key) - def set_label(self, key, label): + def set_label(self, key, label, column_only=False): """ Set/override the label for a column. @@ -440,11 +494,18 @@ class Grid: :param label: New label for the column header. + :param column_only: Boolean indicating whether the label + should be applied *only* to the column header (if + ``True``), vs. applying also to the filter (if ``False``). + See also :meth:`get_label()`. Label overrides are tracked via :attr:`labels`. """ self.labels[key] = label + if not column_only and key in self.filters: + self.filters[key].label = label + def get_label(self, key): """ Returns the label text for a given column. @@ -543,6 +604,92 @@ class Grid: return True return False + def set_searchable(self, key, searchable=True): + """ + (Un)set the given column's searchable flag for the Vue + component. + + See also :meth:`is_searchable()`. Flags are tracked via + :attr:`searchable_columns`. + """ + if searchable: + self.searchable_columns.add(key) + elif key in self.searchable_columns: + self.searchable_columns.remove(key) + + def is_searchable(self, key): + """ + Check if the given column is marked as searchable for the Vue + component. + + See also :meth:`set_searchable()`. + """ + return key in self.searchable_columns + + 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)) + + ############################## + # joining methods + ############################## + + def set_joiner(self, key, joiner): + """ + Set/override the backend joiner for a column. + + A "joiner" is sometimes needed when a column with "related but + not primary" data is involved in a sort or filter operation. + + A sorter or filter may need to "join" other table(s) to get at + the appropriate data. But if a given column has both a sorter + and filter defined, and both are used at the same time, we + don't want the join to happen twice. + + Hence we track joiners separately, also keyed by column name + (as are sorters and filters). When a column's sorter **and/or** + filter is needed, the joiner will be invoked. + + :param key: Name of column. + + :param joiner: A joiner callable, as described below. + + A joiner callable must accept just one ``(data)`` arg and + return the "joined" data/query, for example:: + + model = app.model + grid = Grid(request, model_class=model.Person) + + def join_external_profile_value(query): + return query.join(model.ExternalProfile) + + def sort_external_profile(query, direction): + sortspec = getattr(model.ExternalProfile.description, direction) + return query.order_by(sortspec()) + + grid.set_joiner('external_profile', join_external_profile) + grid.set_sorter('external_profile', sort_external_profile) + + See also :meth:`remove_joiner()`. Backend joiners are tracked + via :attr:`joiners`. + """ + self.joiners[key] = joiner + + def remove_joiner(self, key): + """ + Remove the backend joiner for a column. + + Note that this removes the joiner *function*, so there is no + way to apply joins for this column unless another joiner is + later defined for it. + + See also :meth:`set_joiner()`. + """ + self.joiners.pop(key, None) + ############################## # sorting methods ############################## @@ -844,6 +991,147 @@ class Grid: return key in self.sorters return True + ############################## + # filtering methods + ############################## + + def make_backend_filters(self, filters=None): + """ + Make backend filters for all columns in the grid. + + This is called by the constructor, if :attr:`filterable` is + true. + + For each column in the grid, this checks the provided + ``filters`` and if the column is not yet in there, will call + :meth:`make_filter()` to add it. + + .. note:: + + This only works if grid has a :attr:`model_class`. If not, + this method just returns the initial filters (or empty + dict). + + :param filters: Optional dict of initial filters. Any + existing filters will be left intact, not replaced. + + :returns: Final dict of all filters. Includes any from the + initial ``filters`` param as well as any which were + created. + """ + filters = filters or {} + + if self.model_class: + for key in self.columns: + if key in filters: + continue + prop = getattr(self.model_class, key, None) + if (prop and hasattr(prop, 'property') + and isinstance(prop.property, orm.ColumnProperty)): + filters[prop.key] = self.make_filter(prop) + + return filters + + def make_filter(self, columninfo, **kwargs): + """ + Create and return a :class:`GridFilter` instance suitable for + use on the given column. + + Code usually does not need to call this directly. See also + :meth:`set_filter()`, which calls this method automatically. + + :param columninfo: Can be either a model property (see below), + or a column name. + + :returns: A :class:`GridFilter` instance. + """ + model_property = None + if isinstance(columninfo, str): + key = columninfo + if self.model_class: + try: + mapper = sa.inspect(self.model_class) + except sa.exc.NoInspectionAvailable: + pass + else: + model_property = mapper.get_property(key) + if not model_property: + raise ValueError(f"cannot locate model property for key: {key}") + else: + model_property = columninfo + + return GridFilter(self.request, model_property, **kwargs) + + def set_filter(self, key, filterinfo=None, **kwargs): + """ + Set/override the backend filter for a column. + + Only relevant if :attr:`filterable` is true. + + :param key: Name of column. + + :param filterinfo: Can be either a + :class:`~wuttweb.grids.filters.GridFilter` instance, or + else a model property (see below). + + If ``filterinfo`` is a ``GridFilter`` instance, it will be + used as-is for the backend filter. + + Otherwise :meth:`make_filter()` will be called to obtain the + backend filter. The ``filterinfo`` will be passed along to + that call; if it is empty then ``key`` will be used instead. + + See also :meth:`remove_filter()`. Backend filters are tracked + via :attr:`filters`. + """ + filtr = None + + if filterinfo and callable(filterinfo): + # filtr = filterinfo + raise NotImplementedError + else: + kwargs.setdefault('label', self.get_label(key)) + filtr = self.make_filter(filterinfo or key, **kwargs) + + self.filters[key] = filtr + + def remove_filter(self, key): + """ + Remove the backend filter for a column. + + This removes the filter *instance*, so there is no way to + filter by this column unless another filter is later defined + for it. + + See also :meth:`set_filter()`. + """ + self.filters.pop(key, None) + + def set_filter_defaults(self, **defaults): + """ + Set default state preferences for the grid filters. + + These preferences will affect the initial grid display, until + user requests a different filtering method. + + Each kwarg should be named by filter key, and the value should + be a dict of preferences for that filter. For instance:: + + grid.set_filter_defaults(name={'active': True, + 'verb': 'contains', + 'value': 'foo'}, + value={'active': True}) + + Filter defaults are tracked via :attr:`filter_defaults`. + """ + filter_defaults = dict(getattr(self, 'filter_defaults', {})) + + for key, values in defaults.items(): + filtr = filter_defaults.setdefault(key, {}) + filtr.update(values) + + self.filter_defaults = filter_defaults + ############################## # paging methods ############################## @@ -934,6 +1222,15 @@ class Grid: # initial default settings settings = {} + if self.filterable: + for filtr in self.filters.values(): + defaults = self.filter_defaults.get(filtr.key, {}) + settings[f'filter.{filtr.key}.active'] = defaults.get('active', + filtr.default_active) + settings[f'filter.{filtr.key}.verb'] = defaults.get('verb', + filtr.default_verb) + settings[f'filter.{filtr.key}.value'] = defaults.get('value', + filtr.default_value) if self.sortable: if self.sort_defaults: # nb. as of writing neither Buefy nor Oruga support a @@ -951,11 +1248,27 @@ class Grid: # update settings dict based on what we find in the request # and/or user session. always prioritize the former. - if self.request_has_settings('sort'): + # nb. do not read settings if user wants a reset + if self.request.GET.get('reset-view'): + # at this point we only have default settings, and we want + # to keep those *and* persist them for next time, below + pass + + elif self.request_has_settings('filter'): + self.update_filter_settings(settings, src='request') + if self.request_has_settings('sort'): + self.update_sort_settings(settings, src='request') + else: + self.update_sort_settings(settings, src='session') + self.update_page_settings(settings) + + elif self.request_has_settings('sort'): + self.update_filter_settings(settings, src='session') self.update_sort_settings(settings, src='request') self.update_page_settings(settings) elif self.request_has_settings('page'): + self.update_filter_settings(settings, src='session') self.update_sort_settings(settings, src='session') self.update_page_settings(settings) @@ -964,6 +1277,7 @@ class Grid: persist = False # but still should load whatever is in user session + self.update_filter_settings(settings, src='session') self.update_sort_settings(settings, src='session') self.update_page_settings(settings) @@ -973,6 +1287,13 @@ class Grid: # update ourself to reflect settings dict.. + # filtering + if self.filterable: + for filtr in self.filters.values(): + filtr.active = settings[f'filter.{filtr.key}.active'] + filtr.verb = settings[f'filter.{filtr.key}.verb'] or filtr.default_verb + filtr.value = settings[f'filter.{filtr.key}.value'] + # sorting if self.sortable: # nb. doing this for frontend sorting also @@ -997,11 +1318,18 @@ class Grid: def request_has_settings(self, typ): """ """ - if typ == 'sort': + if typ == 'filter' and self.filterable: + for filtr in self.filters.values(): + if filtr.key in self.request.GET: + return True + if 'filter' in self.request.GET: # user may be applying empty filters + return True + + elif typ == 'sort' and self.sortable and self.sort_on_backend: if 'sort1key' in self.request.GET: return True - elif typ == 'page': + elif typ == 'page' and self.paginated and self.paginate_on_backend: for key in ['pagesize', 'page']: if key in self.request.GET: return True @@ -1033,6 +1361,31 @@ class Grid: # okay then, default it is return default + def update_filter_settings(self, settings, src=None): + """ """ + if not self.filterable: + return + + for filtr in self.filters.values(): + prefix = f'filter.{filtr.key}' + + if src == 'request': + # consider filter active if query string contains a value for it + settings[f'{prefix}.active'] = filtr.key in self.request.GET + settings[f'{prefix}.verb'] = self.get_setting( + settings, f'{filtr.key}.verb', src='request', default='') + settings[f'{prefix}.value'] = self.get_setting( + settings, filtr.key, src='request', default='') + + elif src == 'session': + settings[f'{prefix}.active'] = self.get_setting( + settings, f'{prefix}.active', src='session', + normalize=lambda v: str(v).lower() == 'true', default=False) + settings[f'{prefix}.verb'] = self.get_setting( + settings, f'{prefix}.verb', src='session', default='') + settings[f'{prefix}.value'] = self.get_setting( + settings, f'{prefix}.value', src='session', default='') + def update_sort_settings(self, settings, src=None): """ """ if not (self.sortable and self.sort_on_backend): @@ -1099,8 +1452,18 @@ class Grid: skey = f'grid.{self.key}.{key}' self.request.session[skey] = value(key) + # filter settings + if self.filterable: + + # always save all filters, with status + for filtr in self.filters.values(): + persist(f'filter.{filtr.key}.active', + value=lambda k: 'true' if settings.get(k) else 'false') + persist(f'filter.{filtr.key}.verb') + persist(f'filter.{filtr.key}.value') + # sort settings - if self.sortable: + if self.sortable and self.sort_on_backend: # first must clear all sort settings from dest. this is # because number of sort settings will vary, so we delete @@ -1144,10 +1507,15 @@ class Grid: See also these methods which may be called by this one: + * :meth:`filter_data()` * :meth:`sort_data()` * :meth:`paginate_data()` """ data = self.data or [] + self.joined = set() + + if self.filterable: + data = self.filter_data(data) if self.sortable and self.sort_on_backend: data = self.sort_data(data) @@ -1158,6 +1526,46 @@ class Grid: return data + @property + def active_filters(self): + """ + Returns the list of currently active filters. + + This inspects each :class:`GridFilter` in :attr:`filters` and + only returns the ones marked active. + """ + return [filtr for filtr in self.filters.values() + if filtr.active] + + def filter_data(self, data, filters=None): + """ + Filter the given data and return the result. This is called + by :meth:`get_visible_data()`. + + :param filters: Optional list of filters to use. If not + specified, the grid's :attr:`active_filters` are used. + """ + if filters is None: + filters = self.active_filters + if not filters: + return data + + for filtr in filters: + key = filtr.key + + if key in self.joiners and key not in self.joined: + data = self.joiners[key](data) + self.joined.add(key) + + try: + data = filtr.apply_filter(data) + except VerbNotSupported as error: + log.warning("verb not supported for '%s' filter: %s", key, error.verb) + except: + log.exception("filtering data by '%s' failed!", key) + + return data + def sort_data(self, data, sorters=None): """ Sort the given data and return the result. This is called by @@ -1188,6 +1596,11 @@ class Grid: if not sortfunc: return data + # join appropriate model if needed + if sortkey in self.joiners and sortkey not in self.joined: + data = self.joiners[sortkey](data) + self.joined.add(sortkey) + # invoke the sorter data = sortfunc(data, sortdir) @@ -1229,6 +1642,58 @@ class Grid: # rendering methods ############################## + def render_table_element( + self, + form=None, + template='/grids/table_element.mako', + **context): + """ + Render a simple Vue table element for the grid. + + This is what you want for a "simple" grid which does require a + unique Vue component, but can instead use the standard table + component. + + This returns something like: + + .. code-block:: html + + + + + + See :meth:`render_vue_template()` for a more complete variant. + + Actual output will of course depend on grid attributes, + :attr:`key`, :attr:`columns` etc. + + :param form: Reference to the + :class:`~wuttaweb.forms.base.Form` instance which + "contains" this grid. This is needed in order to ensure + the grid data is available to the form Vue component. + + :param template: Path to Mako template which is used to render + the output. + + .. note:: + + The above example shows ``gridData['mykey']`` as the Vue + data reference. This should "just work" if you provide the + correct ``form`` arg and the grid is contained directly by + that form's Vue component. + + However, this may not account for all use cases. For now + we wait and see what comes up, but know the dust may not + yet be settled here. + """ + + # nb. must register data for inclusion on page template + if form: + form.add_grid_vue_data(self) + + # otherwise logic is the same, just different template + return self.render_vue_template(template=template, **context) + def render_vue_tag(self, **kwargs): """ Render the Vue component tag for the grid. @@ -1251,6 +1716,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 +1729,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/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.mako b/src/wuttaweb/templates/base.mako index f58f7ec..6c57c46 100644 --- a/src/wuttaweb/templates/base.mako +++ b/src/wuttaweb/templates/base.mako @@ -151,6 +151,33 @@ white-space: nowrap; } + ############################## + ## grids + ############################## + + .wutta-filter { + display: flex; + gap: 0.5rem; + } + + .wutta-filter .button.filter-toggle { + justify-content: left; + } + + .wutta-filter .button.filter-toggle, + .wutta-filter .filter-verb { + min-width: 15rem; + } + + .wutta-filter .filter-verb .select, + .wutta-filter .filter-verb .select select { + width: 100%; + } + + ############################## + ## forms + ############################## + .wutta-form-wrapper { margin-left: 5rem; margin-top: 2rem; @@ -501,7 +528,7 @@ label="Delete This" /> % endif % elif master.editing: - % if instance_viewable and master.has_perm('view'): + % if master.has_perm('view'): % endif % elif master.deleting: - % if instance_viewable and master.has_perm('view'): + % if master.has_perm('view'): ${app.get_title()} +<%def name="global_title()">${app.get_node_title()} <%def name="extra_styles()"> @@ -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/configure.mako b/src/wuttaweb/templates/configure.mako index 6b7a766..f0363e0 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()} @@ -42,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/templates/deform/readonly/objectref.pt b/src/wuttaweb/templates/deform/readonly/objectref.pt index 0b941ab..3ab9e0e 100644 --- a/src/wuttaweb/templates/deform/readonly/objectref.pt +++ b/src/wuttaweb/templates/deform/readonly/objectref.pt @@ -1 +1,9 @@ -${str(field.schema.model_instance or '')} + + + ${str(field.schema.model_instance or '')} + + + ${str(field.schema.model_instance or '')} + + diff --git a/src/wuttaweb/templates/deform/readonly/rolerefs.pt b/src/wuttaweb/templates/deform/readonly/rolerefs.pt new file mode 100644 index 0000000..ba27041 --- /dev/null +++ b/src/wuttaweb/templates/deform/readonly/rolerefs.pt @@ -0,0 +1,7 @@ + diff --git a/src/wuttaweb/templates/forms/vue_template.mako b/src/wuttaweb/templates/forms/vue_template.mako index 70540d0..e7d3f2b 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: diff --git a/src/wuttaweb/templates/grids/table_element.mako b/src/wuttaweb/templates/grids/table_element.mako new file mode 100644 index 0000000..ba35bf3 --- /dev/null +++ b/src/wuttaweb/templates/grids/table_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/templates/grids/vue_template.mako b/src/wuttaweb/templates/grids/vue_template.mako index b161d9f..84dcb58 100644 --- a/src/wuttaweb/templates/grids/vue_template.mako +++ b/src/wuttaweb/templates/grids/vue_template.mako @@ -1,129 +1,228 @@ ## -*- coding: utf-8; -*- + +<%def name="make_wutta_filter_component()"> + + + + +<%def name="make_wutta_filter_value_component()"> + + + 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/master.py b/src/wuttaweb/views/master.py index d43de9e..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()` @@ -263,6 +280,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 @@ -277,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 @@ -286,6 +311,7 @@ class MasterView(View): viewable = True editable = True deletable = True + has_autocomplete = False configurable = False # current action @@ -330,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) @@ -573,6 +612,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 ############################## @@ -1123,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) @@ -1208,8 +1327,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): """ @@ -1886,6 +2007,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..a19df57 100644 --- a/src/wuttaweb/views/people.py +++ b/src/wuttaweb/views/people.py @@ -24,8 +24,11 @@ Views for people """ +import sqlalchemy as sa + from wuttjamaican.db.model import Person from wuttaweb.views import MasterView +from wuttaweb.forms.schema import UserRefs class PersonView(MasterView): @@ -46,6 +49,7 @@ class PersonView(MasterView): model_title_plural = "People" route_prefix = 'people' sort_defaults = 'full_name' + has_autocomplete = True grid_columns = [ 'full_name', @@ -54,6 +58,10 @@ class PersonView(MasterView): 'last_name', ] + filter_defaults = { + 'full_name': {'active': True}, + } + def configure_grid(self, g): """ """ super().configure_grid(g) @@ -67,23 +75,36 @@ 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): + """ """ + 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): """ """ diff --git a/src/wuttaweb/views/roles.py b/src/wuttaweb/views/roles.py index 0da0712..fa7c8fc 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): @@ -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): """ """ @@ -115,6 +119,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/src/wuttaweb/views/settings.py b/src/wuttaweb/views/settings.py index 1f2447c..a20e1f6 100644 --- a/src/wuttaweb/views/settings.py +++ b/src/wuttaweb/views/settings.py @@ -24,6 +24,10 @@ Views for app settings """ +import json +import os +import sys +import subprocess from collections import OrderedDict from wuttjamaican.db.model import Setting @@ -47,58 +51,59 @@ 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 - def configure_get_simple_settings(self): - """ """ - return [ + grid_columns = [ + 'name', + 'version', + 'editable_project_location', + ] - # basics - {'name': f'{self.app.appname}.app_title'}, - {'name': f'{self.app.appname}.production', - 'type': bool}, + # TODO: for tailbone backward compat with get_liburl() etc. + weblib_config_prefix = None - # 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): + def get_grid_data(self, columns=None, session=None): """ """ - # normal context - context = super().configure_get_context(**kwargs) + # nb. init with empty data, only load it upon user request + if not self.request.GET.get('partial'): + return [] - # we will add `weblibs` to context, based on config values - weblibs = OrderedDict([ + # 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 + + # name + g.set_searchable('name') + + # editable_project_location + g.set_searchable('editable_project_location') + + def get_weblibs(self): + """ """ + return OrderedDict([ ('vue', "(Vue2) Vue"), ('vue_resource', "(Vue2) vue-resource"), ('buefy', "(Vue2) Buefy"), @@ -113,6 +118,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] = { @@ -122,13 +169,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()) @@ -149,8 +201,19 @@ 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) + 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/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/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('