diff --git a/CHANGELOG.md b/CHANGELOG.md index b17ddc7..0935339 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,30 +5,6 @@ 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 9fc1d83..fe91ac9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "WuttaWeb" -version = "0.12.0" +version = "0.11.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.1", + "WuttJamaican[db]>=0.12.0", "zope.sqlalchemy>=1.5", ] diff --git a/src/wuttaweb/forms/base.py b/src/wuttaweb/forms/base.py index d5b893a..5acca59 100644 --- a/src/wuttaweb/forms/base.py +++ b/src/wuttaweb/forms/base.py @@ -25,7 +25,6 @@ Base form classes """ import logging -from collections import OrderedDict import colander import deform @@ -312,9 +311,6 @@ 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 @@ -754,10 +750,6 @@ 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 @@ -826,17 +818,6 @@ 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 a3a464b..8c245f6 100644 --- a/src/wuttaweb/forms/schema.py +++ b/src/wuttaweb/forms/schema.py @@ -246,9 +246,6 @@ 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) @@ -324,28 +321,6 @@ 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 ee58a1a..b4d8254 100644 --- a/src/wuttaweb/forms/widgets.py +++ b/src/wuttaweb/forms/widgets.py @@ -44,7 +44,6 @@ 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): @@ -84,19 +83,9 @@ class ObjectRefWidget(SelectWidget): """ readonly_template = 'readonly/objectref' - def __init__(self, request, url=None, *args, **kwargs): + def __init__(self, request, *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): @@ -148,17 +137,12 @@ 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: @@ -175,78 +159,10 @@ 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 3e7695c..3f22aad 100644 --- a/src/wuttaweb/grids/base.py +++ b/src/wuttaweb/grids/base.py @@ -39,7 +39,6 @@ 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__) @@ -283,40 +282,6 @@ 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__( @@ -341,11 +306,6 @@ 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 @@ -356,7 +316,6 @@ 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() @@ -385,19 +344,6 @@ 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 @@ -486,7 +432,7 @@ class Grid: if key in self.columns: self.columns.remove(key) - def set_label(self, key, label, column_only=False): + def set_label(self, key, label): """ Set/override the label for a column. @@ -494,18 +440,11 @@ 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. @@ -604,92 +543,6 @@ 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 ############################## @@ -991,147 +844,6 @@ 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 ############################## @@ -1222,15 +934,6 @@ 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 @@ -1248,27 +951,11 @@ class Grid: # update settings dict based on what we find in the request # and/or user session. always prioritize the former. - # 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') + if self.request_has_settings('sort'): 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) @@ -1277,7 +964,6 @@ 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) @@ -1287,13 +973,6 @@ 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 @@ -1318,18 +997,11 @@ class Grid: def request_has_settings(self, typ): """ """ - 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 typ == 'sort': if 'sort1key' in self.request.GET: return True - elif typ == 'page' and self.paginated and self.paginate_on_backend: + elif typ == 'page': for key in ['pagesize', 'page']: if key in self.request.GET: return True @@ -1361,31 +1033,6 @@ 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): @@ -1452,18 +1099,8 @@ 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 and self.sort_on_backend: + if self.sortable: # first must clear all sort settings from dest. this is # because number of sort settings will vary, so we delete @@ -1507,15 +1144,10 @@ 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) @@ -1526,46 +1158,6 @@ 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 @@ -1596,11 +1188,6 @@ 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) @@ -1642,58 +1229,6 @@ 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. @@ -1716,9 +1251,6 @@ 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 @@ -1729,21 +1261,12 @@ 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 278992b..e6b77c6 100644 --- a/src/wuttaweb/templates/auth/login.mako +++ b/src/wuttaweb/templates/auth/login.mako @@ -4,9 +4,13 @@ <%def name="title()">Login +<%def name="render_this_page()"> + ${self.page_content()} + + <%def name="page_content()">
-
${base_meta.full_logo(image_url or None)}
+
${base_meta.full_logo()}
${form.render_vue_tag()} @@ -40,3 +44,6 @@ + + +${parent.body()} diff --git a/src/wuttaweb/templates/base.mako b/src/wuttaweb/templates/base.mako index 6c57c46..f58f7ec 100644 --- a/src/wuttaweb/templates/base.mako +++ b/src/wuttaweb/templates/base.mako @@ -151,33 +151,6 @@ 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; @@ -528,7 +501,7 @@ label="Delete This" /> % endif % elif master.editing: - % if master.has_perm('view'): + % if instance_viewable and master.has_perm('view'): % endif % elif master.deleting: - % if master.has_perm('view'): + % if instance_viewable and master.has_perm('view'): ${app.get_node_title()} +<%def name="global_title()">${app.get_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(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="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="footer()"> diff --git a/src/wuttaweb/templates/configure.mako b/src/wuttaweb/templates/configure.mako index f0363e0..6b7a766 100644 --- a/src/wuttaweb/templates/configure.mako +++ b/src/wuttaweb/templates/configure.mako @@ -3,17 +3,6 @@ <%def name="title()">Configure ${config_title} -<%def name="extra_styles()"> - ${parent.extra_styles()} - - - <%def name="page_content()">
${self.buttons_content()} @@ -53,14 +42,15 @@ Cancel - ${h.form(request.current_route_url(), **{'@submit': 'purgingSettings = true'})} + ${h.form(request.current_route_url())} ${h.csrf_token(request)} ${h.hidden('remove_settings', 'true')} + icon-left="trash" + @click="purgingSettings = true"> {{ 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 3ab9e0e..0b941ab 100644 --- a/src/wuttaweb/templates/deform/readonly/objectref.pt +++ b/src/wuttaweb/templates/deform/readonly/objectref.pt @@ -1,9 +1 @@ - - - ${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 deleted file mode 100644 index ba27041..0000000 --- a/src/wuttaweb/templates/deform/readonly/rolerefs.pt +++ /dev/null @@ -1,7 +0,0 @@ - diff --git a/src/wuttaweb/templates/forms/vue_template.mako b/src/wuttaweb/templates/forms/vue_template.mako index e7d3f2b..70540d0 100644 --- a/src/wuttaweb/templates/forms/vue_template.mako +++ b/src/wuttaweb/templates/forms/vue_template.mako @@ -11,12 +11,7 @@ % 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 deleted file mode 100644 index ba35bf3..0000000 --- a/src/wuttaweb/templates/grids/table_element.mako +++ /dev/null @@ -1,49 +0,0 @@ -## -*- 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 84dcb58..b161d9f 100644 --- a/src/wuttaweb/templates/grids/vue_template.mako +++ b/src/wuttaweb/templates/grids/vue_template.mako @@ -1,228 +1,129 @@ ## -*- 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 a13fc50..233ef20 100644 --- a/src/wuttaweb/views/common.py +++ b/src/wuttaweb/views/common.py @@ -53,11 +53,6 @@ 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 4258cfd..d43de9e 100644 --- a/src/wuttaweb/views/master.py +++ b/src/wuttaweb/views/master.py @@ -181,23 +181,6 @@ 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()` @@ -280,12 +263,6 @@ 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 @@ -300,8 +277,6 @@ class MasterView(View): # features listable = True has_grid = True - filterable = True - filter_defaults = None sortable = True sort_on_backend = True sort_defaults = None @@ -311,7 +286,6 @@ class MasterView(View): viewable = True editable = True deletable = True - has_autocomplete = False configurable = False # current action @@ -356,26 +330,13 @@ class MasterView(View): if self.has_grid: grid = self.make_model_grid() - # handle "full" vs. "partial" differently + # so-called 'partial' requests get just data, no html 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) @@ -612,84 +573,6 @@ 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 ############################## @@ -1240,8 +1123,6 @@ 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) @@ -1327,10 +1208,8 @@ class MasterView(View): self.set_labels(grid) - # 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) + for key in self.get_model_key(): + grid.set_link(key) def grid_render_notes(self, record, key, value, maxlen=100): """ @@ -2007,15 +1886,6 @@ 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 a19df57..5097d06 100644 --- a/src/wuttaweb/views/people.py +++ b/src/wuttaweb/views/people.py @@ -24,11 +24,8 @@ 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): @@ -49,7 +46,6 @@ class PersonView(MasterView): model_title_plural = "People" route_prefix = 'people' sort_defaults = 'full_name' - has_autocomplete = True grid_columns = [ 'full_name', @@ -58,10 +54,6 @@ class PersonView(MasterView): 'last_name', ] - filter_defaults = { - 'full_name': {'active': True}, - } - def configure_grid(self, g): """ """ super().configure_grid(g) @@ -75,36 +67,23 @@ 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 - # TODO: master should handle these? (nullable column) + # first_name f.set_required('first_name', False) + + # middle_name f.set_required('middle_name', False) + + # last_name f.set_required('last_name', False) # 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 + if 'users' in f: + f.fields.remove('users') def view_profile(self, session=None): """ """ diff --git a/src/wuttaweb/views/roles.py b/src/wuttaweb/views/roles.py index fa7c8fc..0da0712 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 UserRefs, Permissions +from wuttaweb.forms.schema import Permissions class RoleView(MasterView): @@ -52,10 +52,6 @@ class RoleView(MasterView): 'notes', ] - filter_defaults = { - 'name': {'active': True}, - } - # TODO: master should handle this, possibly via configure_form() def get_query(self, session=None): """ """ @@ -119,13 +115,6 @@ 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 a20e1f6..1f2447c 100644 --- a/src/wuttaweb/views/settings.py +++ b/src/wuttaweb/views/settings.py @@ -24,10 +24,6 @@ Views for app settings """ -import json -import os -import sys -import subprocess from collections import OrderedDict from wuttjamaican.db.model import Setting @@ -51,59 +47,58 @@ class AppInfoView(MasterView): model_name = 'AppInfo' model_title_plural = "App Info" route_prefix = 'appinfo' - sort_on_backend = False - sort_defaults = 'name' - paginated = False + has_grid = False creatable = False viewable = False editable = False deletable = False configurable = True - grid_columns = [ - 'name', - 'version', - 'editable_project_location', - ] + def configure_get_simple_settings(self): + """ """ + return [ - # TODO: for tailbone backward compat with get_liburl() etc. - weblib_config_prefix = None + # basics + {'name': f'{self.app.appname}.app_title'}, + {'name': f'{self.app.appname}.production', + 'type': bool}, - def get_grid_data(self, columns=None, session=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): """ """ - # nb. init with empty data, only load it upon user request - if not self.request.GET.get('partial'): - return [] + # normal context + context = super().configure_get_context(**kwargs) - # 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([ + # we will add `weblibs` to context, based on config values + weblibs = OrderedDict([ ('vue', "(Vue2) Vue"), ('vue_resource', "(Vue2) vue-resource"), ('buefy', "(Vue2) Buefy"), @@ -118,48 +113,6 @@ 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] = { @@ -169,18 +122,13 @@ 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, - prefix=self.weblib_config_prefix, - default_only=True), - 'live_url': get_liburl(self.request, key, - prefix=self.weblib_config_prefix), + 'default_version': get_libver(self.request, key, default_only=True), + 'live_url': get_liburl(self.request, key), } context['weblibs'] = list(weblibs.values()) @@ -201,19 +149,8 @@ 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 d05b8eb..4f4b6f0 100644 --- a/src/wuttaweb/views/users.py +++ b/src/wuttaweb/views/users.py @@ -55,10 +55,6 @@ 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 50d27bc..70ee55f 100644 --- a/tests/forms/test_base.py +++ b/tests/forms/test_base.py @@ -10,7 +10,6 @@ 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): @@ -406,29 +405,6 @@ class TestForm(TestCase): self.assertIn('